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:
@@ -27,32 +27,23 @@ const taskRow = {
|
||||
content_format: "image_text",
|
||||
share_token: "tok-123",
|
||||
project_id: "proj-1",
|
||||
claimed_quantity: 0,
|
||||
status: "active",
|
||||
owner_userid: "owner-task",
|
||||
task_chat_id: null,
|
||||
};
|
||||
|
||||
const projectRow = {
|
||||
id: "proj-1",
|
||||
name: "项目一",
|
||||
brand: "品牌A",
|
||||
main_group_member_ids: JSON.stringify(["zhangsan", "lisi"]),
|
||||
main_group_member_ids: JSON.stringify(["zhangsan", "lisi", "owner-proj"]),
|
||||
owner_userid: "owner-proj",
|
||||
main_chat_id: "",
|
||||
status: "active",
|
||||
note: "",
|
||||
created_at: "2026-08-01",
|
||||
};
|
||||
|
||||
function fakePushDb({
|
||||
task = taskRow,
|
||||
project = projectRow,
|
||||
links = [
|
||||
{ project_id: "proj-1", chat_id: "wr-1", partner_id: "p-1" },
|
||||
{ project_id: "proj-1", chat_id: "wr-2", partner_id: "" },
|
||||
],
|
||||
groupChats = [
|
||||
{ chat_id: "wr-1", name: "一群", owner_user_id: "zhangsan", member_count: 10, status: 0 },
|
||||
{ chat_id: "wr-2", name: "二群", owner_user_id: "lisi", member_count: 5, status: 0 },
|
||||
],
|
||||
} = {}) {
|
||||
function fakePushDb({ task = { ...taskRow }, project = { ...projectRow } } = {}) {
|
||||
const executed = [];
|
||||
const makeStatement = (sql, params = []) => ({
|
||||
sql,
|
||||
@@ -62,10 +53,25 @@ function fakePushDb({
|
||||
},
|
||||
async run() {
|
||||
executed.push({ sql, params });
|
||||
if (/UPDATE tasks SET task_chat_id/.test(sql)) {
|
||||
task.task_chat_id = params[0];
|
||||
}
|
||||
return { meta: { changes: 1 } };
|
||||
},
|
||||
async first() {
|
||||
executed.push({ sql, params });
|
||||
if (/FROM tasks t\s+LEFT JOIN projects p/.test(sql)) {
|
||||
return {
|
||||
id: task.id,
|
||||
name: task.name,
|
||||
task_chat_id: task.task_chat_id ?? null,
|
||||
task_owner_userid: task.owner_userid ?? null,
|
||||
project_owner_userid: project ? (project.owner_userid ?? null) : null,
|
||||
};
|
||||
}
|
||||
if (/SELECT task_chat_id FROM tasks/.test(sql)) {
|
||||
return { task_chat_id: task.task_chat_id ?? null };
|
||||
}
|
||||
if (/FROM tasks WHERE id = \?/.test(sql)) return task;
|
||||
if (/FROM projects WHERE id = \?/.test(sql)) return project;
|
||||
return null;
|
||||
@@ -75,36 +81,12 @@ function fakePushDb({
|
||||
if (/FROM tasks WHERE project_id/.test(sql)) {
|
||||
return { results: task ? [{ ...task }] : [] };
|
||||
}
|
||||
if (/FROM project_group_chats g/.test(sql)) {
|
||||
return {
|
||||
results: links
|
||||
.filter((link) => link.project_id === params[0])
|
||||
.map((link) => {
|
||||
const chat = groupChats.find((item) => item.chat_id === link.chat_id);
|
||||
return {
|
||||
chat_id: link.chat_id,
|
||||
partner_id: link.partner_id,
|
||||
chat_name: chat ? chat.name : null,
|
||||
owner_user_id: chat ? chat.owner_user_id : null,
|
||||
member_count: chat ? chat.member_count : null,
|
||||
chat_status: chat ? chat.status : null,
|
||||
partner_name: null,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (/FROM wecom_group_chats/.test(sql)) {
|
||||
return {
|
||||
results: sql.includes(" IN (")
|
||||
? groupChats.filter((row) => params.includes(row.chat_id))
|
||||
: groupChats,
|
||||
};
|
||||
}
|
||||
return { results: [] };
|
||||
},
|
||||
});
|
||||
return {
|
||||
executed,
|
||||
task,
|
||||
prepare(sql) {
|
||||
return makeStatement(sql);
|
||||
},
|
||||
@@ -114,7 +96,7 @@ function fakePushDb({
|
||||
};
|
||||
}
|
||||
|
||||
function fakeWecomFetch(calls, { failAppFor = [] } = {}) {
|
||||
function fakeWecomFetch(calls, chats, { failAppFor = [], failChatSend = [] } = {}) {
|
||||
return async (input, init = {}) => {
|
||||
const url = new URL(String(input));
|
||||
calls.push({ url, init });
|
||||
@@ -133,8 +115,40 @@ function fakeWecomFetch(calls, { failAppFor = [] } = {}) {
|
||||
}
|
||||
return Response.json({ errcode: 0, errmsg: "ok" });
|
||||
}
|
||||
if (url.pathname.endsWith("/cgi-bin/externalcontact/add_msg_template")) {
|
||||
return Response.json({ errcode: 0, errmsg: "ok", msgid: "msg-1", fail_list: [] });
|
||||
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/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/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/send")) {
|
||||
const body = JSON.parse(String(init.body ?? "{}"));
|
||||
if (failChatSend.includes(body.chatid) || !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 });
|
||||
};
|
||||
@@ -151,86 +165,150 @@ test("buildProjectAppMessage targets internal members with claim url", () => {
|
||||
assert.match(message, /领取链接:https:\/\/portal\.example\.com\/\?task=tok-123/);
|
||||
});
|
||||
|
||||
test("pushProjectTask sends per-member app messages and group templates", async () => {
|
||||
test("pushProjectTask broadcasts once into the main chat and sends the task chat", async () => {
|
||||
process.env.KOC_PORTAL_URL = "https://portal.example.com";
|
||||
try {
|
||||
const db = fakePushDb();
|
||||
const db = fakePushDb({
|
||||
project: { ...projectRow, main_chat_id: "pgcproj1" },
|
||||
});
|
||||
const chats = new Map([
|
||||
["pgcproj1", { name: "【总群】项目一", userlist: ["zhangsan", "lisi", "owner-proj"] }],
|
||||
]);
|
||||
const calls = [];
|
||||
const outcome = await pushProjectTask(
|
||||
db,
|
||||
{ taskId: "task-1", sendMainGroup: true, sendGroupChats: true },
|
||||
resolveWecomConfig(baseBindings),
|
||||
fakeWecomFetch(calls),
|
||||
fakeWecomFetch(calls, chats),
|
||||
);
|
||||
const appCalls = calls.filter((call) =>
|
||||
call.url.pathname.endsWith("/cgi-bin/message/send"),
|
||||
assert.equal(outcome.mainChatMode, "group");
|
||||
assert.deepEqual(outcome.appResults, [], "no per-member sends when chat exists");
|
||||
const chatSends = calls.filter((call) =>
|
||||
call.url.pathname.endsWith("/cgi-bin/appchat/send"),
|
||||
);
|
||||
assert.deepEqual(
|
||||
appCalls.map((call) => JSON.parse(String(call.init.body)).touser),
|
||||
["zhangsan", "lisi"],
|
||||
"one message/send per main group member",
|
||||
chatSends.map((call) => JSON.parse(String(call.init.body)).chatid).sort(),
|
||||
["pgcproj1", "tgctask1"],
|
||||
"one send into main chat and one into task chat",
|
||||
);
|
||||
for (const call of appCalls) {
|
||||
assert.match(JSON.parse(String(call.init.body)).text.content, /【项目任务】测试任务/);
|
||||
}
|
||||
assert.equal(outcome.appResults.length, 2);
|
||||
assert.ok(outcome.appResults.every((item) => item.ok));
|
||||
const templateCalls = calls.filter((call) =>
|
||||
call.url.pathname.endsWith("/cgi-bin/externalcontact/add_msg_template"),
|
||||
);
|
||||
assert.equal(templateCalls.length, 2, "one template per owner");
|
||||
assert.equal(outcome.groupResults.length, 2);
|
||||
assert.equal(outcome.groupChatCount, 2);
|
||||
assert.equal(calls.filter((call) => call.url.pathname.endsWith("/cgi-bin/message/send")).length, 0);
|
||||
assert.deepEqual(outcome.skippedReasons, []);
|
||||
assert.equal(outcome.taskChat.sent, true);
|
||||
assert.equal(outcome.taskChat.chatId, "tgctask1");
|
||||
assert.equal(outcome.linkUrl, "https://portal.example.com/?task=tok-123");
|
||||
} finally {
|
||||
delete process.env.KOC_PORTAL_URL;
|
||||
}
|
||||
});
|
||||
|
||||
test("single member failure does not block the rest of the push", async () => {
|
||||
test("pushProjectTask falls back to per-member messages when main chat missing", async () => {
|
||||
process.env.KOC_PORTAL_URL = "https://portal.example.com";
|
||||
try {
|
||||
const db = fakePushDb();
|
||||
const db = fakePushDb({ project: { ...projectRow, main_chat_id: "" } });
|
||||
const chats = new Map();
|
||||
const calls = [];
|
||||
const outcome = await pushProjectTask(
|
||||
db,
|
||||
{ taskId: "task-1" },
|
||||
{ taskId: "task-1", sendMainGroup: true, sendGroupChats: true },
|
||||
resolveWecomConfig(baseBindings),
|
||||
fakeWecomFetch(calls, { failAppFor: ["zhangsan"] }),
|
||||
fakeWecomFetch(calls, chats),
|
||||
);
|
||||
assert.equal(outcome.mainChatMode, "per_member");
|
||||
assert.deepEqual(
|
||||
outcome.appResults.map((item) => [item.userid, item.ok]),
|
||||
[
|
||||
["zhangsan", false],
|
||||
["lisi", true],
|
||||
],
|
||||
outcome.appResults.map((item) => item.userid).sort(),
|
||||
["lisi", "owner-proj", "zhangsan"],
|
||||
);
|
||||
assert.match(outcome.appResults[0].error, /userid not found/);
|
||||
const templateCalls = calls.filter((call) =>
|
||||
call.url.pathname.endsWith("/cgi-bin/externalcontact/add_msg_template"),
|
||||
);
|
||||
assert.equal(templateCalls.length, 2, "group push still runs");
|
||||
assert.ok(outcome.appResults.every((item) => item.ok));
|
||||
assert.ok(outcome.skippedReasons.includes("项目总群未建,已逐人私信"));
|
||||
assert.equal(outcome.taskChat.sent, true);
|
||||
} finally {
|
||||
delete process.env.KOC_PORTAL_URL;
|
||||
}
|
||||
});
|
||||
|
||||
test("pushProjectTask skips empty targets instead of failing", async () => {
|
||||
test("single member failure does not block the rest of the per-member push", async () => {
|
||||
process.env.KOC_PORTAL_URL = "https://portal.example.com";
|
||||
try {
|
||||
const db = fakePushDb({ project: { ...projectRow, main_chat_id: "" } });
|
||||
const calls = [];
|
||||
const outcome = await pushProjectTask(
|
||||
db,
|
||||
{ taskId: "task-1", sendMainGroup: true, sendGroupChats: false },
|
||||
resolveWecomConfig(baseBindings),
|
||||
fakeWecomFetch(calls, new Map(), { failAppFor: ["zhangsan"] }),
|
||||
);
|
||||
const byUserid = Object.fromEntries(
|
||||
outcome.appResults.map((item) => [item.userid, item.ok]),
|
||||
);
|
||||
assert.equal(byUserid.zhangsan, false, "failed member recorded");
|
||||
assert.equal(byUserid.lisi, true, "other members still sent");
|
||||
assert.equal(byUserid["owner-proj"], true, "project owner also notified");
|
||||
assert.match(
|
||||
outcome.appResults.find((item) => !item.ok).error,
|
||||
/userid not found/,
|
||||
);
|
||||
} finally {
|
||||
delete process.env.KOC_PORTAL_URL;
|
||||
}
|
||||
});
|
||||
|
||||
test("main chat send failure degrades to per-member push", async () => {
|
||||
process.env.KOC_PORTAL_URL = "https://portal.example.com";
|
||||
try {
|
||||
const db = fakePushDb({
|
||||
project: { ...projectRow, main_chat_id: "pgcproj1" },
|
||||
});
|
||||
const chats = new Map([
|
||||
["pgcproj1", { name: "【总群】项目一", userlist: ["zhangsan"] }],
|
||||
]);
|
||||
const calls = [];
|
||||
const outcome = await pushProjectTask(
|
||||
db,
|
||||
{ taskId: "task-1", sendMainGroup: true, sendGroupChats: false },
|
||||
resolveWecomConfig(baseBindings),
|
||||
fakeWecomFetch(calls, chats, { failChatSend: ["pgcproj1"] }),
|
||||
);
|
||||
assert.equal(outcome.mainChatMode, "per_member");
|
||||
assert.ok(
|
||||
outcome.skippedReasons.some((reason) => /降级逐人私信/.test(reason)),
|
||||
);
|
||||
assert.equal(outcome.appResults.length, 3);
|
||||
} finally {
|
||||
delete process.env.KOC_PORTAL_URL;
|
||||
}
|
||||
});
|
||||
|
||||
test("task chat is skipped with reason when no owner at all", async () => {
|
||||
const db = fakePushDb({
|
||||
project: { ...projectRow, main_group_member_ids: "[]" },
|
||||
links: [{ project_id: "proj-1", chat_id: "wr-gone", partner_id: "" }],
|
||||
groupChats: [],
|
||||
task: { ...taskRow, owner_userid: "" },
|
||||
project: { ...projectRow, owner_userid: "", main_group_member_ids: "[]" },
|
||||
});
|
||||
const calls = [];
|
||||
const outcome = await pushProjectTask(
|
||||
db,
|
||||
{ taskId: "task-1" },
|
||||
{ taskId: "task-1", sendMainGroup: false, sendGroupChats: true },
|
||||
resolveWecomConfig(baseBindings),
|
||||
fakeWecomFetch(calls),
|
||||
fakeWecomFetch(calls, new Map()),
|
||||
);
|
||||
assert.deepEqual(outcome.skippedReasons, ["项目总群未配置成员", "项目没有可用的任务群"]);
|
||||
assert.equal(calls.length, 0, "no wecom api calls expected");
|
||||
assert.equal(outcome.taskChat.sent, false);
|
||||
assert.equal(outcome.taskChat.chatId, "");
|
||||
assert.equal(outcome.taskChat.reason, "任务与项目都未填负责人");
|
||||
assert.ok(outcome.skippedReasons.includes("任务群未发送:任务与项目都未填负责人"));
|
||||
assert.equal(calls.length, 0);
|
||||
});
|
||||
|
||||
test("task chat is skipped when fewer than 2 members", async () => {
|
||||
const db = fakePushDb({
|
||||
task: { ...taskRow, owner_userid: "" },
|
||||
project: { ...projectRow, owner_userid: "owner-proj", main_group_member_ids: "[]" },
|
||||
});
|
||||
const outcome = await pushProjectTask(
|
||||
db,
|
||||
{ taskId: "task-1", sendMainGroup: false, sendGroupChats: true },
|
||||
resolveWecomConfig(baseBindings),
|
||||
fakeWecomFetch([], new Map()),
|
||||
);
|
||||
assert.equal(outcome.taskChat.reason, "任务群成员不足 2 人");
|
||||
});
|
||||
|
||||
test("pushProjectTask rejects task without project or unknown task", async () => {
|
||||
@@ -241,7 +319,7 @@ test("pushProjectTask rejects task without project or unknown task", async () =>
|
||||
unlinked,
|
||||
{ taskId: "task-1" },
|
||||
resolveWecomConfig(baseBindings),
|
||||
fakeWecomFetch([]),
|
||||
fakeWecomFetch([], new Map()),
|
||||
),
|
||||
(error) => error instanceof ProjectError && /未挂项目/.test(error.message),
|
||||
);
|
||||
@@ -257,7 +335,7 @@ test("pushProjectTask rejects task without project or unknown task", async () =>
|
||||
fakePushDb(),
|
||||
{ taskId: "task-1", sendMainGroup: false, sendGroupChats: false },
|
||||
resolveWecomConfig(baseBindings),
|
||||
fakeWecomFetch([]),
|
||||
fakeWecomFetch([], new Map()),
|
||||
),
|
||||
(error) => error instanceof ProjectError && /至少选择一个发送目标/.test(error.message),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user