Browse Source

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
main
npmrun 2 months ago
parent
commit
cac81289ab
  1. 17
      README.md
  2. 209
      app/pages/me/export/index.vue
  3. 11
      app/pages/me/index.vue
  4. 16
      app/types/export.ts
  5. 723
      docs/superpowers/plans/2026-04-24-user-data-export-implementation.md

17
README.md

@ -23,3 +23,20 @@
3. 编辑`.env`环境变量 3. 编辑`.env`环境变量
4. `sh run.sh` 4. `sh run.sh`
不采用重量级的`docker`,可以直接打包`.output`到服务器部署,数据库目前只支持`sqlite`。 不采用重量级的`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`

209
app/pages/me/export/index.vue

@ -0,0 +1,209 @@
<script setup lang="ts">
import type { ExportMaskPolicy, ExportTaskItem } from '../../../types/export'
usePageTitle('数据导出')
const toast = useToast()
const { fetchData, getApiErrorMessage } = useClientApi()
const maskPolicy = ref<ExportMaskPolicy>('masked')
const creating = ref(false)
const loading = ref(true)
const refreshing = ref(false)
const tasks = ref<ExportTaskItem[]>([])
const maskPolicyItems = [
{ label: 'masked(默认,脱敏导出)', value: 'masked' as const },
{ label: 'raw(原始值导出)', value: 'raw' as const },
]
const statusToneMap: Record<ExportTaskItem['status'], 'neutral' | 'primary' | 'success' | 'error' | 'warning'> = {
queued: 'neutral',
running: 'primary',
succeeded: 'success',
failed: 'error',
expired: 'warning',
}
const statusLabelMap: Record<ExportTaskItem['status'], string> = {
queued: '排队中',
running: '处理中',
succeeded: '已完成',
failed: '失败',
expired: '已过期',
}
function formatTime(iso: string | null): string {
if (!iso) {
return '—'
}
const dt = new Date(iso)
return Number.isNaN(dt.getTime()) ? iso : dt.toLocaleString('zh-CN')
}
function formatBytes(n: number | null): string {
if (n === null || Number.isNaN(n)) {
return '—'
}
if (n < 1024) {
return `${n} B`
}
if (n < 1024 * 1024) {
return `${(n / 1024).toFixed(1)} KB`
}
return `${(n / (1024 * 1024)).toFixed(1)} MB`
}
function taskDownloadUrl(taskId: number): string {
return `/api/me/export/tasks/${taskId}/download`
}
async function loadTasks() {
const useBlocking = tasks.value.length === 0
if (useBlocking) {
loading.value = true
} else {
refreshing.value = true
}
try {
const res = await fetchData<{ items: ExportTaskItem[] }>('/api/me/export/tasks', { notify: false })
tasks.value = res.items
} catch (e: unknown) {
toast.add({ title: getApiErrorMessage(e), color: 'error' })
} finally {
loading.value = false
refreshing.value = false
}
}
async function createTask() {
if (creating.value) {
return
}
creating.value = true
try {
await fetchData('/api/me/export/request', {
method: 'POST',
body: { maskPolicy: maskPolicy.value },
notify: false,
})
toast.add({ title: '导出任务已创建', color: 'success' })
await loadTasks()
} catch (e: unknown) {
toast.add({ title: getApiErrorMessage(e), color: 'error' })
} finally {
creating.value = false
}
}
onMounted(() => {
void loadTasks()
})
</script>
<template>
<UContainer class="max-w-6xl py-8 space-y-6">
<header class="space-y-1">
<h1 class="text-2xl font-semibold">
数据导出
</h1>
<p class="text-sm text-muted">
选择导出策略并发起任务完成后可直接下载结果包
</p>
</header>
<UCard>
<div class="flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
<UFormField label="导出策略(maskPolicy)" class="w-full md:max-w-sm">
<USelect
v-model="maskPolicy"
class="w-full"
:items="maskPolicyItems"
value-key="value"
/>
</UFormField>
<div class="flex items-center gap-2">
<UButton
icon="i-lucide-file-output"
:loading="creating"
:disabled="creating"
@click="createTask"
>
发起导出
</UButton>
<UButton
variant="outline"
color="neutral"
icon="i-lucide-refresh-cw"
:loading="refreshing"
:disabled="creating || loading"
@click="loadTasks"
>
刷新
</UButton>
</div>
</div>
</UCard>
<section class="space-y-3" aria-labelledby="export-task-list-title">
<div class="flex items-center justify-between gap-3">
<h2 id="export-task-list-title" class="text-sm font-medium text-muted">
导出任务{{ tasks.length }}
</h2>
</div>
<div v-if="loading" class="text-sm text-muted">
加载中...
</div>
<UEmpty
v-else-if="!tasks.length"
title="暂无导出任务"
description="选择导出策略后,点击“发起导出”创建第一条任务。"
/>
<div v-else class="space-y-3">
<UCard v-for="task in tasks" :key="task.id">
<div class="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div class="space-y-2 min-w-0">
<div class="flex flex-wrap items-center gap-2">
<span class="text-sm font-medium">任务 #{{ task.id }}</span>
<UBadge :color="statusToneMap[task.status]" variant="soft">
{{ statusLabelMap[task.status] }}
</UBadge>
<UBadge color="neutral" variant="outline">
{{ task.maskPolicy }}
</UBadge>
</div>
<p class="text-xs text-muted">
创建时间{{ formatTime(task.createdAt) }}
</p>
<p class="text-xs text-muted">
更新时间{{ formatTime(task.updatedAt) }}
</p>
<p class="text-xs text-muted">
文件大小{{ formatBytes(task.totalBytes) }}
</p>
<p v-if="task.expiresAt" class="text-xs text-muted">
下载截止{{ formatTime(task.expiresAt) }}
</p>
<p v-if="task.errorMessage" class="text-xs text-error">
失败原因{{ task.errorMessage }}
</p>
</div>
<div class="flex items-center gap-2">
<UButton
v-if="task.status === 'succeeded'"
icon="i-lucide-download"
:to="taskDownloadUrl(task.id)"
target="_blank"
rel="noopener"
size="sm"
>
下载
</UButton>
</div>
</div>
</UCard>
</div>
</section>
</UContainer>
</template>

11
app/pages/me/index.vue

@ -45,6 +45,17 @@ onMounted(async () => {
<div class="grid gap-3 sm:grid-cols-2"> <div class="grid gap-3 sm:grid-cols-2">
<UCard> <UCard>
<div class="font-medium"> <div class="font-medium">
数据导出
</div>
<p class="text-sm text-muted mt-1">
发起个人数据导出任务并在任务完成后下载结果
</p>
<UButton to="/me/export" class="mt-3" size="sm">
进入导出中心
</UButton>
</UCard>
<UCard>
<div class="font-medium">
个人资料 个人资料
</div> </div>
<p class="text-sm text-muted mt-1"> <p class="text-sm text-muted mt-1">

16
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
}

723
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<T extends Record<string, unknown>>(input: T, policy: "masked" | "raw") {
const out: Record<string, unknown> = {};
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<void> {
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
<!-- File: app/pages/me/export/index.vue -->
<script setup lang="ts">
const { fetchData } = useClientApi();
const maskPolicy = ref<"masked" | "raw">("masked");
const loading = ref(false);
const tasks = ref<Array<{ id: number; status: string; maskPolicy: string; createdAt: string; expiresAt: string | null }>>([]);
async function loadTasks() {
const res = await fetchData<{ items: typeof tasks.value }>("/api/me/export/tasks");
tasks.value = res.items;
}
async function createTask() {
loading.value = true;
try {
await fetchData("/api/me/export/request", { method: "POST", body: { maskPolicy: maskPolicy.value } });
await loadTasks();
} finally {
loading.value = false;
}
}
onMounted(loadTasks);
</script>
```
```vue
<!-- File: app/pages/me/index.vue (new card) -->
<UCard>
<div class="font-medium">数据导出</div>
<p class="text-sm text-muted mt-1">导出用户文件与业务数据用于迁移。</p>
<UButton to="/me/export" class="mt-3" size="sm">进入</UButton>
</UCard>
```
- [ ] **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
<!-- File: README.md (append section) -->
## 用户数据导出(迁移)
- 发起导出:`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`
Loading…
Cancel
Save