feat: 数据回流通知(提交/采集结果推运营群,默认关闭)

This commit is contained in:
ABAPPLO
2026-09-01 17:34:14 +08:00
parent d01281fba9
commit bca524417e
5 changed files with 276 additions and 11 deletions

View File

@@ -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 });
}

View File

@@ -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;
};

View File

@@ -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<ReturnType<typeof runDueSoonWecomNotifications>> | 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", {

View File

@@ -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<boolean> {
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<SubmittedRow>();
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<boolean> {
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;
}

View File

@@ -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/);
});