写在前面:一个让我改了四遍满减逻辑的需求
品牌商开了三家门店,每家店有各自的货、各自的促销。消费者在购物车里混装了 A 店和 B 店的商品,点结算的时候,产品经理问了一句:这一车货到底算一张订单还是几张订单?
我当时的答案是"合成一张,数据库只用一张 order 表,多简单"。结果上线前一天晚上,我自己想通了一个数就彻底推翻了这个设计。
A 店 80 元,B 店 80 元。购物车总额 160,触发了满 99 减 10。但拆成两单之后,每一单都不到 99。这 10 块钱该记在哪一单头上?我当时盯着屏幕想了五分钟,没想出来。因为它根本就是个错的问题——拆单和按总额算满减这两个设计,在数学上就不兼容。
这篇文章把这个坑连同另外两个一起摊开讲,顺便把跨店铺拆单从数据模型到逐店支付的完整链路写清楚。所有代码来自一个真实跑起来的实现:B/S 门户的 services/php/portal/views/user_cart.php,以及 UniApp 小程序版的 uniapp/src/utils/cart.js,两端逻辑完全对应。
一、先把方案选择摆出来
拆与不拆,不是技术偏好问题,是业务约束问题:
| 方案 | 问题 |
|---|---|
| 合成一张订单 | 发货方是谁?退款找谁?对账怎么拆?A 店的满减凭什么给 B 店用? |
| 按店铺拆成多单 | 每单归属明确,各店独立算促销,独立发货、独立退款 |
注意合成方案那一栏的四个问号,它们不是四个 bug,是同一个问题的四种表现:一旦一张订单里混了多个供货方,履约链路上的每一个环节都失去了唯一的归属主体。
反过来,按店铺拆单的代价是真实的:结算要循环下单、支付要循环拉起、购物车要按店分组。但这些复杂度都收敛在前端,后端反而变简单了——因为后端只需要相信"一张订单只属于一个店"这条约束。
我选了后者。
二、数据模型:一条约束撑起整条链路
三个关键字段:
Product.storeId → 商品归属哪个店铺(空 = 平台自营)
Order.storeId → 订单归属哪个店铺(空 = 自营订单)
Order.items[] → 该单的商品明细(同店)
核心约束只有一条:一笔订单只归属一个店铺。
别小看这一条。有了它,后面的发货、退款、对账、佣金计算全部可以按 storeId 维度清晰切分,不需要任何"如果这一单里混了 A 店和 B 店怎么办"的分支判断。反过来,如果允许混单,这四个功能每一个都要写一遍拆单逻辑。
约束一旦清晰,代码量是往下走的,不是往上走的。
2.1 storeName 为什么读时算、写时冗余
这里有个很值得琢磨的设计点。店铺名不冗余进商品和订单,而是读取时实时算:
// products.js
// 读时富化:店铺名不落库,查询时实时挂上去
function withStoreName(p) {
return { ...p, storeName: p.storeId ? storesMod.getStoreName(p.storeId) : null };
}
export function getProducts(query) {
return list.map(withStoreName);
}
好处很直接:店铺改了名,所有商品和订单页显示的名字自动跟着变,一次洗数据的活都不用干。
但订单里的商品名、单价、收货地址是写时冗余的——商品后来改名或改价,历史订单必须保留下单那一刻的快照,否则用户翻三个月前的订单会看到一个今天才有的价格,这种事在电商里属于事故。
一句话记住:
主数据读时富化,快照数据写时冗余。
判断标准很简单:会变的算,不变的存。 店铺会变,商品价格也会变,但订单一旦生成,它记录的就是"当时的事实",不该再跟着变。
2.2 购物车存在哪:故意放在前端
购物车存在浏览器 localStorage,不在后端。
| 端 | 实现 | 存储 |
|---|---|---|
| B/S 门户 | services/php/portal/public/cart.js | localStorage |
| 小程序 | uniapp/src/utils/cart.js | localStorage(uni.getStorageSync) |
好处是匿名用户也能加购,不用先注册——注册流程每多一步,转化就掉一截。
代价是换设备不同步。对 MVP 阶段的分销场景我认为可以接受;如果要做全渠道购物车,就得把购物车提到后端,那是另一个权衡。
三、分组算法:20 行,但有三个细节不能省
两个端的实现完全一致,核心只有 20 行:
// 未归属店铺的商品统一归为「平台自营」
function storeKey(it) { return it.storeId || '__self__'; }
function storeLabel(it) {
if (!it.storeId) return '平台自营';
return STORE_NAME[it.storeId] || it.storeName || ('店铺 ' + it.storeId);
}
function groupSubtotal(g) {
return g.items.reduce((s, x) => s + x.price * x.qty, 0);
}
// 按店铺分组,保持首次出现顺序
function groupByStore(items) {
const map = {};
const order = [];
items.forEach((it) => {
const k = storeKey(it);
if (!map[k]) {
map[k] = { storeId: it.storeId || '', name: storeLabel(it), items: [] };
order.push(k); // ← 单独记顺序,见下文细节 2
}
map[k].items.push(it);
});
return order.map((k) => map[k]);
}
三个细节,每一个都是踩过才知道的:
① '__self__' 哨兵值。 自营商品的 storeId 为空。理论上可以直接用空字符串当 Map 的 key,但那会和"未选择店铺"这个语义混在一起,后面排查问题会很痛苦。给一个显式的哨兵值,代码读起来就是人话。
② order 数组保序。 这是我最容易忘的一个。JS 对象的字符串 key 在部分场景会重排(数字型 key 尤其),所以不能依赖 Object.keys(map) 的顺序。用一个额外数组记录首次出现顺序,分组结果才稳定。
用户加购的顺序是 A 店 → B 店 → A 店,购物车就应该按 A 店、B 店展示,而不是可能变成 B、A。没有这个数组,这个 bug 只在特定数据下出现,测不出来,上线之后偶发。
③ 店名三级 fallback。 STORE_NAME[id](从公开接口拉的最新名)→ it.storeName(加购时的快照)→ '店铺 STO0001'(兜底)。保证任何情况下都能显示一个可读的名字,不会出现 undefined 或空白。
3.1 店名从公开接口拉,.catch() 是空的
var STORE_NAME = {}; // storeId -> 店铺名称(从公开接口拉取,保证店名最新)
fetch('/portal/u/stores')
.then(r => r.json())
.then(res => {
(res.data || []).forEach(s => { STORE_NAME[s.id] = s.name; });
renderCart();
})
.catch(() => {}); // ← 拉不到就用快照名,不影响主流程
注意那个空的 .catch()。 一开始我写了完整的错误提示和降级方案,后来全删了。理由是:店铺接口挂了,购物车该能用还是要用,只是店名可能显示成旧的。给用户弹一个红框说"加载失败",然后把购物车整个卡住,是把一个非关键信息的可用性问题升级成了关键路径的可用性问题。
公开数据的降级容错就该这么做:失败要无声,降级要自动。
后端对应的地方做了字段裁剪:
public function userStores(): void
{
$resp = $this->apiCall('/api/stores');
$items = array_map(function ($s) {
return [
'id' => $s['id'] ?? '',
'name' => $s['name'] ?? '',
'status' => $s['status'] ?? 'active',
]; // ← 只挑这三个字段返回
}, $resp['items'] ?? []);
$this->json(['code' => 0, 'data' => $items]);
}
/api/stores 的 GET 是公开的,而店铺表里还有 contact / phone / address。所以 C 端必须裁剪后再返回。
任何时候给匿名用户的响应,都要想一遍"这个字段能公开吗"。
这条后来成了我检查所有公开接口的默认动作。字段裁剪的成本是几行代码,泄漏的成本是事故。
四、坑一:满减必须按各店小计算
开头说的那个坑,这里正式处理。
export const PROMO = { threshold: 99, off: 10 };
// 单店满减:只看这一单自己的小计
export function autoDiscount(subtotal) {
return subtotal >= PROMO.threshold ? PROMO.off : 0;
}
// 总优惠 = 各店满减之和 + 优惠券
export function totalDiscount(groups, couponDiscount = 0) {
let d = 0;
groups.forEach((g) => { d += autoDiscount(groupSubtotal(g)); });
return d + couponDiscount;
}
为什么按各店小计算,而不是按购物车总额?
回到 A 店 80、B 店 80 那个例子。按总额算是 160,触发满减减 10;但拆成两单后每单都不到 99。这 10 块钱没有任何一个合法的落点——记在 A 单,A 单实收 70 但只消费了 80;记在 B 单,同理。账从此对不上。
按各店算:A 店 80 不满 → 不减;B 店 80 不满 → 不减。消费者看到的提示也是分店的:
function promoHintFor(sub) {
if (sub >= PROMO.threshold) return `🎉 已享 满${PROMO.threshold} 减 ${PROMO.off}`;
return `🛍️ 本店再买 ${PROMO.threshold - sub} 可减 ${PROMO.off}`; // ← 引导在本店凑单
}
这个提示文案还有个副作用我觉得挺好:它主动引导用户在同一家店凑单。既避免了跨店凑单的账务纠纷,又提升单店客单价,比一个笼统的"再买 19 元减 10 元"更有价值。
改满减规则只需要改 PROMO 这一处常量(两个端各有一份,记得同步)。如果要做阶梯满减(满 99 减 10、满 199 减 25),把 autoDiscount 改成查表:
// 阶梯满减:必须按 threshold 降序排列
const TIERS = [
{ threshold: 199, off: 25 },
{ threshold: 99, off: 10 },
];
export function autoDiscount(subtotal) {
const t = TIERS.find(t => subtotal >= t.threshold);
return t ? t.off : 0;
}
注意 find 是从前到后取第一个匹配的,所以 threshold 必须降序。我一开始写成了升序,结果 199 元的小单只减了 10,测试的时候手算才发现。
五、坑二:一券只能核销一次,作用在哪一单
多店拆单时,优惠券只能作用于金额最大的那一店:
// 优惠券只作用于「金额最大」的那一店(多店拆单时一券只能用在一单)
var couponStoreId = null;
if (appliedCoupon) {
var biggest = groups.slice().sort(function (a, b) {
return groupSubtotal(b) - groupSubtotal(a); // 降序,取第一个
})[0];
couponStoreId = storeKey(biggest.items[0]);
}
这是一条业务决策,不是技术限制。 一张券只能核销一次,多店拆单时只能选一单来用。选金额最大的那单,对消费者最有利——同样的券,减在 200 的单上比减在 30 的单上,感知价值差好几倍。
如果业务允许"每店各用一张券",改这里即可;但后端的 validateCoupon() 也要同步放开,否则第二单用券时券码会被判为已使用。前后端这类隐式契约,改一处必须找全另一处。
六、坑三:失败要 break,且只清成功的那部分
这是结算主流程,也是全篇最核心的一段:
async function checkout() {
// ① 表单校验:先在前端拦一道,减少无谓请求
var customerName = qs('custName').value.trim();
var phone = qs('custPhone').value.trim();
var addr = qs('custAddr').value.trim();
if (!customerName) { toast('请填写收货人姓名', 'error'); return; }
if (!/^1[3-9]\d{9}$/.test(phone)) { toast('请填写正确的手机号', 'error'); return; }
if (!addr) { toast('请填写收货地址', 'error'); return; }
// ② 取要结算的商品(有勾选就结算勾选的,否则全部)
var list = Cart.list();
if (!list.length) { toast('购物车为空', 'error'); return; }
var sel = getSelected();
var items = list.filter(x => !sel.length || sel.indexOf(x.skuId) >= 0);
// ③ 按店铺拆单
var groups = groupByStore(items);
if (!groups.length) return;
// ④ 逐店下单
var btn = qs('checkoutBtn');
btn.disabled = true;
var orders = [];
for (var i = 0; i < groups.length; i++) {
var g = groups[i];
var sub = groupSubtotal(g);
btn.textContent = `正在下单 ${i + 1}/${groups.length}…`; // ← 循环内更新进度
var res = await submitStoreOrder({
items: g.items,
storeId: g.storeId,
discount: autoDiscount(sub), // 每单独立算满减
couponCode: storeKey(g.items[0]) === couponStoreId ? appliedCoupon.code : null,
customerName, customerPhone: phone, customerAddress: addr, remark,
});
if (!res || res.code !== 0) {
// 失败:提示哪一店失败,然后跳出循环
toast(`「${g.name}」下单失败:` + (res?.msg || '未知错误'), 'error');
break;
}
orders.push({ orderNo: res.data.orderNo, storeName: g.name, amount: sub });
// 该店已成功下单 → 只移除这一店的商品,其余保留
g.items.forEach(x => Cart.remove(x.skuId));
}
btn.disabled = false;
btn.textContent = '去结算并支付';
if (!orders.length) return;
// ⑤ 依次支付
payOrders(orders, 0, phone);
}
6.1 为什么是 break 而不是 continue
if (!res || res.code !== 0) {
toast(`「${g.name}」下单失败:...`, 'error');
break; // 跳出,不再尝试后续店铺
}
orders.push({ ... });
g.items.forEach(x => Cart.remove(x.skuId)); // 只移除成功的
我一开始写的是 continue,想着"这一家失败别影响其他家,尽量让用户多下单"。测试的时候发现体验很糟:3 家店,第 2 家失败,结果用户拿到 2 笔订单,第 3 家店的东西不在购物车里也没进订单,消失了。用户完全不知道发生了什么。
改成 break 之后:失败就停下来,提示明确是哪一家,后续店铺的商品原封不动留在购物车里,用户手动重试即可。状态清晰比成功率高更重要。
6.2 为什么每店成功就立刻移除,而且只移除成功的
因为订单已经生成了。不移除的话,用户看到红色报错点重试,会把同一批商品再下一遍单,产生重复订单。
而只移除成功的那部分,保证了失败店铺的商品还在车里,可以单独重试。这两件事是一体的:移除动作必须紧跟在 orders.push() 之后、循环内,不能挪到循环结束统一清空。
6.3 支付为什么用递归而不是 for + await
function payOrders(orders, idx, phone) {
if (idx >= orders.length) {
toast('全部订单已支付 🎉');
setTimeout(() => { location.href = '/portal/u/orders'; }, 900);
return;
}
var o = orders[idx];
PayModal.open({
orderNo: o.orderNo,
phone: phone,
// 支付成功回调 → 递归处理下一单
onPaid: function () { payOrders(orders, idx + 1, phone); },
});
}
因为支付是用户交互驱动的——每一单都要用户自己扫码、确认,程序不能自动往下走。用回调在"用户确认支付成功"的时机推进到下一单,比 await 一个不知道什么时候 resolve 的 Promise 更贴合真实交互。
还有一点上面没展开:支付失败的分支要自己补。上一单支付失败时,按钮要恢复可点、提示用户重试当前这一单,并且不要递归到下一单——否则用户会遇到"第一单没付上,但第二单已经弹出支付框"的错位状态。
6.4 后端:校验必须在落库之前
// orders.js(后端)
let orderStoreId = null;
if (body.storeId) {
const s = storesMod.getStoreSafe(String(body.storeId).trim());
// 店铺不存在 → 直接抛 404,不往下走
if (!s) throw Object.assign(new Error(`店铺 ${body.storeId} 不存在`), { status: 404 });
orderStoreId = s.id;
}
顺序不能反。先落库再校验,会留下 storeId 指向不存在店铺的脏订单。更麻烦的是,脏订单在购物车分组时,这块商品会因为 storeKey 找不到对应分组而**"消失"**——用户既看不到它,也没法下单。
七、两端双份代码,是这套架构最大的债
B/S 门户和小程序是两套独立代码,但逻辑一一对应:
| 能力 | B/S 门户 | 小程序 |
|---|---|---|
| 购物车存储 | public/cart.js | uniapp/src/utils/cart.js |
| 分组函数 | views/user_cart.php 内联 groupByStore | cart.js 导出 groupByStore |
| 满减常量 | PROMO = { threshold: 99, off: 10 } | 同(各自一份) |
| 页面 | /portal/u/shop、/u/cart、/u/orders | pages/shop/index、cart、orders |
| 结算 | checkout() + payOrders() | pages/shop/cart.vue 同名逻辑 |
改促销规则务必同步两处。 我建议把 PROMO 这类常量挪到后端配置,由接口下发——改一次两边同时生效,也不会出现"网页显示减 10、小程序显示减 15"这种尴尬。
这是全文我唯一想重构的地方。业务规则在客户端存副本,短期开发快,长期是必然的 bug 来源。
八、支付方式的抽象,顺手记一下
后端 handlers/payment.js 已经抽象好:
export const PAYMENT_METHODS = { /* mock / wechat / alipay ... */ };
export function getPaymentConfig() // 读配置(敏感字段脱敏)
export function getCheckoutMethods() // C 端结算页可用方式
export function getDefaultMethod() // 默认方式
export function isMethodActive(id) // 某方式是否启用
export function validateMethod(id) // 校验
export function updatePaymentConfig({ activeMethods, defaultMethod, configs })
export function createPayment(order, methodId) // 创建支付记录
export function confirmPaymentRecord(paymentId, { operator })
export function getPayment(id)
export function getPaymentsByOrder(orderId)
export function getEffectiveWechatConfig() // 微信支付真实凭据
启用哪些方式由环境变量控制:
# 默认只开模拟支付,本地开发够用
PAYMENT_ACTIVE_METHODS=mock
PAYMENT_DEFAULT_METHOD=mock
# 启用微信支付
PAYMENT_ACTIVE_METHODS=mock,wechat
PAYMENT_DEFAULT_METHOD=wechat
微信支付的商户号、私钥、平台证书走环境变量注入(config.wechatPay),不进 db.json——这是刻意的设计,避免敏感信息被快照备份带走。
⚠️ 二开接真实支付时注意两个接口的权限差异:/api/payment/methods 是公开接口(C 端匿名要读),而 /api/payment/config 是管理员接口(返回脱敏后的配置)。别把含密钥的配置从公开口子放出去。
九、改商城功能前的自查清单
这几条是我在这次开发里用血换来的,列出来给后面二开的人:
- 满减规则改动后,B/S 和小程序两处
PROMO都改了 - 新增商品字段,三处同步:后端
normalize/ 门户 Action / 前端表单 - 给匿名用户的响应裁剪过敏感字段(联系人、电话、密钥等)
- 后端创建订单先校验 storeId 存在,再落库
- 拆单失败的分支测试过(断网 / 库存不足时,购物车只移除成功的店)
- 支付相关改动在模拟支付下先跑通全流程
最后一条尤其重要。真实支付调通一次的成本很高,而绝大多数逻辑 bug 都在"下单成功 → 生成支付单 → 支付成功回调"这段,在模拟模式下全都能验出来。
小结
跨店铺拆单这件事,技术上并不难,真正的难点全在业务一致性上:
- 一笔订单只属于一个店 —— 这条约束是地基,有了它后面所有逻辑都简单。
- 促销必须按店算 —— 不是风格选择,是数学约束,跨店凑单的钱没有合法落点。
- 失败要停在原地,且只清成功的那部分 —— 前端复杂度全在这里,值得多花时间。
主数据读时富化、快照数据写时冗余;公开接口逐字段过一遍。这两条是通用原则,不限于商城。
代码已开源,Apache 2.0 协议,可自取:atomgit.com/pangzi_zi/d…