feat: 项目总群自动建群+任务负责人后台视角+客户群活码进群+任务编辑

- 项目总群:≥3 人 appchat/create 自动建群(chatid=pgc+projectId),成员 diff 增删同步;DROP project_group_chats;推送改 appchat/send 群发、无群降级逐人
- 任务负责人:tasks.owner_userid(新建/编辑任务通讯录选人),task_set_owner 分配即预建 tgc 任务群发介绍,群主链接只归属负责人
- 任务编辑 task_update:基础字段+截图任务数量/关键词/说明,contents 调平只删 available 尾行
- 删除「一键推送」:task-one-click-push-service 整体移除(recordCardAck 移入 wecom-callback-events)
- 负责人后台视角:users.wecom_userid 绑定,lib/owner-scope 行级过滤(undefined=全量/""=空范围/非空=过滤),~30 action 补 managerGate/taskOwnerGate,owner 登录仅见工作台/任务中心/数据回收/复盘报表
- 任务绑定客户群+进群活码:tasks 增 customer_chat_id/name、join_config_id、join_qr_code 4 列,join_way add/update/get/del(换绑走 update 保 config_id 活码不变),后台「客户群」弹窗,portal 认领后扫码自助进群(满员自动开新群)
- 迁移 0013/0014/0015;催办支持按任务;企微可信域名 IP 验证文件
- 288 测试全绿;真机全链路验收通过(列群/活码四端点/portal 渲染/解绑清空)
This commit is contained in:
ABAPPLO
2026-09-04 18:03:46 +08:00
parent 5430624a43
commit dd5b603149
44 changed files with 4655 additions and 2039 deletions

View File

@@ -1,13 +1,16 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
addTaskJoinWay,
buildWecomOAuthUrl,
clearWecomAccessTokenCacheForTests,
createAppChat,
createGroupMsgTemplate,
deleteJoinWay,
exchangeWecomOAuthCode,
fetchWecomExternalContactName,
getAppChat,
getJoinWayQrCode,
listCustomerGroupChats,
listExternalContacts,
listInternalContacts,
@@ -16,6 +19,7 @@ import {
sendWecomAppMessage,
sendWecomRobotMessage,
updateAppChat,
updateTaskJoinWay,
WecomClientError,
} from "../lib/wecom-client.ts";
@@ -428,6 +432,14 @@ function fakeGroupPush(options = {}) {
"wr-normal-1": { name: "KOC 一群", owner: "zhangsan", member_count: 42 },
"wr-normal-2": { name: "KOC 二群", owner: "lisi", member_count: 7 },
};
const joinWay = {
addErrcode: options.joinAddErrcode ?? 0,
updateErrcode: options.joinUpdateErrcode ?? 0,
getErrcode: options.joinGetErrcode ?? 0,
delErrcode: options.joinDelErrcode ?? 0,
qrCode: options.joinQrCode ?? "https://p.qpic.cn/qr-test.png",
configId: options.joinConfigId ?? "cfgtest123",
};
const fetchImpl = async (input, init = {}) => {
const url = new URL(String(input));
calls.push({ url, init });
@@ -466,6 +478,34 @@ function fakeGroupPush(options = {}) {
fail_list: options.failList ?? [],
});
}
if (url.pathname.endsWith("/cgi-bin/externalcontact/groupchat/add_join_way")) {
if (joinWay.addErrcode !== 0) {
return Response.json({ errcode: joinWay.addErrcode, errmsg: "add failed" });
}
return Response.json({ errcode: 0, errmsg: "ok", config_id: joinWay.configId });
}
if (url.pathname.endsWith("/cgi-bin/externalcontact/groupchat/get_join_way")) {
if (joinWay.getErrcode !== 0) {
return Response.json({ errcode: joinWay.getErrcode, errmsg: "get failed" });
}
return Response.json({
errcode: 0,
errmsg: "ok",
join_way: { qr_code: joinWay.qrCode, scene: 2 },
});
}
if (url.pathname.endsWith("/cgi-bin/externalcontact/groupchat/update_join_way")) {
if (joinWay.updateErrcode !== 0) {
return Response.json({ errcode: joinWay.updateErrcode, errmsg: "update failed" });
}
return Response.json({ errcode: 0, errmsg: "ok" });
}
if (url.pathname.endsWith("/cgi-bin/externalcontact/groupchat/del_join_way")) {
if (joinWay.delErrcode !== 0) {
return Response.json({ errcode: joinWay.delErrcode, errmsg: "del failed" });
}
return Response.json({ errcode: 0, errmsg: "ok" });
}
return new Response("not found", { status: 404 });
};
return { calls, fetchImpl };
@@ -524,6 +564,118 @@ test("listCustomerGroupChats skips groups whose detail lookup fails", async () =
);
});
test("listCustomerGroupChats sends owner_filter when ownerUserids given", async () => {
const { calls, fetchImpl } = fakeGroupPush({
pages: [{ next_cursor: "", group_chat_list: [{ chat_id: "wr-normal-1", status: 0 }] }],
details: {
"wr-normal-1": { name: "KOC 一群", owner: "zhangsan", member_count: 42 },
},
});
const groups = await listCustomerGroupChats(
resolveWecomConfig(baseBindings),
fetchImpl,
["zhangsan", ""],
);
assert.equal(groups.length, 1);
const listCall = calls.find((c) =>
c.url.pathname.endsWith("/cgi-bin/externalcontact/groupchat/list"),
);
const body = JSON.parse(String(listCall.init.body));
assert.deepEqual(body.owner_filter, { userid_list: ["zhangsan"] });
});
test("addTaskJoinWay posts scene/auto_create_room/payload and returns configId", async () => {
const { calls, fetchImpl } = fakeGroupPush();
const result = await addTaskJoinWay(
resolveWecomConfig(baseBindings),
{
chatIdList: ["wr-normal-1"],
roomBaseName: "每".repeat(50),
state: "task-task-baf7fd59",
},
fetchImpl,
);
assert.equal(result.configId, "cfgtest123");
const call = calls.find((c) =>
c.url.pathname.endsWith("/cgi-bin/externalcontact/groupchat/add_join_way"),
);
assert.ok(call, "add_join_way was called");
assert.equal(call.url.searchParams.get("access_token"), "token-test");
const body = JSON.parse(String(call.init.body));
assert.equal(body.scene, 2);
assert.equal(body.auto_create_room, 1);
assert.deepEqual(body.chat_id_list, ["wr-normal-1"]);
assert.equal(body.room_base_name.length, 40, "room_base_name truncated to 40 code points");
assert.equal(body.state, "task-task-baf7fd59");
});
test("addTaskJoinWay surfaces upstream errcode as WecomClientError", async () => {
const { fetchImpl } = fakeGroupPush({ joinAddErrcode: 60011 });
await assert.rejects(
() =>
addTaskJoinWay(
resolveWecomConfig(baseBindings),
{ chatIdList: ["wr-normal-1"], roomBaseName: "群", state: "task-t1" },
fetchImpl,
),
(error) =>
error instanceof WecomClientError && /创建企业微信进群活码失败/.test(error.message),
);
});
test("getJoinWayQrCode returns qr_code url from join_way", async () => {
const { calls, fetchImpl } = fakeGroupPush({ joinQrCode: "https://p.qpic.cn/qr-x.png" });
const result = await getJoinWayQrCode(
resolveWecomConfig(baseBindings),
"cfgtest123",
fetchImpl,
);
assert.equal(result.qrCode, "https://p.qpic.cn/qr-x.png");
const call = calls.find((c) =>
c.url.pathname.endsWith("/cgi-bin/externalcontact/groupchat/get_join_way"),
);
assert.deepEqual(JSON.parse(String(call.init.body)), { config_id: "cfgtest123" });
});
test("updateTaskJoinWay re-sends full body including scene for overwrite semantics", async () => {
const { calls, fetchImpl } = fakeGroupPush();
await updateTaskJoinWay(
resolveWecomConfig(baseBindings),
{
configId: "cfgtest123",
chatIdList: ["wr-normal-2"],
roomBaseName: "新群名",
state: "task-t2",
},
fetchImpl,
);
const call = calls.find((c) =>
c.url.pathname.endsWith("/cgi-bin/externalcontact/groupchat/update_join_way"),
);
const body = JSON.parse(String(call.init.body));
assert.equal(body.config_id, "cfgtest123");
assert.equal(body.scene, 2);
assert.equal(body.auto_create_room, 1);
assert.deepEqual(body.chat_id_list, ["wr-normal-2"]);
assert.equal(body.room_base_name, "新群名");
});
test("deleteJoinWay tolerates upstream rejection but still calls del endpoint", async () => {
const { calls, fetchImpl } = fakeGroupPush({ joinDelErrcode: 40068 });
await deleteJoinWay(resolveWecomConfig(baseBindings), "cfgtest123", fetchImpl);
const call = calls.find((c) =>
c.url.pathname.endsWith("/cgi-bin/externalcontact/groupchat/del_join_way"),
);
assert.ok(call, "del_join_way was called");
assert.deepEqual(JSON.parse(String(call.init.body)), { config_id: "cfgtest123" });
});
test("deleteJoinWay skips empty configId", async () => {
const { calls, fetchImpl } = fakeGroupPush();
await deleteJoinWay(resolveWecomConfig(baseBindings), " ", fetchImpl);
assert.equal(calls.length, 0, "no API calls for empty config id");
});
test("createGroupMsgTemplate posts group payload with text and link attachment", async () => {
const { calls, fetchImpl } = fakeGroupPush();
const result = await createGroupMsgTemplate(
@@ -834,6 +986,7 @@ function fakeAppChat(options = {}) {
["tgc_task-existing", { name: "【任务】已有群", userlist: ["owner-a"] }],
],
);
const createExisted = options.createExisted ?? false;
const fetchImpl = async (input, init = {}) => {
const url = new URL(String(input));
calls.push({ url, init });
@@ -846,6 +999,9 @@ function fakeAppChat(options = {}) {
});
}
if (url.pathname.endsWith("/cgi-bin/appchat/create")) {
if (createExisted) {
return Response.json({ errcode: 86215, errmsg: "chatid existed" });
}
const body = JSON.parse(String(init.body ?? "{}"));
chats.set(body.chatid, { name: body.name, userlist: body.userlist });
return Response.json({ errcode: 0, errmsg: "ok", chatid: body.chatid });
@@ -893,15 +1049,15 @@ test("createAppChat posts name/userlist/chatid and returns chatid", async () =>
const { calls, fetchImpl } = fakeAppChat();
const result = await createAppChat(
resolveWecomConfig(baseBindings),
{ name: "【任务】任务名超长会被截断到二十个字符", userlist: ["owner-a", "owner-b"], chatid: "tgc_task-new" },
{ name: "【任务】任务名超长会被截断到二十个字符", userlist: ["owner-a", "owner-b"], chatid: "tgcnewchat" },
fetchImpl,
);
assert.equal(result.chatid, "tgc_task-new");
assert.equal(result.chatid, "tgcnewchat");
const call = calls.find((c) => c.url.pathname.endsWith("/cgi-bin/appchat/create"));
assert.ok(call, "appchat/create was called");
assert.equal(call.url.searchParams.get("access_token"), "token-test");
const body = JSON.parse(String(call.init.body));
assert.equal(body.chatid, "tgc_task-new");
assert.equal(body.chatid, "tgcnewchat");
assert.equal(body.userlist.length, 2);
assert.ok(body.name.length <= 20, "chat name truncated to 20 chars");
});
@@ -925,6 +1081,16 @@ test("createAppChat rejects fewer than 2 members and oversized chatid", async ()
assert.equal(calls.length, 0, "no API calls for invalid payload");
});
test("createAppChat treats chatid existed (86215) as success", async () => {
const { fetchImpl } = fakeAppChat({ createExisted: true });
const result = await createAppChat(
resolveWecomConfig(baseBindings),
{ name: "【总群】项目一", userlist: ["a", "b"], chatid: "pgcproj1" },
fetchImpl,
);
assert.equal(result.chatid, "pgcproj1");
});
test("updateAppChat adds and removes members", async () => {
const { calls, fetchImpl, chats } = fakeAppChat();
await updateAppChat(
@@ -962,7 +1128,7 @@ test("getAppChat returns member list or null on error", async () => {
assert.equal(info.chatId, "tgc_task-existing");
assert.deepEqual(info.userlist, ["owner-a"]);
assert.equal(
await getAppChat(resolveWecomConfig(baseBindings), "tgc_task-missing", fetchImpl),
await getAppChat(resolveWecomConfig(baseBindings), "tgcmissingchat", fetchImpl),
null,
);
});
@@ -988,7 +1154,7 @@ test("sendAppChatMessage throws WecomClientError when provider errors", async ()
() =>
sendAppChatMessage(
resolveWecomConfig(baseBindings),
{ chatid: "tgc_task-missing", text: "hello" },
{ chatid: "tgcmissingchat", text: "hello" },
fetchImpl,
),
(error) => {