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:
atoz03
2025-12-14 13:43:02 +08:00
parent 26ca696b91
commit f6f4b5cfec
11 changed files with 358 additions and 167 deletions

View File

@@ -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()