mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-22 16:43:35 +00:00
feat(admin): 余额脚本驱动的余额/配额刷新与管理端体验修复
- 明确刷新语义:仅脚本启用且已配置时触发远程查询;未配置时前端禁用并提示\n- 新增余额脚本安全开关 BALANCE_SCRIPT_ENABLED(默认开启),脚本测试接口受控\n- Redis 增加单账户脚本配置存取,响应透出 scriptEnabled/scriptConfigured 供 UI 判定\n- accountBalanceService:本地统计汇总改用 SCAN+pipeline,避免 KEYS;仅缓存远程成功结果,避免失败/降级覆盖有效缓存\n- 管理端体验:刷新按钮按配置状态灰置;脚本弹窗内容可滚动、底部操作栏固定,并 append-to-body 使弹窗跟随当前视窗
This commit is contained in:
@@ -1615,15 +1615,17 @@ class RedisClient {
|
||||
}
|
||||
|
||||
// 🧩 账户余额脚本配置
|
||||
async setBalanceScriptConfig(platform, accountId, config) {
|
||||
async setBalanceScriptConfig(platform, accountId, scriptConfig) {
|
||||
const key = `account_balance_script:${platform}:${accountId}`
|
||||
await this.client.set(key, JSON.stringify(config || {}))
|
||||
await this.client.set(key, JSON.stringify(scriptConfig || {}))
|
||||
}
|
||||
|
||||
async getBalanceScriptConfig(platform, accountId) {
|
||||
const key = `account_balance_script:${platform}:${accountId}`
|
||||
const raw = await this.client.get(key)
|
||||
if (!raw) return null
|
||||
if (!raw) {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
return JSON.parse(raw)
|
||||
} catch (error) {
|
||||
|
||||
@@ -3,6 +3,7 @@ const { authenticateAdmin } = require('../../middleware/auth')
|
||||
const logger = require('../../utils/logger')
|
||||
const accountBalanceService = require('../../services/accountBalanceService')
|
||||
const balanceScriptService = require('../../services/balanceScriptService')
|
||||
const { isBalanceScriptEnabled } = require('../../utils/featureFlags')
|
||||
|
||||
const router = express.Router()
|
||||
|
||||
@@ -47,7 +48,7 @@ router.get('/accounts/:accountId/balance', authenticateAdmin, async (req, res) =
|
||||
}
|
||||
})
|
||||
|
||||
// 2) 强制刷新账户余额(触发 Provider)
|
||||
// 2) 强制刷新账户余额(强制触发查询:优先脚本;Provider 仅为降级)
|
||||
// POST /admin/accounts/:accountId/balance/refresh
|
||||
// Body: { platform: 'xxx' }
|
||||
router.post('/accounts/:accountId/balance/refresh', authenticateAdmin, async (req, res) => {
|
||||
@@ -174,6 +175,12 @@ router.post('/accounts/:accountId/balance/script/test', authenticateAdmin, async
|
||||
return res.status(valid.status).json({ success: false, error: valid.error })
|
||||
}
|
||||
|
||||
if (!isBalanceScriptEnabled()) {
|
||||
return res
|
||||
.status(403)
|
||||
.json({ success: false, error: '余额脚本功能已禁用(可通过 BALANCE_SCRIPT_ENABLED=true 启用)' })
|
||||
}
|
||||
|
||||
const payload = req.body || {}
|
||||
const scriptBody = payload.scriptBody
|
||||
if (!scriptBody) {
|
||||
|
||||
@@ -22,7 +22,6 @@ const droidAccountsRoutes = require('./droidAccounts')
|
||||
const dashboardRoutes = require('./dashboard')
|
||||
const usageStatsRoutes = require('./usageStats')
|
||||
const accountBalanceRoutes = require('./accountBalance')
|
||||
const balanceScriptsRoutes = require('./balanceScripts')
|
||||
const systemRoutes = require('./system')
|
||||
const concurrencyRoutes = require('./concurrency')
|
||||
const claudeRelayConfigRoutes = require('./claudeRelayConfig')
|
||||
@@ -39,7 +38,6 @@ router.use('/', droidAccountsRoutes)
|
||||
router.use('/', dashboardRoutes)
|
||||
router.use('/', usageStatsRoutes)
|
||||
router.use('/', accountBalanceRoutes)
|
||||
router.use('/', balanceScriptsRoutes)
|
||||
router.use('/', systemRoutes)
|
||||
router.use('/', concurrencyRoutes)
|
||||
router.use('/', claudeRelayConfigRoutes)
|
||||
|
||||
@@ -2,6 +2,7 @@ const redis = require('../models/redis')
|
||||
const balanceScriptService = require('./balanceScriptService')
|
||||
const logger = require('../utils/logger')
|
||||
const CostCalculator = require('../utils/costCalculator')
|
||||
const { isBalanceScriptEnabled } = require('../utils/featureFlags')
|
||||
|
||||
class AccountBalanceService {
|
||||
constructor(options = {}) {
|
||||
@@ -277,6 +278,20 @@ class AccountBalanceService {
|
||||
throw new Error('账户缺少 id')
|
||||
}
|
||||
|
||||
// 余额脚本配置状态(用于前端控制“刷新余额”按钮)
|
||||
let scriptConfig = null
|
||||
let scriptConfigured = false
|
||||
if (typeof this.redis?.getBalanceScriptConfig === 'function') {
|
||||
scriptConfig = await this.redis.getBalanceScriptConfig(platform, accountId)
|
||||
scriptConfigured = !!(
|
||||
scriptConfig &&
|
||||
scriptConfig.scriptBody &&
|
||||
String(scriptConfig.scriptBody).trim().length > 0
|
||||
)
|
||||
}
|
||||
const scriptEnabled = isBalanceScriptEnabled()
|
||||
const scriptMeta = { scriptEnabled, scriptConfigured }
|
||||
|
||||
const localBalance = await this._getBalanceFromLocal(accountId, platform)
|
||||
const localStatistics = localBalance.statistics || {}
|
||||
|
||||
@@ -300,7 +315,8 @@ class AccountBalanceService {
|
||||
accountId,
|
||||
platform,
|
||||
'cache',
|
||||
cached.ttlSeconds
|
||||
cached.ttlSeconds,
|
||||
scriptMeta
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -317,15 +333,16 @@ class AccountBalanceService {
|
||||
},
|
||||
accountId,
|
||||
platform,
|
||||
'local'
|
||||
'local',
|
||||
null,
|
||||
scriptMeta
|
||||
)
|
||||
}
|
||||
|
||||
// 强制查询:调用 Provider,失败自动降级到本地统计
|
||||
const scriptConfig = await this.redis.getBalanceScriptConfig(platform, accountId)
|
||||
// 强制查询:优先脚本(如启用且已配置),否则调用 Provider;失败自动降级到本地统计
|
||||
let providerResult
|
||||
|
||||
if (scriptConfig && scriptConfig.scriptBody) {
|
||||
if (scriptEnabled && scriptConfigured) {
|
||||
providerResult = await this._getBalanceFromScript(scriptConfig, accountId, platform)
|
||||
} else {
|
||||
const provider = this.providers.get(platform)
|
||||
@@ -342,15 +359,28 @@ class AccountBalanceService {
|
||||
},
|
||||
accountId,
|
||||
platform,
|
||||
'local'
|
||||
'local',
|
||||
null,
|
||||
scriptMeta
|
||||
)
|
||||
}
|
||||
providerResult = await this._getBalanceFromProvider(provider, account)
|
||||
}
|
||||
|
||||
await this.redis.setAccountBalance(platform, accountId, providerResult, this.CACHE_TTL_SECONDS)
|
||||
const isRemoteSuccess =
|
||||
providerResult.status === 'success' && ['api', 'script'].includes(providerResult.queryMethod)
|
||||
|
||||
const source = providerResult.status === 'success' ? 'api' : 'local'
|
||||
// 仅缓存“真实远程查询成功”的结果,避免把字段/本地降级结果当作 API 结果缓存 1h
|
||||
if (isRemoteSuccess) {
|
||||
await this.redis.setAccountBalance(
|
||||
platform,
|
||||
accountId,
|
||||
providerResult,
|
||||
this.CACHE_TTL_SECONDS
|
||||
)
|
||||
}
|
||||
|
||||
const source = isRemoteSuccess ? 'api' : 'local'
|
||||
|
||||
return this._buildResponse(
|
||||
{
|
||||
@@ -364,7 +394,9 @@ class AccountBalanceService {
|
||||
},
|
||||
accountId,
|
||||
platform,
|
||||
source
|
||||
source,
|
||||
null,
|
||||
scriptMeta
|
||||
)
|
||||
}
|
||||
|
||||
@@ -507,35 +539,50 @@ class AccountBalanceService {
|
||||
async _sumModelCostsByKeysPattern(pattern) {
|
||||
try {
|
||||
const client = this.redis.getClientSafe()
|
||||
const keys = await client.keys(pattern)
|
||||
if (!keys || keys.length === 0) {
|
||||
return 0
|
||||
}
|
||||
|
||||
const pipeline = client.pipeline()
|
||||
keys.forEach((key) => pipeline.hgetall(key))
|
||||
const results = await pipeline.exec()
|
||||
|
||||
let totalCost = 0
|
||||
for (let i = 0; i < results.length; i += 1) {
|
||||
const [, data] = results[i] || []
|
||||
if (!data || Object.keys(data).length === 0) {
|
||||
let cursor = '0'
|
||||
const scanCount = 200
|
||||
let iterations = 0
|
||||
const maxIterations = 2000
|
||||
|
||||
do {
|
||||
const [nextCursor, keys] = await client.scan(cursor, 'MATCH', pattern, 'COUNT', scanCount)
|
||||
cursor = nextCursor
|
||||
iterations += 1
|
||||
|
||||
if (!keys || keys.length === 0) {
|
||||
continue
|
||||
}
|
||||
|
||||
const parts = String(keys[i]).split(':')
|
||||
const model = parts[4] || 'unknown'
|
||||
const pipeline = client.pipeline()
|
||||
keys.forEach((key) => pipeline.hgetall(key))
|
||||
const results = await pipeline.exec()
|
||||
|
||||
const usage = {
|
||||
input_tokens: parseInt(data.inputTokens || 0),
|
||||
output_tokens: parseInt(data.outputTokens || 0),
|
||||
cache_creation_input_tokens: parseInt(data.cacheCreateTokens || 0),
|
||||
cache_read_input_tokens: parseInt(data.cacheReadTokens || 0)
|
||||
for (let i = 0; i < results.length; i += 1) {
|
||||
const [, data] = results[i] || []
|
||||
if (!data || Object.keys(data).length === 0) {
|
||||
continue
|
||||
}
|
||||
|
||||
const parts = String(keys[i]).split(':')
|
||||
const model = parts[4] || 'unknown'
|
||||
|
||||
const usage = {
|
||||
input_tokens: parseInt(data.inputTokens || 0),
|
||||
output_tokens: parseInt(data.outputTokens || 0),
|
||||
cache_creation_input_tokens: parseInt(data.cacheCreateTokens || 0),
|
||||
cache_read_input_tokens: parseInt(data.cacheReadTokens || 0)
|
||||
}
|
||||
|
||||
const costResult = CostCalculator.calculateCost(usage, model)
|
||||
totalCost += costResult.costs.total || 0
|
||||
}
|
||||
|
||||
const costResult = CostCalculator.calculateCost(usage, model)
|
||||
totalCost += costResult.costs.total || 0
|
||||
}
|
||||
if (iterations >= maxIterations) {
|
||||
this.logger.warn(`SCAN 次数超过上限,停止汇总:${pattern}`)
|
||||
break
|
||||
}
|
||||
} while (cursor !== '0')
|
||||
|
||||
return totalCost
|
||||
} catch (error) {
|
||||
@@ -610,7 +657,7 @@ class AccountBalanceService {
|
||||
return new Date(resetAtMs).toISOString()
|
||||
}
|
||||
|
||||
_buildResponse(balanceData, accountId, platform, source, ttlSeconds = null) {
|
||||
_buildResponse(balanceData, accountId, platform, source, ttlSeconds = null, extraData = {}) {
|
||||
const now = new Date()
|
||||
|
||||
const amount = typeof balanceData.balance === 'number' ? balanceData.balance : null
|
||||
@@ -642,7 +689,8 @@ class AccountBalanceService {
|
||||
lastRefreshAt: balanceData.lastRefreshAt || now.toISOString(),
|
||||
cacheExpiresAt,
|
||||
status: balanceData.status || 'success',
|
||||
error: balanceData.errorMessage || null
|
||||
error: balanceData.errorMessage || null,
|
||||
...(extraData && typeof extraData === 'object' ? extraData : {})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,81 +1,13 @@
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
const vm = require('vm')
|
||||
const axios = require('axios')
|
||||
const logger = require('../utils/logger')
|
||||
const { isBalanceScriptEnabled } = require('../utils/featureFlags')
|
||||
|
||||
/**
|
||||
* 可配置脚本余额查询服务
|
||||
* - 存储位置:data/balanceScripts.json
|
||||
* 可配置脚本余额查询执行器
|
||||
* - 脚本格式:({ request: {...}, extractor: function(response){...} })
|
||||
* - 模板变量:{{baseUrl}}, {{apiKey}}, {{token}}, {{accountId}}, {{platform}}, {{extra}}
|
||||
*/
|
||||
class BalanceScriptService {
|
||||
constructor() {
|
||||
this.filePath = path.join(__dirname, '..', '..', 'data', 'balanceScripts.json')
|
||||
this.ensureStore()
|
||||
}
|
||||
|
||||
ensureStore() {
|
||||
const dir = path.dirname(this.filePath)
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true })
|
||||
}
|
||||
if (!fs.existsSync(this.filePath)) {
|
||||
fs.writeFileSync(this.filePath, JSON.stringify({}, null, 2))
|
||||
}
|
||||
}
|
||||
|
||||
loadAll() {
|
||||
try {
|
||||
const raw = fs.readFileSync(this.filePath, 'utf8')
|
||||
return JSON.parse(raw || '{}')
|
||||
} catch (error) {
|
||||
logger.error('读取余额脚本配置失败', error)
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
saveAll(data) {
|
||||
fs.writeFileSync(this.filePath, JSON.stringify(data, null, 2))
|
||||
}
|
||||
|
||||
listConfigs() {
|
||||
const all = this.loadAll()
|
||||
return Object.values(all)
|
||||
}
|
||||
|
||||
getConfig(name) {
|
||||
const all = this.loadAll()
|
||||
if (all[name]) {
|
||||
return all[name]
|
||||
}
|
||||
return {
|
||||
name,
|
||||
baseUrl: '',
|
||||
apiKey: '',
|
||||
token: '',
|
||||
timeoutSeconds: 10,
|
||||
autoIntervalMinutes: 0,
|
||||
scriptBody:
|
||||
"({\n request: {\n url: \"{{baseUrl}}/user/balance\",\n method: \"GET\",\n headers: {\n \"Authorization\": \"Bearer {{apiKey}}\",\n \"User-Agent\": \"cc-switch/1.0\"\n }\n },\n extractor: function(response) {\n return {\n isValid: !response.error,\n remaining: response.balance,\n unit: \"USD\"\n };\n }\n})",
|
||||
updatedAt: null
|
||||
}
|
||||
}
|
||||
|
||||
saveConfig(name, payload) {
|
||||
const all = this.loadAll()
|
||||
const config = {
|
||||
...this.getConfig(name),
|
||||
...payload,
|
||||
name,
|
||||
updatedAt: new Date().toISOString()
|
||||
}
|
||||
all[name] = config
|
||||
this.saveAll(all)
|
||||
return config
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行脚本:返回标准余额结构 + 原始响应
|
||||
* @param {object} options
|
||||
@@ -84,6 +16,12 @@ class BalanceScriptService {
|
||||
* - timeoutSeconds: number
|
||||
*/
|
||||
async execute(options = {}) {
|
||||
if (!isBalanceScriptEnabled()) {
|
||||
const error = new Error('余额脚本功能已禁用(可通过 BALANCE_SCRIPT_ENABLED=true 启用)')
|
||||
error.code = 'BALANCE_SCRIPT_DISABLED'
|
||||
throw error
|
||||
}
|
||||
|
||||
const scriptBody = options.scriptBody?.trim()
|
||||
if (!scriptBody) {
|
||||
throw new Error('脚本内容为空')
|
||||
@@ -99,7 +37,7 @@ class BalanceScriptService {
|
||||
let scriptResult
|
||||
try {
|
||||
const wrapped = scriptBody.startsWith('(') ? scriptBody : `(${scriptBody})`
|
||||
const script = new vm.Script(wrapped, { timeout: timeoutMs })
|
||||
const script = new vm.Script(wrapped)
|
||||
scriptResult = script.runInNewContext(sandbox, { timeout: timeoutMs })
|
||||
} catch (error) {
|
||||
throw new Error(`脚本解析失败: ${error.message}`)
|
||||
@@ -111,12 +49,16 @@ class BalanceScriptService {
|
||||
|
||||
const variables = options.variables || {}
|
||||
const request = this.applyTemplates(scriptResult.request || {}, variables)
|
||||
const extractor = scriptResult.extractor
|
||||
const { extractor } = scriptResult
|
||||
|
||||
if (!request.url) {
|
||||
if (!request?.url || typeof request.url !== 'string') {
|
||||
throw new Error('脚本 request.url 不能为空')
|
||||
}
|
||||
|
||||
if (typeof extractor !== 'function') {
|
||||
throw new Error('脚本 extractor 必须是函数')
|
||||
}
|
||||
|
||||
const axiosConfig = {
|
||||
url: request.url,
|
||||
method: (request.method || 'GET').toUpperCase(),
|
||||
@@ -131,23 +73,24 @@ class BalanceScriptService {
|
||||
axiosConfig.data = request.body || request.data
|
||||
}
|
||||
|
||||
let httpResponse = null
|
||||
let httpResponse
|
||||
try {
|
||||
httpResponse = await axios(axiosConfig)
|
||||
} catch (error) {
|
||||
const status = error.response?.status
|
||||
const data = error.response?.data
|
||||
throw new Error(`请求失败: ${status || ''} ${error.message}${data ? ` | ${JSON.stringify(data)}` : ''}`)
|
||||
const { response } = error || {}
|
||||
const { status, data } = response || {}
|
||||
throw new Error(
|
||||
`请求失败: ${status || ''} ${error.message}${data ? ` | ${JSON.stringify(data)}` : ''}`
|
||||
)
|
||||
}
|
||||
|
||||
const responseData = httpResponse?.data
|
||||
|
||||
let extracted = {}
|
||||
if (typeof extractor === 'function') {
|
||||
try {
|
||||
extracted = extractor(responseData) || {}
|
||||
} catch (error) {
|
||||
throw new Error(`extractor 执行失败: ${error.message}`)
|
||||
}
|
||||
try {
|
||||
extracted = extractor(responseData) || {}
|
||||
} catch (error) {
|
||||
throw new Error(`extractor 执行失败: ${error.message}`)
|
||||
}
|
||||
|
||||
const mapped = this.mapExtractorResult(extracted, responseData)
|
||||
@@ -213,29 +156,6 @@ class BalanceScriptService {
|
||||
rawData: responseData || result.raw
|
||||
}
|
||||
}
|
||||
|
||||
async testScript(name, payload = {}) {
|
||||
const config = payload.useBodyConfig ? this.getConfig(name) : this.getConfig(name)
|
||||
const scriptBody = payload.scriptBody || config.scriptBody
|
||||
const timeoutSeconds = payload.timeoutSeconds || config.timeoutSeconds
|
||||
const variables = {
|
||||
baseUrl: payload.baseUrl || config.baseUrl,
|
||||
apiKey: payload.apiKey || config.apiKey,
|
||||
token: payload.token || config.token,
|
||||
accountId: payload.accountId || '',
|
||||
platform: payload.platform || '',
|
||||
extra: payload.extra || ''
|
||||
}
|
||||
|
||||
const result = await this.execute({ scriptBody, variables, timeoutSeconds })
|
||||
return {
|
||||
name,
|
||||
variables,
|
||||
mapped: result.mapped,
|
||||
extracted: result.extracted,
|
||||
response: result.response
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new BalanceScriptService()
|
||||
|
||||
44
src/utils/featureFlags.js
Normal file
44
src/utils/featureFlags.js
Normal file
@@ -0,0 +1,44 @@
|
||||
let config = {}
|
||||
try {
|
||||
// config/config.js 可能在某些环境不存在(例如仅拷贝了 config.example.js)
|
||||
// 为保证可运行,这里做容错处理
|
||||
// eslint-disable-next-line global-require
|
||||
config = require('../../config/config')
|
||||
} catch (error) {
|
||||
config = {}
|
||||
}
|
||||
|
||||
const parseBooleanEnv = (value) => {
|
||||
if (typeof value === 'boolean') {
|
||||
return value
|
||||
}
|
||||
if (typeof value !== 'string') {
|
||||
return false
|
||||
}
|
||||
const normalized = value.trim().toLowerCase()
|
||||
return normalized === 'true' || normalized === '1' || normalized === 'yes' || normalized === 'on'
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否允许执行“余额脚本”(安全开关)
|
||||
* 默认开启,便于保持现有行为;如需禁用请显式设置 BALANCE_SCRIPT_ENABLED=false(环境变量优先)
|
||||
*/
|
||||
const isBalanceScriptEnabled = () => {
|
||||
if (
|
||||
process.env.BALANCE_SCRIPT_ENABLED !== undefined &&
|
||||
process.env.BALANCE_SCRIPT_ENABLED !== ''
|
||||
) {
|
||||
return parseBooleanEnv(process.env.BALANCE_SCRIPT_ENABLED)
|
||||
}
|
||||
|
||||
const fromConfig =
|
||||
config?.accountBalance?.enableBalanceScript ??
|
||||
config?.features?.balanceScriptEnabled ??
|
||||
config?.security?.enableBalanceScript
|
||||
|
||||
return typeof fromConfig === 'boolean' ? fromConfig : true
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
isBalanceScriptEnabled
|
||||
}
|
||||
Reference in New Issue
Block a user