☰
社区团购系统Java实现:订单状态机、库存扣减与分布式锁实战
2026/9/29 18:19:05 网站建设 项目流程

简介:基于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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 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),不要用double,Java侧对应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 Result<String> 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步防超卖,核心在XML:

<update id="deductStock"> 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 状态更新必须带期望值:防止覆盖和乱跳

支付回调更新订单状态,不能用无条件的UPDATE:

<update id="updateStatusWithExpect"> 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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 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实现,精确到秒,使用简单:

RBlockingQueue<String> queue = redissonClient.getBlockingQueue("order:timeout"); RDelayedQueue<String> 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里闭环。这套社区团购系统代码的价值,不在功能多炫,而在每一笔订单都能对上账、每一份库存都有据可查。希望这些踩过的坑和参数习惯能帮到你,少走我走过的弯路。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询