Comments 用 (EntityType, EntityId) 把评论挂到任意业务资源,并用 ParentCommentId 形成回复树。它实现了租户存储、四项通用权限、客户端 CommentId 幂等键、30 分钟作者编辑窗口、软删除占位和 Ticket 旧 JSON 迁移。
1. 模块真正拥有的边界
Comments 拥有:
- 通用评论支持行
SysComment; - CommentId 幂等键与父评论引用;
- 正文、作者、编辑时间、软删除状态;
- 线程树 DTO 与已删除占位;
- 作者/硬编码 admin 角色的编辑删除规则;
- Ticket.CommentsJson 到 SysComment 的一次性迁移代码。
Comments 当前不拥有:目标资源存在性和访问决策、内容审核、提及解析、通知、反垃圾、附件、Reaction、搜索、分页、事件、保留策略或 GDPR 清除编排。
2. 当前端到端结构
Contracts 只保存公开 DTO;Application 同时拥有权限目录、请求、处理器和存储端口;Infrastructure 用 ORM 中立 IEntitySet<CommentThreadSupportRecord> 实现存储。模块标记只声明依赖 Authorization,但 Application 实际引用 Tickets.Contracts,Infrastructure 还直接引用 Tickets.Infrastructure 以迁移旧字段;这与治理声明和“窄端口隔离”叙述不一致。
3. HTTP 表面
| 方法与路由 | 消息 | 权限动作 | 业务规则 |
|---|---|---|---|
POST /api/comments/ | CreateComment.Command | Create | 非空、Body ≤ 10000、幂等、父评论有效 |
GET /api/comments?entityType=&entityId= | GetComments.Query | View | 读取当前租户含删除记录,内存建树 |
PUT /api/comments/{id} | UpdateComment.Command | Update | 作者 30 分钟内或 admin 角色 |
DELETE /api/comments/{id} | DeleteComment.Command | Delete | 作者或 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. 数据模型
SysComment 是 tenant + soft-delete 支持表:
| 字段 | 限制 | 说明 |
|---|---|---|
| CommentId | 36 | 客户端幂等键,迁移时可复用旧 TicketComment Id |
| EntityType | 50 | 客户端自由字符串,目前无 registry |
| EntityId | 36 | 目标资源 Id,目前不验证 owner |
| ParentCommentId | 36,可空 | 引用同资源父评论的 CommentId |
| AuthorId | 36 | UserId 字符串;无 User 时 Create 写 "0" |
| Body | 10000,可空 | 创建/更新限制长度;删除后清空 |
| EditedAt | 可空 | 最近编辑时间;删除不记录删除时间/人 |
唯一索引为 (TenantId, EntityType, EntityId, CommentId)。另有 EntityType+EntityId、ParentCommentId、AuthorId 普通索引;后三个没有显式 TenantId 前缀,需用实际执行计划验证多租户大表性能。
5. 创建和回复
POST /api/comments/ HTTP/1.1Authorization: Bearer eyJhbGciOiJSUzI1NiJ9Content-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 创建不能跨资源回复。
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. 当前最重要的缺口
- EntityType/EntityId 是自由输入,没有 subject registry 或 owner policy。
- Documents.AllowComments、Ticket 可见性/状态等 owner 规则完全未执行。
- Create/Get 使用
User.TenantId调 store,而 store 同时要求EffectiveTenantId;模拟租户时可能空读或幂等失效。 - Create 不要求 User;无 UserId 时 AuthorId=
0。 - 管理员绕过依赖角色字符串
admin,不是权限/能力决策。 - 标识长度只在数据库元数据声明,handler 不提前验证。
- 幂等不覆盖并发插入和软删除重放。
- 线程没有分页、深度/回复数限制、环保护或稳定同时间排序键。
- 没有创建/编辑/删除事件、提及、通知、审核或审计专用数据。
- 没有 Feature catalog;不能按套餐/租户关闭评论。
8. 阅读路径
关联章节:Documents、Authorization、Multitenancy 和 Auditing。
9. 源码核对
# 路由、动作和通用权限目录。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'