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