From 3e4269729c72486cad0a59247526b30159512de3 Mon Sep 17 00:00:00 2001 From: ABAPPLO <1134226402@qq.com> Date: Tue, 1 Sep 2026 17:37:35 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=82=AC=E5=8A=9E=E5=88=86=E5=B1=82?= =?UTF-8?q?=EF=BC=88=E8=B6=85=E6=9C=9F=E5=8D=87=E7=BA=A7+=E4=BA=A4?= =?UTF-8?q?=E4=BA=92=E5=8D=A1=E7=89=87=EF=BC=89=E4=B8=8E=E8=B6=85=E6=9C=9F?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E9=87=8A=E6=94=BE=E5=BC=80=E5=85=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/admin-app.tsx | 45 +++++- app/api/action/route.ts | 14 +- lib/runtime-env.ts | 2 + lib/scheduler.ts | 24 ++- lib/wecom-client.ts | 81 ++++++++++ lib/wecom-notifier-service.ts | 223 ++++++++++++++++++++++++++ tests/wecom-escalation.test.mjs | 268 ++++++++++++++++++++++++++++++++ 7 files changed, 654 insertions(+), 3 deletions(-) create mode 100644 tests/wecom-escalation.test.mjs diff --git a/app/admin-app.tsx b/app/admin-app.tsx index cd2c4b5..8e1ec6a 100644 --- a/app/admin-app.tsx +++ b/app/admin-app.tsx @@ -1775,6 +1775,41 @@ function PartnersPage({ } }; + const triggerOverdueEscalation = async () => { + try { + setPartnerWorkingId("escalation"); + const response = await fetch("/api/action", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ action: "trigger_wecom_overdue_escalation" }), + }); + const result = await readApiResponse<{ + escalation?: { + overdueTasks?: number; + overdueDistributions?: number; + robotSent?: boolean; + cardSent?: number; + cardFailed?: number; + }; + autoRelease?: { enabled?: boolean; released?: number; blocked?: number; failed?: number }; + error?: string; + }>(response, "触发超期升级失败"); + if (!response.ok) throw new Error(result.error || "触发超期升级失败"); + const esc = result.escalation ?? {}; + const release = result.autoRelease ?? {}; + const releaseText = release.enabled + ? `;自动释放 ${release.released ?? 0} 条(跳过 ${release.blocked ?? 0},失败 ${release.failed ?? 0})` + : ";自动释放未开启(WECOM_AUTO_RELEASE_DAYS)"; + setToast( + `超期升级完成:${esc.overdueTasks ?? 0} 个任务 / ${esc.overdueDistributions ?? 0} 条超期,卡片已发 ${esc.cardSent ?? 0} 人${releaseText}`, + ); + } catch (reason) { + setToast(reason instanceof Error ? reason.message : "触发超期升级失败"); + } finally { + setPartnerWorkingId(null); + } + }; + const testAppMessage = async (partner: Partner) => { if (!partner.wecom_external_user_id) { setToast("请先保存企业微信外部联系人 ID"); @@ -1940,8 +1975,16 @@ function PartnersPage({ > {partnerWorkingId === "due-soon" ? "执行中…" : "立即跑一次催办"} +

- 立即跑一次 daily 09:00 的催办链路:按 partner 聚合发应用消息,再向群机器人发当日汇总。 + 立即跑一次 daily 09:00 的催办链路:按 partner 聚合发应用消息,再向群机器人发当日汇总。超期升级会向运营群推送超期清单,并向任务所属项目总群成员发催办卡片。

{(!status?.robotConfigured || !status?.appConfigured) && (

diff --git a/app/api/action/route.ts b/app/api/action/route.ts index 6ea1b59..ee893fb 100644 --- a/app/api/action/route.ts +++ b/app/api/action/route.ts @@ -45,7 +45,11 @@ import { WecomClientError, type WecomBindings, } from "../../../lib/wecom-client"; -import { runDueSoonWecomNotifications } from "../../../lib/wecom-notifier-service"; +import { + runDueSoonWecomNotifications, + runOverdueAutoRelease, + runOverdueEscalation, +} from "../../../lib/wecom-notifier-service"; import { listGroupChatRows, listGroupPushes, @@ -664,6 +668,14 @@ export async function POST(request: Request) { wecomConfig, ); return Response.json(summary); + } else if (body.action === "trigger_wecom_overdue_escalation") { + if (!isManagerRequest(request)) return adminForbidden(); + 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 wecomConfig = resolveWecomConfig( env as unknown as WecomBindings, diff --git a/lib/runtime-env.ts b/lib/runtime-env.ts index d08c2ff..d4ad73f 100644 --- a/lib/runtime-env.ts +++ b/lib/runtime-env.ts @@ -28,6 +28,8 @@ export type RuntimeEnv = { WECOM_CALLBACK_ENCODING_AES_KEY?: string; WECOM_CALLBACK_ENABLED?: string; WECOM_FEEDBACK_ENABLED?: string; + WECOM_ESCALATION_ENABLED?: string; + WECOM_AUTO_RELEASE_DAYS?: string; SEED_DEMO_DATA?: string; ENABLE_SCHEDULER?: string; }; diff --git a/lib/scheduler.ts b/lib/scheduler.ts index 5719f71..13c927d 100644 --- a/lib/scheduler.ts +++ b/lib/scheduler.ts @@ -12,7 +12,11 @@ import { resolveWecomConfig, type WecomBindings, } from "./wecom-client"; -import { runDueSoonWecomNotifications } from "./wecom-notifier-service"; +import { + runDueSoonWecomNotifications, + runOverdueAutoRelease, + runOverdueEscalation, +} from "./wecom-notifier-service"; import { isWecomFeedbackEnabled, notifyCollectionFinished, @@ -35,6 +39,10 @@ async function runDailyJob() { const wecomConfig = resolveWecomConfig(env as unknown as WecomBindings); let wecom: Awaited> | null = null; + let escalation: Awaited> | null = + null; + let autoRelease: Awaited> | null = + null; if ( isEnabled(env.WECOM_NOTIFY_ENABLED, true) && (wecomConfig.robotWebhook || @@ -46,6 +54,18 @@ async function runDailyJob() { console.error("[KOC LOOP] wecom notify failed", error); } } + if (isEnabled(env.WECOM_ESCALATION_ENABLED, false)) { + try { + escalation = await runOverdueEscalation(db, wecomConfig); + } catch (error) { + console.error("[KOC LOOP] wecom overdue escalation failed", error); + } + } + try { + autoRelease = await runOverdueAutoRelease(db, wecomConfig); + } catch (error) { + console.error("[KOC LOOP] wecom overdue auto release failed", error); + } if (isWecomFeedbackEnabled()) { try { await notifyCollectionFinished(collections, wecomConfig); @@ -57,6 +77,8 @@ async function runDailyJob() { collections, accounts, wecom, + escalation, + autoRelease, }); }); } diff --git a/lib/wecom-client.ts b/lib/wecom-client.ts index f91c63b..6d49184 100644 --- a/lib/wecom-client.ts +++ b/lib/wecom-client.ts @@ -244,6 +244,87 @@ export function clearWecomAccessTokenCacheForTests() { cachedAccessToken = null; } +export type WecomTemplateCardButton = { + key: string; + name: string; + url?: string; +}; + +export async function sendWecomTemplateCard( + config: WecomConfig, + params: { + touser: string[]; + taskId: string; + title: string; + description: string; + url: string; + buttons: WecomTemplateCardButton[]; + }, + fetchImpl: FetchLike = fetch, +): Promise<{ sent: number; failed: number; skipped: boolean }> { + const normalized = params.touser + .map((id) => bindingValue(id)) + .filter((id) => id.length > 0); + if (normalized.length === 0) { + return { sent: 0, failed: 0, skipped: true }; + } + if (!hasAppCredentials(config)) { + return { sent: 0, failed: 0, skipped: true }; + } + const taskId = bindingValue(params.taskId); + if (!taskId) { + throw new WecomClientError("模板卡片缺少 task_id(回调识别用)", 400); + } + const token = await fetchAccessToken(config, fetchImpl); + const url = `${WECOM_API_ORIGIN}/cgi-bin/message/send?access_token=${encodeURIComponent(token)}`; + const response = await fetchImpl(url, { + method: "POST", + headers: { "Content-Type": "application/json; charset=utf-8" }, + body: JSON.stringify({ + touser: normalized.join("|"), + msgtype: "template_card", + agentid: Number(config.agentId), + template_card: { + card_type: "text_notice", + source: { + desc: "KOC LOOP", + desc_color: 0, + }, + main_title: { + title: safeMessage(params.title), + desc: safeMessage(params.description).slice(0, 120), + }, + card_action: { + type: 1, + url: bindingValue(params.url), + }, + button_list: params.buttons.map((button) => ({ + type: button.url ? 1 : 0, + text: safeMessage(button.name), + key: bindingValue(button.key), + ...(button.url ? { url: button.url } : {}), + })), + task_id: taskId, + }, + }), + signal: AbortSignal.timeout(12_000), + }); + const payload = await readEnvelope(response, "企业微信模板卡片推送失败"); + if (!response.ok) { + throw new WecomClientError( + `企业微信模板卡片推送失败(HTTP ${response.status})`, + 502, + ); + } + ensureOk(payload, "企业微信模板卡片推送失败"); + const invalid = bindingValue(payload.invaliduser).split("|").filter(Boolean); + return { + sent: Math.max(0, normalized.length - invalid.length), + failed: invalid.length, + skipped: false, + }; +} + export type WecomGroupChat = { chatId: string; name: string; diff --git a/lib/wecom-notifier-service.ts b/lib/wecom-notifier-service.ts index 8707c37..5dbaf57 100644 --- a/lib/wecom-notifier-service.ts +++ b/lib/wecom-notifier-service.ts @@ -3,9 +3,11 @@ import { shanghaiDateFromTimestamp } from "./collection-service"; import { sendWecomAppMessage, sendWecomRobotMessage, + sendWecomTemplateCard, type WecomConfig, } from "./wecom-client"; import { getRuntimeEnv } from "./runtime-env"; +import { releaseUnfinishedDistribution } from "./distribution-release-service"; type FetchLike = typeof fetch; @@ -162,6 +164,227 @@ function bindingValue(value: unknown) { return String(value ?? "").trim(); } +export function resolveAutoReleaseDays(value: string | undefined) { + const parsed = Number(String(value ?? "").trim()); + if (!Number.isFinite(parsed) || parsed < 1) return 0; + return Math.min(90, Math.floor(parsed)); +} + +type OverdueRow = { + id: string; + task_id: string; + task_name: string; + due_at: string; + project_id: string; + partner_id: string; + partner_name: string; +}; + +export type OverdueEscalationSummary = { + overdueTasks: number; + overdueDistributions: number; + robotSent: boolean; + cardSent: number; + cardFailed: number; +}; + +async function queryOverdueUnpublished( + db: DatabaseClient, + cutoff: string, +): Promise { + const result = await db + .prepare( + `SELECT d.id, t.id AS task_id, t.name AS task_name, t.due_at, + t.project_id, p.id AS partner_id, p.name AS partner_name + FROM distributions d + JOIN tasks t ON t.id = d.task_id + JOIN partners p ON p.id = d.partner_id + WHERE (d.publish_url IS NULL OR d.publish_url = '') + AND t.due_at IS NOT NULL AND t.due_at != '' + AND t.due_at < ? + ORDER BY t.due_at ASC, t.name ASC`, + ) + .bind(cutoff) + .all(); + return result.results; +} + +export async function runOverdueEscalation( + db: DatabaseClient, + config: WecomConfig, + fetchImpl: FetchLike = fetch, + now: number = Date.now(), +): Promise { + const today = shanghaiDateFromTimestamp(now); + const rows = await queryOverdueUnpublished(db, today); + + const byTask = new Map< + string, + { taskName: string; dueAt: string; projectId: string; count: number; partners: Set } + >(); + for (const row of rows) { + const entry = byTask.get(row.task_id) ?? { + taskName: row.task_name, + dueAt: row.due_at, + projectId: bindingValue(row.project_id), + count: 0, + partners: new Set(), + }; + entry.count += 1; + entry.partners.add(row.partner_name || row.partner_id); + byTask.set(row.task_id, entry); + } + + const summary: OverdueEscalationSummary = { + overdueTasks: byTask.size, + overdueDistributions: rows.length, + robotSent: false, + cardSent: 0, + cardFailed: 0, + }; + + if (rows.length === 0) return summary; + + if (config.robotWebhook) { + const lines = [...byTask.values()].map((entry) => { + const partners = [...entry.partners].slice(0, 5).join("、"); + const overflow = entry.partners.size > 5 ? ` 等 ${entry.partners.size} 位` : ""; + return `· ${truncate(entry.taskName, 24)}(截止 ${entry.dueAt}):${entry.count} 条超期未发布 — ${partners}${overflow}`; + }); + try { + await sendWecomRobotMessage( + `【超期未发布升级】${today}\n${lines.join("\n")}\n请运营跟进催办或释放。`, + config, + fetchImpl, + ); + summary.robotSent = true; + } catch (error) { + console.warn("[KOC LOOP] overdue escalation robot failed", { + error: safeError(error), + }); + } + } + + const env = getRuntimeEnv(); + const cardUrl = bindingValue(env.APP_ORIGIN) || bindingValue(env.KOC_PORTAL_URL); + for (const [taskId, entry] of byTask) { + if (!entry.projectId) continue; + try { + const project = await db + .prepare("SELECT main_group_member_ids FROM projects WHERE id = ?") + .bind(entry.projectId) + .first<{ main_group_member_ids: string }>(); + const members = safeParseIdList(project?.main_group_member_ids); + if (members.length === 0) continue; + const sent = await sendWecomTemplateCard( + config, + { + touser: members, + taskId: `task:${taskId}:due`, + title: `超期催办:${truncate(entry.taskName, 20)}`, + description: `${entry.count} 条内容超期未发布(截止 ${entry.dueAt}),请跟进催办或释放`, + url: cardUrl, + buttons: [ + { key: "view_task", name: "查看任务", url: cardUrl }, + { key: "mark_handled", name: "已处理" }, + ], + }, + fetchImpl, + ); + if (!sent.skipped) { + summary.cardSent += sent.sent; + summary.cardFailed += sent.failed; + } + } catch (error) { + summary.cardFailed += 1; + console.warn("[KOC LOOP] overdue escalation card failed", { + taskId, + error: safeError(error), + }); + } + } + + return summary; +} + +export type AutoReleaseSummary = { + enabled: boolean; + days: number; + cutoff: string; + released: number; + blocked: number; + failed: number; + robotSent: boolean; +}; + +export async function runOverdueAutoRelease( + db: DatabaseClient, + config: WecomConfig, + fetchImpl: FetchLike = fetch, + now: number = Date.now(), +): Promise { + const days = resolveAutoReleaseDays(getRuntimeEnv().WECOM_AUTO_RELEASE_DAYS); + const summary: AutoReleaseSummary = { + enabled: days > 0, + days, + cutoff: "", + released: 0, + blocked: 0, + failed: 0, + robotSent: false, + }; + if (days <= 0) return summary; + + const today = shanghaiDateFromTimestamp(now); + summary.cutoff = computeDueCutoff(today, -days); + const rows = await queryOverdueUnpublished(db, summary.cutoff); + + for (const row of rows) { + try { + await releaseUnfinishedDistribution(db, row.id); + summary.released += 1; + } catch (error) { + const status = (error as { status?: number }).status; + if (status === 409) { + summary.blocked += 1; + } else { + summary.failed += 1; + console.warn("[KOC LOOP] auto release failed", { + distributionId: row.id, + error: safeError(error), + }); + } + } + } + + if (summary.released > 0 && config.robotWebhook) { + try { + await sendWecomRobotMessage( + `【超期自动释放】截止 ${summary.cutoff} 前仍未发布的内容已释放 ${summary.released} 条回库存(跳过 ${summary.blocked} 条已提交、失败 ${summary.failed} 条),可重新领取。`, + config, + fetchImpl, + ); + summary.robotSent = true; + } catch (error) { + console.warn("[KOC LOOP] auto release robot failed", { + error: safeError(error), + }); + } + } + + return summary; +} + +function safeParseIdList(value: string | null | undefined) { + try { + const parsed = JSON.parse(String(value ?? "[]")); + if (!Array.isArray(parsed)) return []; + return parsed.map((item) => String(item)).filter(Boolean); + } catch { + return []; + } +} + function truncate(value: string, max: number) { return value.length > max ? `${value.slice(0, max)}…` : value; } diff --git a/tests/wecom-escalation.test.mjs b/tests/wecom-escalation.test.mjs new file mode 100644 index 0000000..593db4e --- /dev/null +++ b/tests/wecom-escalation.test.mjs @@ -0,0 +1,268 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + resolveAutoReleaseDays, + runOverdueAutoRelease, + runOverdueEscalation, +} from "../lib/wecom-notifier-service.ts"; +import { + clearWecomAccessTokenCacheForTests, + 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 TODAY = "2026-09-01"; +const NOW = new Date(`${TODAY}T02:00:00Z`).getTime(); + +const overdueRows = [ + { + id: "d-1", + task_id: "task-A", + task_name: "超期任务A", + due_at: "2026-08-20", + project_id: "proj-1", + partner_id: "p-1", + partner_name: "合作方甲", + }, + { + id: "d-2", + task_id: "task-A", + task_name: "超期任务A", + due_at: "2026-08-20", + project_id: "proj-1", + partner_id: "p-2", + partner_name: "合作方乙", + }, + { + id: "d-3", + task_id: "task-B", + task_name: "无项目任务B", + due_at: "2026-08-25", + project_id: "", + partner_id: "p-3", + partner_name: "合作方丙", + }, +]; + +const releasableDistribution = { + id: "d-1", + task_id: "task-A", + task_type: "content_publish", + content_id: "c-1", + content_title: "超期内容", + partner_id: "p-1", + partner_name: "合作方甲", + claim_id: null, + delegation_bundle_id: null, + publish_url: null, + result_submitted_at: null, +}; + +function fakeEscalationDb({ rows = overdueRows } = {}) { + return { + prepare(sql) { + return { + bind(...params) { + return { + async first() { + if (/FROM projects WHERE id = \?/.test(sql)) { + return { main_group_member_ids: JSON.stringify(["opsZhang", "opsLi"]) }; + } + return null; + }, + async all() { + if (/FROM distributions d/.test(sql) && /publish_url IS NULL/.test(sql)) { + return { results: rows.filter((row) => row.due_at < params[0]) }; + } + return { results: [] }; + }, + }; + }, + }; + }, + }; +} + +function fakeReleaseDb({ rows = overdueRows, blockedIds = [] } = {}) { + const executed = []; + const inner = { + prepare(sql) { + return { + bind(...params) { + return { + async first() { + if (/FROM projects WHERE id = \?/.test(sql)) { + return { main_group_member_ids: JSON.stringify(["opsZhang"]) }; + } + if (/FOR UPDATE/.test(sql)) { + const row = rows.find((item) => item.id === params[0]); + if (!row) return null; + return { + ...releasableDistribution, + id: row.id, + task_id: row.task_id, + partner_id: row.partner_id, + partner_name: row.partner_name, + result_submitted_at: blockedIds.includes(row.id) + ? "2026-08-30 10:00:00" + : null, + }; + } + return null; + }, + async all() { + if (/FROM distributions d/.test(sql) && /publish_url IS NULL/.test(sql)) { + return { results: rows.filter((row) => row.due_at < params[0]) }; + } + return { results: [] }; + }, + async run() { + executed.push({ sql, params }); + return { meta: { changes: 1 } }; + }, + }; + }, + }; + }, + }; + return { + executed, + ...inner, + async transaction(fn) { + return fn(inner); + }, + }; +} + +function wecomFetch(calls) { + 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") || + url.host === "qyapi.example.com" + ) { + return Response.json({ errcode: 0, errmsg: "ok" }); + } + return new Response("not found", { status: 404 }); + }; +} + +test.beforeEach(() => { + clearWecomAccessTokenCacheForTests(); + delete process.env.WECOM_AUTO_RELEASE_DAYS; + delete process.env.APP_ORIGIN; +}); + +test("resolveAutoReleaseDays parses bounds and defaults to disabled", () => { + assert.equal(resolveAutoReleaseDays(undefined), 0); + assert.equal(resolveAutoReleaseDays(""), 0); + assert.equal(resolveAutoReleaseDays("0"), 0); + assert.equal(resolveAutoReleaseDays("-5"), 0); + assert.equal(resolveAutoReleaseDays("abc"), 0); + assert.equal(resolveAutoReleaseDays("7"), 7); + assert.equal(resolveAutoReleaseDays("120"), 90); +}); + +test("runOverdueEscalation posts robot digest and template cards per project", async () => { + const calls = []; + const summary = await runOverdueEscalation( + fakeEscalationDb(), + config, + wecomFetch(calls), + NOW, + ); + assert.equal(summary.overdueTasks, 2); + assert.equal(summary.overdueDistributions, 3); + assert.equal(summary.robotSent, true); + + const robotBodies = calls + .filter((call) => call.url.host === "qyapi.example.com") + .map((call) => JSON.parse(String(call.init.body))); + assert.equal(robotBodies.length, 1); + assert.match(robotBodies[0].text.content, /【超期未发布升级】/); + assert.match(robotBodies[0].text.content, /超期任务A/); + assert.match(robotBodies[0].text.content, /无项目任务B/); + + const cardCalls = calls.filter((call) => + call.url.pathname.endsWith("/cgi-bin/message/send"), + ); + assert.equal(cardCalls.length, 1, "only the project task gets a card"); + const body = JSON.parse(String(cardCalls[0].init.body)); + assert.equal(body.msgtype, "template_card"); + assert.equal(body.template_card.task_id, "task:task-A:due"); + assert.equal(body.touser, "opsZhang|opsLi"); + const buttons = body.template_card.button_list; + assert.equal(buttons.length, 2); + assert.ok(buttons.some((button) => button.type === 0 && button.key === "mark_handled")); + assert.equal(summary.cardSent, 2); + assert.equal(summary.cardFailed, 0); +}); + +test("runOverdueEscalation no-ops without overdue rows", async () => { + const calls = []; + const summary = await runOverdueEscalation( + fakeEscalationDb({ rows: [] }), + config, + wecomFetch(calls), + NOW, + ); + assert.equal(summary.overdueDistributions, 0); + assert.equal(summary.robotSent, false); + assert.equal(calls.length, 0); +}); + +test("runOverdueAutoRelease is disabled without env switch", async () => { + const calls = []; + const db = fakeReleaseDb(); + const summary = await runOverdueAutoRelease(db, config, wecomFetch(calls), NOW); + assert.equal(summary.enabled, false); + assert.equal(summary.released, 0); + assert.equal(calls.length, 0); + assert.equal(db.executed.length, 0); +}); + +test("runOverdueAutoRelease releases overdue rows and skips blocked ones", async () => { + process.env.WECOM_AUTO_RELEASE_DAYS = "10"; + const calls = []; + const db = fakeReleaseDb({ blockedIds: ["d-3"] }); + const summary = await runOverdueAutoRelease(db, config, wecomFetch(calls), NOW); + // cutoff = 2026-08-22:d-1/d-2(08-20)命中,d-3(08-25)不在窗口内 + assert.equal(summary.enabled, true); + assert.equal(summary.cutoff, "2026-08-22"); + assert.equal(summary.released, 2); + assert.equal(summary.blocked, 0); + assert.ok( + db.executed.some((item) => /UPDATE contents SET status = 'available'/.test(item.sql)), + "released content returns to pool", + ); + const robotBodies = calls + .filter((call) => call.url.host === "qyapi.example.com") + .map((call) => JSON.parse(String(call.init.body))); + assert.equal(robotBodies.length, 1); + assert.match(robotBodies[0].text.content, /已释放 2 条/); +}); + +test("runOverdueAutoRelease counts blocked rows as skipped", async () => { + process.env.WECOM_AUTO_RELEASE_DAYS = "3"; + const db = fakeReleaseDb({ blockedIds: ["d-1", "d-2", "d-3"] }); + const summary = await runOverdueAutoRelease(db, config, wecomFetch([]), NOW); + assert.equal(summary.cutoff, "2026-08-29"); + assert.equal(summary.released, 0); + assert.equal(summary.blocked, 3); + assert.equal(summary.failed, 0); +});