ARTICLE DETAIL

资讯详情

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

Spring Boot实战社区团购系统:订单库存与自提设计全解析

Spring Boot实战社区团购系统:订单库存与自提设计全解析 简介这是一套基于Spring Boot与Vue的社区团购管理系统完整源码面向需要完成毕业设计、课程设计或入门Java全栈开发的读者。项目覆盖用户管理、商品分类、订单处理、配送管理等团购业务核心模块采用MySQL 5.7存储数据搭配MyBatisPlus与ElementUI实现后端接口与后台界面技术栈贴合主流企业实践。压缩包共810个文件大小约16MB其中以JS、SVG、VUE等前端资源为主另有130个Java后端类、SQL脚本、Maven配置及项目文档方便前后端对照学习。资源内附多份BAT脚本如依赖安装、项目启动、工程构建可辅助快速搭建运行环境同时包含论文目录与系统实现章节涵盖选题动因、技术介绍、可行性分析等内容对撰写设计文档有直接参考价值。目前已有240人学习下载适合具备Java基础并希望借鉴完整项目结构、快速跑通全栈流程的开发者。1. 社区团购系统到底在管什么一个 Spring Boot 项目要面对的订单、库存与自提做社区团购系统的第一周我们最头疼的不是功能写不出来而是团长半夜发来一张 Excel 说订单对不上。社区团购系统说白了就是把“微信群接龙 手工记账”替换成一套数据库里的标准流程平台建团购活动用户下单截单后集中采购货到自提点团长核销。它和普通电商最大的差别是预售制——先有订单再采购库存扣减和补货发生在不同时间点。适合谁社区团购平台运营者、准备把业务数字化的中小供应商以及想找一个完整 Spring Boot 实战项目的 Java 工程师。基于 Spring Boot 做这件事理由很实际生态成熟、招人容易MyBatis-Plus 这类基建能直接让管理端的 CRUD 速度提起来也让这份源码具备真实的落地价值。2. 业务建模先行从预售到自提的字段与状态怎么设计2.1 社区团购和普通电商的模型差异预售、自提、分佣普通电商的链路是“现货 → 下单 → 支付 → 发货 → 收货”库存是确定值订单表只需要记录用户买走了哪个 SKU。社区团购的链路是“选品开团 → 用户下单支付 → 截单汇总 → 集中采购 → 送货到自提点 → 团长核销 → 平台与团长结算”。链路变了数据模型的侧重点就完全不同。第一订单先于库存出现。截单前商品数量只是平台设置的“预估可卖量”截单后才形成真正的采购需求所以商品库存表不能按实时库存来处理而是要拆成“活动可卖量”和“实际到货库存”两个概念。第二自提点是一个独立实体。用户的下单地址是不需要的订单里记录的是团长和自提店 ID核销动作发生在线下系统只需要提供核销码和核销记录。第三分佣涉钱。团长佣金怎么算、什么时候结算、退款时佣金怎么冲正这些都要有迹可循。很多第一次做社区团购系统的团队直接套电商模板结果发现订单表里没有 activity_id截单时根本没法汇总数量。所以建模时要先分清主实体平台用户、团长自提点、商品/SKU、团购活动、订单、订单明细、支付流水、库存流水。每个实体都要冗余一些展示快照字段因为订单生成之后商品名称、价格、活动名称都可能被后台修改历史订单必须显示下单那一刻的快照。2.2 建表 SQL用户、团购活动、订单这三张表的关键字段我一般用 MySQL 8字符集统一 utf8mb4因为团长在微信群昵称里什么字符都有。下面是三张核心表的建表语句省略了索引以外的次要约束重点看字段选择的理由。CREATE TABLE user ( id bigint NOT NULL AUTO_INCREMENT, open_id varchar(64) NOT NULL COMMENT 微信openid一个用户对应一个, nickname varchar(64) DEFAULT , phone varchar(20) DEFAULT , role tinyint NOT NULL DEFAULT 0 COMMENT 0普通用户 1团长 2平台运营, captain_id bigint DEFAULT NULL COMMENT 如果用户是团长关联自提点, create_time datetime NOT NULL, PRIMARY KEY (id), UNIQUE KEY uk_open_id (open_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT用户表; CREATE TABLE group_buy_activity ( id bigint NOT NULL AUTO_INCREMENT, name varchar(128) NOT NULL COMMENT 活动名称比如“春日水果专场”, product_id bigint NOT NULL COMMENT 关联商品SPU, sku_id bigint DEFAULT NULL COMMENT 规格ID同一活动只卖一个规格时不用, price decimal(10,2) NOT NULL COMMENT 团购价单位元, original_price decimal(10,2) DEFAULT NULL COMMENT 划线价仅展示, total_quota int NOT NULL DEFAULT 0 COMMENT 活动总可卖量, sold_quota int NOT NULL DEFAULT 0 COMMENT 已卖量扣减用, start_time datetime NOT NULL COMMENT 开团时间, end_time datetime NOT NULL COMMENT 截单时间这个字段最要紧, status tinyint NOT NULL DEFAULT 0 COMMENT 0未开始 1进行中 2已结束, version int NOT NULL DEFAULT 0 COMMENT 乐观锁版本号, create_time datetime NOT NULL, PRIMARY KEY (id), KEY idx_status_time (status,end_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT团购活动表; CREATE TABLE orders ( id bigint NOT NULL AUTO_INCREMENT, order_no varchar(32) NOT NULL COMMENT 业务订单号对用户展示, user_id bigint NOT NULL, captain_id bigint NOT NULL COMMENT 团长/自提点ID, activity_id bigint NOT NULL, activity_name varchar(128) NOT NULL COMMENT 快照活动改名不影响历史订单, product_snapshot varchar(1024) NOT NULL COMMENT 商品名称/规格/图片JSON快照, amount decimal(10,2) NOT NULL COMMENT 订单总金额, status tinyint NOT NULL DEFAULT 0 COMMENT 0待支付 1已支付 2已取消 3已核销 4已退款, pay_time datetime DEFAULT NULL, pickup_code varchar(8) DEFAULT NULL COMMENT 核销码, version int NOT NULL DEFAULT 0, create_time datetime NOT NULL, PRIMARY KEY (id), UNIQUE KEY uk_order_no (order_no), KEY idx_activity_status (activity_id,status), KEY idx_captain_pickup (captain_id,status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT订单表;订单表里的activity_name和product_snapshot是典型的快照字段。团购活动可以改名字、调价格甚至把商品图片换掉但用户已经下的单必须保留下单那一刻的信息否则售后时看到的商品和用户收到的对不上客诉根本处理不了。pickup_code是生成给用户展示并用于团长核销的 6 位随机码位数太少容易被撞6 位配合当天活动维度足够。group_buy_activity里的sold_quota是展示数字不是库存扣减的唯一依据。真正扣库存时要用一句原子 UPDATE把sold_quota和version一起更新条件里加sold_quota 购买数量 total_quota。这张表的idx_status_time索引是给后台定时任务用的后面讲订单超时自动取消时你就能理解为什么必须建这个联合索引。2.3 订单状态机与乐观锁一开始就要定下来的两个设计订单状态必须在代码里集中定义成枚举而不是散落 1、2、3 魔法值。社区团购里最常见的状态流转是待支付 → 已支付 → 已核销以及待支付 → 已取消已支付 → 已退款。这里有两个容易被忽略的点一是已取消和已退款之间不能直接跳转必须先校验支付流水二是已核销的订单理论上不能退款只能走线下售后否则账目会乱。所以我会在OrderStatus枚举里放一个allowedNextStatuses集合任何状态变更都先校验目标状态是否和支付流水、核销记录的实际情况一致。另一个必须一开始定下来的设计是金额字段类型。金额一律用BigDecimal禁止用double或float数据库用decimal(10,2)。社区团购金额不大但佣金比例、退款拆分算下来浮点误差一旦出现月底对账非常难看。乐观锁version字段建议每个核心主表都加上。一开始我觉得 MyBatis-Plus 自带乐观锁插件就行后来发现有些团队为了省事让代码直接update ... set status 1 where id ?同一个订单被支付回调和后台人工改单同时操作前面的修改直接丢失。带version的条件更新能把这个风险压到很低代价几乎为零。3. 用 Spring Boot 把管理端跑起来依赖、商品发布与订单支付3.1 项目骨架与 Spring Boot 配置pom.xml 和 application.yml社区团购系统源码的第一步是搭一个标准 Spring Boot 项目。我建议 Spring Boot 版本不要盲目追新如果你团队的主力 JDK 还是 8就用 2.7.x如果已经切到 JDK 17才考虑 3.2 以上。Spring Boot 3 的包名从javax.servlet换成了jakarta.servlet不少老项目迁移时会在这里栽跟头后面避坑章节还会展开。parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version2.7.18/version relativePath/ /parent dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-validation/artifactId /dependency dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.5/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId optionaltrue/optional /dependency /dependencies这是管理端常用的一套组合Spring Boot Web 提供接口能力Validation 做参数校验MyBatis-Plus 负责单表 CRUD 和分页Lombok 减少 getter/setter 样板代码。MyBatis-Plus 的代码生成器可以根据前面建好的表直接生成实体和 Mapper社区团购这种字段动辄二三十张表的管理系统手写实体既慢又容易漏字段。server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/community_group_buy username: root password: root driver-class-name: com.mysql.cj.jdbc.Driver jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT8 mybatis-plus: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.groupbuy.entity configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpljackson.time-zone写成GMT8而不是Asia/Shanghai是为了避免某些服务器时区不准导致接口返回的时间差 8 小时。map-underscore-to-camel-case必须打开这样数据库里的create_time能直接映射到实体的createTime不用写一堆TableField。log-impl建议本地调试打开生产环境务必换成别的或关闭否则每条 SQL 都打印在高并发下会拖慢吞吐。3.2 商品与团购活动接口Controller 里的参数校验和快照落库管理端最常见的操作是发布团购活动。运营填完活动名称、选好商品、设定价格和可卖量保存到数据库。这里要限制一个活动只能关联一个 SPU社区团购的商品池本来就不大一个活动绑多个商品会让结算逻辑复杂化不值得。RestController RequestMapping(/api/admin/activity) public class ActivityController { private final GroupBuyActivityService activityService; public ActivityController(GroupBuyActivityService activityService) { this.activityService activityService; } PostMapping public ResultLong create(Valid RequestBody ActivityCreateRequest request) { GroupBuyActivity activity new GroupBuyActivity(); activity.setName(request.getName()); activity.setProductId(request.getProductId()); activity.setPrice(request.getPrice()); activity.setTotalQuota(request.getTotalQuota()); activity.setStartTime(request.getStartTime()); activity.setEndTime(request.getEndTime()); activity.setStatus(0); activityService.save(activity); return Result.ok(activity.getId()); } }ActivityCreateRequest里用NotBlank和NotNull做基础校验价格要用DecimalMin(value 0.01)totalQuota要有Min(1)。这里我用了构造函数注入而不是Autowired字段注入Spring Boot 项目里更推荐这个方式单元测试时可以手动 new 一个 service 进去比反射注入干净。Result是统一返回结构字段固定为code、message、data前端只看这个结构不要每个接口各自返回不同字段。快照落库的逻辑放在 service 里Controller 只负责接收请求和转换参数。创建活动时我还会从product表把商品名称和主图复制到活动的productSnapshot字段。这样即使商品后来下架活动页面依然能正常展示。这里有个细节endTime一旦创建后不允许通过编辑接口随意修改截单时间是预售效率的生命线改一次就会让已下单用户不知所措。3.3 支付回调里改订单状态条件更新代替先查后改订单支付回调是管理端系统里最容易写坏的地方。新手常见的写法是先根据orderNo查订单判断状态再更新成已支付。这个“先查后改”在并发场景下两个线程读到同一个待支付订单都去更新后一次更新可能覆盖掉前一次已经处理完的核销逻辑。Service public class OrderService { Transactional public boolean markPaid(String orderNo, String transactionId, BigDecimal paidAmount) { // 条件更新只有“待支付”状态才允许更新为“已支付” UpdateWrapperOrders update new UpdateWrapper(); update.eq(order_no, orderNo) .eq(status, 0) .eq(amount, paidAmount) .set(status, 1) .set(pay_time, LocalDateTime.now()) .set(pickup_code, generatePickupCode()); boolean updated ordersMapper.update(null, update) 0; if (!updated) { throw new BizException(订单状态异常或支付金额不一致); } return true; } }条件更新把订单状态字段直接放进WHERE条件数据库层面保证只有一个并发分支能成功。transactionId应该单独存到支付流水表里而不是塞进订单表订单表只保留pay_time和状态因为同一个订单可能多次支付失败支付流水表才是记录明细的地方。pickup_code使用SecureRandom生成避免用Math.random()后者生成的核销码可预测安全上会有问题。这里再补充一个容易被忽略的点amount比较要放在WHERE里。有些支付回调会带着支付金额如果回调金额和订单金额不一致直接更新会留下财务隐患。数据库里decimal(10,2)和 Java 里BigDecimal比较时注意 scale 要一致回调金额先setScale(2)再比较否则 A 代表 1.50B 代表 1.5equals 返回 false。4. 社区团购系统避坑指南超卖、回调和 Spring Boot 版本陷阱4.1 库存超卖扣库存的 SQL 写错导致负库存现象一个团购活动刚开团 30 秒后台看到sold_quota超过了total_quota下单接口还在继续返回成功。原因扣减逻辑写成了先查sold_quota在 Java 里判断是否小于total_quota再执行update。两个线程同时查到旧值都认为还有库存都去更新最终超卖。MySQL 的普通update并不会锁住整行前提是查询语句和更新语句之间没有事务保护或者用了select for update但没生效。解决把判断和扣减合并成一条原子 SQL。UPDATE group_buy_activity SET sold_quota sold_quota 1, version version 1 WHERE id #{activityId} AND sold_quota 1 total_quota AND status 1;受影响行数为 1 才算抢到库存否则直接返回“已售罄”。这条 SQL 里sold_quota 1 total_quota是硬约束数据库在更新时会加行锁并发到这里会排队不会出现超卖。社区团购的单品并发量比一般秒杀低很多这样足够。如果你还要同时扣减多个 SKU就把扣减操作放进一个事务里并统一按id排序加锁防止不同事务造成死锁。4.2 支付回调重复通知订单状态被旧报文覆盖现象同一笔支付微信或支付宝回调推了三次前两次成功更新成“已支付”第三次带着同样的报文又来了结果订单状态被从“已核销”改回“已支付”。原因回调接口没有做幂等。每次回调都执行一次无条件更新把status设置为 1覆盖了后续核销操作修改的状态。解决支付回调里只允许“待支付 → 已支付”这一种状态跳转也就是用上一章的markPaid条件更新。如果更新失败说明状态已经不是待支付直接返回“成功”给支付平台不再做任何动作。另外增加一张payment_callback_log表按transactionId唯一索引重复报文直接忽略。这张表也能在售后对账时提供证据谁在什么时间通知过什么内容一目了然。4.3 Spring Boot 版本太高JDK 不匹配导致项目启动即失败现象社区团购源码项目在本地跑不起来报错UnsupportedClassVersionError或者NoClassDefFoundError: jakarta/servlet/*。原因Spring Boot 3 要求 JDK 17 及以上很多公司的生产环境还在用 JDK 8。另外 Spring Boot 3 把包名从javax.*换成jakarta.*如果代码里引用了老版本的HttpServletRequest编译阶段就可能报错。很多人拿到源码第一件事就是升级 Spring Boot 到最新版本结果依赖不兼容白花一下午。解决先确认 JDK 版本再选 Spring Boot 版本。JDK 8 就用 2.7.xJDK 17 以上才用 3.x。如果项目里用了javax.validationSpring Boot 3 得换jakarta.validation这个迁移没有任何取巧路径只能逐文件替换。社区团购这类业务系统对新技术没有硬需求稳定压倒一切不要为了“新版”而上新版本。4.4 慢查询打满连接与截单时间错乱现象早高峰团长批量查订单后台页面转圈数据库连接池报HikariPool-1 - Connection is not available。另一台服务器上晚上 11 点截单的定时任务提前了一小时执行。原因订单表按captain_id查询时SQL 没有走到索引MySQL 全表扫描几百万行每个查询要 3 秒连接池 10 个连接全部被占满。截单时间错乱则是 JDBC 连接串里没写serverTimezoneAsia/Shanghai数据库会话时区与业务时区不一致end_time存进去以后查询出来差了 8 小时。解决orders表上必须有idx_captain_status联合索引查询条件里同时带captain_id和status。连接串写成jdbc:mysql://localhost:3306/community_group_buy?serverTimezoneAsia/Shanghai并且和application.yml里 Jackson 的时区保持一致。定时任务涉及时间比较的统一存数据库时间戳不在应用层拼new Date()。这里我吃过一次大亏上线第一天就因为这个时区问题导致截单提前那批订单只能全部手工取消重下血泪教训。5. 订单超时自动取消用 Spring Boot 定时任务守住截单和库存回滚5.1 截单逻辑为什么必须在服务端前端倒计时只是装饰社区团购用户下单后有支付时间窗口常见是 15 分钟。到点未支付订单要自动取消释放活动可卖量。很多人第一反应是前端做倒计时到了就弹窗提醒用户。问题是用户的手机时间可以随便改页面切到后台以后定时器被浏览器挂起这些都不是可靠触发点。真正的截单逻辑必须放在服务端。支付超时、订单关闭、库存回滚这三个动作是业务正确性的一部分不能依赖网页端。Spring Boot 里最朴素的实现是Scheduled定时扫描表每分钟或每 30 秒跑一批。小区团购的场景订单量不大一个定时任务完全够用如果将来订单量涨上去再换 RabbitMQ 延迟队列或者 XXL-Job 也不迟。这里先用最简单可靠的方案。5.2 定时扫描订单Scheduled 批量关单代码Component public class OrderTimeoutJob { private final OrdersMapper ordersMapper; private final GroupBuyActivityMapper activityMapper; private static final int BATCH_SIZE 200; Scheduled(fixedDelay 30_000, initialDelay 10_000) public void closeExpiredOrders() { ListOrders expiredOrders ordersMapper.selectList(new LambdaQueryWrapperOrders() .eq(Orders::getStatus, 0) .lt(Orders::getCreateTime, LocalDateTime.now().minusMinutes(15)) .last(LIMIT BATCH_SIZE)); if (expiredOrders.isEmpty()) { return; } for (Orders order : expiredOrders) { try { closeOneOrder(order); } catch (Exception e) { log.error(close order failed, orderNo{}, order.getOrderNo(), e); } } } Transactional public void closeOneOrder(Orders order) { int updated ordersMapper.update(null, new LambdaUpdateWrapperOrders() .eq(Orders::getId, order.getId()) .eq(Orders::getStatus, 0) .set(Orders::getStatus, 2)); if (updated 0) { return; } activityMapper.rollbackQuota(order.getActivityId(), order.getQuantity()); } }fixedDelay 30_000表示上一次任务执行完 30 秒后再执行下一次避免任务本身跑太久导致重叠。initialDelay让应用启动后先等 10 秒给数据库连接池预热时间。closeOneOrder里的条件更新保证了同一个订单不会被两个定时批次重复关闭即使任务被集群中的两个节点同时触发也只有一条更新能成功。库存回滚是一个单独的 SQLUPDATE group_buy_activity SET sold_quota sold_quota - #{quantity} WHERE id #{activityId}。这里要记录一条库存流水字段包含activity_id、sku_id、change_type下单扣减、超时回滚、退款回补、change_value月底对账必须靠流水还原每个活动的卖量变化只看活动表最终值是不够的。5.3 任务重复执行如何保证幂等状态机占位与库存流水定时任务最怕的不是不执行而是重复执行。应用重启、网络抖动、手动补跑都会造成同一批订单被处理两次。上一节的解决方案是“先改订单状态再回滚库存”而且改动状态时WHERE条件是status 0。第一个执行的事务把状态从 0 改成 2 后第二个事务再来条件更新返回 0直接放弃。这样就保证了“至少一次”执行且最终只有一次生效。如果存在跨库操作或者回调下游接口的场景单纯靠状态机还不够。比如关单后要给团长发站内信重复发会打扰用户。我给这类动作设计的办法是先写一条notice_task记录状态为待发送随后再调用发送服务。发送成功变成已发送失败则保留待发送并由下一个定时任务重试。每个任务都围绕一条业务主键做幂等不依赖“只跑一次”的保证。当订单量到一天几万单的时候Scheduled扫描全表会开始吃力我会考虑把定时任务拆成两级核心关单仍用数据库批量更新报表和实时对账再引入 Spring Boot 整合 Flink 做流计算。Flink 解决的是分析链路不是交易链路这里不要为了用 Flink 而把简单问题复杂化。5.4 定时任务日志与监控失败时怎么快速定位定时任务出问题往往不是“完全没跑”而是跑了一部分、失败了一部分。我给这类 Job 增加两个观测点一是每次扫描的订单数量和处理结果写进日志二是每一条失败记录单独打堆栈。上线后如果发现库存和订单对不上按log_time和order_no就能定位到是哪次执行回滚失败了。另外Spring Boot 定时任务默认是单线程执行的。如果同一个项目里还有别的Scheduled任务比如佣金结算、活动自动结束它们会挤在一个调度器里。建议在配置类里把线程池加大一点Configuration public class ScheduleConfig implements SchedulingConfigurer { Override public void configureTasks(ScheduledTaskRegistrar taskRegistrar) { taskRegistrar.setScheduler(Executors.newScheduledThreadPool(4)); } }四个线程分别给关单、结算、活动状态刷新、失败重试使用避免一个慢任务拖住所有调度。这是我被“所有定时任务都卡住”坑过一次之后才加上的配置建议你在项目一开始就写进去。6. 上线前做一次压测和账目核对社区团购系统的验收清单6.1 用 ab 对商品详情和下单接口做一轮快速压测接口能不能扛住早高峰不需要上 Kubernetes 才能验证本地用ab或JMeter先跑一轮就够了。下单是写接口压测前先在测试环境建一个活动把total_quota调大然后执行ab -n 2000 -c 50 -p order.json -T application/json \ http://localhost:8080/api/user/order/create-n 2000是总请求数-c 50是并发数order.json里放活动 ID 和购买数量。压测时重点看两个指标Requests per second和Failed requests。社区团购的单品峰值并发一般不会超过几十如果这个接口能稳定跑到每秒 100 次以上且失败率低于 1%管理端就基本合格。压测结束必须验证账目把活动表里sold_quota的总增量、订单状态为已支付的数量、支付流水表里成功笔数三处相加数字必须一致。任何一个数对不上都要先查日志再放量不要带着账目差异上线。6.2 我的上线教训按索引和连接池来验收我第一次上线社区团购系统时功能全测完了压测也做了结果第二天早高峰还是把数据库打满了。原因是测试时数据量小全表扫描也就几千行没人发现orders表少了idx_captain_status联合索引。数据一破十万慢查询直接耗尽连接池。后来我把“检查所有核心查询的 EXPLAIN 是否命中索引”写进上线验收清单每次发版先跑一遍。现在我做社区团购系统项目上线前只问三个问题核心写接口有没有做条件更新和幂等订单表有没有按状态和时间建好索引压测后的账目能不能对上这三个问题答完系统的大方向就不会差。看了这么多团队在线下跑得稳稳当当、一到上线就各种翻车其实问题都一样——提前把边界情况按生产数据量压一遍能省掉半夜被自提点核销故障惊醒的麻烦。希望帮到你。本文还有配套的精品资源点击获取
返回列表