《互联网产品的客户端架构·交易、增长与平台化》2.1 商详页的三份数据:价格、库存与时效

商详页的价格、库存、时效常来自三个接口、三种新鲜度,端上拼页面时很容易拼出一个「不存在的商品」。本节用字段级来源标记、年龄标注、版本一致性与库存时效校验,给出一个可运行的合并视图与下单闸门,并复盘一次价格和库存来自不同快照的典型接口与展示问题。

商详页为什么像一次「多源拼装」

商详页是交易端最像拼装的页面。用户看到的是一张卡片:价格、库存、发货地、预计送达。它们很少由同一个接口返回。价格可能来自营销价服务,库存来自库存中心,时效来自履约服务。三个接口的缓存策略、发布节奏、失败方式都不同。

如果端上把它们当成同一个对象的不同字段,就会出现一种很难排查的状态:价格是新的、库存是旧的、时效是昨天的。用户看到的是一个从未同时存在过的商品。后面的下单、支付、售后都建立在这个状态上,错误会被逐级放大。

本节要解决的问题很具体:如何在一个页面里诚实地展示三份不同时刻的数据,并且在进入下单前拒绝拼装结果。

三份数据各自的来源与新鲜度

数据典型来源变化频率浏览可接受过期过期后的展示
价格营销价 / 价格服务活动切换时30–60 秒旧价 + 「以下单时为准」
库存库存中心 / 区域仓秒级10–30 秒可售数或紧张度 + 刷新提示
时效履约服务小时级5–30 分钟「预计」而不是承诺

新鲜度要求不同,意味着不能用同一个 TTL 处理三份数据,也不能因为一份过期就把整页打成错误。价格过期可以继续浏览,库存过期要提示可能变化,时效过期只需标注「预计」。

真正危险的是另一种做法:把三份数据当成一个整体,只要有一份请求失败就整页报错。用户失去了浏览能力,端上也失去了降级空间。

端上要做的四个判断

  1. 字段级来源:每个字段独立标注 server 或 stale,不共享一个 loading 与一个错误态。
  2. 版本一致性:三份数据各带一个 revision,不一致时提示刷新,而不是静默混用。
  3. 字段校验:负价格、可售大于总量、时效区间倒置都要丢弃并记录冲突。
  4. 下单门槛:三份齐全、版本一致、没有过期字段,才允许进入结算。

这四条不是 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 返回数据,那属于接口契约测试。

一次「价格与库存来自两个版本」的复盘

某交易客户端在大促切换时出过一次典型问题。价格服务先发布了新价,库存服务还在旧版本。端上按字段合并,把新价格和旧的区域库存拼在同一个页面里。用户看到新价,点进结算;服务端按新版本校验,发现该区域根本不在可售范围。

复盘发现三个缺口:

  1. 三份响应没有共同的版本标识,端上无法知道自己拼了两个版本。
  2. 价格有过期标记,库存没有,页面只提示了价格。
  3. 进入结算前没有重新拉取,拼装结果被直接带进了下单流程。

修复对应三点:三份数据统一带 revision;不一致时禁止结算并自动刷新;每个字段独立标注年龄,价格与库存分别提示。修复后同类问题不再表现为「下单后才被拒」,而是在商详页就提示刷新。

这个复盘的教训不是「接口要一起发布」——现实中很难做到。教训是:端上必须有能力发现自己正在拼装不同版本,并且拒绝把拼装结果用于承诺。

小结

商详页的三份数据来自不同服务、不同新鲜度。端上不能把它们压成一个对象,而要让每个字段带来源、年龄与版本。合并视图只负责展示,canCheckout 负责把不完整、跨版本、已过期的视图挡在下单之外。

下一节处理商详页内部的另一组约束:当商品有多个规格时,哪些组合存在、哪些缺货、切换规格后价格与库存如何联动。

阅读导航:上一节:1.3 lab 的三条验证线:纯计算、进程内下单、真实回调 · 下一节:2.2 SKU 选择与规格约束 。

常见错误与边界

  • 三个接口共用一个 loading 与一个错误态,任意一份失败就整页不可用。
  • 用同一个 TTL 处理价格、库存与时效,忽略了它们的变化频率差异。
  • 服务端价格过期后回退到端上预览值,用户看到的是更旧或更乐观的数。
  • 只校验字段是否存在,不校验可售是否大于总量、时效区间是否倒置。
  • revision 不一致时仍然允许下单,把两个快照拼成一个商品。
  • 把「预计送达」渲染成确定时间,履约稍有波动就变成承诺违约。
  • 价格与库存分别刷新、分别覆盖,页面短暂出现跨版本组合。
  • 把 complete 当成 fresh,字段齐全就认为可以提交。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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