You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
7.6 KiB
7.6 KiB
设计:文章详情评论(公开 + 未列出)
日期:2026-04-18
状态:已定稿(与产品对话一致)
1. 背景与目标
在 公开文章详情(/@{publicSlug}/posts/{postSlug})与 未列出分享页(/p/{publicSlug}/t/{shareToken},且 kind === 'post')下方提供 无限嵌套 评论区;两种入口访问 同一篇帖子 时共用 同一评论线程(由 posts.id 唯一确定)。
2. 产品规则(已确认)
| 维度 | 规则 |
|---|---|
| 参与者 | 混合:已登录用户完整评论;访客极简留言(更短上限、无 Markdown、无链接)。 |
| 访客删除 | 不提供自助删除;仅 文章作者(及可选扩展的管理员)可 软删除 任意评论。 |
| 登录用户删除 | 可 软删除自己的 评论(与作者删评共用软删除语义)。 |
| 编辑 | 不提供评论编辑(无 editedAt)。 |
| 作者/管理 | 删评均为 软删除:前台 占位(不展示原文),库内保留记录并记录 删除者与时间 供审计。 |
| 结构 | 无限嵌套(邻接表 parent_id)。 |
| 入口 | B:公开 slug 与未列出 token 两种页面均挂载评论区(服务端按可见性校验,见 API)。 |
3. 非目标(第一期)
- 评论通知、@ 提醒、邮件订阅。
- 访客自助删除、一次性管理链接、验证码(已选 D 方案中的「极简」而非验证码分支)。
- 管理员全局删评:若现有
role === 'admin'能力成熟,可在实现计划中列为 可选增量;本设计正文以 文章作者 为主。 - 评论全文搜索、反应表情、排序切换(多种排序)。
4. 数据模型
表名:post_comments(与 packages/drizzle-pkg 内现有 posts 同库、迁移方式一致)。
| 字段 | 说明 |
|---|---|
id |
主键;整数策略与 posts.id 一致。 |
postId |
外键 → posts.id,ON DELETE CASCADE。 |
parentId |
可空,自引用 → post_comments.id;根评论为 NULL。 |
authorUserId |
可空;登录用户评论必填,访客为 NULL。 |
guestDisplayName |
可空;访客必填,登录用户为 NULL。 |
body |
存储正文:访客为净化后纯文本;登录用户为 Markdown 原文或经约定的可逆存储(实现计划定稿)。 |
kind |
guest | user(与是否登录一致,服务端强制)。 |
deletedAt |
可空;非空即软删除。 |
deletedByUserId |
可空;执行软删除的操作者(文章作者或评论者本人)。 |
createdAt / updatedAt |
与项目现有表风格一致。 |
索引:至少 (postId);建议 (postId, createdAt) 或等价,便于列表;(parentId) 便于取子节点。
约束(业务层强制执行):parentId 若非空,父节点须存在、属同一 postId,且 父节点未软删(禁止回复已删除节点)。根评论按 createdAt 升序;子评论按 createdAt 升序。
字数上限(第一期建议值,实现可微调但须写入契约):
- 访客
guestDisplayName:最多 32 字符(trim 后)。 - 访客
body:最多 500 字符,服务端 去除或拒绝 URL/类 Markdown 语法(具体策略在实现计划中写清)。 - 登录用户
body:最多 8000 字符,Markdown + 服务端安全净化(与站点正文策略对齐)。
5. API 契约
5.1 列表与发表(无需登录即可读;发表可为访客)
公开文章:
GET /api/public/profile/:publicSlug/posts/:postSlug/commentsPOST同上路径
未列出文章:
GET /api/public/unlisted/:publicSlug/:shareToken/commentsPOST同上路径
解析规则:
- 公开路径:仅当帖子
visibility === 'public'且 slug 匹配时 200;否则 404。 - 未列出路径:仅当
visibility === 'unlisted'且shareToken匹配时 200;否则 404。
GET 响应(概念):
postId:整数,供前端调用DELETE /api/me/posts/:postId/comments/:commentId(公开文章接口仍不返回postId,仅评论接口返回)。comments:树形 或 扁平列表(含parentId),实现任选;软删节点 占位(如deleted: true、body: null),不返回已删原文。viewerCanModeratePost:boolean,当前会话用户是否为 该帖userId(服务端计算,不在公开文章 DTO 中暴露userId)。- 每条 未软删 评论均含
viewerCanDelete:boolean。定义为:当前用户是该条评论的发表者(kind=user且authorUserId匹配会话)或 当前用户是文章作者(与viewerCanModeratePost === true一致)。访客永远不能自助删自己的留言;楼主对访客留言此字段为true。
POST body:
- 公共:
parentId可选(整数或 null)。 - 会话判定:
kind由服务端根据是否存在有效会话决定,不信任客户端自报(避免伪造kind=user)。 - 未登录:
guestDisplayName+body(纯文本),按访客规则校验。 - 已登录:
body(Markdown);忽略guestDisplayName。
频控:访客按 IP(可叠加简单 UA)限流;登录用户较宽,仍需基本上限。
5.2 软删除(须登录)
路径(推荐):DELETE /api/me/posts/:postId/comments/:commentId
鉴权:requireUser()。
允许条件(满足其一且目标未删):
comment.authorUserId === currentUser.id(删自己的);或post.userId === currentUser.id(文章作者删任意楼)。
效果:写入 deletedAt、deletedByUserId;不物理删除。
可选扩展:role === 'admin' 同权删除——第一期不写入必选验收,可在实现计划中标注。
6. 前端
- 共用组件(如
PostComments):由公开详情页与未列出详情页传入不同 API 基路径(slug+postSlug vs slug+shareToken)。 - 位置:正文
article下方;未列出页仅在data.kind === 'post'分支渲染。 - 登录态:
useAuthSession/ 现有 auth;登录用户 Markdown 输入与 安全渲染(prose等);访客昵称 + 纯文本。 - 树形展示:递归或扁平缩进;注意移动端溢出(左边线 /
max-width)。 - 删除按钮:依赖 GET 返回的
viewerCanModeratePost、viewerCanDelete(及楼主对子树的删除入口),避免前端猜测post.userId。
7. 错误与边界
| 场景 | 处理 |
|---|---|
| 帖子不存在或可见性不匹配 | 404 |
parentId 非法、父已删、跨帖 |
400 |
| 正文超长或访客含禁用内容 | 400 |
| 未登录调用 DELETE | 401 |
| 已登录但无删评权限 | 403 |
| 频控触发 | 429 |
软删节点保留在树中占位,子评论仍可显示(第一期);若未来要「隐藏整棵子树」另开需求。
8. 测试
- Service 层:
postId解析(公开 vs 未列出)、嵌套parentId、软删权限、访客净化。 - 若有现有 API 测试惯例:补公开与未列出各一条 GET+POST happy path。
- 前端:手动验收清单(两入口、登录/访客、深嵌套、删评占位、无权限删评)。
9. 方案取舍摘要
- 存储结构:邻接表(
parent_id),不用物化路径或闭包表。 - 删评 API:放在
/api/me/...,与写操作、会话模型一致。 - 权限提示:由 GET comments 返回
viewerCanModeratePost与逐条viewerCanDelete,避免扩展公开文章接口。