feat: 催办分层(超期升级+交互卡片)与超期自动释放开关

This commit is contained in:
ABAPPLO
2026-09-01 17:37:35 +08:00
parent bca524417e
commit 3e4269729c
7 changed files with 654 additions and 3 deletions

View File

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