评论生命周期由四个 Application handler 编排,没有领域聚合。业务规则分散在 Create/Update/Delete/GetComments 与 CommentStore 中,因此审查必须同时覆盖 handler、数据库索引和软删除读取。
1. 两个标识
| 标识 | 产生方 | 用途 |
|---|---|---|
Id | 持久化实体/框架 | Update/Delete 路由参数 |
CommentId | 客户端或旧 Ticket 数据 | 幂等键、ParentCommentId 引用 |
不要把 /api/comments/{id} 的参数换成 CommentId。若 SDK 只保留 CommentId,就无法调用当前更新/删除接口;CommentThreadNode 同时返回两者正是为了区分。
2. 创建状态机
创建只验证五个输入非空和 Body 长度。EntityType/EntityId/CommentId/ParentCommentId 的数据库长度没有在 handler 提前验证;过长值会在持久化阶段失败,错误语义取决于 Provider。
Body 使用 .NET string.Length,即 UTF-16 code unit 数,不是 Unicode grapheme、UTF-8 字节或渲染后长度。前端计数与服务端需要采用同一规则,或者 API 明确定义字符标准。
3. 幂等的精确范围
幂等键是:
TenantId + EntityType + EntityId + CommentId相同 CommentId 可以用于另一个 EntityId;同资源重复请求会返回首次记录,新的 Body 或 ParentCommentId 会被忽略。这是结果重放,不是幂等冲突检测。
var command = new CreateComment.Command( CommentId: operationId, EntityType: "Ticket", EntityId: ticketId, ParentCommentId: parentId, Body: normalizedBody);
// 重试必须复用全部载荷;当前服务不会比较已有 Body/Parent 是否一致。var first = await mediator.Send(command, cancellationToken);var replay = await mediator.Send(command, cancellationToken);
// 两次成功应指向同一持久化 Id,而不是产生两个节点。first.Value!.Id.ShouldBe(replay.Value!.Id);并发两个首请求都可能在 Insert 前看不到记录。唯一索引会阻止两个提交,但 handler 没有捕获唯一冲突再读取已有结果。生产实现应把冲突归一为幂等成功,并校验请求指纹,避免同键不同载荷被静默接受。
4. 父评论规则
父查询带 TenantId、EntityType、EntityId 和 ParentCommentId,因此普通路径保证父子属于同一 subject。父已软删除时 GetByCommentId 的普通查询通常被软删除过滤,代码会进入 ParentNotFound;ParentDeleted 分支只有存储返回已删除记录时才可达,而当前 store 不会这样做。
这意味着源码宣称的两个错误存在实际可达性差异:已删除父评论更可能返回 Comment.ParentNotFound,而不是 Comment.ParentDeleted。
没有最大回复深度、每节点回复数或总线程数。客户端不应无限展开;服务端生产化需要分页游标和显式深度策略。
5. 编辑规则
Update 先按持久化 Id 和 EffectiveTenantId 查找未删除记录,然后判断:
- AuthorId == 当前 UserId 字符串;或
- Roles 包含不区分大小写的
admin。
非 admin 的 UtcNow - CreateTime 超过 30 分钟时拒绝;恰好 30 分钟仍允许。admin 不受窗口限制。更新会 trim Body 并写 EditedAt。
using var response = await api.PutAsJsonAsync( $"/api/comments/{comment.Id}", new { body = editedBody }, cancellationToken);
// Forbidden 可能是非作者或 30 分钟窗口过期,不要自动重试。if (response.StatusCode == HttpStatusCode.Forbidden) throw new CommentEditRejectedException(await response.Content.ReadAsStringAsync());
// NotFound 同时覆盖不存在、其他有效租户和已删除记录。response.EnsureSuccessStatusCode();硬编码 role name 绕过了 Authorization 权限目录。更稳妥的设计是 comments.comment.moderate 能力或资源策略,并把管理员操作写入审计。
6. 删除与线程占位
Delete 不受 30 分钟限制。作者或 admin 可删除;store 设置 IsDeleted=true 并把 Body=null,保留 CommentId、ParentCommentId、AuthorId 和时间。
重复 Delete 使用普通 GetById,因此软删除过滤后返回 NotFound,而不是幂等成功。删除也不写 DeletedAt/DeletedBy/Reason;若存在合规或版主管理要求,当前记录无法回答谁在何时删除。
7. 建树算法
GetComments 读出 subject 的所有记录,包括软删除,然后:
- 用 CommentId 建字典;
- 按 ParentCommentId 建 lookup;
- 空父或父不存在的节点作为顶层;
- 递归生成 Replies;
- 每层按 CreateTime 升序。
同一 CreateTime 没有次级排序,跨 Provider/执行可能不稳定。算法没有 visited set;坏数据中的环会无限递归。读取全部记录也使单一热点资源可以造成内存和响应放大。
CommentThreadNode Build(CommentRecord current, HashSet<string> path){ // path 只追踪当前递归分支;重复进入说明数据形成环。 if (!path.Add(current.CommentId)) throw new InvalidOperationException("comment cycle detected");
var replies = children[current.CommentId] .OrderBy(x => x.CreateTime) .ThenBy(x => x.CommentId, StringComparer.Ordinal) .Select(child => Build(child, new HashSet<string>(path))) .ToList();
// 已删除节点仍保留结构,但正文不进入响应。 return ToNode(current, replies);}大规模线程更适合“根评论分页 + 每个根的有限回复 + 按需展开”,而不是一次返回整棵树。
8. 输出安全与隐私
服务只 trim,不做 Markdown/HTML 清洗。JSON 序列化通常会安全编码响应,但 UI 若用 raw HTML/Markdown 渲染,必须采用 allowlist sanitizer,并禁止脚本、事件属性和危险 URL。
软删除清空 Body,降低直接泄漏,但数据库日志、备份、审计、搜索索引或通知副本可能仍保留正文。Comments 当前没有跨系统删除编排,不能仅凭 Body=null 宣称彻底擦除。
9. 错误与客户端动作
| 错误 | 条件 | 客户端动作 |
|---|---|---|
Comment.InvalidInput | 创建字段缺失 | 修正输入 |
Comment.BodyTooLong | Create/Update > 10000 | 缩短正文 |
Comment.EmptyBody | Update 为空 | 修正输入 |
Comment.ParentNotFound | 父查询无结果 | 刷新线程 |
Comment.ParentDeleted | store 返回 deleted parent | 当前 store 下通常不可达 |
Comment.NotAuthor | 既非作者也非 admin | 不重试 |
Comment.EditWindowExpired | 非 admin 超过 30 分钟 | 新建补充评论或走审核流程 |
Comment.NotFound | Id 不存在/非有效租户/已删除 | 刷新或隐藏操作 |
10. 必测场景
- 同键同载荷、同键不同载荷和并发首请求;
- 删除后 CommentId 重放;
- 父评论活动/已删除/跨 subject/跨 tenant;
- 恰好 30 分钟、未来 CreateTime、admin 大小写;
- Client caller、UserId 为空和 AuthorId=
0; - 1 万字符的 Unicode 边界;
- 深链、环、孤儿、重复 CommentId、相同 CreateTime;
- 软删除后 Body 不在普通查询、含删除查询和响应中出现。