feat: 催办分层(超期升级+交互卡片)与超期自动释放开关
This commit is contained in:
@@ -1775,6 +1775,41 @@ function PartnersPage({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const triggerOverdueEscalation = async () => {
|
||||||
|
try {
|
||||||
|
setPartnerWorkingId("escalation");
|
||||||
|
const response = await fetch("/api/action", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ action: "trigger_wecom_overdue_escalation" }),
|
||||||
|
});
|
||||||
|
const result = await readApiResponse<{
|
||||||
|
escalation?: {
|
||||||
|
overdueTasks?: number;
|
||||||
|
overdueDistributions?: number;
|
||||||
|
robotSent?: boolean;
|
||||||
|
cardSent?: number;
|
||||||
|
cardFailed?: number;
|
||||||
|
};
|
||||||
|
autoRelease?: { enabled?: boolean; released?: number; blocked?: number; failed?: number };
|
||||||
|
error?: string;
|
||||||
|
}>(response, "触发超期升级失败");
|
||||||
|
if (!response.ok) throw new Error(result.error || "触发超期升级失败");
|
||||||
|
const esc = result.escalation ?? {};
|
||||||
|
const release = result.autoRelease ?? {};
|
||||||
|
const releaseText = release.enabled
|
||||||
|
? `;自动释放 ${release.released ?? 0} 条(跳过 ${release.blocked ?? 0},失败 ${release.failed ?? 0})`
|
||||||
|
: ";自动释放未开启(WECOM_AUTO_RELEASE_DAYS)";
|
||||||
|
setToast(
|
||||||
|
`超期升级完成:${esc.overdueTasks ?? 0} 个任务 / ${esc.overdueDistributions ?? 0} 条超期,卡片已发 ${esc.cardSent ?? 0} 人${releaseText}`,
|
||||||
|
);
|
||||||
|
} catch (reason) {
|
||||||
|
setToast(reason instanceof Error ? reason.message : "触发超期升级失败");
|
||||||
|
} finally {
|
||||||
|
setPartnerWorkingId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const testAppMessage = async (partner: Partner) => {
|
const testAppMessage = async (partner: Partner) => {
|
||||||
if (!partner.wecom_external_user_id) {
|
if (!partner.wecom_external_user_id) {
|
||||||
setToast("请先保存企业微信外部联系人 ID");
|
setToast("请先保存企业微信外部联系人 ID");
|
||||||
@@ -1940,8 +1975,16 @@ function PartnersPage({
|
|||||||
>
|
>
|
||||||
{partnerWorkingId === "due-soon" ? "执行中…" : "立即跑一次催办"}
|
{partnerWorkingId === "due-soon" ? "执行中…" : "立即跑一次催办"}
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ghost-button"
|
||||||
|
disabled={partnerWorkingId === "escalation" || working || statusLoading}
|
||||||
|
onClick={() => void triggerOverdueEscalation()}
|
||||||
|
>
|
||||||
|
{partnerWorkingId === "escalation" ? "执行中…" : "跑一次超期升级"}
|
||||||
|
</button>
|
||||||
<p className="wecom-status-hint">
|
<p className="wecom-status-hint">
|
||||||
立即跑一次 daily 09:00 的催办链路:按 partner 聚合发应用消息,再向群机器人发当日汇总。
|
立即跑一次 daily 09:00 的催办链路:按 partner 聚合发应用消息,再向群机器人发当日汇总。超期升级会向运营群推送超期清单,并向任务所属项目总群成员发催办卡片。
|
||||||
</p>
|
</p>
|
||||||
{(!status?.robotConfigured || !status?.appConfigured) && (
|
{(!status?.robotConfigured || !status?.appConfigured) && (
|
||||||
<p className="wecom-status-hint">
|
<p className="wecom-status-hint">
|
||||||
|
|||||||
@@ -45,7 +45,11 @@ import {
|
|||||||
WecomClientError,
|
WecomClientError,
|
||||||
type WecomBindings,
|
type WecomBindings,
|
||||||
} from "../../../lib/wecom-client";
|
} from "../../../lib/wecom-client";
|
||||||
import { runDueSoonWecomNotifications } from "../../../lib/wecom-notifier-service";
|
import {
|
||||||
|
runDueSoonWecomNotifications,
|
||||||
|
runOverdueAutoRelease,
|
||||||
|
runOverdueEscalation,
|
||||||
|
} from "../../../lib/wecom-notifier-service";
|
||||||
import {
|
import {
|
||||||
listGroupChatRows,
|
listGroupChatRows,
|
||||||
listGroupPushes,
|
listGroupPushes,
|
||||||
@@ -664,6 +668,14 @@ export async function POST(request: Request) {
|
|||||||
wecomConfig,
|
wecomConfig,
|
||||||
);
|
);
|
||||||
return Response.json(summary);
|
return Response.json(summary);
|
||||||
|
} else if (body.action === "trigger_wecom_overdue_escalation") {
|
||||||
|
if (!isManagerRequest(request)) return adminForbidden();
|
||||||
|
const wecomConfig = resolveWecomConfig(
|
||||||
|
env as unknown as WecomBindings,
|
||||||
|
);
|
||||||
|
const escalation = await runOverdueEscalation(db, wecomConfig);
|
||||||
|
const autoRelease = await runOverdueAutoRelease(db, wecomConfig);
|
||||||
|
return Response.json({ escalation, autoRelease });
|
||||||
} else if (body.action === "wecom_list_external_contacts") {
|
} else if (body.action === "wecom_list_external_contacts") {
|
||||||
const wecomConfig = resolveWecomConfig(
|
const wecomConfig = resolveWecomConfig(
|
||||||
env as unknown as WecomBindings,
|
env as unknown as WecomBindings,
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ export type RuntimeEnv = {
|
|||||||
WECOM_CALLBACK_ENCODING_AES_KEY?: string;
|
WECOM_CALLBACK_ENCODING_AES_KEY?: string;
|
||||||
WECOM_CALLBACK_ENABLED?: string;
|
WECOM_CALLBACK_ENABLED?: string;
|
||||||
WECOM_FEEDBACK_ENABLED?: string;
|
WECOM_FEEDBACK_ENABLED?: string;
|
||||||
|
WECOM_ESCALATION_ENABLED?: string;
|
||||||
|
WECOM_AUTO_RELEASE_DAYS?: string;
|
||||||
SEED_DEMO_DATA?: string;
|
SEED_DEMO_DATA?: string;
|
||||||
ENABLE_SCHEDULER?: string;
|
ENABLE_SCHEDULER?: string;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -12,7 +12,11 @@ import {
|
|||||||
resolveWecomConfig,
|
resolveWecomConfig,
|
||||||
type WecomBindings,
|
type WecomBindings,
|
||||||
} from "./wecom-client";
|
} from "./wecom-client";
|
||||||
import { runDueSoonWecomNotifications } from "./wecom-notifier-service";
|
import {
|
||||||
|
runDueSoonWecomNotifications,
|
||||||
|
runOverdueAutoRelease,
|
||||||
|
runOverdueEscalation,
|
||||||
|
} from "./wecom-notifier-service";
|
||||||
import {
|
import {
|
||||||
isWecomFeedbackEnabled,
|
isWecomFeedbackEnabled,
|
||||||
notifyCollectionFinished,
|
notifyCollectionFinished,
|
||||||
@@ -35,6 +39,10 @@ async function runDailyJob() {
|
|||||||
const wecomConfig = resolveWecomConfig(env as unknown as WecomBindings);
|
const wecomConfig = resolveWecomConfig(env as unknown as WecomBindings);
|
||||||
let wecom: Awaited<ReturnType<typeof runDueSoonWecomNotifications>> | null =
|
let wecom: Awaited<ReturnType<typeof runDueSoonWecomNotifications>> | null =
|
||||||
null;
|
null;
|
||||||
|
let escalation: Awaited<ReturnType<typeof runOverdueEscalation>> | null =
|
||||||
|
null;
|
||||||
|
let autoRelease: Awaited<ReturnType<typeof runOverdueAutoRelease>> | null =
|
||||||
|
null;
|
||||||
if (
|
if (
|
||||||
isEnabled(env.WECOM_NOTIFY_ENABLED, true) &&
|
isEnabled(env.WECOM_NOTIFY_ENABLED, true) &&
|
||||||
(wecomConfig.robotWebhook ||
|
(wecomConfig.robotWebhook ||
|
||||||
@@ -46,6 +54,18 @@ async function runDailyJob() {
|
|||||||
console.error("[KOC LOOP] wecom notify failed", error);
|
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()) {
|
if (isWecomFeedbackEnabled()) {
|
||||||
try {
|
try {
|
||||||
await notifyCollectionFinished(collections, wecomConfig);
|
await notifyCollectionFinished(collections, wecomConfig);
|
||||||
@@ -57,6 +77,8 @@ async function runDailyJob() {
|
|||||||
collections,
|
collections,
|
||||||
accounts,
|
accounts,
|
||||||
wecom,
|
wecom,
|
||||||
|
escalation,
|
||||||
|
autoRelease,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -244,6 +244,87 @@ export function clearWecomAccessTokenCacheForTests() {
|
|||||||
cachedAccessToken = null;
|
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 = {
|
export type WecomGroupChat = {
|
||||||
chatId: string;
|
chatId: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
|||||||
@@ -3,9 +3,11 @@ import { shanghaiDateFromTimestamp } from "./collection-service";
|
|||||||
import {
|
import {
|
||||||
sendWecomAppMessage,
|
sendWecomAppMessage,
|
||||||
sendWecomRobotMessage,
|
sendWecomRobotMessage,
|
||||||
|
sendWecomTemplateCard,
|
||||||
type WecomConfig,
|
type WecomConfig,
|
||||||
} from "./wecom-client";
|
} from "./wecom-client";
|
||||||
import { getRuntimeEnv } from "./runtime-env";
|
import { getRuntimeEnv } from "./runtime-env";
|
||||||
|
import { releaseUnfinishedDistribution } from "./distribution-release-service";
|
||||||
|
|
||||||
type FetchLike = typeof fetch;
|
type FetchLike = typeof fetch;
|
||||||
|
|
||||||
@@ -162,6 +164,227 @@ function bindingValue(value: unknown) {
|
|||||||
return String(value ?? "").trim();
|
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) {
|
function truncate(value: string, max: number) {
|
||||||
return value.length > max ? `${value.slice(0, max)}…` : value;
|
return value.length > max ? `${value.slice(0, max)}…` : value;
|
||||||
}
|
}
|
||||||
|
|||||||
268
tests/wecom-escalation.test.mjs
Normal file
268
tests/wecom-escalation.test.mjs
Normal file
@@ -0,0 +1,268 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
import {
|
||||||
|
resolveAutoReleaseDays,
|
||||||
|
runOverdueAutoRelease,
|
||||||
|
runOverdueEscalation,
|
||||||
|
} from "../lib/wecom-notifier-service.ts";
|
||||||
|
import {
|
||||||
|
clearWecomAccessTokenCacheForTests,
|
||||||
|
resolveWecomConfig,
|
||||||
|
} from "../lib/wecom-client.ts";
|
||||||
|
|
||||||
|
const config = resolveWecomConfig({
|
||||||
|
WECOM_CORP_ID: "corp-test",
|
||||||
|
WECOM_AGENT_ID: "1000001",
|
||||||
|
WECOM_SECRET: "secret-test",
|
||||||
|
WECOM_ROBOT_WEBHOOK: "https://qyapi.example.com/webhook/send?key=abc",
|
||||||
|
});
|
||||||
|
|
||||||
|
const TODAY = "2026-09-01";
|
||||||
|
const NOW = new Date(`${TODAY}T02:00:00Z`).getTime();
|
||||||
|
|
||||||
|
const overdueRows = [
|
||||||
|
{
|
||||||
|
id: "d-1",
|
||||||
|
task_id: "task-A",
|
||||||
|
task_name: "超期任务A",
|
||||||
|
due_at: "2026-08-20",
|
||||||
|
project_id: "proj-1",
|
||||||
|
partner_id: "p-1",
|
||||||
|
partner_name: "合作方甲",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "d-2",
|
||||||
|
task_id: "task-A",
|
||||||
|
task_name: "超期任务A",
|
||||||
|
due_at: "2026-08-20",
|
||||||
|
project_id: "proj-1",
|
||||||
|
partner_id: "p-2",
|
||||||
|
partner_name: "合作方乙",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "d-3",
|
||||||
|
task_id: "task-B",
|
||||||
|
task_name: "无项目任务B",
|
||||||
|
due_at: "2026-08-25",
|
||||||
|
project_id: "",
|
||||||
|
partner_id: "p-3",
|
||||||
|
partner_name: "合作方丙",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const releasableDistribution = {
|
||||||
|
id: "d-1",
|
||||||
|
task_id: "task-A",
|
||||||
|
task_type: "content_publish",
|
||||||
|
content_id: "c-1",
|
||||||
|
content_title: "超期内容",
|
||||||
|
partner_id: "p-1",
|
||||||
|
partner_name: "合作方甲",
|
||||||
|
claim_id: null,
|
||||||
|
delegation_bundle_id: null,
|
||||||
|
publish_url: null,
|
||||||
|
result_submitted_at: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
function fakeEscalationDb({ rows = overdueRows } = {}) {
|
||||||
|
return {
|
||||||
|
prepare(sql) {
|
||||||
|
return {
|
||||||
|
bind(...params) {
|
||||||
|
return {
|
||||||
|
async first() {
|
||||||
|
if (/FROM projects WHERE id = \?/.test(sql)) {
|
||||||
|
return { main_group_member_ids: JSON.stringify(["opsZhang", "opsLi"]) };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
async all() {
|
||||||
|
if (/FROM distributions d/.test(sql) && /publish_url IS NULL/.test(sql)) {
|
||||||
|
return { results: rows.filter((row) => row.due_at < params[0]) };
|
||||||
|
}
|
||||||
|
return { results: [] };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function fakeReleaseDb({ rows = overdueRows, blockedIds = [] } = {}) {
|
||||||
|
const executed = [];
|
||||||
|
const inner = {
|
||||||
|
prepare(sql) {
|
||||||
|
return {
|
||||||
|
bind(...params) {
|
||||||
|
return {
|
||||||
|
async first() {
|
||||||
|
if (/FROM projects WHERE id = \?/.test(sql)) {
|
||||||
|
return { main_group_member_ids: JSON.stringify(["opsZhang"]) };
|
||||||
|
}
|
||||||
|
if (/FOR UPDATE/.test(sql)) {
|
||||||
|
const row = rows.find((item) => item.id === params[0]);
|
||||||
|
if (!row) return null;
|
||||||
|
return {
|
||||||
|
...releasableDistribution,
|
||||||
|
id: row.id,
|
||||||
|
task_id: row.task_id,
|
||||||
|
partner_id: row.partner_id,
|
||||||
|
partner_name: row.partner_name,
|
||||||
|
result_submitted_at: blockedIds.includes(row.id)
|
||||||
|
? "2026-08-30 10:00:00"
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
async all() {
|
||||||
|
if (/FROM distributions d/.test(sql) && /publish_url IS NULL/.test(sql)) {
|
||||||
|
return { results: rows.filter((row) => row.due_at < params[0]) };
|
||||||
|
}
|
||||||
|
return { results: [] };
|
||||||
|
},
|
||||||
|
async run() {
|
||||||
|
executed.push({ sql, params });
|
||||||
|
return { meta: { changes: 1 } };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
executed,
|
||||||
|
...inner,
|
||||||
|
async transaction(fn) {
|
||||||
|
return fn(inner);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function wecomFetch(calls) {
|
||||||
|
return async (input, init = {}) => {
|
||||||
|
const url = new URL(String(input));
|
||||||
|
calls.push({ url, init });
|
||||||
|
if (url.pathname.endsWith("/cgi-bin/gettoken")) {
|
||||||
|
return Response.json({
|
||||||
|
errcode: 0,
|
||||||
|
errmsg: "ok",
|
||||||
|
access_token: "token-test",
|
||||||
|
expires_in: 7200,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
url.pathname.endsWith("/cgi-bin/message/send") ||
|
||||||
|
url.host === "qyapi.example.com"
|
||||||
|
) {
|
||||||
|
return Response.json({ errcode: 0, errmsg: "ok" });
|
||||||
|
}
|
||||||
|
return new Response("not found", { status: 404 });
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test.beforeEach(() => {
|
||||||
|
clearWecomAccessTokenCacheForTests();
|
||||||
|
delete process.env.WECOM_AUTO_RELEASE_DAYS;
|
||||||
|
delete process.env.APP_ORIGIN;
|
||||||
|
});
|
||||||
|
|
||||||
|
test("resolveAutoReleaseDays parses bounds and defaults to disabled", () => {
|
||||||
|
assert.equal(resolveAutoReleaseDays(undefined), 0);
|
||||||
|
assert.equal(resolveAutoReleaseDays(""), 0);
|
||||||
|
assert.equal(resolveAutoReleaseDays("0"), 0);
|
||||||
|
assert.equal(resolveAutoReleaseDays("-5"), 0);
|
||||||
|
assert.equal(resolveAutoReleaseDays("abc"), 0);
|
||||||
|
assert.equal(resolveAutoReleaseDays("7"), 7);
|
||||||
|
assert.equal(resolveAutoReleaseDays("120"), 90);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("runOverdueEscalation posts robot digest and template cards per project", async () => {
|
||||||
|
const calls = [];
|
||||||
|
const summary = await runOverdueEscalation(
|
||||||
|
fakeEscalationDb(),
|
||||||
|
config,
|
||||||
|
wecomFetch(calls),
|
||||||
|
NOW,
|
||||||
|
);
|
||||||
|
assert.equal(summary.overdueTasks, 2);
|
||||||
|
assert.equal(summary.overdueDistributions, 3);
|
||||||
|
assert.equal(summary.robotSent, true);
|
||||||
|
|
||||||
|
const robotBodies = calls
|
||||||
|
.filter((call) => call.url.host === "qyapi.example.com")
|
||||||
|
.map((call) => JSON.parse(String(call.init.body)));
|
||||||
|
assert.equal(robotBodies.length, 1);
|
||||||
|
assert.match(robotBodies[0].text.content, /【超期未发布升级】/);
|
||||||
|
assert.match(robotBodies[0].text.content, /超期任务A/);
|
||||||
|
assert.match(robotBodies[0].text.content, /无项目任务B/);
|
||||||
|
|
||||||
|
const cardCalls = calls.filter((call) =>
|
||||||
|
call.url.pathname.endsWith("/cgi-bin/message/send"),
|
||||||
|
);
|
||||||
|
assert.equal(cardCalls.length, 1, "only the project task gets a card");
|
||||||
|
const body = JSON.parse(String(cardCalls[0].init.body));
|
||||||
|
assert.equal(body.msgtype, "template_card");
|
||||||
|
assert.equal(body.template_card.task_id, "task:task-A:due");
|
||||||
|
assert.equal(body.touser, "opsZhang|opsLi");
|
||||||
|
const buttons = body.template_card.button_list;
|
||||||
|
assert.equal(buttons.length, 2);
|
||||||
|
assert.ok(buttons.some((button) => button.type === 0 && button.key === "mark_handled"));
|
||||||
|
assert.equal(summary.cardSent, 2);
|
||||||
|
assert.equal(summary.cardFailed, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("runOverdueEscalation no-ops without overdue rows", async () => {
|
||||||
|
const calls = [];
|
||||||
|
const summary = await runOverdueEscalation(
|
||||||
|
fakeEscalationDb({ rows: [] }),
|
||||||
|
config,
|
||||||
|
wecomFetch(calls),
|
||||||
|
NOW,
|
||||||
|
);
|
||||||
|
assert.equal(summary.overdueDistributions, 0);
|
||||||
|
assert.equal(summary.robotSent, false);
|
||||||
|
assert.equal(calls.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("runOverdueAutoRelease is disabled without env switch", async () => {
|
||||||
|
const calls = [];
|
||||||
|
const db = fakeReleaseDb();
|
||||||
|
const summary = await runOverdueAutoRelease(db, config, wecomFetch(calls), NOW);
|
||||||
|
assert.equal(summary.enabled, false);
|
||||||
|
assert.equal(summary.released, 0);
|
||||||
|
assert.equal(calls.length, 0);
|
||||||
|
assert.equal(db.executed.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("runOverdueAutoRelease releases overdue rows and skips blocked ones", async () => {
|
||||||
|
process.env.WECOM_AUTO_RELEASE_DAYS = "10";
|
||||||
|
const calls = [];
|
||||||
|
const db = fakeReleaseDb({ blockedIds: ["d-3"] });
|
||||||
|
const summary = await runOverdueAutoRelease(db, config, wecomFetch(calls), NOW);
|
||||||
|
// cutoff = 2026-08-22:d-1/d-2(08-20)命中,d-3(08-25)不在窗口内
|
||||||
|
assert.equal(summary.enabled, true);
|
||||||
|
assert.equal(summary.cutoff, "2026-08-22");
|
||||||
|
assert.equal(summary.released, 2);
|
||||||
|
assert.equal(summary.blocked, 0);
|
||||||
|
assert.ok(
|
||||||
|
db.executed.some((item) => /UPDATE contents SET status = 'available'/.test(item.sql)),
|
||||||
|
"released content returns to pool",
|
||||||
|
);
|
||||||
|
const robotBodies = calls
|
||||||
|
.filter((call) => call.url.host === "qyapi.example.com")
|
||||||
|
.map((call) => JSON.parse(String(call.init.body)));
|
||||||
|
assert.equal(robotBodies.length, 1);
|
||||||
|
assert.match(robotBodies[0].text.content, /已释放 2 条/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("runOverdueAutoRelease counts blocked rows as skipped", async () => {
|
||||||
|
process.env.WECOM_AUTO_RELEASE_DAYS = "3";
|
||||||
|
const db = fakeReleaseDb({ blockedIds: ["d-1", "d-2", "d-3"] });
|
||||||
|
const summary = await runOverdueAutoRelease(db, config, wecomFetch([]), NOW);
|
||||||
|
assert.equal(summary.cutoff, "2026-08-29");
|
||||||
|
assert.equal(summary.released, 0);
|
||||||
|
assert.equal(summary.blocked, 3);
|
||||||
|
assert.equal(summary.failed, 0);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user