feat: 任务发布到企微客户群(企业群发)与资源库单条新增
- 任务中心新增「发布到企微群」:同步客户群清单(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/
This commit is contained in:
326
lib/wecom-group-push-service.ts
Normal file
326
lib/wecom-group-push-service.ts
Normal file
@@ -0,0 +1,326 @@
|
||||
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 [];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user