04ef1c9105
Add jscode2session exchange, CSRF bypass for servicewechat.com, and openid-based auto register/login for the mini program client. Co-authored-by: Cursor <cursoragent@cursor.com>
3103 lines
103 KiB
JavaScript
3103 lines
103 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 skill_name = skill_name`,
|
|
[name, enabled ? 1 : 0, now],
|
|
);
|
|
}
|
|
};
|
|
|
|
// goosedTarget may be the upstream URL the session is pinned to (preferred), or
|
|
// a legacy integer index into the targets list. We persist the URL in
|
|
// goosed_target and still derive a numeric goosed_node so older readers keep
|
|
// working; a string target stores goosed_node = 0 (its value is ignored once
|
|
// goosed_target is set).
|
|
const registerAgentSession = async (userId, agentSessionId, goosedTarget = 0) => {
|
|
const isLegacyIndex =
|
|
typeof goosedTarget === 'number' || /^\d+$/.test(String(goosedTarget));
|
|
const goosedNode = isLegacyIndex ? Number(goosedTarget) : 0;
|
|
const targetUrl = isLegacyIndex ? null : String(goosedTarget);
|
|
await pool.query(
|
|
`INSERT INTO h5_user_sessions (agent_session_id, user_id, goosed_node, goosed_target, created_at)
|
|
VALUES (?, ?, ?, ?, ?)
|
|
ON DUPLICATE KEY UPDATE user_id = VALUES(user_id),
|
|
goosed_node = VALUES(goosed_node), goosed_target = VALUES(goosed_target)`,
|
|
[agentSessionId, userId, goosedNode, targetUrl, Date.now()],
|
|
);
|
|
};
|
|
|
|
// Legacy integer-index accessor, kept for callers/bundles that still route by
|
|
// array index. Prefer getSessionTarget.
|
|
const getSessionNode = async (agentSessionId) => {
|
|
const [rows] = await pool.query(
|
|
`SELECT goosed_node FROM h5_user_sessions WHERE agent_session_id = ? LIMIT 1`,
|
|
[agentSessionId],
|
|
);
|
|
return rows[0]?.goosed_node ?? 0;
|
|
};
|
|
|
|
// Returns { target, node }: target is the pinned upstream URL (null if the row
|
|
// predates goosed_target), node is the legacy integer index fallback.
|
|
const getSessionTarget = async (agentSessionId) => {
|
|
const [rows] = await pool.query(
|
|
`SELECT goosed_node, goosed_target FROM h5_user_sessions WHERE agent_session_id = ? LIMIT 1`,
|
|
[agentSessionId],
|
|
);
|
|
return { target: rows[0]?.goosed_target ?? null, node: rows[0]?.goosed_node ?? 0 };
|
|
};
|
|
|
|
const ownsSession = async (userId, agentSessionId) => {
|
|
const [rows] = await pool.query(
|
|
`SELECT 1 FROM h5_user_sessions WHERE agent_session_id = ? AND user_id = ? LIMIT 1`,
|
|
[agentSessionId, userId],
|
|
);
|
|
return rows.length > 0;
|
|
};
|
|
|
|
const listOwnedSessionIds = async (userId) => {
|
|
const [rows] = await pool.query(
|
|
`SELECT agent_session_id FROM h5_user_sessions WHERE user_id = ?`,
|
|
[userId],
|
|
);
|
|
return new Set(rows.map((row) => row.agent_session_id));
|
|
};
|
|
|
|
const setSessionOrigin = async (agentSessionId, origin) => {
|
|
if (!agentSessionId || (origin !== 'h5' && origin !== 'wechat')) return;
|
|
await pool.query(
|
|
`UPDATE h5_user_sessions SET origin = ? WHERE agent_session_id = ?`,
|
|
[origin, agentSessionId],
|
|
);
|
|
};
|
|
|
|
const getSessionOrigins = async (agentSessionIds = []) => {
|
|
const ids = [...new Set(agentSessionIds)].filter(Boolean);
|
|
if (ids.length === 0) return new Map();
|
|
const [rows] = await pool.query(
|
|
`SELECT agent_session_id, origin FROM h5_user_sessions WHERE agent_session_id IN (?)`,
|
|
[ids],
|
|
);
|
|
return new Map(rows.map((row) => [row.agent_session_id, row.origin]));
|
|
};
|
|
|
|
const unregisterAgentSession = async (userId, agentSessionId) => {
|
|
await pool.query(
|
|
`DELETE FROM h5_user_sessions WHERE agent_session_id = ? AND user_id = ?`,
|
|
[agentSessionId, userId],
|
|
);
|
|
};
|
|
|
|
const canUseChat = async (userId) => {
|
|
const user = await getUserById(userId);
|
|
if (!user) return { ok: false, message: '用户不存在' };
|
|
if (user.status === 'disabled') {
|
|
return { ok: false, message: '账户已禁用' };
|
|
}
|
|
if (user.status === 'suspended' && !isAdminRole(user)) {
|
|
return { ok: false, message: '账户已暂停' };
|
|
}
|
|
if (isAdminRole(user)) {
|
|
return { ok: true, balanceCents: Number(user.balance_cents ?? 0) };
|
|
}
|
|
|
|
// Subscription check: active subscription with remaining quota bypasses balance requirement.
|
|
if (subscriptionService) {
|
|
const sub = await subscriptionService.getActiveSubscription(userId);
|
|
if (sub) {
|
|
const unlimited = sub.periodTokensLimit === 0;
|
|
const hasQuota = unlimited || sub.periodTokensUsed < sub.periodTokensLimit;
|
|
const balanceCents = Number(user.balance_cents ?? 0);
|
|
if (hasQuota) {
|
|
return { ok: true, balanceCents, subscription: sub };
|
|
}
|
|
// Quota exhausted — allow if balance covers overage, otherwise block.
|
|
if (balanceCents > 0) {
|
|
return { ok: true, balanceCents, subscription: sub, overQuota: true };
|
|
}
|
|
return {
|
|
ok: false,
|
|
message: '本月额度已用完,余额不足,请充值或升级套餐',
|
|
...buildInsufficientBalancePayload(balanceCents, loadRechargeConfig()),
|
|
};
|
|
}
|
|
}
|
|
|
|
const balanceCents = Number(user.balance_cents ?? 0);
|
|
if (balanceCents <= 0) {
|
|
return {
|
|
ok: false,
|
|
message: '余额不足,请充值后继续使用',
|
|
...buildInsufficientBalancePayload(balanceCents, loadRechargeConfig()),
|
|
};
|
|
}
|
|
return { ok: true, balanceCents };
|
|
};
|
|
|
|
const listUsers = async ({ page = 1, pageSize = 20, search = '', role = '', status = '' } = {}) => {
|
|
const safePageSize = Math.min(Math.max(Number(pageSize) || 20, 1), 100);
|
|
const safePage = Math.max(Number(page) || 1, 1);
|
|
const offset = (safePage - 1) * safePageSize;
|
|
const params = [];
|
|
const clauses = [];
|
|
if (search) {
|
|
clauses.push('(u.username LIKE ? OR u.display_name LIKE ?)');
|
|
params.push(`%${search}%`, `%${search}%`);
|
|
}
|
|
if (role) { clauses.push('u.role = ?'); params.push(role); }
|
|
if (status) { clauses.push('u.status = ?'); params.push(status); }
|
|
const where = clauses.length ? `WHERE ${clauses.join(' AND ')}` : '';
|
|
const [[{ total }]] = await pool.query(
|
|
`SELECT COUNT(*) AS total FROM h5_users u ${where}`,
|
|
params,
|
|
);
|
|
const [rows] = await pool.query(
|
|
`SELECT u.id, u.username, u.slug, u.email, u.display_name, u.role, u.status,
|
|
u.plan_type, u.workspace_root,
|
|
s.quota_bytes, s.used_bytes, s.reserved_bytes,
|
|
u.created_at, u.updated_at, w.balance_cents, w.tokens_used
|
|
FROM h5_users u
|
|
LEFT JOIN h5_user_spaces s ON s.user_id = u.id
|
|
LEFT JOIN h5_user_wallets w ON w.user_id = u.id
|
|
${where}
|
|
ORDER BY u.created_at DESC
|
|
LIMIT ${safePageSize} OFFSET ${offset}`,
|
|
params,
|
|
);
|
|
return {
|
|
users: rows.map((row) => ({ ...publicUser(row), createdAt: Number(row.created_at), updatedAt: Number(row.updated_at) })),
|
|
total: Number(total),
|
|
page: safePage,
|
|
pageSize: safePageSize,
|
|
};
|
|
};
|
|
|
|
const getUserPublic = async (userId) => {
|
|
const user = await getUserById(userId);
|
|
return user ? publicUser(user) : null;
|
|
};
|
|
|
|
const createUser = async ({
|
|
username,
|
|
password,
|
|
displayName,
|
|
workspaceRoot,
|
|
balanceCents,
|
|
role = 'user',
|
|
email,
|
|
}) => {
|
|
const normalized = normalizeUsername(username);
|
|
if (!isValidUsername(normalized)) {
|
|
return { ok: false, message: '用户名格式无效' };
|
|
}
|
|
if (!password || password.length < 6) {
|
|
return { ok: false, message: '密码至少 6 位' };
|
|
}
|
|
|
|
const isAdmin = role === 'admin';
|
|
const userId = crypto.randomUUID();
|
|
const root = (await publishLayoutFor({ id: userId, username: normalized })).publishDir;
|
|
const { salt, passwordHash, passwordAlgorithm } = createPasswordRecord(password);
|
|
const now = Date.now();
|
|
|
|
const conn = await pool.getConnection();
|
|
const initialBalanceCents = Number(balanceCents ?? defaultSignupBalanceCents);
|
|
try {
|
|
await conn.beginTransaction();
|
|
await conn.query(
|
|
`INSERT INTO h5_users
|
|
(id, username, slug, email, display_name, salt, password_hash, password_algorithm,
|
|
role, status, plan_type, workspace_root, low_balance_gift_eligible, low_balance_gift_granted_at,
|
|
created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', 'free', ?, ?, NULL, ?, ?)`,
|
|
[
|
|
userId,
|
|
normalized,
|
|
normalized,
|
|
email?.trim().toLowerCase() || null,
|
|
displayName?.trim() || normalized,
|
|
salt,
|
|
passwordHash,
|
|
passwordAlgorithm,
|
|
isAdmin ? 'admin' : 'user',
|
|
root,
|
|
shouldEnableLowBalanceGift({ isAdmin, initialBalanceCents }) ? 1 : 0,
|
|
now,
|
|
now,
|
|
],
|
|
);
|
|
await conn.query(
|
|
`INSERT INTO h5_user_wallets (user_id, balance_cents, tokens_used, updated_at)
|
|
VALUES (?, ?, 0, ?)`,
|
|
[userId, initialBalanceCents, now],
|
|
);
|
|
await conn.query(
|
|
`INSERT INTO h5_user_path_grants (user_id, path, mode) VALUES (?, ?, 'readwrite')`,
|
|
[userId, root],
|
|
);
|
|
if (!isAdmin) {
|
|
await initializeDefaultSpace(conn, userId, {
|
|
quotaBytes: Number(process.env.MINDSPACE_FREE_QUOTA_BYTES ?? 5 * 1024 * 1024),
|
|
now,
|
|
});
|
|
}
|
|
await 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;
|
|
};
|
|
|
|
const loginByWechatMiniProgram = async ({
|
|
appId,
|
|
openid,
|
|
unionid,
|
|
now = Date.now(),
|
|
}) => {
|
|
const existing = await findWechatUserByOpenid(appId, openid);
|
|
if (existing) {
|
|
if (existing.status === 'disabled') {
|
|
return { ok: false, message: '账户已禁用,请联系管理员' };
|
|
}
|
|
return loginBoundWechatUser({
|
|
userId: existing.userId,
|
|
appId,
|
|
openid,
|
|
unionid,
|
|
nickname: existing.nickname,
|
|
avatarUrl: null,
|
|
now,
|
|
});
|
|
}
|
|
|
|
const registered = await registerViaWechat({
|
|
appId,
|
|
openid,
|
|
unionid,
|
|
nickname: '微信用户',
|
|
avatarUrl: null,
|
|
now,
|
|
});
|
|
if (!registered.ok) return registered;
|
|
const token = await issueUserSession(registered.user.id, registered.user.role, now);
|
|
return {
|
|
ok: true,
|
|
token,
|
|
user: registered.user,
|
|
isNewUser: true,
|
|
};
|
|
};
|
|
|
|
return {
|
|
USER_COOKIE,
|
|
register,
|
|
login,
|
|
loginByWechat,
|
|
loginByWechatMiniProgram,
|
|
resolveWechatAuth,
|
|
completeWechatRegister,
|
|
completeWechatBindAccount,
|
|
getWechatPendingBind,
|
|
getWechatBindingStatus,
|
|
getWechatOpenidForUser,
|
|
setRechargeNotifier(callback) {
|
|
rechargeNotifier = typeof callback === 'function' ? callback : null;
|
|
},
|
|
findWechatUserByOpenid,
|
|
getWechatAgentRoute,
|
|
upsertWechatAgentRoute,
|
|
clearWechatAgentRoute,
|
|
touchWechatAgentRoute,
|
|
countWechatAgentSessionMessages,
|
|
getWechatAgentSessionSnapshotMessageCount,
|
|
recordWechatMpMessage,
|
|
finishWechatMpMessage,
|
|
insertWechatMpMessageDetail,
|
|
resetPassword,
|
|
verify,
|
|
revoke,
|
|
revokeAllSessionsForUser,
|
|
getMe,
|
|
listPathGrants,
|
|
resolveWorkingDir,
|
|
getUserPublishLayout,
|
|
isPathAllowed,
|
|
repairAllUserPublishDirs,
|
|
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();
|
|
}
|