1 changed files with 565 additions and 0 deletions
@ -0,0 +1,565 @@ |
|||||
|
# 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`): |
||||
|
|
||||
|
```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<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** |
||||
|
|
||||
|
```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/<filename>.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<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** |
||||
|
|
||||
|
```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** — 本会话按任务顺序直接改代码,检查点处停顿。 |
||||
|
|
||||
|
你想用哪一种? |
||||
Loading…
Reference in new issue