import crypto from 'node:crypto'; import fs from 'node:fs'; import net from 'node:net'; import path from 'node:path'; import { Algorithm as Argon2Algorithm, hashRawSync as argon2HashRawSync } from '@node-rs/argon2'; import { computeDeltaCostCents, loadBillingConfig, normalizeTokenState } from './billing.mjs'; import { buildInsufficientBalancePayload, loadRechargeConfig } from './billing-recharge.mjs'; import { buildAgentExtensionPolicy, CAPABILITY_CATALOG, catalogKeys, clampUserCapabilities, DEFAULT_USER_CAPABILITIES, isValidCapabilityKey, normalizeCapabilityPatch, resolveSandboxMcpServerPath, USER_NON_GRANTABLE_CAPABILITIES, } from './capabilities.mjs'; import { applyPoliciesToCapabilities, DEFAULT_USER_POLICIES, normalizePolicyPatch, POLICY_CATALOG, policyKeys, resolvePolicies, } from './policies.mjs'; import { ensurePublishSkillInstalled, ensureUserPublishLayout, ensureWorkspaceHintsInstalled, PUBLISH_SKILL_NAME, resolveLegacyPublishDir, } from './user-publish.mjs'; import { ensureUserSpaceLayout, isPathInsideUserWorkspace, } from './user-space.mjs'; import { buildMindSpacePublicUrlForUser, resolveMindSpaceAgentWorkspaceCapability, resolveMindSpaceRuntimeConfig, } from './mindspace-runtime-config.mjs'; import { ensureUserMemoryProfile } from './user-memory-profile.mjs'; import { applySkillGrantsToCapabilities, DEFAULT_USER_SKILLS, grantedSkillNames, listPlatformSkillCatalog, normalizeSkillPatch, resolveSkillMap, syncSkillsToWorkspace, } from './skills-registry.mjs'; import { initializeDefaultSpace } from './mindspace.mjs'; export const USER_COOKIE = 'tkmind_user_session'; function safeEqual(left, right) { const a = Buffer.from(left); const b = Buffer.from(right); return a.length === b.length && crypto.timingSafeEqual(a, b); } const PASSWORD_ALGORITHM_PBKDF2 = 'pbkdf2-sha512'; const PASSWORD_ALGORITHM_ARGON2ID = 'argon2id'; const ARGON2_MEMORY = 64 * 1024; const ARGON2_PASSES = 3; const ARGON2_PARALLELISM = 1; const ARGON2_TAG_LENGTH = 32; function hashPasswordPbkdf2(password, salt) { return crypto.pbkdf2Sync(password, salt, 100000, 64, 'sha512').toString('hex'); } function hashPasswordArgon2id(password, salt) { return argon2HashRawSync(password, { salt: Buffer.from(salt, 'hex'), parallelism: ARGON2_PARALLELISM, outputLen: ARGON2_TAG_LENGTH, memoryCost: ARGON2_MEMORY, timeCost: ARGON2_PASSES, algorithm: Argon2Algorithm.Argon2id, }).toString('hex'); } function createPasswordRecord(password, algorithm = PASSWORD_ALGORITHM_ARGON2ID) { const salt = crypto.randomBytes(16).toString('hex'); if (algorithm === PASSWORD_ALGORITHM_ARGON2ID) { return { salt, passwordHash: hashPasswordArgon2id(password, salt), passwordAlgorithm: PASSWORD_ALGORITHM_ARGON2ID, }; } return { salt, passwordHash: hashPasswordPbkdf2(password, salt), passwordAlgorithm: PASSWORD_ALGORITHM_PBKDF2, }; } function verifyPassword(password, row) { const algorithm = row.password_algorithm || PASSWORD_ALGORITHM_PBKDF2; if (algorithm === PASSWORD_ALGORITHM_ARGON2ID) { return safeEqual(hashPasswordArgon2id(password, row.salt), row.password_hash); } return safeEqual(hashPasswordPbkdf2(password, row.salt), row.password_hash); } function normalizeUsername(username) { return username.trim().toLowerCase(); } function isValidUsername(username) { return /^[a-z0-9_]{2,32}$/.test(username); } function isValidEmail(email) { return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); } function hashSessionToken(token) { return crypto.createHash('sha256').update(token).digest('hex'); } export function createUserAuth(pool, options = {}) { const usersRoot = path.resolve(options.usersRoot ?? '/tmp/tkmind_go_users'); const h5Root = path.resolve(options.h5Root ?? path.join(usersRoot, '..')); const env = options.env ?? process.env; const { storageRoot, publicBaseUrl } = resolveMindSpaceRuntimeConfig(h5Root, env); const skillCatalog = listPlatformSkillCatalog(h5Root); const defaultSignupBalanceCents = Number(options.defaultSignupBalanceCents ?? 500); const lowBalanceGiftThresholdCents = Number(options.lowBalanceGiftThresholdCents ?? 100); const lowBalanceGiftAmountCents = Number(options.lowBalanceGiftAmountCents ?? 1000); const sessionTtlMs = Number(options.sessionTtlMs ?? 7 * 24 * 60 * 60 * 1000); const loginMaxFailures = Number(options.loginMaxFailures ?? 5); const loginFailureWindowMs = Number(options.loginFailureWindowMs ?? 5 * 60 * 1000); const persistSessions = options.persistSessions !== false && Boolean(pool); let rechargeNotifier = typeof options.onRechargeNotification === 'function' ? options.onRechargeNotification : null; const subscriptionService = options.subscriptionService ?? null; const provisionUserDataSpace = typeof options.provisionUserDataSpace === 'function' ? options.provisionUserDataSpace : null; const getMindSearchConfig = typeof options.getMindSearchConfig === 'function' ? options.getMindSearchConfig : null; const sessions = new Map(); const loginFailures = new Map(); const pruneLoginFailures = (now = Date.now()) => { for (const [key, state] of loginFailures) { if (state.resetAt <= now) loginFailures.delete(key); } }; const pruneSessions = (now = Date.now()) => { for (const [token, session] of sessions) { if (session.expiresAt <= now) sessions.delete(token); } }; const storeSession = async (userId, role, token, now = Date.now()) => { const expiresAt = now + sessionTtlMs; sessions.set(token, { userId, role, expiresAt }); if (!persistSessions) return expiresAt; await pool.query( `INSERT INTO h5_login_sessions (id, user_id, token_hash, expires_at, created_at) VALUES (?, ?, ?, ?, ?)`, [crypto.randomUUID(), userId, hashSessionToken(token), expiresAt, now], ); return expiresAt; }; const revokeAllSessionsForUser = async (userId, now = Date.now()) => { for (const [token, session] of sessions) { if (session.userId === userId) sessions.delete(token); } if (!persistSessions) return; await pool.query( `UPDATE h5_login_sessions SET revoked_at = ? WHERE user_id = ? AND revoked_at IS NULL`, [now, userId], ); }; const ensureWorkspace = (workspaceRoot) => { fs.mkdirSync(workspaceRoot, { recursive: true }); }; const isAdminRole = (user) => user?.role === 'admin'; const getUserById = async (userId) => { const [rows] = await pool.query( `SELECT u.id, u.username, u.slug, u.email, u.display_name, u.role, u.status, u.plan_type, u.workspace_root, s.quota_bytes, s.used_bytes, s.reserved_bytes, w.balance_cents, w.tokens_used, (SELECT COALESCE(SUM(ABS(amount_cents)), 0) FROM h5_billing_ledger l WHERE l.user_id = u.id AND l.type = 'deduct') AS spent_cents FROM h5_users u LEFT JOIN h5_user_spaces s ON s.user_id = u.id LEFT JOIN h5_user_wallets w ON w.user_id = u.id WHERE u.id = ?`, [userId], ); return rows[0] ?? null; }; const publicUser = (row) => { const balanceCents = Number(row.balance_cents ?? 0); const spentCents = Number(row.spent_cents ?? 0); const base = { id: row.id, username: row.username, slug: row.slug ?? row.username, email: row.email ?? null, displayName: row.display_name, role: row.role, status: row.status, planType: row.plan_type ?? 'free', workspaceRoot: row.workspace_root, balanceCents, totalCreditCents: balanceCents + spentCents, tokensUsed: Number(row.tokens_used ?? 0), spaceQuotaBytes: Number(row.quota_bytes ?? 0), spaceUsedBytes: Number(row.used_bytes ?? 0), spaceReservedBytes: Number(row.reserved_bytes ?? 0), spaceAvailableBytes: Math.max( 0, Number(row.quota_bytes ?? 0) - Number(row.used_bytes ?? 0) - Number(row.reserved_bytes ?? 0), ), }; const publishKey = row.id; return { ...base, publishSlug: publishKey, publishUrl: buildMindSpacePublicUrlForUser({ h5Root, env, user: publishKey }), publishSkillName: PUBLISH_SKILL_NAME, }; }; const publishLayoutFor = async (user, { migrateLegacy = true } = {}) => { const web = ensureUserPublishLayout({ h5Root, publicBaseUrl, user, legacyUsersRoot: migrateLegacy ? usersRoot : null, }); const space = await ensureUserSpaceLayout({ pool, storageRoot, userId: user.id, username: user.username ?? web.slug, displayName: user.displayName, publicBaseUrl, slug: web.slug, workspaceRoot: web.publishDir, }); const hintsContext = { slug: web.slug, username: user.username ?? web.username, displayName: user.displayName, publicBaseUrl, publishDir: web.publishDir, }; ensurePublishSkillInstalled(web.publishDir, hintsContext); ensureWorkspaceHintsInstalled(web.publishDir, hintsContext); const legacyPublishDir = resolveLegacyPublishDir(h5Root, user); if (legacyPublishDir && legacyPublishDir !== web.publishDir && fs.existsSync(legacyPublishDir)) { ensureWorkspaceHintsInstalled(legacyPublishDir, { ...hintsContext, publishDir: legacyPublishDir }); } ensureUserMemoryProfile(web.publishDir, { userId: user.id, displayName: user.displayName ?? user.display_name, username: user.username ?? web.username, slug: web.slug, }); return { ...web, ...space, publishDir: web.publishDir, constraints: web.constraints, }; }; const ensureUserDataSpaceForUser = async (user, workspaceRoot = null) => { if (!provisionUserDataSpace || !user?.id || user.role === 'admin') return null; return provisionUserDataSpace({ userId: user.id, workspaceRoot: workspaceRoot ?? user.workspaceRoot ?? user.workspace_root, }); }; const listSkillGrants = async (subjectType, subjectId) => { const [rows] = await pool.query( `SELECT skill_name, enabled FROM h5_user_skill_grants WHERE subject_type = ? AND subject_id = ?`, [subjectType, subjectId], ); return Object.fromEntries(rows.map((row) => [row.skill_name, Boolean(row.enabled)])); }; const resolveUserSkillMap = async (user) => { if (!user || user.role === 'admin') { return Object.fromEntries(skillCatalog.map((item) => [item.name, true])); } const roleDefaults = await listSkillGrants('role', 'user'); const userOverrides = await listSkillGrants('user', user.id); return resolveSkillMap(roleDefaults, userOverrides, skillCatalog); }; const syncUserSkillsForUser = async (user) => { if (!user || user.role === 'admin') return; const layout = await syncUserPublishWorkspace(user); const skillMap = await resolveUserSkillMap(user); syncSkillsToWorkspace({ h5Root, publishDir: layout?.publishDir, skillMap, catalog: skillCatalog, user, publicBaseUrl, }); }; const syncUserPublishWorkspace = async (user) => { if (!user) return null; const layout = await publishLayoutFor(user); const current = path.resolve(user.workspace_root); const target = path.resolve(layout.publishDir); if (current !== target) { const now = Date.now(); await pool.query(`UPDATE h5_users SET workspace_root = ?, updated_at = ? WHERE id = ?`, [ layout.publishDir, now, user.id, ]); await pool.query(`DELETE FROM h5_user_path_grants WHERE user_id = ?`, [user.id]); await pool.query( `INSERT INTO h5_user_path_grants (user_id, path, mode) VALUES (?, ?, 'readwrite')`, [user.id, layout.publishDir], ); } return layout; }; const recordSignupBonus = async (conn, userId, amountCents, now) => { const amount = Number(amountCents); if (!Number.isFinite(amount) || amount <= 0) return; await conn.query( `INSERT INTO h5_billing_ledger (user_id, type, amount_cents, tokens, note, operator_id, created_at) VALUES (?, 'adjust', ?, 0, '新用户赠送', NULL, ?)`, [userId, amount, now], ); }; const shouldEnableLowBalanceGift = ({ isAdmin = false, initialBalanceCents }) => { return !isAdmin && Number(initialBalanceCents) === defaultSignupBalanceCents && defaultSignupBalanceCents > 0; }; const grantLowBalanceGiftIfNeeded = async (conn, { userId, currentBalance, nextBalance, now }) => { if (lowBalanceGiftAmountCents <= 0 || lowBalanceGiftThresholdCents < 0 || nextBalance > lowBalanceGiftThresholdCents) { return { gifted: false, balanceAfter: nextBalance }; } const [rows] = await conn.query( `SELECT low_balance_gift_eligible, low_balance_gift_granted_at FROM h5_users WHERE id = ? FOR UPDATE`, [userId], ); const user = rows[0]; if (!user || !Boolean(user.low_balance_gift_eligible) || user.low_balance_gift_granted_at != null) { return { gifted: false, balanceAfter: nextBalance }; } const giftedBalance = nextBalance + lowBalanceGiftAmountCents; await conn.query( `UPDATE h5_user_wallets SET balance_cents = ?, updated_at = ? WHERE user_id = ?`, [giftedBalance, now, userId], ); await conn.query( `UPDATE h5_users SET low_balance_gift_eligible = 0, low_balance_gift_granted_at = ?, status = CASE WHEN status = 'suspended' THEN 'active' ELSE status END, updated_at = ? WHERE id = ?`, [now, now, userId], ); await conn.query( `INSERT INTO h5_billing_ledger (user_id, type, amount_cents, tokens, note, operator_id, created_at) VALUES (?, 'adjust', ?, 0, ?, NULL, ?)`, [userId, lowBalanceGiftAmountCents, '新用户低余额自动赠送', now], ); await conn.query( `INSERT INTO h5_user_notifications (id, user_id, channel, notification_type, title, body, data_json, status, read_at, created_at, updated_at) VALUES (?, ?, 'web', 'low_balance_gift', ?, ?, ?, 'unread', NULL, ?, ?)`, [ crypto.randomUUID(), userId, '新用户额度已自动补送', `检测到你的余额已低于 ¥${(lowBalanceGiftThresholdCents / 100).toFixed(2)},系统已自动赠送 ¥${(lowBalanceGiftAmountCents / 100).toFixed(2)} 新用户额度。本福利仅可领取一次。`, JSON.stringify({ triggerBalanceCents: nextBalance, previousBalanceCents: currentBalance, giftAmountCents: lowBalanceGiftAmountCents, thresholdCents: lowBalanceGiftThresholdCents, }), now, now, ], ); return { gifted: true, balanceAfter: giftedBalance }; }; const register = async ({ username, password, displayName, email }) => { const normalized = normalizeUsername(username); if (!isValidUsername(normalized)) { return { ok: false, message: '用户名仅支持 2-32 位小写字母、数字、下划线' }; } if (!password || password.length < 6) { return { ok: false, message: '密码至少 6 位' }; } if (!email || !isValidEmail(email.trim())) { return { ok: false, message: '请输入有效邮箱' }; } const { salt, passwordHash, passwordAlgorithm } = createPasswordRecord(password); const userId = crypto.randomUUID(); const layout = await publishLayoutFor({ id: userId, username: normalized }); const workspaceRoot = layout.publishDir; const now = Date.now(); const conn = await pool.getConnection(); try { await conn.beginTransaction(); await conn.query( `INSERT INTO h5_users (id, username, slug, email, display_name, salt, password_hash, password_algorithm, role, status, plan_type, workspace_root, low_balance_gift_eligible, low_balance_gift_granted_at, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'user', 'active', 'free', ?, 1, NULL, ?, ?)`, [ userId, normalized, normalized, email?.trim().toLowerCase() || null, displayName?.trim() || normalized, salt, passwordHash, passwordAlgorithm, workspaceRoot, now, now, ], ); await conn.query( `INSERT INTO h5_user_wallets (user_id, balance_cents, tokens_used, updated_at) VALUES (?, ?, 0, ?)`, [userId, defaultSignupBalanceCents, now], ); await recordSignupBonus(conn, userId, defaultSignupBalanceCents, now); await conn.query( `INSERT INTO h5_user_path_grants (user_id, path, mode) VALUES (?, ?, 'readwrite')`, [userId, workspaceRoot], ); await initializeDefaultSpace(conn, userId, { quotaBytes: Number(process.env.MINDSPACE_FREE_QUOTA_BYTES ?? 5 * 1024 * 1024), now, }); await ensureUserDataSpaceForUser({ id: userId, role: 'user' }, workspaceRoot); await conn.commit(); ensureWorkspace(workspaceRoot); ensureUserMemoryProfile(workspaceRoot, { userId, displayName: displayName?.trim() || normalized, username: normalized, slug: normalized, }); if (subscriptionService) { subscriptionService.grantSubscription(userId, 'free', null, null, '注册赠送免费套餐').catch(() => {}); } const user = await getUserById(userId); return { ok: true, user: publicUser(user) }; } catch (err) { await conn.rollback(); if (err?.code === 'ER_DUP_ENTRY') { return { ok: false, message: '用户名、主页地址或邮箱已存在' }; } throw err; } finally { conn.release(); } }; const login = async ({ username, password, ip = 'unknown', now = Date.now() }) => { pruneLoginFailures(now); const normalized = normalizeUsername(username); const failureKey = `${ip}:${normalized}`; const failure = loginFailures.get(failureKey); if (failure && failure.count >= loginMaxFailures && failure.resetAt > now) { return { ok: false, message: '尝试次数过多,请稍后再试', retryAfterMs: failure.resetAt - now, }; } const [rows] = await pool.query( `SELECT u.id, u.username, u.slug, u.email, u.display_name, u.role, u.status, u.plan_type, u.workspace_root, u.salt, u.password_hash, u.password_algorithm, w.balance_cents, w.tokens_used FROM h5_users u LEFT JOIN h5_user_wallets w ON w.user_id = u.id WHERE u.username = ?`, [normalized], ); const row = rows[0]; if (!row) { const current = failure && failure.resetAt > now ? failure : { count: 0, resetAt: now + loginFailureWindowMs }; current.count += 1; loginFailures.set(failureKey, current); return { ok: false, message: '用户名或密码错误' }; } if (!verifyPassword(password, row)) { const current = failure && failure.resetAt > now ? failure : { count: 0, resetAt: now + loginFailureWindowMs }; current.count += 1; loginFailures.set(failureKey, current); return { ok: false, message: '用户名或密码错误' }; } if (row.status === 'disabled') { return { ok: false, message: '账户已禁用,请联系管理员' }; } if ((row.password_algorithm || PASSWORD_ALGORITHM_PBKDF2) !== PASSWORD_ALGORITHM_ARGON2ID) { const nextPassword = createPasswordRecord(password); await pool.query( `UPDATE h5_users SET salt = ?, password_hash = ?, password_algorithm = ?, updated_at = ? WHERE id = ?`, [nextPassword.salt, nextPassword.passwordHash, nextPassword.passwordAlgorithm, now, row.id], ); row.salt = nextPassword.salt; row.password_hash = nextPassword.passwordHash; row.password_algorithm = nextPassword.passwordAlgorithm; } loginFailures.delete(failureKey); const token = crypto.randomBytes(32).toString('base64url'); await storeSession(row.id, row.role, token, now); return { ok: true, token, user: publicUser(row) }; }; const resetPassword = async ({ username, email, password }) => { const normalized = normalizeUsername(username); if (!isValidUsername(normalized)) { return { ok: false, message: '用户名或邮箱不正确' }; } if (!email || !isValidEmail(email.trim())) { return { ok: false, message: '请输入有效邮箱' }; } if (!password || password.length < 6) { return { ok: false, message: '新密码至少 6 位' }; } const [rows] = await pool.query( `SELECT id, email, status FROM h5_users WHERE username = ? LIMIT 1`, [normalized], ); const row = rows[0]; const normalizedEmail = email.trim().toLowerCase(); if (!row || (row.email ?? '').toLowerCase() !== normalizedEmail) { return { ok: false, message: '用户名或邮箱不正确' }; } if (row.status === 'disabled') { return { ok: false, message: '账户已禁用,请联系管理员' }; } const { salt, passwordHash, passwordAlgorithm } = createPasswordRecord(password); const now = Date.now(); await pool.query( `UPDATE h5_users SET salt = ?, password_hash = ?, password_algorithm = ?, updated_at = ? WHERE id = ?`, [salt, passwordHash, passwordAlgorithm, now, row.id], ); await revokeAllSessionsForUser(row.id, now); return { ok: true }; }; const verify = async (token, now = Date.now()) => { if (!token) return null; pruneSessions(now); const cached = sessions.get(token); if (cached) { if (cached.expiresAt <= now) { sessions.delete(token); return null; } const user = await getUserById(cached.userId); if (!user || user.status === 'disabled') { await revoke(token, now); return null; } cached.expiresAt = now + sessionTtlMs; if (persistSessions) { await pool.query( `UPDATE h5_login_sessions SET expires_at = ? WHERE token_hash = ? AND revoked_at IS NULL`, [cached.expiresAt, hashSessionToken(token)], ); } return cached; } if (!persistSessions) return null; const tokenHash = hashSessionToken(token); const [rows] = await pool.query( `SELECT s.user_id, s.expires_at, u.role, u.status FROM h5_login_sessions s JOIN h5_users u ON u.id = s.user_id WHERE s.token_hash = ? AND s.revoked_at IS NULL LIMIT 1`, [tokenHash], ); const row = rows[0]; if (!row || Number(row.expires_at ?? 0) <= now || row.status === 'disabled') { if (row) await revoke(token, now); return null; } const expiresAt = now + sessionTtlMs; await pool.query( `UPDATE h5_login_sessions SET expires_at = ? WHERE token_hash = ? AND revoked_at IS NULL`, [expiresAt, tokenHash], ); const session = { userId: row.user_id, role: row.role, expiresAt }; sessions.set(token, session); return session; }; const revoke = async (token, now = Date.now()) => { if (!token) return; sessions.delete(token); if (!persistSessions) return; await pool.query( `UPDATE h5_login_sessions SET revoked_at = ? WHERE token_hash = ? AND revoked_at IS NULL`, [now, hashSessionToken(token)], ); }; const getMe = async (token) => { const session = await verify(token); if (!session) return null; const user = await getUserById(session.userId); if (!user) return null; return publicUser(user); }; const listPathGrants = async (userId) => { const [rows] = await pool.query( `SELECT path, mode FROM h5_user_path_grants WHERE user_id = ? ORDER BY path`, [userId], ); return rows.map((row) => ({ path: row.path, mode: row.mode })); }; const resolveWorkingDir = async (userId) => { const user = await getUserById(userId); if (!user) throw new Error('用户不存在'); const layout = await syncUserPublishWorkspace(user); return layout.publishDir; }; const getUserPublishLayout = async (userId) => { const user = await getUserById(userId); if (!user) return null; return syncUserPublishWorkspace(user); }; const isPathAllowed = async (userId, requestedPath) => { const user = await getUserById(userId); if (!user) return false; if (isAdminRole(user)) return true; const layout = await publishLayoutFor(user, { migrateLegacy: false }); return isPathInsideUserWorkspace(layout.publishDir, requestedPath); }; const repairAllUserPublishDirs = async () => { const [rows] = await pool.query( `SELECT id, username, role, workspace_root FROM h5_users WHERE role = 'user'`, ); const errors = []; for (const row of rows) { try { await syncUserSkillsForUser(row); } catch (error) { errors.push({ userId: row.id, error }); console.warn( `[UserAuth] Workspace skill repair skipped for user ${row.id}:`, error instanceof Error ? error.message : error, ); } } return { repaired: rows.length - errors.length, errors }; }; const ensureAllUserDataSpaces = async () => { if (!provisionUserDataSpace) return { provisioned: 0, errors: [] }; const [rows] = await pool.query( `SELECT id, workspace_root FROM h5_users WHERE role = 'user' AND status = 'active'`, ); let provisioned = 0; const errors = []; for (const row of rows) { try { await ensureUserDataSpaceForUser({ id: row.id, role: 'user' }, row.workspace_root); provisioned += 1; } catch (error) { errors.push({ userId: row.id, code: error?.code ?? null, message: error instanceof Error ? error.message : String(error), }); } } return { provisioned, errors }; }; const seedRoleSkillDefaults = async () => { const now = Date.now(); for (const [name, enabled] of Object.entries(DEFAULT_USER_SKILLS)) { await pool.query( `INSERT INTO h5_user_skill_grants (subject_type, subject_id, skill_name, enabled, updated_at) VALUES ('role', 'user', ?, ?, ?) ON DUPLICATE KEY UPDATE skill_name = skill_name`, [name, enabled ? 1 : 0, now], ); } }; // goosedTarget may be the upstream URL the session is pinned to (preferred), or // a legacy integer index into the targets list. We persist the URL in // goosed_target and still derive a numeric goosed_node so older readers keep // working; a string target stores goosed_node = 0 (its value is ignored once // goosed_target is set). const registerAgentSession = async (userId, agentSessionId, goosedTarget = 0) => { const isLegacyIndex = typeof goosedTarget === 'number' || /^\d+$/.test(String(goosedTarget)); const goosedNode = isLegacyIndex ? Number(goosedTarget) : 0; const targetUrl = isLegacyIndex ? null : String(goosedTarget); await pool.query( `INSERT INTO h5_user_sessions (agent_session_id, user_id, goosed_node, goosed_target, created_at) VALUES (?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE user_id = VALUES(user_id), goosed_node = VALUES(goosed_node), goosed_target = VALUES(goosed_target)`, [agentSessionId, userId, goosedNode, targetUrl, Date.now()], ); }; // Legacy integer-index accessor, kept for callers/bundles that still route by // array index. Prefer getSessionTarget. const getSessionNode = async (agentSessionId) => { const [rows] = await pool.query( `SELECT goosed_node FROM h5_user_sessions WHERE agent_session_id = ? LIMIT 1`, [agentSessionId], ); return rows[0]?.goosed_node ?? 0; }; // Returns { target, node }: target is the pinned upstream URL (null if the row // predates goosed_target), node is the legacy integer index fallback. const getSessionTarget = async (agentSessionId) => { const [rows] = await pool.query( `SELECT goosed_node, goosed_target FROM h5_user_sessions WHERE agent_session_id = ? LIMIT 1`, [agentSessionId], ); return { target: rows[0]?.goosed_target ?? null, node: rows[0]?.goosed_node ?? 0 }; }; const ownsSession = async (userId, agentSessionId) => { const [rows] = await pool.query( `SELECT 1 FROM h5_user_sessions WHERE agent_session_id = ? AND user_id = ? LIMIT 1`, [agentSessionId, userId], ); return rows.length > 0; }; const listOwnedSessionIds = async (userId) => { const [rows] = await pool.query( `SELECT agent_session_id FROM h5_user_sessions WHERE user_id = ?`, [userId], ); return new Set(rows.map((row) => row.agent_session_id)); }; const setSessionOrigin = async (agentSessionId, origin) => { if (!agentSessionId || (origin !== 'h5' && origin !== 'wechat')) return; await pool.query( `UPDATE h5_user_sessions SET origin = ? WHERE agent_session_id = ?`, [origin, agentSessionId], ); }; const getSessionOrigins = async (agentSessionIds = []) => { const ids = [...new Set(agentSessionIds)].filter(Boolean); if (ids.length === 0) return new Map(); const [rows] = await pool.query( `SELECT agent_session_id, origin FROM h5_user_sessions WHERE agent_session_id IN (?)`, [ids], ); return new Map(rows.map((row) => [row.agent_session_id, row.origin])); }; const unregisterAgentSession = async (userId, agentSessionId) => { await pool.query( `DELETE FROM h5_user_sessions WHERE agent_session_id = ? AND user_id = ?`, [agentSessionId, userId], ); }; const canUseChat = async (userId) => { const user = await getUserById(userId); if (!user) return { ok: false, message: '用户不存在' }; if (user.status === 'disabled') { return { ok: false, message: '账户已禁用' }; } if (user.status === 'suspended' && !isAdminRole(user)) { return { ok: false, message: '账户已暂停' }; } if (isAdminRole(user)) { return { ok: true, balanceCents: Number(user.balance_cents ?? 0) }; } // Subscription check: active subscription with remaining quota bypasses balance requirement. if (subscriptionService) { const sub = await subscriptionService.getActiveSubscription(userId); if (sub) { const unlimited = sub.periodTokensLimit === 0; const hasQuota = unlimited || sub.periodTokensUsed < sub.periodTokensLimit; const balanceCents = Number(user.balance_cents ?? 0); if (hasQuota) { return { ok: true, balanceCents, subscription: sub }; } // Quota exhausted — allow if balance covers overage, otherwise block. if (balanceCents > 0) { return { ok: true, balanceCents, subscription: sub, overQuota: true }; } return { ok: false, message: '本月额度已用完,余额不足,请充值或升级套餐', ...buildInsufficientBalancePayload(balanceCents, loadRechargeConfig()), }; } } const balanceCents = Number(user.balance_cents ?? 0); if (balanceCents <= 0) { return { ok: false, message: '余额不足,请充值后继续使用', ...buildInsufficientBalancePayload(balanceCents, loadRechargeConfig()), }; } return { ok: true, balanceCents }; }; const listUsers = async ({ page = 1, pageSize = 20, search = '', role = '', status = '' } = {}) => { const safePageSize = Math.min(Math.max(Number(pageSize) || 20, 1), 100); const safePage = Math.max(Number(page) || 1, 1); const offset = (safePage - 1) * safePageSize; const params = []; const clauses = []; if (search) { clauses.push('(u.username LIKE ? OR u.display_name LIKE ?)'); params.push(`%${search}%`, `%${search}%`); } if (role) { clauses.push('u.role = ?'); params.push(role); } if (status) { clauses.push('u.status = ?'); params.push(status); } const where = clauses.length ? `WHERE ${clauses.join(' AND ')}` : ''; const [[{ total }]] = await pool.query( `SELECT COUNT(*) AS total FROM h5_users u ${where}`, params, ); const [rows] = await pool.query( `SELECT u.id, u.username, u.slug, u.email, u.display_name, u.role, u.status, u.plan_type, u.workspace_root, s.quota_bytes, s.used_bytes, s.reserved_bytes, u.created_at, u.updated_at, w.balance_cents, w.tokens_used FROM h5_users u LEFT JOIN h5_user_spaces s ON s.user_id = u.id LEFT JOIN h5_user_wallets w ON w.user_id = u.id ${where} ORDER BY u.created_at DESC LIMIT ${safePageSize} OFFSET ${offset}`, params, ); return { users: rows.map((row) => ({ ...publicUser(row), createdAt: Number(row.created_at), updatedAt: Number(row.updated_at) })), total: Number(total), page: safePage, pageSize: safePageSize, }; }; const getUserPublic = async (userId) => { const user = await getUserById(userId); return user ? publicUser(user) : null; }; const createUser = async ({ username, password, displayName, workspaceRoot, balanceCents, role = 'user', email, }) => { const normalized = normalizeUsername(username); if (!isValidUsername(normalized)) { return { ok: false, message: '用户名格式无效' }; } if (!password || password.length < 6) { return { ok: false, message: '密码至少 6 位' }; } const isAdmin = role === 'admin'; const userId = crypto.randomUUID(); const root = (await publishLayoutFor({ id: userId, username: normalized })).publishDir; const { salt, passwordHash, passwordAlgorithm } = createPasswordRecord(password); const now = Date.now(); const conn = await pool.getConnection(); const initialBalanceCents = Number(balanceCents ?? defaultSignupBalanceCents); try { await conn.beginTransaction(); await conn.query( `INSERT INTO h5_users (id, username, slug, email, display_name, salt, password_hash, password_algorithm, role, status, plan_type, workspace_root, low_balance_gift_eligible, low_balance_gift_granted_at, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', 'free', ?, ?, NULL, ?, ?)`, [ userId, normalized, normalized, email?.trim().toLowerCase() || null, displayName?.trim() || normalized, salt, passwordHash, passwordAlgorithm, isAdmin ? 'admin' : 'user', root, shouldEnableLowBalanceGift({ isAdmin, initialBalanceCents }) ? 1 : 0, now, now, ], ); await conn.query( `INSERT INTO h5_user_wallets (user_id, balance_cents, tokens_used, updated_at) VALUES (?, ?, 0, ?)`, [userId, initialBalanceCents, now], ); await conn.query( `INSERT INTO h5_user_path_grants (user_id, path, mode) VALUES (?, ?, 'readwrite')`, [userId, root], ); if (!isAdmin) { await initializeDefaultSpace(conn, userId, { quotaBytes: Number(process.env.MINDSPACE_FREE_QUOTA_BYTES ?? 5 * 1024 * 1024), now, }); await ensureUserDataSpaceForUser({ id: userId, role: 'user' }, root); } await conn.commit(); ensureWorkspace(root); if (subscriptionService && !isAdmin) { subscriptionService.grantSubscription(userId, 'free', null, null, '注册赠送免费套餐').catch(() => {}); } const user = await getUserById(userId); return { ok: true, user: publicUser(user) }; } catch (err) { await conn.rollback(); if (err?.code === 'ER_DUP_ENTRY') { return { ok: false, message: '用户名已存在' }; } throw err; } finally { conn.release(); } }; const updateUser = async (userId, patch) => { const user = await getUserById(userId); if (!user) return { ok: false, message: '用户不存在' }; const now = Date.now(); const fields = []; const values = []; if (patch.displayName !== undefined) { fields.push('display_name = ?'); values.push(patch.displayName.trim() || user.username); } if (patch.status !== undefined) { fields.push('status = ?'); values.push(patch.status); } if (patch.workspaceRoot !== undefined) { const root = path.resolve(patch.workspaceRoot); fields.push('workspace_root = ?'); values.push(root); ensureWorkspace(root); await pool.query(`DELETE FROM h5_user_path_grants WHERE user_id = ?`, [userId]); await pool.query( `INSERT INTO h5_user_path_grants (user_id, path, mode) VALUES (?, ?, 'readwrite')`, [userId, root], ); } if (patch.role !== undefined) { fields.push('role = ?'); values.push(patch.role === 'admin' ? 'admin' : 'user'); } if (fields.length > 0) { fields.push('updated_at = ?'); values.push(now, userId); await pool.query(`UPDATE h5_users SET ${fields.join(', ')} WHERE id = ?`, values); } if (patch.status === 'disabled' || patch.status === 'suspended') { await revokeAllSessionsForUser(userId, now); } if (patch.balanceCents !== undefined) { await pool.query( `INSERT INTO h5_user_wallets (user_id, balance_cents, tokens_used, updated_at) VALUES (?, ?, 0, ?) ON DUPLICATE KEY UPDATE balance_cents = VALUES(balance_cents), updated_at = VALUES(updated_at)`, [userId, Number(patch.balanceCents), now], ); } if (patch.spaceQuotaBytes !== undefined) { const quotaBytes = Math.floor(Number(patch.spaceQuotaBytes)); if (!Number.isFinite(quotaBytes) || quotaBytes <= 0) { return { ok: false, message: '空间大小无效' }; } const [spaceRows] = await pool.query( `SELECT id, quota_bytes, used_bytes, reserved_bytes FROM h5_user_spaces WHERE user_id = ? LIMIT 1`, [userId], ); const currentSpace = spaceRows[0]; const occupiedBytes = Number(currentSpace?.used_bytes ?? 0) + Number(currentSpace?.reserved_bytes ?? 0); if (quotaBytes < occupiedBytes) { return { ok: false, message: `空间不能小于已使用容量 ${Math.ceil(occupiedBytes / 1024 / 1024)} MB`, }; } if (currentSpace?.id) { await pool.query( `UPDATE h5_user_spaces SET quota_bytes = ?, updated_at = ? WHERE user_id = ?`, [quotaBytes, now, userId], ); } else { await initializeDefaultSpace(pool, userId, { quotaBytes, now, }); } } const updated = await getUserById(userId); return { ok: true, user: publicUser(updated) }; }; const purchaseSpaceQuota = async (userId, sizeMb) => { const purchaseMb = Math.floor(Number(sizeMb)); if (!Number.isFinite(purchaseMb) || purchaseMb <= 0) { return { ok: false, message: '扩容大小无效' }; } const deltaBytes = purchaseMb * 1024 * 1024; const costCents = purchaseMb * 200; const now = Date.now(); const conn = await pool.getConnection(); try { await conn.beginTransaction(); const [spaceRows] = await conn.query( `SELECT id, quota_bytes, used_bytes, reserved_bytes FROM h5_user_spaces WHERE user_id = ? LIMIT 1 FOR UPDATE`, [userId], ); if (!spaceRows[0]) { await initializeDefaultSpace(conn, userId, { now }); } const [walletRows] = await conn.query( `SELECT balance_cents FROM h5_user_wallets WHERE user_id = ? FOR UPDATE`, [userId], ); const balanceCents = Number(walletRows[0]?.balance_cents ?? 0); if (balanceCents < costCents) { await conn.rollback(); return { ok: false, code: 'INSUFFICIENT_BALANCE', message: '余额不足,请先充值后再购买空间', balanceCents, minRechargeCents: Math.max(500, costCents - balanceCents), suggestedTiers: loadRechargeConfig().tiersCents, }; } await conn.query( `UPDATE h5_user_wallets SET balance_cents = balance_cents - ?, updated_at = ? WHERE user_id = ?`, [costCents, now, userId], ); await conn.query( `UPDATE h5_user_spaces SET quota_bytes = quota_bytes + ?, updated_at = ? WHERE user_id = ?`, [deltaBytes, now, userId], ); await conn.query( `INSERT INTO h5_billing_ledger (user_id, type, amount_cents, tokens, note, operator_id, created_at) VALUES (?, 'deduct', ?, 0, ?, NULL, ?)`, [userId, costCents, `space_purchase:${purchaseMb}MB`, now], ); await conn.query( `INSERT INTO h5_user_notifications (id, user_id, channel, notification_type, title, body, data_json, status, read_at, created_at, updated_at) VALUES (?, ?, 'web', 'space_purchase', ?, ?, ?, 'unread', NULL, ?, ?)`, [ crypto.randomUUID(), userId, '空间扩容成功', `已购买 ${purchaseMb} MB 空间,支付 ¥${(costCents / 100).toFixed(2)}。`, JSON.stringify({ purchaseMb, deltaBytes, costCents }), now, now, ], ); await conn.commit(); const [updatedSpaceRows] = await pool.query( `SELECT quota_bytes, used_bytes, reserved_bytes FROM h5_user_spaces WHERE user_id = ? LIMIT 1`, [userId], ); const updatedSpace = updatedSpaceRows[0] ?? {}; const updatedUser = await getUserById(userId); return { ok: true, balanceCents: Number(updatedUser?.balance_cents ?? Math.max(0, balanceCents - costCents)), quota: { quotaBytes: Number(updatedSpace.quota_bytes ?? 0), usedBytes: Number(updatedSpace.used_bytes ?? 0), reservedBytes: Number(updatedSpace.reserved_bytes ?? 0), availableBytes: Math.max( 0, Number(updatedSpace.quota_bytes ?? 0) - Number(updatedSpace.used_bytes ?? 0) - Number(updatedSpace.reserved_bytes ?? 0), ), }, }; } catch (err) { await conn.rollback(); throw err; } finally { conn.release(); } }; const recharge = async (userId, amountCents, operatorId, note = '', options = {}) => { const amount = Number(amountCents); if (!Number.isFinite(amount) || amount <= 0) { return { ok: false, message: '充值金额无效' }; } const user = await getUserById(userId); if (!user) return { ok: false, message: '用户不存在' }; const paymentOrderId = options.paymentOrderId ?? null; const ledgerNote = paymentOrderId ? `order:${paymentOrderId}` : note || (operatorId ? '管理员充值' : '账户充值'); const rechargeType = paymentOrderId ? 'self_recharge' : operatorId ? 'admin_recharge' : 'recharge'; const now = Date.now(); const ownsConnection = !options.conn; const conn = options.conn ?? (await pool.getConnection()); try { if (ownsConnection) await conn.beginTransaction(); await conn.query( `INSERT INTO h5_user_wallets (user_id, balance_cents, tokens_used, updated_at) VALUES (?, ?, 0, ?) ON DUPLICATE KEY UPDATE balance_cents = balance_cents + VALUES(balance_cents), updated_at = VALUES(updated_at)`, [userId, amount, now], ); await conn.query( `INSERT INTO h5_billing_ledger (user_id, type, amount_cents, tokens, note, operator_id, created_at) VALUES (?, 'recharge', ?, 0, ?, ?, ?)`, [userId, amount, ledgerNote, operatorId, now], ); const title = paymentOrderId ? '充值成功' : operatorId ? '管理员已充值' : '账户充值成功'; const body = paymentOrderId ? `你已成功充值 ¥${(amount / 100).toFixed(2)},余额已更新。` : operatorId ? `管理员已为你充值 ¥${(amount / 100).toFixed(2)},余额已更新。` : `你的账户已充值 ¥${(amount / 100).toFixed(2)},余额已更新。`; await conn.query( `INSERT INTO h5_user_notifications (id, user_id, channel, notification_type, title, body, data_json, status, read_at, created_at, updated_at) VALUES (?, ?, 'web', ?, ?, ?, ?, 'unread', NULL, ?, ?)`, [ crypto.randomUUID(), userId, rechargeType, title, body, JSON.stringify({ amountCents: amount, operatorId: operatorId ?? null, paymentOrderId, }), now, now, ], ); if (user.status === 'suspended') { await conn.query(`UPDATE h5_users SET status = 'active', updated_at = ? WHERE id = ?`, [ now, userId, ]); } if (ownsConnection) await conn.commit(); const updated = await getUserById(userId); if (rechargeNotifier) { try { await rechargeNotifier({ userId, amountCents: amount, operatorId: operatorId ?? null, paymentOrderId, dedupeKey: paymentOrderId ? `recharge:${paymentOrderId}` : null, notificationType: rechargeType, title, body, user: publicUser(updated), }); } catch (err) { console.warn( 'Recharge notifier failed:', err instanceof Error ? err.message : String(err), ); } } return { ok: true, user: publicUser(updated) }; } catch (err) { if (ownsConnection) await conn.rollback(); throw err; } finally { if (ownsConnection) conn.release(); } }; const getBillingState = async (agentSessionId) => { const [rows] = await pool.query( `SELECT agent_session_id, user_id, last_accumulated_cost, last_input_tokens, last_output_tokens, updated_at FROM h5_session_billing_state WHERE agent_session_id = ?`, [agentSessionId], ); const row = rows[0]; if (!row) return null; return { agentSessionId: row.agent_session_id, userId: row.user_id, lastAccumulatedCost: row.last_accumulated_cost, lastInputTokens: Number(row.last_input_tokens ?? 0), lastOutputTokens: Number(row.last_output_tokens ?? 0), updatedAt: Number(row.updated_at), }; }; const billSessionUsage = async (userId, agentSessionId, tokenStateRaw, requestId = null) => { const user = await getUserById(userId); if (isAdminRole(user)) { return { ok: true, costCents: 0, balanceCents: Number(user?.balance_cents ?? 0), tokensUsed: Number(user?.tokens_used ?? 0), deltaInputTokens: 0, deltaOutputTokens: 0, }; } const tokenState = normalizeTokenState(tokenStateRaw); const config = loadBillingConfig(); const normalizedRequestId = requestId ? String(requestId).trim() || null : null; const now = Date.now(); const conn = await pool.getConnection(); try { await conn.beginTransaction(); if (normalizedRequestId) { const [existingUsage] = await conn.query( `SELECT cost_cents FROM h5_usage_records WHERE request_id = ? LIMIT 1`, [normalizedRequestId], ); if (existingUsage[0]) { const [walletRows] = await conn.query( `SELECT balance_cents, tokens_used FROM h5_user_wallets WHERE user_id = ?`, [userId], ); await conn.commit(); return { ok: true, costCents: 0, balanceCents: walletRows[0] ? Number(walletRows[0].balance_cents) : null, tokensUsed: walletRows[0] ? Number(walletRows[0].tokens_used ?? 0) : null, deltaInputTokens: 0, deltaOutputTokens: 0, }; } } // Placeholder insert so concurrent billers serialize on the same session row. await conn.query( `INSERT INTO h5_session_billing_state (agent_session_id, user_id, last_accumulated_cost, last_input_tokens, last_output_tokens, updated_at) VALUES (?, ?, NULL, 0, 0, ?) ON DUPLICATE KEY UPDATE agent_session_id = agent_session_id`, [agentSessionId, userId, now], ); const [stateRows] = await conn.query( `SELECT last_accumulated_cost, last_input_tokens, last_output_tokens FROM h5_session_billing_state WHERE agent_session_id = ? FOR UPDATE`, [agentSessionId], ); const stateRow = stateRows[0]; const previous = stateRow ? { lastAccumulatedCost: stateRow.last_accumulated_cost, lastInputTokens: Number(stateRow.last_input_tokens ?? 0), lastOutputTokens: Number(stateRow.last_output_tokens ?? 0), } : null; if ( previous && tokenState.accumulatedInputTokens <= Number(previous.lastInputTokens ?? 0) && tokenState.accumulatedOutputTokens <= Number(previous.lastOutputTokens ?? 0) ) { const [walletRows] = await conn.query( `SELECT balance_cents, tokens_used FROM h5_user_wallets WHERE user_id = ?`, [userId], ); await conn.commit(); return { ok: true, costCents: 0, balanceCents: walletRows[0] ? Number(walletRows[0].balance_cents) : null, tokensUsed: walletRows[0] ? Number(walletRows[0].tokens_used ?? 0) : null, deltaInputTokens: 0, deltaOutputTokens: 0, }; } let costCents = computeDeltaCostCents(previous, tokenState, config); const deltaIn = Math.max( 0, tokenState.accumulatedInputTokens - Number(previous?.lastInputTokens ?? 0), ); const deltaOut = Math.max( 0, tokenState.accumulatedOutputTokens - Number(previous?.lastOutputTokens ?? 0), ); const deltaTokens = deltaIn + deltaOut; await conn.query( `INSERT INTO h5_session_billing_state (agent_session_id, user_id, last_accumulated_cost, last_input_tokens, last_output_tokens, updated_at) VALUES (?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE last_accumulated_cost = VALUES(last_accumulated_cost), last_input_tokens = VALUES(last_input_tokens), last_output_tokens = VALUES(last_output_tokens), updated_at = VALUES(updated_at)`, [ agentSessionId, userId, tokenState.accumulatedCost, tokenState.accumulatedInputTokens, tokenState.accumulatedOutputTokens, now, ], ); // Subscription quota check: consume tokens from active plan before touching balance. if (costCents > 0 && subscriptionService) { const coverage = await subscriptionService.consumeQuota(userId, deltaTokens, conn); if (coverage.fullyCovers) { costCents = 0; } else if (coverage.overageRate < 1.0) { costCents = Math.max(1, Math.ceil(costCents * coverage.overageRate)); } } let balanceAfter = null; let tokensUsedAfter = null; if (costCents > 0) { const [walletRows] = await conn.query( `SELECT w.balance_cents, w.tokens_used, u.status FROM h5_user_wallets w JOIN h5_users u ON u.id = w.user_id WHERE w.user_id = ? FOR UPDATE`, [userId], ); const wallet = walletRows[0]; if (!wallet) { await conn.rollback(); return { ok: false, message: '钱包不存在', costCents: 0 }; } const currentBalance = Number(wallet.balance_cents ?? 0); const nextBalance = Math.max(0, currentBalance - costCents); balanceAfter = nextBalance; tokensUsedAfter = Number(wallet.tokens_used ?? 0) + deltaTokens; await conn.query( `UPDATE h5_user_wallets SET balance_cents = ?, tokens_used = tokens_used + ?, updated_at = ? WHERE user_id = ?`, [nextBalance, deltaTokens, now, userId], ); await conn.query( `INSERT INTO h5_usage_records (user_id, agent_session_id, request_id, input_tokens, output_tokens, cost_cents, balance_after_cents, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, [ userId, agentSessionId, normalizedRequestId, deltaIn, deltaOut, costCents, nextBalance, now, ], ); await conn.query( `INSERT INTO h5_billing_ledger (user_id, type, amount_cents, tokens, session_id, note, operator_id, created_at) VALUES (?, 'deduct', ?, ?, ?, ?, NULL, ?)`, [ userId, -costCents, deltaTokens, agentSessionId, `对话扣费 input=${deltaIn} output=${deltaOut}`, now, ], ); const lowBalanceGift = await grantLowBalanceGiftIfNeeded(conn, { userId, currentBalance, nextBalance, now, }); balanceAfter = lowBalanceGift.balanceAfter; if (balanceAfter <= 0) { await conn.query(`UPDATE h5_users SET status = 'suspended', updated_at = ? WHERE id = ?`, [ now, userId, ]); } } else { const user = await getUserById(userId); balanceAfter = user ? Number(user.balance_cents) : null; tokensUsedAfter = user ? Number(user.tokens_used ?? 0) : null; } await conn.commit(); return { ok: true, costCents, balanceCents: balanceAfter, tokensUsed: tokensUsedAfter, deltaInputTokens: deltaIn, deltaOutputTokens: deltaOut, }; } catch (err) { await conn.rollback(); throw err; } finally { conn.release(); } }; const listUsageRecords = async ({ userId = null, page = 1, pageSize = 20, limit = null } = {}) => { // legacy: if limit is passed (from dashboard summary), skip pagination if (limit !== null) { const safeLimit = Math.min(Math.max(Number(limit) || 50, 1), 200); const params = []; const where = userId ? 'WHERE r.user_id = ?' : ''; if (userId) params.push(userId); const [rows] = await pool.query( `SELECT r.id, r.user_id, u.username, r.agent_session_id, r.request_id, r.input_tokens, r.output_tokens, r.cost_cents, r.balance_after_cents, r.created_at FROM h5_usage_records r JOIN h5_users u ON u.id = r.user_id ${where} ORDER BY r.created_at DESC LIMIT ${safeLimit}`, params, ); return rows.map((row) => ({ id: Number(row.id), userId: row.user_id, username: row.username, agentSessionId: row.agent_session_id, requestId: row.request_id, inputTokens: Number(row.input_tokens), outputTokens: Number(row.output_tokens), costCents: Number(row.cost_cents), balanceAfterCents: Number(row.balance_after_cents), createdAt: Number(row.created_at) })); } const safePageSize = Math.min(Math.max(Number(pageSize) || 50, 1), 200); const safePage = Math.max(Number(page) || 1, 1); const offset = (safePage - 1) * safePageSize; const params = []; const where = userId ? 'WHERE r.user_id = ?' : ''; if (userId) params.push(userId); const [[{ total }]] = await pool.query( `SELECT COUNT(*) AS total FROM h5_usage_records r ${where}`, params, ); const [rows] = await pool.query( `SELECT r.id, r.user_id, u.username, r.agent_session_id, r.request_id, r.input_tokens, r.output_tokens, r.cost_cents, r.balance_after_cents, r.created_at FROM h5_usage_records r JOIN h5_users u ON u.id = r.user_id ${where} ORDER BY r.created_at DESC LIMIT ${safePageSize} OFFSET ${offset}`, params, ); return { records: rows.map((row) => ({ id: Number(row.id), userId: row.user_id, username: row.username, agentSessionId: row.agent_session_id, requestId: row.request_id, inputTokens: Number(row.input_tokens), outputTokens: Number(row.output_tokens), costCents: Number(row.cost_cents), balanceAfterCents: Number(row.balance_after_cents), createdAt: Number(row.created_at) })), total: Number(total), page: safePage, pageSize: safePageSize, }; }; const listBillingLedger = async ({ userId = null, page = 1, pageSize = 20, limit = null, types = null } = {}) => { const buildWhere = (params) => { const clauses = []; if (userId) { clauses.push('l.user_id = ?'); params.push(userId); } if (Array.isArray(types) && types.length) { clauses.push(`l.type IN (${types.map(() => '?').join(', ')})`); params.push(...types); } return clauses.length ? `WHERE ${clauses.join(' AND ')}` : ''; }; const mapRow = (row) => ({ id: Number(row.id), userId: row.user_id, username: row.username, type: row.type, amountCents: Number(row.amount_cents), tokens: Number(row.tokens), sessionId: row.session_id, note: row.note, createdAt: Number(row.created_at) }); // legacy: if limit is passed (from dashboard summary), skip pagination if (limit !== null) { const safeLimit = Math.min(Math.max(Number(limit) || 50, 1), 200); const params = []; const where = buildWhere(params); const [rows] = await pool.query(`SELECT l.id, l.user_id, u.username, l.type, l.amount_cents, l.tokens, l.session_id, l.note, l.created_at FROM h5_billing_ledger l JOIN h5_users u ON u.id = l.user_id ${where} ORDER BY l.created_at DESC LIMIT ${safeLimit}`, params); return rows.map(mapRow); } const safePageSize = Math.min(Math.max(Number(pageSize) || 50, 1), 200); const safePage = Math.max(Number(page) || 1, 1); const offset = (safePage - 1) * safePageSize; const countParams = []; const where = buildWhere(countParams); const [[{ total }]] = await pool.query( `SELECT COUNT(*) AS total FROM h5_billing_ledger l ${where}`, countParams, ); const dataParams = []; const dataWhere = buildWhere(dataParams); const [rows] = await pool.query( `SELECT l.id, l.user_id, u.username, l.type, l.amount_cents, l.tokens, l.session_id, l.note, l.created_at FROM h5_billing_ledger l JOIN h5_users u ON u.id = l.user_id ${dataWhere} ORDER BY l.created_at DESC LIMIT ${safePageSize} OFFSET ${offset}`, dataParams, ); return { entries: rows.map(mapRow), total: Number(total), page: safePage, pageSize: safePageSize }; }; const getAdminSummary = async () => { const since24h = Date.now() - 24 * 60 * 60 * 1000; const [userRows] = await pool.query( `SELECT u.id, u.username, u.display_name, u.role, u.status, COALESCE(w.balance_cents, 0) AS balance_cents FROM h5_users u LEFT JOIN h5_user_wallets w ON w.user_id = u.id`, ); let total = 0; let active = 0; let lowBalance = 0; let totalBalanceCents = 0; const lowBalanceUsers = []; for (const row of userRows) { total += 1; if (row.status === 'active') active += 1; const balanceCents = Number(row.balance_cents); totalBalanceCents += balanceCents; if (row.role === 'user' && balanceCents <= 0) { lowBalance += 1; if (lowBalanceUsers.length < 8) { lowBalanceUsers.push({ id: row.id, username: row.username, displayName: row.display_name, balanceCents, }); } } } const [[usage24h]] = await pool.query( `SELECT COUNT(*) AS count, COALESCE(SUM(cost_cents), 0) AS cost_cents FROM h5_usage_records WHERE created_at >= ?`, [since24h], ); const recentUsage = await listUsageRecords({ limit: 8 }); const recentLedger = await listBillingLedger({ limit: 8 }); return { users: { total, active, lowBalance, totalBalanceCents }, usage24h: { count: Number(usage24h.count), costCents: Number(usage24h.cost_cents), }, lowBalanceUsers, recentUsage, recentLedger, }; }; const syncAdminPassword = async () => { const adminUsername = normalizeUsername(process.env.H5_ADMIN_USERNAME ?? 'admin'); const adminPassword = process.env.H5_ADMIN_PASSWORD; if (!adminPassword) return; const [rows] = await pool.query( `SELECT id FROM h5_users WHERE username = ? AND role = 'admin' LIMIT 1`, [adminUsername], ); if (rows.length === 0) return; const { salt, passwordHash, passwordAlgorithm } = createPasswordRecord(adminPassword); const now = Date.now(); await pool.query( `UPDATE h5_users SET salt = ?, password_hash = ?, password_algorithm = ?, updated_at = ? WHERE id = ?`, [salt, passwordHash, passwordAlgorithm, now, rows[0].id], ); }; const seedRoleCapabilityDefaults = async () => { const now = Date.now(); for (const [key, allowed] of Object.entries(DEFAULT_USER_CAPABILITIES)) { await pool.query( `INSERT INTO h5_capability_grants (subject_type, subject_id, capability_key, allowed, updated_at) VALUES ('role', 'user', ?, ?, ?) ON DUPLICATE KEY UPDATE capability_key = capability_key`, [key, allowed ? 1 : 0, now], ); } }; /** Enable L3 memory_store for existing role defaults without touching per-user overrides. */ const upgradeMemoryStoreCapability = async () => { const now = Date.now(); await pool.query( `INSERT INTO h5_capability_grants (subject_type, subject_id, capability_key, allowed, updated_at) VALUES ('role', 'user', 'memory_store', 1, ?) ON DUPLICATE KEY UPDATE allowed = 1, updated_at = VALUES(updated_at)`, [now], ); }; /** Enable platform/web (web_search, fetch_url) for existing role defaults. */ const upgradeWebCapability = async () => { const now = Date.now(); await pool.query( `INSERT INTO h5_capability_grants (subject_type, subject_id, capability_key, allowed, updated_at) VALUES ('role', 'user', 'web', 1, ?) ON DUPLICATE KEY UPDATE allowed = 1, updated_at = VALUES(updated_at)`, [now], ); }; /** Enable platform skill loading + chat recall for existing role defaults. */ const upgradeDefaultUserCapabilities = async () => { const now = Date.now(); for (const key of ['skills', 'chat_recall']) { if (!DEFAULT_USER_CAPABILITIES[key]) continue; await pool.query( `INSERT INTO h5_capability_grants (subject_type, subject_id, capability_key, allowed, updated_at) VALUES ('role', 'user', ?, 1, ?) ON DUPLICATE KEY UPDATE allowed = 1, updated_at = VALUES(updated_at)`, [key, now], ); } }; /** Enable default platform skills for existing role defaults. */ const upgradeDefaultUserSkills = async () => { const now = Date.now(); for (const [name, enabled] of Object.entries(DEFAULT_USER_SKILLS)) { if (!enabled) continue; await pool.query( `INSERT INTO h5_user_skill_grants (subject_type, subject_id, skill_name, enabled, updated_at) VALUES ('role', 'user', ?, 1, ?) ON DUPLICATE KEY UPDATE enabled = 1, updated_at = VALUES(updated_at)`, [name, now], ); } }; const serializePolicyValue = (key, value) => { const def = POLICY_CATALOG.find((item) => item.key === key); if (def?.type === 'boolean') return value ? 'true' : 'false'; return String(value); }; const parsePolicyValue = (key, raw) => { const def = POLICY_CATALOG.find((item) => item.key === key); if (def?.type === 'boolean') return raw === 'true' || raw === '1'; return raw; }; const seedRolePolicyDefaults = async () => { const now = Date.now(); for (const [key, value] of Object.entries(DEFAULT_USER_POLICIES)) { await pool.query( `INSERT INTO h5_user_policies (subject_type, subject_id, policy_key, policy_value, updated_at) VALUES ('role', 'user', ?, ?, ?) ON DUPLICATE KEY UPDATE policy_key = policy_key`, [key, serializePolicyValue(key, value), now], ); } }; const listPolicyEntries = async (subjectType, subjectId) => { const [rows] = await pool.query( `SELECT policy_key, policy_value FROM h5_user_policies WHERE subject_type = ? AND subject_id = ?`, [subjectType, subjectId], ); return Object.fromEntries( rows.map((row) => [row.policy_key, parsePolicyValue(row.policy_key, row.policy_value)]), ); }; const resolveUserPolicies = async (user) => { if (!user || user.role === 'admin') { return { unrestricted: true, policies: {} }; } const roleDefaults = await listPolicyEntries('role', 'user'); const userOverrides = await listPolicyEntries('user', user.id); return { unrestricted: false, policies: resolvePolicies(roleDefaults, userOverrides), }; }; const listCapabilityGrants = async (subjectType, subjectId) => { const [rows] = await pool.query( `SELECT capability_key, allowed FROM h5_capability_grants WHERE subject_type = ? AND subject_id = ?`, [subjectType, subjectId], ); return Object.fromEntries( rows.map((row) => [row.capability_key, Boolean(row.allowed)]), ); }; const resolveUserCapabilities = async (user) => { if (!user) return { unrestricted: true, capabilities: {} }; if (user.role === 'admin') { const skillMap = Object.fromEntries(skillCatalog.map((item) => [item.name, true])); return { unrestricted: true, capabilities: Object.fromEntries(catalogKeys().map((key) => [key, true])), skills: skillMap, grantedSkills: grantedSkillNames(skillMap), }; } const roleDefaults = await listCapabilityGrants('role', 'user'); const userOverrides = await listCapabilityGrants('user', user.id); const capabilities = {}; for (const key of catalogKeys()) { if (key in userOverrides) { capabilities[key] = userOverrides[key]; } else if (key in roleDefaults) { capabilities[key] = roleDefaults[key]; } else { capabilities[key] = DEFAULT_USER_CAPABILITIES[key] ?? false; } } const skillMap = await resolveUserSkillMap(user); const withSkills = applySkillGrantsToCapabilities(capabilities, skillMap); return { unrestricted: false, capabilities: clampUserCapabilities(withSkills), skills: skillMap, grantedSkills: grantedSkillNames(skillMap), }; }; const getAgentSessionPolicy = async ( userId, { toolMode = 'chat', sessionId = null, packageId = null, } = {}, ) => { const user = await getUserById(userId); if (!user) throw new Error('用户不存在'); const capabilityState = await resolveUserCapabilities(user); const policyState = await resolveUserPolicies(user); const mindSearchConfig = getMindSearchConfig ? await getMindSearchConfig({ userId, user }) : null; await syncUserSkillsForUser(user); if (capabilityState.unrestricted) { return { ...buildAgentExtensionPolicy(capabilityState.capabilities, { unrestricted: true, toolMode, mindSearchConfig, userId: user.id, }), policies: {}, unrestricted: true, toolMode, }; } const effectiveCapabilities = applyPoliciesToCapabilities( capabilityState.capabilities, policyState.policies, ); // Wire up the sandbox MCP for user workspace tools and the private data space. // File and Excel operations are OS-bound to the user's workspace; // private_data_* tools only touch the user's isolated PostgreSQL schema. let sandboxMcp = null; if ( effectiveCapabilities.static_publish || effectiveCapabilities.private_data_space || effectiveCapabilities.excel_analysis ) { try { const workspaceCapability = resolveMindSpaceAgentWorkspaceCapability({ h5Root, env, user, }); const containerFlag = String(env.GOOSED_MCP_CONTAINERIZED ?? '').trim(); const containerized = containerFlag ? containerFlag === '1' : Boolean( env.GOOSED_MCP_NODE_PATH && path.resolve(env.GOOSED_MCP_NODE_PATH) !== path.resolve(process.execPath), ); sandboxMcp = { // When goosed runs in a container its filesystem is split from the portal's, // so the host paths the portal would otherwise send (node binary, MCP script) // are not resolvable. These env overrides let the portal send container-canonical // paths instead. Unset (e.g. local dev, co-located native goosed) => fall back to // the portal's own paths, so behavior is unchanged. serverPath: resolveSandboxMcpServerPath(env.GOOSED_MCP_SERVER_PATH), sandboxRoot: workspaceCapability.sandboxRoot, workspaceRoot: workspaceCapability.workspaceRoot, workspaceRef: workspaceCapability.workspaceRef, sessionId: String(sessionId ?? '').trim() || null, packageId: String(packageId ?? '').trim() || (String(sessionId ?? '').trim() ? `cp_${String( sessionId, ).trim()}` : null), mcpBaseUrl: env.MINDSPACE_MCP_BASE_URL ?? env.MINDSPACE_REMOTE_BASE_URL, mcpTokenSecret: env.MINDSPACE_MCP_TOKEN_SECRET, userId: user.id, nodeExecPath: env.GOOSED_MCP_NODE_PATH, containerized, userDataBackend: env.MINDSPACE_USERDATA_BACKEND, userDataPgUrl: env.MINDSPACE_USERDATA_PG_URL, userDataMcpPgUrl: env.MINDSPACE_USERDATA_MCP_PG_URL, userDataPgHostGateway: env.MINDSPACE_USERDATA_MCP_PG_HOST, userDataAutoProvision: env.MINDSPACE_USERDATA_AUTO_PROVISION, agentApiBaseUrl: env.MINDSPACE_AGENT_API_BASE_URL, publicBaseUrl: env.H5_PUBLIC_BASE_URL, portalBaseUrl: env.H5_PORTAL_BASE_URL, portalPort: env.H5_PORT, pageDataDeliveryBaseUrl: env.MEMIND_PAGE_DATA_DELIVERY_BASE_URL, internalAgentSecret: env.MINDSPACE_INTERNAL_AGENT_SECRET ?? env.TKMIND_SERVER__SECRET_KEY, }; } catch (err) { console.warn('[getAgentSessionPolicy] sandbox MCP setup failed, falling back:', err?.message); } } return { ...buildAgentExtensionPolicy(effectiveCapabilities, { unrestricted: false, policies: policyState.policies, sandboxMcp, toolMode, mindSearchConfig, userId: user.id, }), capabilities: effectiveCapabilities, policies: policyState.policies, unrestricted: false, toolMode, }; }; const getCodeAgentSessionPolicy = async ( userId, scope = {}, ) => getAgentSessionPolicy(userId, { ...scope, toolMode: 'code', }); const getRoleCapabilities = async (role = 'user') => { const roleDefaults = await listCapabilityGrants('role', role); const capabilities = {}; for (const key of catalogKeys()) { capabilities[key] = key in roleDefaults ? roleDefaults[key] : (DEFAULT_USER_CAPABILITIES[key] ?? false); } return { role, capabilities: clampUserCapabilities(capabilities) }; }; const setRoleCapabilities = async (role, patch) => { if (role !== 'user') { return { ok: false, message: '仅支持配置普通用户角色默认权限' }; } const normalized = normalizeCapabilityPatch(patch); const now = Date.now(); for (const [key, allowed] of Object.entries(normalized)) { const effectiveAllowed = USER_NON_GRANTABLE_CAPABILITIES.has(key) ? false : allowed; await pool.query( `INSERT INTO h5_capability_grants (subject_type, subject_id, capability_key, allowed, updated_at) VALUES ('role', ?, ?, ?, ?) ON DUPLICATE KEY UPDATE allowed = VALUES(allowed), updated_at = VALUES(updated_at)`, [role, key, effectiveAllowed ? 1 : 0, now], ); } return { ok: true, ...(await getRoleCapabilities(role)) }; }; const getUserCapabilities = async (userId) => { const user = await getUserById(userId); if (!user) return { ok: false, message: '用户不存在' }; const resolved = await resolveUserCapabilities(user); const overrides = await listCapabilityGrants('user', userId); return { ok: true, userId, role: user.role, unrestricted: resolved.unrestricted, capabilities: resolved.capabilities, overrides, }; }; const setUserCapabilities = async (userId, patch) => { const user = await getUserById(userId); if (!user) return { ok: false, message: '用户不存在' }; if (user.role === 'admin') { return { ok: false, message: '管理员不受能力限制' }; } const normalized = normalizeCapabilityPatch(patch); const now = Date.now(); for (const [key, allowed] of Object.entries(normalized)) { if (!isValidCapabilityKey(key)) continue; const effectiveAllowed = USER_NON_GRANTABLE_CAPABILITIES.has(key) ? false : allowed; await pool.query( `INSERT INTO h5_capability_grants (subject_type, subject_id, capability_key, allowed, updated_at) VALUES ('user', ?, ?, ?, ?) ON DUPLICATE KEY UPDATE allowed = VALUES(allowed), updated_at = VALUES(updated_at)`, [userId, key, effectiveAllowed ? 1 : 0, now], ); } return getUserCapabilities(userId); }; const clearUserCapabilityOverrides = async (userId) => { await pool.query( `DELETE FROM h5_capability_grants WHERE subject_type = 'user' AND subject_id = ?`, [userId], ); return getUserCapabilities(userId); }; const getRolePolicies = async (role = 'user') => { const roleDefaults = await listPolicyEntries('role', role); const policies = resolvePolicies(roleDefaults, {}); return { role, policies }; }; const setRolePolicies = async (role, patch) => { if (role !== 'user') { return { ok: false, message: '仅支持配置普通用户角色默认策略' }; } const normalized = normalizePolicyPatch(patch); const now = Date.now(); for (const [key, value] of Object.entries(normalized)) { await pool.query( `INSERT INTO h5_user_policies (subject_type, subject_id, policy_key, policy_value, updated_at) VALUES ('role', ?, ?, ?, ?) ON DUPLICATE KEY UPDATE policy_value = VALUES(policy_value), updated_at = VALUES(updated_at)`, [role, key, serializePolicyValue(key, value), now], ); } return { ok: true, ...(await getRolePolicies(role)) }; }; const getUserPolicies = async (userId) => { const user = await getUserById(userId); if (!user) return { ok: false, message: '用户不存在' }; const policyState = await resolveUserPolicies(user); const overrides = await listPolicyEntries('user', userId); return { ok: true, userId, role: user.role, unrestricted: policyState.unrestricted, policies: policyState.policies, overrides, }; }; const setUserPolicies = async (userId, patch) => { const user = await getUserById(userId); if (!user) return { ok: false, message: '用户不存在' }; if (user.role === 'admin') { return { ok: false, message: '管理员不受策略限制' }; } const normalized = normalizePolicyPatch(patch); const now = Date.now(); for (const [key, value] of Object.entries(normalized)) { if (!policyKeys().includes(key)) continue; await pool.query( `INSERT INTO h5_user_policies (subject_type, subject_id, policy_key, policy_value, updated_at) VALUES ('user', ?, ?, ?, ?) ON DUPLICATE KEY UPDATE policy_value = VALUES(policy_value), updated_at = VALUES(updated_at)`, [userId, key, serializePolicyValue(key, value), now], ); } return getUserPolicies(userId); }; const clearUserPolicyOverrides = async (userId) => { await pool.query(`DELETE FROM h5_user_policies WHERE subject_type = 'user' AND subject_id = ?`, [ userId, ]); return getUserPolicies(userId); }; const getRoleSkills = async (role = 'user') => { const roleDefaults = await listSkillGrants('role', role); const skills = resolveSkillMap(roleDefaults, {}, skillCatalog); return { role, skills }; }; const setRoleSkills = async (role, patch) => { if (role !== 'user') { return { ok: false, message: '仅支持配置普通用户角色默认技能' }; } const normalized = normalizeSkillPatch(skillCatalog, patch); const now = Date.now(); for (const [name, enabled] of Object.entries(normalized)) { await pool.query( `INSERT INTO h5_user_skill_grants (subject_type, subject_id, skill_name, enabled, updated_at) VALUES ('role', ?, ?, ?, ?) ON DUPLICATE KEY UPDATE enabled = VALUES(enabled), updated_at = VALUES(updated_at)`, [role, name, enabled ? 1 : 0, now], ); } const [users] = await pool.query(`SELECT id, username, role, workspace_root FROM h5_users WHERE role = 'user'`); for (const row of users) { await syncUserSkillsForUser(row); } return { ok: true, ...(await getRoleSkills(role)) }; }; const getUserSkills = async (userId) => { const user = await getUserById(userId); if (!user) return { ok: false, message: '用户不存在' }; const skills = await resolveUserSkillMap(user); const overrides = await listSkillGrants('user', userId); return { ok: true, userId, role: user.role, skills, grantedSkills: grantedSkillNames(skills), overrides, }; }; const setUserSkills = async (userId, patch) => { const user = await getUserById(userId); if (!user) return { ok: false, message: '用户不存在' }; if (user.role === 'admin') { return { ok: false, message: '管理员不受技能限制' }; } const normalized = normalizeSkillPatch(skillCatalog, patch); const now = Date.now(); for (const [name, enabled] of Object.entries(normalized)) { await pool.query( `INSERT INTO h5_user_skill_grants (subject_type, subject_id, skill_name, enabled, updated_at) VALUES ('user', ?, ?, ?, ?) ON DUPLICATE KEY UPDATE enabled = VALUES(enabled), updated_at = VALUES(updated_at)`, [userId, name, enabled ? 1 : 0, now], ); } await syncUserSkillsForUser(user); return getUserSkills(userId); }; const clearUserSkillOverrides = async (userId) => { await pool.query(`DELETE FROM h5_user_skill_grants WHERE subject_type = 'user' AND subject_id = ?`, [ userId, ]); const user = await getUserById(userId); if (user) await syncUserSkillsForUser(user); return getUserSkills(userId); }; const ensureAdminUser = async () => { const adminUsername = normalizeUsername(process.env.H5_ADMIN_USERNAME ?? 'admin'); const adminPassword = process.env.H5_ADMIN_PASSWORD; const [rows] = await pool.query(`SELECT id FROM h5_users WHERE username = ? LIMIT 1`, [ adminUsername, ]); if (rows.length === 0) { if (!adminPassword) return; await createUser({ username: adminUsername, password: adminPassword, displayName: '管理员', balanceCents: 9_999_999_99, role: 'admin', }); } else { const adminId = rows[0].id; const now = Date.now(); const adminLayout = await publishLayoutFor({ id: adminId, username: adminUsername, displayName: '管理员', }); await pool.query(`UPDATE h5_users SET workspace_root = ?, updated_at = ? WHERE id = ?`, [ adminLayout.publishDir, now, adminId, ]); await pool.query(`DELETE FROM h5_user_path_grants WHERE user_id = ?`, [adminId]); await pool.query( `INSERT INTO h5_user_path_grants (user_id, path, mode) VALUES (?, ?, 'readwrite')`, [adminId, adminLayout.publishDir], ); await pool.query( `UPDATE h5_user_wallets SET balance_cents = GREATEST(balance_cents, ?), updated_at = ? WHERE user_id = ?`, [9_999_999_99, now, adminId], ); } if (adminPassword) { await syncAdminPassword(); } await seedRoleCapabilityDefaults(); await upgradeMemoryStoreCapability(); await upgradeWebCapability(); await upgradeDefaultUserCapabilities(); await seedRolePolicyDefaults(); await seedRoleSkillDefaults(); await upgradeDefaultUserSkills(); await repairAllUserPublishDirs(); }; const PENDING_BIND_TTL_MS = 15 * 60 * 1000; const issueUserSession = async (userId, role, now = Date.now()) => { const token = crypto.randomBytes(32).toString('base64url'); await storeSession(userId, role, token, now); return token; }; const findBindingByOpenid = async (appId, openid) => { const [rows] = await pool.query( `SELECT wi.user_id, u.status FROM h5_user_wechat_identities wi JOIN h5_users u ON u.id = wi.user_id WHERE wi.app_id = ? AND wi.openid = ? LIMIT 1`, [appId, openid], ); return rows[0] ?? null; }; const findBindingByUnionid = async (unionid) => { if (!unionid) return null; const [rows] = await pool.query( `SELECT wi.user_id, wi.app_id, u.status FROM h5_user_wechat_identities wi JOIN h5_users u ON u.id = wi.user_id WHERE wi.unionid = ? LIMIT 1`, [unionid], ); return rows[0] ?? null; }; const getWechatBindingForUser = async (userId, appId) => { const [rows] = await pool.query( `SELECT id, nickname, avatar_url, last_login_at, created_at FROM h5_user_wechat_identities WHERE user_id = ? AND app_id = ? LIMIT 1`, [userId, appId], ); return rows[0] ?? null; }; const getWechatOpenidForUser = async (userId, appId) => { const [rows] = await pool.query( `SELECT openid FROM h5_user_wechat_identities WHERE user_id = ? AND app_id = ? LIMIT 1`, [userId, appId], ); return rows[0]?.openid ?? null; }; const findWechatUserByOpenid = async (appId, openid) => { const [rows] = await pool.query( `SELECT wi.user_id, wi.nickname, u.username, u.slug, u.display_name, u.status FROM h5_user_wechat_identities wi JOIN h5_users u ON u.id = wi.user_id WHERE wi.app_id = ? AND wi.openid = ? LIMIT 1`, [appId, openid], ); return rows[0] ? { userId: rows[0].user_id, status: rows[0].status, nickname: rows[0].nickname, username: rows[0].username, slug: rows[0].slug, displayName: rows[0].display_name, } : null; }; const getWechatAgentRoute = async (appId, openid) => { const [rows] = await pool.query( `SELECT id, user_id, agent_session_id, status, created_at, updated_at FROM h5_wechat_agent_routes WHERE app_id = ? AND openid = ? LIMIT 1`, [appId, openid], ); const row = rows[0]; if (!row) return null; return { id: row.id, userId: row.user_id, agentSessionId: row.agent_session_id, status: row.status, createdAt: Number(row.created_at ?? 0), updatedAt: Number(row.updated_at ?? 0), }; }; const upsertWechatAgentRoute = async ({ userId, appId, openid, agentSessionId, status = 'active', now = Date.now(), }) => { const id = crypto.randomUUID(); await pool.query( `INSERT INTO h5_wechat_agent_routes (id, user_id, app_id, openid, agent_session_id, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE user_id = VALUES(user_id), agent_session_id = VALUES(agent_session_id), status = VALUES(status), updated_at = VALUES(updated_at)`, [id, userId, appId, openid, agentSessionId, status, now, now], ); const route = await getWechatAgentRoute(appId, openid); return route?.id ?? id; }; const clearWechatAgentRoute = async (appId, openid) => { await pool.query(`DELETE FROM h5_wechat_agent_routes WHERE app_id = ? AND openid = ?`, [ appId, openid, ]); }; const touchWechatAgentRoute = async (appId, openid, now = Date.now()) => { if (!appId || !openid) return; await pool.query( `UPDATE h5_wechat_agent_routes SET updated_at = ? WHERE app_id = ? AND openid = ?`, [now, appId, openid], ); }; const countWechatAgentSessionMessages = async ({ appId, openid, agentSessionId, }) => { if (!appId || !openid || !agentSessionId) return 0; const [rows] = await pool.query( `SELECT COUNT(*) AS count FROM h5_wechat_mp_messages WHERE app_id = ? AND openid = ? AND agent_session_id = ?`, [appId, openid, agentSessionId], ); return Number(rows?.[0]?.count ?? 0); }; const getWechatAgentSessionSnapshotMessageCount = async (agentSessionId) => { if (!agentSessionId) return 0; const [rows] = await pool.query( `SELECT synced_msg_count FROM h5_session_snapshots WHERE agent_session_id = ? LIMIT 1`, [agentSessionId], ); return Number(rows?.[0]?.synced_msg_count ?? 0); }; const recordWechatMpMessage = async ({ appId, openid, msgId, now = Date.now(), }) => { if (!appId || !openid || !msgId) return { inserted: true }; const [result] = await pool.query( `INSERT IGNORE INTO h5_wechat_mp_messages (app_id, openid, msg_id, status, created_at, updated_at) VALUES (?, ?, ?, 'processing', ?, ?)`, [appId, openid, String(msgId), now, now], ); if (Number(result?.affectedRows ?? 0) > 0) return { inserted: true }; const retryCutoff = now - 10 * 60 * 1000; const [retryResult] = await pool.query( `UPDATE h5_wechat_mp_messages SET status = 'processing', agent_session_id = NULL, updated_at = ? WHERE app_id = ? AND openid = ? AND msg_id = ? AND (status = 'failed' OR (status = 'processing' AND updated_at < ?))`, [now, appId, openid, String(msgId), retryCutoff], ); return { inserted: Number(retryResult?.affectedRows ?? 0) > 0, duplicate: Number(retryResult?.affectedRows ?? 0) === 0, }; }; const finishWechatMpMessage = async ({ appId, openid, msgId, status = 'done', agentSessionId = null, now = Date.now(), }) => { if (!appId || !openid || !msgId) return; const safeStatus = status === 'failed' ? 'failed' : 'done'; await pool.query( `UPDATE h5_wechat_mp_messages SET status = ?, agent_session_id = COALESCE(?, agent_session_id), updated_at = ? WHERE app_id = ? AND openid = ? AND msg_id = ?`, [safeStatus, agentSessionId, now, appId, openid, String(msgId)], ); }; const insertWechatMpMessageDetail = async ({ appId, openid, userId = null, msgId = null, msgType, displayText = '', agentText = '', mediaId = null, mediaUrl = null, mediaPublicUrl = null, mediaFormat = null, locationLat = null, locationLng = null, locationLabel = null, linkUrl = null, linkTitle = null, rawXmlHash = null, rawJson = null, now = Date.now(), }) => { if (!appId || !openid || !msgType) return null; const id = crypto.randomUUID(); await pool.query( `INSERT INTO h5_wechat_mp_message_details (id, app_id, openid, user_id, msg_id, msg_type, display_text, agent_text, media_id, media_url, media_public_url, media_format, location_lat, location_lng, location_label, link_url, link_title, raw_xml_hash, raw_json, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [ id, appId, openid, userId, msgId ? String(msgId) : null, String(msgType), displayText || null, agentText || null, mediaId, mediaUrl, mediaPublicUrl, mediaFormat, locationLat, locationLng, locationLabel, linkUrl, linkTitle, rawXmlHash, rawJson ? JSON.stringify(rawJson) : null, now, ], ); return id; }; const bindWechatToUser = async ({ userId, appId, openid, unionid, nickname, avatarUrl, now = Date.now(), }) => { const existingOpenid = await findBindingByOpenid(appId, openid); if (existingOpenid && existingOpenid.user_id !== userId) { return { ok: false, message: '该微信已绑定其他账号,请先用该账号登录' }; } const existingUserBind = await getWechatBindingForUser(userId, appId); if (existingUserBind) { return { ok: false, message: '你的账号已绑定其他微信,需先解绑后再试' }; } try { await pool.query( `INSERT INTO h5_user_wechat_identities (id, user_id, app_id, openid, unionid, nickname, avatar_url, last_login_at, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [ crypto.randomUUID(), userId, appId, openid, unionid, nickname, avatarUrl, now, now, now, ], ); return { ok: true }; } catch (err) { if (err?.code === 'ER_DUP_ENTRY') { return { ok: false, message: '微信绑定冲突,请重试' }; } throw err; } }; const touchWechatIdentity = async ({ appId, openid, unionid, nickname, avatarUrl, now = Date.now(), }) => { await pool.query( `UPDATE h5_user_wechat_identities SET nickname = COALESCE(?, nickname), avatar_url = COALESCE(?, avatar_url), unionid = COALESCE(?, unionid), last_login_at = ?, updated_at = ? WHERE app_id = ? AND openid = ?`, [nickname, avatarUrl, unionid, now, now, appId, openid], ); }; const pruneWechatPendingBinds = async (now = Date.now()) => { await pool.query(`DELETE FROM h5_wechat_pending_binds WHERE expires_at <= ?`, [now]); }; const createWechatPendingBind = async ({ appId, openid, unionid, nickname, avatarUrl, returnTo = '/', utmSource = null, utmMedium = null, utmCampaign = null, now = Date.now(), }) => { await pruneWechatPendingBinds(now); const token = crypto.randomBytes(24).toString('base64url'); await pool.query( `INSERT INTO h5_wechat_pending_binds (token, app_id, openid, unionid, nickname, avatar_url, return_to, utm_source, utm_medium, utm_campaign, expires_at, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [ token, appId, openid, unionid, nickname, avatarUrl, returnTo, utmSource, utmMedium, utmCampaign, now + PENDING_BIND_TTL_MS, now, ], ); return token; }; const getWechatPendingBind = async (token, now = Date.now()) => { if (!token) return null; await pruneWechatPendingBinds(now); const [rows] = await pool.query( `SELECT token, app_id, openid, unionid, nickname, avatar_url, return_to, utm_source, utm_medium, utm_campaign, expires_at FROM h5_wechat_pending_binds WHERE token = ? LIMIT 1`, [token], ); const row = rows[0]; if (!row || Number(row.expires_at) <= now) return null; return row; }; const consumeWechatPendingBind = async (token) => { await pool.query(`DELETE FROM h5_wechat_pending_binds WHERE token = ?`, [token]); }; const loginBoundWechatUser = async ({ userId, appId, openid, unionid, nickname, avatarUrl, now = Date.now(), }) => { await touchWechatIdentity({ appId, openid, unionid, nickname, avatarUrl, now }); const user = await getUserById(userId); if (!user || user.status === 'disabled') { return { ok: false, message: '账户已禁用,请联系管理员' }; } const token = await issueUserSession(user.id, user.role, now); return { ok: true, token, user: publicUser(user), isNewUser: false }; }; const generateWechatUsername = async (openid) => { const cleaned = String(openid).replace(/[^a-zA-Z0-9]/g, '').toLowerCase(); const suffix = cleaned.slice(-8) || crypto.randomBytes(4).toString('hex'); let candidate = `wx_${suffix}`.slice(0, 32); if (!isValidUsername(candidate)) { candidate = `wx_${crypto.randomBytes(4).toString('hex')}`; } for (let attempt = 0; attempt < 8; attempt += 1) { const [rows] = await pool.query(`SELECT id FROM h5_users WHERE username = ? LIMIT 1`, [ candidate, ]); if (!rows[0]) return candidate; candidate = `wx_${suffix.slice(0, Math.max(1, 8 - attempt))}${crypto.randomBytes(2).toString('hex')}`.slice( 0, 32, ); } return `wx_${crypto.randomBytes(6).toString('hex')}`.slice(0, 32); }; const registerViaWechat = async ({ appId, openid, unionid, nickname, avatarUrl, now = Date.now(), }) => { const normalized = await generateWechatUsername(openid); const randomPassword = crypto.randomBytes(24).toString('base64url'); const { salt, passwordHash, passwordAlgorithm } = createPasswordRecord(randomPassword); const userId = crypto.randomUUID(); const layout = await publishLayoutFor({ id: userId, username: normalized }); const workspaceRoot = layout.publishDir; const displayName = nickname?.trim() || '微信用户'; const conn = await pool.getConnection(); try { await conn.beginTransaction(); await conn.query( `INSERT INTO h5_users (id, username, slug, email, display_name, salt, password_hash, password_algorithm, role, status, plan_type, workspace_root, signup_source, low_balance_gift_eligible, low_balance_gift_granted_at, created_at, updated_at) VALUES (?, ?, ?, NULL, ?, ?, ?, ?, 'user', 'active', 'free', ?, 'wechat', 1, NULL, ?, ?)`, [ userId, normalized, normalized, displayName, salt, passwordHash, passwordAlgorithm, workspaceRoot, now, now, ], ); await conn.query( `INSERT INTO h5_user_wallets (user_id, balance_cents, tokens_used, updated_at) VALUES (?, ?, 0, ?)`, [userId, defaultSignupBalanceCents, now], ); await recordSignupBonus(conn, userId, defaultSignupBalanceCents, now); await conn.query( `INSERT INTO h5_user_path_grants (user_id, path, mode) VALUES (?, ?, 'readwrite')`, [userId, workspaceRoot], ); await conn.query( `INSERT INTO h5_user_wechat_identities (id, user_id, app_id, openid, unionid, nickname, avatar_url, last_login_at, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [ crypto.randomUUID(), userId, appId, openid, unionid, nickname, avatarUrl, now, now, now, ], ); await initializeDefaultSpace(conn, userId, { quotaBytes: Number(process.env.MINDSPACE_FREE_QUOTA_BYTES ?? 5 * 1024 * 1024), now, }); await ensureUserDataSpaceForUser({ id: userId, role: 'user' }, workspaceRoot); await conn.commit(); ensureWorkspace(workspaceRoot); ensureUserMemoryProfile(workspaceRoot, { userId, displayName, username: normalized, slug: normalized, }); if (subscriptionService) { subscriptionService.grantSubscription(userId, 'free', null, null, '注册赠送免费套餐').catch(() => {}); } const user = await getUserById(userId); return { ok: true, user: publicUser(user) }; } catch (err) { await conn.rollback(); if (err?.code === 'ER_DUP_ENTRY') { return { ok: false, message: '微信账号注册冲突,请重试' }; } throw err; } finally { conn.release(); } }; const resolveWechatAuth = async ({ appId, openid, unionid, nickname, avatarUrl, intent = 'login', bindUserId = null, returnTo = '/', utmSource = null, utmMedium = null, utmCampaign = null, now = Date.now(), }) => { let binding = await findBindingByOpenid(appId, openid); if (!binding && unionid) { const unionBinding = await findBindingByUnionid(unionid); if (unionBinding) { const linked = await bindWechatToUser({ userId: unionBinding.user_id, appId, openid, unionid, nickname, avatarUrl, now, }); if (!linked.ok) return linked; binding = { user_id: unionBinding.user_id, status: unionBinding.status }; } } if (binding) { if (binding.status === 'disabled') { return { ok: false, message: '账户已禁用,请联系管理员' }; } return loginBoundWechatUser({ userId: binding.user_id, appId, openid, unionid, nickname, avatarUrl, now, }).then((result) => (result.ok ? { ...result, action: 'login' } : result)); } if (intent === 'bind' && bindUserId) { const user = await getUserById(bindUserId); if (!user) return { ok: false, message: '用户不存在' }; if (user.status === 'disabled') { return { ok: false, message: '账户已禁用,请联系管理员' }; } const bound = await bindWechatToUser({ userId: bindUserId, appId, openid, unionid, nickname, avatarUrl, now, }); if (!bound.ok) return bound; const token = await issueUserSession(user.id, user.role, now); return { ok: true, action: 'login', token, user: publicUser(user), isNewUser: false, bound: true, }; } if (intent === 'register') { const registered = await registerViaWechat({ appId, openid, unionid, nickname, avatarUrl, now, }); if (!registered.ok) return registered; const token = await issueUserSession(registered.user.id, registered.user.role, now); return { ok: true, action: 'login', token, user: registered.user, isNewUser: true, }; } const pendingToken = await createWechatPendingBind({ appId, openid, unionid, nickname, avatarUrl, returnTo, utmSource, utmMedium, utmCampaign, now, }); return { ok: true, action: 'binding_gate', pendingToken, wechatProfile: { nickname: nickname ?? null, avatarUrl: avatarUrl ?? null, }, returnTo, utmSource, utmMedium, utmCampaign, }; }; const completeWechatRegister = async ({ pendingToken, now = Date.now() }) => { const pending = await getWechatPendingBind(pendingToken, now); if (!pending) { return { ok: false, message: '绑定会话已过期,请重新微信登录' }; } const registered = await registerViaWechat({ appId: pending.app_id, openid: pending.openid, unionid: pending.unionid, nickname: pending.nickname, avatarUrl: pending.avatar_url, now, }); if (!registered.ok) return registered; await consumeWechatPendingBind(pendingToken); const token = await issueUserSession(registered.user.id, registered.user.role, now); return { ok: true, token, user: registered.user, isNewUser: true, returnTo: pending.return_to || '/', utmSource: pending.utm_source, utmMedium: pending.utm_medium, utmCampaign: pending.utm_campaign, }; }; const completeWechatBindAccount = async ({ pendingToken, username, password, ip = 'unknown', now = Date.now(), }) => { const pending = await getWechatPendingBind(pendingToken, now); if (!pending) { return { ok: false, message: '绑定会话已过期,请重新微信登录' }; } const loginResult = await login({ username, password, ip, now }); if (!loginResult.ok) return loginResult; const bound = await bindWechatToUser({ userId: loginResult.user.id, appId: pending.app_id, openid: pending.openid, unionid: pending.unionid, nickname: pending.nickname, avatarUrl: pending.avatar_url, now, }); if (!bound.ok) return bound; await consumeWechatPendingBind(pendingToken); return { ok: true, token: loginResult.token, user: loginResult.user, isNewUser: false, bound: true, returnTo: pending.return_to || '/', }; }; const getWechatBindingStatus = async (userId, appId) => { const row = await getWechatBindingForUser(userId, appId); if (!row) return { bound: false }; return { bound: true, nickname: row.nickname, avatarUrl: row.avatar_url, lastLoginAt: Number(row.last_login_at), boundAt: Number(row.created_at), }; }; const loginByWechat = async (params) => { const result = await resolveWechatAuth({ ...params, intent: 'login' }); if (!result.ok) return result; if (result.action === 'binding_gate') { return { ok: false, message: '需要完成账号绑定' }; } return result; }; const loginByWechatMiniProgram = async ({ appId, openid, unionid, now = Date.now(), }) => { const existing = await findWechatUserByOpenid(appId, openid); if (existing) { if (existing.status === 'disabled') { return { ok: false, message: '账户已禁用,请联系管理员' }; } return loginBoundWechatUser({ userId: existing.userId, appId, openid, unionid, nickname: existing.nickname, avatarUrl: null, now, }); } const registered = await registerViaWechat({ appId, openid, unionid, nickname: '微信用户', avatarUrl: null, now, }); if (!registered.ok) return registered; const token = await issueUserSession(registered.user.id, registered.user.role, now); return { ok: true, token, user: registered.user, isNewUser: true, }; }; return { USER_COOKIE, register, login, loginByWechat, loginByWechatMiniProgram, resolveWechatAuth, completeWechatRegister, completeWechatBindAccount, getWechatPendingBind, getWechatBindingStatus, getWechatOpenidForUser, setRechargeNotifier(callback) { rechargeNotifier = typeof callback === 'function' ? callback : null; }, findWechatUserByOpenid, getWechatAgentRoute, upsertWechatAgentRoute, clearWechatAgentRoute, touchWechatAgentRoute, countWechatAgentSessionMessages, getWechatAgentSessionSnapshotMessageCount, recordWechatMpMessage, finishWechatMpMessage, insertWechatMpMessageDetail, resetPassword, verify, revoke, revokeAllSessionsForUser, getMe, listPathGrants, resolveWorkingDir, getUserPublishLayout, isPathAllowed, repairAllUserPublishDirs, ensureAllUserDataSpaces, registerAgentSession, getSessionNode, getSessionTarget, unregisterAgentSession, ownsSession, listOwnedSessionIds, setSessionOrigin, getSessionOrigins, canUseChat, getUserById, getUserPublic, listUsers, createUser, updateUser, purchaseSpaceQuota, recharge, getBillingState, billSessionUsage, listUsageRecords, listBillingLedger, getAdminSummary, ensureAdminUser, seedRoleCapabilityDefaults, resolveUserCapabilities, getAgentSessionPolicy, getCodeAgentSessionPolicy, getRoleCapabilities, setRoleCapabilities, getUserCapabilities, setUserCapabilities, clearUserCapabilityOverrides, resolveUserPolicies, getRolePolicies, setRolePolicies, getUserPolicies, setUserPolicies, clearUserPolicyOverrides, getRoleSkills, setRoleSkills, getUserSkills, setUserSkills, clearUserSkillOverrides, syncUserSkillsForUser, capabilityCatalog: CAPABILITY_CATALOG, policyCatalog: POLICY_CATALOG, skillCatalog, publicUser, }; } function buildUserSessionCookie(token, secure, { domain, maxAge }) { const parts = [ `${USER_COOKIE}=${encodeURIComponent(token)}`, 'Path=/', 'HttpOnly', 'SameSite=Lax', `Max-Age=${maxAge}`, ]; if (domain) parts.push(`Domain=${domain}`); if (secure) parts.push('Secure'); return parts.join('; '); } export function userSessionCookie(token, secure, domain = resolveCookieDomain()) { return buildUserSessionCookie(token, secure, { domain, maxAge: 7 * 24 * 60 * 60, }); } export function clearUserSessionCookie(secure, domain = resolveCookieDomain()) { return buildUserSessionCookie('', secure, { domain, maxAge: 0 }); } /** Mirror userLoginCookies: clear shared-domain and legacy host-only session cookies. */ export function clearUserLogoutCookies(secure, domain = resolveCookieDomain()) { const cookies = [clearUserSessionCookie(secure, domain)]; if (domain) { cookies.push(clearUserSessionCookie(secure, null)); } return cookies; } /** Set shared-domain session and drop legacy host-only cookie from before H5_COOKIE_DOMAIN. */ export function userLoginCookies(token, secure, domain = resolveCookieDomain()) { const cookies = [userSessionCookie(token, secure, domain)]; if (domain) { cookies.push(clearUserSessionCookie(secure, null)); } return cookies; } /** Share login cookie across MindSpace + Plaza subdomains (e.g. .tkmind.cn). */ export function resolveCookieDomain() { const explicit = String(process.env.H5_COOKIE_DOMAIN ?? '').trim(); if (explicit) return explicit; try { const base = String(process.env.H5_PUBLIC_BASE_URL ?? '').trim(); if (!base) return null; const hostname = new URL(base).hostname.toLowerCase(); if (hostname === 'localhost' || hostname.endsWith('.localhost')) { return '.localhost'; } if (hostname === 'tkmind.cn' || hostname.endsWith('.tkmind.cn')) { return '.tkmind.cn'; } } catch { // ignore invalid base URL } return null; } /** 本地 localhost 开发时不设置 Domain,否则浏览器不会保存跨域 cookie。 */ export function resolveCookieDomainForRequest(req) { const explicit = String(process.env.H5_COOKIE_DOMAIN ?? '').trim(); if (explicit) return explicit; const hostCandidates = [ req?.get?.('x-forwarded-host'), req?.get?.('host'), req?.hostname, ]; const origin = req?.get?.('origin'); if (origin) { try { hostCandidates.push(new URL(origin).host); } catch { // ignore invalid origin } } for (const raw of hostCandidates) { const hostname = String(raw ?? '').split(':')[0].toLowerCase(); if (hostname.endsWith('.localhost')) { return '.localhost'; } if ( hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1' || net.isIP(hostname) ) { return null; } } const hostname = String(req?.get?.('host') ?? req?.hostname ?? '') .split(':')[0] .toLowerCase(); if ( !hostname || hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1' || net.isIP(hostname) ) { return null; } return resolveCookieDomain(); }