Files
koc-loop/app/api/partner/route.ts
ABAPPLO dd5b603149 feat: 项目总群自动建群+任务负责人后台视角+客户群活码进群+任务编辑
- 项目总群:≥3 人 appchat/create 自动建群(chatid=pgc+projectId),成员 diff 增删同步;DROP project_group_chats;推送改 appchat/send 群发、无群降级逐人
- 任务负责人:tasks.owner_userid(新建/编辑任务通讯录选人),task_set_owner 分配即预建 tgc 任务群发介绍,群主链接只归属负责人
- 任务编辑 task_update:基础字段+截图任务数量/关键词/说明,contents 调平只删 available 尾行
- 删除「一键推送」:task-one-click-push-service 整体移除(recordCardAck 移入 wecom-callback-events)
- 负责人后台视角:users.wecom_userid 绑定,lib/owner-scope 行级过滤(undefined=全量/""=空范围/非空=过滤),~30 action 补 managerGate/taskOwnerGate,owner 登录仅见工作台/任务中心/数据回收/复盘报表
- 任务绑定客户群+进群活码:tasks 增 customer_chat_id/name、join_config_id、join_qr_code 4 列,join_way add/update/get/del(换绑走 update 保 config_id 活码不变),后台「客户群」弹窗,portal 认领后扫码自助进群(满员自动开新群)
- 迁移 0013/0014/0015;催办支持按任务;企微可信域名 IP 验证文件
- 288 测试全绿;真机全链路验收通过(列群/活码四端点/portal 渲染/解绑清空)
2026-09-04 18:03:46 +08:00

1319 lines
42 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { getRuntimeEnv } from "../../../lib/runtime-env";
import { runInBackground } from "../../../lib/background";
import type { DatabaseStatement } from "../../../lib/database";
const env = getRuntimeEnv();
import { enrichDistributionAccount } from "../../../lib/account-enrichment-service";
import {
createCollectionRunTasks,
runDueScheduledCollections,
} from "../../../lib/collection-service";
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";
import {
resolveWecomClaimant,
verifyWecomIdentityTicket,
type WecomIdentity,
} from "../../../lib/wecom-oauth-service";
import {
isWecomFeedbackEnabled,
notifyPartnerSubmitted,
} from "../../../lib/wecom-feedback-service";
import {
resolveWecomConfig,
type WecomBindings,
} from "../../../lib/wecom-client";
import {
ensureTaskChatForMembers,
sendTaskChatMessage,
taskChatIntroText,
taskClaimBroadcastText,
} from "../../../lib/task-group-service";
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;
claimantName?: string;
identityTicket?: string;
owner?: string;
quantity?: number;
publishUrl?: string;
exposure?: number | string;
views?: number | string;
resultScreenshotKey?: string;
};
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;
};
async function findTask(taskToken: string) {
return getRawDb()
.prepare(
`SELECT id, name, brand, quantity, claimed_quantity, due_at, status,
task_type, platform, content_format
FROM tasks WHERE share_token = ?`,
)
.bind(taskToken)
.first<PartnerTask>();
}
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,
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 & {
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`;
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 = ?
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;
}>();
}
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;
}>();
}
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,
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,
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,
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();
const joinQrRow = await getRawDb()
.prepare("SELECT join_qr_code FROM tasks WHERE id = ?")
.bind(task.id)
.first<{ join_qr_code: string | null }>();
const joinQrCode = String(joinQrRow?.join_qr_code ?? "").trim();
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),
image_assets: undefined,
video_assets: undefined,
})),
delegations: delegations.results,
joinQrCode,
};
} 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,
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,
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" ? "转派截图任务包" : "转派发布包",
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),
image_assets: undefined,
video_assets: undefined,
})),
};
}
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,
}
: {
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,
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"
) {
return Response.json(
{ error: "分享链接只能用于查看和回填包内任务" },
{ status: 403 },
);
}
const db = getRawDb();
let wecomIdentity: WecomIdentity | null = null;
if (body.identityTicket) {
try {
wecomIdentity = await verifyWecomIdentityTicket(
textValue(body.identityTicket, 512),
);
} catch {
wecomIdentity = null;
}
}
if (body.action === "recover") {
const identifierInput = textValue(
body.claimantIdentifier ?? body.claimantName,
64,
);
const claimantIdentifier = parseClaimantIdentifier(identifierInput);
let wecomPartnerId: string | null = null;
if (wecomIdentity?.userId) {
const internalPartner = await db
.prepare(
`SELECT id FROM partners
WHERE wecom_userid = ? AND partner_type = 'group_owner'
LIMIT 1`,
)
.bind(wecomIdentity.userId.trim())
.first<{ id: string }>();
wecomPartnerId = internalPartner?.id ?? null;
} else if (wecomIdentity?.externalUserId) {
wecomPartnerId = (
await resolveWecomClaimant(db, wecomIdentity.externalUserId)
).partnerId;
}
if (!claimantIdentifier && identifierInput.length < 2 && !wecomPartnerId) {
return Response.json(
{ error: "请输入领取时使用的正确微信号或手机号" },
{ status: 400 },
);
}
const legacyPartnerId = `partner-ext-${hashText(
`${task.id}:${identifierInput.toLowerCase()}`,
)}`;
const partnerId = claimantIdentifier
? `partner-ext-${hashText(
`claimant:${claimantIdentifier.canonical}`,
)}`
: legacyPartnerId;
const partnerCandidates = [
...new Set(
wecomPartnerId ? [wecomPartnerId] : [partnerId, legacyPartnerId],
),
];
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,
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 (${partnerCandidates
.map(() => "?")
.join(", ")})
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, ...partnerCandidates)
.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: "没有找到领取记录,请确认微信号或手机号与领取时完全一致" },
{ 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" ? "领取的截图任务" : "领取的笔记"),
})),
});
}
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);
const quantity = quantityValue(body.quantity);
if (!claimantIdentifier && !wecomIdentity) {
return Response.json(
{ error: "请输入正确的微信号或手机号" },
{ status: 400 },
);
}
const internalUserid = wecomIdentity?.userId?.trim() ?? "";
const externalIdentity =
!internalUserid && wecomIdentity ? wecomIdentity : null;
let partnerId: string;
let claimantName: string;
if (internalUserid) {
const ownerPartner = await db
.prepare(
`SELECT id, name FROM partners
WHERE wecom_userid = ? AND partner_type = 'group_owner'
LIMIT 1`,
)
.bind(internalUserid)
.first<{ id: string; name: string }>();
if (!ownerPartner) {
return Response.json(
{ error: "未找到你的群主身份,请先联系运营在后台登记" },
{ status: 403 },
);
}
partnerId = ownerPartner.id;
claimantName = ownerPartner.name || internalUserid;
} else if (externalIdentity) {
partnerId = (
await resolveWecomClaimant(db, externalIdentity.externalUserId)
).partnerId;
claimantName = externalIdentity.displayName;
} else {
partnerId = `partner-ext-${hashText(
`claimant:${claimantIdentifier?.canonical}`,
)}`;
claimantName = claimantIdentifier?.display ?? "";
}
const ownerInput = textValue(body.owner, 64);
let ownerPartnerId = "";
if (ownerInput) {
const ownerRow = await db
.prepare(
`SELECT id FROM partners WHERE id = ? AND partner_type = 'group_owner'`,
)
.bind(ownerInput)
.first<{ id: string }>();
if (ownerRow) ownerPartnerId = ownerRow.id;
}
const claimId = uid("claim");
const claimToken = crypto.randomUUID().replaceAll("-", "");
const claimResult = await db.transaction(async (tx) => {
const available = await tx
.prepare(
`SELECT id FROM contents
WHERE task_id = ? AND status = 'available'
ORDER BY COALESCE(source_row, 999999), created_at, id
LIMIT ${quantity}
FOR UPDATE`,
)
.bind(task.id)
.all<{ id: string }>();
if (available.results.length === 0) return null;
const statements: DatabaseStatement[] = [
externalIdentity
? db
.prepare(
`INSERT INTO partners
(id, name, wecom_name, owner, claimed_total, completed_total,
wecom_external_user_id, partner_type)
VALUES (?, ?, ?, '外部KOC', 0, 0, ?, 'group_owner')
ON CONFLICT(id) DO UPDATE SET
name = excluded.name,
wecom_name = excluded.wecom_name,
wecom_external_user_id = excluded.wecom_external_user_id,
partner_type = 'group_owner'`,
)
.bind(
partnerId,
claimantName,
claimantName,
externalIdentity.externalUserId,
)
: 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, owner_partner_id, claimant_name, claim_token, quantity)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
)
.bind(
claimId,
task.id,
partnerId,
ownerPartnerId,
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),
);
for (const statement of statements) {
await tx.execute(statement.sql, statement.values());
}
return { claimToken, claimedCount: available.results.length };
});
if (!claimResult) {
return Response.json({ error: "当前任务已领完" }, { status: 409 });
}
runInBackground(
(async () => {
const wecomConfig = resolveWecomConfig(
env as unknown as WecomBindings,
);
const ensure = await ensureTaskChatForMembers(
db,
{
taskId: task.id,
...(internalUserid ? { addUserids: [internalUserid] } : {}),
},
wecomConfig,
);
if (!ensure.chatId) return;
if (ensure.created) {
await sendTaskChatMessage(
db,
task.id,
taskChatIntroText({
name: task.name,
brand: task.brand,
quantity: task.quantity,
dueAt: task.due_at,
}),
wecomConfig,
);
}
await sendTaskChatMessage(
db,
task.id,
taskClaimBroadcastText(
task.name,
claimantName,
claimResult.claimedCount,
),
wecomConfig,
);
})(),
`task-group-claim-${task.id}`,
);
return Response.json(claimResult);
}
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" ? "请至少选择一份待提交任务" : "请至少选择一篇待发布笔记" },
{ 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
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;
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,
)) {
return Response.json(
{ error: task.task_type === "screenshot_collect" ? "已提交的截图任务不能再次转派" : "已发布的笔记不能再次转派" },
{ 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[] = [
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
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
.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)`,
)
.bind(bundle.id)
.first<{ count: number }>();
if ((completed?.count ?? 0) > 0) {
return Response.json(
{ error: task.task_type === "screenshot_collect" ? "该分享包已有截图提交,不能撤销" : "该分享包已有笔记发布,需保留链接继续完成第7天数据回收" },
{ 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 });
}
const claimToken = textValue(body.claimToken, 80);
const distributionId = textValue(body.distributionId, 80);
const publishInput = textValue(body.publishUrl, 5000);
if (!publishInput) {
return Response.json(
{ error: "请填写发布链接" },
{ status: 400 },
);
}
const platform = task.platform === "抖音" ? "抖音" : "小红书";
const publishUrl = extractPublishUrl(publishInput, platform);
if (!publishUrl) {
return Response.json(
{ error: `请粘贴包含${platform}作品链接的分享内容` },
{ status: 400 },
);
}
const account = accountFromPublishLink(publishUrl, platform);
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 });
}
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,
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,
),
);
}
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),
);
}
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();
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",
);
}
}
const enrichment = enrichDistributionAccount(
db,
assignment.id,
publishUrl,
account.nickname,
resolveCollectionMcpConfig(
env as unknown as CollectionMcpBindings,
),
).catch(() => undefined);
runInBackground(enrichment, "distribution account enrichment");
if (isWecomFeedbackEnabled()) {
const feedback = notifyPartnerSubmitted(
db,
{ distributionId: assignment.id, kind: "publish" },
resolveWecomConfig(env as unknown as WecomBindings),
).catch(() => undefined);
runInBackground(feedback, "wecom publish feedback");
}
return Response.json({ ok: true });
}
if (body.action === "submit_creator_metrics") {
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 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);
if (isWecomFeedbackEnabled()) {
const feedback = notifyPartnerSubmitted(
db,
{ distributionId: assignment.id, kind: "screenshot" },
resolveWecomConfig(env as unknown as WecomBindings),
).catch(() => undefined);
runInBackground(feedback, "wecom screenshot feedback");
}
return Response.json({ submitted: true });
}
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);
}