diff --git a/app/admin-app.tsx b/app/admin-app.tsx index 28afe3c..cd2c4b5 100644 --- a/app/admin-app.tsx +++ b/app/admin-app.tsx @@ -533,6 +533,7 @@ export default function Home({ currentUser }: { currentUser: AuthUser }) { const [menuOpen, setMenuOpen] = useState(false); const [taskModalOpen, setTaskModalOpen] = useState(false); const [groupPushTask, setGroupPushTask] = useState(null); + const [onePushTask, setOnePushTask] = useState(null); const [metricModal, setMetricModal] = useState(null); const [taskForm, setTaskForm] = useState({ taskType: "content_publish" as "content_publish" | "screenshot_collect", @@ -1181,6 +1182,7 @@ export default function Home({ currentUser }: { currentUser: AuthUser }) { onCreate={() => setTaskModalOpen(true)} onCopyShare={copyTaskShareLink} onPushToGroups={(task) => setGroupPushTask(task)} + onOneClickPush={(task) => setOnePushTask(task)} /> )} {activeNav === "distributions" && ( @@ -1518,6 +1520,14 @@ export default function Home({ currentUser }: { currentUser: AuthUser }) { /> )} + {onePushTask && ( + setOnePushTask(null)} + setToast={setToast} + /> + )} + {toast &&
✓{toast}
} {working &&
}
@@ -3224,6 +3234,320 @@ function ProjectPushModal({ ); } +type BoundWecomPartnerRow = { + id: string; + name: string; + wecomExternalUserId: string; +}; + +type TaskPushLogRecord = { + id: string; + task_id: string; + push_kind: string; + main_group_count: number; + koc_ok_count: number; + detail: string; + created_at: string; +}; + +type OneClickPushOutcomeView = { + linkUrl: string; + kocSent: number; + kocFailed: number; + kocResults: Array<{ partnerId: string; partnerName: string; ok: boolean; error: string }>; + skippedReasons: string[]; + hint?: string; +}; + +const PUSH_KIND_LABELS: Record = { + task_open: "任务推送", + card_ack: "卡片确认", +}; + +function formatPushLogTime(value: string) { + const date = new Date(value); + if (Number.isNaN(date.getTime())) return value; + return `${formatDate(date.toISOString().slice(0, 10))} ${date.toTimeString().slice(0, 5)}`; +} + +function OneClickPushModal({ + task, + onClose, + setToast, +}: { + task: Task; + onClose: () => void; + setToast: (message: string) => void; +}) { + const [partners, setPartners] = useState([]); + const [loading, setLoading] = useState(true); + const [search, setSearch] = useState(""); + const [selected, setSelected] = useState([]); + const [sendMainGroup, setSendMainGroup] = useState(true); + const [createGroupTemplate, setCreateGroupTemplate] = useState(true); + const [sendKoc, setSendKoc] = useState(true); + const [force, setForce] = useState(false); + const [pushedToday, setPushedToday] = useState(false); + const [sending, setSending] = useState(false); + const [result, setResult] = useState(null); + const [logs, setLogs] = useState([]); + + const postAction = useCallback(async (payload: Record, fallback: string) => { + const response = await fetch("/api/action", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + const parsed = await readApiResponse(response, fallback); + if (!response.ok) throw new Error(parsed.error || fallback); + return parsed; + }, []); + + const loadLogs = useCallback(async () => { + try { + const parsed = await postAction<{ logs: TaskPushLogRecord[] }>( + { action: "task_push_logs", taskId: task.id }, + "读取推送记录失败", + ); + setLogs(parsed.logs); + } catch { + // 推送记录加载失败不阻断弹窗 + } + }, [postAction, task.id]); + + useEffect(() => { + const timer = window.setTimeout(() => { + void (async () => { + try { + const parsed = await postAction<{ + partners: BoundWecomPartnerRow[]; + pushedToday: unknown; + }>({ action: "wecom_bound_partners", taskId: task.id }, "读取合作方失败"); + setPartners(parsed.partners); + setSelected(parsed.partners.map((partner) => partner.id)); + setPushedToday(Boolean(parsed.pushedToday)); + } catch (reason) { + setToast(reason instanceof Error ? reason.message : "读取合作方失败"); + } finally { + setLoading(false); + } + })(); + void loadLogs(); + }, 0); + return () => window.clearTimeout(timer); + }, [postAction, loadLogs, task.id, setToast]); + + const filtered = partners.filter((partner) => { + const keyword = search.trim().toLowerCase(); + if (!keyword) return true; + return ( + partner.name.toLowerCase().includes(keyword) || + partner.wecomExternalUserId.toLowerCase().includes(keyword) + ); + }); + const allSelected = partners.length > 0 && selected.length === partners.length; + + const togglePartner = (partnerId: string) => { + setSelected((previous) => + previous.includes(partnerId) + ? previous.filter((id) => id !== partnerId) + : [...previous, partnerId], + ); + }; + + const submitPush = async () => { + if (!sendMainGroup && !sendKoc && !createGroupTemplate) { + setToast("请至少选择一个推送目标"); + return; + } + if (sendKoc && selected.length === 0) { + setToast("请至少勾选一位合作方,或取消勾选「KOC 个人直达」"); + return; + } + setSending(true); + try { + const parsed = await postAction( + { + action: "task_one_click_push", + taskId: task.id, + sendMainGroup, + sendKoc, + createGroupTemplate, + selectedKocPartnerIds: selected, + force, + }, + "一键推送失败", + ); + setResult(parsed); + setPushedToday(true); + setToast("推送已执行"); + await loadLogs(); + } catch (reason) { + const message = reason instanceof Error ? reason.message : "一键推送失败"; + if (message.includes("仍要推送")) setPushedToday(true); + setToast(message); + } finally { + setSending(false); + } + }; + + return ( +
+
event.stopPropagation()}> +
+
+

企业微信 · 一键推送

+

推送「{task.name}」

+
+ +
+ {pushedToday && ( + + )} +
+ + + +
+ {sendKoc && ( +
+ {loading ? ( + 正在读取已绑定合作方… + ) : partners.length === 0 ? ( + 还没有已绑定企微身份的合作方;合作方在企微内打开领取链接领取后会自动绑定。 + ) : ( + <> +
+ setSearch(event.target.value)} + placeholder="搜索合作方名称 / 企微 ID" + disabled={sending} + /> + +
+
+ {filtered.map((partner) => ( + + ))} + {filtered.length === 0 && 没有匹配的合作方} +
+ + )} +
+ )} + {result && ( +
+

+ KOC 直达:成功 {result.kocSent} 人、失败 {result.kocFailed} 人。 + {result.skippedReasons.length > 0 ? `(${result.skippedReasons.join(";")})` : ""} + {result.hint ? ` ${result.hint}。` : ""} +

+ {result.kocResults.some((item) => !item.ok) && ( +
    + {result.kocResults.filter((item) => !item.ok).map((item) => ( +
  • + {item.partnerName}:{item.error || "发送失败"} +
  • + ))} +
+ )} +
+ )} + {logs.length > 0 && ( +
+ 推送记录 +
    + {logs.slice(0, 5).map((log) => ( +
  • + {formatPushLogTime(log.created_at)} · {PUSH_KIND_LABELS[log.push_kind] ?? log.push_kind} + {log.push_kind === "task_open" + ? `(总群 ${log.main_group_count} 人,KOC 直达 ${log.koc_ok_count} 人)` + : ""} +
  • + ))} +
+
+ )} +
+ + +
+
+
+ ); +} + function LoadingState() { return (
@@ -3388,12 +3712,14 @@ function TasksPage({ onCreate, onCopyShare, onPushToGroups, + onOneClickPush, }: { tasks: Task[]; portalUrl: string; onCreate: () => void; onCopyShare: (task: Task) => void; onPushToGroups: (task: Task) => void; + onOneClickPush: (task: Task) => void; }) { return (
@@ -3447,6 +3773,13 @@ function TasksPage({ > 发布到企微群 + {portalUrl && task.share_token && ( [index("wecom_group_pushes_task_idx").on(table.taskId)], ); +export const wecomPushLogs = mysqlTable( + "wecom_push_logs", + { + id: varchar("id", { length: 64 }).primaryKey(), + taskId: varchar("task_id", { length: 64 }).notNull(), + projectId: varchar("project_id", { length: 64 }).notNull().default(""), + pushKind: varchar("push_kind", { length: 32 }).notNull().default("task_open"), + mainGroupCount: int("main_group_count").notNull().default(0), + kocUserids: text("koc_userids").notNull(), + kocOkCount: int("koc_ok_count").notNull().default(0), + kocFailList: text("koc_fail_list").notNull(), + groupPushId: varchar("group_push_id", { length: 128 }).notNull().default(""), + detail: text("detail").notNull(), + createdAt: timestamp("created_at"), + }, + (table) => [index("wecom_push_logs_task_idx").on(table.taskId, table.pushKind)], +); + export const projects = mysqlTable("projects", { id: varchar("id", { length: 64 }).primaryKey(), name: varchar("name", { length: 255 }).notNull(), diff --git a/lib/task-one-click-push-service.ts b/lib/task-one-click-push-service.ts new file mode 100644 index 0000000..eac4513 --- /dev/null +++ b/lib/task-one-click-push-service.ts @@ -0,0 +1,355 @@ +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, + }; +} diff --git a/lib/wecom-callback-events.ts b/lib/wecom-callback-events.ts index 58a9c84..968dca9 100644 --- a/lib/wecom-callback-events.ts +++ b/lib/wecom-callback-events.ts @@ -1,3 +1,6 @@ +import type { DatabaseClient } from "./database"; +import { recordCardAck } from "./task-one-click-push-service"; + export type WecomCallbackFields = Record; export type WecomCallbackEventResult = { @@ -27,9 +30,10 @@ export function extractWecomEncryptField(body: string): string { return fields.Encrypt ?? ""; } -export function handleWecomCallbackEvent( +export async function handleWecomCallbackEvent( + db: DatabaseClient, fields: WecomCallbackFields, -): WecomCallbackEventResult { +): Promise { const event = fields.Event ?? ""; const taskId = fields.TaskId ?? ""; const btnKey = fields.BtnKey ?? ""; @@ -37,10 +41,8 @@ export function handleWecomCallbackEvent( if (fields.MsgType === "event" && event === "template_card_event") { if (btnKey === "mark_handled") { - console.info( - "[KOC LOOP] wecom card ack", - { taskId, operator }, - ); + await recordCardAck(db, { cardTaskId: taskId, btnKey, operator }); + console.info("[KOC LOOP] wecom card ack recorded", { taskId, operator }); return { recognized: true, action: "card_ack", taskId, btnKey, operator }; } return { recognized: true, action: "ignored", taskId, btnKey, operator }; diff --git a/mysql/0011_wecom_push_logs.sql b/mysql/0011_wecom_push_logs.sql new file mode 100644 index 0000000..c2c8769 --- /dev/null +++ b/mysql/0011_wecom_push_logs.sql @@ -0,0 +1,15 @@ +CREATE TABLE IF NOT EXISTS `wecom_push_logs` ( + `id` VARCHAR(64) NOT NULL, + `task_id` VARCHAR(64) NOT NULL, + `project_id` VARCHAR(64) NOT NULL DEFAULT '', + `push_kind` VARCHAR(32) NOT NULL DEFAULT 'task_open', + `main_group_count` INT NOT NULL DEFAULT 0, + `koc_userids` TEXT NOT NULL, + `koc_ok_count` INT NOT NULL DEFAULT 0, + `koc_fail_list` TEXT NOT NULL, + `group_push_id` VARCHAR(128) NOT NULL DEFAULT '', + `detail` TEXT NOT NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + KEY `wecom_push_logs_task_idx` (`task_id`,`push_kind`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; diff --git a/tests/task-one-click-push.test.mjs b/tests/task-one-click-push.test.mjs new file mode 100644 index 0000000..443d416 --- /dev/null +++ b/tests/task-one-click-push.test.mjs @@ -0,0 +1,250 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + buildKocDirectMessage, + oneClickPushTask, + OneClickPushError, +} from "../lib/task-one-click-push-service.ts"; +import { + clearWecomAccessTokenCacheForTests, + resolveWecomConfig, +} from "../lib/wecom-client.ts"; + +const baseBindings = { + WECOM_CORP_ID: "corp-test", + WECOM_AGENT_ID: "1000001", + WECOM_SECRET: "secret-test", +}; + +const taskRow = { + id: "task-1", + name: "测试任务", + brand: "品牌A", + quantity: 3, + due_at: "2026-09-05", + task_type: "content_publish", + platform: "小红书", + content_format: "image_text", + share_token: "tok-123", + project_id: "", +}; + +const boundPartners = [ + { id: "p-1", name: "合作方甲", wecom_external_user_id: "wmOK1" }, + { id: "p-2", name: "合作方乙", wecom_external_user_id: "wmOK2" }, + { id: "p-3", name: "合作方丙", wecom_external_user_id: "wmFAIL" }, +]; + +function fakePushDb({ + task = taskRow, + partners = boundPartners, + todayLog = null, +} = {}) { + const executed = []; + const makeStatement = (sql, params = []) => ({ + sql, + params, + bind(...args) { + return makeStatement(sql, args); + }, + async run() { + executed.push({ sql, params }); + return { meta: { changes: 1 } }; + }, + async first() { + executed.push({ sql, params }); + if (/FROM tasks WHERE id = \?/.test(sql)) return task; + if (/FROM wecom_push_logs/.test(sql) && /DATE\(created_at\) = CURDATE\(\)/.test(sql)) { + return todayLog; + } + return null; + }, + async all() { + executed.push({ sql, params }); + if (/FROM partners/.test(sql) && /wecom_external_user_id/.test(sql)) { + return { results: partners }; + } + return { results: [] }; + }, + }); + return { + executed, + prepare(sql) { + return makeStatement(sql); + }, + }; +} + +function fakeWecomFetch(calls, { invalidUsers = [] } = {}) { + return async (input, init = {}) => { + const url = new URL(String(input)); + calls.push({ url, init }); + if (url.pathname.endsWith("/cgi-bin/gettoken")) { + return Response.json({ + errcode: 0, + errmsg: "ok", + access_token: "token-test", + expires_in: 7200, + }); + } + if (url.pathname.endsWith("/cgi-bin/message/send")) { + const body = JSON.parse(String(init.body)); + if (invalidUsers.includes(body.touser)) { + return Response.json({ + errcode: 0, + errmsg: "ok", + invaliduser: body.touser, + }); + } + return Response.json({ errcode: 0, errmsg: "ok" }); + } + return new Response("not found", { status: 404 }); + }; +} + +test.beforeEach(() => { + clearWecomAccessTokenCacheForTests(); +}); + +test("buildKocDirectMessage targets KOC with claim url and OAuth hint", () => { + const message = buildKocDirectMessage(taskRow, "https://portal.example.com/"); + assert.match(message, /【新任务】测试任务/); + assert.match(message, /先到先得/); + assert.match(message, /免输身份/); + assert.match(message, /https:\/\/portal\.example\.com\/\?task=tok-123/); +}); + +test("oneClickPushTask sends only to selected partners and logs the push", async () => { + process.env.KOC_PORTAL_URL = "https://portal.example.com"; + try { + const db = fakePushDb(); + const calls = []; + const outcome = await oneClickPushTask( + db, + { + taskId: "task-1", + sendMainGroup: false, + sendKoc: true, + createGroupTemplate: false, + selectedKocPartnerIds: ["p-1", "p-3"], + }, + resolveWecomConfig(baseBindings), + fakeWecomFetch(calls, { invalidUsers: ["wmFAIL"] }), + ); + const appCalls = calls.filter((call) => + call.url.pathname.endsWith("/cgi-bin/message/send"), + ); + assert.deepEqual( + appCalls.map((call) => JSON.parse(String(call.init.body)).touser), + ["wmOK1", "wmFAIL"], + ); + for (const call of appCalls) { + assert.match(JSON.parse(String(call.init.body)).text.content, /【新任务】测试任务/); + } + assert.equal(outcome.kocSent, 1); + assert.equal(outcome.kocFailed, 1); + assert.equal(outcome.kocResults[1].partnerName, "合作方丙"); + assert.match(outcome.kocResults[1].error, /invaliduser/); + assert.equal(outcome.linkUrl, "https://portal.example.com/?task=tok-123"); + + const insert = db.executed.find((item) => /INSERT INTO wecom_push_logs/.test(item.sql)); + assert.ok(insert, "push log should be written"); + assert.equal(insert.params[1], "task-1"); + assert.equal(insert.params[3], "task_open"); + assert.equal(insert.params[6], 1); + const kocUserids = JSON.parse(insert.params[5]); + assert.equal(kocUserids.length, 2); + assert.equal(kocUserids[0].externalUserId, "wmOK1"); + } finally { + delete process.env.KOC_PORTAL_URL; + } +}); + +test("oneClickPushTask blocks duplicate same-day push unless forced", async () => { + process.env.KOC_PORTAL_URL = "https://portal.example.com"; + try { + const db = fakePushDb({ + todayLog: { id: "wpl-old", task_id: "task-1", push_kind: "task_open" }, + }); + const calls = []; + await assert.rejects( + () => + oneClickPushTask( + db, + { taskId: "task-1", sendMainGroup: false, sendKoc: true }, + resolveWecomConfig(baseBindings), + fakeWecomFetch(calls), + ), + (error) => + error instanceof OneClickPushError && /仍要推送/.test(error.message) && error.status === 409, + ); + assert.equal(calls.length, 0, "no wecom api calls on duplicate block"); + + const outcome = await oneClickPushTask( + db, + { + taskId: "task-1", + sendMainGroup: false, + sendKoc: true, + selectedKocPartnerIds: ["p-2"], + force: true, + }, + resolveWecomConfig(baseBindings), + fakeWecomFetch(calls), + ); + assert.equal(outcome.kocSent, 1); + } finally { + delete process.env.KOC_PORTAL_URL; + } +}); + +test("oneClickPushTask without project skips project targets and still reaches KOC", async () => { + process.env.KOC_PORTAL_URL = "https://portal.example.com"; + try { + const db = fakePushDb({ task: { ...taskRow, project_id: "" } }); + const calls = []; + const outcome = await oneClickPushTask( + db, + { taskId: "task-1", sendMainGroup: true, sendKoc: true }, + resolveWecomConfig(baseBindings), + fakeWecomFetch(calls), + ); + assert.ok( + outcome.skippedReasons.some((reason) => /未挂项目/.test(reason)), + "should explain project skip", + ); + assert.equal(outcome.projectOutcome, null); + assert.equal(outcome.kocSent, 3); + assert.equal( + calls.filter((call) => call.url.pathname.includes("/cgi-bin/externalcontact/")).length, + 0, + "no group template calls expected", + ); + } finally { + delete process.env.KOC_PORTAL_URL; + } +}); + +test("oneClickPushTask requires share token and at least one target", async () => { + await assert.rejects( + () => + oneClickPushTask( + fakePushDb({ task: { ...taskRow, share_token: "" } }), + { taskId: "task-1", sendMainGroup: false, sendKoc: true }, + resolveWecomConfig(baseBindings), + fakeWecomFetch([]), + ), + (error) => error instanceof OneClickPushError && /领取链接/.test(error.message), + ); + + await assert.rejects( + () => + oneClickPushTask( + fakePushDb(), + { taskId: "task-1", sendMainGroup: false, sendKoc: false, createGroupTemplate: false }, + resolveWecomConfig(baseBindings), + fakeWecomFetch([]), + ), + (error) => error instanceof OneClickPushError && /至少选择一个推送目标/.test(error.message), + ); +}); diff --git a/tests/wecom-callback-crypto.test.mjs b/tests/wecom-callback-crypto.test.mjs index ed31073..4574812 100644 --- a/tests/wecom-callback-crypto.test.mjs +++ b/tests/wecom-callback-crypto.test.mjs @@ -84,8 +84,23 @@ test("parse callback xml handles CDATA and plain fields", () => { assert.equal(extractWecomEncryptField(body), "RW5jcnlwdGVkQmFzZTY0PT0="); }); -test("event dispatcher classifies template_card mark_handled and ignores others", () => { - const ack = handleWecomCallbackEvent({ +test("event dispatcher classifies template_card mark_handled and ignores others", async () => { + const inserts = []; + const fakeDb = { + prepare(sql) { + return { + bind(...params) { + return { + async run() { + inserts.push({ sql, params }); + return { success: true }; + }, + }; + }, + }; + }, + }; + const ack = await handleWecomCallbackEvent(fakeDb, { MsgType: "event", Event: "template_card_event", TaskId: "task:t123:due", @@ -96,8 +111,12 @@ test("event dispatcher classifies template_card mark_handled and ignores others" assert.equal(ack.action, "card_ack"); assert.equal(ack.taskId, "task:t123:due"); assert.equal(ack.operator, "opsUser01"); + assert.equal(inserts.length, 1); + assert.match(inserts[0].sql, /INSERT INTO wecom_push_logs/); + assert.equal(inserts[0].params[0].startsWith("wpl-"), true); + assert.equal(inserts[0].params[1], "t123"); - const view = handleWecomCallbackEvent({ + const view = await handleWecomCallbackEvent(fakeDb, { MsgType: "event", Event: "template_card_event", TaskId: "task:t123:due", @@ -106,7 +125,7 @@ test("event dispatcher classifies template_card mark_handled and ignores others" assert.equal(view.recognized, true); assert.equal(view.action, "ignored"); - const other = handleWecomCallbackEvent({ + const other = await handleWecomCallbackEvent(fakeDb, { MsgType: "event", Event: "change_contact", });