Browse Source

docs(plan): public about page and media_refs ownerType

Made-with: Cursor
main
npmrun 11 hours ago
parent
commit
ba36a2dd85
  1. 492
      docs/superpowers/plans/2026-04-18-public-profile-about-media-refs-owner-implementation-plan.md

492
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<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**
```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 按检查点批量执行。
你更倾向哪一种?
Loading…
Cancel
Save