Skip to content
bitzorcas
中EN

Guide

Comments 线程、幂等、编辑与删除

深入讲解 CommentId、父子线程、完整树投影、30 分钟编辑窗口、admin 绕过、软删除占位和并发边界。

Last updated

评论生命周期由四个 Application handler 编排,没有领域聚合。业务规则分散在 Create/Update/Delete/GetComments 与 CommentStore 中,因此审查必须同时覆盖 handler、数据库索引和软删除读取。

1. 两个标识

标识产生方用途
Id持久化实体/框架Update/Delete 路由参数
CommentId客户端或旧 Ticket 数据幂等键、ParentCommentId 引用

不要把 /api/comments/{id} 的参数换成 CommentId。若 SDK 只保留 CommentId,就无法调用当前更新/删除接口;CommentThreadNode 同时返回两者正是为了区分。

2. 创建状态机

POSTsame tenant + subject +CommentIdreturn stored nodenew CommentIdparent missing or deletedinsert rowauthor within 30 min oradminauthor or adminauthor or adminrepeated delete returns notfound

Validating

Existing

Returned

ParentCheck

Rejected

Active

Edited

Deleted

创建只验证五个输入非空和 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 和时间。

已删除根评论
Body=null

可见回复 A

可见回复 B

重复 Delete 使用普通 GetById,因此软删除过滤后返回 NotFound,而不是幂等成功。删除也不写 DeletedAt/DeletedBy/Reason;若存在合规或版主管理要求,当前记录无法回答谁在何时删除。

7. 建树算法

GetComments 读出 subject 的所有记录,包括软删除,然后:

  1. 用 CommentId 建字典;
  2. 按 ParentCommentId 建 lookup;
  3. 空父或父不存在的节点作为顶层;
  4. 递归生成 Replies;
  5. 每层按 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.BodyTooLongCreate/Update > 10000缩短正文
Comment.EmptyBodyUpdate 为空修正输入
Comment.ParentNotFound父查询无结果刷新线程
Comment.ParentDeletedstore 返回 deleted parent当前 store 下通常不可达
Comment.NotAuthor既非作者也非 admin不重试
Comment.EditWindowExpired非 admin 超过 30 分钟新建补充评论或走审核流程
Comment.NotFoundId 不存在/非有效租户/已删除刷新或隐藏操作

10. 必测场景

  • 同键同载荷、同键不同载荷和并发首请求;
  • 删除后 CommentId 重放;
  • 父评论活动/已删除/跨 subject/跨 tenant;
  • 恰好 30 分钟、未来 CreateTime、admin 大小写;
  • Client caller、UserId 为空和 AuthorId=0;
  • 1 万字符的 Unicode 边界;
  • 深链、环、孤儿、重复 CommentId、相同 CreateTime;
  • 软删除后 Body 不在普通查询、含删除查询和响应中出现。

返回 Comments 总览

100%

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