简介这是一份基于Java的创创猫多商户电商平台B2B2C消费端前端源码面向电商开发者、Vue前端工程师及毕业设计。前端采用uni-app开发可打包成小程序、APP与H5后台为Java技术便于学习完整业务链路。压缩包共342个文件约2.85MB以197个Vue页面组件为主另有64个PNG图片、51个JavaScript逻辑文件以及SCSS、JSON、wxs、nvue等配置覆盖商品展示、购物车、订单等模块。目前已有439人学习下载。通过这份源码可学习多商户电商平台前端的组件划分与交互实现理解B2B2C模式下消费、商户、平台三端的协作关系其uni-app跨端结构也有助于掌握多端打包适配为二次开发或课程设计提供完整参考。1. 为什么“多商户”不是一张表加一个商户ID一听到“基于Java的创创猫多商户电商平台设计源码”很多Java开发的第一反应是给商品表、订单表加个merchant_id查询时带上去不就是一个多商户商城了吗真把一套代码跑起来后才发现多商户电商平台设计源码里最花时间的不是CRUD而是权限边界、数据隔离、订单拆分、结算对账这一连串和“归属”有关的设计。这类源码名义上是设计稿加代码实际落地时是在同时服务平台运营、商户后台和C端会员三套角色。如果你正准备照着这类源码改造自己的项目或者想用Java快速搭一个多商户商城这篇能直接给你一条可复现的路径。2. 先立设计从“平台、商户、用户”三角色看多商户源码的骨架拿到一套多商户电商平台设计源码不要急着打开Controller。第一件事是看它的数据模型和账号体系因为多商户平台所有的复杂度都源于“一个平台下有很多商户每个商户又有自己的后台操作员”。这一章把骨架讲清楚后面交易链路才能接得住。2.1 账号体系别把商户ID直接挂在会员表上多商户平台表面是一套电商系统实际是三个子系统的叠加平台运营后台管商户审核和商品审核商户后台管自己的商品和订单会员端管浏览和购买。很多新手在第一次设计时图省事把merchant_id直接加到member表上结果后面店铺收藏、会员等级、订单归属全部绕不开这个字段权限越查越乱。正确做法是把“商户”当作组织把“商户用户”当作组织下的操作账号会员表只维护会员自身信息不直接挂商户ID。一个最简的账号体系SQL长这样CREATE TABLE merchant ( id bigint NOT NULL COMMENT 商户ID, merchant_name varchar(64) NOT NULL COMMENT 商户名称, status tinyint NOT NULL DEFAULT 0 COMMENT 状态0待审核 1正常 2冻结, contact_mobile varchar(20) DEFAULT NULL, created_at datetime NOT NULL, PRIMARY KEY (id) ) COMMENT商户表; CREATE TABLE merchant_user ( id bigint NOT NULL COMMENT 商户后台账号ID, merchant_id bigint NOT NULL COMMENT 所属商户ID, login_name varchar(32) NOT NULL COMMENT 登录账号, password_hash varchar(128) NOT NULL COMMENT 密码哈希, role_code varchar(32) NOT NULL COMMENT 角色编码如SHOP_ADMIN/SHOP_OPERATOR, PRIMARY KEY (id), KEY idx_merchant (merchant_id) ) COMMENT商户后台账号表; CREATE TABLE member ( id bigint NOT NULL COMMENT 会员ID, nickname varchar(32) DEFAULT NULL, mobile varchar(20) DEFAULT NULL, status tinyint NOT NULL DEFAULT 1 COMMENT 1正常 0禁用, PRIMARY KEY (id) ) COMMENTC端会员表;这里的核心参数说明merchant_user通过merchant_id关联商户而不是在member表上冗余商户ID。登录商户后台时身份信息里同时携带merchant_user_id和merchant_id后续所有商户维度的业务表都直接按merchant_id过滤。会员表只存储会员个人属性他买了哪个商户的商品通过订单表体现属于“业务关系”而不是“账号属性”。这样设计的好处是会员可以同时购买多个商户的商品而不会在账号表里留下混乱的多租户痕迹。如果以后要支持一个人管理多个商户只要把merchant_user改成“账号-角色-商户”多对多映射即可不需要动会员表。2.2 租户隔离选型共享库 逻辑隔离是这类源码的主流多商户比单商户多出来的首要问题是“数据怎么隔离”。在设计源码时通常有三种方案独立数据库、独立Schema、共享库加租户字段。独立数据库隔离性最强但一台机器跑一套成本高独立Schema需要数据库层面支持运维复杂。绝大多数Java单体多商户平台采用“共享库merchant_id逻辑隔离”因为部署简单恢复方便适合中小型平台。下表把这三种方案放在一起看隔离方案隔离级别部署成本适用场景独立数据库物理隔离高大客户、金融级独立Schema中等隔离中SaaS多租户共享库租户字段逻辑隔离低中小型多商户平台逻辑隔离最大的坑是查询时漏掉商户条件。你在读设计源码时重点看它是“手写where”还是“插件拦截”。手写where直观但接口一多就漏插件拦截统一但需要精确配置忽略表。我一般推荐在MyBatis-Plus里配置租户插件这是这套技术栈里最不容易翻车的方式Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new TenantLineInnerInterceptor(new TenantLineHandler() { Override public Expression getTenantId() { // 从当前登录上下文取商户ID平台运营后台不需要隔离时返回0 return new LongValue(MerchantContextHolder.getMerchantId()); } Override public String getTenantIdColumn() { return merchant_id; } Override public boolean ignoreTable(String tableName) { // 平台账号、区域字典、支付渠道等公共表不拦截 return Arrays.asList(platform_user, area, pay_channel).contains(tableName); } })); return interceptor; }这段配置会让所有带merchant_id字段的表在执行select、update、delete时自动追加merchant_id条件插入时自动补这个字段。注意两个参数getTenantIdColumn指定租户字段名默认就是merchant_idignoreTable里的公共表如果没有配置会导致平台管理员连登录菜单都查询不到。配置完成后业务代码里几乎不需要再手写“where merchant_id ?”但前提是表中必须存在该字段否则会被插件拦截成SQL异常。2.3 三张核心账本商品、订单、结算的主表设计多商户电商平台的业务最终落在三组表上商品表组、订单表组、结算表组。先把这三组表的主键和关键字段理清看源码时就能抓住主干不会被一堆活动表、优惠券表带偏。商品表组一般由spu和sku两张表组成表名关键字段说明spuid, merchant_id, category_id, title, status标准商品单元status区分草稿、待审核、上架skuid, spu_id, merchant_id, spec, price, stock销售单元price用分存储避免浮点数误差订单表组建议拆成订单主表、订单明细、支付流水表名关键字段说明orderid, order_no, group_no, member_id, merchant_id, total_amount, pay_amount, status每笔订单只属于一个商户group_no关联同一笔订单组order_itemid, order_id, spu_id, sku_id, merchant_id, num, price冗余merchant_id是为了后续按商户统计避免joinpayment_recordid, payment_no, order_no, merchant_id, amount, status支付流水幂等控制的唯一依据结算表组是很多开发容易忽略的但它才是多商户平台能不能持续跑下去的关键表名关键字段说明merchant_settlementid, merchant_id, period, total_amount, commission_amount, pay_amount, status按周期聚合的结算单settlement_detailid, settlement_id, order_no, amount, commission, status结算明细对账用在阅读设计源码时先打开SQL目录把这三组表的关系画出来。我的习惯是如果settlement表和order表之间没有关联字段说明作者还没把结算逻辑想清楚这类代码要谨慎参考如果order表有group_no字段说明作者已经考虑了多商户拆单。先看这些细节再去看Controller和Service会轻松很多。2.4 为什么常见组合是Spring Boot MyBatis-Plus Redis MySQL设计源码的技术选型通常会选国内团队最熟的组合Spring Boot Spring MVC MyBatis-Plus Redis MySQL。原因很简单Maven依赖容易拉社区资料多遇到问题能搜到答案。对需要快速落地多商户平台的团队来说选这套组合不属于炫技而是用最低的学习成本覆盖最核心的业务需求。代码分层也基本是固定的controller层只做参数校验和结果包装service层写事务逻辑mapper层对数据库操作。比如商品服务一般会有ProductController、ProductService、ProductServiceImpl、SpuMapper、SkuMapper。你在读源码时应该先在service层找Transactional注解再看事务边界是否覆盖了同一业务里的全部写操作。一个常见的坏味道是Controller类上直接加Transactional这会把事务范围扩大到HTTP请求解析和响应序列化拉长数据库连接占用时间并发一高就会出现连接池耗尽。多商户平台的正确事务边界是单个业务操作比如“创建商品并生成SKU”是一个原子操作放到一个service方法里而“批量上架商品”不应该在一个事务里处理否则一批里有一个失败全部回滚商户操作体验会非常差。这一章的落点是骨架先立住账号模型、隔离方案、账本表设计、技术组合四件事统一了之后后续交易链路里的所有代码才有讨论的意义。多商户平台不是功能堆得越多越好而是边界分得清楚。边界清楚了商户才不会看到别人的数据平台才敢让商户自己经营店铺。3. 把核心交易链路翻译成Java代码租户隔离、SPU、订单拆分骨架定好后核心交易链路是源码里最值得反复看的部分。多商户平台和单商户系统的本质差异就藏在这几条链路里商品发布、下单、支付回调、订单查询。每一条链路都要把“商户身份”放在第一位否则数据就会串。3.1 商品发布链路SPU/SKU怎么在同一事务里完成商品发布是商户第一次接触系统的入口也是多商户权限的第一个边界。设计源码里商品发布通常要求商户信息合法、商户状态正常新商品默认进入待审核状态。SPU是描述商品的抽象层SKU是具体可销售的规格单元。举一个例子“红色M码T恤”是一个SKU“T恤”本身是SPU。多商户平台上每个商户发布的是自己的SPU/SKUSKU必须冗余merchant_id这样库存扣减和销量统计不用关联SPU表。典型的商品发布Service代码Override Transactional(rollbackFor Exception.class) public Long createProduct(ProductCreateDTO dto) { Long merchantId dto.getMerchantId(); // 参数说明merchantId由商户后台登录态获取不能由前端传入 Merchant merchant merchantMapper.selectById(merchantId); if (merchant null || merchant.getStatus() ! 1) { throw new BizException(merchant.not.available); } Spu spu new Spu(); spu.setMerchantId(merchantId); spu.setCategoryId(dto.getCategoryId()); spu.setTitle(dto.getTitle()); spu.setStatus(SpuStatus.PENDING_AUDIT); spuMapper.insert(spu); for (SkuDTO skuDTO : dto.getSkus()) { Sku sku new Sku(); sku.setSpuId(spu.getId()); sku.setMerchantId(merchantId); sku.setSpec(skuDTO.getSpec()); sku.setPrice(skuDTO.getPrice()); sku.setStock(skuDTO.getStock()); skuMapper.insert(sku); } return spu.getId(); }逻辑说明Transactional保证SPU和SKU要么全部插入成功要么全部回滚如果出现一条SKU价格非法整个商品都不会落库避免商品主数据残缺。参数说明里最关键的是merchantId的来源——它必须来自商户后台的登录态或token而不是前端json里传一个值否则商户可以伪造身份创建别人的商品。状态字段使用枚举常量SpuStatus.PENDING_AUDIT而不是直接写数字这样代码里不会到处是魔法值。3.2 下单链路按商户拆单与幂等控制当会员购物车里有商户A和商户B的商品时一个“订单组”会被拆成多个“子订单”每个商户一个订单每个订单独立算价、独立结算。这是多商户平台在订单设计上和单商户系统最大的区别。如果不拆单平台就没法人按商户清分结算也没法让各商户分别发货。下单链路还要解决幂等问题。会员点击“提交订单”的一瞬间可能会连续触发两次请求如果没有幂等库存会被扣两次。常见做法是前端生成一个唯一token后端用Redis做互斥Transactional(rollbackFor Exception.class) public String createOrder(OrderCreateDTO dto) { // 幂等参数说明token由前端生成一次提交一个 boolean firstSubmit redisTemplate.opsForValue() .setIfAbsent(order:token: dto.getToken(), 1, Duration.ofMinutes(30)); if (!firstSubmit) { throw new BizException(duplicate.submit); } // 按商户分组每个商户生成一张订单 MapLong, ListCartItemDTO merchantGroup dto.getItems().stream() .collect(Collectors.groupingBy(CartItemDTO::getMerchantId)); String groupNo generateOrderNo(G); for (Map.EntryLong, ListCartItemDTO entry : merchantGroup.entrySet()) { Long merchantId entry.getKey(); ListCartItemDTO items entry.getValue(); Order order new Order(); order.setOrderNo(generateOrderNo(O)); order.setGroupNo(groupNo); order.setMerchantId(merchantId); order.setMemberId(dto.getMemberId()); order.setTotalAmount(items.stream() .mapToLong(item - item.getPrice() * item.getNum()) .sum()); order.setStatus(OrderStatus.PENDING_PAY); orderMapper.insert(order); } // 扣减库存减库存SQL在Mapper中完成 for (CartItemDTO item : dto.getItems()) { int rows skuMapper.deductStock(item.getSkuId(), item.getNum(), item.getMerchantId()); if (rows ! 1) { throw new BizException(stock.not.enough); } } return groupNo; }逻辑说明Redis的setIfAbsent是原子操作同一token在30分钟内只会有一个请求成功这是第一层幂等。订单表里的group_no把多张子订单关联成一组会员下单后看到的是一笔付款单商户后台看到的则是属于自己的子订单。库存扣减方法deductStock接收skuId、数量、merchantId三个参数在SQL里做条件更新而不是先查再改。参数说明里要注意金额计算用long类型存分避免double的精度问题generateOrderNo要保证全局唯一最简单的方式是“日期 redis自增 随机后缀”不要用UUID当订单号因为对账和客服系统里可读性太差。3.3 支付回调链路先幂等后记账支付回调是多商户平台最容易出事故的地方。支付平台通知你的服务器“这笔订单已支付成功”但这个通知可能因为网络原因重发多次。如果不做幂等处理订单状态会被重复流转结算流水也会多记。设计源码里一般会有一张payment_record表作为唯一键约束的依据。支付回调处理的核心逻辑可以这样拆Transactional(rollbackFor Exception.class) public void handlePayCallback(PayCallbackDTO callback) { // 参数说明paymentNo是支付平台流水号orderNo是本系统订单号 PaymentRecord record paymentRecordMapper.selectOne(new LambdaQueryWrapperPaymentRecord() .eq(PaymentRecord::getPaymentNo, callback.getPaymentNo())); if (record ! null) { // 已处理过直接返回避免重复记账 return; } paymentRecordMapper.insert(PaymentRecord.builder() .paymentNo(callback.getPaymentNo()) .orderNo(callback.getOrderNo()) .merchantId(callback.getMerchantId()) .amount(callback.getAmount()) .status(PaymentStatus.SUCCESS) .build()); // 订单从待支付变为已支付 int updated orderMapper.updateStatusByOrderNo(callback.getOrderNo(), OrderStatus.PENDING_PAY, OrderStatus.PAID); if (updated ! 1) { throw new BizException(order.state.changed); } // 触发商户结算记账由单独结算服务处理 settlementService.recordPaySuccess(callback.getOrderNo()); }逻辑说明先查payment_record表同样的paymentNo如果已经存在就说明回调重复直接忽略。这是订单状态之外的第三道幂等屏障。然后用条件更新“只有当前状态是PENDING_PAY时才更新为PAID”如果订单已经被取消或关闭updated为0程序就会抛异常不会继续往下走结算。参数说明回调数据里必须有paymentNo和orderNo并且orderNo可以找到对应的merchant_id回调接口对外暴露时还需要验签这里不做展示但真实项目里绝不能省掉。3.4 查询链路会员订单和商户订单必须分两套查询多商户平台里同一个订单数据会员端和商户端看到的是不同视角。会员端通过member_id查询“我买过什么”商户端通过merchant_id查询“这个店铺卖出过什么”。它们的查询条件、字段展示、状态维度完全不同设计源码时千万不要用一个通用接口同时处理两个视角。商户后台的分页查询示例public PageResultOrderVO pageMerchantOrders(OrderPageQuery query) { Long merchantId MerchantContextHolder.getMerchantId(); // 强制从上下文取merchantId不允许前端传参覆盖 PageOrder page Page.of(query.getPageNum(), query.getPageSize()); LambdaQueryWrapperOrder wrapper new LambdaQueryWrapperOrder() .eq(Order::getMerchantId, merchantId) .eq(StringUtils.hasText(query.getOrderNo()), Order::getOrderNo, query.getOrderNo()) .eq(query.getStatus() ! null, Order::getStatus, query.getStatus()) .orderByDesc(Order::getCreatedAt); PageOrder result orderMapper.selectPage(page, wrapper); // 再关联查询会员昵称等信息避免在订单表里存冗余昵称 return convertToVO(result); }这段代码看起来简单但正是这些简单的条件拼接决定了商户能否只能看到自己的订单。如果merchantId是通过前端表单传进来的那商户把参数改成别人的merchantId就能把别家的订单拉出来。所以设计源码里MerchantContextHolder.getMerchantId()必须是从登录态解析出来的和HTTP请求上下文绑定。会员端订单查询同理用member_id过滤不允许传商户ID。把这两个查询拆开既是为了安全也是为了各自的索引设计能够独立优化。4. 从源码可读性到可扩展性模块拆分与状态机交易链路跑通之后源码能不能长久维护取决于状态管理和模块边界。这一章专门讲订单状态机、结算处理和异步上下架三个扩展点。多商户平台里这些模块一旦混乱后续加一个优惠券功能都可能动到核心代码。4.1 订单状态机状态字段别散落在if/else里订单状态是多商户平台里最容易被写乱的地方。很多源码喜欢在Service里用一串if判断“如果状态是A就改为B如果状态是C就允许退货”结果各个接口判断逻辑不一致。订单状态机要做的是把合法流转集中定义让代码只按照状态机规则走。一个典型的Java枚举写法public enum OrderStatus { PENDING_PAY(10, 待支付), PAID(20, 已支付), DELIVERED(30, 已发货), COMPLETED(40, 已完成), CLOSED(50, 已关闭), REFUNDING(60, 退款中); private final int code; private final String desc; OrderStatus(int code, String desc) { this.code code; this.desc desc; } public static boolean canChange(OrderStatus from, OrderStatus to) { switch (from) { case PENDING_PAY: return to PAID || to CLOSED; case PAID: return to DELIVERED || to REFUNDING; case DELIVERED: return to COMPLETED || to REFUNDING; case COMPLETED: return to REFUNDING; case REFUNDING: return to COMPLETED || to CLOSED; default: return false; } } }使用这个枚举后状态流转的逻辑收敛到一个方法里。任何接口想做状态变更先判断canChange不允许流转就直接抛异常。实际用的时候还有一张order_status_log表记录每一次状态变更的operator和reason这样售后纠纷时能看到操作痕迹。参数说明里尤其要注意退款中到已完成的路径属于退款成功或部分退款成功不是所有状态都能直接到CLOSED。4.2 商户结算按账期生成结算单佣金自动计算多商户平台如果没有结算模块订单收了钱却分不清楚钱该给谁。设计源码里结算通常是T1或T7当天订单成功后第二天进入待结算池按商户和账期聚合成结算单同时按平台设定的佣金比例扣掉平台分成。一个按账期生成结算单的Service方法可以这样写Transactional(rollbackFor Exception.class) public void generateSettlement(String settlementPeriod) { // 参数说明settlementPeriod格式为yyyy-MM-dd例如2025-06-01 ListOrder paidOrders orderMapper.selectList(new LambdaQueryWrapperOrder() .eq(Order::getStatus, OrderStatus.COMPLETED) .isNull(Order::getSettlementNo) .likeRight(Order::getUpdatedAt, settlementPeriod)); // 按商户聚合 MapLong, ListOrder merchantOrders paidOrders.stream() .collect(Collectors.groupingBy(Order::getMerchantId)); for (Map.EntryLong, ListOrder entry : merchantOrders.entrySet()) { MerchantSettlement settlement new MerchantSettlement(); settlement.setMerchantId(entry.getKey()); settlement.setPeriod(settlementPeriod); settlement.setStatus(SettlementStatus.PENDING); long totalAmount 0L; long commissionAmount 0L; for (Order order : entry.getValue()) { Long merchantId order.getMerchantId(); // 从商户配置取佣金比例比如0.15代表15% int commissionRate merchantConfigMapper.selectByMerchantId(merchantId).getCommissionRate(); long commission order.getPayAmount() * commissionRate / 100; totalAmount order.getPayAmount(); commissionAmount commission; SettlementDetail detail new SettlementDetail(); detail.setSettlementNo(settlement.getSettlementNo()); detail.setOrderNo(order.getOrderNo()); detail.setAmount(order.getPayAmount()); detail.setCommission(commission); settlementDetailMapper.insert(detail); } settlement.setTotalAmount(totalAmount); settlement.setCommissionAmount(commissionAmount); settlement.setPayAmount(totalAmount - commissionAmount); merchantSettlementMapper.insert(settlement); } }结算模块最怕重复生成。这里的条件isNull(Order::getSettlementNo)保证同一笔订单只会归属到一个结算单同时在settlement_detail表对order_no建唯一索引双保险。参数说明中佣金比例存的是整数百分比不是小数这样在Java乘法里不会出现浮点误差。如果你的源码里没有settlement_no这个字段说明结算模块还没闭环这是需要补的第一优先级字段。4.3 商品上下架消息审核通过后同步缓存和索引商品审核通过后不只是改一个数据库状态还要把商品信息同步到会员端查询的缓存或搜索引擎。这里如果同步失败会员端就会看到商品缺失或下架不生效。设计源码里会用一个事件发布机制把“审核通过”和“会员端可见”解耦。使用Spring自带的ApplicationEvent就足够Service public class ProductAuditService { Autowired private ApplicationEventPublisher eventPublisher; Transactional public void passAudit(Long spuId, Long merchantId) { int updated spuMapper.updateStatus(spuId, merchantId, SpuStatus.PENDING_AUDIT, SpuStatus.ON_SALE); if (updated ! 1) { throw new BizException(spu.status.not.pending); } // 发布事件同步Redis商品详情和搜索索引 eventPublisher.publishEvent(new ProductOnShelfEvent(spuId, merchantId)); } }事件监听器可以放在单独类里负责写Redis和同步搜索引擎。为什么用事件而不是直接在事务里同步缓存因为缓存写入失败不该让数据库事务回滚商品已经审核通过是事实缓存可以稍后重建。参数说明中updateStatus方法带了merchantId条件是为了防止平台运营人员审核时把别的商户的同ID商品误改状态。这种写法的代价是缓存会存在短暂不一致所以还要加一个定时任务每隔几分钟把最近变更的商品重新刷一遍缓存。这个设计取舍在很多源码里不一定写全但你在落地时一定要补上。5. 多商户电商平台设计源码避坑从部署到上线的5个真实问题这一章来自血泪经验。代码按设计跑通不难难的是上线后在高并发、多商户真实数据下各种边界问题才开始露头。下面5个坑是我认为多商户平台里最值得提前堵住的。5.1 商户ID没加索引商户后台列表越查越慢现象商户后台打开“商品列表”“订单列表”时响应时间从1秒慢慢变成3秒、5秒甚至超时。查看MySQL慢查询日志发现大量语句都在扫描全表过滤条件里明明有merchant_id和status执行计划却显示possible_keys为NULL。原因建表时只给主键加了索引后面为了实现租户隔离所有查询都带merchant_id这个字段却没有索引数据量超过几十万后全表扫描代价极高。解决第一时间给每个核心业务表加上组合索引索引顺序按“等值字段在前范围字段在后”的原则ALTER TABLE order ADD INDEX idx_merchant_status_created (merchant_id, status, created_at); ALTER TABLE spu ADD INDEX idx_merchant_status (merchant_id, status); ALTER TABLE sku ADD INDEX idx_merchant_spu (merchant_id, spu_id);加完索引后用EXPLAIN验证执行计划。注意新增索引不要盲目加order表订单查询会有很多维度加三个以内的组合索引即可。如果查询条件里还有order_no也给order_no单独建唯一索引。这个坑的根源不是代码问题而是对多商户数据量增长估计不足上线前就应把索引设计在SQL迁移脚本里。5.2 支付回调没有幂等订单重复结算现象运营在对账时发现同一笔订单在结算明细里出现两次或者在订单表里看到状态从待支付变成已支付后又被回调改了一次产生重复发货记录。原因支付平台为了确保通知必达会多次发送回调。第一次回调成功后应用正好重启或Redis缓存失效第二次回调进来时程序没检查支付流水直接又做了一次订单状态更新和结算入账。解决在设计上用好三个幂等点。一是payment_record表给payment_no加唯一索引重复回调时MySQL直接报错或让应用根据DuplicateKey捕获二是在更新订单状态时使用条件更新比如update order set statusPAID where order_no? and statusPENDING_PAY三是回调处理业务里先查后插public void handlePayCallback(PayCallbackDTO dto) { try { paymentRecordMapper.insert(PaymentRecord.from(dto)); } catch (DuplicateKeyException e) { // 已处理过忽略本次回调 return; } // 后续更新订单与结算 }这一段代码把幂等从“应用层判断”下沉到了“数据库唯一约束”比先查询再判断更可靠。如果命令行建表时没有唯一索引现在数据库里还存在重复数据需要先清理再补索引否则索引建不上去。5.3 库存扣减用“先查再改”高并发下超卖现象秒杀场景或爆款商品促销时明明页面显示库存还有10件商户后台却看到卖出去了12件和真实库存对不上。原因代码里先select stock from sku where id?在Java里判断stock num再执行update sku set stock? where id?。两个并发请求同时读到同一库存各自判断库存足够分别扣减导致超卖。解决把库存扣减做成一条原子SQL把判断条件放进whereUpdate(UPDATE sku SET stock stock - #{num} WHERE id #{skuId} AND merchant_id #{merchantId} AND stock #{num}) int deductStock(Param(skuId) Long skuId, Param(merchantId) Long merchantId, Param(num) Integer num);这个Mapper方法返回受影响行数如果返回0说明库存不足或商户不匹配业务层再抛异常。注意这里不能把merchant_id排除掉否则可能扣了别人的货。参数说明中stock字段不能设置为unsigned小于0否则MySQL在strict mode下会直接报错影响性能诊断。库存扣减和订单插入放在同一个事务里事务提交后才会释放行锁并发量太高时也可以走Redis预扣再异步同步但那属于更复杂的方案普通多商户平台用这条SQL就够。5.4 图片上传到本地磁盘多实例部署后图片丢失现象本地开发时商户上传商品图片一切正常打包部署到测试环境后图片偶尔能显示偶尔404。重启服务后之前上传的商品图全部打不开。原因开发期把图片写到了本地磁盘的某个目录比如./upload但部署环境是两台实例每次请求可能被负载均衡分发到不同机器加上服务重启时临时目录被清空图片自然找不回。解决多商户平台的文件存储必须独立于应用进程。最普遍的做法是接入对象存储服务代码里只保存相对路径或URLpublic String uploadImage(MultipartFile file, Long merchantId) { String ext StringUtils.getFilenameExtension(file.getOriginalFilename()); String objectKey merchant/ merchantId / System.currentTimeMillis() _ UUID.randomUUID() . ext; // 这里使用存储客户端的putObjectbucket固定或按业务分 storageClient.putObject(bucketName, objectKey, file.getInputStream()); return storageClient.buildUrl(objectKey); }这个方案的关键参数是objectKey它必须按商户ID分目录否则很难管理谁传了什么文件名里加时间戳和随机串是为了防重名。如果你的项目暂时没有对象存储也要至少保证文件落在固定的、不走临时目录的独立磁盘路径并且部署时把该路径挂在共享存储上。还有一点容易漏上传接口要做文件类型白名单校验不能只依赖前端后缀判断否则恶意商户会传可执行文件。5.5 定时关单任务误伤刚支付的订单现象用户在下单后立即支付支付网关已经回调成功但定时关单任务先一步执行把订单状态改成“已关闭”随后支付回调回来反而更新失败用户付了钱却看不到订单。原因关单任务执行时只判断“订单状态是待支付”以及“订单创建时间距今超过N分钟”没有考虑支付回调可能正在路上。两个操作并发关单任务抢先了。解决把“关单”也设计成条件更新并且把时间阈值放在SQL的条件里而不是先把数据查出来再判断Update(UPDATE order SET status CLOSED, close_time NOW() WHERE status PENDING_PAY AND created_at #{deadline} AND merchant_id #{merchantId}) int closeExpiredOrders(Param(deadline) Date deadline, Param(merchantId) Long merchantId); ### 关于技术细节补充作者这里把SQL写到了一半就是为了说明一个关键点状态更新必须带原始状态条件这样即使支付回调与关单任务并发也只有一个能成功。支付回调更新时条件statusPENDING_PAY关单更新时条件也是statusPENDING_PAY数据库的行锁会保证这两个更新串行执行后执行的那一个看到状态已经变化更新行数为0从而做出不同反应。定时关单任务不感知商户ID也行但加上merchantId条件可以在多商户平台里按商户粒度控制关单策略。这5个坑解决后多商户平台才敢从“能跑demo”走向“能上线运营”。6. 进阶验证给多商户平台加一个对账任务用它对冲“数据扑朔迷离”最后一个技巧也是运维阶段最能救命的一件事给平台写一个每日对账任务。很多设计源码只把订单、支付、结算做出来就结束了但对账任务往往缺失。我的习惯是新接一套多商户源码后先写这个任务每天核对三组数字支付平台流水是否和本地payment_record一致本地订单金额汇总是否和支付流水一致结算单金额是否和订单明细一致。对账任务最核心的代码不是复杂算法而是简单的分组聚合加状态标记Component public class SettlementReconcileTask { Scheduled(cron 0 30 1 * * ?) public void reconcile() { String yestoday LocalDate.now().minusDays(1).toString(); ListMapString, Object orderAmounts orderMapper.sumAmountByMerchant(yestoday); ListMapString, Object payAmounts paymentRecordMapper.sumAmountByMerchant(yestoday); MapLong, Long payMap payAmounts.stream() .collect(Collectors.toMap(m - (Long) m.get(merchantId), m - (Long) m.get(totalAmount))); for (MapString, Object orderAmount : orderAmounts) { Long merchantId (Long) orderAmount.get(merchantId); Long orderTotal (Long) orderAmount.get(totalAmount); Long payTotal payMap.getOrDefault(merchantId, 0L); if (!orderTotal.equals(payTotal)) { // 不一致时写入对账结果表并触发告警通知 reconcileRecordMapper.insert(ReconcileRecord.builder() .merchantId(merchantId) .orderAmount(orderTotal) .payAmount(payTotal) .period(yestoday) .status(MISMATCH) .build()); } } } }这个任务的价值不在于能发现所有资金问题而在于每天强制暴露一次数据不一致。多商户平台一旦商户数量多了任何一笔手工改库、脚本修正都可能造成商户对账不平。对账任务的输出结果应该直接推给运营人员而不是写进日志里等人翻看。我早期吃过这个亏总觉得自己写的代码不会出账目问题结果某天一个定时任务参数配错连续三天结算金额少算了商户来找才发现。从那以后每个平台都先补对账任务再补别的功能。多商户的复杂不在某一处代码而在整体数据闭环对账就是那个把一切拉回正轨的兜底手段。希望帮到你。本文还有配套的精品资源点击获取