From ba36a2dd858649e9d3deafef5de793ab1b48800c Mon Sep 17 00:00:00 2001 From: npmrun <1549469775@qq.com> Date: Sat, 18 Apr 2026 22:07:46 +0800 Subject: [PATCH] docs(plan): public about page and media_refs ownerType Made-with: Cursor --- ...e-about-media-refs-owner-implementation-plan.md | 492 +++++++++++++++++++++ 1 file changed, 492 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-18-public-profile-about-media-refs-owner-implementation-plan.md diff --git a/docs/superpowers/plans/2026-04-18-public-profile-about-media-refs-owner-implementation-plan.md b/docs/superpowers/plans/2026-04-18-public-profile-about-media-refs-owner-implementation-plan.md new file mode 100644 index 0000000..4a60822 --- /dev/null +++ b/docs/superpowers/plans/2026-04-18-public-profile-about-media-refs-owner-implementation-plan.md @@ -0,0 +1,492 @@ +# 公开「关于我」页 + `media_refs` ownerType(profile)实现计划 + +> **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:** 实现 `docs/superpowers/specs/2026-04-18-public-profile-about-and-media-refs-design.md`:公开路由 `/@:publicSlug/about`、轻量 API `GET /api/public/profile/:publicSlug/about`,并将 `media_refs` 从「仅 post」演进为 `(owner_type, owner_id, asset_id)`,新增 `syncProfileMediaRefs`,使简介与头像中的站内图纳入孤儿判定。 + +**Architecture:** 数据上 **`users.bio_markdown` / `avatar` 仍为唯一真相**;`media_refs` 中 `owner_type` 为 `post` 时 `owner_id === posts.id`,为 `profile` 时 `owner_id === users.id`。**不对 `owner_id` 做多态外键**;删除文章时**先**删 `owner_type='post'` 的引用行再删 `posts` 行,再 `reconcileAssetTimestampsAfterRefChange`。资料保存路径在 `updateProfile` 成功后对 `bioMarkdown`/`avatar` 变更调用 `syncProfileMediaRefs`。公开 about 页仅拉轻量 API;主页 bio 与 about 共用 `renderSafeMarkdown` + `v-html`。 + +**Tech Stack:** Nuxt 4.4 / Nitro、Drizzle + SQLite(`drizzle-pkg`)、Bun、现有 `markdown-it` + `isomorphic-dompurify`(`app/utils/render-markdown.ts`)。 + +**Spec:** `docs/superpowers/specs/2026-04-18-public-profile-about-and-media-refs-design.md` + +**现状说明:** 表已从 `post_media_refs` 重命名为 **`media_refs`**(`0004` 迁移);本计划在 **`media_refs` 上**做列结构升级,不是第二次改名。 + +**测试说明:** 以 spec §7 **手工验收**为主;每任务完成后执行 `bun run build` 或通过 `bun run dev` 冒烟。 + +--- + +## 文件结构(将创建 / 修改) + +| 路径 | 职责 | +|------|------| +| `packages/drizzle-pkg/database/sqlite/schema/content.ts` | `mediaRefs` 改为 `ownerType` + `ownerId` + `assetId`;移除对 `posts` 的外键 | +| `packages/drizzle-pkg/migrations/0005_media_refs_owner_type.sql` | 重建表并回填 `post` 行 | +| `packages/drizzle-pkg/migrations/meta/_journal.json` | 追加 `0005` 条目 | +| `packages/drizzle-pkg/migrations/meta/0005_snapshot.json` | Drizzle meta(由 0004 派生并替换 `media_refs` 块) | +| `packages/drizzle-pkg/db.sqlite` | 本地迁移后提交(若仓库跟踪该文件) | +| `server/constants/media-refs.ts`(或并入 `server/constants/media.ts`) | `MEDIA_REF_OWNER_POST`、`MEDIA_REF_OWNER_PROFILE` 字符串常量 | +| `server/utils/post-media-urls.ts` | 新增 `mergeProfileMediaUrls(bioMarkdown, avatar)`(内部复用 `mergePostMediaUrls`) | +| `server/service/media/index.ts` | `syncPostMediaRefs`、`syncProfileMediaRefs`、`reconcile*`、孤儿与删除判定改为按 `mediaRefs` 全表 | +| `server/service/posts/index.ts` | `deletePost`:先删 `media_refs` post 行再删 post;`syncPostMediaRefs` 改用新列名 | +| `server/service/profile/index.ts` | `updateProfile` 末尾在 `bioMarkdown`/`avatar` 参与 patch 时调用 `syncProfileMediaRefs` | +| `server/api/me/profile.put.ts` | 若同步放在 service 内则可能无需改;若放在 handler 则调用 service 暴露的同步(优先 **集中在 `updateProfile`**) | +| `server/api/public/profile/[publicSlug]/about.get.ts` | 轻量 200/404 | +| `app/pages/@[publicSlug]/about/index.vue` | about 页布局 + `renderSafeMarkdown` | +| `app/pages/@[publicSlug]/index.vue` | bio 区块改为 `v-html` + 条件「查看全文」链到 `/@slug/about` | +| `docs/superpowers/specs/2026-04-18-public-profile-about-and-media-refs-design.md` | 可选:将文中 `post_media_refs` 改为 `media_refs`(文档一致性,非阻塞) | + +--- + +### Task 1: `media_refs` 表结构与迁移 `0005` + +**Files:** + +- Modify: `packages/drizzle-pkg/database/sqlite/schema/content.ts` +- Create: `packages/drizzle-pkg/migrations/0005_media_refs_owner_type.sql` +- Modify: `packages/drizzle-pkg/migrations/meta/_journal.json` +- Create: `packages/drizzle-pkg/migrations/meta/0005_snapshot.json` + +- [ ] **Step 1: 替换 `mediaRefs` 表定义为三列复合主键** + +在 `packages/drizzle-pkg/database/sqlite/schema/content.ts` 中,将现有 `mediaRefs`(`postId` + `assetId`)整块替换为: + +```typescript +export const mediaRefs = sqliteTable( + "media_refs", + { + ownerType: text("owner_type").notNull(), + ownerId: integer("owner_id").notNull(), + assetId: integer("asset_id") + .notNull() + .references(() => mediaAssets.id, { onDelete: "cascade" }), + }, + (table) => [ + primaryKey({ columns: [table.ownerType, table.ownerId, table.assetId] }), + index("media_refs_asset_id_idx").on(table.assetId), + ], +); +``` + +**不得**保留 `post_id` → `posts` 的外键(与 spec 一致,删除 post 由应用层先清 ref)。 + +- [ ] **Step 2: 新增 SQL 迁移文件** + +创建 `packages/drizzle-pkg/migrations/0005_media_refs_owner_type.sql`: + +```sql +CREATE TABLE `media_refs_new` ( + `owner_type` text NOT NULL, + `owner_id` integer NOT NULL, + `asset_id` integer NOT NULL, + PRIMARY KEY(`owner_type`, `owner_id`, `asset_id`), + FOREIGN KEY (`asset_id`) REFERENCES `media_assets`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +INSERT INTO `media_refs_new` (`owner_type`, `owner_id`, `asset_id`) +SELECT 'post', `post_id`, `asset_id` FROM `media_refs`; +--> statement-breakpoint +DROP TABLE `media_refs`; +--> statement-breakpoint +ALTER TABLE `media_refs_new` RENAME TO `media_refs`; +--> statement-breakpoint +CREATE INDEX `media_refs_asset_id_idx` ON `media_refs` (`asset_id`); +``` + +- [ ] **Step 3: 更新 `_journal.json`** + +在 `entries` 数组末尾追加(`when` 可用当前毫秒时间戳): + +```json +{ + "idx": 5, + "version": "6", + "when": 1776700000000, + "tag": "0005_media_refs_owner_type", + "breakpoints": true +} +``` + +- [ ] **Step 4: 生成 `0005_snapshot.json`** + +复制 `packages/drizzle-pkg/migrations/meta/0004_snapshot.json` → `0005_snapshot.json`,然后: + +1. 将顶层 `"id"` 改为 **新 UUID**,`"prevId"` 改为 **`0004_snapshot.json` 的 `id`**(当前为 `2b8a9201-fbd7-4993-871d-a351e56f204a`)。 +2. 在 `"tables"` 中替换整个 **`media_refs`** 对象为下列结构(删除 `post_id` 及指向 `posts` 的外键,主键为三列): + +```json + "media_refs": { + "name": "media_refs", + "columns": { + "owner_type": { + "name": "owner_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "owner_id": { + "name": "owner_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "asset_id": { + "name": "asset_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "media_refs_asset_id_idx": { + "name": "media_refs_asset_id_idx", + "columns": ["asset_id"], + "isUnique": false + } + }, + "foreignKeys": { + "media_refs_asset_id_media_assets_id_fk": { + "name": "media_refs_asset_id_media_assets_id_fk", + "tableFrom": "media_refs", + "tableTo": "media_assets", + "columnsFrom": ["asset_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "media_refs_owner_type_owner_id_asset_id_pk": { + "columns": ["owner_type", "owner_id", "asset_id"], + "name": "media_refs_owner_type_owner_id_asset_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + } +``` + +- [ ] **Step 5: 运行迁移并校验** + +```bash +cd /home/dash/projects/person-panel && bun run db:migrate +``` + +Expected: 无报错;`sqlite3 packages/drizzle-pkg/db.sqlite ".schema media_refs"` 可见三列与主键。 + +- [ ] **Step 6: 更新 `lib/schema` 导出** + +确认 `packages/drizzle-pkg/lib/schema/content.ts` 仍 `export { … mediaRefs … }`(无 `postId` 引用)。 + +- [ ] **Step 7: Commit** + +```bash +git add packages/drizzle-pkg/database/sqlite/schema/content.ts \ + packages/drizzle-pkg/migrations/0005_media_refs_owner_type.sql \ + packages/drizzle-pkg/migrations/meta/_journal.json \ + packages/drizzle-pkg/migrations/meta/0005_snapshot.json \ + packages/drizzle-pkg/db.sqlite packages/drizzle-pkg/lib/schema/content.ts +git commit -m "feat(db): media_refs owner_type and owner_id for post/profile" +``` + +--- + +### Task 2: 常量与 `mergeProfileMediaUrls` + +**Files:** + +- Create: `server/constants/media-refs.ts`(或修改 `server/constants/media.ts` 追加导出) +- Modify: `server/utils/post-media-urls.ts` + +- [ ] **Step 1: 定义 owner 常量** + +```typescript +// server/constants/media-refs.ts +export const MEDIA_REF_OWNER_POST = "post" as const; +export const MEDIA_REF_OWNER_PROFILE = "profile" as const; +export type MediaRefOwnerType = typeof MEDIA_REF_OWNER_POST | typeof MEDIA_REF_OWNER_PROFILE; +``` + +- [ ] **Step 2: 新增合并函数** + +在 `server/utils/post-media-urls.ts` 末尾追加: + +```typescript +export function mergeProfileMediaUrls(bioMarkdown: string | null | undefined, avatar: string | null | undefined): string[] { + return mergePostMediaUrls(bioMarkdown ?? "", avatar ?? null); +} +``` + +(`mergePostMediaUrls` 已在同文件,直接调用。) + +- [ ] **Step 3: `bun run build`** + +Expected: 通过(此时若 Task 1 已改 schema 但 media service 未改,可能 TS 报错 —— **顺序上**可先完成 Task 3 再 build,或将本 Task 与 Task 3 同一 commit)。 + +- [ ] **Step 4: Commit** + +```bash +git add server/constants/media-refs.ts server/utils/post-media-urls.ts +git commit -m "feat(media): profile URL merge helper and ref owner constants" +``` + +--- + +### Task 3: `server/service/media/index.ts` 同步与孤儿逻辑 + +**Files:** + +- Modify: `server/service/media/index.ts` + +- [ ] **Step 1: 更新 import** + +增加:`and`、`eq` 与 `MEDIA_REF_OWNER_POST`、`MEDIA_REF_OWNER_PROFILE`;`mergeProfileMediaUrls` 从 `#server/utils/post-media-urls` 引入。 + +- [ ] **Step 2: 重写 `syncPostMediaRefs`** + +逻辑保持不变,但: + +- `beforeRows` / `delete` / `insert` 条件改为 `and(eq(mediaRefs.ownerType, MEDIA_REF_OWNER_POST), eq(mediaRefs.ownerId, postId))`。 +- `insert` values:`{ ownerType: MEDIA_REF_OWNER_POST, ownerId: postId, assetId }`。 + +- [ ] **Step 3: 新增 `syncProfileMediaRefs`** + +```typescript +export async function syncProfileMediaRefs( + userId: number, + bioMarkdown: string | null, + avatar: string | null, +): Promise { + const beforeRows = await dbGlobal + .select({ assetId: mediaRefs.assetId }) + .from(mediaRefs) + .where(and(eq(mediaRefs.ownerType, MEDIA_REF_OWNER_PROFILE), eq(mediaRefs.ownerId, userId))); + const beforeIds = beforeRows.map((r) => r.assetId); + + await dbGlobal + .delete(mediaRefs) + .where(and(eq(mediaRefs.ownerType, MEDIA_REF_OWNER_PROFILE), eq(mediaRefs.ownerId, userId))); + + const urls = mergeProfileMediaUrls(bioMarkdown, avatar); + const keys = [...new Set(urls.map((u) => publicAssetUrlToStorageKey(u)).filter((k): k is string => k != null))]; + + let afterIds: number[] = []; + if (keys.length > 0) { + const assetRows = await dbGlobal + .select({ id: mediaAssets.id }) + .from(mediaAssets) + .where(and(eq(mediaAssets.userId, userId), inArray(mediaAssets.storageKey, keys))); + afterIds = assetRows.map((r) => r.id); + if (afterIds.length > 0) { + await dbGlobal + .insert(mediaRefs) + .values( + afterIds.map((assetId) => ({ + ownerType: MEDIA_REF_OWNER_PROFILE, + ownerId: userId, + assetId, + })), + ) + .onConflictDoNothing(); + } + } + + await reconcileAssetTimestampsAfterRefChange([...new Set([...beforeIds, ...afterIds])]); +} +``` + +- [ ] **Step 4: `reconcileAssetTimestampsAfterRefChange`** + +其中 `count()` 查询已 `from(mediaRefs).where(eq(mediaRefs.assetId, id))`,**无需改 WHERE**(全表任意 owner 即算引用)。 + +- [ ] **Step 5: `assertAssetDeletableOrThrow` 文案** + +将 `statusMessage` 从「仍被文章引用」改为 **「资源仍被引用」**(因可能来自 profile)。 + +- [ ] **Step 6: `bun run build`** + +Expected: PASS。 + +- [ ] **Step 7: Commit** + +```bash +git add server/service/media/index.ts +git commit -m "feat(media): syncPostMediaRefs owner columns and syncProfileMediaRefs" +``` + +--- + +### Task 4: `deletePost` 顺序与 posts 服务 + +**Files:** + +- Modify: `server/service/posts/index.ts` + +- [ ] **Step 1: 调整 `deletePost`** + +在 `dbGlobal.delete(posts)` **之前**: + +```typescript +const refRows = await dbGlobal + .select({ assetId: mediaRefs.assetId }) + .from(mediaRefs) + .where(and(eq(mediaRefs.ownerType, MEDIA_REF_OWNER_POST), eq(mediaRefs.ownerId, id))); +const touched = refRows.map((r) => r.assetId); +await dbGlobal + .delete(mediaRefs) + .where(and(eq(mediaRefs.ownerType, MEDIA_REF_OWNER_POST), eq(mediaRefs.ownerId, id))); +await dbGlobal.delete(posts).where(and(eq(posts.id, id), eq(posts.userId, userId))); +await reconcileAssetTimestampsAfterRefChange(touched); +``` + +删除原来的「仅 select + delete post 依赖级联」路径(级联已不存在)。 + +- [ ] **Step 2: `bun run build`** + +- [ ] **Step 3: Commit** + +```bash +git add server/service/posts/index.ts +git commit -m "fix(posts): delete media_refs before post row" +``` + +--- + +### Task 5: `updateProfile` 触发 `syncProfileMediaRefs` + +**Files:** + +- Modify: `server/service/profile/index.ts` + +- [ ] **Step 1: 在 `updateProfile` 末尾同步** + +`await dbGlobal.update(users)...` 之后、`return getProfileRow` 之前: + +- 若 `patch.bioMarkdown !== undefined || patch.avatar !== undefined`:读取 **最终行**(可先 `const row = await getProfileRow(userId)`),调用: + +```typescript +import { syncProfileMediaRefs } from "#server/service/media"; + +// ... +await syncProfileMediaRefs(userId, row.bioMarkdown ?? null, row.avatar ?? null); +``` + +注意:`avatar` / `bioMarkdown` 以**落库后**的 `row` 为准,避免 patch 只改其一却传错另一字段。 + +- [ ] **Step 2: `bun run build`** + +- [ ] **Step 3: Commit** + +```bash +git add server/service/profile/index.ts +git commit -m "feat(profile): sync profile media refs after update" +``` + +--- + +### Task 6: 公开 `GET .../about` API + +**Files:** + +- Create: `server/api/public/profile/[publicSlug]/about.get.ts` + +- [ ] **Step 1: 实现 handler** + +与 `[publicSlug].get.ts` 相同方式解析 `publicSlug`;查 `users` `publicSlug` + `active`。若不存在 → 404。 + +若 `bioVisibility !== 'public'` 或 `!(bioMarkdown?.trim())` → 404。 + +否则返回(形状与聚合接口对齐,减少前端分叉): + +```typescript +return R.success({ + user: { + publicSlug: owner.publicSlug, + nickname: owner.nickname, + avatar: owner.avatarVisibility === "public" ? owner.avatar : null, + }, + bio: { markdown: owner.bioMarkdown }, +}); +``` + +- [ ] **Step 2: `bun run build`** + +- [ ] **Step 3: Commit** + +```bash +git add server/api/public/profile/\[publicSlug\]/about.get.ts +git commit -m "feat(api): public profile about endpoint" +``` + +--- + +### Task 7: 前端 about 页 + 主页 bio 与链接 + +**Files:** + +- Create: `app/pages/@[publicSlug]/about/index.vue` +- Modify: `app/pages/@[publicSlug]/index.vue` + +- [ ] **Step 1: 新增 `about/index.vue`** + +- `definePageMeta({ layout: 'public' })` +- `useAsyncData` → `$fetch` `/api/public/profile/${encodeURIComponent(slug)}/about`(与现 unwrap 模式一致,见 `@[publicSlug]/index.vue`) +- 404:`throw createError({ statusCode: 404 })` 或使用项目统一错误页 +- 正文:`div.prose` + `v-html="renderSafeMarkdown(data.bio.markdown)"`(`import { renderSafeMarkdown } from '../../utils/render-markdown'`,路径按文件深度调整) +- `useHead`:`title` 含昵称 + 「关于」 + +- [ ] **Step 2: 修改主页 `index.vue`** + +- 将所有展示 `data.bio.markdown` 的 **`{{ }}`** 改为 **`v-html="renderSafeMarkdown(data.bio.markdown)"`**(须 `import { renderSafeMarkdown } from '...'`)。 +- 在 **bio 区块**(两处布局若均有 bio)增加 `ULink` 或 `NuxtLink`:**仅当** `data.bio?.markdown` 存在时显示「查看全文」→ `` `/${slug}/about` `` 或 `` `/@${slug}/about` ``(与现路由一致:动态段为 `publicSlug` 时路径为 `/@xxx/about`)。 + +- [ ] **Step 3: `bun run build`** + +- [ ] **Step 4: Commit** + +```bash +git add app/pages/\@\[publicSlug\]/about/index.vue app/pages/\@\[publicSlug\]/index.vue +git commit -m "feat(public): about page and bio markdown rendering" +``` + +--- + +### Task 8: 规格 §7 手工验收 + +- [ ] **文章 regression**:创建/编辑/删除带图文章,孤儿列表行为与升级前一致。 +- [ ] **Profile**:公开 bio 含站内图 → 保存资料 → 孤儿列表不出现误报;移除图并保存 → 进入冷却/可删流程符合宽限期。 +- [ ] **About URL**:bio 公开且非空 → `/@slug/about` 可访问;bio 改 private 或清空 → **404**。 +- [ ] **全文链接**:主页「查看全文」仅在 about 可 200 时显示。 + +--- + +## 计划自检(对照 spec) + +| Spec 章节 | 覆盖任务 | +|-----------|----------| +| §2 路由与 404 规则 | Task 6、7 | +| §3 `ownerType` / `ownerId` / 迁移 | Task 1 | +| §3.2 `syncProfileMediaRefs` + URL 范围 | Task 2、3、5 | +| §3.3 孤儿与删除 | Task 3、4 | +| §3.4 迁移与 backfill | Task 1 迁移;**不强制**全站 backfill,历史数据靠「再保存一次资料」修复 | +| §4 API | Task 6 | +| §5 前端 | Task 7 | +| §6 非目标 | 未包含 timeline 引用 | +| §7 测试 | Task 8 | + +**占位符扫描:** 无 TBD。 +**类型一致:** `owner_type` 字面量与 `MEDIA_REF_OWNER_*` 常量一致;`owner_id` 在 post/profile 下语义与 spec §8 一致。 + +--- + +**计划已保存至:** `docs/superpowers/plans/2026-04-18-public-profile-about-media-refs-owner-implementation-plan.md` + +**执行方式可以二选一:** + +1. **Subagent-Driven(推荐)** — 每任务派生子代理并在任务间评审,迭代快。 +2. **Inline Execution** — 在本会话用 executing-plans 按检查点批量执行。 + +你更倾向哪一种?