feat: 完善视频任务与 KOC 资源库

This commit is contained in:
巫凤萍
2026-08-15 03:53:09 +08:00
parent ad3dbdcc86
commit f37d05dd88
66 changed files with 6633 additions and 558 deletions

View File

@@ -2,7 +2,10 @@ import { getRuntimeEnv } from "../../../lib/runtime-env";
import { runInBackground } from "../../../lib/background";
const env = getRuntimeEnv();
import { backfillAccountProfiles } from "../../../lib/account-enrichment-service";
import {
backfillAccountProfiles,
enrichDistributionAccount,
} from "../../../lib/account-enrichment-service";
import {
ensureSchema,
getDashboardData,
@@ -35,6 +38,7 @@ import {
releaseUnfinishedDistribution,
} from "../../../lib/distribution-release-service";
import { isManagerRequest } from "../../../lib/user-auth";
import { extractPublishUrl } from "../../../lib/publish-url";
type ActionBody = {
action?: string;
@@ -63,6 +67,14 @@ export async function POST(request: Request) {
sheetName: source.sheetName,
syncedAt: source.syncedAt,
rowCount: source.rows.length,
imageCount: source.rows.reduce(
(total, row) => total + row.images.length,
0,
),
videoCount: source.rows.reduce(
(total, row) => total + row.videos.length,
0,
),
columns: source.columns,
preview: source.rows.slice(0, 3),
});
@@ -72,6 +84,8 @@ export async function POST(request: Request) {
const name = String(body.name ?? "").trim();
const brand = String(body.brand ?? "").trim();
const dueAt = String(body.dueAt ?? "").trim();
const platform = body.platform === "抖音" ? "抖音" : "小红书";
const contentFormat = body.contentFormat === "video" ? "video" : "image_text";
if (!name || !brand || !dueAt) {
return Response.json(
{ error: "请补全任务名称、品牌和截止日期" },
@@ -84,6 +98,8 @@ export async function POST(request: Request) {
name,
brand,
dueAt,
platform,
contentFormat,
},
env as unknown as FeishuBindings,
);
@@ -146,6 +162,159 @@ export async function POST(request: Request) {
db,
String(body.distributionId ?? "").trim(),
);
} else if (body.action === "update_distribution_publish_url") {
if (!(await isManagerRequest(request))) return adminForbidden();
const distributionId = String(body.distributionId ?? "").trim();
if (!distributionId) {
return Response.json(
{ error: "作品记录不存在" },
{ status: 400 },
);
}
const current = await db
.prepare(
`SELECT d.id, d.task_id, d.partner_id, d.publish_url,
t.task_type, t.platform, t.collection_start_date, t.collection_days,
COALESCE(a.nickname, '待识别账号') AS account_nickname
FROM distributions d
JOIN tasks t ON t.id = d.task_id
LEFT JOIN accounts a ON a.id = d.account_id
WHERE d.id = ?`,
)
.bind(distributionId)
.first<{
id: string;
task_id: string;
partner_id: string;
publish_url: string | null;
task_type?: string | null;
platform: string;
collection_start_date: string | null;
collection_days: string;
account_nickname: string;
}>();
if (!current) {
return Response.json({ error: "作品记录不存在" }, { status: 404 });
}
if (current.task_type === "screenshot_collect") {
return Response.json(
{ error: "截图回收任务不需要填写发布链接" },
{ status: 400 },
);
}
const platform = current.platform === "抖音" ? "抖音" : "小红书";
const publishUrl = extractPublishUrl(
String(body.publishUrl ?? "").trim(),
platform,
);
if (!publishUrl) {
return Response.json(
{ error: `请填写包含${platform}作品链接的发布内容` },
{ status: 400 },
);
}
if (current.publish_url === publishUrl) {
return Response.json(await getDashboardData());
}
let collectionDays: number[] = [];
try {
const parsed = JSON.parse(current.collection_days || "[]");
if (Array.isArray(parsed)) {
collectionDays = [...new Set(parsed.map(Number))]
.filter(
(day) =>
Number.isInteger(day) && day >= 1 && day <= 7,
)
.sort((a, b) => a - b);
}
} catch {
collectionDays = [];
}
const isScheduled = Boolean(
current.collection_start_date && collectionDays.length > 0,
);
const statements = [
db
.prepare(
`UPDATE distributions SET
publish_url = ?,
publish_time = CURRENT_TIMESTAMP,
status = 'published',
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 = ?,
collection_status_description = ?,
collection_updated_at = NULL,
last_collection_day = NULL,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?`,
)
.bind(
publishUrl,
isScheduled ? "scheduled" : "pending",
isScheduled
? `管理员已更新链接,等待${collectionDays.length}个采集日`
: "管理员已更新链接,等待设置采集计划",
distributionId,
),
db
.prepare("DELETE FROM collection_runs WHERE distribution_id = ?")
.bind(distributionId),
];
if (!current.publish_url) {
statements.push(
db
.prepare(
`UPDATE partners SET completed_total = completed_total + 1
WHERE id = ?`,
)
.bind(current.partner_id),
);
}
await db.batch(statements);
if (isScheduled && current.collection_start_date) {
await createCollectionRunTasks(
db,
current.task_id,
current.collection_start_date,
collectionDays,
);
runInBackground(
runDueScheduledCollections(
db,
Date.now(),
resolveCollectionMcpConfig(
env as unknown as CollectionMcpBindings,
),
"catchup",
current.task_id,
).catch(() => undefined),
"collection catchup after publish URL update",
);
}
runInBackground(
enrichDistributionAccount(
db,
distributionId,
publishUrl,
current.account_nickname,
resolveCollectionMcpConfig(
env as unknown as CollectionMcpBindings,
),
).catch(() => undefined),
"account enrichment after publish URL update",
);
} else if (body.action === "save_collection_schedule") {
const taskId = String(body.taskId ?? "").trim();
const startDate = String(body.startDate ?? "").trim();

View File

@@ -32,6 +32,8 @@ const toolOutputSchema = z.object({
due_date: z.string(),
sheet_name: z.string(),
note_count: z.number().int().nonnegative(),
platform: z.enum(["小红书", "抖音"]),
content_format: z.enum(["image_text", "video"]),
claim_url: z.string().url(),
});
@@ -57,7 +59,7 @@ function createServer(context: McpRequestContext) {
{
title: "创建 KOC 分发任务",
description:
"读取飞书电子表格中的标题、正文和配图,在 KOC LOOP 创建分发任务,并返回可直接发给 KOC 的领取链接。飞书表格若有多个工作表,链接必须包含目标 sheet 参数。",
"读取飞书电子表格中的标题、正文及图片或视频,在 KOC LOOP 创建小红书/抖音分发任务,并返回可直接发给 KOC 的领取链接。飞书表格若有多个工作表,链接必须包含目标 sheet 参数。",
inputSchema: z.object({
feishu_url: z
.string()
@@ -74,6 +76,14 @@ function createServer(context: McpRequestContext) {
.max(100)
.optional()
.describe("品牌或项目名称;未提供时系统记录为“未设置项目”"),
platform: z
.enum(["小红书", "抖音"])
.optional()
.describe("发布平台,默认小红书"),
content_format: z
.enum(["image_text", "video"])
.optional()
.describe("内容形式image_text 图文video 视频;默认图文"),
}),
outputSchema: toolOutputSchema,
annotations: {
@@ -83,7 +93,7 @@ function createServer(context: McpRequestContext) {
openWorldHint: true,
},
},
async ({ feishu_url, task_name, due_date, brand_project }) => {
async ({ feishu_url, task_name, due_date, brand_project, platform, content_format }) => {
try {
const bindings = getBindings();
const portalUrl = String(bindings.KOC_PORTAL_URL ?? "").trim();
@@ -96,6 +106,8 @@ function createServer(context: McpRequestContext) {
name: task_name,
brand: brand_project?.trim() || "未设置项目",
dueAt: due_date,
platform: platform ?? "小红书",
contentFormat: content_format ?? "image_text",
},
bindings,
{ deduplicate: true },
@@ -108,6 +120,8 @@ function createServer(context: McpRequestContext) {
due_date: result.dueAt,
sheet_name: result.sheetName,
note_count: result.noteCount,
platform: result.platform,
content_format: result.contentFormat,
claim_url: buildClaimUrl(portalUrl, result.shareToken),
};
const actionText = result.created ? "已创建" : "已找到相同任务";
@@ -115,7 +129,7 @@ function createServer(context: McpRequestContext) {
content: [
{
type: "text",
text: `${actionText}${result.name}”,共 ${result.noteCount}笔记。领取链接:${output.claim_url}`,
text: `${actionText}${result.name}”,平台:${result.platform},内容形式:${result.contentFormat === "video" ? "视频" : "图文"}${result.noteCount} 篇。领取链接:${output.claim_url}`,
},
],
structuredContent: output,

View File

@@ -0,0 +1,750 @@
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);
}

View File

@@ -13,6 +13,7 @@ import {
withPartnerCors,
} from "../../../lib/partner-cors";
import { parseResultScreenshotKeys } from "../../../lib/result-screenshots";
import { hasMp4FileSignature } from "../../../lib/video-file";
const env = getRuntimeEnv();
@@ -26,7 +27,11 @@ function textValue(value: string | null, maxLength = 100) {
return String(value ?? "").trim().slice(0, maxLength);
}
function findAsset(value: string, imageIndex: number) {
function findAsset(
value: string,
imageIndex: number,
prefixes = ["content-assets/", "task-assets/"],
) {
try {
const assets = JSON.parse(value) as StoredAsset[];
return Array.isArray(assets)
@@ -34,8 +39,7 @@ function findAsset(value: string, imageIndex: number) {
(asset) =>
asset.index === imageIndex &&
typeof asset.key === "string" &&
(asset.key.startsWith("content-assets/") ||
asset.key.startsWith("task-assets/")) &&
prefixes.some((prefix) => asset.key.startsWith(prefix)) &&
(asset.fileToken === undefined ||
typeof asset.fileToken === "string"),
)
@@ -55,18 +59,20 @@ async function handleGet(request: Request) {
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 });
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
@@ -80,6 +86,7 @@ async function handleGet(request: Request) {
.bind(distributionId, delegationToken)
.first<{
image_assets: string;
video_assets: string;
result_screenshot_key: string | null;
publish_screenshot_key: string | null;
screenshot_key: string | null;
@@ -87,6 +94,7 @@ async function handleGet(request: Request) {
: await getRawDb()
.prepare(
`SELECT c.image_assets,
c.video_assets,
d.result_screenshot_key,
d.publish_screenshot_key,
d.screenshot_key
@@ -102,6 +110,7 @@ async function handleGet(request: Request) {
.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;
@@ -124,36 +133,77 @@ async function handleGet(request: Request) {
? row?.screenshot_key?.startsWith("creator-center/")
? { index: 1, key: row.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 });
return Response.json({ error: "没有找到这个素材" }, { status: 404 });
}
const bucket = getUploadBucket();
let object = await bucket.get(asset.key);
if (!object && asset.fileToken) {
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: media.contentType },
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) {
return Response.json({ error: "图片同步失败,请稍后重试" }, { status: 404 });
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);
headers.set("Cache-Control", "private, max-age=3600");
headers.set("Content-Disposition", `inline; filename="note-${imageIndex}"`);
return new Response(await object.arrayBuffer(), { headers });
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 : "图片读取失败" },
{ error: error instanceof Error ? error.message : "素材读取失败" },
{ status: 500 },
);
}

View File

@@ -4,7 +4,10 @@ import type { DatabaseStatement } from "../../../lib/database";
const env = getRuntimeEnv();
import { enrichDistributionAccount } from "../../../lib/account-enrichment-service";
import { createCollectionRunTasks } from "../../../lib/collection-service";
import {
createCollectionRunTasks,
runDueScheduledCollections,
} from "../../../lib/collection-service";
import {
resolveCollectionMcpConfig,
type CollectionMcpBindings,
@@ -17,8 +20,8 @@ import {
} from "../../../lib/mvp-db";
import {
accountFromPublishLink,
extractXhsPublishUrl,
} from "../../../lib/partner-utils";
import { extractPublishUrl } from "../../../lib/publish-url";
import { parseClaimantIdentifier } from "../../../lib/claimant-identifier";
import {
partnerOptions,
@@ -101,23 +104,28 @@ function publicImageAssets(value: unknown): ImageAsset[] {
}
}
type PartnerTask = {
id: string;
name: string;
brand: string;
quantity: number;
claimed_quantity: number;
due_at: string;
status: string;
task_type: string;
platform: string;
content_format: string;
};
async function findTask(taskToken: string) {
return getRawDb()
.prepare(
`SELECT id, name, brand, quantity, claimed_quantity, due_at, status, task_type
`SELECT id, name, brand, quantity, claimed_quantity, due_at, status,
task_type, platform, content_format
FROM tasks WHERE share_token = ?`,
)
.bind(taskToken)
.first<{
id: string;
name: string;
brand: string;
quantity: number;
claimed_quantity: number;
due_at: string;
status: string;
task_type: string;
}>();
.first<PartnerTask>();
}
async function findDelegationAccess(delegationToken: string) {
@@ -132,6 +140,8 @@ async function findDelegationAccess(delegationToken: string) {
t.due_at,
t.status,
t.task_type,
t.platform,
t.content_format,
b.id AS bundle_id,
b.label AS bundle_label,
b.quantity AS bundle_quantity,
@@ -141,15 +151,7 @@ async function findDelegationAccess(delegationToken: string) {
WHERE b.share_token = ? AND b.status = 'active'`,
)
.bind(delegationToken)
.first<{
id: string;
name: string;
brand: string;
quantity: number;
claimed_quantity: number;
due_at: string;
status: string;
task_type: string;
.first<PartnerTask & {
bundle_id: string;
bundle_label: string;
bundle_quantity: number;
@@ -172,13 +174,15 @@ async function findAccessibleAssignment(
d.publish_screenshot_key,
d.screenshot_key,
d.result_screenshot_key,
d.result_submitted_at`;
d.result_submitted_at,
c.claimant_name`;
if (delegationToken) {
return db
.prepare(
`${select}
FROM distributions d
JOIN delegation_bundles b ON b.id = d.delegation_bundle_id
JOIN claims c ON c.id = d.claim_id
WHERE d.id = ?
AND b.share_token = ?
AND b.task_id = ?
@@ -194,6 +198,7 @@ async function findAccessibleAssignment(
screenshot_key: string | null;
result_screenshot_key: string | null;
result_submitted_at: string | null;
claimant_name: string;
}>();
}
if (!claimToken) return null;
@@ -214,6 +219,7 @@ async function findAccessibleAssignment(
screenshot_key: string | null;
result_screenshot_key: string | null;
result_submitted_at: string | null;
claimant_name: string;
}>();
}
@@ -284,6 +290,7 @@ async function handleGet(request: Request) {
c.body,
c.source_row,
c.image_assets,
c.video_assets,
a.nickname AS account_nickname,
b.id AS delegation_bundle_id,
b.label AS delegation_label
@@ -326,7 +333,9 @@ async function handleGet(request: Request) {
assignments: assignments.results.map((assignment) => ({
...assignment,
images: publicImageAssets(assignment.image_assets),
videos: publicImageAssets(assignment.video_assets),
image_assets: undefined,
video_assets: undefined,
})),
delegations: delegations.results,
};
@@ -349,6 +358,7 @@ async function handleGet(request: Request) {
c.body,
c.source_row,
c.image_assets,
c.video_assets,
a.nickname AS account_nickname
FROM distributions d
JOIN contents c ON c.id = d.content_id
@@ -366,7 +376,9 @@ async function handleGet(request: Request) {
assignments: assignments.results.map((assignment) => ({
...assignment,
images: publicImageAssets(assignment.image_assets),
videos: publicImageAssets(assignment.video_assets),
image_assets: undefined,
video_assets: undefined,
})),
};
}
@@ -379,6 +391,8 @@ async function handleGet(request: Request) {
dueAt: task.due_at,
status: task.status,
type: task.task_type,
platform: task.platform,
contentFormat: task.content_format,
}
: {
name: task.name,
@@ -388,6 +402,8 @@ async function handleGet(request: Request) {
dueAt: task.due_at,
status: task.status,
type: task.task_type,
platform: task.platform,
contentFormat: task.content_format,
availableQuantity: available?.count ?? 0,
},
claim,
@@ -798,14 +814,15 @@ async function handlePost(request: Request) {
{ status: 400 },
);
}
const publishUrl = extractXhsPublishUrl(publishInput);
const platform = task.platform === "抖音" ? "抖音" : "小红书";
const publishUrl = extractPublishUrl(publishInput, platform);
if (!publishUrl) {
return Response.json(
{ error: "请粘贴包含小红书长链或短链的分享内容" },
{ error: `请粘贴包含${platform}作品链接的分享内容` },
{ status: 400 },
);
}
const account = accountFromPublishLink(publishUrl);
const account = accountFromPublishLink(publishUrl, platform);
if (!account) {
return Response.json({ error: "发布链接格式不正确" }, { status: 400 });
}
@@ -816,28 +833,69 @@ async function handlePost(request: Request) {
delegationToken,
);
if (!assignment) {
return Response.json({ error: "笔记与领取凭证不匹配" }, { status: 403 });
return Response.json({ error: "作品与领取凭证不匹配" }, { status: 403 });
}
if (!assignment.publish_screenshot_key) {
return Response.json({ error: "请先上传发布截图" }, { status: 400 });
}
const reuseExistingAccount =
assignment.publish_url === publishUrl && assignment.account_id;
const matchedAccount = reuseExistingAccount
? null
: await db
.prepare(
`SELECT id FROM accounts
WHERE platform = ? AND platform_uid = ?
LIMIT 1`,
)
.bind(account.platform, account.platformUid)
.first<{ id: string }>();
const accountId =
reuseExistingAccount ||
matchedAccount?.id ||
`account-${hashText(`${account.platform}:${account.platformUid}`)}`;
const statements: DatabaseStatement[] = [];
const publishUrlChanged = Boolean(
assignment.publish_url && assignment.publish_url !== publishUrl,
);
if (publishUrlChanged) {
statements.push(
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.id),
db
.prepare("DELETE FROM collection_runs WHERE distribution_id = ?")
.bind(assignment.id),
);
}
if (!reuseExistingAccount) {
statements.push(
db
.prepare(
`INSERT INTO accounts
(id, platform, platform_uid, nickname, profile_url, post_count)
VALUES (?, ?, ?, ?, ?, 1)
(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,
post_count = accounts.post_count + ?,
current_contact = CASE
WHEN excluded.current_contact != ''
THEN excluded.current_contact
ELSE accounts.current_contact
END,
last_seen_at = CURRENT_TIMESTAMP`,
)
.bind(
@@ -846,7 +904,7 @@ async function handlePost(request: Request) {
account.platformUid,
account.nickname,
account.profileUrl,
assignment.publish_url ? 0 : 1,
assignment.claimant_name,
),
);
}
@@ -863,6 +921,26 @@ async function handlePost(request: Request) {
)
.bind(accountId, publishUrl, assignment.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 (!assignment.publish_url) {
statements.push(
db
@@ -896,26 +974,46 @@ async function handlePost(request: Request) {
collectionDays = [];
}
if (collectionDays.length > 0) {
await db
.prepare(
`UPDATE distributions SET collection_status = 'scheduled',
collection_status_description = ? WHERE id = ?`,
)
.bind(
`已安排${collectionDays.length}个采集日每日09:00执行`,
assignment.id,
)
.run();
await createCollectionRunTasks(
db,
task.id,
collectionSchedule.collection_start_date,
collectionDays,
);
runInBackground(
runDueScheduledCollections(
db,
Date.now(),
resolveCollectionMcpConfig(
env as unknown as CollectionMcpBindings,
),
"catchup",
task.id,
).catch(() => undefined),
"collection catchup after partner publish update",
);
}
}
if (account.platform === "小红书") {
const enrichment = enrichDistributionAccount(
db,
assignment.id,
publishUrl,
account.nickname,
resolveCollectionMcpConfig(
env as unknown as CollectionMcpBindings,
),
).catch(() => undefined);
runInBackground(enrichment, "distribution account enrichment");
}
const enrichment = enrichDistributionAccount(
db,
assignment.id,
publishUrl,
account.nickname,
resolveCollectionMcpConfig(
env as unknown as CollectionMcpBindings,
),
).catch(() => undefined);
runInBackground(enrichment, "distribution account enrichment");
return Response.json({ ok: true });
}

View File

@@ -17,6 +17,7 @@ import {
type RecoveryWorkbookRow,
} from "../../../lib/recovery-workbook";
import { consumeMcpExportToken } from "../../../lib/mcp-export-token";
import { normalizeWorkbookImage } from "../../../lib/workbook-image";
const env = getRuntimeEnv();
@@ -53,6 +54,7 @@ type ExportRow = {
latest_likes: number | null;
latest_comments: number | null;
latest_collects: number | null;
latest_shares: number | null;
collection_status: string | null;
collection_status_description: string | null;
collection_updated_at: string | null;
@@ -121,12 +123,16 @@ function latestMetrics(row: ExportRow) {
const likes = row.latest_likes ?? legacyLikes;
const comments = row.latest_comments ?? legacyComments;
const collects = row.latest_collects ?? legacyCollects;
const shares = row.latest_shares;
return {
likes,
comments,
collects,
shares,
total:
likes === null ? null : likes + (comments ?? 0) + (collects ?? 0),
likes === null
? null
: likes + (comments ?? 0) + (collects ?? 0) + (shares ?? 0),
};
}
@@ -181,13 +187,13 @@ async function loadImage(reference: ImageReference) {
object = await bucket.get(reference.key);
}
if (!object) return null;
return {
return normalizeWorkbookImage({
bytes: new Uint8Array(await object.arrayBuffer()),
contentType: contentTypeFromObject(object),
width: reference.width,
height: reference.height,
description: reference.description,
} satisfies RecoveryWorkbookImage;
} satisfies RecoveryWorkbookImage);
}
async function loadImages(references: ImageReference[]) {
@@ -236,9 +242,9 @@ export async function GET(request: Request) {
}
const db = getRawDb();
const task = await db
.prepare("SELECT id, name, brand FROM tasks WHERE id = ?")
.prepare("SELECT id, name, brand, platform FROM tasks WHERE id = ?")
.bind(taskId)
.first<{ id: string; name: string; brand: string }>();
.first<{ id: string; name: string; brand: string; platform: string }>();
if (!task) {
return Response.json({ error: "没有找到这个任务" }, { status: 404 });
}
@@ -269,6 +275,7 @@ export async function GET(request: Request) {
d.latest_likes,
d.latest_comments,
d.latest_collects,
d.latest_shares,
d.collection_status,
d.collection_status_description,
d.collection_updated_at,
@@ -318,18 +325,19 @@ export async function GET(request: Request) {
}
});
const loadedImages = await loadImages(references);
const isDouyin = task.platform === "抖音";
const metricHeaders = isDouyin
? ["点赞", "收藏", "转发", "评论", "总互动"]
: ["点赞", "收藏", "评论", "总互动"];
const headers = [
"序号(不能改)",
"标题",
"笔记内容(正文+话题)",
...Array.from({ length: maxContentImages }, (_, index) => `图片${index + 1}`),
"小红书昵称",
`${task.platform}昵称`,
"发布链接",
"发布时间",
"点赞",
"收藏",
"评论",
"总互动",
...metricHeaders,
"曝光量-实际第7天",
"阅读量-实际第7天",
"数据分析截图(单篇笔记数据分析截图)",
@@ -341,7 +349,7 @@ export async function GET(request: Request) {
];
const originalImageStart = 3;
const accountColumn = originalImageStart + maxContentImages;
const creatorScreenshotColumn = accountColumn + 9;
const creatorScreenshotColumn = accountColumn + (isDouyin ? 10 : 9);
const publishScreenshotColumn = creatorScreenshotColumn + 1;
const workbookRows: RecoveryWorkbookRow[] = rows.map((row, rowIndex) => {
const metrics = latestMetrics(row);
@@ -350,7 +358,7 @@ export async function GET(request: Request) {
{ length: maxContentImages },
(_, index) => {
const asset = contentAssets.find((item) => item.index === index + 1);
return asset && loadedImages.get(asset.key) ? "见图" : asset ? "图片读取失败" : "";
return "";
},
);
const creatorImage = row.screenshot_key
@@ -369,12 +377,13 @@ export async function GET(request: Request) {
formatExportDate(row.publish_time),
metrics.likes,
metrics.collects,
...(isDouyin ? [metrics.shares] : []),
metrics.comments,
metrics.total,
row.exposure,
row.views,
creatorImage ? "见图" : row.screenshot_key ? "截图读取失败" : "",
publishImage ? "见图" : row.publish_screenshot_key ? "截图读取失败" : "",
"",
"",
formatExportDate(row.collection_updated_at || row.updated_at),
row.distribution_id ? collectionLabel(row) : "未领取",
row.partner_name || "",
@@ -403,6 +412,7 @@ export async function GET(request: Request) {
20,
11,
11,
...(isDouyin ? [11] : []),
11,
11,
18,

View File

@@ -15,8 +15,12 @@ type AccountRow = {
profile_url: string;
ip_location: string;
followers: number;
gender: string;
bio: string;
tags: string;
post_count: number;
cooperation_source: string;
current_contact: string;
first_seen_at: string;
last_seen_at: string;
};
@@ -24,6 +28,7 @@ type AccountRow = {
type CooperationRow = {
account_id: string;
partner_name: string;
claimant_name: string | null;
delegation_bundle_id: string | null;
};
@@ -74,9 +79,11 @@ async function exportAccounts(accountIds: string[]) {
`SELECT
d.account_id,
p.name AS partner_name,
cl.claimant_name,
d.delegation_bundle_id
FROM distributions d
JOIN partners p ON p.id = d.partner_id
LEFT JOIN claims cl ON cl.id = d.claim_id
WHERE d.account_id IS NOT NULL`,
)
.all<CooperationRow>(),
@@ -109,8 +116,12 @@ async function exportAccounts(accountIds: string[]) {
"账号主页",
"IP地",
"粉丝数",
"性别",
"简介",
"标签",
"合作发布数",
"历史合作来源",
"当前联系人",
"资源归属",
"首次合作时间",
"最近合作时间",
@@ -119,7 +130,13 @@ async function exportAccounts(accountIds: string[]) {
const cooperation = cooperationByAccount.get(account.id) ?? [];
const sources = [
...new Set([
...cooperation.map((item) => item.partner_name),
...cooperation
.filter(
(item) =>
!item.claimant_name ||
item.partner_name !== item.claimant_name,
)
.map((item) => item.partner_name),
...(account.cooperation_source || "")
.split(/[、,;|]/)
.map((item) => item.trim())
@@ -138,8 +155,12 @@ async function exportAccounts(accountIds: string[]) {
account.profile_url || "",
account.ip_location || "待识别",
account.followers,
account.gender || "",
account.bio || "",
account.tags || "",
account.post_count,
sources.join("、"),
account.current_contact || "",
partnerManagedOnly ? "合作社资源 · 不可直联" : "可直联",
formatExportDate(account.first_seen_at),
formatExportDate(account.last_seen_at),
@@ -161,9 +182,13 @@ async function exportAccounts(accountIds: string[]) {
44,
14,
14,
10,
36,
32,
14,
32,
22,
22,
21,
21,
],

View File

@@ -1,8 +1,9 @@
import { isManagerRequest, managerForbidden } from "../../../lib/user-auth";
import { runInBackground } from "../../../lib/background";
import { ensureSchema, getRawDb } from "../../../lib/mvp-db";
import {
resolveCollectionMcpConfig,
resolveXhsProfileDetailsFromMcp,
resolveProfileDetailsFromMcp,
resolveXhsPublicAccountDetails,
type CollectionMcpBindings,
} from "../../../lib/mcp-collection-client";
@@ -12,6 +13,7 @@ import {
normalizeProfileUrl,
parseResourceImportFile,
RESOURCE_IMPORT_MAX_BYTES,
RESOURCE_IMPORT_MAX_ROWS,
resourcePlatformUid,
resourceImportMissingFields,
type ResourceImportRow,
@@ -26,6 +28,9 @@ type AccountRow = {
profile_url: string;
ip_location: string;
followers: number;
gender: string;
bio: string;
tags: string;
cooperation_source: string;
};
@@ -36,6 +41,10 @@ type AnalyzedRow = ResourceImportRow & {
cooperationSource: string;
};
const RESOURCE_IMPORT_PREVIEW_ROWS = 100;
const RESOURCE_IMPORT_SYNC_ENRICH_ROWS = 100;
const RESOURCE_IMPORT_DB_BATCH_SIZE = 100;
function identityKey(platform: string, value: string) {
return `${platform.trim().toLocaleLowerCase("zh-CN")}|${value
.trim()
@@ -46,7 +55,8 @@ async function loadAccounts() {
return getRawDb()
.prepare(
`SELECT id, platform, platform_uid, public_account_id, nickname,
profile_url, ip_location, followers, cooperation_source
profile_url, ip_location, followers, gender, bio, tags,
cooperation_source
FROM accounts`,
)
.all<AccountRow>();
@@ -71,7 +81,7 @@ async function mapConcurrent<T, R>(
return results;
}
async function enrichRows(rows: ResourceImportRow[], accounts: AccountRow[]) {
function mergeExistingFields(rows: ResourceImportRow[], accounts: AccountRow[]) {
const existingByProfile = new Map<string, AccountRow>();
for (const account of accounts) {
const profileUrl = normalizeProfileUrl(account.profile_url || "");
@@ -79,21 +89,30 @@ async function enrichRows(rows: ResourceImportRow[], accounts: AccountRow[]) {
existingByProfile.set(identityKey(account.platform, profileUrl), account);
}
}
const mcpConfig = resolveCollectionMcpConfig(
getRuntimeEnv() as unknown as CollectionMcpBindings,
);
return mapConcurrent(rows, 4, async (row) => {
if (row.errors.length > 0 || !row.profileUrl || row.platform !== "小红书") {
return rows.map((row) => {
if (
row.errors.length > 0 ||
!row.profileUrl ||
!["小红书", "抖音"].includes(row.platform)
) {
return row;
}
const existing = existingByProfile.get(identityKey(row.platform, row.profileUrl));
const existingNickname =
existing?.nickname && existing.nickname !== "待识别账号"
? existing.nickname
: "";
const existingIpLocation =
existing?.ip_location && existing.ip_location !== "待识别"
? existing.ip_location
: "";
const baseline: ResourceImportRow = {
const existingGender: ResourceImportRow["gender"] =
existing?.gender === "男" || existing?.gender === "女"
? existing.gender
: "";
return {
...row,
nickname: row.nickname || existing?.nickname || "",
nickname: row.nickname || existingNickname,
publicAccountId: row.publicAccountId || existing?.public_account_id || "",
ipLocation: row.ipLocation || existingIpLocation,
followers: row.followersResolved
@@ -101,7 +120,33 @@ async function enrichRows(rows: ResourceImportRow[], accounts: AccountRow[]) {
: Number(existing?.followers || 0),
followersResolved:
row.followersResolved || Number(existing?.followers || 0) > 0,
gender: row.gender || existingGender,
bio: row.bio || existing?.bio || "",
tags: row.tags.length > 0
? row.tags
: (existing?.tags || "")
.split(/[,,、;|]/)
.map((item) => item.trim())
.filter(Boolean)
.slice(0, 5),
};
});
}
async function enrichRows(rows: ResourceImportRow[], accounts: AccountRow[]) {
const mcpConfig = resolveCollectionMcpConfig(
getRuntimeEnv() as unknown as CollectionMcpBindings,
);
const baselineRows = mergeExistingFields(rows, accounts);
return mapConcurrent(baselineRows, 4, async (baseline) => {
const row = baseline;
if (
row.errors.length > 0 ||
!row.profileUrl ||
!["小红书", "抖音"].includes(row.platform)
) {
return row;
}
if (resourceImportMissingFields(baseline).length === 0) {
return baseline;
}
@@ -111,20 +156,42 @@ async function enrichRows(rows: ResourceImportRow[], accounts: AccountRow[]) {
redId: string | null;
followers: number | null;
ipLocation: string | null;
} = await resolveXhsProfileDetailsFromMcp(row.profileUrl, mcpConfig).catch(
() => ({ nickname: null, redId: null, followers: null, ipLocation: null }),
);
gender: "" | "男" | "女";
bio: string;
recentNoteTitles: string[];
providerTags: string[];
} = await resolveProfileDetailsFromMcp(
row.profileUrl,
row.platform === "抖音" ? "抖音" : "小红书",
mcpConfig,
).catch(() => ({
nickname: null,
redId: null,
followers: null,
ipLocation: null,
gender: "" as const,
bio: "",
recentNoteTitles: [],
providerTags: [],
}));
const mcpResult = {
nickname: baseline.nickname || details.nickname?.trim() || "",
publicAccountId: baseline.publicAccountId || details.redId?.trim() || "",
ipLocation: baseline.ipLocation || details.ipLocation?.trim() || "",
followersResolved: baseline.followersResolved || details.followers !== null,
gender: baseline.gender || details.gender,
bio: baseline.bio || details.bio,
tags: baseline.tags,
};
if (resourceImportMissingFields(mcpResult).length > 0) {
if (
row.platform === "小红书" &&
resourceImportMissingFields(mcpResult).length > 0
) {
const publicDetails = await resolveXhsPublicAccountDetails(row.profileUrl).catch(
() => ({ nickname: null, redId: null, followers: null, ipLocation: null }),
);
details = {
...details,
nickname: details.nickname || publicDetails.nickname,
redId: details.redId || publicDetails.redId,
followers: details.followers ?? publicDetails.followers,
@@ -141,6 +208,9 @@ async function enrichRows(rows: ResourceImportRow[], accounts: AccountRow[]) {
: (details.followers ?? 0),
followersResolved:
baseline.followersResolved || details.followers !== null,
gender: baseline.gender || details.gender,
bio: baseline.bio || details.bio,
tags: baseline.tags,
};
});
}
@@ -198,6 +268,12 @@ function analyzeRows(rows: ResourceImportRow[], accountRows: AccountRow[]) {
profile_url: row.profileUrl || existing?.profile_url || "",
ip_location: row.ipLocation || existing?.ip_location || "待识别",
followers: row.followers || existing?.followers || 0,
gender: row.gender || existing?.gender || "",
bio: row.bio || existing?.bio || "",
tags: (row.tags.length > 0
? row.tags
: (existing?.tags || "").split(/[,,、;|]/).filter(Boolean)
).slice(0, 5).join(","),
cooperation_source: analyzed.cooperationSource,
};
if (row.profileUrl) profileMap.set(identityKey(row.platform, row.profileUrl), virtual);
@@ -219,6 +295,123 @@ function summarize(rows: AnalyzedRow[]) {
};
}
function previewAnalyzedRows(rows: AnalyzedRow[]) {
const errorRows = rows.filter((row) => row.action === "error");
if (errorRows.length === 0) {
return rows.slice(0, RESOURCE_IMPORT_PREVIEW_ROWS);
}
const importableRows = rows.filter((row) => row.action !== "error");
return [
...errorRows.slice(0, RESOURCE_IMPORT_PREVIEW_ROWS),
...importableRows.slice(
0,
Math.max(0, RESOURCE_IMPORT_PREVIEW_ROWS - errorRows.length),
),
];
}
function statementForAnalyzedRow(
db: ReturnType<typeof getRawDb>,
row: AnalyzedRow,
) {
return row.action === "update"
? db
.prepare(
`UPDATE accounts SET
nickname = ?,
public_account_id = CASE WHEN ? != '' THEN ? ELSE public_account_id END,
profile_url = CASE WHEN ? != '' THEN ? ELSE profile_url END,
ip_location = CASE
WHEN ? != '' AND ? != '待识别' THEN ? ELSE ip_location END,
followers = CASE WHEN ? = 1 THEN ? ELSE followers END,
gender = CASE WHEN ? != '' THEN ? ELSE gender END,
bio = CASE WHEN ? != '' THEN ? ELSE bio END,
tags = CASE WHEN ? != '' THEN ? ELSE tags END,
cooperation_source = ?,
last_seen_at = CURRENT_TIMESTAMP
WHERE id = ?`,
)
.bind(
row.nickname || "待识别账号",
row.publicAccountId,
row.publicAccountId,
row.profileUrl,
row.profileUrl,
row.ipLocation,
row.ipLocation,
row.ipLocation,
row.followersResolved ? 1 : 0,
row.followers,
row.gender,
row.gender,
row.bio,
row.bio,
row.tags.join(","),
row.tags.join(","),
row.cooperationSource,
row.accountId,
)
: db
.prepare(
`INSERT INTO accounts
(id, platform, platform_uid, public_account_id, nickname,
profile_url, ip_location, followers, post_count, avg_views,
gender, bio, tags, cooperation_source)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, 0, ?, ?, ?, ?)`,
)
.bind(
row.accountId,
row.platform,
row.platformUid,
row.publicAccountId,
row.nickname || "待识别账号",
row.profileUrl,
row.ipLocation || "待识别",
row.followers,
row.gender,
row.bio,
row.tags.join(","),
row.cooperationSource,
);
}
async function writeAnalyzedRows(rows: AnalyzedRow[]) {
const db = getRawDb();
const statements = rows
.filter((row) => row.action !== "error")
.map((row) => statementForAnalyzedRow(db, row));
for (let index = 0; index < statements.length; index += RESOURCE_IMPORT_DB_BATCH_SIZE) {
await db.batch(statements.slice(index, index + RESOURCE_IMPORT_DB_BATCH_SIZE));
}
}
function deferredEnrichmentCount(rows: ResourceImportRow[]) {
return rows.filter(
(row) =>
row.errors.length === 0 &&
row.profileUrl &&
resourceImportMissingFields(row).length > 0,
).length;
}
async function enrichImportedRowsInBackground(rows: ResourceImportRow[]) {
const accounts = await loadAccounts();
const baselineRows = mergeExistingFields(rows, accounts.results);
const missingRows = baselineRows.filter(
(row) =>
row.errors.length === 0 &&
row.profileUrl &&
resourceImportMissingFields(row).length > 0,
);
if (missingRows.length === 0) return;
const enriched = await enrichRows(missingRows, accounts.results);
const latestAccounts = await loadAccounts();
const analyzed = analyzeRows(enriched, latestAccounts.results).filter(
(row) => row.action !== "error",
);
await writeAnalyzedRows(analyzed);
}
export async function POST(request: Request) {
if (!(await isManagerRequest(request))) return managerForbidden();
try {
@@ -230,17 +423,30 @@ export async function POST(request: Request) {
return Response.json({ error: "请选择需要导入的 Excel 文件" }, { status: 400 });
}
if (file.size <= 0 || file.size > RESOURCE_IMPORT_MAX_BYTES) {
return Response.json({ error: "文件不能为空,且不能超过 5MB" }, { status: 400 });
return Response.json({ error: "文件不能为空,且不能超过 20MB" }, { status: 400 });
}
const rows = parseResourceImportFile(file.name, new Uint8Array(await file.arrayBuffer()));
const accounts = await loadAccounts();
const enriched = await enrichRows(rows, accounts.results);
const analyzed = analyzeRows(enriched, accounts.results);
const shouldEnrichSynchronously = rows.length <= RESOURCE_IMPORT_SYNC_ENRICH_ROWS;
const preparedRows = shouldEnrichSynchronously
? await enrichRows(rows, accounts.results)
: mergeExistingFields(rows, accounts.results);
const analyzed = analyzeRows(preparedRows, accounts.results);
const summary = summarize(analyzed);
const importableRows = analyzed.filter((row) => row.action !== "error");
const importableRowNumbers = new Set(
importableRows.map((row) => row.rowNumber),
);
const importablePreparedRows = preparedRows.filter((row) =>
importableRowNumbers.has(row.rowNumber),
);
const deferredEnrichment = shouldEnrichSynchronously
? 0
: deferredEnrichmentCount(importablePreparedRows);
if (mode !== "commit") {
return Response.json({
summary,
rows: analyzed.slice(0, 100).map((row) => ({
rows: previewAnalyzedRows(analyzed).map((row) => ({
rowNumber: row.rowNumber,
platform: row.platform,
nickname: row.nickname,
@@ -248,74 +454,44 @@ export async function POST(request: Request) {
profileUrl: row.profileUrl,
ipLocation: row.ipLocation,
followers: row.followers,
gender: row.gender,
bio: row.bio,
tags: row.tags,
cooperationSource: row.cooperationSource,
action: row.action,
errors: row.errors,
})),
truncated: analyzed.length > 100,
truncated: analyzed.length > RESOURCE_IMPORT_PREVIEW_ROWS,
deferredEnrichment,
maxRows: RESOURCE_IMPORT_MAX_ROWS,
});
}
if (summary.error > 0) {
if (importableRows.length === 0) {
return Response.json(
{ error: `${summary.error} 行数据未通过校验,请修正后重新上传`, summary },
{ error: "没有可导入的有效账号,请修正异常数据后重新上传", summary },
{ status: 400 },
);
}
const db = getRawDb();
const statements = analyzed.map((row) =>
row.action === "update"
? db
.prepare(
`UPDATE accounts SET
nickname = ?,
public_account_id = CASE WHEN ? != '' THEN ? ELSE public_account_id END,
profile_url = CASE WHEN ? != '' THEN ? ELSE profile_url END,
ip_location = CASE
WHEN ? != '' AND ? != '待识别' THEN ? ELSE ip_location END,
followers = CASE WHEN ? = 1 THEN ? ELSE followers END,
cooperation_source = ?,
last_seen_at = CURRENT_TIMESTAMP
WHERE id = ?`,
)
.bind(
row.nickname,
row.publicAccountId,
row.publicAccountId,
row.profileUrl,
row.profileUrl,
row.ipLocation,
row.ipLocation,
row.ipLocation,
row.followersResolved ? 1 : 0,
row.followers,
row.cooperationSource,
row.accountId,
)
: db
.prepare(
`INSERT INTO accounts
(id, platform, platform_uid, public_account_id, nickname,
profile_url, ip_location, followers, post_count, avg_views,
cooperation_source)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, 0, ?)`,
)
.bind(
row.accountId,
row.platform,
row.platformUid,
row.publicAccountId,
row.nickname,
row.profileUrl,
row.ipLocation,
row.followers,
row.cooperationSource,
),
);
if (statements.length > 0) await db.batch(statements);
await writeAnalyzedRows(importableRows);
if (deferredEnrichment > 0) {
const importableSourceRows = rows.filter((row) =>
importableRowNumbers.has(row.rowNumber),
);
runInBackground(
enrichImportedRowsInBackground(importableSourceRows),
"bulk resource profile enrichment",
);
}
return Response.json({
summary,
message: `已导入 ${summary.create} 个新账号,更新 ${summary.update} 个已有账号`,
deferredEnrichment,
message: `已导入 ${summary.create} 个新账号,更新 ${summary.update} 个已有账号${
summary.error > 0 ? `;跳过 ${summary.error} 条异常数据` : ""
}${
deferredEnrichment > 0
? `${deferredEnrichment} 个账号的缺失公开资料将在后台补全`
: ""
}`,
});
} catch (error) {
const message = error instanceof Error ? error.message : "导入失败";