I18n 的业务语言是“翻译”,但物理表类型位于 MasterData Infrastructure。理解这一点很重要:公开 Contracts 没有泄漏持久化类型,然而 Infrastructure-to-Infrastructure 引用和治理依赖声明仍存在偏差。
1. 两张表的 owner
SysTranslationCatalogRecord 和 SysLanguageCatalogRecord 都在 BitzOrcas.Platform.MasterData.Infrastructure.Persistence。I18n Infrastructure 项目直接引用 MasterData Infrastructure,并用这些类型构造 IEntitySet<T>。
治理 marker 只声明 Authorization,未声明 MasterData。应选择:把表与配置迁回 I18n owner,或由 MasterData 提供窄公开契约/读写端口并更新治理事实。长期保留跨模块 Infrastructure 引用会让迁移、测试和发布边界模糊。
2. SysTranslation 字段
| 字段 | 当前合同 |
|---|---|
| TranslationKey | 必填,最长 300 |
| LanguageCode | 必填,最长 20,未规范化 |
| ScopeType | int,期望 0/1/2,数据库未见 check constraint |
| TenantId | 继承,Platform 写 "0" |
| OfficeId | 继承 string,无 Office 写 "0" |
| Context | 可空,最长 200,目前不参与选择 |
| Value | 必填,最长 2000 |
| Status | int,读取只接受 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 前就被拒绝,不再依赖数据库在更晚阶段失败。
没有显式 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 caller | RootCrossTenant + 平台操作人 + 能力票据 | 真实 HTTP 身份与权限矩阵 |
| Tenant/Office 缺失 | Handler 已 fail-closed 拒绝 | 并发与脏组合下的零副作用证据 |
| 输入校验 | Trim + 长度 + BCP-47 + Scope 枚举 | collation 与多 ORM 一致性 |
| 状态 | Active 查询 | 审核/发布/回滚或删简化状态 |
| 保存通知 | 无故障注入 | DB/cache/sync/notification 每点失败 |
| 多 ORM | 场景 parity | collation、prefix、并发错误合同 |
14. 检查命令
# 物理 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'