Skip to content
bitzorcas
中EN

Guide

Comments Ticket 旧评论迁移

解释 MigrateTicketComments 的触发、事务、批量、幂等、租户风险、损坏 JSON、报告语义和安全迁移步骤。

Last updated

MigrateTicketComments 把 Ticket 聚合的 CommentsJson 拆到 SysComment。它是进程内 Command,没有 HTTP 端点、没有 IAuthorizedRequest,源码也没有找到调度器或宿主调用点。运维必须自己建立受控触发面和租户上下文。

1. 迁移数据流

transactionCommentStoreTicketCommentMigrationSourceMediator commandTrusted operatortransactionCommentStoreTicketCommentMigrationSourceMediator commandTrusted operatorloop[each legacy comment]default Command pipeline wraps the whole handlerTenantId + BatchSizelist Ticket where CommentsJson != []batches with deserialized commentslookup tenant + Ticket + CommentIdinsert when missingset Ticket.CommentsJson = []

旧评论全部作为顶层评论写入,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. 安全迁移运行手册

  1. 备份 Ticket CommentsJson 与 SysComment,验证可恢复;
  2. 统计每租户 Ticket 数、评论数、最大 Body/Id、损坏 JSON;
  3. 停止旧路径写 CommentsJson,或启用明确双写栅栏;
  4. 在目标 EffectiveTenant 上 dry-run 并验证 TenantId 相等;
  5. 小批稳定游标迁移,逐批提交并记录 checkpoint;
  6. 对账 CommentId、AuthorId、Body 哈希、Ticket 计数与剩余非空 JSON;
  7. 抽样 GET 线程并验证目标资源授权;
  8. 保留回滚窗口后,移除旧字段读写和跨模块依赖。

10. 必测场景

  • Command TenantId 与 EffectiveTenantId 不同;
  • BatchSize 0、负数、极大值和 DB 下推;
  • 中途第 N 个 Ticket 失败时事务范围;
  • 两个迁移实例并发;
  • Insert 成功/Mark 失败后的重跑;
  • 坏 JSON、空 JSON、null、空白与非规范 [];
  • 旧 Body/Id 超过新表长度;
  • 同 CommentId 已存在但正文不同;
  • 迁移完成后源剩余数为 0、目标数/哈希一致;
  • 双 ORM 下迁移语义一致。

返回 Comments 总览

100%

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