Files
memind/mindspace.mjs
T
john 9b4a25799f Add smart ACK provider for WeChat MP replies
Replace fixed ackText with a rule-based AckProvider that picks
response templates by message type and intent (translate, summary,
rewrite, poster, ppt, mindmap, code, search, schedule). Pure sync,
zero I/O, auto-falls back to config.ackText on any error.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 15:19:03 +08:00

266 lines
8.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: '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 publicPageLimitFallback = Number(options.publicPageLimit ?? 5);
const monthlyViewLimit = Number(options.monthlyViewLimit ?? 1000);
const scheduleService = options.scheduleService ?? null;
const resolvePublicPageLimit = async () => {
try {
const config = await loadMindSpaceConfig(pool);
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 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],
);
const publicPageLimit = await resolvePublicPageLimit();
let schedule = null;
if (scheduleService) {
try {
const [todayTodoItems, digestSubscriptions] = await Promise.all([
typeof scheduleService.listTodayTodoItems === 'function'
? scheduleService.listTodayTodoItems({ userId })
: Promise.resolve([]),
typeof scheduleService.listDigestSubscriptions === 'function'
? scheduleService.listDigestSubscriptions({
userId,
digestType: 'todo_day',
status: ['active', 'locked'],
limit: 10,
})
: Promise.resolve([]),
]);
schedule = { todayTodoItems, 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,
};
}