19 KiB
Post Comments(公开 + 未列出)Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: 为公开文章与未列出分享文章提供无限嵌套评论(访客极简 + 登录 Markdown 文本展示)、软删除与 GET 返回 postId / 权限位,两端页面共用评论区组件。
Architecture: 在 drizzle-pkg 增加 post_comments 表;server/service/posts 增加仅服务端使用的帖子解析(返回 postId + ownerUserId);新建 server/service/post-comments 负责列表组树、创建、软删与 DTO;公开路径四套 Nitro 路由(两 GET + 两 POST)+ DELETE /api/me/posts/:postId/comments/:commentId;访客校验与限流放在独立 util;前端 PostComments 用现有 $fetch + unwrapApiBody / 公开接口包装约定。
Tech Stack: Nuxt 4 / Nitro、Vue 3、Drizzle ORM 0.45、SQLite(better-sqlite3)、Bun test、@nuxt/ui 4.6、h3 getRequestIP。
Spec: docs/superpowers/specs/2026-04-18-post-comments-design.md
文件结构(将创建 / 修改)
| 路径 | 职责 |
|---|---|
packages/drizzle-pkg/database/sqlite/schema/content.ts |
定义 postComments 表 + 索引 |
packages/drizzle-pkg/lib/schema/content.ts |
export { ..., postComments } |
packages/drizzle-pkg/migrations/0002_*.sql 与 meta/* |
由 drizzle-kit generate 生成后提交 |
server/service/posts/index.ts |
新增 getPublicPostCommentContext、getUnlistedPostCommentContext({ id, userId } | null) |
server/utils/post-comment-guest.ts |
访客昵称/正文校验(拒绝 URL、Markdown 链接、[]()) |
server/utils/post-comment-guest.test.ts |
访客校验单测 |
server/service/post-comments/index.ts |
listCommentTreeForPost、createCommentOnPost、softDeleteComment |
server/api/public/profile/[publicSlug]/posts/[postSlug]/comments.get.ts |
公开 GET |
server/api/public/profile/[publicSlug]/posts/[postSlug]/comments.post.ts |
公开 POST |
server/api/public/unlisted/[publicSlug]/[shareToken]/comments.get.ts |
未列出 GET |
server/api/public/unlisted/[publicSlug]/[shareToken]/comments.post.ts |
未列出 POST |
server/api/me/posts/[postId]/comments/[commentId].delete.ts |
软删(defineWrappedResponseHandler + requireUser) |
app/components/PostComments.vue |
列表 + 表单 + 递归展示 + 删除 |
app/pages/@[publicSlug]/posts/[postSlug].vue |
挂载组件 |
app/pages/p/[publicSlug]/t/[shareToken].vue |
在 kind === 'post' 分支挂载 |
展示策略(与正文对齐): 用户评论 body 与文章 bodyMarkdown 一样,在 Vue 模板中用 文本插值 {{ }} 输出,依赖自动转义防 XSS;不新增 Markdown 渲染依赖。访客 body 经服务端校验为纯文本后同样展示。
Task 1: Drizzle 表 post_comments 与迁移
Files:
-
Modify:
packages/drizzle-pkg/database/sqlite/schema/content.ts -
Modify:
packages/drizzle-pkg/lib/schema/content.ts -
Create:
packages/drizzle-pkg/migrations/0002_post-comments.sql(或由 generate 产出,以实际文件名为准) -
Modify:
packages/drizzle-pkg/migrations/meta/_journal.json(若 generate 已更新则只提交) -
Step 1: 在 schema 中追加表定义
packages/drizzle-pkg/database/sqlite/schema/content.ts 在 posts 表定义之后追加(注意 posts 已 import,自引用用箭头函数):
export const postComments = sqliteTable(
"post_comments",
{
id: integer().primaryKey(),
postId: integer("post_id")
.notNull()
.references(() => posts.id, { onDelete: "cascade" }),
parentId: integer("parent_id").references(() => postComments.id),
authorUserId: integer("author_user_id").references(() => users.id, { onDelete: "set null" }),
guestDisplayName: text("guest_display_name"),
body: text("body").notNull(),
kind: text("kind").notNull(),
deletedAt: integer("deleted_at", { mode: "timestamp_ms" }),
deletedByUserId: integer("deleted_by_user_id").references(() => users.id, { onDelete: "set null" }),
createdAt: integer("created_at", { mode: "timestamp_ms" }).defaultNow().notNull(),
updatedAt: integer("updated_at", { mode: "timestamp_ms" })
.defaultNow()
.$onUpdate(() => new Date())
.notNull(),
},
(table) => [
index("post_comments_post_id_idx").on(table.postId),
index("post_comments_parent_id_idx").on(table.parentId),
],
);
若 users 未在该文件 import,在文件顶部自 ./auth 引入 users(与 posts 引用方式一致)。
packages/drizzle-pkg/lib/schema/content.ts:
export { posts, timelineEvents, postComments } from "../../database/sqlite/schema/content";
- Step 2: 生成迁移 SQL
Run:
cd /home/dash/projects/person-panel && bun run db:generate post-comments
Expected: packages/drizzle-pkg/migrations 下新增一条 migration,且 meta/_journal.json 更新。
- Step 3: 本地执行迁移
Run:
cd /home/dash/projects/person-panel && bun run db:migrate
Expected: 无报错,本地 DB 存在 post_comments 表。
- Step 4: Commit
cd /home/dash/projects/person-panel
git add packages/drizzle-pkg/database/sqlite/schema/content.ts packages/drizzle-pkg/lib/schema/content.ts packages/drizzle-pkg/migrations
git commit -m "feat(db): add post_comments table for nested comments"
Task 2: 访客昵称与正文校验(TDD)
Files:
-
Create:
server/utils/post-comment-guest.ts -
Create:
server/utils/post-comment-guest.test.ts -
Step 1: 编写失败测试
server/utils/post-comment-guest.test.ts:
import { describe, expect, test } from "bun:test";
import {
GuestCommentValidationError,
normalizeGuestDisplayName,
validateGuestCommentBody,
} from "./post-comment-guest";
describe("normalizeGuestDisplayName", () => {
test("trims and accepts valid name", () => {
expect(normalizeGuestDisplayName(" ada ")).toBe("ada");
});
test("rejects empty", () => {
expect(() => normalizeGuestDisplayName(" ")).toThrow(GuestCommentValidationError);
});
test("rejects too long", () => {
expect(() => normalizeGuestDisplayName("a".repeat(33))).toThrow(GuestCommentValidationError);
});
});
describe("validateGuestCommentBody", () => {
test("accepts plain text", () => {
expect(validateGuestCommentBody("你好,世界")).toBe("你好,世界");
});
test("rejects http URL", () => {
expect(() => validateGuestCommentBody("see http://x.com")).toThrow(GuestCommentValidationError);
});
test("rejects markdown link", () => {
expect(() => validateGuestCommentBody("[a](http://b)")).toThrow(GuestCommentValidationError);
});
test("rejects too long", () => {
expect(() => validateGuestCommentBody("z".repeat(501))).toThrow(GuestCommentValidationError);
});
});
- Step 2: 运行测试确认失败
Run:
cd /home/dash/projects/person-panel && bun test server/utils/post-comment-guest.test.ts
Expected: FAIL(模块不存在或未导出)。
- Step 3: 最小实现
server/utils/post-comment-guest.ts:
const MAX_NAME = 32;
const MAX_BODY = 500;
export class GuestCommentValidationError extends Error {
constructor(message: string) {
super(message);
this.name = "GuestCommentValidationError";
}
}
export function normalizeGuestDisplayName(raw: string): string {
const t = raw.trim();
if (!t) {
throw new GuestCommentValidationError("请填写昵称");
}
if (t.length > MAX_NAME) {
throw new GuestCommentValidationError("昵称过长");
}
return t;
}
/** 返回 trim 后正文;违规抛 GuestCommentValidationError(由 API 映射为 400) */
export function validateGuestCommentBody(raw: string): string {
const t = raw.trim();
if (!t) {
throw new GuestCommentValidationError("请填写内容");
}
if (t.length > MAX_BODY) {
throw new GuestCommentValidationError("内容过长");
}
if (/https?:\/\//i.test(t) || /\bwww\./i.test(t)) {
throw new GuestCommentValidationError("不能包含链接");
}
if (/\[[^\]]*\]\([^)]*\)/.test(t)) {
throw new GuestCommentValidationError("不能使用 Markdown 链接");
}
return t;
}
- Step 4: 运行测试确认通过
Run:
cd /home/dash/projects/person-panel && bun test server/utils/post-comment-guest.test.ts
Expected: PASS。
- Step 5: Commit
cd /home/dash/projects/person-panel
git add server/utils/post-comment-guest.ts server/utils/post-comment-guest.test.ts
git commit -m "feat(comments): guest display name and body validation"
Task 3: 帖子上下文查询(postId + ownerUserId)
Files:
-
Modify:
server/service/posts/index.ts -
Step 1: 新增两个导出函数
在 server/service/posts/index.ts 中 import postComments 不需要;仅查询 posts + users。追加(与现有 getPublicPostByPublicSlugAndSlug 条件一致,但 select posts.id 与 posts.userId):
export async function getPublicPostCommentContext(publicSlug: string, postSlug: string) {
const slug = postSlug.trim();
if (!slug) return null;
const [row] = await dbGlobal
.select({
id: posts.id,
userId: posts.userId,
})
.from(posts)
.innerJoin(users, eq(posts.userId, users.id))
.where(
and(
eq(users.publicSlug, publicSlug),
eq(users.status, "active"),
eq(posts.visibility, "public"),
eq(posts.slug, slug),
),
)
.limit(1);
return row ?? null;
}
export async function getUnlistedPostCommentContext(publicSlug: string, shareToken: string) {
const [row] = await dbGlobal
.select({
id: posts.id,
userId: posts.userId,
})
.from(posts)
.innerJoin(users, eq(posts.userId, users.id))
.where(
and(
eq(users.publicSlug, publicSlug),
eq(users.status, "active"),
eq(posts.visibility, "unlisted"),
eq(posts.shareToken, shareToken),
),
)
.limit(1);
return row ?? null;
}
- Step 2: Commit
cd /home/dash/projects/person-panel
git add server/service/posts/index.ts
git commit -m "feat(posts): add comment context resolvers for public and unlisted"
Task 4: post-comments 服务(列表树、创建、软删)
Files:
-
Create:
server/service/post-comments/index.ts -
Step 1: 实现服务模块
要点(实现时写完整 TypeScript,此处为契约摘要):
- import
dbGlobal、postComments、posts、users、and、asc、eq、isNull、sql(按需)、nextIntegerId、GuestCommentValidationError、normalizeGuestDisplayName、validateGuestCommentBody、assertUnderRateLimit、getRequestIP(在 handler 层调用限流也可)。 listCommentsPayload(postId, ownerUserId, viewerUserId | null)- 查询该
postId全部评论 leftJoinusers取nickname、username、publicSlug。 - 按
createdAt升序。 - 在内存构建
id -> node,根列表parentId == null,子节点挂到父节点children(数组),顺序保持升序。 viewerCanModeratePost = viewerUserId != null && viewerUserId === ownerUserId(ownerUserId为帖子作者,来自 Task 3 的userId)。- 每条节点:
deleted=deletedAt != null;若删除则body: null,guestDisplayName/author置空或最小占位(与 spec 一致);viewerCanDelete:未删且 (viewerCanModeratePost或 (kind==='user'且authorUserId===viewerUserId))。
- 查询该
createComment(params)postId、ownerUserId、parentId、viewer(MinimalUser | null)、body、guestDisplayName?。- 若
viewer非空:kind='user',bodytrim 后长度 ≤8000,否则 400;authorUserId=viewer.id;guestDisplayName=null。 - 若
viewer为空:调用normalizeGuestDisplayName、validateGuestCommentBody;kind='guest'。 - 校验
parentId:若存在,查父行,须同postId且deletedAt为空,否则 400。 id = await nextIntegerId(postComments, postComments.id),insert。
softDeleteComment({ postId, commentId, actorUserId })- 查评论与帖子;帖子
userId为ownerUserId。 - 允许条件:
comment.authorUserId === actorUserId或ownerUserId === actorUserId;否则 403。 - 已删则 400 或 404(选一种并在实现中统一)。
update设置deletedAt、deletedByUserId。
- 查评论与帖子;帖子
限流建议在 API 层 对 POST:assertUnderRateLimit(\comment:guest:${ip}`, 20, 15 * 60 * 1000)与登录comment:user:${userId}` 更宽(如 60/15min)。
- Step 2: Commit
cd /home/dash/projects/person-panel
git add server/service/post-comments/index.ts
git commit -m "feat(comments): service for tree listing, create, and soft delete"
Task 5: 公开 / 未列出 GET + POST 路由
Files:
-
Create:
server/api/public/profile/[publicSlug]/posts/[postSlug]/comments.get.ts -
Create:
server/api/public/profile/[publicSlug]/posts/[postSlug]/comments.post.ts -
Create:
server/api/public/unlisted/[publicSlug]/[shareToken]/comments.get.ts -
Create:
server/api/public/unlisted/[publicSlug]/[shareToken]/comments.post.ts -
Step 1: 实现四个 handler
模式与 server/api/public/profile/[publicSlug]/posts/[postSlug].get.ts 相同:defineEventHandler,参数校验,404 用 createError。
GET:
const ctx = await getPublicPostCommentContext(...) 或 unlisted 版本;若无则 404。
const viewer = await event.context.auth.getCurrent()(01.context.ts 已注入 auth;公开路由同样应有 event.context.auth — 若不存在则改用 createAuthContext(event).getCurrent(),以仓库实际 server/plugins/01.context.ts 为准)。
return R.success(await listCommentsPayload(ctx.id, ctx.userId, viewer?.id ?? null)) — 注意将函数签名在 Task 4 中定义为 (postId, ownerUserId, viewerId)。
POST:
readBody 取 { parentId?: number | null, guestDisplayName?: string, body: string }。
限流 + createComment。
捕获 GuestCommentValidationError → createError({ statusCode: 400, statusMessage: err.message })。
- Step 2: 手动冒烟
Run bun run dev,对公开文章 curl GET /api/public/profile/.../posts/.../comments,Expect code:0 且 data.postId 存在。
- Step 3: Commit
cd /home/dash/projects/person-panel
git add server/api/public/profile server/api/public/unlisted
git commit -m "feat(api): public and unlisted comment list and create endpoints"
Task 6: DELETE /api/me/posts/:postId/comments/:commentId
Files:
-
Create:
server/api/me/posts/[postId]/comments/[commentId].delete.ts -
Step 1: 实现 handler
import { softDeleteComment } from "#server/service/post-comments";
export default defineWrappedResponseHandler(async (event) => {
const user = await event.context.auth.requireUser();
const postId = Number(event.context.params?.postId);
const commentId = Number(event.context.params?.commentId);
if (!Number.isFinite(postId) || !Number.isFinite(commentId)) {
throw createError({ statusCode: 400, statusMessage: "无效请求" });
}
await softDeleteComment({ postId, commentId, actorUserId: user.id });
return R.success({ ok: true });
});
softDeleteComment 内部校验 post 存在且 comment.postId === postId。
- Step 2: Commit
cd /home/dash/projects/person-panel
git add "server/api/me/posts/[postId]/comments/[commentId].delete.ts"
git commit -m "feat(api): soft-delete comment from me scope"
Task 7: 前端 PostComments 与页面挂载
Files:
-
Create:
app/components/PostComments.vue -
Modify:
app/pages/@[publicSlug]/posts/[postSlug].vue -
Modify:
app/pages/p/[publicSlug]/t/[shareToken].vue -
Step 1: 组件行为
Props:'public-post' | 'unlisted' + publicSlug + (postSlug 或 shareToken)。
-
useAsyncData拉取 comments(key 含路由参数)。 -
展示树:递归子组件或内联递归模板;软删显示「此评论已删除」类文案(
UAlert或text-muted)。 -
发表:
$fetchPOST 到对应 API;登录用户用request(若需 cookie)——与app/pages/me一致;公开页 POST 若需 cookie,$fetch加credentials: 'include'。 -
删除:若
viewerCanDelete,调用request(\/api/me/posts/${postId}/comments/${id}`, { method: 'DELETE' }),成功后refreshNuxtData`。 -
Step 2: 两处页面挂载
在 [postSlug].vue 的 template 中 article 后插入 <PostComments mode="public-post" ... />。
在 t/[shareToken].vue 的 kind === 'post' 分支内 article 后插入 <PostComments mode="unlisted" ... />。
- Step 3: Commit
cd /home/dash/projects/person-panel
git add app/components/PostComments.vue app/pages
git commit -m "feat(ui): post comments on public and unlisted post pages"
Task 8: 验证与收尾
- Step 1: 运行单测
Run:
cd /home/dash/projects/person-panel && bun test server/utils/post-comment-guest.test.ts
Expected: PASS。
- Step 2: 构建
Run:
cd /home/dash/projects/person-panel && bun run build
Expected: PASS(若失败,检查 Nitro 路由与 import 别名)。
-
Step 3: 手动验收清单(勾选)
-
公开文章:列表空、访客发帖、登录发帖、嵌套回复、本人删、作者删访客帖、软删占位。
-
未列出链接:同上。
-
访客含 URL → 400。
-
回复已删父节点 → 400。
可选增量(非验收)
- 管理员删评: 在
softDeleteComment中若user.role === 'admin'允许删除(需查users.role),与#server/utils/admin-guard语义对齐。 - 更严 Markdown: 若未来正文改为 HTML 渲染,评论侧同步改为同一净化管道。
Plan self-review
| Spec 章节 | 对应 Task |
|---|---|
| 数据模型 | Task 1 |
| 访客规则 | Task 2、4、5 |
| GET/POST 双入口 | Task 3、5 |
| 软删 + me DELETE | Task 4、6 |
| 前端共用 | Task 7 |
| 测试 | Task 2、8 |
Placeholder scan: 无 TBD;限流数字已写死示例,可按压测调整。
类型一致性: listCommentsPayload 参数顺序在 Task 4/5 中均为 (postId, ownerUserId, viewerId);若实现时改名,需两文件同步。
Plan complete and saved to docs/superpowers/plans/2026-04-18-post-comments-implementation-plan.md. Two execution options:
1. Subagent-Driven (recommended) — 每个 Task 派生子代理,Task 之间人工过一遍,迭代快。
2. Inline Execution — 本会话按 Task 顺序实现,批次之间设检查点。
你想用哪一种?