Skip to content
bitzorcas
中EN

Concept

Comments 通用评论

源码校验的 Comments 模块说明书,覆盖通用资源键、评论线程、幂等创建、编辑删除、租户边界、工单迁移与生产缺口。

Last updated

Comments 用 (EntityType, EntityId) 把评论挂到任意业务资源,并用 ParentCommentId 形成回复树。它实现了租户存储、四项通用权限、客户端 CommentId 幂等键、30 分钟作者编辑窗口、软删除占位和 Ticket 旧 JSON 迁移。

1. 模块真正拥有的边界

Comments 拥有:

  • 通用评论支持行 SysComment;
  • CommentId 幂等键与父评论引用;
  • 正文、作者、编辑时间、软删除状态;
  • 线程树 DTO 与已删除占位;
  • 作者/硬编码 admin 角色的编辑删除规则;
  • Ticket.CommentsJson 到 SysComment 的一次性迁移代码。

Comments 当前不拥有:目标资源存在性和访问决策、内容审核、提及解析、通知、反垃圾、附件、Reaction、搜索、分页、事件、保留策略或 GDPR 清除编排。

2. 当前端到端结构

当前没有策略调用

已认证客户端

生成式 /api/comments 端点

通用 comments.comment.* 授权

Create / Get / Update / Delete

ICommentStore

SysComment
CommentThreadSupportRecord

CommentThreadNode 树

目标资源 owner
Documents / Tickets / ...

Contracts 只保存公开 DTO;Application 同时拥有权限目录、请求、处理器和存储端口;Infrastructure 用 ORM 中立 IEntitySet<CommentThreadSupportRecord> 实现存储。模块标记只声明依赖 Authorization,但 Application 实际引用 Tickets.Contracts,Infrastructure 还直接引用 Tickets.Infrastructure 以迁移旧字段;这与治理声明和“窄端口隔离”叙述不一致。

3. HTTP 表面

方法与路由消息权限动作业务规则
POST /api/comments/CreateComment.CommandCreate非空、Body ≤ 10000、幂等、父评论有效
GET /api/comments?entityType=&entityId=GetComments.QueryView读取当前租户含删除记录,内存建树
PUT /api/comments/{id}UpdateComment.CommandUpdate作者 30 分钟内或 admin 角色
DELETE /api/comments/{id}DeleteComment.CommandDelete作者或 admin,清空正文并软删除

Update 使用 StandardCommand timeout;Create、Delete 和 Get 没有显式 timeout。四条路由都没有显式 RateLimitPolicy。{id} 是持久化 Id,不是客户端 CommentId;响应同时暴露两者,SDK 必须区分。

权限目录精确声明:comments.comment.view/create/update/delete。通用权限可以控制“谁能调用 Comments”,但 ResourceDescriptor 只有 module=comments、resource=comment,不携带 EntityType/EntityId,也不能回答“谁能访问 ticket-42”。

4. 数据模型

业务资源
EntityType + EntityId

顶层评论
ParentCommentId=null

回复
ParentCommentId=root.CommentId

软删除占位
Body=null

SysComment 是 tenant + soft-delete 支持表:

字段限制说明
CommentId36客户端幂等键,迁移时可复用旧 TicketComment Id
EntityType50客户端自由字符串,目前无 registry
EntityId36目标资源 Id,目前不验证 owner
ParentCommentId36,可空引用同资源父评论的 CommentId
AuthorId36UserId 字符串;无 User 时 Create 写 "0"
Body10000,可空创建/更新限制长度;删除后清空
EditedAt可空最近编辑时间;删除不记录删除时间/人

唯一索引为 (TenantId, EntityType, EntityId, CommentId)。另有 EntityType+EntityId、ParentCommentId、AuthorId 普通索引;后三个没有显式 TenantId 前缀,需用实际执行计划验证多租户大表性能。

5. 创建和回复

创建顶层评论
POST /api/comments/ HTTP/1.1
Authorization: Bearer eyJhbGciOiJSUzI1NiJ9
Content-Type: application/json
{
"commentId": "019c64f8-55a7-7d7f-9da7-b60f53bcf9e7",
"entityType": "Document",
"entityId": "doc-42",
"parentCommentId": null,
"body": "第二步需要补充失败回滚说明。"
}

CommentId 应由调用方稳定生成,并在网络重试时复用。创建处理器先按 Tenant + Subject + CommentId 查询;已有记录直接返回成功。父评论查询使用相同 Tenant、EntityType 和 EntityId,所以正常 HTTP 创建不能跨资源回复。

客户端复用 CommentId 重试
export async function createComment(
subject: { type: string; id: string },
body: string,
parentCommentId?: string,
): Promise<CommentNode> {
// 在第一次请求前生成;超时重试仍复用同一个值。
const commentId = crypto.randomUUID();
const payload = {
commentId,
entityType: subject.type,
entityId: subject.id,
parentCommentId: parentCommentId ?? null,
body,
};
for (let attempt = 1; attempt <= 2; attempt++) {
const response = await fetch("/api/comments/", {
method: "POST",
credentials: "include",
headers: { "content-type": "application/json" },
body: JSON.stringify(payload),
});
// 只有传输失败/可重试状态才重放;4xx 输入与权限错误直接返回。
if (response.ok) return response.json() as Promise<CommentNode>;
if (response.status < 500 || attempt === 2)
throw new Error(`comment create failed: ${response.status}`);
}
throw new Error("unreachable");
}

这不是框架级 IIdempotentRequest:查询与插入之间仍有竞态,数据库唯一索引是最终防线,但 handler 没有把并发唯一冲突翻译成已有结果。软删除记录被普通幂等查询过滤,索引却仍可能占用 CommentId,因此删除后重放也可能变成数据库冲突。

6. Thread 查询语义

GetComments 有意调用 ListIncludingDeletedAsync,让删除节点继续承载子回复。树构建规则:

  • ParentCommentId 为空的记录是顶层;
  • 父 CommentId 不在结果中的孤儿也提升为顶层;
  • 子节点按 CreateTime 升序;
  • 已删除节点 Body=null、IsDeleted=true,Replies 保留;
  • Create 的单节点响应 Replies 始终为空,完整线程要重新 GET。

查询没有分页、最大记录数或最大深度,先读出资源的全部评论再递归。异常深链可能耗尽栈;被人工导入的环会递归不终止;重复 CommentId 会让 ToDictionary 抛异常。唯一索引只能防正常写入,不能替代导入/修复后的数据校验。

7. 当前最重要的缺口

  1. EntityType/EntityId 是自由输入,没有 subject registry 或 owner policy。
  2. Documents.AllowComments、Ticket 可见性/状态等 owner 规则完全未执行。
  3. Create/Get 使用 User.TenantId 调 store,而 store 同时要求 EffectiveTenantId;模拟租户时可能空读或幂等失效。
  4. Create 不要求 User;无 UserId 时 AuthorId=0。
  5. 管理员绕过依赖角色字符串 admin,不是权限/能力决策。
  6. 标识长度只在数据库元数据声明,handler 不提前验证。
  7. 幂等不覆盖并发插入和软删除重放。
  8. 线程没有分页、深度/回复数限制、环保护或稳定同时间排序键。
  9. 没有创建/编辑/删除事件、提及、通知、审核或审计专用数据。
  10. 没有 Feature catalog;不能按套餐/租户关闭评论。

8. 阅读路径

  1. 线程、幂等、编辑与删除
  2. 目标资源授权、租户与隐私
  3. Ticket 旧评论迁移
  4. 测试、运营与 GA 门禁

关联章节:Documents、Authorization、Multitenancy 和 Auditing。

9. 源码核对

Terminal window
# 路由、动作和通用权限目录。
rg -n 'GenerateEndpoint|AuthorizationAction|PermissionDefinition' \
src/Platform/Comments -g '*.cs'
# 目标资源策略、AllowComments、事件与通知;当前 Comments 路径预期无命中。
rg -n 'EnsureCanComment|AllowComments|CommentCreated|Notification|Mention' \
src/Platform/Comments -g '*.cs'
# 租户来源分歧和硬编码管理员角色。
rg -n 'User.TenantId|EffectiveTenantId|AdminRole|\?\? "0"' \
src/Platform/Comments -g '*.cs'

返回平台模块目录

100%

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