feat(schedule): 待办提醒推送、行事历仅展示提醒与管理 API
单次提醒 worker 投递、local 时间写入校验、未来 7 天提醒列表与忽略/批量删除;行事历去掉事项重复展示。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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
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'
|
||||
```
|
||||
|
||||
## 回退到上一版 runtime(0630001)
|
||||
|
||||
在 **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
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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/remindLocal(YYYY-MM-DD HH:mm,用户时区),禁止自行估算 Unix 毫秒。`,
|
||||
);
|
||||
}
|
||||
return safe;
|
||||
}
|
||||
|
||||
+33
@@ -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);
|
||||
|
||||
@@ -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. 先判断用户是在创建、查询,还是补充提醒。
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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[];
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 / remindLocal(YYYY-MM-DD HH:mm),不要自行估算 Unix 毫秒。',
|
||||
renderCurrentTimeAnchor({ timezone: scheduleTimezone }),
|
||||
'只有在 `schedule_create_item` / `schedule_create_reminder` 等工具成功返回后,才能告诉用户“已经设置好了”。',
|
||||
intent?.msgId ? `调用 schedule_create_item 时必须传入 sourceMessageId: ${intent.msgId}` : '',
|
||||
'',
|
||||
|
||||
Reference in New Issue
Block a user