- 任务中心新增「发布到企微群」:同步客户群清单(wecom_group_chats)、 按群主分组创建企业群发任务(add_msg_template)、发送记录落库 wecom_group_pushes,迁移 mysql/0009;lib/wecom-client.ts 补 listCustomerGroupChats/createGroupMsgTemplate - KOC 资源库支持单条新增:app/api/resources-insert + lib/resource-write, 拆出资源写入公共逻辑供导入复用;0008 补合作方外部联系人字段 - 环境变量示例补企微凭证与 SEED_DEMO_DATA;next.config 增加 allowedDevOrigins;CLAUDE.md 补充项目说明 - .gitignore 排除 .codegraph/ 与 .ipynb_checkpoints/
327 lines
9.0 KiB
TypeScript
327 lines
9.0 KiB
TypeScript
import type { DatabaseClient } from "./database";
|
||
import { getRuntimeEnv } from "./runtime-env";
|
||
import {
|
||
createGroupMsgTemplate,
|
||
listCustomerGroupChats,
|
||
WecomClientError,
|
||
type WecomConfig,
|
||
} from "./wecom-client";
|
||
|
||
type FetchLike = typeof fetch;
|
||
|
||
export const WECOM_GROUP_BATCH_LIMIT = 2000;
|
||
const TEXT_MAX_BYTES = 4000;
|
||
const LINK_TITLE_MAX_BYTES = 128;
|
||
const LINK_DESC_MAX_BYTES = 512;
|
||
const LINK_URL_MAX_BYTES = 2048;
|
||
|
||
export type TaskPushRow = {
|
||
id: string;
|
||
name: string;
|
||
brand: string;
|
||
quantity: number;
|
||
due_at: string;
|
||
task_type: string;
|
||
platform: string;
|
||
content_format: string;
|
||
share_token: string | null;
|
||
};
|
||
|
||
export type TaskPushMessage = {
|
||
text: string;
|
||
link: { title: string; desc: string; url: string } | null;
|
||
};
|
||
|
||
export type GroupChatRow = {
|
||
chat_id: string;
|
||
name: string;
|
||
owner_user_id: string;
|
||
member_count: number;
|
||
status: number;
|
||
};
|
||
|
||
export type GroupPushResult = {
|
||
sender: string;
|
||
msgid: string;
|
||
chatCount: number;
|
||
failList: string[];
|
||
};
|
||
|
||
export type GroupPushRecord = {
|
||
id: string;
|
||
taskId: string;
|
||
msgid: string;
|
||
sender: string;
|
||
chatIds: string[];
|
||
failList: string[];
|
||
createdAt: string;
|
||
};
|
||
|
||
function bindingValue(value: unknown) {
|
||
return String(value ?? "").trim();
|
||
}
|
||
|
||
export function trimToBytes(value: string, maxBytes: number) {
|
||
let output = "";
|
||
let used = 0;
|
||
for (const char of Array.from(value)) {
|
||
const size = Buffer.byteLength(char, "utf8");
|
||
if (used + size > maxBytes) break;
|
||
output += char;
|
||
used += size;
|
||
}
|
||
return output;
|
||
}
|
||
|
||
export function buildTaskGroupPushMessage(
|
||
task: TaskPushRow,
|
||
portalUrl: string,
|
||
): TaskPushMessage {
|
||
const portal = portalUrl.trim().replace(/\/$/, "");
|
||
const shareToken = bindingValue(task.share_token);
|
||
const claimUrl = portal && shareToken ? `${portal}/?task=${shareToken}` : "";
|
||
const formatLabel =
|
||
task.task_type === "screenshot_collect"
|
||
? "截图回收"
|
||
: task.content_format === "video"
|
||
? "视频"
|
||
: "图文";
|
||
const lines = [
|
||
`【新任务】${task.name}`,
|
||
`品牌:${task.brand}|数量:${task.quantity} 份|截止:${task.due_at}`,
|
||
`平台:${task.platform}|形式:${formatLabel}`,
|
||
claimUrl ? `领取链接:${claimUrl}` : "领取链接生成失败,请联系管理员",
|
||
];
|
||
return {
|
||
text: trimToBytes(lines.join("\n"), TEXT_MAX_BYTES),
|
||
link: claimUrl
|
||
? {
|
||
title: trimToBytes(task.name, LINK_TITLE_MAX_BYTES),
|
||
desc: trimToBytes(
|
||
`品牌 ${task.brand} · ${task.quantity} 份 · 截止 ${task.due_at}`,
|
||
LINK_DESC_MAX_BYTES,
|
||
),
|
||
url: claimUrl.slice(0, LINK_URL_MAX_BYTES),
|
||
}
|
||
: null,
|
||
};
|
||
}
|
||
|
||
export function groupChatsByOwner(chats: GroupChatRow[]) {
|
||
const grouped = new Map<string, string[]>();
|
||
for (const chat of chats) {
|
||
const owner = bindingValue(chat.owner_user_id);
|
||
if (!owner) continue;
|
||
grouped.set(owner, [...(grouped.get(owner) ?? []), chat.chat_id]);
|
||
}
|
||
return [...grouped.entries()].map(([ownerUserId, chatIds]) => ({
|
||
ownerUserId,
|
||
chatIds,
|
||
}));
|
||
}
|
||
|
||
export async function syncGroupChats(
|
||
db: DatabaseClient,
|
||
config: WecomConfig,
|
||
fetchImpl: FetchLike = fetch,
|
||
): Promise<GroupChatRow[]> {
|
||
if (!(config.corpId && config.agentId && config.secret)) {
|
||
throw new WecomClientError(
|
||
"企业微信自建应用未配置(WECOM_CORP_ID / WECOM_AGENT_ID / WECOM_SECRET)",
|
||
400,
|
||
);
|
||
}
|
||
const groups = await listCustomerGroupChats(config, fetchImpl);
|
||
const syncedAt = new Date().toISOString();
|
||
await db.prepare("DELETE FROM wecom_group_chats").run();
|
||
const inserts = groups.map((group) =>
|
||
db
|
||
.prepare(
|
||
`INSERT INTO wecom_group_chats
|
||
(chat_id, name, owner_user_id, member_count, status, synced_at)
|
||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||
)
|
||
.bind(
|
||
group.chatId,
|
||
group.name,
|
||
group.ownerUserId,
|
||
group.memberCount,
|
||
group.status,
|
||
syncedAt,
|
||
),
|
||
);
|
||
if (inserts.length > 0) await db.batch(inserts);
|
||
return groups.map((group) => ({
|
||
chat_id: group.chatId,
|
||
name: group.name,
|
||
owner_user_id: group.ownerUserId,
|
||
member_count: group.memberCount,
|
||
status: group.status,
|
||
}));
|
||
}
|
||
|
||
export async function listGroupChatRows(db: DatabaseClient) {
|
||
const result = await db
|
||
.prepare(
|
||
`SELECT chat_id, name, owner_user_id, member_count, status
|
||
FROM wecom_group_chats
|
||
ORDER BY member_count DESC`,
|
||
)
|
||
.all<GroupChatRow>();
|
||
return result.results;
|
||
}
|
||
|
||
export async function listGroupPushes(
|
||
db: DatabaseClient,
|
||
taskId?: string,
|
||
): Promise<GroupPushRecord[]> {
|
||
const filter = taskId ? "WHERE task_id = ?" : "";
|
||
const statement = db
|
||
.prepare(
|
||
`SELECT id, task_id, msgid, sender, chat_ids, fail_list, created_at
|
||
FROM wecom_group_pushes
|
||
${filter}
|
||
ORDER BY created_at DESC
|
||
LIMIT 50`,
|
||
)
|
||
.bind(...(taskId ? [taskId] : []));
|
||
const result = await statement.all<{
|
||
id: string;
|
||
task_id: string;
|
||
msgid: string;
|
||
sender: string;
|
||
chat_ids: string;
|
||
fail_list: string;
|
||
created_at: string;
|
||
}>();
|
||
return result.results.map((row) => ({
|
||
id: row.id,
|
||
taskId: row.task_id,
|
||
msgid: row.msgid,
|
||
sender: row.sender,
|
||
chatIds: safeParseArray(row.chat_ids),
|
||
failList: safeParseArray(row.fail_list),
|
||
createdAt: row.created_at,
|
||
}));
|
||
}
|
||
|
||
export async function pushTaskToGroupChats(
|
||
db: DatabaseClient,
|
||
params: { taskId: string; chatIds: unknown; text?: unknown },
|
||
config: WecomConfig,
|
||
fetchImpl: FetchLike = fetch,
|
||
): Promise<{ results: GroupPushResult[]; linkUrl: string }> {
|
||
const taskId = bindingValue(params.taskId);
|
||
if (!taskId) throw new WecomClientError("缺少 taskId", 400);
|
||
|
||
const requested = [
|
||
...new Set(
|
||
(Array.isArray(params.chatIds) ? params.chatIds : [])
|
||
.map((id) => bindingValue(id))
|
||
.filter(Boolean),
|
||
),
|
||
];
|
||
if (requested.length === 0) {
|
||
throw new WecomClientError("请选择要发送的客户群", 400);
|
||
}
|
||
|
||
const task = await db
|
||
.prepare(
|
||
`SELECT id, name, brand, quantity, due_at, task_type, platform,
|
||
content_format, share_token
|
||
FROM tasks WHERE id = ?`,
|
||
)
|
||
.bind(taskId)
|
||
.first<TaskPushRow>();
|
||
if (!task) throw new WecomClientError("任务不存在", 404);
|
||
|
||
const chatRows: GroupChatRow[] = [];
|
||
for (let index = 0; index < requested.length; index += 100) {
|
||
const chunk = requested.slice(index, index + 100);
|
||
const placeholders = chunk.map(() => "?").join(", ");
|
||
const result = await db
|
||
.prepare(
|
||
`SELECT chat_id, name, owner_user_id, member_count, status
|
||
FROM wecom_group_chats WHERE chat_id IN (${placeholders})`,
|
||
)
|
||
.bind(...chunk)
|
||
.all<GroupChatRow>();
|
||
chatRows.push(...result.results);
|
||
}
|
||
const byId = new Map(chatRows.map((row) => [row.chat_id, row]));
|
||
const missing = requested.filter((id) => !byId.has(id));
|
||
if (missing.length > 0) {
|
||
throw new WecomClientError(
|
||
`以下客户群未同步到平台,请先同步群列表:${missing.join("、")}`,
|
||
400,
|
||
);
|
||
}
|
||
const noOwner = requested
|
||
.map((id) => byId.get(id))
|
||
.filter((row) => row && !bindingValue(row.owner_user_id));
|
||
if (noOwner.length > 0) {
|
||
throw new WecomClientError(
|
||
`群「${noOwner.map((row) => row?.name || row?.chat_id).join("、")}」缺少群主信息,请重新同步群列表`,
|
||
400,
|
||
);
|
||
}
|
||
|
||
const portalUrl = bindingValue(getRuntimeEnv().KOC_PORTAL_URL);
|
||
const fallback = buildTaskGroupPushMessage(task, portalUrl);
|
||
const text = bindingValue(params.text) || fallback.text;
|
||
const link = fallback.link;
|
||
if (!text && !link) {
|
||
throw new WecomClientError("文本与图文附件不能同时为空", 400);
|
||
}
|
||
|
||
const results: GroupPushResult[] = [];
|
||
const groups = requested.map((id) => byId.get(id)!);
|
||
for (const { ownerUserId, chatIds } of groupChatsByOwner(groups)) {
|
||
for (
|
||
let index = 0;
|
||
index < chatIds.length;
|
||
index += WECOM_GROUP_BATCH_LIMIT
|
||
) {
|
||
const batch = chatIds.slice(index, index + WECOM_GROUP_BATCH_LIMIT);
|
||
const sent = await createGroupMsgTemplate(
|
||
config,
|
||
{ sender: ownerUserId, chatIdList: batch, text, link: link ?? undefined },
|
||
fetchImpl,
|
||
);
|
||
await db
|
||
.prepare(
|
||
`INSERT INTO wecom_group_pushes
|
||
(id, task_id, msgid, sender, chat_ids, text_content, link_url, fail_list)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||
)
|
||
.bind(
|
||
`wgp-${crypto.randomUUID().slice(0, 12)}`,
|
||
taskId,
|
||
sent.msgid,
|
||
ownerUserId,
|
||
JSON.stringify(batch),
|
||
text,
|
||
link?.url ?? "",
|
||
JSON.stringify(sent.failList),
|
||
)
|
||
.run();
|
||
results.push({
|
||
sender: ownerUserId,
|
||
msgid: sent.msgid,
|
||
chatCount: batch.length,
|
||
failList: sent.failList,
|
||
});
|
||
}
|
||
}
|
||
return { results, linkUrl: link?.url ?? "" };
|
||
}
|
||
|
||
function safeParseArray(value: string) {
|
||
try {
|
||
const parsed = JSON.parse(value);
|
||
return Array.isArray(parsed) ? parsed.map((item) => String(item)) : [];
|
||
} catch {
|
||
return [];
|
||
}
|
||
}
|