简介:这是一套面向计算机相关专业学生与项目实战学习者的微信小程序社区团购系统毕业设计源码,采用微信小程序前端搭配SSM(Spring、SpringMVC、MyBatis)后端架构,覆盖需求分析、系统设计、编码实现到测试的完整开发流程,可作为毕业设计、课程设计或期末大作业的高分参考案例。压缩包共1148个文件,约51.97MB,包含118个Java后端源码、160个JavaScript脚本、109个Vue组件、75个wxss与73个wxml小程序页面文件,以及数据库sql脚本、演示视频、使用说明和环境安装文档,另附png、svg等图片素材与bat启动脚本,目录结构清晰。已有60人学习下载。项目经导师指导认可并严格调试,可运行性强,读者可据此掌握小程序开发流程、SSM框架应用与数据库设计优化,积累完整项目经验,为就业竞争打下基础。
1. 社区团购系统为什么总在“下单那一刻”翻车
社区团购这套业务,表面看是“团长开团、居民下单、次日自提”,真做起来才发现,最难的从来不是页面好不好看,而是下单那一刻库存、价格、成团状态三者能不能对齐。我见过不少基于微信小程序的社区团购系统,前端页面做得挺漂亮,一到并发下单就开始超卖,或者订单生成了库存没扣,团长那边看到的成团人数和后台对不上。这类问题在毕业设计里尤其常见,因为很多同学把精力全砸在小程序端,后端 SSM 只写了个能跑通的 CRUD,没考虑事务和并发。
这篇笔记就围绕“基于微信小程序的社区团购系统 + SSM 后端”这个方向,把从环境搭建、数据库设计、核心下单链路到联调排错完整走一遍。适合两类人:一是正在做类似毕设、需要一套能讲清楚也能跑起来的方案的同学;二是想用 SSM 练手一个真实业务闭环的后端新手。我不会假装手里有某份现成源码包,而是按这个标题下最常见、最可靠的做法,把每个环节该写什么、参数怎么设、哪里容易翻车讲透。你照着做,至少能拿到一个逻辑自洽、能演示、能答辩的系统。
2. 技术选型与工程骨架:SSM 后端到底怎么搭才不散
2.1 为什么是 SSM 而不是 Spring Boot 一把梭
标题写的是 SSM,也就是 Spring + Spring MVC + MyBatis。很多同学会问,现在都流行 Spring Boot 了,为什么毕设还要求 SSM?原因很实际:SSM 的配置文件是显式的,applicationContext.xml、spring-mvc.xml、mybatis-config.xml各管一摊,答辩时老师能清楚看到你理解 IoC、AOP、事务管理和 MVC 分层。Spring Boot 自动配置把很多东西藏起来了,反而不好讲清楚“为什么这么配”。
我一般会这样组织工程目录,让分层一眼可见:
community-group-buy/ ├── src/main/java/com/example/groupbuy/ │ ├── controller/ # 接收小程序请求 │ ├── service/ # 业务逻辑,事务在这里 │ ├── service/impl/ │ ├── mapper/ # MyBatis 接口 │ ├── entity/ # 与数据库表对应 │ ├── dto/ # 前端传参和返回封装 │ └── common/ # 统一返回、异常、工具类 ├── src/main/resources/ │ ├── mapper/ # XML 映射文件 │ ├── applicationContext.xml │ ├── spring-mvc.xml │ ├── mybatis-config.xml │ └── jdbc.properties └── src/main/webapp/WEB-INF/web.xml这个结构不新鲜,但胜在清晰。controller 只做参数校验和调用 service,service 里写业务和事务,mapper 只负责 SQL。别把业务逻辑写进 controller,否则后面加一个“取消订单返库存”的功能,你会改到怀疑人生。
2.2 依赖版本与关键配置
依赖用 Maven 管理,核心就这几块:Spring 5.x、Spring MVC 5.x、MyBatis 3.5.x、MySQL 8.x 驱动、Druid 连接池、Jackson 做 JSON 转换。版本不用追最新,稳定能跑通最重要。下面是一个可用的pom.xml关键片段:
<properties> <spring.version>5.3.30</spring.version> <mybatis.version>3.5.13</mybatis.version> </properties> <dependencies> <!-- Spring 核心 --> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-context</artifactId> <version>${spring.version}</version> </dependency> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-tx</artifactId> <version>${spring.version}</version> </dependency> <!-- Spring MVC --> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-webmvc</artifactId> <version>${spring.version}</version> </dependency> <!-- MyBatis 与 Spring 整合 --> <dependency> <groupId>org.mybatis</groupId> <artifactId>mybatis-spring</artifactId> <version>2.1.2</version> </dependency> <dependency> <groupId>org.mybatis</groupId> <artifactId>mybatis</artifactId> <version>${mybatis.version}</version> </dependency> <!-- MySQL 驱动 --> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <version>8.0.33</version> </dependency> <!-- Druid 连接池 --> <dependency> <groupId>com.alibaba</groupId> <artifactId>druid</artifactId> <version>1.2.20</version> </dependency> <!-- Jackson --> <dependency> <groupId>com.fasterxml.jackson.core</groupId> <artifactId>jackson-databind</artifactId> <version>2.15.3</version> </dependency> </dependencies>applicationContext.xml里要开注解扫描和事务管理,注意事务管理器要配在 service 层:
<!-- 数据源 --> <bean id="dataSource" class="com.alibaba.druid.pool.DruidDataSource"> <property name="driverClassName" value="com.mysql.cj.jdbc.Driver"/> <property name="url" value="jdbc:mysql://localhost:3306/groupbuy?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai"/> <property name="username" value="root"/> <property name="password" value="你的密码"/> <property name="initialSize" value="5"/> <property name="maxActive" value="20"/> </bean> <!-- 事务管理器 --> <bean id="transactionManager" class="org.springframework.jdbc.datasource.DataSourceTransactionManager"> <property name="dataSource" ref="dataSource"/> </bean> <tx:annotation-driven transaction-manager="transactionManager"/> <!-- 扫描 service --> <context:component-scan base-package="com.example.groupbuy.service"/>参数说明:initialSize是初始连接数,毕设演示 5 够用;maxActive是最大连接数,设 20 是为了压测时能看到连接池行为,别设太大,否则本地 MySQL 容易报连接数超限。serverTimezone必须写,不然 MySQL 8 会报时区错误,这是新手最常见的翻车点之一。
2.3 小程序端与后端的通信约定
小程序端用wx.request调后端,后端统一返回一个 JSON 结构,别一会儿返回数组一会儿返回对象,否则前端处理起来很乱。我一般用这个格式:
{ "code": 200, "msg": "success", "data": {} }后端写一个Result类封装,controller 里统一返回Result.success(data)或Result.error(msg)。这样小程序端只需要判断code是否为 200,再取data。注意跨域问题:小程序开发工具里可以在“详情-本地设置”勾选“不校验合法域名”,但真机调试必须配 HTTPS 域名,毕设演示用开发工具即可。
3. 数据库设计与核心表:订单、库存、成团三张表怎么连
3.1 表结构设计思路
社区团购的核心实体有:用户、团长、商品、团购活动、订单、订单明细、自提点。其中最容易出问题的是团购活动表和订单表的关系。一个团购活动有开始时间、结束时间、成团人数、当前人数、状态(进行中/已成团/已结束)。订单表要记录用户买了哪个活动、买了几件、订单状态(待支付/已支付/已取消/已退款)。
下面给出核心表的建表语句,字段类型和索引都按实际业务来:
-- 团购活动表 CREATE TABLE `group_activity` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `product_id` BIGINT NOT NULL COMMENT '商品ID', `leader_id` BIGINT NOT NULL COMMENT '团长ID', `group_price` DECIMAL(10,2) NOT NULL COMMENT '团购价', `min_count` INT NOT NULL DEFAULT 1 COMMENT '成团最低人数', `current_count` INT NOT NULL DEFAULT 0 COMMENT '当前参团人数', `start_time` DATETIME NOT NULL, `end_time` DATETIME NOT NULL, `status` TINYINT NOT NULL DEFAULT 0 COMMENT '0进行中 1已成团 2已结束', `stock` INT NOT NULL DEFAULT 0 COMMENT '活动库存', PRIMARY KEY (`id`), KEY `idx_product` (`product_id`), KEY `idx_status_end` (`status`, `end_time`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 订单表 CREATE TABLE `order` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `order_no` VARCHAR(32) NOT NULL COMMENT '订单号', `user_id` BIGINT NOT NULL, `activity_id` BIGINT NOT NULL, `total_amount` DECIMAL(10,2) NOT NULL, `status` TINYINT NOT NULL DEFAULT 0 COMMENT '0待支付 1已支付 2已取消 3已退款', `pickup_point_id` BIGINT NOT NULL COMMENT '自提点', `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_order_no` (`order_no`), KEY `idx_user` (`user_id`), KEY `idx_activity` (`activity_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 订单明细表 CREATE TABLE `order_item` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `order_id` BIGINT NOT NULL, `product_id` BIGINT NOT NULL, `quantity` INT NOT NULL, `price` DECIMAL(10,2) NOT NULL, PRIMARY KEY (`id`), KEY `idx_order` (`order_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;注意group_activity表上的idx_status_end联合索引,因为定时任务要扫描“进行中且已到结束时间”的活动来关团,这个索引能显著减少扫描行数。order表的order_no加了唯一索引,防止重复提交生成重复订单。
3.2 库存扣减的两种写法与选择
库存扣减是社区团购最核心也最容易翻车的地方。常见两种写法:
第一种,先查库存再更新:
SELECT stock FROM group_activity WHERE id = #{activityId}; -- 在 Java 里判断 stock >= quantity UPDATE group_activity SET stock = stock - #{quantity} WHERE id = #{activityId};这种写法在并发下会超卖,因为两个线程可能同时查到库存为 1,都判断通过,然后都执行更新,库存变成 -1。
第二种,用条件更新,把判断交给数据库:
UPDATE group_activity SET stock = stock - #{quantity}, current_count = current_count + 1 WHERE id = #{activityId} AND stock >= #{quantity} AND status = 0;然后在 service 里判断受影响行数,如果为 0 就抛异常回滚。这是我在实际项目里最常用的做法,简单可靠,不需要引入分布式锁。配合@Transactional注解,下单、扣库存、生成订单明细在一个事务里,要么全成功要么全回滚。
3.3 成团逻辑与定时任务
成团有两种触发方式:一是用户下单后检查current_count >= min_count,满足就把活动状态改成“已成团”;二是定时任务扫描已到结束时间但还没成团的活动,把它们关掉。第一种用代码在 service 里判断即可,第二种用 Spring 的@Scheduled注解:
@Component public class ActivityCloseTask { @Autowired private GroupActivityMapper activityMapper; // 每分钟执行一次,关闭已到结束时间且未成团的活动 @Scheduled(cron = "0 * * * * ?") public void closeExpiredActivity() { List<GroupActivity> list = activityMapper.selectExpiredRunning(); for (GroupActivity activity : list) { activity.setStatus(2); // 已结束 activityMapper.updateStatus(activity); } } }cron表达式0 * * * * ?表示每分钟的第 0 秒执行。注意要在spring-mvc.xml或applicationContext.xml里开<task:annotation-driven/>,否则定时任务不生效。这个任务只做状态流转,不涉及退款逻辑,退款在订单模块单独处理。
4. 下单链路实现:从加购到支付回调的完整代码
4.1 下单接口的 Controller 与参数校验
小程序端点击“立即参团”时,会调后端的/api/order/create接口,传参包括activityId、quantity、pickupPointId。Controller 里先做基础校验,再调 service:
@RestController @RequestMapping("/api/order") public class OrderController { @Autowired private OrderService orderService; @PostMapping("/create") public Result createOrder(@RequestBody OrderCreateDTO dto, @RequestHeader("token") String token) { // 从 token 解析用户ID,实际项目里用拦截器做 Long userId = TokenUtil.getUserId(token); if (dto.getQuantity() == null || dto.getQuantity() <= 0) { return Result.error("购买数量不合法"); } try { OrderVO order = orderService.createOrder(userId, dto); return Result.success(order); } catch (BusinessException e) { return Result.error(e.getMessage()); } } }@RequestBody要求前端传 JSON,小程序端wx.request的header里要设content-type: application/json。token放在 header 里而不是 body 里,是为了后续加拦截器统一鉴权。这里用TokenUtil模拟解析,实际可以用 JWT 或简单的 Redis token。
4.2 Service 层事务与库存扣减
Service 层是整个下单链路的核心,事务注解加在这里:
@Service public class OrderServiceImpl implements OrderService { @Autowired private GroupActivityMapper activityMapper; @Autowired private OrderMapper orderMapper; @Autowired private OrderItemMapper orderItemMapper; @Override @Transactional(rollbackFor = Exception.class) public OrderVO createOrder(Long userId, OrderCreateDTO dto) { // 1. 查询活动,校验状态和时间 GroupActivity activity = activityMapper.selectById(dto.getActivityId()); if (activity == null || activity.getStatus() != 0) { throw new BusinessException("活动不存在或已结束"); } Date now = new Date(); if (now.before(activity.getStartTime()) || now.after(activity.getEndTime())) { throw new BusinessException("不在团购时间内"); } // 2. 条件更新扣库存,防止超卖 int affected = activityMapper.reduceStock(dto.getActivityId(), dto.getQuantity()); if (affected == 0) { throw new BusinessException("库存不足"); } // 3. 生成订单 Order order = new Order(); order.setOrderNo(generateOrderNo()); order.setUserId(userId); order.setActivityId(dto.getActivityId()); order.setTotalAmount(activity.getGroupPrice() .multiply(new BigDecimal(dto.getQuantity()))); order.setStatus(0); order.setPickupPointId(dto.getPickupPointId()); orderMapper.insert(order); // 4. 生成订单明细 OrderItem item = new OrderItem(); item.setOrderId(order.getId()); item.setProductId(activity.getProductId()); item.setQuantity(dto.getQuantity()); item.setPrice(activity.getGroupPrice()); orderItemMapper.insert(item); // 5. 检查是否成团 activityMapper.checkAndUpdateGroupStatus(dto.getActivityId()); return new OrderVO(order.getOrderNo(), order.getTotalAmount()); } private String generateOrderNo() { // 时间戳 + 随机数,实际项目可用雪花算法 return System.currentTimeMillis() + String.format("%04d", new Random().nextInt(10000)); } }reduceStock对应的 XML SQL 就是前面说的条件更新:
<update id="reduceStock"> UPDATE group_activity SET stock = stock - #{quantity}, current_count = current_count + 1 WHERE id = #{activityId} AND stock >= #{quantity} AND status = 0 </update>checkAndUpdateGroupStatus用一条 SQL 完成判断和更新:
<update id="checkAndUpdateGroupStatus"> UPDATE group_activity SET status = 1 WHERE id = #{activityId} AND status = 0 AND current_count >= min_count </update>这样整个下单链路就是:校验活动 → 扣库存 → 生成订单 → 生成明细 → 检查成团,全部在一个事务里。任何一步失败,前面的操作都会回滚,不会出现“库存扣了订单没生成”的脏数据。
4.3 支付回调与订单状态流转
毕设里支付一般用模拟支付,不接真实支付渠道。小程序端点击“支付”后,调后端/api/order/pay接口,后端把订单状态从 0 改成 1,并记录支付时间。这里要注意幂等:同一个订单重复回调不能重复改状态。
@Transactional(rollbackFor = Exception.class) public void paySuccess(String orderNo) { Order order = orderMapper.selectByOrderNo(orderNo); if (order == null) { throw new BusinessException("订单不存在"); } // 只有待支付状态才处理,防止重复回调 if (order.getStatus() != 0) { return; } orderMapper.updateStatus(orderNo, 1); }updateStatus的 SQL 也要带状态条件:
<update id="updateStatus"> UPDATE `order` SET status = #{status} WHERE order_no = #{orderNo} AND status = 0 </update>这样即使回调重复到达,第二次受影响行数为 0,不会产生副作用。这是支付场景的标配写法,别省。
5. 避坑与排查:联调时最容易翻车的 5 个点
5.1 事务不生效,库存扣了订单没生成
现象:下单后库存减少了,但订单表里查不到记录。原因通常是@Transactional加在了 controller 上,或者 service 类没有被 Spring 扫描到,或者异常被 catch 了没抛出去。解决:确认@Transactional加在 service 实现类的方法上,rollbackFor = Exception.class要写,catch 块里如果是业务异常要重新抛出,别吞掉。
5.2 小程序请求后端返回 404 或 415
现象:小程序端调接口报 404,或者报 415 Unsupported Media Type。原因:404 多半是spring-mvc.xml里没配<mvc:annotation-driven/>或者 controller 没被扫描到;415 是@RequestBody要求 JSON,但小程序端header没设content-type: application/json。解决:检查spring-mvc.xml的扫描包路径,小程序端wx.request的 header 补上 content-type。
5.3 MySQL 8 时区报错导致连接失败
现象:启动 Tomcat 时报The server time zone value '�й���ʱ��' is unrecognized。原因:MySQL 8 的 JDBC URL 没指定serverTimezone。解决:在jdbc.properties的 url 后面加&serverTimezone=Asia/Shanghai,注意 XML 里&要写成&。
5.4 定时任务不执行
现象:活动到了结束时间,状态还是“进行中”。原因:<task:annotation-driven/>没配,或者@Scheduled所在的类没被 Spring 扫描到。解决:在applicationContext.xml里加<task:annotation-driven/>,并确保定时任务类在 component-scan 的包路径下。另外注意 cron 表达式是 6 位还是 7 位,Spring 用 6 位。
5.5 订单号重复导致插入失败
现象:并发下单时偶尔报Duplicate entry for key 'uk_order_no'。原因:用时间戳加随机数生成订单号,并发高时可能撞车。解决:改用System.currentTimeMillis() + 用户ID后四位 + 随机数,或者直接上雪花算法。毕设演示并发不高,但答辩时老师可能会问,提前想好答案。
6. 进阶技巧:用压测和日志把系统讲清楚
6.1 用 JMeter 压测下单接口看库存是否超卖
毕设答辩时,如果你能拿出压测数据,说服力会强很多。用 JMeter 建一个线程组,100 个线程同时调/api/order/create,观察库存字段有没有变成负数。压测前把活动库存设成 50,成团人数设成 10,跑完后查数据库:
SELECT stock, current_count FROM group_activity WHERE id = 1; SELECT COUNT(*) FROM `order` WHERE activity_id = 1;如果stock是 0 或正数,current_count等于订单数,说明条件更新生效了。如果stock是负数,说明你的扣减逻辑有问题,回去检查 SQL 的stock >= #{quantity}条件。
6.2 日志配置与关键链路追踪
在logback.xml或log4j.properties里把 MyBatis 的 SQL 日志打开,方便排查:
log4j.logger.com.example.groupbuy.mapper=DEBUG这样每次 SQL 执行都会打印参数和结果,联调时能快速定位是参数传错了还是 SQL 写错了。但上线前记得关掉,否则日志量太大。
6.3 一个我踩过的坑:事务里查不到未提交的数据
有一次我在createOrder里先插入订单,然后调另一个 service 方法去查这个订单,结果查不到。原因是两个方法在同一个事务里,但 MyBatis 的一级缓存和数据库隔离级别导致查不到未提交的数据。后来我把查询逻辑改成直接用插入后的对象,不再回查数据库。这个坑不常遇到,但一旦遇到很难查,记住:事务里尽量用内存对象,别依赖回查。
6.4 答辩时怎么讲清楚“为什么用 SSM”
老师大概率会问:“你为什么不用 Spring Boot?”我的回答是:“SSM 的配置是显式的,我能清楚说明每个 bean 的作用和事务边界的划分,这对理解 Spring 的 IoC 和 AOP 更有帮助。Spring Boot 虽然开发快,但自动配置隐藏了细节,不适合用来展示对框架原理的掌握。”这个回答既诚实又体现思考,比硬说“SSM 更好”要稳。
最后说个习惯:我每次写完一个下单接口,都会先手动构造并发场景跑一遍,看库存和订单数对不对,再去看日志里 SQL 的执行顺序。这个习惯帮我省了很多答辩前的后悔药。希望帮到你。
本文还有配套的精品资源,点击获取