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

243 lines
8.3 KiB
TypeScript

import { getRuntimeEnv } from "../../../lib/runtime-env";
import {
ensureSchema,
getRawDb,
getUploadBucket,
} from "../../../lib/mvp-db";
import {
downloadFeishuMedia,
type FeishuBindings,
} from "../../../lib/feishu-client";
import {
partnerOptions,
withPartnerCors,
} from "../../../lib/partner-cors";
import { parseResultScreenshotKeys } from "../../../lib/result-screenshots";
import { hasMp4FileSignature } from "../../../lib/video-file";
const env = getRuntimeEnv();
type StoredAsset = {
index: number;
key: string;
fileToken?: string;
};
function textValue(value: string | null, maxLength = 100) {
return String(value ?? "").trim().slice(0, maxLength);
}
function findAsset(
value: string,
imageIndex: number,
prefixes = ["content-assets/", "task-assets/"],
) {
try {
const assets = JSON.parse(value) as StoredAsset[];
return Array.isArray(assets)
? assets.find(
(asset) =>
asset.index === imageIndex &&
typeof asset.key === "string" &&
prefixes.some((prefix) => asset.key.startsWith(prefix)) &&
(asset.fileToken === undefined ||
typeof asset.fileToken === "string"),
)
: undefined;
} catch {
return undefined;
}
}
async function handleGet(request: Request) {
try {
await ensureSchema();
const url = new URL(request.url);
const taskToken = textValue(url.searchParams.get("task"));
const claimToken = textValue(url.searchParams.get("claim"));
const delegationToken = textValue(url.searchParams.get("share"));
const distributionId = textValue(url.searchParams.get("distribution"));
const imageIndex = Number(url.searchParams.get("index"));
const imageKind = textValue(url.searchParams.get("kind"), 20);
const downloadRequested = url.searchParams.get("download") === "1";
if (
(!delegationToken && (!taskToken || !claimToken)) ||
!distributionId ||
!Number.isInteger(imageIndex) ||
imageIndex < 1
) {
return Response.json({ error: "素材链接不完整" }, { status: 400 });
}
const row = delegationToken
? await getRawDb()
.prepare(
`SELECT c.image_assets,
c.video_assets,
d.result_screenshot_key,
d.publish_screenshot_key,
d.screenshot_key,
d.public_metrics_screenshot_key,
t.recovery_example_images
FROM distributions d
JOIN contents c ON c.id = d.content_id
JOIN tasks t ON t.id = d.task_id
JOIN delegation_bundles b ON b.id = d.delegation_bundle_id
WHERE d.id = ?
AND b.share_token = ?
AND b.status = 'active'`,
)
.bind(distributionId, delegationToken)
.first<{
image_assets: string;
video_assets: string;
result_screenshot_key: string | null;
publish_screenshot_key: string | null;
screenshot_key: string | null;
public_metrics_screenshot_key: string | null;
recovery_example_images: string;
}>()
: await getRawDb()
.prepare(
`SELECT c.image_assets,
c.video_assets,
d.result_screenshot_key,
d.publish_screenshot_key,
d.screenshot_key,
d.public_metrics_screenshot_key,
t.recovery_example_images
FROM distributions d
JOIN contents c ON c.id = d.content_id
JOIN claims cl ON cl.id = d.claim_id
JOIN tasks t ON t.id = d.task_id
WHERE d.id = ?
AND cl.claim_token = ?
AND t.share_token = ?
AND cl.task_id = t.id`,
)
.bind(distributionId, claimToken, taskToken)
.first<{
image_assets: string;
video_assets: string;
result_screenshot_key: string | null;
publish_screenshot_key: string | null;
screenshot_key: string | null;
public_metrics_screenshot_key: string | null;
recovery_example_images: string;
}>();
const asset =
imageKind === "result"
? row
? {
index: imageIndex,
key: parseResultScreenshotKeys(row.result_screenshot_key)[
imageIndex - 1
],
}
: undefined
: imageKind === "task-example"
? row
? findAsset(row.recovery_example_images, imageIndex, ["task-assets/"])
: undefined
: imageKind === "publish"
? row?.publish_screenshot_key?.startsWith("publish-evidence/")
? { index: 1, key: row.publish_screenshot_key }
: undefined
: imageKind === "creator"
? row?.screenshot_key?.startsWith("creator-center/")
? { index: 1, key: row.screenshot_key }
: undefined
: imageKind === "public-metrics"
? row?.public_metrics_screenshot_key?.startsWith("public-metrics/")
? { index: 1, key: row.public_metrics_screenshot_key }
: undefined
: imageKind === "video"
? row
? findAsset(row.video_assets, imageIndex, ["content-videos/"])
: undefined
: row
? findAsset(row.image_assets, imageIndex)
: undefined;
if (!asset?.key) {
return Response.json({ error: "没有找到这个素材" }, { status: 404 });
}
const bucket = getUploadBucket();
let object = await bucket.get(asset.key);
let objectBytes = object ? await object.arrayBuffer() : null;
const invalidStoredVideo =
imageKind === "video" &&
objectBytes !== null &&
!hasMp4FileSignature(objectBytes);
if ((!object || invalidStoredVideo) && asset.fileToken) {
const media = await downloadFeishuMedia(
asset.fileToken,
env as unknown as FeishuBindings,
fetch,
{
maxBytes: imageKind === "video" ? 500 * 1024 * 1024 : undefined,
label: imageKind === "video" ? "视频" : "图片",
},
);
if (imageKind === "video" && !hasMp4FileSignature(media.bytes)) {
return Response.json(
{ error: "源视频不是可下载的 MP4 文件,请重新上传 MP4 视频" },
{ status: 422 },
);
}
await bucket.put(asset.key, media.bytes, {
httpMetadata: {
contentType: imageKind === "video" ? "video/mp4" : media.contentType,
},
customMetadata: { source: "feishu-api" },
});
object = await bucket.get(asset.key);
objectBytes = object ? await object.arrayBuffer() : media.bytes;
}
if (!object || !objectBytes) {
return Response.json({ error: "素材同步失败,请稍后重试" }, { status: 404 });
}
if (imageKind === "video" && !hasMp4FileSignature(objectBytes)) {
return Response.json(
{ error: "已存储的视频不是有效的 MP4 文件,请联系运营重新同步" },
{ status: 422 },
);
}
const headers = new Headers();
object.writeHttpMetadata(headers);
const isMutableEvidence =
imageKind === "publish" ||
imageKind === "creator" ||
imageKind === "public-metrics";
headers.set(
"Cache-Control",
isMutableEvidence ? "private, no-store" : "private, max-age=3600",
);
if (imageKind === "video") {
headers.set("Content-Type", "video/mp4");
}
headers.set("Content-Length", String(objectBytes.byteLength));
headers.set("X-Content-Type-Options", "nosniff");
const fileName =
imageKind === "video"
? `video-${imageIndex}.mp4`
: `image-${imageIndex}`;
headers.set(
"Content-Disposition",
`${downloadRequested ? "attachment" : "inline"}; filename="${fileName}"; filename*=UTF-8''${encodeURIComponent(fileName)}`,
);
return new Response(objectBytes, { headers });
} catch (error) {
return Response.json(
{ error: error instanceof Error ? error.message : "素材读取失败" },
{ status: 500 },
);
}
}
export async function GET(request: Request) {
return withPartnerCors(request, await handleGet(request));
}
export async function OPTIONS(request: Request) {
return partnerOptions(request);
}