商详页为什么像一次「多源拼装」
商详页是交易端最像拼装的页面。用户看到的是一张卡片:价格、库存、发货地、预计送达。它们很少由同一个接口返回。价格可能来自营销价服务,库存来自库存中心,时效来自履约服务。三个接口的缓存策略、发布节奏、失败方式都不同。
如果端上把它们当成同一个对象的不同字段,就会出现一种很难排查的状态:价格是新的、库存是旧的、时效是昨天的。用户看到的是一个从未同时存在过的商品。后面的下单、支付、售后都建立在这个状态上,错误会被逐级放大。
本节要解决的问题很具体:如何在一个页面里诚实地展示三份不同时刻的数据,并且在进入下单前拒绝拼装结果。
三份数据各自的来源与新鲜度
| 数据 | 典型来源 | 变化频率 | 浏览可接受过期 | 过期后的展示 |
|---|---|---|---|---|
| 价格 | 营销价 / 价格服务 | 活动切换时 | 30–60 秒 | 旧价 + 「以下单时为准」 |
| 库存 | 库存中心 / 区域仓 | 秒级 | 10–30 秒 | 可售数或紧张度 + 刷新提示 |
| 时效 | 履约服务 | 小时级 | 5–30 分钟 | 「预计」而不是承诺 |
新鲜度要求不同,意味着不能用同一个 TTL 处理三份数据,也不能因为一份过期就把整页打成错误。价格过期可以继续浏览,库存过期要提示可能变化,时效过期只需标注「预计」。
真正危险的是另一种做法:把三份数据当成一个整体,只要有一份请求失败就整页报错。用户失去了浏览能力,端上也失去了降级空间。
端上要做的四个判断
- 字段级来源:每个字段独立标注
server或stale,不共享一个 loading 与一个错误态。 - 版本一致性:三份数据各带一个
revision,不一致时提示刷新,而不是静默混用。 - 字段校验:负价格、可售大于总量、时效区间倒置都要丢弃并记录冲突。
- 下单门槛:三份齐全、版本一致、没有过期字段,才允许进入结算。
这四条不是 UI 细节,而是「页面上的这个商品是否存在」的判定。只做前三条,页面会显示得很诚实;加上第四条,错误才不会流进订单。
合并视图的实现
下面的模块属于 L1:纯函数,时间由参数注入,不读 Date.now()。它把三份独立报价合并成一个带来源标记的视图。
// file: src/product/product-view.ts
// tier: L1
// verify: node --test test/product/product-view.test.ts
export type PriceKind = "list" | "activity" | "member";
export interface PriceQuote {
kind: PriceKind;
cents: number;
at: number;
revision: string;
}
export interface StockQuote {
total: number;
available: number;
region: string;
at: number;
revision: string;
}
export interface DeliveryQuote {
shipFrom: string;
etaDays: [number, number];
at: number;
revision: string;
}
export interface ProductInput {
isMember: boolean;
prices: PriceQuote[];
stock: StockQuote | null;
delivery: DeliveryQuote | null;
}
export type FieldSource = "server" | "stale";
export interface FieldView<T> {
value: T;
source: FieldSource;
ageMs: number;
revision: string;
}
export interface ProductView {
price: FieldView<{ kind: PriceKind; cents: number }> | null;
stock: FieldView<{ total: number; available: number; region: string }> | null;
delivery: FieldView<{ shipFrom: string; etaDays: [number, number] }> | null;
conflicts: string[];
mixedRevision: boolean;
complete: boolean;
staleFields: string[];
}
const PRICE_PRIORITY: Record<PriceKind, number> = {
member: 0,
activity: 1,
list: 2,
};
export interface PricePick {
quote: PriceQuote;
fresh: boolean;
conflicts: string[];
}
// 价格来自同一接口的多条报价:只保留用户实际可用的最低价;
// 会员价必须等会员身份确认后才参与比较,过期报价仍展示但标记为 stale。
export function selectPrice(
prices: PriceQuote[],
options: { isMember: boolean; now: number; ttlMs: number },
): PricePick | null {
const conflicts: string[] = [];
const seen = new Map<PriceKind, number>();
const eligible: PriceQuote[] = [];
for (const quote of prices) {
if (!Number.isSafeInteger(quote.cents) || quote.cents < 0) {
conflicts.push(`price:invalid:${quote.kind}`);
continue;
}
if (quote.kind === "member" && !options.isMember) continue;
const previous = seen.get(quote.kind);
if (previous !== undefined && previous !== quote.cents) {
conflicts.push(`price:duplicate:${quote.kind}`);
continue;
}
if (previous === undefined) seen.set(quote.kind, quote.cents);
eligible.push(quote);
}
if (eligible.length === 0) return null;
const fresh = eligible.filter((quote) => options.now - quote.at <= options.ttlMs);
const pool = fresh.length > 0 ? fresh : eligible;
pool.sort((a, b) =>
a.cents !== b.cents ? a.cents - b.cents : PRICE_PRIORITY[a.kind] - PRICE_PRIORITY[b.kind],
);
const quote = pool[0];
return { quote, fresh: options.now - quote.at <= options.ttlMs, conflicts };
}
export function fieldView<T>(
value: T,
at: number,
revision: string,
now: number,
ttlMs: number,
): FieldView<T> {
const ageMs = Math.max(0, now - at);
return { value, source: ageMs <= ttlMs ? "server" : "stale", ageMs, revision };
}
export function ageLabel(ageMs: number): string {
const seconds = Math.floor(Math.max(0, ageMs) / 1000);
if (seconds < 60) return `${seconds} 秒前`;
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes} 分钟前`;
return `${Math.floor(minutes / 60)} 小时前`;
}
function stockView(
quote: StockQuote | null,
now: number,
ttlMs: number,
conflicts: string[],
): FieldView<{ total: number; available: number; region: string }> | null {
if (quote === null) return null;
if (!Number.isInteger(quote.total) || !Number.isInteger(quote.available)) {
conflicts.push("stock:invalid");
return null;
}
if (quote.total < 0 || quote.available < 0) {
conflicts.push("stock:negative");
return null;
}
if (quote.available > quote.total) {
conflicts.push("stock:available>total");
return null;
}
const value = { total: quote.total, available: quote.available, region: quote.region };
return fieldView(value, quote.at, quote.revision, now, ttlMs);
}
function deliveryView(
quote: DeliveryQuote | null,
now: number,
ttlMs: number,
conflicts: string[],
): FieldView<{ shipFrom: string; etaDays: [number, number] }> | null {
if (quote === null) return null;
const [min, max] = quote.etaDays;
if (!Number.isInteger(min) || !Number.isInteger(max) || min < 0 || max < min) {
conflicts.push("delivery:invalid");
return null;
}
const value = { shipFrom: quote.shipFrom, etaDays: [min, max] as [number, number] };
return fieldView(value, quote.at, quote.revision, now, ttlMs);
}
// 三份数据合成一个视图:每份字段各自带来源与年龄,
// 版本号不一致时明确标记,避免把不同快照拼成一个「不存在的商品」。
export function mergeProductView(
input: ProductInput,
now: number,
ttlMs = 60_000,
): ProductView {
const conflicts: string[] = [];
const pick = selectPrice(input.prices, {
isMember: input.isMember,
now,
ttlMs,
});
if (pick !== null) conflicts.push(...pick.conflicts);
const price =
pick === null
? null
: fieldView(
{ kind: pick.quote.kind, cents: pick.quote.cents },
pick.quote.at,
pick.quote.revision,
now,
ttlMs,
);
const stock = stockView(input.stock, now, ttlMs, conflicts);
const delivery = deliveryView(input.delivery, now, ttlMs, conflicts);
const revisions: string[] = [];
if (price !== null) revisions.push(price.revision);
if (stock !== null) revisions.push(stock.revision);
if (delivery !== null) revisions.push(delivery.revision);
const mixedRevision = new Set(revisions).size > 1;
const staleFields: string[] = [];
if (price?.source === "stale") staleFields.push("price");
if (stock?.source === "stale") staleFields.push("stock");
if (delivery?.source === "stale") staleFields.push("delivery");
return {
price,
stock,
delivery,
conflicts,
mixedRevision,
complete: price !== null && stock !== null && delivery !== null,
staleFields,
};
}
export function viewBadges(view: ProductView): string[] {
const badges: string[] = [];
if (view.price?.source === "stale") {
badges.push(`价格 ${ageLabel(view.price.ageMs)}更新,以下单时为准`);
}
if (view.stock?.source === "stale") {
badges.push(`库存 ${ageLabel(view.stock.ageMs)}更新`);
}
if (view.delivery?.source === "stale") {
badges.push(`时效 ${ageLabel(view.delivery.ageMs)}更新`);
}
if (view.mixedRevision) badges.push("数据版本不一致,请刷新后下单");
if (!view.complete) badges.push("部分信息暂不可用");
return badges;
}
// 拼装出来的视图不能直接用于下单:必须三份齐全、版本一致且未过期。
export function canCheckout(view: ProductView): { ok: boolean; reason: string } {
if (!view.complete) return { ok: false, reason: "商品信息不完整" };
if (view.mixedRevision) return { ok: false, reason: "数据版本不一致" };
if (view.staleFields.length > 0) return { ok: false, reason: "信息已过期,请刷新" };
return { ok: true, reason: "" };
}
三个函数分别承担一件事。
selectPrice 在用户实际可用的报价里选最低价。会员价必须等会员身份确认后才参与比较;同一类报价出现两个金额时记录冲突,而不是随便取一个。服务端价格过期时,它不会回退到端上预览值——预览可能更旧,也可能基于过期数据算出。
mergeProductView 让每个字段各自调用 fieldView,因此价格、库存、时效可以有不同的年龄。版本号收集后只做一件事:判断是否一致。它不替业务决定「哪个版本更可信」,只把不一致暴露出来。
canCheckout 是拼装视图与下单之间的闸门。三份齐全只说明字段存在,版本一致说明它们来自同一份快照,没有过期字段说明还能用于决策。三条同时满足,页面才有资格说「可以买」。
测试与真实输出
本机运行结果如下:
$ node --test test/product/product-view.test.ts
✔ 可用报价中选最低价 (1.026166ms)
✔ 非会员不参与会员价比价 (0.083ms)
✔ 会员价参与比较时取更低的一份 (0.062708ms)
✔ 过期报价仍展示但标记为不新鲜 (0.058333ms)
✔ 同类报价金额冲突被记录 (0.416583ms)
✔ 非法金额被丢弃 (0.083875ms)
✔ 没有可用报价时返回 null (0.0585ms)
✔ 三份数据齐全时合并为完整视图 (0.205084ms)
✔ 缺一份数据时视图不完整 (0.125667ms)
✔ 库存可售大于总量视为冲突并丢弃库存 (0.116791ms)
✔ 负库存视为非法 (0.074334ms)
✔ 时效区间倒置时丢弃时效 (0.064667ms)
✔ 版本号不一致时标记 mixedRevision (0.058625ms)
✔ 过期字段被逐一列出 (0.059583ms)
✔ 年龄文案按秒、分钟、小时分级 (0.061625ms)
✔ 过期价格给出带年龄的提示 (0.088875ms)
✔ 完整、同版本、未过期时才允许进入下单 (0.08975ms)
✔ 拼装视图的四种拒单理由 (0.099542ms)
✔ fieldView 不把未来时间算成过期 (0.036416ms)
ℹ tests 19
ℹ suites 0
ℹ pass 19
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 127.973167
十九条用例覆盖:报价选择、会员资格、过期标记、重复与非法金额、三份合并、字段校验、版本冲突、年龄文案、下单闸门与未来时间。它们不验证服务端是否按同样的 TTL 返回数据,那属于接口契约测试。
一次「价格与库存来自两个版本」的复盘
某交易客户端在大促切换时出过一次典型问题。价格服务先发布了新价,库存服务还在旧版本。端上按字段合并,把新价格和旧的区域库存拼在同一个页面里。用户看到新价,点进结算;服务端按新版本校验,发现该区域根本不在可售范围。
复盘发现三个缺口:
- 三份响应没有共同的版本标识,端上无法知道自己拼了两个版本。
- 价格有过期标记,库存没有,页面只提示了价格。
- 进入结算前没有重新拉取,拼装结果被直接带进了下单流程。
修复对应三点:三份数据统一带 revision;不一致时禁止结算并自动刷新;每个字段独立标注年龄,价格与库存分别提示。修复后同类问题不再表现为「下单后才被拒」,而是在商详页就提示刷新。
这个复盘的教训不是「接口要一起发布」——现实中很难做到。教训是:端上必须有能力发现自己正在拼装不同版本,并且拒绝把拼装结果用于承诺。
小结
商详页的三份数据来自不同服务、不同新鲜度。端上不能把它们压成一个对象,而要让每个字段带来源、年龄与版本。合并视图只负责展示,canCheckout 负责把不完整、跨版本、已过期的视图挡在下单之外。
下一节处理商详页内部的另一组约束:当商品有多个规格时,哪些组合存在、哪些缺货、切换规格后价格与库存如何联动。
阅读导航:上一节:1.3 lab 的三条验证线:纯计算、进程内下单、真实回调 · 下一节:2.2 SKU 选择与规格约束 。
常见错误与边界
- 三个接口共用一个 loading 与一个错误态,任意一份失败就整页不可用。
- 用同一个 TTL 处理价格、库存与时效,忽略了它们的变化频率差异。
- 服务端价格过期后回退到端上预览值,用户看到的是更旧或更乐观的数。
- 只校验字段是否存在,不校验可售是否大于总量、时效区间是否倒置。
revision不一致时仍然允许下单,把两个快照拼成一个商品。- 把「预计送达」渲染成确定时间,履约稍有波动就变成承诺违约。
- 价格与库存分别刷新、分别覆盖,页面短暂出现跨版本组合。
- 把
complete当成fresh,字段齐全就认为可以提交。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。