做社区团购系统这件事我前后帮朋友折腾过两个版本。第一版用的是纯原生微信小程序 Java Servlet第二版换成了 uniapp SpringBoot也就是你看到这个标题里的组合。选题越做越顺是因为社区团购本质上不是“开发一个商城”那么简单它透着一股典型的“线下履约 线上交易”混合业务的味道。把微信小程序当入口、uniapp当跨端壳、SpringBoot当业务中台是我目前觉得性价比最高的一套搭配。这篇文章就把这套系统的核心设计、关键代码逻辑、以及我在联调时踩过的真实坑一次性讲透。适用人群我直接说如果你在做一个社区团购类的毕设项目或者正在公司从零搭一套社区团购的小程序后端又或者单纯想看 uniapp 和 SpringBoot 到底怎么配合这篇都能给你省下不少弯路。基础要求不高懂点 Java 和 Vue 语法就行。1. 社区团购的业务闭环与技术选型逻辑1.1 社区团购的“人货场”模型社区团购不是普通的 B2C 电商。它的核心差异在于“团长”这个角色——一个小区里负责拉群、推广、收货、分拣、通知邻居们取货的中间节点。整个业务链路是用户C端从小程序浏览商品、发起拼团或直接下单。平台你的后端汇总订单把当天的订单批量推给供应商或仓库。次日或约定时间段货品统一配送到团长所在的自提点。团长在小程序端核对到货情况并通知用户取货用户在小程序里确认收货、评价。这就是社区团购典型的“预售 集单 自提”模式。和即时零售相比它对时效要求没那么苛刻但对订单聚合、支付对账、分团长结算这类逻辑要求反而更高。你设计数据库表的时候脑子里必须始终带着这条链而不是简单照搬一个电商开源项目。1.2 为什么是微信小程序 uniapp SpringBoot选这套组合我的理由很实际微信小程序是流量入口绕不开。社区团购的目标用户尤其是家庭采购人群习惯用微信小程序用完即走不用下载 App分享到微信群也顺畅。团长在群里丢一个拼团链接用户点开就是商品详情页这个路径是 App 给不了的。uniapp 解决的是“多端复用”的焦虑。我当时做第二版的时候客户提了一句“以后可能要上支付宝小程序”或者“团长端想看有没有 App 版”。uniap 可以一套 Vue 代码编译到微信小程序、H5、App哪怕最终只上线微信小程序它也帮我把组件生态和状态管理理顺了。而且 uniapp 在微信小程序上的性能表现在常规业务场景下完全够用没必要为了省一点点性能去写两套原生。SpringBoot 作为后端开发效率是最大优势。社区团购的业务复杂度主要在后端订单、支付回调、库存扣减、拼团状态机、团长佣金结算。SpringBoot 的 starter 生态把这些东西都覆盖了JPA 或者 MyBatis-Plus 任选安全认证用 Sa-Token 或者 Spring Security 都行消息推送集成个 websocket 也就几行配置。再说这种项目基本都是一个人开发社区资料多、遇到问题能搜到答案就是对开发者最大的友好。提示如果你是在校生拿这个题做毕设SpringBoot uniapp 还是评审老师最眼熟的技术栈。稳妥加分。1.3 整体架构从下单到履约的链路拆解我用一个实际的订单生命周期来给你拆解整个系统的模块边界环节用户端动作后端核心模块关键数据表浏览进入小程序首页、查看拼团列表商品/活动模块goods, groupon_activity下单选择自提点、提交订单订单模块order, order_item支付微信支付支付模块payment_record集单订单进入待成团状态拼团模块groupon_session, groupon_member配送管理员/仓库点击发货配货模块delivery_order核销团长确认到货、用户取货核销模块pickup_code结算团长提现佣金结算模块commission_account, commission_log从这个链路能看出这不是简单的一个“卖货”系统而是一个 线上零售 线下履约 资金分账 的小型交易平台。模块边界在项目初期就划分清楚后期才不会因为“订单状态到底该谁改”这种问题打架。2. uniapp 小程序端的关键实现2.1 多端适配与基础框架搭建要点uniapp 项目创建之后第一个要处理的是目录结构和请求封装。社区团购小程序端至少有三类角色页面普通用户端、团长端、平台管理员端通常做进 H5 后台用小程序 web-view 承载。我建议在项目根目录下直接按角色分 pages 目录再配合 uni-simple-router 做路由权限判断在路由守卫里统一处理。请求封装这里有个必须注意的点——uniapp 的uni.request在小程序端默认不带 cookie 概念token 保存到uni.setStorageSync请求拦截器统一从 storage 里取。如果后端用的是 JWT就没问题如果用 session麻烦后面第 4 章我会细说。下面是我项目里基础的 request.js 封装// utils/request.js const BASE_URL https://api.yourdomain.com/api; export const request (options) { return new Promise((resolve, reject) { const token uni.getStorageSync(token); uni.request({ url: BASE_URL options.url, method: options.method || GET, data: options.data || {}, header: { Content-Type: application/json, Authorization: token ? Bearer ${token} : }, success: (res) { if (res.statusCode 200) { resolve(res.data); } else if (res.statusCode 401) { uni.navigateTo({ url: /pages/login/login }); reject(res); } else { uni.showToast({ title: res.data.msg || 请求失败, icon: none }); reject(res); } }, fail: (err) { uni.showToast({ title: 网络异常, icon: none }); reject(err); } }); }); };2.2 拼团/团购页面的核心交互逻辑社区团购首页和普通商城首页最大的区别是它需要突出“今日开团”的活动概念。用户进入首页看到的不是一个个商品而是一个个“团”——每个团有起购人数、已参团人数、倒计时、开团价。我当时的页面逻辑是首页接口返回当前有效的 groupon_activity 列表每个 activity 包含主商品图、团价、原价、已参团人数。商品详情页用onLoad接收 activityId 和 skuId通过接口获取详情。点击“我要参团”后如果该活动已经成团且未结束进入付款流程否则创建一个 new session成为团长开团人。下单成功后前端把订单状态、拼团 session 状态、支付状态三个字段联合起来判断页面展示待分享/已参团/已成团/已失效。这里用到 uniapp 的一个细节微信小程序的分享回调。用户开团成功后通常会引导他分享到微信群但微信群打开的页面携带不到自定义参数——这个问题的解法我放在 2.4。拼团倒计时也是容易踩坑的地方。后端返回 session 的结束时间戳前端做一个全局定时器统一管理而不是每个页面单独setInterval否则页面栈多了以后内存和计算都会被拖累。// util/countdown.js class CountdownManager { constructor() { this.timers []; } add(pageObj, endTime, callback) { const timer setInterval(() { const remain endTime * 1000 - Date.now(); if (remain 0) { clearInterval(timer); callback(expired); } else { const h Math.floor(remain / 3600000); const m Math.floor((remain % 3600000) / 60000); const s Math.floor((remain % 60000) / 1000); callback({ h, m, s }); } }, 1000); this.timers.push(timer); pageObj.onUnload () this.clear(timer); } clear(timer) { clearInterval(timer); } } export default new CountdownManager();2.3 团长端与配送状态的实时更新团长端的核心是“核销”和“配送通知”。我用了两种方案并存自提单核销用户到店后团长在团长工作台中扫用户的取货码。我这里建议后端不要在接口里只校验“取货码是否正确”而是校验三件事——取货码、自提点 ID、订单状态是否为“已送达”。缺一不可否则会出现用户在 A 点下单、去 B 点自提导致库存错乱的情况。到货通知用户端要实时看到“货已到自提点”。这里走的 websocket 推送。SpringBoot 后端在管理员/仓库执行“确认到货”操作后通过 Stomp 协议广播给该自提点关联的所有用户。uniapp 端用uni.connectSocket封装一个长连接管理器在拿到到货推送时刷新订单列表。我封装的 websocket 核心逻辑// utils/socket.js let socketTask null; export function connectSocket(userId) { if (socketTask) return; const token uni.getStorageSync(token); socketTask uni.connectSocket({ url: wss://api.yourdomain.com/ws?userId${userId}token${token}, success: () {} }); socketTask.onMessage((res) { const msg JSON.parse(res.data); if (msg.type ORDER_ARRIVED) { uni.showToast({ title: 您的商品已到自提点, icon: none }); // 触发页面刷新事件 uni.$emit(refreshOrderList); } }); socketTask.onClose(() { socketTask null; }); }2.4 自定义分享与话术携带社区团购的拼团玩法特别依赖“分享”。微信小程序里按钮触发open-typeshare后onShareAppMessage 可以自定义转发标题、图片和 path。但你没法在小程序内部拿到“用户是否转发成功”的回调只能通过onShow时判断页面options里有没有grouponSessionId来判断用户有没有从分享链接进入。这个环节我在项目里遇到过一个细节问题用户从分享卡片进入小程序时path 上带的参数会被微信拼在路径后面但如果你用的是分包路径页面未加载完成时 onLoad 里的 query 是拿不到的。稳妥做法是在onLoad和onShow两处都做一次参数解析onLoad(options) { if (options.scene) { const scene decodeURIComponent(options.scene); const params this.parseScene(scene); // scenegroupId_123_uid_456 this.grouponSessionId params.groupId; } else if (options.grouponSessionId) { this.grouponSessionId options.grouponSessionId; } }, onShow() { if (this.grouponSessionId) { this.loadGrouponSessionDetail(this.grouponSessionId); } }scene这个参数的使用场景是线下扫码、群二维码等入口。不过微信群内的分享卡片路径上直接带参数也会触发onShareAppMessage中 path 自动拼接所以更多时候你从onLoad的 options 里拿到的就是grouponSessionId。两个入口都处理基本不会漏。3. SpringBoot 后端核心业务设计3.1 订单链路与库存扣减方案后端第一个要设计好的是订单和库存的数据模型。社区团购是“预售制”不用像即时零售那样实时锁库存但正因为有团购人数门槛库存扣减的设计反而要更谨慎。我采用的方案是商品表记录库存总量和已售数量下单时不直接扣减stock而是先扣减lock_stock锁定库存拼团结束后统一核减。举个例子某生鲜商品总库存 100 份用户下单 2 份时stock100不变lock_stock98。拼团成功后把lock_stock变成正式扣减同时更新sales_count。为什么这样设计因为社区团购的订单不是下单即成而是“先付款、后成团、再发货”。如果某团因为人数不足退款了锁定库存会释放回stock如果一人多下单锁定库存能防止用户把货买超。直接用stock做原子扣减会在退款时出现超卖或库存恢复错乱。SpringBoot 层面对单库存做并发保护我用的是乐观锁 版本号// GoodService.java Transactional(rollbackFor Exception.class) public boolean lockStock(Integer goodsId, Integer quantity) { Goods goods goodsMapper.selectForUpdate(goodsId); // 悲观锁兜底 if (goods.getLockStock() quantity goods.getStock()) { return false; } goodsMapper.increaseLockStock(goodsId, quantity); return true; }提示如果业务量不大这个方法够了。如果你的系统日订单超过几万单把库存扣减改成 Redis Lua 脚本保证原子性数据库只做最终记录这也是我后来优化时的方向。3.2 支付回调的幂等与对账微信支付在小程序端的流程是前端调起uni.requestPayment拿到支付结果但客户端回调结果不可信真正的支付结果要以微信服务器推送的payNotify为准。SpringBoot 接收微信支付回调的接口必须注意三件事验签。微信支付回调的签名用商户 API v3 密钥做 AES-256-GCM 解密要把证书序列号、时间戳、随机串、签名串都校验一遍。幂等。可能微信网络抖动会重复推送同一笔回调所以业务表要建立order_no唯一索引更新前先查payment_record是否存在。异步处理。回调接口不要同步去触发大量业务逻辑比如建履约单、发通知只更新支付流水状态、把订单置为已支付然后把“支付成功”事件丢进ApplicationEventPublisher由监听器异步处理后续业务。代码思路// PayNotifyController.java PostMapping(/notify/pay) public String payNotify(HttpServletRequest request) throws Exception { // 1. 从请求头获取 Wechatpay-Serial, Wechatpay-Timestamp, Wechatpay-Nonce, Wechatpay-Signature // 2. 读取 body用证书验签 // 3. 解密 resource 得到明文 transaction 对象 // 4. 调用 payService.handlePaidOrder(transaction.getOutTradeNo()); return SUCCESS; } // PayServiceImpl.java Transactional public void handlePaidOrder(String orderNo) { if (paymentRecordMapper.existsByOrderNo(orderNo)) { return; // 幂等返回 } orderMapper.updateStatus(orderNo, OrderStatus.PAID); paymentRecordMapper.insert(orderNo, PayStatus.SUCCESS); eventPublisher.publishEvent(new PaidOrderEvent(orderNo)); }3.3 拼团状态的并发处理拼团系统最典型的并发场景是“成团瞬间”最后一个人支付完成后系统要判断该 session 是否达到成团人数然后批量更新所有参团订单。这个判断必须放在synchronized或数据库锁里执行否则会出现“成团人数刚刚够却有两个线程同时把状态置为失败”的情况。我的做法是用数据库行锁兜底 状态机约束// GrouponService.java Transactional(rollbackFor Exception.class) public boolean tryCompleteGroupon(Integer sessionId) { GrouponSession session grouponSessionMapper.selectByIdForUpdate(sessionId); if (session.getStatus().equals(GrouponStatus.SUCCESS)) { return true; } int memberCount grouponMemberMapper.countBySessionId(sessionId); if (memberCount session.getTargetCount()) { session.setStatus(GrouponStatus.SUCCESS); grouponMemberMapper.updateStatusBySession(sessionId, GrouponMemberStatus.SUCCESS); orderMapper.updateStatusBySession(sessionId, OrderStatus.WAIT_DELIVERY); return true; } return false; }注意selectByIdForUpdate必须在事务内执行MySQL 的 InnoDB 才会对该行加锁否则锁会立即释放。这就是为什么很多新人在测试并发时明明写了行锁却没有效果最后查 bug 发现Transactional没生效。拼团“成团失败”的退款逻辑我放在定时任务里每天凌晨扫一遍所有超过有效期且未成团的 session把订单状态置为 CLOSED统一退款。退款也是幂等的调微信退款接口前先检查本系统退款记录表。3.4 全局过滤器安全加固社区团购系统绕不开用户提交的数据安全。我这边用的是 SpringBoot 全局过滤器统一处理 XSS 过滤。当时有一个需求场景是后台管理里有“上传 PDF 文件”的功能PDF 文件本身的二进制内容不能被 String 类型的过滤器拦截否则上传文件会损坏但表单提交的文本内容商品名称、公告内容必须经过 XSS 清洗。我给过滤器设计了这样的区分逻辑// XssFilter.java Component public class XssFilter extends OncePerRequestFilter { Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException { if (isMultipartContent(request) || isPdfUploadRequest(request)) { chain.doFilter(request, response); return; } chain.doFilter(new XssHttpServletRequestWrapper(request), response); } }关键在于isMultipartContent的判断Content-Type为multipart/form-data的请求直接放行否则 wrapper 里对 JSON 和 form 参数做转义清洗。这样既把编辑器提交的script标签洗掉了又不会破坏文件上传的二进制流。4. 联调阶段最容易踩的坑4.1 顶部导航栏高度与胶囊按钮适配微信小程序的导航栏和其他端的布局差异在 uniapp 里尤其明显。社区团购首页顶部往往需要自定义搜索框 自提点切换入口很多新手直接用status-bar-height当导航栏高度结果 iPhone 和安卓的胶囊位置全不一样。我的实践方案是// 获取胶囊按钮位置 const menuButton uni.getMenuButtonBoundingClientRect(); const systemInfo uni.getSystemInfoSync(); const navBarHeight menuButton.top menuButton.height 8; const statusBarHeight systemInfo.statusBarHeight;把这两个值放到 Vuex 或全局配置里导航栏组件统一读取。用胶囊为准计算导航栏高度标题按钮才不会在不同机型上错位。这是微信小程序开发里最基础也最容易翻车的适配问题。4.2 manifest 配置与域名指向uniapp 的manifest.json里有很多玄学坑。最典型的一个开发微信小程序时H5 和 App 都可以本地联调但微信开发者工具里请求本地接口http://localhost会直接报“不在合法域名列表”。你必须在微信公众平台配置 request 合法域名而且必须是 HTTPS。开发阶段可以用“不校验合法域名”这个开关顶一阵子上线前一定要把正式域名配置好。注意uniapp 在小程序端有自己的环境判断方式manifest.json - h5 - devServer只对 H5 生效小程序端请求地址需要单独在代码里用条件编译区分。// config/env.js let BASE_URL ; // #ifdef MP-WEIXIN BASE_URL https://api.yourdomain.com/api; // #endif // #ifdef H5 BASE_URL /api; // 走 devServer 代理 // #endif另外如果你在小程序里引入了unicloud的云函数相关包网络请求会被强制走云函数路由这种时候再配置独立域名没用要注意你到底是用了云开发还是自建后端两者不要混。4.3 H5 与小程序双端的跨域与会话问题开发时你可能用 uniapp H5 模式打开页面调试登录后再切到微信小程序模式结果发现登录态全丢了。原因很直接H5 端用的可能是 cookie/session 或 localStorage小程序端用的是uni.setStorageSync两边存储机制完全隔离。要保证多端统一建议后端全部走 Token 认证前端在登录成功接口处统一把 token 写入 storage请求拦截器统一从 storage 读取。这样一来H5 端正常携带Authorization头。小程序端同样携带。后端只需在网关层做SaCheckLogin或手动校验 JWT无需关心前端到底来自哪个端。4.4 缓存失效与数据一致性问题社区团购首页会有很多“热数据”——首页轮播图、活动列表、热门商品。如果每次都打数据库QPS 一上来 MySQL 就顶不住了。我的做法是把首页数据缓存到 Rediskey 设计成home:index:cityId:version后台每次上下架商品或发布活动都把对应版本的 key 主动 delete。下次请求自然回源数据库再重建缓存。但这里有个隐蔽的坑用户端刚下单订单列表页要展示最新状态如果订单列表也做了 Redis 缓存就会出现“支付成功但列表还显示待付款”的诡异问题。我的建议是订单、支付、拼团状态这类实时性要求高的数据一律不缓存直接查库只有商品详情、首页聚合信息这种弱一致性数据才走 Redis。别为了省一次数据库查询把核心交易数据弄脏了。5. 这套系统还能怎么扩展整套系统跑通以后如果想继续深入我会优先推荐这几个方向团长佣金结算系统社区团购绕不开分账。给每个团长配一个佣金账户每天生成订单佣金流水周结算或者月结算。这时候你就要引入异步任务、Excel 导出、甚至微信商家转账到零钱。供销存可视化后端加一套报表模块按自提点、商品品类、团长维度统计销量和履约率。用SpringBoot ECharts做数据面板前端可以用 uniapp 的renderjs或 web-view 嵌 H5 报表。秒杀/限时购如果你想挑战高并发社区团购里最常见的活动是整点秒杀。库存扣减从数据库锁升级为 Redis Lua限流用Sentinel或自建令牌桶这套做下来简历上可以写“支撑瞬时千级并发”了。uniapp 原生插件如果你以后要出团长端 Appuniapp 支持调用 iOS/Android 原生插件。扫描包装条形码、调用系统相机、推送能力都能走 uni-app 原生插件市场这一点是纯 web 技术栈给不了的。我在实际项目里的体会是社区团购这种系统业务入口不复杂复杂度全在“交易状态的正确性”和“多角色权限的边界”上。只要架构分层清晰、状态机约定明确后面扩展任何功能都像搭积木。当时做完第一版我在本机跑了全链路测试看着拼团成功、支付回调、到货通知三个状态依次流转起来的时候那种成就感还是很真实的。你也可以把地址栏里那串1g4y216z当成这个项目的内部标识继续在这个代码上长出更多模块过程中踩的每一个坑最后都会变成你自己的经验资产。