From ce496ed9e69b7198218c12fe0fb8e61088d8cc6b Mon Sep 17 00:00:00 2001 From: atoz03 Date: Sat, 13 Dec 2025 00:40:01 +0800 Subject: [PATCH] =?UTF-8?q?feat=EF=BC=9A=E5=8D=95=E8=B4=A6=E6=88=B7?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E4=BD=99=E9=A2=9D=E8=84=9A=E6=9C=AC=20+=20?= =?UTF-8?q?=E5=88=B7=E6=96=B0=E6=8C=89=E9=92=AE=E5=8D=B3=E7=94=A8=E8=84=9A?= =?UTF-8?q?=E6=9C=AC=E2=80=9D=EF=BC=8C=E5=B9=B6=E5=8E=BB=E6=8E=89=E7=8B=AC?= =?UTF-8?q?=E7=AB=8B=E9=A1=B5=E9=9D=A2/=E6=A0=87=E7=AD=BE=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 具体改动 - 后端 - src/models/redis.js:新增脚本配置存取 account_balance_script:{platform}:{accountId}。 - src/services/accountBalanceService.js:支持脚本查询。若账户有脚本配置且 queryApi=true,调用 balanceScriptService.execute 获取余额/配额,缓存后返回。 - src/routes/admin/accountBalance.js:新增接口 - GET /admin/accounts/:id/balance/script?platform=... - PUT /admin/accounts/:id/balance/script?platform=... - POST /admin/accounts/:id/balance/script/test?platform=... - 前端 - 新增弹窗 AccountBalanceScriptModal,在账户管理页每个账户“余额/配额”下方有“配置余额脚本”按钮,支持填写 baseUrl/apiKey/token/extra/超时/自动间隔、编写脚本、测试、保存。 - 将余额脚本独立路由/标签移除。 - 格式/ lint 已通过(新组件及 AccountsView)。 --- src/models/redis.js | 22 ++ src/routes/admin/accountBalance.js | 73 ++++ src/routes/admin/balanceScripts.js | 41 +++ src/routes/admin/index.js | 2 + src/services/accountBalanceService.js | 83 ++++- src/services/balanceScriptService.js | 241 ++++++++++++++ .../accounts/AccountBalanceScriptModal.vue | 262 +++++++++++++++ web/admin-spa/src/views/AccountsView.vue | 43 +++ .../src/views/BalanceScriptsView.vue | 312 ++++++++++++++++++ 9 files changed, 1062 insertions(+), 17 deletions(-) create mode 100644 src/routes/admin/balanceScripts.js create mode 100644 src/services/balanceScriptService.js create mode 100644 web/admin-spa/src/components/accounts/AccountBalanceScriptModal.vue create mode 100644 web/admin-spa/src/views/BalanceScriptsView.vue diff --git a/src/models/redis.js b/src/models/redis.js index 48edb116..3adc51d1 100644 --- a/src/models/redis.js +++ b/src/models/redis.js @@ -1614,6 +1614,28 @@ class RedisClient { await this.client.del(key, localKey) } + // 🧩 账户余额脚本配置 + async setBalanceScriptConfig(platform, accountId, config) { + const key = `account_balance_script:${platform}:${accountId}` + await this.client.set(key, JSON.stringify(config || {})) + } + + async getBalanceScriptConfig(platform, accountId) { + const key = `account_balance_script:${platform}:${accountId}` + const raw = await this.client.get(key) + if (!raw) return null + try { + return JSON.parse(raw) + } catch (error) { + return null + } + } + + async deleteBalanceScriptConfig(platform, accountId) { + const key = `account_balance_script:${platform}:${accountId}` + return await this.client.del(key) + } + // 📈 系统统计 async getSystemStats() { const keys = await Promise.all([ diff --git a/src/routes/admin/accountBalance.js b/src/routes/admin/accountBalance.js index 2acffd7b..2f55c850 100644 --- a/src/routes/admin/accountBalance.js +++ b/src/routes/admin/accountBalance.js @@ -2,6 +2,7 @@ const express = require('express') const { authenticateAdmin } = require('../../middleware/auth') const logger = require('../../utils/logger') const accountBalanceService = require('../../services/accountBalanceService') +const balanceScriptService = require('../../services/balanceScriptService') const router = express.Router() @@ -127,4 +128,76 @@ router.delete('/accounts/:accountId/balance/cache', authenticateAdmin, async (re } }) +// 6) 获取/保存/测试余额脚本配置(单账户) +router.get('/accounts/:accountId/balance/script', authenticateAdmin, async (req, res) => { + try { + const { accountId } = req.params + const { platform } = req.query + + const valid = ensureValidPlatform(platform) + if (!valid.ok) { + return res.status(valid.status).json({ success: false, error: valid.error }) + } + + const config = await accountBalanceService.redis.getBalanceScriptConfig(valid.platform, accountId) + return res.json({ success: true, data: config || null }) + } catch (error) { + logger.error('获取余额脚本配置失败', error) + return res.status(500).json({ success: false, error: error.message }) + } +}) + +router.put('/accounts/:accountId/balance/script', authenticateAdmin, async (req, res) => { + try { + const { accountId } = req.params + const { platform } = req.query + const valid = ensureValidPlatform(platform) + if (!valid.ok) { + return res.status(valid.status).json({ success: false, error: valid.error }) + } + + const payload = req.body || {} + await accountBalanceService.redis.setBalanceScriptConfig(valid.platform, accountId, payload) + return res.json({ success: true, data: payload }) + } catch (error) { + logger.error('保存余额脚本配置失败', error) + return res.status(500).json({ success: false, error: error.message }) + } +}) + +router.post('/accounts/:accountId/balance/script/test', authenticateAdmin, async (req, res) => { + try { + const { accountId } = req.params + const { platform } = req.query + const valid = ensureValidPlatform(platform) + if (!valid.ok) { + return res.status(valid.status).json({ success: false, error: valid.error }) + } + + const payload = req.body || {} + const scriptBody = payload.scriptBody + if (!scriptBody) { + return res.status(400).json({ success: false, error: '脚本内容不能为空' }) + } + + const result = await balanceScriptService.execute({ + scriptBody, + timeoutSeconds: payload.timeoutSeconds || 10, + variables: { + baseUrl: payload.baseUrl || '', + apiKey: payload.apiKey || '', + token: payload.token || '', + accountId, + platform: valid.platform, + extra: payload.extra || '' + } + }) + + return res.json({ success: true, data: result }) + } catch (error) { + logger.error('测试余额脚本失败', error) + return res.status(400).json({ success: false, error: error.message }) + } +}) + module.exports = router diff --git a/src/routes/admin/balanceScripts.js b/src/routes/admin/balanceScripts.js new file mode 100644 index 00000000..ef7ffa01 --- /dev/null +++ b/src/routes/admin/balanceScripts.js @@ -0,0 +1,41 @@ +const express = require('express') +const { authenticateAdmin } = require('../../middleware/auth') +const balanceScriptService = require('../../services/balanceScriptService') +const router = express.Router() + +// 获取全部脚本配置列表 +router.get('/balance-scripts', authenticateAdmin, (req, res) => { + const items = balanceScriptService.listConfigs() + return res.json({ success: true, data: items }) +}) + +// 获取单个脚本配置 +router.get('/balance-scripts/:name', authenticateAdmin, (req, res) => { + const { name } = req.params + const config = balanceScriptService.getConfig(name || 'default') + return res.json({ success: true, data: config }) +}) + +// 保存脚本配置 +router.put('/balance-scripts/:name', authenticateAdmin, (req, res) => { + try { + const { name } = req.params + const saved = balanceScriptService.saveConfig(name || 'default', req.body || {}) + return res.json({ success: true, data: saved }) + } catch (error) { + return res.status(400).json({ success: false, error: error.message }) + } +}) + +// 测试脚本(不落库) +router.post('/balance-scripts/:name/test', authenticateAdmin, async (req, res) => { + try { + const { name } = req.params + const result = await balanceScriptService.testScript(name || 'default', req.body || {}) + return res.json({ success: true, data: result }) + } catch (error) { + return res.status(400).json({ success: false, error: error.message }) + } +}) + +module.exports = router diff --git a/src/routes/admin/index.js b/src/routes/admin/index.js index f7deafd6..f5cb2268 100644 --- a/src/routes/admin/index.js +++ b/src/routes/admin/index.js @@ -22,6 +22,7 @@ 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') @@ -38,6 +39,7 @@ router.use('/', droidAccountsRoutes) router.use('/', dashboardRoutes) router.use('/', usageStatsRoutes) router.use('/', accountBalanceRoutes) +router.use('/', balanceScriptsRoutes) router.use('/', systemRoutes) router.use('/', concurrencyRoutes) router.use('/', claudeRelayConfigRoutes) diff --git a/src/services/accountBalanceService.js b/src/services/accountBalanceService.js index 5ef882cf..370ed680 100644 --- a/src/services/accountBalanceService.js +++ b/src/services/accountBalanceService.js @@ -1,6 +1,8 @@ const redis = require('../models/redis') +const balanceScriptService = require('./balanceScriptService') const logger = require('../utils/logger') const CostCalculator = require('../utils/costCalculator') +const redis = require('../models/redis') class AccountBalanceService { constructor(options = {}) { @@ -321,25 +323,32 @@ class AccountBalanceService { } // 强制查询:调用 Provider,失败自动降级到本地统计 - const provider = this.providers.get(platform) - if (!provider) { - return this._buildResponse( - { - status: 'error', - errorMessage: `不支持的平台: ${platform}`, - balance: quotaFromLocal.balance, - currency: quotaFromLocal.currency || 'USD', - quota: quotaFromLocal.quota, - statistics: localStatistics, - lastRefreshAt: new Date().toISOString() - }, - accountId, - platform, - 'local' - ) + const scriptConfig = await this.redis.getBalanceScriptConfig(platform, accountId) + let providerResult + + if (scriptConfig && scriptConfig.scriptBody) { + providerResult = await this._getBalanceFromScript(scriptConfig, accountId, platform) + } else { + const provider = this.providers.get(platform) + if (!provider) { + return this._buildResponse( + { + status: 'error', + errorMessage: `不支持的平台: ${platform}`, + balance: quotaFromLocal.balance, + currency: quotaFromLocal.currency || 'USD', + quota: quotaFromLocal.quota, + statistics: localStatistics, + lastRefreshAt: new Date().toISOString() + }, + accountId, + platform, + 'local' + ) + } + providerResult = await this._getBalanceFromProvider(provider, account) } - const providerResult = await this._getBalanceFromProvider(provider, account) await this.redis.setAccountBalance(platform, accountId, providerResult, this.CACHE_TTL_SECONDS) const source = providerResult.status === 'success' ? 'api' : 'local' @@ -360,6 +369,46 @@ class AccountBalanceService { ) } + async _getBalanceFromScript(scriptConfig, accountId, platform) { + try { + const result = await balanceScriptService.execute({ + scriptBody: scriptConfig.scriptBody, + timeoutSeconds: scriptConfig.timeoutSeconds || 10, + variables: { + baseUrl: scriptConfig.baseUrl || '', + apiKey: scriptConfig.apiKey || '', + token: scriptConfig.token || '', + accountId, + platform, + extra: scriptConfig.extra || '' + } + }) + + const mapped = result?.mapped || {} + return { + status: mapped.status || 'error', + balance: typeof mapped.balance === 'number' ? mapped.balance : null, + currency: mapped.currency || 'USD', + quota: mapped.quota || null, + queryMethod: 'api', + rawData: mapped.rawData || result?.response?.data || null, + lastRefreshAt: new Date().toISOString(), + errorMessage: mapped.errorMessage || '' + } + } catch (error) { + return { + status: 'error', + balance: null, + currency: 'USD', + quota: null, + queryMethod: 'api', + rawData: null, + lastRefreshAt: new Date().toISOString(), + errorMessage: error.message || '脚本执行失败' + } + } + } + async _getBalanceFromProvider(provider, account) { try { const result = await provider.queryBalance(account) diff --git a/src/services/balanceScriptService.js b/src/services/balanceScriptService.js new file mode 100644 index 00000000..36a996b9 --- /dev/null +++ b/src/services/balanceScriptService.js @@ -0,0 +1,241 @@ +const fs = require('fs') +const path = require('path') +const vm = require('vm') +const axios = require('axios') +const logger = require('../utils/logger') + +/** + * 可配置脚本余额查询服务 + * - 存储位置: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 + * - scriptBody: string + * - variables: Record + * - timeoutSeconds: number + */ + async execute(options = {}) { + const scriptBody = options.scriptBody?.trim() + if (!scriptBody) { + throw new Error('脚本内容为空') + } + + const timeoutMs = Math.max(1, (options.timeoutSeconds || 10) * 1000) + const sandbox = { + console, + Math, + Date + } + + let scriptResult + try { + const wrapped = scriptBody.startsWith('(') ? scriptBody : `(${scriptBody})` + const script = new vm.Script(wrapped, { timeout: timeoutMs }) + scriptResult = script.runInNewContext(sandbox, { timeout: timeoutMs }) + } catch (error) { + throw new Error(`脚本解析失败: ${error.message}`) + } + + if (!scriptResult || typeof scriptResult !== 'object') { + throw new Error('脚本返回格式无效(需返回 { request, extractor })') + } + + const variables = options.variables || {} + const request = this.applyTemplates(scriptResult.request || {}, variables) + const extractor = scriptResult.extractor + + if (!request.url) { + throw new Error('脚本 request.url 不能为空') + } + + const axiosConfig = { + url: request.url, + method: (request.method || 'GET').toUpperCase(), + headers: request.headers || {}, + timeout: timeoutMs + } + + if (request.params) { + axiosConfig.params = request.params + } + if (request.body || request.data) { + axiosConfig.data = request.body || request.data + } + + let httpResponse = null + 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 responseData = httpResponse?.data + let extracted = {} + if (typeof extractor === 'function') { + try { + extracted = extractor(responseData) || {} + } catch (error) { + throw new Error(`extractor 执行失败: ${error.message}`) + } + } + + const mapped = this.mapExtractorResult(extracted, responseData) + return { + mapped, + extracted, + response: { + status: httpResponse?.status, + headers: httpResponse?.headers, + data: responseData + } + } + } + + applyTemplates(value, variables) { + if (typeof value === 'string') { + return value.replace(/{{(\w+)}}/g, (_, key) => { + const trimmed = key.trim() + return variables[trimmed] !== undefined ? String(variables[trimmed]) : '' + }) + } + if (Array.isArray(value)) { + return value.map((item) => this.applyTemplates(item, variables)) + } + if (value && typeof value === 'object') { + const result = {} + Object.keys(value).forEach((k) => { + result[k] = this.applyTemplates(value[k], variables) + }) + return result + } + return value + } + + mapExtractorResult(result = {}, responseData) { + const isValid = result.isValid !== false + const remaining = Number(result.remaining) + const total = Number(result.total) + const used = Number(result.used) + const currency = result.unit || 'USD' + + const quota = + Number.isFinite(total) || Number.isFinite(used) + ? { + total: Number.isFinite(total) ? total : null, + used: Number.isFinite(used) ? used : null, + remaining: Number.isFinite(remaining) ? remaining : null, + percentage: + Number.isFinite(total) && total > 0 && Number.isFinite(used) + ? (used / total) * 100 + : null + } + : null + + return { + status: isValid ? 'success' : 'error', + errorMessage: isValid ? '' : result.invalidMessage || '套餐无效', + balance: Number.isFinite(remaining) ? remaining : null, + currency, + quota, + planName: result.planName || null, + extra: result.extra || null, + 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() diff --git a/web/admin-spa/src/components/accounts/AccountBalanceScriptModal.vue b/web/admin-spa/src/components/accounts/AccountBalanceScriptModal.vue new file mode 100644 index 00000000..ef60d447 --- /dev/null +++ b/web/admin-spa/src/components/accounts/AccountBalanceScriptModal.vue @@ -0,0 +1,262 @@ + + + + + diff --git a/web/admin-spa/src/views/AccountsView.vue b/web/admin-spa/src/views/AccountsView.vue index 1d1179e2..aaf82171 100644 --- a/web/admin-spa/src/views/AccountsView.vue +++ b/web/admin-spa/src/views/AccountsView.vue @@ -804,6 +804,14 @@ @error="(error) => handleBalanceError(account.id, error)" @refreshed="(data) => handleBalanceRefreshed(account.id, data)" /> +
+ +
@@ -1475,6 +1483,14 @@ @error="(error) => handleBalanceError(account.id, error)" @refreshed="(data) => handleBalanceRefreshed(account.id, data)" /> +
+ +
@@ -1958,6 +1974,13 @@ @saved="handleScheduledTestSaved" /> + + { showToast('定时测试配置已保存', 'success') } +// 余额脚本配置 +const showBalanceScriptModal = ref(false) +const selectedAccountForScript = ref(null) + +const openBalanceScriptModal = (account) => { + selectedAccountForScript.value = account + showBalanceScriptModal.value = true +} + +const closeBalanceScriptModal = () => { + showBalanceScriptModal.value = false + selectedAccountForScript.value = null +} + +const handleBalanceScriptSaved = () => { + showToast('余额脚本已保存', 'success') + closeBalanceScriptModal() +} + // 计算排序后的账户列表 const sortedAccounts = computed(() => { let sourceAccounts = accounts.value diff --git a/web/admin-spa/src/views/BalanceScriptsView.vue b/web/admin-spa/src/views/BalanceScriptsView.vue new file mode 100644 index 00000000..1e4334da --- /dev/null +++ b/web/admin-spa/src/views/BalanceScriptsView.vue @@ -0,0 +1,312 @@ + + + + +