
简介本资源是面向Java与前端开发者的学习型电商项目源码聚焦B2B2C多商户运营模式适用于毕业设计、课程实训或中小型企业级平台二次开发。项目完整呈现创创猫消费端前端实现采用uni-app框架开发支持一键打包至微信小程序、APP及H5兼顾跨端能力与工程可维护性。压缩包共342个文件含197个Vue组件文件构建页面逻辑与交互、51个JS脚本封装业务工具与API调用、64个PNG/JPG图片资源含商品图、图标与状态占位图及SCSS样式文件整体仅2.85MB轻量易导入。已有437人学习下载资源结构清晰包含area.js地理区域选择、u-charts数据可视化、iconfont字体图标等实用模块配套user-bg.jpg、emptyCart.jpg等UI资源开箱即用便于快速理解多商户电商前端架构与典型功能实现路径。1. 为什么“创创猫”不是一只猫而是一套可落地的多商户电商分账逻辑“创创猫”这个名字在 Java 开发者圈里常被误认为是某个开源宠物社区项目实际它代表一类典型的轻量级多商户 SaaS 电商平台架构范式以 Java 为基底通过租户隔离 分账路由 商户独立运营后台支撑中小品牌、区域连锁、本地生活服务商等多类主体在同一套系统中并行开店。它不追求淘宝级的秒杀吞吐但必须解决「一个订单跨三个商户主站自营本地服务商第三方品牌如何拆单、分账、开票、售后归责」这类真实业务痛点。这类系统常见于县域电商中台、高校创业孵化平台、产业园区数字化服务门户等场景——它们不要 Kubernetes 编排但要求数据库字段级租户标识清晰、Spring Security 权限模型能按merchant_id粒度动态加载、支付回调能精准路由到对应商户配置的 Webhook 地址。如果你正被「商户入驻审核流怎么和 Spring Boot Admin 集成」「商品 SKU 如何避免跨商户重复编码」「同一用户在不同商户下会员等级互不影响」这些问题卡住这篇就是为你写的实战笔记。2. 用 Spring Boot MyBatis-Plus 实现多商户核心数据模型与租户隔离多商户系统的根基不在前端页面有多炫而在数据层能否让每个商户的数据像装进独立保险箱一样安全、可审计、可伸缩。我们不采用数据库物理隔离成本高、运维重也不用视图或存储过程硬编码难测试、难迭代而是基于MyBatis-Plus 的多租户插件 动态表前缀 字段级租户标识构建三层防护。2.1 租户识别策略从请求头提取 merchant_id 到 ThreadLocal 上下文创创猫系统约定所有商户 API 请求必须携带X-Merchant-ID: M20240501001头后端通过自定义 Filter 拦截并注入上下文Component public class MerchantContextFilter implements Filter { Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest httpRequest (HttpServletRequest) request; String merchantId httpRequest.getHeader(X-Merchant-ID); if (StringUtils.isNotBlank(merchantId) merchantId.matches(M\\d{9})) { MerchantContextHolder.setMerchantId(merchantId); // 存入 ThreadLocal } try { chain.doFilter(request, response); } finally { MerchantContextHolder.clear(); // 必须清理防止线程复用污染 } } }提示MerchantContextHolder是一个简单的ThreadLocalString封装类切记在 finally 块中 clear()否则 Tomcat 线程池复用时会导致后续请求拿到上一个商户的 ID这是线上最隐蔽的租户越权 bug 来源之一。2.2 数据库设计一张表承载 N 个商户靠 merchant_id 字段兜底以商品表product为例关键字段设计如下MySQL 8.0字段名类型是否为空注释idBIGINT PKNOT NULL全局唯一 ID雪花算法生成sku_codeVARCHAR(64)NOT NULL商户内唯一非全局唯一如 M20240501001-001merchant_idVARCHAR(32)NOT NULL强制索引所有查询 WHERE 条件必带nameVARCHAR(255)NOT NULL商品名称priceDECIMAL(10,2)NOT NULL售价单位元statusTINYINTNOT NULL0-下架 1-上架 2-审核中created_atDATETIMENOT NULL创建时间updated_atDATETIMENOT NULL更新时间-- 必建复合索引提升按商户查商品列表性能 CREATE INDEX idx_merchant_status ON product (merchant_id, status, created_at); -- 防止商户间 sku_code 冲突的唯一约束需配合应用层校验 ALTER TABLE product ADD CONSTRAINT uk_merchant_sku UNIQUE (merchant_id, sku_code);注意sku_code不做全局唯一索引因为不同商户完全可能使用相同编码如“iPhone15-128G”。唯一性约束必须带上merchant_id否则建表会失败。2.3 MyBatis-Plus 多租户插件配置自动追加 WHERE 条件在application.yml中启用 MP 多租户插件mybatis-plus: configuration: default-enum-type-handler: com.baomidou.mybatisplus.extension.handlers.MybatisEnumTypeHandler global-config: db-config: id-type: assign_id # 使用雪花算法 logic-delete-field: deleted # 逻辑删除字段 logic-delete-value: 1 logic-not-delete-value: 0 tenant: enable: true # 开启多租户 ignore-tables: # 这些表不走租户过滤如 sys_user、sys_role - sys_user - sys_role - merchant_info编写TenantLineInnerInterceptor并注册为 BeanBean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); // 租户拦截器对所有 mapper 查询自动添加 AND merchant_id ? TenantLineInnerInterceptor tenantInterceptor new TenantLineInnerInterceptor(); tenantInterceptor.setTenantHandler(new TenantHandler() { Override public Expression getTenantId() { String mid MerchantContextHolder.getMerchantId(); return new LongValue(Long.parseLong(mid.substring(1))); // M20240501001 → 20240501001 } Override public String getTenantIdColumn() { return merchant_id; // 指定租户字段名 } Override public boolean ignoreTable(String tableName) { // 白名单表不加租户条件如商户信息表本身 return Arrays.asList(merchant_info, sys_user).contains(tableName); } }); interceptor.addInnerInterceptor(tenantInterceptor); return interceptor; }该配置生效后调用productMapper.selectList(null)实际执行的 SQL 会自动变成SELECT * FROM product WHERE deleted 0 AND merchant_id 20240501001;无需在每个 Mapper XML 或 LambdaQueryWrapper 中手动写.eq(merchant_id, mid)大幅降低漏写导致的数据泄露风险。3. 基于 Spring Security 的商户级权限控制与动态菜单加载多商户系统不能只靠数据库字段隔离前端菜单、API 接口、按钮操作都必须按商户维度动态裁剪。创创猫采用SecurityContext 自定义 PermissionEvaluator 商户角色树实现细粒度控制。3.1 商户角色模型RBAC 扩展为 RBAC-MMerchant-aware在标准 RBAC用户-角色-权限基础上增加merchant_role关联表结构如下字段类型说明idBIGINT主键user_idBIGINT用户 ID关联 sys_userrole_idBIGINT角色 ID关联 sys_rolemerchant_idVARCHAR(32)所属商户关键statusTINYINT0-禁用 1-启用当用户登录后UserDetailsServiceImpl加载权限时必须带上当前merchant_idOverride public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { SysUser user userMapper.selectOne(new QueryWrapperSysUser().eq(username, username)); if (user null) throw new UsernameNotFoundException(用户不存在); // 根据当前请求的 merchant_id 加载该商户下的角色与权限 String currentMid MerchantContextHolder.getMerchantId(); ListString permissions permissionMapper.selectPermissionsByUserIdAndMid(user.getId(), currentMid); return User.builder() .username(user.getUsername()) .password(user.getPassword()) .authorities(permissions.stream().map(SimpleGrantedAuthority::new).collect(Collectors.toList())) .build(); }3.2 前端菜单动态渲染后端返回 JSON前端递归生成 Ant Design Menu商户后台左侧菜单不是写死的而是由MenuService根据当前商户角色实时查询GetMapping(/menu) public ResultListMenuVO getMerchantMenu() { String mid MerchantContextHolder.getMerchantId(); ListMenuEntity menus menuMapper.selectByMerchantId(mid); return Result.success(buildTree(menus)); // 构建父子树结构 } private ListMenuVO buildTree(ListMenuEntity allMenus) { MapLong, MenuVO map new HashMap(); ListMenuVO roots new ArrayList(); // 第一遍转 VO 并存入 map for (MenuEntity m : allMenus) { MenuVO vo MenuVO.builder() .id(m.getId()) .title(m.getTitle()) .path(m.getPath()) .icon(m.getIcon()) .sort(m.getSort()) .parentId(m.getParentId()) .build(); map.put(m.getId(), vo); } // 第二遍挂载子节点 for (MenuEntity m : allMenus) { if (m.getParentId() null || m.getParentId() 0L) { roots.add(map.get(m.getId())); } else { MenuVO parent map.get(m.getParentId()); if (parent ! null) { parent.getChildren().add(map.get(m.getId())); } } } return roots; }返回的 JSON 示例已精简[ { id: 101, title: 商品管理, path: /product, icon: appstore, children: [ { id: 102, title: 商品列表, path: /product/list }, { id: 103, title: 分类管理, path: /product/category } ] } ]提示MenuEntity表中path字段必须是完整前端路由路径如/product/list且需与 Vue Router 或 React Router 的routes配置严格一致否则前端跳转会 404。3.3 接口级权限校验PreAuthorize 自定义表达式对敏感接口如修改商品价格、导出订单添加注解PreAuthorize(permissionService.hasPermission(product:price:edit)) PostMapping(/update-price) public ResultVoid updatePrice(RequestBody PriceUpdateDTO dto) { // 业务逻辑 }PermissionService实现校验逻辑Service public class PermissionService { Autowired private SysUserMapper userMapper; public boolean hasPermission(String permissionCode) { Long userId SecurityUtils.getUserId(); // 从 SecurityContext 获取当前用户 ID String mid MerchantContextHolder.getMerchantId(); // 查询该用户在当前商户下是否拥有此权限 return permissionMapper.hasPermission(userId, mid, permissionCode) 0; } }该方案比单纯用hasRole(ADMIN)更灵活同一用户在 A 商户是“商品运营”在 B 商户可能是“财务专员”权限代码完全不同。4. 订单分账与跨商户交易的核心实现状态机驱动 分布式事务补偿创创猫最复杂的模块不是商品展示而是当用户下一单买了 A 商户的手机壳、B 商户的贴膜、C 商户的充电线时系统如何确保① 各商户库存准确扣减② 支付成功后钱按比例分到三个商户账户③ 任一环节失败能原子回滚。我们采用Saga 模式 状态机 本地消息表实现最终一致性。4.1 订单状态机设计用 state-machine-spring-boot-starter 管理生命周期定义核心状态与事件状态说明可触发事件CREATED订单创建成功PAY_REQUESTPAYING支付中用户跳转支付页PAY_SUCCESS / PAY_FAILEDPAID支付成功STOCK_LOCKSTOCK_LOCKED库存锁定成功ORDER_CONFIRM / STOCK_RELEASECONFIRMED订单确认可发货DELIVER / CANCELCANCELED已取消—使用spring-statemachine配置状态流转Configuration EnableStateMachineFactory public class OrderStateMachineConfig extends StateMachineConfigurerAdapterString, String { Override public void configure(StateMachineConfigurationConfigurerString, String config) throws Exception { config .withConfiguration() .autoStartup(true) .listener(stateMachineListener()); } Override public void configure(StateMachineTransitionConfigurerString, String transitions) throws Exception { transitions .withExternal().source(CREATED).target(PAYING).event(PAY_REQUEST) .and() .withExternal().source(PAYING).target(PAID).event(PAY_SUCCESS) .and() .withExternal().source(PAID).target(STOCK_LOCKED).event(STOCK_LOCK) .and() .withExternal().source(STOCK_LOCKED).target(CONFIRMED).event(ORDER_CONFIRM) .and() .withExternal().source(STOCK_LOCKED).target(CREATED).event(STOCK_RELEASE); // 库存释放回 CREATED } }4.2 分账逻辑订单拆单 本地消息表保障幂等用户下单时后端解析购物车按merchant_id分组生成子订单// 主订单master_order MasterOrder master MasterOrder.builder() .orderNo(OrderNoGenerator.generate()) // M20240501001-20240501123456 .userId(userId) .totalAmount(totalAmount) .status(CREATED) .build(); // 拆分子订单sub_order MapString, ListCartItem cartByMid cartItems.stream() .collect(Collectors.groupingBy(CartItem::getMerchantId)); ListSubOrder subOrders new ArrayList(); for (Map.EntryString, ListCartItem entry : cartByMid.entrySet()) { String mid entry.getKey(); ListCartItem items entry.getValue(); SubOrder sub SubOrder.builder() .orderNo(master.getOrderNo() - mid.substring(1)) // M20240501001-20240501123456-20240501001 .masterOrderNo(master.getOrderNo()) .merchantId(mid) .itemList(items) .amount(calculateAmount(items)) .status(CREATED) .build(); subOrders.add(sub); }关键点在于子订单插入数据库后立即向本地消息表local_message插入一条记录用于后续异步分账INSERT INTO local_message (id, biz_type, biz_id, status, next_retry_time, max_retry, content) VALUES (123456, SUB_ORDER_PAID, M20240501001-20240501123456-20240501001, READY, NOW(), 3, {amount:199.00,mid:M20240501001});定时任务每 5 秒扫描status READY的消息调用商户分账接口并更新消息状态为SUCCESS或FAILED。若失败则更新next_retry_time NOW() INTERVAL 1 MINUTE最多重试 3 次。注意biz_id必须是子订单号content中的金额必须是子订单实际应付金额不可从主订单总金额按比例折算因满减、优惠券可能跨商户叠加必须以子订单最终结算价为准。4.3 库存扣减的分布式锁Redis Lua 脚本保证原子性扣减库存不能依赖数据库行锁高并发下易死锁改用 Redis 分布式锁public Boolean lockStock(String merchantId, String skuCode, Integer quantity) { String lockKey stock:lock: merchantId : skuCode; String requestId UUID.randomUUID().toString(); // Lua 脚本先判断剩余库存再扣减一步到位 String script local stock redis.call(HGET, KEYS[1], ARGV[1]) if not stock or tonumber(stock) tonumber(ARGV[2]) then return 0 else redis.call(HINCRBY, KEYS[1], ARGV[1], -ARGV[2]) return 1 end; Long result (Long) redisTemplate.execute( new DefaultRedisScript(script, Long.class), Collections.singletonList(stock:hash: merchantId), // Hash key skuCode, String.valueOf(quantity) ); return result 1; }库存 Hash 结构示例HGETALL stock:hash:M20240501001 1) IP15-128G - 99 2) CASE-001 - 200该脚本在 Redis 单线程中执行彻底规避并发超卖。5. 源码工程结构与关键参数调优从 IDEA 导入到生产部署创创猫源码不是单模块巨石应用而是按领域拆分为清晰的 Maven 多模块结构便于团队协作与灰度发布。以下是推荐的模块划分与 JVM 参数配置。5.1 标准 Maven 模块结构IDEA 导入指南模块名说明依赖关系ccat-parent父 POM统一管理 Spring Boot 版本、MP 版本、Lombok 等无ccat-common工具类、异常定义、DTO、枚举、通用配置无ccat-modelEntity、Mapper 接口、XML含多租户插件配置依赖 commonccat-serviceService 层、状态机、分账逻辑、分布式锁实现依赖 model commonccat-webController、Security 配置、全局异常处理器依赖 service commonccat-job定时任务消息重试、过期订单关闭、库存预警依赖 service导入 IDEA 步骤File → Open → 选择 ccat-parent/pom.xml勾选Import Maven projects automatically在Maven工具窗口中右键ccat-web→Run ccat-web.Application确保application-prod.yml中spring.profiles.activeprod5.2 生产环境 JVM 参数调优表基于 4C8G 服务器参数推荐值说明-Xms/-Xmx3g堆内存设为机器内存 3/4避免频繁 GC-XX:MetaspaceSize/-XX:MaxMetaspaceSize256m / 512m防止动态代理类过多导致 Metaspace OOM-XX:UseG1GC启用G1 垃圾收集器适合大堆、低延迟场景-XX:MaxGCPauseMillis200目标停顿时间G1 会据此调整 Region 大小-XX:HeapDumpOnOutOfMemoryError启用OOM 时自动生成 heap dump 用于分析-Dfile.encodingUTF-8必加防止 Linux 环境下中文日志乱码-Duser.timezoneGMT08必加统一时区避免定时任务错时启动脚本start.sh示例#!/bin/bash JAVA_HOME/opt/java/jdk-11.0.20 $JAVA_HOME/bin/java \ -Xms3g -Xmx3g \ -XX:MetaspaceSize256m -XX:MaxMetaspaceSize512m \ -XX:UseG1GC -XX:MaxGCPauseMillis200 \ -XX:HeapDumpOnOutOfMemoryError -XX:HeapDumpPath/data/logs/heap.hprof \ -Dfile.encodingUTF-8 -Duser.timezoneGMT08 \ -jar /data/app/ccat-web.jar --spring.profiles.activeprod5.3 MySQL 连接池 HikariCP 关键参数验证在application-prod.yml中务必覆盖以下参数spring: datasource: hikari: connection-timeout: 30000 # 连接超时 30s默认30s够用 validation-timeout: 3000 # 校验超时 3s必须小于 connection-timeout idle-timeout: 600000 # 空闲连接存活 10 分钟 max-lifetime: 1800000 # 连接最大存活 30 分钟必须小于 MySQL wait_timeout maximum-pool-size: 20 # 根据 DB 连接数上限设置如 MySQL max_connections200则最多 10 个应用实例 minimum-idle: 5 # 最小空闲连接数防冷启动慢 connection-test-query: SELECT 1 # MySQL 8.0 必须用这个不能用 SELECT 1 FROM DUAL提示max-lifetime必须小于 MySQL 的wait_timeout默认 28800 秒 8 小时否则连接会被 MySQL 主动断开HikariCP 无法感知后续使用时报Connection reset。可通过SHOW VARIABLES LIKE wait_timeout;查看。验证连接池健康状态访问 Actuator 端点http://localhost:8080/actuator/hikaricp检查active、idle、threadsAwaitingConnection等指标是否在合理范围如threadsAwaitingConnection 0持续存在说明连接池太小或 DB 响应慢。本文还有配套的精品资源点击获取