Initial commit: Memind H5 portal with MindSpace, Plaza, and agent jobs.
Track application source and tests; exclude local env, user workspaces, and runtime data via .gitignore. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+232
@@ -0,0 +1,232 @@
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
export const DEFAULT_SPACE_QUOTA_BYTES = 5 * 1024 * 1024;
|
||||
export const DEFAULT_MAX_FILE_BYTES = 2 * 1024 * 1024;
|
||||
|
||||
export const SYSTEM_CATEGORIES = Object.freeze([
|
||||
{
|
||||
code: 'oa',
|
||||
name: 'OA 工作区',
|
||||
visibilityPolicy: 'private',
|
||||
aiAccessPolicy: 'selected_assets',
|
||||
publishPolicy: 'derivative_only',
|
||||
sortOrder: 10,
|
||||
},
|
||||
{
|
||||
code: 'private',
|
||||
name: '私人区',
|
||||
visibilityPolicy: 'private',
|
||||
aiAccessPolicy: 'explicit_asset_grant',
|
||||
publishPolicy: 'desensitized_copy_only',
|
||||
sortOrder: 20,
|
||||
},
|
||||
{
|
||||
code: 'public',
|
||||
name: '公开区',
|
||||
visibilityPolicy: 'public_candidate',
|
||||
aiAccessPolicy: 'selected_assets',
|
||||
publishPolicy: 'security_scan_required',
|
||||
sortOrder: 30,
|
||||
},
|
||||
{
|
||||
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);
|
||||
}
|
||||
|
||||
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 publicPageLimit = Number(options.publicPageLimit ?? 5);
|
||||
const monthlyViewLimit = Number(options.monthlyViewLimit ?? 1000);
|
||||
|
||||
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 COUNT(DISTINCT p.id)
|
||||
WHEN c.category_code = 'public' THEN COUNT(DISTINCT pr.page_id)
|
||||
ELSE COUNT(DISTINCT a.id)
|
||||
END AS item_count
|
||||
FROM h5_space_categories c
|
||||
LEFT JOIN h5_assets a
|
||||
ON a.category_id = c.id
|
||||
AND a.user_id = c.user_id
|
||||
AND a.status <> 'deleted'
|
||||
AND a.source_type = 'upload'
|
||||
LEFT JOIN h5_page_records p
|
||||
ON p.category_id = c.id AND p.user_id = c.user_id AND p.status <> 'deleted'
|
||||
LEFT JOIN h5_publish_records pr
|
||||
ON pr.user_id = c.user_id AND pr.status = 'online'
|
||||
WHERE c.user_id = ? AND c.space_id = ?
|
||||
GROUP BY c.id, c.category_code, c.category_name, c.visibility_policy,
|
||||
c.ai_access_policy, c.publish_policy, c.is_system, c.sort_order
|
||||
ORDER BY c.sort_order, c.category_name`,
|
||||
[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],
|
||||
);
|
||||
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),
|
||||
};
|
||||
};
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user