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.
 
 
 
 

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_*.sqlmeta/* drizzle-kit generate 生成后提交
server/service/posts/index.ts 新增 getPublicPostCommentContextgetUnlistedPostCommentContext{ id, userId } | null
server/utils/post-comment-guest.ts 访客昵称/正文校验(拒绝 URL、Markdown 链接、[]()
server/utils/post-comment-guest.test.ts 访客校验单测
server/service/post-comments/index.ts listCommentTreeForPostcreateCommentOnPostsoftDeleteComment
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.tsposts 表定义之后追加(注意 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.idposts.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,此处为契约摘要):

  1. import dbGlobalpostCommentspostsusersandasceqisNullsql(按需)、nextIntegerIdGuestCommentValidationErrornormalizeGuestDisplayNamevalidateGuestCommentBodyassertUnderRateLimitgetRequestIP(在 handler 层调用限流也可)。
  2. listCommentsPayload(postId, ownerUserId, viewerUserId | null)
    • 查询该 postId 全部评论 leftJoin usersnicknameusernamepublicSlug
    • createdAt 升序。
    • 在内存构建 id -> node,根列表 parentId == null,子节点挂到父节点 children(数组),顺序保持升序。
    • viewerCanModeratePost = viewerUserId != null && viewerUserId === ownerUserIdownerUserId 为帖子作者,来自 Task 3 的 userId)。
    • 每条节点:deleted = deletedAt != null;若删除则 body: nullguestDisplayName / author 置空或最小占位(与 spec 一致);viewerCanDelete:未删且 (viewerCanModeratePost 或 (kind==='user'authorUserId===viewerUserId))。
  3. createComment(params)
    • postIdownerUserIdparentIdviewerMinimalUser | null)、bodyguestDisplayName?
    • viewer 非空:kind='user'body trim 后长度 ≤8000,否则 400;authorUserId=viewer.idguestDisplayName=null
    • viewer 为空:调用 normalizeGuestDisplayNamevalidateGuestCommentBodykind='guest'
    • 校验 parentId:若存在,查父行,须同 postIddeletedAt 为空,否则 400。
    • id = await nextIntegerId(postComments, postComments.id)insert
  4. softDeleteComment({ postId, commentId, actorUserId })
    • 查评论与帖子;帖子 userIdownerUserId
    • 允许条件:comment.authorUserId === actorUserIdownerUserId === actorUserId;否则 403。
    • 已删则 400 或 404(选一种并在实现中统一)。
    • update 设置 deletedAtdeletedByUserId

限流建议在 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,参数校验,404createError

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
捕获 GuestCommentValidationErrorcreateError({ statusCode: 400, statusMessage: err.message })

  • Step 2: 手动冒烟

Run bun run dev,对公开文章 curl GET /api/public/profile/.../posts/.../comments,Expect code:0data.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 + (postSlugshareToken)。

  • useAsyncData 拉取 comments(key 含路由参数)。

  • 展示树:递归子组件或内联递归模板;软删显示「此评论已删除」类文案(UAlerttext-muted)。

  • 发表:$fetch POST 到对应 API;登录用户用 request(若需 cookie)——与 app/pages/me 一致;公开页 POST 若需 cookie,$fetchcredentials: 'include'

  • 删除:若 viewerCanDelete,调用 request(\/api/me/posts/${postId}/comments/${id}`, { method: 'DELETE' }),成功后 refreshNuxtData`。

  • Step 2: 两处页面挂载

[postSlug].vuetemplatearticle 后插入 <PostComments mode="public-post" ... />
t/[shareToken].vuekind === '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 顺序实现,批次之间设检查点。

你想用哪一种?