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