「有货」是一个被过度简化的词
商详页上的库存通常只有三种文案:有货、仅剩 N 件、无货。但服务端实际维护的是一组数:总库存、锁定库存、可售、区域可售、预售。它们不是同一个概念,也不能互相替代。
端上最容易犯的两个错误,一个偏乐观,一个偏悲观:
- 把可售当成有货承诺。 页面显示「仅剩 3 件」,用户下单时却被服务端拒绝,因为另外两个订单在同一秒锁定了库存。
- 把请求失败当成售罄。 库存接口超时,端上直接显示「无货」,用户以为商品卖完了,实际上只是这一屏没取到数。
这两个错误的共同根因,是端上把「展示判断」当成了「库存事实」。库存事实只在服务端的下单事务里成立。
五个数之间的关系
| 字段 | 含义 | 端上怎么用 |
|---|---|---|
total | 仓库账面总量 | 只用于对账与校验 |
locked | 未支付订单占用的数量 | 从可售中扣除 |
| 可售 | max(0, total - locked) | 展示与预检的基础 |
| 区域可售 | 某个区域仓实际可发数量 | 命中区域时优先于全国可售 |
| 预售 | 未来库存,不承诺现货 | 允许尝试,展示预售时间 |
可售的计算很简单,难的是解释。total 不是「有货数」,它包含已经被别人锁定但还没付款的部分;locked 也不是「卖出数」,订单取消后会释放。端上把 total 直接渲染成「库存 N 件」,是很多「页面有货、下单无货」的起点。
端上判断的边界
端上能做三件事:
- 预检:明显不可买的组合提前禁用,减少无效提交。
- 降级:请求失败、数据非法、地区未知时,给出可理解的提示。
- 展示:把可售数、紧张度、预售、区域限制翻译成用户能懂的文案。
端上不能做两件事:
- 锁库存:端上没有事务,也没有跨用户可见性。
- 承诺有货:预检通过只代表「可以尝试下单」。
代码里用 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」都映射成同一个「售罄」状态。
用户看到的是「已售罄」,实际含义是「不配送」。客服收到大量「什么时候补货」的询问,运营则以为库存被瞬间抢空。复盘发现三个问题:
- 区域未命中与可售为零共用一个 UI 状态。
- 请求失败也走同一个「售罄」分支,进一步放大了误判。
- 页面没有「以下单结果为准」的提示,用户把展示当成了承诺。
修复把状态拆成 region_blocked、out_of_stock、unknown 三种:不配送显示「当前地区暂不销售」,无货显示「暂时无货」,请求失败显示「库存信息暂不可用」。同时,所有库存判断统一标注需要服务端确认。
这个复盘留下一条判断:库存展示的第一原则不是「准确」,而是「不撒谎」。 取不到数时说取不到,比猜一个「无货」更接近事实。
小结
库存至少有五个数:总量、锁定、可售、区域可售、预售。端上能算可售、能做预检、能降级展示,但不能锁库存,也不能承诺有货。decideStock 把所有状态收进一个返回值,requiresServerConfirm 把边界固定在类型上。
下一章从商品进入购物车:当本地车与服务端车同时存在时,谁合并谁、金额以哪一份为准。
阅读导航:上一节:2.2 SKU 选择与规格约束 · 下一节:3.1 购物车归属:本地车、服务端车与合并 。
常见错误与边界
- 把
total当可售展示,忽略已锁定库存。 - 计算可售时不做
max(0, …),出现负库存文案。 - 区域列表未命中时显示「售罄」,而不是「该地区不销售」。
- 库存请求失败直接显示无货,把「不知道」说成「没有」。
- 把端上预检通过当成有货承诺,结算页不再确认。
- 预售与现货无货共用一个状态,用户不知道能不能买。
- 快照过期后仍按实时库存展示,不降级、不提示。
- 可售小于购买数量时仍允许提交,等服务端拒绝才提示。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。