229805a070
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>
350 lines
11 KiB
JavaScript
350 lines
11 KiB
JavaScript
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,
|
||
};
|
||
}
|