feat: 任务一键推送企微(总群+KOC个人直达+群发,含推送记录)

This commit is contained in:
ABAPPLO
2026-09-01 17:30:47 +08:00
parent b243562e60
commit d01281fba9
10 changed files with 1103 additions and 12 deletions

View File

@@ -0,0 +1,355 @@
import type { DatabaseClient } from "./database";
import { getRuntimeEnv } from "./runtime-env";
import {
sendWecomAppMessage,
type WecomConfig,
} from "./wecom-client";
import { pushProjectTask, type ProjectPushOutcome } from "./project-push-service";
import type { TaskPushRow } from "./wecom-group-push-service";
type FetchLike = typeof fetch;
export class OneClickPushError extends Error {
status: number;
constructor(message: string, status = 400) {
super(message);
this.name = "OneClickPushError";
this.status = status;
}
}
export type BoundWecomPartner = {
id: string;
name: string;
wecomExternalUserId: string;
};
export type KocPushResult = {
partnerId: string;
partnerName: string;
externalUserId: string;
ok: boolean;
error: string;
};
export type TaskPushLogRow = {
id: string;
task_id: string;
project_id: string;
push_kind: string;
main_group_count: number;
koc_userids: string;
koc_ok_count: number;
koc_fail_list: string;
group_push_id: string;
detail: string;
created_at: string;
};
export type OneClickPushOutcome = {
linkUrl: string;
projectOutcome: ProjectPushOutcome | null;
kocResults: KocPushResult[];
kocSent: number;
kocFailed: number;
skippedReasons: string[];
logId: string;
};
function text(value: unknown) {
return String(value ?? "").trim();
}
export function buildKocDirectMessage(
task: Pick<
TaskPushRow,
| "name"
| "brand"
| "quantity"
| "due_at"
| "task_type"
| "platform"
| "content_format"
| "share_token"
>,
portalUrl: string,
): string {
const portal = text(portalUrl).replace(/\/$/, "");
const claimUrl =
portal && task.share_token ? `${portal}/?task=${task.share_token}` : "";
const formatLabel =
task.task_type === "screenshot_collect"
? "截图回收"
: task.content_format === "video"
? "视频"
: "图文";
return [
`【新任务】${task.name}`,
`品牌:${task.brand}|数量:${task.quantity} 份|截止:${task.due_at}`,
`平台:${task.platform}|形式:${formatLabel}`,
"数量有限,先到先得,点击链接直接领取(企微内打开免输身份):",
claimUrl || "领取链接生成失败,请联系管理员",
].join("\n");
}
export async function listBoundWecomPartners(
db: DatabaseClient,
): Promise<BoundWecomPartner[]> {
const result = await db
.prepare(
`SELECT id, name, wecom_external_user_id
FROM partners
WHERE wecom_external_user_id IS NOT NULL
AND wecom_external_user_id != ''
ORDER BY name ASC`,
)
.all<{
id: string;
name: string;
wecom_external_user_id: string;
}>();
return result.results.map((row) => ({
id: row.id,
name: row.name,
wecomExternalUserId: row.wecom_external_user_id,
}));
}
export async function findTodayTaskPushLog(
db: DatabaseClient,
taskId: string,
pushKind = "task_open",
): Promise<TaskPushLogRow | null> {
const result = await db
.prepare(
`SELECT id, task_id, project_id, push_kind, main_group_count,
koc_userids, koc_ok_count, koc_fail_list, group_push_id,
detail, created_at
FROM wecom_push_logs
WHERE task_id = ? AND push_kind = ?
AND DATE(created_at) = CURDATE()
ORDER BY created_at DESC
LIMIT 1`,
)
.bind(taskId, pushKind)
.first<TaskPushLogRow>();
return result ?? null;
}
export async function listTaskPushLogs(
db: DatabaseClient,
taskId: string,
): Promise<TaskPushLogRow[]> {
const filter = taskId ? "WHERE task_id = ?" : "";
const statement = db
.prepare(
`SELECT id, task_id, project_id, push_kind, main_group_count,
koc_userids, koc_ok_count, koc_fail_list, group_push_id,
detail, created_at
FROM wecom_push_logs
${filter}
ORDER BY created_at DESC
LIMIT 50`,
)
.bind(...(taskId ? [taskId] : []));
const result = await statement.all<TaskPushLogRow>();
return result.results;
}
export async function recordCardAck(
db: DatabaseClient,
params: { cardTaskId: string; btnKey: string; operator: string },
) {
const taskId = /^task:([^:]+):/.exec(params.cardTaskId)?.[1] ?? "";
await db
.prepare(
`INSERT INTO wecom_push_logs
(id, task_id, project_id, push_kind, main_group_count,
koc_userids, koc_ok_count, koc_fail_list, group_push_id, detail)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
)
.bind(
`wpl-${crypto.randomUUID().slice(0, 12)}`,
taskId || params.cardTaskId,
"",
"card_ack",
0,
"[]",
0,
"[]",
"",
JSON.stringify({
cardTaskId: params.cardTaskId,
btnKey: params.btnKey,
operator: params.operator,
}),
)
.run();
}
export async function oneClickPushTask(
db: DatabaseClient,
params: {
taskId: unknown;
sendMainGroup?: unknown;
sendKoc?: unknown;
createGroupTemplate?: unknown;
selectedKocPartnerIds?: unknown;
force?: unknown;
text?: unknown;
},
config: WecomConfig,
fetchImpl: FetchLike = fetch,
): Promise<OneClickPushOutcome> {
const taskId = text(params.taskId);
if (!taskId) throw new OneClickPushError("缺少 taskId");
const sendMainGroup = params.sendMainGroup !== false;
const sendKoc = params.sendKoc !== false;
const createGroupTemplate = params.createGroupTemplate === true;
if (!sendMainGroup && !sendKoc && !createGroupTemplate) {
throw new OneClickPushError("请至少选择一个推送目标");
}
const task = await db
.prepare(
`SELECT id, name, brand, quantity, due_at, task_type, platform,
content_format, share_token, project_id
FROM tasks WHERE id = ?`,
)
.bind(taskId)
.first<TaskPushRow & { project_id: string }>();
if (!task) throw new OneClickPushError("任务不存在", 404);
if (!text(task.share_token)) {
throw new OneClickPushError("任务尚未生成领取链接,请先开放领取", 400);
}
if (params.force !== true) {
const existing = await findTodayTaskPushLog(db, taskId, "task_open");
if (existing) {
throw new OneClickPushError(
"今天已推送过该任务;如确需再次推送,请勾选「仍要推送」",
409,
);
}
}
const skippedReasons: string[] = [];
const wantsProjectTargets = sendMainGroup || createGroupTemplate;
let projectOutcome: ProjectPushOutcome | null = null;
if (wantsProjectTargets) {
if (!text(task.project_id)) {
skippedReasons.push("任务未挂项目,已跳过项目总群与任务群");
} else {
projectOutcome = await pushProjectTask(
db,
{
taskId,
sendMainGroup,
sendGroupChats: createGroupTemplate,
text: params.text,
},
config,
fetchImpl,
);
skippedReasons.push(...projectOutcome.skippedReasons);
}
}
const portalUrl = text(getRuntimeEnv().KOC_PORTAL_URL);
const linkUrl = task.share_token
? `${portalUrl.replace(/\/$/, "")}/?task=${task.share_token}`
: "";
const kocResults: KocPushResult[] = [];
if (sendKoc) {
const bound = await listBoundWecomPartners(db);
const selected = Array.isArray(params.selectedKocPartnerIds)
? params.selectedKocPartnerIds.map((id) => text(id)).filter(Boolean)
: null;
const targets = selected
? bound.filter((partner) => selected.includes(partner.id))
: bound;
if (targets.length === 0) {
skippedReasons.push("没有可直达的合作方(未勾选或均未绑定企微身份)");
} else {
const message = buildKocDirectMessage(task, portalUrl);
for (const partner of targets) {
try {
const sent = await sendWecomAppMessage(
[partner.wecomExternalUserId],
message,
config,
fetchImpl,
);
const failed = sent.sent === 0;
kocResults.push({
partnerId: partner.id,
partnerName: partner.name,
externalUserId: partner.wecomExternalUserId,
ok: !failed,
error: failed ? "企业微信未送达(invaliduser)" : "",
});
} catch (error) {
kocResults.push({
partnerId: partner.id,
partnerName: partner.name,
externalUserId: partner.wecomExternalUserId,
ok: false,
error: error instanceof Error ? error.message : "发送失败",
});
}
}
}
}
const kocSent = kocResults.filter((item) => item.ok).length;
const kocFailed = kocResults.length - kocSent;
const logId = `wpl-${crypto.randomUUID().slice(0, 12)}`;
const groupPushId =
projectOutcome?.groupResults.map((item) => item.msgid).join(",") ?? "";
await db
.prepare(
`INSERT INTO wecom_push_logs
(id, task_id, project_id, push_kind, main_group_count,
koc_userids, koc_ok_count, koc_fail_list, group_push_id, detail)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
)
.bind(
logId,
taskId,
text(task.project_id),
"task_open",
projectOutcome ? projectOutcome.appResults.length : 0,
JSON.stringify(
kocResults.map((item) => ({
partnerId: item.partnerId,
name: item.partnerName,
externalUserId: item.externalUserId,
ok: item.ok,
error: item.error,
})),
),
kocSent,
JSON.stringify(kocResults.filter((item) => !item.ok)),
groupPushId,
JSON.stringify({
skippedReasons,
createGroupTemplate,
linkUrl,
}),
)
.run();
return {
linkUrl,
projectOutcome,
kocResults,
kocSent,
kocFailed,
skippedReasons,
logId,
};
}

View File

@@ -1,3 +1,6 @@
import type { DatabaseClient } from "./database";
import { recordCardAck } from "./task-one-click-push-service";
export type WecomCallbackFields = Record<string, string>;
export type WecomCallbackEventResult = {
@@ -27,9 +30,10 @@ export function extractWecomEncryptField(body: string): string {
return fields.Encrypt ?? "";
}
export function handleWecomCallbackEvent(
export async function handleWecomCallbackEvent(
db: DatabaseClient,
fields: WecomCallbackFields,
): WecomCallbackEventResult {
): Promise<WecomCallbackEventResult> {
const event = fields.Event ?? "";
const taskId = fields.TaskId ?? "";
const btnKey = fields.BtnKey ?? "";
@@ -37,10 +41,8 @@ export function handleWecomCallbackEvent(
if (fields.MsgType === "event" && event === "template_card_event") {
if (btnKey === "mark_handled") {
console.info(
"[KOC LOOP] wecom card ack",
{ taskId, operator },
);
await recordCardAck(db, { cardTaskId: taskId, btnKey, operator });
console.info("[KOC LOOP] wecom card ack recorded", { taskId, operator });
return { recognized: true, action: "card_ack", taskId, btnKey, operator };
}
return { recognized: true, action: "ignored", taskId, btnKey, operator };