From cac81289abeb130ab09fb17a8c74163591821f4b Mon Sep 17 00:00:00 2001 From: npmrun <1549469775@qq.com> Date: Fri, 24 Apr 2026 01:01:36 +0800 Subject: [PATCH] feat(export): add export center UI and usage docs Create the me export center page with task controls and expose API usage guidance so users can launch and track data export from the dashboard. Made-with: Cursor --- README.md | 19 +- app/pages/me/export/index.vue | 209 ++++++ app/pages/me/index.vue | 11 + app/types/export.ts | 16 + .../2026-04-24-user-data-export-implementation.md | 723 +++++++++++++++++++++ 5 files changed, 977 insertions(+), 1 deletion(-) create mode 100644 app/pages/me/export/index.vue create mode 100644 app/types/export.ts create mode 100644 docs/superpowers/plans/2026-04-24-user-data-export-implementation.md diff --git a/README.md b/README.md index 948d3f7..6bdbce8 100644 --- a/README.md +++ b/README.md @@ -22,4 +22,21 @@ 2. 进入`.output`文件夹 3. 编辑`.env`环境变量 4. `sh run.sh` -不采用重量级的`docker`,可以直接打包`.output`到服务器部署,数据库目前只支持`sqlite`。 \ No newline at end of file +不采用重量级的`docker`,可以直接打包`.output`到服务器部署,数据库目前只支持`sqlite`。 + +## 用户数据导出 API + +已登录用户可通过以下接口发起并获取导出结果: + +1. `POST /api/me/export/request` + - 用途:创建导出任务并异步执行。 + - 请求体:`{ "maskPolicy": "masked" | "raw" }`(可省略,默认 `masked`)。 + - 返回示例:`{ "code": 0, "data": { "taskId": 123, "status": "pending" } }` + +2. `GET /api/me/export/tasks` + - 用途:查询当前用户导出任务列表。 + - 返回数据包含:`id`、`status`、`maskPolicy`、`outputName`、`totalBytes`、`errorCode`、`errorMessage`、`createdAt`、`updatedAt`、`expiresAt`。 + +3. `GET /api/me/export/tasks/:id/download` + - 用途:下载已完成任务的 `manifest.json`。 + - 说明:仅可下载当前用户自己的任务;任务未完成会返回 `409`,已过期返回 `410`。 \ No newline at end of file diff --git a/app/pages/me/export/index.vue b/app/pages/me/export/index.vue new file mode 100644 index 0000000..d2dfe91 --- /dev/null +++ b/app/pages/me/export/index.vue @@ -0,0 +1,209 @@ + + + diff --git a/app/pages/me/index.vue b/app/pages/me/index.vue index e7580ab..9919fc5 100644 --- a/app/pages/me/index.vue +++ b/app/pages/me/index.vue @@ -45,6 +45,17 @@ onMounted(async () => {
+ 数据导出 +
+

+ 发起个人数据导出任务,并在任务完成后下载结果。 +

+ + 进入导出中心 + +
+ +
个人资料

diff --git a/app/types/export.ts b/app/types/export.ts new file mode 100644 index 0000000..2f94d4f --- /dev/null +++ b/app/types/export.ts @@ -0,0 +1,16 @@ +export type ExportMaskPolicy = 'masked' | 'raw' + +export type ExportTaskStatus = 'queued' | 'running' | 'succeeded' | 'failed' | 'expired' + +export type ExportTaskItem = { + id: number + status: ExportTaskStatus + maskPolicy: ExportMaskPolicy + outputName: string | null + totalBytes: number | null + errorCode: string | null + errorMessage: string | null + createdAt: string + updatedAt: string + expiresAt: string | null +} diff --git a/docs/superpowers/plans/2026-04-24-user-data-export-implementation.md b/docs/superpowers/plans/2026-04-24-user-data-export-implementation.md new file mode 100644 index 0000000..ad18d00 --- /dev/null +++ b/docs/superpowers/plans/2026-04-24-user-data-export-implementation.md @@ -0,0 +1,723 @@ +# User Data Export 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:** Build a user-owned export pipeline that produces a versioned `manifest + data JSON + files` package with configurable masking and secure download. + +**Architecture:** Add an async export job table and service pipeline in Nitro server: request API creates job, worker executes export with a fixed cutoff timestamp, and download API serves completed artifacts. Export logic is split into small modules for domain data extraction, masking/sanitization, file collection, checksum generation, and manifest assembly. UI exposes a simple “Data Export Center” under `/me` for request, progress, and download. + +**Tech Stack:** Nuxt 4, Nitro server routes/tasks, Bun test, Drizzle ORM (sqlite schema + migrations), Node fs/crypto/path APIs, Vue + Nuxt UI. + +--- + +## File Structure (planned changes) + +- Create: `packages/drizzle-pkg/database/sqlite/schema/export.ts`(导出任务表) +- Modify: `packages/drizzle-pkg/database/sqlite/schema/index.ts`(导出新 schema) +- Modify: `packages/drizzle-pkg/lib/schema/content.ts`(按现有导出方式补 export schema) +- Create: `packages/drizzle-pkg/migrations/0010_user_export_tasks.sql` +- Create: `server/constants/export.ts`(状态、策略、路径、TTL) +- Create: `server/utils/export-mask.ts`(脱敏与永不导出过滤) +- Create: `server/utils/export-hash.ts`(SHA256 计算) +- Create: `server/service/export/jobs.ts`(任务创建/查询/状态机) +- Create: `server/service/export/build-data.ts`(业务数据抽取) +- Create: `server/service/export/build-files.ts`(媒体文件收集) +- Create: `server/service/export/build-manifest.ts`(manifest 组装) +- Create: `server/service/export/run.ts`(任务执行主流程) +- Create: `server/tasks/export/process.ts`(定时/触发执行任务) +- Create: `server/api/me/export/request.post.ts` +- Create: `server/api/me/export/tasks.get.ts` +- Create: `server/api/me/export/tasks/[id]/download.get.ts` +- Create: `server/utils/me-export-request-body.ts`(请求参数校验) +- Create: `server/utils/me-export-request-body.test.ts` +- Create: `server/service/export/jobs.test.ts` +- Create: `server/service/export/build-manifest.test.ts` +- Modify: `app/pages/me/index.vue`(加入“数据导出中心”入口) +- Create: `app/pages/me/export/index.vue`(导出页面) +- Create: `app/types/export.ts`(前端 DTO) + +--- + +### Task 1: Export Job Schema + Migration + +**Files:** +- Create: `packages/drizzle-pkg/database/sqlite/schema/export.ts` +- Modify: `packages/drizzle-pkg/database/sqlite/schema/index.ts` +- Modify: `packages/drizzle-pkg/lib/schema/content.ts` +- Create: `packages/drizzle-pkg/migrations/0010_user_export_tasks.sql` +- Test: `bun run db:migrate` + +- [ ] **Step 1: Write the failing schema export test (smoke)** + +```ts +// File: server/service/export/jobs.test.ts (temporary first test) +import { describe, expect, test } from "bun:test"; +import { userExportTasks } from "drizzle-pkg/lib/schema/export"; + +describe("userExportTasks schema", () => { + test("exposes required columns", () => { + expect(userExportTasks.userId.name).toBe("user_id"); + expect(userExportTasks.status.name).toBe("status"); + expect(userExportTasks.maskPolicy.name).toBe("mask_policy"); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `bun test server/service/export/jobs.test.ts` +Expected: FAIL with module or symbol not found for `drizzle-pkg/lib/schema/export`. + +- [ ] **Step 3: Add minimal schema + migration** + +```ts +// File: packages/drizzle-pkg/database/sqlite/schema/export.ts +import { index, integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; +import { users } from "./auth"; + +export const userExportTasks = sqliteTable( + "user_export_tasks", + { + id: integer().primaryKey(), + userId: integer("user_id").notNull().references(() => users.id, { onDelete: "cascade" }), + status: text().notNull().default("queued"), // queued|running|succeeded|failed|expired + maskPolicy: text("mask_policy").notNull().default("masked"), // masked|raw + exportCutoffAt: integer("export_cutoff_at", { mode: "timestamp_ms" }), + outputDir: text("output_dir"), + outputName: text("output_name"), + totalBytes: integer("total_bytes"), + errorCode: text("error_code"), + errorMessage: text("error_message"), + expiresAt: integer("expires_at", { mode: "timestamp_ms" }), + createdAt: integer("created_at", { mode: "timestamp_ms" }).defaultNow().notNull(), + updatedAt: integer("updated_at", { mode: "timestamp_ms" }).defaultNow().$onUpdate(() => new Date()).notNull(), + }, + (table) => [ + index("user_export_tasks_user_id_idx").on(table.userId), + index("user_export_tasks_status_idx").on(table.status), + ], +); +``` + +```sql +-- File: packages/drizzle-pkg/migrations/0010_user_export_tasks.sql +CREATE TABLE user_export_tasks ( + id INTEGER PRIMARY KEY NOT NULL, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + status TEXT NOT NULL DEFAULT 'queued', + mask_policy TEXT NOT NULL DEFAULT 'masked', + export_cutoff_at INTEGER, + output_dir TEXT, + output_name TEXT, + total_bytes INTEGER, + error_code TEXT, + error_message TEXT, + expires_at INTEGER, + created_at INTEGER NOT NULL DEFAULT (unixepoch('subsec') * 1000), + updated_at INTEGER NOT NULL DEFAULT (unixepoch('subsec') * 1000) +); +CREATE INDEX user_export_tasks_user_id_idx ON user_export_tasks(user_id); +CREATE INDEX user_export_tasks_status_idx ON user_export_tasks(status); +``` + +- [ ] **Step 4: Run tests and migration** + +Run: `bun test server/service/export/jobs.test.ts && bun run db:migrate` +Expected: PASS test; migration applies without SQL error. + +- [ ] **Step 5: Commit** + +```bash +git add packages/drizzle-pkg/database/sqlite/schema/export.ts \ + packages/drizzle-pkg/database/sqlite/schema/index.ts \ + packages/drizzle-pkg/lib/schema/content.ts \ + packages/drizzle-pkg/migrations/0010_user_export_tasks.sql \ + server/service/export/jobs.test.ts +git commit -m "feat(export): add user export task schema and migration" +``` + +--- + +### Task 2: Request Validation + Masking Rules + +**Files:** +- Create: `server/constants/export.ts` +- Create: `server/utils/me-export-request-body.ts` +- Create: `server/utils/me-export-request-body.test.ts` +- Create: `server/utils/export-mask.ts` +- Test: `server/utils/me-export-request-body.test.ts` + +- [ ] **Step 1: Write failing tests for request body and masking behavior** + +```ts +// File: server/utils/me-export-request-body.test.ts +import { describe, expect, test } from "bun:test"; +import { parseMeExportRequestBody } from "./me-export-request-body"; +import { sanitizeUserForExport } from "./export-mask"; + +describe("parseMeExportRequestBody", () => { + test("defaults to masked when absent", () => { + expect(parseMeExportRequestBody({})).toEqual({ maskPolicy: "masked" }); + }); + test("rejects invalid policy", () => { + expect(() => parseMeExportRequestBody({ maskPolicy: "abc" })).toThrow(); + }); +}); + +describe("sanitizeUserForExport", () => { + test("never exports password/session-like fields", () => { + const sanitized = sanitizeUserForExport({ username: "u", password: "hash", sessionToken: "x" } as any, "raw"); + expect("password" in sanitized).toBe(false); + expect("sessionToken" in sanitized).toBe(false); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `bun test server/utils/me-export-request-body.test.ts` +Expected: FAIL with missing modules/functions. + +- [ ] **Step 3: Implement parser + masking utility** + +```ts +// File: server/utils/me-export-request-body.ts +export type ExportMaskPolicy = "masked" | "raw"; + +export function parseMeExportRequestBody(body: unknown): { maskPolicy: ExportMaskPolicy } { + const payload = (body && typeof body === "object" ? body : {}) as { maskPolicy?: unknown }; + const raw = payload.maskPolicy; + if (raw === undefined || raw === null || raw === "") return { maskPolicy: "masked" }; + if (raw === "masked" || raw === "raw") return { maskPolicy: raw }; + throw createError({ statusCode: 400, statusMessage: "maskPolicy 仅支持 masked/raw" }); +} +``` + +```ts +// File: server/utils/export-mask.ts +const NEVER_EXPORT_KEYS = new Set([ + "password", + "passwordHash", + "sessionToken", + "refreshToken", + "resetToken", + "apiKey", + "secretKey", +]); + +export function sanitizeUserForExport>(input: T, policy: "masked" | "raw") { + const out: Record = {}; + for (const [k, v] of Object.entries(input)) { + if (NEVER_EXPORT_KEYS.has(k)) continue; + out[k] = policy === "masked" ? maskField(k, v) : v; + } + return out; +} + +function maskField(key: string, value: unknown): unknown { + if (typeof value !== "string") return value; + if (key.includes("email")) return value.replace(/^(.).+(@.+)$/, "$1***$2"); + if (key.includes("phone")) return value.replace(/^(\d{3})\d+(\d{2})$/, "$1****$2"); + if (key.toLowerCase().includes("ip")) return value.replace(/^(\d+\.\d+)\.\d+\.\d+$/, "$1.*.*"); + return value; +} +``` + +- [ ] **Step 4: Run tests to verify pass** + +Run: `bun test server/utils/me-export-request-body.test.ts` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add server/constants/export.ts \ + server/utils/me-export-request-body.ts \ + server/utils/me-export-request-body.test.ts \ + server/utils/export-mask.ts +git commit -m "feat(export): add request validation and masking utilities" +``` + +--- + +### Task 3: Export Job Service (create/list/update) + +**Files:** +- Create: `server/service/export/jobs.ts` +- Modify: `server/service/export/jobs.test.ts` +- Test: `server/service/export/jobs.test.ts` + +- [ ] **Step 1: Write failing tests for job lifecycle** + +```ts +// File: server/service/export/jobs.test.ts +import { describe, expect, test } from "bun:test"; +import { createExportTask, listExportTasksByUser, markExportTaskRunning } from "./jobs"; + +describe("export jobs service", () => { + test("createExportTask creates queued task", async () => { + const task = await createExportTask({ userId: 1, maskPolicy: "masked" }); + expect(task.status).toBe("queued"); + expect(task.maskPolicy).toBe("masked"); + }); + + test("markExportTaskRunning sets cutoff timestamp", async () => { + const task = await createExportTask({ userId: 1, maskPolicy: "raw" }); + const updated = await markExportTaskRunning(task.id); + expect(updated.status).toBe("running"); + expect(updated.exportCutoffAt).toBeTruthy(); + }); + + test("listExportTasksByUser only returns own tasks", async () => { + const rows = await listExportTasksByUser(1); + expect(Array.isArray(rows)).toBe(true); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `bun test server/service/export/jobs.test.ts` +Expected: FAIL with missing implementation. + +- [ ] **Step 3: Implement minimal job service** + +```ts +// File: server/service/export/jobs.ts +import { dbGlobal } from "drizzle-pkg/lib/db"; +import { userExportTasks } from "drizzle-pkg/lib/schema/export"; +import { and, desc, eq } from "drizzle-orm"; +import { nextIntegerId } from "#server/utils/sqlite-id"; + +export async function createExportTask(params: { userId: number; maskPolicy: "masked" | "raw" }) { + const id = await nextIntegerId(userExportTasks, userExportTasks.id); + await dbGlobal.insert(userExportTasks).values({ + id, + userId: params.userId, + maskPolicy: params.maskPolicy, + status: "queued", + }); + const [row] = await dbGlobal.select().from(userExportTasks).where(eq(userExportTasks.id, id)).limit(1); + return row!; +} + +export async function listExportTasksByUser(userId: number) { + return dbGlobal.select().from(userExportTasks).where(eq(userExportTasks.userId, userId)).orderBy(desc(userExportTasks.id)); +} + +export async function markExportTaskRunning(taskId: number) { + const cutoff = new Date(); + await dbGlobal.update(userExportTasks).set({ status: "running", exportCutoffAt: cutoff }).where(eq(userExportTasks.id, taskId)); + const [row] = await dbGlobal.select().from(userExportTasks).where(eq(userExportTasks.id, taskId)).limit(1); + return row!; +} + +export async function markExportTaskSucceeded(taskId: number, payload: { outputDir: string; outputName: string; totalBytes: number; expiresAt: Date }) { + await dbGlobal + .update(userExportTasks) + .set({ status: "succeeded", ...payload }) + .where(eq(userExportTasks.id, taskId)); +} + +export async function markExportTaskFailed(taskId: number, payload: { errorCode: string; errorMessage: string }) { + await dbGlobal.update(userExportTasks).set({ status: "failed", ...payload }).where(eq(userExportTasks.id, taskId)); +} + +export async function getExportTaskForUser(taskId: number, userId: number) { + const [row] = await dbGlobal + .select() + .from(userExportTasks) + .where(and(eq(userExportTasks.id, taskId), eq(userExportTasks.userId, userId))) + .limit(1); + return row ?? null; +} +``` + +- [ ] **Step 4: Run tests to verify pass** + +Run: `bun test server/service/export/jobs.test.ts` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add server/service/export/jobs.ts server/service/export/jobs.test.ts +git commit -m "feat(export): add export task lifecycle service" +``` + +--- + +### Task 4: Build Export Artifacts (data/files/manifest) + +**Files:** +- Create: `server/service/export/build-data.ts` +- Create: `server/service/export/build-files.ts` +- Create: `server/service/export/build-manifest.ts` +- Create: `server/service/export/build-manifest.test.ts` +- Create: `server/utils/export-hash.ts` +- Test: `server/service/export/build-manifest.test.ts` + +- [ ] **Step 1: Write failing test for manifest shape + checksum keys** + +```ts +// File: server/service/export/build-manifest.test.ts +import { describe, expect, test } from "bun:test"; +import { buildExportManifest } from "./build-manifest"; + +describe("buildExportManifest", () => { + test("outputs schemaVersion and checksums", () => { + const manifest = buildExportManifest({ + schemaVersion: 1, + userId: 1, + maskPolicy: "masked", + exportCutoffAt: new Date("2026-04-24T00:00:00.000Z"), + dataChecksums: [{ file: "data/user.json", sha256: "a" }], + fileChecksums: [{ file: "files/a.webp", sha256: "b" }], + stats: { dataRows: 10, files: 1, bytes: 1234 }, + }); + expect(manifest.schemaVersion).toBe(1); + expect(manifest.checksums.files[0].file).toBe("files/a.webp"); + }); +}); +``` + +- [ ] **Step 2: Run test to verify fail** + +Run: `bun test server/service/export/build-manifest.test.ts` +Expected: FAIL with missing module/function. + +- [ ] **Step 3: Implement data/files/manifest builders** + +```ts +// File: server/service/export/build-data.ts (core signature) +export async function buildExportDataJson(params: { + userId: number; + cutoffAt: Date; + maskPolicy: "masked" | "raw"; + outputDataDir: string; +}): Promise<{ files: Array<{ file: string; rowCount: number }> }> { + // 1) query users/profile/posts/timeline/comments/mediaAssets/mediaRefs up to cutoffAt + // 2) sanitize with export-mask policy + // 3) write JSON files into outputDataDir + // 4) return file list + row counts +} +``` + +```ts +// File: server/service/export/build-files.ts (core signature) +export async function buildExportMediaFiles(params: { + userId: number; + cutoffAt: Date; + outputFilesDir: string; +}): Promise<{ files: Array<{ file: string; bytes: number }> }> { + // query media_assets by user, copy each storageKey into files/, keep deterministic relative paths +} +``` + +```ts +// File: server/service/export/build-manifest.ts +export function buildExportManifest(input: { + schemaVersion: number; + userId: number; + maskPolicy: "masked" | "raw"; + exportCutoffAt: Date; + dataChecksums: Array<{ file: string; sha256: string }>; + fileChecksums: Array<{ file: string; sha256: string }>; + stats: { dataRows: number; files: number; bytes: number }; +}) { + return { + schemaVersion: input.schemaVersion, + exportedAt: new Date().toISOString(), + exportCutoffAt: input.exportCutoffAt.toISOString(), + userId: input.userId, + maskPolicy: input.maskPolicy, + stats: input.stats, + checksums: { data: input.dataChecksums, files: input.fileChecksums }, + }; +} +``` + +- [ ] **Step 4: Run tests to verify pass** + +Run: `bun test server/service/export/build-manifest.test.ts` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add server/service/export/build-data.ts \ + server/service/export/build-files.ts \ + server/service/export/build-manifest.ts \ + server/service/export/build-manifest.test.ts \ + server/utils/export-hash.ts +git commit -m "feat(export): add export artifact builders and manifest" +``` + +--- + +### Task 5: Runner + Task Processor + Me APIs + +**Files:** +- Create: `server/service/export/run.ts` +- Create: `server/tasks/export/process.ts` +- Create: `server/api/me/export/request.post.ts` +- Create: `server/api/me/export/tasks.get.ts` +- Create: `server/api/me/export/tasks/[id]/download.get.ts` +- Modify: `server/service/export/jobs.ts`(补 claimNextQueuedTask) +- Test: `server/service/export/jobs.test.ts` + manual API smoke + +- [ ] **Step 1: Write failing tests for queue claim behavior** + +```ts +// File: server/service/export/jobs.test.ts (append) +import { claimNextQueuedTask } from "./jobs"; + +test("claimNextQueuedTask returns null when no queued task", async () => { + const task = await claimNextQueuedTask(); + expect(task).toBeNull(); +}); +``` + +- [ ] **Step 2: Run test to verify fail** + +Run: `bun test server/service/export/jobs.test.ts` +Expected: FAIL with missing function. + +- [ ] **Step 3: Implement runner and APIs** + +```ts +// File: server/service/export/run.ts +export async function runExportTask(taskId: number): Promise { + const running = await markExportTaskRunning(taskId); + try { + // create output dirs + // build data json + files + // hash files + build manifest + write manifest.json + // mark succeeded with expiresAt + } catch (error) { + await markExportTaskFailed(taskId, { + errorCode: "EXPORT_BUILD_FAILED", + errorMessage: error instanceof Error ? error.message : "unknown export error", + }); + } +} +``` + +```ts +// File: server/api/me/export/request.post.ts +export default defineWrappedResponseHandler(async (event) => { + const user = await event.context.auth.requireUser(); + const body = await readBody(event); + const { maskPolicy } = parseMeExportRequestBody(body); + const task = await createExportTask({ userId: user.id, maskPolicy }); + return R.success({ taskId: task.id, status: task.status }); +}); +``` + +```ts +// File: server/api/me/export/tasks.get.ts +export default defineWrappedResponseHandler(async (event) => { + const user = await event.context.auth.requireUser(); + const tasks = await listExportTasksByUser(user.id); + return R.success({ + items: tasks.map((t) => ({ + id: t.id, + status: t.status, + maskPolicy: t.maskPolicy, + createdAt: t.createdAt.toISOString(), + expiresAt: t.expiresAt?.toISOString() ?? null, + })), + }); +}); +``` + +```ts +// File: server/api/me/export/tasks/[id]/download.get.ts +export default defineWrappedResponseHandler(async (event) => { + const user = await event.context.auth.requireUser(); + const id = Number(getRouterParam(event, "id")); + const task = await getExportTaskForUser(id, user.id); + if (!task || task.status !== "succeeded" || !task.outputDir) { + throw createError({ statusCode: 404, statusMessage: "导出文件不存在或未完成" }); + } + // serve file/dir stream according to deployment convention +}); +``` + +- [ ] **Step 4: Run tests and smoke endpoints** + +Run: +- `bun test server/service/export/jobs.test.ts` +- `bun run dev` then call: + - `POST /api/me/export/request` + - `GET /api/me/export/tasks` + +Expected: +- tests PASS +- request returns queued task id +- list endpoint contains new task. + +- [ ] **Step 5: Commit** + +```bash +git add server/service/export/run.ts \ + server/tasks/export/process.ts \ + server/api/me/export/request.post.ts \ + server/api/me/export/tasks.get.ts \ + server/api/me/export/tasks/[id]/download.get.ts \ + server/service/export/jobs.ts \ + server/service/export/jobs.test.ts +git commit -m "feat(export): add export runner, processor task, and me export APIs" +``` + +--- + +### Task 6: Frontend Export Center (`/me/export`) + +**Files:** +- Modify: `app/pages/me/index.vue` +- Create: `app/pages/me/export/index.vue` +- Create: `app/types/export.ts` +- Test: manual browser verification + +- [ ] **Step 1: Write lightweight UI contract notes as failing checklist** + +```md +// File: app/pages/me/export/index.vue (top TODO block during implementation) +- must show "发起导出" button +- must allow selecting mask policy (masked/raw) +- must show task list status + created time +- must show download button only when succeeded +``` + +- [ ] **Step 2: Run app and verify page not present yet (expected fail)** + +Run: `bun run dev` and open `/me/export` +Expected: 404 or missing route behavior. + +- [ ] **Step 3: Implement page and dashboard entry** + +```vue + + +``` + +```vue + + +

数据导出
+

导出用户文件与业务数据用于迁移。

+ 进入 +
+``` + +- [ ] **Step 4: Manual verify pass** + +Run: `bun run dev` +Expected: +- `/me/export` 可访问 +- 可创建导出任务 +- 成功任务显示下载入口。 + +- [ ] **Step 5: Commit** + +```bash +git add app/pages/me/index.vue app/pages/me/export/index.vue app/types/export.ts +git commit -m "feat(export): add me export center page and task actions" +``` + +--- + +### Task 7: Security/Regression Tests + Docs + +**Files:** +- Modify: `server/service/export/jobs.test.ts` +- Modify: `server/utils/me-export-request-body.test.ts` +- Modify: `README.md` +- Modify: `docs/superpowers/specs/2026-04-24-user-data-backup-design.md`(实现状态备注) +- Test: targeted bun tests + lint + +- [ ] **Step 1: Add failing security regression tests** + +```ts +// File: server/utils/me-export-request-body.test.ts (append) +test("raw policy still excludes never-export fields", () => { + const data = sanitizeUserForExport({ password: "x", apiKey: "k", email: "a@b.com" } as any, "raw"); + expect(data).toEqual({ email: "a@b.com" }); +}); +``` + +- [ ] **Step 2: Run tests to verify failure (if behavior regresses)** + +Run: `bun test server/utils/me-export-request-body.test.ts` +Expected: PASS only when blacklist behavior is correct. + +- [ ] **Step 3: Update docs with usage commands** + +```md + +## 用户数据导出(迁移) + +- 发起导出:`POST /api/me/export/request`,body: `{ "maskPolicy": "masked" | "raw" }` +- 查询任务:`GET /api/me/export/tasks` +- 下载结果:`GET /api/me/export/tasks/:id/download` +``` + +- [ ] **Step 4: Final verification** + +Run: +- `bun test server/service/export/jobs.test.ts` +- `bun test server/service/export/build-manifest.test.ts` +- `bun test server/utils/me-export-request-body.test.ts` + +Expected: all PASS. + +- [ ] **Step 5: Commit** + +```bash +git add server/service/export/jobs.test.ts \ + server/utils/me-export-request-body.test.ts \ + README.md \ + docs/superpowers/specs/2026-04-24-user-data-backup-design.md +git commit -m "test/docs(export): add security regression coverage and usage docs" +``` + +--- + +## Self-Review Checklist + +- Spec coverage: + - 异步任务模型:Task 1, 3, 5 + - 目录+清单导出:Task 4 + - 全量用户域数据 + 文件:Task 4 + - 脱敏与永不导出:Task 2, 7 + - 安全下载与过期:Task 5 + - 前端入口与操作:Task 6 +- Placeholder scan: 已避免 `TODO/TBD`;每个任务给出代码/命令/预期。 +- Type consistency: + - `maskPolicy` 统一 `masked | raw` + - 状态统一 `queued|running|succeeded|failed|expired` + - 清单版本固定 `schemaVersion: 1` +