Files
memind/mindspace.mjs
T
john 2c91689692 feat(schedule): 待办提醒推送、行事历仅展示提醒与管理 API
单次提醒 worker 投递、local 时间写入校验、未来 7 天提醒列表与忽略/批量删除;行事历去掉事项重复展示。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-01 18:08:06 +08:00

293 lines
9.1 KiB
JavaScript

import crypto from 'node:crypto';
import { loadMindSpaceConfig } from './mindspace-config.mjs';
export const DEFAULT_SPACE_QUOTA_BYTES = 5 * 1024 * 1024;
export const DEFAULT_MAX_FILE_BYTES = 5 * 1024 * 1024;
export const SYSTEM_CATEGORIES = Object.freeze([
{
code: 'oa',
name: 'OA 工作区',
visibilityPolicy: 'private',
aiAccessPolicy: 'selected_assets',
publishPolicy: 'derivative_only',
sortOrder: 10,
},
{
code: 'public',
name: '公开区',
visibilityPolicy: 'public_candidate',
aiAccessPolicy: 'selected_assets',
publishPolicy: 'security_scan_required',
sortOrder: 20,
},
{
code: 'draft',
name: '页面草稿',
visibilityPolicy: 'private',
aiAccessPolicy: 'job_output',
publishPolicy: 'security_scan_required',
sortOrder: 40,
},
{
code: 'archive',
name: '归档区',
visibilityPolicy: 'private',
aiAccessPolicy: 'none',
publishPolicy: 'forbidden',
sortOrder: 50,
},
]);
function asNumber(value) {
return Number(value ?? 0);
}
async function withOptionalTimeout(promise, timeoutMs, fallback) {
let timeout;
try {
return await Promise.race([
promise,
new Promise((resolve) => {
timeout = setTimeout(() => resolve(fallback), timeoutMs);
}),
]);
} finally {
if (timeout) clearTimeout(timeout);
}
}
function categoryResponse(row) {
return {
id: row.id,
code: row.category_code,
name: row.category_name,
visibilityPolicy: row.visibility_policy,
aiAccessPolicy: row.ai_access_policy,
publishPolicy: row.publish_policy,
isSystem: Boolean(row.is_system),
sortOrder: asNumber(row.sort_order),
itemCount: asNumber(row.item_count),
};
}
export async function initializeDefaultSpace(
db,
userId,
{
quotaBytes = DEFAULT_SPACE_QUOTA_BYTES,
spaceName = '我的空间',
now = Date.now(),
idFactory = () => crypto.randomUUID(),
} = {},
) {
const spaceId = idFactory();
await db.query(
`INSERT INTO h5_user_spaces
(id, user_id, space_name, quota_bytes, used_bytes, reserved_bytes, status, created_at, updated_at)
VALUES (?, ?, ?, ?, 0, 0, 'active', ?, ?)
ON DUPLICATE KEY UPDATE user_id = user_id`,
[spaceId, userId, spaceName, quotaBytes, now, now],
);
const [spaces] = await db.query(
`SELECT id FROM h5_user_spaces WHERE user_id = ? LIMIT 1`,
[userId],
);
const resolvedSpaceId = spaces[0]?.id;
if (!resolvedSpaceId) {
throw new Error('用户空间初始化失败');
}
for (const category of SYSTEM_CATEGORIES) {
await db.query(
`INSERT INTO h5_space_categories
(id, user_id, space_id, category_code, category_name, visibility_policy,
ai_access_policy, publish_policy, is_system, sort_order, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?)
ON DUPLICATE KEY UPDATE
category_name = VALUES(category_name),
visibility_policy = VALUES(visibility_policy),
ai_access_policy = VALUES(ai_access_policy),
publish_policy = VALUES(publish_policy),
is_system = 1,
sort_order = VALUES(sort_order),
updated_at = VALUES(updated_at)`,
[
idFactory(),
userId,
resolvedSpaceId,
category.code,
category.name,
category.visibilityPolicy,
category.aiAccessPolicy,
category.publishPolicy,
category.sortOrder,
now,
now,
],
);
}
return resolvedSpaceId;
}
export async function ensureDefaultSpaces(pool, options = {}) {
const [users] = await pool.query(
`SELECT u.id
FROM h5_users u
LEFT JOIN h5_user_spaces s ON s.user_id = u.id
WHERE u.role = 'user' AND s.id IS NULL`,
);
for (const user of users) {
await initializeDefaultSpace(pool, user.id, options);
}
return users.length;
}
export function createMindSpaceService(pool, options = {}) {
const maxFileBytes = Number(options.maxFileBytes ?? DEFAULT_MAX_FILE_BYTES);
const aiDailyLimit = Number(options.aiDailyLimit ?? 10);
const publicPageLimitFallback = Number(options.publicPageLimit ?? 5);
const monthlyViewLimit = Number(options.monthlyViewLimit ?? 1000);
const scheduleService = options.scheduleService ?? null;
const resolvePublicPageLimit = async () => {
try {
const config = await withOptionalTimeout(
loadMindSpaceConfig(pool),
1000,
{ publicPageLimit: publicPageLimitFallback },
);
return Number(config.publicPageLimit ?? publicPageLimitFallback);
} catch {
return publicPageLimitFallback;
}
};
const getSpace = async (userId) => {
const [spaces] = await pool.query(
`SELECT id, user_id, space_name, quota_bytes, used_bytes, reserved_bytes, status,
created_at, updated_at
FROM h5_user_spaces
WHERE user_id = ?
LIMIT 1`,
[userId],
);
const row = spaces[0];
if (!row) return null;
const [categories] = await pool.query(
`SELECT c.id, c.category_code, c.category_name, c.visibility_policy,
c.ai_access_policy, c.publish_policy, c.is_system, c.sort_order,
CASE
WHEN c.category_code = 'draft' THEN COALESCE(pc.item_count, 0)
WHEN c.category_code = 'public' THEN COALESCE(pub.item_count, 0)
ELSE COALESCE(ac.item_count, 0)
END AS item_count
FROM h5_space_categories c
LEFT JOIN (
SELECT category_id, user_id, COUNT(*) AS item_count
FROM h5_assets
WHERE user_id = ? AND status <> 'deleted' AND source_type = 'upload'
GROUP BY category_id, user_id
) ac ON ac.category_id = c.id AND ac.user_id = c.user_id
LEFT JOIN (
SELECT category_id, user_id, COUNT(*) AS item_count
FROM h5_page_records
WHERE user_id = ? AND status <> 'deleted'
GROUP BY category_id, user_id
) pc ON pc.category_id = c.id AND pc.user_id = c.user_id
LEFT JOIN (
SELECT user_id, COUNT(DISTINCT page_id) AS item_count
FROM h5_publish_records
WHERE user_id = ? AND status = 'online'
GROUP BY user_id
) pub ON pub.user_id = c.user_id
WHERE c.user_id = ? AND c.space_id = ?
ORDER BY c.sort_order, c.category_name`,
[userId, userId, userId, userId, row.id],
);
const quotaBytes = asNumber(row.quota_bytes);
const usedBytes = asNumber(row.used_bytes);
const reservedBytes = asNumber(row.reserved_bytes);
const [publicationUsage] = await pool.query(
`SELECT COUNT(DISTINCT CASE WHEN status = 'online' THEN page_id END) AS public_page_used,
COALESCE(SUM(CASE WHEN published_at >= ? THEN view_count ELSE 0 END), 0) AS monthly_view_used
FROM h5_publish_records WHERE user_id = ?`,
[Date.now() - 30 * 24 * 60 * 60 * 1000, userId],
);
const publicPageLimit = await resolvePublicPageLimit();
let schedule = null;
if (scheduleService) {
try {
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,
digestType: 'todo_day',
status: ['active', 'locked'],
limit: 10,
})
: Promise.resolve([]),
]),
1000,
[[], [], [], []],
);
schedule = { todayTodoItems, upcomingItems, upcomingReminders, digestSubscriptions };
} catch {
schedule = null;
}
}
return {
id: row.id,
userId: row.user_id,
name: row.space_name,
status: row.status,
quota: {
quotaBytes,
usedBytes,
reservedBytes,
availableBytes: Math.max(0, quotaBytes - usedBytes - reservedBytes),
maxFileBytes,
publicPageLimit,
publicPageUsed: asNumber(publicationUsage[0]?.public_page_used),
aiDailyLimit,
aiDailyUsed: 0,
monthlyViewLimit,
monthlyViewUsed: asNumber(publicationUsage[0]?.monthly_view_used),
},
categories: categories.map(categoryResponse),
createdAt: asNumber(row.created_at),
updatedAt: asNumber(row.updated_at),
schedule,
};
};
const getQuota = async (userId) => {
const space = await getSpace(userId);
return space?.quota ?? null;
};
const listCategories = async (userId) => {
const space = await getSpace(userId);
return space?.categories ?? null;
};
return {
getSpace,
getQuota,
listCategories,
};
}