做社区团购这类项目,最容易被低估的不是前端页面,而是“一批货、一个团长、一天截单”这套节奏背后的数据一致性。我见过不少团队,小程序端做得挺漂亮,商品列表、购物车、下单流程一应俱全,结果第一次开团就翻了车:库存扣成负数、订单状态卡在“待成团”、团长拿不到自己的佣金明细。问题不在写代码的能力,而在于把社区团购当成了普通电商来建模。这篇内容围绕微信小程序的社区团购系统展开,讲清楚业务形态怎么倒推架构、五张核心表怎么定、下单链路上哪三处最容易出事、团长端和履约环节又该怎么落地。适合正在做或准备做这类系统的开发者,也适合已经上线但被超卖、对账、核销这些问题折腾过的同行参考。
1. 社区团购不是“给超市套个小程序壳”,业务节奏决定架构边界
1.1 三个角色、一条主链路,先把业务画成一条线
社区团购的业务角色非常固定:平台方负责选品、采购、定价和履约调度;团长负责在自己所在的小区或社群里发起分享、汇总需求、收货分发;用户则是同一小区里的居民,看到团长分享的链接进来下单。
一条完整的主链路是这样的:平台在后台创建团次(也可以叫“开团”,本质是一个有明确起止时间的售卖活动),把若干个 SKU 挂到这个团次上并设定团购价;团长把团次分享到群里,用户点进来挑选、下单、支付;到了截单时间,系统关团,平台按团次汇总所有订单生成采购单;货到仓库后按自提点分拣打包,配送到各个自提点;用户收到通知后到自己选择的提货点,出示核销码取货;最后平台按订单明细给团长结算佣金。
这条链路上有三个时间点是强约束:开团时间、截单时间、自提截止时间。它们不是随便填的展示字段,而是会直接驱动状态机和定时任务的核心参数。我见过有团队把截单时间做成一个纯展示的文案,结果用户在下单页看到“还剩 2 小时”,后端却根本没有关团逻辑,第二天采购单汇总出来一堆重复数据。
所以动手写代码之前,先把这条链路在纸上画一遍,标清楚每个环节谁触发、状态怎么变、失败了怎么回滚。这一步花半天时间,后面能省掉两周的返工。
1.2 为什么“预售 + 次日自提”能省钱,以及它省掉的是哪部分复杂度
传统生鲜电商最难的是“即时履约”:用户下单后要在很短时间内拣货、打包、配送上门,这对仓储和运力的要求极高,损耗也大。社区团购换了个思路——先收单、再采购、集中配送、用户自提。
这个模式把复杂度从“实时配送网络”转移到了“批次化调度”上。平台不需要养配送团队挨家挨户送,只需要把同一自提点的货打成一个大包,一次送到位。团长承担了最后一百米的分散工作。
但从系统角度看,复杂度并没有消失,只是换了个地方:
| 维度 | 传统即时电商 | 社区团购 |
|---|---|---|
| 库存逻辑 | 实时库存,随时可能被抢 | 按团次预售,允许先卖后采 |
| 履约单位 | 单个订单 | 团次 + 自提点 |
| 关键时间点 | 承诺送达时间 | 截单时间、到货时间、自提截止时间 |
| 状态流转 | 相对线性 | 多分支,存在“成团失败”“缺货退款” |
| 对账口径 | 单笔订单 | 团次汇总 + 团长佣金 + 供应商货款 |
看清楚这张表就能明白,社区团购系统的技术重心不在页面,而在批量状态的正确流转和多口径的资金核算。如果你的团队前端人手多、后端薄弱,那我建议先把后端的状态机和汇总任务写扎实,前端页面反而是最容易补的。
1.3 第一版该砍掉哪些功能,才能一个月内跑起来
我参与过的项目里,最容易失控的是“什么都想做”。第一版就上多商家入驻、复杂优惠券叠加、积分体系、直播带货,最后哪一块都是半成品。
合理的 MVP 范围建议是:
- 商品维度只做“团次 SKU”,不做复杂的规格组合(比如颜色尺码矩阵),生鲜类目其实用不上;
- 促销只保留“团购价”一种,最多加一个新人首单立减,别碰满减叠加;
- 履约只做“到点自提”,不做配送上门;
- 结算只做“按明细比例抽佣”,不做阶梯佣金和团队分红;
- 团长端先做成小程序里的一个隐藏入口,用权限字段控制,不做独立 App。
砍掉这些不是为了省事,而是因为每一项都会引入新的状态维度,而状态维度是成倍增长的。你多一个“拼团失败自动退款”的分支,就要多考虑退款时机、库存回补、佣金回滚、通知发送四件事。第一版跑通主链路,比什么都重要。
2. 库表设计:一张订单表撑不起团购,先把这五类实体定下来
2.1 团次和 SKU 必须解耦,否则价格永远改不干净
新手最常见的建模方式是把商品表直接当成售卖单元,加一个price字段和一个end_time字段。听起来省事,但跑两周就会崩:同一个商品这一期卖 9.9,下一期卖 12.8,历史订单要按哪个价格算?佣金按哪个价格抽?
正确做法是三层结构:商品(product)→ 团次 SKU(activity_sku)→ 订单明细(order_item)。商品表只存描述性信息(名称、图片、类目、单位、供应商),价格和可售数量挂在团次 SKU 上,订单明细里冗余存下单那一刻的名称、单价、数量快照。
CREATE TABLE activity ( id BIGINT PRIMARY KEY AUTO_INCREMENT, title VARCHAR(64) NOT NULL, station_id BIGINT NOT NULL COMMENT '所属自提点,0 表示全平台', start_time DATETIME NOT NULL, cut_off_time DATETIME NOT NULL COMMENT '截单时间', arrive_time DATETIME NULL COMMENT '预计到货时间', status TINYINT NOT NULL DEFAULT 0 COMMENT '0草稿 1进行中 2已截单 3已到货 4已结束 5已取消', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_station_status (station_id, status), KEY idx_cutoff (cut_off_time, status) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE activity_sku ( id BIGINT PRIMARY KEY AUTO_INCREMENT, activity_id BIGINT NOT NULL, product_id BIGINT NOT NULL, sale_price INT NOT NULL COMMENT '单位:分', origin_price INT NOT NULL COMMENT '划线价,单位:分', total_stock INT NOT NULL, sold_count INT NOT NULL DEFAULT 0, limit_per_user INT NOT NULL DEFAULT 0 COMMENT '0 表示不限购', UNIQUE KEY uk_activity_product (activity_id, product_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;有个细节要强调:金额一律用整数分存储。用 DECIMAL 也行,但千万别用 FLOAT,浮点数在累加佣金的时候会飘,几千条明细累加出来差几分钱,对账的时候够你查一下午。这个坑我在早期项目里踩过,后来所有涉及钱的字段全部改成INT存分,前端展示时再除以 100。
2.2 团长、自提点、佣金账户是三件事,不能压成一张表
很多系统把“团长”和“自提点”合成一条记录,因为大多数情况下一个团长对应一个自提点。但只要出现“一个团长管两个小区”或者“团长换人、提货点不变”的情况,这套模型就废了。更麻烦的是历史订单的归属关系会跟着团长变动而漂移。
所以我一般拆成三张:
station(自提点):小区名、详细地址、经纬度、营业时间段;leader(团长):姓名、手机号、绑定用户 ID、状态;leader_station(绑定关系):leader_id、station_id、生效时间、失效时间。
团长离职或者换点位时,只改绑定关系的失效时间,历史订单依然指向当时的 station_id 和 leader_id,不会错乱。
佣金账户单独抽一张commission_account,记录每个团长的累计佣金、冻结金额、可提现金额、已提现金额。这四个数字必须能相互校验:累计 = 冻结 + 可提现 + 已提现 + 已失效。上线前写个定时任务每天跑一次校验,对不上就报警。这个习惯能帮你提前发现问题,而不是等到团长来问“我这个月怎么少了三十块”。
2.3 订单和订单明细拆开,是为了让聚合查询不痛苦
订单主表存总额、支付状态、自提点、团长、核销码这些“一个订单一份”的信息;订单明细存具体买了什么,价格、数量、佣金比例都在明细上。
为什么要这样拆?因为分拣单是按自提点聚合的,需要按明细统计“这个站点一共有多少份土豆”;佣金结算是按明细的类目比例算的;退款可能是部分退,只退其中一件。这些操作都落在明细粒度上,主表只负责展示和状态流转。
CREATE TABLE `order` ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL, user_id BIGINT NOT NULL, activity_id BIGINT NOT NULL, station_id BIGINT NOT NULL, leader_id BIGINT NOT NULL, total_amount INT NOT NULL COMMENT '单位:分', pay_amount INT NOT NULL COMMENT '实付,单位:分', status TINYINT NOT NULL DEFAULT 0 COMMENT '0待支付 1已支付 2已核销 3已取消 4退款中 5已退款', pickup_code VARCHAR(8) NOT NULL COMMENT '核销码', paid_at DATETIME NULL, verified_at DATETIME NULL, UNIQUE KEY uk_order_no (order_no), UNIQUE KEY uk_pickup_code (pickup_code), KEY idx_activity_status (activity_id, status), KEY idx_station_status (station_id, status) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;pickup_code上加唯一索引这件事,看着不起眼,实际上是防止重复核销的第一道锁。因为核销接口一定会有并发和重复点击,靠代码判断“已核销就返回”存在竞态窗口,让数据库来兜底最稳。
2.4 库存字段放哪,决定你后面会不会被超卖折磨
库存有两种放法:一种是放在activity_sku.total_stock和sold_count上,用条件更新扣减;另一种是单独建一张stock表。我倾向第一种,因为库存本身就是团次 SKU 的属性,拆出去只会多一次关联查询。
关键是扣减语句必须带条件:
UPDATE activity_sku SET sold_count = sold_count + #{num} WHERE id = #{skuId} AND sold_count + #{num} <= total_stock;受影响行数为 0 就说明库存不足,直接回滚整个事务。这一句比任何应用层的“先查再判断”都可靠,因为判断和更新是一个原子操作。我见过用 Redis 预扣再异步落库的方案,在秒杀场景确实更快,但社区团购的并发量根本不需要——一个小区几百人,峰值 QPS 能到几十就算高了,用数据库行锁完全扛得住,还省了缓存和数据库不一致的心智负担。
3. 小程序端的骨架:分包、登录态与首屏速度
3.1 目录结构从第一天就按分包规划,别等打包超限再拆
小程序主包有大小限制,业务页面一多就会顶到天花板。社区团购系统的页面结构其实很清晰:首页(团次列表)、分类/搜索、订单列表、我的,这四个是 tabBar 页面,必须放主包;商品详情、下单页、支付结果、团长端、佣金明细、核销页,这些都可以塞进分包。
/pages 主包 /index 首页:当前团次 + 推荐 /category 分类 /orders 我的订单 /mine 我的 /package-goods 分包:商品相关 /detail 商品详情 /confirm 下单确认 /package-leader 分包:团长端 /dashboard 团长首页 /verify 核销 /commission 佣金明细分包预下载也值得配上,在首页onLoad之后预下载团长端和商品分包,用户点进去时几乎无感。这个配置很多人不知道,其实就是app.json里几行代码:
{ "preloadRule": { "pages/index/index": { "network": "all", "packages": ["package-goods"] } } }主包里尽量别放图片资源。商品图走远端 CDN,图标能用字体图标或 SVG 就别用 PNG,一张 200KB 的图就能把主包撑爆。这个规则听着琐碎,但真到打包报错那天,改起来是很烦的。
3.2 登录态:静默换取会话,别把敏感信息塞进 token
小程序登录的标准流程是:前端调用登录接口拿到临时 code,传给自己的服务端;服务端用 code 换取用户唯一标识和会话密钥,然后签发自己的业务 token 返回给前端。
这里有两个实践要点。第一,会话密钥绝对不能下发到前端,它只能留在服务端,用来解密手机号等敏感数据。第二,业务 token 里只放用户 ID、角色、过期时间这类非敏感信息,别把手机号、地址塞进去,token 是会被本地缓存的。
// 前端:静默登录 + token 续期 const login = async () => { const { code } = await wx.login(); const res = await request('/api/auth/login', { method: 'POST', data: { code } }); wx.setStorageSync('token', res.token); return res.token; }; // 请求拦截:token 失效时自动重登一次 const request = async (url, options = {}, retry = true) => { const token = wx.getStorageSync('token'); const res = await wx.request({ url: BASE_URL + url, header: { Authorization: `Bearer ${token}` }, ...options }); if (res.statusCode === 401 && retry) { await login(); return request(url, options, false); } return res.data; };重试只做一次很重要。做多了会在服务端异常时形成循环请求,把接口压垮。另外,token 有效期建议设 7 天,配合“每次访问滑动续期”,用户基本感知不到登录这件事。
3.3 首屏 1.5 秒,靠的是减少串行而不是优化代码
用户点开小程序,最怕看到白屏转圈。首屏快的核心不是把代码写得多精简,而是别串行发请求。
我一般的做法是:首页进入后立刻并行发起三个请求——当前团次信息、推荐商品列表、用户未读通知数。这三个接口互不依赖,用Promise.all并发,总耗时等于最慢的那个,而不是三个相加。
图片是另一个大头。商品列表图必须用 CDN 的缩略图参数,按屏幕宽度取合适尺寸,别直接加载原图。列表滚动时开启懒加载,屏幕外的图片不加载。骨架屏也可以用上,虽然不减少真实加载时间,但用户感知会好很多。
还有一个容易被忽略的点:首页的团次信息要带缓存。同一个小区所有用户看到的是同一个团次,这个数据完全可以缓存 30 秒。团购场景对实时性要求没那么高,多缓存几秒换来的是接口压力下降一个数量级。
3.4 自提点定位:权限拿不到的时候,降级方案决定转化率
选择自提点是下单前的必经步骤。理想情况是调用定位接口拿到经纬度,服务端算距离返回最近的自提点。但现实中相当一部分用户会拒绝授权,或者系统定位精度很差(在室内尤其明显)。
所以必须有降级:定位失败时,展示“常用自提点列表 + 手动搜索”。用过的自提点存在本地,下次进来直接默认选中。这个降级链路一定要在真机上测,模拟器的定位永远是准的,测不出问题。
还有个细节:自提点选择要能记住。用户第二次下单不该再选一遍。我一般把最近一次选择写进本地存储,同时服务端也记一份,换设备时能以服务端的为准。这种小事做不好,用户下单意愿会明显下降。
4. 下单链路里最容易出事的三处:库存、成团、支付回调
4.1 库存扣减放在支付前还是支付后,这是道必答题
这个问题没有绝对答案,但取舍逻辑要清楚。
下单即扣库存:用户提交订单就先把库存占住,未支付超时再释放。好处是不会超卖,坏处是恶意占库存——有人下一堆订单不付款,把货锁死。
支付成功才扣库存:好处是库存不会被无效订单占用,坏处是支付回调那一刻可能库存已经不够,需要走退款。
社区团购的实际场景里,我倾向于下单即扣、超时释放,配合每个用户的限购数量。因为这个场景的用户是同一小区的邻居,恶意占库存的概率极低,而“付了钱结果没货”的体验伤害更大。限购字段limit_per_user就是为这个准备的,热销品设成 2 到 3 份,基本能挡住异常下单。
超时释放用延迟任务实现,把订单号丢进延迟队列,30 分钟后检查状态,仍未支付就取消订单并回补库存。回补库存同样要用带条件的更新语句,防止把已售出的量补多。
UPDATE activity_sku SET sold_count = sold_count - #{num} WHERE id = #{skuId} AND sold_count >= #{num};4.2 成团状态机:分支不多,但每一条都要能回滚
社区团购的“团”有两层含义,容易混淆。一层是团次,平台开的售卖活动,到点就截单;另一层是用户发起的拼团,凑够人数才算成功。很多系统其实只做前者,不做后者——团长分享的是团次链接,不存在“人不够就失败”的逻辑。
如果你确实要做拼团,状态机就必须画清楚:
| 状态 | 触发条件 | 可流转到 | 副作用 |
|---|---|---|---|
| 待成团 | 用户开团或参团 | 已成团、已失败 | 占用库存、冻结名额 |
| 已成团 | 人数达标 | 待发货、已退款 | 停止招募、生成履约单 |
| 已失败 | 到期人数不足 | 已退款 | 回补库存、发起退款、回滚佣金 |
| 已退款 | 退款完成 | 终态 | 通知用户 |
关键在“已失败”这一条:库存回补、退款发起、佣金回滚、消息通知,这四件事必须在一个可重试的流程里,任何一步失败都要能单独补偿。我一般把它做成一张task_log表,每次状态变更写一条待办,由定时任务扫描未完成的记录重试。别指望一次事务全部搞定,退款这种跨系统的操作注定要异步。
4.3 支付回调:不做幂等,早晚会重复发货
支付平台的回调是“至少一次”投递,也就是说同一个订单的回调可能来好几次。如果回调处理里直接写“把订单改成已支付”,重复回调不会有大问题;但如果里面还有“扣库存”“加佣金”“发通知”,那就会重复执行。
标准做法是三步:
- 用订单号加唯一索引写一条回调流水,插入失败说明是重复回调,直接返回成功;
- 校验金额是否与订单实付一致,不一致记录异常并告警,不做业务处理;
- 用状态机做前置判断,只有“待支付”状态才允许流转到“已支付”。
async function handlePayCallback(payload) { const { outTradeNo, totalFee, tradeNo } = payload; const inserted = await db.insertIgnore('pay_callback_log', { order_no: outTradeNo, trade_no: tradeNo, amount: totalFee }); if (!inserted) return { code: 'SUCCESS' }; const order = await db.order.findByNo(outTradeNo); if (!order || order.pay_amount !== totalFee) { await alert('金额不一致', { outTradeNo, totalFee }); return { code: 'SUCCESS' }; } const affected = await db.order.updateStatus(outTradeNo, 0, 1, { paid_at: new Date() }); if (affected === 0) return { code: 'SUCCESS' }; await Promise.all([ pushToDelayQueue('order-paid', outTradeNo), notifyLeader(order.leader_id, outTradeNo) ]); return { code: 'SUCCESS' }; }注意最后返回给支付平台的一定是成功,哪怕内部处理出了问题。因为你返回失败,平台会一直重试,把问题放大。内部异常走自己的告警和重试队列,跟回调应答解耦。
5. 团长端与佣金结算:账算错一次,团长就跑了
5.1 团长端不是“管理员后台”,功能要按场景裁剪
团长在手机上操作,场景是碎片化的:可能在菜市场称重,可能在楼道里分发,手指上还沾着水。所以团长端的功能必须极简,一屏能完成一件事。
核心就三个页面:今日待办(今天到货多少单、还有多少未核销)、核销(扫码或输码,一键确认)、佣金(本月预估、已结算、可提现)。
核销页的交互要特别设计。输入框自动聚焦、支持扫码、核销成功后震动反馈加大字号提示,这些细节决定了团长会不会用你的系统。我见过一个系统核销成功只有一个很小的绿色勾,团长在嘈杂环境里根本看不清,结果同一单核销了三次。
5.2 佣金按明细算,不按订单总额算
佣金计算最容易踩的坑是“一刀切按订单总额乘比例”。实际上不同类目的毛利差别很大,蔬菜和冻品的佣金比例可能差一倍。所以比例字段要挂在订单明细上,下单时把当时的佣金比例快照进去,后续调整比例不影响历史订单。
CREATE TABLE order_item ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_id BIGINT NOT NULL, sku_id BIGINT NOT NULL, product_name VARCHAR(128) NOT NULL COMMENT '下单快照', unit_price INT NOT NULL COMMENT '单位:分', quantity INT NOT NULL, commission_rate INT NOT NULL COMMENT '万分比,如 500 表示 5%', commission_amt INT NOT NULL COMMENT '单位:分' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;佣金状态流转分四步:冻结(订单支付后)→可提现(订单核销后,过售后期)→提现中→已提现。为什么要等核销之后才转可提现?因为用户可能退款,退款了佣金要能回滚。冻结状态就是给这个回滚留的缓冲。
有个经验:佣金明细一定要给团长看得到每一笔的来源。只显示一个月度总额,团长一定会来问“这个数怎么来的”。把订单号、商品名、金额、比例列出来,争议立刻少一大半。
5.3 资金结算的合规边界,技术方案要提前对齐
这部分属于业务合规范畴,但技术方案必须提前想清楚。平台代收货款再转给团长,涉及资金归集的问题,不是随便写个转账接口就完事。
比较稳妥的做法是:货款直接进入平台商户账户,佣金作为平台支付给团长的服务费单独结算,走平台提供的分账能力或者对公/对私转账,每一笔都有对应的结算单和凭证。系统里要保留完整的资金流水,包括结算批次号、结算金额、结算时间、凭证号。
技术上要做的是:提现申请走审批流,不是团长点一下就自动打款;每次结算生成一条不可修改的结算记录;对账任务每天比对“应结佣金总额”和“实际打款总额”,差异超过阈值就告警。这几条看起来是财务的事,实际上是系统设计的一部分,后期再补非常痛苦。
6. 履约环节:截单、分拣单、核销,一步错步步错
6.1 截单任务必须有锁,别让定时任务跑两遍
截单是整个履约流程的起点。定时任务每分钟扫一次“到了截单时间但状态还是进行中”的团次,把它置为已截单,并触发汇总。
问题在于,如果服务部署了多个实例,或者任务执行时间超过了调度间隔,同一个团次可能被处理两次,结果是采购单翻倍。解决办法很简单:用数据库的乐观更新来抢锁。
UPDATE activity SET status = 2, cut_off_at = NOW() WHERE id = #{id} AND status = 1 AND cut_off_time <= NOW();受影响行数为 1 的实例才继续往下走,其他实例直接跳过。这个模式比引入分布式锁简单得多,而且不依赖额外的中间件。所有定时任务我都建议这么写,包括超时取消订单、佣金解冻、对账。
6.2 分拣单的聚合维度,决定了仓库打包要花几个小时
分拣单是给仓库用的,格式直接决定效率。最没用的格式是按订单列——仓库要一个个订单去捡,等于没聚合。正确的是按自提点 + 商品聚合:这个站点土豆总共 38 份、西红柿 22 份,仓库一次性称重打包,贴上站点标签就完事。
生成分拣单的时候要注意单位的处理。商品可能有“份”“斤”“盒”不同单位,不能简单相加。所以分拣单要分两层:一层是商品汇总(多少份),一层是订单明细对照表(哪个订单要几份),后者用于分装时核对。
导出格式建议同时提供 Excel 和打印视图。仓库的老师傅不一定用电脑,打印出来勾选更实际。这个需求听着土,但真的很影响落地。
6.3 核销码的设计:短、唯一、能离线兜底
核销码一般用 6 到 8 位数字,去掉容易混淆的 0 和 O、1 和 I。生成时用随机数加唯一索引兜底,冲突就重试。
核销接口的逻辑是:根据核销码查订单,判断状态是否为“已支付”,是则更新为“已核销”并记录核销人和时间。这里同样要用条件更新防止并发:
UPDATE `order` SET status = 2, verified_at = NOW(), verified_by = #{leaderId} WHERE pickup_code = #{code} AND status = 1;受影响行数为 0 时要区分情况返回:订单不存在、已核销、已退款,给出不同的提示文案。团长最怕的是点了没反应,不知道是网络问题还是码错了。
还要考虑离线兜底。有些自提点在地下车库,信号很差。可以让团长端缓存当天该站点的订单列表(订单号 + 核销码 + 商品简要),核销时先在本地标记,联网后批量同步。这个功能不复杂,但能救急。
6.4 到货通知:订阅消息的授权要在下单时收集
小程序的消息通知需要用订阅消息,而且是一次性订阅——用户授权一次,你只能发一条。这意味着你不能等到货到了才去请求授权,那时候用户早就退出小程序了。
正确的做法是:下单成功页弹一次订阅授权,文案写清楚“到货提醒”,用户同意后把授权记录存下来,等货到了再发。可以同时请求多个模板(下单成功、到货提醒、退款通知),一次弹窗收集。
如果用户拒绝了,就只能靠短信或者团长在群里喊。所以系统里最好留一个“未订阅用户”的标记,到货时对这些用户走短信通道,虽然成本高一点,但总比用户忘了来取货、货烂在站点强。
7. 上线前后踩过的坑,都是花钱买来的
7.1 页面栈和分享参数,两个看起来很小的坑
小程序页面栈有层数限制,超过之后跳转就失效。社区团购的典型路径是:首页 → 团次详情 → 商品详情 → 下单 → 支付结果 → 订单详情,一不小心就叠满了。解决办法是跳转时用重定向替代普通跳转,把不需要回退的中间页替换掉。
分享参数也有坑。通过分享卡片进入时,参数是编码过的,需要解码;而且参数长度有限制,不能把整个对象序列化塞进去。正确的做法是只传关键 ID(团次 ID、团长 ID、渠道标识),其他信息进页面后请求接口拿。
onLoad(options) { // 分享进入时参数在 scene 里,需要解码 const scene = options.scene ? decodeURIComponent(options.scene) : ''; const params = scene ? Object.fromEntries(new URLSearchParams(scene)) : options; this.setData({ activityId: Number(params.aid) || 0 }); }渠道标识很重要,它决定了你能知道哪个团长带来的订单。这个字段从分享链接一路带到订单表,后面算业绩、做激励全靠它。
7.2 海报生成:跨域、圆角、保存授权,一个都不能少
团长分享用得最多的其实是海报,因为图片在群里比小程序卡片更醒目。生成海报的流程是:拉商品图 → 画到画布 → 叠加二维码和文字 → 导出图片 → 保存到相册。
三个高频问题:第一,网络图片必须先下载成本地临时文件才能画进画布,直接画会失败;第二,画布要用新版接口,旧接口在部分机型上渲染异常;第三,保存到相册需要用户授权,拒绝后要引导去设置页开启。
还有个体验细节:海报生成需要一两秒,一定要有 loading 提示,别让团长以为卡死了猛点。生成失败要有重试按钮,并且把失败原因用大白话提示出来,比如“图片加载失败,请检查网络”,而不是抛一个错误码。
7.3 审核相关的准备,上线前两周就该开始
社区团购类小程序涉及商品售卖和支付,审核时需要相应的经营资质和类目。这些材料的准备周期不确定,建议在开发中期就同步启动,别等代码写完才发现类目没下来。
页面内容上也有几个注意点:商品详情页不能出现诱导分享的文案;价格标注要清晰,划线价要有依据;不能出现“最”“第一”这类绝对化用语;抽奖、返现类活动要谨慎,容易触发额外审核。
还有一个实际经验:首次提审的功能越少越好。把团长端、佣金提现这些敏感功能先隐藏(用白名单控制入口),等主体功能过审之后再逐步放开。这样即使后面某个功能被驳回,也不影响已经上线的部分。
7.4 灰度与回滚:新团次才是最好的试验田
社区团购系统有个天然优势:团次是周期性的,每一期都是独立的。这意味着你可以拿某一期做灰度,出问题影响范围可控。
我的习惯是:新功能先在单个自提点的一期团次上开启,观察完整链路(下单、支付、截单、分拣、核销、结算)跑通一遍,再放开到全部站点。配置开关放在团次级别,出问题直接关掉,比回滚代码快得多。
数据库变更要格外小心。加字段可以随便加,改字段类型和删字段一定要分两步走:先加新字段双写,等所有实例都升级完,再切读、删旧字段。这个过程要跨两个发布周期,急不得。
最后分享一个我个人一直在用的检查清单,每次大版本上线前过一遍:金额字段是否全为整数分、所有状态流转是否都有回滚路径、定时任务是否都带条件更新、核销和支付回调是否都做了幂等、团长端能否看到每一笔佣金的来源。这五条只要有一条不满足,我就会推迟上线——因为这五个地方出的问题,用户和团长都能直接感知到,而修复成本远高于上线前多花的那半天。