feat: 任务一键推送企微(总群+KOC个人直达+群发,含推送记录)

This commit is contained in:
ABAPPLO
2026-09-01 17:30:47 +08:00
parent b243562e60
commit d01281fba9
10 changed files with 1103 additions and 12 deletions

View File

@@ -0,0 +1,250 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
buildKocDirectMessage,
oneClickPushTask,
OneClickPushError,
} from "../lib/task-one-click-push-service.ts";
import {
clearWecomAccessTokenCacheForTests,
resolveWecomConfig,
} from "../lib/wecom-client.ts";
const baseBindings = {
WECOM_CORP_ID: "corp-test",
WECOM_AGENT_ID: "1000001",
WECOM_SECRET: "secret-test",
};
const taskRow = {
id: "task-1",
name: "测试任务",
brand: "品牌A",
quantity: 3,
due_at: "2026-09-05",
task_type: "content_publish",
platform: "小红书",
content_format: "image_text",
share_token: "tok-123",
project_id: "",
};
const boundPartners = [
{ id: "p-1", name: "合作方甲", wecom_external_user_id: "wmOK1" },
{ id: "p-2", name: "合作方乙", wecom_external_user_id: "wmOK2" },
{ id: "p-3", name: "合作方丙", wecom_external_user_id: "wmFAIL" },
];
function fakePushDb({
task = taskRow,
partners = boundPartners,
todayLog = null,
} = {}) {
const executed = [];
const makeStatement = (sql, params = []) => ({
sql,
params,
bind(...args) {
return makeStatement(sql, args);
},
async run() {
executed.push({ sql, params });
return { meta: { changes: 1 } };
},
async first() {
executed.push({ sql, params });
if (/FROM tasks WHERE id = \?/.test(sql)) return task;
if (/FROM wecom_push_logs/.test(sql) && /DATE\(created_at\) = CURDATE\(\)/.test(sql)) {
return todayLog;
}
return null;
},
async all() {
executed.push({ sql, params });
if (/FROM partners/.test(sql) && /wecom_external_user_id/.test(sql)) {
return { results: partners };
}
return { results: [] };
},
});
return {
executed,
prepare(sql) {
return makeStatement(sql);
},
};
}
function fakeWecomFetch(calls, { invalidUsers = [] } = {}) {
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")) {
const body = JSON.parse(String(init.body));
if (invalidUsers.includes(body.touser)) {
return Response.json({
errcode: 0,
errmsg: "ok",
invaliduser: body.touser,
});
}
return Response.json({ errcode: 0, errmsg: "ok" });
}
return new Response("not found", { status: 404 });
};
}
test.beforeEach(() => {
clearWecomAccessTokenCacheForTests();
});
test("buildKocDirectMessage targets KOC with claim url and OAuth hint", () => {
const message = buildKocDirectMessage(taskRow, "https://portal.example.com/");
assert.match(message, /【新任务】测试任务/);
assert.match(message, /先到先得/);
assert.match(message, /免输身份/);
assert.match(message, /https:\/\/portal\.example\.com\/\?task=tok-123/);
});
test("oneClickPushTask sends only to selected partners and logs the push", async () => {
process.env.KOC_PORTAL_URL = "https://portal.example.com";
try {
const db = fakePushDb();
const calls = [];
const outcome = await oneClickPushTask(
db,
{
taskId: "task-1",
sendMainGroup: false,
sendKoc: true,
createGroupTemplate: false,
selectedKocPartnerIds: ["p-1", "p-3"],
},
resolveWecomConfig(baseBindings),
fakeWecomFetch(calls, { invalidUsers: ["wmFAIL"] }),
);
const appCalls = calls.filter((call) =>
call.url.pathname.endsWith("/cgi-bin/message/send"),
);
assert.deepEqual(
appCalls.map((call) => JSON.parse(String(call.init.body)).touser),
["wmOK1", "wmFAIL"],
);
for (const call of appCalls) {
assert.match(JSON.parse(String(call.init.body)).text.content, /【新任务】测试任务/);
}
assert.equal(outcome.kocSent, 1);
assert.equal(outcome.kocFailed, 1);
assert.equal(outcome.kocResults[1].partnerName, "合作方丙");
assert.match(outcome.kocResults[1].error, /invaliduser/);
assert.equal(outcome.linkUrl, "https://portal.example.com/?task=tok-123");
const insert = db.executed.find((item) => /INSERT INTO wecom_push_logs/.test(item.sql));
assert.ok(insert, "push log should be written");
assert.equal(insert.params[1], "task-1");
assert.equal(insert.params[3], "task_open");
assert.equal(insert.params[6], 1);
const kocUserids = JSON.parse(insert.params[5]);
assert.equal(kocUserids.length, 2);
assert.equal(kocUserids[0].externalUserId, "wmOK1");
} finally {
delete process.env.KOC_PORTAL_URL;
}
});
test("oneClickPushTask blocks duplicate same-day push unless forced", async () => {
process.env.KOC_PORTAL_URL = "https://portal.example.com";
try {
const db = fakePushDb({
todayLog: { id: "wpl-old", task_id: "task-1", push_kind: "task_open" },
});
const calls = [];
await assert.rejects(
() =>
oneClickPushTask(
db,
{ taskId: "task-1", sendMainGroup: false, sendKoc: true },
resolveWecomConfig(baseBindings),
fakeWecomFetch(calls),
),
(error) =>
error instanceof OneClickPushError && /仍要推送/.test(error.message) && error.status === 409,
);
assert.equal(calls.length, 0, "no wecom api calls on duplicate block");
const outcome = await oneClickPushTask(
db,
{
taskId: "task-1",
sendMainGroup: false,
sendKoc: true,
selectedKocPartnerIds: ["p-2"],
force: true,
},
resolveWecomConfig(baseBindings),
fakeWecomFetch(calls),
);
assert.equal(outcome.kocSent, 1);
} finally {
delete process.env.KOC_PORTAL_URL;
}
});
test("oneClickPushTask without project skips project targets and still reaches KOC", async () => {
process.env.KOC_PORTAL_URL = "https://portal.example.com";
try {
const db = fakePushDb({ task: { ...taskRow, project_id: "" } });
const calls = [];
const outcome = await oneClickPushTask(
db,
{ taskId: "task-1", sendMainGroup: true, sendKoc: true },
resolveWecomConfig(baseBindings),
fakeWecomFetch(calls),
);
assert.ok(
outcome.skippedReasons.some((reason) => /未挂项目/.test(reason)),
"should explain project skip",
);
assert.equal(outcome.projectOutcome, null);
assert.equal(outcome.kocSent, 3);
assert.equal(
calls.filter((call) => call.url.pathname.includes("/cgi-bin/externalcontact/")).length,
0,
"no group template calls expected",
);
} finally {
delete process.env.KOC_PORTAL_URL;
}
});
test("oneClickPushTask requires share token and at least one target", async () => {
await assert.rejects(
() =>
oneClickPushTask(
fakePushDb({ task: { ...taskRow, share_token: "" } }),
{ taskId: "task-1", sendMainGroup: false, sendKoc: true },
resolveWecomConfig(baseBindings),
fakeWecomFetch([]),
),
(error) => error instanceof OneClickPushError && /领取链接/.test(error.message),
);
await assert.rejects(
() =>
oneClickPushTask(
fakePushDb(),
{ taskId: "task-1", sendMainGroup: false, sendKoc: false, createGroupTemplate: false },
resolveWecomConfig(baseBindings),
fakeWecomFetch([]),
),
(error) => error instanceof OneClickPushError && /至少选择一个推送目标/.test(error.message),
);
});

View File

@@ -84,8 +84,23 @@ test("parse callback xml handles CDATA and plain fields", () => {
assert.equal(extractWecomEncryptField(body), "RW5jcnlwdGVkQmFzZTY0PT0=");
});
test("event dispatcher classifies template_card mark_handled and ignores others", () => {
const ack = handleWecomCallbackEvent({
test("event dispatcher classifies template_card mark_handled and ignores others", async () => {
const inserts = [];
const fakeDb = {
prepare(sql) {
return {
bind(...params) {
return {
async run() {
inserts.push({ sql, params });
return { success: true };
},
};
},
};
},
};
const ack = await handleWecomCallbackEvent(fakeDb, {
MsgType: "event",
Event: "template_card_event",
TaskId: "task:t123:due",
@@ -96,8 +111,12 @@ test("event dispatcher classifies template_card mark_handled and ignores others"
assert.equal(ack.action, "card_ack");
assert.equal(ack.taskId, "task:t123:due");
assert.equal(ack.operator, "opsUser01");
assert.equal(inserts.length, 1);
assert.match(inserts[0].sql, /INSERT INTO wecom_push_logs/);
assert.equal(inserts[0].params[0].startsWith("wpl-"), true);
assert.equal(inserts[0].params[1], "t123");
const view = handleWecomCallbackEvent({
const view = await handleWecomCallbackEvent(fakeDb, {
MsgType: "event",
Event: "template_card_event",
TaskId: "task:t123:due",
@@ -106,7 +125,7 @@ test("event dispatcher classifies template_card mark_handled and ignores others"
assert.equal(view.recognized, true);
assert.equal(view.action, "ignored");
const other = handleWecomCallbackEvent({
const other = await handleWecomCallbackEvent(fakeDb, {
MsgType: "event",
Event: "change_contact",
});