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.
 
 
 

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_refsowner_typepostowner_id === posts.id,为 profileowner_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-dompurifyapp/utils/render-markdown.ts)。

Spec: docs/superpowers/specs/2026-04-18-public-profile-about-and-media-refs-design.md

现状说明: 表已从 post_media_refs 重命名为 media_refs0004 迁移);本计划在 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_POSTMEDIA_REF_OWNER_PROFILE 字符串常量
server/utils/post-media-urls.ts 新增 mergeProfileMediaUrls(bioMarkdown, avatar)(内部复用 mergePostMediaUrls
server/service/media/index.ts syncPostMediaRefssyncProfileMediaRefsreconcile*、孤儿与删除判定改为按 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 中,将现有 mediaRefspostId + 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_idposts 的外键(与 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.json0005_snapshot.json,然后:

  1. 将顶层 "id" 改为 新 UUID"prevId" 改为 0004_snapshot.jsonid(当前为 2b8a9201-fbd7-4993-871d-a351e56f204a)。
  2. "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.tsexport { … 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

增加:andeqMEDIA_REF_OWNER_POSTMEDIA_REF_OWNER_PROFILEmergeProfileMediaUrls#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

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',路径按文件深度调整)

  • useHeadtitle 含昵称 + 「关于」

  • Step 2: 修改主页 index.vue

  • 将所有展示 data.bio.markdown{{ }} 改为 v-html="renderSafeMarkdown(data.bio.markdown)"(须 import { renderSafeMarkdown } from '...')。

  • bio 区块(两处布局若均有 bio)增加 ULinkNuxtLink仅当 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

执行方式可以二选一:

  1. Subagent-Driven(推荐) — 每任务派生子代理并在任务间评审,迭代快。
  2. Inline Execution — 在本会话用 executing-plans 按检查点批量执行。

你更倾向哪一种?