# 公开「关于我」页 + `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 按检查点批量执行。 你更倾向哪一种?