feat: 项目负责人+内部任务群+portal 内部身份领取+群主链接归属

- 迁移 0012:projects.owner_userid / tasks.task_chat_id / claims.owner_partner_id
- lib/task-group-service.ts:懒建 appchat 任务群(首个内部群主领取时创建,成员 diff 补员),播报/卡片文案
- wecom-client 新增 appchat create/update/get/send 封装
- OAuth ticket 增可选 uid(v:1 兼容),内部成员领取按 wecom_userid 解析群主身份
- 外部 OAuth 领取人 upsert 打 partner_type='group_owner'
- portal 链接带 ?owner= 穿透 OAuth 往返,claims 记录归属群主
- 后台任务卡「群主链接」面板:复制链接/推送内部群主/任务群状态
- 催办与超期升级同步播报进任务群
This commit is contained in:
ABAPPLO
2026-09-03 11:30:26 +08:00
parent 5d2a17f5b5
commit bd80b2d7d8
17 changed files with 1466 additions and 39 deletions

View File

@@ -3,14 +3,18 @@ import test from "node:test";
import {
buildWecomOAuthUrl,
clearWecomAccessTokenCacheForTests,
createAppChat,
createGroupMsgTemplate,
exchangeWecomOAuthCode,
fetchWecomExternalContactName,
getAppChat,
listCustomerGroupChats,
listExternalContacts,
resolveWecomConfig,
sendAppChatMessage,
sendWecomAppMessage,
sendWecomRobotMessage,
updateAppChat,
WecomClientError,
} from "../lib/wecom-client.ts";
@@ -771,3 +775,175 @@ test("fetchWecomExternalContactName resolves name and falls back to null", async
null,
);
});
function fakeAppChat(options = {}) {
const calls = [];
const chats = new Map(
options.chats ?? [
["tgc_task-existing", { name: "【任务】已有群", userlist: ["owner-a"] }],
],
);
const fetchImpl = 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/appchat/create")) {
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 });
}
if (url.pathname.endsWith("/cgi-bin/appchat/update")) {
const body = JSON.parse(String(init.body ?? "{}"));
const chat = chats.get(body.chatid);
if (!chat) {
return Response.json({ errcode: 60111, errmsg: "chat not found" });
}
for (const userid of body.add_user_list ?? []) {
if (!chat.userlist.includes(userid)) chat.userlist.push(userid);
}
return Response.json({ errcode: 0, errmsg: "ok" });
}
if (url.pathname.endsWith("/cgi-bin/appchat/get")) {
const body = JSON.parse(String(init.body ?? "{}"));
const chat = chats.get(body.chatid);
if (!chat) {
return Response.json({ errcode: 60111, errmsg: "chat not found" });
}
return Response.json({
errcode: 0,
errmsg: "ok",
chat_info: {
chatid: body.chatid,
name: chat.name,
userlist: chat.userlist,
},
});
}
if (url.pathname.endsWith("/cgi-bin/appchat/send")) {
const body = JSON.parse(String(init.body ?? "{}"));
if (!chats.has(body.chatid)) {
return Response.json({ errcode: 60111, errmsg: "chat not found" });
}
return Response.json({ errcode: 0, errmsg: "ok" });
}
return new Response("not found", { status: 404 });
};
return { calls, fetchImpl, chats };
}
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" },
fetchImpl,
);
assert.equal(result.chatid, "tgc_task-new");
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.userlist.length, 2);
assert.ok(body.name.length <= 20, "chat name truncated to 20 chars");
});
test("createAppChat rejects fewer than 2 members and oversized chatid", async () => {
const { calls, fetchImpl } = fakeAppChat();
const config = resolveWecomConfig(baseBindings);
await assert.rejects(
() => createAppChat(config, { name: "x", userlist: ["only"], chatid: "tgc_t" }, fetchImpl),
/至少需要 2 名成员/,
);
await assert.rejects(
() =>
createAppChat(
config,
{ name: "x", userlist: ["a", "b"], chatid: "c".repeat(33) },
fetchImpl,
),
/32 字符/,
);
assert.equal(calls.length, 0, "no API calls for invalid payload");
});
test("updateAppChat adds and removes members", async () => {
const { calls, fetchImpl, chats } = fakeAppChat();
await updateAppChat(
resolveWecomConfig(baseBindings),
{ chatid: "tgc_task-existing", addUserList: ["owner-b"], delUserList: [] },
fetchImpl,
);
const call = calls.find((c) => c.url.pathname.endsWith("/cgi-bin/appchat/update"));
assert.ok(call, "appchat/update was called");
const body = JSON.parse(String(call.init.body));
assert.deepEqual(body.add_user_list, ["owner-b"]);
assert.deepEqual(chats.get("tgc_task-existing").userlist, ["owner-a", "owner-b"]);
});
test("updateAppChat skips the API call when there is nothing to change", async () => {
const { calls, fetchImpl } = fakeAppChat();
await updateAppChat(
resolveWecomConfig(baseBindings),
{ chatid: "tgc_task-existing", addUserList: [], delUserList: [] },
fetchImpl,
);
assert.equal(
calls.filter((c) => c.url.pathname.endsWith("/cgi-bin/appchat/update")).length,
0,
);
});
test("getAppChat returns member list or null on error", async () => {
const { fetchImpl } = fakeAppChat();
const info = await getAppChat(
resolveWecomConfig(baseBindings),
"tgc_task-existing",
fetchImpl,
);
assert.equal(info.chatId, "tgc_task-existing");
assert.deepEqual(info.userlist, ["owner-a"]);
assert.equal(
await getAppChat(resolveWecomConfig(baseBindings), "tgc_task-missing", fetchImpl),
null,
);
});
test("sendAppChatMessage posts text payload to appchat/send", async () => {
const { calls, fetchImpl } = fakeAppChat();
await sendAppChatMessage(
resolveWecomConfig(baseBindings),
{ chatid: "tgc_task-existing", text: "【领取】张三 认领 5 份" },
fetchImpl,
);
const call = calls.find((c) => c.url.pathname.endsWith("/cgi-bin/appchat/send"));
assert.ok(call, "appchat/send was called");
const body = JSON.parse(String(call.init.body));
assert.equal(body.chatid, "tgc_task-existing");
assert.equal(body.msgtype, "text");
assert.equal(body.text.content, "【领取】张三 认领 5 份");
});
test("sendAppChatMessage throws WecomClientError when provider errors", async () => {
const { fetchImpl } = fakeAppChat({ chats: [] });
await assert.rejects(
() =>
sendAppChatMessage(
resolveWecomConfig(baseBindings),
{ chatid: "tgc_task-missing", text: "hello" },
fetchImpl,
),
(error) => {
assert.ok(error instanceof WecomClientError);
assert.equal(error.status, 502);
return true;
},
);
});