2026-08-18 16:39:59 +08:00
|
|
|
|
import type { DatabaseClient } from "./database";
|
|
|
|
|
|
import { shanghaiDateFromTimestamp } from "./collection-service";
|
|
|
|
|
|
import {
|
|
|
|
|
|
sendWecomAppMessage,
|
|
|
|
|
|
sendWecomRobotMessage,
|
2026-09-01 17:37:35 +08:00
|
|
|
|
sendWecomTemplateCard,
|
2026-08-18 16:39:59 +08:00
|
|
|
|
type WecomConfig,
|
|
|
|
|
|
} from "./wecom-client";
|
2026-09-03 11:30:26 +08:00
|
|
|
|
import { sendTaskChatMessage } from "./task-group-service";
|
2026-08-18 16:39:59 +08:00
|
|
|
|
import { getRuntimeEnv } from "./runtime-env";
|
2026-09-01 17:37:35 +08:00
|
|
|
|
import { releaseUnfinishedDistribution } from "./distribution-release-service";
|
2026-08-18 16:39:59 +08:00
|
|
|
|
|
|
|
|
|
|
type FetchLike = typeof fetch;
|
|
|
|
|
|
|
|
|
|
|
|
export type WecomNotifySummary = {
|
|
|
|
|
|
dueSoonAttempted: number;
|
|
|
|
|
|
dueSoonSent: number;
|
|
|
|
|
|
dueSoonFailed: number;
|
|
|
|
|
|
dueSoonSkipped: number;
|
|
|
|
|
|
digestSent: boolean;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
type DueSoonRow = {
|
|
|
|
|
|
distribution_id: string;
|
|
|
|
|
|
partner_id: string;
|
|
|
|
|
|
partner_name: string;
|
|
|
|
|
|
wecom_external_user_id: string | null;
|
2026-09-03 11:30:26 +08:00
|
|
|
|
task_id: string;
|
2026-08-18 16:39:59 +08:00
|
|
|
|
task_name: string;
|
2026-10-09 17:02:46 +08:00
|
|
|
|
pending_phase: "publish" | "recovery" | "metrics";
|
2026-09-24 15:52:45 +08:00
|
|
|
|
pending_due_at: string;
|
2026-08-18 16:39:59 +08:00
|
|
|
|
content_title: string;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-09-24 15:52:45 +08:00
|
|
|
|
function pendingActionLabel(pendingPhase: string) {
|
2026-10-09 17:02:46 +08:00
|
|
|
|
if (pendingPhase === "recovery") return "截图待提交";
|
|
|
|
|
|
if (pendingPhase === "metrics") return "公开数据待补交";
|
|
|
|
|
|
return "待发布";
|
2026-09-17 15:21:24 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function pendingSummary(rows: DueSoonRow[]) {
|
2026-09-24 15:52:45 +08:00
|
|
|
|
const shot = rows.filter((row) => row.pending_phase === "recovery").length;
|
2026-10-09 17:02:46 +08:00
|
|
|
|
const metrics = rows.filter((row) => row.pending_phase === "metrics").length;
|
|
|
|
|
|
const publish = rows.length - shot - metrics;
|
|
|
|
|
|
const parts: string[] = [];
|
|
|
|
|
|
if (publish > 0) parts.push(`${publish} 条笔记待发布`);
|
|
|
|
|
|
if (shot > 0) parts.push(`${shot} 份截图待提交`);
|
|
|
|
|
|
if (metrics > 0) parts.push(`${metrics} 条公开数据待补交`);
|
|
|
|
|
|
if (parts.length === 0) return `${rows.length} 条待跟进`;
|
|
|
|
|
|
return parts.join("、");
|
2026-09-17 15:21:24 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-18 16:39:59 +08:00
|
|
|
|
export function computeDueCutoff(today: string, dueDays: number) {
|
|
|
|
|
|
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(today);
|
|
|
|
|
|
if (!match) return today;
|
|
|
|
|
|
const [, y, m, d] = match;
|
|
|
|
|
|
const date = new Date(
|
|
|
|
|
|
Date.UTC(Number(y), Number(m) - 1, Number(d)) + dueDays * 24 * 60 * 60 * 1_000,
|
|
|
|
|
|
);
|
|
|
|
|
|
return date.toISOString().slice(0, 10);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export async function runDueSoonWecomNotifications(
|
|
|
|
|
|
db: DatabaseClient,
|
|
|
|
|
|
config: WecomConfig,
|
|
|
|
|
|
fetchImpl: FetchLike = fetch,
|
|
|
|
|
|
now: number = Date.now(),
|
2026-09-04 18:03:46 +08:00
|
|
|
|
taskId?: string,
|
2026-08-18 16:39:59 +08:00
|
|
|
|
): Promise<WecomNotifySummary> {
|
|
|
|
|
|
const today = shanghaiDateFromTimestamp(now);
|
|
|
|
|
|
const cutoff = computeDueCutoff(today, config.dueDays);
|
|
|
|
|
|
const portalUrl = bindingValue(getRuntimeEnv().KOC_PORTAL_URL);
|
2026-09-04 18:03:46 +08:00
|
|
|
|
const taskFilter = String(taskId ?? "").trim();
|
2026-08-18 16:39:59 +08:00
|
|
|
|
|
2026-10-09 17:02:46 +08:00
|
|
|
|
// 双阶段催办:发布阶段(未回填链接)看 due_at;回收阶段(已发布未收截图)看 recovery_due_at;
|
|
|
|
|
|
// 采集失败兜底:已发布但公开数据采集失败时催 KOC 补交数据截图(不限日期,尽快补)
|
2026-09-04 18:03:46 +08:00
|
|
|
|
const dueSoonBase = `SELECT
|
2026-08-18 16:39:59 +08:00
|
|
|
|
d.id AS distribution_id,
|
|
|
|
|
|
d.partner_id,
|
|
|
|
|
|
p.name AS partner_name,
|
|
|
|
|
|
p.wecom_external_user_id,
|
2026-09-03 11:30:26 +08:00
|
|
|
|
t.id AS task_id,
|
2026-08-18 16:39:59 +08:00
|
|
|
|
t.name AS task_name,
|
2026-10-09 17:02:46 +08:00
|
|
|
|
CASE
|
|
|
|
|
|
WHEN d.publish_url IS NULL OR d.publish_url = '' THEN 'publish'
|
|
|
|
|
|
WHEN d.collection_status = 'failed' THEN 'metrics'
|
|
|
|
|
|
ELSE 'recovery'
|
|
|
|
|
|
END AS pending_phase,
|
|
|
|
|
|
CASE
|
|
|
|
|
|
WHEN d.publish_url IS NULL OR d.publish_url = '' THEN t.due_at
|
|
|
|
|
|
WHEN d.collection_status = 'failed'
|
|
|
|
|
|
THEN COALESCE(NULLIF(t.recovery_due_at, ''), t.due_at)
|
|
|
|
|
|
ELSE t.recovery_due_at
|
|
|
|
|
|
END AS pending_due_at,
|
2026-08-18 16:39:59 +08:00
|
|
|
|
c.title AS content_title
|
|
|
|
|
|
FROM distributions d
|
|
|
|
|
|
JOIN tasks t ON t.id = d.task_id
|
|
|
|
|
|
JOIN partners p ON p.id = d.partner_id
|
|
|
|
|
|
JOIN contents c ON c.id = d.content_id
|
2026-09-02 13:56:07 +08:00
|
|
|
|
WHERE (
|
2026-09-24 15:52:45 +08:00
|
|
|
|
((d.publish_url IS NULL OR d.publish_url = '')
|
|
|
|
|
|
AND t.due_at IS NOT NULL AND t.due_at != '' AND t.due_at <= ?)
|
|
|
|
|
|
OR (t.recovery_enabled = 1
|
|
|
|
|
|
AND d.publish_url IS NOT NULL AND d.publish_url != ''
|
|
|
|
|
|
AND d.result_submitted_at IS NULL
|
|
|
|
|
|
AND t.recovery_due_at IS NOT NULL AND t.recovery_due_at != ''
|
|
|
|
|
|
AND t.recovery_due_at <= ?)
|
2026-10-09 17:02:46 +08:00
|
|
|
|
OR (d.publish_url IS NOT NULL AND d.publish_url != ''
|
|
|
|
|
|
AND d.collection_status = 'failed')
|
2026-09-24 15:52:45 +08:00
|
|
|
|
)`;
|
2026-09-04 18:03:46 +08:00
|
|
|
|
const statement = db
|
|
|
|
|
|
.prepare(
|
|
|
|
|
|
taskFilter
|
2026-09-24 15:52:45 +08:00
|
|
|
|
? `${dueSoonBase} AND t.id = ? ORDER BY pending_due_at ASC, p.name ASC`
|
|
|
|
|
|
: `${dueSoonBase} ORDER BY pending_due_at ASC, p.name ASC`,
|
2026-08-18 16:39:59 +08:00
|
|
|
|
)
|
2026-09-24 15:52:45 +08:00
|
|
|
|
.bind(...(taskFilter ? [cutoff, cutoff, taskFilter] : [cutoff, cutoff]));
|
2026-09-04 18:03:46 +08:00
|
|
|
|
const result = await statement.all<DueSoonRow>();
|
2026-08-18 16:39:59 +08:00
|
|
|
|
|
|
|
|
|
|
const grouped = new Map<
|
|
|
|
|
|
string,
|
|
|
|
|
|
{
|
|
|
|
|
|
partnerName: string;
|
|
|
|
|
|
externalUserId: string | null;
|
|
|
|
|
|
rows: DueSoonRow[];
|
|
|
|
|
|
}
|
|
|
|
|
|
>();
|
|
|
|
|
|
for (const row of result.results) {
|
|
|
|
|
|
const entry = grouped.get(row.partner_id) ?? {
|
|
|
|
|
|
partnerName: row.partner_name,
|
|
|
|
|
|
externalUserId: row.wecom_external_user_id,
|
|
|
|
|
|
rows: [],
|
|
|
|
|
|
};
|
|
|
|
|
|
entry.rows.push(row);
|
|
|
|
|
|
if (!entry.externalUserId && row.wecom_external_user_id) {
|
|
|
|
|
|
entry.externalUserId = row.wecom_external_user_id;
|
|
|
|
|
|
}
|
|
|
|
|
|
grouped.set(row.partner_id, entry);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
let dueSoonAttempted = 0;
|
|
|
|
|
|
let dueSoonSent = 0;
|
|
|
|
|
|
let dueSoonFailed = 0;
|
|
|
|
|
|
let dueSoonSkipped = 0;
|
|
|
|
|
|
const digestTasks: string[] = [];
|
|
|
|
|
|
|
|
|
|
|
|
for (const [, entry] of grouped) {
|
|
|
|
|
|
dueSoonAttempted += 1;
|
|
|
|
|
|
const external = entry.externalUserId
|
|
|
|
|
|
? [entry.externalUserId]
|
|
|
|
|
|
: [];
|
|
|
|
|
|
const lines = entry.rows.slice(0, 5).map((row) => {
|
2026-09-24 15:52:45 +08:00
|
|
|
|
return `· 《${truncate(row.task_name, 24)}》— ${truncate(row.content_title, 24)}(${pendingActionLabel(row.pending_phase)},截止 ${row.pending_due_at})`;
|
2026-08-18 16:39:59 +08:00
|
|
|
|
});
|
|
|
|
|
|
const overflow =
|
|
|
|
|
|
entry.rows.length > 5 ? `\n…还有 ${entry.rows.length - 5} 条` : "";
|
|
|
|
|
|
const link = portalUrl ? `\n领取链接:${portalUrl}` : "";
|
|
|
|
|
|
const content =
|
2026-09-17 15:21:24 +08:00
|
|
|
|
`${entry.partnerName},你有 ${pendingSummary(entry.rows)}:\n${lines.join("\n")}${overflow}${link}`;
|
2026-08-18 16:39:59 +08:00
|
|
|
|
const appResult = await sendWecomAppMessage(
|
|
|
|
|
|
external,
|
|
|
|
|
|
content,
|
|
|
|
|
|
config,
|
|
|
|
|
|
fetchImpl,
|
|
|
|
|
|
).catch((error: unknown) => {
|
|
|
|
|
|
console.warn(
|
|
|
|
|
|
"[KOC LOOP] wecom app message failed",
|
|
|
|
|
|
{ partner: entry.partnerName, error: safeError(error) },
|
|
|
|
|
|
);
|
|
|
|
|
|
return null;
|
|
|
|
|
|
});
|
|
|
|
|
|
if (appResult === null) {
|
|
|
|
|
|
dueSoonFailed += 1;
|
|
|
|
|
|
} else if (appResult.skipped) {
|
|
|
|
|
|
dueSoonSkipped += 1;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
dueSoonSent += 1;
|
|
|
|
|
|
}
|
2026-09-24 15:52:45 +08:00
|
|
|
|
const earliestDue = entry.rows[0]?.pending_due_at ?? "";
|
2026-08-18 16:39:59 +08:00
|
|
|
|
digestTasks.push(
|
|
|
|
|
|
`· ${entry.partnerName}(${entry.rows.length} 条,最近截止 ${earliestDue})`,
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-03 11:30:26 +08:00
|
|
|
|
const byTask = new Map<
|
|
|
|
|
|
string,
|
|
|
|
|
|
{ taskName: string; dueAt: string; rows: DueSoonRow[] }
|
|
|
|
|
|
>();
|
|
|
|
|
|
for (const row of result.results) {
|
|
|
|
|
|
const entry = byTask.get(row.task_id) ?? {
|
|
|
|
|
|
taskName: row.task_name,
|
2026-09-24 15:52:45 +08:00
|
|
|
|
dueAt: row.pending_due_at,
|
2026-09-03 11:30:26 +08:00
|
|
|
|
rows: [],
|
|
|
|
|
|
};
|
|
|
|
|
|
entry.rows.push(row);
|
|
|
|
|
|
byTask.set(row.task_id, entry);
|
|
|
|
|
|
}
|
2026-09-04 18:03:46 +08:00
|
|
|
|
for (const [groupTaskId, entry] of byTask) {
|
2026-09-03 11:30:26 +08:00
|
|
|
|
const partners = [...new Set(entry.rows.map((row) => row.partner_name))];
|
|
|
|
|
|
const overflow = partners.length > 5 ? ` 等 ${partners.length} 位` : "";
|
|
|
|
|
|
await sendTaskChatMessage(
|
|
|
|
|
|
db,
|
2026-09-04 18:03:46 +08:00
|
|
|
|
groupTaskId,
|
2026-09-17 15:21:24 +08:00
|
|
|
|
`【催办】《${truncate(entry.taskName, 24)}》有 ${pendingSummary(entry.rows)}(截止 ${entry.dueAt}):\n${partners.slice(0, 5).join("、")}${overflow}`,
|
2026-09-03 11:30:26 +08:00
|
|
|
|
config,
|
|
|
|
|
|
fetchImpl,
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-18 16:39:59 +08:00
|
|
|
|
let digestSent = false;
|
2026-09-04 18:03:46 +08:00
|
|
|
|
if (config.robotWebhook && digestTasks.length > 0 && !taskFilter) {
|
2026-08-18 16:39:59 +08:00
|
|
|
|
const digest =
|
|
|
|
|
|
`今日待发布催办(${today},截止 ≤ ${cutoff}):\n${digestTasks.join("\n")}`;
|
|
|
|
|
|
try {
|
|
|
|
|
|
await sendWecomRobotMessage(digest, config, fetchImpl);
|
|
|
|
|
|
digestSent = true;
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.warn(
|
|
|
|
|
|
"[KOC LOOP] wecom robot digest failed",
|
|
|
|
|
|
{ error: safeError(error) },
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
dueSoonAttempted,
|
|
|
|
|
|
dueSoonSent,
|
|
|
|
|
|
dueSoonFailed,
|
|
|
|
|
|
dueSoonSkipped,
|
|
|
|
|
|
digestSent,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function bindingValue(value: unknown) {
|
|
|
|
|
|
return String(value ?? "").trim();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-01 17:37:35 +08:00
|
|
|
|
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;
|
2026-09-24 15:52:45 +08:00
|
|
|
|
pending_phase: "publish" | "recovery";
|
|
|
|
|
|
pending_due_at: string;
|
2026-09-01 17:37:35 +08:00
|
|
|
|
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,
|
2026-09-24 15:52:45 +08:00
|
|
|
|
options: { phases: "publish" | "all" } = { phases: "all" },
|
2026-09-01 17:37:35 +08:00
|
|
|
|
): Promise<OverdueRow[]> {
|
2026-09-24 15:52:45 +08:00
|
|
|
|
const publishBranch = `(d.publish_url IS NULL OR d.publish_url = '')
|
|
|
|
|
|
AND t.due_at IS NOT NULL AND t.due_at != ''
|
|
|
|
|
|
AND t.due_at < ?`;
|
|
|
|
|
|
const recoveryBranch = `t.recovery_enabled = 1
|
|
|
|
|
|
AND d.publish_url IS NOT NULL AND d.publish_url != ''
|
|
|
|
|
|
AND d.result_submitted_at IS NULL
|
|
|
|
|
|
AND t.recovery_due_at IS NOT NULL AND t.recovery_due_at != ''
|
|
|
|
|
|
AND t.recovery_due_at < ?`;
|
|
|
|
|
|
const where =
|
|
|
|
|
|
options.phases === "publish"
|
|
|
|
|
|
? publishBranch
|
|
|
|
|
|
: `(${publishBranch}) OR (${recoveryBranch})`;
|
2026-09-01 17:37:35 +08:00
|
|
|
|
const result = await db
|
|
|
|
|
|
.prepare(
|
2026-09-24 15:52:45 +08:00
|
|
|
|
`SELECT d.id, t.id AS task_id, t.name AS task_name,
|
|
|
|
|
|
CASE WHEN d.publish_url IS NULL OR d.publish_url = '' THEN 'publish' ELSE 'recovery' END AS pending_phase,
|
|
|
|
|
|
CASE WHEN d.publish_url IS NULL OR d.publish_url = '' THEN t.due_at ELSE t.recovery_due_at END AS pending_due_at,
|
2026-09-01 17:37:35 +08:00
|
|
|
|
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
|
2026-09-24 15:52:45 +08:00
|
|
|
|
WHERE ${where}
|
|
|
|
|
|
ORDER BY pending_due_at ASC, t.name ASC`,
|
2026-09-01 17:37:35 +08:00
|
|
|
|
)
|
2026-09-24 15:52:45 +08:00
|
|
|
|
.bind(...(options.phases === "publish" ? [cutoff] : [cutoff, cutoff]))
|
2026-09-01 17:37:35 +08:00
|
|
|
|
.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,
|
2026-09-24 15:52:45 +08:00
|
|
|
|
{
|
|
|
|
|
|
taskName: string;
|
|
|
|
|
|
dueAt: string;
|
|
|
|
|
|
phase: "publish" | "recovery";
|
|
|
|
|
|
projectId: string;
|
|
|
|
|
|
count: number;
|
|
|
|
|
|
partners: Set<string>;
|
|
|
|
|
|
}
|
2026-09-01 17:37:35 +08:00
|
|
|
|
>();
|
|
|
|
|
|
for (const row of rows) {
|
2026-09-24 15:52:45 +08:00
|
|
|
|
const key = `${row.task_id}:${row.pending_phase}`;
|
|
|
|
|
|
const entry = byTask.get(key) ?? {
|
2026-09-01 17:37:35 +08:00
|
|
|
|
taskName: row.task_name,
|
2026-09-24 15:52:45 +08:00
|
|
|
|
dueAt: row.pending_due_at,
|
|
|
|
|
|
phase: row.pending_phase,
|
2026-09-01 17:37:35 +08:00
|
|
|
|
projectId: bindingValue(row.project_id),
|
|
|
|
|
|
count: 0,
|
|
|
|
|
|
partners: new Set<string>(),
|
|
|
|
|
|
};
|
|
|
|
|
|
entry.count += 1;
|
|
|
|
|
|
entry.partners.add(row.partner_name || row.partner_id);
|
2026-09-24 15:52:45 +08:00
|
|
|
|
byTask.set(key, entry);
|
2026-09-01 17:37:35 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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} 位` : "";
|
2026-09-24 15:52:45 +08:00
|
|
|
|
const label = entry.phase === "recovery" ? "截图超期未提交" : "超期未发布";
|
|
|
|
|
|
return `· ${truncate(entry.taskName, 24)}(截止 ${entry.dueAt}):${entry.count} 条${label} — ${partners}${overflow}`;
|
2026-09-01 17:37:35 +08:00
|
|
|
|
});
|
|
|
|
|
|
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);
|
2026-09-24 15:52:45 +08:00
|
|
|
|
for (const [key, entry] of byTask) {
|
|
|
|
|
|
const taskId = key.split(":")[0];
|
|
|
|
|
|
const overdueText =
|
|
|
|
|
|
entry.phase === "recovery"
|
|
|
|
|
|
? `${entry.count} 份截图超期未提交`
|
|
|
|
|
|
: `${entry.count} 条内容超期未发布`;
|
2026-09-03 11:30:26 +08:00
|
|
|
|
await sendTaskChatMessage(
|
|
|
|
|
|
db,
|
|
|
|
|
|
taskId,
|
2026-09-24 15:52:45 +08:00
|
|
|
|
`【超期催办】《${truncate(entry.taskName, 24)}》(截止 ${entry.dueAt})有 ${overdueText},请跟进催办或释放。`,
|
2026-09-03 11:30:26 +08:00
|
|
|
|
config,
|
|
|
|
|
|
fetchImpl,
|
|
|
|
|
|
);
|
2026-09-01 17:37:35 +08:00
|
|
|
|
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)}`,
|
2026-09-24 15:52:45 +08:00
|
|
|
|
description: `${entry.phase === "recovery" ? `${entry.count} 份截图超期未提交` : `${entry.count} 条内容超期未发布`}(截止 ${entry.dueAt}),请跟进催办或释放`,
|
2026-09-01 17:37:35 +08:00
|
|
|
|
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);
|
2026-09-24 15:52:45 +08:00
|
|
|
|
// 自动释放只处理发布阶段超期:回收超期的笔记已发布,释放重派无意义
|
|
|
|
|
|
const rows = await queryOverdueUnpublished(db, summary.cutoff, {
|
|
|
|
|
|
phases: "publish",
|
|
|
|
|
|
});
|
2026-09-01 17:37:35 +08:00
|
|
|
|
|
|
|
|
|
|
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 [];
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-18 16:39:59 +08:00
|
|
|
|
function truncate(value: string, max: number) {
|
|
|
|
|
|
return value.length > max ? `${value.slice(0, max)}…` : value;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function safeError(error: unknown) {
|
|
|
|
|
|
return error instanceof Error ? error.message : String(error);
|
|
|
|
|
|
}
|