Skip to content
bitzorcas
中EN

Reference

I18n 翻译作用域与持久化

深入解释 SysTranslation/SysLanguage 物理模型、三层 Scope 覆盖、唯一索引、写入身份、状态、读模型、双 ORM 边界以及 MasterData owner 偏差。

Last updated

I18n 的业务语言是“翻译”,但物理表类型位于 MasterData Infrastructure。理解这一点很重要:公开 Contracts 没有泄漏持久化类型,然而 Infrastructure-to-Infrastructure 引用和治理依赖声明仍存在偏差。

1. 两张表的 owner

SysTranslationCatalogRecord 和 SysLanguageCatalogRecord 都在 BitzOrcas.Platform.MasterData.Infrastructure.Persistence。I18n Infrastructure 项目直接引用 MasterData Infrastructure,并用这些类型构造 IEntitySet<T>。

I18n.Contracts
DTO + enum

I18n.Application
Ports + handlers

I18n.Infrastructure
Read/write adapters

MasterData.Infrastructure
SysTranslation + SysLanguage

IEntitySet
SqlSugar / EF Core

治理 marker 只声明 Authorization,未声明 MasterData。应选择:把表与配置迁回 I18n owner,或由 MasterData 提供窄公开契约/读写端口并更新治理事实。长期保留跨模块 Infrastructure 引用会让迁移、测试和发布边界模糊。

2. SysTranslation 字段

字段当前合同
TranslationKey必填,最长 300
LanguageCode必填,最长 20,未规范化
ScopeTypeint,期望 0/1/2,数据库未见 check constraint
TenantId继承,Platform 写 "0"
OfficeId继承 string,无 Office 写 "0"
Context可空,最长 200,目前不参与选择
Value必填,最长 2000
Statusint,读取只接受 Active=0
IsDeleted/DeleteTime继承软删除元数据

表声明 IsTenant=true、IsSoftDelete=true。ReadModelStore 谓词没有显式 IsDeleted,是否由 IEntitySet 自动注入全局过滤器需要双 ORM 测试证明;文档不能只因元数据存在就承诺。

3. 业务唯一键

唯一索引:

TranslationKey + LanguageCode + ScopeType + TenantId + OfficeId

它为 Upsert 提供最后防线,但没有 Context、Status 或版本。OfficeId 在 Platform/Tenant 作用域必须固定 "0",否则逻辑上重复的数据可绕开唯一键。LanguageCode 大小写/别名也可产生重复,取决于数据库 Collation。

4. 作用域编码

TranslationScope 固定为 Platform=0、Tenant=1、TenantOffice=2。Repository 写入时把 Platform TenantId 设为 "0";其他 Scope 使用当前 TenantId或 "0"。只有 TenantOffice 保留当前 OfficeId,其他 Scope 写 "0"。

写入前应验证作用域上下文
// 目标规则:Tenant 和 TenantOffice 都必须有真实租户。
if (scope is TranslationScope.Tenant or TranslationScope.TenantOffice
&& string.IsNullOrWhiteSpace(currentTenantId))
return Errors.TenantRequired();
// 目标规则:办公室覆盖不能退化为 OfficeId=0。
if (scope is TranslationScope.TenantOffice && currentOfficeId is null)
return Errors.OfficeRequired();
// Platform 写应使用专用平台权限,而非任意 Application caller。
await platformPolicy.EnsureCanManageTranslationsAsync(currentActor, cancellationToken);

这段目标校验已经落地。Handler 在到达 Repository 之前就拒绝缺失上下文的写入:非 Platform 作用域缺少可信租户时返回 I18nErrors.TranslationInvalidInput(“Tenant 与 TenantOffice 级翻译必须具有可信租户上下文”);TenantOffice 作用域缺少有效 OfficeId 时同样失败。Platform 作用域的写入身份见 §9。

5. 读取候选集合

Store 先过滤 Language、Active、可选 Keys/Prefix。当前存在 TenantId 时只读 TenantId == 当前租户 或 "0";不存在 TenantId 时没有 tenant 条件,可能读取所有租户行,再按 Scope 合并。这使无租户调用者成为高风险路径。

办公室行只在 row.OfficeId == 当前 OfficeId 字符串 时参与;Tenant 行不检查 OfficeId;Platform 行也不检查 TenantId 必须为 0。损坏行可能产生意外覆盖。

6. 决胜算法

当前覆盖算法的等价写法
var merged = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
// ScopeType 升序:Platform 先写,Tenant 再覆盖,Office 最后覆盖。
foreach (var row in rows.OrderBy(item => item.ScopeType))
{
// 办公室行只有在当前 Office 精确匹配时才能进入决胜字典。
if ((TranslationScope)row.ScopeType == TranslationScope.TenantOffice
&& row.OfficeId != effectiveOfficeId)
continue;
merged[row.TranslationKey] = row.Value;
}

同一自然键在每个 Scope 有唯一约束,理论上顺序稳定;但非法 ScopeType、大小写重复、软删除过滤差异或脏 Tenant/Office 组合仍可能使结果依赖数据库返回顺序。

7. Prefix 与 Keys

Keys 非空时按集合过滤,并忽略 KeyPrefix;Keys 为空时表示加载全部,才允许 Prefix 下推。StartsWith 的大小写与索引使用取决于 ORM 和数据库;在大语言包上需验证执行计划,不应假设前缀一定走索引。

传入用户可控的空 Prefix 等价无过滤。Endpoint 应限制前缀长度、字符和返回条数,避免普通 View 权限把整个租户语言包无限拉取。

8. SaveTranslation 的真实事务窗口

Save 先校验 Key/Language/Value 非空,再调用 Repository Upsert。输入规范化集中在 I18nRequestInput:会 Trim,并校验长度上限(Key 300、LanguageCode 20、Value 2000、Context 200),通过 CultureInfo.GetCultureInfo 验证 BCP-47 合法性(解析失败即返回失败),并用 Enum.IsDefined 限制 Scope。非法输入在到达 Repository 前就被拒绝,不再依赖数据库在更晚阶段失败。

"NotificationPublisher""LocalResourceSync""Local Cache""TranslationRepository""Save Handler""NotificationPublisher""LocalResourceSync""Local Cache""TranslationRepository""Save Handler"Upsert ActiveRemoveByTag(i18n)Translation Updated + timestamp versioni18n.translations.changedsuccess

没有显式 Unit of Work/transaction/outbox。任一步后续失败都会留下部分成功;sourceVersion 是当前毫秒时间,不是数据库版本。通知 payload 的 keyPrefix 实际填完整 Key,消费者不能假设它一定以点结尾。

9. 平台写身份

Platform Scope 写入不再是仅凭 caller type 放行。Handler 现在要求调用者同时满足两个条件:持有 WellKnownPermissionCodes.RootCrossTenant 权限,且通过 ActorKey.TryCreatePlatformOperator 被识别为稳定的 Host/System 平台操作人。两者都满足后,Handler 构造一个 RootCrossTenantOperation.Authorize(user, RootCrossTenantOperations.I18nPlatformTranslationManage, ...) 根能力票据,随写入下传到 Repository;Repository 进一步用 rootOperation.EnsureOperation(...) 复核操作码 i18n.platform-translation.manage,确保能力票据与本次写入匹配。错误信息明确:“仅持有跨租户权限的稳定 Host/System 平台操作人可写入 Platform 级翻译。”

平台文案影响所有租户,属于供应链级配置。当前实现已经从”任意 Application caller 可写”收紧到”跨租户权限 + 平台操作人身份 + 审计能力票据”三重校验。

10. TranslationStatus 不是发布流

枚举有 Active、NeedsReview、Deprecated;Save 固定写 Active。没有命令把新值保存为 NeedsReview,也没有 Review/Publish/Deprecate;现有 Upsert 直接覆盖 Active 文本。

如果产品不需要审核,应删掉误导状态并明确即时发布;如果面向商业内容运营,则应分离 Draft Revision 与 Published Projection,保留提交人、审核人、时间、理由、diff 和回滚目标。

11. SysLanguage 目录

语言表按 (TenantId, Code) 唯一,保存 Name、DisplayName、Icon、IsDefault、IsDisabled。ReadModelStore 精确按当前 TenantId 查询,不合并平台语言。

MasterData CSV 种子只有 zh-CN/en-US,TenantId 为空,Seeder 的业务键只按 Code 匹配。这会把多租户表当全局 owner 数据处理;若不同租户需要不同状态,当前种子与唯一/查询语义没有闭环。

12. 并发与幂等

Upsert 是先查后增/改,没有 expected version。两个并发首次写可能都查不到,再由唯一索引让其中一个失败;Handler 没有把唯一冲突重读成幂等成功。两个并发更新则可能最后写覆盖,客户端无法检测丢失更新。

GA 应支持 ETag/ExpectedVersion 或幂等请求键;数据库 upsert/冲突处理必须在 SqlSugar 与 EF Core 上具有相同外部错误合同。

13. 测试矩阵

场景当前证据GA 补充
三作用域覆盖双 ORM parity 基础非法/脏组合与全数据库矩阵
唯一键元数据 + 初始化并发首次写/大小写/软删除重建
语言列表Handler 单元 + parity种子、新租户、平台合并策略
Platform callerRootCrossTenant + 平台操作人 + 能力票据真实 HTTP 身份与权限矩阵
Tenant/Office 缺失Handler 已 fail-closed 拒绝并发与脏组合下的零副作用证据
输入校验Trim + 长度 + BCP-47 + Scope 枚举collation 与多 ORM 一致性
状态Active 查询审核/发布/回滚或删简化状态
保存通知无故障注入DB/cache/sync/notification 每点失败
多 ORM场景 paritycollation、prefix、并发错误合同

14. 检查命令

Terminal window
# 物理 owner、唯一键和读写覆盖算法。
rg -n "SysTranslationCatalogRecord|UX_SysTranslation|effectiveTenant|OrderBy\(t => t.ScopeType\)" \
src/Platform/I18n src/Platform/MasterData -g '*.cs'
# 发布、审计、并发与 Outbox 当前预期无命中。
rg -n "PublishTranslation|ReviewedBy|ExpectedVersion|Outbox|IdempotencyKey" \
src/Platform/I18n -g '*.cs'

I18n 总览 · Localizer、缓存与资源 · MasterData 模块

100%

滚轮或按钮缩放 · 放大后拖动画面 · 双击切换 100% / 200%