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_refs;media_assets 存 storage_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 |
新增 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):
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.sqlite 含 media_assets、post_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列表为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。
- 无 ref 定义:左连接
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:
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,在函数内部:
- 查询该
postId变更前 的assetId集合为beforeIds。 - 删除该 post 的全部
post_media_refs,按合并后的bodyMarkdown/coverUrl插入新 refs,得到 变更后afterIds。 - 对集合
new Set([...beforeIds, ...afterIds])调用reconcileAssetTimestampsAfterRefChange([...])。
然后在 createPost 的 insert(...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(逻辑要点)
const user = await event.context.auth.requireUser();(与server/api/me/posts.post.ts一致)。- multer 仍用
diskStorage到public/assets,先得到原始扩展名文件。 - 对每个已上传文件:
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)。
- 写成功后 删除 原始 multer 文件(若与目标路径不同)。
insertMediaAssetRow({ userId: user.id, storageKey: filename, mime: "image/webp", sizeBytes: stat.size, ... })。- 若 Sharp 抛错:删除 已生成的部分文件,不插入 DB,整请求失败。
- 响应
url为/public/assets/<filename>.webp,mimeType/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(自动清扫)与UInputNumber或UInput 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 dev 报 experimental.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: '图片孤儿审查' })。 -
使用
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
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。
执行方式二选一:
- Subagent-Driven(推荐) — 每任务派生子代理,任务间人工过一遍。
- Inline Execution — 本会话按任务顺序直接改代码,检查点处停顿。
你想用哪一种?