fix(scheduled-task): deliver verified WeChat links only after page is ready
Memind CI / Test, build, and release guards (push) Successful in 8m42s

Wait for HTML materialization before releasing delivery contracts, pass
verified MindSpace URLs through proactive WeChat sends, resend on reconcile
when pages land late, and report deferred customer-service delivery as unsent.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
john
2026-08-17 22:07:41 +08:00
parent 0a7b2df5fe
commit 1ca06e58e7
10 changed files with 374 additions and 63 deletions
+168 -40
View File
@@ -78,6 +78,23 @@ export function formatScheduledTaskDeliveryMessage(task, deliveryText) {
return `${header}\n\n${body}`.trim();
}
export function resolveScheduledTaskPublicBaseUrl(env = process.env) {
return String(env.H5_PUBLIC_BASE_URL ?? 'https://m.tkmind.cn').replace(/\/+$/, '');
}
export function buildScheduledTaskVerifiedHtmlUrls(userId, readyPaths = [], {
publicBaseUrl = resolveScheduledTaskPublicBaseUrl(),
} = {}) {
const normalizedUserId = String(userId ?? '').trim();
if (!normalizedUserId) return [];
return [...new Set(
(Array.isArray(readyPaths) ? readyPaths : [])
.map((relativePath) => normalizeDeliveryRelativePath(relativePath))
.filter(Boolean)
.map((relativePath) => `${publicBaseUrl}/MindSpace/${normalizedUserId}/${relativePath}`),
)];
}
const SCHEDULED_TASK_CLARIFICATION_PATTERNS = [
/需确认/u,
/请确认/u,
@@ -90,30 +107,24 @@ const SCHEDULED_TASK_CLARIFICATION_PATTERNS = [
/缺(?:少|失)/u,
];
const DEFAULT_SCHEDULED_TASK_DELIVERY_RETRY_DELAYS_MS = [
250,
1_000,
3_000,
5_000,
10_000,
30_000,
60_000,
];
const DEFAULT_SCHEDULED_TASK_DELIVERY_POLL_INTERVAL_MS = 5_000;
export function resolveScheduledTaskDeliveryRetryDelaysMs(
export function resolveScheduledTaskDeliveryPollIntervalMs(
env = process.env,
) {
const raw = String(
env.H5_SCHEDULED_TASK_DELIVERY_RETRY_DELAYS_MS ?? '',
).trim();
if (!raw) return [...DEFAULT_SCHEDULED_TASK_DELIVERY_RETRY_DELAYS_MS];
const parsed = raw
.split(',')
.map((value) => Number(value.trim()))
.filter((value) => Number.isFinite(value) && value >= 0);
return parsed.length > 0
const parsed = Number(
env.H5_SCHEDULED_TASK_DELIVERY_POLL_INTERVAL_MS
?? DEFAULT_SCHEDULED_TASK_DELIVERY_POLL_INTERVAL_MS,
);
return Number.isFinite(parsed) && parsed > 0
? parsed
: [...DEFAULT_SCHEDULED_TASK_DELIVERY_RETRY_DELAYS_MS];
: DEFAULT_SCHEDULED_TASK_DELIVERY_POLL_INTERVAL_MS;
}
/** @deprecated use resolveScheduledTaskDeliveryPollIntervalMs */
export function resolveScheduledTaskDeliveryRetryDelaysMs(env = process.env) {
const interval = resolveScheduledTaskDeliveryPollIntervalMs(env);
return [0, interval, interval, interval];
}
export function extractPublicHtmlPathsFromText(text) {
@@ -260,6 +271,7 @@ export async function finalizeScheduledTaskPageDelivery({
userId,
relativePaths: [...pathsToRelease],
allowPgRequired: true,
publishDir,
}).catch((error) => {
logger.warn?.('[ScheduledTask] release delivery contracts failed:', error);
return [];
@@ -281,57 +293,72 @@ export async function awaitScheduledTaskPageDelivery({
messages,
publishDir,
deliveryText = '',
task = null,
tkmindProxy = null,
sessionSnapshotService = null,
retryDelaysMs = resolveScheduledTaskDeliveryRetryDelaysMs(),
timeoutMs = 15 * 60 * 1000,
pollIntervalMs = resolveScheduledTaskDeliveryPollIntervalMs(),
sleepFn = (delayMs) => new Promise((resolve) => {
setTimeout(resolve, delayMs);
}),
logger = console,
} = {}) {
let currentMessages = Array.isArray(messages) ? messages : [];
let currentDeliveryText = String(deliveryText ?? '');
let readyPaths = [];
const attempts = [0, ...retryDelaysMs];
const deadline = Date.now() + Math.max(Number(pollIntervalMs) || 0, Number(timeoutMs) || 0);
let attempt = 0;
for (let attempt = 0; attempt < attempts.length; attempt += 1) {
if (attempt > 0) {
await sleepFn(attempts[attempt]);
currentMessages = await refreshScheduledTaskMessages({
userId,
sessionId,
tkmindProxy,
sessionSnapshotService,
});
}
while (true) {
attempt += 1;
readyPaths = await finalizeScheduledTaskPageDelivery({
pool,
userId,
sessionId,
messages: currentMessages,
publishDir,
deliveryText,
deliveryText: currentDeliveryText,
logger,
}).catch((error) => {
logger.warn?.('[ScheduledTask] finalize page delivery failed:', error);
return [];
});
const promisesHtml = deliveryTextPromisesPublicHtml(deliveryText)
const promisesHtml = deliveryTextPromisesPublicHtml(currentDeliveryText)
|| collectScheduledTaskPageRelativePaths({
messages: currentMessages,
publishDir,
userId,
deliveryText,
deliveryText: currentDeliveryText,
}).relativePaths.length > 0;
if (!promisesHtml || readyPaths.length > 0) {
if (!promisesHtml || readyPaths.length > 0 || Date.now() >= deadline) {
if (promisesHtml && readyPaths.length === 0 && Date.now() >= deadline) {
logger.warn?.('[ScheduledTask] page delivery timed out while preparing', {
userId,
sessionId,
attempt,
timeoutMs,
});
}
break;
}
logger.warn?.('[ScheduledTask] page delivery still preparing', {
userId,
sessionId,
attempt: attempt + 1,
maxAttempts: attempts.length,
attempt,
nextPollMs: pollIntervalMs,
});
await sleepFn(pollIntervalMs);
currentMessages = await refreshScheduledTaskMessages({
userId,
sessionId,
tkmindProxy,
sessionSnapshotService,
});
if (task) {
currentDeliveryText = extractScheduledTaskDeliveryText(currentMessages, task);
}
}
return {
@@ -390,7 +417,7 @@ export async function reconcileStuckStaticPageDeliveryContracts({
relativePath,
})
) {
released.push(relativePath);
released.push({ userId: row.user_id, relativePath });
logger.info?.('[ScheduledTask] reconciled static delivery contract', {
userId: row.user_id,
relativePath,
@@ -400,6 +427,105 @@ export async function reconcileStuckStaticPageDeliveryContracts({
return released;
}
export async function resendScheduledTaskWechatForReadyPage({
pool,
userId,
relativePath,
notificationDispatcher = null,
publicBaseUrl = resolveScheduledTaskPublicBaseUrl(),
lookbackMs = 24 * 60 * 60 * 1000,
now = Date.now(),
logger = console,
} = {}) {
const normalizedPath = normalizeDeliveryRelativePath(relativePath);
const normalizedUserId = String(userId ?? '').trim();
if (
!pool
|| !normalizedUserId
|| !normalizedPath
|| typeof notificationDispatcher?.sendScheduleNotification !== 'function'
) {
return false;
}
const [rows] = await pool.query(
`SELECT id, title, notify_channel, last_result_json, last_run_at
FROM h5_scheduled_tasks
WHERE user_id = ? AND last_run_at IS NOT NULL AND last_run_at >= ?
ORDER BY last_run_at DESC
LIMIT 20`,
[normalizedUserId, now - Math.max(lookbackMs, 60_000)],
);
const verifiedUrl = `${publicBaseUrl}/MindSpace/${normalizedUserId}/${normalizedPath}`;
for (const row of rows ?? []) {
const channel = row.notify_channel ?? 'both';
if (channel !== 'wechat' && channel !== 'both') continue;
const lastResult = row.last_result_json && typeof row.last_result_json === 'object'
? row.last_result_json
: null;
const deliveryText = String(lastResult?.deliveryText ?? '');
if (
!deliveryText.includes(normalizedPath)
&& !deliveryText.includes(verifiedUrl)
) {
continue;
}
const priorDelivery = lastResult?.wechatDelivery;
if (
priorDelivery?.sentAt
&& Array.isArray(priorDelivery.relativePaths)
&& priorDelivery.relativePaths.includes(normalizedPath)
) {
continue;
}
const text = formatScheduledTaskDeliveryMessage(
{ title: row.title },
deliveryText,
);
const verifiedHtmlUrls = buildScheduledTaskVerifiedHtmlUrls(
normalizedUserId,
[normalizedPath],
{ publicBaseUrl },
);
const sent = await notificationDispatcher.sendScheduleNotification({
userId: normalizedUserId,
text,
verifiedHtmlUrls,
}).catch((error) => {
logger.warn?.('[ScheduledTask] reconcile wechat resend failed:', error);
return false;
});
if (!sent) continue;
const nextResult = {
...(lastResult ?? {}),
wechatDelivery: {
sentAt: now,
relativePaths: [normalizedPath],
source: 'reconcile',
},
};
await pool.query(
`UPDATE h5_scheduled_tasks
SET last_result_json = ?, updated_at = ?
WHERE id = ? AND user_id = ?`,
[JSON.stringify(nextResult), now, row.id, normalizedUserId],
);
logger.info?.('[ScheduledTask] reconciled wechat delivery resent', {
userId: normalizedUserId,
taskId: row.id,
relativePath: normalizedPath,
});
return true;
}
return false;
}
export async function executeScheduledTask(task, {
userAuth,
tkmindProxy,
@@ -470,8 +596,10 @@ export async function executeScheduledTask(task, {
messages,
publishDir,
deliveryText,
task,
tkmindProxy,
sessionSnapshotService,
timeoutMs,
logger,
})
: { messages, readyPaths: [] };
@@ -485,7 +613,7 @@ export async function executeScheduledTask(task, {
&& task.userId
) {
const links = readyPaths.map(
(relativePath) => `https://m.tkmind.cn/MindSpace/${task.userId}/${relativePath}`,
(relativePath) => `${resolveScheduledTaskPublicBaseUrl()}/MindSpace/${task.userId}/${relativePath}`,
);
deliveryText = `${deliveryText}\n\n页面链接:\n${links.join('\n')}`.trim();
}