Files
koc-loop/app/api/action/route.ts
ABAPPLO 87f4d4066d feat: KOC详情弹窗 + 任务中心单入口改版 + 资源池默认合作方/采集兜底
KOC 详情弹窗(合作方/数据回收/工作台三处入口):
- 新增 KocDetailModal:基本信息 / 绑定KOC账号画像 / 参与任务记录 / 履约漏斗
- 工作台最近分发、回收队列账号名、截图回收合作方名、合作方页名称、分发详情头部合作方均可点开
- 分发表行点击仍打开分发详情(stopPropagation 隔离)

任务中心单入口改版(对齐"单任务双阶段"模型):
- 任务卡发布/回收面板整块可点,全卡仅保留一个「查看明细」,回收面板重复按钮移除
- 详情页自述身份:动态 label(任务详情·分发与回收一体/仅分发)+ 阶段徽章(含回收·截止/未开通回收)
- 内容分发表新增阶段筛选 chips(全部/待发布/已发布待数据/待收截图,未开通回收不显示截图项),筛选落空给专用空态文案
- 数据回收页入口文案改为「选择一个任务进入回收队列」,全站不再区分发布/回收两种任务类型

资源池 KOC 默认加入合作方 + 采集兜底:
- partners 新增 released_account_id(mysql/0023):显式解绑/被顶替的账号不再被默认合作方同步回填
- 任务未设置采集计划时,默认按发布时间起连续采集 7 天,等待文案显示「等待D1 · 默认7天」
2026-10-12 11:11:31 +08:00

1354 lines
48 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";
const env = getRuntimeEnv();
import {
backfillAccountProfiles,
enrichDistributionAccount,
} from "../../../lib/account-enrichment-service";
import {
ensureSchema,
getDashboardData,
getRawDb,
uid,
} from "../../../lib/mvp-db";
import {
collectDistributionMetrics,
createCollectionRunTasks,
parseCollectionDays,
retryFailedCollections,
runDueScheduledCollections,
shanghaiDateFromTimestamp,
} from "../../../lib/collection-service";
import {
resolveCollectionMcpConfig,
type CollectionMcpBindings,
} from "../../../lib/mcp-collection-client";
import { adminForbidden, isAdminRequest } from "../../../lib/admin-auth";
import { refreshTaskStatus } from "../../../lib/task-service";
import {
FeishuSourceError,
readFeishuSource,
type FeishuBindings,
} from "../../../lib/feishu-client";
import {
createDistributionTask,
enableTaskRecovery,
updateTask,
} from "../../../lib/task-service";
import {
DistributionReleaseError,
releaseUnfinishedDistribution,
} from "../../../lib/distribution-release-service";
import {
resolveWecomConfig,
sendWecomAppMessage,
sendWecomRobotMessage,
listExternalContacts,
listInternalContacts,
WecomClientError,
type WecomBindings,
} from "../../../lib/wecom-client";
import {
runDueSoonWecomNotifications,
runOverdueAutoRelease,
runOverdueEscalation,
} from "../../../lib/wecom-notifier-service";
import {
listGroupChatRows,
listGroupPushes,
pushTaskToGroupChats,
syncGroupChats,
} from "../../../lib/wecom-group-push-service";
import {
bindTaskCustomerChat,
unbindTaskCustomerChat,
} from "../../../lib/task-customer-chat-service";
import { isManagerRequest, managerForbidden } from "../../../lib/user-auth";
import {
getOwnerScope,
isDistributionOwner,
isProjectOwner,
isTaskOwner,
isTaskProjectOwner,
ownerForbidden,
ownerScopeUserid,
projectForbidden,
} from "../../../lib/owner-scope";
import { extractPublishUrl } from "../../../lib/publish-url";
import {
getAccountFieldRefreshStatus,
startAccountFieldRefresh,
} from "../../../lib/account-field-refresh-service";
import {
deleteProject,
ensureProjectMainChat,
findOrCreateGroupOwnerPartner,
getProjectDetail,
listGroupOwnerPartners,
listProjects,
ProjectError,
saveGroupOwnerPartner,
saveProject,
} from "../../../lib/project-service";
import { assignTaskOwner } from "../../../lib/task-group-service";
import { pushProjectTask } from "../../../lib/project-push-service";
type ActionBody = {
action?: string;
[key: string]: unknown;
};
function numberValue(value: unknown, fallback = 0) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : fallback;
}
async function assignOwnerQuietly(taskId: string, ownerUserid: unknown) {
const owner = String(ownerUserid ?? "").trim();
if (!owner) return "";
try {
const assignment = await assignTaskOwner(
getRawDb(),
{ taskId, ownerUserid: owner },
resolveWecomConfig(env as unknown as WecomBindings),
);
return assignment.warning ?? "";
} catch (error) {
return error instanceof Error ? error.message : String(error);
}
}
type PartnerAccountHolderRow = {
id: string;
name: string;
partner_type: string | null;
wecom_external_user_id: string | null;
wecom_userid: string;
};
/**
* 占用方是否只是资源池默认同步生成的空壳合作方:
* 无企微绑定、无领取和分发记录。空壳可在换绑时直接让位。
*/
async function isShellAccountHolder(
db: ReturnType<typeof getRawDb>,
holder: PartnerAccountHolderRow,
) {
if ((holder.partner_type ?? "external_koc") !== "external_koc") return false;
if ((holder.wecom_external_user_id ?? "").trim() || holder.wecom_userid.trim()) {
return false;
}
const claim = await db
.prepare("SELECT 1 FROM claims WHERE partner_id = ? LIMIT 1")
.bind(holder.id)
.first();
if (claim) return false;
const distribution = await db
.prepare("SELECT 1 FROM distributions WHERE partner_id = ? LIMIT 1")
.bind(holder.id)
.first();
return !distribution;
}
export async function POST(request: Request) {
if (!(await isAdminRequest(request))) return adminForbidden();
try {
await ensureSchema();
const body = (await request.json()) as ActionBody;
const db = getRawDb();
let ownerWarning = "";
const scope = await getOwnerScope(request);
const managerGate = () =>
scope.kind === "owner" ? managerForbidden() : null;
const taskOwnerGate = async (taskId: unknown, options?: { strict?: boolean }) => {
if (scope.kind === "full") return null;
const id = String(taskId ?? "").trim();
return id &&
(await isTaskOwner(db, id, scope.wecomUserId, options))
? null
: ownerForbidden();
};
const distributionOwnerGate = async (distributionId: unknown) => {
if (scope.kind === "full") return null;
const id = String(distributionId ?? "").trim();
return id && (await isDistributionOwner(db, id, scope.wecomUserId))
? null
: ownerForbidden();
};
const projectOwnerGate = async (projectId: unknown) => {
if (scope.kind === "full") return null;
const id = String(projectId ?? "").trim();
return id && (await isProjectOwner(db, id, scope.wecomUserId))
? null
: projectForbidden();
};
const taskProjectOwnerGate = async (taskId: unknown) => {
if (scope.kind === "full") return null;
const id = String(taskId ?? "").trim();
return id && (await isTaskProjectOwner(db, id, scope.wecomUserId))
? null
: projectForbidden();
};
if (body.action === "inspect_feishu") {
const source = await readFeishuSource(
String(body.feishuUrl ?? "").trim(),
env as unknown as FeishuBindings,
);
return Response.json({
sheetId: source.sheetId,
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),
});
}
if (body.action === "create_task") {
const forbidden = await projectOwnerGate(body.projectId);
if (forbidden) return forbidden;
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: "请补全任务名称、品牌和截止日期" },
{ status: 400 },
);
}
const creation = await createDistributionTask(
{
feishuUrl: String(body.feishuUrl ?? "").trim(),
name,
brand,
dueAt,
platform,
contentFormat,
projectId: String(body.projectId ?? "").trim(),
ownerUserid: String(body.ownerUserid ?? "").trim(),
recoveryEnabled: body.recoveryEnabled !== false,
recoveryDueAt: String(body.recoveryDueAt ?? "").trim(),
recoveryInstructions: String(body.recoveryInstructions ?? "").trim(),
recoveryExampleImageKey: String(body.recoveryExampleImageKey ?? "").trim(),
},
env as unknown as FeishuBindings,
);
ownerWarning = await assignOwnerQuietly(creation.taskId, body.ownerUserid);
} else if (body.action === "task_enable_recovery") {
const taskId = String(body.taskId ?? "").trim();
if (!taskId) {
return Response.json({ error: "缺少 taskId" }, { status: 400 });
}
const taskRow = await db
.prepare("SELECT project_id FROM tasks WHERE id = ?")
.bind(taskId)
.first<{ project_id: string }>();
if (!taskRow) {
return Response.json({ error: "任务不存在" }, { status: 404 });
}
const forbidden = await projectOwnerGate(taskRow.project_id);
if (forbidden) return forbidden;
await enableTaskRecovery({
taskId,
recoveryDueAt: String(body.recoveryDueAt ?? "").trim(),
recoveryInstructions: String(body.recoveryInstructions ?? "").trim(),
recoveryExampleImageKey: String(body.recoveryExampleImageKey ?? "").trim(),
});
} else if (body.action === "claim") {
const forbidden = managerGate();
if (forbidden) return forbidden;
const partnerId = String(body.partnerId ?? "");
const taskId = String(body.taskId ?? "");
const quantity = Math.max(1, Math.min(50, numberValue(body.quantity, 1)));
const available = await db
.prepare(
`SELECT id FROM contents
WHERE task_id = ? AND status = 'available'
ORDER BY created_at, id
LIMIT ${quantity}`,
)
.bind(taskId)
.all<{ id: string }>();
if (available.results.length === 0) {
return Response.json({ error: "当前没有可领取内容" }, { status: 409 });
}
const statements = available.results.flatMap((content) => [
db
.prepare(
`INSERT INTO distributions
(id, task_id, content_id, partner_id, status)
VALUES (?, ?, ?, ?, 'claimed')`,
)
.bind(uid("dist"), taskId, content.id, partnerId),
db
.prepare("UPDATE contents SET status = 'allocated' WHERE id = ?")
.bind(content.id),
]);
statements.push(
db
.prepare(
`UPDATE tasks SET claimed_quantity = claimed_quantity + ?
WHERE id = ?`,
)
.bind(available.results.length, taskId),
db
.prepare(
`UPDATE partners SET claimed_total = claimed_total + ?
WHERE id = ?`,
)
.bind(available.results.length, partnerId),
);
await db.batch(statements);
} else if (body.action === "release_distribution") {
if (!(await isManagerRequest(request))) return adminForbidden();
await releaseUnfinishedDistribution(
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());
}
const collectionDays = parseCollectionDays(current.collection_days);
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,
"scheduled",
isScheduled
? `管理员已更新链接,等待${collectionDays.length}个采集日`
: "管理员已更新链接,默认发布后7天自动采集",
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,
);
}
// 未设置计划的任务同样追采:调度器会按默认 7 天(自发布日起)执行。
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 forbidden = managerGate();
if (forbidden) return forbidden;
const taskId = String(body.taskId ?? "").trim();
const startDate = String(body.startDate ?? "").trim();
const days = Array.isArray(body.days)
? [...new Set(body.days.map(Number))]
.filter(
(day) =>
Number.isInteger(day) && day >= 1 && day <= 7,
)
.sort((a, b) => a - b)
: [];
if (!taskId || !/^\d{4}-\d{2}-\d{2}$/.test(startDate)) {
return Response.json(
{ error: "请选择有效的开始采集日期" },
{ status: 400 },
);
}
if (days.length === 0) {
return Response.json(
{ error: "请至少选择一个自动采集日" },
{ status: 400 },
);
}
const task = await db
.prepare("SELECT id, task_type FROM tasks WHERE id = ?")
.bind(taskId)
.first<{ id: string; task_type?: string | null }>();
if (!task) {
return Response.json({ error: "任务不存在" }, { status: 404 });
}
if (task.task_type === "screenshot_collect") {
return Response.json(
{ error: "截图回收任务不需要设置数据采集计划" },
{ status: 400 },
);
}
await db.batch([
db
.prepare(
`UPDATE tasks
SET collection_start_date = ?,
collection_days = ?,
collection_schedule_updated_at = CURRENT_TIMESTAMP
WHERE id = ?`,
)
.bind(startDate, JSON.stringify(days), taskId),
db
.prepare(
`UPDATE distributions
SET collection_status = CASE
WHEN latest_likes IS NULL THEN 'scheduled'
ELSE collection_status
END,
collection_status_description = CASE
WHEN latest_likes IS NULL THEN ?
ELSE collection_status_description
END,
updated_at = CURRENT_TIMESTAMP
WHERE task_id = ?
AND publish_url IS NOT NULL
AND publish_url != ''`,
)
.bind(
`已安排${days.length}个采集日,每日09:00执行`,
taskId,
),
]);
await createCollectionRunTasks(db, taskId, startDate, days);
const catchup = runDueScheduledCollections(
db,
Date.now(),
resolveCollectionMcpConfig(
env as unknown as CollectionMcpBindings,
),
"catchup",
taskId,
).catch(() => undefined);
runInBackground(catchup, "collection catchup");
} else if (body.action === "run_due_collections") {
const forbidden = managerGate();
if (forbidden) return forbidden;
await runDueScheduledCollections(
db,
Date.now(),
resolveCollectionMcpConfig(
env as unknown as CollectionMcpBindings,
),
"catchup",
);
} else if (
body.action === "collect_now" ||
body.action === "collect"
) {
const forbidden = managerGate();
if (forbidden) return forbidden;
const distributionId = String(body.distributionId ?? "");
const rawDay =
body.day === null || body.day === undefined
? null
: numberValue(body.day);
const day =
rawDay !== null && Number.isInteger(rawDay) && rawDay >= 1 && rawDay <= 7
? rawDay
: null;
if (body.action === "collect" && day === null) {
return Response.json({ error: "采集周期无效" }, { status: 400 });
}
const distribution = await db
.prepare(
`SELECT t.task_type
FROM distributions d
JOIN tasks t ON t.id = d.task_id
WHERE d.id = ?`,
)
.bind(distributionId)
.first<{ task_type?: string | null }>();
if (!distribution) {
return Response.json({ error: "笔记记录不存在" }, { status: 404 });
}
if (distribution.task_type === "screenshot_collect") {
return Response.json(
{ error: "截图回收任务不支持公开数据采集" },
{ status: 400 },
);
}
await collectDistributionMetrics(
db,
distributionId,
shanghaiDateFromTimestamp(Date.now()),
day,
"manual",
resolveCollectionMcpConfig(
env as unknown as CollectionMcpBindings,
),
);
} else if (body.action === "retry_failed_collections") {
const forbidden = managerGate();
if (forbidden) return forbidden;
const taskId = String(body.taskId ?? "").trim();
if (!taskId) {
return Response.json({ error: "请选择需要补采的任务" }, { status: 400 });
}
const task = await db
.prepare("SELECT task_type FROM tasks WHERE id = ?")
.bind(taskId)
.first<{ task_type?: string | null }>();
if (!task) {
return Response.json({ error: "任务不存在" }, { status: 404 });
}
if (task.task_type === "screenshot_collect") {
return Response.json(
{ error: "截图回收任务没有需要补采的公开数据" },
{ status: 400 },
);
}
await retryFailedCollections(
db,
taskId,
resolveCollectionMcpConfig(
env as unknown as CollectionMcpBindings,
),
);
} else if (body.action === "backfill_account_profiles") {
const forbidden = managerGate();
if (forbidden) return forbidden;
const backfill = backfillAccountProfiles(
db,
resolveCollectionMcpConfig(
env as unknown as CollectionMcpBindings,
),
Math.max(1, Math.min(25, numberValue(body.limit, 10))),
).catch(() => undefined);
runInBackground(backfill, "account profile backfill");
} else if (body.action === "set_public_account_ids") {
const forbidden = managerGate();
if (forbidden) return forbidden;
const items = Array.isArray(body.items) ? body.items.slice(0, 50) : [];
const normalized = items
.map((item) => {
const record =
item && typeof item === "object"
? (item as Record<string, unknown>)
: {};
return {
accountId: String(record.accountId ?? "").trim().slice(0, 80),
publicAccountId: String(record.publicAccountId ?? "")
.trim()
.slice(0, 80),
};
})
.filter(
(item) =>
item.accountId &&
/^[\p{L}\p{N}._-]{2,80}$/u.test(item.publicAccountId),
);
if (normalized.length === 0) {
return Response.json(
{ error: "没有可回填的账号号值" },
{ status: 400 },
);
}
await db.batch(
normalized.map((item) =>
db
.prepare(
`UPDATE accounts
SET public_account_id = ?,
last_seen_at = CURRENT_TIMESTAMP
WHERE id = ?`,
)
.bind(item.publicAccountId, item.accountId),
),
);
} else if (body.action === "manual_metrics") {
const forbidden = await distributionOwnerGate(body.distributionId);
if (forbidden) return forbidden;
const distributionId = String(body.distributionId ?? "");
const hasCreatorMetrics =
body.exposure !== undefined &&
body.exposure !== null &&
body.views !== undefined &&
body.views !== null;
const exposure = hasCreatorMetrics
? Math.max(0, numberValue(body.exposure))
: null;
const views = hasCreatorMetrics ? Math.max(0, numberValue(body.views)) : null;
// 采集失败兜底:运营按 KOC 补交的数据截图核对补录公开互动数据
const hasPublicMetrics =
body.publicLikes !== undefined ||
body.publicCollects !== undefined ||
body.publicComments !== undefined;
const publicLikes = Math.max(0, numberValue(body.publicLikes));
const publicCollects = Math.max(0, numberValue(body.publicCollects));
const publicComments = Math.max(0, numberValue(body.publicComments));
await db
.prepare(
`UPDATE distributions SET
exposure = CASE WHEN ? THEN ? ELSE exposure END,
views = CASE WHEN ? THEN ? ELSE views END,
ocr_status = CASE WHEN ? THEN 'manual' ELSE ocr_status END,
status = CASE WHEN exposure IS NOT NULL AND views IS NOT NULL THEN 'complete' ELSE status END,
latest_likes = CASE WHEN ? THEN ? ELSE latest_likes END,
latest_collects = CASE WHEN ? THEN ? ELSE latest_collects END,
latest_comments = CASE WHEN ? THEN ? ELSE latest_comments END,
collection_status = CASE WHEN ? THEN 'success' ELSE collection_status END,
collection_status_description = CASE
WHEN ? THEN '运营已按补交截图核对补录'
ELSE collection_status_description
END,
collection_updated_at = CASE
WHEN ? THEN CURRENT_TIMESTAMP
ELSE collection_updated_at
END,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?`,
)
.bind(
hasCreatorMetrics ? 1 : 0,
exposure,
hasCreatorMetrics ? 1 : 0,
views,
hasCreatorMetrics ? 1 : 0,
hasPublicMetrics ? 1 : 0,
publicLikes,
hasPublicMetrics ? 1 : 0,
publicCollects,
hasPublicMetrics ? 1 : 0,
publicComments,
hasPublicMetrics ? 1 : 0,
hasPublicMetrics ? 1 : 0,
hasPublicMetrics ? 1 : 0,
distributionId,
)
.run();
const ownerTask = await db
.prepare("SELECT task_id FROM distributions WHERE id = ?")
.bind(distributionId)
.first<{ task_id: string }>();
if (ownerTask) {
await refreshTaskStatus(ownerTask.task_id, db).catch(() => undefined);
}
} else if (body.action === "bind_wecom_external_id") {
const forbidden = managerGate();
if (forbidden) return forbidden;
const partnerId = String(body.partnerId ?? "").trim().slice(0, 80);
const externalId = String(body.wecomExternalUserId ?? "")
.trim()
.slice(0, 128);
const wecomName = String(body.wecomName ?? "").trim().slice(0, 120);
if (!partnerId) {
return Response.json(
{ error: "缺少 partnerId" },
{ status: 400 },
);
}
if (wecomName) {
await db
.prepare(
"UPDATE partners SET wecom_external_user_id = ?, wecom_name = ? WHERE id = ?",
)
.bind(externalId || null, wecomName, partnerId)
.run();
} else {
await db
.prepare(
"UPDATE partners SET wecom_external_user_id = ? WHERE id = ?",
)
.bind(externalId || null, partnerId)
.run();
}
return Response.json({
partnerId,
wecomExternalUserId: externalId || null,
wecomName,
});
} else if (body.action === "partner_bind_account") {
const forbidden = managerGate();
if (forbidden) return forbidden;
const partnerId = String(body.partnerId ?? "").trim();
const accountId = String(body.accountId ?? "").trim();
if (!partnerId) {
return Response.json({ error: "缺少 partnerId" }, { status: 400 });
}
const partner = await db
.prepare("SELECT id, name, partner_type, account_id FROM partners WHERE id = ?")
.bind(partnerId)
.first<{ id: string; name: string; partner_type: string | null; account_id: string | null }>();
if (!partner) return Response.json({ error: "合作方不存在" }, { status: 404 });
if ((partner.partner_type ?? "external_koc") !== "external_koc") {
return Response.json({ error: "群主合作方不绑定 KOC 资源" }, { status: 400 });
}
if (!accountId) {
await db
.prepare(
"UPDATE partners SET account_id = '', released_account_id = ? WHERE id = ?",
)
.bind((partner.account_id ?? "").trim(), partnerId)
.run();
return Response.json({ partnerId, accountId: "" });
}
const account = await db
.prepare("SELECT id, nickname FROM accounts WHERE id = ?")
.bind(accountId)
.first<{ id: string; nickname: string }>();
if (!account) return Response.json({ error: "KOC 资源不存在" }, { status: 404 });
const holder = await db
.prepare(
`SELECT id, name, partner_type, wecom_external_user_id, wecom_userid
FROM partners WHERE account_id = ? AND id != ? LIMIT 1`,
)
.bind(accountId, partnerId)
.first<PartnerAccountHolderRow>();
let displacedShellName: string | null = null;
if (holder) {
if (!(await isShellAccountHolder(db, holder))) {
return Response.json(
{ error: `该 KOC 资源已绑定合作方「${holder.name}」,请先在对方条目上解绑` },
{ status: 400 },
);
}
displacedShellName = holder.name;
await db
.prepare(
"UPDATE partners SET account_id = '', released_account_id = ? WHERE id = ?",
)
.bind(accountId, holder.id)
.run();
}
await db
.prepare(
"UPDATE partners SET account_id = ?, released_account_id = '' WHERE id = ?",
)
.bind(accountId, partnerId)
.run();
return Response.json({
partnerId,
accountId,
nickname: account.nickname,
displacedShellName,
});
} else if (body.action === "partner_create_from_account") {
const forbidden = managerGate();
if (forbidden) return forbidden;
const accountId = String(body.accountId ?? "").trim();
if (!accountId) {
return Response.json({ error: "缺少 accountId" }, { status: 400 });
}
const account = await db
.prepare("SELECT id, nickname, current_contact FROM accounts WHERE id = ?")
.bind(accountId)
.first<{ id: string; nickname: string; current_contact: string }>();
if (!account) return Response.json({ error: "KOC 资源不存在" }, { status: 404 });
const holder = await db
.prepare("SELECT id, name FROM partners WHERE account_id = ? LIMIT 1")
.bind(accountId)
.first<{ id: string; name: string }>();
if (holder) {
return Response.json(
{ error: `该 KOC 已是合作方「${holder.name}」,无需重复创建` },
{ status: 400 },
);
}
const partnerId = `partner-koc-${crypto.randomUUID().slice(0, 12)}`;
await db
.prepare(
`INSERT INTO partners
(id, name, wecom_name, owner, claimed_total, completed_total,
partner_type, wecom_userid, account_id, created_at)
VALUES (?, ?, '', '运营组', 0, 0, 'external_koc', '', ?, ?)`,
)
.bind(partnerId, account.nickname, accountId, new Date().toISOString())
.run();
return Response.json({
partnerId,
accountId,
nickname: account.nickname,
contact: account.current_contact ?? "",
});
} else if (body.action === "send_test_wecom") {
const forbidden = managerGate();
if (forbidden) return forbidden;
const wecomConfig = resolveWecomConfig(
env as unknown as WecomBindings,
);
const partnerId = String(body.partnerId ?? "").trim();
let partnerExternalId: string | null = null;
if (partnerId) {
const row = await db
.prepare(
"SELECT wecom_external_user_id FROM partners WHERE id = ?",
)
.bind(partnerId)
.first<{ wecom_external_user_id: string | null }>();
partnerExternalId = row?.wecom_external_user_id ?? null;
}
const testContent = `[KOC LOOP 测试] 群机器人连通性正常,时间 ${new Date().toISOString()}`;
let robotStatus: "ok" | "skipped" = "skipped";
if (wecomConfig.robotWebhook) {
await sendWecomRobotMessage(testContent, wecomConfig);
robotStatus = "ok";
}
let appStatus: "ok" | "skipped" | "failed" = "skipped";
if (
partnerExternalId &&
wecomConfig.corpId &&
wecomConfig.agentId &&
wecomConfig.secret
) {
const result = await sendWecomAppMessage(
[partnerExternalId],
testContent,
wecomConfig,
);
appStatus = result.failed > 0 ? "failed" : "ok";
}
return Response.json({
robot: robotStatus,
app: appStatus,
});
} else if (body.action === "wecom_status") {
const wecomConfig = resolveWecomConfig(
env as unknown as WecomBindings,
);
return Response.json({
robotConfigured: Boolean(wecomConfig.robotWebhook),
appConfigured: Boolean(
wecomConfig.corpId && wecomConfig.agentId && wecomConfig.secret,
),
dueDays: wecomConfig.dueDays,
});
} else if (body.action === "trigger_wecom_due_soon") {
const taskId = String(body.taskId ?? "").trim();
const forbidden = await taskOwnerGate(taskId || undefined);
if (forbidden) return forbidden;
const wecomConfig = resolveWecomConfig(
env as unknown as WecomBindings,
);
const summary = await runDueSoonWecomNotifications(
db,
wecomConfig,
undefined,
undefined,
taskId || undefined,
);
return Response.json(summary);
} else if (body.action === "trigger_wecom_overdue_escalation") {
const forbidden = managerGate();
if (forbidden) return forbidden;
const wecomConfig = resolveWecomConfig(
env as unknown as WecomBindings,
);
const escalation = await runOverdueEscalation(db, wecomConfig);
const autoRelease = await runOverdueAutoRelease(db, wecomConfig);
return Response.json({ escalation, autoRelease });
} else if (body.action === "wecom_list_external_contacts") {
const forbidden = managerGate();
if (forbidden) return forbidden;
const wecomConfig = resolveWecomConfig(
env as unknown as WecomBindings,
);
const contacts = await listExternalContacts(wecomConfig);
return Response.json({ contacts });
} else if (body.action === "wecom_list_internal_contacts") {
const wecomConfig = resolveWecomConfig(
env as unknown as WecomBindings,
);
const contacts = await listInternalContacts(wecomConfig);
return Response.json({ contacts });
} else if (body.action === "wecom_sync_group_chats") {
const forbidden = managerGate();
if (forbidden) return forbidden;
const wecomConfig = resolveWecomConfig(
env as unknown as WecomBindings,
);
const groups = await syncGroupChats(db, wecomConfig);
return Response.json({ groups, syncedCount: groups.length });
} else if (body.action === "wecom_push_task_to_groups") {
const forbidden = await taskOwnerGate(body.taskId);
if (forbidden) return forbidden;
const wecomConfig = resolveWecomConfig(
env as unknown as WecomBindings,
);
const summary = await pushTaskToGroupChats(
db,
{
taskId: String(body.taskId ?? ""),
chatIds: body.chatIds,
text: body.text,
},
wecomConfig,
);
const failCount = summary.results.reduce(
(total, item) => total + item.failList.length,
0,
);
const groupCount = summary.results.reduce(
(total, item) => total + item.chatCount,
0,
);
return Response.json({
results: summary.results,
groupCount,
failCount,
hint: "群发任务已创建,群主需在企微客户端「群发助手」点击发送后,消息才会送达客户群",
});
} else if (body.action === "wecom_task_group_pushes") {
const taskId = String(body.taskId ?? "").trim();
const forbidden = await taskOwnerGate(taskId || undefined);
if (forbidden) return forbidden;
const pushes = await listGroupPushes(db, taskId || undefined);
return Response.json({ pushes });
} else if (body.action === "project_list") {
return Response.json({
projects: await listProjects(db, ownerScopeUserid(scope)),
});
} else if (body.action === "project_detail") {
const forbidden = await projectOwnerGate(body.projectId);
if (forbidden) return forbidden;
return Response.json(await getProjectDetail(db, String(body.projectId ?? "")));
} else if (body.action === "project_save") {
const projectId = String(body.id ?? "").trim();
// 项目负责人只能更新自己负责的项目,且归属 ownerUserid 强制保留原值
if (scope.kind === "owner" && !projectId) return managerForbidden();
const forbidden = await projectOwnerGate(projectId);
if (forbidden) return forbidden;
let nextOwnerUserid = body.ownerUserid;
if (scope.kind === "owner") {
const current = await db
.prepare("SELECT owner_userid FROM projects WHERE id = ?")
.bind(projectId)
.first<{ owner_userid: string }>();
nextOwnerUserid = current?.owner_userid ?? "";
}
const saved = await saveProject(
db,
{
id: body.id,
name: body.name,
brand: body.brand,
mainGroupMemberIds: body.mainGroupMemberIds,
ownerUserid: nextOwnerUserid,
note: body.note,
},
resolveWecomConfig(env as unknown as WecomBindings),
);
return Response.json(saved);
} else if (body.action === "project_delete") {
const forbidden = await projectOwnerGate(body.projectId);
if (forbidden) return forbidden;
await deleteProject(db, String(body.projectId ?? ""));
return Response.json({ ok: true });
} else if (body.action === "project_ensure_main_chat") {
const forbidden = await projectOwnerGate(body.projectId);
if (forbidden) return forbidden;
const result = await ensureProjectMainChat(
db,
String(body.projectId ?? ""),
resolveWecomConfig(env as unknown as WecomBindings),
);
return Response.json(result);
} else if (body.action === "task_set_owner") {
const forbidden = await taskProjectOwnerGate(body.taskId);
if (forbidden) return forbidden;
const taskId = String(body.taskId ?? "").trim();
const ownerUserid = String(body.ownerUserid ?? "").trim();
if (!taskId) {
return Response.json({ error: "缺少 taskId" }, { status: 400 });
}
if (!ownerUserid) {
return Response.json({ error: "请选择任务负责人" }, { status: 400 });
}
const existing = await db
.prepare("SELECT id FROM tasks WHERE id = ?")
.bind(taskId)
.first<{ id: string }>();
if (!existing) {
return Response.json({ error: "任务不存在" }, { status: 404 });
}
const assignment = await assignTaskOwner(
db,
{ taskId, ownerUserid },
resolveWecomConfig(env as unknown as WecomBindings),
);
const task = await db
.prepare("SELECT owner_userid, task_chat_id FROM tasks WHERE id = ?")
.bind(taskId)
.first<{ owner_userid: string; task_chat_id: string }>();
return Response.json({ ok: true, assignment, task });
} else if (body.action === "task_update") {
const forbidden = await taskProjectOwnerGate(body.taskId);
if (forbidden) return forbidden;
if (scope.kind === "owner") {
// 迁移目标也必须是本人负责的项目;置空(脱离项目)同样拒绝
const nextProjectId = String(body.projectId ?? "").trim();
const projectForbiddenResponse = nextProjectId
? await projectOwnerGate(nextProjectId)
: projectForbidden();
if (projectForbiddenResponse) return projectForbiddenResponse;
}
const platform = body.platform === "抖音" || body.platform === "小红书"
? (body.platform as "小红书" | "抖音")
: undefined;
const contentFormat = body.contentFormat === "video" || body.contentFormat === "image_text"
? (body.contentFormat as "image_text" | "video")
: undefined;
const update = await updateTask({
taskId: String(body.taskId ?? ""),
name: String(body.name ?? ""),
brand: String(body.brand ?? ""),
dueAt: String(body.dueAt ?? ""),
projectId: String(body.projectId ?? ""),
platform,
contentFormat,
recoveryEnabled:
body.recoveryEnabled === undefined
? undefined
: Boolean(body.recoveryEnabled),
recoveryDueAt: String(body.recoveryDueAt ?? ""),
recoveryInstructions: String(body.recoveryInstructions ?? ""),
recoveryExampleImageKey: String(body.recoveryExampleImageKey ?? ""),
});
ownerWarning = await assignOwnerQuietly(update.taskId, body.ownerUserid);
} else if (body.action === "partner_save") {
const forbidden = managerGate();
if (forbidden) return forbidden;
return Response.json({
partner: await saveGroupOwnerPartner(db, {
id: body.id,
name: body.name,
wecomUserid: body.wecomUserid,
}),
});
} else if (body.action === "partner_group_owners") {
const forbidden = managerGate();
if (forbidden) return forbidden;
return Response.json({ partners: await listGroupOwnerPartners(db) });
} else if (body.action === "project_push_task") {
const forbidden = await taskProjectOwnerGate(body.taskId);
if (forbidden) return forbidden;
const wecomConfig = resolveWecomConfig(env as unknown as WecomBindings);
const outcome = await pushProjectTask(
db,
{
taskId: body.taskId,
sendMainGroup: body.sendMainGroup,
sendGroupChats: body.sendGroupChats,
text: body.text,
},
wecomConfig,
);
const appFailed = outcome.appResults.filter((item) => !item.ok);
return Response.json({
...outcome,
appSent: outcome.appResults.length - appFailed.length,
appFailedCount: appFailed.length,
hint: "任务群消息已直接发送至任务内部群(负责人与领取人即时可见)",
});
} else if (body.action === "refresh_account_fields") {
const forbidden = managerGate();
if (forbidden) return forbidden;
const status = startAccountFieldRefresh(
db,
resolveCollectionMcpConfig(env as unknown as CollectionMcpBindings),
body.fields,
);
return Response.json({ refresh: status });
} else if (body.action === "account_field_refresh_status") {
const forbidden = managerGate();
if (forbidden) return forbidden;
return Response.json({ refresh: getAccountFieldRefreshStatus() });
} else if (body.action === "task_group_links") {
const taskId = String(body.taskId ?? "").trim();
const forbidden = await taskOwnerGate(taskId);
if (forbidden) return forbidden;
const task = await db
.prepare(
"SELECT id, name, share_token, task_chat_id, owner_userid FROM tasks WHERE id = ?",
)
.bind(taskId)
.first<{
id: string;
name: string;
share_token: string | null;
task_chat_id: string;
owner_userid: string;
}>();
if (!task) return Response.json({ error: "任务不存在" }, { status: 404 });
const portalUrl = String(env.KOC_PORTAL_URL ?? "").replace(/\/$/, "");
const ownerUserid = String(task.owner_userid ?? "").trim();
let owners: {
partnerId: string;
name: string;
wecomUserid: string;
isInternal: boolean;
link: string;
}[] = [];
if (task.share_token && ownerUserid) {
try {
const partner = await findOrCreateGroupOwnerPartner(db, ownerUserid);
owners = [
{
partnerId: partner.id,
name: partner.name,
wecomUserid: partner.wecom_userid,
isInternal: Boolean(partner.wecom_userid),
link: `${portalUrl}/?task=${task.share_token}&owner=${partner.id}`,
},
];
} catch {
owners = [];
}
}
return Response.json({
taskName: task.name,
taskChatId: task.task_chat_id ?? "",
claimUrl: task.share_token
? `${portalUrl}/?task=${task.share_token}`
: "",
owners,
});
} else if (body.action === "task_customer_chats") {
const taskId = String(body.taskId ?? "").trim();
const forbidden = await taskOwnerGate(taskId, { strict: true });
if (forbidden) return forbidden;
const task = await db
.prepare("SELECT id, customer_chat_id FROM tasks WHERE id = ?")
.bind(taskId)
.first<{ id: string; customer_chat_id: string }>();
if (!task) return Response.json({ error: "任务不存在" }, { status: 404 });
const wecomConfig = resolveWecomConfig(env as unknown as WecomBindings);
// 列表来自全量群缓存(所有群主的外部客户群),缓存为空或要求刷新时全量同步
let rows = body.sync === true ? [] : await listGroupChatRows(db);
if (rows.length === 0) {
rows = await syncGroupChats(db, wecomConfig);
}
return Response.json({
chats: rows.map((row) => ({
chatId: row.chat_id,
name: row.name,
ownerUserId: row.owner_user_id,
memberCount: row.member_count,
status: row.status,
})),
boundChatId: String(task.customer_chat_id ?? ""),
});
} else if (body.action === "task_bind_customer_chat") {
const taskId = String(body.taskId ?? "").trim();
const forbidden = await taskOwnerGate(taskId, { strict: true });
if (forbidden) return forbidden;
const task = await db
.prepare("SELECT id, customer_chat_id FROM tasks WHERE id = ?")
.bind(taskId)
.first<{ id: string; customer_chat_id: string }>();
if (!task) return Response.json({ error: "任务不存在" }, { status: 404 });
const chatId = String(body.chatId ?? "").trim();
const wecomConfig = resolveWecomConfig(env as unknown as WecomBindings);
if (chatId) {
const chatName = String(body.chatName ?? "").trim();
const result = await bindTaskCustomerChat(
db,
{ taskId, chatId, chatName },
wecomConfig,
);
return Response.json({ task: result });
}
await unbindTaskCustomerChat(db, taskId, wecomConfig);
return Response.json({
task: {
customerChatId: "",
customerChatName: "",
joinConfigId: "",
joinQrCode: "",
},
});
} else if (body.action === "task_group_push") {
const taskId = String(body.taskId ?? "").trim();
const task = await db
.prepare(
"SELECT id, name, share_token, owner_userid FROM tasks WHERE id = ?",
)
.bind(taskId)
.first<{
id: string;
name: string;
share_token: string | null;
owner_userid: string;
}>();
if (!task) return Response.json({ error: "任务不存在" }, { status: 404 });
const forbidden = await taskOwnerGate(taskId);
if (forbidden) return forbidden;
if (!task.share_token) {
return Response.json({ error: "任务缺少领取链接" }, { status: 400 });
}
const ownerUserid = String(task.owner_userid ?? "").trim();
if (!ownerUserid) {
return Response.json(
{ error: "该任务未分配负责人,请先在任务中心分配" },
{ status: 400 },
);
}
const owner = await findOrCreateGroupOwnerPartner(db, ownerUserid);
const portalUrl = String(env.KOC_PORTAL_URL ?? "").replace(/\/$/, "");
const wecomConfig = resolveWecomConfig(env as unknown as WecomBindings);
let sent = 0;
let failed = 0;
const failList: string[] = [];
const link = `${portalUrl}/?task=${task.share_token}&owner=${owner.id}`;
try {
const result = await sendWecomAppMessage(
[owner.wecom_userid],
`【任务派发】${task.name}\n请把领取链接转发给你的 KOC(链接已绑定你的群主身份):\n${link}`,
wecomConfig,
);
if (result.skipped) {
failed += 1;
failList.push(owner.name);
} else {
sent += result.sent;
failed += result.failed;
}
} catch {
failed += 1;
failList.push(owner.name);
}
return Response.json({ sent, failed, failList });
} else if (body.action === "wecom_group_chats") {
const groups = await listGroupChatRows(db);
return Response.json({ groups });
} else {
return Response.json({ error: "不支持的操作" }, { status: 400 });
}
const dashboard = await getDashboardData(ownerScopeUserid(scope));
return Response.json(
ownerWarning ? { ...dashboard, ownerWarning } : dashboard,
);
} catch (error) {
return Response.json(
{ error: error instanceof Error ? error.message : "操作失败" },
{
status:
error instanceof FeishuSourceError ||
error instanceof DistributionReleaseError ||
error instanceof WecomClientError ||
error instanceof ProjectError
? error.status
: 500,
},
);
}
}