feat(schedule): 待办提醒推送、行事历仅展示提醒与管理 API
单次提醒 worker 投递、local 时间写入校验、未来 7 天提醒列表与忽略/批量删除;行事历去掉事项重复展示。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+238
-2
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user