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

@@ -5,6 +5,7 @@ export type ProjectRow = {
name: string;
brand: string;
main_group_member_ids: string;
owner_userid: string;
status: string;
note: string;
created_at: string;
@@ -18,6 +19,7 @@ export type ProjectSummary = {
note: string;
createdAt: string;
mainGroupMemberIds: string[];
ownerUserid: string;
taskCount: number;
groupChatCount: number;
};
@@ -95,7 +97,7 @@ function parseJsonArray(value: string): string[] {
export async function listProjects(db: DatabaseClient): Promise<ProjectSummary[]> {
const result = await db
.prepare(
`SELECT p.id, p.name, p.brand, p.main_group_member_ids, p.status, p.note, p.created_at,
`SELECT p.id, p.name, p.brand, p.main_group_member_ids, p.owner_userid, p.status, p.note, p.created_at,
(SELECT COUNT(*) FROM tasks t WHERE t.project_id = p.id) AS task_count,
(SELECT COUNT(*) FROM project_group_chats g WHERE g.project_id = p.id) AS group_chat_count
FROM projects p
@@ -110,6 +112,7 @@ export async function listProjects(db: DatabaseClient): Promise<ProjectSummary[]
note: row.note,
createdAt: row.created_at,
mainGroupMemberIds: parseJsonArray(row.main_group_member_ids),
ownerUserid: row.owner_userid ?? "",
taskCount: Number(row.task_count),
groupChatCount: Number(row.group_chat_count),
}));
@@ -122,7 +125,7 @@ export async function getProjectDetail(
const id = text(projectId);
const project = await db
.prepare(
`SELECT id, name, brand, main_group_member_ids, status, note, created_at
`SELECT id, name, brand, main_group_member_ids, owner_userid, status, note, created_at
FROM projects WHERE id = ?`,
)
.bind(id)
@@ -170,6 +173,7 @@ export async function getProjectDetail(
note: project.note,
createdAt: project.created_at,
mainGroupMemberIds: parseJsonArray(project.main_group_member_ids),
ownerUserid: project.owner_userid ?? "",
taskCount: tasks.results.length,
groupChatCount: chats.results.length,
},
@@ -192,6 +196,7 @@ export type SaveProjectInput = {
name?: unknown;
brand?: unknown;
mainGroupMemberIds?: unknown;
ownerUserid?: unknown;
note?: unknown;
};
@@ -204,6 +209,7 @@ export async function saveProject(
const brand = text(input.brand);
const note = text(input.note ?? "");
const memberIds = parseMemberIds(input.mainGroupMemberIds);
const ownerUserid = text(input.ownerUserid).slice(0, 64);
if (!name) throw new ProjectError("请填写项目名称");
const now = new Date().toISOString();
@@ -212,10 +218,10 @@ export async function saveProject(
const result = await db
.prepare(
`UPDATE projects
SET name = ?, brand = ?, main_group_member_ids = ?, note = ?
SET name = ?, brand = ?, main_group_member_ids = ?, owner_userid = ?, note = ?
WHERE id = ?`,
)
.bind(name, brand, JSON.stringify(memberIds), note, id)
.bind(name, brand, JSON.stringify(memberIds), ownerUserid, note, id)
.run();
if (result.meta.changes === 0) throw new ProjectError("项目不存在", 404);
} else {
@@ -223,14 +229,15 @@ export async function saveProject(
await db
.prepare(
`INSERT INTO projects
(id, name, brand, main_group_member_ids, status, note, created_at)
VALUES (?, ?, ?, ?, 'active', ?, ?)`,
(id, name, brand, main_group_member_ids, owner_userid, status, note, created_at)
VALUES (?, ?, ?, ?, ?, 'active', ?, ?)`,
)
.bind(
projectId,
name,
brand,
JSON.stringify(memberIds),
ownerUserid,
note,
now,
)

158
lib/task-group-service.ts Normal file
View 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");
}

View File

@@ -96,7 +96,7 @@ function parseDueDays(value: string | undefined) {
return Math.min(30, Math.floor(parsed));
}
function hasAppCredentials(config: WecomConfig) {
export function hasAppCredentials(config: WecomConfig) {
return Boolean(config.corpId && config.agentId && config.secret);
}
@@ -635,3 +635,135 @@ export async function fetchWecomExternalContactName(
return null;
}
}
async function postAppChat(
config: WecomConfig,
path: string,
body: Record<string, unknown>,
errorLabel: string,
fetchImpl: FetchLike,
): Promise<Record<string, unknown>> {
if (!hasAppCredentials(config)) {
throw new WecomClientError(
"企业微信自建应用未配置(WECOM_CORP_ID / WECOM_AGENT_ID / WECOM_SECRET)",
400,
);
}
const token = await fetchAccessToken(config, fetchImpl);
const url = `${WECOM_API_ORIGIN}${path}?access_token=${encodeURIComponent(token)}`;
const response = await fetchImpl(url, {
method: "POST",
headers: { "Content-Type": "application/json; charset=utf-8" },
body: JSON.stringify(body),
signal: AbortSignal.timeout(12_000),
});
const payload = await readEnvelope(response, errorLabel);
if (!response.ok) {
throw new WecomClientError(`${errorLabel}(HTTP ${response.status})`, 502);
}
ensureOk(payload, errorLabel);
return payload;
}
export async function createAppChat(
config: WecomConfig,
params: { name: string; userlist: string[]; chatid: string },
fetchImpl: FetchLike = fetch,
): Promise<{ chatid: string }> {
const userlist = params.userlist
.map((id) => bindingValue(id))
.filter((id) => id.length > 0);
if (userlist.length < 2) {
throw new WecomClientError("创建群聊会话至少需要 2 名成员", 400);
}
const chatid = bindingValue(params.chatid);
if (!chatid || chatid.length > 32) {
throw new WecomClientError("群聊 chatid 缺失或超过 32 字符", 400);
}
const name = bindingValue(params.name).slice(0, 20);
const payload = await postAppChat(
config,
"/cgi-bin/appchat/create",
{ name, owner: Number(config.agentId), userlist, chatid },
"创建企业微信群聊会话失败",
fetchImpl,
);
return { chatid: bindingValue(payload.chatid) || chatid };
}
export async function updateAppChat(
config: WecomConfig,
params: {
chatid: string;
addUserList?: string[];
delUserList?: string[];
},
fetchImpl: FetchLike = fetch,
): Promise<void> {
const body: Record<string, unknown> = {
chatid: bindingValue(params.chatid),
};
const addUserList = (params.addUserList ?? [])
.map((id) => bindingValue(id))
.filter((id) => id.length > 0);
const delUserList = (params.delUserList ?? [])
.map((id) => bindingValue(id))
.filter((id) => id.length > 0);
if (addUserList.length === 0 && delUserList.length === 0) return;
if (addUserList.length > 0) body.add_user_list = addUserList;
if (delUserList.length > 0) body.del_user_list = delUserList;
await postAppChat(
config,
"/cgi-bin/appchat/update",
body,
"更新企业微信群聊会话失败",
fetchImpl,
);
}
export async function getAppChat(
config: WecomConfig,
chatid: string,
fetchImpl: FetchLike = fetch,
): Promise<{ chatId: string; name: string; userlist: string[] } | null> {
try {
const payload = await postAppChat(
config,
"/cgi-bin/appchat/get",
{ chatid: bindingValue(chatid) },
"获取企业微信群聊会话失败",
fetchImpl,
);
const detail = payload.chat_info as
| { chatid?: unknown; name?: unknown; userlist?: unknown[] }
| undefined;
if (!detail) return null;
return {
chatId: bindingValue(detail.chatid),
name: bindingValue(detail.name),
userlist: Array.isArray(detail.userlist)
? detail.userlist.map((id: unknown) => bindingValue(id)).filter(Boolean)
: [],
};
} catch {
return null;
}
}
export async function sendAppChatMessage(
config: WecomConfig,
params: { chatid: string; text: string },
fetchImpl: FetchLike = fetch,
): Promise<void> {
const chatid = bindingValue(params.chatid);
const content = bindingValue(params.text);
if (!chatid || !content) return;
await postAppChat(
config,
"/cgi-bin/appchat/send",
{ chatid, safe: 0, msgtype: "text", text: { content } },
"发送企业微信群聊消息失败",
fetchImpl,
);
}

View File

@@ -6,6 +6,7 @@ import {
sendWecomTemplateCard,
type WecomConfig,
} from "./wecom-client";
import { sendTaskChatMessage } from "./task-group-service";
import { getRuntimeEnv } from "./runtime-env";
import { releaseUnfinishedDistribution } from "./distribution-release-service";
@@ -24,6 +25,7 @@ type DueSoonRow = {
partner_id: string;
partner_name: string;
wecom_external_user_id: string | null;
task_id: string;
task_name: string;
due_at: string;
content_title: string;
@@ -56,6 +58,7 @@ export async function runDueSoonWecomNotifications(
d.partner_id,
p.name AS partner_name,
p.wecom_external_user_id,
t.id AS task_id,
t.name AS task_name,
t.due_at,
c.title AS content_title
@@ -140,6 +143,31 @@ export async function runDueSoonWecomNotifications(
);
}
const byTask = new Map<
string,
{ taskName: string; dueAt: string; rows: DueSoonRow[] }
>();
for (const row of result.results) {
const entry = byTask.get(row.task_id) ?? {
taskName: row.task_name,
dueAt: row.due_at,
rows: [],
};
entry.rows.push(row);
byTask.set(row.task_id, entry);
}
for (const [taskId, entry] of byTask) {
const partners = [...new Set(entry.rows.map((row) => row.partner_name))];
const overflow = partners.length > 5 ? ` 等 ${partners.length} 位` : "";
await sendTaskChatMessage(
db,
taskId,
`【催办】《${truncate(entry.taskName, 24)}》有 ${entry.rows.length} 条内容待发布(截止 ${entry.dueAt}):\n${partners.slice(0, 5).join("、")}${overflow}`,
config,
fetchImpl,
);
}
let digestSent = false;
if (config.robotWebhook && digestTasks.length > 0) {
const digest =
@@ -276,6 +304,13 @@ export async function runOverdueEscalation(
const env = getRuntimeEnv();
const cardUrl = bindingValue(env.APP_ORIGIN) || bindingValue(env.KOC_PORTAL_URL);
for (const [taskId, entry] of byTask) {
await sendTaskChatMessage(
db,
taskId,
`【超期催办】《${truncate(entry.taskName, 24)}》(截止 ${entry.dueAt})有 ${entry.count} 条内容超期未发布,请跟进催办或释放。`,
config,
fetchImpl,
);
if (!entry.projectId) continue;
try {
const project = await db

View File

@@ -8,6 +8,7 @@ const WECOM_IDENTITY_GRACE_MS = 60 * 1_000;
type WecomIdentityPayload = {
v: 1;
ext: string;
uid?: string;
name: string;
iat: number;
exp: number;
@@ -17,6 +18,7 @@ type WecomIdentityPayload = {
export type WecomIdentity = {
externalUserId: string;
displayName: string;
userId?: string;
};
export function getWecomOAuthSecret(): string {
@@ -74,13 +76,19 @@ function constantTimeEquals(a: Uint8Array, b: Uint8Array): boolean {
export async function signWecomIdentityTicket(
externalUserId: string,
displayName: string,
options: { secret?: string; nowMs?: number; ttlMs?: number } = {},
options: {
secret?: string;
nowMs?: number;
ttlMs?: number;
userId?: string;
} = {},
): Promise<{ ticket: string; expiresIn: number }> {
const secret = options.secret ?? getWecomOAuthSecret();
const issuedAt = options.nowMs ?? Date.now();
const ttl = options.ttlMs ?? WECOM_IDENTITY_TTL_MS;
const ext = String(externalUserId ?? "").trim();
if (!ext) throw new Error("缺少企微外部联系人 ID");
const uid = String(options.userId ?? "").trim().slice(0, 64);
if (!ext && !uid) throw new Error("缺少企微身份 ID(外部联系人或内部成员)");
const payload: WecomIdentityPayload = {
v: 1,
ext,
@@ -89,6 +97,7 @@ export async function signWecomIdentityTicket(
exp: issuedAt + ttl,
jti: toBase64Url(crypto.getRandomValues(new Uint8Array(12))),
};
if (uid) payload.uid = uid;
const payloadSegment = toBase64Url(
new TextEncoder().encode(JSON.stringify(payload)),
);
@@ -122,11 +131,14 @@ export async function verifyWecomIdentityTicket(
if (
payload?.v !== 1 ||
typeof payload.ext !== "string" ||
!payload.ext.trim() ||
typeof payload.exp !== "number"
) {
return null;
}
const extId = payload.ext.trim();
const uid =
typeof payload.uid === "string" ? payload.uid.trim().slice(0, 64) : "";
if (!extId && !uid) return null;
const secret = options.secret ?? getWecomOAuthSecret();
const expected = await hmacSha256(secret, payloadSegment);
const provided = fromBase64Url(signatureSegment);
@@ -134,8 +146,9 @@ export async function verifyWecomIdentityTicket(
const now = options.nowMs ?? Date.now();
if (now > payload.exp + WECOM_IDENTITY_GRACE_MS) return null;
return {
externalUserId: payload.ext.trim(),
externalUserId: extId,
displayName: payload.name || "企微客户",
...(uid ? { userId: uid } : {}),
};
}