# Post Media Assets(元数据、WebP 优化、孤儿审查与自动清扫)Implementation Plan > **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-post-media-assets-design.md` 实现文章图片的 `media_assets` / `post_media_refs`、保存文章时同步引用、上传后 Sharp 转 WebP 与限宽、`/me` 孤儿审查与手动删除、管理员全站自动清扫开关 + 定时任务;默认不自动删文件。 **Architecture:** 引用真相在 `post_media_refs`;`media_assets` 存 `storage_key`(与 `/public/assets/` 一一对应)、`first_referenced_at` / `dereferenced_at` 用于宽限期判定(从未被引用用 `created_at`,曾被引用用 `dereferenced_at`)。`createPost` / `updatePost` / `deletePost` 后调用同一套 `syncPostMediaRefs` + `reconcileAssetReferenceTimestamps`。上传走 multer 落盘后经 Sharp 写出 `.webp` 主文件并插入 DB。定时任务通过 `getGlobalConfigValue("mediaOrphanAutoSweepEnabled")` 判断是否执行删除,与手动删除共用 `purgeDeletableOrphans` 核心逻辑。 **Tech Stack:** Nuxt 4.4 / Nitro、Drizzle + SQLite(`drizzle-pkg`)、Bun、`sharp`(图片处理)、现有 `multer` 上传、`#server/service/config` 全局配置、`requireAdmin` / `event.context.auth.requireUser()`。 **Spec:** `docs/superpowers/specs/2026-04-18-post-media-assets-design.md` **测试说明:** 以 spec 第 12 节 **手动验收** 为主;仓库无现成 HTTP 集成测试框架,计划中不虚构「先写失败 E2E」步骤。每任务完成后至少 `bun run build` 或通过 `bun run dev` 冒烟。 --- ## 文件结构(将创建 / 修改) | 路径 | 职责 | |------|------| | `packages/drizzle-pkg/database/sqlite/schema/content.ts` | 新增 `mediaAssets`、`postMediaRefs` 表定义 | | `packages/drizzle-pkg/migrations/*` | 由 `db:generate` 生成 + 本地 `db:migrate` | | `packages/drizzle-pkg/lib/schema/content.ts` | `export` 新表 | | `server/constants/media.ts` | 本站资源 URL 前缀、宽限期小时数、最大宽度、WebP 质量 | | `server/utils/post-media-urls.ts` | 从 `bodyMarkdown` + `coverUrl` 解析本站图片 URL;`url` → `storageKey` | | `server/service/media/index.ts` | 插入 asset、同步 refs、reconcile 时间戳、列表孤儿、按用户删除、全站可删清扫 | | `server/service/posts/index.ts` | 在 create/update/delete 后调用 media 同步;`createPost` 需拿到新 `id` 再同步 | | `server/api/file/upload.post.ts` | `requireUser`、Sharp 流水线、写最终 `.webp`、`insert media_assets`、响应字段与现契约一致 | | `server/api/me/media/orphans.get.ts` | 分页 + 筛选 `all \| deletable \| cooling` | | `server/api/me/media/orphans-delete.post.ts` | 批量删除(仅本人 asset + 仅 deletable) | | `server/service/config/registry.ts` | `mediaOrphanAutoSweepEnabled`(bool,默认 false)、`mediaOrphanAutoSweepIntervalMinutes`(number,默认 60,范围 15–1440) | | `app/pages/me/admin/config/index.vue` | 加载/保存新全局项;文案说明全站风险 | | `server/tasks/media/orphan-sweep.ts` | Nitro `defineTask`:读配置,为真则调用清扫 | | `nuxt.config.ts` | `nitro.experimental.tasks`、`nitro.scheduledTasks`(cron 使用短间隔占位,任务内再读「间隔」配置可选:首版可 **固定每小时** 触发任务,任务内若距离上次执行不足 `intervalMinutes` 则跳过——实现计划 Step 中给出伪代码) | | `app/pages/me/media/orphans.vue` | 审查列表、筛选、二次确认、批量删 | | `app/pages/me/index.vue` | 入口卡片链到 `/me/media/orphans` | | `package.json` / `bun.lock` | 依赖 `sharp` | --- ### Task 1: Drizzle 表与迁移 **Files:** - Modify: `packages/drizzle-pkg/database/sqlite/schema/content.ts` - Modify: `packages/drizzle-pkg/lib/schema/content.ts`(导出) - Create: `packages/drizzle-pkg/migrations/0003_*.sql`(由 generate 生成) - Modify: `packages/drizzle-pkg/migrations/meta/_journal.json`(通常由 generate 写入) - [ ] **Step 1: 在 `content.ts` 追加表定义** 在 `packages/drizzle-pkg/database/sqlite/schema/content.ts` 现有 `drizzle-orm/sqlite-core` import 中 **追加** `primaryKey`(不要重复整段 import)。 在 `posts` 表定义 **之后**、`timelineEvents` **之前**插入(顺序便于阅读;`postMediaRefs` 引用 `posts` 与 `mediaAssets`): ```typescript export const mediaAssets = sqliteTable( "media_assets", { id: integer().primaryKey(), userId: integer("user_id") .notNull() .references(() => users.id, { onDelete: "cascade" }), storageKey: text("storage_key").notNull(), mime: text().notNull(), sizeBytes: integer("size_bytes").notNull(), sha256: text("sha256"), variantsJson: text("variants_json"), status: text().notNull().default("ready"), firstReferencedAt: integer("first_referenced_at", { mode: "timestamp_ms" }), dereferencedAt: integer("dereferenced_at", { mode: "timestamp_ms" }), createdAt: integer("created_at", { mode: "timestamp_ms" }).defaultNow().notNull(), }, (table) => [ uniqueIndex("media_assets_storage_key_unique").on(table.storageKey), index("media_assets_user_id_idx").on(table.userId), ], ); export const postMediaRefs = sqliteTable( "post_media_refs", { postId: integer("post_id") .notNull() .references(() => posts.id, { onDelete: "cascade" }), assetId: integer("asset_id") .notNull() .references(() => mediaAssets.id, { onDelete: "cascade" }), }, (table) => [ primaryKey({ columns: [table.postId, table.assetId] }), index("post_media_refs_asset_id_idx").on(table.assetId), ], ); ``` - [ ] **Step 2: 导出** 在 `packages/drizzle-pkg/lib/schema/content.ts` 中把导出改为(保留原有符号): ```typescript export { mediaAssets, postComments, postMediaRefs, posts, timelineEvents } from "../../database/sqlite/schema/content"; ``` - [ ] **Step 3: 生成并应用迁移** Run: ```bash cd /home/dash/projects/person-panel && bun run db:generate -- --name media-assets ``` Expected: `packages/drizzle-pkg/migrations/` 下出现新 SQL,`meta/_journal.json` 更新。 Run: ```bash cd /home/dash/projects/person-panel && bun run db:migrate ``` Expected: 无报错;本地 `db.sqlite` 含 `media_assets`、`post_media_refs`。 - [ ] **Step 4: Commit** ```bash cd /home/dash/projects/person-panel && git add packages/drizzle-pkg && git commit -m "feat(db): media_assets and post_media_refs for post media" ``` --- ### Task 2: 常量与 URL 解析工具 **Files:** - Create: `server/constants/media.ts` - Create: `server/utils/post-media-urls.ts` - [ ] **Step 1: 新增 `server/constants/media.ts`** ```typescript /** 与 `upload` 返回及静态路径一致,无前导 host */ export const POST_MEDIA_PUBLIC_PREFIX = "/public/assets/"; /** 从未引用:created_at 起算;曾引用:dereferenced_at 起算 */ export const MEDIA_ORPHAN_GRACE_HOURS_NEVER_REF = 24; export const MEDIA_ORPHAN_GRACE_HOURS_AFTER_DEREF = 24; export const MEDIA_IMAGE_MAX_WIDTH_PX = 1920; export const MEDIA_WEBP_QUALITY = 82; export const RELATIVE_ASSETS_DIR = "public/assets"; ``` - [ ] **Step 2: 新增 `server/utils/post-media-urls.ts`** ```typescript import { POST_MEDIA_PUBLIC_PREFIX } from "#server/constants/media"; const MD_IMG_RE = /!\[[^\]]*]\(([^)]+)\)/g; function normalizeUrl(raw: string): string { const t = raw.trim().replace(/^<|>$/g, "").split(/\s+/)[0] ?? ""; if (t.startsWith(POST_MEDIA_PUBLIC_PREFIX)) { return t; } return ""; } /** 从 markdown 中提取本站图片 URL(去重,顺序稳定) */ export function extractMediaUrlsFromMarkdown(markdown: string): string[] { const out: string[] = []; const seen = new Set(); let m: RegExpExecArray | null; const re = new RegExp(MD_IMG_RE.source, MD_IMG_RE.flags); while ((m = re.exec(markdown)) !== null) { const u = normalizeUrl(m[1] ?? ""); if (u && !seen.has(u)) { seen.add(u); out.push(u); } } return out; } export function extractMediaUrlsFromCover(coverUrl: string | null | undefined): string[] { if (!coverUrl) { return []; } const u = normalizeUrl(coverUrl); return u ? [u] : []; } export function mergePostMediaUrls(bodyMarkdown: string, coverUrl: string | null | undefined): string[] { const a = extractMediaUrlsFromMarkdown(bodyMarkdown); const b = extractMediaUrlsFromCover(coverUrl); const seen = new Set(a); for (const u of b) { if (!seen.has(u)) { seen.add(u); a.push(u); } } return a; } /** `/public/assets/foo.webp` → `foo.webp` */ export function publicAssetUrlToStorageKey(url: string): string | null { const t = url.trim(); if (!t.startsWith(POST_MEDIA_PUBLIC_PREFIX)) { return null; } const key = t.slice(POST_MEDIA_PUBLIC_PREFIX.length).replace(/^\/+/, ""); if (!key || key.includes("..") || key.includes("/") || key.includes("\\")) { return null; } return key; } ``` - [ ] **Step 3: Commit** ```bash cd /home/dash/projects/person-panel && git add server/constants/media.ts server/utils/post-media-urls.ts && git commit -m "feat(media): constants and markdown/cover URL extraction" ``` --- ### Task 3: Media 领域服务(同步引用、宽限期、删除) **Files:** - Create: `server/service/media/index.ts` - [ ] **Step 1: 实现服务文件** 创建 `server/service/media/index.ts`,实现以下导出(签名可微调,但行为须一致): - `insertMediaAssetRow(...)`:插入一行(上传成功后调用)。 - `syncPostMediaRefs(userId, postId, bodyMarkdown, coverUrl)`:先读该 `postId` 现有 `assetId` 列表为 `beforeIds`;`delete from post_media_refs where post_id = ?`;再按 URL 解析 `storageKey`,`select id from media_assets where storage_key in (...)` 且 `user_id = userId`,插入新 `post_media_refs`;若某 URL 无对应行(旧数据或未登记文件)**跳过**不抛错;最后对 `beforeIds ∪ afterIds` 调用 `reconcileAssetTimestampsAfterRefChange`。 - `reconcileAssetTimestampsAfterRefChange(assetIds: number[])`:对每个 `assetId` 查当前是否存在 `post_media_refs`;若存在且 `first_referenced_at` 为空则设为 `now`,`dereferenced_at` 置 `null`;若不存在任何 ref:若 `first_referenced_at` 仍为空则不动(从未引用,靠 `created_at`);若曾引用则设 `dereferenced_at = now`(若已为非空可保留首次时间,避免反复刷新时间——**首版保留首次 dereference 时间**:仅当 `dereferenced_at` 为 null 时写入)。 - `listOrphanCandidatesForUser(userId, filter, page, pageSize)`:返回 `{ items, total }`;`filter`: `all`(所有无 ref)、`deletable`、`cooling`。 - **无 ref** 定义:左连接 `post_media_refs` 分组 `having count(ref)=0` 或 `not exists`。 - **deletable**:无 ref 且(`(first_referenced_at is null and created_at + grace1)` 或 `(first_referenced_at is not null and dereferenced_at is not null and dereferenced_at + grace2)`)。 - **cooling**:无 ref 且非 deletable。 - `deleteMediaAssetsForUser(userId, ids: number[])`:逐条校验 `userId`、`deletable`、无 ref,再删磁盘文件(`path.join(process.cwd(), RELATIVE_ASSETS_DIR, storageKey)`)及 `variants_json` 内路径(首版可为空),最后 `delete from media_assets`。 - `purgeAllDeletableOrphansGlobally(limit: number)`:不分用户枚举可删行,同上删除逻辑;供定时任务调用。 使用 `dbGlobal`、`eq`、`and`、`sql`、`inArray`、`desc` 等 Drizzle API;时间比较用 `Date.now()` 与 grace 小时换算毫秒。 - [ ] **Step 2: `bun run build`** Run: ```bash cd /home/dash/projects/person-panel && bun run build ``` Expected: TypeScript 通过(若 `#server` 别名未覆盖新路径,按现有 server 文件修正 import)。 - [ ] **Step 3: Commit** ```bash cd /home/dash/projects/person-panel && git add server/service/media/index.ts && git commit -m "feat(media): sync refs, orphan eligibility, delete helpers" ``` --- ### Task 4: 接入 Post CRUD **Files:** - Modify: `server/service/posts/index.ts` - [ ] **Step 1: 在 `createPost` 成功插入后同步 refs** **推荐实现(与 Task 3 一致)**:在 `server/service/media/index.ts` 中实现 `syncPostMediaRefs`,在函数内部: 1. 查询该 `postId` **变更前** 的 `assetId` 集合为 `beforeIds`。 2. 删除该 post 的全部 `post_media_refs`,按合并后的 `bodyMarkdown` / `coverUrl` 插入新 refs,得到 **变更后** `afterIds`。 3. 对集合 `new Set([...beforeIds, ...afterIds])` 调用 `reconcileAssetTimestampsAfterRefChange([...])`。 然后在 `createPost` 的 `insert(...values)` 之后调用: ```typescript await syncPostMediaRefs(userId, id, input.bodyMarkdown, input.coverUrl ?? null); ``` **禁止** 在 `posts/index.ts` 里复制粘贴 URL 解析逻辑;一律走 `syncPostMediaRefs`。 - [ ] **Step 2: `updatePost` 在 `.update` 成功后调用同一 `syncPostMediaRefs`** 使用 **最终** `bodyMarkdown` / `coverUrl`(patch 与 existing 合并后的值)调用。 - [ ] **Step 3: `deletePost`** 在 `delete(posts)` **之前**: ```typescript const { dbGlobal } = await import("drizzle-pkg/lib/db"); const { postMediaRefs } = await import("drizzle-pkg/lib/schema/content"); const { eq } = await import("drizzle-orm"); const refRows = await dbGlobal .select({ assetId: postMediaRefs.assetId }) .from(postMediaRefs) .where(eq(postMediaRefs.postId, id)); const touched = refRows.map((r) => r.assetId); ``` 删除 post 后: ```typescript const { reconcileAssetTimestampsAfterRefChange } = await import("#server/service/media"); await reconcileAssetTimestampsAfterRefChange(touched); ``` - [ ] **Step 4: Commit** ```bash cd /home/dash/projects/person-panel && git add server/service/posts/index.ts server/service/media/index.ts && git commit -m "feat(posts): sync media refs on create/update/delete" ``` (若仅改 posts,只 add 该文件。) --- ### Task 5: 上传 + Sharp + `requireUser` **Files:** - Modify: `package.json` / `bun.lock` - Modify: `server/api/file/upload.post.ts` - [ ] **Step 1: 安装 sharp** ```bash cd /home/dash/projects/person-panel && bun add sharp ``` - [ ] **Step 2: 改写上传 handler(逻辑要点)** 1. `const user = await event.context.auth.requireUser();`(与 `server/api/me/posts.post.ts` 一致)。 2. multer **仍用 `diskStorage`** 到 `public/assets`,先得到原始扩展名文件。 3. 对每个已上传文件: - `sharp(path).rotate()`(尊重 EXIF) - `.resize({ width: MEDIA_IMAGE_MAX_WIDTH_PX, height: MEDIA_IMAGE_MAX_WIDTH_PX, fit: "inside", withoutEnlargement: true })` - `.webp({ quality: MEDIA_WEBP_QUALITY })` - 目标文件名:`${uniqueSuffix}-${baseName}.webp`(与现命名规则一致,扩展名固定 `.webp`)。 4. 写成功后 **删除** 原始 multer 文件(若与目标路径不同)。 5. `insertMediaAssetRow({ userId: user.id, storageKey: filename, mime: "image/webp", sizeBytes: stat.size, ... })`。 6. 若 Sharp 抛错:**删除** 已生成的部分文件,不插入 DB,整请求失败。 7. 响应 `url` 为 `/public/assets/.webp`,`mimeType` / `size` 为 WebP。 - [ ] **Step 3: `bun run build`** - [ ] **Step 4: Commit** ```bash cd /home/dash/projects/person-panel && git add package.json bun.lock server/api/file/upload.post.ts && git commit -m "feat(upload): require auth, sharp webp pipeline, media_assets row" ``` --- ### Task 6: `GET/POST` 孤儿审查 API **Files:** - Create: `server/api/me/media/orphans.get.ts` - Create: `server/api/me/media/orphans-delete.post.ts` - [ ] **Step 1: `orphans.get.ts`** - `requireUser`。 - Query:`filter`(默认 `all`)、`page`(默认 1)、`pageSize`(默认 20,最大 100)。 - 返回 `{ items: [...], total }`;每项含 `id`, `storageKey`, `publicUrl`, `sizeBytes`, `createdAt`, `firstReferencedAt`, `dereferencedAt`, `state: 'deletable' | 'cooling'`。 - [ ] **Step 2: `orphans-delete.post.ts`** - `requireUser`。 - Body:`{ ids: number[] }`,最多 50。 - 调用 `deleteMediaAssetsForUser(user.id, ids)`;若包含非 deletable id,返回 **400** 与明确 `statusMessage`。 - [ ] **Step 3: Commit** ```bash cd /home/dash/projects/person-panel && git add server/api/me/media && git commit -m "feat(api): me media orphans list and batch delete" ``` --- ### Task 7: 全局配置注册 + 管理端 UI **Files:** - Modify: `server/service/config/registry.ts` - Modify: `app/pages/me/admin/config/index.vue` - [ ] **Step 1: 注册两个 key** 在 `CONFIG_REGISTRY` 中增加: ```typescript mediaOrphanAutoSweepEnabled: defineConfig({ key: "mediaOrphanAutoSweepEnabled", scope: "global", valueType: "boolean", defaultValue: false, userOverridable: false, }), mediaOrphanAutoSweepIntervalMinutes: defineConfig({ key: "mediaOrphanAutoSweepIntervalMinutes", scope: "global", valueType: "number", defaultValue: 60, userOverridable: false, validate: (value: number) => Number.isInteger(value) && value >= 15 && value <= 1440, }), ``` - [ ] **Step 2: 管理页加载/保存** - 扩展 `GlobalConfigPayload` 类型包含上述字段。 - 增加 `UCheckbox`(自动清扫)与 `UInputNumber` 或 `UInput type=number`(间隔分钟)。 - `save()` 中 `putKey('mediaOrphanAutoSweepEnabled', ...)` 与 `putKey('mediaOrphanAutoSweepIntervalMinutes', ...)`。 - 文案说明:**全站**、仅已过宽限期且无引用资源、建议配合审查页。 - [ ] **Step 3: Commit** ```bash cd /home/dash/projects/person-panel && git add server/service/config/registry.ts app/pages/me/admin/config/index.vue && git commit -m "feat(config): admin toggles for media orphan auto-sweep" ``` --- ### Task 8: 定时任务 + `nuxt.config` **Files:** - Create: `server/tasks/media/orphan-sweep.ts` - Modify: `nuxt.config.ts` - [ ] **Step 1: 任务实现** ```typescript import { getGlobalConfigValue } from "#server/service/config"; import { purgeAllDeletableOrphansGlobally } from "#server/service/media"; export default defineTask({ meta: { name: "media:orphan-sweep", description: "Delete deletable orphan media when admin global switch is on", }, async run() { const enabled = await getGlobalConfigValue("mediaOrphanAutoSweepEnabled"); if (!enabled) { return { result: "skipped: disabled" }; } const deleted = await purgeAllDeletableOrphansGlobally(50); return { result: "ok", deletedCount: deleted }; }, }); ``` `purgeAllDeletableOrphansGlobally` 应返回本批删除条数;若 Task 3 未设计返回值,改为 void 并在任务内记录日志即可。 **间隔配置**:首版可在任务内用内存变量记录 `lastRunAt`,若当前时间与 `lastRunAt` 差小于 `getGlobalConfigValue("mediaOrphanAutoSweepIntervalMinutes")` 分钟则 `{ result: "skipped: throttle" }`。进程重启后节流重置——可接受;若需持久化,二期再写 DB。 - [ ] **Step 2: `nuxt.config.ts`** 在现有 `nitro` 对象中合并(保留 `typescript`): ```typescript nitro: { experimental: { tasks: true, }, scheduledTasks: { "5 * * * *": ["media:orphan-sweep"], }, typescript: { // ...existing }, }, ``` 若 `bun run dev` 报 `experimental.tasks` 无效,查阅当前 Nuxt 文档调整键名;以 **能跑通 `orphan-sweep` 手动触发** 为验收底线:`npx nuxt task run media:orphan-sweep`(以官方 CLI 为准)。 - [ ] **Step 3: Commit** ```bash cd /home/dash/projects/person-panel && git add nuxt.config.ts server/tasks/media/orphan-sweep.ts && git commit -m "feat(nitro): scheduled media orphan sweep task" ``` --- ### Task 9: 审查页 + 控制台入口 **Files:** - Create: `app/pages/me/media/orphans.vue` - Modify: `app/pages/me/index.vue` - [ ] **Step 1: `orphans.vue`** - `definePageMeta({ title: '图片孤儿审查' })`。 - 使用 `UTabs` 或 `USelect` 切换 `filter`。 - `UTable` 或卡片列表展示;`UButton` 删除单条;`UCheckbox` 多选 + 批量删除。 - 使用 `UModal` 二次确认。 - 调用 `/api/me/media/orphans` 与 `POST /api/me/media/orphans-delete`(路径以 Step 6 实际为准)。 - [ ] **Step 2: 控制台卡片** 在 `app/pages/me/index.vue` 的 grid 中增加一张卡片,链接 `to="/me/media/orphans"`,说明孤儿图片审查与清理。 - [ ] **Step 3: Commit** ```bash cd /home/dash/projects/person-panel && git add app/pages/me/media/orphans.vue app/pages/me/index.vue && git commit -m "feat(ui): media orphans review page and dashboard link" ``` --- ### Task 10: 全量验收 - [ ] **Step 1: 构建** ```bash cd /home/dash/projects/person-panel && bun run build ``` Expected: 成功。 - [ ] **Step 2: 手动走 spec 第 12 节**(上传、保存带图文章、ref、审查列表、冷却/可删、手动删、管理员开关节流、自动清扫) - [ ] **Step 3: 若有文档修正**(仅当发现 spec 与实现不一致时更新 `docs/superpowers/specs/2026-04-18-post-media-assets-design.md`) --- ## Self-review(对照 spec) | Spec 章节 | 对应 Task | |-----------|-----------| | 4 数据模型 | Task 1 | | 5 引用解析 | Task 2 | | 6.0–6.3 孤儿与删除 | Task 3、6、8 | | 6.4 管理员全站开关 | Task 7、8 | | 6.5 审查入口 | Task 9 | | 7 WebP 优化 | Task 5 | | 8 上传 | Task 5 | | 9 Post 事务 | Task 4 | | 10 安全 | Task 3/6(userId 校验) | **占位符扫描:** 计划中任务级代码已给出;`purgeAllDeletableOrphansGlobally` 若在 Task 3 未定义返回值,实现时补全并消除歧义。 **类型一致:** `KnownConfigKey` 随 registry 自动扩展;管理页 TS 类型需同步新增 key。 --- **计划已保存至 `docs/superpowers/plans/2026-04-18-post-media-assets-implementation-plan.md`。** **执行方式二选一:** 1. **Subagent-Driven(推荐)** — 每任务派生子代理,任务间人工过一遍。 2. **Inline Execution** — 本会话按任务顺序直接改代码,检查点处停顿。 你想用哪一种?