diff --git a/app/api/partner/route.ts b/app/api/partner/route.ts index 936864d..eb914fa 100644 --- a/app/api/partner/route.ts +++ b/app/api/partner/route.ts @@ -28,6 +28,14 @@ import { verifyWecomIdentityTicket, type WecomIdentity, } from "../../../lib/wecom-oauth-service"; +import { + isWecomFeedbackEnabled, + notifyPartnerSubmitted, +} from "../../../lib/wecom-feedback-service"; +import { + resolveWecomConfig, + type WecomBindings, +} from "../../../lib/wecom-client"; import { partnerOptions, withPartnerCors, @@ -1063,6 +1071,14 @@ async function handlePost(request: Request) { ), ).catch(() => undefined); runInBackground(enrichment, "distribution account enrichment"); + if (isWecomFeedbackEnabled()) { + const feedback = notifyPartnerSubmitted( + db, + { distributionId: assignment.id, kind: "publish" }, + resolveWecomConfig(env as unknown as WecomBindings), + ).catch(() => undefined); + runInBackground(feedback, "wecom publish feedback"); + } return Response.json({ ok: true }); } @@ -1156,6 +1172,14 @@ async function handlePost(request: Request) { ); } await db.batch(statements); + if (isWecomFeedbackEnabled()) { + const feedback = notifyPartnerSubmitted( + db, + { distributionId: assignment.id, kind: "screenshot" }, + resolveWecomConfig(env as unknown as WecomBindings), + ).catch(() => undefined); + runInBackground(feedback, "wecom screenshot feedback"); + } return Response.json({ submitted: true }); } diff --git a/lib/runtime-env.ts b/lib/runtime-env.ts index d2f20f3..d08c2ff 100644 --- a/lib/runtime-env.ts +++ b/lib/runtime-env.ts @@ -27,6 +27,7 @@ export type RuntimeEnv = { WECOM_CALLBACK_TOKEN?: string; WECOM_CALLBACK_ENCODING_AES_KEY?: string; WECOM_CALLBACK_ENABLED?: string; + WECOM_FEEDBACK_ENABLED?: string; SEED_DEMO_DATA?: string; ENABLE_SCHEDULER?: string; }; diff --git a/lib/scheduler.ts b/lib/scheduler.ts index 2ee6686..5719f71 100644 --- a/lib/scheduler.ts +++ b/lib/scheduler.ts @@ -13,6 +13,10 @@ import { type WecomBindings, } from "./wecom-client"; import { runDueSoonWecomNotifications } from "./wecom-notifier-service"; +import { + isWecomFeedbackEnabled, + notifyCollectionFinished, +} from "./wecom-feedback-service"; declare global { var __kocLoopScheduler: ScheduledTask | undefined; @@ -28,19 +32,25 @@ async function runDailyJob() { ); const collections = await runScheduledCollections(db, Date.now(), config); const accounts = await backfillAccountProfiles(db, config, 10); + const wecomConfig = resolveWecomConfig(env as unknown as WecomBindings); let wecom: Awaited> | null = null; - if (isEnabled(env.WECOM_NOTIFY_ENABLED, true)) { - const wecomConfig = resolveWecomConfig(env as unknown as WecomBindings); - if ( - wecomConfig.robotWebhook || - (wecomConfig.corpId && wecomConfig.agentId && wecomConfig.secret) - ) { - try { - wecom = await runDueSoonWecomNotifications(db, wecomConfig); - } catch (error) { - console.error("[KOC LOOP] wecom notify failed", error); - } + if ( + isEnabled(env.WECOM_NOTIFY_ENABLED, true) && + (wecomConfig.robotWebhook || + (wecomConfig.corpId && wecomConfig.agentId && wecomConfig.secret)) + ) { + try { + wecom = await runDueSoonWecomNotifications(db, wecomConfig); + } catch (error) { + console.error("[KOC LOOP] wecom notify failed", error); + } + } + if (isWecomFeedbackEnabled()) { + try { + await notifyCollectionFinished(collections, wecomConfig); + } catch (error) { + console.error("[KOC LOOP] wecom collection feedback failed", error); } } console.info("[KOC LOOP] daily scheduler completed", { diff --git a/lib/wecom-feedback-service.ts b/lib/wecom-feedback-service.ts new file mode 100644 index 0000000..aadf401 --- /dev/null +++ b/lib/wecom-feedback-service.ts @@ -0,0 +1,107 @@ +import type { DatabaseClient } from "./database"; +import { getRuntimeEnv, isEnabled } from "./runtime-env"; +import { sendWecomRobotMessage, type WecomConfig } from "./wecom-client"; + +type FetchLike = typeof fetch; + +export function isWecomFeedbackEnabled() { + return isEnabled(getRuntimeEnv().WECOM_FEEDBACK_ENABLED, false); +} + +type SubmittedRow = { + claimant_name: string | null; + partner_name: string | null; + task_name: string; + content_title: string | null; + publish_url: string | null; +}; + +function truncate(value: string, max: number) { + return value.length > max ? `${value.slice(0, max)}…` : value; +} + +export function buildPublishSubmittedMessage( + row: SubmittedRow, + kind: "publish" | "screenshot", +): string { + const partner = row.partner_name || row.claimant_name || "未知合作方"; + if (kind === "screenshot") { + return [ + "【KOC 已提交截图】", + `合作方:${partner}`, + `任务:${truncate(row.task_name, 24)}`, + `内容:${truncate(row.content_title ?? "", 24)}`, + "请到后台「数据回收」页查看与审核。", + ].join("\n"); + } + return [ + "【KOC 已提交发布】", + `合作方:${partner}`, + `任务:${truncate(row.task_name, 24)}`, + `内容:${truncate(row.content_title ?? "", 24)}`, + `链接:${row.publish_url ?? ""}`, + "链接已入库,采集计划将按日自动执行。", + ].join("\n"); +} + +export async function notifyPartnerSubmitted( + db: DatabaseClient, + params: { distributionId: string; kind: "publish" | "screenshot" }, + config: WecomConfig, + fetchImpl: FetchLike = fetch, +): Promise { + if (!config.robotWebhook) return false; + const distributionId = String(params.distributionId ?? "").trim(); + if (!distributionId) return false; + const row = await db + .prepare( + `SELECT d.claimant_name, d.publish_url, t.name AS task_name, + c.title AS content_title, p.name AS partner_name + FROM distributions d + JOIN tasks t ON t.id = d.task_id + LEFT JOIN contents c ON c.id = d.content_id + LEFT JOIN partners p ON p.id = d.partner_id + WHERE d.id = ?`, + ) + .bind(distributionId) + .first(); + if (!row) return false; + const message = buildPublishSubmittedMessage(row, params.kind); + await sendWecomRobotMessage(message, config, fetchImpl); + return true; +} + +export function buildCollectionFinishedMessage(summary: { + currentDate: string; + dueTasks: number; + attempted: number; + succeeded: number; + failed: number; +}): string { + const lines = [ + `【数据采集日报】${summary.currentDate}`, + `应采集任务 ${summary.dueTasks} 个,执行 ${summary.attempted} 条:成功 ${summary.succeeded}、失败 ${summary.failed}。`, + ]; + if (summary.failed > 0) { + lines.push("有采集失败,请到后台「数据回收」页查看并重试。"); + } + return lines.join("\n"); +} + +export async function notifyCollectionFinished( + summary: { + currentDate: string; + dueTasks: number; + attempted: number; + succeeded: number; + failed: number; + }, + config: WecomConfig, + fetchImpl: FetchLike = fetch, +): Promise { + if (!config.robotWebhook) return false; + if (summary.attempted <= 0 && summary.failed <= 0) return false; + const message = buildCollectionFinishedMessage(summary); + await sendWecomRobotMessage(message, config, fetchImpl); + return true; +} diff --git a/tests/wecom-feedback.test.mjs b/tests/wecom-feedback.test.mjs new file mode 100644 index 0000000..d291044 --- /dev/null +++ b/tests/wecom-feedback.test.mjs @@ -0,0 +1,123 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + buildCollectionFinishedMessage, + buildPublishSubmittedMessage, + notifyCollectionFinished, + notifyPartnerSubmitted, +} from "../lib/wecom-feedback-service.ts"; +import { resolveWecomConfig } from "../lib/wecom-client.ts"; + +const config = resolveWecomConfig({ + WECOM_CORP_ID: "corp-test", + WECOM_AGENT_ID: "1000001", + WECOM_SECRET: "secret-test", + WECOM_ROBOT_WEBHOOK: "https://qyapi.example.com/webhook/send?key=abc", +}); + +const submittedRow = { + claimant_name: "小王", + partner_name: "合作方甲", + task_name: "夏日轻盈计划", + content_title: "好物分享笔记", + publish_url: "https://www.xiaohongshu.com/explore/abc", +}; + +function robotFetch(calls) { + return async (input, init = {}) => { + const url = new URL(String(input)); + calls.push({ url, init }); + return Response.json({ errcode: 0, errmsg: "ok" }); + }; +} + +function fakeSubmittedDb(row = submittedRow) { + return { + prepare(sql) { + return { + bind() { + return { + async first() { + if (/FROM distributions d/.test(sql)) return row; + return null; + }, + }; + }, + }; + }, + }; +} + +test("publish submitted message covers partner, task and link", () => { + const message = buildPublishSubmittedMessage(submittedRow, "publish"); + assert.match(message, /【KOC 已提交发布】/); + assert.match(message, /合作方:合作方甲/); + assert.match(message, /夏日轻盈计划/); + assert.match(message, /xiaohongshu\.com\/explore\/abc/); + const screenshot = buildPublishSubmittedMessage(submittedRow, "screenshot"); + assert.match(screenshot, /【KOC 已提交截图】/); + assert.doesNotMatch(screenshot, /链接:/); +}); + +test("collection digest includes failure hint only when failed", () => { + const ok = buildCollectionFinishedMessage({ + currentDate: "2026-09-01", + dueTasks: 3, + attempted: 10, + succeeded: 10, + failed: 0, + }); + assert.match(ok, /成功 10、失败 0/); + assert.doesNotMatch(ok, /重试/); + const bad = buildCollectionFinishedMessage({ + currentDate: "2026-09-01", + dueTasks: 3, + attempted: 10, + succeeded: 7, + failed: 3, + }); + assert.match(bad, /重试/); +}); + +test("notifyPartnerSubmitted posts robot message and skips without webhook", async () => { + const calls = []; + const sent = await notifyPartnerSubmitted( + fakeSubmittedDb(), + { distributionId: "d-1", kind: "publish" }, + config, + robotFetch(calls), + ); + assert.equal(sent, true); + assert.equal(calls.length, 1); + const body = JSON.parse(String(calls[0].init.body)); + assert.equal(body.msgtype, "text"); + assert.match(body.text.content, /【KOC 已提交发布】/); + + const noWebhook = await notifyPartnerSubmitted( + fakeSubmittedDb(), + { distributionId: "d-1", kind: "publish" }, + resolveWecomConfig({}), + robotFetch([]), + ); + assert.equal(noWebhook, false); +}); + +test("notifyCollectionFinished skips empty batches and posts on activity", async () => { + const calls = []; + const idle = await notifyCollectionFinished( + { currentDate: "2026-09-01", dueTasks: 0, attempted: 0, succeeded: 0, failed: 0 }, + config, + robotFetch(calls), + ); + assert.equal(idle, false); + assert.equal(calls.length, 0); + + const active = await notifyCollectionFinished( + { currentDate: "2026-09-01", dueTasks: 2, attempted: 5, succeeded: 4, failed: 1 }, + config, + robotFetch(calls), + ); + assert.equal(active, true); + assert.equal(calls.length, 1); + assert.match(JSON.parse(String(calls[0].init.body)).text.content, /成功 4、失败 1/); +});