import type { DatabaseClient } from "./database"; import { getRuntimeEnv } from "./runtime-env"; import { sendWecomAppMessage, type WecomConfig, } from "./wecom-client"; import { pushProjectTask, type ProjectPushOutcome } from "./project-push-service"; import type { TaskPushRow } from "./wecom-group-push-service"; type FetchLike = typeof fetch; export class OneClickPushError extends Error { status: number; constructor(message: string, status = 400) { super(message); this.name = "OneClickPushError"; this.status = status; } } export type BoundWecomPartner = { id: string; name: string; wecomExternalUserId: string; }; export type KocPushResult = { partnerId: string; partnerName: string; externalUserId: string; ok: boolean; error: string; }; export type TaskPushLogRow = { id: string; task_id: string; project_id: string; push_kind: string; main_group_count: number; koc_userids: string; koc_ok_count: number; koc_fail_list: string; group_push_id: string; detail: string; created_at: string; }; export type OneClickPushOutcome = { linkUrl: string; projectOutcome: ProjectPushOutcome | null; kocResults: KocPushResult[]; kocSent: number; kocFailed: number; skippedReasons: string[]; logId: string; }; function text(value: unknown) { return String(value ?? "").trim(); } export function buildKocDirectMessage( task: Pick< TaskPushRow, | "name" | "brand" | "quantity" | "due_at" | "task_type" | "platform" | "content_format" | "share_token" >, portalUrl: string, ): string { const portal = text(portalUrl).replace(/\/$/, ""); const claimUrl = portal && task.share_token ? `${portal}/?task=${task.share_token}` : ""; const formatLabel = task.task_type === "screenshot_collect" ? "截图回收" : task.content_format === "video" ? "视频" : "图文"; return [ `【新任务】${task.name}`, `品牌:${task.brand}|数量:${task.quantity} 份|截止:${task.due_at}`, `平台:${task.platform}|形式:${formatLabel}`, "数量有限,先到先得,点击链接直接领取(企微内打开免输身份):", claimUrl || "领取链接生成失败,请联系管理员", ].join("\n"); } export async function listBoundWecomPartners( db: DatabaseClient, ): Promise { const result = await db .prepare( `SELECT id, name, wecom_external_user_id FROM partners WHERE wecom_external_user_id IS NOT NULL AND wecom_external_user_id != '' ORDER BY name ASC`, ) .all<{ id: string; name: string; wecom_external_user_id: string; }>(); return result.results.map((row) => ({ id: row.id, name: row.name, wecomExternalUserId: row.wecom_external_user_id, })); } export async function findTodayTaskPushLog( db: DatabaseClient, taskId: string, pushKind = "task_open", ): Promise { const result = await db .prepare( `SELECT id, task_id, project_id, push_kind, main_group_count, koc_userids, koc_ok_count, koc_fail_list, group_push_id, detail, created_at FROM wecom_push_logs WHERE task_id = ? AND push_kind = ? AND DATE(created_at) = CURDATE() ORDER BY created_at DESC LIMIT 1`, ) .bind(taskId, pushKind) .first(); return result ?? null; } export async function listTaskPushLogs( db: DatabaseClient, taskId: string, ): Promise { const filter = taskId ? "WHERE task_id = ?" : ""; const statement = db .prepare( `SELECT id, task_id, project_id, push_kind, main_group_count, koc_userids, koc_ok_count, koc_fail_list, group_push_id, detail, created_at FROM wecom_push_logs ${filter} ORDER BY created_at DESC LIMIT 50`, ) .bind(...(taskId ? [taskId] : [])); const result = await statement.all(); return result.results; } export async function recordCardAck( db: DatabaseClient, params: { cardTaskId: string; btnKey: string; operator: string }, ) { const taskId = /^task:([^:]+):/.exec(params.cardTaskId)?.[1] ?? ""; await db .prepare( `INSERT INTO wecom_push_logs (id, task_id, project_id, push_kind, main_group_count, koc_userids, koc_ok_count, koc_fail_list, group_push_id, detail) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, ) .bind( `wpl-${crypto.randomUUID().slice(0, 12)}`, taskId || params.cardTaskId, "", "card_ack", 0, "[]", 0, "[]", "", JSON.stringify({ cardTaskId: params.cardTaskId, btnKey: params.btnKey, operator: params.operator, }), ) .run(); } export async function oneClickPushTask( db: DatabaseClient, params: { taskId: unknown; sendMainGroup?: unknown; sendKoc?: unknown; createGroupTemplate?: unknown; selectedKocPartnerIds?: unknown; force?: unknown; text?: unknown; }, config: WecomConfig, fetchImpl: FetchLike = fetch, ): Promise { const taskId = text(params.taskId); if (!taskId) throw new OneClickPushError("缺少 taskId"); const sendMainGroup = params.sendMainGroup !== false; const sendKoc = params.sendKoc !== false; const createGroupTemplate = params.createGroupTemplate === true; if (!sendMainGroup && !sendKoc && !createGroupTemplate) { throw new OneClickPushError("请至少选择一个推送目标"); } const task = await db .prepare( `SELECT id, name, brand, quantity, due_at, task_type, platform, content_format, share_token, project_id FROM tasks WHERE id = ?`, ) .bind(taskId) .first(); if (!task) throw new OneClickPushError("任务不存在", 404); if (!text(task.share_token)) { throw new OneClickPushError("任务尚未生成领取链接,请先开放领取", 400); } if (params.force !== true) { const existing = await findTodayTaskPushLog(db, taskId, "task_open"); if (existing) { throw new OneClickPushError( "今天已推送过该任务;如确需再次推送,请勾选「仍要推送」", 409, ); } } const skippedReasons: string[] = []; const wantsProjectTargets = sendMainGroup || createGroupTemplate; let projectOutcome: ProjectPushOutcome | null = null; if (wantsProjectTargets) { if (!text(task.project_id)) { skippedReasons.push("任务未挂项目,已跳过项目总群与任务群"); } else { projectOutcome = await pushProjectTask( db, { taskId, sendMainGroup, sendGroupChats: createGroupTemplate, text: params.text, }, config, fetchImpl, ); skippedReasons.push(...projectOutcome.skippedReasons); } } const portalUrl = text(getRuntimeEnv().KOC_PORTAL_URL); const linkUrl = task.share_token ? `${portalUrl.replace(/\/$/, "")}/?task=${task.share_token}` : ""; const kocResults: KocPushResult[] = []; if (sendKoc) { const bound = await listBoundWecomPartners(db); const selected = Array.isArray(params.selectedKocPartnerIds) ? params.selectedKocPartnerIds.map((id) => text(id)).filter(Boolean) : null; const targets = selected ? bound.filter((partner) => selected.includes(partner.id)) : bound; if (targets.length === 0) { skippedReasons.push("没有可直达的合作方(未勾选或均未绑定企微身份)"); } else { const message = buildKocDirectMessage(task, portalUrl); for (const partner of targets) { try { const sent = await sendWecomAppMessage( [partner.wecomExternalUserId], message, config, fetchImpl, ); const failed = sent.sent === 0; kocResults.push({ partnerId: partner.id, partnerName: partner.name, externalUserId: partner.wecomExternalUserId, ok: !failed, error: failed ? "企业微信未送达(invaliduser)" : "", }); } catch (error) { kocResults.push({ partnerId: partner.id, partnerName: partner.name, externalUserId: partner.wecomExternalUserId, ok: false, error: error instanceof Error ? error.message : "发送失败", }); } } } } const kocSent = kocResults.filter((item) => item.ok).length; const kocFailed = kocResults.length - kocSent; const logId = `wpl-${crypto.randomUUID().slice(0, 12)}`; const groupPushId = projectOutcome?.groupResults.map((item) => item.msgid).join(",") ?? ""; await db .prepare( `INSERT INTO wecom_push_logs (id, task_id, project_id, push_kind, main_group_count, koc_userids, koc_ok_count, koc_fail_list, group_push_id, detail) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, ) .bind( logId, taskId, text(task.project_id), "task_open", projectOutcome ? projectOutcome.appResults.length : 0, JSON.stringify( kocResults.map((item) => ({ partnerId: item.partnerId, name: item.partnerName, externalUserId: item.externalUserId, ok: item.ok, error: item.error, })), ), kocSent, JSON.stringify(kocResults.filter((item) => !item.ok)), groupPushId, JSON.stringify({ skippedReasons, createGroupTemplate, linkUrl, }), ) .run(); return { linkUrl, projectOutcome, kocResults, kocSent, kocFailed, skippedReasons, logId, }; }