1. 项目整体设计与思路拆解
社区团购系统这个题目,我前前后后接触过三次,第一次是帮一个做生鲜的朋友搭原型,第二次是给一个县城的本地生活团队做重构,第三次是自己完整地从零写了一遍。三次下来最大的感受是:微信小程序的社区团购系统,难点从来不在小程序本身。小程序只是最外面那层壳,真正决定系统能不能跑起来、能不能扛住早高峰的,是壳子背后那张“钱和货的流向图”——谁下单、谁收款、谁分拣、谁分佣、谁负责售后。我见过太多团队一上来就埋头写商品列表页和购物车,写到一半发现订单状态对不上、退款之后佣金没回冲、截单时间和库存扣减打架,然后推倒重来。
这篇文章我打算按我自己的落地顺序讲:先讲清楚整体为什么这么设计,再把核心数据模型和状态机摊开,然后是几个最容易写错的关键逻辑,接着给一份可以照着跑通的实操记录,最后是踩过的坑和排查表。读者对象我预设成两类:一类是接到需求要独立搭一套系统的小团队开发者,一类是想搞清楚这套东西到底怎么运转的产品或运营同学。基础薄弱也没关系,涉及原理的地方我会尽量用生活里的场景打比方。
1.1 为什么入口锁定小程序而不是App或H5
这个选择题其实是业务决定的,不是技术决定的。社区团购的用户画像很固定:一个小区里以家庭采购为主的居民,年龄跨度大,很多是不太愿意折腾手机的中老年用户,外加一批兼职的团长——通常是小区门口的便利店老板、快递驿站经营者或者全职在家带娃的住户。这批人的共同特征是:手机里一定装着微信,但不一定愿意为了一次买菜再装一个几十兆的App。
从获客成本算一笔账就很清楚了。App的下载安装转化率在这个人群里低得可怜,一个注册用户的拉新成本可能要到几十块,而这门生意的单笔毛利经常只有几块钱。H5看起来轻,但它在微信内的支付体验、页面唤起、消息触达都有明显短板,而且很难做出接近原生App的流畅感,商品图一多就卡。小程序的定位刚好卡在中间:免安装、可以从群聊卡片和朋友圈直接点进来、能调起支付、能做订阅消息推送,而且分享到微信群里的体验是原生的。
但小程序也有它硬性的约束,这些约束在设计阶段就得提前想好,不然做到一半会很难受。我整理了一张对比表,是我自己选型时用的:
| 维度 | 小程序 | H5页面 | 原生App |
|---|---|---|---|
| 用户获取成本 | 低,可群内裂变 | 低 | 高,需应用商店分发 |
| 支付体验 | 原生调起,流畅 | 需要跳转,链路长 | 原生,最流畅 |
| 消息触达 | 订阅消息,需用户授权 | 基本没有 | 推送能力强 |
| 主包体积限制 | 2MB,总包20MB | 无 | 无 |
| 长连接能力 | 受限,切后台会断 | 受限 | 完整支持 |
| 审核上线 | 需要提交审核,有周期 | 随时发布 | 需要商店审核 |
主包2MB这条限制对电商类小程序影响最大。商品图、图标、字体全往里塞,很容易爆。我自己的做法是把商品列表、详情、下单、我的订单这四个高频页面放主包,团长端、售后申请、活动专题页全部拆成分包,图片一律走CDN并且用WebP格式加懒加载,图标用字体图标或者SVG雪碧图,能省一大截。
还有一条容易被忽略:小程序不能长时间在后台跑,也做不了真正的长连接。这意味着“下单后库存实时刷新”“到货实时提醒”这类需求,不能指望WebSocket常驻,得靠轮询加订阅消息兜底。我在第一版里天真地用了WebSocket做库存广播,结果用户一切后台连接就断了,重连逻辑写得一团糟,后来直接改成前端定时拉取加本地缓存,反而更稳。
1.2 三类角色与业务闭环拆解
做这类系统,我习惯先不碰代码,先在纸上把角色和闭环画清楚。这套系统里最少有三类角色:买家、团长、平台运营,规模大一点还要加上供应商和分拣配送人员。每类角色对应一套完全不同的界面和权限,混在一起做后期会非常痛苦。
买家的路径是:进小程序 → 看当前在售的团 → 加购物车 → 选提货点 → 支付 → 等通知 → 到点自提。团长的路径是:申请成为团长 → 审核通过 → 绑定提货点 → 分享自己的专属推广码 → 查看自己名下的订单和佣金 → 收货核对 → 通知用户 → 核销。运营的路径是:建商品 → 建团购场次 → 设置截单时间和自提点 → 看订单汇总 → 导出分拣单 → 处理售后 → 结算佣金。
把这些串起来,就是一个完整的闭环:选品开团 → 用户下单支付 → 截单汇总 → 向供应商采购 → 分拣配送到自提点 → 通知用户 → 核销提货 → 佣金结算。这个链条里,最容易出问题的节点我标了三个:截单时刻的并发下单、分拣环节的订单汇总准确性、退款之后的佣金回冲。后面的章节会重点拆这三个。
角色权限我建议从一开始就用一张关联表管起来,而不是在用户表上打一个role字段。原因很简单:同一个人可能既是买家又是团长,团长自己也会下单。用user_role表存多角色,每条记录带审核状态和生效时间,后面加“区域代理”“供应商账号”这类新角色时不用改表结构。
1.3 技术栈选型与部署形态
这套系统的技术栈其实没什么花样,稳定压倒一切。我自己的常用组合是:后端用 Node.js 配 NestJS 框架(团队熟悉 Java 的话 Spring Boot 完全没问题,逻辑是一样的),数据库 MySQL 8,缓存和分布式锁用 Redis,定时任务用 Redis 的延时队列或者简单的调度框架,管理后台用 Vue 或 React 单独部署,图片和静态资源放对象存储加CDN。
为什么这么选,我说三个理由。第一,社区团购的读多写少特征非常明显,商品和团购场次几乎是只读的,真正有并发压力的是下单那一瞬间和截单前十分钟。这种场景下,MySQL 加一层 Redis 缓存完全够用,上更重的中间件是浪费。第二,业务变化极快,运营今天想加“新人首单立减”,明天想加“满三件打九折”,后天想加“团长专属价”,后端如果写得过于僵硬,改一个活动就要发一次版。所以我在设计时会把价格计算抽成一个独立的规则层,活动配置存成 JSON 放在数据库里,运行时解析,这样新增一种优惠类型不用动核心下单逻辑。第三,小团队的运维能力有限,能少一个组件就少一个组件,能不拆微服务就先不拆,单体应用加清晰的分层,撑到日订单几千单完全没问题。
服务器配置这块给个参考:起步阶段两台 4核8G 的云服务器,一台跑应用和定时任务,一台跑 MySQL 和 Redis,对象存储和CDN按量付费。日订单五百单以内这个配置绰绰有余,真正的瓶颈往往在图片加载和支付回调的验签计算上,而不是数据库。等到日订单过两千、截单瞬间有明显卡顿的时候,再考虑把下单链路做读写分离、把商品缓存前置到CDN边缘,那时候再做优化也不迟。过早优化是这类项目里最常见的浪费。
2. 核心数据模型与状态机设计
数据模型这块我想多花点篇幅,因为这套系统里百分之八十的线上事故,追根溯源都是状态和字段没设计好。我见过订单表上用status存一个数字,1到9分别代表什么全靠注释,注释还过期了,最后连写代码的人都说不清3和4的区别。状态机要显式设计,而不是靠约定俗成。
2.1 团购场次的状态机与时间边界
这里要先区分两个概念:商品和团购场次。商品是长期存在的,比如“散养土鸡蛋30枚装”;场次是周期性的,比如“3月11日—3月12日 土鸡蛋专场”。用户实际下单的是场次里的商品,价格、库存、截单时间都挂在场次上。这个拆分非常重要,因为同一个商品在不同场次可以有不同价格、不同限量,而且数据统计天然按场次聚合,运营看报表一目了然。
场次的状态我设计成五个:草稿、预告、进行中、已截单、已结束。状态流转靠定时任务驱动,而不是靠用户访问时判断时间。这一点很多人会踩坑:如果只在下单接口里比较“当前时间是否小于截单时间”,那截单之后没有人访问系统,场次状态就一直是“进行中”,后台报表会全部算错。我的做法是每个场次入库时就算好start_at和end_at,同时写一条延时任务,到点自动触发状态变更。
| 状态 | 触发条件 | 用户可见行为 | 后台可执行操作 |
|---|---|---|---|
| 草稿 | 运营创建后 | 完全不可见 | 编辑全部字段 |
| 预告 | 运营点击发布 | 可见但不能下单,可设置提醒 | 编辑非价格字段 |
| 进行中 | 到达开始时间 | 正常下单支付 | 可追加库存,不可改价 |
| 已截单 | 到达截单时间 | 只能查看,不能再下单 | 导出订单,生成分拣单 |
| 已结束 | 全部提货完成或人工结束 | 订单转为历史记录 | 结算佣金,归档 |
这张表我在项目里贴在墙上,谁改需求先对照一遍。有一个细节值得单独说:截单时间和提货时间必须分开。截单是停止接单的时刻,提货是用户能到自提点拿货的时间,中间隔着采购和分拣的作业窗口。我第一版把这两个混成一个字段,结果凌晨截单之后用户立刻收到“可以提货”的通知,跑到便利店门口发现货还在仓库。后来改成场次上挂三个时间:开始时间、截单时间、预计到货时间,通知逻辑严格按第三个时间触发。
2.2 订单、提货点与团长的关联模型
订单表的核心字段我列一下,这些是踩过坑之后沉淀下来的:订单号(业务单号,不是主键)、用户ID、场次ID、提货点ID、团长ID、商品总额、优惠金额、实付金额、支付状态、履约状态、下单时间、支付时间、核销时间、核销人。
这里有一个设计上的取舍值得展开讲:团长ID要不要冗余存在订单上。规范化的做法是通过提货点关联到团长,不在订单上存团长ID。但我在实际项目里选择了冗余,理由是佣金结算和历史归属。团长是会变动的,某个提货点三月份归A团长,四月份换成B团长,如果订单上不存当时的团长ID,等到四月结算三月的佣金时,数据就全串了。冗余存储牺牲了一点一致性,换来的是账务上的可追溯,这笔买卖我认为很值。同理,实付金额、佣金比例这些易变字段也全部在订单生成时快照下来,绝不在结算时再去查配置表。
提货点表相对简单:名称、详细地址、经纬度、联系人、联系电话、所属团长、营业时间、状态。但有一个字段非常关键——提货点的承载能力。一个便利店门口能堆多少货是有上限的,如果一个提货点挂了两百单生鲜,到货当天必然爆仓。我在提货点表上加了daily_capacity字段,下单时校验当前订单数是否超限,超了就把用户引导到邻近提货点。这个功能上线后,客服投诉量直接掉了一半。
还有一个小设计,提货点二维码。我建议做成带参数的永久码,贴在店门口,用户扫码直接进入这个提货点下的团购列表,省掉一次选择操作。参数里带提货点ID和团长推广码,用户下单后自动归属,团长也不用再手动核对是谁的客户。
2.3 佣金、结算与退款回冲的账务字段
佣金这块是整套系统里最容易出纠纷的地方,我主张按记账的思路来做,而不是简单地算个数。核心是三张表:commission_rule(规则)、commission_record(明细)、settlement_batch(结算批次)。
commission_record的字段包括:团长ID、关联订单ID、订单实付金额、佣金比例快照、佣金金额、状态(待结算/已结算/已冲销)、产生时间、结算时间。关键点是每笔订单生成时就写一条待结算记录,而不是等到结算的时候才批量计算。这样做的好处是每一分钱都有据可查,团长在后台能看到自己每一单赚了多少,信任度完全不一样。等到结算日,只要把待结算的记录按团长聚合成一个批次,标记为已结算,同时生成一条结算流水。
退款的处理是重点。用户申请退款成功后,不能只改订单状态,必须同步处理佣金记录:如果佣金还没结算,直接把记录状态改成已冲销,金额置零但保留记录;如果已经结算打款了,就得生成一条负向的冲销记录,在下一期结算时抵扣。我见过有系统退款后佣金照发,一个月下来损失不小,而且团长自己都不知道。账务系统里,“删”永远不如“冲”,所有变动留痕,这是铁律。
另外提醒一个细节:佣金比例的计算基数应该是实付金额还是商品原价?我建议是实付金额,而且要扣掉运费和平台补贴部分。用原价算的话,一旦做了大额优惠券活动,订单实付可能只有十几块,佣金却按原价算,平台直接亏。这个规则一定要在需求阶段和业务方确认清楚,写进规则表里,别等到结算时才发现。
3. 关键业务逻辑的实现细节
模型搭好之后,真正考验功力的是几条核心链路。这一节我挑四个最容易写错的地方展开,每个都给出我的实现思路和取舍理由。
3.1 截单判定与订单汇总的准确性
截单这件事看起来简单,实际上是整套系统里并发最集中的时刻。经验数据是,一个场次里百分之三十到四十的订单会集中在截单前半小时产生,很多用户就是抱着“最后再看一眼”的心态。这个时间点如果系统卡了,或者出现“截单后还能下单”,运营那边会非常被动。
我的做法是双保险。第一层是接口层校验:下单接口进来第一件事就是查场次状态和end_at,用服务器时间比较,超时直接返回“本场已结束”。第二层是定时任务:到点触发状态变更,同时用一个 Redis 分布式锁保证多实例部署时只有一个节点执行。接口层校验用的是数据库里读出来的时间,不用客户端传的时间,客户端时间可以随便改,这一点必须守住。
汇总环节我单独做了一个“场次级快照表”,截单任务执行时,把所有有效订单按商品聚合,算出每个商品的总数量,写进快照表。分拣人员按照快照表作业,而不是实时查订单表。好处是数据冻结,即使后面有退款,也不会让分拣单在打印过程中不断变化。退款单独走一个差异表,分拣完成后核对一次,多退少补。
这里有个坑我印象很深:有一版系统里,订单状态用了“已支付”和“已确认”两个状态,但支付回调和后台确认两个入口都能改状态,导致同一笔订单被汇总了两次,采购量凭空多了一倍。后来我引入了汇总唯一键,快照表上对(场次ID,商品ID)建唯一索引,并给订单加一个settled标记,重复汇总直接报错拦截。数据一致性这种事,靠自觉不如靠约束。
3.2 支付回调、幂等与超时关单
支付回调是电商系统的老生常谈,但依然是最容易出线上事故的地方。核心原则只有一条:回调可能重复、可能乱序、可能延迟,业务处理必须幂等。
具体做法是:回调进来先验签,验签失败直接丢弃并记录日志;验签通过后,用支付平台的交易单号做唯一键插入一张回调流水表,插入冲突就说明这条回调处理过了,直接返回成功。只有插入成功的请求才进入业务处理,更新订单状态、写入支付时间、触发佣金记录生成。整个流程包在一个数据库事务里,任何一步失败都回滚,让支付平台重试。
这里必须强调一点:给支付平台的响应一定要快。如果你在回调里做了太多耗时操作,比如发短信、生成对账单、调外部接口,很容易超时,支付平台会认为失败然后反复重试,最后形成大量重复回调。我的做法是回调里只做最核心的落库和状态变更,其他动作全部扔进消息队列或者延时任务异步处理。响应体也非常简单,成功就返回约定格式,不要自作聪明加字段。
超时关单是另一个必备机制。用户下单后没付款,库存被占着,不释放的话别人买不了。我的设置是下单即锁库存,十五分钟未支付自动关单并释放。实现上用延时任务,订单创建时写入一条十五分钟后执行的关单任务,任务执行前先查订单是否已支付,已支付就跳过。这里要注意关单和支付回调的竞争:如果恰好在关单任务执行的同时用户完成了支付,必须保证只有一方成功。我在订单表上加了一个版本号字段,两边都带版本号做乐观锁更新,谁先更新成功算谁的,失败方检查最新状态再决定后续动作。这个细节不做,一定会出现“用户付了钱订单却被关掉”的投诉。
3.3 库存扣减的三种方案与取舍
库存这块我试过三种方案,分享下对比。第一种是纯数据库扣减,update stock set num = num - 1 where id = ? and num >= 1,看影响行数判断成功与否。简单直接,但并发高了数据库压力大,而且下单链路里还有别的写操作,容易锁等待。
第二种是 Redis 预扣加异步落库。用 Lua 脚本在 Redis 里做原子判断和扣减,扣成功再异步写数据库。性能很好,但引入了缓存和数据库的一致性问题,一旦 Redis 挂了或者重启丢数据,库存就对不上,得写核对脚本定时校正。
第三种是分段库存,把总库存拆成若干段放到不同的 Redis key 上,请求按用户ID哈希到某一段扣减,减少单 key 热点。适合秒杀场景,对社区团购来说属于过度设计。
我最后选的是第一种加缓存的组合:库存数字放 Redis 做展示和预检,真正扣减走数据库的条件更新,用num >= 1这个条件保证不会超卖。理由是社区团购的并发量级没那么极端,一个场次的爆款也就几百人同时抢,数据库完全扛得住,而简单方案带来的一致性保证远比那点性能提升值钱。真到了扛不住的时候,前面还有排队和限流可以加。
这里有个业务细节要提醒:库存的粒度是商品还是商品加提货点?我建议按场次商品维度设总库存,然后在提货点维度上做可售上限。总库存控采购量,提货点上限控履约能力,两个维度分开管,比混在一起清晰得多。
3.4 到货通知、订阅消息与核销闭环
用户付完钱之后,其实才是履约体验的开始。这段时间如果没消息触达,用户会不断来问“我的货到了吗”,客服压力巨大。小程序在这方面的手段是订阅消息,一次授权只能推一条,所以每一次推送机会都必须用在刀刃上。
我的推送节奏设计成三次:支付成功后推一次“订单已确认”,到货后推一次“您的商品已到提货点”,提货前两小时如果还没核销再推一次提醒。每次推送都要在用户点按钮时引导授权,不能偷偷调,否则会失败。文案尽量短,把提货点名称、地址、营业时间、核销码塞进去,用户看一眼就知道该干嘛。
核销环节我做了两条路径。主路径是用户到店出示核销码,团长在小程序团长端扫码,调核销接口,校验订单状态和提货点是否匹配,成功后更新核销时间和核销人。副路径是团长在后台手动输入订单尾号核销,应对用户手机没电的情况。核销接口必须做幂等,同一笔订单重复核销要返回明确提示而不是报错。
还有一个容易被忽略的点:部分提货。一单买了五件商品,可能有两件缺货,用户先拿走三件。如果系统只支持整单核销,团长只能全部拒收或者全部核销,两种情况都会产生纠纷。我在核销表上按订单明细做了粒度,每件商品单独标记核销状态,订单整体状态根据明细汇总计算,缺货的部分走单独的退款流程。这个设计让售后的复杂度下降了很多,虽然实现上多写了不少代码。
4. 从零跑通一次完整下单:实操记录
理论讲完,这一节给一份可以照着做的实操记录。我用一个最小可运行的场景:用户打开小程序、看到场次商品、下单、支付、收到通知、核销。技术栈按前面说的来,Node.js 配 NestJS,MySQL 加 Redis,代码做了简化,重点展示关键位置的写法。
4.1 工程结构与关键配置
目录我按职责划分,不按技术分层堆在一起:
community-group-buy/ apps/ api/ # 后端主服务 src/ modules/ activity/ # 团购场次 product/ # 商品 order/ # 订单 payment/ # 支付与回调 commission/ # 佣金 pickup/ # 提货点与核销 common/ guards/ # 登录态与角色校验 interceptors/ # 统一响应与日志 tasks/ # 定时任务 admin/ # 运营后台 miniprogram/ # 小程序端 pages/ home/ # 场次列表(主包) detail/ # 商品详情(主包) checkout/ # 下单页(主包) orders/ # 我的订单(主包) packages/ leader/ # 团长端(分包) aftersale/ # 售后(分包)分包这条我特别强调一下,packages/leader和packages/aftersale一定要独立出去,团长端功能多、页面杂,塞主包必爆。主包控制在1.5MB以内留点余量。
环境变量我习惯集中管理,关键项如下:
DB_HOST=127.0.0.1 DB_PORT=3306 DB_NAME=cgb REDIS_HOST=127.0.0.1 REDIS_PORT=6379 MP_APPID=小程序AppID MP_SECRET=小程序密钥 PAY_MCHID=支付商户号 PAY_SERIAL_NO=证书序列号 PAY_PRIVATE_KEY_PATH=./certs/apiclient_key.pem ORDER_EXPIRE_MINUTES=15ORDER_EXPIRE_MINUTES这个值我单独拎出来,因为它直接影响库存周转,业务方经常会调,做成配置比写死强。
4.2 下单接口的核心实现
下单接口是整个系统的心脏,我把它的执行顺序固定成七步,顺序不能乱:
- 校验登录态和用户状态
- 查场次,校验状态为进行中且未过截单时间
- 查商品和场次价格,用服务端价格重新计算金额,绝不信任前端传来的价格
- 校验提货点状态和当日承载量
- 扣减库存
- 生成订单和订单明细,快照佣金比例
- 写入超时关单延时任务,返回支付参数
第3步是安全底线,我见过前端传price=0.01直接下单成功的系统,损失惨重。所有金额计算必须在服务端完成,前端传来的只有商品ID和数量。
async createOrder(userId, dto) { const { activityId, pickupId, items } = dto; // 1. 场次校验 const activity = await this.activityRepo.findOne({ where: { id: activityId }, lock: { mode: 'pessimistic_read' }, }); if (!activity || activity.status !== 'ONGOING') { throw new BizError('本场活动已结束'); } if (new Date() >= activity.endAt) { throw new BizError('已过截单时间'); } // 2. 提货点承载校验 const pickup = await this.pickupRepo.findOne({ where: { id: pickupId } }); if (!pickup || pickup.status !== 'OPEN') { throw new BizError('提货点不可用'); } const todayCount = await this.orderRepo.count({ where: { pickupId, createdAt: MoreThan(startOfToday()) }, }); if (todayCount >= pickup.dailyCapacity) { throw new BizError('该提货点今日已满,请选择其他提货点'); } // 3. 服务端重算金额 let totalAmount = 0; const details = []; for (const item of items) { const sku = await this.skuRepo.findOne({ where: { activityId, productId: item.productId }, }); if (!sku || sku.status !== 'ON') { throw new BizError('商品已下架'); } const amount = sku.activityPrice * item.quantity; totalAmount += amount; details.push({ productId: item.productId, quantity: item.quantity, priceSnapshot: sku.activityPrice, amount, }); } // 4. 事务内扣库存 + 建单 return this.dataSource.transaction(async (manager) => { for (const item of items) { const affected = await manager.query( 'UPDATE activity_sku SET stock = stock - ? WHERE activity_id = ? AND product_id = ? AND stock >= ?', [item.quantity, activityId, item.productId, item.quantity], ); if (affected.affectedRows === 0) { throw new BizError('库存不足'); } } const orderNo = this.genOrderNo(userId); const commissionRate = await this.getCommissionRate(pickup.leaderId); const order = manager.create(Order, { orderNo, userId, activityId, pickupId, leaderId: pickup.leaderId, totalAmount, payAmount: totalAmount, commissionRate, payStatus: 'UNPAID', fulfillStatus: 'WAIT_PAY', }); await manager.save(order); await manager.save(details.map((d) => ({ ...d, orderId: order.id }))); // 5. 延时关单任务 await this.taskQueue.add( 'close-order', { orderId: order.id }, { delay: this.config.get('ORDER_EXPIRE_MINUTES') * 60 * 1000 }, ); return order; }); }这段代码里有两个地方值得单独说。一是库存扣减用了带条件的 UPDATE,stock >= ?这个条件保证了不会扣成负数,比先查再改安全得多。二是悲观读锁我只加在了场次查询上,没有加在库存上,因为库存那一步已经有条件更新兜底了,再加锁反而增加死锁概率。锁加在哪里,永远要问一句:不加会出什么问题。
4.3 支付回调的幂等处理
回调这块我用一张独立的pay_callback_log表做幂等,表上对transaction_id建唯一索引。逻辑很直白:
async handlePayCallback(rawBody, headers) { // 1. 验签 const verified = await this.payClient.verifySignature(rawBody, headers); if (!verified) { this.logger.warn('pay callback verify failed'); return { code: 'FAIL' }; } const payload = this.payClient.decryptResource(rawBody); const { out_trade_no: orderNo, transaction_id } = payload; // 2. 幂等落库,靠唯一索引拦截重复 try { await this.callbackLogRepo.insert({ transactionId: transaction_id, orderNo, raw: JSON.stringify(payload), createdAt: new Date(), }); } catch (e) { if (isDuplicateKey(e)) { return { code: 'SUCCESS' }; // 已处理过,直接应答成功 } throw e; } // 3. 事务内更新订单 await this.dataSource.transaction(async (manager) => { const order = await manager.findOne(Order, { where: { orderNo }, lock: { mode: 'pessimistic_write' }, }); if (!order) throw new BizError('订单不存在'); if (order.payStatus === 'PAID') return; await manager.update(Order, order.id, { payStatus: 'PAID', payTime: new Date(payload.success_time), transactionId: transaction_id, fulfillStatus: 'WAIT_DELIVER', }); // 4. 生成佣金待结算记录(只写记录,不结算) await manager.insert(CommissionRecord, { orderId: order.id, leaderId: order.leaderId, baseAmount: order.payAmount, rate: order.commissionRate, amount: Math.round(order.payAmount * order.commissionRate), status: 'PENDING', }); }); // 5. 通知走异步,不阻塞应答 await this.notifyQueue.add('order-paid', { orderNo }); return { code: 'SUCCESS' }; }应答必须是秒回,所有非核心动作全部异步。这一点我在生产环境验证过:早期版本在回调里同步发短信,短信服务商偶发超时,导致回调响应超过五秒,支付平台判定失败连续重试,一天之内产生了上百条重复回调,排查了很久才定位到。从那以后我把回调里所有外部调用全部移出去了。
4.4 并发压测与容量结论
功能跑通之后我做了一轮压测,场景是模拟截单前的高并发下单。用压测工具起了两百个并发用户,持续打同一个热门商品的库存,观察超卖和响应时间。
结果分两轮。第一轮没做任何优化,平均响应时间在三百毫秒左右,P99 到了一点二秒,出现了三笔超卖。排查发现超卖来自另一个入口——后台手动调整库存的接口没有走条件更新,直接set stock = ?,和用户下单并发时互相覆盖。加上条件更新和行锁之后,第二轮再压,两百并发下零超卖,P99 稳定在四百毫秒以内。
这个结果说明一件事:社区团购的量级下,MySQL 的条件更新完全够用,超卖几乎都是因为存在绕过校验的第二条写路径。所以我把所有能改库存的入口清点了一遍,统一收敛到一个服务方法里,任何一个入口想改库存都必须走它。这个“单一写入口”的约束,比任何复杂的并发方案都有效。
5. 常见问题与排查技巧实录
跑过几套系统,问题清单攒了不少,挑最高频的整理成速查表,出了事按表排查,能省很多时间。
5.1 支付与订单类问题速查
| 现象 | 高概率原因 | 排查动作 |
|---|---|---|
| 用户已付款但订单显示未支付 | 回调未收到或验签失败 | 查回调流水表是否有该交易单号,查服务端日志验签结果 |
| 同一订单被重复发货 | 回调重复处理,幂等失效 | 检查 callback_log 唯一索引是否生效,检查订单更新是否带状态条件 |
| 用户付了钱订单被关闭 | 关单任务与回调竞争 | 查订单版本号更新记录,确认关单前是否复检支付状态 |
| 支付时提示商户参数错误 | 签名证书或序列号配置错误 | 核对证书路径、序列号、商户号三项,重点看换行符 |
| 部分用户支付成功但库存未扣 | 支付与扣库存顺序设计错误 | 确认扣库存发生在下单而非支付回调 |
| 退款成功但佣金照发 | 退款流程未联动佣金记录 | 查 commission_record 是否有冲销记录 |
表格之外我补充一条经验:支付相关的日志一定要记全。请求原文、验签结果、解密后内容、处理结果,四样都要留,而且要有交易单号可检索。出问题的时候,能靠一个单号把整条链路拉出来,排查时间能从小时级降到分钟级。日志别怕多,电商系统的日志量在这个体量下根本不算什么。
5.2 性能与数据一致性的坑
坑一:商品列表接口慢。一百多个商品带图,一次返回几兆,小程序那边直接卡住。解决办法是列表接口只返回必要字段,图片给缩略图地址,分页加载,翻页用游标而不是 offset,因为 offset 在数据量大时会越翻越慢。
坑二:场次列表每次都查数据库。首页的场次列表是访问量最大的接口,几乎不变,完全可以缓存。我把进行中的场次缓存五分钟,运营修改场次时主动失效缓存,首页响应从两百毫秒降到二十毫秒。
坑三:定时任务重复执行。多实例部署时,每个实例都跑定时任务,同一批订单被汇总了多次。加分布式锁,锁的 key 用任务名加日期,过期时间略大于任务预期执行时间,并且锁的持有者要做续期,防止任务没跑完锁就过期了。
坑四:金额用浮点数。这个不用多解释,所有金额一律用整数存分,前端展示时除以一百。小数运算的误差在账务系统里是致命的,一分钱的差异能让人对账对到半夜。
坑五:软删除和统计打架。商品下架用了软删除,但统计脚本忘了加删除条件,导致销量报表比实际多了一截。所有统计查询都要显式带上状态条件,别指望默认行为。
5.3 运营与团长侧的实战心得
技术问题好解,运营侧的坑更磨人。分享几条我认为最有价值的:
第一条,一定要给团长做一个能看明白的数据页面。团长最关心三件事:今天多少单、能赚多少钱、什么时候到账。这三个数字做成首屏大字,佣金明细可以点开看每一单。我做过对比,团长端有这个页面之后,来问“我佣金怎么算的”的消息减少了八成以上。信任是留住宿主的核心,而信任来自透明。
第二条,提货点的订单量要可视化。后台给每个提货点画一个当日订单数的进度条,接近承载上限就变色提醒运营。运营看到预警可以提前调配,或者在下单页面上把该提货点排到后面。这个功能看起来不起眼,但真正解决了到货当天爆仓的问题。
第三条,售后流程不要设计得太复杂。生鲜类目的售后率天然高,叶子菜蔫了、鸡蛋破了都很常见。我的做法是给一个金额门槛,低于门槛的售后申请由团长直接审批通过,不用走平台客服。团长最了解实际情况,让他这个一线的人做决定,效率远高于层层审批。当然,团长审批的操作要留痕,每月抽查,防止滥用。
第四条,退款不要和订单状态耦合太死。一个订单可能部分退款,如果订单上只有一个“已退款”状态,那部分退款的订单就不知道该怎么标。我的做法是订单状态和退款状态分成两个字段,退款单独建表,一个订单可以有多条退款记录,订单的实付金额根据退款记录动态计算。这样无论退几次、退多少,数据都是准的。
6. 我在这套系统上的一点个人体会
写了这么多,最后说几句掏心窝的话。这套系统做了几遍下来,我最大的转变是:不再把小程序当成一个“前端项目”来做。它表面上是几个页面,实质是一整套履约链路在移动端的出口,链路上任何一个环节没想清楚,前端做得再漂亮也会被用户骂。
如果要给准备动手的人一个建议,我会说:先把那张角色和闭环的图在纸上画完,把订单状态机的每个状态和每条边都写清楚,然后再写第一行代码。这张纸上花的两天时间,能省掉后面两个月的返工。我自己第二次做的时候就是跳过了这一步,直接照着上一版抄,结果业务模式变了——上一版是次日自提,这一版要做当日达,整个时间模型全变了,返工得非常狼狈。
还有一个小技巧分享给同行:把提货点的二维码做成带参数的永久码,同时把团长推广码和提货点ID编进参数里,用户扫码进来之后,后续下单会自动归属,团长完全不用手动核对归属关系。这个改动实现起来不到半天,但把团长每天最烦的一件事——翻记录对单子——彻底解决了。有时候系统的价值不在于功能多,而在于把某个反复出现的摩擦点真的抹平了。