feat: 催办分层(超期升级+交互卡片)与超期自动释放开关
This commit is contained in:
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user