ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Java多商户电商平台源码改造:数据隔离、订单状态机与支付幂等实战

Java多商户电商平台源码改造:数据隔离、订单状态机与支付幂等实战 简介基于Java的创创猫B2B2C多商户电商平台前端源码面向需要搭建微信小程序、APP或H5商城的开发者和运维人员可用于学习多商户电商系统的前端工程结构。资源属于消费端界面以Vue和uni-app为基础涵盖页面组件、业务逻辑、样式配置、图片素材与图表交互整体采用模块化设计方便二次扩展与维护。包内共342个文件以Vue单文件组件、JavaScript脚本、PNG图片及样式配置为主压缩包约2.85MB目录结构清晰便于按功能查找对应代码。项目源自线上验证过的电商系统读者可参考真实的页面交互、数据绑定及多端打包方案配合商户端、平台端和Java后台源码可完整理解平台运转机制。目前已有438人学习下载适合有Vue基础、想深入B2B2C电商项目实战的开发者。1. 创创猫这类基于Java的多商户电商平台源码到底拆出来能留什么创创猫这类基于Java的多商户电商平台源码通俗点说就是“商城版天猫”一个系统里跑着多个独立商家每个商家有自己的商品、订单、结算但会员和支付是共享的。很多人在网上下载后却不知道改哪里最后只是换个 logo 就交了作业。如果你是为了应对 java 面试多商户的数据隔离、订单状态机、支付幂等是高频考点如果你是要做毕设或接外包这套源码真正值钱的是它演示了“一个单体应用如何支撑多个商户独立经营”。这篇笔记按“数据模型→权限隔离→订单支付→部署踩坑→改造验证”的顺序讲源码里没写清楚的部分用我实际做过的方案补上。2. 基于Java的多商户平台设计数据模型与代码结构怎么立住一个多商户平台最怕的是把单商户逻辑硬塞进多商户里。很多源码看起来功能齐全但商户之间数据互相串就是因为表结构里没有统一的商户维度。这套源码用来复习面向对象编程 java 也很合适商户、商品、订单都能抽象成清晰的类与接口代码结构比算法题更接近真实落地。我先讲数据模型再讲项目结构最后给一个最小可运行的骨架。2.1 商户、会员、订单三张核心表的关联设计多商户平台里通常有三类主体商户seller、会员member、订单order。会员是全平台共享的用 member 表单独存商户是入驻的商家用 seller 表订单是连接两者的表必须同时持有 member_id 和 seller_id。我一般这样设计三张主表CREATE TABLE seller ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT 商户ID, shop_name VARCHAR(64) NOT NULL COMMENT 店铺名称, status TINYINT DEFAULT 0 COMMENT 0-未审核 1-正常 2-冻结, create_time DATETIME NOT NULL, update_time DATETIME NOT NULL ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT商户表; CREATE TABLE member ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT 会员ID, nickname VARCHAR(64), mobile VARCHAR(20) UNIQUE, create_time DATETIME NOT NULL ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT会员表; CREATE TABLE order ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_sn VARCHAR(32) NOT NULL COMMENT 订单号, seller_id BIGINT NOT NULL COMMENT 所属商户, member_id BIGINT NOT NULL COMMENT 下单会员, total_amount DECIMAL(10,2) NOT NULL, status TINYINT NOT NULL DEFAULT 0, create_time DATETIME NOT NULL, KEY idx_seller_id (seller_id), KEY idx_member_id (member_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT订单表;这里的重点是订单表同时建了 seller_id 和 member_id 的索引。查询“某个商户的所有订单”和“某个会员的所有订单”都是高频场景不建索引会直接拖垮列表页。很多课程设计源码为了省事只在 order 表里留一个 member_id商户订单要 join 中间表这是性能隐患。另外member 表没有 seller_id。会员属于平台不属于某个商户如果会员表也加了商户 ID就变成了每个商户一套会员那不是多商户是多个独立系统。这个边界在设计时要划清楚。2.2 商品模型SPU、SKU与店铺维度商品是电商源码里最容易写乱的部分。我见过不少“阉割版”多商户源码一张 goods 表把商品所有信息塞进去没有 SKU 的概念。但真实场景里一件衣服有不同的颜色、尺码价格和库存都不一样这就要区分 SPU标准产品单元和 SKU库存量单位。对于多商户平台商品表必须带 seller_id并且 SKU 表必须通过 goods_id 关联回商品再由 goods.seller_id 确定归属。CREATE TABLE goods ( id BIGINT PRIMARY KEY AUTO_INCREMENT, seller_id BIGINT NOT NULL, spu_name VARCHAR(128) NOT NULL, category_id BIGINT, status TINYINT DEFAULT 0, create_time DATETIME NOT NULL, KEY idx_seller_status (seller_id, status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT商品SPU表; CREATE TABLE goods_sku ( id BIGINT PRIMARY KEY AUTO_INCREMENT, goods_id BIGINT NOT NULL, sku_code VARCHAR(64) NOT NULL, spec_values VARCHAR(255) COMMENT 颜色:黑,尺码:M, price DECIMAL(10,2) NOT NULL, stock INT NOT NULL, KEY idx_goods_id (goods_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT商品SKU表;SKU 表里我没有冗余 seller_id因为通过 goods_id join goods 表就能拿到。但如果你面对的是订单明细order_item最好把 seller_id 和 sku_code 冗余进去防止商品被删除后 join 不出历史数据。这是一个“反规范化”取舍很多 java 基础教程不会讲但实际项目里特别重要。2.3 用Spring Boot MyBatis搭一个最小可运行的项目结构源码的代码结构往往能看出作者的功底。我建议拿到源码后先看 controller/service/mapper 三层是否分开再看包名是否有商户域、订单域拆分的迹象。一个可运行的 Java 多商户项目最少要有这几个模块src/main/java ├── controller # 对接前端动作只做参数接收 ├── service # 业务逻辑下单、支付、结算 ├── mapper # MyBatis接口与XML ├── entity # 数据库表对应实体 ├── dto # 参数与返回对象 └── config # 拦截器、公共配置对应的 Maven 依赖我一般用 Spring Boot 2.x MyBatis Plus 的组合原因不是它最新而是课程设计和中小型电商项目里社区问答多踩坑容易找答案。核心依赖如下dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.3/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId scoperuntime/scope /dependency如果你拿到的是传统 MyBatis 而非 MyBatis Plus代码会多一些 BaseMapper但核心设计不变。启动类放在根包下保证 Spring 能扫到所有子包。这点看起来基础但很多源码本地跑不起来就是启动类放错包导致 Controller 不被加载。MyBatis XML 里最常见的操作是带条件分页查询我一般这样写select idselectOrderPage resultTypecom.example.entity.Order SELECT id, order_sn, seller_id, member_id, total_amount, status, create_time FROM order WHERE seller_id #{sellerId} if teststatus ! null AND status #{status} /if ORDER BY create_time DESC LIMIT #{offset}, #{limit} /select动态 SQL 里的 if 是最常用的注意 LIMIT 的 offset 和 limit 要单独作为参数传入不能拼在 #{} 里。MyBatis Plus 的 selectPage 在底层也是生成类似的 SQL只是帮你封装了分页参数。3. 把“多商户”落地权限隔离和数据越权拦截多商户平台源码能不能用先看商户 A 能不能查到商户 B 的订单。很多“商用源码”其实只做了前端菜单隐藏后端接口不设防这种项目上线必出事故。这一章就讲如何在 Java 代码里把“多商户”落到实处。3.1 商户ID贯穿全链路哪些表必须带哪些查询必须带上先说原则所有业务表商品、订单、售后、结算单都必须带 seller_id 或通过可追溯的关联表确定 seller_id。不做多商户隔离的表只有平台级的member、admin、platform_config。首要的是在代码层定一个铁律任何 Controller 接口涉及商户数据参数里必须显式传入或从登录态解析出当前操作的是哪个商户。不要在 Service 里写“查询所有订单然后内存过滤”那是性能灾难。例如商户后台查询订单列表正确写法是GetMapping(/seller/order/list) public Result orderList(RequestParam Long sellerId, RequestParam(defaultValue 1) Integer page, RequestParam(defaultValue 10) Integer size) { // 此处sellerId应该从登录session或token中解析而不是让前端传 PageOrder result orderMapper.selectPage( new Page(page, size), new LambdaQueryWrapperOrder() .eq(Order::getSellerId, sellerId) .orderByDesc(Order::getCreateTime)); return Result.ok(result); }这里的关键点是sellerId 从登录态取而不是信任前端传参。如果你让前端随便传 sellerId那商户 A 把参数改成 B 的 ID 就能看到 B 的订单。很多 java 面试题里问“多商户数据越权”就是这个场景源码里如果所有查询都硬编码“只取当前登录用户对应的店铺”那这个源码质量就是合格的。3.2 权限模型用简单的角色还是上RBAC多商户后台一般有两种账号体系平台管理员和商户管理员。如果只是课程设计用角色字段就够了但如果你想让它像一个能接外包的项目建议用 RBAC基于角色的访问控制。RBAC 的核心是 user、role、user_role、role_permission、permission 五张表。商户管理员登录后只拥有该商户 ID 下的菜单权限和数据权限。这里不需要过度设计一个 Java 多商户项目里有这些表即可CREATE TABLE sys_role ( id BIGINT PRIMARY KEY AUTO_INCREMENT, role_code VARCHAR(32) NOT NULL, role_name VARCHAR(64) NOT NULL ); CREATE TABLE sys_user_role ( user_id BIGINT NOT NULL, role_id BIGINT NOT NULL, seller_id BIGINT NOT NULL COMMENT 角色归属的商户0表示平台 );注意 sys_user_role 加了一个 seller_id。这比标准 RBAC 多了一个维度。原因是同一个用户可能在不同商户里担任不同角色但在绝大部分多商户系统里一个用户只属于一个商户加 seller_id 是为了防止角色被跨商户使用。这个设计在源码里常见但很少有人解释为什么。3.3 用拦截器做统一数据权限过滤最稳妥的越权防护是在所有操作之前做一次校验当前登录用户是否有权访问目标商户资源。常见做法是定义一个注解比如 SellerAuth然后配合 Spring 的 HandlerInterceptor。public class SellerAuthInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { String uri request.getRequestURI(); if (!uri.startsWith(/seller/)) { return true; // 非商户端接口不拦截 } // 从Header或Token解析出当前商户ID而不是前端参数 Long loginSellerId parseSellerIdFromToken(request); String targetSellerId request.getParameter(sellerId); if (targetSellerId ! null !targetSellerId.equals(String.valueOf(loginSellerId))) { throw new BizException(无权访问该商户数据); } // 把当前商户ID写入ThreadLocal供Service层直接使用 SellerContext.set(loginSellerId); return true; } }这里的核心是ThreadLocal 保存当前商户 ID后续 Service、Mapper 都不用再传 sellerId。但要注意请求结束后必须清除 ThreadLocal否则线程池复用会把上一个商户的信息串给下一个请求。这个拦截器逻辑并不复杂但很多源码里没有。你拿到源码后可以先全局搜一下“setSellerId”和“ThreadLocal”这两个词搜索不到就说明这套源码在后端隔离上是裸奔的。为了验证隔离是否生效我一般会写一个最简单不过的测试用例public void testCrossSellerAccess() { LoginUser userA loginAsSeller(1L); // 直接用商户B的订单详情ID期望抛业务异常 Order orderB orderMapper.selectById(1002L); assertThrows(BizException.class, () - { orderService.getOrderDetail(userA, orderB.getSellerId(), orderB.getId()); }); }这个测试不依赖前端登录只验证 Service 层在传入商户 ID 不一致时会不会拦截。建议在拿到任何多商户源码后先跑通这个测试再决定要不要往下读代码。4. 订单与支付流程源码拆解状态机、幂等与结算电商平台最重要的一条业务线是订单。多商户平台比单商户多的复杂度在商户结算但订单和支付仍是核心。这一章从源码里最常被考的三个点讲订单状态机、支付回调幂等、商户结算。4.1 订单状态机用枚举和受控流转替代if else很多源码把订单状态写成魔法数字到处散落 if 判断改一个状态要全项目搜。我建议用一个 Java 枚举来定义状态并把允许的流转放在枚举里。public enum OrderStatus { CREATED(0, 已创建) { Override public boolean canTransferTo(OrderStatus target) { return target UNPAID || target CLOSED; } }, UNPAID(1, 待付款) { Override public boolean canTransferTo(OrderStatus target) { return target PAID || target CLOSED; } }, PAID(2, 已付款) { Override public boolean canTransferTo(OrderStatus target) { return target SHIPPED || target REFUNDING; } }, SHIPPED(3, 已发货) { Override public boolean canTransferTo(OrderStatus target) { return target COMPLETED || target REFUNDING; } }, COMPLETED(4, 已完成) { Override public boolean canTransferTo(OrderStatus target) { return target REFUNDING; } }, REFUNDING(5, 退款中) { Override public boolean canTransferTo(OrderStatus target) { return target COMPLETED || target CLOSED; } }, CLOSED(6, 已关闭); public final int value; public final String desc; OrderStatus(int value, String desc) { this.value value; this.desc desc; } public boolean canTransferTo(OrderStatus target) { return false; } }更新状态时先判断能否迁移public void updateOrderStatus(Order order, OrderStatus target) { OrderStatus current OrderStatus.from(order.getStatus()); if (!current.canTransferTo(target)) { throw new BizException(非法订单状态流转: current - target); } order.setStatus(target.value); orderMapper.updateById(order); }这个做法的价值是所有状态流转规则集中在枚举一处而不是散落在各个 Service 里。如果你把源码交给别人维护人家看这个枚举就能懂整个订单流程。这也是面试官最想听到的订单状态机必须做受控迁移而不是直接 setStatus。4.2 支付回调的幂等处理支付回调是多商户项目最容易翻车的地方。支付宝或微信会重复通知如果你不处理重复回调订单金额会被加两次、订单状态会被覆盖。常见且可靠的方案是在订单流水表上建一个唯一业务单号回调处理时先尝试插入流水插入成功才处理业务插入失败说明重复直接返回成功。Transactional public void handlePayCallback(String orderSn, String tradeNo, BigDecimal amount) { // 1. 查流水是否存在存在说明已处理过 PayTransaction exist payTransactionMapper.selectOne( new LambdaQueryWrapperPayTransaction() .eq(PayTransaction::getOrderSn, orderSn) .eq(PayTransaction::getTradeNo, tradeNo)); if (exist ! null) { return; // 已经处理过直接返回不能抛异常 } // 2. 插入流水如果唯一索引冲突会抛异常捕获后返回成功 PayTransaction tx new PayTransaction(); tx.setOrderSn(orderSn); tx.setTradeNo(tradeNo); tx.setAmount(amount); tx.setStatus(1); payTransactionMapper.insert(tx); // 3. 更新订单状态为已付款 updateOrderStatusBySn(orderSn, OrderStatus.PAID); }注意第 1 步先查再插看起来有并发窗口所以最终要依赖数据库唯一索引。建表时会给 pay_transaction 表加 UNIQUE KEY(order_sn, trade_no)。如果插入冲突要 catch DuplicateKeyException并当作成功处理而不是把异常抛给支付平台。代码里的 tradeNo 表示第三方支付流水号orderSn 是系统订单号。两个字段同时唯一可以保证同一个订单被支付两次时第二次插入失败。这套逻辑在很多 java 面试题里叫“保证支付幂等”源码里必须实现。4.3 商户结算与分润的实现思路订单支付成功后钱在平台手里然后按周期结算给商户。多商户源码里最简单的结算方式是在订单表增加 settlement_status 字段跑一个定时任务扫描已支付且未结算的订单按商户分组汇总生成结算单。CREATE TABLE settlement ( id BIGINT PRIMARY KEY AUTO_INCREMENT, seller_id BIGINT NOT NULL, period VARCHAR(20) NOT NULL COMMENT 结算周期如2025-06, order_total DECIMAL(12,2) NOT NULL COMMENT 订单总金额, commission DECIMAL(12,2) NOT NULL COMMENT 平台抽成, seller_income DECIMAL(12,2) NOT NULL COMMENT 商户实得, status TINYINT NOT NULL DEFAULT 0 COMMENT 0-待结算 1-已结算, KEY idx_seller_period (seller_id, period) );定时任务的做法是每天凌晨统计前一天已完成的订单生成结算单。注意这里的抽成比例应该由商户等级或活动规则决定而不是写死。如果源码里把所有商户的抽成都写成 0.05那你就要改造成从 seller 表或单独的分润规则表里读取。Scheduled(cron 0 0 2 * * ?) Transactional public void dailySettlement() { ListOrder doneOrders orderMapper.selectPaidNeedSettle(); MapLong, ListOrder grouped doneOrders.stream() .collect(Collectors.groupingBy(Order::getSellerId)); for (Map.EntryLong, ListOrder entry : grouped.entrySet()) { createSettlement(entry.getKey(), entry.getValue()); } }cron 表达式“0 0 2 * * ?”表示每天凌晨 2 点执行。这里要特别注意事务边界不要在循环外开一个大事务否则一个商户结算失败会影响所有商户。我习惯每个商户单独一个事务失败记录日志下个周期补算。5. 部署与二次开发避坑5个常见问题与解决办法拿到源码第一件事不是看代码而是让它跑起来。很多人在这一步就栽了跟头。我把自己在部署和整改多商户项目时遇到的高频问题列出来每条按现象、原因、解决来说。5.1 数据库脚本导入失败字符集、存储引擎、外键顺序现象执行 .sql 文件时报错比如“Specified key was too long”或者“Cannot add foreign key constraint”。原因一半是建表语句没有显式指定 utf8mb4导致默认排序规则下索引超长另一半是外键引用的父表还没创建而建表语句按字母顺序导外键先执行了。解决打开 .sql 文件把 ENGINE 改成 InnoDB、DEFAULT CHARSET 改成 utf8mb4同时删除所有外键约束等业务代码跑通后再手工加外键。多商户项目表多用 Navicat 或命令行导入时先导基础表seller、member再导业务表goods、order。如果导入工具支持单表选择按依赖顺序分批导入。5.2 本地启动失败JDK版本、端口占用、依赖下载现象Maven build 报错或者 Spring Boot 启动后立刻退出提示端口被占用。原因源码可能基于早期 Spring Boot 版本要求 JDK8而你本地是 JDK11 或 17另外 Tomcat 默认 8080 端口被占用。解决先看 pom.xml 里的 spring-boot.version确定 JDK 版本。我在本地统一用 JDK8 跑课程设计源码遇到 JDK17 编译错误就改 maven-compiler-plugin 的 source 和 target 为 1.8。端口冲突直接改 application.yml 里的 server.port或杀掉占用进程。注意改端口后前端里调用的后端地址也要同步改很多源码前端是硬编码 http://localhost:8080。5.3 商户A看到了商户B的数据现象登录商户 A 后台订单列表里能看到商户 B 的订单或者统计图表数据串了。原因后端查询语句没有带 seller_id 条件或者 MyBatis XML 里写的是 select * from order然后 Service 层用 Java 代码过滤。更隐蔽的是缓存了商户 B 的报表数据key 里没有商户 ID。解决全局搜索 XML 里的 select检查每一个涉及业务表的查询是否有 seller_id #{sellerId}。如果用的是 MyBatis Plus检查是否有 LambdaQueryWrapper.eq。这里我提一个血泪经验别信任前端传来的 sellerId一定从 Session/Token 里取已有拦截器的就检查拦截器是否在请求结束时清空了 ThreadLocal。5.4 上传图片无法显示本地路径与服务器路径不一致现象商品图片上传成功数据库也有 URL但前端访问图片显示 404。原因源码把图片存在本地磁盘返回的 URL 是绝对路径比如 D:/upload/x.jpg前端在另一个机器或容器里访问不到。解决把图片访问改成静态资源映射。在 Spring Boot 里加一个配置类把本地目录映射为 /upload/** 的虚拟路径同时把图片 URL 改为相对路径 /upload/xxx.jpg不要把磁盘盘符存到数据库。如果部署到 Linux 服务器建议直接用云存储上传 SDK 替换本地存储省去这台机器到那台机器拷贝文件的麻烦。5.5 接口返回格式不统一前端联调困难现象列表接口返回 {code:0}详情接口返回 {code:200}错误时前端拿不到一致结构。原因源码里每个 Controller 自己组装 Result 对象有的用 Map有的用 JSONObject有的直接返回实体。这是重构出来的历史包袱不是不能工作但很影响后续扩展。解决统一封装一个 Result 类包含 code、message、data 三个字段。把所有 Controller 返回值改成 Result 错误通过全局异常处理器返回。这是二次开发前必做的一步否则你给平台加个新功能前端就得为你的新接口单独写一套兼容逻辑。6. 进阶把创创猫源码改造成自己的多商户项目验证与扩展技巧6.1 先跑通最小闭环注册商户→发布商品→下单→支付→结算拿到源码后先不要急着改 UI按下面的顺序跑通最小流程用平台账号在后台创建一个测试商户用该商户后台发布一件商品设置 SKU 和库存用一个测试会员在前台下单模拟支付回调一般源码里会有 mock 支付接口查看订单状态流转和结算单生成如果这五步能通说明核心业务闭环没问题。如果某一步卡住优先去排查对应的 Mapper XML而不是改前端。6.2 验证数据隔离是否彻底两个商户交叉操作我习惯用一个很笨但有效的办法创建商户 A 和商户 B各发一件商品。先用 A 的 token 请求 B 的订单列表接口再改 URL 参数或 Header看是否返回 B 的数据。如果返回了就是越权。可以用一个临时测试接口验证拦截器是否生效比如调用 /seller/order/list?sellerId2用商户 1 的 token 请求期望返回无权访问。如果这个测试在源码里没有建议自己补上一个后面每次重构都跑一遍。6.3 扩展商户类型需要动哪些地方假设要在平台上增加“品牌旗舰店”和“个体小店”两种商户类型我会按这个顺序改动seller 表加 type 字段1-旗舰店2-小店商品表加一个是否允许跨店组合购买的开关或者不加默认全部单店下单结算规则表加一条按 type 区分抽成比例的数据Service 层在下单时读取商户类型决定是否校验资质核心原则是先动数据模型再动 Service最后动 Controller。如果你拿到源码后发现改一个功能要同时改十几个文件说明这个源码的职责边界拆得不够好你要想清楚是继续改还是换一个架构更清晰的源码。最后提一个我自己的习惯每次改完代码我都会把订单状态机的枚举和支付回调的幂等逻辑单独跑一遍单测因为这两个地方是生产事故的高发区。多商户项目不比单商户商户 A 的订单状态被商户 B 的错误回调改掉这种事故一出平台信誉就没了。希望这篇笔记能帮你把这个源码真正跑通、改对也希望你拿着它做出来的项目能在面试或验收时让面试官眼前一亮。本文还有配套的精品资源点击获取
返回列表