751 lines
25 KiB
TypeScript
751 lines
25 KiB
TypeScript
|
|
import { getRuntimeEnv } from "../../../lib/runtime-env";
|
|||
|
|
import { runInBackground } from "../../../lib/background";
|
|||
|
|
import type { DatabaseStatement } from "../../../lib/database";
|
|||
|
|
import {
|
|||
|
|
downloadFeishuMedia,
|
|||
|
|
type FeishuBindings,
|
|||
|
|
} from "../../../lib/feishu-client";
|
|||
|
|
import {
|
|||
|
|
ensureSchema,
|
|||
|
|
getRawDb,
|
|||
|
|
getUploadBucket,
|
|||
|
|
hashText,
|
|||
|
|
uid,
|
|||
|
|
} from "../../../lib/mvp-db";
|
|||
|
|
import {
|
|||
|
|
PARTNER_BATCH_MAX_BYTES,
|
|||
|
|
buildPartnerBatchWorkbookColumns,
|
|||
|
|
parsePartnerBatchWorkbook,
|
|||
|
|
resolvePartnerWorkbookOrigin,
|
|||
|
|
} from "../../../lib/partner-batch-workbook";
|
|||
|
|
import {
|
|||
|
|
accountFromPublishLink,
|
|||
|
|
} from "../../../lib/partner-utils";
|
|||
|
|
import { extractPublishUrl } from "../../../lib/publish-url";
|
|||
|
|
import {
|
|||
|
|
partnerOptions,
|
|||
|
|
withPartnerCors,
|
|||
|
|
} from "../../../lib/partner-cors";
|
|||
|
|
import {
|
|||
|
|
buildRecoveryWorkbook,
|
|||
|
|
type RecoveryWorkbookImage,
|
|||
|
|
type RecoveryWorkbookRow,
|
|||
|
|
} from "../../../lib/recovery-workbook";
|
|||
|
|
import {
|
|||
|
|
resolveCollectionMcpConfig,
|
|||
|
|
type CollectionMcpBindings,
|
|||
|
|
} from "../../../lib/mcp-collection-client";
|
|||
|
|
import { enrichDistributionAccount } from "../../../lib/account-enrichment-service";
|
|||
|
|
import {
|
|||
|
|
createCollectionRunTasks,
|
|||
|
|
runDueScheduledCollections,
|
|||
|
|
} from "../../../lib/collection-service";
|
|||
|
|
import { normalizeWorkbookImage } from "../../../lib/workbook-image";
|
|||
|
|
|
|||
|
|
const env = getRuntimeEnv();
|
|||
|
|
|
|||
|
|
type StoredAsset = {
|
|||
|
|
index: number;
|
|||
|
|
key: string;
|
|||
|
|
fileToken?: string;
|
|||
|
|
width?: number | null;
|
|||
|
|
height?: number | null;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
type BatchRow = {
|
|||
|
|
distribution_id: string;
|
|||
|
|
title: string;
|
|||
|
|
body: string;
|
|||
|
|
source_row: number | null;
|
|||
|
|
image_assets: string;
|
|||
|
|
video_assets: string;
|
|||
|
|
publish_url: string | null;
|
|||
|
|
publish_screenshot_key: string | null;
|
|||
|
|
screenshot_key: string | null;
|
|||
|
|
partner_id: string;
|
|||
|
|
account_id: string | null;
|
|||
|
|
claimant_name: string;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
type TaskRow = {
|
|||
|
|
id: string;
|
|||
|
|
name: string;
|
|||
|
|
task_type: string;
|
|||
|
|
collection_start_date: string | null;
|
|||
|
|
collection_days: string;
|
|||
|
|
platform: "小红书" | "抖音";
|
|||
|
|
content_format: "image_text" | "video";
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
type BatchAccess = {
|
|||
|
|
task: TaskRow;
|
|||
|
|
rows: BatchRow[];
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
function textValue(value: string | null, maxLength = 100) {
|
|||
|
|
return String(value ?? "").trim().slice(0, maxLength);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function safeFileName(value: string) {
|
|||
|
|
return value.replace(/[\\/:*?"<>|]/g, " ").trim().slice(0, 60) || "领取笔记";
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function exactArrayBuffer(bytes: Uint8Array) {
|
|||
|
|
const copy = new Uint8Array(bytes.byteLength);
|
|||
|
|
copy.set(bytes);
|
|||
|
|
return copy.buffer;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function contentTypeFromObject(object: { writeHttpMetadata(headers: Headers): void }) {
|
|||
|
|
const headers = new Headers();
|
|||
|
|
object.writeHttpMetadata(headers);
|
|||
|
|
return headers.get("Content-Type") || "application/octet-stream";
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function parseAssets(
|
|||
|
|
value: string,
|
|||
|
|
prefixes = ["content-assets/", "task-assets/"],
|
|||
|
|
) {
|
|||
|
|
try {
|
|||
|
|
const assets = JSON.parse(value || "[]") as StoredAsset[];
|
|||
|
|
return Array.isArray(assets)
|
|||
|
|
? assets
|
|||
|
|
.filter(
|
|||
|
|
(asset) =>
|
|||
|
|
Number.isInteger(Number(asset.index)) &&
|
|||
|
|
Number(asset.index) > 0 &&
|
|||
|
|
typeof asset.key === "string" &&
|
|||
|
|
prefixes.some((prefix) => asset.key.startsWith(prefix)),
|
|||
|
|
)
|
|||
|
|
.map((asset) => ({ ...asset, index: Number(asset.index) }))
|
|||
|
|
.sort((left, right) => left.index - right.index)
|
|||
|
|
: [];
|
|||
|
|
} catch {
|
|||
|
|
return [];
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function loadImage(
|
|||
|
|
key: string,
|
|||
|
|
description: string,
|
|||
|
|
fileToken?: string,
|
|||
|
|
width?: number | null,
|
|||
|
|
height?: number | null,
|
|||
|
|
compactSource = false,
|
|||
|
|
) {
|
|||
|
|
const bucket = getUploadBucket();
|
|||
|
|
let object = await bucket.get(key);
|
|||
|
|
if (!object && fileToken) {
|
|||
|
|
const media = await downloadFeishuMedia(
|
|||
|
|
fileToken,
|
|||
|
|
env as unknown as FeishuBindings,
|
|||
|
|
);
|
|||
|
|
await bucket.put(key, media.bytes, {
|
|||
|
|
httpMetadata: { contentType: media.contentType },
|
|||
|
|
customMetadata: { source: "feishu-api" },
|
|||
|
|
});
|
|||
|
|
object = await bucket.get(key);
|
|||
|
|
}
|
|||
|
|
if (!object) return null;
|
|||
|
|
return normalizeWorkbookImage(
|
|||
|
|
{
|
|||
|
|
bytes: new Uint8Array(await object.arrayBuffer()),
|
|||
|
|
contentType: contentTypeFromObject(object),
|
|||
|
|
description,
|
|||
|
|
width,
|
|||
|
|
height,
|
|||
|
|
} satisfies RecoveryWorkbookImage,
|
|||
|
|
compactSource
|
|||
|
|
? { maxDimension: 1600, outputFormat: "jpeg", jpegQuality: 82 }
|
|||
|
|
: undefined,
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function findAccess(
|
|||
|
|
taskToken: string,
|
|||
|
|
claimToken: string,
|
|||
|
|
delegationToken: string,
|
|||
|
|
): Promise<BatchAccess | null> {
|
|||
|
|
const db = getRawDb();
|
|||
|
|
const task = delegationToken
|
|||
|
|
? await db
|
|||
|
|
.prepare(
|
|||
|
|
`SELECT t.id, t.name, t.task_type, t.collection_start_date, t.collection_days,
|
|||
|
|
t.platform, t.content_format
|
|||
|
|
FROM tasks t
|
|||
|
|
JOIN delegation_bundles b ON b.task_id = t.id
|
|||
|
|
WHERE b.share_token = ? AND b.status = 'active'`,
|
|||
|
|
)
|
|||
|
|
.bind(delegationToken)
|
|||
|
|
.first<TaskRow>()
|
|||
|
|
: await db
|
|||
|
|
.prepare(
|
|||
|
|
`SELECT t.id, t.name, t.task_type, t.collection_start_date, t.collection_days,
|
|||
|
|
t.platform, t.content_format
|
|||
|
|
FROM tasks t
|
|||
|
|
JOIN claims cl ON cl.task_id = t.id
|
|||
|
|
WHERE t.share_token = ? AND cl.claim_token = ?`,
|
|||
|
|
)
|
|||
|
|
.bind(taskToken, claimToken)
|
|||
|
|
.first<TaskRow>();
|
|||
|
|
if (!task) return null;
|
|||
|
|
const rows = delegationToken
|
|||
|
|
? await db
|
|||
|
|
.prepare(
|
|||
|
|
`SELECT d.id AS distribution_id, d.partner_id, d.account_id,
|
|||
|
|
d.publish_url, d.publish_screenshot_key, d.screenshot_key,
|
|||
|
|
c.title, c.body, c.source_row, c.image_assets, c.video_assets,
|
|||
|
|
cl.claimant_name
|
|||
|
|
FROM distributions d
|
|||
|
|
JOIN contents c ON c.id = d.content_id
|
|||
|
|
JOIN delegation_bundles b ON b.id = d.delegation_bundle_id
|
|||
|
|
JOIN claims cl ON cl.id = d.claim_id
|
|||
|
|
WHERE b.share_token = ? AND b.task_id = ? AND b.status = 'active'
|
|||
|
|
ORDER BY d.claimed_at, d.id`,
|
|||
|
|
)
|
|||
|
|
.bind(delegationToken, task.id)
|
|||
|
|
.all<BatchRow>()
|
|||
|
|
: await db
|
|||
|
|
.prepare(
|
|||
|
|
`SELECT d.id AS distribution_id, d.partner_id, d.account_id,
|
|||
|
|
d.publish_url, d.publish_screenshot_key, d.screenshot_key,
|
|||
|
|
c.title, c.body, c.source_row, c.image_assets, c.video_assets,
|
|||
|
|
cl.claimant_name
|
|||
|
|
FROM distributions d
|
|||
|
|
JOIN contents c ON c.id = d.content_id
|
|||
|
|
JOIN claims cl ON cl.id = d.claim_id
|
|||
|
|
WHERE cl.claim_token = ? AND cl.task_id = ?
|
|||
|
|
ORDER BY d.claimed_at, d.id`,
|
|||
|
|
)
|
|||
|
|
.bind(claimToken, task.id)
|
|||
|
|
.all<BatchRow>();
|
|||
|
|
return { task, rows: rows.results };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
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"));
|
|||
|
|
if (!delegationToken && (!taskToken || !claimToken)) {
|
|||
|
|
return Response.json({ error: "领取凭证不完整" }, { status: 400 });
|
|||
|
|
}
|
|||
|
|
const access = await findAccess(taskToken, claimToken, delegationToken);
|
|||
|
|
if (!access) return Response.json({ error: "领取凭证无效" }, { status: 403 });
|
|||
|
|
if (access.task.task_type !== "content_publish") {
|
|||
|
|
return Response.json({ error: "当前任务不支持笔记批量回填" }, { status: 400 });
|
|||
|
|
}
|
|||
|
|
const maxSourceImages = Math.max(
|
|||
|
|
0,
|
|||
|
|
...access.rows.map((row) => parseAssets(row.image_assets).length),
|
|||
|
|
);
|
|||
|
|
const maxSourceVideos = Math.max(
|
|||
|
|
0,
|
|||
|
|
...access.rows.map(
|
|||
|
|
(row) => parseAssets(row.video_assets, ["content-videos/"]).length,
|
|||
|
|
),
|
|||
|
|
);
|
|||
|
|
const columns = buildPartnerBatchWorkbookColumns({
|
|||
|
|
contentFormat: access.task.content_format,
|
|||
|
|
maxSourceImages,
|
|||
|
|
maxSourceVideos,
|
|||
|
|
});
|
|||
|
|
const downloadOrigin = resolvePartnerWorkbookOrigin(
|
|||
|
|
request,
|
|||
|
|
env.APP_ORIGIN,
|
|||
|
|
);
|
|||
|
|
const workbookRows: RecoveryWorkbookRow[] = [];
|
|||
|
|
for (let rowIndex = 0; rowIndex < access.rows.length; rowIndex += 1) {
|
|||
|
|
const row = access.rows[rowIndex];
|
|||
|
|
const images: RecoveryWorkbookRow["images"] = [];
|
|||
|
|
const hyperlinks: NonNullable<RecoveryWorkbookRow["hyperlinks"]> = [];
|
|||
|
|
const assets =
|
|||
|
|
columns.sourceImageCount > 0 ? parseAssets(row.image_assets) : [];
|
|||
|
|
for (let index = 0; index < assets.length; index += 1) {
|
|||
|
|
const asset = assets[index];
|
|||
|
|
const image = await loadImage(
|
|||
|
|
asset.key,
|
|||
|
|
`${row.title} 原图${asset.index}`,
|
|||
|
|
asset.fileToken,
|
|||
|
|
asset.width,
|
|||
|
|
asset.height,
|
|||
|
|
true,
|
|||
|
|
);
|
|||
|
|
if (image) {
|
|||
|
|
images.push({
|
|||
|
|
column: columns.sourceImageStartColumn + index,
|
|||
|
|
image,
|
|||
|
|
maxWidth: 160,
|
|||
|
|
maxHeight: 118,
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
if (row.publish_screenshot_key) {
|
|||
|
|
const image = await loadImage(
|
|||
|
|
row.publish_screenshot_key,
|
|||
|
|
`${row.title} 笔记截图`,
|
|||
|
|
);
|
|||
|
|
if (image) {
|
|||
|
|
images.push({ column: columns.publishScreenshotColumn, image });
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
if (row.screenshot_key) {
|
|||
|
|
const image = await loadImage(
|
|||
|
|
row.screenshot_key,
|
|||
|
|
`${row.title} 数据分析截图`,
|
|||
|
|
);
|
|||
|
|
if (image) {
|
|||
|
|
images.push({ column: columns.creatorScreenshotColumn, image });
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
const videoAssets = parseAssets(row.video_assets, ["content-videos/"]);
|
|||
|
|
for (let index = 0; index < videoAssets.length; index += 1) {
|
|||
|
|
const params = new URLSearchParams({
|
|||
|
|
distribution: row.distribution_id,
|
|||
|
|
index: String(videoAssets[index].index),
|
|||
|
|
kind: "video",
|
|||
|
|
download: "1",
|
|||
|
|
});
|
|||
|
|
if (delegationToken) params.set("share", delegationToken);
|
|||
|
|
else {
|
|||
|
|
params.set("task", taskToken);
|
|||
|
|
params.set("claim", claimToken);
|
|||
|
|
}
|
|||
|
|
hyperlinks.push({
|
|||
|
|
column: columns.sourceVideoStartColumn + index,
|
|||
|
|
url: `${downloadOrigin}/api/partner-image?${params}`,
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
workbookRows.push({
|
|||
|
|
cells: [
|
|||
|
|
rowIndex + 1,
|
|||
|
|
row.title,
|
|||
|
|
row.body,
|
|||
|
|
...Array.from({ length: columns.sourceImageCount }, () => ""),
|
|||
|
|
...Array.from(
|
|||
|
|
{ length: columns.sourceVideoCount },
|
|||
|
|
(_, index) => (index < videoAssets.length ? `下载视频${index + 1}` : ""),
|
|||
|
|
),
|
|||
|
|
row.publish_url || "",
|
|||
|
|
"",
|
|||
|
|
"",
|
|||
|
|
row.distribution_id,
|
|||
|
|
row.publish_screenshot_key || "",
|
|||
|
|
row.screenshot_key || "",
|
|||
|
|
],
|
|||
|
|
images,
|
|||
|
|
hyperlinks: [
|
|||
|
|
...hyperlinks,
|
|||
|
|
...(row.publish_url
|
|||
|
|
? [{ column: columns.publishUrlColumn, url: row.publish_url }]
|
|||
|
|
: []),
|
|||
|
|
],
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
const workbook = buildRecoveryWorkbook({
|
|||
|
|
sheetName: "批量回填",
|
|||
|
|
headers: columns.headers,
|
|||
|
|
columnWidths: columns.columnWidths,
|
|||
|
|
rows: workbookRows,
|
|||
|
|
hiddenColumns: [
|
|||
|
|
columns.systemColumn,
|
|||
|
|
columns.systemColumn + 1,
|
|||
|
|
columns.systemColumn + 2,
|
|||
|
|
],
|
|||
|
|
});
|
|||
|
|
const fileName = `${safeFileName(access.task.name)}-批量回填.xlsx`;
|
|||
|
|
return new Response(exactArrayBuffer(workbook), {
|
|||
|
|
headers: {
|
|||
|
|
"Cache-Control": "private, no-store",
|
|||
|
|
"Content-Type":
|
|||
|
|
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|||
|
|
"Content-Disposition": `attachment; filename="koc-batch.xlsx"; filename*=UTF-8''${encodeURIComponent(fileName)}`,
|
|||
|
|
"X-Content-Type-Options": "nosniff",
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
} catch (error) {
|
|||
|
|
return Response.json(
|
|||
|
|
{ error: error instanceof Error ? error.message : "导出失败" },
|
|||
|
|
{ status: 500 },
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function extensionForImage(image: { fileName: string; contentType: string }) {
|
|||
|
|
const fromName = image.fileName.split(".").at(-1)?.replace(/[^a-zA-Z0-9]/g, "");
|
|||
|
|
if (fromName) return fromName.toLowerCase() === "jpeg" ? "jpg" : fromName;
|
|||
|
|
return image.contentType.includes("png")
|
|||
|
|
? "png"
|
|||
|
|
: image.contentType.includes("webp")
|
|||
|
|
? "webp"
|
|||
|
|
: image.contentType.includes("gif")
|
|||
|
|
? "gif"
|
|||
|
|
: "jpg";
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function storeImportedImage(
|
|||
|
|
kind: "publish" | "creator",
|
|||
|
|
distributionId: string,
|
|||
|
|
image: { bytes: Uint8Array; contentType: string; fileName: string },
|
|||
|
|
) {
|
|||
|
|
const prefix = kind === "publish" ? "publish-evidence" : "creator-center";
|
|||
|
|
const key = `${prefix}/${distributionId}/${uid("sheet")}.${extensionForImage(image)}`;
|
|||
|
|
await getUploadBucket().put(key, image.bytes, {
|
|||
|
|
httpMetadata: { contentType: image.contentType },
|
|||
|
|
customMetadata: { source: "partner-batch-workbook" },
|
|||
|
|
});
|
|||
|
|
return key;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function isDifferentFromStoredImage(
|
|||
|
|
existingKey: string | null,
|
|||
|
|
image: { bytes: Uint8Array },
|
|||
|
|
) {
|
|||
|
|
if (!existingKey) return true;
|
|||
|
|
const stored = await getUploadBucket().get(existingKey);
|
|||
|
|
if (!stored) return true;
|
|||
|
|
const storedBytes = new Uint8Array(await stored.arrayBuffer());
|
|||
|
|
if (storedBytes.byteLength !== image.bytes.byteLength) return true;
|
|||
|
|
for (let index = 0; index < storedBytes.byteLength; index += 1) {
|
|||
|
|
if (storedBytes[index] !== image.bytes[index]) return true;
|
|||
|
|
}
|
|||
|
|
return false;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function handlePost(request: Request) {
|
|||
|
|
try {
|
|||
|
|
await ensureSchema();
|
|||
|
|
const form = await request.formData();
|
|||
|
|
const file = form.get("file");
|
|||
|
|
if (!(file instanceof File) || file.size === 0) {
|
|||
|
|
return Response.json({ error: "请选择填写完成的Excel表" }, { status: 400 });
|
|||
|
|
}
|
|||
|
|
if (file.size > PARTNER_BATCH_MAX_BYTES) {
|
|||
|
|
return Response.json({ error: "批量回填表不能超过80MB" }, { status: 400 });
|
|||
|
|
}
|
|||
|
|
if (!/\.xlsx$/i.test(file.name)) {
|
|||
|
|
return Response.json({ error: "仅支持系统导出的 .xlsx 表格" }, { status: 400 });
|
|||
|
|
}
|
|||
|
|
const taskToken = textValue(String(form.get("taskToken") ?? ""));
|
|||
|
|
const claimToken = textValue(String(form.get("claimToken") ?? ""));
|
|||
|
|
const delegationToken = textValue(String(form.get("delegationToken") ?? ""));
|
|||
|
|
const access = await findAccess(taskToken, claimToken, delegationToken);
|
|||
|
|
if (!access) return Response.json({ error: "领取凭证无效" }, { status: 403 });
|
|||
|
|
if (access.task.task_type !== "content_publish") {
|
|||
|
|
return Response.json({ error: "当前任务不支持笔记批量回填" }, { status: 400 });
|
|||
|
|
}
|
|||
|
|
const importedRows = parsePartnerBatchWorkbook(await file.arrayBuffer());
|
|||
|
|
const assignmentById = new Map(
|
|||
|
|
access.rows.map((row) => [row.distribution_id, row]),
|
|||
|
|
);
|
|||
|
|
const seen = new Set<string>();
|
|||
|
|
const errors: string[] = [];
|
|||
|
|
const prepared = importedRows.map((row) => {
|
|||
|
|
const assignment = assignmentById.get(row.distributionId);
|
|||
|
|
if (!assignment) {
|
|||
|
|
errors.push(`第${row.spreadsheetRow}行不属于当前领取批次,请重新导出表格`);
|
|||
|
|
} else if (seen.has(row.distributionId)) {
|
|||
|
|
errors.push(`第${row.spreadsheetRow}行笔记重复`);
|
|||
|
|
} else if (row.title && row.title !== assignment.title) {
|
|||
|
|
errors.push(`第${row.spreadsheetRow}行标题已被修改,请重新导出表格`);
|
|||
|
|
}
|
|||
|
|
seen.add(row.distributionId);
|
|||
|
|
const publishUrl = row.publishUrl
|
|||
|
|
? extractPublishUrl(row.publishUrl, access.task.platform)
|
|||
|
|
: assignment?.publish_url || "";
|
|||
|
|
if (row.publishUrl && !publishUrl) {
|
|||
|
|
errors.push(`第${row.spreadsheetRow}行发布链接不是有效的${access.task.platform}作品链接`);
|
|||
|
|
}
|
|||
|
|
const hasPublishScreenshot = Boolean(
|
|||
|
|
assignment?.publish_screenshot_key || row.publishScreenshot,
|
|||
|
|
);
|
|||
|
|
if (publishUrl && !hasPublishScreenshot) {
|
|||
|
|
errors.push(`第${row.spreadsheetRow}行填写了发布链接,请同时插入笔记截图`);
|
|||
|
|
}
|
|||
|
|
if (row.publishScreenshot && !publishUrl) {
|
|||
|
|
errors.push(`第${row.spreadsheetRow}行插入了笔记截图,请同时填写发布链接`);
|
|||
|
|
}
|
|||
|
|
if (row.creatorScreenshot && !publishUrl) {
|
|||
|
|
errors.push(`第${row.spreadsheetRow}行需先回填发布链接,再补数据分析截图`);
|
|||
|
|
}
|
|||
|
|
return {
|
|||
|
|
imported: row,
|
|||
|
|
assignment,
|
|||
|
|
publishUrl,
|
|||
|
|
};
|
|||
|
|
});
|
|||
|
|
if (errors.length > 0) {
|
|||
|
|
return Response.json(
|
|||
|
|
{ error: errors.slice(0, 8).join(";"), errors },
|
|||
|
|
{ status: 400 },
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const db = getRawDb();
|
|||
|
|
let updatedRows = 0;
|
|||
|
|
let publishedCount = 0;
|
|||
|
|
let analysisScreenshotCount = 0;
|
|||
|
|
let noteScreenshotCount = 0;
|
|||
|
|
let publishUrlChangedCount = 0;
|
|||
|
|
const enrichments: Array<{ id: string; url: string; nickname: string }> = [];
|
|||
|
|
for (const item of prepared) {
|
|||
|
|
const assignment = item.assignment!;
|
|||
|
|
let publishScreenshotKey = assignment.publish_screenshot_key;
|
|||
|
|
let creatorScreenshotKey = assignment.screenshot_key;
|
|||
|
|
const hasNewPublishScreenshot = item.imported.publishScreenshot
|
|||
|
|
? await isDifferentFromStoredImage(
|
|||
|
|
assignment.publish_screenshot_key,
|
|||
|
|
item.imported.publishScreenshot,
|
|||
|
|
)
|
|||
|
|
: false;
|
|||
|
|
const hasNewCreatorScreenshot = item.imported.creatorScreenshot
|
|||
|
|
? await isDifferentFromStoredImage(
|
|||
|
|
assignment.screenshot_key,
|
|||
|
|
item.imported.creatorScreenshot,
|
|||
|
|
)
|
|||
|
|
: false;
|
|||
|
|
if (hasNewPublishScreenshot && item.imported.publishScreenshot) {
|
|||
|
|
publishScreenshotKey = await storeImportedImage(
|
|||
|
|
"publish",
|
|||
|
|
assignment.distribution_id,
|
|||
|
|
item.imported.publishScreenshot,
|
|||
|
|
);
|
|||
|
|
noteScreenshotCount += 1;
|
|||
|
|
}
|
|||
|
|
if (hasNewCreatorScreenshot && item.imported.creatorScreenshot) {
|
|||
|
|
creatorScreenshotKey = await storeImportedImage(
|
|||
|
|
"creator",
|
|||
|
|
assignment.distribution_id,
|
|||
|
|
item.imported.creatorScreenshot,
|
|||
|
|
);
|
|||
|
|
analysisScreenshotCount += 1;
|
|||
|
|
}
|
|||
|
|
const statements: DatabaseStatement[] = [];
|
|||
|
|
if (publishScreenshotKey !== assignment.publish_screenshot_key) {
|
|||
|
|
statements.push(
|
|||
|
|
db
|
|||
|
|
.prepare(
|
|||
|
|
`UPDATE distributions SET publish_screenshot_key = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
|
|||
|
|
)
|
|||
|
|
.bind(publishScreenshotKey, assignment.distribution_id),
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
if (creatorScreenshotKey !== assignment.screenshot_key) {
|
|||
|
|
statements.push(
|
|||
|
|
db
|
|||
|
|
.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(creatorScreenshotKey, assignment.distribution_id),
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
if (item.publishUrl && item.publishUrl !== assignment.publish_url) {
|
|||
|
|
const isReplacement = Boolean(assignment.publish_url);
|
|||
|
|
const account = accountFromPublishLink(
|
|||
|
|
item.publishUrl,
|
|||
|
|
access.task.platform,
|
|||
|
|
);
|
|||
|
|
if (!account) {
|
|||
|
|
return Response.json(
|
|||
|
|
{ error: `第${item.imported.spreadsheetRow}行发布链接格式不正确` },
|
|||
|
|
{ status: 400 },
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
const matchedAccount = await db
|
|||
|
|
.prepare(
|
|||
|
|
`SELECT id FROM accounts
|
|||
|
|
WHERE platform = ? AND platform_uid = ?
|
|||
|
|
LIMIT 1`,
|
|||
|
|
)
|
|||
|
|
.bind(account.platform, account.platformUid)
|
|||
|
|
.first<{ id: string }>();
|
|||
|
|
const accountId =
|
|||
|
|
matchedAccount?.id ||
|
|||
|
|
`account-${hashText(`${account.platform}:${account.platformUid}`)}`;
|
|||
|
|
statements.push(
|
|||
|
|
...(isReplacement
|
|||
|
|
? [
|
|||
|
|
db
|
|||
|
|
.prepare(
|
|||
|
|
`UPDATE distributions SET
|
|||
|
|
d2_likes = NULL, d2_comments = NULL, d2_collects = NULL,
|
|||
|
|
d5_likes = NULL, d5_comments = NULL, d5_collects = NULL,
|
|||
|
|
d7_likes = NULL, d7_comments = NULL, d7_collects = NULL,
|
|||
|
|
latest_likes = NULL, latest_comments = NULL,
|
|||
|
|
latest_collects = NULL, latest_shares = NULL,
|
|||
|
|
collection_status = 'pending',
|
|||
|
|
collection_status_description = '批量回填已更新链接,等待重新采集',
|
|||
|
|
collection_updated_at = NULL, last_collection_day = NULL,
|
|||
|
|
updated_at = CURRENT_TIMESTAMP
|
|||
|
|
WHERE id = ?`,
|
|||
|
|
)
|
|||
|
|
.bind(assignment.distribution_id),
|
|||
|
|
db
|
|||
|
|
.prepare("DELETE FROM collection_runs WHERE distribution_id = ?")
|
|||
|
|
.bind(assignment.distribution_id),
|
|||
|
|
]
|
|||
|
|
: []),
|
|||
|
|
db
|
|||
|
|
.prepare(
|
|||
|
|
`INSERT INTO accounts
|
|||
|
|
(id, platform, platform_uid, nickname, profile_url,
|
|||
|
|
current_contact, post_count)
|
|||
|
|
VALUES (?, ?, ?, ?, ?, ?, 1)
|
|||
|
|
ON CONFLICT(platform, platform_uid) DO UPDATE SET
|
|||
|
|
nickname = excluded.nickname,
|
|||
|
|
profile_url = excluded.profile_url,
|
|||
|
|
current_contact = CASE
|
|||
|
|
WHEN excluded.current_contact != ''
|
|||
|
|
THEN excluded.current_contact
|
|||
|
|
ELSE accounts.current_contact
|
|||
|
|
END,
|
|||
|
|
last_seen_at = CURRENT_TIMESTAMP`,
|
|||
|
|
)
|
|||
|
|
.bind(
|
|||
|
|
accountId,
|
|||
|
|
account.platform,
|
|||
|
|
account.platformUid,
|
|||
|
|
account.nickname,
|
|||
|
|
account.profileUrl,
|
|||
|
|
assignment.claimant_name,
|
|||
|
|
),
|
|||
|
|
db
|
|||
|
|
.prepare(
|
|||
|
|
`UPDATE distributions SET account_id = ?, publish_url = ?,
|
|||
|
|
publish_time = COALESCE(publish_time, CURRENT_TIMESTAMP),
|
|||
|
|
status = 'published', updated_at = CURRENT_TIMESTAMP
|
|||
|
|
WHERE id = ?`,
|
|||
|
|
)
|
|||
|
|
.bind(accountId, item.publishUrl, assignment.distribution_id),
|
|||
|
|
);
|
|||
|
|
statements.push(
|
|||
|
|
db
|
|||
|
|
.prepare(
|
|||
|
|
`UPDATE accounts SET post_count = (
|
|||
|
|
SELECT COUNT(*) FROM distributions WHERE account_id = ?
|
|||
|
|
) WHERE id = ?`,
|
|||
|
|
)
|
|||
|
|
.bind(accountId, accountId),
|
|||
|
|
);
|
|||
|
|
if (assignment.account_id && assignment.account_id !== accountId) {
|
|||
|
|
statements.push(
|
|||
|
|
db
|
|||
|
|
.prepare(
|
|||
|
|
`UPDATE accounts SET post_count = (
|
|||
|
|
SELECT COUNT(*) FROM distributions WHERE account_id = ?
|
|||
|
|
) WHERE id = ?`,
|
|||
|
|
)
|
|||
|
|
.bind(assignment.account_id, assignment.account_id),
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
if (isReplacement) publishUrlChangedCount += 1;
|
|||
|
|
if (!assignment.publish_url) {
|
|||
|
|
statements.push(
|
|||
|
|
db
|
|||
|
|
.prepare(
|
|||
|
|
`UPDATE partners SET completed_total = completed_total + 1 WHERE id = ?`,
|
|||
|
|
)
|
|||
|
|
.bind(assignment.partner_id),
|
|||
|
|
);
|
|||
|
|
publishedCount += 1;
|
|||
|
|
}
|
|||
|
|
enrichments.push({
|
|||
|
|
id: assignment.distribution_id,
|
|||
|
|
url: item.publishUrl,
|
|||
|
|
nickname: account.nickname,
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
if (statements.length > 0) {
|
|||
|
|
await db.batch(statements);
|
|||
|
|
updatedRows += 1;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
if (
|
|||
|
|
(publishedCount > 0 || publishUrlChangedCount > 0) &&
|
|||
|
|
access.task.collection_start_date &&
|
|||
|
|
access.task.collection_days !== "[]"
|
|||
|
|
) {
|
|||
|
|
let collectionDays: number[] = [];
|
|||
|
|
try {
|
|||
|
|
const parsed = JSON.parse(access.task.collection_days);
|
|||
|
|
if (Array.isArray(parsed)) collectionDays = parsed.map(Number);
|
|||
|
|
} catch {
|
|||
|
|
collectionDays = [];
|
|||
|
|
}
|
|||
|
|
if (collectionDays.length > 0) {
|
|||
|
|
await db
|
|||
|
|
.prepare(
|
|||
|
|
`UPDATE distributions SET collection_status = 'scheduled',
|
|||
|
|
collection_status_description = ?
|
|||
|
|
WHERE task_id = ? AND publish_url IS NOT NULL AND publish_url != ''`,
|
|||
|
|
)
|
|||
|
|
.bind(
|
|||
|
|
`已安排${collectionDays.length}个采集日,每日09:00执行`,
|
|||
|
|
access.task.id,
|
|||
|
|
)
|
|||
|
|
.run();
|
|||
|
|
await createCollectionRunTasks(
|
|||
|
|
db,
|
|||
|
|
access.task.id,
|
|||
|
|
access.task.collection_start_date,
|
|||
|
|
collectionDays,
|
|||
|
|
);
|
|||
|
|
runInBackground(
|
|||
|
|
runDueScheduledCollections(
|
|||
|
|
db,
|
|||
|
|
Date.now(),
|
|||
|
|
resolveCollectionMcpConfig(
|
|||
|
|
env as unknown as CollectionMcpBindings,
|
|||
|
|
),
|
|||
|
|
"catchup",
|
|||
|
|
access.task.id,
|
|||
|
|
).catch(() => undefined),
|
|||
|
|
"collection catchup after batch publish update",
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
for (const enrichment of enrichments) {
|
|||
|
|
runInBackground(
|
|||
|
|
enrichDistributionAccount(
|
|||
|
|
db,
|
|||
|
|
enrichment.id,
|
|||
|
|
enrichment.url,
|
|||
|
|
enrichment.nickname,
|
|||
|
|
resolveCollectionMcpConfig(env as unknown as CollectionMcpBindings),
|
|||
|
|
).catch(() => undefined),
|
|||
|
|
"batch distribution account enrichment",
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
return Response.json({
|
|||
|
|
imported: true,
|
|||
|
|
updatedRows,
|
|||
|
|
publishedCount,
|
|||
|
|
publishUrlChangedCount,
|
|||
|
|
noteScreenshotCount,
|
|||
|
|
analysisScreenshotCount,
|
|||
|
|
skippedRows: importedRows.length - updatedRows,
|
|||
|
|
});
|
|||
|
|
} 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 POST(request: Request) {
|
|||
|
|
return withPartnerCors(request, await handlePost(request));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export async function OPTIONS(request: Request) {
|
|||
|
|
return partnerOptions(request);
|
|||
|
|
}
|