Files
ABAPPLO c11afd5f99 feat: 采集失败KOC补交截图闭环 + 任务中心分发详情弹窗
公开数据补交闭环(F1):
- distributions 新增 public_metrics_screenshot_key(mysql/0022 迁移)
- KOC 门户:链接已回填但采集失败时展示补交数据截图入口(kind=public-metrics)
- 运营端:回收队列"核对补录"支持公开互动数据(赞/藏/评),按补交截图补录后采集状态置成功
- 企微催办:采集失败纳入待办(公开数据待补交)
- task-result-image 支持 kind=publish 输出发布截图

门户文案(F2):
- 自动采集数据明确"由系统每天自动采集,无需你填写",仅识别失败时才手动填写曝光/阅读

任务中心分发详情:
- 任务中心/数据回收的分发表行可点击,弹窗查看详情
- 详情按任务类型适配:发布阶段/数据回收(自动采集)/创作者数据(第7天)/截图回收四区块三段式布局
2026-10-09 17:02:46 +08:00

268 lines
9.2 KiB
TypeScript
Raw Permalink 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 {
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";
import { creatorScreenshotMcpUrl } from "../../../lib/creator-screenshot-access";
import {
extractCreatorMetricsFromMcp,
resolveCollectionMcpConfig,
type CollectionMcpBindings,
} from "../../../lib/mcp-collection-client";
import { getRuntimeEnv } from "../../../lib/runtime-env";
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 });
}
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";
const isPublicMetrics = upload.uploadKind === "public-metrics";
if (
!["publish", "creator-center", "task-result", "public-metrics"].includes(
upload.uploadKind,
)
) {
return Response.json({ error: "不支持的截图类型" }, { status: 400 });
}
const assignment = upload.delegationToken
? await getRawDb()
.prepare(
`SELECT d.id, d.publish_url, d.result_screenshot_key, t.recovery_enabled
FROM distributions d
JOIN delegation_bundles b ON b.id = d.delegation_bundle_id
JOIN tasks t ON t.id = d.task_id
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; recovery_enabled: number }>()
: await getRawDb()
.prepare(
`SELECT d.id, d.publish_url, d.result_screenshot_key, t.recovery_enabled
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; recovery_enabled: number }>();
if (!assignment) {
return Response.json({ error: "笔记与领取凭证不匹配" }, { status: 403 });
}
if ((isCreatorCenter || isPublicMetrics) && !assignment.publish_url) {
return Response.json(
{ error: "请先回填这篇笔记的发布信息" },
{ status: 409 },
);
}
if (isTaskResult && Number(assignment.recovery_enabled) !== 1) {
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 },
);
}
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
? `creator-center/${upload.distributionId}/${uid("shot")}.${extension}`
: isPublicMetrics
? `public-metrics/${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 (isPublicMetrics) {
// 公开数据自动采集失败时的兜底凭证:运营据此核对补录互动数据
await getRawDb()
.prepare(
`UPDATE distributions SET
public_metrics_screenshot_key = ?,
collection_status_description = 'KOC已补交数据截图,待运营核对',
updated_at = CURRENT_TIMESTAMP
WHERE id = ?`,
)
.bind(key, upload.distributionId)
.run();
} else if (isCreatorCenter) {
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 'processing'
END,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?`,
)
.bind(key, upload.distributionId)
.run();
try {
const metrics = await extractCreatorMetricsFromMcp(
creatorScreenshotMcpUrl(request, upload.distributionId, key),
resolveCollectionMcpConfig(
getRuntimeEnv() as unknown as CollectionMcpBindings,
),
);
await getRawDb()
.prepare(
`UPDATE distributions SET exposure = ?, views = ?,
ocr_status = 'success', updated_at = CURRENT_TIMESTAMP
WHERE id = ?`,
)
.bind(metrics.exposure, metrics.views, upload.distributionId)
.run();
return Response.json({
uploaded: true,
exposure: metrics.exposure,
views: metrics.views,
ocrStatus: "success",
kind: "creator-center",
});
} catch (error) {
console.error("[KOC LOOP] creator screenshot OCR failed", {
distributionId: upload.distributionId,
detail: error instanceof Error ? error.message : String(error),
});
await getRawDb()
.prepare(
`UPDATE distributions SET ocr_status = 'failed', updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
)
.bind(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"
: isPublicMetrics
? "public-metrics"
: "publish",
ocrStatus: isCreatorCenter ? "failed" : undefined,
});
} 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);
}