同一个版本,三个区域三种能力
某产品在同一个版本里同时进入德国、巴西和阿联酋。发布前,三个区域的负责人分别试用,得到的结论却完全不同。
德国用户能下单,但支付页跳转到一个第三方托管收银台;巴西用户下单后只能选择「货到付款」,因为本地物流合作方只提供自提点;阿联酋用户能找到在线客服入口,但点进去只有邮件工单,没有实时聊天。
产品经理问:「我们到底支持不支持这些区域?」这个问题没有布尔答案。支付、物流、地图、客服是四类独立能力,每一类在不同区域可能处于不同状态:原生支持、合作方支持、受限支持、完全缺失。把它们压成一个「支持/不支持」的开关,只会让降级逻辑散落在各个页面里。
更麻烦的是区域判定。一个德国用户出差到新加坡,账号区域是 DE,IP 在 SG,手机时区是 Asia/Singapore,浏览器语言是 de-DE。四个信号指向三个区域,系统应该信哪个?
本节把区域能力矩阵、降级路径与区域判定优先级写成可测试模块,并给出判据与反例。文中的区域与能力状态都是构造示例,用于说明结构,不代表任何真实服务商的覆盖范围。
能力不是布尔值,而是四档状态
先定义状态。四档状态能同时表达「有没有」和「由谁提供」:
| 状态 | 含义 | 用户看到的行为 | 例子 |
|---|---|---|---|
native | 自有能力完整可用 | 应用内直接完成 | 中国区的应用内支付 |
partner | 通过合作方提供 | 跳转或嵌入合作方页面 | 德国的托管收银台 |
limited | 只有部分功能 | 功能收窄,但有明确入口 | 巴西的自提点物流 |
missing | 当前区域不可用 | 走兜底路径,不隐藏入口 | 无在线客服时的邮件工单 |
四档状态的价值在于:它强迫团队回答「受限时到底还剩什么」。如果只写「不支持」,产品和工程会在实现时各自发明降级方式,最后同一个区域在不同页面表现不一致。
能力矩阵的行是区域,列是能力。每一格填状态,空格按 missing 处理。矩阵应该由区域负责人评审,而不是由客户端同学凭印象填写。
| 区域 | 支付 | 物流 | 地图 | 客服 |
|---|---|---|---|---|
| CN | native | native | native | native |
| US | native | partner | native | native |
| DE | partner | partner | partner | limited |
| BR | partner | limited | partner | limited |
| AE | partner | partner | native | limited |
这张表最重要的作用不是展示覆盖范围,而是让每一格的降级路径都有负责人。partner 的失败由谁兜底,limited 的功能边界由谁定义,missing 的入口由谁决定是否隐藏,都要在矩阵旁边写清楚。
降级路径必须是一条链,不是一个提示
降级不是「弹一个 toast 说暂不支持」。降级是一条有序的通道链:原生失败走合作方,合作方失败走受限通道,最后才是人工兜底。每一档状态对应链上的一个起点。
| 能力 | native | partner | limited | missing |
|---|---|---|---|---|
| 支付 | 应用内支付 | 托管收银台 | Web 收银台 | 人工发票 |
| 物流 | 应用内跟踪 | 承运商跟踪 | 自提点 | 人工录入单号 |
| 地图 | 应用内地图 | 地图 SDK | 静态地图 | 地址文本 |
| 客服 | 应用内聊天 | 合作方工单 | 邮件工单 | 常见问题 |
降级链要满足三个条件。
第一,每一档都有可执行的通道。写「联系客服」不算通道,必须写清楚是应用内聊天、邮件工单还是电话。
第二,降级是单调的。从 partner 开始降级,只能往 limited 与 missing 走,不能绕过中间档直接跳到最底层。否则用户会从「托管收银台」直接掉到「人工发票」,体验断崖式下跌。
第三,降级要可观测。每次降级都应该记录区域、能力、起始状态与最终通道。没有这些记录,团队只知道「支付成功率下降」,不知道是合作方故障还是区域配置错误。
区域判定:优先级与置信度
区域判定最常见的错误是只看 IP。IP 会被 VPN、企业代理、跨境专线改变;用户出差时 IP 与账号区域也会不一致。更稳的做法是把多个信号按优先级排列,并记录最终采用了哪个信号。
| 优先级 | 信号 | 置信度 | 适用场景 | 风险 |
|---|---|---|---|---|
| 1 | 显式区域参数 | 1.00 | 用户手动选择、B 端合同区域 | 可能被伪造,需要服务端校验 |
| 2 | 账号区域 | 0.90 | 已登录用户的账单与收货地址 | 用户搬家后未更新 |
| 3 | IP 国家 | 0.75 | 未登录用户、首屏兜底 | VPN、企业代理、跨境专线 |
| 4 | 时区 | 0.55 | 客户端本地推断 | 出差、跨时区办公 |
| 5 | locale | 0.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 语义结构与焦点顺序 。
常见错误与边界
- 把区域能力写成布尔开关。 布尔值无法表达合作方、受限与缺失三种状态,降级逻辑会散落在页面里。
- 只按 IP 判断区域。 VPN、企业代理与跨境专线会让 IP 偏离用户真实归属,必须结合账号与显式选择。
- 把 locale 当成区域。 语言偏好不等于地理位置,
en-US用户可能在德国,de-DE用户也可能在巴西。 - 未知区域默认按可用处理。 配置缺失时返回
native会把用户带进必然失败的流程,未知区域应该保守返回missing。 - 降级只弹提示不给通道。 「暂不支持」不是降级,必须告诉用户还能用什么,以及入口在哪里。
- 降级链允许跳档。 从合作方直接掉到人工兜底会让体验断崖式下跌,降级应该逐档往下走。
- 降级没有观测。 不记录区域、能力、起始状态与最终通道,就无法区分配置错误与合作方故障。
- 合作方失败后无限重试。 重试要有次数与时间上限,超过后必须进入下一档,否则用户会卡在同一个失败通道。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。