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 = 30 * 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 IN ('oa', 'public') THEN COALESCE(pc.item_count, 0) + COALESCE(ac.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' 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 WHERE c.user_id = ? AND c.space_id = ? ORDER BY c.sort_order, c.category_name`, [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, }; }