Browse Source

docs(spec): add post comments feature design for public and unlisted posts

Made-with: Cursor
main
npmrun 7 hours ago
parent
commit
9bee515a8b
  1. 137
      docs/superpowers/specs/2026-04-18-post-comments-design.md

137
docs/superpowers/specs/2026-04-18-post-comments-design.md

@ -0,0 +1,137 @@
# 设计:文章详情评论(公开 + 未列出)
**日期**: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/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: 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()`。
**允许条件**(满足其一且目标未删):
1. `comment.authorUserId === currentUser.id`(删自己的);或
2. `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`,避免扩展公开文章接口。
Loading…
Cancel
Save