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
+251 -12
View File
@@ -149,6 +149,42 @@ function formatLocalTime(epochMs, timezone = DEFAULT_TIMEZONE) {
const parts = getLocalParts(epochMs, timezone);
return `${pad2(parts.hour)}:${pad2(parts.minute)}`;
}
function parseLocalDateTimeString(value, timezone = DEFAULT_TIMEZONE) {
const raw = String(value ?? "").trim();
if (!raw) return null;
const match = raw.match(
/^(\d{4})-(\d{1,2})-(\d{1,2})(?:[ T](\d{1,2}):(\d{2})(?::(\d{2}))?)?$/
);
if (!match) {
throw new Error(`\u65E0\u6CD5\u89E3\u6790\u672C\u5730\u65F6\u95F4\u300C${raw}\u300D\uFF0C\u8BF7\u4F7F\u7528 YYYY-MM-DD HH:mm`);
}
return zonedTimeToEpochMs(
{
year: Number(match[1]),
month: Number(match[2]),
day: Number(match[3]),
hour: Number(match[4] ?? 0),
minute: Number(match[5] ?? 0),
second: Number(match[6] ?? 0)
},
timezone
);
}
function resolveScheduleTimestamp({
epochMs = null,
localString = null,
timezone = DEFAULT_TIMEZONE,
fieldName = "\u65F6\u95F4"
} = {}) {
const local = String(localString ?? "").trim();
if (local) return parseLocalDateTimeString(local, timezone);
if (epochMs == null || epochMs === "") return null;
const safe = Number(epochMs);
if (!Number.isFinite(safe) || safe <= 0) {
throw new Error(`${fieldName}\u65E0\u6548\uFF0C\u8BF7\u6539\u7528 YYYY-MM-DD HH:mm \u7684 local \u5B57\u6BB5`);
}
return safe;
}
// schedule-service.mjs
var DEFAULT_TIMEZONE2 = "Asia/Shanghai";
@@ -227,6 +263,18 @@ function rowToReminder(row) {
updatedAt: Number(row.updated_at)
};
}
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_TIMEZONE2,
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") {
@@ -387,6 +435,65 @@ function createScheduleService(pool, options = {}) {
const end = addLocalDays(start, 1, timezone);
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("\u7F3A\u5C11\u7528\u6237");
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')
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("\u7F3A\u5C11\u4E8B\u9879\u53C2\u6570");
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 createReminder = async ({
userId,
itemId,
@@ -610,6 +717,88 @@ function createScheduleService(pool, options = {}) {
attempts: 0
};
};
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 = 12e4 } = {}) => {
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 ?? "\u5DF2\u53D6\u6D88").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 * 1e3, 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 ?? "\u53D1\u9001\u5931\u8D25").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 = [`\u3010\u5F85\u529E\u63D0\u9192\u3011${item.title}`];
if (eventLabel && eventLabel !== remindLabel) {
lines.push(`\u4E8B\u9879\u65F6\u95F4\uFF1A${eventLabel}`);
}
lines.push(`\u63D0\u9192\u65F6\u95F4\uFF1A${remindLabel}`);
if (item.location) lines.push(`\u5730\u70B9\uFF1A${item.location}`);
return lines.join("\n");
};
const listDueBalanceAlerts = async ({ now = clock.now(), limit = 50 } = {}) => {
const [rows] = await pool.query(
`SELECT *
@@ -712,14 +901,24 @@ function createScheduleService(pool, options = {}) {
[status, nextRunAt, String(error?.message ?? error ?? "\u53D1\u9001\u5931\u8D25").slice(0, 500), now, subscription.id]
);
};
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,
@@ -835,13 +1034,22 @@ function createScheduleService(pool, options = {}) {
};
return {
createItem,
getItem,
createReminder,
listItems,
listItemsBySourceMessage,
listTodayTodoItems,
listUpcomingItems,
listUpcomingReminders,
listDigestSubscriptions,
createDailyTodoDigest,
createBalanceLowAlert,
listDueReminders,
lockReminder,
markReminderSent,
markReminderCancelled,
markReminderFailed,
buildReminderText,
listDueDigestSubscriptions,
lockDigestSubscription,
markDigestSent,
@@ -1031,9 +1239,12 @@ if (isScheduleConfigured()) {
kind: { type: "string", description: "task \u6216 event\uFF0C\u9ED8\u8BA4 task" },
title: { type: "string", description: "\u4E8B\u9879\u6807\u9898" },
description: { type: "string", description: "\u4E8B\u9879\u63CF\u8FF0\uFF0C\u53EF\u9009" },
startAt: { type: "number", description: "\u5F00\u59CB\u65F6\u95F4 Unix \u6BEB\u79D2\u65F6\u95F4\u6233\uFF0C\u53EF\u9009" },
endAt: { type: "number", description: "\u7ED3\u675F\u65F6\u95F4 Unix \u6BEB\u79D2\u65F6\u95F4\u6233\uFF0C\u53EF\u9009" },
dueAt: { type: "number", description: "\u622A\u6B62\u65F6\u95F4 Unix \u6BEB\u79D2\u65F6\u95F4\u6233\uFF0C\u53EF\u9009" },
startAt: { type: "number", description: "\u5F00\u59CB\u65F6\u95F4 Unix \u6BEB\u79D2\u65F6\u95F4\u6233\uFF0C\u53EF\u9009\uFF08\u4F18\u5148\u4F7F\u7528 startLocal\uFF09" },
startLocal: { type: "string", description: "\u672C\u5730\u5F00\u59CB\u65F6\u95F4 YYYY-MM-DD HH:mm\uFF0C\u63A8\u8350" },
endAt: { type: "number", description: "\u7ED3\u675F\u65F6\u95F4 Unix \u6BEB\u79D2\u65F6\u95F4\u6233\uFF0C\u53EF\u9009\uFF08\u4F18\u5148\u4F7F\u7528 endLocal\uFF09" },
endLocal: { type: "string", description: "\u672C\u5730\u7ED3\u675F\u65F6\u95F4 YYYY-MM-DD HH:mm\uFF0C\u63A8\u8350" },
dueAt: { type: "number", description: "\u622A\u6B62\u65F6\u95F4 Unix \u6BEB\u79D2\u65F6\u95F4\u6233\uFF0C\u53EF\u9009\uFF08\u4F18\u5148\u4F7F\u7528 dueLocal\uFF09" },
dueLocal: { type: "string", description: "\u672C\u5730\u622A\u6B62\u65F6\u95F4 YYYY-MM-DD HH:mm\uFF0C\u53EF\u9009" },
allDay: { type: "boolean", description: "\u662F\u5426\u5168\u5929\u4E8B\u9879\uFF0C\u53EF\u9009" },
timezone: { type: "string", description: "\u65F6\u533A\uFF0C\u53EF\u9009" },
location: { type: "string", description: "\u5730\u70B9\uFF0C\u53EF\u9009" },
@@ -1050,11 +1261,12 @@ if (isScheduleConfigured()) {
type: "object",
properties: {
itemId: { type: "string", description: "\u4E8B\u9879 ID" },
remindAt: { type: "number", description: "\u63D0\u9192\u89E6\u53D1\u65F6\u95F4 Unix \u6BEB\u79D2\u65F6\u95F4\u6233" },
remindAt: { type: "number", description: "\u63D0\u9192\u89E6\u53D1\u65F6\u95F4 Unix \u6BEB\u79D2\u65F6\u95F4\u6233\uFF08\u4F18\u5148\u4F7F\u7528 remindLocal\uFF09" },
remindLocal: { type: "string", description: "\u672C\u5730\u63D0\u9192\u65F6\u95F4 YYYY-MM-DD HH:mm\uFF0C\u63A8\u8350" },
offsetMinutes: { type: "number", description: "\u76F8\u5BF9\u4E8B\u9879\u65F6\u95F4\u7684\u63D0\u524D\u5206\u949F\u6570\uFF0C\u53EF\u9009" },
channel: { type: "string", description: "\u63D0\u9192\u901A\u9053\uFF0C\u9ED8\u8BA4 wechat" }
},
required: ["itemId", "remindAt"]
required: ["itemId"]
}
},
{
@@ -1303,16 +1515,35 @@ async function callTool(name, args) {
case "private_data_execute":
return [{ type: "text", text: await executePrivateData(args.sql) }];
case "schedule_create_item": {
const timezone = args.timezone ?? process.env.H5_DEFAULT_TIMEZONE ?? "Asia/Shanghai";
const startAt = resolveScheduleTimestamp({
epochMs: args.startAt,
localString: args.startLocal,
timezone,
fieldName: "\u5F00\u59CB\u65F6\u95F4"
});
const endAt = resolveScheduleTimestamp({
epochMs: args.endAt,
localString: args.endLocal,
timezone,
fieldName: "\u7ED3\u675F\u65F6\u95F4"
});
const dueAt = resolveScheduleTimestamp({
epochMs: args.dueAt,
localString: args.dueLocal,
timezone,
fieldName: "\u622A\u6B62\u65F6\u95F4"
});
const item = await getScheduleService().createItem({
userId: PRIVATE_DATA_USER_ID,
kind: args.kind,
title: args.title,
description: args.description ?? null,
startAt: args.startAt ?? null,
endAt: args.endAt ?? null,
dueAt: args.dueAt ?? null,
startAt,
endAt,
dueAt,
allDay: Boolean(args.allDay),
timezone: args.timezone ?? process.env.H5_DEFAULT_TIMEZONE ?? "Asia/Shanghai",
timezone,
location: args.location ?? null,
sourceChannel: "agent",
sourceMessageId: args.sourceMessageId ?? null,
@@ -1322,10 +1553,18 @@ async function callTool(name, args) {
return [{ type: "text", text: JSON.stringify(item, null, 2) }];
}
case "schedule_create_reminder": {
const timezone = args.timezone ?? process.env.H5_DEFAULT_TIMEZONE ?? "Asia/Shanghai";
const remindAt = resolveScheduleTimestamp({
epochMs: args.remindAt,
localString: args.remindLocal,
timezone,
fieldName: "\u63D0\u9192\u65F6\u95F4"
});
if (remindAt == null) throw new Error("\u7F3A\u5C11\u63D0\u9192\u65F6\u95F4 remindLocal \u6216 remindAt");
const reminder = await getScheduleService().createReminder({
userId: PRIVATE_DATA_USER_ID,
itemId: args.itemId,
remindAt: args.remindAt,
remindAt,
offsetMinutes: args.offsetMinutes ?? null,
channel: args.channel ?? "wechat"
});
+998 -364
View File
File diff suppressed because it is too large Load Diff
@@ -27,6 +27,14 @@ description: 处理待办、提醒、日程类消息;先澄清缺失时间,
- `schedule_create_reminder`
- `schedule_list_items`
## 时间写入(必守)
1. **禁止**自行估算 Unix 毫秒时间戳;模型算 epoch 极易出错。
2. 创建事项时用 `startLocal` / `endLocal` / `dueLocal`,格式 **`YYYY-MM-DD HH:mm`**(用户时区下的墙上时钟)。
3. 创建提醒时用 `remindLocal`,格式同上;不要只传 `remindAt`
4. 用户说「明天 / 后天」时,必须结合会话里的「当前日期」锚点推算具体日期后再写入 local 字段。
5. 写入后可用 `schedule_list_items` 核对返回的时间是否正确。
## 工作规则
1. 先判断用户是在创建、查询,还是补充提醒。
@@ -0,0 +1,67 @@
# 103 发布 0630002 — 回退说明
**发布编号:** `20260630-013953-7906f3c`
**Git commit** `7906f3c`(分支 `0630002`
**发布时间:** 2026-06-30
## 本次内容
- **修复删除无效**MySQL `source_snapshot_json` 经 mysql2 返回为 object 时,`JSON.parse(object)` 抛错导致 `relative_path` 为空,工作区 HTML 未清理,列表同步后页面「复活」
- 新增 `parseJsonColumn()`,统一解析 JSON 列(`mindspace-pages.mjs``mindspace-page-sync.mjs`
- 删除 purge 失败时增加 warn 日志
## 103 自动备份(发布脚本已创建)
| 类型 | 路径(103 上) |
|------|----------------|
| 全量 live 备份 | `/Users/john/Project/backups/memind/memind-full-20260630-013953-7906f3c-before.tar.gz` |
| 持久目录备份 | `/Users/john/Project/backups/memind/memind-persisted-20260630-013953-7906f3c-before.tar.gz` |
| 旧源码归档 | `/Users/john/Project/archives/Memind-source-before-20260630-013953-7906f3c` |
| 当前 live | `/Users/john/Project/Memind`(无源码 runtime |
## 健康验证
```bash
ssh 58.38.22.103 'curl -sf http://127.0.0.1:8081/api/status && echo'
```
## 回退到上一版 runtime0630001
**103** 上执行:
```bash
RELEASE_ID=20260630-013953-7906f3c
PREV_RELEASE=20260630-012244-66ca0b3
APP_DIR=/Users/john/Project/Memind
ARCHIVE=/Users/john/Project/archives/Memind-source-before-${RELEASE_ID}
PORTAL_LABEL=cn.tkmind.memind-portal
LAUNCHD_GUI="gui/$(id -u)"
launchctl bootout "${LAUNCHD_GUI}/${PORTAL_LABEL}" 2>/dev/null || true
lsof -tiTCP:8081 -sTCP:LISTEN | xargs kill -9 2>/dev/null || true
mv "${APP_DIR}" "${APP_DIR}.failed-runtime-${RELEASE_ID}"
# 若需回到 0630001 runtime,从全量备份解压或保留的 runtime artifact 恢复
tar -xzf /Users/john/Project/backups/memind/memind-full-${PREV_RELEASE}-before.tar.gz -C /Users/john/Project
launchctl bootstrap "${LAUNCHD_GUI}" "${HOME}/Library/LaunchAgents/${PORTAL_LABEL}.plist"
launchctl kickstart -k "${LAUNCHD_GUI}/${PORTAL_LABEL}"
curl -sf http://127.0.0.1:8081/api/status
```
## 从全量 tar 恢复(极端情况)
```bash
cd /Users/john/Project
mv Memind "Memind.broken-$(date +%Y%m%d-%H%M%S)"
tar -xzf backups/memind/memind-full-20260630-013953-7906f3c-before.tar.gz
# 再启动 Portal LaunchAgent
```
## 本地复现 / 再发版
```bash
git checkout 0630002
npm run verify:mindspace-publish-guards:full
bash scripts/release-portal-runtime-prod.sh --yes
```
+42 -10
View File
@@ -14,6 +14,7 @@ import readline from 'node:readline';
import { execFileSync } from 'node:child_process';
import mysql from 'mysql2/promise';
import { createScheduleService } from './schedule-service.mjs';
import { resolveScheduleTimestamp } from './schedule-time.mjs';
const SANDBOX_ROOT = process.argv[2]?.trim() || process.env.SANDBOX_ROOT?.trim();
if (!SANDBOX_ROOT) {
@@ -203,9 +204,12 @@ if (isScheduleConfigured()) {
kind: { type: 'string', description: 'task 或 event,默认 task' },
title: { type: 'string', description: '事项标题' },
description: { type: 'string', description: '事项描述,可选' },
startAt: { type: 'number', description: '开始时间 Unix 毫秒时间戳,可选' },
endAt: { type: 'number', description: '结束时间 Unix 毫秒时间戳,可选' },
dueAt: { type: 'number', description: '截止时间 Unix 毫秒时间戳,可选' },
startAt: { type: 'number', description: '开始时间 Unix 毫秒时间戳,可选(优先使用 startLocal' },
startLocal: { type: 'string', description: '本地开始时间 YYYY-MM-DD HH:mm,推荐' },
endAt: { type: 'number', description: '结束时间 Unix 毫秒时间戳,可选(优先使用 endLocal' },
endLocal: { type: 'string', description: '本地结束时间 YYYY-MM-DD HH:mm,推荐' },
dueAt: { type: 'number', description: '截止时间 Unix 毫秒时间戳,可选(优先使用 dueLocal' },
dueLocal: { type: 'string', description: '本地截止时间 YYYY-MM-DD HH:mm,可选' },
allDay: { type: 'boolean', description: '是否全天事项,可选' },
timezone: { type: 'string', description: '时区,可选' },
location: { type: 'string', description: '地点,可选' },
@@ -223,11 +227,12 @@ if (isScheduleConfigured()) {
type: 'object',
properties: {
itemId: { type: 'string', description: '事项 ID' },
remindAt: { type: 'number', description: '提醒触发时间 Unix 毫秒时间戳' },
remindAt: { type: 'number', description: '提醒触发时间 Unix 毫秒时间戳(优先使用 remindLocal' },
remindLocal: { type: 'string', description: '本地提醒时间 YYYY-MM-DD HH:mm,推荐' },
offsetMinutes: { type: 'number', description: '相对事项时间的提前分钟数,可选' },
channel: { type: 'string', description: '提醒通道,默认 wechat' },
},
required: ['itemId', 'remindAt'],
required: ['itemId'],
},
},
{
@@ -495,16 +500,35 @@ async function callTool(name, args) {
case 'private_data_execute':
return [{ type: 'text', text: await executePrivateData(args.sql) }];
case 'schedule_create_item': {
const timezone = args.timezone ?? process.env.H5_DEFAULT_TIMEZONE ?? 'Asia/Shanghai';
const startAt = resolveScheduleTimestamp({
epochMs: args.startAt,
localString: args.startLocal,
timezone,
fieldName: '开始时间',
});
const endAt = resolveScheduleTimestamp({
epochMs: args.endAt,
localString: args.endLocal,
timezone,
fieldName: '结束时间',
});
const dueAt = resolveScheduleTimestamp({
epochMs: args.dueAt,
localString: args.dueLocal,
timezone,
fieldName: '截止时间',
});
const item = await getScheduleService().createItem({
userId: PRIVATE_DATA_USER_ID,
kind: args.kind,
title: args.title,
description: args.description ?? null,
startAt: args.startAt ?? null,
endAt: args.endAt ?? null,
dueAt: args.dueAt ?? null,
startAt,
endAt,
dueAt,
allDay: Boolean(args.allDay),
timezone: args.timezone ?? process.env.H5_DEFAULT_TIMEZONE ?? 'Asia/Shanghai',
timezone,
location: args.location ?? null,
sourceChannel: 'agent',
sourceMessageId: args.sourceMessageId ?? null,
@@ -514,10 +538,18 @@ async function callTool(name, args) {
return [{ type: 'text', text: JSON.stringify(item, null, 2) }];
}
case 'schedule_create_reminder': {
const timezone = args.timezone ?? process.env.H5_DEFAULT_TIMEZONE ?? 'Asia/Shanghai';
const remindAt = resolveScheduleTimestamp({
epochMs: args.remindAt,
localString: args.remindLocal,
timezone,
fieldName: '提醒时间',
});
if (remindAt == null) throw new Error('缺少提醒时间 remindLocal 或 remindAt');
const reminder = await getScheduleService().createReminder({
userId: PRIVATE_DATA_USER_ID,
itemId: args.itemId,
remindAt: args.remindAt,
remindAt,
offsetMinutes: args.offsetMinutes ?? null,
channel: args.channel ?? 'wechat',
});
+9 -3
View File
@@ -221,11 +221,17 @@ export function createMindSpaceService(pool, options = {}) {
let schedule = null;
if (scheduleService) {
try {
const [todayTodoItems, digestSubscriptions] = await withOptionalTimeout(
const [todayTodoItems, upcomingItems, upcomingReminders, digestSubscriptions] = await withOptionalTimeout(
Promise.all([
typeof scheduleService.listTodayTodoItems === 'function'
? scheduleService.listTodayTodoItems({ userId })
: Promise.resolve([]),
typeof scheduleService.listUpcomingItems === 'function'
? scheduleService.listUpcomingItems({ userId })
: Promise.resolve([]),
typeof scheduleService.listUpcomingReminders === 'function'
? scheduleService.listUpcomingReminders({ userId })
: Promise.resolve([]),
typeof scheduleService.listDigestSubscriptions === 'function'
? scheduleService.listDigestSubscriptions({
userId,
@@ -236,9 +242,9 @@ export function createMindSpaceService(pool, options = {}) {
: Promise.resolve([]),
]),
1000,
[[], []],
[[], [], [], []],
);
schedule = { todayTodoItems, digestSubscriptions };
schedule = { todayTodoItems, upcomingItems, upcomingReminders, digestSubscriptions };
} catch {
schedule = null;
}
+40
View File
@@ -165,6 +165,44 @@ test('getSpace includes schedule snapshot when schedule service is available', a
},
];
},
async listUpcomingItems({ userId }) {
assert.equal(userId, 'user-1');
return [
{
id: 'item-2',
kind: 'event',
title: '早起跑步',
description: null,
status: 'active',
startAt: Date.UTC(2026, 6, 1, 22, 0, 0),
endAt: Date.UTC(2026, 6, 1, 22, 30, 0),
dueAt: null,
allDay: false,
timezone: 'Asia/Shanghai',
location: null,
createdAt: 11,
updatedAt: 21,
},
];
},
async listUpcomingReminders({ userId }) {
assert.equal(userId, 'user-1');
return [
{
id: 'rem-1',
itemId: 'item-2',
itemTitle: '早起跑步',
itemKind: 'event',
itemTimezone: 'Asia/Shanghai',
itemStartAt: Date.UTC(2026, 6, 1, 22, 0, 0),
itemEndAt: Date.UTC(2026, 6, 1, 22, 30, 0),
remindAt: Date.UTC(2026, 6, 1, 21, 55, 0),
offsetMinutes: 5,
channel: 'wechat',
status: 'pending',
},
];
},
async listDigestSubscriptions({ userId, digestType, status }) {
assert.equal(userId, 'user-1');
assert.equal(digestType, 'todo_day');
@@ -189,6 +227,8 @@ test('getSpace includes schedule snapshot when schedule service is available', a
const space = await service.getSpace('user-1');
assert.equal(space.schedule.todayTodoItems[0].title, '跟进报价单');
assert.equal(space.schedule.upcomingItems[0].title, '早起跑步');
assert.equal(space.schedule.upcomingReminders[0].itemTitle, '早起跑步');
assert.equal(space.schedule.digestSubscriptions[0].hour, 7);
});
+44
View File
@@ -17,6 +17,50 @@ export function startScheduleReminderWorker({
if (running || stopped) return;
running = true;
try {
const dueReminders = await scheduleService.listDueReminders({ limit: 50 });
for (const candidate of dueReminders) {
const reminder = await scheduleService.lockReminder(candidate.id);
if (!reminder) continue;
try {
const text = await scheduleService.buildReminderText(reminder);
if (!text) {
await scheduleService.markReminderCancelled(reminder, '事项已失效或不存在');
continue;
}
await scheduleService.createUserNotification?.({
userId: reminder.userId,
channel: 'web',
notificationType: 'schedule_reminder',
title: '待办提醒',
body: text,
data: {
reminderId: reminder.id,
itemId: reminder.itemId,
},
});
if (reminder.channel === 'wechat') {
await sendWechatTextToUser(reminder.userId, text);
}
await scheduleService.logDelivery({
reminderId: reminder.id,
userId: reminder.userId,
channel: reminder.channel,
status: 'success',
});
await scheduleService.markReminderSent(reminder);
} catch (err) {
logger.warn?.('Schedule reminder delivery failed:', err);
await scheduleService.logDelivery({
reminderId: reminder.id,
userId: reminder.userId,
channel: reminder.channel,
status: 'failed',
errorMessage: err instanceof Error ? err.message : String(err),
}).catch(() => {});
await scheduleService.markReminderFailed(reminder, err, { maxAttempts });
}
}
const due = await scheduleService.listDueDigestSubscriptions({ limit: 50 });
for (const candidate of due) {
const subscription = await scheduleService.lockDigestSubscription(candidate.id);
+152
View File
@@ -17,6 +17,12 @@ test('schedule reminder worker sends due daily todo digest', async () => {
const worker = startScheduleReminderWorker({
intervalMs: 60_000,
scheduleService: {
async listDueReminders() {
return [];
},
async lockReminder() {
return null;
},
async listDueDigestSubscriptions() {
calls.push('list');
return [subscription];
@@ -59,6 +65,12 @@ test('schedule reminder worker sends due balance alert for same user only', asyn
const worker = startScheduleReminderWorker({
intervalMs: 60_000,
scheduleService: {
async listDueReminders() {
return [];
},
async lockReminder() {
return null;
},
async listDueDigestSubscriptions() {
return [];
},
@@ -112,3 +124,143 @@ test('schedule reminder worker sends due balance alert for same user only', asyn
]);
assert.deepEqual(alerts, ['log:success:user-1', 'sent:alert-1']);
});
test('schedule reminder worker sends due item reminders via wechat', async () => {
const sent = [];
const calls = [];
const reminder = {
id: 'rem-1',
userId: 'user-1',
itemId: 'item-1',
remindAt: Date.now() - 1000,
channel: 'wechat',
attempts: 1,
};
const worker = startScheduleReminderWorker({
intervalMs: 60_000,
scheduleService: {
async listDueReminders() {
calls.push('list-reminders');
return [reminder];
},
async lockReminder(id) {
calls.push(`lock-reminder:${id}`);
return reminder;
},
async buildReminderText(input) {
calls.push(`text:${input.id}`);
return '【待办提醒】带材料\n提醒时间:09:00';
},
async createUserNotification(input) {
calls.push(`notify:${input.notificationType}`);
},
async logDelivery(input) {
calls.push(`log:${input.status}:${input.reminderId}`);
},
async markReminderSent(input) {
calls.push(`sent:${input.id}`);
},
async markReminderCancelled() {
calls.push('cancelled');
},
async markReminderFailed() {
calls.push('failed');
},
async listDueDigestSubscriptions() {
return [];
},
async lockDigestSubscription() {
return null;
},
async listDueBalanceAlerts() {
return [];
},
async lockBalanceAlert() {
return null;
},
async buildTodoDigestText() {
return '';
},
async markDigestSent() {},
async markDigestFailed() {},
},
async sendWechatTextToUser(userId, text) {
sent.push({ userId, text });
},
logger: { warn() {} },
runOnStart: false,
});
await worker.runOnce();
worker.stop();
assert.deepEqual(sent, [
{ userId: 'user-1', text: '【待办提醒】带材料\n提醒时间:09:00' },
]);
assert.deepEqual(calls, [
'list-reminders',
'lock-reminder:rem-1',
'text:rem-1',
'notify:schedule_reminder',
'log:success:rem-1',
'sent:rem-1',
]);
});
test('schedule reminder worker skips wechat for in_app reminders', async () => {
const sent = [];
const reminder = {
id: 'rem-2',
userId: 'user-2',
itemId: 'item-2',
remindAt: Date.now() - 1000,
channel: 'in_app',
attempts: 0,
};
const worker = startScheduleReminderWorker({
intervalMs: 60_000,
scheduleService: {
async listDueReminders() {
return [reminder];
},
async lockReminder() {
return reminder;
},
async buildReminderText() {
return '【待办提醒】开会';
},
async createUserNotification() {},
async logDelivery() {},
async markReminderSent() {},
async markReminderCancelled() {},
async markReminderFailed() {},
async listDueDigestSubscriptions() {
return [];
},
async lockDigestSubscription() {
return null;
},
async listDueBalanceAlerts() {
return [];
},
async lockBalanceAlert() {
return null;
},
async buildTodoDigestText() {
return '';
},
async markDigestSent() {},
async markDigestFailed() {},
},
async sendWechatTextToUser(userId, text) {
sent.push({ userId, text });
},
logger: { warn() {} },
runOnStart: false,
});
await worker.runOnce();
worker.stop();
assert.deepEqual(sent, []);
});
+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,
+114
View File
@@ -31,3 +31,117 @@ test('listUserNotifications accepts mysql JSON columns returned as objects', asy
assert.equal(notifications.length, 1);
assert.deepEqual(notifications[0].data, { thresholdCents: 2000 });
});
test('buildReminderText formats reminder with event time', async () => {
const queries = [];
const service = createScheduleService({
async query(sql, params) {
queries.push(sql.trim().slice(0, 40));
if (sql.includes('FROM h5_schedule_items')) {
return [
[
{
id: 'item-1',
user_id: 'user-1',
kind: 'event',
title: '开会',
description: null,
status: 'active',
start_at: 1_786_000_000_000,
end_at: null,
due_at: null,
all_day: 0,
timezone: 'Asia/Shanghai',
location: '会议室 A',
created_at: 1,
updated_at: 1,
},
],
];
}
return [[]];
},
clock: { now: () => 1_786_000_000_000 },
});
const text = await service.buildReminderText({
userId: 'user-1',
itemId: 'item-1',
remindAt: 1_785_964_000_000,
});
assert.match(text, /【待办提醒】开会/);
assert.match(text, /事项时间:/);
assert.match(text, /提醒时间:/);
assert.match(text, /地点:会议室 A/);
});
test('parseLocalDateTimeString resolves Asia/Shanghai wall clock', async () => {
const { parseLocalDateTimeString, assertReasonableScheduleEpoch } = await import('./schedule-time.mjs');
const ms = parseLocalDateTimeString('2026-07-01 06:00', 'Asia/Shanghai');
assert.equal(new Date(ms).toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' }), '2026/7/1 06:00:00');
const now = Date.UTC(2026, 5, 29, 16, 0, 0);
assert.throws(
() => assertReasonableScheduleEpoch(1779127200000, { now, fieldName: '开始时间' }),
/超出合理范围/,
);
});
test('cancelReminder marks pending reminder as cancelled', async () => {
const service = createScheduleService({
async query(sql) {
if (sql.includes('FROM h5_schedule_reminders r') && sql.includes('WHERE r.id = ?')) {
return [
[
{
id: 'rem-1',
user_id: 'user-1',
item_id: 'item-1',
remind_at: 1782856500000,
offset_minutes: 5,
channel: 'wechat',
status: 'pending',
attempts: 0,
last_error: null,
locked_until: null,
sent_at: null,
created_at: 1,
updated_at: 1,
item_title: '早起跑步',
item_kind: 'event',
item_timezone: 'Asia/Shanghai',
item_start_at: 1782856800000,
item_end_at: 1782858600000,
},
],
];
}
if (sql.includes('UPDATE h5_schedule_reminders') && sql.includes('cancelled')) {
return [{ affectedRows: 1 }];
}
return [[], undefined];
},
});
const reminder = await service.cancelReminder({ userId: 'user-1', reminderId: 'rem-1' });
assert.equal(reminder.status, 'cancelled');
});
test('deleteReminders bulk deletes by user', async () => {
const service = createScheduleService({
async query(sql, params) {
if (sql.startsWith('DELETE FROM h5_schedule_reminders')) {
assert.equal(params[0], 'user-1');
assert.deepEqual(params.slice(1), ['rem-1', 'rem-2']);
return [{ affectedRows: 2 }];
}
return [[], undefined];
},
});
const deleted = await service.deleteReminders({
userId: 'user-1',
reminderIds: ['rem-1', 'rem-2'],
});
assert.equal(deleted, 2);
});
+61
View File
@@ -150,3 +150,64 @@ export function formatLocalTime(epochMs, timezone = DEFAULT_TIMEZONE) {
const parts = getLocalParts(epochMs, timezone);
return `${pad2(parts.hour)}:${pad2(parts.minute)}`;
}
/**
* Parse a wall-clock datetime in the given timezone, e.g. "2026-07-01 06:00".
*/
export function parseLocalDateTimeString(value, timezone = DEFAULT_TIMEZONE) {
const raw = String(value ?? '').trim();
if (!raw) return null;
const match = raw.match(
/^(\d{4})-(\d{1,2})-(\d{1,2})(?:[ T](\d{1,2}):(\d{2})(?::(\d{2}))?)?$/,
);
if (!match) {
throw new Error(`无法解析本地时间「${raw}」,请使用 YYYY-MM-DD HH:mm`);
}
return zonedTimeToEpochMs(
{
year: Number(match[1]),
month: Number(match[2]),
day: Number(match[3]),
hour: Number(match[4] ?? 0),
minute: Number(match[5] ?? 0),
second: Number(match[6] ?? 0),
},
timezone,
);
}
export function resolveScheduleTimestamp({
epochMs = null,
localString = null,
timezone = DEFAULT_TIMEZONE,
fieldName = '时间',
now = Date.now(),
} = {}) {
const local = String(localString ?? '').trim();
if (local) return parseLocalDateTimeString(local, timezone);
if (epochMs == null || epochMs === '') return null;
return assertReasonableScheduleEpoch(epochMs, { now, fieldName });
}
export function assertReasonableScheduleEpoch(
epochMs,
{ now = Date.now(), fieldName = '时间', maxPastDays = 2, maxFutureDays = 400 } = {},
) {
const safe = Number(epochMs);
if (!Number.isFinite(safe) || safe <= 0) {
throw new Error(`${fieldName}无效,请改用 YYYY-MM-DD HH:mm 的 local 字段`);
}
const min = now - maxPastDays * 86400000;
const max = now + maxFutureDays * 86400000;
if (safe < min || safe > max) {
const label = new Intl.DateTimeFormat('zh-CN', {
timeZone: normalizeTimezone(process.env.H5_DEFAULT_TIMEZONE),
dateStyle: 'short',
timeStyle: 'short',
}).format(new Date(safe));
throw new Error(
`${fieldName}${label})超出合理范围。请改用 startLocal/remindLocalYYYY-MM-DD HH:mm,用户时区),禁止自行估算 Unix 毫秒。`,
);
}
return safe;
}
+33
View File
@@ -1871,6 +1871,39 @@ api.get('/mindspace/v1/space', async (req, res) => {
return sendData(res, req, space);
});
api.post('/mindspace/v1/schedule/reminders/:reminderId/ignore', async (req, res) => {
if (!ensureMindSpaceEnabled(res, req) || !scheduleService) {
return sendError(res, req, 503, 'feature_disabled', '日程服务未启用');
}
try {
const reminder = await scheduleService.cancelReminder({
userId: req.currentUser.id,
reminderId: req.params.reminderId,
});
return sendData(res, req, reminder);
} catch (err) {
const message = err instanceof Error ? err.message : '忽略提醒失败';
return sendError(res, req, 400, 'invalid_schedule_input', message);
}
});
api.post('/mindspace/v1/schedule/reminders/bulk-delete', async (req, res) => {
if (!ensureMindSpaceEnabled(res, req) || !scheduleService) {
return sendError(res, req, 503, 'feature_disabled', '日程服务未启用');
}
try {
const ids = Array.isArray(req.body?.ids) ? req.body.ids.map(String) : [];
const deleted = await scheduleService.deleteReminders({
userId: req.currentUser.id,
reminderIds: ids,
});
return sendData(res, req, { deleted });
} catch (err) {
const message = err instanceof Error ? err.message : '删除提醒失败';
return sendError(res, req, 400, 'invalid_schedule_input', message);
}
});
api.get('/mindspace/v1/space/quota', async (req, res) => {
if (!mindSpace) return res.status(503).json({ message: 'MindSpace 未启用' });
const quota = await mindSpace.getQuota(req.currentUser.id);
+8
View File
@@ -27,6 +27,14 @@ description: 处理待办、提醒、日程类消息;先澄清缺失时间,
- `schedule_create_reminder`
- `schedule_list_items`
## 时间写入(必守)
1. **禁止**自行估算 Unix 毫秒时间戳;模型算 epoch 极易出错。
2. 创建事项时用 `startLocal` / `endLocal` / `dueLocal`,格式 **`YYYY-MM-DD HH:mm`**(用户时区下的墙上时钟)。
3. 创建提醒时用 `remindLocal`,格式同上;不要只传 `remindAt`
4. 用户说「明天 / 后天」时,必须结合会话里的「当前日期」锚点推算具体日期后再写入 local 字段。
5. 写入后可用 `schedule_list_items` 核对返回的时间是否正确。
## 工作规则
1. 先判断用户是在创建、查询,还是补充提醒。
+20
View File
@@ -28,6 +28,7 @@ import type {
MindSpacePageDeletePreview,
MindSpacePageDeleteResult,
MindSpaceQuota,
MindSpaceScheduleReminder,
MindSpaceSaveCategory,
MindSpacePublication,
MindSpacePublicationStats,
@@ -580,6 +581,25 @@ export async function getMindSpace(): Promise<MindSpace> {
return result.data;
}
export async function ignoreMindSpaceScheduleReminder(reminderId: string) {
const result = await apiFetch<{ data: MindSpaceScheduleReminder }>(
`/mindspace/v1/schedule/reminders/${encodeURIComponent(reminderId)}/ignore`,
{ method: 'POST' },
);
return result.data;
}
export async function deleteMindSpaceScheduleReminders(ids: string[]) {
const result = await apiFetch<{ data: { deleted: number } }>(
'/mindspace/v1/schedule/reminders/bulk-delete',
{
method: 'POST',
body: JSON.stringify({ ids }),
},
);
return result.data;
}
export async function listMindSpaceCleanupItems(): Promise<{
items: MindSpaceCleanupItem[];
totalBytes: number;
+237 -61
View File
@@ -8,11 +8,13 @@ import {
createMindSpacePageFromAsset,
deleteMindSpaceAsset,
deleteMindSpacePage,
deleteMindSpaceScheduleReminders,
bindMindSpacePageLiveEdit,
getMindSpaceAgentJob,
getMindSpace,
getMindSpacePage,
getMindSpacePageDeletePreview,
ignoreMindSpaceScheduleReminder,
listMindSpaceAgentJobs,
listMindSpaceAssets,
listMindSpaceCleanupItems,
@@ -34,6 +36,7 @@ import type {
MindSpacePage,
MindSpacePageDeletePreview,
MindSpaceQuota,
MindSpaceScheduleReminder,
MindSpaceSaveCategory,
PortalUser,
} from '../types';
@@ -119,33 +122,40 @@ const JOB_STATUS_LABELS: Record<MindSpaceAgentJob['status'], string> = {
timed_out: '超时',
};
const PREVIEW_SCHEDULE_ITEMS = [
const PREVIEW_SCHEDULE_REMINDERS = [
{
id: 'schedule-preview-1',
time: '09:30',
kind: '待办',
title: '跟进客户报价单',
meta: 'OA 工作区资料',
tone: 'task',
},
{
id: 'schedule-preview-2',
time: '14:00',
kind: '日程',
title: '产品评审会',
meta: '提前 30 分钟提醒',
tone: 'event',
},
{
id: 'schedule-preview-3',
time: '明天',
kind: '提醒',
id: 'schedule-preview-rem-1',
time: '明天 09:00',
title: '带项目材料去会议',
meta: '提前 30 分钟 · 服务号推送',
notifyLabel: '待通知',
canIgnore: true,
},
{
id: 'schedule-preview-rem-2',
time: '今天 14:00',
title: '产品评审会',
meta: '服务号推送',
tone: 'reminder',
notifyLabel: '已通知',
canIgnore: false,
},
] as const;
function scheduleReminderNotifyLabel(status: MindSpaceScheduleReminder['status']) {
return status === 'sent' ? '已通知' : '待通知';
}
function formatReminderEventWindow(reminder: MindSpaceScheduleReminder) {
const tz = reminder.itemTimezone || 'Asia/Shanghai';
const start = reminder.itemStartAt ?? null;
const end = reminder.itemEndAt ?? null;
if (!start) return null;
const startLabel = formatScheduleWhen(start, tz);
if (!end || end === start) return `事项 ${startLabel}`;
const endClock = formatScheduleClock(end, tz);
return `事项 ${startLabel} ~ ${endClock}`;
}
function formatScheduleClock(value: number | null | undefined, timezone?: string | null) {
if (typeof value !== 'number' || Number.isNaN(value)) return '—';
try {
@@ -175,6 +185,48 @@ function formatDigestSchedule(digest: {
return nextRun === '—' ? time : `${time} · 下次 ${nextRun}`;
}
function scheduleLocalDateParts(epochMs: number, timezone?: string | null) {
const parts = new Intl.DateTimeFormat('en-US', {
timeZone: timezone || 'Asia/Shanghai',
year: 'numeric',
month: 'numeric',
day: 'numeric',
}).formatToParts(new Date(epochMs));
const pick = (type: string) => Number(parts.find((part) => part.type === type)?.value ?? 0);
return { year: pick('year'), month: pick('month'), day: pick('day') };
}
function isSameScheduleDay(aMs: number, bMs: number, timezone?: string | null) {
const a = scheduleLocalDateParts(aMs, timezone);
const b = scheduleLocalDateParts(bMs, timezone);
return a.year === b.year && a.month === b.month && a.day === b.day;
}
function formatScheduleDayLabel(epochMs: number, timezone?: string | null) {
const tz = timezone || 'Asia/Shanghai';
const now = Date.now();
if (isSameScheduleDay(epochMs, now, tz)) return '今天';
if (isSameScheduleDay(epochMs, now + 24 * 60 * 60 * 1000, tz)) return '明天';
try {
return new Intl.DateTimeFormat('zh-CN', {
timeZone: tz,
month: 'numeric',
day: 'numeric',
weekday: 'short',
}).format(new Date(epochMs));
} catch {
return formatScheduleDayLabel(epochMs, 'Asia/Shanghai');
}
}
function formatScheduleWhen(epochMs: number | null | undefined, timezone?: string | null, allDay = false) {
if (typeof epochMs !== 'number' || Number.isNaN(epochMs)) return '—';
const day = formatScheduleDayLabel(epochMs, timezone);
if (allDay) return day;
const clock = formatScheduleClock(epochMs, timezone);
return clock === '—' ? day : `${day} ${clock}`;
}
const PREVIEW_PAGE_READERS: Record<string, { kicker: string; body: string[] }> = {
'page-1': {
kicker: '市场趋势',
@@ -534,6 +586,8 @@ export function MindSpaceView({
const [allPagesLoading, setAllPagesLoading] = useState(false);
const [selectedAllPageIds, setSelectedAllPageIds] = useState<string[]>([]);
const [bulkDeletingAllPages, setBulkDeletingAllPages] = useState(false);
const [selectedScheduleReminderIds, setSelectedScheduleReminderIds] = useState<string[]>([]);
const [scheduleReminderActionLoading, setScheduleReminderActionLoading] = useState(false);
const [recentPagesTotal, setRecentPagesTotal] = useState(0);
const [exportingPageId, setExportingPageId] = useState<string | null>(null);
const [sharePayload, setSharePayload] = useState<SharePayload | null>(null);
@@ -1040,6 +1094,27 @@ export function MindSpaceView({
void load();
}, []);
useEffect(() => {
if (previewMode) return;
const refreshSchedule = () => {
if (!selectedCategory && !selectedPageId && !newPageOpen && !agentJobsPanelOpen && !allPagesOpen) {
void refreshSpaceQuietly();
}
};
window.addEventListener('focus', refreshSchedule);
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') refreshSchedule();
});
return () => window.removeEventListener('focus', refreshSchedule);
}, [
previewMode,
selectedCategory,
selectedPageId,
newPageOpen,
agentJobsPanelOpen,
allPagesOpen,
]);
useEffect(() => {
if (initialPageId) setSelectedPageId(initialPageId);
}, [initialPageId]);
@@ -1813,19 +1888,74 @@ export function MindSpaceView({
const showHomeModules =
space && !selectedCategory && !selectedPageId && !newPageOpen && !agentJobsPanelOpen && !allPagesOpen;
const scheduleItems = previewMode
? PREVIEW_SCHEDULE_ITEMS
: (space?.schedule?.todayTodoItems ?? []).map((item) => ({
id: item.id,
time: item.allDay ? '全天' : formatScheduleClock(item.dueAt ?? item.startAt, item.timezone),
kind: item.kind === 'event' ? '日程' : '待办',
title: item.title,
meta:
item.description?.trim() ||
(item.sourceChannel === 'wechat' ? '来自服务号' : '来自空间同步'),
tone: item.kind === 'event' ? 'event' : 'task',
}));
const rawUpcomingReminders = previewMode ? [] : space?.schedule?.upcomingReminders ?? [];
const scheduleReminders = previewMode
? PREVIEW_SCHEDULE_REMINDERS.map((item) => ({ ...item }))
: rawUpcomingReminders.map((reminder) => {
const metaParts = [
formatReminderEventWindow(reminder),
reminder.offsetMinutes != null && reminder.offsetMinutes > 0
? `提前 ${reminder.offsetMinutes} 分钟`
: null,
reminder.channel === 'wechat' ? '服务号推送' : '站内提醒',
].filter(Boolean);
return {
id: reminder.id,
time: formatScheduleWhen(reminder.remindAt, reminder.itemTimezone),
title: reminder.itemTitle || '待办提醒',
meta: metaParts.join(' · '),
notifyLabel: scheduleReminderNotifyLabel(reminder.status),
canIgnore: reminder.status === 'pending' || reminder.status === 'locked',
};
});
const scheduleDigests = previewMode ? [] : space?.schedule?.digestSubscriptions ?? [];
const pendingReminderCount = scheduleReminders.filter((item) => item.notifyLabel === '待通知').length;
const hasScheduleContent = scheduleReminders.length > 0 || scheduleDigests.length > 0;
const toggleScheduleReminderSelection = (reminderId: string) => {
setSelectedScheduleReminderIds((prev) =>
prev.includes(reminderId) ? prev.filter((id) => id !== reminderId) : [...prev, reminderId],
);
};
const toggleAllScheduleReminders = () => {
if (selectedScheduleReminderIds.length === scheduleReminders.length) {
setSelectedScheduleReminderIds([]);
} else {
setSelectedScheduleReminderIds(scheduleReminders.map((item) => item.id));
}
};
const ignoreScheduleReminder = async (reminderId: string) => {
if (previewMode) return;
setScheduleReminderActionLoading(true);
setError(null);
try {
await ignoreMindSpaceScheduleReminder(reminderId);
setSelectedScheduleReminderIds((prev) => prev.filter((id) => id !== reminderId));
await refreshSpaceQuietly();
} catch (err) {
setError(err instanceof Error ? err.message : '忽略提醒失败');
} finally {
setScheduleReminderActionLoading(false);
}
};
const deleteSelectedScheduleReminders = async () => {
if (previewMode || selectedScheduleReminderIds.length === 0) return;
setScheduleReminderActionLoading(true);
setError(null);
try {
await deleteMindSpaceScheduleReminders(selectedScheduleReminderIds);
setSelectedScheduleReminderIds([]);
await refreshSpaceQuietly();
} catch (err) {
setError(err instanceof Error ? err.message : '删除提醒失败');
} finally {
setScheduleReminderActionLoading(false);
}
};
const selectedPreviewPage = selectedPageId
? pages.find((page) => page.id === selectedPageId) ?? null
: null;
@@ -2082,24 +2212,83 @@ export function MindSpaceView({
</button>
</div>
<div className="mindspace-schedule-summary">
<strong> {scheduleItems.length || 0} </strong>
<span>
{scheduleItems.length > 0
? '已有待办记录'
: scheduleDigests.length > 0
? `${scheduleDigests.length} 个服务号待办推送`
: '还没有安排'}
</span>
<strong>
7 {scheduleReminders.length} · {pendingReminderCount}
</strong>
<span>{hasScheduleContent ? '仅展示提醒通知' : '还没有安排'}</span>
</div>
{scheduleReminders.length > 0 && (
<div className="mindspace-schedule-toolbar">
<label className="mindspace-schedule-select-all">
<input
type="checkbox"
checked={
scheduleReminders.length > 0 &&
selectedScheduleReminderIds.length === scheduleReminders.length
}
onChange={() => toggleAllScheduleReminders()}
disabled={scheduleReminderActionLoading || previewMode}
/>
<span></span>
</label>
<button
type="button"
className="mindspace-schedule-toolbar-btn danger"
disabled={
previewMode ||
scheduleReminderActionLoading ||
selectedScheduleReminderIds.length === 0
}
onClick={() => void deleteSelectedScheduleReminders()}
>
{scheduleReminderActionLoading ? '处理中…' : '删除提醒'}
</button>
</div>
)}
{scheduleReminders.length > 0 && (
<div className="mindspace-schedule-list">
{scheduleReminders.map((item) => (
<article className="mindspace-schedule-item has-checkbox" key={item.id}>
<label className="mindspace-schedule-checkbox">
<input
type="checkbox"
checked={selectedScheduleReminderIds.includes(item.id)}
onChange={() => toggleScheduleReminderSelection(item.id)}
disabled={scheduleReminderActionLoading || previewMode}
/>
</label>
<time>{item.time}</time>
<span
className={`mindspace-schedule-notify is-${item.notifyLabel === '已通知' ? 'sent' : 'pending'}`}
>
{item.notifyLabel}
</span>
<div className="mindspace-schedule-item-body">
<strong>{item.title}</strong>
<small>{item.meta}</small>
</div>
{item.canIgnore && !previewMode && (
<button
type="button"
className="mindspace-schedule-ignore"
disabled={scheduleReminderActionLoading}
onClick={() => void ignoreScheduleReminder(item.id)}
>
</button>
)}
</article>
))}
</div>
)}
{scheduleDigests.length > 0 && (
<div className="mindspace-schedule-list">
<p className="mindspace-schedule-subhead"></p>
{scheduleDigests.map((digest) => (
<article className="mindspace-schedule-item" key={digest.id}>
<article className="mindspace-schedule-item mindspace-schedule-item-digest" key={digest.id}>
<time>{String(digest.hour).padStart(2, '0')}:{String(digest.minute).padStart(2, '0')}</time>
<span className={`mindspace-schedule-kind is-${digest.status === 'active' ? 'event' : 'reminder'}`}>
</span>
<div>
<span className="mindspace-schedule-notify is-pending"></span>
<div className="mindspace-schedule-item-body">
<strong></strong>
<small>{formatDigestSchedule(digest)}</small>
</div>
@@ -2107,22 +2296,9 @@ export function MindSpaceView({
))}
</div>
)}
{scheduleItems.length > 0 ? (
<div className="mindspace-schedule-list">
{scheduleItems.map((item) => (
<article className="mindspace-schedule-item" key={item.id}>
<time>{item.time}</time>
<span className={`mindspace-schedule-kind is-${item.tone}`}>{item.kind}</span>
<div>
<strong>{item.title}</strong>
<small>{item.meta}</small>
</div>
</article>
))}
</div>
) : (
{!hasScheduleContent && (
<p className="mindspace-schedule-empty">
7 6 5 55
</p>
)}
</section>
+97
View File
@@ -5416,6 +5416,15 @@ body,
font-size: 13px;
}
.mindspace-schedule-subhead {
margin: 0 0 0.35rem;
font-size: 0.72rem;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
color: #7a857f;
}
.mindspace-schedule-list {
display: grid;
gap: 7px;
@@ -5433,6 +5442,94 @@ body,
background: rgba(24, 33, 29, 0.045);
}
.mindspace-schedule-item.has-checkbox {
grid-template-columns: 22px 72px 52px minmax(0, 1fr) auto;
}
.mindspace-schedule-item.mindspace-schedule-item-digest {
grid-template-columns: 72px 52px minmax(0, 1fr);
}
.mindspace-schedule-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-top: 10px;
}
.mindspace-schedule-select-all {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: #68716c;
}
.mindspace-schedule-toolbar-btn {
border: 0;
border-radius: 999px;
padding: 6px 12px;
font-size: 12px;
cursor: pointer;
background: rgba(24, 33, 29, 0.08);
color: #18211d;
}
.mindspace-schedule-toolbar-btn.danger {
background: rgba(196, 74, 58, 0.12);
color: #a33f32;
}
.mindspace-schedule-toolbar-btn:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.mindspace-schedule-checkbox {
display: flex;
align-items: center;
}
.mindspace-schedule-item-body strong {
display: block;
}
.mindspace-schedule-notify {
display: inline-flex;
justify-content: center;
border-radius: 999px;
padding: 3px 6px;
font-size: 11px;
line-height: 1.2;
white-space: nowrap;
}
.mindspace-schedule-notify.is-pending {
color: #8a5b16;
background: rgba(238, 176, 78, 0.22);
}
.mindspace-schedule-notify.is-sent {
color: #2f6f57;
background: rgba(47, 111, 87, 0.16);
}
.mindspace-schedule-ignore {
border: 0;
border-radius: 999px;
padding: 5px 10px;
font-size: 11px;
cursor: pointer;
color: #68716c;
background: rgba(24, 33, 29, 0.08);
}
.mindspace-schedule-ignore:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.mindspace-schedule-item time {
color: #2f6f57;
font-size: 12px;
+17
View File
@@ -359,8 +359,25 @@ export type MindSpaceDigestSubscription = {
attempts?: number;
};
export type MindSpaceScheduleReminder = {
id: string;
itemId: string;
itemTitle: string;
itemKind: 'task' | 'event';
itemTimezone: string;
itemStartAt?: number | null;
itemEndAt?: number | null;
remindAt: number;
offsetMinutes?: number | null;
channel: 'wechat' | 'in_app';
status: 'pending' | 'locked' | 'sent' | 'failed' | 'cancelled';
sentAt?: number | null;
};
export type MindSpaceSchedule = {
todayTodoItems: MindSpaceScheduleItem[];
upcomingItems: MindSpaceScheduleItem[];
upcomingReminders: MindSpaceScheduleReminder[];
digestSubscriptions: MindSpaceDigestSubscription[];
};
+13
View File
@@ -214,6 +214,19 @@ export function buildSessionMemoryEntries({
content: renderCurrentTimeAnchor({ now, timezone }),
});
const scheduleTools = (sessionPolicy?.extensionOverrides ?? []).find((ext) => ext.name === 'sandbox-fs');
if ((scheduleTools?.available_tools ?? []).includes('schedule_create_item')) {
entries.push({
title: 'TKMind 日程写入规则',
content: [
'创建或提醒待办/日程时:',
'- 必须使用 startLocal / endLocal / remindLocal,格式 YYYY-MM-DD HH:mm(用户时区墙上时钟)。',
'- 禁止自行估算 Unix 毫秒时间戳;传错会被工具拒绝。',
'- 写入后调用 schedule_list_items 核对日期是否与用户表述一致。',
].join('\n'),
});
}
if (!hasMemoryStore(sessionPolicy)) {
return entries;
}
+4
View File
@@ -6,6 +6,7 @@ import { developerToolsFromPolicy } from './capabilities.mjs';
import { mergeMessageContent } from './message-stream.mjs';
import { reconcileAgentSession } from './session-reconcile.mjs';
import { isScheduleIntent, parseScheduleIntent, shouldUseScheduleAssistant } from './schedule-intent.mjs';
import { renderCurrentTimeAnchor } from './user-memory-profile.mjs';
import { materializeMissingPublicHtmlWrites } from './mindspace-public-finish-sync.mjs';
import { PUBLISH_ROOT_DIR } from './user-publish.mjs';
import { downloadTemporaryMedia, persistWechatImage } from './wechat-media.mjs';
@@ -1079,10 +1080,13 @@ function buildWechatAgentPrompt(intent) {
'',
].join('\n')
: '';
const scheduleTimezone = process.env.H5_DEFAULT_TIMEZONE || 'Asia/Shanghai';
const scheduleAssistantHint = shouldUseScheduleAssistant(intent?.agentText ?? intent?.content)
? [
'【日程技能要求】这条消息涉及待办、提醒或日程。',
'开始前先加载 `schedule-assistant` skill,并严格按 skill 里的边界执行。',
'写入工具时优先使用 startLocal / endLocal / remindLocalYYYY-MM-DD HH:mm),不要自行估算 Unix 毫秒。',
renderCurrentTimeAnchor({ timezone: scheduleTimezone }),
'只有在 `schedule_create_item` / `schedule_create_reminder` 等工具成功返回后,才能告诉用户“已经设置好了”。',
intent?.msgId ? `调用 schedule_create_item 时必须传入 sourceMessageId: ${intent.msgId}` : '',
'',