Files
koc-loop/lib/task-group-service.ts
ABAPPLO dd5b603149 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 渲染/解绑清空)
2026-09-04 18:03:46 +08:00

268 lines
7.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { DatabaseClient } from "./database";
import {
effectiveProjectMembers,
ensureProjectMainChat,
findOrCreateGroupOwnerPartner,
} from "./project-service";
import {
createAppChat,
getAppChat,
hasAppCredentials,
sanitizeChatid,
sendAppChatMessage,
updateAppChat,
type WecomConfig,
} from "./wecom-client";
type FetchLike = typeof fetch;
export type TaskChatEnsureResult = {
chatId: string | null;
created: boolean;
reason?: "no_task" | "no_owner" | "not_enough_members" | "no_credentials";
};
type TaskChatRow = {
id: string;
name: string;
task_chat_id: string | null;
task_owner_userid: string | null;
project_owner_userid: string | null;
};
function text(value: unknown) {
return String(value ?? "").trim();
}
export function taskChatId(taskId: string) {
return `tgc${sanitizeChatid(text(taskId))}`;
}
function chatNameForTask(taskName: string) {
return `【任务】${text(taskName)}`.slice(0, 20);
}
async function syncMembers(
config: WecomConfig,
chatid: string,
members: string[],
fetchImpl: FetchLike,
) {
const info = await getAppChat(config, chatid, fetchImpl);
if (!info) {
// appchat/get 要求应用可见范围为根部门,查不到时降级为只增同步(重复添加成员企微按成功处理)
await updateAppChat(config, { chatid, addUserList: members }, fetchImpl);
return;
}
const existing = new Set(info.userlist);
const missing = members.filter((userid) => !existing.has(userid));
if (missing.length > 0) {
await updateAppChat(config, { chatid, addUserList: missing }, fetchImpl);
}
}
export async function ensureTaskChatForMembers(
db: DatabaseClient,
params: { taskId: string; addUserids?: string[] },
config: WecomConfig,
fetchImpl: FetchLike = fetch,
): Promise<TaskChatEnsureResult> {
const taskId = text(params.taskId);
if (!taskId) return { chatId: null, created: false, reason: "no_task" };
if (!hasAppCredentials(config)) {
return { chatId: null, created: false, reason: "no_credentials" };
}
const task = await db
.prepare(
`SELECT t.id, t.name, t.task_chat_id, t.owner_userid AS task_owner_userid,
p.owner_userid AS project_owner_userid
FROM tasks t
LEFT JOIN projects p ON p.id = t.project_id
WHERE t.id = ?`,
)
.bind(taskId)
.first<TaskChatRow>();
if (!task) return { chatId: null, created: false, reason: "no_task" };
const ownerUserid = text(task.project_owner_userid);
const taskOwnerUserid = text(task.task_owner_userid);
if (!ownerUserid && !taskOwnerUserid) {
return { chatId: null, created: false, reason: "no_owner" };
}
const members = [
...new Set(
[
ownerUserid,
taskOwnerUserid,
...(params.addUserids ?? []).map((id) => text(id)),
].filter(Boolean),
),
];
let chatId = text(task.task_chat_id);
let createdNow = false;
if (!chatId) {
chatId = taskChatId(taskId);
const existing = await getAppChat(config, chatId, fetchImpl);
if (!existing) {
if (members.length < 2) {
return { chatId: null, created: false, reason: "not_enough_members" };
}
const created = await createAppChat(
config,
{ name: chatNameForTask(task.name), userlist: members, chatid: chatId },
fetchImpl,
);
chatId = created.chatid;
createdNow = true;
}
await db
.prepare("UPDATE tasks SET task_chat_id = ? WHERE id = ?")
.bind(chatId, taskId)
.run();
}
await syncMembers(config, chatId, members, fetchImpl);
return { chatId, created: createdNow };
}
export type TaskOwnerAssignment = {
ownerUserid: string;
partnerId: string;
chatId: string | null;
chatCreated: boolean;
warning?: string;
};
export async function assignTaskOwner(
db: DatabaseClient,
params: { taskId: string; ownerUserid: string },
config: WecomConfig,
fetchImpl: FetchLike = fetch,
): Promise<TaskOwnerAssignment> {
const taskId = text(params.taskId);
const ownerUserid = text(params.ownerUserid).slice(0, 64);
if (!taskId) throw new Error("缺少 taskId");
if (!ownerUserid) throw new Error("请填写任务负责人");
const task = await db
.prepare(
"SELECT id, name, brand, quantity, due_at, project_id FROM tasks WHERE id = ?",
)
.bind(taskId)
.first<{
id: string;
name: string;
brand: string;
quantity: number;
due_at: string;
project_id: string;
}>();
if (!task) throw new Error("任务不存在");
await db
.prepare("UPDATE tasks SET owner_userid = ? WHERE id = ?")
.bind(ownerUserid, taskId)
.run();
const partner = await findOrCreateGroupOwnerPartner(db, ownerUserid);
const warnings: string[] = [];
const projectId = text(task.project_id);
if (projectId) {
const project = await db
.prepare(
"SELECT main_group_member_ids, owner_userid FROM projects WHERE id = ?",
)
.bind(projectId)
.first<{ main_group_member_ids: string; owner_userid: string }>();
if (project) {
// 负责人不在成员列表则并入,保持「负责人都在总群」
const stored = effectiveProjectMembers(project.main_group_member_ids, "");
if (!stored.includes(ownerUserid)) {
await db
.prepare("UPDATE projects SET main_group_member_ids = ? WHERE id = ?")
.bind(JSON.stringify([...stored, ownerUserid]), projectId)
.run();
}
const mainChat = await ensureProjectMainChat(db, projectId, config, fetchImpl);
if (mainChat.warning) warnings.push(mainChat.warning);
}
}
let ensure: TaskChatEnsureResult = { chatId: null, created: false };
try {
ensure = await ensureTaskChatForMembers(db, { taskId }, config, fetchImpl);
if (ensure.chatId && ensure.created) {
await sendTaskChatMessage(
db,
taskId,
taskChatIntroText({
name: task.name,
brand: task.brand,
quantity: task.quantity,
dueAt: task.due_at,
}),
config,
fetchImpl,
);
}
} catch (error) {
warnings.push(`任务群创建失败:${error instanceof Error ? error.message : String(error)}`);
}
return {
ownerUserid,
partnerId: partner.id,
chatId: ensure.chatId,
chatCreated: ensure.created,
warning: warnings.length > 0 ? warnings.join(";") : undefined,
};
}
export async function sendTaskChatMessage(
db: DatabaseClient,
taskId: string,
message: string,
config: WecomConfig,
fetchImpl: FetchLike = fetch,
): Promise<boolean> {
try {
const content = text(message);
if (!content) return false;
const row = await db
.prepare("SELECT task_chat_id FROM tasks WHERE id = ?")
.bind(text(taskId))
.first<{ task_chat_id: string | null }>();
const chatId = text(row?.task_chat_id);
if (!chatId) return false;
await sendAppChatMessage(config, { chatid: chatId, text: content }, fetchImpl);
return true;
} catch (error) {
console.error(
`[KOC LOOP] 任务群消息发送失败(task=${taskId})`,
error instanceof Error ? error.message : error,
);
return false;
}
}
export function taskClaimBroadcastText(
taskName: string,
claimantName: string,
quantity: number,
) {
return `【领取】${text(claimantName)} 认领 ${quantity} 份 · ${text(taskName)}`;
}
export function taskChatIntroText(task: {
name: string;
brand: string;
quantity: number;
dueAt: string;
}) {
return [
`【新任务】${text(task.name)}`,
`品牌:${text(task.brand)}|数量:${task.quantity} 份|截止:${text(task.dueAt)}`,
].join("\n");
}