feat(schedule): 待办提醒推送、行事历仅展示提醒与管理 API

单次提醒 worker 投递、local 时间写入校验、未来 7 天提醒列表与忽略/批量删除;行事历去掉事项重复展示。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
john
2026-06-30 02:02:58 +08:00
parent 06eb129a28
commit 2c91689692
20 changed files with 2453 additions and 452 deletions
+238 -2
View File
@@ -91,6 +91,19 @@ function rowToReminder(row) {
};
}
function rowToReminderWithItem(row) {
const reminder = rowToReminder(row);
if (!reminder) return null;
return {
...reminder,
itemTitle: String(row.item_title ?? '').trim(),
itemKind: row.item_kind === 'event' ? 'event' : 'task',
itemTimezone: row.item_timezone || DEFAULT_TIMEZONE,
itemStartAt: row.item_start_at == null ? null : Number(row.item_start_at),
itemEndAt: row.item_end_at == null ? null : Number(row.item_end_at),
};
}
function parseJsonColumn(value) {
if (value == null || value === '') return null;
if (typeof value === 'string') {
@@ -253,6 +266,114 @@ export function createScheduleService(pool, options = {}) {
return listItems({ userId, from: start, to: end, status: 'active', limit: 200 });
};
const listUpcomingItems = async ({
userId,
timezone = defaultTimezone,
days = 7,
now = clock.now(),
} = {}) => {
const safeDays = Math.max(1, Math.min(30, Number(days) || 7));
const start = startOfLocalDay(now, timezone);
const end = addLocalDays(start, safeDays, timezone);
return listItems({ userId, from: start, to: end, status: 'active', limit: 200 });
};
const listUpcomingReminders = async ({
userId,
timezone = defaultTimezone,
days = 7,
now = clock.now(),
limit = 100,
} = {}) => {
if (!userId) throw new Error('缺少用户');
const safeDays = Math.max(1, Math.min(30, Number(days) || 7));
const start = startOfLocalDay(now, timezone);
const end = addLocalDays(start, safeDays, timezone);
const [rows] = await pool.query(
`SELECT r.*,
i.title AS item_title,
i.kind AS item_kind,
i.timezone AS item_timezone,
i.start_at AS item_start_at,
i.end_at AS item_end_at
FROM h5_schedule_reminders r
INNER JOIN h5_schedule_items i ON i.id = r.item_id
WHERE r.user_id = ?
AND r.status IN ('pending', 'locked', 'sent')
AND r.remind_at >= ?
AND r.remind_at < ?
AND i.deleted_at IS NULL
AND i.status = 'active'
ORDER BY r.remind_at ASC
LIMIT ?`,
[
userId,
start,
end,
Math.max(1, Math.min(200, Number(limit) || 100)),
],
);
return rows.map(rowToReminderWithItem);
};
const getItem = async ({ userId, itemId }) => {
if (!userId || !itemId) throw new Error('缺少事项参数');
const [rows] = await pool.query(
`SELECT *
FROM h5_schedule_items
WHERE id = ? AND user_id = ? AND deleted_at IS NULL
LIMIT 1`,
[itemId, userId],
);
return rowToItem(rows[0]);
};
const getReminder = async ({ userId, reminderId }) => {
if (!userId || !reminderId) throw new Error('缺少提醒参数');
const [rows] = await pool.query(
`SELECT r.*,
i.title AS item_title,
i.kind AS item_kind,
i.timezone AS item_timezone,
i.start_at AS item_start_at,
i.end_at AS item_end_at
FROM h5_schedule_reminders r
INNER JOIN h5_schedule_items i ON i.id = r.item_id
WHERE r.id = ? AND r.user_id = ? AND i.deleted_at IS NULL
LIMIT 1`,
[reminderId, userId],
);
return rowToReminderWithItem(rows[0]);
};
const cancelReminder = async ({ userId, reminderId, reason = '用户忽略' } = {}) => {
const reminder = await getReminder({ userId, reminderId });
if (!reminder) throw new Error('提醒不存在或无权访问');
if (reminder.status === 'cancelled') return reminder;
if (reminder.status === 'sent') throw new Error('已通知的提醒不能忽略');
return markReminderCancelled(reminder, reason);
};
const deleteReminder = async ({ userId, reminderId }) => {
if (!userId || !reminderId) throw new Error('缺少提醒参数');
const [result] = await pool.query(
`DELETE FROM h5_schedule_reminders WHERE id = ? AND user_id = ?`,
[reminderId, userId],
);
return Number(result?.affectedRows ?? 0) > 0;
};
const deleteReminders = async ({ userId, reminderIds }) => {
if (!userId) throw new Error('缺少用户');
const ids = [...new Set((reminderIds ?? []).map((id) => String(id ?? '').trim()).filter(Boolean))];
if (ids.length === 0) return 0;
const [result] = await pool.query(
`DELETE FROM h5_schedule_reminders WHERE user_id = ? AND id IN (${ids.map(() => '?').join(', ')})`,
[userId, ...ids],
);
return Number(result?.affectedRows ?? 0);
};
const createReminder = async ({
userId,
itemId,
@@ -483,6 +604,98 @@ export function createScheduleService(pool, options = {}) {
};
};
const listDueReminders = async ({ now = clock.now(), limit = 50 } = {}) => {
const [rows] = await pool.query(
`SELECT r.*
FROM h5_schedule_reminders r
INNER JOIN h5_schedule_items i ON i.id = r.item_id
WHERE r.status = 'pending'
AND r.remind_at <= ?
AND (r.locked_until IS NULL OR r.locked_until <= ?)
AND i.deleted_at IS NULL
AND i.status = 'active'
ORDER BY r.remind_at ASC
LIMIT ?`,
[now, now, Math.max(1, Math.min(200, Number(limit) || 50))],
);
return rows.map(rowToReminder);
};
const lockReminder = async (id, { now = clock.now(), lockMs = 120_000 } = {}) => {
const lockedUntil = now + lockMs;
const [result] = await pool.query(
`UPDATE h5_schedule_reminders
SET status = 'locked', locked_until = ?, attempts = attempts + 1, updated_at = ?
WHERE id = ? AND status = 'pending' AND remind_at <= ?`,
[lockedUntil, now, id, now],
);
if (Number(result?.affectedRows ?? 0) !== 1) return null;
const [rows] = await pool.query(
`SELECT * FROM h5_schedule_reminders WHERE id = ? LIMIT 1`,
[id],
);
return rowToReminder(rows[0]);
};
const markReminderSent = async (reminder, { now = clock.now() } = {}) => {
await pool.query(
`UPDATE h5_schedule_reminders
SET status = 'sent', sent_at = ?, locked_until = NULL, last_error = NULL, updated_at = ?
WHERE id = ?`,
[now, now, reminder.id],
);
return { ...reminder, status: 'sent', sentAt: now, lockedUntil: null };
};
const markReminderCancelled = async (reminder, reason, { now = clock.now() } = {}) => {
const lastError = String(reason ?? '已取消').slice(0, 500);
await pool.query(
`UPDATE h5_schedule_reminders
SET status = 'cancelled', locked_until = NULL, last_error = ?, updated_at = ?
WHERE id = ?`,
[lastError, now, reminder.id],
);
return { ...reminder, status: 'cancelled', lastError };
};
const markReminderFailed = async (
reminder,
error,
{ now = clock.now(), retryMs = 10 * 60 * 1000, maxAttempts = 5 } = {},
) => {
const attempts = Number(reminder.attempts ?? 0);
const status = attempts >= maxAttempts ? 'failed' : 'pending';
const lockedUntil = status === 'pending' ? now + retryMs : null;
await pool.query(
`UPDATE h5_schedule_reminders
SET status = ?, locked_until = ?, last_error = ?, updated_at = ?
WHERE id = ?`,
[
status,
lockedUntil,
String(error?.message ?? error ?? '发送失败').slice(0, 500),
now,
reminder.id,
],
);
};
const buildReminderText = async (reminder) => {
const item = await getItem({ userId: reminder.userId, itemId: reminder.itemId });
if (!item || item.status !== 'active') return null;
const timezone = item.timezone || defaultTimezone;
const remindLabel = formatLocalTime(reminder.remindAt, timezone);
const eventAt = item.startAt ?? item.dueAt ?? null;
const eventLabel = eventAt ? formatLocalTime(eventAt, timezone) : null;
const lines = [`【待办提醒】${item.title}`];
if (eventLabel && eventLabel !== remindLabel) {
lines.push(`事项时间:${eventLabel}`);
}
lines.push(`提醒时间:${remindLabel}`);
if (item.location) lines.push(`地点:${item.location}`);
return lines.join('\n');
};
const listDueBalanceAlerts = async ({ now = clock.now(), limit = 50 } = {}) => {
const [rows] = await pool.query(
`SELECT *
@@ -593,14 +806,24 @@ export function createScheduleService(pool, options = {}) {
);
};
const logDelivery = async ({ subscriptionId, userId, channel = 'wechat', status, providerMessageId = null, errorCode = null, errorMessage = null }) => {
const logDelivery = async ({
reminderId = null,
subscriptionId = null,
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, ?, ?, ?, ?, ?, ?, ?, ?)`,
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
crypto.randomUUID(),
reminderId,
subscriptionId,
userId,
channel,
@@ -725,13 +948,26 @@ export function createScheduleService(pool, options = {}) {
return {
createItem,
getItem,
createReminder,
listItems,
listItemsBySourceMessage,
listTodayTodoItems,
listUpcomingItems,
listUpcomingReminders,
getReminder,
cancelReminder,
deleteReminder,
deleteReminders,
listDigestSubscriptions,
createDailyTodoDigest,
createBalanceLowAlert,
listDueReminders,
lockReminder,
markReminderSent,
markReminderCancelled,
markReminderFailed,
buildReminderText,
listDueDigestSubscriptions,
lockDigestSubscription,
markDigestSent,