《互联网产品的客户端架构·交易、增长与平台化》2.3 库存展示:可售不等于有货

库存展示的核心不是「有货/无货」两个状态,而是可售、锁定、预售、区域限制与数据降级五件事。本节把端上的库存判断写成纯函数,明确它只能决定「是否允许尝试」、不能承诺有货,说明取不到库存时如何安全降级,并复盘一次把区域不可售误显示成售罄的展示问题。

「有货」是一个被过度简化的词

商详页上的库存通常只有三种文案:有货、仅剩 N 件、无货。但服务端实际维护的是一组数:总库存、锁定库存、可售、区域可售、预售。它们不是同一个概念,也不能互相替代。

端上最容易犯的两个错误,一个偏乐观,一个偏悲观:

  • 把可售当成有货承诺。 页面显示「仅剩 3 件」,用户下单时却被服务端拒绝,因为另外两个订单在同一秒锁定了库存。
  • 把请求失败当成售罄。 库存接口超时,端上直接显示「无货」,用户以为商品卖完了,实际上只是这一屏没取到数。

这两个错误的共同根因,是端上把「展示判断」当成了「库存事实」。库存事实只在服务端的下单事务里成立。

五个数之间的关系

字段含义端上怎么用
total仓库账面总量只用于对账与校验
locked未支付订单占用的数量从可售中扣除
可售max(0, total - locked)展示与预检的基础
区域可售某个区域仓实际可发数量命中区域时优先于全国可售
预售未来库存,不承诺现货允许尝试,展示预售时间

可售的计算很简单,难的是解释。total 不是「有货数」,它包含已经被别人锁定但还没付款的部分;locked 也不是「卖出数」,订单取消后会释放。端上把 total 直接渲染成「库存 N 件」,是很多「页面有货、下单无货」的起点。

端上判断的边界

端上能做三件事:

  1. 预检:明显不可买的组合提前禁用,减少无效提交。
  2. 降级:请求失败、数据非法、地区未知时,给出可理解的提示。
  3. 展示:把可售数、紧张度、预售、区域限制翻译成用户能懂的文案。

端上不能做两件事:

  1. 锁库存:端上没有事务,也没有跨用户可见性。
  2. 承诺有货:预检通过只代表「可以尝试下单」。

代码里用 requiresServerConfirm: true 这个字面量类型把第二条固定下来。它不是注释,而是类型层面的提醒:任何消费 StockDecision 的地方,都不能把 canAttempt 当成 guaranteed。

区域与降级

区域判断有三个分支,必须分开:

  • 区域列表为空:全国可售,使用 total - locked。
  • 区域列表非空且命中:使用该区域可售,并且不能超过全国可售。
  • 区域列表非空但未命中:该地区不可售,禁止购买。
  • 地区本身为空:用户还没选地址,返回 unknown,允许尝试但不能说无货。

降级同样要区分原因。网络失败、超时、数据非法都返回 unknown,但文案不同;unknown 状态下 canAttempt 为 true,因为让用户进入结算再由服务端确认,比在商详页谎报售罄更安全。快照过期不改变可售判断,只增加 degraded 标记与提示。

库存判断的实现

下面的模块属于 L1:不访问网络,不读时钟,所有时间由查询参数注入。

// file: src/product/stock.ts
// tier: L1
// verify: node --test test/product/stock.test.ts
export interface RegionStock {
  region: string;
  available: number;
}

export interface StockSnapshot {
  skuId: string;
  total: number;
  locked: number;
  regions: RegionStock[];
  presale: boolean;
  at: number;
  revision: string;
}

export type StockState =
  | "in_stock"
  | "low_stock"
  | "insufficient"
  | "out_of_stock"
  | "region_blocked"
  | "presale"
  | "unknown";

// requiresServerConfirm 恒为字面量 true:端上的一切判断都只是「允许尝试」,
// 不承诺有货。真正的扣减只发生在服务端下单事务里。
export interface StockDecision {
  state: StockState;
  canAttempt: boolean;
  available: number;
  reason: string;
  degraded: boolean;
  requiresServerConfirm: true;
  badges: string[];
}

export interface StockQuery {
  region: string;
  quantity: number;
  now: number;
  ttlMs?: number;
  lowThreshold?: number;
}

export type DegradeReason = "network" | "timeout" | "invalid";

export function sellableCount(
  snapshot: Pick<StockSnapshot, "total" | "locked">,
): number {
  return Math.max(0, snapshot.total - snapshot.locked);
}

export function validateSnapshot(snapshot: StockSnapshot): string[] {
  const issues: string[] = [];
  if (!Number.isInteger(snapshot.total) || snapshot.total < 0) {
    issues.push("total 非法");
  }
  if (!Number.isInteger(snapshot.locked) || snapshot.locked < 0) {
    issues.push("locked 非法");
  }
  if (
    Number.isInteger(snapshot.total) &&
    Number.isInteger(snapshot.locked) &&
    snapshot.locked > snapshot.total
  ) {
    issues.push("locked 大于 total");
  }
  const seen = new Set<string>();
  for (const item of snapshot.regions) {
    if (item.region.trim() === "") {
      issues.push("区域编码为空");
    }
    if (seen.has(item.region)) {
      issues.push(`区域重复:${item.region}`);
    }
    seen.add(item.region);
    if (!Number.isInteger(item.available) || item.available < 0) {
      issues.push(`区域可售非法:${item.region}`);
    }
  }
  return issues;
}

export type RegionStatus =
  | { status: "known"; available: number; source: "snapshot" | "region" }
  | { status: "unknown"; available: 0 }
  | { status: "blocked"; available: 0 };

// 区域列表为空表示全国可售;列表非空但不包含当前地区,表示该地区不可售;
// 地区本身为空(用户还没选地址)只能返回 unknown,不能冒充「无货」。
export function regionStatus(
  snapshot: StockSnapshot,
  region: string,
): RegionStatus {
  if (region.trim() === "") {
    return { status: "unknown", available: 0 };
  }
  if (snapshot.regions.length === 0) {
    return {
      status: "known",
      available: sellableCount(snapshot),
      source: "snapshot",
    };
  }
  const hit = snapshot.regions.find((item) => item.region === region);
  if (hit === undefined) {
    return { status: "blocked", available: 0 };
  }
  return {
    status: "known",
    available: Math.min(hit.available, sellableCount(snapshot)),
    source: "region",
  };
}

export function degradeStock(
  reason: DegradeReason,
  detail = "",
): StockDecision {
  const labels: Record<DegradeReason, string> = {
    network: "库存请求失败",
    timeout: "库存请求超时",
    invalid: "库存数据异常",
  };
  const reasonText =
    detail === "" ? `${labels[reason]},以下单结果为准` : `${detail},以下单结果为准`;
  return {
    state: "unknown",
    canAttempt: true,
    available: 0,
    reason: reasonText,
    degraded: true,
    requiresServerConfirm: true,
    badges: ["库存信息暂不可用,以下单结果为准"],
  };
}

export function decideStock(
  snapshot: StockSnapshot | null,
  query: StockQuery,
): StockDecision {
  if (!Number.isInteger(query.quantity) || query.quantity < 1) {
    throw new Error("购买数量必须是正整数");
  }
  const lowThreshold = query.lowThreshold ?? 5;
  const ttlMs = query.ttlMs ?? 60_000;
  if (snapshot === null) {
    return degradeStock("network", "库存请求失败");
  }
  const issues = validateSnapshot(snapshot);
  if (issues.length > 0) {
    return degradeStock("invalid", `库存数据异常:${issues[0]}`);
  }
  const fresh = query.now - snapshot.at <= ttlMs;
  const degraded = !fresh;
  const badges: string[] = [];
  if (!fresh) badges.push("库存信息可能已过期,以下单结果为准");

  const region = regionStatus(snapshot, query.region);
  if (region.status === "blocked") {
    return {
      state: "region_blocked",
      canAttempt: false,
      available: 0,
      reason: "当前地区暂不销售",
      degraded,
      requiresServerConfirm: true,
      badges,
    };
  }
  if (region.status === "unknown") {
    return {
      state: "unknown",
      canAttempt: true,
      available: 0,
      reason: "未识别收货地区,库存以下单结果为准",
      degraded: true,
      requiresServerConfirm: true,
      badges: [...badges, "请选择收货地址以确认库存"],
    };
  }

  const available = region.available;
  if (snapshot.presale) {
    return {
      state: "presale",
      canAttempt: true,
      available,
      reason: "预售商品,付款后按预售时间发货",
      degraded,
      requiresServerConfirm: true,
      badges: [...badges, "预售"],
    };
  }
  if (available === 0) {
    return {
      state: "out_of_stock",
      canAttempt: false,
      available: 0,
      reason: "该地区暂时无货",
      degraded,
      requiresServerConfirm: true,
      badges,
    };
  }
  if (available < query.quantity) {
    return {
      state: "insufficient",
      canAttempt: false,
      available,
      reason: `仅剩 ${available} 件`,
      degraded,
      requiresServerConfirm: true,
      badges,
    };
  }
  if (available < lowThreshold) {
    return {
      state: "low_stock",
      canAttempt: true,
      available,
      reason: `库存紧张,仅剩 ${available} 件`,
      degraded,
      requiresServerConfirm: true,
      badges: [...badges, "库存紧张"],
    };
  }
  return {
    state: "in_stock",
    canAttempt: true,
    available,
    reason: "",
    degraded,
    requiresServerConfirm: true,
    badges,
  };
}

四个函数构成一条判断链。

validateSnapshot 先做算术校验:总量与锁定必须是非负整数,锁定不能大于总量,区域不能重复。数据本身矛盾时,后面所有判断都没有意义。

regionStatus 把「全国、命中、未命中、地址未知」四种情况分开。它返回的是状态而不是布尔值,因为「不可售」和「不知道」需要不同的 UI。

decideStock 按固定顺序判断:数据合法性 → 区域 → 预售 → 可售为零 → 数量不足 → 低于阈值 → 正常。顺序很重要,先判预售可以避免把预售商品误判成无货,先判区域可以避免用全国库存回答一个不配送的地区。

degradeStock 是失败路径的统一出口。它不返回 out_of_stock,而是返回 unknown,并把「以下单结果为准」写进提示。

测试与真实输出

本机运行结果如下:

$ node --test test/product/stock.test.ts
✔ 可售等于总量减锁定 (0.59575ms)
✔ 锁定超过总量时判为数据非法 (0.230208ms)
✔ 区域列表为空时按全国可售计算 (0.087667ms)
✔ 命中区域时使用该区域可售 (0.07525ms)
✔ 区域可售不会超过总可售 (0.069208ms)
✔ 列表中没有当前地区时禁止购买 (0.074166ms)
✔ 未选地区时降级为 unknown 而不是无货 (0.068834ms)
✔ 可售为零时禁买 (0.076208ms)
✔ 可售小于购买数量时提示仅剩几件 (0.074042ms)
✔ 低于阈值时进入库存紧张 (0.108083ms)
✔ 预售商品允许尝试购买 (0.070167ms)
✔ 过期快照降级但仍给出可售判断 (0.063833ms)
✔ 请求失败时降级为 unknown 且不禁买 (0.045542ms)
✔ 超时与非法数据有各自的降级文案 (0.039833ms)
✔ 区域重复被视为非法快照 (0.059625ms)
✔ 区域可售互不影响 (0.067333ms)
✔ 锁定全部库存后变为无货 (0.044792ms)
✔ 购买数量非法时直接报错 (0.267333ms)
✔ 端上判断永远不承诺有货 (0.070166ms)
ℹ tests 19
ℹ suites 0
ℹ pass 19
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 122.812416

十九条用例覆盖:可售计算、非法数据、全国与区域、区域未命中、地址未知、无货、数量不足、库存紧张、预售、过期、请求失败、降级文案、重复区域、区域隔离、全部锁定,以及「任何判断都不承诺有货」。

一次「区域不可售被显示成售罄」的复盘

某交易客户端上线新城市时,库存接口按区域返回列表。新城市尚未配置区域仓,返回的列表里没有这座城市。端上把「列表里找不到」和「可售为 0」都映射成同一个「售罄」状态。

用户看到的是「已售罄」,实际含义是「不配送」。客服收到大量「什么时候补货」的询问,运营则以为库存被瞬间抢空。复盘发现三个问题:

  1. 区域未命中与可售为零共用一个 UI 状态。
  2. 请求失败也走同一个「售罄」分支,进一步放大了误判。
  3. 页面没有「以下单结果为准」的提示,用户把展示当成了承诺。

修复把状态拆成 region_blocked、out_of_stock、unknown 三种:不配送显示「当前地区暂不销售」,无货显示「暂时无货」,请求失败显示「库存信息暂不可用」。同时,所有库存判断统一标注需要服务端确认。

这个复盘留下一条判断:库存展示的第一原则不是「准确」,而是「不撒谎」。 取不到数时说取不到,比猜一个「无货」更接近事实。

小结

库存至少有五个数:总量、锁定、可售、区域可售、预售。端上能算可售、能做预检、能降级展示,但不能锁库存,也不能承诺有货。decideStock 把所有状态收进一个返回值,requiresServerConfirm 把边界固定在类型上。

下一章从商品进入购物车:当本地车与服务端车同时存在时,谁合并谁、金额以哪一份为准。

阅读导航:上一节:2.2 SKU 选择与规格约束 · 下一节:3.1 购物车归属:本地车、服务端车与合并 。

常见错误与边界

  • 把 total 当可售展示,忽略已锁定库存。
  • 计算可售时不做 max(0, …),出现负库存文案。
  • 区域列表未命中时显示「售罄」,而不是「该地区不销售」。
  • 库存请求失败直接显示无货,把「不知道」说成「没有」。
  • 把端上预检通过当成有货承诺,结算页不再确认。
  • 预售与现货无货共用一个状态,用户不知道能不能买。
  • 快照过期后仍按实时库存展示,不降级、不提示。
  • 可售小于购买数量时仍允许提交,等服务端拒绝才提示。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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