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.
 
 
 

22 KiB

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_refsmedia_assetsstorage_key(与 /public/assets/<filename> 一一对应)、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 新增 mediaAssetspostMediaRefs 表定义
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;urlstorageKey
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 流水线、写最终 .webpinsert 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.tasksnitro.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 引用 postsmediaAssets):

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 中把导出改为(保留原有符号):

export { mediaAssets, postComments, postMediaRefs, posts, timelineEvents } from "../../database/sqlite/schema/content";
  • Step 3: 生成并应用迁移

Run:

cd /home/dash/projects/person-panel && bun run db:generate -- --name media-assets

Expected: packages/drizzle-pkg/migrations/ 下出现新 SQL,meta/_journal.json 更新。

Run:

cd /home/dash/projects/person-panel && bun run db:migrate

Expected: 无报错;本地 db.sqlitemedia_assetspost_media_refs

  • Step 4: Commit
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

/** 与 `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
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<string>();
  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<string>(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
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 列表为 beforeIdsdelete from post_media_refs where post_id = ?;再按 URL 解析 storageKeyselect 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 为空则设为 nowdereferenced_atnull;若不存在任何 ref:若 first_referenced_at 仍为空则不动(从未引用,靠 created_at);若曾引用则设 dereferenced_at = now(若已为非空可保留首次时间,避免反复刷新时间——首版保留首次 dereference 时间:仅当 dereferenced_at 为 null 时写入)。
  • listOrphanCandidatesForUser(userId, filter, page, pageSize):返回 { items, total }filter: all(所有无 ref)、deletablecooling
    • 无 ref 定义:左连接 post_media_refs 分组 having count(ref)=0not 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[]):逐条校验 userIddeletable、无 ref,再删磁盘文件(path.join(process.cwd(), RELATIVE_ASSETS_DIR, storageKey))及 variants_json 内路径(首版可为空),最后 delete from media_assets
  • purgeAllDeletableOrphansGlobally(limit: number):不分用户枚举可删行,同上删除逻辑;供定时任务调用。

使用 dbGlobaleqandsqlinArraydesc 等 Drizzle API;时间比较用 Date.now() 与 grace 小时换算毫秒。

  • Step 2: bun run build

Run:

cd /home/dash/projects/person-panel && bun run build

Expected: TypeScript 通过(若 #server 别名未覆盖新路径,按现有 server 文件修正 import)。

  • Step 3: Commit
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([...])

然后在 createPostinsert(...values) 之后调用:

  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) 之前

  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 后:

  const { reconcileAssetTimestampsAfterRefChange } = await import("#server/service/media");
  await reconcileAssetTimestampsAfterRefChange(touched);
  • Step 4: Commit
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

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 仍用 diskStoragepublic/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/<filename>.webpmimeType / size 为 WebP。
  • Step 3: bun run build

  • Step 4: Commit

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

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 中增加:

  mediaOrphanAutoSweepEnabled: defineConfig<boolean>({
    key: "mediaOrphanAutoSweepEnabled",
    scope: "global",
    valueType: "boolean",
    defaultValue: false,
    userOverridable: false,
  }),
  mediaOrphanAutoSweepIntervalMinutes: defineConfig<number>({
    key: "mediaOrphanAutoSweepIntervalMinutes",
    scope: "global",
    valueType: "number",
    defaultValue: 60,
    userOverridable: false,
    validate: (value: number) => Number.isInteger(value) && value >= 15 && value <= 1440,
  }),
  • Step 2: 管理页加载/保存

  • 扩展 GlobalConfigPayload 类型包含上述字段。

  • 增加 UCheckbox(自动清扫)与 UInputNumberUInput type=number(间隔分钟)。

  • save()putKey('mediaOrphanAutoSweepEnabled', ...)putKey('mediaOrphanAutoSweepIntervalMinutes', ...)

  • 文案说明:全站、仅已过宽限期且无引用资源、建议配合审查页。

  • Step 3: Commit

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: 任务实现

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):

  nitro: {
    experimental: {
      tasks: true,
    },
    scheduledTasks: {
      "5 * * * *": ["media:orphan-sweep"],
    },
    typescript: {
      // ...existing
    },
  },

bun run devexperimental.tasks 无效,查阅当前 Nuxt 文档调整键名;以 能跑通 orphan-sweep 手动触发 为验收底线:npx nuxt task run media:orphan-sweep(以官方 CLI 为准)。

  • Step 3: Commit
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: '图片孤儿审查' })

  • 使用 UTabsUSelect 切换 filter

  • UTable 或卡片列表展示;UButton 删除单条;UCheckbox 多选 + 批量删除。

  • 使用 UModal 二次确认。

  • 调用 /api/me/media/orphansPOST /api/me/media/orphans-delete(路径以 Step 6 实际为准)。

  • Step 2: 控制台卡片

app/pages/me/index.vue 的 grid 中增加一张卡片,链接 to="/me/media/orphans",说明孤儿图片审查与清理。

  • Step 3: Commit
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: 构建
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 — 本会话按任务顺序直接改代码,检查点处停顿。

你想用哪一种?