287 lines
8.8 KiB
JavaScript
287 lines
8.8 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 = 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,
|
|
};
|
|
}
|