Browse Source

feat(export): support bundle download and task deletion

Add full export bundle download, auto-expire missing artifacts on task refresh, and provide task deletion actions in both API and export center UI.

Made-with: Cursor
main
npmrun 2 weeks ago
parent
commit
927b7bbbd3
  1. 44
      app/pages/me/export/index.vue
  2. 454
      bun.lock
  3. 2
      package.json
  4. BIN
      packages/drizzle-pkg/db.sqlite
  5. 25
      server/api/me/export/tasks.get.ts
  6. 14
      server/api/me/export/tasks/[id].delete.ts
  7. 27
      server/api/me/export/tasks/[id]/download.get.ts
  8. 33
      server/service/export/jobs.test.ts
  9. 49
      server/service/export/jobs.ts

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

@ -96,6 +96,28 @@ async function createTask() {
} }
} }
async function recreateTask(task: ExportTaskItem) {
maskPolicy.value = task.maskPolicy
await createTask()
}
async function deleteTask(task: ExportTaskItem) {
const ok = window.confirm(`确定删除导出任务 #${task.id} 吗?`)
if (!ok) {
return
}
try {
await fetchData(`/api/me/export/tasks/${task.id}`, {
method: 'DELETE',
notify: false,
})
toast.add({ title: `任务 #${task.id} 已删除`, color: 'success' })
await loadTasks()
} catch (e: unknown) {
toast.add({ title: getApiErrorMessage(e), color: 'error' })
}
}
onMounted(() => { onMounted(() => {
void loadTasks() void loadTasks()
}) })
@ -200,6 +222,28 @@ onMounted(() => {
> >
下载 下载
</UButton> </UButton>
<UButton
v-else-if="task.status === 'expired'"
icon="i-lucide-rotate-ccw"
variant="outline"
size="sm"
:loading="creating"
:disabled="creating"
@click="recreateTask(task)"
>
重新导出
</UButton>
<UButton
v-if="task.status !== 'running'"
icon="i-lucide-trash-2"
color="error"
variant="ghost"
size="sm"
:disabled="creating || refreshing"
@click="deleteTask(task)"
>
删除
</UButton>
</div> </div>
</div> </div>
</UCard> </UCard>

454
bun.lock

File diff suppressed because it is too large

2
package.json

@ -28,7 +28,7 @@
"drizzle-seed": "0.3.1", "drizzle-seed": "0.3.1",
"drizzle-zod": "0.8.3", "drizzle-zod": "0.8.3",
"fast-xml-parser": "5.7.0", "fast-xml-parser": "5.7.0",
"highlight.js": "^11.11.1", "highlight.js": "11.11.1",
"isomorphic-dompurify": "3.9.0", "isomorphic-dompurify": "3.9.0",
"log4js": "6.9.1", "log4js": "6.9.1",
"logger": "workspace:*", "logger": "workspace:*",

BIN
packages/drizzle-pkg/db.sqlite

Binary file not shown.

25
server/api/me/export/tasks.get.ts

@ -1,11 +1,32 @@
import { listExportTasksByUser } from "#server/service/export/jobs"; import fs from "node:fs";
import path from "node:path";
import { listExportTasksByUser, markExportTaskExpired } from "#server/service/export/jobs";
import { R } from "#server/utils/response"; import { R } from "#server/utils/response";
export default defineWrappedResponseHandler(async (event) => { export default defineWrappedResponseHandler(async (event) => {
const user = await event.context.auth.requireUser(); const user = await event.context.auth.requireUser();
const tasks = await listExportTasksByUser(user.id); const tasks = await listExportTasksByUser(user.id);
const now = Date.now();
const normalizedTasks = await Promise.all(
tasks.map(async (task) => {
if (task.status !== "succeeded") {
return task;
}
if (!task.expiresAt || task.expiresAt.getTime() <= now) {
return markExportTaskExpired(task.id, "导出结果已过期,请重新导出");
}
if (!task.outputDir || !task.outputName) {
return markExportTaskExpired(task.id, "导出结果缺失,请重新导出");
}
const manifestPath = path.resolve(task.outputDir, "manifest.json");
if (!fs.existsSync(manifestPath)) {
return markExportTaskExpired(task.id, "导出文件已丢失,请重新导出");
}
return task;
}),
);
return R.success({ return R.success({
items: tasks.map((task) => ({ items: normalizedTasks.map((task) => ({
id: task.id, id: task.id,
status: task.status, status: task.status,
maskPolicy: task.maskPolicy, maskPolicy: task.maskPolicy,

14
server/api/me/export/tasks/[id].delete.ts

@ -0,0 +1,14 @@
import { deleteExportTaskForUser } from "#server/service/export/jobs";
import { R } from "#server/utils/response";
export default defineWrappedResponseHandler(async (event) => {
const user = await event.context.auth.requireUser();
const idRaw = getRouterParam(event, "id");
const taskId = Number(idRaw);
if (!Number.isInteger(taskId) || taskId < 1) {
throw createError({ statusCode: 400, statusMessage: "无效的任务 id" });
}
await deleteExportTaskForUser(taskId, user.id);
return R.success({ ok: true });
});

27
server/api/me/export/tasks/[id]/download.get.ts

@ -1,7 +1,8 @@
import fs from "node:fs"; import fs from "node:fs";
import path from "node:path"; import path from "node:path";
import { spawn } from "node:child_process";
import { sendStream, setHeader } from "h3"; import { sendStream, setHeader } from "h3";
import { getExportTaskForUser } from "#server/service/export/jobs"; import { getExportTaskForUser, markExportTaskExpired } from "#server/service/export/jobs";
export default defineWrappedResponseHandler(async (event) => { export default defineWrappedResponseHandler(async (event) => {
const user = await event.context.auth.requireUser(); const user = await event.context.auth.requireUser();
@ -19,18 +20,32 @@ export default defineWrappedResponseHandler(async (event) => {
throw createError({ statusCode: 409, statusMessage: "导出任务尚未完成" }); throw createError({ statusCode: 409, statusMessage: "导出任务尚未完成" });
} }
if (!task.expiresAt || task.expiresAt.getTime() <= Date.now()) { if (!task.expiresAt || task.expiresAt.getTime() <= Date.now()) {
await markExportTaskExpired(task.id, "导出结果已过期,请重新导出");
throw createError({ statusCode: 410, statusMessage: "导出结果已过期" }); throw createError({ statusCode: 410, statusMessage: "导出结果已过期" });
} }
if (!task.outputDir || !task.outputName) { if (!task.outputDir || !task.outputName) {
throw createError({ statusCode: 500, statusMessage: "导出结果缺失" }); await markExportTaskExpired(task.id, "导出结果缺失,请重新导出");
throw createError({ statusCode: 410, statusMessage: "导出文件已丢失,请重新导出" });
} }
const manifestPath = path.resolve(task.outputDir, "manifest.json"); const manifestPath = path.resolve(task.outputDir, "manifest.json");
if (!fs.existsSync(manifestPath)) { if (!fs.existsSync(manifestPath)) {
throw createError({ statusCode: 404, statusMessage: "导出文件不存在" }); await markExportTaskExpired(task.id, "导出文件已丢失,请重新导出");
throw createError({ statusCode: 410, statusMessage: "导出文件已丢失,请重新导出" });
} }
setHeader(event, "Content-Type", "application/json; charset=utf-8"); const archiveName = `${task.outputName}.tar.gz`;
setHeader(event, "Content-Disposition", `attachment; filename="${task.outputName}-manifest.json"`); const tarProc = spawn("tar", ["-czf", "-", "-C", task.outputDir, "."], {
return sendStream(event, fs.createReadStream(manifestPath)); stdio: ["ignore", "pipe", "pipe"],
});
tarProc.on("exit", async (code) => {
if (code === 0) {
return;
}
await markExportTaskExpired(task.id, "导出文件打包失败,请重新导出");
});
setHeader(event, "Content-Type", "application/gzip");
setHeader(event, "Content-Disposition", `attachment; filename="${archiveName}"`);
return sendStream(event, tarProc.stdout);
}); });

33
server/service/export/jobs.test.ts

@ -11,9 +11,11 @@ const { userExportTasks } = await import("drizzle-pkg/lib/schema/export");
const { const {
claimNextQueuedTask, claimNextQueuedTask,
createExportTask, createExportTask,
deleteExportTaskForUser,
getExportTaskForUser, getExportTaskForUser,
listExportTasksByUser, listExportTasksByUser,
markExportTaskFailed, markExportTaskFailed,
markExportTaskExpired,
markExportTaskRunning, markExportTaskRunning,
markExportTaskSucceeded, markExportTaskSucceeded,
} = await import("./jobs"); } = await import("./jobs");
@ -150,4 +152,35 @@ describe("export jobs service", () => {
statusCode: 409, statusCode: 409,
}); });
}); });
test("markExportTaskExpired updates status and message", async () => {
const task = await createExportTask({ userId: USER_1.id, maskPolicy: "masked" });
await markExportTaskRunning(task.id);
await markExportTaskSucceeded(task.id, {
outputDir: "/tmp/export-expired",
outputName: "export-expired.zip",
totalBytes: 100,
expiresAt: new Date("2026-04-24T00:00:00.000Z"),
});
const expired = await markExportTaskExpired(task.id, "导出文件已丢失,请重新导出");
expect(expired.status).toBe("expired");
expect(expired.errorCode).toBe("EXPORT_EXPIRED");
expect(expired.errorMessage).toBe("导出文件已丢失,请重新导出");
});
test("deleteExportTaskForUser removes own non-running task", async () => {
const task = await createExportTask({ userId: USER_1.id, maskPolicy: "masked" });
await deleteExportTaskForUser(task.id, USER_1.id);
const deleted = await getExportTaskForUser(task.id, USER_1.id);
expect(deleted).toBeNull();
});
test("deleteExportTaskForUser rejects running task", async () => {
const task = await createExportTask({ userId: USER_1.id, maskPolicy: "masked" });
await markExportTaskRunning(task.id);
await expect(deleteExportTaskForUser(task.id, USER_1.id)).rejects.toMatchObject({
statusCode: 409,
});
});
}); });

49
server/service/export/jobs.ts

@ -1,11 +1,22 @@
import fs from "node:fs/promises";
import path from "node:path";
import { dbGlobal } from "drizzle-pkg/lib/db"; import { dbGlobal } from "drizzle-pkg/lib/db";
import { userExportTasks } from "drizzle-pkg/lib/schema/export"; import { userExportTasks } from "drizzle-pkg/lib/schema/export";
import { and, desc, eq, or } from "drizzle-orm"; import { and, desc, eq, or } from "drizzle-orm";
import { createError } from "h3";
import { nextIntegerId } from "../../utils/sqlite-id"; import { nextIntegerId } from "../../utils/sqlite-id";
type ExportMaskPolicy = "masked" | "raw"; type ExportMaskPolicy = "masked" | "raw";
function exportRootDir(): string {
return path.resolve(process.cwd(), ".tmp", "exports");
}
function isPathUnderExportRoot(dir: string): boolean {
const root = exportRootDir();
const resolved = path.resolve(dir);
return resolved === root || resolved.startsWith(`${root}${path.sep}`);
}
async function getExportTaskById(taskId: number) { async function getExportTaskById(taskId: number) {
const [row] = await dbGlobal const [row] = await dbGlobal
.select() .select()
@ -35,7 +46,7 @@ export async function createExportTask(params: { userId: number; maskPolicy: Exp
) )
.limit(1); .limit(1);
if (activeTask) { if (activeTask) {
throw createError({ statusCode: 409, statusMessage: "已有导出任务在处理中,请稍后再试" }); throw { statusCode: 409, statusMessage: "已有导出任务在处理中,请稍后再试" };
} }
const id = await nextIntegerId(userExportTasks, userExportTasks.id); const id = await nextIntegerId(userExportTasks, userExportTasks.id);
await dbGlobal.insert(userExportTasks).values({ await dbGlobal.insert(userExportTasks).values({
@ -145,6 +156,18 @@ export async function markExportTaskFailed(
return row; return row;
} }
export async function markExportTaskExpired(taskId: number, message: string) {
await dbGlobal
.update(userExportTasks)
.set({
status: "expired",
errorCode: "EXPORT_EXPIRED",
errorMessage: message,
})
.where(eq(userExportTasks.id, taskId));
return getRequiredExportTaskById(taskId);
}
export async function getExportTaskForUser(taskId: number, userId: number) { export async function getExportTaskForUser(taskId: number, userId: number) {
const [row] = await dbGlobal const [row] = await dbGlobal
.select() .select()
@ -153,3 +176,25 @@ export async function getExportTaskForUser(taskId: number, userId: number) {
.limit(1); .limit(1);
return row ?? null; return row ?? null;
} }
export async function deleteExportTaskForUser(taskId: number, userId: number) {
const task = await getExportTaskForUser(taskId, userId);
if (!task) {
throw { statusCode: 404, statusMessage: "导出任务不存在" };
}
if (task.status === "running") {
throw { statusCode: 409, statusMessage: "任务处理中,暂不可删除" };
}
if (task.outputDir && isPathUnderExportRoot(task.outputDir)) {
try {
await fs.rm(task.outputDir, { recursive: true, force: true });
} catch {
// ignore fs cleanup failures to keep deletion resilient
}
}
await dbGlobal
.delete(userExportTasks)
.where(and(eq(userExportTasks.id, taskId), eq(userExportTasks.userId, userId)));
}

Loading…
Cancel
Save