简介:这是一份基于Spring Boot与Vue的小区团购管理系统设计与实现源码项目,主要面向Java Web方向的学生、毕业设计人员以及需要快速搭建管理后台的开发者。系统围绕小区团购真实场景,实现用户信息管理、图片素材管理、视频素材管理等功能模块,技术栈涵盖Spring Boot、MyBatisPlus、MySQL、Maven、Ajax等,采用前后端分离架构与ElementUI界面,并配有从可行性分析、数据库设计到系统实现开发的完整代码与文档。资源包共826个文件,压缩包约25.55MB,主要内容包括Java后端源码、Vue组件、JavaScript脚本、HTML页面、CSS样式、SVG图标等前端资源,以及安装/运行批处理脚本、数据库配置文件、演示图片和视频,便于直接导入运行和二次修改。压缩包内同时提供论文式说明文档,覆盖绪论、相关技术介绍、系统分析、系统设计与用户信息管理等章节,有助于对照学习系统的整体设计。目前已有63人学习下载,适合需要完整项目源码、数据库设计参考及前后端联调实践的学习者。
1. 小区团购管理系统到底在解决什么问题
周末打开小区业主群,满屏都是接龙报名、群接龙表格和“还有谁没付钱”的@提醒;团购结束后,团长对着聊天记录和Excel对账,经常出现多收、漏收、货对不上的情况。所谓“小区团购管理系统的设计与实现”,就是用Java和Spring Boot把这套手工流程变成一套可维护的管理系统:商品管理、团购活动发布、用户下单、库存扣减、订单核销和基础统计一站式处理。它要解决的核心痛点不是“能不能下单”,而是让团长从“人肉记账”里解脱出来,让每一笔订单都有据可查。适合两类人看:一类是正在做社区团购运营、想低成本搭一个内部工具的团队;另一类是准备做Java毕设或Spring Boot练手项目的开发者,把一个真实的业务建模过程完整走一遍。
2. 数据模型设计:团购订单为什么不能按“一次一单”来设计
2.1 先从业务口拆实体:商品、团购批次、订单、提货点
小区团购和普通电商的差别,在于“批次”这个概念。普通电商里,用户看到的商品是长期在架的SKU,库存实时扣减;而小区团购是周期性发起,每周三开团、周五截单、周六到货,用户下单的是“这一批”的团购商品。如果只设计一张商品表加一张订单表,订单直接挂商品ID,后续做批次统计、按提货点汇总、批量核销,全部要写额外逻辑,而且容易算错。
我一般把核心实体拆成四个:商品(product)存基础信息,团购活动(group_activity)存每个批次的团购价和起购规则,订单(orders)存用户和商品快照,提货点(pickup_point)存自提地址。订单不要直接挂product_id,而是挂activity_id——因为同一个商品在不同批次的团购价可能不同,只有在活动快照里才能还原“当时卖多少钱”。
用户的维度也值得注意。小区团购通常一个家庭多人参与,我建议用户表保留一个“门牌号”字段,这样后端可以按门牌号聚合,方便团长确认“三号楼二单元哪几家还没提货”。如果你们已经有业主系统,可以直接对接用户ID,不需要重复造用户体系。
CREATE TABLE `product` ( `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '商品ID', `name` VARCHAR(128) NOT NULL COMMENT '商品名称', `price` DECIMAL(10,2) NOT NULL COMMENT '单买价', `unit` VARCHAR(16) DEFAULT '份' COMMENT '计量单位', `stock` INT NOT NULL DEFAULT 0 COMMENT '总库存', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, `update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品表'; CREATE TABLE `group_activity` ( `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '活动ID', `product_id` BIGINT NOT NULL COMMENT '关联商品ID', `group_price` DECIMAL(10,2) NOT NULL COMMENT '团购价', `target_count` INT NOT NULL DEFAULT 50 COMMENT '成团目标人数', `start_time` DATETIME NOT NULL COMMENT '开团时间', `end_time` DATETIME NOT NULL COMMENT '截单时间', `status` TINYINT NOT NULL DEFAULT 0 COMMENT '0未开始 1进行中 2已结束', PRIMARY KEY (`id`), KEY `idx_product` (`product_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='团购活动表'; CREATE TABLE `orders` ( `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键', `order_no` VARCHAR(32) NOT NULL COMMENT '业务订单号', `user_id` BIGINT NOT NULL COMMENT '用户ID', `activity_id` BIGINT NOT NULL COMMENT '团购活动ID', `product_id` BIGINT NOT NULL COMMENT '商品ID(冗余)', `product_name` VARCHAR(128) NOT NULL COMMENT '商品名称快照', `quantity` INT NOT NULL DEFAULT 1 COMMENT '购买数量', `unit_price` DECIMAL(10,2) NOT NULL COMMENT '下单时单价', `total_amount` DECIMAL(10,2) NOT NULL COMMENT '订单金额', `status` TINYINT NOT NULL DEFAULT 0 COMMENT '0待支付 1已支付 2已提货 3已退款', `pickup_point_id` BIGINT DEFAULT NULL COMMENT '提货点ID', `pay_time` DATETIME DEFAULT NULL COMMENT '支付时间', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_order_no` (`order_no`), KEY `idx_activity` (`activity_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单表';这段DDL里有三个设计意图值得说透。第一,orders表冗余了product_id和product_name,下单时把商品名直接写进订单,后期商品改名也不影响历史订单展示,这是订单快照的基本做法。第二,订单金额字段统一用DECIMAL(10,2),绝不用double,后面避坑章节会专门展开。第三,order_no加唯一索引,业务上每次下单生成的订单号必须全局唯一,这个唯一约束在幂等处理里是最后一道防线。
2.2 订单状态机:从待支付到已退款,状态流转要闭环
小区团购的订单状态比普通电商多两个节点:待提货和已完成。状态流转是这样的:用户下单成功进入“待支付”,支付回调确认后变成“已支付”,团长到货后点击核销变成“已提货”,如果中间发生退款则从“待支付”或“已支付”直接跳到“已退款”。这套流转必须写清楚,否则提货点人员看到订单就乱点。
我的习惯是把状态常量定义在枚举里,代码里禁止魔法数字。很多Spring Boot项目翻车,就是因为service层到处写order.setStatus(1),三个月后没人知道1代表什么。
public enum OrderStatus { PENDING_PAY(0, "待支付"), PAID(1, "已支付"), PICKED_UP(2, "已提货"), REFUNDED(3, "已退款"); private final int code; private final String desc; OrderStatus(int code, String desc) { this.code = code; this.desc = desc; } public int getCode() { return code; } }这里有个容易忽略的点:退款不是所有状态都能退。已提货的订单如果要做售后,应该走退货流程而不是退款接口。很多新手直接把退款接口做成status改为3,结果已提货订单也进了退款列表,仓库对货时发现货已经提走了账还挂着。判断条件就一行:status in (0, 1)才允许退款,2和3都直接拒绝。
2.3 字段和索引设计:为什么尽量不用物理外键
在DML里我没有写任何FOREIGN KEY,这符合大多数Spring Boot项目实践。物理外键在并发写入时有锁竞争,团队迭代时改表结构也麻烦,更重要的是业务上的关联约束完全可以靠service层保证。activity_id和product_id只要建普通索引就行,这也是MyBatis-Plus关联查询走索引的前提。
索引设计上只要抓住查询频率最高的两条路径:按activity_id查订单列表,按user_id查我的订单。上面DDL已经给orders表建了idx_activity,但没有建user_id索引。这是因为实际项目里“我的订单”通常还要关联时间倒序,所以建议你改成KEY idx_user_activity (user_id, activity_id),覆盖两个查询条件。提货点核销频次没有用户查询高,可以后面再加。
3. 用 Spring Boot 跑通主流程:从商品上架到订单核销
3.1 工程骨架和依赖选型:web + mybatis-plus + mysql 的组合
这是一个典型的管理系统后端,不涉及复杂的前端渲染,用spring-boot-starter-web提供REST接口,mybatis-plus做ORM,MySQL存数据,这套组合是Java从业者最熟悉的配置。下面给出pom.xml里最核心的依赖,注意Spring Boot版本不同,mybatis-plus的适配版本也不同。
<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.7.18</version> <relativePath/> </parent> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.3.1</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.33</version> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> </dependencies>Spring Boot 2.7.x是目前兼容性最稳的版本,MySQL Connector 8.0系列支持MySQL 5.7和8.x。如果你用的是Spring Boot 3.x,注意javax包名要换成jakarta,DataSource配置方式也不同。我建议新手固定在2.7.x,避免版本太高带来的自动配置差异,等主流程跑通了再升级。
application.yml配置里有一个细节:要主动打开MyBatis-Plus的驼峰映射和下划线映射,否则数据库字段product_name映射不到实体属性productName。还有时间字段,建议统一用LocalDateTime,JDBC驱动连接串加上serverTimezone=Asia/Shanghai,这两个同时配置才能避免时区坑。
spring: datasource: url: jdbc:mysql://localhost:3306/community_group_buy?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: root driver-class-name: com.mysql.cj.jdbc.Driver mybatis-plus: configuration: map-underscore-to-camel-case: true global-config: db-config: logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0上面配置里有两个使用习惯。map-underscore-to-camel-case必开,这是MyBatis规范命名的基础。logic-delete相关配置是逻辑删除,小区团购的订单和商品不建议物理删,一旦误删对账数据就彻底丢了。我实际项目里订单表不启用逻辑删除,只对商品和活动启用,因为订单是流水账,删了订单号对不上支付记录。
3.2 商品与团购活动的增删改查:Controller和Mapper的结构
管理系统最基础的能力是维护商品和团购活动。实体类加上@TableName注解,主键用@TableId(type = IdType.AUTO),然后Mapper继承BaseMapper,CRUD方法就都齐了。
@Data @TableName("product") public class Product { @TableId(type = IdType.AUTO) private Long id; private String name; private BigDecimal price; private String unit; private Integer stock; private LocalDateTime createTime; private LocalDateTime updateTime; } @Mapper public interface ProductMapper extends BaseMapper<Product> { // 使用MyBatis-Plus内置方法即可完成大部分增删改查 }Controller层我习惯只做参数接收和结果封装,不写业务。创建商品时,库存和价格校验放在Service层,Controller保持干净。下面是最小可用的商品新增接口,重点是@RequestBody后面紧跟校验逻辑,不要等入库了才发现价格是负数。
@RestController @RequestMapping("/api/product") public class ProductController { @Autowired private ProductService productService; @PostMapping("/create") public Result<Long> createProduct(@RequestBody ProductCreateDTO dto) { if (dto.getPrice() == null || dto.getPrice().compareTo(BigDecimal.ZERO) < 0) { throw new BizException("商品价格不合法"); } if (dto.getStock() == null || dto.getStock() < 0) { throw new BizException("库存不能为负数"); } return Result.ok(productService.createProduct(dto)); } }这个接口里DTO和Entity分离的做法值得坚持。ProductCreateDTO只包含name、price、unit、stock四个可录入字段,Entity里的createTime、deleted等字段由系统填充,避免前端传值覆盖。很多毕设项目直接用Entity接收前端参数,结果前端多传一个id就把线上数据覆盖了,这种事故不遇到一次不会长记性。
团购活动的创建逻辑比商品多一个约束:endTime必须晚于startTime,而且活动不能重复开启。数据库层只能保证数据格式,业务层要保证“同一时间段同一商品只能有一个活动”。这个我用唯一索引做不了,因为时间段是区间,只能靠Service查重。
@Service public class GroupActivityServiceImpl implements GroupActivityService { @Autowired private GroupActivityMapper activityMapper; @Override @Transactional(rollbackFor = Exception.class) public Long createActivity(GroupActivityCreateDTO dto) { // 查询当前是否有重叠时间段的同商品活动 LambdaQueryWrapper<GroupActivity> wrapper = new LambdaQueryWrapper<>(); wrapper.eq(GroupActivity::getProductId, dto.getProductId()) .eq(GroupActivity::getStatus, 1) .last("AND start_time < '" + dto.getEndTime() + "' AND end_time > '" + dto.getStartTime() + "'"); if (activityMapper.selectCount(wrapper) > 0) { throw new BizException("该商品在此时段已有进行中的团购活动"); } GroupActivity activity = new GroupActivity(); activity.setProductId(dto.getProductId()); activity.setGroupPrice(dto.getGroupPrice()); activity.setTargetCount(dto.getTargetCount()); activity.setStartTime(dto.getStartTime()); activity.setEndTime(dto.getEndTime()); activity.setStatus(0); activityMapper.insert(activity); return activity.getId(); } }上面代码用了LambdaQueryWrapper做存活活动查重,这里有一个执行效率问题:如果活动表数据量超过十万,last("AND start_time < ...")这种写法会放弃索引。我一般只在管理后台创建活动时查重,频次低,所以先用这种直观写法。等后期数据量大,再改成时间区间索引或单独建一张活动排期表。
3.3 下单接口:行锁、库存扣减和订单生成的代码逻辑
下单接口是整个系统里最需要谨慎对待的地方,小区团购虽然没有秒杀那么夸张,但截单前半小时也可能出现几百人同时支付。我把下单拆成四步:校验活动状态、加锁读取活动、条件更新库存、插入订单快照。
@Override @Transactional(rollbackFor = Exception.class) public OrderVO createOrder(OrderCreateDTO dto) { // 1. 按主键加行锁读取活动,防止活动状态并发修改 GroupActivity activity = activityMapper.selectByIdForUpdate(dto.getActivityId()); if (activity == null || activity.getStatus() != 1) { throw new BizException("活动不存在或已下线"); } LocalDateTime now = LocalDateTime.now(); if (now.isBefore(activity.getStartTime()) || now.isAfter(activity.getEndTime())) { throw new BizException("当前不在可下单时间内"); } // 2. 读取商品信息 Product product = productMapper.selectById(activity.getProductId()); if (product == null) { throw new BizException("商品不存在"); } // 3. 条件更新扣减库存,影响行数为0说明库存不足 int updated = productMapper.deductStock(product.getId(), dto.getQuantity()); if (updated == 0) { throw new BizException("库存不足"); } // 4. 生成订单快照,状态为待支付 Order order = new Order(); order.setOrderNo(generateOrderNo()); order.setUserId(dto.getUserId()); order.setActivityId(activity.getId()); order.setProductId(product.getId()); order.setProductName(product.getName()); order.setQuantity(dto.getQuantity()); order.setUnitPrice(activity.getGroupPrice()); order.setTotalAmount(activity.getGroupPrice().multiply(BigDecimal.valueOf(dto.getQuantity()))); order.setStatus(OrderStatus.PENDING_PAY.getCode()); order.setPickupPointId(dto.getPickupPointId()); orderMapper.insert(order); return convertToVO(order); }这段代码对应Mapper里的一条关键SQL。我不用select再update的方式扣库存,因为那两步之间存在竞态窗口,两个请求同时读到stock=10,各自减1,最后库存还剩9,明显错了。
@Update("UPDATE product SET stock = stock - #{quantity} WHERE id = #{id} AND stock >= #{quantity}") int deductStock(@Param("id") Long id, @Param("quantity") Integer quantity);条件更新的巧妙之处在于把“校验库存是否足够”和“扣减库存”合并成一条原子SQL,数据库行锁保证同一时刻只有一个事务能更新这行。影响行数返回0时,说明stock < quantity,直接返回库存不足。这是目前性价比最高的防超卖方案,不需要引入Redis,单机能扛住绝大多数小区团购的量级。
还有一个细节:generateOrderNo不要用数据库自增ID,业务订单号既要短又要唯一。我用时间戳加四位随机数的方式够用了,但并发稍微上去,随机数碰撞概率会增加。稳妥的做法是用雪花算法,MyBatis-Plus自带IdWorker.getIdStr(),直接调就行。
3.4 核销与退款:状态流转换的条件更新
提货点核销的逻辑很简单,难的是不能把已退款的订单重新核销。核销必须要求订单处于“已支付”状态,这一步同样用条件更新保护。
@Update("UPDATE orders SET status = 2 WHERE id = #{id} AND status = 1") int markPickedUp(@Param("id") Long id);退款接口要做的操作多一步:先更新订单状态,再回补库存。如果先回补库存再更新状态,订单主流程失败时库存已经加回去了,但订单还是已支付状态,用户再申请退款就会二次回补。我把两步放在同一个事务里,并让状态更新先行。
@Override @Transactional(rollbackFor = Exception.class) public void refund(String orderNo) { // 状态由待支付或已支付改为已退款,影响行数为0说明状态不允许退款 int updated = orderMapper.refundByOrderNo(orderNo); if (updated == 0) { throw new BizException("当前订单状态不允许退款"); } Order order = orderMapper.findByOrderNo(orderNo); // 回补库存 productMapper.addStock(order.getProductId(), order.getQuantity()); }refundByOrderNo对应的SQL是UPDATE orders SET status = 3 WHERE order_no = #{orderNo} AND status IN (0, 1)。这个写法一举两得:保证退款只发生在正确状态,同时order_no唯一索引保证了并发重复退款时只有一条更新成功。数据库行锁和唯一索引在这里是双保险,实际支付回调并发重复调用时,这两层缺一不可。
4. 避坑排查:小区团购系统最容易翻车的 5 个场景
4.1 金额精度:用 double 记账,月底对账差了八分钱
现象:商品价格是19.9元,三件总价算出来是59.699999999999996,数据库里存成59.7,但页面显示59.70,月底对账发现支付金额和订单金额对不上,差几分钱都查不出来。
原因:double和float在计算机里是浮点表示,二进制无法精确表示所有十进制小数,累计运算误差会在某个订单上暴露出来。Java的double做乘法尤其危险,BigDecimal才是金额计算的唯一正确选择。
解决:数据库金额字段全部用DECIMAL(10,2),Java字段用BigDecimal,所有运算调用BigDecimal的add和multiply方法,禁止其他类型参与金额计算。记住setScale(2, RoundingMode.HALF_UP)做四舍五入,因为MySQL的DECIMAL默认舍入方式是四舍五入,两边保持一致才不会出现0.005这类边界差。
4.2 库存并发:开团瞬间库存被扣成负数
现象:截单前半小时,用户集中下单,明明后台库存显示剩10份,实际成交了15单。查看订单列表,多出来的5单是超卖订单,仓库无货可发。
原因:下单逻辑是先select stock,判断stock > quantity,再update stock,这三步不是原子操作。两个并发请求同时读到stock=10,都判断库存够,都执行update,最后stock可能变成8、7或者负数,取决于执行顺序。
解决:用条件更新UPDATE product SET stock = stock - #{quantity} WHERE id = #{id} AND stock >= #{quantity},数据库保证同一行同一时刻只有一个更新生效,影响行数为0就抛库存不足。这是我在3.3节给出的方案,不需要Redis也能解决大部分场景。如果你的并发量到几千QPS,再考虑Redis预扣减,但普通小区团购业务量根本到不了这个级别。
4.3 支付回调重复通知:同一订单被出库两次
现象:支付渠道回调支付成功的接口,由于网络超时重发了三次通知,后台生成了三条出库记录,订单状态却还是“已完成”。用户提货时仓库找不到货,后台却能查到订单已核销。
原因:回调接口没有做幂等。每一次通知进来,系统都执行“查订单、改状态、调出库”,没有判断订单当前状态是否已经是“已完成”。回调通知在分布式系统里至少投递一次,这是网络通信的基本事实,不做幂等就是拿生产环境开玩笑。
解决:回调处理逻辑里,第一步用order_no查订单,判断status是否等于1,只有“已支付”状态才允许流转到“已提货”。我把这个判断直接写进SQL:UPDATE orders SET status = 2 WHERE order_no = #{orderNo} AND status = 1,影响行数为0就说明已经处理过,直接返回成功,不重复出库。这是最简单也最有效的幂等方案。
4.4 时间字段错乱:MySQL 8 和 Java 的 LocalDateTime 时区隔阂
现象:上线后发现订单创建时间比实际时间快了8小时,活动截单时间判断失效,还没到晚上八点用户就无法下单。
原因:MySQL连接串没设置serverTimezone,Java应用默认使用服务器时区,数据库连接时双方对“当前时间”的理解不一致。JDBC驱动版本较新时,时区不匹配会直接导致时间偏差,或者干脆报错Server returns invalid timezone。
解决:连接串固定带serverTimezone=Asia/Shanghai,Java字段统一使用LocalDateTime对应MySQL的DATETIME类型。还有一个隐藏坑:如果服务器本身部署在UTC时区,JVM启动参数加-Duser.timezone=Asia/Shanghai。检查时区问题最快的方式是登录MySQL执行SELECT NOW(),对比应用日志打印的时间,差多少就是时区配置哪里不对。
4.5 逻辑删除字段与唯一索引冲突:删掉的商品还能重新上架
现象:商品删掉后重新上架,系统报“商品名称已存在”错误,但列表里根本看不到这个商品。
原因:启用了MyBatis-Plus逻辑删除后,deleted字段默认值为0,删除时置为1。如果你给商品名称建了唯一索引,逻辑删除的记录依然占用索引位置,再次插入相同名称必然冲突。这也是逻辑删除最大的副作用。
解决:方案有两个。一是给唯一索引加函数索引,MySQL 8.0支持CREATE UNIQUE INDEX uk_name ON product (name, deleted),把deleted也放进唯一索引,逻辑删除的记录用deleted=1区分,不会阻塞重新插入;二是放弃唯一索引,在Service层查询时先过滤deleted字段再判断名称是否存在。我建议中小型项目直接选方案二,减少数据库索引复杂度。
5. 性能和扩展:Redis 库存、定时任务与订单快照
5.1 用 Redis 优化库存预扣:从数据库行锁到原子递减的升级路径
活动规模做大之后,比如同时开五个小区的团购,数据库条件更新的行锁会变成一个热点。同一个商品的库存行,所有下单请求都在这行上排队等锁,数据库连接池很快被打满,接口响应从50ms涨到800ms。这时候常见的做法是把库存预热到Redis,用DECR原子操作做预扣。
// 活动开始前把库存预热到redis stringRedisTemplate.opsForValue().set("stock:activity:" + activityId, String.valueOf(stock)); // 下单时先走redis扣减 Long remain = stringRedisTemplate.opsForValue().decrement("stock:activity:" + activityId, quantity); if (remain == null || remain < 0) { // 扣成负数说明超卖,需要回补redis stringRedisTemplate.opsForValue().increment("stock:activity:" + activityId, quantity); throw new BizException("库存不足"); }这个方案的代价是架构复杂度增加了不少:Redis和数据库之间会存在短暂不一致,你需要在支付成功后异步同步一次库存到数据库,还要做定时对账任务。我的观点是业务在日订单千单以下,别碰Redis,条件更新够用。先确认瓶颈,再引入组件,这比“为了炫技加Redis”可靠得多。
Redis模式下还有一个轮询任务,每五分钟跑一次,把数据库剩余库存和Redis库存做对比修正。原因是用户下单但没支付,库存已经在Redis扣了,数据库还没扣,这部分“冻结库存”要在超时后回补。处理方式就是把订单超时未支付的回滚任务同时回补两边。
5.2 定时任务:处理截单、自动退款与当日销售统计
Spring Boot项目里做定时任务,最轻量的方案是@Scheduled注解。小区团购有三个时间触发的任务特别适合用定时任务处理:到点自动关闭活动状态、超过30分钟未支付自动取消订单并回补库存、每天晚上生成销售日报表。下面是一个自动关闭活动的示例:
@Component public class GroupActivityJob { @Autowired private GroupActivityMapper activityMapper; @Scheduled(cron = "0 * * * * ?") public void closeExpiredActivities() { // 每分钟扫描一次,把过了end_time且状态为进行中的活动关闭 LambdaUpdateWrapper<GroupActivity> wrapper = new LambdaUpdateWrapper<>(); wrapper.eq(GroupActivity::getStatus, 1) .lt(GroupActivity::getEndTime, LocalDateTime.now()) .set(GroupActivity::getStatus, 2); int updated = activityMapper.update(null, wrapper); if (updated > 0) { log.info("已自动关闭{}个超时团购活动", updated); } } }定时任务的坑在于单机部署时可靠,但一旦用多节点部署,同一个任务会被每个节点各执行一次。你在生产环境要留意这个问题,常见做法是引入分布式锁,比如用Redis的SETNX,或者直接用xxl-job这类调度平台。我的建议是:系统初期单体部署,@Scheduled完全够用;只有当你明确要横向扩容时,才考虑分布式任务调度。
自动取消未支付订单的任务要特别注意幂等:当然不能让定时任务把已经支付的订单取消了。SQL条件里必须带status=0,执行UPDATE orders SET status = 3 WHERE create_time < NOW() - INTERVAL 30 MINUTE AND status = 0,并且取消的同时要判断回补库存是否已经做过,避免重复回补。
5.3 订单快照模式:改价格和改名称都不允许影响历史订单
某个商品上个批次团购价是39.9,这个批次改成35.9。如果订单表直接关联商品表实时取价格,麻烦就来了:历史订单全部显示35.9,团长对账时执行什么价格都对不上。
这就是订单快照存在的意义。我在建表时把product_name和unit_price直接冗余在orders表里,下单那一刻固化成历史证据。这个设计不只是社区团购需要,任何交易系统都必须有。区分“引用”和“快照”是数据建模里最低成本又最高收益的习惯。
除了订单表冗余快照,我还在group_activity表设计了sold_count字段。每成功支付一单,sold_count加一,这个统计值也是增量更新。我不用SELECT COUNT(*) FROM orders WHERE activity_id = ?去实时算,因为在orders表数据量到五十万后,这种count查询会拖慢接口。定时任务每天晚上把当天的订单数异步汇总到活动表的冗余字段,页面展示时直接读,秒开。
5.4 分页查询与列表接口的通用优化
管理后台的订单列表、商品列表,最容易出现全表扫描。MyBatis-Plus的分页插件要配置两个东西:PaginationInnerInterceptor和DbType。很多人只配了拦截器没配数据库类型,导致分页SQL生成方言不对,LIMIT语句失效。
@Configuration public class MybatisPlusConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); PaginationInnerInterceptor paginationInterceptor = new PaginationInnerInterceptor(DbType.MYSQL); paginationInterceptor.setMaxLimit(100L); interceptor.addInnerInterceptor(paginationInterceptor); return interceptor; } }setMaxLimit(100L)这一行是保护机制:防止用户在管理后台设置pageSize=10000,把数据库拖垮。列表接口查询条件尽量走索引,比如订单列表必须传activity_id或user_id,不允许无条件分页查orders表。如果真要做全表订单导出,用异步任务生成文件,绝不能用同步接口。
6. 上线前验证这套系统的三个实用手段
针对Spring Boot后端,我积累了一套低成本高回报的验证方法,不需要复杂的测试框架,跑通一轮基本能规避大部分生产环境问题。
第一件事是启动日志里确认MyBatis-Plus的SQL打印。开发环境配置mybatis-plus.configuration.log-impl: org.apache.ibatis.logging.stdout.StdOutImpl,每个接口调用都能看到完整SQL。我会在测试环境调一遍“创建活动→下单→支付→核销”主链路,盯着控制台确认每条SQL都走了正确的索引,特别留意WHERE条件里有没有字段类型转换导致索引失效。确认没问题后关掉SQL打印,生产环境开着非常耗性能。
第二件事是用Spring Boot Actuator暴露健康检查接口。加依赖后配置management.endpoints.web.exposure.include=health,info,持续集成或云平台的探活就会持续依赖/actuator/health。我平时会结合定时任务做一次“假下单”冒烟测试:用一个测试账号对超低价格的商品下单,如果整个链路200返回,说明数据库和Redis的核心环节都活着。
第三件事是查慢SQL日志。MySQL开启slow_query_log,超过300ms的查询全部记录下来,上线前两天每天早上看一眼。最容易出现的是订单列表关联查询没有索引,第二常见的是分页offset太大导致扫描深度过大。这两种问题在数据量小时毫无存在感,上线三周后才暴露,一旦线上出问题,补救成本远高于提前监测。
这套系统我前前后后做了三轮迭代,最深刻的教训是:刚动手时不要急着加功能,先把订单状态机和库存扣减这两条主干流程彻底跑通、跑熟。后面所有功能,退款、统计、导出页面,都是在这两条主线上挂枝叶。你把订单快照和状态条件更新这两个设计吃透了,小区团购管理系统这个题目就立得住。希望这些从实际项目里踩出来的方案和思路,能帮你在自己的Spring Boot项目里少走一段弯路。
本文还有配套的精品资源,点击获取