《互联网产品的客户端架构·交易、增长与平台化》3.3 失效商品、限购与勾选状态

购物车的全选、半选、失效商品与限购,最终都会落到一个数字上:结算金额。本节把勾选状态建模成可售行的三态汇总,明确失效项不可选、限购按上限截断、服务端拒绝后只回退被拒绝的行,并用可运行代码验证勾选与金额联动,附真实测试输出、一次典型复盘与边界清单。

勾选状态为什么值得单独建模

购物车页面上,用户最常做的动作不是改数量,而是勾选。全选、取消全选、单独勾一件、勾到一半——这些动作看起来是 UI 细节,实际上决定了结算金额。

把勾选写成一个布尔字段,散落在每个行组件里,很快会出现这些问题:

  • 全选时把已下架商品也勾上,结算金额多算一笔。
  • 取消全选后,失效商品的勾选状态没清掉,下次全选又冒出来。
  • 限购商品数量被手动改超,输入框显示 10,实际只能买 3。
  • 服务端拒绝某件商品后,端上只弹一个错误,勾选和数量原样保留,用户反复提交。

这些问题共同说明:勾选不是一行的私有状态,而是一个需要汇总、校验、回退的集合状态。它值得一个独立模块。

三态与不可选行

先定义清楚哪些行可以被勾选。

行状态含义能否勾选全选时
active可售能勾选
offline已下架不能跳过并清零
soldout已售罄不能跳过并清零

规则只有一条:只有可售行参与勾选。 失效行可以留在购物车里让用户看到,但它的 selected 必须永远是 false。

全选状态也不是简单的「所有行都勾选」。它只统计可售行:

  • 可售行全部勾选:all。
  • 可售行全部未勾选:none。
  • 部分勾选:partial。
  • 没有可售行:none,而不是 all。

最后一条容易写错。空购物车或全是失效商品时,every 在空数组上返回 true,如果直接用它判断,页面会显示「已全选」,而金额是 0。

勾选与结算金额联动

结算金额只汇总「可售且勾选」的行:

勾选小计 = Σ(单价 × 数量),仅限 active && selected
实付预演 = 勾选小计 + 运费

这里的实付仍然是预演。真正的优惠、税费与最终金额由 3.2 的明细对象负责。勾选模块只回答「哪些行进入计算」,不回答「最终多少钱」。

这个分工很重要。如果把优惠也塞进勾选模块,两个模块都会试图修改金额,最后没人说得清哪个是权威。

限购不是输入框的 max

限购上限经常被实现成 <input max="3">。它只能拦住键盘输入,拦不住这些情况:

  • 用户从商品页带过来 5 件,直接进入购物车。
  • 服务端下调了限购,端上还显示旧上限。
  • 用户粘贴数量、用步进器连点、或者接口返回超限数量。

所以限购必须是数据层校验:任何设置数量的入口都调用同一个 setQuantity,它负责截断并返回原因。UI 只负责展示原因,不负责判断。

截断后的数量仍要满足「至少 1 件」。如果服务端把上限降到 0,正确做法是把行标记为不可售,而不是保留一个数量为 0 的行。

服务端拒绝后的最小回退

结算时服务端可能拒绝部分商品:下架、售罄、限购下调、地区不售。端上的回退原则是只改被拒绝的行。

常见错误有两种:

  • 整单回退:任何一件商品被拒,就把整个购物车恢复成上一次快照。用户刚勾好的其他商品全部丢失。
  • 只弹提示:提示「商品不可用」,但勾选和数量不变,用户再点结算还是失败。

正确做法是接收服务端的逐行结果,对每个被拒绝的 SKU:

  1. 用服务端状态覆盖本地状态。
  2. 失效行取消勾选。
  3. 数量超过新上限的截断。
  4. 记录一条说明,供 UI 展示。

其他行不动。这样用户只需要处理真正有问题的商品,而不是重新勾一遍。

一个可运行的勾选模块

下面的模块属于 L1:纯函数,不读网络、不读时钟,所有状态通过参数传入。

// file: src/cart/selection.ts
// tier: L1
// verify: node --test test/cart/selection.test.ts

export type LineStatus = "active" | "offline" | "soldout";
export type SelectAllState = "all" | "none" | "partial";

export interface SelectableLine {
  skuId: string; unitCents: number; quantity: number;
  maxQuantity: number; status: LineStatus; selected: boolean;
}
export interface SelectionSummary {
  state: SelectAllState; selectedCount: number;
  selectedQuantity: number; selectedSubtotalCents: number;
}
export interface QuantityResult { lines: SelectableLine[]; clamped: boolean; reason: string; }
export interface ServerRejection { skuId: string; status: LineStatus; maxQuantity?: number; }
export interface RejectionNote {
  skuId: string;
  action: "unselected" | "clamped" | "removed" | "unchanged";
  detail: string;
}
export interface RejectionResult { lines: SelectableLine[]; notes: RejectionNote[]; changed: boolean; }

export function isSelectable(line: SelectableLine): boolean {
  return line.status === "active" && line.quantity >= 1;
}

// 全选只对可售行生效;失效行既不参与「全选」,也不参与「半选」判定。
export function selectAll(lines: SelectableLine[], checked: boolean): SelectableLine[] {
  assertLines(lines);
  return lines.map((line) => ({ ...line, selected: isSelectable(line) && checked }));
}

export function selectLine(
  lines: SelectableLine[],
  skuId: string,
  checked: boolean,
): SelectableLine[] {
  assertLines(lines);
  const target = lines.find((line) => line.skuId === skuId);
  if (!target) throw new Error(`购物车没有该商品:${skuId}`);
  // 取消勾选永远允许,勾选失效商品属于调用方错误。
  if (checked && !isSelectable(target)) throw new Error(`失效商品不可勾选:${skuId}`);
  return lines.map((line) =>
    line.skuId === skuId ? { ...line, selected: checked } : { ...line },
  );
}

export function selectionState(lines: SelectableLine[]): SelectAllState {
  const active = lines.filter(isSelectable);
  if (active.length === 0) return "none";
  const selected = active.filter((line) => line.selected).length;
  if (selected === 0) return "none";
  return selected === active.length ? "all" : "partial";
}

// 服务端返回的失效状态优先落盘,同时清掉残留勾选。
export function sanitizeSelection(lines: SelectableLine[]): SelectableLine[] {
  assertLines(lines);
  return lines.map((line) => ({
    ...line,
    selected: isSelectable(line) && line.selected,
  }));
}

export function summarize(lines: SelectableLine[]): SelectionSummary {
  let selectedCount = 0;
  let selectedQuantity = 0;
  let selectedSubtotalCents = 0;
  for (const line of lines) {
    if (!isSelectable(line) || !line.selected) continue;
    selectedCount += 1;
    selectedQuantity += line.quantity;
    selectedSubtotalCents += line.unitCents * line.quantity;
  }
  if (!Number.isSafeInteger(selectedSubtotalCents)) {
    throw new Error("勾选金额溢出");
  }
  return {
    state: selectionState(lines),
    selectedCount,
    selectedQuantity,
    selectedSubtotalCents,
  };
}

// 勾选与结算金额联动:端上只汇总已勾选的可售行,运费原样加上。
export function payableCents(lines: SelectableLine[], shippingCents: number): number {
  if (!Number.isSafeInteger(shippingCents) || shippingCents < 0) {
    throw new Error("运费必须是非负安全整数");
  }
  return summarize(lines).selectedSubtotalCents + shippingCents;
}

export function setQuantity(
  lines: SelectableLine[],
  skuId: string,
  quantity: number,
): QuantityResult {
  assertLines(lines);
  if (!Number.isInteger(quantity) || quantity < 1) {
    throw new Error(`数量必须是大于 0 的整数:${skuId}`);
  }
  const target = lines.find((line) => line.skuId === skuId);
  if (!target) throw new Error(`购物车没有该商品:${skuId}`);
  const nextQuantity = Math.min(quantity, target.maxQuantity);
  return {
    lines: lines.map((line) =>
      line.skuId === skuId ? { ...line, quantity: nextQuantity } : { ...line },
    ),
    clamped: nextQuantity !== quantity,
    reason:
      nextQuantity === quantity
        ? "未触及限购"
        : `超过限购 ${target.maxQuantity} 件,已按上限保留`,
  };
}

// 服务端拒绝后只回退被拒绝的行;其余勾选与数量保持不变。
export function applyServerRejections(
  lines: SelectableLine[],
  rejections: ServerRejection[],
): RejectionResult {
  assertLines(lines);
  const bySku = new Map(rejections.map((item) => [item.skuId, item]));
  const notes: RejectionNote[] = [];
  const next = lines.map((line) => {
    const rejection = bySku.get(line.skuId);
    if (!rejection) return { ...line };
    const rawMax = rejection.maxQuantity ?? line.maxQuantity;
    if (!Number.isInteger(rawMax) || rawMax < 0) {
      throw new Error(`服务端限购非法:${line.skuId}`);
    }
    const maxQuantity = Math.max(1, rawMax);
    const quantity =
      rejection.status !== "active" || line.quantity > maxQuantity
        ? Math.min(Math.max(1, line.quantity), maxQuantity)
        : line.quantity;
    const changed =
      rejection.status !== line.status ||
      quantity !== line.quantity ||
      (rejection.status !== "active" && line.selected);
    if (!changed) {
      notes.push({ skuId: line.skuId, action: "unchanged", detail: "与本地状态一致" });
      return { ...line };
    }
    if (rejection.status !== "active") {
      notes.push({
        skuId: line.skuId,
        action: "unselected",
        detail: `服务端标记为 ${rejection.status},取消勾选`,
      });
    }
    if (quantity !== line.quantity) {
      notes.push({
        skuId: line.skuId,
        action: "clamped",
        detail: `数量 ${line.quantity} -> ${quantity}`,
      });
    }
    return {
      ...line,
      status: rejection.status,
      maxQuantity,
      quantity,
      selected: rejection.status === "active" && line.selected,
    };
  });
  return {
    lines: next,
    notes,
    changed: notes.some((note) => note.action !== "unchanged"),
  };
}

function assertLines(lines: SelectableLine[]): void {
  const seen = new Set<string>();
  for (const line of lines) {
    if (!line.skuId || seen.has(line.skuId)) {
      throw new Error(`SKU 非法或重复:${line.skuId}`);
    }
    seen.add(line.skuId);
    if (!Number.isSafeInteger(line.unitCents) || line.unitCents < 0) {
      throw new Error(`单价非法:${line.skuId}`);
    }
    if (!Number.isInteger(line.quantity) || line.quantity < 1) {
      throw new Error(`数量非法:${line.skuId}`);
    }
    if (!Number.isInteger(line.maxQuantity) || line.maxQuantity < 1) {
      throw new Error(`限购数量非法:${line.skuId}`);
    }
  }
}

几个函数的职责:

  • isSelectable:唯一的可选性判定,避免各组件各写一套。
  • selectAll / selectLine:全选与单行勾选,失效行强制清零。
  • selectionState:只统计可售行,空集合返回 none。
  • summarize / payableCents:勾选与金额联动,只汇总可售且勾选的行。
  • setQuantity:限购截断,返回是否截断与原因。
  • applyServerRejections:按 SKU 最小回退,返回变更说明。
  • sanitizeSelection:把服务端返回的失效状态落盘,同时清掉残留勾选。

applyServerRejections 里有一个容易忽略的细节:它只处理被点名的 SKU。没有被拒绝的行原样复制,连对象引用都不共享,避免调用方改一个行影响整份列表。

测试与真实输出

本机运行结果如下:

$ node --test test/cart/selection.test.ts
✔ 全选只作用于可售商品 (1.449125ms)
✔ 全部可售行勾选后状态为 all (0.112042ms)
✔ 没有勾选时状态为 none (0.0605ms)
✔ 部分勾选时状态为 partial (0.055958ms)
✔ 只有失效行时状态为 none (0.060208ms)
✔ 勾选失效商品被拒绝 (0.255792ms)
✔ 取消勾选失效商品是允许的 (0.078917ms)
✔ 取消全选清空所有勾选 (0.071542ms)
✔ 汇总只计算已勾选的可售行 (0.107334ms)
✔ 空车汇总为零且状态为 none (0.111583ms)
✔ 实付金额等于勾选小计加运费 (0.083375ms)
✔ 数量超过限购时按上限截断 (0.145791ms)
✔ 未触及限购时保持原数量 (0.05075ms)
✔ 数量为零或非整数被拒绝 (0.072584ms)
✔ 服务端标记下架后取消勾选并记录 (0.206375ms)
✔ 服务端下调限购后截断数量 (0.084833ms)
✔ 服务端拒绝只影响被点名的行 (0.045333ms)
✔ 服务端拒绝与本地一致时不产生变更 (0.053542ms)
✔ 清洗勾选会清掉失效行的残留勾选 (0.056417ms)
ℹ tests 19
ℹ suites 0
ℹ pass 19
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 131.065625

十九条用例覆盖:全选过滤、三态判定、失效不可选、取消勾选、汇总、空车、金额联动、限购截断、非法数量、下架回退、限购下调、只影响被拒行、无变更、清洗残留勾选。

一次「全选把失效商品算进金额」的复盘

「回声」的购物车曾经用一个 allSelected 布尔值控制全选。点击全选时,端上把所有行的 selected 设为 true,包括已经下架但还留在列表里的商品。

结算页按 selected 汇总金额,于是失效商品也被算进去。用户看到实付 238 元,提交后服务端只认 199 元。差额不大,但用户会问:「为什么页面写的和扣款不一样?」

更麻烦的是回退。服务端拒绝整单后,端上把 allSelected 设为 false,但逐行的 selected 没有清。用户再点一次全选,失效商品又被勾上,形成循环。

复盘发现三个问题:

  1. 全选没有过滤不可售行。
  2. 金额汇总没有再次校验可售状态。
  3. 服务端拒绝后只改了汇总开关,没有改行状态。

修复对应三点:全选只作用于 active 行;汇总时再次过滤;服务端拒绝按 SKU 回退并取消勾选。修复后,失效商品仍显示在列表里,但永远不会进入金额。

这个复盘留下的规则是:勾选是一个集合状态,必须有一个统一的入口做过滤。 只要允许某个组件直接改 selected,就一定会有人忘记判断可售性。

为什么不用一个布尔值表示全选

allSelected 的问题在于它把「结果」当成了「输入」。真正的输入是每一行的勾选状态,全选态是它的派生值。

派生值的好处是永远不会和行状态矛盾。用户单独取消一件商品,selectionState 自动从 all 变成 partial,不需要额外同步一个布尔值。反过来,如果用布尔值驱动 UI,就必须在每次行变化时手动维护它,漏一处就出现「显示全选但实际少一件」。

同样,半选态也不应该单独存储。它只是 partial 的展示形式,由 selectionState 计算得出。

小结

勾选、失效与限购共同决定「哪些商品进入结算」。勾选是集合状态,不是行组件的私有布尔值;失效行可以展示但不能被选中;限购必须在数据层截断;服务端拒绝后只回退被拒绝的行。

selectAll 与 selectLine 统一过滤可售性,selectionState 只统计可售行,summarize 与 payableCents 把勾选映射成金额,applyServerRejections 做最小回退。它们都是纯函数,结果可测试、可复现。

下一章进入提交环节:用户点下「提交订单」之后,如何保证只产生一张订单。

阅读导航:上一节:3.2 价格明细的端上重算边界 · 下一节:4.1 提交防重:幂等键与按钮状态 。

常见错误与边界

  • 用 every 判断全选,空数组返回 true,空车显示「已全选」。
  • 全选把下架、售罄商品一起勾上,结算金额多算。
  • 汇总金额时不再次校验可售状态,只信任 selected。
  • 限购只写在输入框 max 上,接口返回的超限数量直接进入购物车。
  • 服务端拒绝后整单回退,用户刚勾好的其他商品全部丢失。
  • 服务端拒绝后只弹提示,不改勾选与数量,用户反复提交。
  • 单独存一个 allSelected 布尔值,与逐行状态不同步。
  • 失效行被直接删除,用户不知道商品为什么消失。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查