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

设计:文章详情评论(公开 + 未列出)

日期: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.idON 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/comments
  • POST 同上路径

未列出文章

  • GET /api/public/unlisted/:publicSlug/:shareToken/comments
  • POST 同上路径

解析规则

  • 公开路径:仅当帖子 visibility === 'public' 且 slug 匹配时 200;否则 404
  • 未列出路径:仅当 visibility === 'unlisted'shareToken 匹配时 200;否则 404

GET 响应(概念):

  • postId:整数,供前端调用 DELETE /api/me/posts/:postId/comments/:commentId(公开文章接口仍不返回 postId,仅评论接口返回)。
  • comments:树形 扁平列表(含 parentId),实现任选;软删节点 占位(如 deleted: truebody: null),不返回已删原文。
  • viewerCanModeratePostboolean,当前会话用户是否为 该帖 userId(服务端计算,在公开文章 DTO 中暴露 userId)。
  • 每条 未软删 评论均含 viewerCanDeleteboolean。定义为:当前用户是该条评论的发表者(kind=userauthorUserId 匹配会话) 当前用户是文章作者(与 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()

允许条件(满足其一且目标未删):

  1. comment.authorUserId === currentUser.id(删自己的);或
  2. post.userId === currentUser.id(文章作者删任意楼)。

效果:写入 deletedAtdeletedByUserId物理删除。

可选扩展role === 'admin' 同权删除——第一期不写入必选验收,可在实现计划中标注。

6. 前端

  • 共用组件(如 PostComments):由公开详情页与未列出详情页传入不同 API 基路径(slug+postSlug vs slug+shareToken)。
  • 位置:正文 article 下方;未列出页仅在 data.kind === 'post' 分支渲染。
  • 登录态useAuthSession / 现有 auth;登录用户 Markdown 输入与 安全渲染prose 等);访客昵称 + 纯文本。
  • 树形展示:递归或扁平缩进;注意移动端溢出(左边线 / max-width)。
  • 删除按钮:依赖 GET 返回的 viewerCanModeratePostviewerCanDelete(及楼主对子树的删除入口),避免前端猜测 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,避免扩展公开文章接口。