Improve WeChat MP replies and ship MindSpace/H5 production updates.

Add WeChat service account routing with sync acks, connectivity tests, and context isolation; document deploy runbooks; and bundle related MindSpace, voice, Plaza, and server gateway changes for production rollout.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
john
2026-06-19 23:06:43 +08:00
parent b0f5d6a51c
commit 229805a070
241 changed files with 13190 additions and 902 deletions
+349
View File
@@ -0,0 +1,349 @@
import crypto from 'node:crypto';
import {
addLocalDays,
formatLocalTime,
localDateLabel,
nextDailyRunAt,
normalizeTimezone,
startOfLocalDay,
} from './schedule-time.mjs';
const DEFAULT_TIMEZONE = 'Asia/Shanghai';
function nowMs() {
return Date.now();
}
function rowToItem(row) {
if (!row) return null;
return {
id: row.id,
userId: row.user_id,
kind: row.kind,
title: row.title,
description: row.description ?? null,
status: row.status,
startAt: row.start_at == null ? null : Number(row.start_at),
endAt: row.end_at == null ? null : Number(row.end_at),
dueAt: row.due_at == null ? null : Number(row.due_at),
allDay: Boolean(row.all_day),
timezone: row.timezone || DEFAULT_TIMEZONE,
location: row.location ?? null,
createdAt: Number(row.created_at),
updatedAt: Number(row.updated_at),
};
}
function rowToDigest(row) {
if (!row) return null;
return {
id: row.id,
userId: row.user_id,
hour: Number(row.hour),
minute: Number(row.minute),
timezone: row.timezone || DEFAULT_TIMEZONE,
channel: row.channel || 'wechat',
status: row.status,
nextRunAt: Number(row.next_run_at),
lastRunAt: row.last_run_at == null ? null : Number(row.last_run_at),
attempts: Number(row.attempts ?? 0),
};
}
function formatTodoDigest(items, { now = nowMs(), timezone = DEFAULT_TIMEZONE } = {}) {
const date = localDateLabel(now, timezone);
if (!items.length) {
return `${date} 待办记录\n\n今天暂时没有待办。`;
}
const lines = [`${date} 待办记录`, ''];
items.forEach((item, index) => {
const time = item.dueAt || item.startAt ? `${formatLocalTime(item.dueAt || item.startAt, timezone)}` : '';
lines.push(`${index + 1}. ${item.title}${time}`);
});
return lines.join('\n');
}
export function createScheduleService(pool, options = {}) {
const defaultTimezone = normalizeTimezone(options.defaultTimezone || process.env.H5_DEFAULT_TIMEZONE);
const clock = options.clock || { now: nowMs };
const createItem = async ({
userId,
kind = 'task',
title,
description = null,
startAt = null,
endAt = null,
dueAt = null,
allDay = false,
timezone = defaultTimezone,
location = null,
sourceChannel = 'agent',
sourceSessionId = null,
sourceMessageId = null,
sourceText = null,
metadata = null,
}) => {
if (!userId) throw new Error('缺少用户');
const cleanTitle = String(title ?? '').trim();
if (!cleanTitle) throw new Error('缺少待办标题');
const safeKind = kind === 'event' ? 'event' : 'task';
const id = crypto.randomUUID();
const ts = clock.now();
await pool.query(
`INSERT INTO h5_schedule_items
(id, user_id, kind, title, description, status, start_at, end_at, due_at, all_day,
timezone, location, source_channel, source_session_id, source_message_id, source_text,
metadata_json, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, 'active', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
id,
userId,
safeKind,
cleanTitle,
description,
startAt,
endAt,
dueAt,
allDay ? 1 : 0,
normalizeTimezone(timezone),
location,
sourceChannel,
sourceSessionId,
sourceMessageId,
sourceText,
metadata ? JSON.stringify(metadata) : null,
ts,
ts,
],
);
return {
id,
userId,
kind: safeKind,
title: cleanTitle,
startAt,
endAt,
dueAt,
allDay: Boolean(allDay),
timezone: normalizeTimezone(timezone),
status: 'active',
createdAt: ts,
updatedAt: ts,
};
};
const listItems = async ({ userId, from = null, to = null, status = 'active', limit = 100 } = {}) => {
if (!userId) throw new Error('缺少用户');
const clauses = ['user_id = ?', 'deleted_at IS NULL'];
const params = [userId];
if (status) {
clauses.push('status = ?');
params.push(status);
}
if (from != null && to != null) {
clauses.push(
`(
(start_at IS NOT NULL AND start_at >= ? AND start_at < ?)
OR (due_at IS NOT NULL AND due_at >= ? AND due_at < ?)
OR (start_at IS NULL AND due_at IS NULL)
)`,
);
params.push(from, to, from, to);
}
params.push(Math.max(1, Math.min(500, Number(limit) || 100)));
const [rows] = await pool.query(
`SELECT *
FROM h5_schedule_items
WHERE ${clauses.join(' AND ')}
ORDER BY COALESCE(start_at, due_at, 9223372036854775807), created_at
LIMIT ?`,
params,
);
return rows.map(rowToItem);
};
const listTodayTodoItems = async ({ userId, timezone = defaultTimezone, now = clock.now() } = {}) => {
const start = startOfLocalDay(now, timezone);
const end = addLocalDays(start, 1, timezone);
return listItems({ userId, from: start, to: end, status: 'active', limit: 200 });
};
const createDailyTodoDigest = async ({
userId,
hour,
minute = 0,
timezone = defaultTimezone,
channel = 'wechat',
sourceChannel = 'agent',
sourceSessionId = null,
sourceMessageId = null,
sourceText = null,
}) => {
if (!userId) throw new Error('缺少用户');
const safeHour = Number(hour);
const safeMinute = Number(minute);
if (!Number.isInteger(safeHour) || safeHour < 0 || safeHour > 23) {
throw new Error('提醒小时无效');
}
if (!Number.isInteger(safeMinute) || safeMinute < 0 || safeMinute > 59) {
throw new Error('提醒分钟无效');
}
const tz = normalizeTimezone(timezone);
const nextRunAt = nextDailyRunAt({
hour: safeHour,
minute: safeMinute,
timezone: tz,
now: clock.now(),
});
const id = crypto.randomUUID();
const ts = clock.now();
await pool.query(
`INSERT INTO h5_schedule_digest_subscriptions
(id, user_id, digest_type, hour, minute, timezone, channel, status, next_run_at,
source_channel, source_session_id, source_message_id, source_text, created_at, updated_at)
VALUES (?, ?, 'todo_day', ?, ?, ?, ?, 'active', ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
hour = VALUES(hour),
minute = VALUES(minute),
timezone = VALUES(timezone),
channel = VALUES(channel),
status = 'active',
next_run_at = VALUES(next_run_at),
source_channel = VALUES(source_channel),
source_session_id = VALUES(source_session_id),
source_message_id = VALUES(source_message_id),
source_text = VALUES(source_text),
updated_at = VALUES(updated_at)`,
[
id,
userId,
safeHour,
safeMinute,
tz,
channel,
nextRunAt,
sourceChannel,
sourceSessionId,
sourceMessageId,
sourceText,
ts,
ts,
],
);
const [rows] = await pool.query(
`SELECT * FROM h5_schedule_digest_subscriptions
WHERE user_id = ? AND digest_type = 'todo_day' AND channel = ?
LIMIT 1`,
[userId, channel],
);
return rowToDigest(rows[0]) || {
id,
userId,
hour: safeHour,
minute: safeMinute,
timezone: tz,
channel,
status: 'active',
nextRunAt,
lastRunAt: null,
attempts: 0,
};
};
const listDueDigestSubscriptions = async ({ now = clock.now(), limit = 50 } = {}) => {
const [rows] = await pool.query(
`SELECT *
FROM h5_schedule_digest_subscriptions
WHERE next_run_at <= ?
AND (status = 'active' OR (status = 'locked' AND locked_until IS NOT NULL AND locked_until <= ?))
ORDER BY next_run_at ASC
LIMIT ?`,
[now, now, Math.max(1, Math.min(200, Number(limit) || 50))],
);
return rows.map(rowToDigest);
};
const lockDigestSubscription = async (id, { now = clock.now(), lockMs = 120_000 } = {}) => {
const lockedUntil = now + lockMs;
const [result] = await pool.query(
`UPDATE h5_schedule_digest_subscriptions
SET status = 'locked', locked_until = ?, attempts = attempts + 1, updated_at = ?
WHERE id = ? AND (status = 'active' OR (status = 'locked' AND locked_until IS NOT NULL AND locked_until <= ?))`,
[lockedUntil, now, id, now],
);
if (Number(result?.affectedRows ?? 0) !== 1) return null;
const [rows] = await pool.query(
`SELECT * FROM h5_schedule_digest_subscriptions WHERE id = ? LIMIT 1`,
[id],
);
return rowToDigest(rows[0]);
};
const markDigestSent = async (subscription, { now = clock.now() } = {}) => {
const nextRunAt = nextDailyRunAt({
hour: subscription.hour,
minute: subscription.minute,
timezone: subscription.timezone,
now: now + 1000,
});
await pool.query(
`UPDATE h5_schedule_digest_subscriptions
SET status = 'active', next_run_at = ?, last_run_at = ?, locked_until = NULL,
last_error = NULL, updated_at = ?
WHERE id = ?`,
[nextRunAt, now, now, subscription.id],
);
return { ...subscription, status: 'active', nextRunAt, lastRunAt: now };
};
const markDigestFailed = async (subscription, error, { now = clock.now(), retryMs = 10 * 60 * 1000, maxAttempts = 5 } = {}) => {
const attempts = Number(subscription.attempts ?? 0);
const status = attempts >= maxAttempts ? 'failed' : 'active';
const nextRunAt = status === 'active' ? now + retryMs : subscription.nextRunAt;
await pool.query(
`UPDATE h5_schedule_digest_subscriptions
SET status = ?, next_run_at = ?, locked_until = NULL, last_error = ?, updated_at = ?
WHERE id = ?`,
[status, nextRunAt, String(error?.message ?? error ?? '发送失败').slice(0, 500), now, subscription.id],
);
};
const logDelivery = async ({ subscriptionId, userId, channel = 'wechat', status, providerMessageId = null, errorCode = null, errorMessage = null }) => {
await pool.query(
`INSERT INTO h5_schedule_delivery_logs
(id, reminder_id, subscription_id, user_id, channel, status, provider_message_id,
error_code, error_message, created_at)
VALUES (?, NULL, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
crypto.randomUUID(),
subscriptionId,
userId,
channel,
status,
providerMessageId,
errorCode,
errorMessage ? String(errorMessage).slice(0, 500) : null,
clock.now(),
],
);
};
const buildTodoDigestText = async ({ userId, timezone = defaultTimezone, now = clock.now() }) => {
const items = await listTodayTodoItems({ userId, timezone, now });
return formatTodoDigest(items, { now, timezone });
};
return {
createItem,
listItems,
listTodayTodoItems,
createDailyTodoDigest,
listDueDigestSubscriptions,
lockDigestSubscription,
markDigestSent,
markDigestFailed,
logDelivery,
buildTodoDigestText,
};
}