Files
koc-loop/app/api/partner-upload/route.ts

208 lines
7.1 KiB
TypeScript
Raw Permalink Normal View History

2026-07-30 12:06:41 +08:00
import {
ensureSchema,
getRawDb,
getUploadBucket,
uid,
} from "../../../lib/mvp-db";
import {
partnerOptions,
withPartnerCors,
} from "../../../lib/partner-cors";
import {
MAX_RESULT_SCREENSHOTS,
parseResultScreenshotKeys,
serializeResultScreenshotKeys,
} from "../../../lib/result-screenshots";
2026-07-30 12:06:41 +08:00
async function readUpload(request: Request) {
const contentType = request.headers.get("content-type") ?? "";
if (contentType.startsWith("multipart/form-data")) {
const form = await request.formData();
const file = form.get("file");
if (!(file instanceof File)) return null;
return {
taskToken: String(form.get("taskToken") ?? "").trim(),
claimToken: String(form.get("claimToken") ?? "").trim(),
delegationToken: String(form.get("delegationToken") ?? "").trim(),
distributionId: String(form.get("distributionId") ?? "").trim(),
uploadKind: String(form.get("uploadKind") ?? "publish").trim(),
fileName: file.name,
fileType: file.type,
fileBytes: await file.arrayBuffer(),
};
}
const encodedName = request.headers.get("x-koc-file-name") ?? "screenshot.jpg";
let fileName = "screenshot.jpg";
try {
fileName = decodeURIComponent(encodedName);
} catch {
fileName = "screenshot.jpg";
}
return {
taskToken: String(request.headers.get("x-koc-task") ?? "").trim(),
claimToken: String(request.headers.get("x-koc-claim") ?? "").trim(),
delegationToken: String(
request.headers.get("x-koc-delegation") ?? "",
).trim(),
distributionId: String(
request.headers.get("x-koc-distribution") ?? "",
).trim(),
uploadKind: String(
request.headers.get("x-koc-upload-kind") ?? "publish",
).trim(),
fileName,
fileType: contentType.split(";")[0].trim(),
fileBytes: await request.arrayBuffer(),
};
}
async function handlePost(request: Request) {
try {
await ensureSchema();
const upload = await readUpload(request);
if (
!upload ||
(!upload.claimToken && !upload.delegationToken) ||
(!upload.delegationToken && !upload.taskToken) ||
!upload.distributionId ||
upload.fileBytes.byteLength === 0
) {
return Response.json({ error: "请选择需要上传的截图" }, { status: 400 });
2026-07-30 12:06:41 +08:00
}
if (
!upload.fileType.startsWith("image/") ||
upload.fileBytes.byteLength > 8_000_000
) {
return Response.json(
{ error: "仅支持8MB以内的图片" },
{ status: 400 },
);
}
const isCreatorCenter = upload.uploadKind === "creator-center";
const isTaskResult = upload.uploadKind === "task-result";
if (!["publish", "creator-center", "task-result"].includes(upload.uploadKind)) {
return Response.json({ error: "不支持的截图类型" }, { status: 400 });
}
2026-07-30 12:06:41 +08:00
const assignment = upload.delegationToken
? await getRawDb()
.prepare(
`SELECT d.id, d.publish_url, d.result_screenshot_key, t.task_type
2026-07-30 12:06:41 +08:00
FROM distributions d
JOIN delegation_bundles b ON b.id = d.delegation_bundle_id
JOIN tasks t ON t.id = d.task_id
2026-07-30 12:06:41 +08:00
WHERE d.id = ?
AND b.share_token = ?
AND b.status = 'active'`,
)
.bind(upload.distributionId, upload.delegationToken)
.first<{ id: string; publish_url: string | null; result_screenshot_key: string | null; task_type: string }>()
2026-07-30 12:06:41 +08:00
: await getRawDb()
.prepare(
`SELECT d.id, d.publish_url, d.result_screenshot_key, t.task_type
2026-07-30 12:06:41 +08:00
FROM distributions d
JOIN claims c ON c.id = d.claim_id
JOIN tasks t ON t.id = d.task_id
WHERE d.id = ?
AND c.claim_token = ?
AND t.share_token = ?
AND c.task_id = t.id`,
)
.bind(upload.distributionId, upload.claimToken, upload.taskToken)
.first<{ id: string; publish_url: string | null; result_screenshot_key: string | null; task_type: string }>();
2026-07-30 12:06:41 +08:00
if (!assignment) {
return Response.json({ error: "笔记与领取凭证不匹配" }, { status: 403 });
}
if (isCreatorCenter && !assignment.publish_url) {
return Response.json(
{ error: "请先回填这篇笔记的发布信息" },
{ status: 409 },
);
}
if (isTaskResult && assignment.task_type !== "screenshot_collect") {
return Response.json({ error: "当前任务不支持截图结果回填" }, { status: 400 });
}
if (!isTaskResult && assignment.task_type === "screenshot_collect") {
return Response.json({ error: "截图回收任务请上传任务结果截图" }, { status: 400 });
}
const existingResultKeys = isTaskResult
? parseResultScreenshotKeys(assignment.result_screenshot_key)
: [];
if (isTaskResult && existingResultKeys.length >= MAX_RESULT_SCREENSHOTS) {
return Response.json(
{ error: `每份任务最多上传${MAX_RESULT_SCREENSHOTS}张截图` },
{ status: 409 },
);
}
2026-07-30 12:06:41 +08:00
const extension =
upload.fileName.split(".").at(-1)?.replace(/[^a-zA-Z0-9]/g, "") || "jpg";
const key = isTaskResult
? `task-results/${upload.distributionId}/${uid("shot")}.${extension}`
: isCreatorCenter
2026-07-30 12:06:41 +08:00
? `creator-center/${upload.distributionId}/${uid("shot")}.${extension}`
: `publish-evidence/${upload.distributionId}/${uid("shot")}.${extension}`;
await getUploadBucket().put(key, upload.fileBytes, {
httpMetadata: { contentType: upload.fileType },
});
if (isTaskResult) {
await getRawDb()
.prepare(
`UPDATE distributions SET
result_screenshot_key = ?,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?`,
)
.bind(
serializeResultScreenshotKeys([...existingResultKeys, key]),
upload.distributionId,
)
.run();
} else if (isCreatorCenter) {
2026-07-30 12:06:41 +08:00
await getRawDb()
.prepare(
`UPDATE distributions SET
screenshot_key = ?,
ocr_status = CASE
WHEN exposure IS NOT NULL AND views IS NOT NULL THEN ocr_status
ELSE 'uploaded'
END,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?`,
)
.bind(key, upload.distributionId)
.run();
} else {
await getRawDb()
.prepare(
`UPDATE distributions SET
publish_screenshot_key = ?,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?`,
)
.bind(key, upload.distributionId)
.run();
}
return Response.json({
uploaded: true,
screenshotCount: isTaskResult ? existingResultKeys.length + 1 : undefined,
kind: isTaskResult
? "task-result"
: isCreatorCenter
? "creator-center"
: "publish",
2026-07-30 12:06:41 +08:00
});
} catch (error) {
return Response.json(
{ error: error instanceof Error ? error.message : "上传失败" },
{ status: 500 },
);
}
}
export async function POST(request: Request) {
return withPartnerCors(request, await handlePost(request));
}
export async function OPTIONS(request: Request) {
return partnerOptions(request);
}