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:
158
lib/task-group-service.ts
Normal file
158
lib/task-group-service.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
import type { DatabaseClient } from "./database";
|
||||
import {
|
||||
createAppChat,
|
||||
getAppChat,
|
||||
hasAppCredentials,
|
||||
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;
|
||||
owner_userid: string | null;
|
||||
};
|
||||
|
||||
function text(value: unknown) {
|
||||
return String(value ?? "").trim();
|
||||
}
|
||||
|
||||
export function taskChatId(taskId: string) {
|
||||
return `tgc_${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) 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, p.owner_userid AS 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.owner_userid);
|
||||
if (!ownerUserid) {
|
||||
return { chatId: null, created: false, reason: "no_owner" };
|
||||
}
|
||||
const members = [
|
||||
...new Set(
|
||||
[ownerUserid, ...(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 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");
|
||||
}
|
||||
Reference in New Issue
Block a user