ARTICLE DETAIL

资讯详情

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

社区团购系统Java实现:订单状态机、库存扣减与分布式锁实战

社区团购系统Java实现:订单状态机、库存扣减与分布式锁实战 简介基于SpringBoot开发的社区团购系统完整代码包面向Java学习者、计算机与电子信息类专业学生适用于毕业设计、课程设计及期末大作业。代码包共七百九十二个文件压缩后约十五点三MB核心包括一百一十五个Java后端源码、四十五个Vue组件、一百六十四个JavaScript及HTML/CSS等前端资源并附带XML/YAML配置、Maven工程配置、启停批处理脚本及部分备份文件目录结构清晰可导入开发环境快速运行。已有二百五十四人学习参考源码经过严格测试可放心用于项目复现和二次开发。资源采用前后端分离的B/S架构覆盖SpringBoot与Mybatis的后端接口、Vue页面及Ajax交互完整展现社区团购系统中用户、商品、订单等典型模块的实现思路并包含从页面请求到数据库操作的完整调用链同时内置了多项可直接运行的批处理命令方便本地启动和构建。对掌握主流JavaWeb技术栈、完成课程设计或毕业设计均有直接帮助。1. 社区团购系统代码难在哪里先理清业务链路再谈Java工程实现网上能搜到的社区团购系统代码不算少从课程设计案例源码到开源项目Java系的能翻出几百个。但大部分跑通一次Demo就搁置了因为交易节奏和普通电商不一样用户不是下单后等快递而是今天下单、晚上截单、明天统一配送到团长自提点。预售、集单、次日自提这三件事决定了订单状态机、库存扣减和配送批次都不能照搬标准电商模板。这篇文章写给打算用Java从零写社区团购系统的开发者按业务建模、表结构、下单链路、高频踩坑、拼团与集单的顺序落地最后用压测和对账把系统验证到敢上线。新手能照着建表跑通下单熟手可以直接对照自己方案里的边界条件和参数设置。2. 把业务拆成可落地的Java模块角色边界、核心表结构与实体骨架2.1 三个角色决定模块划分用户、团长和平台侧社区团购的参与方就三类C端用户、团长、平台运营。用户只关心今天有什么品、多少钱、几点截单、去哪提团长负责收货、分拣、通知和售后本质是一个物理自提点的负责人平台侧管商品上架、活动批次、集单、采购和配送调度。Java工程的模块划分直接照这个边界走user、goods、groupon批次、order、payment、delivery、settlement。这个体量的系统我一般做单体应用加模块分包不需要微服务。社区团购的核心瓶颈在数据库行锁和集单扫描不在服务拆分过早引入微服务只会把分布式事务的复杂度转嫁给自己。一个容易犯的建模错误是把「团长」和「自提点」混成一个实体。团长是个人可以负责多个自提点也可能被替换自提点是物理位置有地址、营业时间、冷藏条件。两者分开设计后续做配送批次和团长结算时不用返工。面向对象编程Java落地时最重要的是边界清楚而不是继承层次多漂亮。另一个认知决定成败社区团购的核心业务对象不是商品是「团购批次」。一个批次包含一批SKU有独立的截单时间、成团门槛、可售库存。很多Java源码包做不好就是因为把批次当成普通促销活动库里只有商品和订单两张表截单、集单的逻辑完全没有落点。批次这个概念一旦立住后面所有模块都围着它转。2.2 核心表结构批次、库存、订单三张表先立住我先给最小可用的表集合额外字段生产环境再加。批次表是社区团购的节拍器CREATE TABLE t_groupon_batch ( id bigint(20) NOT NULL AUTO_INCREMENT, batch_name varchar(100) NOT NULL COMMENT 批次名称, cutoff_time datetime NOT NULL COMMENT 截单时间, start_time datetime NOT NULL COMMENT 开始售卖时间, min_group_size int(11) NOT NULL DEFAULT 1 COMMENT 成团门槛, status tinyint(4) NOT NULL DEFAULT 1 COMMENT 1未开始 2进行中 3已截单 4已结束, create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_status_cutoff (status, cutoff_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT团购批次;cutoff_time不是展示文案下单接口里要强校验超过截单时间任何下单请求直接拒绝。idx_status_cutoff索引给截单后的定时任务用按状态加时间扫出需要集单的批次。库存不能挂在普通商品表上要挂在「批次SKU」维度因为同一个SKU在不同批次里价格和可售数量可能不同CREATE TABLE t_batch_sku_stock ( id bigint(20) NOT NULL AUTO_INCREMENT, batch_id bigint(20) NOT NULL COMMENT 批次ID, sku_id bigint(20) NOT NULL COMMENT SKU ID, total_stock int(11) NOT NULL DEFAULT 0 COMMENT 总库存, sold_stock int(11) NOT NULL DEFAULT 0 COMMENT 已售数量, price decimal(10,2) NOT NULL COMMENT 该批次内售价, create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_batch_sku (batch_id, sku_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT批次SKU库存;total_stock和sold_stock分开存而不是直接存剩余量是为了对账。每天结算时sold_stock的增量应该等于支付成功的订单行数量对不上就说明链路里有洞。price放在这张表而不是商品表是因为同一SKU可能在不同批次做不同折扣下单时取的是批次价快照。主订单表是交易核心CREATE TABLE t_order ( id bigint(20) NOT NULL AUTO_INCREMENT, order_no varchar(32) NOT NULL COMMENT 订单号业务唯一, user_id bigint(20) NOT NULL, batch_id bigint(20) NOT NULL, pick_point_id bigint(20) NOT NULL COMMENT 自提点ID, status tinyint(4) NOT NULL DEFAULT 0 COMMENT 0待支付 10已支付 20已取消 30已配送 40已完成 50退款中, total_amount decimal(10,2) NOT NULL COMMENT 商品总额, pay_amount decimal(10,2) NOT NULL COMMENT 实付金额, create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_order_no (order_no), KEY idx_user_batch (user_id, batch_id), KEY idx_status_create_time (status, create_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT社区团购主订单;idx_status_create_time这个索引会被集单任务频繁使用截单后要把已支付但未进配送批次的订单成批扫出来没有它订单量过十万之后一次集单可能跑出几十秒延迟。订单行表单独存字段是order_no、sku_id、goods_name、price、quantity、subtotal名称和价格必须做快照不能下单后去join商品表取实时名称。自提点表单独建字段有leader_id、name、address、start_time、end_time、status和团长表分开。字段类型上有几个细节要提醒。订单金额用decimal(10,2)不要用doubleJava侧对应BigDecimal时间统一用datetime并让数据库维护create_time和update_time减少应用层时钟不一致问题字符集固定utf8mb4而不是utf8因为用户备注可能填表情符号utf8存不了。这些细节不影响功能Demo但影响上线后的稳定性。2.3 实体设计与Mapper状态机枚举和贫血模型Java侧我把订单状态做成枚举流转规则写在枚举里避免全项目散落魔法数字public enum OrderStatus { PENDING_PAY(0, 待支付), PAID(10, 已支付), CANCELED(20, 已取消), DELIVERED(30, 已配送), COMPLETED(40, 已完成), REFUNDING(50, 退款中); private final int code; private final String desc; OrderStatus(int code, String desc) { this.code code; this.desc desc; } public boolean canTransitTo(OrderStatus target) { return switch (this) { case PENDING_PAY - target PAID || target CANCELED; case PAID - target DELIVERED || target REFUNDING; case DELIVERED - target COMPLETED; case COMPLETED - false; case CANCELED - false; case REFUNDING - target CANCELED || target COMPLETED; }; } }canTransitTo让订单流转规则集中在一处。支付回调、取消订单、配送扫描进来都要先过这个状态机再落库而不是随手setStatus。很多项目状态乱套就是因为到处都能直接改状态。实体类我用贫血模型POJO只承载数据业务逻辑在Service里。有人觉得充血模型更面向对象但社区团购这种状态多、事务边界复杂的系统贫血模型配合事务Service更好维护。DAO层我习惯用原生MyBatis加XML写核心SQL因为库存扣减、集单扫描这类语句需要精细控制用MyBatis-Plus的Wrapper拼条件反而难调优。到这里表结构和实体骨架立住了后续所有功能的复杂度都能落到这个模型上。下一步就是最关键的下单链路库存怎么扣、订单怎么落、事务边界画在哪。3. 用Spring Boot跑通下单链路库存扣减SQL、事务边界与状态更新3.1 下单接口骨架Controller只管参数Service管业务下单是社区团购系统代码里最容易写错的地方。很多课程设计的写法是先查库存再在Java里判断if (stock quantity)然后UPDATE stock stock - quantity。这个读改写三步在并发下必然超卖而且这个场景几乎是Java面试题里被问烂的经典真正动手写的时候却有一大半人按错的写。Controller只做参数校验和身份注入RestController RequestMapping(/api/order) public class OrderController { Resource private OrderService orderService; PostMapping(/create) public ResultString create(RequestBody Valid OrderCreateCommand cmd, RequestHeader(X-User-Id) Long userId) { cmd.setUserId(userId); return Result.success(orderService.createOrder(cmd)); } }X-User-Id由网关或登录拦截器解析后注入Controller不碰用户体系只认已登录的用户ID。OrderCreateCommand里有batchId、skuId、quantity、pickPointId用Valid做基础非空校验。Service层的核心链路是三步校验窗口、原子扣库存、落订单Service public class OrderService { Resource private OrderMapper orderMapper; Resource private BatchSkuStockMapper stockMapper; Resource private GrouponBatchMapper batchMapper; Transactional(rollbackFor Exception.class) public String createOrder(OrderCreateCommand cmd) { // 1. 校验批次是否在可售窗口内 GrouponBatch batch batchMapper.selectById(cmd.getBatchId()); if (batch null || !batch.isOnSale()) { throw new BizException(批次不在售卖窗口内); } // 2. 原子扣库存条件里带库存校验 int rows stockMapper.deductStock( cmd.getBatchId(), cmd.getSkuId(), cmd.getQuantity()); if (rows 0) { throw new BizException(手慢了库存不足); } // 3. 生成订单头和订单行 Order order OrderBuilder.create(cmd, batch.getPrice(cmd.getSkuId())); orderMapper.insert(order); orderMapper.insertItem(order.getOrderNo(), cmd); // 4. 返回业务订单号 return order.getOrderNo(); } }第1步的批次校验很关键。isOnSale()内部用当前时间对比start_time和cutoff_time截单后即使库存还有也不能下单。有些项目把截单校验写在页面JS里后端不校验改一下请求时间就能绕过这种属于上线必翻车。第2步防超卖核心在XMLupdate iddeductStock UPDATE t_batch_sku_stock SET sold_stock sold_stock #{quantity} WHERE batch_id #{batchId} AND sku_id #{skuId} AND sold_stock #{quantity} total_stock /update这条SQL把「查库存、判足够、扣减」合并成一个原子UPDATE。MySQL InnoDB执行UPDATE时会对命中的行加X锁两个并发请求同时进来只有一个能匹配到条件成立的那一行另一个更新0行Service拿到rows 0后抛业务异常。这里不需要显式SELECT ... FOR UPDATE条件更新天然具备这个语义代码也更短。3.2 事务边界扣库存和插订单必须同生共死我把扣库存和插订单放在同一个Transactional方法里因为这两步要么都成、要么都败。否则先扣库存后插订单插单失败事务回滚库存自动还原反过来先插单后扣库存扣库存失败要手动删订单很容易留下中间态。rollbackFor Exception.class必须显式声明Spring默认只对RuntimeException回滚自定义的BizException如果不继承运行时异常事务不会生效。事务里最忌讳的是外呼。如果在下单事务里调支付接口、发MQ、写大量日志持锁时间会无限拉长t_batch_sku_stock的X锁不释放后面所有下单请求全堵在锁等待。截单前十分钟流量峰值一来系统直接雪崩。常见做法是把下单事务控制在「扣库存插订单」两个操作内支付异步做回调进来另开事务更新状态。发MQ、发短信这类操作放到事务提交后。有一个隐藏问题一单多SKU时的事务加锁顺序。如果订单1先锁SKU A再锁SKU B订单2先锁SKU B再锁SKU A两个事务就可能互相等锁直到MySQL检测死锁并回滚一个。规避办法很简单在一个事务内需要扣减多个SKU库存时先把SKU ID排序再按顺序执行UPDATE。这样所有事务的加锁顺序一致死锁从根上消失。3.3 状态更新必须带期望值防止覆盖和乱跳支付回调更新订单状态不能用无条件的UPDATEupdate idupdateStatusWithExpect UPDATE t_order SET status #{targetStatus} WHERE order_no #{orderNo} AND status #{expectStatus} /update如果更新行数为0说明当前状态不是期望值——要么支付回调重复了要么用户已经取消。后一种情况不能直接覆盖状态要走退款流程。配合OrderStatus.canTransitTo状态机才有实际约束力。我在Service里写了一个通用的transition(orderNo, expectStatus, targetStatus)所有状态变更都走它杜绝散落的UPDATE t_order SET status xxx。订单号生成也不能马虎。我一般用「时间戳 用户ID后四位 随机数」拼一个字符串数据库建唯一索引兜底。不要用自增ID当订单号暴露给用户会泄露业务量而且分表后全局唯一性很难保证也不要用UUID当订单号32位太长用户对着客服报单号时体验很差。下单接口上线前我还会在Service入口加一个结构化日志log.info(createOrder|userId{}|batchId{}|skuId{}|quantity{}, ...)。线上排查超卖、重复单、批次校验失败全靠这条日志串起全链路。出问题时先看日志里有没有对应订单号再决定是查库存操作日志还是查回调幂等表。日志里不带手机号、地址这类敏感信息避免合规麻烦。注意所有状态变更必须走统一出口「updateStatusWithExpect canTransitTo」不要在业务代码里写裸 UPDATE这是订单状态不乱套的最后底线。4. 社区团购接单后的四个高频坑超卖、重复回调、状态乱套与一致性幻觉这一章的内容都是我实际踩过的每条按「现象→原因→解决」说清楚。社区团购系统的代码能不能扛住真实流量看的就是这几个地方。4.1 超卖先查再扣为什么一定会翻车现象库存剩最后1件时两个用户同时下单都成功sold_stock被加到2。原因读改写三步不是原子操作。两个并发请求同时读到sold_stock 1各自在Java层判断1 1 2成立然后都执行SET sold_stock sold_stock 1最终结果变成2。这个问题在单体架构下都不安全更别说后面接微服务。解决条件UPDATE是首选不需要引入Redis预扣。Redis预扣的方案DECR后判断负数确实扛并发但它引入了Redis和MySQL的双写一致性问题Redis扣了、MySQL事务失败库存两边对不上得额外做补偿。小体量社区团购系统直接用数据库条件更新简单、可对账压测到每秒几百单没问题。真要上Redis预扣前提是团队有能力处理对账补偿否则上线后每天对账都会发现有差额。4.2 支付回调重复重复通知不是异常是常态现象一笔支付回调被支付渠道重试了3次回调机制就是会重试直到业务方返回成功应答如果处理逻辑没有防重订单的入账动作被执行多次可能出现重复发货、重复入账。原因回调处理代码没有幂等设计。很多人的第一版代码是这样回调来了查一下订单存在就更新状态。但「查一下」和「更新」之间第二次回调可能已经进来了。解决加一张幂等表核心是唯一索引CREATE TABLE t_pay_callback_log ( id bigint(20) NOT NULL AUTO_INCREMENT, payment_no varchar(64) NOT NULL COMMENT 支付流水号, order_no varchar(32) NOT NULL, notify_type varchar(32) NOT NULL DEFAULT PAY, payload text COMMENT 原始回调报文, status tinyint(4) NOT NULL DEFAULT 1 COMMENT 1待处理 2已入账, create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_payment_no_notify (payment_no, notify_type), PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT支付回调幂等表;回调处理逻辑是先INSERT幂等表插入成功说明是第一次来继续后续业务插入报唯一键冲突说明已经处理过直接返回成功应答。注意幂等表插入和订单状态更新必须在同一个事务里否则第二次回调可能在第一次事务提交前进来照样重复处理。这个方案在Java单体项目里是性价比最高的不需要引入分布式锁。4.3 状态乱套没有状态机约束的任意更新现象待支付订单被支付回调直接置为「已完成」跳过了配送和自提流程或者用户取消订单后支付回调又进来把状态改回「已支付」钱收了货没发。原因状态更新SQL不带期望值任何调用方都能直接改状态。比如UPDATE t_order SET status 40 WHERE order_no ?执行前根本不管订单当前是什么状态。解决所有状态变更走统一出口用第3章的updateStatusWithExpect加上OrderStatus.canTransitTo双保险。代码层面加一个约束Mapper层只暴露updateStatusWithExpect方法不暴露裸的updateStatus。配合支付的退款流程用户取消订单后如果支付回调再进来expectStatus PENDING_PAY匹配不上走向退款分支而不是覆盖状态。4.4 数据一致性幻觉本地消息表才是Java单体项目的现实选择现象支付成功后发MQ通知库存模块占位MQ丢了一条消息订单显示已支付但库存侧没有扣减或者反向的库存已扣、订单未生成两边对不上。原因把MQ当成了可靠组件。事实上业务库和MQ之间天然存在双写问题先改库再发消息发消息失败怎么办先发消息再改库改库失败怎么办分布式事务中间件能解决但很多团队没有基建条件。解决单体应用用「本地消息表」最实在。业务表和消息表在同一个事务里写入事务提交后由定时任务扫消息表发送。发成功的标记sent 1失败的不断重试。消息消费方处理完业务后回调确认确认不了的由对账脚本兜底。这个方案不依赖任何中间件MySQL本身就是可靠存储唯一要注意的是消息表要定期清理避免膨胀。Java怎么保证数据一致性这个问题在网上有各种八股文答案从两阶段提交到TCC事务但落到社区团购这种体量本地消息表加对账脚本是最稳的。高深方案留给高频交易场景这里要的是能跑三个月不出事。5. 把拼团、分布式锁和集单做厚Redis锁参数、延迟队列和截单扫描5.1 拼团逻辑下单时校验门槛定时任务兜底成团批次表里min_group_size是成团门槛。常见做法是双保险下单时校验「当前已支付人数 1」是否达到门槛达到就立即成团订单状态直接标记PAID并进入待配送没达到就正常待支付等支付回调来了再判断一次是否成团。成团判断不能只在支付回调里做因为可能差一个人一直不成团直到截单。截单后的定时任务每小时扫一次批次内已支付订单数大于等于min_group_size把该批次标记成团成功小于门槛的批次把已支付订单全部退款。退款走支付回调的反向接口资金原路退回。5.2 分布式锁SETNX只是半成品过期时间和owner才是关键集单、团批状态切换这类任务要求同一时刻只能有一个实例执行。单机版的synchronized在部署多实例后失效得用分布式锁。网上最常见的写法是SETNX key value但这是半成品——如果拿到锁的实例在执行业务时宕机锁永远不会释放后续所有任务全部卡死这就是死锁。正确的锁要带三个参数NX不存在才设置、EX过期时间、owner持有者标识。public class RedisLock { private final StringRedisTemplate redisTemplate; public boolean tryLock(String key, String owner, long expireSeconds) { // SET key owner NX EX expireSeconds Boolean success redisTemplate.opsForValue() .setIfAbsent(key, owner, Duration.ofSeconds(expireSeconds)); return Boolean.TRUE.equals(success); } public void releaseLock(String key, String owner) { // 只有持有者才能释放防止误删别人的锁 String script if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end; redisTemplate.execute( new DefaultRedisScript(script, Long.class), Collections.singletonList(key), owner); } }owner用UUID或「实例ID线程ID」释放时校验owner否则可能出现A的锁过期后B拿到锁然后A执行完把B的锁删了这类误删事件在日志里极难排查。过期时间不能拍脑袋设30秒要看任务实际耗时一般按「预估最大耗时 × 2」设置比如集单任务正常2秒跑完设置5秒或10秒。用Redisson的RLock时它自带看门狗续期省去估时但原理仍然是这三个参数。提示如果项目里已经引入Redisson优先用RLock不要自己造锁。RLock内置了看门狗续期、owner校验和重入踩坑概率比手写SETNX低很多。5.3 延迟队列支付超时关单用Redisson还是数据库轮询支付超时关单是社区团购的刚需用户下了单15分钟不支付要自动关掉并释放库存。这个场景本质是延迟任务两种常见方案。Redisson的RDelayedQueue基于Redis ZSet实现精确到秒使用简单RBlockingQueueString queue redissonClient.getBlockingQueue(order:timeout); RDelayedQueueString delayedQueue redissonClient.getDelayedQueue(queue); delayedQueue.offer(orderNo, 15, TimeUnit.MINUTES);但延迟消息存在Redis里Redis重启且未开启AOF持久化时未触发的消息会丢。而且消费端是阻塞队列要常驻一个线程。我一般用数据库轮询更符合单体项目的可靠性预期。每1分钟扫一次待支付超时订单SELECT order_no, user_id, batch_id, sku_id, quantity FROM t_order WHERE status 0 AND create_time DATE_SUB(NOW(), INTERVAL 15 MINUTE) LIMIT 200扫到后逐单关单、释放库存即sold_stock回退。轮询的延迟误差最多1分钟对15分钟超时场景完全可接受而且任务本身就是操作数据库扫出来直接处理少一层MQ转发。注意关单释放库存的SQL同样要带条件比如AND sold_stock quantity防止关单和支付回调并发时把已支付订单的库存扣成负数。轮询方案还有一个好处天然支持重试。某次关单事务因为数据库连接超时失败下一次轮询1分钟后会再次扫到这单直到处理成功。而纯内存队列方案实例重启后队列就空了还得额外做初始化扫描兜底。所以我最终把状态类延迟操作全部收敛到数据库轮询Redis延迟队列只留给提醒类消息比如截单前提醒团长备货这类消息丢了影响也不大。5.4 配送批次生成截单后按自提点集单截单后要跑集单任务把批次下所有PAID状态且未生成配送批次的订单按自提点分组生成配送批次表。配送批次就是团长的分拣单一个批次包含订单明细和按SKU汇总的数量。集单任务要保证只执行一次截单时刻可能有多个应用实例同时触发定时调度用5.2的分布式锁包住整个任务key设计成delivery:batch:{batchId}。生成配送批次前查一下配送批次表如果该批次该自提点已经生成过跳过。配送批次表加UNIQUE KEY uk_batch_pick(batch_id, pick_point_id)数据库兜底防重。配送批次生成后订单状态从PAID推进到DELIVERED这一步用批量更新UPDATE t_order SET status 30 WHERE batch_id #{batchId} AND status 10 AND pick_point_id #{pickPointId}状态推进和生成配送批次要在同一个事务里做保证要么都是已配送、要么都是已支付。集单完成后团长的自提点端就能看到今天要分拣多少个订单、多少件货。6. 验证这套社区团购系统代码压测指标、幂等回归和对账习惯写完功能只能算完成一半上线前我会按下面三件事验证。第一步是压测下单接口。用JMeter模拟100个并发线程各下一单看两个指标错误率必须为0失败都应该是业务性失败库存不足而不是异常堆栈下单接口的TPS结合你预期的峰值流量判断。社区团购的峰值通常出现在截单前半小时估一下那个时段的下单量压测目标定在它的两到三倍。压测前先跑一次单线程请求预热数据库连接池否则第一批请求会把连接池打满误报性能问题。第二步是幂等回归。我维护一个checklist每个版本上线前手动过一遍同一笔支付回调连发三次订单只入账一次用户取消订单后回调再进来订单不进已支付资金走退款截单后继续下单接口直接拒绝下单接口连点两次只生成一笔待支付订单。这些场景自动化测试不太好写但手动回归成本低、覆盖核心风险。第三步是对账脚本。我每接一个新项目都会先写这个SQL再谈上线-- 批次维度对账订单金额 vs 支付流水 SELECT o.batch_id, SUM(o.pay_amount) AS order_amount, (SELECT SUM(p.amount) FROM t_payment_record p WHERE p.batch_id o.batch_id AND p.status SUCCESS) AS pay_amount FROM t_order o WHERE o.status IN (10, 30, 40, 50) GROUP BY o.batch_id HAVING ABS(order_amount - pay_amount) 0.01跑出来有差额就说明链路里有洞先查差异订单再放量。我见过一个项目上线三个月不做对账团长提现时金额对不上最后发现是退款流程里库存回补了但资金没原路退回。从那次以后我的习惯是任何一次状态变更都要能在对账SQL里闭环。这套社区团购系统代码的价值不在功能多炫而在每一笔订单都能对上账、每一份库存都有据可查。希望这些踩过的坑和参数习惯能帮到你少走我走过的弯路。本文还有配套的精品资源点击获取
返回列表