分销代理多端触达系统 · 二次开发教程(04)

📌 转载声明:本文为《分销代理多端触达系统 · 二次开发教程》系列第 04 篇。转载请注明出处;可运行的配套代码与最新版本见官方开源仓库(Apache 2.0):atomgit.com/pangzi_zi/distributor-touch

C 端商城与跨店铺拆单

上一篇:03 管理端页面开发 本篇目标:彻底搞懂商城的数据模型和交易链路,能改满减规则、加支付方式、调拆单逻辑。 代码来自真实实现:services/php/portal/views/user_cart.php(B/S 版)+ uniapp/src/utils/cart.js(小程序版),两版逻辑完全对应。

一、先看一个真实痛点

品牌商开了多家门店,每家店有自己的货、自己的促销。消费者在购物车里混装了 A 店和 B 店的商品,然后——

问题来了:这一车货该怎么结算?

方案问题
合成一张订单发货方是谁?退款找谁?对账怎么拆?A 店的满减凭什么给 B 店用?
按店铺拆成多单 ✅每单归属明确,各店独立算促销,独立发货、独立退款

本项目选后者,这就是跨店铺自动拆单。


二、数据模型

2.1 三个关键字段

Product.storeId   →  商品归属哪个店铺(空 = 平台自营)
Order.storeId     →  订单归属哪个店铺(空 = 自营订单)
Order.items[]     →  该单的商品明细(同店)

核心约束:一笔订单只归属一个店铺。

这条约束保证了后续的发货、退款、对账、佣金计算都能按店铺维度清晰划分。

2.2 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.3 购物车存在哪

浏览器 localStorage,不在后端。

端实现存储 key
B/S 门户services/php/portal/public/cart.jslocalStorage
小程序uniapp/src/utils/cart.jslocalStorage(uni.getStorageSync)

好处是匿名用户也能加购,不用先注册。代价是换设备不同步 —— 对 MVP 阶段的分销场景可以接受。


三、购物车分组:核心算法

两个版本的实现完全一致,只有 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);
    }
    map[k].items.push(it);
  });
  return order.map((k) => map[k]);
}

三个细节:

  1. '__self__' 哨兵值 —— 自营商品 storeId 为空,用哨兵保证它们也能进 Map(不能直接用空字符串当 key,语义上会和下文的"未选择店铺"混淆)。
  2. order 数组保序 —— JS 对象的字符串 key 在部分场景会重排(数字型 key 尤其),用额外数组记录首次出现顺序,保证分组顺序稳定。
  3. 店名三级 fallback —— STORE_NAME[id](从公开接口拉的最新名)→ it.storeName(加购时的快照)→ '店铺 STO0001'(兜底)。保证任何情况下都能显示一个可读的名字。

3.1 店名从公开接口拉

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() —— 店铺接口挂了购物车照样能用,只是店名可能是旧的。公开数据的降级容错就该这么做。

后端的 userStores() 专门做了字段裁剪:

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 端必须裁剪后再返回 —— 任何时候给匿名用户的响应,都要想一遍"这个字段能公开吗"。

四、促销规则

4.1 满减:按各店小计独立计算

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 触发满减,但拆成两单后每单都不满 99,减 10 块该记在哪一单?账就烂了。

按各店算:A 店 80 不满 → 不减;B 店 80 不满 → 不减。消费者看到的提示也是分店的:

function promoHintFor(sub) {
  if (sub >= PROMO.threshold) return `🎉 已享 满${PROMO.threshold} 减 ${PROMO.off}`;
  return `🛍️ 本店再买 ${PROMO.threshold - sub} 可减 ${PROMO.off}`;
}
💡 要改满减规则,只改 PROMO 这一处常量就行(两个版本各有一份,记得同步)。 如果要做阶梯满减(满 99 减 10、满 199 减 25),把 autoDiscount 改成查表即可: ``js 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; } `` 注意按 threshold 降序查找。

4.2 优惠券:多店时只作用于金额最大的那一店

// 优惠券只作用于「金额最大」的那一店(多店拆单时一券只能用在一单)
var couponStoreId = null;
if (appliedCoupon) {
  var biggest = groups.slice().sort(function (a, b) {
    return groupSubtotal(b) - groupSubtotal(a);
  })[0];
  couponStoreId = storeKey(biggest.items[0]);
}

这是一条业务决策,不是技术限制:一张券只能核销一次,多店拆单时只能选一单来用。选金额最大的那单,对消费者最有利。

如果你的业务允许"每店各用一张券",改这里即可;但后端的 validateCoupon() 也要同步放开,否则券码会被判为已使用。

五、结算主流程:逐店下单 + 依次支付

这是全篇最核心的一段:

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);
}

5.1 关键设计:失败只影响对应店铺

if (!res || res.code !== 0) {
  toast(`「${g.name}」下单失败:...`, 'error');
  break;                       // 跳出,不再尝试后续店铺
}

orders.push({ ... });
g.items.forEach(x => Cart.remove(x.skuId));   // 只移除成功的

为什么是 break 而不是 continue?

如果第 2 店下单失败还继续第 3 店,用户会拿到"部分成功"的订单集合,很容易困惑。停下来让用户手动重试,状态更清晰。

为什么每店成功就立刻从购物车移除?

因为已经生成订单了。不移除的话,用户重试会把同一批商品再下一遍单。而只移除成功的保证了失败店铺的商品还在车里,可以单独重试。

5.2 依次支付:递归而不是循环

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); },   // 成功后递归下一单
  });
}

为什么用递归回调而不是 for + await?

因为支付是用户交互驱动的 —— 每一单都要用户扫码/确认,程序不能自动往下走。用回调在"用户确认支付成功"的时机推进到下一单,比 await 一个 Promise 更贴合真实交互。

5.3 后端:一单一店

// orders.js(后端)
let orderStoreId = null;
if (body.storeId) {
  const s = storesMod.getStoreSafe(String(body.storeId).trim());
  if (!s) throw Object.assign(new Error(`店铺 ${body.storeId} 不存在`), { status: 404 });
  orderStoreId = s.id;
}
⚠️ 校验要在创建订单之前做。先校验 storeId 存在,再落库 —— 否则会留下脏订单(storeId 指向不存在的店铺),Cart 分组时这块商品就"消失"了。

六、两版实现对照表

B/S 门户和小程序是两套独立代码,但逻辑一一对应。改规则时两边都要动:

能力B/S 门户小程序
购物车存储public/cart.jsuniapp/src/utils/cart.js
分组函数views/user_cart.php 内联 groupByStorecart.js 导出 groupByStore
满减常量PROMO = { threshold: 99, off: 10 }同(各自一份)
页面/portal/u/shop、/u/cart、/u/orderspages/shop/index、cart、orders
结算checkout() + payOrders()pages/shop/cart.vue 同名逻辑
📌 改促销规则务必同步两处。建议把 PROMO 这类常量挪到后端配置,由接口下发 —— 这样改一次两边同时生效,也不会出现"网页显示减 10、小程序显示减 15"的尴尬。

七、支付方式扩展

后端 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 是管理员接口(返回脱敏后的配置)。别把含密钥的配置从公开口子放出去。

八、本篇 checklist

改商城相关功能时:

  • [ ] 满减规则改动后,B/S 和小程序两处 PROMO 都改了
  • [ ] 新增商品字段,三处同步:后端 normalize / 门户 Action / 前端表单
  • [ ] 给匿名用户的响应裁剪过敏感字段(联系人、电话、密钥等)
  • [ ] 后端创建订单先校验 storeId 存在,再落库
  • [ ] 拆单失败的分支测试过(断网/库存不足时购物车只移除成功的店)
  • [ ] 支付相关改动在模拟支付下跑通全流程

下一篇

05 UniApp 小程序二开 —— vite 目录结构、新增页面、API 层与离线占位数据、7 语言 i18n、tabBar 图标生成、多端编译,以及本机构建的那个坑。