MigrateTicketComments 把 Ticket 聚合的 CommentsJson 拆到 SysComment。它是进程内 Command,没有 HTTP 端点、没有 IAuthorizedRequest,源码也没有找到调度器或宿主调用点。运维必须自己建立受控触发面和租户上下文。
1. 迁移数据流
旧评论全部作为顶层评论写入,EntityType 固定 Ticket,EntityId 使用 Ticket 物理 Id,ParentCommentId=null。CommentId、AuthorId、Body 原样继承;旧数据没有回复链。
2. 触发边界
Command 不实现 IAuthorizedRequest,也没有 [GenerateEndpoint]。这是合理的“不暴露公共 HTTP”起点,但不代表已安全:任何能在进程内 Send 该 Command 的代码都可以提供任意 TenantId。
生产触发器应:
- 只存在于受控 JobHost/迁移工具;
- 要求平台运营授权和双人审批(如商业要求);
- 为目标租户 Push EffectiveTenant 上下文;
- 断言 command TenantId == EffectiveTenantId;
- 支持 dry-run、最大批次、截止时间和取消;
- 写不可变迁移审计,而不是记录正文。
3. 事务不是逐 Ticket
源码注释写“逐工单事务内原子迁移”,但 handler 没有创建 per-ticket transaction。由于消息实现 ICommand<Result<...>> 且未实现 INonTransactionalCommand,默认 TransactionPipelineBehavior 会包裹整个 handler,包括 while 循环全部批次。
这意味着:
- 任一后期异常可能回滚本次命令全部 Ticket;
- 大租户可能形成长事务、锁、日志膨胀和超时;
- BatchSize 只控制每轮 handler 处理数量,不控制事务提交边界;
- “已迁移一批即可恢复”不能从当前代码推导。
生产方案应让外层 job 按稳定游标发送多个小 Command,每个 Command 处理有限 Ticket,并明确每 Ticket 或每批事务边界。
4. BatchSize 的实际行为
TicketCommentMigrationSource 先 ListAsync 读取全部匹配 Ticket,再在内存 .Take(batchSize)。所以 BatchSize 不会限制数据库读量。BatchSize 没有 1..N 校验;0 或负数会得到空批次并返回看似成功的空报告。
public static Result<MigrationSlice> CreateSlice( string requestedTenant, string effectiveTenant, int batchSize){ // 阻止跨租户读取与写入上下文分裂。 if (!string.Equals(requestedTenant, effectiveTenant, StringComparison.Ordinal)) return Result.Failure<MigrationSlice>(MigrationErrors.TenantMismatch);
// 限制事务、内存和数据库压力;范围应来自容量测试。 if (batchSize is < 1 or > 500) return Result.Failure<MigrationSlice>(MigrationErrors.InvalidBatchSize);
return new MigrationSlice(effectiveTenant, batchSize);}正确分页应在存储查询中下推 Take/游标,并有稳定排序(例如 Ticket Id)。
5. 幂等与部分失败
迁移对每条旧评论调用 GetByCommentId,已存在则跳过;新评论插入后才把 Ticket.CommentsJson 设为 []。若 Insert 已执行但 Mark 失败,整个事务通常回滚;若未来改成分批事务,重跑也能跳过已写记录再清空旧字段。
但仍有边界:
- 并发运行两个迁移命令会在查询/插入竞态处撞唯一索引;
- CommentStore lookup 同时要求 batch TenantId == EffectiveTenantId;
- migration 绕过 Create handler,不校验 Body/Id 长度;
- 同键已有但内容不同会静默跳过,不比较指纹;
- migratedAt 参数传给 source,却没有保存,Ticket 只写
[]。
6. 损坏 JSON 与报告
Deserialize 抛 InvalidOperationException 时 source 记录 Warning 并跳过该 Ticket。Handler 的 skippedTickets 变量从未递增,所以报告中的 SkippedTickets 永远是 0,TotalTickets 也不包含损坏 Ticket。
若剩余全是损坏/空反序列化记录,source 返回空 batches,while 结束。报告可能成功且无跳过,但旧 CommentsJson 仍然非空。
{ "totalTickets": 120, "migratedTickets": 118, "totalComments": 842, "skippedTickets": 0, "alreadyMigrated": 2}不能用 skippedTickets=0 证明没有坏数据。上线门禁应另做源表剩余非 [] 计数,并把 TicketId + 错误类别写入隔离清单。
7. AlreadyMigrated 的含义
若 Ticket.CommentsJson 仍有评论,但每个 CommentId 已存在 SysComment,commentCount=0,handler 清空旧 JSON并把 AlreadyMigrated +1。它表达“本轮没有新增评论而完成清理”,不一定表示此前完整迁移已经被正式标记。
TotalTickets = MigratedTickets + AlreadyMigrated + SkippedTickets;由于 skipped 不更新,它只是 handler 处理并返回的有效 batch 数。
8. 依赖隔离问题
Application 的迁移端口直接引用 Tickets.Contracts 的 TicketComment;Infrastructure 项目直接引用 Tickets.Infrastructure 并查询 Ticket 聚合。这与 CommentsModule 只 [DependsOn("Authorization")] 的治理声明不一致,也使模块难以独立部署/测试。
迁移是物理重构的例外,可以放入专门 migration assembly/tool,而不是长期留在主 Comments Infrastructure。至少应:
- 在治理清单声明临时 Tickets 依赖和移除条件;
- 迁移完成后删除 adapter/project reference;
- 用导出 DTO 或数据库迁移脚本隔离旧结构;
- 保留回滚/备份验证,而不是长期运行双模型。
9. 安全迁移运行手册
- 备份 Ticket CommentsJson 与 SysComment,验证可恢复;
- 统计每租户 Ticket 数、评论数、最大 Body/Id、损坏 JSON;
- 停止旧路径写 CommentsJson,或启用明确双写栅栏;
- 在目标 EffectiveTenant 上 dry-run 并验证 TenantId 相等;
- 小批稳定游标迁移,逐批提交并记录 checkpoint;
- 对账 CommentId、AuthorId、Body 哈希、Ticket 计数与剩余非空 JSON;
- 抽样 GET 线程并验证目标资源授权;
- 保留回滚窗口后,移除旧字段读写和跨模块依赖。
10. 必测场景
- Command TenantId 与 EffectiveTenantId 不同;
- BatchSize 0、负数、极大值和 DB 下推;
- 中途第 N 个 Ticket 失败时事务范围;
- 两个迁移实例并发;
- Insert 成功/Mark 失败后的重跑;
- 坏 JSON、空 JSON、
null、空白与非规范[]; - 旧 Body/Id 超过新表长度;
- 同 CommentId 已存在但正文不同;
- 迁移完成后源剩余数为 0、目标数/哈希一致;
- 双 ORM 下迁移语义一致。