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, 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) : {}; 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(); 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, }, ); } }