2026-06-15 15:04:43 -07:00
import crypto from 'node:crypto' ;
import fs from 'node:fs' ;
2026-06-26 13:36:05 +08:00
import net from 'node:net' ;
2026-06-15 15:04:43 -07:00
import path from 'node:path' ;
2026-06-19 23:06:43 +08:00
import { Algorithm as Argon2Algorithm , hashRawSync as argon2HashRawSync } from '@node-rs/argon2' ;
2026-06-15 15:04:43 -07:00
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 ,
2026-06-26 13:36:05 +08:00
resolveSandboxMcpServerPath ,
2026-06-15 15:04:43 -07:00
USER _NON _GRANTABLE _CAPABILITIES ,
} from './capabilities.mjs' ;
import {
applyPoliciesToCapabilities ,
DEFAULT _USER _POLICIES ,
normalizePolicyPatch ,
POLICY _CATALOG ,
policyKeys ,
resolvePolicies ,
} from './policies.mjs' ;
import {
ensurePublishSkillInstalled ,
ensureUserPublishLayout ,
2026-06-26 13:36:05 +08:00
ensureWorkspaceHintsInstalled ,
2026-06-15 15:04:43 -07:00
PUBLISH _SKILL _NAME ,
2026-06-26 13:36:05 +08:00
resolveLegacyPublishDir ,
2026-06-15 15:04:43 -07:00
} from './user-publish.mjs' ;
import {
ensureUserSpaceLayout ,
isPathInsideUserWorkspace ,
} from './user-space.mjs' ;
2026-07-03 08:40:28 +08:00
import {
buildMindSpacePublicUrlForUser ,
resolveMindSpaceAgentWorkspaceCapability ,
resolveMindSpaceRuntimeConfig ,
} from './mindspace-runtime-config.mjs' ;
2026-06-15 22:09:38 -07:00
import { ensureUserMemoryProfile } from './user-memory-profile.mjs' ;
2026-06-15 15:04:43 -07:00
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' ) ;
}
2026-06-17 16:39:39 -07:00
function hashPasswordArgon2id ( password , salt ) {
2026-06-19 23:06:43 +08:00
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' ) ;
2026-06-15 15:04:43 -07:00
}
2026-06-17 16:39:39 -07:00
function createPasswordRecord ( password , algorithm = PASSWORD _ALGORITHM _ARGON2ID ) {
2026-06-15 15:04:43 -07:00
const salt = crypto . randomBytes ( 16 ) . toString ( 'hex' ) ;
if ( algorithm === PASSWORD _ALGORITHM _ARGON2ID ) {
return {
salt ,
2026-06-17 16:39:39 -07:00
passwordHash : hashPasswordArgon2id ( password , salt ) ,
2026-06-15 15:04:43 -07:00
passwordAlgorithm : PASSWORD _ALGORITHM _ARGON2ID ,
} ;
}
return {
salt ,
passwordHash : hashPasswordPbkdf2 ( password , salt ) ,
passwordAlgorithm : PASSWORD _ALGORITHM _PBKDF2 ,
} ;
}
2026-06-17 16:39:39 -07:00
function verifyPassword ( password , row ) {
2026-06-15 15:04:43 -07:00
const algorithm = row . password _algorithm || PASSWORD _ALGORITHM _PBKDF2 ;
if ( algorithm === PASSWORD _ALGORITHM _ARGON2ID ) {
2026-06-17 16:39:39 -07:00
return safeEqual ( hashPasswordArgon2id ( password , row . salt ) , row . password _hash ) ;
2026-06-15 15:04:43 -07:00
}
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 ;
2026-07-03 08:40:28 +08:00
const { storageRoot , publicBaseUrl } = resolveMindSpaceRuntimeConfig ( h5Root , env ) ;
2026-06-15 15:04:43 -07:00
const skillCatalog = listPlatformSkillCatalog ( h5Root ) ;
2026-06-15 22:09:38 -07:00
const defaultSignupBalanceCents = Number ( options . defaultSignupBalanceCents ? ? 500 ) ;
2026-08-22 08:45:43 +08:00
const lowBalanceGiftThresholdCents = Number (
options . lowBalanceGiftThresholdCents ? ? env . H5 _LOW _BALANCE _GIFT _THRESHOLD _CENTS ? ? 100 ,
) ;
const lowBalanceGiftAmountCents = Number (
options . lowBalanceGiftAmountCents ? ? env . H5 _LOW _BALANCE _GIFT _AMOUNT _CENTS ? ? 0 ,
) ;
2026-06-15 15:04:43 -07:00
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 ) ;
2026-06-26 13:36:05 +08:00
let rechargeNotifier =
typeof options . onRechargeNotification === 'function' ? options . onRechargeNotification : null ;
2026-08-22 08:45:43 +08:00
let subscriptionService = options . subscriptionService ? ? null ;
2026-07-13 15:29:29 +08:00
const provisionUserDataSpace = typeof options . provisionUserDataSpace === 'function'
? options . provisionUserDataSpace
: null ;
2026-07-15 13:46:17 +08:00
const getMindSearchConfig = typeof options . getMindSearchConfig === 'function' ? options . getMindSearchConfig : null ;
2026-08-06 15:29:16 +08:00
const billingConfigService = options . billingConfigService ? ? null ;
2026-06-15 15:04:43 -07:00
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,
2026-08-22 08:45:43 +08:00
u.plan_type, u.workspace_root, u.billing_formula,
2026-06-26 13:36:05 +08:00
s.quota_bytes, s.used_bytes, s.reserved_bytes,
2026-06-15 15:04:43 -07:00
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
2026-06-26 13:36:05 +08:00
LEFT JOIN h5_user_spaces s ON s.user_id = u.id
2026-06-15 15:04:43 -07:00
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 ,
2026-08-22 08:45:43 +08:00
billingFormula : row . billing _formula === 'B' ? 'B' : 'A' ,
2026-06-15 15:04:43 -07:00
balanceCents ,
totalCreditCents : balanceCents + spentCents ,
tokensUsed : Number ( row . tokens _used ? ? 0 ) ,
2026-06-26 13:36:05 +08:00
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 ) ,
) ,
2026-06-15 15:04:43 -07:00
} ;
const publishKey = row . id ;
return {
... base ,
publishSlug : publishKey ,
2026-07-03 08:40:28 +08:00
publishUrl : buildMindSpacePublicUrlForUser ( { h5Root , env , user : publishKey } ) ,
2026-06-15 15:04:43 -07:00
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 ,
} ) ;
2026-06-26 13:36:05 +08:00
const hintsContext = {
2026-06-15 15:04:43 -07:00
slug : web . slug ,
username : user . username ? ? web . username ,
displayName : user . displayName ,
publicBaseUrl ,
publishDir : web . publishDir ,
2026-06-26 13:36:05 +08:00
} ;
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 } ) ;
}
2026-06-15 22:09:38 -07:00
ensureUserMemoryProfile ( web . publishDir , {
userId : user . id ,
displayName : user . displayName ? ? user . display _name ,
username : user . username ? ? web . username ,
slug : web . slug ,
} ) ;
2026-06-15 15:04:43 -07:00
return {
... web ,
... space ,
publishDir : web . publishDir ,
constraints : web . constraints ,
} ;
} ;
2026-07-13 15:29:29 +08:00
const ensureUserDataSpaceForUser = async ( user , workspaceRoot = null ) => {
if ( ! provisionUserDataSpace || ! user ? . id || user . role === 'admin' ) return null ;
return provisionUserDataSpace ( {
userId : user . id ,
workspaceRoot : workspaceRoot ? ? user . workspaceRoot ? ? user . workspace _root ,
} ) ;
} ;
2026-06-15 15:04:43 -07:00
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 ) => {
2026-06-26 13:36:05 +08:00
if ( ! user ) return null ;
2026-06-15 15:04:43 -07:00
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 ;
} ;
2026-06-15 22:09:38 -07:00
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 ] ,
) ;
} ;
2026-06-30 20:39:46 +08:00
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 } ;
} ;
2026-06-15 15:04:43 -07:00
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 : '请输入有效邮箱' } ;
}
2026-06-17 16:39:39 -07:00
const { salt , passwordHash , passwordAlgorithm } = createPasswordRecord ( password ) ;
2026-06-15 15:04:43 -07:00
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,
2026-06-30 20:39:46 +08:00
role, status, plan_type, workspace_root, low_balance_gift_eligible, low_balance_gift_granted_at,
created_at, updated_at)
2026-08-22 08:45:43 +08:00
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'user', 'active', 'free', ?, 0, NULL, ?, ?) ` ,
2026-06-15 15:04:43 -07:00
[
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 ] ,
) ;
2026-06-15 22:09:38 -07:00
await recordSignupBonus ( conn , userId , defaultSignupBalanceCents , now ) ;
2026-06-15 15:04:43 -07:00
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 ,
} ) ;
2026-07-13 15:29:29 +08:00
await ensureUserDataSpaceForUser ( { id : userId , role : 'user' } , workspaceRoot ) ;
2026-06-15 15:04:43 -07:00
await conn . commit ( ) ;
ensureWorkspace ( workspaceRoot ) ;
2026-06-15 22:09:38 -07:00
ensureUserMemoryProfile ( workspaceRoot , {
userId ,
displayName : displayName ? . trim ( ) || normalized ,
username : normalized ,
slug : normalized ,
} ) ;
2026-06-26 13:36:05 +08:00
if ( subscriptionService ) {
subscriptionService . grantSubscription ( userId , 'free' , null , null , '注册赠送免费套餐' ) . catch ( ( ) => { } ) ;
}
2026-06-15 15:04:43 -07:00
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 : '用户名或密码错误' } ;
}
2026-06-17 16:39:39 -07:00
if ( ! verifyPassword ( password , row ) ) {
2026-06-15 15:04:43 -07:00
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 ) {
2026-06-17 16:39:39 -07:00
const nextPassword = createPasswordRecord ( password ) ;
2026-06-15 15:04:43 -07:00
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 : '账户已禁用,请联系管理员' } ;
}
2026-06-17 16:39:39 -07:00
const { salt , passwordHash , passwordAlgorithm } = createPasswordRecord ( password ) ;
2026-06-15 15:04:43 -07:00
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 ) ;
2026-06-26 13:36:05 +08:00
if ( ! user ) return null ;
2026-06-15 15:04:43 -07:00
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' ` ,
) ;
2026-07-20 20:04:44 +08:00
const errors = [ ] ;
2026-06-15 15:04:43 -07:00
for ( const row of rows ) {
2026-07-20 20:04:44 +08:00
try {
await syncUserSkillsForUser ( row ) ;
} catch ( error ) {
errors . push ( { userId : row . id , error } ) ;
console . warn (
` [UserAuth] Workspace skill repair skipped for user ${ row . id } : ` ,
error instanceof Error ? error . message : error ,
) ;
}
2026-06-15 15:04:43 -07:00
}
2026-07-20 20:04:44 +08:00
return { repaired : rows . length - errors . length , errors } ;
2026-06-15 15:04:43 -07:00
} ;
2026-07-13 15:29:29 +08:00
const ensureAllUserDataSpaces = async ( ) => {
if ( ! provisionUserDataSpace ) return { provisioned : 0 , errors : [ ] } ;
const [ rows ] = await pool . query (
` SELECT id, workspace_root FROM h5_users WHERE role = 'user' AND status = 'active' ` ,
) ;
let provisioned = 0 ;
const errors = [ ] ;
for ( const row of rows ) {
try {
await ensureUserDataSpaceForUser ( { id : row . id , role : 'user' } , row . workspace _root ) ;
provisioned += 1 ;
} catch ( error ) {
errors . push ( {
userId : row . id ,
code : error ? . code ? ? null ,
message : error instanceof Error ? error . message : String ( error ) ,
} ) ;
}
}
return { provisioned , errors } ;
} ;
2026-06-15 15:04:43 -07:00
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', ?, ?, ?)
2026-07-11 00:23:01 +08:00
ON DUPLICATE KEY UPDATE skill_name = skill_name ` ,
2026-06-15 15:04:43 -07:00
[ name , enabled ? 1 : 0 , now ] ,
) ;
}
} ;
2026-06-27 08:25:02 +08:00
// 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 ) ;
2026-06-15 15:04:43 -07:00
await pool . query (
2026-06-27 08:25:02 +08:00
` 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 ( ) ] ,
2026-06-15 15:04:43 -07:00
) ;
} ;
2026-06-27 08:25:02 +08:00
// Legacy integer-index accessor, kept for callers/bundles that still route by
// array index. Prefer getSessionTarget.
2026-06-19 23:06:43 +08:00
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 ;
} ;
2026-06-27 08:25:02 +08:00
// 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 } ;
} ;
2026-06-15 15:04:43 -07:00
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 ) ) ;
} ;
2026-07-01 21:10:56 +08:00
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 ] ) ) ;
} ;
2026-06-15 22:09:38 -07:00
const unregisterAgentSession = async ( userId , agentSessionId ) => {
await pool . query (
` DELETE FROM h5_user_sessions WHERE agent_session_id = ? AND user_id = ? ` ,
[ agentSessionId , userId ] ,
) ;
} ;
2026-06-15 15:04:43 -07:00
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 ) } ;
}
2026-06-26 13:36:05 +08:00
// 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 ( ) ) ,
} ;
}
}
2026-06-15 15:04:43 -07:00
const balanceCents = Number ( user . balance _cents ? ? 0 ) ;
if ( balanceCents <= 0 ) {
return {
ok : false ,
message : '余额不足,请充值后继续使用' ,
... buildInsufficientBalancePayload ( balanceCents , loadRechargeConfig ( ) ) ,
} ;
}
return { ok : true , balanceCents } ;
} ;
2026-06-17 16:39:39 -07:00
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 ,
) ;
2026-06-15 15:04:43 -07:00
const [ rows ] = await pool . query (
` SELECT u.id, u.username, u.slug, u.email, u.display_name, u.role, u.status,
2026-08-22 08:45:43 +08:00
u.plan_type, u.workspace_root, u.billing_formula,
2026-06-26 13:36:05 +08:00
s.quota_bytes, s.used_bytes, s.reserved_bytes,
2026-06-15 15:04:43 -07:00
u.created_at, u.updated_at, w.balance_cents, w.tokens_used
FROM h5_users u
2026-06-26 13:36:05 +08:00
LEFT JOIN h5_user_spaces s ON s.user_id = u.id
2026-06-15 15:04:43 -07:00
LEFT JOIN h5_user_wallets w ON w.user_id = u.id
2026-06-17 16:39:39 -07:00
${ where }
ORDER BY u.created_at DESC
LIMIT ${ safePageSize } OFFSET ${ offset } ` ,
params ,
2026-06-15 15:04:43 -07:00
) ;
2026-06-17 16:39:39 -07:00
return {
users : rows . map ( ( row ) => ( { ... publicUser ( row ) , createdAt : Number ( row . created _at ) , updatedAt : Number ( row . updated _at ) } ) ) ,
total : Number ( total ) ,
page : safePage ,
pageSize : safePageSize ,
} ;
2026-06-15 15:04:43 -07:00
} ;
2026-06-26 13:36:05 +08:00
const getUserPublic = async ( userId ) => {
const user = await getUserById ( userId ) ;
return user ? publicUser ( user ) : null ;
} ;
2026-06-15 15:04:43 -07:00
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 ( ) ;
2026-06-26 13:36:05 +08:00
const root = ( await publishLayoutFor ( { id : userId , username : normalized } ) ) . publishDir ;
2026-06-17 16:39:39 -07:00
const { salt , passwordHash , passwordAlgorithm } = createPasswordRecord ( password ) ;
2026-06-15 15:04:43 -07:00
const now = Date . now ( ) ;
const conn = await pool . getConnection ( ) ;
2026-06-30 20:39:46 +08:00
const initialBalanceCents = Number ( balanceCents ? ? defaultSignupBalanceCents ) ;
2026-06-15 15:04:43 -07:00
try {
await conn . beginTransaction ( ) ;
await conn . query (
` INSERT INTO h5_users
(id, username, slug, email, display_name, salt, password_hash, password_algorithm,
2026-06-30 20:39:46 +08:00
role, status, plan_type, workspace_root, low_balance_gift_eligible, low_balance_gift_granted_at,
created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', 'free', ?, ?, NULL, ?, ?) ` ,
2026-06-15 15:04:43 -07:00
[
userId ,
normalized ,
normalized ,
email ? . trim ( ) . toLowerCase ( ) || null ,
displayName ? . trim ( ) || normalized ,
salt ,
passwordHash ,
passwordAlgorithm ,
isAdmin ? 'admin' : 'user' ,
root ,
2026-06-30 20:39:46 +08:00
shouldEnableLowBalanceGift ( { isAdmin , initialBalanceCents } ) ? 1 : 0 ,
2026-06-15 15:04:43 -07:00
now ,
now ,
] ,
) ;
await conn . query (
` INSERT INTO h5_user_wallets (user_id, balance_cents, tokens_used, updated_at)
VALUES (?, ?, 0, ?) ` ,
2026-06-30 20:39:46 +08:00
[ userId , initialBalanceCents , now ] ,
2026-06-15 15:04:43 -07:00
) ;
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 ,
} ) ;
2026-07-13 15:29:29 +08:00
await ensureUserDataSpaceForUser ( { id : userId , role : 'user' } , root ) ;
2026-06-15 15:04:43 -07:00
}
await conn . commit ( ) ;
ensureWorkspace ( root ) ;
2026-06-26 13:36:05 +08:00
if ( subscriptionService && ! isAdmin ) {
subscriptionService . grantSubscription ( userId , 'free' , null , null , '注册赠送免费套餐' ) . catch ( ( ) => { } ) ;
}
2026-06-15 15:04:43 -07:00
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' ) ;
}
2026-08-22 08:45:43 +08:00
if ( patch . billingFormula !== undefined ) {
const formula = String ( patch . billingFormula ) . trim ( ) . toUpperCase ( ) ;
if ( ! [ 'A' , 'B' ] . includes ( formula ) ) {
return { ok : false , message : '计量公式只能是 A 或 B' } ;
}
fields . push ( 'billing_formula = ?' ) ;
values . push ( formula ) ;
}
2026-06-15 15:04:43 -07:00
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 ] ,
) ;
}
2026-06-26 13:36:05 +08:00
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 ,
} ) ;
}
}
2026-06-15 15:04:43 -07:00
const updated = await getUserById ( userId ) ;
return { ok : true , user : publicUser ( updated ) } ;
} ;
2026-06-26 13:36:05 +08:00
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 ( ) ;
}
} ;
2026-06-15 15:04:43 -07:00
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 ? '管理员充值' : '账户充值' ) ;
2026-06-26 13:36:05 +08:00
const rechargeType = paymentOrderId ? 'self_recharge' : operatorId ? 'admin_recharge' : 'recharge' ;
2026-06-15 15:04:43 -07:00
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 ] ,
) ;
2026-06-26 13:36:05 +08:00
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 ,
] ,
) ;
2026-06-15 15:04:43 -07:00
if ( user . status === 'suspended' ) {
await conn . query ( ` UPDATE h5_users SET status = 'active', updated_at = ? WHERE id = ? ` , [
now ,
userId ,
] ) ;
}
if ( ownsConnection ) await conn . commit ( ) ;
2026-08-22 08:45:43 +08:00
if ( subscriptionService ? . processAutoRenewalsForUser ) {
try {
await subscriptionService . processAutoRenewalsForUser ( userId ) ;
} catch ( err ) {
console . warn (
'Post-recharge auto-renew retry failed:' ,
err instanceof Error ? err . message : String ( err ) ,
) ;
}
}
2026-06-15 15:04:43 -07:00
const updated = await getUserById ( userId ) ;
2026-06-26 13:36:05 +08:00
if ( rechargeNotifier ) {
try {
await rechargeNotifier ( {
userId ,
amountCents : amount ,
operatorId : operatorId ? ? null ,
paymentOrderId ,
2026-06-30 21:26:46 +08:00
dedupeKey : paymentOrderId ? ` recharge: ${ paymentOrderId } ` : null ,
2026-06-26 13:36:05 +08:00
notificationType : rechargeType ,
title ,
body ,
user : publicUser ( updated ) ,
} ) ;
} catch ( err ) {
console . warn (
'Recharge notifier failed:' ,
err instanceof Error ? err . message : String ( err ) ,
) ;
}
}
2026-06-15 15:04:43 -07:00
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 ) ,
2026-06-15 22:09:38 -07:00
tokensUsed : Number ( user ? . tokens _used ? ? 0 ) ,
2026-06-15 15:04:43 -07:00
deltaInputTokens : 0 ,
deltaOutputTokens : 0 ,
} ;
}
const tokenState = normalizeTokenState ( tokenStateRaw ) ;
2026-08-06 15:29:16 +08:00
const config = billingConfigService ? . getEffectiveBillingConfig
2026-08-22 08:45:43 +08:00
? await billingConfigService . getEffectiveBillingConfig ( { userId } )
2026-08-06 15:29:16 +08:00
: loadBillingConfig ( env ) ;
2026-06-29 22:20:04 +08:00
const normalizedRequestId = requestId ? String ( requestId ) . trim ( ) || null : null ;
2026-06-15 15:04:43 -07:00
const now = Date . now ( ) ;
const conn = await pool . getConnection ( ) ;
try {
await conn . beginTransaction ( ) ;
2026-06-29 22:20:04 +08:00
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 ;
2026-06-15 15:04:43 -07:00
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)
2026-06-17 16:39:39 -07:00
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) ` ,
2026-06-15 15:04:43 -07:00
[
2026-06-17 16:39:39 -07:00
agentSessionId ,
userId ,
2026-06-15 15:04:43 -07:00
tokenState . accumulatedCost ,
tokenState . accumulatedInputTokens ,
tokenState . accumulatedOutputTokens ,
now ,
] ,
) ;
2026-06-26 13:36:05 +08:00
// Subscription quota check: consume tokens from active plan before touching balance.
2026-08-06 10:52:55 +00:00
let subscriptionCovered = false ;
2026-06-26 13:36:05 +08:00
if ( costCents > 0 && subscriptionService ) {
const coverage = await subscriptionService . consumeQuota ( userId , deltaTokens , conn ) ;
if ( coverage . fullyCovers ) {
costCents = 0 ;
2026-08-06 10:52:55 +00:00
subscriptionCovered = true ;
2026-06-26 13:36:05 +08:00
} else if ( coverage . overageRate < 1.0 ) {
costCents = Math . max ( 1 , Math . ceil ( costCents * coverage . overageRate ) ) ;
}
}
2026-06-15 15:04:43 -07:00
let balanceAfter = null ;
2026-06-15 22:09:38 -07:00
let tokensUsedAfter = null ;
2026-06-15 15:04:43 -07:00
if ( costCents > 0 ) {
const [ walletRows ] = await conn . query (
2026-06-30 20:39:46 +08:00
` 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 ` ,
2026-06-15 15:04:43 -07:00
[ 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 ;
2026-06-15 22:09:38 -07:00
tokensUsedAfter = Number ( wallet . tokens _used ? ? 0 ) + deltaTokens ;
2026-06-15 15:04:43 -07:00
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
2026-08-06 10:52:55 +00:00
(user_id, agent_session_id, request_id, input_tokens, output_tokens, cost_cents, balance_after_cents, billing_source, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, 'wallet', ?) ` ,
2026-06-29 22:20:04 +08:00
[
userId ,
agentSessionId ,
normalizedRequestId ,
deltaIn ,
deltaOut ,
costCents ,
nextBalance ,
now ,
] ,
2026-06-15 15:04:43 -07:00
) ;
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 ,
] ,
) ;
2026-06-30 20:39:46 +08:00
const lowBalanceGift = await grantLowBalanceGiftIfNeeded ( conn , {
userId ,
currentBalance ,
nextBalance ,
now ,
} ) ;
balanceAfter = lowBalanceGift . balanceAfter ;
if ( balanceAfter <= 0 ) {
2026-06-15 15:04:43 -07:00
await conn . query ( ` UPDATE h5_users SET status = 'suspended', updated_at = ? WHERE id = ? ` , [
now ,
userId ,
] ) ;
}
2026-08-06 10:52:55 +00:00
} else if ( subscriptionCovered && deltaTokens > 0 ) {
const [ walletRows ] = await conn . query (
` SELECT balance_cents, tokens_used FROM h5_user_wallets WHERE user_id = ? ` ,
[ userId ] ,
) ;
balanceAfter = walletRows [ 0 ] ? Number ( walletRows [ 0 ] . balance _cents ) : 0 ;
tokensUsedAfter = walletRows [ 0 ] ? Number ( walletRows [ 0 ] . tokens _used ? ? 0 ) : null ;
await conn . query (
` INSERT INTO h5_usage_records
(user_id, agent_session_id, request_id, input_tokens, output_tokens, cost_cents, balance_after_cents, billing_source, created_at)
VALUES (?, ?, ?, ?, ?, 0, ?, 'subscription', ?) ` ,
[
userId ,
agentSessionId ,
normalizedRequestId ,
deltaIn ,
deltaOut ,
balanceAfter ,
now ,
] ,
) ;
2026-06-15 15:04:43 -07:00
} else {
const user = await getUserById ( userId ) ;
balanceAfter = user ? Number ( user . balance _cents ) : null ;
2026-06-15 22:09:38 -07:00
tokensUsedAfter = user ? Number ( user . tokens _used ? ? 0 ) : null ;
2026-06-15 15:04:43 -07:00
}
await conn . commit ( ) ;
return {
ok : true ,
costCents ,
balanceCents : balanceAfter ,
2026-06-15 22:09:38 -07:00
tokensUsed : tokensUsedAfter ,
2026-06-15 15:04:43 -07:00
deltaInputTokens : deltaIn ,
deltaOutputTokens : deltaOut ,
} ;
} catch ( err ) {
await conn . rollback ( ) ;
throw err ;
} finally {
conn . release ( ) ;
}
} ;
2026-06-17 16:39:39 -07:00
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,
2026-08-06 10:52:55 +00:00
r.input_tokens, r.output_tokens, r.cost_cents, r.balance_after_cents, r.billing_source, r.created_at
2026-06-17 16:39:39 -07:00
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 ,
2026-06-16 03:10:05 -07:00
) ;
2026-08-06 10:52:55 +00:00
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 ) , billingSource : row . billing _source ? ? 'wallet' , createdAt : Number ( row . created _at ) } ) ) ;
2026-06-16 03:10:05 -07:00
}
2026-06-17 16:39:39 -07:00
const safePageSize = Math . min ( Math . max ( Number ( pageSize ) || 50 , 1 ) , 200 ) ;
const safePage = Math . max ( Number ( page ) || 1 , 1 ) ;
const offset = ( safePage - 1 ) * safePageSize ;
2026-06-15 15:04:43 -07:00
const params = [ ] ;
2026-06-17 16:39:39 -07:00
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 ,
) ;
2026-06-15 15:04:43 -07:00
const [ rows ] = await pool . query (
` SELECT r.id, r.user_id, u.username, r.agent_session_id, r.request_id,
2026-08-06 10:52:55 +00:00
r.input_tokens, r.output_tokens, r.cost_cents, r.balance_after_cents, r.billing_source, r.created_at
2026-06-17 16:39:39 -07:00
FROM h5_usage_records r JOIN h5_users u ON u.id = r.user_id
2026-06-15 15:04:43 -07:00
${ where }
ORDER BY r.created_at DESC
2026-06-17 16:39:39 -07:00
LIMIT ${ safePageSize } OFFSET ${ offset } ` ,
2026-06-15 15:04:43 -07:00
params ,
) ;
2026-06-17 16:39:39 -07:00
return {
2026-08-06 10:52:55 +00:00
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 ) , billingSource : row . billing _source ? ? 'wallet' , createdAt : Number ( row . created _at ) } ) ) ,
2026-06-17 16:39:39 -07:00
total : Number ( total ) ,
page : safePage ,
pageSize : safePageSize ,
} ;
2026-06-15 15:04:43 -07:00
} ;
2026-06-17 16:39:39 -07:00
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 ) ;
2026-06-15 15:04:43 -07:00
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
2026-06-17 16:39:39 -07:00
FROM h5_billing_ledger l JOIN h5_users u ON u.id = l.user_id
${ dataWhere }
2026-06-15 15:04:43 -07:00
ORDER BY l.created_at DESC
2026-06-17 16:39:39 -07:00
LIMIT ${ safePageSize } OFFSET ${ offset } ` ,
dataParams ,
2026-06-15 15:04:43 -07:00
) ;
2026-06-17 16:39:39 -07:00
return { entries : rows . map ( mapRow ) , total : Number ( total ) , page : safePage , pageSize : safePageSize } ;
2026-06-15 15:04:43 -07:00
} ;
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 ;
2026-06-17 16:39:39 -07:00
const { salt , passwordHash , passwordAlgorithm } = createPasswordRecord ( adminPassword ) ;
2026-06-15 15:04:43 -07:00
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 ] ,
) ;
}
} ;
2026-06-15 22:09:38 -07:00
/** 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 ] ,
) ;
} ;
2026-07-06 16:06:26 +08:00
/** 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 ] ,
) ;
} ;
2026-07-29 14:04:11 +08:00
/** Enable MindSearch (tkmind_search via SearXNG) for existing role defaults. */
const upgradeSearchExternalCapability = 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', 'search_external', 1, ?)
ON DUPLICATE KEY UPDATE allowed = 1, updated_at = VALUES(updated_at) ` ,
[ now ] ,
) ;
} ;
/** Allow network egress so platform/web and MindSearch MCP can run searches. */
const upgradeSearchNetworkEgress = async ( ) => {
const now = Date . now ( ) ;
await pool . query (
` INSERT INTO h5_user_policies (subject_type, subject_id, policy_key, policy_value, updated_at)
VALUES ('role', 'user', 'network_egress', 'allow', ?)
ON DUPLICATE KEY UPDATE policy_value = 'allow', updated_at = VALUES(updated_at) ` ,
[ now ] ,
) ;
} ;
2026-06-15 22:09:38 -07:00
/** 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 ] ,
) ;
}
} ;
2026-06-15 15:04:43 -07:00
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' ) {
2026-06-26 13:36:05 +08:00
const skillMap = Object . fromEntries ( skillCatalog . map ( ( item ) => [ item . name , true ] ) ) ;
2026-06-15 15:04:43 -07:00
return {
unrestricted : true ,
capabilities : Object . fromEntries ( catalogKeys ( ) . map ( ( key ) => [ key , true ] ) ) ,
2026-06-26 13:36:05 +08:00
skills : skillMap ,
grantedSkills : grantedSkillNames ( skillMap ) ,
2026-06-15 15:04:43 -07:00
} ;
}
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 ) ,
} ;
} ;
2026-07-27 15:34:35 +08:00
const getAgentSessionPolicy = async (
userId ,
{
toolMode = 'chat' ,
sessionId = null ,
packageId = null ,
} = { } ,
) => {
2026-06-15 15:04:43 -07:00
const user = await getUserById ( userId ) ;
if ( ! user ) throw new Error ( '用户不存在' ) ;
const capabilityState = await resolveUserCapabilities ( user ) ;
const policyState = await resolveUserPolicies ( user ) ;
2026-07-15 13:46:17 +08:00
const mindSearchConfig = getMindSearchConfig ? await getMindSearchConfig ( { userId , user } ) : null ;
2026-06-15 15:04:43 -07:00
await syncUserSkillsForUser ( user ) ;
if ( capabilityState . unrestricted ) {
return {
2026-07-23 22:27:07 +08:00
... buildAgentExtensionPolicy ( capabilityState . capabilities , {
unrestricted : true ,
toolMode ,
mindSearchConfig ,
userId : user . id ,
} ) ,
2026-06-15 15:04:43 -07:00
policies : { } ,
unrestricted : true ,
2026-07-02 07:12:18 +08:00
toolMode ,
2026-06-15 15:04:43 -07:00
} ;
}
const effectiveCapabilities = applyPoliciesToCapabilities (
capabilityState . capabilities ,
policyState . policies ,
) ;
2026-06-26 13:36:05 +08:00
// Wire up the sandbox MCP for user workspace tools and the private data space.
2026-07-17 11:52:21 +08:00
// File and Excel operations are OS-bound to the user's workspace;
// private_data_* tools only touch the user's isolated PostgreSQL schema.
2026-06-17 16:39:39 -07:00
let sandboxMcp = null ;
2026-07-17 11:52:21 +08:00
if (
effectiveCapabilities . static _publish ||
effectiveCapabilities . private _data _space ||
effectiveCapabilities . excel _analysis
) {
2026-06-17 16:39:39 -07:00
try {
2026-07-03 08:40:28 +08:00
const workspaceCapability = resolveMindSpaceAgentWorkspaceCapability ( {
h5Root ,
env ,
user ,
} ) ;
2026-07-17 10:44:41 +08:00
const containerFlag = String ( env . GOOSED _MCP _CONTAINERIZED ? ? '' ) . trim ( ) ;
const containerized = containerFlag
? containerFlag === '1'
: Boolean (
env . GOOSED _MCP _NODE _PATH &&
path . resolve ( env . GOOSED _MCP _NODE _PATH ) !== path . resolve ( process . execPath ) ,
) ;
2026-06-17 16:39:39 -07:00
sandboxMcp = {
2026-06-27 22:28:41 +08:00
// 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.
2026-07-03 08:40:28 +08:00
serverPath : resolveSandboxMcpServerPath ( env . GOOSED _MCP _SERVER _PATH ) ,
sandboxRoot : workspaceCapability . sandboxRoot ,
workspaceRoot : workspaceCapability . workspaceRoot ,
workspaceRef : workspaceCapability . workspaceRef ,
2026-07-27 15:34:35 +08:00
sessionId :
String ( sessionId ? ? '' ) . trim ( ) ||
null ,
packageId :
String ( packageId ? ? '' ) . trim ( ) ||
( String ( sessionId ? ? '' ) . trim ( )
? ` cp_ ${ String (
sessionId ,
) . trim ( ) } `
: null ) ,
mcpBaseUrl :
env . MINDSPACE _MCP _BASE _URL ? ?
env . MINDSPACE _REMOTE _BASE _URL ,
mcpTokenSecret :
env . MINDSPACE _MCP _TOKEN _SECRET ,
2026-06-26 13:36:05 +08:00
userId : user . id ,
2026-07-03 08:40:28 +08:00
nodeExecPath : env . GOOSED _MCP _NODE _PATH ,
2026-07-17 10:44:41 +08:00
containerized ,
userDataBackend : env . MINDSPACE _USERDATA _BACKEND ,
userDataPgUrl : env . MINDSPACE _USERDATA _PG _URL ,
userDataMcpPgUrl : env . MINDSPACE _USERDATA _MCP _PG _URL ,
userDataPgHostGateway : env . MINDSPACE _USERDATA _MCP _PG _HOST ,
userDataAutoProvision : env . MINDSPACE _USERDATA _AUTO _PROVISION ,
2026-07-19 18:39:46 +08:00
agentApiBaseUrl : env . MINDSPACE _AGENT _API _BASE _URL ,
2026-07-24 18:39:24 +08:00
publicBaseUrl : env . H5 _PUBLIC _BASE _URL ,
portalBaseUrl : env . H5 _PORTAL _BASE _URL ,
portalPort : env . H5 _PORT ,
pageDataDeliveryBaseUrl :
env . MEMIND _PAGE _DATA _DELIVERY _BASE _URL ,
2026-07-19 18:39:46 +08:00
internalAgentSecret :
env . MINDSPACE _INTERNAL _AGENT _SECRET ? ? env . TKMIND _SERVER _ _SECRET _KEY ,
2026-06-17 16:39:39 -07:00
} ;
} catch ( err ) {
console . warn ( '[getAgentSessionPolicy] sandbox MCP setup failed, falling back:' , err ? . message ) ;
}
}
2026-06-15 15:04:43 -07:00
return {
... buildAgentExtensionPolicy ( effectiveCapabilities , {
unrestricted : false ,
policies : policyState . policies ,
2026-06-17 16:39:39 -07:00
sandboxMcp ,
2026-07-02 07:12:18 +08:00
toolMode ,
2026-07-15 13:46:17 +08:00
mindSearchConfig ,
2026-07-23 22:27:07 +08:00
userId : user . id ,
2026-06-15 15:04:43 -07:00
} ) ,
2026-06-17 16:39:39 -07:00
capabilities : effectiveCapabilities ,
2026-06-15 15:04:43 -07:00
policies : policyState . policies ,
unrestricted : false ,
2026-07-02 07:12:18 +08:00
toolMode ,
2026-06-15 15:04:43 -07:00
} ;
} ;
2026-07-27 15:34:35 +08:00
const getCodeAgentSessionPolicy = async (
userId ,
scope = { } ,
) =>
getAgentSessionPolicy ( userId , {
... scope ,
toolMode : 'code' ,
} ) ;
2026-07-02 07:12:18 +08:00
2026-06-15 15:04:43 -07:00
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 ) {
2026-06-26 13:36:05 +08:00
if ( ! adminPassword ) return ;
2026-06-15 15:04:43 -07:00
await createUser ( {
username : adminUsername ,
password : adminPassword ,
displayName : '管理员' ,
balanceCents : 9_999_999_99 ,
role : 'admin' ,
} ) ;
} else {
const adminId = rows [ 0 ] . id ;
const now = Date . now ( ) ;
2026-06-26 13:36:05 +08:00
const adminLayout = await publishLayoutFor ( {
id : adminId ,
username : adminUsername ,
displayName : '管理员' ,
} ) ;
2026-06-15 15:04:43 -07:00
await pool . query ( ` UPDATE h5_users SET workspace_root = ?, updated_at = ? WHERE id = ? ` , [
2026-06-26 13:36:05 +08:00
adminLayout . publishDir ,
2026-06-15 15:04:43 -07:00
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') ` ,
2026-06-26 13:36:05 +08:00
[ adminId , adminLayout . publishDir ] ,
2026-06-15 15:04:43 -07:00
) ;
await pool . query (
` UPDATE h5_user_wallets SET balance_cents = GREATEST(balance_cents, ?), updated_at = ? WHERE user_id = ? ` ,
[ 9_999_999_99 , now , adminId ] ,
) ;
}
2026-06-26 13:36:05 +08:00
if ( adminPassword ) {
await syncAdminPassword ( ) ;
}
2026-06-15 15:04:43 -07:00
await seedRoleCapabilityDefaults ( ) ;
2026-06-15 22:09:38 -07:00
await upgradeMemoryStoreCapability ( ) ;
2026-07-06 16:06:26 +08:00
await upgradeWebCapability ( ) ;
2026-07-29 14:04:11 +08:00
await upgradeSearchExternalCapability ( ) ;
await upgradeSearchNetworkEgress ( ) ;
2026-06-15 22:09:38 -07:00
await upgradeDefaultUserCapabilities ( ) ;
2026-06-15 15:04:43 -07:00
await seedRolePolicyDefaults ( ) ;
await seedRoleSkillDefaults ( ) ;
2026-06-15 22:09:38 -07:00
await upgradeDefaultUserSkills ( ) ;
2026-06-15 15:04:43 -07:00
await repairAllUserPublishDirs ( ) ;
} ;
2026-06-15 15:47:19 -07:00
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 ;
} ;
2026-06-16 16:20:04 +08:00
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 ;
} ;
2026-06-19 23:06:43 +08:00
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)
2026-06-26 13:36:05 +08:00
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) ` ,
2026-06-19 23:06:43 +08:00
[ id , userId , appId , openid , agentSessionId , status , now , now ] ,
) ;
2026-06-26 13:36:05 +08:00
const route = await getWechatAgentRoute ( appId , openid ) ;
return route ? . id ? ? id ;
2026-06-19 23:06:43 +08:00
} ;
const clearWechatAgentRoute = async ( appId , openid ) => {
await pool . query ( ` DELETE FROM h5_wechat_agent_routes WHERE app_id = ? AND openid = ? ` , [
appId ,
openid ,
] ) ;
} ;
2026-07-01 21:10:56 +08:00
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 ] ,
) ;
} ;
2026-07-04 12:10:22 +08:00
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 ) ;
} ;
2026-07-04 12:21:14 +08:00
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 ) ;
} ;
2026-06-19 23:06:43 +08:00
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 ) ] ,
) ;
} ;
2026-06-26 13:36:05 +08:00
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 ;
} ;
2026-06-15 15:47:19 -07:00
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 } ;
} ;
2026-06-15 15:04:43 -07:00
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' ) ;
2026-06-17 16:39:39 -07:00
const { salt , passwordHash , passwordAlgorithm } = createPasswordRecord ( randomPassword ) ;
2026-06-15 15:04:43 -07:00
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,
2026-06-30 20:39:46 +08:00
role, status, plan_type, workspace_root, signup_source, low_balance_gift_eligible,
low_balance_gift_granted_at, created_at, updated_at)
2026-08-22 08:45:43 +08:00
VALUES (?, ?, ?, NULL, ?, ?, ?, ?, 'user', 'active', 'free', ?, 'wechat', 0, NULL, ?, ?) ` ,
2026-06-15 15:04:43 -07:00
[
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 ] ,
) ;
2026-06-15 22:09:38 -07:00
await recordSignupBonus ( conn , userId , defaultSignupBalanceCents , now ) ;
2026-06-15 15:04:43 -07:00
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 ,
} ) ;
2026-07-13 15:29:29 +08:00
await ensureUserDataSpaceForUser ( { id : userId , role : 'user' } , workspaceRoot ) ;
2026-06-15 15:04:43 -07:00
await conn . commit ( ) ;
ensureWorkspace ( workspaceRoot ) ;
2026-06-15 22:09:38 -07:00
ensureUserMemoryProfile ( workspaceRoot , {
userId ,
displayName ,
username : normalized ,
slug : normalized ,
} ) ;
2026-06-26 13:36:05 +08:00
if ( subscriptionService ) {
subscriptionService . grantSubscription ( userId , 'free' , null , null , '注册赠送免费套餐' ) . catch ( ( ) => { } ) ;
}
2026-06-15 15:04:43 -07:00
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 ( ) ;
}
} ;
2026-06-15 15:47:19 -07:00
const resolveWechatAuth = async ( {
2026-06-15 15:04:43 -07:00
appId ,
openid ,
unionid ,
nickname ,
avatarUrl ,
2026-06-15 15:47:19 -07:00
intent = 'login' ,
bindUserId = null ,
returnTo = '/' ,
utmSource = null ,
utmMedium = null ,
utmCampaign = null ,
2026-06-15 15:04:43 -07:00
now = Date . now ( ) ,
} ) => {
2026-06-15 15:47:19 -07:00
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 } ;
}
}
2026-06-15 15:04:43 -07:00
if ( binding ) {
if ( binding . status === 'disabled' ) {
return { ok : false , message : '账户已禁用,请联系管理员' } ;
}
2026-06-15 15:47:19 -07:00
return loginBoundWechatUser ( {
userId : binding . user _id ,
appId ,
openid ,
unionid ,
nickname ,
avatarUrl ,
now ,
} ) . then ( ( result ) => ( result . ok ? { ... result , action : 'login' } : result ) ) ;
2026-06-15 15:04:43 -07:00
}
2026-06-15 15:47:19 -07:00
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 ( {
2026-06-15 15:04:43 -07:00
appId ,
openid ,
unionid ,
nickname ,
avatarUrl ,
2026-06-15 15:47:19 -07:00
returnTo ,
utmSource ,
utmMedium ,
utmCampaign ,
2026-06-15 15:04:43 -07:00
now ,
} ) ;
2026-06-15 15:47:19 -07:00
return {
ok : true ,
action : 'binding_gate' ,
pendingToken ,
wechatProfile : {
nickname : nickname ? ? null ,
avatarUrl : avatarUrl ? ? null ,
} ,
returnTo ,
utmSource ,
utmMedium ,
utmCampaign ,
} ;
} ;
2026-06-15 15:04:43 -07:00
2026-06-15 15:47:19 -07:00
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 ) ;
2026-06-15 15:04:43 -07:00
return {
ok : true ,
token ,
user : registered . user ,
isNewUser : true ,
2026-06-15 15:47:19 -07:00
returnTo : pending . return _to || '/' ,
utmSource : pending . utm _source ,
utmMedium : pending . utm _medium ,
utmCampaign : pending . utm _campaign ,
2026-06-15 15:04:43 -07:00
} ;
} ;
2026-06-15 15:47:19 -07:00
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 ;
} ;
2026-07-11 12:41:37 +08:00
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 ,
} ;
} ;
2026-06-15 15:04:43 -07:00
return {
USER _COOKIE ,
register ,
login ,
loginByWechat ,
2026-07-11 12:41:37 +08:00
loginByWechatMiniProgram ,
2026-06-15 15:47:19 -07:00
resolveWechatAuth ,
completeWechatRegister ,
completeWechatBindAccount ,
getWechatPendingBind ,
getWechatBindingStatus ,
2026-06-16 16:20:04 +08:00
getWechatOpenidForUser ,
2026-06-26 13:36:05 +08:00
setRechargeNotifier ( callback ) {
rechargeNotifier = typeof callback === 'function' ? callback : null ;
} ,
2026-08-22 08:45:43 +08:00
setSubscriptionService ( service ) {
subscriptionService = service ? ? null ;
} ,
2026-06-19 23:06:43 +08:00
findWechatUserByOpenid ,
getWechatAgentRoute ,
upsertWechatAgentRoute ,
clearWechatAgentRoute ,
2026-07-01 21:10:56 +08:00
touchWechatAgentRoute ,
2026-07-04 12:10:22 +08:00
countWechatAgentSessionMessages ,
2026-07-04 12:21:14 +08:00
getWechatAgentSessionSnapshotMessageCount ,
2026-06-19 23:06:43 +08:00
recordWechatMpMessage ,
finishWechatMpMessage ,
2026-06-26 13:36:05 +08:00
insertWechatMpMessageDetail ,
2026-06-15 15:04:43 -07:00
resetPassword ,
verify ,
revoke ,
revokeAllSessionsForUser ,
getMe ,
listPathGrants ,
resolveWorkingDir ,
getUserPublishLayout ,
isPathAllowed ,
repairAllUserPublishDirs ,
2026-07-13 15:29:29 +08:00
ensureAllUserDataSpaces ,
2026-06-15 15:04:43 -07:00
registerAgentSession ,
2026-06-19 23:06:43 +08:00
getSessionNode ,
2026-06-27 08:25:02 +08:00
getSessionTarget ,
2026-06-15 22:09:38 -07:00
unregisterAgentSession ,
2026-06-15 15:04:43 -07:00
ownsSession ,
listOwnedSessionIds ,
2026-07-01 21:10:56 +08:00
setSessionOrigin ,
getSessionOrigins ,
2026-06-15 15:04:43 -07:00
canUseChat ,
2026-06-26 13:36:05 +08:00
getUserById ,
getUserPublic ,
2026-06-15 15:04:43 -07:00
listUsers ,
createUser ,
updateUser ,
2026-06-26 13:36:05 +08:00
purchaseSpaceQuota ,
2026-06-15 15:04:43 -07:00
recharge ,
2026-07-04 14:01:00 +08:00
getBillingState ,
2026-06-15 15:04:43 -07:00
billSessionUsage ,
listUsageRecords ,
listBillingLedger ,
getAdminSummary ,
ensureAdminUser ,
seedRoleCapabilityDefaults ,
resolveUserCapabilities ,
getAgentSessionPolicy ,
2026-07-02 07:12:18 +08:00
getCodeAgentSessionPolicy ,
2026-06-15 15:04:43 -07:00
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 } ) ;
}
2026-07-10 20:05:52 +08:00
/** 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 ;
}
2026-06-15 15:04:43 -07:00
/** 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 ( ) ;
2026-06-17 16:39:39 -07:00
if ( hostname === 'localhost' || hostname . endsWith ( '.localhost' ) ) {
return '.localhost' ;
}
2026-06-15 15:04:43 -07:00
if ( hostname === 'tkmind.cn' || hostname . endsWith ( '.tkmind.cn' ) ) {
return '.tkmind.cn' ;
}
} catch {
// ignore invalid base URL
}
return null ;
}
2026-06-15 15:47:19 -07:00
/** 本地 localhost 开发时不设置 Domain,否则浏览器不会保存跨域 cookie。 */
export function resolveCookieDomainForRequest ( req ) {
2026-06-17 16:39:39 -07:00
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' ;
}
2026-06-26 13:36:05 +08:00
if (
hostname === 'localhost' ||
hostname === '127.0.0.1' ||
hostname === '::1' ||
net . isIP ( hostname )
) {
return null ;
}
2026-06-17 16:39:39 -07:00
}
const hostname = String ( req ? . get ? . ( 'host' ) ? ? req ? . hostname ? ? '' )
. split ( ':' ) [ 0 ]
. toLowerCase ( ) ;
2026-06-26 13:36:05 +08:00
if (
! hostname ||
hostname === 'localhost' ||
hostname === '127.0.0.1' ||
hostname === '::1' ||
net . isIP ( hostname )
) {
2026-06-15 15:47:19 -07:00
return null ;
}
return resolveCookieDomain ( ) ;
}