Files
memind/user-auth.mjs
T
2026-07-24 09:40:58 +08:00

3179 lines
106 KiB
JavaScript

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' } = {}) => {
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,
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,
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) =>
getAgentSessionPolicy(userId, { 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();
}