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

1138 lines
36 KiB
TypeScript
Raw Normal View History

import { getRuntimeEnv } from "../../../lib/runtime-env";
import { runInBackground } from "../../../lib/background";
import type { DatabaseStatement } from "../../../lib/database";
const env = getRuntimeEnv();
2026-07-30 12:06:41 +08:00
import { enrichDistributionAccount } from "../../../lib/account-enrichment-service";
import {
createCollectionRunTasks,
runDueScheduledCollections,
} from "../../../lib/collection-service";
2026-07-30 12:06:41 +08:00
import {
resolveCollectionMcpConfig,
type CollectionMcpBindings,
} from "../../../lib/mcp-collection-client";
import {
ensureSchema,
getRawDb,
hashText,
uid,
} from "../../../lib/mvp-db";
import {
accountFromPublishLink,
} from "../../../lib/partner-utils";
import { extractPublishUrl } from "../../../lib/publish-url";
import { parseClaimantIdentifier } from "../../../lib/claimant-identifier";
2026-07-30 12:06:41 +08:00
import {
partnerOptions,
withPartnerCors,
} from "../../../lib/partner-cors";
type PartnerBody = {
action?: string;
taskToken?: string;
claimToken?: string;
delegationToken?: string;
distributionId?: string;
distributionIds?: string[];
delegationLabel?: string;
delegationBundleId?: string;
claimantIdentifier?: string;
2026-07-30 12:06:41 +08:00
claimantName?: string;
quantity?: number;
publishUrl?: string;
exposure?: number | string;
views?: number | string;
resultScreenshotKey?: string;
2026-07-30 12:06:41 +08:00
};
type ImageAsset = {
index: number;
width: number | null;
height: number | null;
};
function textValue(value: unknown, maxLength = 200) {
return String(value ?? "").trim().slice(0, maxLength);
}
function quantityValue(value: unknown) {
const parsed = Number(value);
return Number.isFinite(parsed) ? Math.max(1, Math.min(50, Math.floor(parsed))) : 1;
}
function creatorMetricValue(value: unknown) {
const normalized = String(value ?? "").trim();
if (!/^\d{1,12}$/.test(normalized)) return null;
const parsed = Number(normalized);
return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : null;
}
function idList(value: unknown) {
if (!Array.isArray(value)) return [];
return [
...new Set(
value
.map((item) => textValue(item, 80))
.filter(Boolean)
.slice(0, 50),
),
];
}
function publicImageAssets(value: unknown): ImageAsset[] {
try {
const assets = JSON.parse(String(value ?? "[]")) as Array<
Partial<ImageAsset> & { key?: string }
>;
if (!Array.isArray(assets)) return [];
return assets
.map((asset) => ({
index: Number(asset.index),
width:
typeof asset.width === "number" && Number.isFinite(asset.width)
? asset.width
: null,
height:
typeof asset.height === "number" && Number.isFinite(asset.height)
? asset.height
: null,
}))
.filter((asset) => Number.isInteger(asset.index) && asset.index > 0);
} catch {
return [];
}
}
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;
};
2026-07-30 12:06:41 +08:00
async function findTask(taskToken: string) {
return getRawDb()
.prepare(
`SELECT id, name, brand, quantity, claimed_quantity, due_at, status,
task_type, platform, content_format
2026-07-30 12:06:41 +08:00
FROM tasks WHERE share_token = ?`,
)
.bind(taskToken)
.first<PartnerTask>();
2026-07-30 12:06:41 +08:00
}
async function findDelegationAccess(delegationToken: string) {
return getRawDb()
.prepare(
`SELECT
t.id,
t.name,
t.brand,
t.quantity,
t.claimed_quantity,
t.due_at,
t.status,
t.task_type,
t.platform,
t.content_format,
2026-07-30 12:06:41 +08:00
b.id AS bundle_id,
b.label AS bundle_label,
b.quantity AS bundle_quantity,
b.created_at AS bundle_created_at
FROM delegation_bundles b
JOIN tasks t ON t.id = b.task_id
WHERE b.share_token = ? AND b.status = 'active'`,
)
.bind(delegationToken)
.first<PartnerTask & {
2026-07-30 12:06:41 +08:00
bundle_id: string;
bundle_label: string;
bundle_quantity: number;
bundle_created_at: string;
}>();
}
async function findAccessibleAssignment(
taskId: string,
distributionId: string,
claimToken: string,
delegationToken: string,
) {
const db = getRawDb();
const select = `SELECT
d.id,
d.partner_id,
d.account_id,
d.publish_url,
d.publish_screenshot_key,
d.screenshot_key,
d.result_screenshot_key,
d.result_submitted_at,
c.claimant_name`;
2026-07-30 12:06:41 +08:00
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
2026-07-30 12:06:41 +08:00
WHERE d.id = ?
AND b.share_token = ?
AND b.task_id = ?
AND b.status = 'active'`,
)
.bind(distributionId, delegationToken, taskId)
.first<{
id: string;
partner_id: string;
account_id: string | null;
publish_url: string | null;
publish_screenshot_key: string | null;
screenshot_key: string | null;
result_screenshot_key: string | null;
result_submitted_at: string | null;
claimant_name: string;
2026-07-30 12:06:41 +08:00
}>();
}
if (!claimToken) return null;
return db
.prepare(
`${select}
FROM distributions d
JOIN claims c ON c.id = d.claim_id
WHERE d.id = ? AND c.claim_token = ? AND c.task_id = ?`,
)
.bind(distributionId, claimToken, taskId)
.first<{
id: string;
partner_id: string;
account_id: string | null;
publish_url: string | null;
publish_screenshot_key: string | null;
screenshot_key: string | null;
result_screenshot_key: string | null;
result_submitted_at: string | null;
claimant_name: string;
2026-07-30 12:06:41 +08:00
}>();
}
async function handleGet(request: Request) {
try {
await ensureSchema();
const url = new URL(request.url);
const taskToken = textValue(url.searchParams.get("task"), 80);
const claimToken = textValue(url.searchParams.get("claim"), 80);
const delegationToken = textValue(url.searchParams.get("share"), 80);
const delegationAccess = delegationToken
? await findDelegationAccess(delegationToken)
: null;
const task = delegationAccess ?? (await findTask(taskToken));
if (!task) {
return Response.json(
{
error: delegationToken
? "分享链接无效、已撤销或已失效"
: "任务链接无效或已失效",
},
{ status: 404 },
);
}
const available = await getRawDb()
.prepare(
`SELECT COUNT(*) AS count FROM contents
WHERE task_id = ? AND status = 'available'`,
)
.bind(task.id)
.first<{ count: number }>();
let claim: Record<string, unknown> | null = null;
let delegation: Record<string, unknown> | null = null;
if (claimToken && !delegationToken) {
const claimRow = await getRawDb()
.prepare(
`SELECT id, claimant_name, quantity, created_at
FROM claims
WHERE claim_token = ? AND task_id = ?`,
)
.bind(claimToken, task.id)
.first<{
id: string;
claimant_name: string;
quantity: number;
created_at: string;
}>();
if (!claimRow) {
return Response.json({ error: "领取凭证无效" }, { status: 403 });
}
const assignments = await getRawDb()
.prepare(
`SELECT
d.id,
d.status,
d.publish_url,
d.publish_time,
d.publish_screenshot_key,
d.result_screenshot_key,
d.result_submitted_at,
2026-07-30 12:06:41 +08:00
d.screenshot_key AS creator_screenshot_key,
d.ocr_status,
d.exposure,
d.views,
c.title,
c.body,
c.source_row,
c.image_assets,
c.video_assets,
2026-07-30 12:06:41 +08:00
a.nickname AS account_nickname,
b.id AS delegation_bundle_id,
b.label AS delegation_label
FROM distributions d
JOIN contents c ON c.id = d.content_id
LEFT JOIN accounts a ON a.id = d.account_id
LEFT JOIN delegation_bundles b
ON b.id = d.delegation_bundle_id AND b.status = 'active'
WHERE d.claim_id = ?
ORDER BY d.claimed_at, d.id`,
)
.bind(claimRow.id)
.all();
const delegations = await getRawDb()
.prepare(
`SELECT
b.id,
b.label,
b.share_token,
b.quantity,
b.status,
b.created_at,
b.revoked_at,
SUM(CASE WHEN d.publish_url IS NOT NULL AND d.publish_url != '' THEN 1 ELSE 0 END) AS completed_count,
SUM(CASE WHEN d.result_submitted_at IS NOT NULL THEN 1 ELSE 0 END) AS result_completed_count,
2026-07-30 12:06:41 +08:00
SUM(CASE WHEN d.screenshot_key IS NOT NULL AND d.exposure IS NOT NULL AND d.views IS NOT NULL THEN 1 ELSE 0 END) AS creator_completed_count
FROM delegation_bundles b
LEFT JOIN distributions d ON d.delegation_bundle_id = b.id
WHERE b.claim_id = ?
GROUP BY b.id, b.label, b.share_token, b.quantity, b.status, b.created_at, b.revoked_at
ORDER BY b.created_at DESC, b.id DESC`,
)
.bind(claimRow.id)
.all();
claim = {
id: claimRow.id,
claimantName: claimRow.claimant_name,
quantity: claimRow.quantity,
createdAt: claimRow.created_at,
assignments: assignments.results.map((assignment) => ({
...assignment,
images: publicImageAssets(assignment.image_assets),
videos: publicImageAssets(assignment.video_assets),
2026-07-30 12:06:41 +08:00
image_assets: undefined,
video_assets: undefined,
2026-07-30 12:06:41 +08:00
})),
delegations: delegations.results,
};
} else if (delegationAccess) {
const assignments = await getRawDb()
.prepare(
`SELECT
d.id,
d.status,
d.publish_url,
d.publish_time,
d.publish_screenshot_key,
d.result_screenshot_key,
d.result_submitted_at,
2026-07-30 12:06:41 +08:00
d.screenshot_key AS creator_screenshot_key,
d.ocr_status,
d.exposure,
d.views,
c.title,
c.body,
c.source_row,
c.image_assets,
c.video_assets,
2026-07-30 12:06:41 +08:00
a.nickname AS account_nickname
FROM distributions d
JOIN contents c ON c.id = d.content_id
LEFT JOIN accounts a ON a.id = d.account_id
WHERE d.delegation_bundle_id = ?
ORDER BY d.claimed_at, d.id`,
)
.bind(delegationAccess.bundle_id)
.all();
delegation = {
id: delegationAccess.bundle_id,
label: task.task_type === "screenshot_collect" ? "转派截图任务包" : "转派发布包",
2026-07-30 12:06:41 +08:00
quantity: delegationAccess.bundle_quantity,
createdAt: delegationAccess.bundle_created_at,
assignments: assignments.results.map((assignment) => ({
...assignment,
images: publicImageAssets(assignment.image_assets),
videos: publicImageAssets(assignment.video_assets),
2026-07-30 12:06:41 +08:00
image_assets: undefined,
video_assets: undefined,
2026-07-30 12:06:41 +08:00
})),
};
}
return Response.json({
task: delegationAccess
? {
name: task.name,
brand: task.brand,
dueAt: task.due_at,
status: task.status,
type: task.task_type,
platform: task.platform,
contentFormat: task.content_format,
2026-07-30 12:06:41 +08:00
}
: {
name: task.name,
brand: task.brand,
quantity: task.quantity,
claimedQuantity: task.claimed_quantity,
dueAt: task.due_at,
status: task.status,
type: task.task_type,
platform: task.platform,
contentFormat: task.content_format,
2026-07-30 12:06:41 +08:00
availableQuantity: available?.count ?? 0,
},
claim,
delegation,
});
} catch (error) {
return Response.json(
{ error: error instanceof Error ? error.message : "暂时无法打开任务" },
{ status: 500 },
);
}
}
async function handlePost(request: Request) {
try {
await ensureSchema();
const body = (await request.json()) as PartnerBody;
const taskToken = textValue(body.taskToken, 80);
const delegationToken = textValue(body.delegationToken, 80);
const delegationAccess = delegationToken
? await findDelegationAccess(delegationToken)
: null;
const task = delegationAccess ?? (await findTask(taskToken));
if (!task) {
return Response.json(
{ error: delegationToken ? "分享链接无效或已撤销" : "任务链接无效" },
{ status: 404 },
);
}
if (
delegationToken &&
body.action !== "submit" &&
body.action !== "submit_creator_metrics" &&
body.action !== "submit_screenshot_result"
2026-07-30 12:06:41 +08:00
) {
return Response.json(
{ error: "分享链接只能用于查看和回填包内任务" },
2026-07-30 12:06:41 +08:00
{ status: 403 },
);
}
const db = getRawDb();
if (body.action === "recover") {
const identifierInput = textValue(
body.claimantIdentifier ?? body.claimantName,
64,
);
const claimantIdentifier = parseClaimantIdentifier(identifierInput);
if (!claimantIdentifier && identifierInput.length < 2) {
2026-07-30 12:06:41 +08:00
return Response.json(
{ error: "请输入领取时使用的正确微信号或手机号" },
2026-07-30 12:06:41 +08:00
{ status: 400 },
);
}
const legacyPartnerId = `partner-ext-${hashText(
`${task.id}:${identifierInput.toLowerCase()}`,
2026-07-30 12:06:41 +08:00
)}`;
const partnerId = claimantIdentifier
? `partner-ext-${hashText(
`claimant:${claimantIdentifier.canonical}`,
)}`
: legacyPartnerId;
2026-07-30 12:06:41 +08:00
const recovered = await db
.prepare(
`SELECT
c.claim_token,
c.quantity,
c.created_at,
COUNT(d.id) AS note_count,
SUM(CASE
WHEN ? = 'screenshot_collect' AND d.result_submitted_at IS NOT NULL THEN 1
WHEN ? != 'screenshot_collect' AND d.publish_url IS NOT NULL AND d.publish_url != '' THEN 1
ELSE 0 END) AS completed_count,
2026-07-30 12:06:41 +08:00
MIN(co.title) AS first_title
FROM claims c
LEFT JOIN distributions d ON d.claim_id = c.id
LEFT JOIN contents co ON co.id = d.content_id
WHERE c.task_id = ? AND c.partner_id IN (?, ?)
2026-07-30 12:06:41 +08:00
GROUP BY c.id, c.claim_token, c.quantity, c.created_at
ORDER BY c.created_at DESC, c.id DESC
LIMIT 20`,
)
.bind(task.task_type, task.task_type, task.id, partnerId, legacyPartnerId)
2026-07-30 12:06:41 +08:00
.all<{
claim_token: string;
quantity: number;
created_at: string;
note_count: number;
completed_count: number;
first_title: string | null;
}>();
if (recovered.results.length === 0) {
return Response.json(
{ error: "没有找到领取记录,请确认微信号或手机号与领取时完全一致" },
2026-07-30 12:06:41 +08:00
{ status: 404 },
);
}
return Response.json({
claims: recovered.results.map((claim) => ({
claimToken: claim.claim_token,
quantity: claim.note_count || claim.quantity,
completedCount: claim.completed_count || 0,
createdAt: claim.created_at,
firstTitle: claim.first_title || (task.task_type === "screenshot_collect" ? "领取的截图任务" : "领取的笔记"),
2026-07-30 12:06:41 +08:00
})),
});
}
if (body.action === "claim") {
if (task.status !== "active") {
return Response.json({ error: "任务已结束,无法继续领取" }, { status: 409 });
}
const identifierInput = textValue(
body.claimantIdentifier ?? body.claimantName,
64,
);
const claimantIdentifier = parseClaimantIdentifier(identifierInput);
2026-07-30 12:06:41 +08:00
const quantity = quantityValue(body.quantity);
if (!claimantIdentifier) {
return Response.json(
{ error: "请输入正确的微信号或手机号" },
{ status: 400 },
);
2026-07-30 12:06:41 +08:00
}
const available = await db
.prepare(
`SELECT id FROM contents
WHERE task_id = ? AND status = 'available'
ORDER BY COALESCE(source_row, 999999), created_at, id
LIMIT ${quantity}`,
2026-07-30 12:06:41 +08:00
)
.bind(task.id)
2026-07-30 12:06:41 +08:00
.all<{ id: string }>();
if (available.results.length === 0) {
return Response.json({ error: "当前任务已领完" }, { status: 409 });
}
const partnerId = `partner-ext-${hashText(
`claimant:${claimantIdentifier.canonical}`,
2026-07-30 12:06:41 +08:00
)}`;
const claimantName = claimantIdentifier.display;
2026-07-30 12:06:41 +08:00
const claimId = uid("claim");
const claimToken = crypto.randomUUID().replaceAll("-", "");
const statements: DatabaseStatement[] = [
2026-07-30 12:06:41 +08:00
db
.prepare(
`INSERT INTO partners
(id, name, wecom_name, owner, claimed_total, completed_total)
VALUES (?, ?, ?, '外部KOC', 0, 0)
ON CONFLICT(id) DO UPDATE SET
name = excluded.name,
wecom_name = excluded.wecom_name`,
)
.bind(partnerId, claimantName, claimantName),
db
.prepare(
`INSERT INTO claims
(id, task_id, partner_id, claimant_name, claim_token, quantity)
VALUES (?, ?, ?, ?, ?, ?)`,
)
.bind(
claimId,
task.id,
partnerId,
claimantName,
claimToken,
available.results.length,
),
];
for (const content of available.results) {
statements.push(
db
.prepare(
`INSERT INTO distributions
(id, task_id, content_id, partner_id, claim_id, status)
VALUES (?, ?, ?, ?, ?, 'claimed')`,
)
.bind(uid("dist"), task.id, content.id, partnerId, claimId),
db
.prepare(
`UPDATE contents SET status = 'allocated'
WHERE id = ? AND status = 'available'`,
)
.bind(content.id),
);
}
statements.push(
db
.prepare(
`UPDATE tasks SET claimed_quantity = claimed_quantity + ?
WHERE id = ?`,
)
.bind(available.results.length, task.id),
db
.prepare(
`UPDATE partners SET claimed_total = claimed_total + ?
WHERE id = ?`,
)
.bind(available.results.length, partnerId),
);
await db.batch(statements);
return Response.json({
claimToken,
claimedCount: available.results.length,
});
}
if (body.action === "create_delegation") {
const claimToken = textValue(body.claimToken, 80);
const label = textValue(body.delegationLabel, 24);
const distributionIds = idList(body.distributionIds);
if (!label) {
return Response.json(
{ error: "请填写接收人备注,例如 A 或小王" },
{ status: 400 },
);
}
if (distributionIds.length === 0) {
return Response.json(
{ error: task.task_type === "screenshot_collect" ? "请至少选择一份待提交任务" : "请至少选择一篇待发布笔记" },
2026-07-30 12:06:41 +08:00
{ status: 400 },
);
}
const claimRow = await db
.prepare(
`SELECT id, partner_id
FROM claims
WHERE claim_token = ? AND task_id = ?`,
)
.bind(claimToken, task.id)
.first<{ id: string; partner_id: string }>();
if (!claimRow) {
return Response.json({ error: "领取凭证无效" }, { status: 403 });
}
const placeholders = distributionIds.map(() => "?").join(", ");
const selected = await db
.prepare(
`SELECT id, publish_url, result_submitted_at, delegation_bundle_id
2026-07-30 12:06:41 +08:00
FROM distributions
WHERE claim_id = ? AND id IN (${placeholders})`,
)
.bind(claimRow.id, ...distributionIds)
.all<{
id: string;
publish_url: string | null;
result_submitted_at: string | null;
2026-07-30 12:06:41 +08:00
delegation_bundle_id: string | null;
}>();
if (selected.results.length !== distributionIds.length) {
return Response.json(
{ error: "部分笔记不属于当前领取批次,请刷新后重试" },
{ status: 403 },
);
}
if (selected.results.some((item) =>
task.task_type === "screenshot_collect"
? item.result_submitted_at
: item.publish_url,
)) {
2026-07-30 12:06:41 +08:00
return Response.json(
{ error: task.task_type === "screenshot_collect" ? "已提交的截图任务不能再次转派" : "已发布的笔记不能再次转派" },
2026-07-30 12:06:41 +08:00
{ status: 409 },
);
}
if (selected.results.some((item) => item.delegation_bundle_id)) {
return Response.json(
{ error: "部分笔记已经转派,请刷新后重新选择" },
{ status: 409 },
);
}
const bundleId = uid("delegate");
const shareToken = crypto.randomUUID().replaceAll("-", "");
const statements: DatabaseStatement[] = [
2026-07-30 12:06:41 +08:00
db
.prepare(
`INSERT INTO delegation_bundles
(id, task_id, claim_id, partner_id, label, share_token, quantity)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
)
.bind(
bundleId,
task.id,
claimRow.id,
claimRow.partner_id,
label,
shareToken,
distributionIds.length,
),
];
for (const distributionId of distributionIds) {
statements.push(
db
.prepare(
`UPDATE distributions
SET delegation_bundle_id = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?
AND claim_id = ?
AND publish_url IS NULL
AND result_submitted_at IS NULL
2026-07-30 12:06:41 +08:00
AND delegation_bundle_id IS NULL`,
)
.bind(bundleId, distributionId, claimRow.id),
);
}
await db.batch(statements);
const bound = await db
.prepare(
`SELECT COUNT(*) AS count
FROM distributions
WHERE delegation_bundle_id = ?`,
)
.bind(bundleId)
.first<{ count: number }>();
if ((bound?.count ?? 0) !== distributionIds.length) {
await db.batch([
db
.prepare(
`UPDATE distributions
SET delegation_bundle_id = NULL, updated_at = CURRENT_TIMESTAMP
WHERE delegation_bundle_id = ?`,
)
.bind(bundleId),
db
.prepare("DELETE FROM delegation_bundles WHERE id = ?")
.bind(bundleId),
]);
return Response.json(
{ error: "部分笔记刚刚已被转派,请刷新后重新选择" },
{ status: 409 },
);
}
return Response.json({
delegation: {
id: bundleId,
label,
shareToken,
quantity: distributionIds.length,
},
});
}
if (body.action === "revoke_delegation") {
const claimToken = textValue(body.claimToken, 80);
const bundleId = textValue(body.delegationBundleId, 80);
const bundle = await db
.prepare(
`SELECT b.id
FROM delegation_bundles b
JOIN claims c ON c.id = b.claim_id
WHERE b.id = ?
AND b.task_id = ?
AND b.status = 'active'
AND c.claim_token = ?`,
)
.bind(bundleId, task.id, claimToken)
.first<{ id: string }>();
if (!bundle) {
return Response.json(
{ error: "没有找到可撤销的转派记录" },
{ status: 404 },
);
}
const completed = await db
2026-07-30 12:06:41 +08:00
.prepare(
`SELECT COUNT(*) AS count
FROM distributions
WHERE delegation_bundle_id = ?
AND (publish_url IS NOT NULL AND publish_url != '' OR result_submitted_at IS NOT NULL)`,
2026-07-30 12:06:41 +08:00
)
.bind(bundle.id)
.first<{ count: number }>();
if ((completed?.count ?? 0) > 0) {
2026-07-30 12:06:41 +08:00
return Response.json(
{ error: task.task_type === "screenshot_collect" ? "该分享包已有截图提交,不能撤销" : "该分享包已有笔记发布需保留链接继续完成第7天数据回收" },
2026-07-30 12:06:41 +08:00
{ status: 409 },
);
}
await db.batch([
db
.prepare(
`UPDATE distributions
SET delegation_bundle_id = NULL, updated_at = CURRENT_TIMESTAMP
WHERE delegation_bundle_id = ?`,
)
.bind(bundle.id),
db
.prepare(
`UPDATE delegation_bundles
SET status = 'revoked',
revoked_at = CURRENT_TIMESTAMP,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?`,
)
.bind(bundle.id),
]);
return Response.json({ revoked: true });
}
if (body.action === "submit") {
if (task.task_type === "screenshot_collect") {
return Response.json({ error: "截图回收任务无需填写发布链接" }, { status: 400 });
}
2026-07-30 12:06:41 +08:00
const claimToken = textValue(body.claimToken, 80);
const distributionId = textValue(body.distributionId, 80);
const publishInput = textValue(body.publishUrl, 5000);
if (!publishInput) {
2026-07-30 12:06:41 +08:00
return Response.json(
{ error: "请填写发布链接" },
2026-07-30 12:06:41 +08:00
{ status: 400 },
);
}
const platform = task.platform === "抖音" ? "抖音" : "小红书";
const publishUrl = extractPublishUrl(publishInput, platform);
2026-07-30 12:06:41 +08:00
if (!publishUrl) {
return Response.json(
{ error: `请粘贴包含${platform}作品链接的分享内容` },
2026-07-30 12:06:41 +08:00
{ status: 400 },
);
}
const account = accountFromPublishLink(publishUrl, platform);
2026-07-30 12:06:41 +08:00
if (!account) {
return Response.json({ error: "发布链接格式不正确" }, { status: 400 });
}
const assignment = await findAccessibleAssignment(
task.id,
distributionId,
claimToken,
delegationToken,
);
if (!assignment) {
return Response.json({ error: "作品与领取凭证不匹配" }, { status: 403 });
2026-07-30 12:06:41 +08:00
}
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 }>();
2026-07-30 12:06:41 +08:00
const accountId =
reuseExistingAccount ||
matchedAccount?.id ||
2026-07-30 12:06:41 +08:00
`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),
);
}
2026-07-30 12:06:41 +08:00
if (!reuseExistingAccount) {
statements.push(
db
.prepare(
`INSERT INTO accounts
(id, platform, platform_uid, nickname, profile_url,
current_contact, post_count)
VALUES (?, ?, ?, ?, ?, ?, 1)
2026-07-30 12:06:41 +08:00
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,
2026-07-30 12:06:41 +08:00
last_seen_at = CURRENT_TIMESTAMP`,
)
.bind(
accountId,
account.platform,
account.platformUid,
account.nickname,
account.profileUrl,
assignment.claimant_name,
2026-07-30 12:06:41 +08:00
),
);
}
statements.push(
db
.prepare(
`UPDATE distributions SET
account_id = ?,
publish_url = ?,
publish_time = CURRENT_TIMESTAMP,
status = 'published',
updated_at = CURRENT_TIMESTAMP
WHERE id = ?`,
)
.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),
);
}
2026-07-30 12:06:41 +08:00
if (!assignment.publish_url) {
statements.push(
db
.prepare(
`UPDATE partners SET completed_total = completed_total + 1
WHERE id = ?`,
)
.bind(assignment.partner_id),
);
}
await db.batch(statements);
const collectionSchedule = await db
.prepare(
`SELECT collection_start_date, collection_days
FROM tasks WHERE id = ?`,
)
.bind(task.id)
.first<{
collection_start_date: string | null;
collection_days: string;
}>();
if (
collectionSchedule?.collection_start_date &&
collectionSchedule.collection_days !== "[]"
) {
let collectionDays: number[] = [];
try {
const parsed = JSON.parse(collectionSchedule.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 id = ?`,
)
.bind(
`已安排${collectionDays.length}个采集日每日09:00执行`,
assignment.id,
)
.run();
2026-07-30 12:06:41 +08:00
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",
);
2026-07-30 12:06:41 +08:00
}
}
const enrichment = enrichDistributionAccount(
db,
assignment.id,
publishUrl,
account.nickname,
resolveCollectionMcpConfig(
env as unknown as CollectionMcpBindings,
),
).catch(() => undefined);
runInBackground(enrichment, "distribution account enrichment");
2026-07-30 12:06:41 +08:00
return Response.json({ ok: true });
}
if (body.action === "submit_creator_metrics") {
if (task.task_type === "screenshot_collect") {
return Response.json({ error: "截图回收任务不需要创作者数据" }, { status: 400 });
}
2026-07-30 12:06:41 +08:00
const claimToken = textValue(body.claimToken, 80);
const distributionId = textValue(body.distributionId, 80);
const exposure = creatorMetricValue(body.exposure);
const views = creatorMetricValue(body.views);
if (exposure === null || views === null) {
return Response.json(
{ error: "请填写正确的曝光量和阅读量" },
{ status: 400 },
);
}
const assignment = await findAccessibleAssignment(
task.id,
distributionId,
claimToken,
delegationToken,
);
if (!assignment) {
return Response.json({ error: "笔记与领取凭证不匹配" }, { status: 403 });
}
if (!assignment.publish_url) {
return Response.json(
{ error: "请先回填这篇笔记的发布信息" },
{ status: 409 },
);
}
if (!assignment.screenshot_key) {
return Response.json(
{ error: "请先上传创作者中心截图" },
{ status: 400 },
);
}
await db
.prepare(
`UPDATE distributions SET
exposure = ?,
views = ?,
ocr_status = 'manual',
status = CASE WHEN d7_likes IS NOT NULL THEN 'complete' ELSE status END,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?`,
)
.bind(exposure, views, assignment.id)
.run();
return Response.json({ submitted: true });
}
if (body.action === "submit_screenshot_result") {
if (task.task_type !== "screenshot_collect") {
return Response.json({ error: "当前任务不支持这种回填方式" }, { status: 400 });
}
const claimToken = textValue(body.claimToken, 80);
const distributionId = textValue(body.distributionId, 80);
const assignment = await findAccessibleAssignment(
task.id,
distributionId,
claimToken,
delegationToken,
);
if (!assignment) {
return Response.json({ error: "任务与领取凭证不匹配" }, { status: 403 });
}
if (!assignment.result_screenshot_key) {
return Response.json({ error: "请先上传任务截图" }, { status: 400 });
}
const statements: DatabaseStatement[] = [
db
.prepare(
`UPDATE distributions SET
result_submitted_at = CURRENT_TIMESTAMP,
status = 'complete',
updated_at = CURRENT_TIMESTAMP
WHERE id = ?`,
)
.bind(assignment.id),
];
if (!assignment.result_submitted_at) {
statements.push(
db
.prepare(
`UPDATE partners SET completed_total = completed_total + 1
WHERE id = ?`,
)
.bind(assignment.partner_id),
);
}
await db.batch(statements);
return Response.json({ submitted: true });
}
2026-07-30 12:06:41 +08:00
return Response.json({ error: "不支持的操作" }, { status: 400 });
} catch (error) {
return Response.json(
{ error: error instanceof Error ? error.message : "操作失败" },
{ status: 500 },
);
}
}
export async function GET(request: Request) {
const response = await handleGet(request);
response.headers.set("Cache-Control", "private, no-store");
response.headers.set("Referrer-Policy", "no-referrer");
return withPartnerCors(request, response);
}
export async function POST(request: Request) {
const response = await handlePost(request);
response.headers.set("Cache-Control", "private, no-store");
return withPartnerCors(request, response);
}
export async function OPTIONS(request: Request) {
return partnerOptions(request);
}