feat: 催办分层(超期升级+交互卡片)与超期自动释放开关

This commit is contained in:
ABAPPLO
2026-09-01 17:37:35 +08:00
parent bca524417e
commit 3e4269729c
7 changed files with 654 additions and 3 deletions

View File

@@ -28,6 +28,8 @@ export type RuntimeEnv = {
WECOM_CALLBACK_ENCODING_AES_KEY?: string;
WECOM_CALLBACK_ENABLED?: string;
WECOM_FEEDBACK_ENABLED?: string;
WECOM_ESCALATION_ENABLED?: string;
WECOM_AUTO_RELEASE_DAYS?: string;
SEED_DEMO_DATA?: string;
ENABLE_SCHEDULER?: string;
};

View File

@@ -12,7 +12,11 @@ import {
resolveWecomConfig,
type WecomBindings,
} from "./wecom-client";
import { runDueSoonWecomNotifications } from "./wecom-notifier-service";
import {
runDueSoonWecomNotifications,
runOverdueAutoRelease,
runOverdueEscalation,
} from "./wecom-notifier-service";
import {
isWecomFeedbackEnabled,
notifyCollectionFinished,
@@ -35,6 +39,10 @@ async function runDailyJob() {
const wecomConfig = resolveWecomConfig(env as unknown as WecomBindings);
let wecom: Awaited<ReturnType<typeof runDueSoonWecomNotifications>> | null =
null;
let escalation: Awaited<ReturnType<typeof runOverdueEscalation>> | null =
null;
let autoRelease: Awaited<ReturnType<typeof runOverdueAutoRelease>> | null =
null;
if (
isEnabled(env.WECOM_NOTIFY_ENABLED, true) &&
(wecomConfig.robotWebhook ||
@@ -46,6 +54,18 @@ async function runDailyJob() {
console.error("[KOC LOOP] wecom notify failed", error);
}
}
if (isEnabled(env.WECOM_ESCALATION_ENABLED, false)) {
try {
escalation = await runOverdueEscalation(db, wecomConfig);
} catch (error) {
console.error("[KOC LOOP] wecom overdue escalation failed", error);
}
}
try {
autoRelease = await runOverdueAutoRelease(db, wecomConfig);
} catch (error) {
console.error("[KOC LOOP] wecom overdue auto release failed", error);
}
if (isWecomFeedbackEnabled()) {
try {
await notifyCollectionFinished(collections, wecomConfig);
@@ -57,6 +77,8 @@ async function runDailyJob() {
collections,
accounts,
wecom,
escalation,
autoRelease,
});
});
}

View File

@@ -244,6 +244,87 @@ export function clearWecomAccessTokenCacheForTests() {
cachedAccessToken = null;
}
export type WecomTemplateCardButton = {
key: string;
name: string;
url?: string;
};
export async function sendWecomTemplateCard(
config: WecomConfig,
params: {
touser: string[];
taskId: string;
title: string;
description: string;
url: string;
buttons: WecomTemplateCardButton[];
},
fetchImpl: FetchLike = fetch,
): Promise<{ sent: number; failed: number; skipped: boolean }> {
const normalized = params.touser
.map((id) => bindingValue(id))
.filter((id) => id.length > 0);
if (normalized.length === 0) {
return { sent: 0, failed: 0, skipped: true };
}
if (!hasAppCredentials(config)) {
return { sent: 0, failed: 0, skipped: true };
}
const taskId = bindingValue(params.taskId);
if (!taskId) {
throw new WecomClientError("模板卡片缺少 task_id(回调识别用)", 400);
}
const token = await fetchAccessToken(config, fetchImpl);
const url = `${WECOM_API_ORIGIN}/cgi-bin/message/send?access_token=${encodeURIComponent(token)}`;
const response = await fetchImpl(url, {
method: "POST",
headers: { "Content-Type": "application/json; charset=utf-8" },
body: JSON.stringify({
touser: normalized.join("|"),
msgtype: "template_card",
agentid: Number(config.agentId),
template_card: {
card_type: "text_notice",
source: {
desc: "KOC LOOP",
desc_color: 0,
},
main_title: {
title: safeMessage(params.title),
desc: safeMessage(params.description).slice(0, 120),
},
card_action: {
type: 1,
url: bindingValue(params.url),
},
button_list: params.buttons.map((button) => ({
type: button.url ? 1 : 0,
text: safeMessage(button.name),
key: bindingValue(button.key),
...(button.url ? { url: button.url } : {}),
})),
task_id: taskId,
},
}),
signal: AbortSignal.timeout(12_000),
});
const payload = await readEnvelope(response, "企业微信模板卡片推送失败");
if (!response.ok) {
throw new WecomClientError(
`企业微信模板卡片推送失败(HTTP ${response.status})`,
502,
);
}
ensureOk(payload, "企业微信模板卡片推送失败");
const invalid = bindingValue(payload.invaliduser).split("|").filter(Boolean);
return {
sent: Math.max(0, normalized.length - invalid.length),
failed: invalid.length,
skipped: false,
};
}
export type WecomGroupChat = {
chatId: string;
name: string;

View File

@@ -3,9 +3,11 @@ import { shanghaiDateFromTimestamp } from "./collection-service";
import {
sendWecomAppMessage,
sendWecomRobotMessage,
sendWecomTemplateCard,
type WecomConfig,
} from "./wecom-client";
import { getRuntimeEnv } from "./runtime-env";
import { releaseUnfinishedDistribution } from "./distribution-release-service";
type FetchLike = typeof fetch;
@@ -162,6 +164,227 @@ function bindingValue(value: unknown) {
return String(value ?? "").trim();
}
export function resolveAutoReleaseDays(value: string | undefined) {
const parsed = Number(String(value ?? "").trim());
if (!Number.isFinite(parsed) || parsed < 1) return 0;
return Math.min(90, Math.floor(parsed));
}
type OverdueRow = {
id: string;
task_id: string;
task_name: string;
due_at: string;
project_id: string;
partner_id: string;
partner_name: string;
};
export type OverdueEscalationSummary = {
overdueTasks: number;
overdueDistributions: number;
robotSent: boolean;
cardSent: number;
cardFailed: number;
};
async function queryOverdueUnpublished(
db: DatabaseClient,
cutoff: string,
): Promise<OverdueRow[]> {
const result = await db
.prepare(
`SELECT d.id, t.id AS task_id, t.name AS task_name, t.due_at,
t.project_id, p.id AS partner_id, p.name AS partner_name
FROM distributions d
JOIN tasks t ON t.id = d.task_id
JOIN partners p ON p.id = d.partner_id
WHERE (d.publish_url IS NULL OR d.publish_url = '')
AND t.due_at IS NOT NULL AND t.due_at != ''
AND t.due_at < ?
ORDER BY t.due_at ASC, t.name ASC`,
)
.bind(cutoff)
.all<OverdueRow>();
return result.results;
}
export async function runOverdueEscalation(
db: DatabaseClient,
config: WecomConfig,
fetchImpl: FetchLike = fetch,
now: number = Date.now(),
): Promise<OverdueEscalationSummary> {
const today = shanghaiDateFromTimestamp(now);
const rows = await queryOverdueUnpublished(db, today);
const byTask = new Map<
string,
{ taskName: string; dueAt: string; projectId: string; count: number; partners: Set<string> }
>();
for (const row of rows) {
const entry = byTask.get(row.task_id) ?? {
taskName: row.task_name,
dueAt: row.due_at,
projectId: bindingValue(row.project_id),
count: 0,
partners: new Set<string>(),
};
entry.count += 1;
entry.partners.add(row.partner_name || row.partner_id);
byTask.set(row.task_id, entry);
}
const summary: OverdueEscalationSummary = {
overdueTasks: byTask.size,
overdueDistributions: rows.length,
robotSent: false,
cardSent: 0,
cardFailed: 0,
};
if (rows.length === 0) return summary;
if (config.robotWebhook) {
const lines = [...byTask.values()].map((entry) => {
const partners = [...entry.partners].slice(0, 5).join("、");
const overflow = entry.partners.size > 5 ? ` 等 ${entry.partners.size} 位` : "";
return `· ${truncate(entry.taskName, 24)}(截止 ${entry.dueAt}):${entry.count} 条超期未发布 — ${partners}${overflow}`;
});
try {
await sendWecomRobotMessage(
`【超期未发布升级】${today}\n${lines.join("\n")}\n请运营跟进催办或释放。`,
config,
fetchImpl,
);
summary.robotSent = true;
} catch (error) {
console.warn("[KOC LOOP] overdue escalation robot failed", {
error: safeError(error),
});
}
}
const env = getRuntimeEnv();
const cardUrl = bindingValue(env.APP_ORIGIN) || bindingValue(env.KOC_PORTAL_URL);
for (const [taskId, entry] of byTask) {
if (!entry.projectId) continue;
try {
const project = await db
.prepare("SELECT main_group_member_ids FROM projects WHERE id = ?")
.bind(entry.projectId)
.first<{ main_group_member_ids: string }>();
const members = safeParseIdList(project?.main_group_member_ids);
if (members.length === 0) continue;
const sent = await sendWecomTemplateCard(
config,
{
touser: members,
taskId: `task:${taskId}:due`,
title: `超期催办:${truncate(entry.taskName, 20)}`,
description: `${entry.count} 条内容超期未发布(截止 ${entry.dueAt}),请跟进催办或释放`,
url: cardUrl,
buttons: [
{ key: "view_task", name: "查看任务", url: cardUrl },
{ key: "mark_handled", name: "已处理" },
],
},
fetchImpl,
);
if (!sent.skipped) {
summary.cardSent += sent.sent;
summary.cardFailed += sent.failed;
}
} catch (error) {
summary.cardFailed += 1;
console.warn("[KOC LOOP] overdue escalation card failed", {
taskId,
error: safeError(error),
});
}
}
return summary;
}
export type AutoReleaseSummary = {
enabled: boolean;
days: number;
cutoff: string;
released: number;
blocked: number;
failed: number;
robotSent: boolean;
};
export async function runOverdueAutoRelease(
db: DatabaseClient,
config: WecomConfig,
fetchImpl: FetchLike = fetch,
now: number = Date.now(),
): Promise<AutoReleaseSummary> {
const days = resolveAutoReleaseDays(getRuntimeEnv().WECOM_AUTO_RELEASE_DAYS);
const summary: AutoReleaseSummary = {
enabled: days > 0,
days,
cutoff: "",
released: 0,
blocked: 0,
failed: 0,
robotSent: false,
};
if (days <= 0) return summary;
const today = shanghaiDateFromTimestamp(now);
summary.cutoff = computeDueCutoff(today, -days);
const rows = await queryOverdueUnpublished(db, summary.cutoff);
for (const row of rows) {
try {
await releaseUnfinishedDistribution(db, row.id);
summary.released += 1;
} catch (error) {
const status = (error as { status?: number }).status;
if (status === 409) {
summary.blocked += 1;
} else {
summary.failed += 1;
console.warn("[KOC LOOP] auto release failed", {
distributionId: row.id,
error: safeError(error),
});
}
}
}
if (summary.released > 0 && config.robotWebhook) {
try {
await sendWecomRobotMessage(
`【超期自动释放】截止 ${summary.cutoff} 前仍未发布的内容已释放 ${summary.released} 条回库存(跳过 ${summary.blocked} 条已提交、失败 ${summary.failed} 条),可重新领取。`,
config,
fetchImpl,
);
summary.robotSent = true;
} catch (error) {
console.warn("[KOC LOOP] auto release robot failed", {
error: safeError(error),
});
}
}
return summary;
}
function safeParseIdList(value: string | null | undefined) {
try {
const parsed = JSON.parse(String(value ?? "[]"));
if (!Array.isArray(parsed)) return [];
return parsed.map((item) => String(item)).filter(Boolean);
} catch {
return [];
}
}
function truncate(value: string, max: number) {
return value.length > max ? `${value.slice(0, max)}…` : value;
}