356 lines
9.7 KiB
TypeScript
356 lines
9.7 KiB
TypeScript
|
|
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,
|
|||
|
|
};
|
|||
|
|
}
|