1 changed files with 137 additions and 0 deletions
@ -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…
Reference in new issue