18 KiB
公开「关于我」页 + 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)整块替换为:
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:
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 可用当前毫秒时间戳):
{
"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,然后:
- 将顶层
"id"改为 新 UUID,"prevId"改为0004_snapshot.json的id(当前为2b8a9201-fbd7-4993-871d-a351e56f204a)。 - 在
"tables"中替换整个media_refs对象为下列结构(删除post_id及指向posts的外键,主键为三列):
"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: 运行迁移并校验
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
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 常量
// 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 末尾追加:
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
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))。 -
insertvalues:{ ownerType: MEDIA_REF_OWNER_POST, ownerId: postId, assetId }。 -
Step 3: 新增
syncProfileMediaRefs
export async function syncProfileMediaRefs(
userId: number,
bioMarkdown: string | null,
avatar: string | null,
): Promise<void> {
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
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) 之前:
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
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)),调用:
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
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。
否则返回(形状与聚合接口对齐,减少前端分叉):
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
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
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
执行方式可以二选一:
- Subagent-Driven(推荐) — 每任务派生子代理并在任务间评审,迭代快。
- Inline Execution — 在本会话用 executing-plans 按检查点批量执行。
你更倾向哪一种?