电商交易链路是小程序里出错代价最高的一段代码:购物车算错价格、下单重复扣款、倒计时显示不一致,任何一个都会直接变成客诉和资金损失。本文按"加入购物车 → 购物车管理 → 下单 → 支付 → 倒计时与售后"的时间线,还原我们项目里的真实踩坑与重构过程,覆盖数量更新、本地/服务端购物车、下单幂等、退款倒计时四个高频面试与实战考点。
关键词:微信小程序、电商、购物车、幂等性、微信支付、倒计时、状态同步
一、全链路总览:一张图看懂交易链路
先给整条链路定个坐标系,后面每一节都是在补其中一块短板:
用户侧 服务端 ────── ────── 浏览商品 │ 点击"加入购物车" ├─ 本地购物车写入(未登录) ──────────→ (不请求,或仅同步) │ 登录后合并购物车 ──────────────────→ 购物车合并接口(幂等) 购物车页 ├─ 数量 +/- ── 防抖后批量同步 ─────→ 更新购物车项(带版本号) ├─ 勾选/取消勾选 ──────────────────→ 重新算价 点击"结算" ├─ 请求下单预校验 ──────────────────→ 校验库存/价格快照 ├─ 提交订单(携带幂等Token) ─────────→ 创建订单(幂等消费Token) │ ← 返回 prepay 支付参数 ├─ wx.requestPayment 拉起收银台 │ 支付成功 │ ↓ (异步) │ 支付回调通知(需做幂等+验签) │ ← 轮询/订阅消息获知支付结果 订单详情 ├─ 支付成功 15 分钟内可无理由退款 ├─ 退款倒计时(以服务端时间为准) └─ 发起退款 ───────────────────────→ 退款申请(同样要幂等)
有三个"隐形考点"贯穿全链路:性能(购物车高频操作不能每次都打接口)、一致性(本地和服务端两份购物车数据如何对齐)、幂等(网络重试和用户手抖随时可能让同一次操作发两遍)。下面逐个展开。
二、购物车数量更新:小功能里的大学问
2.1 第一版:点一下,请求一次
数量加减的第一版实现非常直觉——bindtap 里直接调接口:
// v1.0:点一下,同步一次
onIncrease(e) {
const id = e.currentTarget.dataset.id
const item = this.data.cartMap[id]
updateCartApi({ skuId: id, quantity: item.quantity + 1 })
.then(() => this.loadCart()) // 重新拉全量购物车
}
用户快速点 5 下"+",就发出 5 次"更新 + 5 次全量拉取",购物车页60 个 SKU,点一轮就是几百个请求。测试同学的评价是:"你这购物车用起来像在拨号上网。"
更糟的是竞态:5 个请求乱序返回,先发的后到,最终数量显示为"倒数第二次请求"的结果——服务端是 5,页面显示 4。这种 Bug 用户不一定复现得了,但一定会在下单前发现"我买的数量不对"。
2.2 重构:防抖 + 乐观更新 + 版本号
// cart/store.js —— 购物车数量更新的正确姿势
const pendingChanges = {} // 待同步的增量
let syncTimer = null
function changeQuantity(skuId, delta) {
const item = cartMap[skuId]
// 1. 库存上限先在本地拦截,点击无响应比报错体验好
const next = Math.min(item.quantity + delta, item.stock)
if (next <= 0) return // 下限交给长按删除/滑删,不在这做
// 2. 乐观更新:先改本地,界面立即响应
item.quantity = next
recomputeTotals() // 本地重算总价,不用等接口
// 3. 增量记入待同步缓冲,800ms 防抖合并
pendingChanges[skuId] = next
scheduleSync()
}
function scheduleSync() {
clearTimeout(syncTimer)
syncTimer = setTimeout(flushSync, 800)
}
function flushSync() {
if (!Object.keys(pendingChanges).length) return
const payload = { ...pendingChanges, clientVersion: cartVersion }
pendingChanges = {}
updateCartBatchApi(payload)
.then(res => {
cartVersion = res.serverVersion
// 服务端价格可能与本地缓存不同(改价/优惠失效),以返回为准纠偏
applyServerPrices(res.items)
})
.catch(() => loadCart()) // 同步失败,全量拉一次兜底
}
三个关键设计:
-
乐观更新:本地先改、立即渲染,接口只负责"确认 + 纠偏"。移动端点击必须即时反馈,等接口回来再改数字的体验是灾难级的;
-
防抖合并:连续点击合并为一次批量请求,请求数从 N 次降到 1 次;
-
版本号防竞态:请求带上本地已知的
clientVersion,服务端发现版本落后时拒绝并返回最新全量,从根上消灭"乱序覆盖"。
经验总结:购物车数量更新的本质是"高频低价值写操作",策略永远是本地先行、批量同步、服务端兜底纠偏。反过来,下单是"低频高价值写操作",策略则完全相反——下一节会看到。
三、本地购物车与服务端购物车:一场关于"未登录"的拉扯
3.1 业务现实
电商小程序有一个绕不开的场景:用户在未登录/未授权状态下逛和加购(微信生态里强制登录会流失大量用户)。于是天然存在两份购物车:
| 本地购物车 | 服务端购物车 | |
|---|---|---|
| 存储位置 | wx.setStorageSync | 数据库,按 userId |
| 生效条件 | 随时可写 | 需要登录态 |
| 数据可靠性 | 清缓存/换设备即丢 | 持久 |
| 价格库存 | 缓存的快照,可能过期 | 实时权威 |
3.2 第一版设计及其漏洞
第一版我的方案是"能简单就简单":未登录只写本地;登录后把本地购物车 POST 给服务端,直接覆盖服务端购物车。
上线两周,客服收到一类诡异投诉:"我购物车里的东西不见了。"
排查后发现复现路径:用户在 A 设备登录、加购了几件商品 → 换到 B 设备(旧手机、或 iPad 上的小程序),此时 B 设备本地存着几天前未登录时加购的几件旧商品 → B 设备登录,触发合并 → 本地旧数据覆盖了服务端新数据,A 设备加购的没了。
同事的吐槽很到位:
"合并的语义不是'谁新听谁的',而是'并集 + 冲突时问清楚'。你这一句话覆盖,等于让用户丢了数据。"
3.3 正确的合并策略
// 合并策略:以 skuId 为键做并集,冲突时数量取大、勾选状态以服务端为准
function mergeCart(localItems, serverItems) {
const serverMap = new Map(serverItems.map(i => [i.skuId, i]))
const merged = new Map(serverMap)
for (const local of localItems) {
const server = merged.get(local.skuId)
if (!server) {
merged.set(local.skuId, { ...local, selected: false }) // 合并进来的默认不勾选
} else {
merged.set(local.skuId, {
...server,
quantity: Math.max(server.quantity, local.quantity),
// selected 以服务端为准,避免换设备后意外勾选一堆商品直接结算
})
}
}
return [...merged.values()]
}
配套的几条细则,都是被投诉"喂"出来的:
-
合并接口本身要幂等:合并请求可能因网络重试发两次,第一次已生效后第二次重放不能造成数量翻倍(做法:合并完成后删除本地购物车,并以一次性 mergeToken 标记);
-
合并进来的商品默认不勾选:换设备合并后直接勾选全量结算,用户会莫名多付钱;
-
失效商品不删除、置失效态:合并过来的 SKU 可能已下架/改价,展示为"已失效"让用户自己确认,比静默丢弃体面得多;
-
本地购物车有容量上限(我们限制 50 条):
setStorage有 10MB/小程序的限制,购物车塞几百条既浪费又不安全,超限提示"购物车已满,请先结算"。
经验总结:本地购物车的定位是"未登录时的临时草稿 + 弱网兜底",服务端购物车才是唯一事实源(single source of truth)。所有合并语义必须明确到每个字段的冲突规则,"直接覆盖"从来不是合并策略。
四、下单接口的幂等性:最不能出事的一环
4.1 为什么下单必须幂等
幂等(Idempotency):同一个操作执行一次和执行 N 次,效果相同。下单链路里"同一个订单被创建两次"的触发场景多得超出想象:
-
用户连点"提交订单"按钮(弱网下无反馈,必点第二下);
-
弱网超时,前端重试,但服务端其实第一次已经成功;
-
requestPayment拉起失败,用户返回重新提交; -
支付回调通知重试(微信支付机制保证"只要没收到成功应答就会重发通知")。
前三个的后果是创建重复订单;最后一个的后果更严重——发货两次,只收到一笔钱。
4.2 第一版:前端防抖,够吗?
第一版我只做了前端防重复点击:
// v1.0:按钮 loading 防手抖
async onSubmit() {
if (this.submitting) return
this.submitting = true
try {
const order = await createOrderApi(this.buildPayload())
this.requestPayment(order)
} finally {
this.submitting = false
}
}
同事评审时的原话:
"按钮防抖只能防'同一台手机上的手抖',防不了网络层重试,更防不了支付回调重放。幂等是服务端的职责,前端只是第一道闸。"
这句话后来成了我们团队对幂等问题的标准回答。
4.3 完整方案:Token 机制 + 三道防线
第一道:前端预防。 进入结算页时向服务端申请一个一次性幂等 Token,提交时携带:
// 进入结算页时预取 token(而不是点击时才取,减少下单等待)
onLoad() {
getIdempotentTokenApi().then(res => { this.payToken = res.token })
},
async onSubmit() {
if (this.submitting) return
this.submitting = true
try {
const order = await createOrderApi({
...this.buildPayload(),
idempotentToken: this.payToken,
})
this.requestPayment(order)
} catch (err) {
// token 是一次性的,失败后必须换新 token 再允许重试
this.payToken = null
refreshToken().then(t => { this.payToken = t })
throw err
} finally {
this.submitting = false
}
}
第二道:服务端幂等消费。 Token 机制的核心在服务端:以 Token 为唯一键,利用数据库唯一索引 + "先占位再执行":
1. 创建订单前:INSERT INTO idempotent_token (token, status) VALUES (?, 'PROCESSING') —— 唯一索引保证同一个 token 只有一条能插入成功 2. 插入失败(DuplicateKey)→ 说明已有相同请求在处理/已处理 → 查回已有订单直接返回 3. 插入成功 → 执行下单事务 → 更新 token 状态为 DONE 4. 处理中途崩溃 → token 停在 PROCESSING,由定时任务超时回收,客户端可安全重试
这一招对支付回调通知同样适用:微信支付的通知以 out_trade_no(商户订单号)为键做幂等,收到重复通知时校验订单已是"已支付"状态则直接返回成功应答,不再执行加钱/发货逻辑。
第三道:最终一致性校验。 即使前两道都在,下单前仍要服务端做一次"当前用户是否有同商品同数量的未支付订单"的软校验并提示用户,作为兜底。
前端还有个容易被忽略的细节:支付结果不能只依赖 requestPayment 的 success 回调(用户支付成功但回调丢失的场景真实存在),正确姿势是 success 后再向服务端主动查询一次订单状态 + 开启订阅消息兜底,以服务端状态为准。
经验总结:幂等设计口诀——前端防抖是体验,Token 机制是保障,回调幂等是底线,服务端状态是唯一事实。 四层缺一层,就等着一封客服工单。
五、退款倒计时:一个定时器引发的血案
5.1 需求与第一版翻车
需求:"支付成功后 15 分钟内可无理由退款,订单页显示倒计时。" 第一版实现:
// v1.0:拿客户端时间 + setInterval 倒数
onLoad() {
const end = this.data.order.payTime + 15 * 60 * 1000 // payTime 来自接口
this.timer = setInterval(() => {
const remain = end - Date.now()
if (remain <= 0) {
clearInterval(this.timer)
this.setData({ countdown: '00:00', canRefund: false })
} else {
this.setData({ countdown: this.format(remain) })
}
}, 1000)
}
问题清单,全部来自测试和线上:
-
手机时间不准:用户手机快 3 分钟,倒计时提前结束(或反过来多出 3 分钟,运营配置的秒杀逻辑直接错乱)。改法:倒计时的终点必须由服务端下发(返回
remainMillis或截止时间戳),前端只做"从 now 开始倒数"的相对计算; -
锁屏/切后台:小程序进入后台后定时器被暂停,回前台时间对不上。必须在
onShow里向服务端重新校时或重算; -
一页 20 个订单,20 个 setInterval:订单列表页每条订单一个定时器,setData 每秒触发 20 次,低端机直接掉帧。改法:单定时器 + 每秒批量更新,且倒计时期间只更新文本节点,避免大对象 setData。
5.2 重构后的实现
// countdown/manager.js —— 单例倒计时管理器
class CountdownManager {
constructor() {
this.tasks = new Map() // orderId -> { endTime, onUpdate, onEnd }
this.timer = null
}
register(orderId, endTime, { onUpdate, onEnd }) {
this.tasks.set(orderId, { endTime, onUpdate, onEnd })
this.ensureTicking()
}
unregister(orderId) {
this.tasks.delete(orderId)
if (!this.tasks.size) clearInterval(this.timer)
}
ensureTicking() {
if (this.timer) return
this.timer = setInterval(() => this.tick(), 1000)
}
tick() {
const now = Date.now()
const expired = []
this.tasks.forEach((task, id) => {
const remain = task.endTime - now
if (remain <= 0) {
expired.push(id)
task.onEnd && task.onEnd()
} else {
task.onUpdate(this.format(remain))
}
})
expired.forEach(id => this.tasks.delete(id))
}
format(ms) {
const s = Math.floor(ms / 1000)
const mm = String(Math.floor(s / 60)).padStart(2, '0')
const ss = String(s % 60).padStart(2, '0')
return `${mm}:${ss}`
}
}
export default new CountdownManager()
配套规则:
-
时间基准以服务端为准:接口返回
refundDeadline(服务端时间戳),前端用"接口响应时刻的本地时间"与它做差,得到本地相对终点,onShow时重算一次; -
倒计时归零 ≠ 权限收回:前端归零只是视觉提示,"能否退款"最终以下单接口/退款接口返回为准(用户停留在页面上 16 分钟,页面显示已超时,但接口层面服务端才是裁判);
-
跨天/长倒计时(如秒杀开始倒计时)同理,但要注意
setData频率可以降到每分钟一次。
经验总结:前端倒计时只负责展示,不负责裁决。凡是和钱、和资格有关的时间窗口,裁判永远是服务端。
六、异常与边界:链路的"阴暗面"清单
交易链路 80% 的代码在处理正常流程,80% 的客诉来自那 20% 的异常。把我们踩过/复盘过的边界列成清单,供对照自查:
-
加购时库存刚好售罄:加购接口不校验库存(加购≠购买),结算页和下单接口才强校验,但加购成功后要 Toast 提醒"库存紧张";
-
下单成功但拉起支付失败:订单停留在"待支付",用户从订单列表重新发起支付(复用订单号,不重新下单——这正是幂等Token的另一个用途);
-
支付取消/中途返回:
requestPayment的fail不等于支付失败,可能只是用户取消。取消后订单保留待支付态,超时(如 15 分钟)由服务端关单并释放库存; -
支付成功但回调延迟:前端
success回调后立即查询订单状态,若仍是"待支付",进入轮询(500ms 间隔,最多 6 次)+ 订阅消息兜底,页面显示"支付结果确认中",绝不直接报错"支付失败"; -
订单超时关单 vs 用户恰好在此刻支付:这是分布式系统经典竞态,关单与支付回调可能并发到达。服务端以"支付流水是否成功"为最终裁决:先关单后收到支付成功,走自动退款。前端要做的是把这种极小概率场景的文案做成"退款中",而不是报错;
-
退款申请的幂等:与下单同理,退款按钮同样要防重,服务端以退款单号幂等,防止重复退款。
七、沉淀:交易链路八条军规
-
高频操作本地先行:购物车数量、勾选状态,乐观更新 + 防抖批量同步;
-
低价值操作不落库:未登录购物车只写本地,登录时按字段级冲突规则合并,服务端是唯一事实源;
-
所有写接口都按幂等设计:下单、支付回调、合并购物车、退款,一个都别放过;
-
前端只做展示层裁决之外的事:倒计时、支付结果、库存余量,最终状态以服务端为准;
-
定时器全局单例:一页多个倒计时共用一个 setInterval,onShow 校时、onHide 注销;
-
setData 最小化:倒计时只更新文本节点,列表页绝不全量刷新;
-
异常路径先写文案:每个可能失败的接口调用,先想清楚用户看到什么,再写 catch;
-
竞态场景画时序图:关单 vs 支付、合并 vs 同步,两方并发的场景必须先画时序图再写代码。
八、参考文献
只列真正查阅过、对本文观点有直接支撑的资料:
-
微信官方文档:wx.requestPayment、支付结果通知(支付回调重试机制与应答规范的权威来源)
-
微信官方文档:数据缓存 Storage、小程序运行时 - 生命周期(本地存储限制与前后台切换对定时器的影响)
-
Martin Kleppmann. Designing Data-Intensive Applications. O'Reilly, 2017.(幂等性、Exactly-once 语义与并发竞态的系统论述)
九、写在最后
交易链路的代码有一个鲜明特征:正常流程写得再顺滑,用户也不会表扬你;但任何一个异常场景没兜住,用户一定找得到你。 所以这条链路的工程原则和其他页面正好相反——先用最悲观的假设把异常清单列全,再谈流畅体验。
同事的吐槽依然是最好的测试用例来源,而支付成功的那个绿色对勾,是对这条链路上每一层防御的最好回报。
如果觉得本文有帮助,欢迎点赞、收藏、评论三连;你在交易链路上踩过最深的坑是什么,评论区见。
本文首发于 CSDN,作者原创。转载请注明出处。

251

被折叠的 条评论
为什么被折叠?



