Ticket 是 SysTicket 的统一租户聚合。状态、优先级和分派拆成可查询标量,评论与附件则保存在两个 source-generated JSON 数组中。这个模型消除了 Entity/Mapper 镜像,却把集合增长、迁移和并发责任集中到一行。
1. 物理模型
表索引是 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 做工单资源授权后调用 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:
- FileAsset 必须与当前用户同租户;
- 状态必须 Finalized;
- public 文件允许,或调用者是 user/app owner;
- 或调用者持有
files.asset.read.all/files.{ownerType}.read。
基础设施未启用时 UnavailableTicketAttachmentAccessService 返回 Ticket.FileAssetUnavailable,不会绕过检查。
// 文件必须已 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. 审查命令
# 找出仍向 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'