Skip to content
bitzorcas
中EN

Guide

Tickets 评论、附件与统一持久化

深入讲解 Ticket 的 CommentsJson/AttachmentsJson、幂等键、Comments 外迁、Files owner policy、下载授权缺口和双 ORM 存储边界。

Last updated

Ticket 是 SysTicket 的统一租户聚合。状态、优先级和分派拆成可查询标量,评论与附件则保存在两个 source-generated JSON 数组中。这个模型消除了 Entity/Mapper 镜像,却把集合增长、迁移和并发责任集中到一行。

1. 物理模型

Ticket aggregate

StatusName / PriorityName
AssigneeId / DueAt

CommentsJson
TicketComment[]

AttachmentsJson
TicketAttachment[]

SysTicket
tenant + soft delete + Version

表索引是 Tenant+StatusName+AssigneeId 和 Tenant+RequesterId。JSON 集合不单独建表/索引,不能高效按评论作者、附件 FileId 或时间查询。

TicketStorageJson 使用编译期 JsonSerializerContext,拒绝空/损坏/null JSON、空字段、首尾空格和重复键。Restore 还校验 Id/Tenant/Requester/Subject 长度以及软删除标志与 DeleteTime 一致。

2. Ticket 内嵌评论

AddTicketCommentCommand(TicketId, CommentId, Body) 从当前用户取 AuthorId。聚合按 CommentId 幂等:若已存在,返回第一次的评论,不比较新 Body,也不更新 CreatedAt。

客户端正确重放评论
// operationId 在一次用户动作中保持稳定;网络重试继续使用同一个值。
var operationId = $"ticket-comment-{clientRequestId}";
var first = await mediator.Send(
new AddTicketCommentCommand(ticketId, operationId, "请检查 MFA 设备时间"),
cancellationToken);
// 同一个 CommentId 携带不同 Body 仍返回第一次内容,不是冲突。
var replay = await mediator.Send(
new AddTicketCommentCommand(ticketId, operationId, "新的文本不会覆盖旧评论"),
cancellationToken);
first.Value!.Body.ShouldBe("请检查 MFA 设备时间");
replay.Value!.Body.ShouldBe(first.Value.Body);

CommentId 只是 JSON 内唯一,没有数据库独立唯一约束。两个并发请求从同一 Ticket 快照追加不同评论时,当前 repository update 不比较 Version,后保存者可能覆盖前者。

评论没有 parent、编辑、删除、mention、可见性或内部备注概念。Requester 和 assignee 都可通过通用 Update 写评论;详情返回全部评论。

3. Comments 模块外迁的双写问题

Comments 模块提供内部 MigrateTicketComments.Command:按租户读取 CommentsJson != "[]" 的 Ticket,把旧评论写进 SysComment,再把 Ticket.CommentsJson 更新为 []。迁移幂等范围是 TenantId+Ticket+CommentId。

但 Tickets 的公开 /api/tickets/{ticketId}/comments 仍调用 ticket.AddComment 并保存 CommentsJson。一次迁移完成后,新评论会重新出现在旧 JSON,而不是进入 SysComment。因此“外迁完成”不是稳定状态。

Tickets AddCommentSysCommentMigrateTicketCommentsSysTicket.CommentsJsonTickets AddCommentSysCommentMigrateTicketCommentsSysTicket.CommentsJsontwo sources of truth reappearread legacy commentsinsert idempotentlyset []append new comment again

商业化前必须选择单一写模型:推荐让 Tickets 做工单资源授权后调用 Comments 的窄端口,或让 Comments 接收一个 Tickets resource-authorization port。不要同时保留两个公开创建入口。

4. 迁移器的进度语义

TicketCommentMigrationSource.GetUnmigratedAsync 先把所有匹配 Ticket 读入内存,再 Take(batchSize),不是 provider 级分页。损坏 JSON 只写 Warning 并跳过;如果批次只剩损坏行,返回空 batches,Handler 会结束而不是报告失败。

skippedTickets 变量从不递增,报告不能反映损坏行。migratedAt 参数也没有持久化,MarkMigrated 只写 []。Handler 注释声称逐工单事务,但源码没有显式 per-ticket transaction;实际边界取决于 Mediator/UoW 包裹整个命令的方式。

迁移运行前后应对账:旧 JSON 评论数、SysComment 新增数、重复数、损坏数、剩余非空 Ticket 数。发现损坏快照必须失败关闭或进入可重试隔离表,不能仅日志后报告成功。

5. 附件绑定的检查链

Attach Handler 先做 Ticket Update 授权,再调用 ITicketAttachmentAccessService。生产适配器读取 FileAsset,并复用 FileAssetOwnerPolicy.EnsureCanDownload:

  1. FileAsset 必须与当前用户同租户;
  2. 状态必须 Finalized;
  3. public 文件允许,或调用者是 user/app owner;
  4. 或调用者持有 files.asset.read.all / files.{ownerType}.read。

基础设施未启用时 UnavailableTicketAttachmentAccessService 返回 Ticket.FileAssetUnavailable,不会绕过检查。

绑定前固定 FileAsset 边界
// 文件必须已 finalized;PendingUpload 不能只凭 FileId 绑定。
var access = await attachmentAccess.EnsureCanAttachAsync(
command.FileId, currentUser.User, cancellationToken);
if (access.IsFailure)
return Result.Failure<TicketAttachment>(access.Error);
// Ticket 只保存 FileId,不复制 StorageKey、下载 URL 或对象存储凭证。
var attached = ticket.AttachFile(
command.AttachmentId, command.FileId, actorId, clock.UtcNow);
if (attached.IsFailure)
return Result.Failure<TicketAttachment>(attached.Error);
await repository.SaveAsync(ticket, cancellationToken);

6. 绑定不等于共享下载权

校验的是“绑定者能下载”,不是“所有工单参与人能下载”。Ticket 只保存 FileId,没有为 requester/assignee 创建 ACL,也没有 Tickets 下载代理端点。另一位参与人使用 Files 下载 API 时仍按自己的 owner policy 判断,可能得到 AccessDenied。

生产模型需要明确附件共享语义:

  • Ticket attachment 创建受控资源关系;
  • Files 下载策略通过窄端口验证调用者能 View 该 Ticket;
  • 删除 Ticket/附件时撤销关系,但不一定删除 FileAsset;
  • 文件所有者撤销、病毒隔离、软删除后,Ticket UI 显示不可用原因;
  • 下载审计记录 TicketId、FileId、actor 和结果,不记录 StorageKey。

7. 附件幂等的特殊点

AttachFile 以 AttachmentId 或 FileId 任一相同视为已有,并返回首次记录。若同一 AttachmentId 重放时换成另一个 FileId,仍返回旧记录;若新 AttachmentId 指向已绑定 FileId,也返回旧记录。

Handler 通过集合数量判断是否保存。重复请求不会再审计。API 应把这种“返回已有绑定”写进 SDK 契约;若不同载荷应视为冲突,就要比较完整不可变字段并返回 IdempotencyConflict。

当前没有 detach。FileAsset 删除后 JSON 引用仍在;详情还会返回该 FileId,直到调用方自行处理。

8. 整行 JSON 更新与容量

每次评论或附件追加都会:加载完整 Ticket、反序列化两个数组、修改内存集合、序列化整列、执行整行部分列 UPDATE。随着历史增长,写放大、内存分配、锁时间和响应 payload 都会增加。

Description、CommentsJson、AttachmentsJson 都是 max-length 列,聚合没有数量/单项长度上限。攻击者或误用可让一张工单无限增长。详情又返回全部 Comments/Attachments,没有分页。

若 Comments 已正式外迁,应该删除 Ticket 评论写入和最终 JSON 列;附件也更适合独立关系表,以 (TenantId, TicketId, AttachmentId) 和 (TenantId, TicketId, FileId) 唯一约束支持分页、撤销和并发。

9. Repository 的统一聚合优点与缺口

优点:Ticket 自己拥有表/列/索引元数据;双 ORM 都通过 IEntitySet<Ticket>;Find 明确带 TenantId+Id+未删除;读列表只投影所需标量。

缺口:更新使用 TenantId+Id 的 UpdateWhereAsync,不包含 IsDeleted、Version 或影响行数检查。若加载后被软删除,更新仍可能命中;并发写会最后写入者获胜。保存方法总返回 Success,无法区分 0 行、并发或数据库约束冲突。

10. 数据保留与隐私

Tickets 没有删除/归档/保留策略用例。软删除字段来自基类,但没有公开命令设置。评论与描述可能含个人数据,却没有和 GDPR 的擦除/导出协作端口。附件生命周期也没有引用计数或 legal hold。

需定义:关闭后在线保留期、归档格式、搜索索引删除、评论匿名化、附件保留/删除、审计 legal hold、租户注销和 SAR 导出。不要仅依赖通用表软删除。

11. 迁移验收用例

评论迁移后新写必须进入唯一事实源
// Arrange:旧评论迁到 SysComment,并确认 Ticket JSON 已清空。
await migration.Handle(
new MigrateTicketComments.Command("tenant-a", BatchSize: 100),
cancellationToken);
ticket.CommentsJson.ShouldBe("[]");
// Act:调用对外工单评论入口创建新评论。
await comments.AddToTicketAsync(
"tenant-a", ticket.Id, "comment-new", "user-1", "new body",
cancellationToken);
// Assert:新记录只在 SysComment;旧 JSON 不允许重新增长。
(await commentStore.GetByCommentIdAsync(
"tenant-a", "Ticket", ticket.Id, "comment-new", cancellationToken))
.ShouldNotBeNull();
ticket.CommentsJson.ShouldBe("[]");

还要测试损坏 JSON、批次只有损坏行、并发迁移、重复 CommentId、迁移中有新写、FileAsset 跨租户/未 finalized、绑定后另一参与人下载、文件删除和并发附件。

12. 审查命令

Terminal window
# 找出仍向 Ticket JSON 写评论的入口;外迁完成后预期只剩迁移读取或彻底删除。
rg -n "AddComment|CommentsJson|MigrateTicketComments" src/Platform/Tickets src/Platform/Comments -g '*.cs'
# 确认附件从不保存 StorageKey,并列出 Files owner policy 的真实检查。
rg -n "AttachFile|EnsureCanAttach|FileAssetOwnerPolicy|StorageKey" src/Platform/Tickets src/Platform/Files -g '*.cs'
# 检查 JSON 更新是否带 Version、IsDeleted 和影响行数语义。
rg -n "UpdateWhereAsync|CommentsJson|AttachmentsJson|Version" src/Platform/Tickets -g '*.cs'

返回 Tickets 总览 · Comments 模块 · Files 模块 · 事件与审计

100%

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