请求语言不是一个字符串偏好那么简单。它同时影响资源命中、模型验证、日期数字格式、缓存分区、响应语义与日志诊断。当前实现把这些上下文统一起来了,但还没有把“语法合法”“运行时文化存在”“租户允许使用”三个概念分开。
1. 中间件位置
UseRequestLocalization() 先运行,随后经过认证、委托、租户解析和租户模拟,才进入 LanguageResolutionMiddleware。因此自定义中间件能读取租户、当前用户与 Office,并再次覆盖 .NET Culture。
组合根注释中较早的总管道摘要没有列出 LanguageResolution,但实际调用顺序以上述代码为准。修改顺序时必须回归代理头、认证失败、租户失败和异常响应是否仍带正确语言。
2. 解析优先级
当前顺序是 Header、Cookie、Query,三者都没有时读取 platform.i18n.defaultLanguage;Setting 失败或返回空值最终使用 zh-CN。
GET /api/i18n/languages?lang=en-US HTTP/1.1Host: api.example.testAccept-Language: zh-CN,en-US;q=0.9Cookie: bitzorcas.lang=en-US
# 当前结果是 zh-CN,因为中间件只取 Header 第一项。这意味着网页语言选择器若只改 Cookie/Query,却没有同步浏览器 Header,选择可能看似不生效。GA 产品应明确是“显式用户偏好优先”还是“每次请求 Header 优先”。
3. Accept-Language 不是完整协商
实现用逗号拆分后取第一项,再去掉分号后的 q 参数。它不排序 q 值、不识别 *、不忽略 q=0、不做 zh-Hans-CN → zh-CN → zh 匹配,也不与启用语言目录求交集。
GET /api/i18n/resources HTTP/1.1Accept-Language: fr-FR;q=0.1, en-US;q=1.0
# 当前解析 fr-FR;标准质量值没有参与排序。若网关或 CDN 按 Accept-Language 缓存响应,应用还要正确设置 Vary 或把语言写入缓存键。当前中间件只设置 Content-Language,没有设置 Vary: Accept-Language。
4. 语言代码正则
正则允许 2–3 个字母主标签,加一个 2–4 位字母数字子标签。它能挡住换行、路径和长输入,但不等于完整 BCP-47:脚本、扩展、私有使用与多段标签不能表达;看似合法但不存在的代码仍可通过。
// ① 语法:拒绝控制字符和不受支持的标签形态。var parsed = LanguageTag.TryParse(requestedTag);if (parsed.IsFailure) return Errors.InvalidLanguageTag(requestedTag);
// ② 运行时:确认框架能创建 CultureInfo。var culture = CultureInfo.GetCultureInfo(parsed.Value.CanonicalName);
// ③ 产品:确认该租户启用了规范化后的语言。if (!await languageCatalog.IsEnabledAsync(tenantId, culture.Name, cancellationToken)) return Errors.LanguageNotEnabled(culture.Name);这是目标设计示例。当前实现只做第一类的简化正则;CultureNotFoundException 被吞掉,第三类完全没有执行。
5. 大小写与规范化
输入不会通过 CultureInfo.Name 或专用 BCP-47 类型规范化。ZH-cn 可能成功创建 CultureInfo,但 ILanguageContext、缓存键和数据库查询仍使用原字符串。数据库比较是否区分大小写又取决于 Provider/Collation,可能造成跨环境差异。
所有入口应在进入缓存、设置、数据库和响应头前得到同一个 canonical tag。迁移时还要清理现存 zh-cn、ZH-CN 等重复行,并让唯一索引按规范值工作。
6. 默认语言 Setting
platform.i18n.defaultLanguage 在 Setting Registry 中是 Global Scope,默认 zh-CN;中间件却以当前 TenantId 读取 SettingsManager。最终是否允许租户 override 由 Setting 引擎的 scope 规则决定,不能仅看调用参数断言。
Setting 读取失败被当作无值并回退 zh-CN,没有诊断响应或降级标记。商业运行至少应记录结构化指标:setting failure、invalid configured culture、not-enabled culture 与 fallback reason,且不要把 Cookie/Header 原文无界写入日志。
7. 四个同步上下文
解析成功后中间件更新:
- Scoped
ILanguageContext.CurrentLanguage; CultureInfo.CurrentUICulture;CultureInfo.CurrentCulture;- 静态
L.SetContext(language, tenant, office, localizer)的 AsyncLocal。
CurrentUICulture 适合资源查找,CurrentCulture 影响日期、数字和货币解析/格式化。把二者设置成同一语言很方便,但用户“界面语言”和“地区格式”可能不同;例如英文界面与中国时区/人民币格式不应被强绑定。
8. 全局 L() 生命周期
L.SetContext 让下游同步代码使用 L("Error.Key")。请求结束在 finally 中调用 L.ClearContext(),降低线程池复用导致上下文泄漏的风险。
// Domain/Application 保留稳定机器码;不要把中文文案写入聚合规则。var result = await sender.Send(command, cancellationToken);if (result.IsFailure){ // 只在 API/UI 映射边界解析人类可读文本。 var message = L(result.Error.Code); return Results.Problem(title: message, extensions: new { result.Error.Code });}不要在脱离 HTTP 请求的后台 Job、并行 fire-and-forget 或测试中假设 AsyncLocal 已设置。它也不替代显式 tenant/language 参数;跨消息边界应携带规范化语言合同。
9. 响应头
中间件用 OnStarting 设置 Content-Language,但如果下游已设置则保留下游值。即使 CultureInfo 创建失败,响应头仍可能是无效/不支持的输入代码。
错误响应、认证拒绝与中间件前置短路是否经过这段 OnStarting 取决于管道位置。要用真实 Host 测 400/401/403/404/429/500,不要只测成功 Endpoint。
10. 输入安全与缓存污染
正则限制输入长度和字符,能减少直接缓存键注入,但大量不同合法形态仍可制造 language 维度高基数。Localizer 会为每个 language/tenant/office 组合创建 30 分钟缓存项。
GA 应先与启用语言 allowlist 求交集,再进入缓存;对无效语言负缓存或统一回退,限制每租户语言数量,并监控 cache-key cardinality。Header 也应有总长度限制,避免解析与日志放大。
11. 目标协商算法
结果对象应同时给出 Requested、Resolved、FallbackReason 和 FormattingCulture,便于响应头、指标和审计保持一致。不要让各端点自行再解析一次。
12. 测试矩阵
| 场景 | 必须断言 |
|---|---|
| Header/Cookie/Query 冲突 | 与公开优先级完全一致 |
| q 值、通配符、q=0 | 标准或明确限制行为 |
| 大小写与别名 | canonical tag 唯一 |
| 正则通过但 Culture 不存在 | 明确回退/Validation,不回显脏值 |
| 语言已禁用 | 不进入 Localizer 缓存 |
| Setting 失败/无效 | fallback reason 与指标 |
| UI Culture 与 Format Culture | 产品规则可独立配置 |
| 并行请求 | AsyncLocal 不串租户/语言 |
| 401/403/429/500 | Content-Language 合同一致 |
| Header 高基数攻击 | bounded cache cardinality |
13. 检查命令
# 解析优先级、正则、Culture 与 AsyncLocal 生命周期。rg -n "ResolveFromHeader|ResolveFromCookie|ResolveFromQuery|LanguageCodePattern|L\.SetContext|L\.ClearContext" \ src/Hosts/BitzOrcas.Api/Middleware/LanguageResolutionMiddleware.cs
# 完整 q 协商、启用语言检查与 Vary 当前预期无命中。rg -n "Quality|q=|GetEnabledAsync|Vary|ResolvedLanguage" \ src/Hosts/BitzOrcas.Api/Middleware/LanguageResolutionMiddleware.cs