《互联网产品的客户端架构·规模化与架构演进》9.3 区域能力差异与降级

同一版本在德国能刷卡、在巴西只能货到付款、在阿联酋没有在线客服。区域能力不是布尔开关,而是有优先级的矩阵与降级链。本节把支付、物流、地图、客服四类能力、缺失时的降级路径与区域判定优先级写成可测试模块,避免同一区域在不同页面表现不一致,并给出判据与反例。

同一个版本,三个区域三种能力

某产品在同一个版本里同时进入德国、巴西和阿联酋。发布前,三个区域的负责人分别试用,得到的结论却完全不同。

德国用户能下单,但支付页跳转到一个第三方托管收银台;巴西用户下单后只能选择「货到付款」,因为本地物流合作方只提供自提点;阿联酋用户能找到在线客服入口,但点进去只有邮件工单,没有实时聊天。

产品经理问:「我们到底支持不支持这些区域?」这个问题没有布尔答案。支付、物流、地图、客服是四类独立能力,每一类在不同区域可能处于不同状态:原生支持、合作方支持、受限支持、完全缺失。把它们压成一个「支持/不支持」的开关,只会让降级逻辑散落在各个页面里。

更麻烦的是区域判定。一个德国用户出差到新加坡,账号区域是 DE,IP 在 SG,手机时区是 Asia/Singapore,浏览器语言是 de-DE。四个信号指向三个区域,系统应该信哪个?

本节把区域能力矩阵、降级路径与区域判定优先级写成可测试模块,并给出判据与反例。文中的区域与能力状态都是构造示例,用于说明结构,不代表任何真实服务商的覆盖范围。

能力不是布尔值,而是四档状态

先定义状态。四档状态能同时表达「有没有」和「由谁提供」:

状态含义用户看到的行为例子
native自有能力完整可用应用内直接完成中国区的应用内支付
partner通过合作方提供跳转或嵌入合作方页面德国的托管收银台
limited只有部分功能功能收窄,但有明确入口巴西的自提点物流
missing当前区域不可用走兜底路径,不隐藏入口无在线客服时的邮件工单

四档状态的价值在于:它强迫团队回答「受限时到底还剩什么」。如果只写「不支持」,产品和工程会在实现时各自发明降级方式,最后同一个区域在不同页面表现不一致。

能力矩阵的行是区域,列是能力。每一格填状态,空格按 missing 处理。矩阵应该由区域负责人评审,而不是由客户端同学凭印象填写。

区域支付物流地图客服
CNnativenativenativenative
USnativepartnernativenative
DEpartnerpartnerpartnerlimited
BRpartnerlimitedpartnerlimited
AEpartnerpartnernativelimited

这张表最重要的作用不是展示覆盖范围,而是让每一格的降级路径都有负责人。partner 的失败由谁兜底,limited 的功能边界由谁定义,missing 的入口由谁决定是否隐藏,都要在矩阵旁边写清楚。

降级路径必须是一条链,不是一个提示

降级不是「弹一个 toast 说暂不支持」。降级是一条有序的通道链:原生失败走合作方,合作方失败走受限通道,最后才是人工兜底。每一档状态对应链上的一个起点。

能力nativepartnerlimitedmissing
支付应用内支付托管收银台Web 收银台人工发票
物流应用内跟踪承运商跟踪自提点人工录入单号
地图应用内地图地图 SDK静态地图地址文本
客服应用内聊天合作方工单邮件工单常见问题

降级链要满足三个条件。

第一,每一档都有可执行的通道。写「联系客服」不算通道,必须写清楚是应用内聊天、邮件工单还是电话。

第二,降级是单调的。从 partner 开始降级,只能往 limited 与 missing 走,不能绕过中间档直接跳到最底层。否则用户会从「托管收银台」直接掉到「人工发票」,体验断崖式下跌。

第三,降级要可观测。每次降级都应该记录区域、能力、起始状态与最终通道。没有这些记录,团队只知道「支付成功率下降」,不知道是合作方故障还是区域配置错误。

区域判定:优先级与置信度

区域判定最常见的错误是只看 IP。IP 会被 VPN、企业代理、跨境专线改变;用户出差时 IP 与账号区域也会不一致。更稳的做法是把多个信号按优先级排列,并记录最终采用了哪个信号。

优先级信号置信度适用场景风险
1显式区域参数1.00用户手动选择、B 端合同区域可能被伪造,需要服务端校验
2账号区域0.90已登录用户的账单与收货地址用户搬家后未更新
3IP 国家0.75未登录用户、首屏兜底VPN、企业代理、跨境专线
4时区0.55客户端本地推断出差、跨时区办公
5locale0.40语言偏好语言与区域不是一一对应
6默认区域0.20所有信号都缺失可能给出错误的市场默认值

优先级不是「谁最准」,而是「谁最能代表用户意图」。用户手动选择的区域应该压过所有推断;账号区域代表长期归属;IP 代表当前网络位置;时区与 locale 只是弱信号。

置信度不是装饰。它应该参与后续决策:低置信度的区域可以展示更多确认提示,或者在涉及资金时要求用户二次确认。把置信度写进返回值,比在日志里猜「这次为什么判成 DE」可靠得多。

把矩阵与判定写成模块

下面的模块把能力状态、降级链、区域信号优先级都变成纯函数。它不访问网络、不读环境变量,所有输入显式传入,因此可以在测试里覆盖每一个优先级分支。

// file: src/i18n/region-capability.ts
// tier: L1
// verify: node --test test/i18n/region-capability.test.ts

export type Capability = "payment" | "logistics" | "maps" | "support";

export type CapabilityState = "native" | "partner" | "limited" | "missing";

export type RegionCode = string;

export type RegionSource = "explicit" | "account" | "ip" | "timezone" | "locale" | "default";

export type RegionInput = {
  explicit?: string;
  account?: string;
  ipCountry?: string;
  timezone?: string;
  locale?: string;
};

export type RegionResolution = {
  region: RegionCode;
  source: RegionSource;
  confidence: number;
  reason: string;
};

export type FallbackStep = {
  state: CapabilityState;
  channel: string;
};

export type DegradationPlan = {
  region: RegionCode;
  capability: Capability;
  state: CapabilityState;
  path: string[];
  degraded: boolean;
};

export const CAPABILITIES: Capability[] = ["payment", "logistics", "maps", "support"];

export const CAPABILITY_RANK: Record<CapabilityState, number> = {
  native: 3,
  partner: 2,
  limited: 1,
  missing: 0,
};

export const DEFAULT_REGION: RegionCode = "US";

export const REGION_CAPABILITIES: Record<RegionCode, Partial<Record<Capability, CapabilityState>>> = {
  CN: { payment: "native", logistics: "native", maps: "native", support: "native" },
  US: { payment: "native", logistics: "partner", maps: "native", support: "native" },
  JP: { payment: "native", logistics: "partner", maps: "partner", support: "limited" },
  SG: { payment: "native", logistics: "partner", maps: "native", support: "native" },
  DE: { payment: "partner", logistics: "partner", maps: "partner", support: "limited" },
  BR: { payment: "partner", logistics: "limited", maps: "partner", support: "limited" },
  IN: { payment: "partner", logistics: "partner", maps: "native", support: "limited" },
  AE: { payment: "partner", logistics: "partner", maps: "native", support: "limited" },
};

export const FALLBACKS: Record<Capability, FallbackStep[]> = {
  payment: [
    { state: "native", channel: "in-app-pay" },
    { state: "partner", channel: "hosted-checkout" },
    { state: "limited", channel: "web-checkout" },
    { state: "missing", channel: "invoice-manual" },
  ],
  logistics: [
    { state: "native", channel: "in-app-tracking" },
    { state: "partner", channel: "carrier-tracking" },
    { state: "limited", channel: "pickup-point" },
    { state: "missing", channel: "manual-tracking" },
  ],
  maps: [
    { state: "native", channel: "in-app-map" },
    { state: "partner", channel: "map-sdk" },
    { state: "limited", channel: "static-map" },
    { state: "missing", channel: "address-text" },
  ],
  support: [
    { state: "native", channel: "in-app-chat" },
    { state: "partner", channel: "vendor-desk" },
    { state: "limited", channel: "email-ticket" },
    { state: "missing", channel: "faq-only" },
  ],
};

export const TIMEZONE_REGIONS: Record<string, RegionCode> = {
  "Asia/Shanghai": "CN",
  "Asia/Chongqing": "CN",
  "Asia/Urumqi": "CN",
  "Asia/Tokyo": "JP",
  "Asia/Seoul": "KR",
  "Asia/Singapore": "SG",
  "Asia/Kolkata": "IN",
  "Asia/Calcutta": "IN",
  "Asia/Dubai": "AE",
  "Europe/Berlin": "DE",
  "Europe/Paris": "FR",
  "Europe/London": "GB",
  "America/New_York": "US",
  "America/Chicago": "US",
  "America/Los_Angeles": "US",
  "America/Sao_Paulo": "BR",
  "America/Argentina": "AR",
  "America/Indiana": "US",
};

const CONFIDENCE: Record<RegionSource, number> = {
  explicit: 1,
  account: 0.9,
  ip: 0.75,
  timezone: 0.55,
  locale: 0.4,
  default: 0.2,
};

export function normalizeRegion(value?: string): RegionCode | undefined {
  if (!value) return undefined;
  const code = value.trim().toUpperCase();
  return /^[A-Z]{2}$/.test(code) ? code : undefined;
}

export function regionFromLocale(locale: string): RegionCode | undefined {
  const parts = locale.replace(/_/g, "-").split("-").filter((part) => part.length > 0);
  for (let index = parts.length - 1; index >= 1; index -= 1) {
    if (/^[A-Za-z]{2}$/.test(parts[index])) return parts[index].toUpperCase();
  }
  return undefined;
}

export function regionFromTimeZone(timeZone: string): RegionCode | undefined {
  const exact = TIMEZONE_REGIONS[timeZone];
  if (exact) return exact;
  for (const [prefix, region] of Object.entries(TIMEZONE_REGIONS)) {
    if (timeZone.startsWith(`${prefix}/`)) return region;
  }
  return undefined;
}

export function capabilityFor(region: string, capability: Capability): CapabilityState {
  const code = normalizeRegion(region);
  if (!code) return "missing";
  return REGION_CAPABILITIES[code]?.[capability] ?? "missing";
}

export function degradationPlan(region: string, capability: Capability): DegradationPlan {
  const state = capabilityFor(region, capability);
  const steps = FALLBACKS[capability];
  const index = steps.findIndex((step) => step.state === state);
  const start = index < 0 ? steps.length - 1 : index;
  const path = state === "native" ? [steps[0].channel] : steps.slice(start).map((step) => step.channel);
  return {
    region: normalizeRegion(region) ?? region.trim().toUpperCase(),
    capability,
    state,
    path,
    degraded: state !== "native",
  };
}

export function capabilityGaps(
  region: string,
  required: Capability[],
  minimum: CapabilityState = "limited",
): Capability[] {
  const threshold = CAPABILITY_RANK[minimum];
  return required.filter((capability) => CAPABILITY_RANK[capabilityFor(region, capability)] < threshold);
}

export function supportedCapabilities(region: string): Capability[] {
  return CAPABILITIES.filter((capability) => capabilityFor(region, capability) !== "missing");
}

function resolution(
  region: RegionCode,
  source: RegionSource,
  reason: string,
): RegionResolution {
  return { region, source, confidence: CONFIDENCE[source], reason };
}

export function resolveRegion(input: RegionInput, fallback: RegionCode = DEFAULT_REGION): RegionResolution {
  const explicit = normalizeRegion(input.explicit);
  if (explicit) return resolution(explicit, "explicit", `explicit region: ${explicit}`);

  const account = normalizeRegion(input.account);
  if (account) return resolution(account, "account", `account region: ${account}`);

  const ip = normalizeRegion(input.ipCountry);
  if (ip) return resolution(ip, "ip", `ip country: ${ip}`);

  if (input.timezone) {
    const fromZone = regionFromTimeZone(input.timezone);
    if (fromZone) return resolution(fromZone, "timezone", `timezone: ${input.timezone}`);
  }

  if (input.locale) {
    const fromLocale = regionFromLocale(input.locale);
    if (fromLocale) return resolution(fromLocale, "locale", `locale: ${input.locale}`);
  }

  const safeFallback = normalizeRegion(fallback) ?? DEFAULT_REGION;
  return resolution(safeFallback, "default", "no signal matched");
}

模块里有三个关键决策。

第一,capabilityFor 对未知区域一律返回 missing,而不是抛错或返回 native。未知区域的默认行为必须是保守的,不能因为配置缺失就假设能力可用。

第二,degradationPlan 返回的是从当前状态开始的完整通道链,而不是单个通道。调用方可以按顺序尝试,也可以在 UI 上展示「首选 / 备选 / 兜底」。

第三,resolveRegion 把每个信号的来源与置信度一起返回。调用方不仅知道「判成哪个区域」,还知道「为什么」和「有多确定」。

判据与反例

判断一套区域能力实现是否可靠,看五条判据。

判据一:能力状态是四档,不是布尔。 代码里没有 supportsPayment(country) 这样的布尔函数,取而代之的是 capabilityFor(region, "payment") 返回四档状态。

判据二:每一档都有显式降级通道。 从 partner、limited、missing 出发,都能得到一条非空且有序的通道链,链上的每个通道都有负责人。

判据三:区域判定有优先级与来源。 显式参数、账号、IP、时区、locale、默认值按固定顺序尝试,返回值包含 source 与 confidence,日志可以复算。

判据四:未知区域保守处理。 未登记的区域返回 missing,不返回 native,也不静默使用默认区域的能力矩阵。

判据五:降级可观测。 每次降级都记录区域、能力、起始状态、最终通道与耗时,能区分「区域没配置」与「合作方故障」。

反例一:用 if (country === "CN") 判断支付能力。所有非中国区都会落到同一条 else 分支,德国与巴西的差异被抹平。

反例二:只用 IP 判定区域。用户开 VPN 后看到另一个市场的支付方式,账号区域与账单地址却对不上,风控直接拒绝交易。

反例三:降级时只弹「暂不支持」。用户不知道还能用什么,客服也不知道该引导到哪里,最终变成工单堆积。

反例四:把 locale 当成区域。en-US 只能说明语言偏好是美国英语,不能说明用户在美国;一个在德国的用户完全可能使用 en-US。

反例五:合作方失败后无限重试同一个通道。降级链的意义是往下走,不是原地重试;重试应该有次数上限,超过后进入下一档。

小结

区域能力是矩阵,不是开关;降级是链路,不是提示。

  • 支付、物流、地图、客服分别维护四档状态:native、partner、limited、missing。
  • 每一档状态对应一条有序降级链,链上的通道必须可执行、单调、可观测。
  • 区域判定按显式参数、账号、IP、时区、locale、默认值的顺序进行,并记录来源与置信度。
  • 未知区域保守返回 missing,不假设能力可用,也不静默套用默认市场。
  • 降级日志要能区分配置缺失与合作方故障,否则无法定位问题。

先画矩阵,再写降级,最后接区域判定。顺序反了,代码里会长出一堆按国家名判断的 if。

阅读导航:上一节:9.2 RTL、排版与本地内容 · 下一节:10.1 语义结构与焦点顺序 。

常见错误与边界

  1. 把区域能力写成布尔开关。 布尔值无法表达合作方、受限与缺失三种状态,降级逻辑会散落在页面里。
  2. 只按 IP 判断区域。 VPN、企业代理与跨境专线会让 IP 偏离用户真实归属,必须结合账号与显式选择。
  3. 把 locale 当成区域。 语言偏好不等于地理位置,en-US 用户可能在德国,de-DE 用户也可能在巴西。
  4. 未知区域默认按可用处理。 配置缺失时返回 native 会把用户带进必然失败的流程,未知区域应该保守返回 missing。
  5. 降级只弹提示不给通道。 「暂不支持」不是降级,必须告诉用户还能用什么,以及入口在哪里。
  6. 降级链允许跳档。 从合作方直接掉到人工兜底会让体验断崖式下跌,降级应该逐档往下走。
  7. 降级没有观测。 不记录区域、能力、起始状态与最终通道,就无法区分配置错误与合作方故障。
  8. 合作方失败后无限重试。 重试要有次数与时间上限,超过后必须进入下一档,否则用户会卡在同一个失败通道。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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