7f8d692d16
Fix DEV logout cookie clearing, materialize selected MindSpace OA assets before agent runs, and recover zombie runs from synced workspace pages. Add client run wait timeout, harness retry limits, page-edit asset forwarding, and logout/john2 scenario tests. Co-authored-by: Cursor <cursoragent@cursor.com>
3062 lines
102 KiB
JavaScript
3062 lines
102 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 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 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 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'`,
|
|
);
|
|
for (const row of rows) {
|
|
await syncUserSkillsForUser(row);
|
|
}
|
|
};
|
|
|
|
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 enabled = VALUES(enabled), updated_at = VALUES(updated_at)`,
|
|
[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 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);
|
|
await syncUserSkillsForUser(user);
|
|
if (capabilityState.unrestricted) {
|
|
return {
|
|
...buildAgentExtensionPolicy(capabilityState.capabilities, { unrestricted: true, toolMode }),
|
|
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 operations are OS-bound to the user's workspace; private_data_* tools
|
|
// only touch the user's single SQLite data space inside that workspace.
|
|
let sandboxMcp = null;
|
|
if (effectiveCapabilities.static_publish || effectiveCapabilities.private_data_space) {
|
|
try {
|
|
const workspaceCapability = resolveMindSpaceAgentWorkspaceCapability({
|
|
h5Root,
|
|
env,
|
|
user,
|
|
});
|
|
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,
|
|
};
|
|
} catch (err) {
|
|
console.warn('[getAgentSessionPolicy] sandbox MCP setup failed, falling back:', err?.message);
|
|
}
|
|
}
|
|
return {
|
|
...buildAgentExtensionPolicy(effectiveCapabilities, {
|
|
unrestricted: false,
|
|
policies: policyState.policies,
|
|
sandboxMcp,
|
|
toolMode,
|
|
}),
|
|
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 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;
|
|
};
|
|
|
|
return {
|
|
USER_COOKIE,
|
|
register,
|
|
login,
|
|
loginByWechat,
|
|
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,
|
|
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();
|
|
}
|