Files
koc-loop/app/api/screenshot-task-export/route.ts
ABAPPLO e2a93e22cf feat: 任务模型物理合并,发布+回收并为单任务双阶段
- tasks 新增 recovery_enabled/recovery_due_at/recovery_instructions/recovery_example_images,回收挂自己名下(先发布后交截图),不再置 complete
- 存量 screenshot_collect 迁移并入父任务后删除(mysql/0020 含 DELETE,生产执行前务必备份)
- 新 action task_enable_recovery:ghost 面板一键开通回收,截止默认=发布截止
- 企微催办双阶段(待发布/截图待提交);超期自动释放仅发布阶段
- admin 删 groupTaskMasters 聚合改单卡双面板;portal 详情页回收截图区块+批次回收进度
- 测试 309 全绿 + Playwright E2E 20/20
2026-09-24 15:52:45 +08:00

111 lines
4.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { zipSync, strToU8 } from "fflate";
import { adminForbidden, isAdminRequest } from "../../../lib/admin-auth";
import { ensureSchema, getRawDb, getUploadBucket } from "../../../lib/mvp-db";
import { parseResultScreenshotKeys } from "../../../lib/result-screenshots";
import { getOwnerScope, isTaskOwner, ownerForbidden } from "../../../lib/owner-scope";
function safeName(value: string) {
return value.replace(/[\\/:*?"<>|\r\n]/g, "_").trim().slice(0, 60) || "KOC";
}
function csvCell(value: unknown) {
const text = String(value ?? "");
return `"${text.replaceAll('"', '""')}"`;
}
export async function GET(request: Request) {
if (!(await isAdminRequest(request))) return adminForbidden();
try {
await ensureSchema();
const taskId = new URL(request.url).searchParams.get("task")?.trim();
if (!taskId) {
return Response.json({ error: "缺少任务参数" }, { status: 400 });
}
const scope = await getOwnerScope(request);
if (
scope.kind === "owner" &&
!(await isTaskOwner(getRawDb(), taskId, scope.wecomUserId))
) {
return ownerForbidden();
}
const task = await getRawDb()
.prepare("SELECT name FROM tasks WHERE id = ?")
.bind(taskId)
.first<{ name: string }>();
if (!task) {
return Response.json({ error: "没有找到任务" }, { status: 404 });
}
const rows = await getRawDb()
.prepare(
`SELECT d.id, d.result_screenshot_key, d.result_submitted_at,
d.claimed_at, c.source_row, c.title, p.name AS partner_name,
cl.claimant_name
FROM distributions d
JOIN contents c ON c.id = d.content_id
JOIN partners p ON p.id = d.partner_id
LEFT JOIN claims cl ON cl.id = d.claim_id
WHERE d.task_id = ?
ORDER BY COALESCE(c.source_row, 999999), d.claimed_at, d.id`,
)
.bind(taskId)
.all<{
id: string;
result_screenshot_key: string | null;
result_submitted_at: string | null;
claimed_at: string;
source_row: number | null;
title: string;
partner_name: string;
claimant_name: string | null;
}>();
const files: Record<string, Uint8Array> = {};
const manifest = [
["序号", "笔记标题", "领取人", "领取时间", "提交时间", "文件名"],
];
let exported = 0;
for (const [rowIndex, row] of rows.results.entries()) {
const fileNames: string[] = [];
const screenshotKeys = row.result_submitted_at
? parseResultScreenshotKeys(row.result_screenshot_key)
: [];
for (const [imageIndex, screenshotKey] of screenshotKeys.entries()) {
const object = await getUploadBucket().get(screenshotKey);
if (!object) continue;
const extension = screenshotKey.split(".").at(-1) || "jpg";
const fileName = `${String(row.source_row ?? rowIndex + 1).padStart(3, "0")}-${safeName(row.claimant_name || row.partner_name)}-${imageIndex + 1}.${extension}`;
files[fileName] = new Uint8Array(await object.arrayBuffer());
fileNames.push(fileName);
exported += 1;
}
manifest.push([
String(row.source_row ?? ""),
row.title,
row.claimant_name || row.partner_name,
row.claimed_at,
row.result_submitted_at || "",
fileNames.join(";"),
]);
}
files["回收清单.csv"] = strToU8(
`\uFEFF${manifest.map((cells) => cells.map(csvCell).join(",")).join("\r\n")}`,
);
const archive = zipSync(files, { level: 0 });
const headers = new Headers({
"Content-Type": "application/zip",
"Content-Disposition": `attachment; filename*=UTF-8''${encodeURIComponent(`${safeName(task.name)}-截图回收.zip`)}`,
"Cache-Control": "private, no-store",
"X-KOC-Exported-Count": String(exported),
});
const body = archive.buffer.slice(
archive.byteOffset,
archive.byteOffset + archive.byteLength,
) as ArrayBuffer;
return new Response(body, { headers });
} catch (error) {
return Response.json(
{ error: error instanceof Error ? error.message : "截图打包失败" },
{ status: 500 },
);
}
}