feat: 新增项目中心——按项目组织任务/任务群/总群并一键推送

- 新表 projects / project_group_chats,partners 加 partner_type/wecom_userid,tasks 加 project_id(迁移 0010)
- 项目 CRUD、总群成员配置、任务群人工映射(群主→合作方)、任务挂项目
- 推送:总群成员逐人应用消息 + 任务群按群主企业群发,结果分项回显
This commit is contained in:
ABAPPLO
2026-08-31 16:35:17 +08:00
parent ed13262166
commit a6f5a5fd4a
11 changed files with 2557 additions and 15 deletions

264
tests/project-push.test.mjs Normal file
View File

@@ -0,0 +1,264 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
buildProjectAppMessage,
pushProjectTask,
} from "../lib/project-push-service.ts";
import { ProjectError } from "../lib/project-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-01",
task_type: "content_publish",
platform: "小红书",
content_format: "image_text",
share_token: "tok-123",
project_id: "proj-1",
claimed_quantity: 0,
status: "active",
};
const projectRow = {
id: "proj-1",
name: "项目一",
brand: "品牌A",
main_group_member_ids: JSON.stringify(["zhangsan", "lisi"]),
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 },
],
} = {}) {
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 projects WHERE id = \?/.test(sql)) return project;
return null;
},
async all() {
executed.push({ sql, params });
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,
prepare(sql) {
return makeStatement(sql);
},
async batch(statements) {
for (const statement of statements) await statement.run();
},
};
}
function fakeWecomFetch(calls, { failAppFor = [] } = {}) {
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 (failAppFor.includes(body.touser)) {
return Response.json({ errcode: 60111, errmsg: "userid not found" });
}
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: [] });
}
return new Response("not found", { status: 404 });
};
}
test.beforeEach(() => {
clearWecomAccessTokenCacheForTests();
});
test("buildProjectAppMessage targets internal members with claim url", () => {
const message = buildProjectAppMessage(taskRow, "https://portal.example.com/");
assert.match(message, /【项目任务】测试任务/);
assert.match(message, /请把任务转发到你负责的项目任务群/);
assert.match(message, /领取链接:https:\/\/portal\.example\.com\/\?task=tok-123/);
});
test("pushProjectTask sends per-member app messages and group templates", async () => {
process.env.KOC_PORTAL_URL = "https://portal.example.com";
try {
const db = fakePushDb();
const calls = [];
const outcome = await pushProjectTask(
db,
{ taskId: "task-1", sendMainGroup: true, sendGroupChats: true },
resolveWecomConfig(baseBindings),
fakeWecomFetch(calls),
);
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),
["zhangsan", "lisi"],
"one message/send per main group member",
);
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.deepEqual(outcome.skippedReasons, []);
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 () => {
process.env.KOC_PORTAL_URL = "https://portal.example.com";
try {
const db = fakePushDb();
const calls = [];
const outcome = await pushProjectTask(
db,
{ taskId: "task-1" },
resolveWecomConfig(baseBindings),
fakeWecomFetch(calls, { failAppFor: ["zhangsan"] }),
);
assert.deepEqual(
outcome.appResults.map((item) => [item.userid, item.ok]),
[
["zhangsan", false],
["lisi", true],
],
);
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");
} finally {
delete process.env.KOC_PORTAL_URL;
}
});
test("pushProjectTask skips empty targets instead of failing", async () => {
const db = fakePushDb({
project: { ...projectRow, main_group_member_ids: "[]" },
links: [{ project_id: "proj-1", chat_id: "wr-gone", partner_id: "" }],
groupChats: [],
});
const calls = [];
const outcome = await pushProjectTask(
db,
{ taskId: "task-1" },
resolveWecomConfig(baseBindings),
fakeWecomFetch(calls),
);
assert.deepEqual(outcome.skippedReasons, ["项目总群未配置成员", "项目没有可用的任务群"]);
assert.equal(calls.length, 0, "no wecom api calls expected");
});
test("pushProjectTask rejects task without project or unknown task", async () => {
const unlinked = fakePushDb({ task: { ...taskRow, project_id: "" } });
await assert.rejects(
() =>
pushProjectTask(
unlinked,
{ taskId: "task-1" },
resolveWecomConfig(baseBindings),
fakeWecomFetch([]),
),
(error) => error instanceof ProjectError && /未挂项目/.test(error.message),
);
await assert.rejects(
() => pushProjectTask(fakePushDb(), { taskId: "" }, resolveWecomConfig(baseBindings)),
(error) => error instanceof ProjectError,
);
await assert.rejects(
() =>
pushProjectTask(
fakePushDb(),
{ taskId: "task-1", sendMainGroup: false, sendGroupChats: false },
resolveWecomConfig(baseBindings),
fakeWecomFetch([]),
),
(error) => error instanceof ProjectError && /至少选择一个发送目标/.test(error.message),
);
});

View File

@@ -0,0 +1,378 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
deleteProject,
getProjectDetail,
listProjects,
parseMemberIds,
ProjectError,
saveGroupOwnerPartner,
saveProject,
setProjectGroupChats,
} from "../lib/project-service.ts";
function fakeProjectDb({
projects = [],
links = [],
tasks = [],
groupChats = [],
partners = [],
} = {}) {
const executed = [];
const applyWrite = (sql, params) => {
const normalized = sql.trim();
if (/^INSERT INTO projects/.test(normalized)) {
projects.push({
id: params[0],
name: params[1],
brand: params[2],
main_group_member_ids: params[3],
status: "active",
note: params[4],
created_at: params[5],
});
return 1;
}
if (/^UPDATE projects/.test(normalized)) {
const row = projects.find((item) => item.id === params[4]);
if (!row) return 0;
Object.assign(row, {
name: params[0],
brand: params[1],
main_group_member_ids: params[2],
note: params[3],
});
return 1;
}
if (/^DELETE FROM projects/.test(normalized)) {
const index = projects.findIndex((item) => item.id === params[0]);
if (index < 0) return 0;
projects.splice(index, 1);
return 1;
}
if (/^INSERT INTO project_group_chats/.test(normalized)) {
links.push({ project_id: params[1], chat_id: params[2], partner_id: params[3] });
return 1;
}
if (/^DELETE FROM project_group_chats/.test(normalized)) {
for (let index = links.length - 1; index >= 0; index -= 1) {
if (links[index].project_id === params[0]) links.splice(index, 1);
}
return 1;
}
if (/^UPDATE tasks SET project_id = ''/.test(normalized)) {
for (const task of tasks) {
if (task.project_id === params[0]) task.project_id = "";
}
return 1;
}
if (/^INSERT INTO partners/.test(normalized)) {
partners.push({
id: params[0],
name: params[1],
wecom_userid: params[2],
partner_type: "group_owner",
});
return 1;
}
if (/^UPDATE partners/.test(normalized)) {
const row = partners.find((item) => item.id === params[2]);
if (!row) return 0;
Object.assign(row, { name: params[0], wecom_userid: params[1] });
return 1;
}
return 1;
};
const makeStatement = (sql, params = []) => ({
sql,
params,
bind(...args) {
return makeStatement(sql, args);
},
async run() {
executed.push({ sql, params });
return { meta: { changes: applyWrite(sql, params) } };
},
async first() {
executed.push({ sql, params });
if (/FROM projects WHERE id = \?/.test(sql)) {
return projects.find((item) => item.id === params[0]) ?? null;
}
if (/FROM partners WHERE id = \?/.test(sql)) {
return partners.find((item) => item.id === params[0]) ?? null;
}
return null;
},
async all() {
executed.push({ sql, params });
if (/FROM projects p/.test(sql)) {
return {
results: projects.map((item) => ({
...item,
task_count: tasks.filter((task) => task.project_id === item.id).length,
group_chat_count: links.filter((link) => link.project_id === item.id).length,
})),
};
}
if (/FROM tasks WHERE project_id/.test(sql)) {
return {
results: tasks
.filter((task) => task.project_id === params[0])
.map((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:
partners.find((item) => item.id === link.partner_id)?.name ?? null,
};
}),
};
}
if (/FROM partners/.test(sql)) {
return { results: partners.map((item) => ({ ...item })) };
}
return { results: [] };
},
});
return {
projects,
links,
tasks,
groupChats,
partners,
executed,
prepare(sql) {
return makeStatement(sql);
},
async batch(statements) {
const output = [];
for (const statement of statements) output.push(await statement.run());
return output;
},
};
}
test("parseMemberIds accepts lines, arrays and mixed separators, dedupes", () => {
assert.deepEqual(parseMemberIds("zhangsan\nlisi zhangsan"), ["zhangsan", "lisi"]);
assert.deepEqual(parseMemberIds(["a", " b ", "a"]), ["a", "b"]);
assert.deepEqual(parseMemberIds("a,b;c,d"), ["a", "b", "c", "d"]);
assert.deepEqual(parseMemberIds(" \n "), []);
});
test("saveProject inserts with proj- prefixed id and JSON members", async () => {
const db = fakeProjectDb();
const project = await saveProject(db, {
name: "暑期焕新",
brand: "青柠实验室",
mainGroupMemberIds: "zhangsan\nlisi\nzhangsan",
note: "备注",
});
assert.match(project.id, /^proj-/);
assert.equal(project.name, "暑期焕新");
assert.deepEqual(project.mainGroupMemberIds, ["zhangsan", "lisi"]);
assert.equal(db.projects.length, 1);
assert.equal(db.projects[0].status, "active");
});
test("saveProject updates existing row and rejects blank name", async () => {
const db = fakeProjectDb({
projects: [
{
id: "proj-1",
name: "旧名",
brand: "",
main_group_member_ids: "[]",
status: "active",
note: "",
created_at: "2026-08-01",
},
],
});
await saveProject(db, {
id: "proj-1",
name: "新名",
brand: "品牌",
mainGroupMemberIds: ["wangwu"],
});
assert.equal(db.projects[0].name, "新名");
assert.equal(db.projects[0].brand, "品牌");
assert.deepEqual(JSON.parse(db.projects[0].main_group_member_ids), ["wangwu"]);
assert.equal(db.projects.length, 1, "update must not insert");
await assert.rejects(
() => saveProject(db, { id: "proj-1", name: " " }),
(error) => error instanceof ProjectError && error.status === 400,
);
});
test("saveProject rejects unknown id on update", async () => {
const db = fakeProjectDb();
await assert.rejects(
() => saveProject(db, { id: "proj-404", name: "x" }),
(error) => error instanceof ProjectError && error.status === 404,
);
});
test("listProjects aggregates task and group chat counts", async () => {
const db = fakeProjectDb({
projects: [
{
id: "proj-1",
name: "A",
brand: "",
main_group_member_ids: "[\"u1\",\"u2\"]",
status: "active",
note: "",
created_at: "2026-08-01",
},
],
tasks: [
{ id: "t1", project_id: "proj-1" },
{ id: "t2", project_id: "proj-1" },
{ id: "t3", project_id: "" },
],
links: [{ project_id: "proj-1", chat_id: "wr-1", partner_id: "" }],
});
const projects = await listProjects(db);
assert.equal(projects.length, 1);
assert.equal(projects[0].taskCount, 2);
assert.equal(projects[0].groupChatCount, 1);
assert.deepEqual(projects[0].mainGroupMemberIds, ["u1", "u2"]);
});
test("getProjectDetail marks missing chats and dangling partners", async () => {
const db = fakeProjectDb({
projects: [
{
id: "proj-1",
name: "A",
brand: "",
main_group_member_ids: "[]",
status: "active",
note: "",
created_at: "2026-08-01",
},
],
links: [
{ project_id: "proj-1", chat_id: "wr-1", partner_id: "p-1" },
{ project_id: "proj-1", chat_id: "wr-gone", partner_id: "" },
],
groupChats: [
{ chat_id: "wr-1", name: "一群", owner_user_id: "zhangsan", member_count: 12, status: 0 },
],
partners: [],
});
const detail = await getProjectDetail(db, "proj-1");
assert.equal(detail.groupChats.length, 2);
const alive = detail.groupChats.find((chat) => chat.chatId === "wr-1");
assert.equal(alive.missing, false);
assert.equal(alive.partnerName, "", "dangling partner shows blank name");
const gone = detail.groupChats.find((chat) => chat.chatId === "wr-gone");
assert.equal(gone.missing, true);
assert.equal(gone.name, "");
});
test("getProjectDetail throws 404 for unknown project", async () => {
const db = fakeProjectDb();
await assert.rejects(
() => getProjectDetail(db, "proj-404"),
(error) => error instanceof ProjectError && error.status === 404,
);
});
test("setProjectGroupChats replaces links and dedupes chat ids", async () => {
const db = fakeProjectDb({
projects: [
{
id: "proj-1",
name: "A",
brand: "",
main_group_member_ids: "[]",
status: "active",
note: "",
created_at: "2026-08-01",
},
],
links: [{ project_id: "proj-1", chat_id: "wr-old", partner_id: "" }],
groupChats: [
{ chat_id: "wr-1", name: "一群", owner_user_id: "zhangsan", member_count: 12, status: 0 },
],
partners: [{ id: "p-1", name: "张三", wecom_userid: "zhangsan" }],
});
const detail = await setProjectGroupChats(db, "proj-1", [
{ chatId: "wr-1", partnerId: "p-1" },
{ chatId: "wr-1", partnerId: "p-1" },
{ chatId: " ", partnerId: "" },
]);
assert.equal(detail.groupChats.length, 1);
assert.equal(detail.groupChats[0].chatId, "wr-1");
assert.equal(detail.groupChats[0].partnerName, "张三");
assert.equal(db.links.length, 1);
assert.equal(db.links[0].chat_id, "wr-1");
});
test("deleteProject removes links and unlinks tasks", async () => {
const db = fakeProjectDb({
projects: [
{
id: "proj-1",
name: "A",
brand: "",
main_group_member_ids: "[]",
status: "active",
note: "",
created_at: "2026-08-01",
},
],
links: [{ project_id: "proj-1", chat_id: "wr-1", partner_id: "" }],
tasks: [
{ id: "t1", project_id: "proj-1" },
{ id: "t2", project_id: "proj-2" },
],
});
await deleteProject(db, "proj-1");
assert.equal(db.projects.length, 0);
assert.equal(db.links.length, 0);
assert.equal(db.tasks[0].project_id, "");
assert.equal(db.tasks[1].project_id, "proj-2");
await assert.rejects(
() => deleteProject(db, "proj-404"),
(error) => error instanceof ProjectError && error.status === 404,
);
});
test("saveGroupOwnerPartner inserts and updates group_owner partners", async () => {
const db = fakeProjectDb();
const created = await saveGroupOwnerPartner(db, {
name: "张三",
wecomUserid: "zhangsan",
});
assert.match(created.id, /^partner-go-/);
assert.equal(db.partners[0].partner_type, "group_owner");
const updated = await saveGroupOwnerPartner(db, {
id: created.id,
name: "张三丰",
wecomUserid: "zhangsanfeng",
});
assert.equal(updated.name, "张三丰");
assert.equal(db.partners.length, 1);
await assert.rejects(
() => saveGroupOwnerPartner(db, { name: "李四", wecomUserid: "" }),
(error) => error instanceof ProjectError,
);
});