mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-22 16:43:35 +00:00
Merge branch 'main' into antigravity
This commit is contained in:
@@ -1,5 +1,10 @@
|
|||||||
# Claude Relay Service
|
# Claude Relay Service
|
||||||
|
|
||||||
|
> [!CAUTION]
|
||||||
|
> **安全更新通知**:v1.1.240 及以下版本存在严重的管理员认证绕过漏洞,攻击者可未授权访问管理面板。
|
||||||
|
>
|
||||||
|
> **请立即更新到 v1.1.241+ 版本**,或迁移到新一代项目 **[CRS 2.0 (sub2api)](https://github.com/Wei-Shaw/sub2api)**
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
[](https://opensource.org/licenses/MIT)
|
[](https://opensource.org/licenses/MIT)
|
||||||
@@ -426,6 +431,8 @@ export ANTHROPIC_MODEL="gemini-2.5-pro"
|
|||||||
|
|
||||||
如果该文件不存在,请手动创建。Windows 用户路径为 `C:\Users\你的用户名\.claude\config.json`。
|
如果该文件不存在,请手动创建。Windows 用户路径为 `C:\Users\你的用户名\.claude\config.json`。
|
||||||
|
|
||||||
|
> 💡 **IntelliJ IDEA 用户推荐**:[Claude Code Plus](https://github.com/touwaeriol/claude-code-plus) - 将 Claude Code 直接集成到 IDE,支持代码理解、文件读写、命令执行。插件市场搜索 `Claude Code Plus` 即可安装。
|
||||||
|
|
||||||
**Gemini CLI 设置环境变量:**
|
**Gemini CLI 设置环境变量:**
|
||||||
|
|
||||||
**方式一(推荐):通过 Gemini Assist API 方式访问**
|
**方式一(推荐):通过 Gemini Assist API 方式访问**
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
# Claude Relay Service
|
# Claude Relay Service
|
||||||
|
|
||||||
|
> [!CAUTION]
|
||||||
|
> **Security Update**: v1.1.240 and below contain a critical admin authentication bypass vulnerability allowing unauthorized access to the admin panel.
|
||||||
|
>
|
||||||
|
> **Please update to v1.1.241+ immediately**, or migrate to the next-generation project **[CRS 2.0 (sub2api)](https://github.com/Wei-Shaw/sub2api)**
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
[](https://opensource.org/licenses/MIT)
|
[](https://opensource.org/licenses/MIT)
|
||||||
|
|||||||
@@ -205,6 +205,14 @@ const config = {
|
|||||||
hotReload: process.env.HOT_RELOAD === 'true'
|
hotReload: process.env.HOT_RELOAD === 'true'
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// 💰 账户余额相关配置
|
||||||
|
accountBalance: {
|
||||||
|
// 是否允许执行自定义余额脚本(安全开关)
|
||||||
|
// 说明:脚本能力可发起任意 HTTP 请求并在服务端执行 extractor 逻辑,建议仅在受控环境开启
|
||||||
|
// 默认保持开启;如需禁用请显式设置:BALANCE_SCRIPT_ENABLED=false
|
||||||
|
enableBalanceScript: process.env.BALANCE_SCRIPT_ENABLED !== 'false'
|
||||||
|
},
|
||||||
|
|
||||||
// 📬 用户消息队列配置
|
// 📬 用户消息队列配置
|
||||||
// 优化说明:锁在请求发送成功后立即释放(而非请求完成后),因为 Claude API 限流基于请求发送时刻计算
|
// 优化说明:锁在请求发送成功后立即释放(而非请求完成后),因为 Claude API 限流基于请求发送时刻计算
|
||||||
userMessageQueue: {
|
userMessageQueue: {
|
||||||
|
|||||||
10
package-lock.json
generated
10
package-lock.json
generated
@@ -26,6 +26,7 @@
|
|||||||
"ioredis": "^5.3.2",
|
"ioredis": "^5.3.2",
|
||||||
"ldapjs": "^3.0.7",
|
"ldapjs": "^3.0.7",
|
||||||
"morgan": "^1.10.0",
|
"morgan": "^1.10.0",
|
||||||
|
"node-cron": "^4.2.1",
|
||||||
"nodemailer": "^7.0.6",
|
"nodemailer": "^7.0.6",
|
||||||
"ora": "^5.4.1",
|
"ora": "^5.4.1",
|
||||||
"rate-limiter-flexible": "^5.0.5",
|
"rate-limiter-flexible": "^5.0.5",
|
||||||
@@ -7028,6 +7029,15 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/node-cron": {
|
||||||
|
"version": "4.2.1",
|
||||||
|
"resolved": "https://registry.npmmirror.com/node-cron/-/node-cron-4.2.1.tgz",
|
||||||
|
"integrity": "sha512-lgimEHPE/QDgFlywTd8yTR61ptugX3Qer29efeyWw2rv259HtGBNn1vZVmp8lB9uo9wC0t/AT4iGqXxia+CJFg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/node-domexception": {
|
"node_modules/node-domexception": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmmirror.com/node-domexception/-/node-domexception-1.0.0.tgz",
|
"resolved": "https://registry.npmmirror.com/node-domexception/-/node-domexception-1.0.0.tgz",
|
||||||
|
|||||||
@@ -65,6 +65,7 @@
|
|||||||
"ioredis": "^5.3.2",
|
"ioredis": "^5.3.2",
|
||||||
"ldapjs": "^3.0.7",
|
"ldapjs": "^3.0.7",
|
||||||
"morgan": "^1.10.0",
|
"morgan": "^1.10.0",
|
||||||
|
"node-cron": "^4.2.1",
|
||||||
"nodemailer": "^7.0.6",
|
"nodemailer": "^7.0.6",
|
||||||
"ora": "^5.4.1",
|
"ora": "^5.4.1",
|
||||||
"rate-limiter-flexible": "^5.0.5",
|
"rate-limiter-flexible": "^5.0.5",
|
||||||
|
|||||||
71
pnpm-lock.yaml
generated
71
pnpm-lock.yaml
generated
@@ -59,6 +59,9 @@ importers:
|
|||||||
morgan:
|
morgan:
|
||||||
specifier: ^1.10.0
|
specifier: ^1.10.0
|
||||||
version: 1.10.1
|
version: 1.10.1
|
||||||
|
node-cron:
|
||||||
|
specifier: ^4.2.1
|
||||||
|
version: 4.2.1
|
||||||
nodemailer:
|
nodemailer:
|
||||||
specifier: ^7.0.6
|
specifier: ^7.0.6
|
||||||
version: 7.0.11
|
version: 7.0.11
|
||||||
@@ -108,6 +111,9 @@ importers:
|
|||||||
prettier:
|
prettier:
|
||||||
specifier: ^3.6.2
|
specifier: ^3.6.2
|
||||||
version: 3.7.4
|
version: 3.7.4
|
||||||
|
prettier-plugin-tailwindcss:
|
||||||
|
specifier: ^0.7.2
|
||||||
|
version: 0.7.2(prettier@3.7.4)
|
||||||
supertest:
|
supertest:
|
||||||
specifier: ^6.3.3
|
specifier: ^6.3.3
|
||||||
version: 6.3.4
|
version: 6.3.4
|
||||||
@@ -2144,6 +2150,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==}
|
resolution: {integrity: sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==}
|
||||||
engines: {node: '>= 0.6'}
|
engines: {node: '>= 0.6'}
|
||||||
|
|
||||||
|
node-cron@4.2.1:
|
||||||
|
resolution: {integrity: sha512-lgimEHPE/QDgFlywTd8yTR61ptugX3Qer29efeyWw2rv259HtGBNn1vZVmp8lB9uo9wC0t/AT4iGqXxia+CJFg==}
|
||||||
|
engines: {node: '>=6.0.0'}
|
||||||
|
|
||||||
node-domexception@1.0.0:
|
node-domexception@1.0.0:
|
||||||
resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==}
|
resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==}
|
||||||
engines: {node: '>=10.5.0'}
|
engines: {node: '>=10.5.0'}
|
||||||
@@ -2302,6 +2312,61 @@ packages:
|
|||||||
resolution: {integrity: sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w==}
|
resolution: {integrity: sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w==}
|
||||||
engines: {node: '>=6.0.0'}
|
engines: {node: '>=6.0.0'}
|
||||||
|
|
||||||
|
prettier-plugin-tailwindcss@0.7.2:
|
||||||
|
resolution: {integrity: sha512-LkphyK3Fw+q2HdMOoiEHWf93fNtYJwfamoKPl7UwtjFQdei/iIBoX11G6j706FzN3ymX9mPVi97qIY8328vdnA==}
|
||||||
|
engines: {node: '>=20.19'}
|
||||||
|
peerDependencies:
|
||||||
|
'@ianvs/prettier-plugin-sort-imports': '*'
|
||||||
|
'@prettier/plugin-hermes': '*'
|
||||||
|
'@prettier/plugin-oxc': '*'
|
||||||
|
'@prettier/plugin-pug': '*'
|
||||||
|
'@shopify/prettier-plugin-liquid': '*'
|
||||||
|
'@trivago/prettier-plugin-sort-imports': '*'
|
||||||
|
'@zackad/prettier-plugin-twig': '*'
|
||||||
|
prettier: ^3.0
|
||||||
|
prettier-plugin-astro: '*'
|
||||||
|
prettier-plugin-css-order: '*'
|
||||||
|
prettier-plugin-jsdoc: '*'
|
||||||
|
prettier-plugin-marko: '*'
|
||||||
|
prettier-plugin-multiline-arrays: '*'
|
||||||
|
prettier-plugin-organize-attributes: '*'
|
||||||
|
prettier-plugin-organize-imports: '*'
|
||||||
|
prettier-plugin-sort-imports: '*'
|
||||||
|
prettier-plugin-svelte: '*'
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@ianvs/prettier-plugin-sort-imports':
|
||||||
|
optional: true
|
||||||
|
'@prettier/plugin-hermes':
|
||||||
|
optional: true
|
||||||
|
'@prettier/plugin-oxc':
|
||||||
|
optional: true
|
||||||
|
'@prettier/plugin-pug':
|
||||||
|
optional: true
|
||||||
|
'@shopify/prettier-plugin-liquid':
|
||||||
|
optional: true
|
||||||
|
'@trivago/prettier-plugin-sort-imports':
|
||||||
|
optional: true
|
||||||
|
'@zackad/prettier-plugin-twig':
|
||||||
|
optional: true
|
||||||
|
prettier-plugin-astro:
|
||||||
|
optional: true
|
||||||
|
prettier-plugin-css-order:
|
||||||
|
optional: true
|
||||||
|
prettier-plugin-jsdoc:
|
||||||
|
optional: true
|
||||||
|
prettier-plugin-marko:
|
||||||
|
optional: true
|
||||||
|
prettier-plugin-multiline-arrays:
|
||||||
|
optional: true
|
||||||
|
prettier-plugin-organize-attributes:
|
||||||
|
optional: true
|
||||||
|
prettier-plugin-organize-imports:
|
||||||
|
optional: true
|
||||||
|
prettier-plugin-sort-imports:
|
||||||
|
optional: true
|
||||||
|
prettier-plugin-svelte:
|
||||||
|
optional: true
|
||||||
|
|
||||||
prettier@3.7.4:
|
prettier@3.7.4:
|
||||||
resolution: {integrity: sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==}
|
resolution: {integrity: sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==}
|
||||||
engines: {node: '>=14'}
|
engines: {node: '>=14'}
|
||||||
@@ -5692,6 +5757,8 @@ snapshots:
|
|||||||
|
|
||||||
negotiator@0.6.4: {}
|
negotiator@0.6.4: {}
|
||||||
|
|
||||||
|
node-cron@4.2.1: {}
|
||||||
|
|
||||||
node-domexception@1.0.0: {}
|
node-domexception@1.0.0: {}
|
||||||
|
|
||||||
node-fetch@3.3.2:
|
node-fetch@3.3.2:
|
||||||
@@ -5840,6 +5907,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
fast-diff: 1.3.0
|
fast-diff: 1.3.0
|
||||||
|
|
||||||
|
prettier-plugin-tailwindcss@0.7.2(prettier@3.7.4):
|
||||||
|
dependencies:
|
||||||
|
prettier: 3.7.4
|
||||||
|
|
||||||
prettier@3.7.4: {}
|
prettier@3.7.4: {}
|
||||||
|
|
||||||
pretty-format@29.7.0:
|
pretty-format@29.7.0:
|
||||||
|
|||||||
106
src/app.js
106
src/app.js
@@ -52,6 +52,16 @@ class Application {
|
|||||||
await redis.connect()
|
await redis.connect()
|
||||||
logger.success('✅ Redis connected successfully')
|
logger.success('✅ Redis connected successfully')
|
||||||
|
|
||||||
|
// 💳 初始化账户余额查询服务(Provider 注册)
|
||||||
|
try {
|
||||||
|
const accountBalanceService = require('./services/accountBalanceService')
|
||||||
|
const { registerAllProviders } = require('./services/balanceProviders')
|
||||||
|
registerAllProviders(accountBalanceService)
|
||||||
|
logger.info('✅ 账户余额查询服务已初始化')
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn('⚠️ 账户余额查询服务初始化失败:', error.message)
|
||||||
|
}
|
||||||
|
|
||||||
// 💰 初始化价格服务
|
// 💰 初始化价格服务
|
||||||
logger.info('🔄 Initializing pricing service...')
|
logger.info('🔄 Initializing pricing service...')
|
||||||
await pricingService.initialize()
|
await pricingService.initialize()
|
||||||
@@ -68,6 +78,10 @@ class Application {
|
|||||||
logger.info('🔄 Initializing admin credentials...')
|
logger.info('🔄 Initializing admin credentials...')
|
||||||
await this.initializeAdmin()
|
await this.initializeAdmin()
|
||||||
|
|
||||||
|
// 🔒 安全启动:清理无效/伪造的管理员会话
|
||||||
|
logger.info('🔒 Cleaning up invalid admin sessions...')
|
||||||
|
await this.cleanupInvalidSessions()
|
||||||
|
|
||||||
// 💰 初始化费用数据
|
// 💰 初始化费用数据
|
||||||
logger.info('💰 Checking cost data initialization...')
|
logger.info('💰 Checking cost data initialization...')
|
||||||
const costInitService = require('./services/costInitService')
|
const costInitService = require('./services/costInitService')
|
||||||
@@ -445,6 +459,54 @@ class Application {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🔒 清理无效/伪造的管理员会话(安全启动检查)
|
||||||
|
async cleanupInvalidSessions() {
|
||||||
|
try {
|
||||||
|
const client = redis.getClient()
|
||||||
|
|
||||||
|
// 获取所有 session:* 键
|
||||||
|
const sessionKeys = await client.keys('session:*')
|
||||||
|
|
||||||
|
let validCount = 0
|
||||||
|
let invalidCount = 0
|
||||||
|
|
||||||
|
for (const key of sessionKeys) {
|
||||||
|
// 跳过 admin_credentials(系统凭据)
|
||||||
|
if (key === 'session:admin_credentials') {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionData = await client.hgetall(key)
|
||||||
|
|
||||||
|
// 检查会话完整性:必须有 username 和 loginTime
|
||||||
|
const hasUsername = !!sessionData.username
|
||||||
|
const hasLoginTime = !!sessionData.loginTime
|
||||||
|
|
||||||
|
if (!hasUsername || !hasLoginTime) {
|
||||||
|
// 无效会话 - 可能是漏洞利用创建的伪造会话
|
||||||
|
invalidCount++
|
||||||
|
logger.security(
|
||||||
|
`🔒 Removing invalid session: ${key} (username: ${hasUsername}, loginTime: ${hasLoginTime})`
|
||||||
|
)
|
||||||
|
await client.del(key)
|
||||||
|
} else {
|
||||||
|
validCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (invalidCount > 0) {
|
||||||
|
logger.security(`🔒 Startup security check: Removed ${invalidCount} invalid sessions`)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.success(
|
||||||
|
`✅ Session cleanup completed: ${validCount} valid, ${invalidCount} invalid removed`
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
// 清理失败不应阻止服务启动
|
||||||
|
logger.error('❌ Failed to cleanup invalid sessions:', error.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 🔍 Redis健康检查
|
// 🔍 Redis健康检查
|
||||||
async checkRedisHealth() {
|
async checkRedisHealth() {
|
||||||
try {
|
try {
|
||||||
@@ -600,10 +662,11 @@ class Application {
|
|||||||
|
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
let totalCleaned = 0
|
let totalCleaned = 0
|
||||||
|
let legacyCleaned = 0
|
||||||
|
|
||||||
// 使用 Lua 脚本批量清理所有过期项
|
// 使用 Lua 脚本批量清理所有过期项
|
||||||
for (const key of keys) {
|
for (const key of keys) {
|
||||||
// 跳过非 Sorted Set 类型的键(这些键有各自的清理逻辑)
|
// 跳过已知非 Sorted Set 类型的键(这些键有各自的清理逻辑)
|
||||||
// - concurrency:queue:stats:* 是 Hash 类型
|
// - concurrency:queue:stats:* 是 Hash 类型
|
||||||
// - concurrency:queue:wait_times:* 是 List 类型
|
// - concurrency:queue:wait_times:* 是 List 类型
|
||||||
// - concurrency:queue:* (不含stats/wait_times) 是 String 类型
|
// - concurrency:queue:* (不含stats/wait_times) 是 String 类型
|
||||||
@@ -618,11 +681,21 @@ class Application {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const cleaned = await redis.client.eval(
|
// 使用原子 Lua 脚本:先检查类型,再执行清理
|
||||||
|
// 返回值:0 = 正常清理无删除,1 = 清理后删除空键,-1 = 遗留键已删除
|
||||||
|
const result = await redis.client.eval(
|
||||||
`
|
`
|
||||||
local key = KEYS[1]
|
local key = KEYS[1]
|
||||||
local now = tonumber(ARGV[1])
|
local now = tonumber(ARGV[1])
|
||||||
|
|
||||||
|
-- 先检查键类型,只对 Sorted Set 执行清理
|
||||||
|
local keyType = redis.call('TYPE', key)
|
||||||
|
if keyType.ok ~= 'zset' then
|
||||||
|
-- 非 ZSET 类型的遗留键,直接删除
|
||||||
|
redis.call('DEL', key)
|
||||||
|
return -1
|
||||||
|
end
|
||||||
|
|
||||||
-- 清理过期项
|
-- 清理过期项
|
||||||
redis.call('ZREMRANGEBYSCORE', key, '-inf', now)
|
redis.call('ZREMRANGEBYSCORE', key, '-inf', now)
|
||||||
|
|
||||||
@@ -641,8 +714,10 @@ class Application {
|
|||||||
key,
|
key,
|
||||||
now
|
now
|
||||||
)
|
)
|
||||||
if (cleaned === 1) {
|
if (result === 1) {
|
||||||
totalCleaned++
|
totalCleaned++
|
||||||
|
} else if (result === -1) {
|
||||||
|
legacyCleaned++
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`❌ Failed to clean concurrency key ${key}:`, error)
|
logger.error(`❌ Failed to clean concurrency key ${key}:`, error)
|
||||||
@@ -652,6 +727,9 @@ class Application {
|
|||||||
if (totalCleaned > 0) {
|
if (totalCleaned > 0) {
|
||||||
logger.info(`🔢 Concurrency cleanup: cleaned ${totalCleaned} expired keys`)
|
logger.info(`🔢 Concurrency cleanup: cleaned ${totalCleaned} expired keys`)
|
||||||
}
|
}
|
||||||
|
if (legacyCleaned > 0) {
|
||||||
|
logger.warn(`🧹 Concurrency cleanup: removed ${legacyCleaned} legacy keys (wrong type)`)
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('❌ Concurrency cleanup task failed:', error)
|
logger.error('❌ Concurrency cleanup task failed:', error)
|
||||||
}
|
}
|
||||||
@@ -680,6 +758,19 @@ class Application {
|
|||||||
'🚦 Skipping concurrency queue cleanup on startup (CLEAR_CONCURRENCY_QUEUES_ON_STARTUP=false)'
|
'🚦 Skipping concurrency queue cleanup on startup (CLEAR_CONCURRENCY_QUEUES_ON_STARTUP=false)'
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🧪 启动账户定时测试调度器
|
||||||
|
// 根据配置定期测试账户连通性并保存测试历史
|
||||||
|
const accountTestSchedulerEnabled =
|
||||||
|
process.env.ACCOUNT_TEST_SCHEDULER_ENABLED !== 'false' &&
|
||||||
|
config.accountTestScheduler?.enabled !== false
|
||||||
|
if (accountTestSchedulerEnabled) {
|
||||||
|
const accountTestSchedulerService = require('./services/accountTestSchedulerService')
|
||||||
|
accountTestSchedulerService.start()
|
||||||
|
logger.info('🧪 Account test scheduler service started')
|
||||||
|
} else {
|
||||||
|
logger.info('🧪 Account test scheduler service disabled')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setupGracefulShutdown() {
|
setupGracefulShutdown() {
|
||||||
@@ -734,6 +825,15 @@ class Application {
|
|||||||
logger.error('❌ Error stopping cost rank service:', error)
|
logger.error('❌ Error stopping cost rank service:', error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 停止账户定时测试调度器
|
||||||
|
try {
|
||||||
|
const accountTestSchedulerService = require('./services/accountTestSchedulerService')
|
||||||
|
accountTestSchedulerService.stop()
|
||||||
|
logger.info('🧪 Account test scheduler service stopped')
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Error stopping account test scheduler service:', error)
|
||||||
|
}
|
||||||
|
|
||||||
// 🔢 清理所有并发计数(Phase 1 修复:防止重启泄漏)
|
// 🔢 清理所有并发计数(Phase 1 修复:防止重启泄漏)
|
||||||
try {
|
try {
|
||||||
logger.info('🔢 Cleaning up all concurrency counters...')
|
logger.info('🔢 Cleaning up all concurrency counters...')
|
||||||
|
|||||||
@@ -87,8 +87,7 @@ function generateSessionHash(req) {
|
|||||||
* 检查 API Key 权限
|
* 检查 API Key 权限
|
||||||
*/
|
*/
|
||||||
function checkPermissions(apiKeyData, requiredPermission = 'gemini') {
|
function checkPermissions(apiKeyData, requiredPermission = 'gemini') {
|
||||||
const permissions = apiKeyData?.permissions || 'all'
|
return apiKeyService.hasPermission(apiKeyData?.permissions, requiredPermission)
|
||||||
return permissions === 'all' || permissions === requiredPermission
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1389,6 +1389,18 @@ const authenticateAdmin = async (req, res, next) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🔒 安全修复:验证会话必须字段(防止伪造会话绕过认证)
|
||||||
|
if (!adminSession.username || !adminSession.loginTime) {
|
||||||
|
logger.security(
|
||||||
|
`🔒 Corrupted admin session from ${req.ip || 'unknown'} - missing required fields (username: ${!!adminSession.username}, loginTime: ${!!adminSession.loginTime})`
|
||||||
|
)
|
||||||
|
await redis.deleteSession(token) // 清理无效/伪造的会话
|
||||||
|
return res.status(401).json({
|
||||||
|
error: 'Invalid session',
|
||||||
|
message: 'Session data corrupted or incomplete'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// 检查会话活跃性(可选:检查最后活动时间)
|
// 检查会话活跃性(可选:检查最后活动时间)
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
const lastActivity = new Date(adminSession.lastActivity || adminSession.loginTime)
|
const lastActivity = new Date(adminSession.lastActivity || adminSession.loginTime)
|
||||||
@@ -1744,9 +1756,13 @@ const requestLogger = (req, res, next) => {
|
|||||||
const referer = req.get('Referer') || 'none'
|
const referer = req.get('Referer') || 'none'
|
||||||
|
|
||||||
// 记录请求开始
|
// 记录请求开始
|
||||||
|
const isDebugRoute = req.originalUrl.includes('event_logging')
|
||||||
if (req.originalUrl !== '/health') {
|
if (req.originalUrl !== '/health') {
|
||||||
// 避免健康检查日志过多
|
if (isDebugRoute) {
|
||||||
logger.info(`▶️ [${requestId}] ${req.method} ${req.originalUrl} | IP: ${clientIP}`)
|
logger.debug(`▶️ [${requestId}] ${req.method} ${req.originalUrl} | IP: ${clientIP}`)
|
||||||
|
} else {
|
||||||
|
logger.info(`▶️ [${requestId}] ${req.method} ${req.originalUrl} | IP: ${clientIP}`)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
res.on('finish', () => {
|
res.on('finish', () => {
|
||||||
@@ -1778,7 +1794,14 @@ const requestLogger = (req, res, next) => {
|
|||||||
logMetadata
|
logMetadata
|
||||||
)
|
)
|
||||||
} else if (req.originalUrl !== '/health') {
|
} else if (req.originalUrl !== '/health') {
|
||||||
logger.request(req.method, req.originalUrl, res.statusCode, duration, logMetadata)
|
if (isDebugRoute) {
|
||||||
|
logger.debug(
|
||||||
|
`🟢 ${req.method} ${req.originalUrl} - ${res.statusCode} (${duration}ms)`,
|
||||||
|
logMetadata
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
logger.request(req.method, req.originalUrl, res.statusCode, duration, logMetadata)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// API Key相关日志
|
// API Key相关日志
|
||||||
|
|||||||
@@ -96,7 +96,25 @@ class RedisClient {
|
|||||||
logger.warn('⚠️ Redis connection closed')
|
logger.warn('⚠️ Redis connection closed')
|
||||||
})
|
})
|
||||||
|
|
||||||
await this.client.connect()
|
// 只有在 lazyConnect 模式下才需要手动调用 connect()
|
||||||
|
// 如果 Redis 已经连接或正在连接中,则跳过
|
||||||
|
if (
|
||||||
|
this.client.status !== 'connecting' &&
|
||||||
|
this.client.status !== 'connect' &&
|
||||||
|
this.client.status !== 'ready'
|
||||||
|
) {
|
||||||
|
await this.client.connect()
|
||||||
|
} else {
|
||||||
|
// 等待 ready 状态
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
if (this.client.status === 'ready') {
|
||||||
|
resolve()
|
||||||
|
} else {
|
||||||
|
this.client.once('ready', resolve)
|
||||||
|
this.client.once('error', reject)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
return this.client
|
return this.client
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('💥 Failed to connect to Redis:', error)
|
logger.error('💥 Failed to connect to Redis:', error)
|
||||||
@@ -1503,6 +1521,123 @@ class RedisClient {
|
|||||||
return await this.client.del(key)
|
return await this.client.del(key)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 💰 账户余额缓存(API 查询结果)
|
||||||
|
async setAccountBalance(platform, accountId, balanceData, ttl = 3600) {
|
||||||
|
const key = `account_balance:${platform}:${accountId}`
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
balance:
|
||||||
|
balanceData && balanceData.balance !== null && balanceData.balance !== undefined
|
||||||
|
? String(balanceData.balance)
|
||||||
|
: '',
|
||||||
|
currency: balanceData?.currency || 'USD',
|
||||||
|
lastRefreshAt: balanceData?.lastRefreshAt || new Date().toISOString(),
|
||||||
|
queryMethod: balanceData?.queryMethod || 'api',
|
||||||
|
status: balanceData?.status || 'success',
|
||||||
|
errorMessage: balanceData?.errorMessage || balanceData?.error || '',
|
||||||
|
rawData: balanceData?.rawData ? JSON.stringify(balanceData.rawData) : '',
|
||||||
|
quota: balanceData?.quota ? JSON.stringify(balanceData.quota) : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.client.hset(key, payload)
|
||||||
|
await this.client.expire(key, ttl)
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAccountBalance(platform, accountId) {
|
||||||
|
const key = `account_balance:${platform}:${accountId}`
|
||||||
|
const [data, ttlSeconds] = await Promise.all([this.client.hgetall(key), this.client.ttl(key)])
|
||||||
|
|
||||||
|
if (!data || Object.keys(data).length === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
let rawData = null
|
||||||
|
if (data.rawData) {
|
||||||
|
try {
|
||||||
|
rawData = JSON.parse(data.rawData)
|
||||||
|
} catch (error) {
|
||||||
|
rawData = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let quota = null
|
||||||
|
if (data.quota) {
|
||||||
|
try {
|
||||||
|
quota = JSON.parse(data.quota)
|
||||||
|
} catch (error) {
|
||||||
|
quota = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
balance: data.balance ? parseFloat(data.balance) : null,
|
||||||
|
currency: data.currency || 'USD',
|
||||||
|
lastRefreshAt: data.lastRefreshAt || null,
|
||||||
|
queryMethod: data.queryMethod || null,
|
||||||
|
status: data.status || null,
|
||||||
|
errorMessage: data.errorMessage || '',
|
||||||
|
rawData,
|
||||||
|
quota,
|
||||||
|
ttlSeconds: Number.isFinite(ttlSeconds) ? ttlSeconds : null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 📊 账户余额缓存(本地统计)
|
||||||
|
async setLocalBalance(platform, accountId, statisticsData, ttl = 300) {
|
||||||
|
const key = `account_balance_local:${platform}:${accountId}`
|
||||||
|
|
||||||
|
await this.client.hset(key, {
|
||||||
|
estimatedBalance: JSON.stringify(statisticsData || {}),
|
||||||
|
lastCalculated: new Date().toISOString()
|
||||||
|
})
|
||||||
|
await this.client.expire(key, ttl)
|
||||||
|
}
|
||||||
|
|
||||||
|
async getLocalBalance(platform, accountId) {
|
||||||
|
const key = `account_balance_local:${platform}:${accountId}`
|
||||||
|
const data = await this.client.hgetall(key)
|
||||||
|
|
||||||
|
if (!data || !data.estimatedBalance) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.parse(data.estimatedBalance)
|
||||||
|
} catch (error) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteAccountBalance(platform, accountId) {
|
||||||
|
const key = `account_balance:${platform}:${accountId}`
|
||||||
|
const localKey = `account_balance_local:${platform}:${accountId}`
|
||||||
|
await this.client.del(key, localKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🧩 账户余额脚本配置
|
||||||
|
async setBalanceScriptConfig(platform, accountId, scriptConfig) {
|
||||||
|
const key = `account_balance_script:${platform}:${accountId}`
|
||||||
|
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
|
||||||
|
}
|
||||||
|
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() {
|
async getSystemStats() {
|
||||||
const keys = await Promise.all([
|
const keys = await Promise.all([
|
||||||
@@ -2122,6 +2257,27 @@ class RedisClient {
|
|||||||
const results = []
|
const results = []
|
||||||
|
|
||||||
for (const key of keys) {
|
for (const key of keys) {
|
||||||
|
// 跳过已知非 Sorted Set 类型的键
|
||||||
|
// - concurrency:queue:stats:* 是 Hash 类型
|
||||||
|
// - concurrency:queue:wait_times:* 是 List 类型
|
||||||
|
// - concurrency:queue:* (不含stats/wait_times) 是 String 类型
|
||||||
|
if (
|
||||||
|
key.startsWith('concurrency:queue:stats:') ||
|
||||||
|
key.startsWith('concurrency:queue:wait_times:') ||
|
||||||
|
(key.startsWith('concurrency:queue:') &&
|
||||||
|
!key.includes(':stats:') &&
|
||||||
|
!key.includes(':wait_times:'))
|
||||||
|
) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查键类型,只处理 Sorted Set
|
||||||
|
const keyType = await client.type(key)
|
||||||
|
if (keyType !== 'zset') {
|
||||||
|
logger.debug(`🔢 getAllConcurrencyStatus skipped non-zset key: ${key} (type: ${keyType})`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
// 提取 apiKeyId(去掉 concurrency: 前缀)
|
// 提取 apiKeyId(去掉 concurrency: 前缀)
|
||||||
const apiKeyId = key.replace('concurrency:', '')
|
const apiKeyId = key.replace('concurrency:', '')
|
||||||
|
|
||||||
@@ -2184,6 +2340,23 @@ class RedisClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 检查键类型,只处理 Sorted Set
|
||||||
|
const keyType = await client.type(key)
|
||||||
|
if (keyType !== 'zset') {
|
||||||
|
logger.warn(
|
||||||
|
`⚠️ getConcurrencyStatus: key ${key} has unexpected type: ${keyType}, expected zset`
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
apiKeyId,
|
||||||
|
key,
|
||||||
|
activeCount: 0,
|
||||||
|
expiredCount: 0,
|
||||||
|
activeRequests: [],
|
||||||
|
exists: true,
|
||||||
|
invalidType: keyType
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 获取所有成员和分数
|
// 获取所有成员和分数
|
||||||
const allMembers = await client.zrange(key, 0, -1, 'WITHSCORES')
|
const allMembers = await client.zrange(key, 0, -1, 'WITHSCORES')
|
||||||
|
|
||||||
@@ -2233,20 +2406,36 @@ class RedisClient {
|
|||||||
const client = this.getClientSafe()
|
const client = this.getClientSafe()
|
||||||
const key = `concurrency:${apiKeyId}`
|
const key = `concurrency:${apiKeyId}`
|
||||||
|
|
||||||
// 获取清理前的状态
|
// 检查键类型
|
||||||
const beforeCount = await client.zcard(key)
|
const keyType = await client.type(key)
|
||||||
|
|
||||||
// 删除整个 key
|
let beforeCount = 0
|
||||||
|
let isLegacy = false
|
||||||
|
|
||||||
|
if (keyType === 'zset') {
|
||||||
|
// 正常的 zset 键,获取条目数
|
||||||
|
beforeCount = await client.zcard(key)
|
||||||
|
} else if (keyType !== 'none') {
|
||||||
|
// 非 zset 且非空的遗留键
|
||||||
|
isLegacy = true
|
||||||
|
logger.warn(
|
||||||
|
`⚠️ forceClearConcurrency: key ${key} has unexpected type: ${keyType}, will be deleted`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除键(无论什么类型)
|
||||||
await client.del(key)
|
await client.del(key)
|
||||||
|
|
||||||
logger.warn(
|
logger.warn(
|
||||||
`🧹 Force cleared concurrency for key ${apiKeyId}, removed ${beforeCount} entries`
|
`🧹 Force cleared concurrency for key ${apiKeyId}, removed ${beforeCount} entries${isLegacy ? ' (legacy key)' : ''}`
|
||||||
)
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
apiKeyId,
|
apiKeyId,
|
||||||
key,
|
key,
|
||||||
clearedCount: beforeCount,
|
clearedCount: beforeCount,
|
||||||
|
type: keyType,
|
||||||
|
legacy: isLegacy,
|
||||||
success: true
|
success: true
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -2265,25 +2454,47 @@ class RedisClient {
|
|||||||
const keys = await client.keys('concurrency:*')
|
const keys = await client.keys('concurrency:*')
|
||||||
|
|
||||||
let totalCleared = 0
|
let totalCleared = 0
|
||||||
|
let legacyCleared = 0
|
||||||
const clearedKeys = []
|
const clearedKeys = []
|
||||||
|
|
||||||
for (const key of keys) {
|
for (const key of keys) {
|
||||||
const count = await client.zcard(key)
|
// 跳过 queue 相关的键(它们有各自的清理逻辑)
|
||||||
await client.del(key)
|
if (key.startsWith('concurrency:queue:')) {
|
||||||
totalCleared += count
|
continue
|
||||||
clearedKeys.push({
|
}
|
||||||
key,
|
|
||||||
clearedCount: count
|
// 检查键类型
|
||||||
})
|
const keyType = await client.type(key)
|
||||||
|
if (keyType === 'zset') {
|
||||||
|
const count = await client.zcard(key)
|
||||||
|
await client.del(key)
|
||||||
|
totalCleared += count
|
||||||
|
clearedKeys.push({
|
||||||
|
key,
|
||||||
|
clearedCount: count,
|
||||||
|
type: 'zset'
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
// 非 zset 类型的遗留键,直接删除
|
||||||
|
await client.del(key)
|
||||||
|
legacyCleared++
|
||||||
|
clearedKeys.push({
|
||||||
|
key,
|
||||||
|
clearedCount: 0,
|
||||||
|
type: keyType,
|
||||||
|
legacy: true
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.warn(
|
logger.warn(
|
||||||
`🧹 Force cleared all concurrency: ${keys.length} keys, ${totalCleared} total entries`
|
`🧹 Force cleared all concurrency: ${clearedKeys.length} keys, ${totalCleared} entries, ${legacyCleared} legacy keys`
|
||||||
)
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
keysCleared: keys.length,
|
keysCleared: clearedKeys.length,
|
||||||
totalEntriesCleared: totalCleared,
|
totalEntriesCleared: totalCleared,
|
||||||
|
legacyKeysCleared: legacyCleared,
|
||||||
clearedKeys,
|
clearedKeys,
|
||||||
success: true
|
success: true
|
||||||
}
|
}
|
||||||
@@ -2311,9 +2522,30 @@ class RedisClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let totalCleaned = 0
|
let totalCleaned = 0
|
||||||
|
let legacyCleaned = 0
|
||||||
const cleanedKeys = []
|
const cleanedKeys = []
|
||||||
|
|
||||||
for (const key of keys) {
|
for (const key of keys) {
|
||||||
|
// 跳过 queue 相关的键(它们有各自的清理逻辑)
|
||||||
|
if (key.startsWith('concurrency:queue:')) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查键类型
|
||||||
|
const keyType = await client.type(key)
|
||||||
|
if (keyType !== 'zset') {
|
||||||
|
// 非 zset 类型的遗留键,直接删除
|
||||||
|
await client.del(key)
|
||||||
|
legacyCleaned++
|
||||||
|
cleanedKeys.push({
|
||||||
|
key,
|
||||||
|
cleanedCount: 0,
|
||||||
|
type: keyType,
|
||||||
|
legacy: true
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
// 只清理过期的条目
|
// 只清理过期的条目
|
||||||
const cleaned = await client.zremrangebyscore(key, '-inf', now)
|
const cleaned = await client.zremrangebyscore(key, '-inf', now)
|
||||||
if (cleaned > 0) {
|
if (cleaned > 0) {
|
||||||
@@ -2332,13 +2564,14 @@ class RedisClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
`🧹 Cleaned up expired concurrency: ${totalCleaned} entries from ${cleanedKeys.length} keys`
|
`🧹 Cleaned up expired concurrency: ${totalCleaned} entries from ${cleanedKeys.length} keys, ${legacyCleaned} legacy keys removed`
|
||||||
)
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
keysProcessed: keys.length,
|
keysProcessed: keys.length,
|
||||||
keysCleaned: cleanedKeys.length,
|
keysCleaned: cleanedKeys.length,
|
||||||
totalEntriesCleaned: totalCleaned,
|
totalEntriesCleaned: totalCleaned,
|
||||||
|
legacyKeysRemoved: legacyCleaned,
|
||||||
cleanedKeys,
|
cleanedKeys,
|
||||||
success: true
|
success: true
|
||||||
}
|
}
|
||||||
@@ -3157,4 +3390,249 @@ redisClient.scanConcurrencyQueueStatsKeys = async function () {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 账户测试历史相关操作
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const ACCOUNT_TEST_HISTORY_MAX = 5 // 保留最近5次测试记录
|
||||||
|
const ACCOUNT_TEST_HISTORY_TTL = 86400 * 30 // 30天过期
|
||||||
|
const ACCOUNT_TEST_CONFIG_TTL = 86400 * 365 // 测试配置保留1年(用户通常长期使用)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 保存账户测试结果
|
||||||
|
* @param {string} accountId - 账户ID
|
||||||
|
* @param {string} platform - 平台类型 (claude/gemini/openai等)
|
||||||
|
* @param {Object} testResult - 测试结果对象
|
||||||
|
* @param {boolean} testResult.success - 是否成功
|
||||||
|
* @param {string} testResult.message - 测试消息/响应
|
||||||
|
* @param {number} testResult.latencyMs - 延迟毫秒数
|
||||||
|
* @param {string} testResult.error - 错误信息(如有)
|
||||||
|
* @param {string} testResult.timestamp - 测试时间戳
|
||||||
|
*/
|
||||||
|
redisClient.saveAccountTestResult = async function (accountId, platform, testResult) {
|
||||||
|
const key = `account:test_history:${platform}:${accountId}`
|
||||||
|
try {
|
||||||
|
const record = JSON.stringify({
|
||||||
|
...testResult,
|
||||||
|
timestamp: testResult.timestamp || new Date().toISOString()
|
||||||
|
})
|
||||||
|
|
||||||
|
// 使用 LPUSH + LTRIM 保持最近5条记录
|
||||||
|
const client = this.getClientSafe()
|
||||||
|
await client.lpush(key, record)
|
||||||
|
await client.ltrim(key, 0, ACCOUNT_TEST_HISTORY_MAX - 1)
|
||||||
|
await client.expire(key, ACCOUNT_TEST_HISTORY_TTL)
|
||||||
|
|
||||||
|
logger.debug(`📝 Saved test result for ${platform} account ${accountId}`)
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to save test result for ${accountId}:`, error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取账户测试历史
|
||||||
|
* @param {string} accountId - 账户ID
|
||||||
|
* @param {string} platform - 平台类型
|
||||||
|
* @returns {Promise<Array>} 测试历史记录数组(最新在前)
|
||||||
|
*/
|
||||||
|
redisClient.getAccountTestHistory = async function (accountId, platform) {
|
||||||
|
const key = `account:test_history:${platform}:${accountId}`
|
||||||
|
try {
|
||||||
|
const client = this.getClientSafe()
|
||||||
|
const records = await client.lrange(key, 0, -1)
|
||||||
|
return records.map((r) => JSON.parse(r))
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to get test history for ${accountId}:`, error)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取账户最新测试结果
|
||||||
|
* @param {string} accountId - 账户ID
|
||||||
|
* @param {string} platform - 平台类型
|
||||||
|
* @returns {Promise<Object|null>} 最新测试结果
|
||||||
|
*/
|
||||||
|
redisClient.getAccountLatestTestResult = async function (accountId, platform) {
|
||||||
|
const key = `account:test_history:${platform}:${accountId}`
|
||||||
|
try {
|
||||||
|
const client = this.getClientSafe()
|
||||||
|
const record = await client.lindex(key, 0)
|
||||||
|
return record ? JSON.parse(record) : null
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to get latest test result for ${accountId}:`, error)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量获取多个账户的测试历史
|
||||||
|
* @param {Array<{accountId: string, platform: string}>} accounts - 账户列表
|
||||||
|
* @returns {Promise<Object>} 以 accountId 为 key 的测试历史映射
|
||||||
|
*/
|
||||||
|
redisClient.getAccountsTestHistory = async function (accounts) {
|
||||||
|
const result = {}
|
||||||
|
try {
|
||||||
|
const client = this.getClientSafe()
|
||||||
|
const pipeline = client.pipeline()
|
||||||
|
|
||||||
|
for (const { accountId, platform } of accounts) {
|
||||||
|
const key = `account:test_history:${platform}:${accountId}`
|
||||||
|
pipeline.lrange(key, 0, -1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const responses = await pipeline.exec()
|
||||||
|
|
||||||
|
accounts.forEach(({ accountId }, index) => {
|
||||||
|
const [err, records] = responses[index]
|
||||||
|
if (!err && records) {
|
||||||
|
result[accountId] = records.map((r) => JSON.parse(r))
|
||||||
|
} else {
|
||||||
|
result[accountId] = []
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to get batch test history:', error)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 保存定时测试配置
|
||||||
|
* @param {string} accountId - 账户ID
|
||||||
|
* @param {string} platform - 平台类型
|
||||||
|
* @param {Object} config - 配置对象
|
||||||
|
* @param {boolean} config.enabled - 是否启用定时测试
|
||||||
|
* @param {string} config.cronExpression - Cron 表达式 (如 "0 8 * * *" 表示每天8点)
|
||||||
|
* @param {string} config.model - 测试使用的模型
|
||||||
|
*/
|
||||||
|
redisClient.saveAccountTestConfig = async function (accountId, platform, testConfig) {
|
||||||
|
const key = `account:test_config:${platform}:${accountId}`
|
||||||
|
try {
|
||||||
|
const client = this.getClientSafe()
|
||||||
|
await client.hset(key, {
|
||||||
|
enabled: testConfig.enabled ? 'true' : 'false',
|
||||||
|
cronExpression: testConfig.cronExpression || '0 8 * * *', // 默认每天早上8点
|
||||||
|
model: testConfig.model || 'claude-sonnet-4-5-20250929', // 默认模型
|
||||||
|
updatedAt: new Date().toISOString()
|
||||||
|
})
|
||||||
|
// 设置过期时间(1年)
|
||||||
|
await client.expire(key, ACCOUNT_TEST_CONFIG_TTL)
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to save test config for ${accountId}:`, error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取定时测试配置
|
||||||
|
* @param {string} accountId - 账户ID
|
||||||
|
* @param {string} platform - 平台类型
|
||||||
|
* @returns {Promise<Object|null>} 配置对象
|
||||||
|
*/
|
||||||
|
redisClient.getAccountTestConfig = async function (accountId, platform) {
|
||||||
|
const key = `account:test_config:${platform}:${accountId}`
|
||||||
|
try {
|
||||||
|
const client = this.getClientSafe()
|
||||||
|
const testConfig = await client.hgetall(key)
|
||||||
|
if (!testConfig || Object.keys(testConfig).length === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
// 向后兼容:如果存在旧的 testHour 字段,转换为 cron 表达式
|
||||||
|
let { cronExpression } = testConfig
|
||||||
|
if (!cronExpression && testConfig.testHour) {
|
||||||
|
const hour = parseInt(testConfig.testHour, 10)
|
||||||
|
cronExpression = `0 ${hour} * * *`
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
enabled: testConfig.enabled === 'true',
|
||||||
|
cronExpression: cronExpression || '0 8 * * *',
|
||||||
|
model: testConfig.model || 'claude-sonnet-4-5-20250929',
|
||||||
|
updatedAt: testConfig.updatedAt
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to get test config for ${accountId}:`, error)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取所有启用定时测试的账户
|
||||||
|
* @param {string} platform - 平台类型
|
||||||
|
* @returns {Promise<Array>} 账户ID列表及 cron 配置
|
||||||
|
*/
|
||||||
|
redisClient.getEnabledTestAccounts = async function (platform) {
|
||||||
|
const accountIds = []
|
||||||
|
let cursor = '0'
|
||||||
|
|
||||||
|
try {
|
||||||
|
const client = this.getClientSafe()
|
||||||
|
do {
|
||||||
|
const [newCursor, keys] = await client.scan(
|
||||||
|
cursor,
|
||||||
|
'MATCH',
|
||||||
|
`account:test_config:${platform}:*`,
|
||||||
|
'COUNT',
|
||||||
|
100
|
||||||
|
)
|
||||||
|
cursor = newCursor
|
||||||
|
|
||||||
|
for (const key of keys) {
|
||||||
|
const testConfig = await client.hgetall(key)
|
||||||
|
if (testConfig && testConfig.enabled === 'true') {
|
||||||
|
const accountId = key.replace(`account:test_config:${platform}:`, '')
|
||||||
|
// 向后兼容:如果存在旧的 testHour 字段,转换为 cron 表达式
|
||||||
|
let { cronExpression } = testConfig
|
||||||
|
if (!cronExpression && testConfig.testHour) {
|
||||||
|
const hour = parseInt(testConfig.testHour, 10)
|
||||||
|
cronExpression = `0 ${hour} * * *`
|
||||||
|
}
|
||||||
|
accountIds.push({
|
||||||
|
accountId,
|
||||||
|
cronExpression: cronExpression || '0 8 * * *',
|
||||||
|
model: testConfig.model || 'claude-sonnet-4-5-20250929'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} while (cursor !== '0')
|
||||||
|
|
||||||
|
return accountIds
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to get enabled test accounts for ${platform}:`, error)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 保存账户上次测试时间(用于调度器判断是否需要测试)
|
||||||
|
* @param {string} accountId - 账户ID
|
||||||
|
* @param {string} platform - 平台类型
|
||||||
|
*/
|
||||||
|
redisClient.setAccountLastTestTime = async function (accountId, platform) {
|
||||||
|
const key = `account:last_test:${platform}:${accountId}`
|
||||||
|
try {
|
||||||
|
const client = this.getClientSafe()
|
||||||
|
await client.set(key, Date.now().toString(), 'EX', 86400 * 7) // 7天过期
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to set last test time for ${accountId}:`, error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取账户上次测试时间
|
||||||
|
* @param {string} accountId - 账户ID
|
||||||
|
* @param {string} platform - 平台类型
|
||||||
|
* @returns {Promise<number|null>} 上次测试时间戳
|
||||||
|
*/
|
||||||
|
redisClient.getAccountLastTestTime = async function (accountId, platform) {
|
||||||
|
const key = `account:last_test:${platform}:${accountId}`
|
||||||
|
try {
|
||||||
|
const client = this.getClientSafe()
|
||||||
|
const timestamp = await client.get(key)
|
||||||
|
return timestamp ? parseInt(timestamp, 10) : null
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to get last test time for ${accountId}:`, error)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = redisClient
|
module.exports = redisClient
|
||||||
|
|||||||
214
src/routes/admin/accountBalance.js
Normal file
214
src/routes/admin/accountBalance.js
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
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 { isBalanceScriptEnabled } = require('../../utils/featureFlags')
|
||||||
|
|
||||||
|
const router = express.Router()
|
||||||
|
|
||||||
|
const ensureValidPlatform = (rawPlatform) => {
|
||||||
|
const normalized = accountBalanceService.normalizePlatform(rawPlatform)
|
||||||
|
if (!normalized) {
|
||||||
|
return { ok: false, status: 400, error: '缺少 platform 参数' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const supported = accountBalanceService.getSupportedPlatforms()
|
||||||
|
if (!supported.includes(normalized)) {
|
||||||
|
return { ok: false, status: 400, error: `不支持的平台: ${normalized}` }
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true, platform: normalized }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1) 获取账户余额(默认本地统计优先,可选触发 Provider)
|
||||||
|
// GET /admin/accounts/:accountId/balance?platform=xxx&queryApi=false
|
||||||
|
router.get('/accounts/:accountId/balance', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { accountId } = req.params
|
||||||
|
const { platform, queryApi } = req.query
|
||||||
|
|
||||||
|
const valid = ensureValidPlatform(platform)
|
||||||
|
if (!valid.ok) {
|
||||||
|
return res.status(valid.status).json({ success: false, error: valid.error })
|
||||||
|
}
|
||||||
|
|
||||||
|
const balance = await accountBalanceService.getAccountBalance(accountId, valid.platform, {
|
||||||
|
queryApi
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!balance) {
|
||||||
|
return res.status(404).json({ success: false, error: 'Account not found' })
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json(balance)
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('获取账户余额失败', error)
|
||||||
|
return res.status(500).json({ success: false, error: error.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 2) 强制刷新账户余额(强制触发查询:优先脚本;Provider 仅为降级)
|
||||||
|
// POST /admin/accounts/:accountId/balance/refresh
|
||||||
|
// Body: { platform: 'xxx' }
|
||||||
|
router.post('/accounts/:accountId/balance/refresh', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { accountId } = req.params
|
||||||
|
const { platform } = req.body || {}
|
||||||
|
|
||||||
|
const valid = ensureValidPlatform(platform)
|
||||||
|
if (!valid.ok) {
|
||||||
|
return res.status(valid.status).json({ success: false, error: valid.error })
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`手动刷新余额: ${valid.platform}:${accountId}`)
|
||||||
|
|
||||||
|
const balance = await accountBalanceService.refreshAccountBalance(accountId, valid.platform)
|
||||||
|
if (!balance) {
|
||||||
|
return res.status(404).json({ success: false, error: 'Account not found' })
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json(balance)
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('刷新账户余额失败', error)
|
||||||
|
return res.status(500).json({ success: false, error: error.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 3) 批量获取平台所有账户余额
|
||||||
|
// GET /admin/accounts/balance/platform/:platform?queryApi=false
|
||||||
|
router.get('/accounts/balance/platform/:platform', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { platform } = req.params
|
||||||
|
const { queryApi } = req.query
|
||||||
|
|
||||||
|
const valid = ensureValidPlatform(platform)
|
||||||
|
if (!valid.ok) {
|
||||||
|
return res.status(valid.status).json({ success: false, error: valid.error })
|
||||||
|
}
|
||||||
|
|
||||||
|
const balances = await accountBalanceService.getAllAccountsBalance(valid.platform, { queryApi })
|
||||||
|
|
||||||
|
return res.json({ success: true, data: balances })
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('批量获取余额失败', error)
|
||||||
|
return res.status(500).json({ success: false, error: error.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 4) 获取余额汇总(Dashboard 用)
|
||||||
|
// GET /admin/accounts/balance/summary
|
||||||
|
router.get('/accounts/balance/summary', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const summary = await accountBalanceService.getBalanceSummary()
|
||||||
|
return res.json({ success: true, data: summary })
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('获取余额汇总失败', error)
|
||||||
|
return res.status(500).json({ success: false, error: error.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 5) 清除缓存
|
||||||
|
// DELETE /admin/accounts/:accountId/balance/cache?platform=xxx
|
||||||
|
router.delete('/accounts/:accountId/balance/cache', 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 })
|
||||||
|
}
|
||||||
|
|
||||||
|
await accountBalanceService.clearCache(accountId, valid.platform)
|
||||||
|
|
||||||
|
return res.json({ success: true, message: '缓存已清除' })
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('清除缓存失败', error)
|
||||||
|
return res.status(500).json({ success: false, error: error.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 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 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isBalanceScriptEnabled()) {
|
||||||
|
return res.status(403).json({
|
||||||
|
success: false,
|
||||||
|
error: '余额脚本功能已禁用(可通过 BALANCE_SCRIPT_ENABLED=true 启用)'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = req.body || {}
|
||||||
|
const { scriptBody } = payload
|
||||||
|
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
|
||||||
@@ -8,6 +8,43 @@ const config = require('../../../config/config')
|
|||||||
|
|
||||||
const router = express.Router()
|
const router = express.Router()
|
||||||
|
|
||||||
|
// 有效的权限值列表
|
||||||
|
const VALID_PERMISSIONS = ['claude', 'gemini', 'openai', 'droid']
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证权限数组格式
|
||||||
|
* @param {any} permissions - 权限值(可以是数组或其他)
|
||||||
|
* @returns {string|null} - 返回错误消息,null 表示验证通过
|
||||||
|
*/
|
||||||
|
function validatePermissions(permissions) {
|
||||||
|
// 空值或未定义表示全部服务
|
||||||
|
if (permissions === undefined || permissions === null || permissions === '') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
// 兼容旧格式字符串
|
||||||
|
if (typeof permissions === 'string') {
|
||||||
|
if (permissions === 'all' || VALID_PERMISSIONS.includes(permissions)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return `Invalid permissions value. Must be an array of: ${VALID_PERMISSIONS.join(', ')}`
|
||||||
|
}
|
||||||
|
// 新格式数组
|
||||||
|
if (Array.isArray(permissions)) {
|
||||||
|
// 空数组表示全部服务
|
||||||
|
if (permissions.length === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
// 验证数组中的每个值
|
||||||
|
for (const perm of permissions) {
|
||||||
|
if (!VALID_PERMISSIONS.includes(perm)) {
|
||||||
|
return `Invalid permission value "${perm}". Valid values are: ${VALID_PERMISSIONS.join(', ')}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return `Permissions must be an array. Valid values are: ${VALID_PERMISSIONS.join(', ')}`
|
||||||
|
}
|
||||||
|
|
||||||
// 👥 用户管理 (用于API Key分配)
|
// 👥 用户管理 (用于API Key分配)
|
||||||
|
|
||||||
// 获取所有用户列表(用于API Key分配)
|
// 获取所有用户列表(用于API Key分配)
|
||||||
@@ -1382,16 +1419,10 @@ router.post('/api-keys', authenticateAdmin, async (req, res) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 验证服务权限字段
|
// 验证服务权限字段(支持数组格式)
|
||||||
if (
|
const permissionsError = validatePermissions(permissions)
|
||||||
permissions !== undefined &&
|
if (permissionsError) {
|
||||||
permissions !== null &&
|
return res.status(400).json({ error: permissionsError })
|
||||||
permissions !== '' &&
|
|
||||||
!['claude', 'gemini', 'openai', 'droid', 'all'].includes(permissions)
|
|
||||||
) {
|
|
||||||
return res.status(400).json({
|
|
||||||
error: 'Invalid permissions value. Must be claude, gemini, openai, droid, or all'
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const newKey = await apiKeyService.generateApiKey({
|
const newKey = await apiKeyService.generateApiKey({
|
||||||
@@ -1481,15 +1512,10 @@ router.post('/api-keys/batch', authenticateAdmin, async (req, res) => {
|
|||||||
.json({ error: 'Base name must be less than 90 characters to allow for numbering' })
|
.json({ error: 'Base name must be less than 90 characters to allow for numbering' })
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
// 验证服务权限字段(支持数组格式)
|
||||||
permissions !== undefined &&
|
const batchPermissionsError = validatePermissions(permissions)
|
||||||
permissions !== null &&
|
if (batchPermissionsError) {
|
||||||
permissions !== '' &&
|
return res.status(400).json({ error: batchPermissionsError })
|
||||||
!['claude', 'gemini', 'openai', 'droid', 'all'].includes(permissions)
|
|
||||||
) {
|
|
||||||
return res.status(400).json({
|
|
||||||
error: 'Invalid permissions value. Must be claude, gemini, openai, droid, or all'
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 生成批量API Keys
|
// 生成批量API Keys
|
||||||
@@ -1592,13 +1618,12 @@ router.put('/api-keys/batch', authenticateAdmin, async (req, res) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
// 验证服务权限字段(支持数组格式)
|
||||||
updates.permissions !== undefined &&
|
if (updates.permissions !== undefined) {
|
||||||
!['claude', 'gemini', 'openai', 'droid', 'all'].includes(updates.permissions)
|
const updatePermissionsError = validatePermissions(updates.permissions)
|
||||||
) {
|
if (updatePermissionsError) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({ error: updatePermissionsError })
|
||||||
error: 'Invalid permissions value. Must be claude, gemini, openai, droid, or all'
|
}
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -1873,11 +1898,10 @@ router.put('/api-keys/:keyId', authenticateAdmin, async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (permissions !== undefined) {
|
if (permissions !== undefined) {
|
||||||
// 验证权限值
|
// 验证服务权限字段(支持数组格式)
|
||||||
if (!['claude', 'gemini', 'openai', 'droid', 'all'].includes(permissions)) {
|
const singlePermissionsError = validatePermissions(permissions)
|
||||||
return res.status(400).json({
|
if (singlePermissionsError) {
|
||||||
error: 'Invalid permissions value. Must be claude, gemini, openai, droid, or all'
|
return res.status(400).json({ error: singlePermissionsError })
|
||||||
})
|
|
||||||
}
|
}
|
||||||
updates.permissions = permissions
|
updates.permissions = permissions
|
||||||
}
|
}
|
||||||
|
|||||||
41
src/routes/admin/balanceScripts.js
Normal file
41
src/routes/admin/balanceScripts.js
Normal file
@@ -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
|
||||||
@@ -9,6 +9,7 @@ const router = express.Router()
|
|||||||
const claudeAccountService = require('../../services/claudeAccountService')
|
const claudeAccountService = require('../../services/claudeAccountService')
|
||||||
const claudeRelayService = require('../../services/claudeRelayService')
|
const claudeRelayService = require('../../services/claudeRelayService')
|
||||||
const accountGroupService = require('../../services/accountGroupService')
|
const accountGroupService = require('../../services/accountGroupService')
|
||||||
|
const accountTestSchedulerService = require('../../services/accountTestSchedulerService')
|
||||||
const apiKeyService = require('../../services/apiKeyService')
|
const apiKeyService = require('../../services/apiKeyService')
|
||||||
const redis = require('../../models/redis')
|
const redis = require('../../models/redis')
|
||||||
const { authenticateAdmin } = require('../../middleware/auth')
|
const { authenticateAdmin } = require('../../middleware/auth')
|
||||||
@@ -583,7 +584,9 @@ router.post('/claude-accounts', authenticateAdmin, async (req, res) => {
|
|||||||
useUnifiedClientId,
|
useUnifiedClientId,
|
||||||
unifiedClientId,
|
unifiedClientId,
|
||||||
expiresAt,
|
expiresAt,
|
||||||
extInfo
|
extInfo,
|
||||||
|
maxConcurrency,
|
||||||
|
interceptWarmup
|
||||||
} = req.body
|
} = req.body
|
||||||
|
|
||||||
if (!name) {
|
if (!name) {
|
||||||
@@ -628,7 +631,9 @@ router.post('/claude-accounts', authenticateAdmin, async (req, res) => {
|
|||||||
useUnifiedClientId: useUnifiedClientId === true, // 默认为false
|
useUnifiedClientId: useUnifiedClientId === true, // 默认为false
|
||||||
unifiedClientId: unifiedClientId || '', // 统一的客户端标识
|
unifiedClientId: unifiedClientId || '', // 统一的客户端标识
|
||||||
expiresAt: expiresAt || null, // 账户订阅到期时间
|
expiresAt: expiresAt || null, // 账户订阅到期时间
|
||||||
extInfo: extInfo || null
|
extInfo: extInfo || null,
|
||||||
|
maxConcurrency: maxConcurrency || 0, // 账户级串行队列:0=使用全局配置,>0=强制启用
|
||||||
|
interceptWarmup: interceptWarmup === true // 拦截预热请求:默认为false
|
||||||
})
|
})
|
||||||
|
|
||||||
// 如果是分组类型,将账户添加到分组
|
// 如果是分组类型,将账户添加到分组
|
||||||
@@ -903,4 +908,219 @@ router.post('/claude-accounts/:accountId/test', authenticateAdmin, async (req, r
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 账户定时测试相关端点
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// 获取账户测试历史
|
||||||
|
router.get('/claude-accounts/:accountId/test-history', authenticateAdmin, async (req, res) => {
|
||||||
|
const { accountId } = req.params
|
||||||
|
|
||||||
|
try {
|
||||||
|
const history = await redis.getAccountTestHistory(accountId, 'claude')
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
accountId,
|
||||||
|
platform: 'claude',
|
||||||
|
history
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`❌ Failed to get test history for account ${accountId}:`, error)
|
||||||
|
return res.status(500).json({
|
||||||
|
error: 'Failed to get test history',
|
||||||
|
message: error.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 获取账户定时测试配置
|
||||||
|
router.get('/claude-accounts/:accountId/test-config', authenticateAdmin, async (req, res) => {
|
||||||
|
const { accountId } = req.params
|
||||||
|
|
||||||
|
try {
|
||||||
|
const testConfig = await redis.getAccountTestConfig(accountId, 'claude')
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
accountId,
|
||||||
|
platform: 'claude',
|
||||||
|
config: testConfig || {
|
||||||
|
enabled: false,
|
||||||
|
cronExpression: '0 8 * * *',
|
||||||
|
model: 'claude-sonnet-4-5-20250929'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`❌ Failed to get test config for account ${accountId}:`, error)
|
||||||
|
return res.status(500).json({
|
||||||
|
error: 'Failed to get test config',
|
||||||
|
message: error.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 设置账户定时测试配置
|
||||||
|
router.put('/claude-accounts/:accountId/test-config', authenticateAdmin, async (req, res) => {
|
||||||
|
const { accountId } = req.params
|
||||||
|
const { enabled, cronExpression, model } = req.body
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 验证 enabled 参数
|
||||||
|
if (typeof enabled !== 'boolean') {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'Invalid parameter',
|
||||||
|
message: 'enabled must be a boolean'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证 cronExpression 参数
|
||||||
|
if (!cronExpression || typeof cronExpression !== 'string') {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'Invalid parameter',
|
||||||
|
message: 'cronExpression is required and must be a string'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 限制 cronExpression 长度防止 DoS
|
||||||
|
const MAX_CRON_LENGTH = 100
|
||||||
|
if (cronExpression.length > MAX_CRON_LENGTH) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'Invalid parameter',
|
||||||
|
message: `cronExpression too long (max ${MAX_CRON_LENGTH} characters)`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用 service 的方法验证 cron 表达式
|
||||||
|
if (!accountTestSchedulerService.validateCronExpression(cronExpression)) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'Invalid parameter',
|
||||||
|
message: `Invalid cron expression: ${cronExpression}. Format: "minute hour day month weekday" (e.g., "0 8 * * *" for daily at 8:00)`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证模型参数
|
||||||
|
const testModel = model || 'claude-sonnet-4-5-20250929'
|
||||||
|
if (typeof testModel !== 'string' || testModel.length > 256) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'Invalid parameter',
|
||||||
|
message: 'model must be a valid string (max 256 characters)'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查账户是否存在
|
||||||
|
const account = await claudeAccountService.getAccount(accountId)
|
||||||
|
if (!account) {
|
||||||
|
return res.status(404).json({
|
||||||
|
error: 'Account not found',
|
||||||
|
message: `Claude account ${accountId} not found`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存配置
|
||||||
|
await redis.saveAccountTestConfig(accountId, 'claude', {
|
||||||
|
enabled,
|
||||||
|
cronExpression,
|
||||||
|
model: testModel
|
||||||
|
})
|
||||||
|
|
||||||
|
logger.success(
|
||||||
|
`📝 Updated test config for Claude account ${accountId}: enabled=${enabled}, cronExpression=${cronExpression}, model=${testModel}`
|
||||||
|
)
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Test config updated successfully',
|
||||||
|
data: {
|
||||||
|
accountId,
|
||||||
|
platform: 'claude',
|
||||||
|
config: { enabled, cronExpression, model: testModel }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`❌ Failed to update test config for account ${accountId}:`, error)
|
||||||
|
return res.status(500).json({
|
||||||
|
error: 'Failed to update test config',
|
||||||
|
message: error.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 手动触发账户测试(非流式,返回JSON结果)
|
||||||
|
router.post('/claude-accounts/:accountId/test-sync', authenticateAdmin, async (req, res) => {
|
||||||
|
const { accountId } = req.params
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 检查账户是否存在
|
||||||
|
const account = await claudeAccountService.getAccount(accountId)
|
||||||
|
if (!account) {
|
||||||
|
return res.status(404).json({
|
||||||
|
error: 'Account not found',
|
||||||
|
message: `Claude account ${accountId} not found`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`🧪 Manual sync test triggered for Claude account: ${accountId}`)
|
||||||
|
|
||||||
|
// 执行测试
|
||||||
|
const testResult = await claudeRelayService.testAccountConnectionSync(accountId)
|
||||||
|
|
||||||
|
// 保存测试结果到历史
|
||||||
|
await redis.saveAccountTestResult(accountId, 'claude', testResult)
|
||||||
|
await redis.setAccountLastTestTime(accountId, 'claude')
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
accountId,
|
||||||
|
platform: 'claude',
|
||||||
|
result: testResult
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`❌ Failed to run sync test for account ${accountId}:`, error)
|
||||||
|
return res.status(500).json({
|
||||||
|
error: 'Failed to run test',
|
||||||
|
message: error.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 批量获取多个账户的测试历史
|
||||||
|
router.post('/claude-accounts/batch-test-history', authenticateAdmin, async (req, res) => {
|
||||||
|
const { accountIds } = req.body
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!Array.isArray(accountIds) || accountIds.length === 0) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'Invalid parameter',
|
||||||
|
message: 'accountIds must be a non-empty array'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 限制批量查询数量
|
||||||
|
const limitedIds = accountIds.slice(0, 100)
|
||||||
|
|
||||||
|
const accounts = limitedIds.map((accountId) => ({
|
||||||
|
accountId,
|
||||||
|
platform: 'claude'
|
||||||
|
}))
|
||||||
|
|
||||||
|
const historyMap = await redis.getAccountsTestHistory(accounts)
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
data: historyMap
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to get batch test history:', error)
|
||||||
|
return res.status(500).json({
|
||||||
|
error: 'Failed to get batch test history',
|
||||||
|
message: error.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
module.exports = router
|
module.exports = router
|
||||||
|
|||||||
@@ -132,7 +132,8 @@ router.post('/claude-console-accounts', authenticateAdmin, async (req, res) => {
|
|||||||
dailyQuota,
|
dailyQuota,
|
||||||
quotaResetTime,
|
quotaResetTime,
|
||||||
maxConcurrentTasks,
|
maxConcurrentTasks,
|
||||||
disableAutoProtection
|
disableAutoProtection,
|
||||||
|
interceptWarmup
|
||||||
} = req.body
|
} = req.body
|
||||||
|
|
||||||
if (!name || !apiUrl || !apiKey) {
|
if (!name || !apiUrl || !apiKey) {
|
||||||
@@ -186,7 +187,8 @@ router.post('/claude-console-accounts', authenticateAdmin, async (req, res) => {
|
|||||||
maxConcurrentTasks !== undefined && maxConcurrentTasks !== null
|
maxConcurrentTasks !== undefined && maxConcurrentTasks !== null
|
||||||
? Number(maxConcurrentTasks)
|
? Number(maxConcurrentTasks)
|
||||||
: 0,
|
: 0,
|
||||||
disableAutoProtection: normalizedDisableAutoProtection
|
disableAutoProtection: normalizedDisableAutoProtection,
|
||||||
|
interceptWarmup: interceptWarmup === true || interceptWarmup === 'true'
|
||||||
})
|
})
|
||||||
|
|
||||||
// 如果是分组类型,将账户添加到分组(CCR 归属 Claude 平台分组)
|
// 如果是分组类型,将账户添加到分组(CCR 归属 Claude 平台分组)
|
||||||
|
|||||||
@@ -21,9 +21,11 @@ const openaiResponsesAccountsRoutes = require('./openaiResponsesAccounts')
|
|||||||
const droidAccountsRoutes = require('./droidAccounts')
|
const droidAccountsRoutes = require('./droidAccounts')
|
||||||
const dashboardRoutes = require('./dashboard')
|
const dashboardRoutes = require('./dashboard')
|
||||||
const usageStatsRoutes = require('./usageStats')
|
const usageStatsRoutes = require('./usageStats')
|
||||||
|
const accountBalanceRoutes = require('./accountBalance')
|
||||||
const systemRoutes = require('./system')
|
const systemRoutes = require('./system')
|
||||||
const concurrencyRoutes = require('./concurrency')
|
const concurrencyRoutes = require('./concurrency')
|
||||||
const claudeRelayConfigRoutes = require('./claudeRelayConfig')
|
const claudeRelayConfigRoutes = require('./claudeRelayConfig')
|
||||||
|
const syncRoutes = require('./sync')
|
||||||
|
|
||||||
// 挂载所有子路由
|
// 挂载所有子路由
|
||||||
// 使用完整路径的模块(直接挂载到根路径)
|
// 使用完整路径的模块(直接挂载到根路径)
|
||||||
@@ -36,9 +38,11 @@ router.use('/', openaiResponsesAccountsRoutes)
|
|||||||
router.use('/', droidAccountsRoutes)
|
router.use('/', droidAccountsRoutes)
|
||||||
router.use('/', dashboardRoutes)
|
router.use('/', dashboardRoutes)
|
||||||
router.use('/', usageStatsRoutes)
|
router.use('/', usageStatsRoutes)
|
||||||
|
router.use('/', accountBalanceRoutes)
|
||||||
router.use('/', systemRoutes)
|
router.use('/', systemRoutes)
|
||||||
router.use('/', concurrencyRoutes)
|
router.use('/', concurrencyRoutes)
|
||||||
router.use('/', claudeRelayConfigRoutes)
|
router.use('/', claudeRelayConfigRoutes)
|
||||||
|
router.use('/', syncRoutes)
|
||||||
|
|
||||||
// 使用相对路径的模块(需要指定基础路径前缀)
|
// 使用相对路径的模块(需要指定基础路径前缀)
|
||||||
router.use('/account-groups', accountGroupsRoutes)
|
router.use('/account-groups', accountGroupsRoutes)
|
||||||
|
|||||||
460
src/routes/admin/sync.js
Normal file
460
src/routes/admin/sync.js
Normal file
@@ -0,0 +1,460 @@
|
|||||||
|
/**
|
||||||
|
* Admin Routes - Sync / Export (for migration)
|
||||||
|
* Exports account data (including secrets) for safe server-to-server syncing.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const express = require('express')
|
||||||
|
const router = express.Router()
|
||||||
|
|
||||||
|
const { authenticateAdmin } = require('../../middleware/auth')
|
||||||
|
const redis = require('../../models/redis')
|
||||||
|
const claudeAccountService = require('../../services/claudeAccountService')
|
||||||
|
const claudeConsoleAccountService = require('../../services/claudeConsoleAccountService')
|
||||||
|
const openaiAccountService = require('../../services/openaiAccountService')
|
||||||
|
const openaiResponsesAccountService = require('../../services/openaiResponsesAccountService')
|
||||||
|
const logger = require('../../utils/logger')
|
||||||
|
|
||||||
|
function toBool(value, defaultValue = false) {
|
||||||
|
if (value === undefined || value === null || value === '') {
|
||||||
|
return defaultValue
|
||||||
|
}
|
||||||
|
if (value === true || value === 'true') {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (value === false || value === 'false') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return defaultValue
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeProxy(proxy) {
|
||||||
|
if (!proxy || typeof proxy !== 'object') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const protocol = proxy.protocol || proxy.type || proxy.scheme || ''
|
||||||
|
const host = proxy.host || ''
|
||||||
|
const port = Number(proxy.port || 0)
|
||||||
|
|
||||||
|
if (!protocol || !host || !Number.isFinite(port) || port <= 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
protocol: String(protocol),
|
||||||
|
host: String(host),
|
||||||
|
port,
|
||||||
|
username: proxy.username ? String(proxy.username) : '',
|
||||||
|
password: proxy.password ? String(proxy.password) : ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildModelMappingFromSupportedModels(supportedModels) {
|
||||||
|
if (!supportedModels) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(supportedModels)) {
|
||||||
|
const mapping = {}
|
||||||
|
for (const model of supportedModels) {
|
||||||
|
if (typeof model === 'string' && model.trim()) {
|
||||||
|
mapping[model.trim()] = model.trim()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Object.keys(mapping).length ? mapping : null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof supportedModels === 'object') {
|
||||||
|
const mapping = {}
|
||||||
|
for (const [from, to] of Object.entries(supportedModels)) {
|
||||||
|
if (typeof from === 'string' && typeof to === 'string' && from.trim() && to.trim()) {
|
||||||
|
mapping[from.trim()] = to.trim()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Object.keys(mapping).length ? mapping : null
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeParseJson(raw, fallback = null) {
|
||||||
|
if (!raw || typeof raw !== 'string') {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw)
|
||||||
|
} catch (_) {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export accounts for migration (includes secrets).
|
||||||
|
// GET /admin/sync/export-accounts?include_secrets=true
|
||||||
|
router.get('/sync/export-accounts', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const includeSecrets = toBool(req.query.include_secrets, false)
|
||||||
|
if (!includeSecrets) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'include_secrets_required',
|
||||||
|
message: 'Set include_secrets=true to export secrets'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Claude official OAuth / Setup Token accounts =====
|
||||||
|
const rawClaudeAccounts = await redis.getAllClaudeAccounts()
|
||||||
|
const claudeAccounts = rawClaudeAccounts.map((account) => {
|
||||||
|
// Backward compatible extraction: prefer individual fields, fallback to claudeAiOauth JSON blob.
|
||||||
|
let decryptedClaudeAiOauth = null
|
||||||
|
if (account.claudeAiOauth) {
|
||||||
|
try {
|
||||||
|
const raw = claudeAccountService._decryptSensitiveData(account.claudeAiOauth)
|
||||||
|
decryptedClaudeAiOauth = raw ? JSON.parse(raw) : null
|
||||||
|
} catch (_) {
|
||||||
|
decryptedClaudeAiOauth = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawScopes =
|
||||||
|
account.scopes && account.scopes.trim()
|
||||||
|
? account.scopes
|
||||||
|
: decryptedClaudeAiOauth?.scopes
|
||||||
|
? decryptedClaudeAiOauth.scopes.join(' ')
|
||||||
|
: ''
|
||||||
|
|
||||||
|
const scopes = rawScopes && rawScopes.trim() ? rawScopes.trim().split(' ') : []
|
||||||
|
const isOAuth = scopes.includes('user:profile') && scopes.includes('user:inference')
|
||||||
|
const authType = isOAuth ? 'oauth' : 'setup-token'
|
||||||
|
|
||||||
|
const accessToken =
|
||||||
|
account.accessToken && String(account.accessToken).trim()
|
||||||
|
? claudeAccountService._decryptSensitiveData(account.accessToken)
|
||||||
|
: decryptedClaudeAiOauth?.accessToken || ''
|
||||||
|
|
||||||
|
const refreshToken =
|
||||||
|
account.refreshToken && String(account.refreshToken).trim()
|
||||||
|
? claudeAccountService._decryptSensitiveData(account.refreshToken)
|
||||||
|
: decryptedClaudeAiOauth?.refreshToken || ''
|
||||||
|
|
||||||
|
let expiresAt = null
|
||||||
|
const expiresAtMs = Number.parseInt(account.expiresAt, 10)
|
||||||
|
if (Number.isFinite(expiresAtMs) && expiresAtMs > 0) {
|
||||||
|
expiresAt = new Date(expiresAtMs).toISOString()
|
||||||
|
} else if (decryptedClaudeAiOauth?.expiresAt) {
|
||||||
|
try {
|
||||||
|
expiresAt = new Date(Number(decryptedClaudeAiOauth.expiresAt)).toISOString()
|
||||||
|
} catch (_) {
|
||||||
|
expiresAt = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const proxy = account.proxy ? normalizeProxy(safeParseJson(account.proxy)) : null
|
||||||
|
|
||||||
|
// 🔧 Parse subscriptionInfo to extract org_uuid and account_uuid
|
||||||
|
let orgUuid = null
|
||||||
|
let accountUuid = null
|
||||||
|
if (account.subscriptionInfo) {
|
||||||
|
try {
|
||||||
|
const subscriptionInfo = JSON.parse(account.subscriptionInfo)
|
||||||
|
orgUuid = subscriptionInfo.organizationUuid || null
|
||||||
|
accountUuid = subscriptionInfo.accountUuid || null
|
||||||
|
} catch (_) {
|
||||||
|
// Ignore parse errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🔧 Calculate expires_in from expires_at
|
||||||
|
let expiresIn = null
|
||||||
|
if (expiresAt) {
|
||||||
|
try {
|
||||||
|
const expiresAtTime = new Date(expiresAt).getTime()
|
||||||
|
const nowTime = Date.now()
|
||||||
|
const diffSeconds = Math.floor((expiresAtTime - nowTime) / 1000)
|
||||||
|
if (diffSeconds > 0) {
|
||||||
|
expiresIn = diffSeconds
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// Ignore calculation errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 🔧 Use default expires_in if calculation failed (Anthropic OAuth: 8 hours)
|
||||||
|
if (!expiresIn && isOAuth) {
|
||||||
|
expiresIn = 28800 // 8 hours
|
||||||
|
}
|
||||||
|
|
||||||
|
const credentials = {
|
||||||
|
access_token: accessToken,
|
||||||
|
refresh_token: refreshToken || undefined,
|
||||||
|
expires_at: expiresAt || undefined,
|
||||||
|
expires_in: expiresIn || undefined,
|
||||||
|
scope: scopes.join(' ') || undefined,
|
||||||
|
token_type: 'Bearer'
|
||||||
|
}
|
||||||
|
// 🔧 Add auth info as top-level credentials fields
|
||||||
|
if (orgUuid) {
|
||||||
|
credentials.org_uuid = orgUuid
|
||||||
|
}
|
||||||
|
if (accountUuid) {
|
||||||
|
credentials.account_uuid = accountUuid
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🔧 Store complete original CRS data in extra
|
||||||
|
const extra = {
|
||||||
|
crs_account_id: account.id,
|
||||||
|
crs_kind: 'claude-account',
|
||||||
|
crs_id: account.id,
|
||||||
|
crs_name: account.name,
|
||||||
|
crs_description: account.description || '',
|
||||||
|
crs_platform: account.platform || 'claude',
|
||||||
|
crs_auth_type: authType,
|
||||||
|
crs_is_active: account.isActive === 'true',
|
||||||
|
crs_schedulable: account.schedulable !== 'false',
|
||||||
|
crs_priority: Number.parseInt(account.priority, 10) || 50,
|
||||||
|
crs_status: account.status || 'active',
|
||||||
|
crs_scopes: scopes,
|
||||||
|
crs_subscription_info: account.subscriptionInfo || undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
kind: 'claude-account',
|
||||||
|
id: account.id,
|
||||||
|
name: account.name,
|
||||||
|
description: account.description || '',
|
||||||
|
platform: account.platform || 'claude',
|
||||||
|
authType,
|
||||||
|
isActive: account.isActive === 'true',
|
||||||
|
schedulable: account.schedulable !== 'false',
|
||||||
|
priority: Number.parseInt(account.priority, 10) || 50,
|
||||||
|
status: account.status || 'active',
|
||||||
|
proxy,
|
||||||
|
credentials,
|
||||||
|
extra
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// ===== Claude Console API Key accounts =====
|
||||||
|
const claudeConsoleSummaries = await claudeConsoleAccountService.getAllAccounts()
|
||||||
|
const claudeConsoleAccounts = []
|
||||||
|
for (const summary of claudeConsoleSummaries) {
|
||||||
|
const full = await claudeConsoleAccountService.getAccount(summary.id)
|
||||||
|
if (!full) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const proxy = normalizeProxy(full.proxy)
|
||||||
|
const modelMapping = buildModelMappingFromSupportedModels(full.supportedModels)
|
||||||
|
|
||||||
|
const credentials = {
|
||||||
|
api_key: full.apiKey,
|
||||||
|
base_url: full.apiUrl
|
||||||
|
}
|
||||||
|
|
||||||
|
if (modelMapping) {
|
||||||
|
credentials.model_mapping = modelMapping
|
||||||
|
}
|
||||||
|
|
||||||
|
if (full.userAgent) {
|
||||||
|
credentials.user_agent = full.userAgent
|
||||||
|
}
|
||||||
|
|
||||||
|
claudeConsoleAccounts.push({
|
||||||
|
kind: 'claude-console-account',
|
||||||
|
id: full.id,
|
||||||
|
name: full.name,
|
||||||
|
description: full.description || '',
|
||||||
|
platform: full.platform || 'claude-console',
|
||||||
|
isActive: full.isActive === true,
|
||||||
|
schedulable: full.schedulable !== false,
|
||||||
|
priority: Number.parseInt(full.priority, 10) || 50,
|
||||||
|
status: full.status || 'active',
|
||||||
|
proxy,
|
||||||
|
maxConcurrentTasks: Number.parseInt(full.maxConcurrentTasks, 10) || 0,
|
||||||
|
credentials,
|
||||||
|
extra: {
|
||||||
|
crs_account_id: full.id,
|
||||||
|
crs_kind: 'claude-console-account',
|
||||||
|
crs_id: full.id,
|
||||||
|
crs_name: full.name,
|
||||||
|
crs_description: full.description || '',
|
||||||
|
crs_platform: full.platform || 'claude-console',
|
||||||
|
crs_is_active: full.isActive === true,
|
||||||
|
crs_schedulable: full.schedulable !== false,
|
||||||
|
crs_priority: Number.parseInt(full.priority, 10) || 50,
|
||||||
|
crs_status: full.status || 'active'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== OpenAI OAuth accounts =====
|
||||||
|
const openaiOAuthAccounts = []
|
||||||
|
{
|
||||||
|
const client = redis.getClientSafe()
|
||||||
|
const openaiKeys = await client.keys('openai:account:*')
|
||||||
|
for (const key of openaiKeys) {
|
||||||
|
const id = key.split(':').slice(2).join(':')
|
||||||
|
const account = await openaiAccountService.getAccount(id)
|
||||||
|
if (!account) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const accessToken = account.accessToken
|
||||||
|
? openaiAccountService.decrypt(account.accessToken)
|
||||||
|
: ''
|
||||||
|
if (!accessToken) {
|
||||||
|
// Skip broken/legacy records without decryptable token
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const scopes =
|
||||||
|
account.scopes && typeof account.scopes === 'string' && account.scopes.trim()
|
||||||
|
? account.scopes.trim().split(' ')
|
||||||
|
: []
|
||||||
|
|
||||||
|
const proxy = normalizeProxy(account.proxy)
|
||||||
|
|
||||||
|
// 🔧 Calculate expires_in from expires_at
|
||||||
|
let expiresIn = null
|
||||||
|
if (account.expiresAt) {
|
||||||
|
try {
|
||||||
|
const expiresAtTime = new Date(account.expiresAt).getTime()
|
||||||
|
const nowTime = Date.now()
|
||||||
|
const diffSeconds = Math.floor((expiresAtTime - nowTime) / 1000)
|
||||||
|
if (diffSeconds > 0) {
|
||||||
|
expiresIn = diffSeconds
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// Ignore calculation errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 🔧 Use default expires_in if calculation failed (OpenAI OAuth: 10 days)
|
||||||
|
if (!expiresIn) {
|
||||||
|
expiresIn = 864000 // 10 days
|
||||||
|
}
|
||||||
|
|
||||||
|
const credentials = {
|
||||||
|
access_token: accessToken,
|
||||||
|
refresh_token: account.refreshToken || undefined,
|
||||||
|
id_token: account.idToken || undefined,
|
||||||
|
expires_at: account.expiresAt || undefined,
|
||||||
|
expires_in: expiresIn || undefined,
|
||||||
|
scope: scopes.join(' ') || undefined,
|
||||||
|
token_type: 'Bearer'
|
||||||
|
}
|
||||||
|
// 🔧 Add auth info as top-level credentials fields
|
||||||
|
if (account.accountId) {
|
||||||
|
credentials.chatgpt_account_id = account.accountId
|
||||||
|
}
|
||||||
|
if (account.chatgptUserId) {
|
||||||
|
credentials.chatgpt_user_id = account.chatgptUserId
|
||||||
|
}
|
||||||
|
if (account.organizationId) {
|
||||||
|
credentials.organization_id = account.organizationId
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🔧 Store complete original CRS data in extra
|
||||||
|
const extra = {
|
||||||
|
crs_account_id: account.id,
|
||||||
|
crs_kind: 'openai-oauth-account',
|
||||||
|
crs_id: account.id,
|
||||||
|
crs_name: account.name,
|
||||||
|
crs_description: account.description || '',
|
||||||
|
crs_platform: account.platform || 'openai',
|
||||||
|
crs_is_active: account.isActive === 'true',
|
||||||
|
crs_schedulable: account.schedulable !== 'false',
|
||||||
|
crs_priority: Number.parseInt(account.priority, 10) || 50,
|
||||||
|
crs_status: account.status || 'active',
|
||||||
|
crs_scopes: scopes,
|
||||||
|
crs_email: account.email || undefined,
|
||||||
|
crs_chatgpt_account_id: account.accountId || undefined,
|
||||||
|
crs_chatgpt_user_id: account.chatgptUserId || undefined,
|
||||||
|
crs_organization_id: account.organizationId || undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
openaiOAuthAccounts.push({
|
||||||
|
kind: 'openai-oauth-account',
|
||||||
|
id: account.id,
|
||||||
|
name: account.name,
|
||||||
|
description: account.description || '',
|
||||||
|
platform: account.platform || 'openai',
|
||||||
|
authType: 'oauth',
|
||||||
|
isActive: account.isActive === 'true',
|
||||||
|
schedulable: account.schedulable !== 'false',
|
||||||
|
priority: Number.parseInt(account.priority, 10) || 50,
|
||||||
|
status: account.status || 'active',
|
||||||
|
proxy,
|
||||||
|
credentials,
|
||||||
|
extra
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== OpenAI Responses API Key accounts =====
|
||||||
|
const openaiResponsesAccounts = []
|
||||||
|
const client = redis.getClientSafe()
|
||||||
|
const openaiResponseKeys = await client.keys('openai_responses_account:*')
|
||||||
|
for (const key of openaiResponseKeys) {
|
||||||
|
const id = key.split(':').slice(1).join(':')
|
||||||
|
const full = await openaiResponsesAccountService.getAccount(id)
|
||||||
|
if (!full) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const proxy = normalizeProxy(full.proxy)
|
||||||
|
|
||||||
|
const credentials = {
|
||||||
|
api_key: full.apiKey,
|
||||||
|
base_url: full.baseApi
|
||||||
|
}
|
||||||
|
|
||||||
|
if (full.userAgent) {
|
||||||
|
credentials.user_agent = full.userAgent
|
||||||
|
}
|
||||||
|
|
||||||
|
openaiResponsesAccounts.push({
|
||||||
|
kind: 'openai-responses-account',
|
||||||
|
id: full.id,
|
||||||
|
name: full.name,
|
||||||
|
description: full.description || '',
|
||||||
|
platform: full.platform || 'openai-responses',
|
||||||
|
isActive: full.isActive === 'true',
|
||||||
|
schedulable: full.schedulable !== 'false',
|
||||||
|
priority: Number.parseInt(full.priority, 10) || 50,
|
||||||
|
status: full.status || 'active',
|
||||||
|
proxy,
|
||||||
|
credentials,
|
||||||
|
extra: {
|
||||||
|
crs_account_id: full.id,
|
||||||
|
crs_kind: 'openai-responses-account',
|
||||||
|
crs_id: full.id,
|
||||||
|
crs_name: full.name,
|
||||||
|
crs_description: full.description || '',
|
||||||
|
crs_platform: full.platform || 'openai-responses',
|
||||||
|
crs_is_active: full.isActive === 'true',
|
||||||
|
crs_schedulable: full.schedulable !== 'false',
|
||||||
|
crs_priority: Number.parseInt(full.priority, 10) || 50,
|
||||||
|
crs_status: full.status || 'active'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
exportedAt: new Date().toISOString(),
|
||||||
|
claudeAccounts,
|
||||||
|
claudeConsoleAccounts,
|
||||||
|
openaiOAuthAccounts,
|
||||||
|
openaiResponsesAccounts
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to export accounts for sync:', error)
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: 'export_failed',
|
||||||
|
message: error.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
module.exports = router
|
||||||
@@ -12,6 +12,13 @@ const { getEffectiveModel, parseVendorPrefixedModel } = require('../utils/modelH
|
|||||||
const sessionHelper = require('../utils/sessionHelper')
|
const sessionHelper = require('../utils/sessionHelper')
|
||||||
const { updateRateLimitCounters } = require('../utils/rateLimitHelper')
|
const { updateRateLimitCounters } = require('../utils/rateLimitHelper')
|
||||||
const claudeRelayConfigService = require('../services/claudeRelayConfigService')
|
const claudeRelayConfigService = require('../services/claudeRelayConfigService')
|
||||||
|
const claudeAccountService = require('../services/claudeAccountService')
|
||||||
|
const claudeConsoleAccountService = require('../services/claudeConsoleAccountService')
|
||||||
|
const {
|
||||||
|
isWarmupRequest,
|
||||||
|
buildMockWarmupResponse,
|
||||||
|
sendMockWarmupStream
|
||||||
|
} = require('../utils/warmupInterceptor')
|
||||||
const { sanitizeUpstreamError } = require('../utils/errorSanitizer')
|
const { sanitizeUpstreamError } = require('../utils/errorSanitizer')
|
||||||
const { dumpAnthropicMessagesRequest } = require('../utils/anthropicRequestDump')
|
const { dumpAnthropicMessagesRequest } = require('../utils/anthropicRequestDump')
|
||||||
const {
|
const {
|
||||||
@@ -115,6 +122,16 @@ async function handleMessagesRequest(req, res) {
|
|||||||
try {
|
try {
|
||||||
const startTime = Date.now()
|
const startTime = Date.now()
|
||||||
|
|
||||||
|
// Claude 服务权限校验,阻止未授权的 Key
|
||||||
|
if (!apiKeyService.hasPermission(req.apiKey.permissions, 'claude')) {
|
||||||
|
return res.status(403).json({
|
||||||
|
error: {
|
||||||
|
type: 'permission_error',
|
||||||
|
message: '此 API Key 无权访问 Claude 服务'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// 🔄 并发满额重试标志:最多重试一次(使用req对象存储状态)
|
// 🔄 并发满额重试标志:最多重试一次(使用req对象存储状态)
|
||||||
if (req._concurrencyRetryAttempted === undefined) {
|
if (req._concurrencyRetryAttempted === undefined) {
|
||||||
req._concurrencyRetryAttempted = false
|
req._concurrencyRetryAttempted = false
|
||||||
@@ -398,6 +415,23 @@ async function handleMessagesRequest(req, res) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🔥 预热请求拦截检查(在转发之前)
|
||||||
|
if (accountType === 'claude-official' || accountType === 'claude-console') {
|
||||||
|
const account =
|
||||||
|
accountType === 'claude-official'
|
||||||
|
? await claudeAccountService.getAccount(accountId)
|
||||||
|
: await claudeConsoleAccountService.getAccount(accountId)
|
||||||
|
|
||||||
|
if (account?.interceptWarmup === 'true' && isWarmupRequest(req.body)) {
|
||||||
|
logger.api(`🔥 Warmup request intercepted for account: ${account.name} (${accountId})`)
|
||||||
|
if (isStream) {
|
||||||
|
return sendMockWarmupStream(res, req.body.model)
|
||||||
|
} else {
|
||||||
|
return res.json(buildMockWarmupResponse(req.body.model))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 根据账号类型选择对应的转发服务并调用
|
// 根据账号类型选择对应的转发服务并调用
|
||||||
if (accountType === 'claude-official') {
|
if (accountType === 'claude-official') {
|
||||||
// 官方Claude账号使用原有的转发服务(会自己选择账号)
|
// 官方Claude账号使用原有的转发服务(会自己选择账号)
|
||||||
@@ -897,6 +931,21 @@ async function handleMessagesRequest(req, res) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🔥 预热请求拦截检查(非流式,在转发之前)
|
||||||
|
if (accountType === 'claude-official' || accountType === 'claude-console') {
|
||||||
|
const account =
|
||||||
|
accountType === 'claude-official'
|
||||||
|
? await claudeAccountService.getAccount(accountId)
|
||||||
|
: await claudeConsoleAccountService.getAccount(accountId)
|
||||||
|
|
||||||
|
if (account?.interceptWarmup === 'true' && isWarmupRequest(req.body)) {
|
||||||
|
logger.api(
|
||||||
|
`🔥 Warmup request intercepted (non-stream) for account: ${account.name} (${accountId})`
|
||||||
|
)
|
||||||
|
return res.json(buildMockWarmupResponse(req.body.model))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 根据账号类型选择对应的转发服务
|
// 根据账号类型选择对应的转发服务
|
||||||
let response
|
let response
|
||||||
logger.debug(`[DEBUG] Request query params: ${JSON.stringify(req.query)}`)
|
logger.debug(`[DEBUG] Request query params: ${JSON.stringify(req.query)}`)
|
||||||
@@ -1465,9 +1514,6 @@ router.post('/v1/messages/count_tokens', authenticateApiKey, async (req, res) =>
|
|||||||
const maxAttempts = 2
|
const maxAttempts = 2
|
||||||
let attempt = 0
|
let attempt = 0
|
||||||
|
|
||||||
// 引入 claudeConsoleAccountService 用于检查 count_tokens 可用性
|
|
||||||
const claudeConsoleAccountService = require('../services/claudeConsoleAccountService')
|
|
||||||
|
|
||||||
const processRequest = async () => {
|
const processRequest = async () => {
|
||||||
const { accountId, accountType } = await unifiedClaudeScheduler.selectAccountForApiKey(
|
const { accountId, accountType } = await unifiedClaudeScheduler.selectAccountForApiKey(
|
||||||
req.apiKey,
|
req.apiKey,
|
||||||
@@ -1663,5 +1709,10 @@ router.post('/v1/messages/count_tokens', authenticateApiKey, async (req, res) =>
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Claude Code 客户端遥测端点 - 返回成功响应避免 404 日志
|
||||||
|
router.post('/api/event_logging/batch', (req, res) => {
|
||||||
|
res.status(200).json({ success: true })
|
||||||
|
})
|
||||||
|
|
||||||
module.exports = router
|
module.exports = router
|
||||||
module.exports.handleMessagesRequest = handleMessagesRequest
|
module.exports.handleMessagesRequest = handleMessagesRequest
|
||||||
|
|||||||
@@ -4,12 +4,12 @@ const { authenticateApiKey } = require('../middleware/auth')
|
|||||||
const droidRelayService = require('../services/droidRelayService')
|
const droidRelayService = require('../services/droidRelayService')
|
||||||
const sessionHelper = require('../utils/sessionHelper')
|
const sessionHelper = require('../utils/sessionHelper')
|
||||||
const logger = require('../utils/logger')
|
const logger = require('../utils/logger')
|
||||||
|
const apiKeyService = require('../services/apiKeyService')
|
||||||
|
|
||||||
const router = express.Router()
|
const router = express.Router()
|
||||||
|
|
||||||
function hasDroidPermission(apiKeyData) {
|
function hasDroidPermission(apiKeyData) {
|
||||||
const permissions = apiKeyData?.permissions || 'all'
|
return apiKeyService.hasPermission(apiKeyData?.permissions, 'droid')
|
||||||
return permissions === 'all' || permissions === 'droid'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ const geminiAccountService = require('../services/geminiAccountService')
|
|||||||
const unifiedGeminiScheduler = require('../services/unifiedGeminiScheduler')
|
const unifiedGeminiScheduler = require('../services/unifiedGeminiScheduler')
|
||||||
const { getAvailableModels } = require('../services/geminiRelayService')
|
const { getAvailableModels } = require('../services/geminiRelayService')
|
||||||
const crypto = require('crypto')
|
const crypto = require('crypto')
|
||||||
|
const apiKeyService = require('../services/apiKeyService')
|
||||||
|
|
||||||
// 生成会话哈希
|
// 生成会话哈希
|
||||||
function generateSessionHash(req) {
|
function generateSessionHash(req) {
|
||||||
@@ -31,8 +32,7 @@ function ensureAntigravityProjectId(account) {
|
|||||||
|
|
||||||
// 检查 API Key 权限
|
// 检查 API Key 权限
|
||||||
function checkPermissions(apiKeyData, requiredPermission = 'gemini') {
|
function checkPermissions(apiKeyData, requiredPermission = 'gemini') {
|
||||||
const permissions = apiKeyData.permissions || 'all'
|
return apiKeyService.hasPermission(apiKeyData?.permissions, requiredPermission)
|
||||||
return permissions === 'all' || permissions === requiredPermission
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 转换 OpenAI 消息格式到 Gemini 格式
|
// 转换 OpenAI 消息格式到 Gemini 格式
|
||||||
@@ -532,7 +532,6 @@ router.post('/v1/chat/completions', authenticateApiKey, async (req, res) => {
|
|||||||
// 记录使用统计
|
// 记录使用统计
|
||||||
if (!usageReported && totalUsage.totalTokenCount > 0) {
|
if (!usageReported && totalUsage.totalTokenCount > 0) {
|
||||||
try {
|
try {
|
||||||
const apiKeyService = require('../services/apiKeyService')
|
|
||||||
await apiKeyService.recordUsage(
|
await apiKeyService.recordUsage(
|
||||||
apiKeyData.id,
|
apiKeyData.id,
|
||||||
totalUsage.promptTokenCount || 0,
|
totalUsage.promptTokenCount || 0,
|
||||||
@@ -634,7 +633,6 @@ router.post('/v1/chat/completions', authenticateApiKey, async (req, res) => {
|
|||||||
// 记录使用统计
|
// 记录使用统计
|
||||||
if (openaiResponse.usage) {
|
if (openaiResponse.usage) {
|
||||||
try {
|
try {
|
||||||
const apiKeyService = require('../services/apiKeyService')
|
|
||||||
await apiKeyService.recordUsage(
|
await apiKeyService.recordUsage(
|
||||||
apiKeyData.id,
|
apiKeyData.id,
|
||||||
openaiResponse.usage.prompt_tokens || 0,
|
openaiResponse.usage.prompt_tokens || 0,
|
||||||
|
|||||||
@@ -20,8 +20,7 @@ function createProxyAgent(proxy) {
|
|||||||
|
|
||||||
// 检查 API Key 是否具备 OpenAI 权限
|
// 检查 API Key 是否具备 OpenAI 权限
|
||||||
function checkOpenAIPermissions(apiKeyData) {
|
function checkOpenAIPermissions(apiKeyData) {
|
||||||
const permissions = apiKeyData?.permissions || 'all'
|
return apiKeyService.hasPermission(apiKeyData?.permissions, 'openai')
|
||||||
return permissions === 'all' || permissions === 'openai'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeHeaders(headers = {}) {
|
function normalizeHeaders(headers = {}) {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ const {
|
|||||||
handleStreamGenerateContent: geminiHandleStreamGenerateContent
|
handleStreamGenerateContent: geminiHandleStreamGenerateContent
|
||||||
} = require('../handlers/geminiHandlers')
|
} = require('../handlers/geminiHandlers')
|
||||||
const openaiRoutes = require('./openaiRoutes')
|
const openaiRoutes = require('./openaiRoutes')
|
||||||
|
const apiKeyService = require('../services/apiKeyService')
|
||||||
|
|
||||||
const router = express.Router()
|
const router = express.Router()
|
||||||
|
|
||||||
@@ -73,7 +74,7 @@ async function routeToBackend(req, res, requestedModel) {
|
|||||||
return await openaiRoutes.handleResponses(req, res)
|
return await openaiRoutes.handleResponses(req, res)
|
||||||
} else if (backend === 'gemini') {
|
} else if (backend === 'gemini') {
|
||||||
// Gemini 后端
|
// Gemini 后端
|
||||||
if (permissions !== 'all' && permissions !== 'gemini') {
|
if (!apiKeyService.hasPermission(permissions, 'gemini')) {
|
||||||
return res.status(403).json({
|
return res.status(403).json({
|
||||||
error: {
|
error: {
|
||||||
message: 'This API key does not have permission to access Gemini',
|
message: 'This API key does not have permission to access Gemini',
|
||||||
|
|||||||
@@ -164,13 +164,27 @@ router.post('/auth/change-password', async (req, res) => {
|
|||||||
|
|
||||||
// 获取当前会话
|
// 获取当前会话
|
||||||
const sessionData = await redis.getSession(token)
|
const sessionData = await redis.getSession(token)
|
||||||
if (!sessionData) {
|
|
||||||
|
// 🔒 安全修复:检查空对象
|
||||||
|
if (!sessionData || Object.keys(sessionData).length === 0) {
|
||||||
return res.status(401).json({
|
return res.status(401).json({
|
||||||
error: 'Invalid token',
|
error: 'Invalid token',
|
||||||
message: 'Session expired or invalid'
|
message: 'Session expired or invalid'
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🔒 安全修复:验证会话完整性
|
||||||
|
if (!sessionData.username || !sessionData.loginTime) {
|
||||||
|
logger.security(
|
||||||
|
`🔒 Invalid session structure in /auth/change-password from ${req.ip || 'unknown'}`
|
||||||
|
)
|
||||||
|
await redis.deleteSession(token)
|
||||||
|
return res.status(401).json({
|
||||||
|
error: 'Invalid session',
|
||||||
|
message: 'Session data corrupted or incomplete'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// 获取当前管理员信息
|
// 获取当前管理员信息
|
||||||
const adminData = await redis.getSession('admin_credentials')
|
const adminData = await redis.getSession('admin_credentials')
|
||||||
if (!adminData) {
|
if (!adminData) {
|
||||||
@@ -269,13 +283,25 @@ router.get('/auth/user', async (req, res) => {
|
|||||||
|
|
||||||
// 获取当前会话
|
// 获取当前会话
|
||||||
const sessionData = await redis.getSession(token)
|
const sessionData = await redis.getSession(token)
|
||||||
if (!sessionData) {
|
|
||||||
|
// 🔒 安全修复:检查空对象
|
||||||
|
if (!sessionData || Object.keys(sessionData).length === 0) {
|
||||||
return res.status(401).json({
|
return res.status(401).json({
|
||||||
error: 'Invalid token',
|
error: 'Invalid token',
|
||||||
message: 'Session expired or invalid'
|
message: 'Session expired or invalid'
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🔒 安全修复:验证会话完整性
|
||||||
|
if (!sessionData.username || !sessionData.loginTime) {
|
||||||
|
logger.security(`🔒 Invalid session structure in /auth/user from ${req.ip || 'unknown'}`)
|
||||||
|
await redis.deleteSession(token)
|
||||||
|
return res.status(401).json({
|
||||||
|
error: 'Invalid session',
|
||||||
|
message: 'Session data corrupted or incomplete'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// 获取管理员信息
|
// 获取管理员信息
|
||||||
const adminData = await redis.getSession('admin_credentials')
|
const adminData = await redis.getSession('admin_credentials')
|
||||||
if (!adminData) {
|
if (!adminData) {
|
||||||
@@ -316,13 +342,24 @@ router.post('/auth/refresh', async (req, res) => {
|
|||||||
|
|
||||||
const sessionData = await redis.getSession(token)
|
const sessionData = await redis.getSession(token)
|
||||||
|
|
||||||
if (!sessionData) {
|
// 🔒 安全修复:检查空对象(hgetall 对不存在的 key 返回 {})
|
||||||
|
if (!sessionData || Object.keys(sessionData).length === 0) {
|
||||||
return res.status(401).json({
|
return res.status(401).json({
|
||||||
error: 'Invalid token',
|
error: 'Invalid token',
|
||||||
message: 'Session expired or invalid'
|
message: 'Session expired or invalid'
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🔒 安全修复:验证会话完整性(必须有 username 和 loginTime)
|
||||||
|
if (!sessionData.username || !sessionData.loginTime) {
|
||||||
|
logger.security(`🔒 Invalid session structure detected from ${req.ip || 'unknown'}`)
|
||||||
|
await redis.deleteSession(token) // 清理无效/伪造的会话
|
||||||
|
return res.status(401).json({
|
||||||
|
error: 'Invalid session',
|
||||||
|
message: 'Session data corrupted or incomplete'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// 更新最后活动时间
|
// 更新最后活动时间
|
||||||
sessionData.lastActivity = new Date().toISOString()
|
sessionData.lastActivity = new Date().toISOString()
|
||||||
await redis.setSession(token, sessionData, config.security.adminSessionTimeout)
|
await redis.setSession(token, sessionData, config.security.adminSessionTimeout)
|
||||||
|
|||||||
748
src/services/accountBalanceService.js
Normal file
748
src/services/accountBalanceService.js
Normal file
@@ -0,0 +1,748 @@
|
|||||||
|
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 = {}) {
|
||||||
|
this.redis = options.redis || redis
|
||||||
|
this.logger = options.logger || logger
|
||||||
|
|
||||||
|
this.providers = new Map()
|
||||||
|
|
||||||
|
this.CACHE_TTL_SECONDS = 3600
|
||||||
|
this.LOCAL_TTL_SECONDS = 300
|
||||||
|
|
||||||
|
this.LOW_BALANCE_THRESHOLD = 10
|
||||||
|
this.HIGH_USAGE_THRESHOLD_PERCENT = 90
|
||||||
|
this.DEFAULT_CONCURRENCY = 10
|
||||||
|
}
|
||||||
|
|
||||||
|
getSupportedPlatforms() {
|
||||||
|
return [
|
||||||
|
'claude',
|
||||||
|
'claude-console',
|
||||||
|
'gemini',
|
||||||
|
'gemini-api',
|
||||||
|
'openai',
|
||||||
|
'openai-responses',
|
||||||
|
'azure_openai',
|
||||||
|
'bedrock',
|
||||||
|
'droid',
|
||||||
|
'ccr'
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
normalizePlatform(platform) {
|
||||||
|
if (!platform) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = String(platform).trim().toLowerCase()
|
||||||
|
|
||||||
|
// 兼容实施文档与历史命名
|
||||||
|
if (value === 'claude-official') {
|
||||||
|
return 'claude'
|
||||||
|
}
|
||||||
|
if (value === 'azure-openai') {
|
||||||
|
return 'azure_openai'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保持前端平台键一致
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
registerProvider(platform, provider) {
|
||||||
|
const normalized = this.normalizePlatform(platform)
|
||||||
|
if (!normalized) {
|
||||||
|
throw new Error('registerProvider: 缺少 platform')
|
||||||
|
}
|
||||||
|
if (!provider || typeof provider.queryBalance !== 'function') {
|
||||||
|
throw new Error(`registerProvider: Provider 无效 (${normalized})`)
|
||||||
|
}
|
||||||
|
this.providers.set(normalized, provider)
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAccountBalance(accountId, platform, options = {}) {
|
||||||
|
const normalizedPlatform = this.normalizePlatform(platform)
|
||||||
|
const account = await this.getAccount(accountId, normalizedPlatform)
|
||||||
|
if (!account) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return await this._getAccountBalanceForAccount(account, normalizedPlatform, options)
|
||||||
|
}
|
||||||
|
|
||||||
|
async refreshAccountBalance(accountId, platform) {
|
||||||
|
const normalizedPlatform = this.normalizePlatform(platform)
|
||||||
|
const account = await this.getAccount(accountId, normalizedPlatform)
|
||||||
|
if (!account) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return await this._getAccountBalanceForAccount(account, normalizedPlatform, {
|
||||||
|
queryApi: true,
|
||||||
|
useCache: false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAllAccountsBalance(platform, options = {}) {
|
||||||
|
const normalizedPlatform = this.normalizePlatform(platform)
|
||||||
|
const accounts = await this.getAllAccountsByPlatform(normalizedPlatform)
|
||||||
|
const queryApi = this._parseBoolean(options.queryApi) || false
|
||||||
|
const useCache = options.useCache !== false
|
||||||
|
|
||||||
|
const results = await this._mapWithConcurrency(
|
||||||
|
accounts,
|
||||||
|
this.DEFAULT_CONCURRENCY,
|
||||||
|
async (acc) => {
|
||||||
|
try {
|
||||||
|
const balance = await this._getAccountBalanceForAccount(acc, normalizedPlatform, {
|
||||||
|
queryApi,
|
||||||
|
useCache
|
||||||
|
})
|
||||||
|
return { ...balance, name: acc.name || '' }
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`批量获取余额失败: ${normalizedPlatform}:${acc?.id}`, error)
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
accountId: acc?.id,
|
||||||
|
platform: normalizedPlatform,
|
||||||
|
balance: null,
|
||||||
|
quota: null,
|
||||||
|
statistics: {},
|
||||||
|
source: 'local',
|
||||||
|
lastRefreshAt: new Date().toISOString(),
|
||||||
|
cacheExpiresAt: null,
|
||||||
|
status: 'error',
|
||||||
|
error: error.message || '批量查询失败'
|
||||||
|
},
|
||||||
|
name: acc?.name || ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
async getBalanceSummary() {
|
||||||
|
const platforms = this.getSupportedPlatforms()
|
||||||
|
|
||||||
|
const summary = {
|
||||||
|
totalBalance: 0,
|
||||||
|
totalCost: 0,
|
||||||
|
lowBalanceCount: 0,
|
||||||
|
platforms: {}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const platform of platforms) {
|
||||||
|
const accounts = await this.getAllAccountsByPlatform(platform)
|
||||||
|
const platformData = {
|
||||||
|
count: accounts.length,
|
||||||
|
totalBalance: 0,
|
||||||
|
totalCost: 0,
|
||||||
|
lowBalanceCount: 0,
|
||||||
|
accounts: []
|
||||||
|
}
|
||||||
|
|
||||||
|
const balances = await this._mapWithConcurrency(
|
||||||
|
accounts,
|
||||||
|
this.DEFAULT_CONCURRENCY,
|
||||||
|
async (acc) => {
|
||||||
|
const balance = await this._getAccountBalanceForAccount(acc, platform, {
|
||||||
|
queryApi: false,
|
||||||
|
useCache: true
|
||||||
|
})
|
||||||
|
return { ...balance, name: acc.name || '' }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
for (const item of balances) {
|
||||||
|
platformData.accounts.push(item)
|
||||||
|
|
||||||
|
const amount = item?.data?.balance?.amount
|
||||||
|
const percentage = item?.data?.quota?.percentage
|
||||||
|
const totalCost = Number(item?.data?.statistics?.totalCost || 0)
|
||||||
|
|
||||||
|
const hasAmount = typeof amount === 'number' && Number.isFinite(amount)
|
||||||
|
const isLowBalance = hasAmount && amount < this.LOW_BALANCE_THRESHOLD
|
||||||
|
const isHighUsage =
|
||||||
|
typeof percentage === 'number' &&
|
||||||
|
Number.isFinite(percentage) &&
|
||||||
|
percentage > this.HIGH_USAGE_THRESHOLD_PERCENT
|
||||||
|
|
||||||
|
if (hasAmount) {
|
||||||
|
platformData.totalBalance += amount
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLowBalance || isHighUsage) {
|
||||||
|
platformData.lowBalanceCount += 1
|
||||||
|
summary.lowBalanceCount += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
platformData.totalCost += totalCost
|
||||||
|
}
|
||||||
|
|
||||||
|
summary.platforms[platform] = platformData
|
||||||
|
summary.totalBalance += platformData.totalBalance
|
||||||
|
summary.totalCost += platformData.totalCost
|
||||||
|
}
|
||||||
|
|
||||||
|
return summary
|
||||||
|
}
|
||||||
|
|
||||||
|
async clearCache(accountId, platform) {
|
||||||
|
const normalizedPlatform = this.normalizePlatform(platform)
|
||||||
|
if (!normalizedPlatform) {
|
||||||
|
throw new Error('缺少 platform 参数')
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.redis.deleteAccountBalance(normalizedPlatform, accountId)
|
||||||
|
this.logger.info(`余额缓存已清除: ${normalizedPlatform}:${accountId}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAccount(accountId, platform) {
|
||||||
|
if (!accountId || !platform) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const serviceMap = {
|
||||||
|
claude: require('./claudeAccountService'),
|
||||||
|
'claude-console': require('./claudeConsoleAccountService'),
|
||||||
|
gemini: require('./geminiAccountService'),
|
||||||
|
'gemini-api': require('./geminiApiAccountService'),
|
||||||
|
openai: require('./openaiAccountService'),
|
||||||
|
'openai-responses': require('./openaiResponsesAccountService'),
|
||||||
|
azure_openai: require('./azureOpenaiAccountService'),
|
||||||
|
bedrock: require('./bedrockAccountService'),
|
||||||
|
droid: require('./droidAccountService'),
|
||||||
|
ccr: require('./ccrAccountService')
|
||||||
|
}
|
||||||
|
|
||||||
|
const service = serviceMap[platform]
|
||||||
|
if (!service || typeof service.getAccount !== 'function') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return await service.getAccount(accountId)
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAllAccountsByPlatform(platform) {
|
||||||
|
if (!platform) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const serviceMap = {
|
||||||
|
claude: require('./claudeAccountService'),
|
||||||
|
'claude-console': require('./claudeConsoleAccountService'),
|
||||||
|
gemini: require('./geminiAccountService'),
|
||||||
|
'gemini-api': require('./geminiApiAccountService'),
|
||||||
|
openai: require('./openaiAccountService'),
|
||||||
|
'openai-responses': require('./openaiResponsesAccountService'),
|
||||||
|
azure_openai: require('./azureOpenaiAccountService'),
|
||||||
|
bedrock: require('./bedrockAccountService'),
|
||||||
|
droid: require('./droidAccountService'),
|
||||||
|
ccr: require('./ccrAccountService')
|
||||||
|
}
|
||||||
|
|
||||||
|
const service = serviceMap[platform]
|
||||||
|
if (!service) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bedrock 特殊:返回 { success, data }
|
||||||
|
if (platform === 'bedrock' && typeof service.getAllAccounts === 'function') {
|
||||||
|
const result = await service.getAllAccounts()
|
||||||
|
return result?.success ? result.data || [] : []
|
||||||
|
}
|
||||||
|
|
||||||
|
if (platform === 'openai-responses') {
|
||||||
|
return await service.getAllAccounts(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof service.getAllAccounts !== 'function') {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
return await service.getAllAccounts()
|
||||||
|
}
|
||||||
|
|
||||||
|
async _getAccountBalanceForAccount(account, platform, options = {}) {
|
||||||
|
const queryApi = this._parseBoolean(options.queryApi) || false
|
||||||
|
const useCache = options.useCache !== false
|
||||||
|
|
||||||
|
const accountId = account?.id
|
||||||
|
if (!accountId) {
|
||||||
|
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 || {}
|
||||||
|
|
||||||
|
const quotaFromLocal = this._buildQuotaFromLocal(account, localStatistics)
|
||||||
|
|
||||||
|
// 非强制查询:优先读缓存
|
||||||
|
if (!queryApi) {
|
||||||
|
if (useCache) {
|
||||||
|
const cached = await this.redis.getAccountBalance(platform, accountId)
|
||||||
|
if (cached && cached.status === 'success') {
|
||||||
|
return this._buildResponse(
|
||||||
|
{
|
||||||
|
status: cached.status,
|
||||||
|
errorMessage: cached.errorMessage,
|
||||||
|
balance: quotaFromLocal.balance ?? cached.balance,
|
||||||
|
currency: quotaFromLocal.currency || cached.currency || 'USD',
|
||||||
|
quota: quotaFromLocal.quota || cached.quota || null,
|
||||||
|
statistics: localStatistics,
|
||||||
|
lastRefreshAt: cached.lastRefreshAt
|
||||||
|
},
|
||||||
|
accountId,
|
||||||
|
platform,
|
||||||
|
'cache',
|
||||||
|
cached.ttlSeconds,
|
||||||
|
scriptMeta
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return this._buildResponse(
|
||||||
|
{
|
||||||
|
status: 'success',
|
||||||
|
errorMessage: null,
|
||||||
|
balance: quotaFromLocal.balance,
|
||||||
|
currency: quotaFromLocal.currency || 'USD',
|
||||||
|
quota: quotaFromLocal.quota,
|
||||||
|
statistics: localStatistics,
|
||||||
|
lastRefreshAt: localBalance.lastCalculated
|
||||||
|
},
|
||||||
|
accountId,
|
||||||
|
platform,
|
||||||
|
'local',
|
||||||
|
null,
|
||||||
|
scriptMeta
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 强制查询:优先脚本(如启用且已配置),否则调用 Provider;失败自动降级到本地统计
|
||||||
|
let providerResult
|
||||||
|
|
||||||
|
if (scriptEnabled && scriptConfigured) {
|
||||||
|
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',
|
||||||
|
null,
|
||||||
|
scriptMeta
|
||||||
|
)
|
||||||
|
}
|
||||||
|
providerResult = await this._getBalanceFromProvider(provider, account)
|
||||||
|
}
|
||||||
|
|
||||||
|
const isRemoteSuccess =
|
||||||
|
providerResult.status === 'success' && ['api', 'script'].includes(providerResult.queryMethod)
|
||||||
|
|
||||||
|
// 仅缓存“真实远程查询成功”的结果,避免把字段/本地降级结果当作 API 结果缓存 1h
|
||||||
|
if (isRemoteSuccess) {
|
||||||
|
await this.redis.setAccountBalance(
|
||||||
|
platform,
|
||||||
|
accountId,
|
||||||
|
providerResult,
|
||||||
|
this.CACHE_TTL_SECONDS
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const source = isRemoteSuccess ? 'api' : 'local'
|
||||||
|
|
||||||
|
return this._buildResponse(
|
||||||
|
{
|
||||||
|
status: providerResult.status,
|
||||||
|
errorMessage: providerResult.errorMessage,
|
||||||
|
balance: quotaFromLocal.balance ?? providerResult.balance,
|
||||||
|
currency: quotaFromLocal.currency || providerResult.currency || 'USD',
|
||||||
|
quota: quotaFromLocal.quota || providerResult.quota || null,
|
||||||
|
statistics: localStatistics,
|
||||||
|
lastRefreshAt: providerResult.lastRefreshAt
|
||||||
|
},
|
||||||
|
accountId,
|
||||||
|
platform,
|
||||||
|
source,
|
||||||
|
null,
|
||||||
|
scriptMeta
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
return {
|
||||||
|
status: 'success',
|
||||||
|
balance: typeof result?.balance === 'number' ? result.balance : null,
|
||||||
|
currency: result?.currency || 'USD',
|
||||||
|
quota: result?.quota || null,
|
||||||
|
queryMethod: result?.queryMethod || 'api',
|
||||||
|
rawData: result?.rawData || null,
|
||||||
|
lastRefreshAt: new Date().toISOString(),
|
||||||
|
errorMessage: ''
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
status: 'error',
|
||||||
|
balance: null,
|
||||||
|
currency: 'USD',
|
||||||
|
quota: null,
|
||||||
|
queryMethod: 'api',
|
||||||
|
rawData: null,
|
||||||
|
lastRefreshAt: new Date().toISOString(),
|
||||||
|
errorMessage: error.message || '查询失败'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async _getBalanceFromLocal(accountId, platform) {
|
||||||
|
const cached = await this.redis.getLocalBalance(platform, accountId)
|
||||||
|
if (cached && cached.statistics) {
|
||||||
|
return cached
|
||||||
|
}
|
||||||
|
|
||||||
|
const statistics = await this._computeLocalStatistics(accountId)
|
||||||
|
const localBalance = {
|
||||||
|
status: 'success',
|
||||||
|
balance: null,
|
||||||
|
currency: 'USD',
|
||||||
|
statistics,
|
||||||
|
queryMethod: 'local',
|
||||||
|
lastCalculated: new Date().toISOString()
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.redis.setLocalBalance(platform, accountId, localBalance, this.LOCAL_TTL_SECONDS)
|
||||||
|
return localBalance
|
||||||
|
}
|
||||||
|
|
||||||
|
async _computeLocalStatistics(accountId) {
|
||||||
|
const safeNumber = (value) => {
|
||||||
|
const num = Number(value)
|
||||||
|
return Number.isFinite(num) ? num : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const usageStats = await this.redis.getAccountUsageStats(accountId)
|
||||||
|
const dailyCost = safeNumber(usageStats?.daily?.cost || 0)
|
||||||
|
const monthlyCost = await this._computeMonthlyCost(accountId)
|
||||||
|
const totalCost = await this._computeTotalCost(accountId)
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalCost,
|
||||||
|
dailyCost,
|
||||||
|
monthlyCost,
|
||||||
|
totalRequests: safeNumber(usageStats?.total?.requests || 0),
|
||||||
|
dailyRequests: safeNumber(usageStats?.daily?.requests || 0),
|
||||||
|
monthlyRequests: safeNumber(usageStats?.monthly?.requests || 0)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.debug(`本地统计计算失败: ${accountId}`, error)
|
||||||
|
return {
|
||||||
|
totalCost: 0,
|
||||||
|
dailyCost: 0,
|
||||||
|
monthlyCost: 0,
|
||||||
|
totalRequests: 0,
|
||||||
|
dailyRequests: 0,
|
||||||
|
monthlyRequests: 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async _computeMonthlyCost(accountId) {
|
||||||
|
const tzDate = this.redis.getDateInTimezone(new Date())
|
||||||
|
const currentMonth = `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart(
|
||||||
|
2,
|
||||||
|
'0'
|
||||||
|
)}`
|
||||||
|
|
||||||
|
const pattern = `account_usage:model:monthly:${accountId}:*:${currentMonth}`
|
||||||
|
return await this._sumModelCostsByKeysPattern(pattern)
|
||||||
|
}
|
||||||
|
|
||||||
|
async _computeTotalCost(accountId) {
|
||||||
|
const pattern = `account_usage:model:monthly:${accountId}:*:*`
|
||||||
|
return await this._sumModelCostsByKeysPattern(pattern)
|
||||||
|
}
|
||||||
|
|
||||||
|
async _sumModelCostsByKeysPattern(pattern) {
|
||||||
|
try {
|
||||||
|
const client = this.redis.getClientSafe()
|
||||||
|
let totalCost = 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 pipeline = client.pipeline()
|
||||||
|
keys.forEach((key) => pipeline.hgetall(key))
|
||||||
|
const results = await pipeline.exec()
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
if (iterations >= maxIterations) {
|
||||||
|
this.logger.warn(`SCAN 次数超过上限,停止汇总:${pattern}`)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
} while (cursor !== '0')
|
||||||
|
|
||||||
|
return totalCost
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.debug(`汇总模型费用失败: ${pattern}`, error)
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_buildQuotaFromLocal(account, statistics) {
|
||||||
|
if (!account || !Object.prototype.hasOwnProperty.call(account, 'dailyQuota')) {
|
||||||
|
return { balance: null, currency: null, quota: null }
|
||||||
|
}
|
||||||
|
|
||||||
|
const dailyQuota = Number(account.dailyQuota || 0)
|
||||||
|
const used = Number(statistics?.dailyCost || 0)
|
||||||
|
|
||||||
|
const resetAt = this._computeNextResetAt(account.quotaResetTime || '00:00')
|
||||||
|
|
||||||
|
// 不限制
|
||||||
|
if (!Number.isFinite(dailyQuota) || dailyQuota <= 0) {
|
||||||
|
return {
|
||||||
|
balance: null,
|
||||||
|
currency: 'USD',
|
||||||
|
quota: {
|
||||||
|
daily: Infinity,
|
||||||
|
used,
|
||||||
|
remaining: Infinity,
|
||||||
|
percentage: 0,
|
||||||
|
unlimited: true,
|
||||||
|
resetAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const remaining = Math.max(0, dailyQuota - used)
|
||||||
|
const percentage = dailyQuota > 0 ? (used / dailyQuota) * 100 : 0
|
||||||
|
|
||||||
|
return {
|
||||||
|
balance: remaining,
|
||||||
|
currency: 'USD',
|
||||||
|
quota: {
|
||||||
|
daily: dailyQuota,
|
||||||
|
used,
|
||||||
|
remaining,
|
||||||
|
resetAt,
|
||||||
|
percentage: Math.round(percentage * 100) / 100
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_computeNextResetAt(resetTime) {
|
||||||
|
const now = new Date()
|
||||||
|
const tzNow = this.redis.getDateInTimezone(now)
|
||||||
|
const offsetMs = tzNow.getTime() - now.getTime()
|
||||||
|
|
||||||
|
const [h, m] = String(resetTime || '00:00')
|
||||||
|
.split(':')
|
||||||
|
.map((n) => parseInt(n, 10))
|
||||||
|
|
||||||
|
const resetHour = Number.isFinite(h) ? h : 0
|
||||||
|
const resetMinute = Number.isFinite(m) ? m : 0
|
||||||
|
|
||||||
|
const year = tzNow.getUTCFullYear()
|
||||||
|
const month = tzNow.getUTCMonth()
|
||||||
|
const day = tzNow.getUTCDate()
|
||||||
|
|
||||||
|
let resetAtMs = Date.UTC(year, month, day, resetHour, resetMinute, 0, 0) - offsetMs
|
||||||
|
if (resetAtMs <= now.getTime()) {
|
||||||
|
resetAtMs += 24 * 60 * 60 * 1000
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Date(resetAtMs).toISOString()
|
||||||
|
}
|
||||||
|
|
||||||
|
_buildResponse(balanceData, accountId, platform, source, ttlSeconds = null, extraData = {}) {
|
||||||
|
const now = new Date()
|
||||||
|
|
||||||
|
const amount = typeof balanceData.balance === 'number' ? balanceData.balance : null
|
||||||
|
const currency = balanceData.currency || 'USD'
|
||||||
|
|
||||||
|
let cacheExpiresAt = null
|
||||||
|
if (source === 'cache') {
|
||||||
|
const ttl =
|
||||||
|
typeof ttlSeconds === 'number' && ttlSeconds > 0 ? ttlSeconds : this.CACHE_TTL_SECONDS
|
||||||
|
cacheExpiresAt = new Date(Date.now() + ttl * 1000).toISOString()
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
accountId,
|
||||||
|
platform,
|
||||||
|
balance:
|
||||||
|
typeof amount === 'number'
|
||||||
|
? {
|
||||||
|
amount,
|
||||||
|
currency,
|
||||||
|
formattedAmount: this._formatCurrency(amount, currency)
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
quota: balanceData.quota || null,
|
||||||
|
statistics: balanceData.statistics || {},
|
||||||
|
source,
|
||||||
|
lastRefreshAt: balanceData.lastRefreshAt || now.toISOString(),
|
||||||
|
cacheExpiresAt,
|
||||||
|
status: balanceData.status || 'success',
|
||||||
|
error: balanceData.errorMessage || null,
|
||||||
|
...(extraData && typeof extraData === 'object' ? extraData : {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_formatCurrency(amount, currency = 'USD') {
|
||||||
|
try {
|
||||||
|
if (typeof amount !== 'number' || !Number.isFinite(amount)) {
|
||||||
|
return 'N/A'
|
||||||
|
}
|
||||||
|
return new Intl.NumberFormat('en-US', { style: 'currency', currency }).format(amount)
|
||||||
|
} catch (error) {
|
||||||
|
return `$${amount.toFixed(2)}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_parseBoolean(value) {
|
||||||
|
if (typeof value === 'boolean') {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
if (typeof value !== 'string') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const normalized = value.trim().toLowerCase()
|
||||||
|
if (normalized === 'true' || normalized === '1' || normalized === 'yes') {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (normalized === 'false' || normalized === '0' || normalized === 'no') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
async _mapWithConcurrency(items, limit, mapper) {
|
||||||
|
const concurrency = Math.max(1, Number(limit) || 1)
|
||||||
|
const list = Array.isArray(items) ? items : []
|
||||||
|
|
||||||
|
const results = new Array(list.length)
|
||||||
|
let nextIndex = 0
|
||||||
|
|
||||||
|
const workers = new Array(Math.min(concurrency, list.length)).fill(null).map(async () => {
|
||||||
|
while (nextIndex < list.length) {
|
||||||
|
const currentIndex = nextIndex
|
||||||
|
nextIndex += 1
|
||||||
|
results[currentIndex] = await mapper(list[currentIndex], currentIndex)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
await Promise.all(workers)
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const accountBalanceService = new AccountBalanceService()
|
||||||
|
module.exports = accountBalanceService
|
||||||
|
module.exports.AccountBalanceService = AccountBalanceService
|
||||||
420
src/services/accountTestSchedulerService.js
Normal file
420
src/services/accountTestSchedulerService.js
Normal file
@@ -0,0 +1,420 @@
|
|||||||
|
/**
|
||||||
|
* 账户定时测试调度服务
|
||||||
|
* 使用 node-cron 支持 crontab 表达式,为每个账户创建独立的定时任务
|
||||||
|
*/
|
||||||
|
|
||||||
|
const cron = require('node-cron')
|
||||||
|
const redis = require('../models/redis')
|
||||||
|
const logger = require('../utils/logger')
|
||||||
|
|
||||||
|
class AccountTestSchedulerService {
|
||||||
|
constructor() {
|
||||||
|
// 存储每个账户的 cron 任务: Map<string, { task: ScheduledTask, cronExpression: string }>
|
||||||
|
this.scheduledTasks = new Map()
|
||||||
|
// 定期刷新配置的间隔 (毫秒)
|
||||||
|
this.refreshIntervalMs = 60 * 1000
|
||||||
|
this.refreshInterval = null
|
||||||
|
// 当前正在测试的账户
|
||||||
|
this.testingAccounts = new Set()
|
||||||
|
// 是否已启动
|
||||||
|
this.isStarted = false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证 cron 表达式是否有效
|
||||||
|
* @param {string} cronExpression - cron 表达式
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
validateCronExpression(cronExpression) {
|
||||||
|
// 长度检查(防止 DoS)
|
||||||
|
if (!cronExpression || cronExpression.length > 100) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return cron.validate(cronExpression)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 启动调度器
|
||||||
|
*/
|
||||||
|
async start() {
|
||||||
|
if (this.isStarted) {
|
||||||
|
logger.warn('⚠️ Account test scheduler is already running')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isStarted = true
|
||||||
|
logger.info('🚀 Starting account test scheduler service (node-cron mode)')
|
||||||
|
|
||||||
|
// 初始化所有已配置账户的定时任务
|
||||||
|
await this._refreshAllTasks()
|
||||||
|
|
||||||
|
// 定期刷新配置,以便动态添加/修改的配置能生效
|
||||||
|
this.refreshInterval = setInterval(() => {
|
||||||
|
this._refreshAllTasks()
|
||||||
|
}, this.refreshIntervalMs)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`📅 Account test scheduler started (refreshing configs every ${this.refreshIntervalMs / 1000}s)`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 停止调度器
|
||||||
|
*/
|
||||||
|
stop() {
|
||||||
|
if (this.refreshInterval) {
|
||||||
|
clearInterval(this.refreshInterval)
|
||||||
|
this.refreshInterval = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// 停止所有 cron 任务
|
||||||
|
for (const [accountKey, taskInfo] of this.scheduledTasks.entries()) {
|
||||||
|
taskInfo.task.stop()
|
||||||
|
logger.debug(`🛑 Stopped cron task for ${accountKey}`)
|
||||||
|
}
|
||||||
|
this.scheduledTasks.clear()
|
||||||
|
|
||||||
|
this.isStarted = false
|
||||||
|
logger.info('🛑 Account test scheduler stopped')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 刷新所有账户的定时任务
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
async _refreshAllTasks() {
|
||||||
|
try {
|
||||||
|
const platforms = ['claude', 'gemini', 'openai']
|
||||||
|
const activeAccountKeys = new Set()
|
||||||
|
|
||||||
|
// 并行加载所有平台的配置
|
||||||
|
const allEnabledAccounts = await Promise.all(
|
||||||
|
platforms.map((platform) =>
|
||||||
|
redis
|
||||||
|
.getEnabledTestAccounts(platform)
|
||||||
|
.then((accounts) => accounts.map((acc) => ({ ...acc, platform })))
|
||||||
|
.catch((error) => {
|
||||||
|
logger.warn(`⚠️ Failed to load test accounts for platform ${platform}:`, error)
|
||||||
|
return []
|
||||||
|
})
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
// 展平平台数据
|
||||||
|
const flatAccounts = allEnabledAccounts.flat()
|
||||||
|
|
||||||
|
for (const { accountId, cronExpression, model, platform } of flatAccounts) {
|
||||||
|
if (!cronExpression) {
|
||||||
|
logger.warn(
|
||||||
|
`⚠️ Account ${accountId} (${platform}) has no valid cron expression, skipping`
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const accountKey = `${platform}:${accountId}`
|
||||||
|
activeAccountKeys.add(accountKey)
|
||||||
|
|
||||||
|
// 检查是否需要更新任务
|
||||||
|
const existingTask = this.scheduledTasks.get(accountKey)
|
||||||
|
if (existingTask) {
|
||||||
|
// 如果 cron 表达式和模型都没变,不需要更新
|
||||||
|
if (existingTask.cronExpression === cronExpression && existingTask.model === model) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// 配置变了,停止旧任务
|
||||||
|
existingTask.task.stop()
|
||||||
|
logger.info(`🔄 Updating cron task for ${accountKey}: ${cronExpression}, model: ${model}`)
|
||||||
|
} else {
|
||||||
|
logger.info(`➕ Creating cron task for ${accountKey}: ${cronExpression}, model: ${model}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建新的 cron 任务
|
||||||
|
this._createCronTask(accountId, platform, cronExpression, model)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清理已删除或禁用的账户任务
|
||||||
|
for (const [accountKey, taskInfo] of this.scheduledTasks.entries()) {
|
||||||
|
if (!activeAccountKeys.has(accountKey)) {
|
||||||
|
taskInfo.task.stop()
|
||||||
|
this.scheduledTasks.delete(accountKey)
|
||||||
|
logger.info(`➖ Removed cron task for ${accountKey} (disabled or deleted)`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Error refreshing account test tasks:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 为单个账户创建 cron 任务
|
||||||
|
* @param {string} accountId
|
||||||
|
* @param {string} platform
|
||||||
|
* @param {string} cronExpression
|
||||||
|
* @param {string} model - 测试使用的模型
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_createCronTask(accountId, platform, cronExpression, model) {
|
||||||
|
const accountKey = `${platform}:${accountId}`
|
||||||
|
|
||||||
|
// 验证 cron 表达式
|
||||||
|
if (!this.validateCronExpression(cronExpression)) {
|
||||||
|
logger.error(`❌ Invalid cron expression for ${accountKey}: ${cronExpression}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const task = cron.schedule(
|
||||||
|
cronExpression,
|
||||||
|
async () => {
|
||||||
|
await this._runAccountTest(accountId, platform, model)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scheduled: true,
|
||||||
|
timezone: process.env.TZ || 'Asia/Shanghai'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
this.scheduledTasks.set(accountKey, {
|
||||||
|
task,
|
||||||
|
cronExpression,
|
||||||
|
model,
|
||||||
|
accountId,
|
||||||
|
platform
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行单个账户测试
|
||||||
|
* @param {string} accountId - 账户ID
|
||||||
|
* @param {string} platform - 平台类型
|
||||||
|
* @param {string} model - 测试使用的模型
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
async _runAccountTest(accountId, platform, model) {
|
||||||
|
const accountKey = `${platform}:${accountId}`
|
||||||
|
|
||||||
|
// 避免重复测试
|
||||||
|
if (this.testingAccounts.has(accountKey)) {
|
||||||
|
logger.debug(`⏳ Account ${accountKey} is already being tested, skipping`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.testingAccounts.add(accountKey)
|
||||||
|
|
||||||
|
try {
|
||||||
|
logger.info(
|
||||||
|
`🧪 Running scheduled test for ${platform} account: ${accountId} (model: ${model})`
|
||||||
|
)
|
||||||
|
|
||||||
|
let testResult
|
||||||
|
|
||||||
|
// 根据平台调用对应的测试方法
|
||||||
|
switch (platform) {
|
||||||
|
case 'claude':
|
||||||
|
testResult = await this._testClaudeAccount(accountId, model)
|
||||||
|
break
|
||||||
|
case 'gemini':
|
||||||
|
testResult = await this._testGeminiAccount(accountId, model)
|
||||||
|
break
|
||||||
|
case 'openai':
|
||||||
|
testResult = await this._testOpenAIAccount(accountId, model)
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
testResult = {
|
||||||
|
success: false,
|
||||||
|
error: `Unsupported platform: ${platform}`,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存测试结果
|
||||||
|
await redis.saveAccountTestResult(accountId, platform, testResult)
|
||||||
|
|
||||||
|
// 更新最后测试时间
|
||||||
|
await redis.setAccountLastTestTime(accountId, platform)
|
||||||
|
|
||||||
|
// 记录日志
|
||||||
|
if (testResult.success) {
|
||||||
|
logger.info(
|
||||||
|
`✅ Scheduled test passed for ${platform} account ${accountId} (${testResult.latencyMs}ms)`
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
logger.warn(
|
||||||
|
`❌ Scheduled test failed for ${platform} account ${accountId}: ${testResult.error}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return testResult
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`❌ Error testing ${platform} account ${accountId}:`, error)
|
||||||
|
|
||||||
|
const errorResult = {
|
||||||
|
success: false,
|
||||||
|
error: error.message,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
}
|
||||||
|
|
||||||
|
await redis.saveAccountTestResult(accountId, platform, errorResult)
|
||||||
|
await redis.setAccountLastTestTime(accountId, platform)
|
||||||
|
|
||||||
|
return errorResult
|
||||||
|
} finally {
|
||||||
|
this.testingAccounts.delete(accountKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 测试 Claude 账户
|
||||||
|
* @param {string} accountId
|
||||||
|
* @param {string} model - 测试使用的模型
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
async _testClaudeAccount(accountId, model) {
|
||||||
|
const claudeRelayService = require('./claudeRelayService')
|
||||||
|
return await claudeRelayService.testAccountConnectionSync(accountId, model)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 测试 Gemini 账户
|
||||||
|
* @param {string} _accountId
|
||||||
|
* @param {string} _model
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
async _testGeminiAccount(_accountId, _model) {
|
||||||
|
// Gemini 测试暂时返回未实现
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'Gemini scheduled test not implemented yet',
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 测试 OpenAI 账户
|
||||||
|
* @param {string} _accountId
|
||||||
|
* @param {string} _model
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
async _testOpenAIAccount(_accountId, _model) {
|
||||||
|
// OpenAI 测试暂时返回未实现
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'OpenAI scheduled test not implemented yet',
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 手动触发账户测试
|
||||||
|
* @param {string} accountId - 账户ID
|
||||||
|
* @param {string} platform - 平台类型
|
||||||
|
* @param {string} model - 测试使用的模型
|
||||||
|
* @returns {Promise<Object>} 测试结果
|
||||||
|
*/
|
||||||
|
async triggerTest(accountId, platform, model = 'claude-sonnet-4-5-20250929') {
|
||||||
|
logger.info(`🎯 Manual test triggered for ${platform} account: ${accountId} (model: ${model})`)
|
||||||
|
return await this._runAccountTest(accountId, platform, model)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取账户测试历史
|
||||||
|
* @param {string} accountId - 账户ID
|
||||||
|
* @param {string} platform - 平台类型
|
||||||
|
* @returns {Promise<Array>} 测试历史
|
||||||
|
*/
|
||||||
|
async getTestHistory(accountId, platform) {
|
||||||
|
return await redis.getAccountTestHistory(accountId, platform)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取账户测试配置
|
||||||
|
* @param {string} accountId - 账户ID
|
||||||
|
* @param {string} platform - 平台类型
|
||||||
|
* @returns {Promise<Object|null>}
|
||||||
|
*/
|
||||||
|
async getTestConfig(accountId, platform) {
|
||||||
|
return await redis.getAccountTestConfig(accountId, platform)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置账户测试配置
|
||||||
|
* @param {string} accountId - 账户ID
|
||||||
|
* @param {string} platform - 平台类型
|
||||||
|
* @param {Object} testConfig - 测试配置 { enabled: boolean, cronExpression: string, model: string }
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
async setTestConfig(accountId, platform, testConfig) {
|
||||||
|
// 验证 cron 表达式
|
||||||
|
if (testConfig.cronExpression && !this.validateCronExpression(testConfig.cronExpression)) {
|
||||||
|
throw new Error(`Invalid cron expression: ${testConfig.cronExpression}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
await redis.saveAccountTestConfig(accountId, platform, testConfig)
|
||||||
|
logger.info(
|
||||||
|
`📝 Test config updated for ${platform} account ${accountId}: enabled=${testConfig.enabled}, cronExpression=${testConfig.cronExpression}, model=${testConfig.model}`
|
||||||
|
)
|
||||||
|
|
||||||
|
// 立即刷新任务,使配置立即生效
|
||||||
|
if (this.isStarted) {
|
||||||
|
await this._refreshAllTasks()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新单个账户的定时任务(配置变更时调用)
|
||||||
|
* @param {string} accountId
|
||||||
|
* @param {string} platform
|
||||||
|
*/
|
||||||
|
async refreshAccountTask(accountId, platform) {
|
||||||
|
if (!this.isStarted) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const accountKey = `${platform}:${accountId}`
|
||||||
|
const testConfig = await redis.getAccountTestConfig(accountId, platform)
|
||||||
|
|
||||||
|
// 停止现有任务
|
||||||
|
const existingTask = this.scheduledTasks.get(accountKey)
|
||||||
|
if (existingTask) {
|
||||||
|
existingTask.task.stop()
|
||||||
|
this.scheduledTasks.delete(accountKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果启用且有有效的 cron 表达式,创建新任务
|
||||||
|
if (testConfig?.enabled && testConfig?.cronExpression) {
|
||||||
|
this._createCronTask(accountId, platform, testConfig.cronExpression, testConfig.model)
|
||||||
|
logger.info(
|
||||||
|
`🔄 Refreshed cron task for ${accountKey}: ${testConfig.cronExpression}, model: ${testConfig.model}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取调度器状态
|
||||||
|
* @returns {Object}
|
||||||
|
*/
|
||||||
|
getStatus() {
|
||||||
|
const tasks = []
|
||||||
|
for (const [accountKey, taskInfo] of this.scheduledTasks.entries()) {
|
||||||
|
tasks.push({
|
||||||
|
accountKey,
|
||||||
|
accountId: taskInfo.accountId,
|
||||||
|
platform: taskInfo.platform,
|
||||||
|
cronExpression: taskInfo.cronExpression,
|
||||||
|
model: taskInfo.model
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
running: this.isStarted,
|
||||||
|
refreshIntervalMs: this.refreshIntervalMs,
|
||||||
|
scheduledTasksCount: this.scheduledTasks.size,
|
||||||
|
scheduledTasks: tasks,
|
||||||
|
currentlyTesting: Array.from(this.testingAccounts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 单例模式
|
||||||
|
const accountTestSchedulerService = new AccountTestSchedulerService()
|
||||||
|
|
||||||
|
module.exports = accountTestSchedulerService
|
||||||
@@ -37,6 +37,51 @@ const ACCOUNT_CATEGORY_MAP = {
|
|||||||
droid: 'droid'
|
droid: 'droid'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 规范化权限数据,兼容旧格式(字符串)和新格式(数组)
|
||||||
|
* @param {string|array} permissions - 权限数据
|
||||||
|
* @returns {array} - 权限数组,空数组表示全部服务
|
||||||
|
*/
|
||||||
|
function normalizePermissions(permissions) {
|
||||||
|
if (!permissions) {
|
||||||
|
return [] // 空 = 全部服务
|
||||||
|
}
|
||||||
|
if (Array.isArray(permissions)) {
|
||||||
|
return permissions
|
||||||
|
}
|
||||||
|
// 尝试解析 JSON 字符串(新格式存储)
|
||||||
|
if (typeof permissions === 'string') {
|
||||||
|
if (permissions.startsWith('[')) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(permissions)
|
||||||
|
if (Array.isArray(parsed)) {
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// 解析失败,继续处理为普通字符串
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 旧格式 'all' 转为空数组
|
||||||
|
if (permissions === 'all') {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
// 旧单个字符串转为数组
|
||||||
|
return [permissions]
|
||||||
|
}
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查是否有访问特定服务的权限
|
||||||
|
* @param {string|array} permissions - 权限数据
|
||||||
|
* @param {string} service - 服务名称(claude/gemini/openai/droid)
|
||||||
|
* @returns {boolean} - 是否有权限
|
||||||
|
*/
|
||||||
|
function hasPermission(permissions, service) {
|
||||||
|
const perms = normalizePermissions(permissions)
|
||||||
|
return perms.length === 0 || perms.includes(service) // 空数组 = 全部服务
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeAccountTypeKey(type) {
|
function normalizeAccountTypeKey(type) {
|
||||||
if (!type) {
|
if (!type) {
|
||||||
return null
|
return null
|
||||||
@@ -89,7 +134,7 @@ class ApiKeyService {
|
|||||||
azureOpenaiAccountId = null,
|
azureOpenaiAccountId = null,
|
||||||
bedrockAccountId = null, // 添加 Bedrock 账号ID支持
|
bedrockAccountId = null, // 添加 Bedrock 账号ID支持
|
||||||
droidAccountId = null,
|
droidAccountId = null,
|
||||||
permissions = 'all', // 可选值:'claude'、'gemini'、'openai'、'droid' 或 'all'
|
permissions = [], // 数组格式,空数组表示全部服务,如 ['claude', 'gemini']
|
||||||
isActive = true,
|
isActive = true,
|
||||||
concurrencyLimit = 0,
|
concurrencyLimit = 0,
|
||||||
rateLimitWindow = null,
|
rateLimitWindow = null,
|
||||||
@@ -132,7 +177,7 @@ class ApiKeyService {
|
|||||||
azureOpenaiAccountId: azureOpenaiAccountId || '',
|
azureOpenaiAccountId: azureOpenaiAccountId || '',
|
||||||
bedrockAccountId: bedrockAccountId || '', // 添加 Bedrock 账号ID
|
bedrockAccountId: bedrockAccountId || '', // 添加 Bedrock 账号ID
|
||||||
droidAccountId: droidAccountId || '',
|
droidAccountId: droidAccountId || '',
|
||||||
permissions: permissions || 'all',
|
permissions: JSON.stringify(normalizePermissions(permissions)),
|
||||||
enableModelRestriction: String(enableModelRestriction),
|
enableModelRestriction: String(enableModelRestriction),
|
||||||
restrictedModels: JSON.stringify(restrictedModels || []),
|
restrictedModels: JSON.stringify(restrictedModels || []),
|
||||||
enableClientRestriction: String(enableClientRestriction || false),
|
enableClientRestriction: String(enableClientRestriction || false),
|
||||||
@@ -186,7 +231,7 @@ class ApiKeyService {
|
|||||||
azureOpenaiAccountId: keyData.azureOpenaiAccountId,
|
azureOpenaiAccountId: keyData.azureOpenaiAccountId,
|
||||||
bedrockAccountId: keyData.bedrockAccountId, // 添加 Bedrock 账号ID
|
bedrockAccountId: keyData.bedrockAccountId, // 添加 Bedrock 账号ID
|
||||||
droidAccountId: keyData.droidAccountId,
|
droidAccountId: keyData.droidAccountId,
|
||||||
permissions: keyData.permissions,
|
permissions: normalizePermissions(keyData.permissions),
|
||||||
enableModelRestriction: keyData.enableModelRestriction === 'true',
|
enableModelRestriction: keyData.enableModelRestriction === 'true',
|
||||||
restrictedModels: JSON.parse(keyData.restrictedModels),
|
restrictedModels: JSON.parse(keyData.restrictedModels),
|
||||||
enableClientRestriction: keyData.enableClientRestriction === 'true',
|
enableClientRestriction: keyData.enableClientRestriction === 'true',
|
||||||
@@ -338,7 +383,7 @@ class ApiKeyService {
|
|||||||
azureOpenaiAccountId: keyData.azureOpenaiAccountId,
|
azureOpenaiAccountId: keyData.azureOpenaiAccountId,
|
||||||
bedrockAccountId: keyData.bedrockAccountId, // 添加 Bedrock 账号ID
|
bedrockAccountId: keyData.bedrockAccountId, // 添加 Bedrock 账号ID
|
||||||
droidAccountId: keyData.droidAccountId,
|
droidAccountId: keyData.droidAccountId,
|
||||||
permissions: keyData.permissions || 'all',
|
permissions: normalizePermissions(keyData.permissions),
|
||||||
tokenLimit: parseInt(keyData.tokenLimit),
|
tokenLimit: parseInt(keyData.tokenLimit),
|
||||||
concurrencyLimit: parseInt(keyData.concurrencyLimit || 0),
|
concurrencyLimit: parseInt(keyData.concurrencyLimit || 0),
|
||||||
rateLimitWindow: parseInt(keyData.rateLimitWindow || 0),
|
rateLimitWindow: parseInt(keyData.rateLimitWindow || 0),
|
||||||
@@ -467,7 +512,7 @@ class ApiKeyService {
|
|||||||
azureOpenaiAccountId: keyData.azureOpenaiAccountId,
|
azureOpenaiAccountId: keyData.azureOpenaiAccountId,
|
||||||
bedrockAccountId: keyData.bedrockAccountId,
|
bedrockAccountId: keyData.bedrockAccountId,
|
||||||
droidAccountId: keyData.droidAccountId,
|
droidAccountId: keyData.droidAccountId,
|
||||||
permissions: keyData.permissions || 'all',
|
permissions: normalizePermissions(keyData.permissions),
|
||||||
tokenLimit: parseInt(keyData.tokenLimit),
|
tokenLimit: parseInt(keyData.tokenLimit),
|
||||||
concurrencyLimit: parseInt(keyData.concurrencyLimit || 0),
|
concurrencyLimit: parseInt(keyData.concurrencyLimit || 0),
|
||||||
rateLimitWindow: parseInt(keyData.rateLimitWindow || 0),
|
rateLimitWindow: parseInt(keyData.rateLimitWindow || 0),
|
||||||
@@ -525,7 +570,7 @@ class ApiKeyService {
|
|||||||
key.isActive = key.isActive === 'true'
|
key.isActive = key.isActive === 'true'
|
||||||
key.enableModelRestriction = key.enableModelRestriction === 'true'
|
key.enableModelRestriction = key.enableModelRestriction === 'true'
|
||||||
key.enableClientRestriction = key.enableClientRestriction === 'true'
|
key.enableClientRestriction = key.enableClientRestriction === 'true'
|
||||||
key.permissions = key.permissions || 'all' // 兼容旧数据
|
key.permissions = normalizePermissions(key.permissions)
|
||||||
key.dailyCostLimit = parseFloat(key.dailyCostLimit || 0)
|
key.dailyCostLimit = parseFloat(key.dailyCostLimit || 0)
|
||||||
key.totalCostLimit = parseFloat(key.totalCostLimit || 0)
|
key.totalCostLimit = parseFloat(key.totalCostLimit || 0)
|
||||||
key.weeklyOpusCostLimit = parseFloat(key.weeklyOpusCostLimit || 0)
|
key.weeklyOpusCostLimit = parseFloat(key.weeklyOpusCostLimit || 0)
|
||||||
@@ -1568,7 +1613,7 @@ class ApiKeyService {
|
|||||||
userId: keyData.userId,
|
userId: keyData.userId,
|
||||||
userUsername: keyData.userUsername,
|
userUsername: keyData.userUsername,
|
||||||
createdBy: keyData.createdBy,
|
createdBy: keyData.createdBy,
|
||||||
permissions: keyData.permissions,
|
permissions: normalizePermissions(keyData.permissions),
|
||||||
dailyCostLimit: parseFloat(keyData.dailyCostLimit || 0),
|
dailyCostLimit: parseFloat(keyData.dailyCostLimit || 0),
|
||||||
totalCostLimit: parseFloat(keyData.totalCostLimit || 0),
|
totalCostLimit: parseFloat(keyData.totalCostLimit || 0),
|
||||||
// 所有平台账户绑定字段
|
// 所有平台账户绑定字段
|
||||||
@@ -1820,4 +1865,8 @@ const apiKeyService = new ApiKeyService()
|
|||||||
// 为了方便其他服务调用,导出 recordUsage 方法
|
// 为了方便其他服务调用,导出 recordUsage 方法
|
||||||
apiKeyService.recordUsageMetrics = apiKeyService.recordUsage.bind(apiKeyService)
|
apiKeyService.recordUsageMetrics = apiKeyService.recordUsage.bind(apiKeyService)
|
||||||
|
|
||||||
|
// 导出权限辅助函数供路由使用
|
||||||
|
apiKeyService.hasPermission = hasPermission
|
||||||
|
apiKeyService.normalizePermissions = normalizePermissions
|
||||||
|
|
||||||
module.exports = apiKeyService
|
module.exports = apiKeyService
|
||||||
|
|||||||
133
src/services/balanceProviders/baseBalanceProvider.js
Normal file
133
src/services/balanceProviders/baseBalanceProvider.js
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
const axios = require('axios')
|
||||||
|
const logger = require('../../utils/logger')
|
||||||
|
const ProxyHelper = require('../../utils/proxyHelper')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provider 抽象基类
|
||||||
|
* 各平台 Provider 需继承并实现 queryBalance(account)
|
||||||
|
*/
|
||||||
|
class BaseBalanceProvider {
|
||||||
|
constructor(platform) {
|
||||||
|
this.platform = platform
|
||||||
|
this.logger = logger
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询余额(抽象方法)
|
||||||
|
* @param {object} account - 账户对象
|
||||||
|
* @returns {Promise<object>}
|
||||||
|
* 形如:
|
||||||
|
* {
|
||||||
|
* balance: number|null,
|
||||||
|
* currency?: string,
|
||||||
|
* quota?: { daily, used, remaining, resetAt, percentage, unlimited? },
|
||||||
|
* queryMethod?: 'api'|'field'|'local',
|
||||||
|
* rawData?: any
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
async queryBalance(_account) {
|
||||||
|
throw new Error('queryBalance 方法必须由子类实现')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通用 HTTP 请求方法(支持代理)
|
||||||
|
* @param {string} url
|
||||||
|
* @param {object} options
|
||||||
|
* @param {object} account
|
||||||
|
*/
|
||||||
|
async makeRequest(url, options = {}, account = {}) {
|
||||||
|
const config = {
|
||||||
|
url,
|
||||||
|
method: options.method || 'GET',
|
||||||
|
headers: options.headers || {},
|
||||||
|
timeout: options.timeout || 15000,
|
||||||
|
data: options.data,
|
||||||
|
params: options.params,
|
||||||
|
responseType: options.responseType
|
||||||
|
}
|
||||||
|
|
||||||
|
const proxyConfig = account.proxyConfig || account.proxy
|
||||||
|
if (proxyConfig) {
|
||||||
|
const agent = ProxyHelper.createProxyAgent(proxyConfig)
|
||||||
|
if (agent) {
|
||||||
|
config.httpAgent = agent
|
||||||
|
config.httpsAgent = agent
|
||||||
|
config.proxy = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await axios(config)
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: response.data,
|
||||||
|
status: response.status,
|
||||||
|
headers: response.headers
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const status = error.response?.status
|
||||||
|
const message = error.response?.data?.message || error.message || '请求失败'
|
||||||
|
this.logger.debug(`余额 Provider HTTP 请求失败: ${url} (${this.platform})`, {
|
||||||
|
status,
|
||||||
|
message
|
||||||
|
})
|
||||||
|
return { success: false, status, error: message }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从账户字段读取 dailyQuota / dailyUsage(通用降级方案)
|
||||||
|
* 注意:部分平台 dailyUsage 字段可能不是实时值,最终以 AccountBalanceService 的本地统计为准
|
||||||
|
*/
|
||||||
|
readQuotaFromFields(account) {
|
||||||
|
const dailyQuota = Number(account?.dailyQuota || 0)
|
||||||
|
const dailyUsage = Number(account?.dailyUsage || 0)
|
||||||
|
|
||||||
|
// 无限制
|
||||||
|
if (!Number.isFinite(dailyQuota) || dailyQuota <= 0) {
|
||||||
|
return {
|
||||||
|
balance: null,
|
||||||
|
currency: 'USD',
|
||||||
|
quota: {
|
||||||
|
daily: Infinity,
|
||||||
|
used: Number.isFinite(dailyUsage) ? dailyUsage : 0,
|
||||||
|
remaining: Infinity,
|
||||||
|
percentage: 0,
|
||||||
|
unlimited: true
|
||||||
|
},
|
||||||
|
queryMethod: 'field'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const used = Number.isFinite(dailyUsage) ? dailyUsage : 0
|
||||||
|
const remaining = Math.max(0, dailyQuota - used)
|
||||||
|
const percentage = dailyQuota > 0 ? (used / dailyQuota) * 100 : 0
|
||||||
|
|
||||||
|
return {
|
||||||
|
balance: remaining,
|
||||||
|
currency: 'USD',
|
||||||
|
quota: {
|
||||||
|
daily: dailyQuota,
|
||||||
|
used,
|
||||||
|
remaining,
|
||||||
|
percentage: Math.round(percentage * 100) / 100
|
||||||
|
},
|
||||||
|
queryMethod: 'field'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
parseCurrency(data) {
|
||||||
|
return data?.currency || data?.Currency || 'USD'
|
||||||
|
}
|
||||||
|
|
||||||
|
async safeExecute(fn, fallbackValue = null) {
|
||||||
|
try {
|
||||||
|
return await fn()
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`余额 Provider 执行失败: ${this.platform}`, error)
|
||||||
|
return fallbackValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = BaseBalanceProvider
|
||||||
30
src/services/balanceProviders/claudeBalanceProvider.js
Normal file
30
src/services/balanceProviders/claudeBalanceProvider.js
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
const BaseBalanceProvider = require('./baseBalanceProvider')
|
||||||
|
const claudeAccountService = require('../claudeAccountService')
|
||||||
|
|
||||||
|
class ClaudeBalanceProvider extends BaseBalanceProvider {
|
||||||
|
constructor() {
|
||||||
|
super('claude')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Claude(OAuth):优先尝试获取 OAuth usage(用于配额/使用信息),不强行提供余额金额
|
||||||
|
*/
|
||||||
|
async queryBalance(account) {
|
||||||
|
this.logger.debug(`查询 Claude 余额(OAuth usage): ${account?.id}`)
|
||||||
|
|
||||||
|
// 仅 OAuth 账户可用;失败时降级
|
||||||
|
const usageData = await claudeAccountService.fetchOAuthUsage(account.id).catch(() => null)
|
||||||
|
if (!usageData) {
|
||||||
|
return { balance: null, currency: 'USD', queryMethod: 'local' }
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
balance: null,
|
||||||
|
currency: 'USD',
|
||||||
|
queryMethod: 'api',
|
||||||
|
rawData: usageData
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = ClaudeBalanceProvider
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
const BaseBalanceProvider = require('./baseBalanceProvider')
|
||||||
|
|
||||||
|
class ClaudeConsoleBalanceProvider extends BaseBalanceProvider {
|
||||||
|
constructor() {
|
||||||
|
super('claude-console')
|
||||||
|
}
|
||||||
|
|
||||||
|
async queryBalance(account) {
|
||||||
|
this.logger.debug(`查询 Claude Console 余额(字段): ${account?.id}`)
|
||||||
|
return this.readQuotaFromFields(account)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = ClaudeConsoleBalanceProvider
|
||||||
23
src/services/balanceProviders/genericBalanceProvider.js
Normal file
23
src/services/balanceProviders/genericBalanceProvider.js
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
const BaseBalanceProvider = require('./baseBalanceProvider')
|
||||||
|
|
||||||
|
class GenericBalanceProvider extends BaseBalanceProvider {
|
||||||
|
constructor(platform) {
|
||||||
|
super(platform)
|
||||||
|
}
|
||||||
|
|
||||||
|
async queryBalance(account) {
|
||||||
|
this.logger.debug(`${this.platform} 暂无专用余额 API,实现降级策略`)
|
||||||
|
|
||||||
|
if (account && Object.prototype.hasOwnProperty.call(account, 'dailyQuota')) {
|
||||||
|
return this.readQuotaFromFields(account)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
balance: null,
|
||||||
|
currency: 'USD',
|
||||||
|
queryMethod: 'local'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = GenericBalanceProvider
|
||||||
24
src/services/balanceProviders/index.js
Normal file
24
src/services/balanceProviders/index.js
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
const ClaudeBalanceProvider = require('./claudeBalanceProvider')
|
||||||
|
const ClaudeConsoleBalanceProvider = require('./claudeConsoleBalanceProvider')
|
||||||
|
const OpenAIResponsesBalanceProvider = require('./openaiResponsesBalanceProvider')
|
||||||
|
const GenericBalanceProvider = require('./genericBalanceProvider')
|
||||||
|
|
||||||
|
function registerAllProviders(balanceService) {
|
||||||
|
// Claude
|
||||||
|
balanceService.registerProvider('claude', new ClaudeBalanceProvider())
|
||||||
|
balanceService.registerProvider('claude-console', new ClaudeConsoleBalanceProvider())
|
||||||
|
|
||||||
|
// OpenAI / Codex
|
||||||
|
balanceService.registerProvider('openai-responses', new OpenAIResponsesBalanceProvider())
|
||||||
|
balanceService.registerProvider('openai', new GenericBalanceProvider('openai'))
|
||||||
|
balanceService.registerProvider('azure_openai', new GenericBalanceProvider('azure_openai'))
|
||||||
|
|
||||||
|
// 其他平台(降级)
|
||||||
|
balanceService.registerProvider('gemini', new GenericBalanceProvider('gemini'))
|
||||||
|
balanceService.registerProvider('gemini-api', new GenericBalanceProvider('gemini-api'))
|
||||||
|
balanceService.registerProvider('bedrock', new GenericBalanceProvider('bedrock'))
|
||||||
|
balanceService.registerProvider('droid', new GenericBalanceProvider('droid'))
|
||||||
|
balanceService.registerProvider('ccr', new GenericBalanceProvider('ccr'))
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { registerAllProviders }
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
const BaseBalanceProvider = require('./baseBalanceProvider')
|
||||||
|
|
||||||
|
class OpenAIResponsesBalanceProvider extends BaseBalanceProvider {
|
||||||
|
constructor() {
|
||||||
|
super('openai-responses')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OpenAI-Responses:
|
||||||
|
* - 优先使用 dailyQuota 字段(如果配置了额度)
|
||||||
|
* - 可选:尝试调用兼容 API(不同服务商实现不一,失败自动降级)
|
||||||
|
*/
|
||||||
|
async queryBalance(account) {
|
||||||
|
this.logger.debug(`查询 OpenAI Responses 余额: ${account?.id}`)
|
||||||
|
|
||||||
|
// 配置了额度时直接返回(字段法)
|
||||||
|
if (account?.dailyQuota && Number(account.dailyQuota) > 0) {
|
||||||
|
return this.readQuotaFromFields(account)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 尝试调用 usage 接口(兼容性不保证)
|
||||||
|
if (account?.apiKey && account?.baseApi) {
|
||||||
|
const baseApi = String(account.baseApi).replace(/\/$/, '')
|
||||||
|
const response = await this.makeRequest(
|
||||||
|
`${baseApi}/v1/usage`,
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${account.apiKey}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
account
|
||||||
|
)
|
||||||
|
|
||||||
|
if (response.success) {
|
||||||
|
return {
|
||||||
|
balance: null,
|
||||||
|
currency: this.parseCurrency(response.data),
|
||||||
|
queryMethod: 'api',
|
||||||
|
rawData: response.data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
balance: null,
|
||||||
|
currency: 'USD',
|
||||||
|
queryMethod: 'local'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = OpenAIResponsesBalanceProvider
|
||||||
161
src/services/balanceScriptService.js
Normal file
161
src/services/balanceScriptService.js
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
const vm = require('vm')
|
||||||
|
const axios = require('axios')
|
||||||
|
const { isBalanceScriptEnabled } = require('../utils/featureFlags')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 可配置脚本余额查询执行器
|
||||||
|
* - 脚本格式:({ request: {...}, extractor: function(response){...} })
|
||||||
|
* - 模板变量:{{baseUrl}}, {{apiKey}}, {{token}}, {{accountId}}, {{platform}}, {{extra}}
|
||||||
|
*/
|
||||||
|
class BalanceScriptService {
|
||||||
|
/**
|
||||||
|
* 执行脚本:返回标准余额结构 + 原始响应
|
||||||
|
* @param {object} options
|
||||||
|
* - scriptBody: string
|
||||||
|
* - variables: Record<string,string>
|
||||||
|
* - 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('脚本内容为空')
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
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
|
||||||
|
|
||||||
|
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(),
|
||||||
|
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
|
||||||
|
try {
|
||||||
|
httpResponse = await axios(axiosConfig)
|
||||||
|
} catch (error) {
|
||||||
|
const { response } = error || {}
|
||||||
|
const { status, data } = response || {}
|
||||||
|
throw new Error(
|
||||||
|
`请求失败: ${status || ''} ${error.message}${data ? ` | ${JSON.stringify(data)}` : ''}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const responseData = httpResponse?.data
|
||||||
|
|
||||||
|
let extracted = {}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = new BalanceScriptService()
|
||||||
@@ -91,7 +91,9 @@ class ClaudeAccountService {
|
|||||||
useUnifiedClientId = false, // 是否使用统一的客户端标识
|
useUnifiedClientId = false, // 是否使用统一的客户端标识
|
||||||
unifiedClientId = '', // 统一的客户端标识
|
unifiedClientId = '', // 统一的客户端标识
|
||||||
expiresAt = null, // 账户订阅到期时间
|
expiresAt = null, // 账户订阅到期时间
|
||||||
extInfo = null // 额外扩展信息
|
extInfo = null, // 额外扩展信息
|
||||||
|
maxConcurrency = 0, // 账户级用户消息串行队列:0=使用全局配置,>0=强制启用串行
|
||||||
|
interceptWarmup = false // 拦截预热请求(标题生成、Warmup等)
|
||||||
} = options
|
} = options
|
||||||
|
|
||||||
const accountId = uuidv4()
|
const accountId = uuidv4()
|
||||||
@@ -136,7 +138,11 @@ class ClaudeAccountService {
|
|||||||
// 账户订阅到期时间
|
// 账户订阅到期时间
|
||||||
subscriptionExpiresAt: expiresAt || '',
|
subscriptionExpiresAt: expiresAt || '',
|
||||||
// 扩展信息
|
// 扩展信息
|
||||||
extInfo: normalizedExtInfo ? JSON.stringify(normalizedExtInfo) : ''
|
extInfo: normalizedExtInfo ? JSON.stringify(normalizedExtInfo) : '',
|
||||||
|
// 账户级用户消息串行队列限制
|
||||||
|
maxConcurrency: maxConcurrency.toString(),
|
||||||
|
// 拦截预热请求
|
||||||
|
interceptWarmup: interceptWarmup.toString()
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// 兼容旧格式
|
// 兼容旧格式
|
||||||
@@ -168,7 +174,11 @@ class ClaudeAccountService {
|
|||||||
// 账户订阅到期时间
|
// 账户订阅到期时间
|
||||||
subscriptionExpiresAt: expiresAt || '',
|
subscriptionExpiresAt: expiresAt || '',
|
||||||
// 扩展信息
|
// 扩展信息
|
||||||
extInfo: normalizedExtInfo ? JSON.stringify(normalizedExtInfo) : ''
|
extInfo: normalizedExtInfo ? JSON.stringify(normalizedExtInfo) : '',
|
||||||
|
// 账户级用户消息串行队列限制
|
||||||
|
maxConcurrency: maxConcurrency.toString(),
|
||||||
|
// 拦截预热请求
|
||||||
|
interceptWarmup: interceptWarmup.toString()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -216,7 +226,8 @@ class ClaudeAccountService {
|
|||||||
useUnifiedUserAgent,
|
useUnifiedUserAgent,
|
||||||
useUnifiedClientId,
|
useUnifiedClientId,
|
||||||
unifiedClientId,
|
unifiedClientId,
|
||||||
extInfo: normalizedExtInfo
|
extInfo: normalizedExtInfo,
|
||||||
|
interceptWarmup
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -574,7 +585,11 @@ class ClaudeAccountService {
|
|||||||
// 添加停止原因
|
// 添加停止原因
|
||||||
stoppedReason: account.stoppedReason || null,
|
stoppedReason: account.stoppedReason || null,
|
||||||
// 扩展信息
|
// 扩展信息
|
||||||
extInfo: parsedExtInfo
|
extInfo: parsedExtInfo,
|
||||||
|
// 账户级用户消息串行队列限制
|
||||||
|
maxConcurrency: parseInt(account.maxConcurrency || '0', 10),
|
||||||
|
// 拦截预热请求
|
||||||
|
interceptWarmup: account.interceptWarmup === 'true'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
@@ -666,7 +681,9 @@ class ClaudeAccountService {
|
|||||||
'useUnifiedClientId',
|
'useUnifiedClientId',
|
||||||
'unifiedClientId',
|
'unifiedClientId',
|
||||||
'subscriptionExpiresAt',
|
'subscriptionExpiresAt',
|
||||||
'extInfo'
|
'extInfo',
|
||||||
|
'maxConcurrency',
|
||||||
|
'interceptWarmup'
|
||||||
]
|
]
|
||||||
const updatedData = { ...accountData }
|
const updatedData = { ...accountData }
|
||||||
let shouldClearAutoStopFields = false
|
let shouldClearAutoStopFields = false
|
||||||
@@ -681,7 +698,7 @@ class ClaudeAccountService {
|
|||||||
updatedData[field] = this._encryptSensitiveData(value)
|
updatedData[field] = this._encryptSensitiveData(value)
|
||||||
} else if (field === 'proxy') {
|
} else if (field === 'proxy') {
|
||||||
updatedData[field] = value ? JSON.stringify(value) : ''
|
updatedData[field] = value ? JSON.stringify(value) : ''
|
||||||
} else if (field === 'priority') {
|
} else if (field === 'priority' || field === 'maxConcurrency') {
|
||||||
updatedData[field] = value.toString()
|
updatedData[field] = value.toString()
|
||||||
} else if (field === 'subscriptionInfo') {
|
} else if (field === 'subscriptionInfo') {
|
||||||
// 处理订阅信息更新
|
// 处理订阅信息更新
|
||||||
|
|||||||
@@ -68,7 +68,8 @@ class ClaudeConsoleAccountService {
|
|||||||
dailyQuota = 0, // 每日额度限制(美元),0表示不限制
|
dailyQuota = 0, // 每日额度限制(美元),0表示不限制
|
||||||
quotaResetTime = '00:00', // 额度重置时间(HH:mm格式)
|
quotaResetTime = '00:00', // 额度重置时间(HH:mm格式)
|
||||||
maxConcurrentTasks = 0, // 最大并发任务数,0表示无限制
|
maxConcurrentTasks = 0, // 最大并发任务数,0表示无限制
|
||||||
disableAutoProtection = false // 是否关闭自动防护(429/401/400/529 不自动禁用)
|
disableAutoProtection = false, // 是否关闭自动防护(429/401/400/529 不自动禁用)
|
||||||
|
interceptWarmup = false // 拦截预热请求(标题生成、Warmup等)
|
||||||
} = options
|
} = options
|
||||||
|
|
||||||
// 验证必填字段
|
// 验证必填字段
|
||||||
@@ -117,7 +118,8 @@ class ClaudeConsoleAccountService {
|
|||||||
quotaResetTime, // 额度重置时间
|
quotaResetTime, // 额度重置时间
|
||||||
quotaStoppedAt: '', // 因额度停用的时间
|
quotaStoppedAt: '', // 因额度停用的时间
|
||||||
maxConcurrentTasks: maxConcurrentTasks.toString(), // 最大并发任务数,0表示无限制
|
maxConcurrentTasks: maxConcurrentTasks.toString(), // 最大并发任务数,0表示无限制
|
||||||
disableAutoProtection: disableAutoProtection.toString() // 关闭自动防护
|
disableAutoProtection: disableAutoProtection.toString(), // 关闭自动防护
|
||||||
|
interceptWarmup: interceptWarmup.toString() // 拦截预热请求
|
||||||
}
|
}
|
||||||
|
|
||||||
const client = redis.getClientSafe()
|
const client = redis.getClientSafe()
|
||||||
@@ -156,6 +158,7 @@ class ClaudeConsoleAccountService {
|
|||||||
quotaStoppedAt: null,
|
quotaStoppedAt: null,
|
||||||
maxConcurrentTasks, // 新增:返回并发限制配置
|
maxConcurrentTasks, // 新增:返回并发限制配置
|
||||||
disableAutoProtection, // 新增:返回自动防护开关
|
disableAutoProtection, // 新增:返回自动防护开关
|
||||||
|
interceptWarmup, // 新增:返回预热请求拦截开关
|
||||||
activeTaskCount: 0 // 新增:新建账户当前并发数为0
|
activeTaskCount: 0 // 新增:新建账户当前并发数为0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -217,7 +220,9 @@ class ClaudeConsoleAccountService {
|
|||||||
// 并发控制相关
|
// 并发控制相关
|
||||||
maxConcurrentTasks: parseInt(accountData.maxConcurrentTasks) || 0,
|
maxConcurrentTasks: parseInt(accountData.maxConcurrentTasks) || 0,
|
||||||
activeTaskCount,
|
activeTaskCount,
|
||||||
disableAutoProtection: accountData.disableAutoProtection === 'true'
|
disableAutoProtection: accountData.disableAutoProtection === 'true',
|
||||||
|
// 拦截预热请求
|
||||||
|
interceptWarmup: accountData.interceptWarmup === 'true'
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -375,6 +380,9 @@ class ClaudeConsoleAccountService {
|
|||||||
if (updates.disableAutoProtection !== undefined) {
|
if (updates.disableAutoProtection !== undefined) {
|
||||||
updatedData.disableAutoProtection = updates.disableAutoProtection.toString()
|
updatedData.disableAutoProtection = updates.disableAutoProtection.toString()
|
||||||
}
|
}
|
||||||
|
if (updates.interceptWarmup !== undefined) {
|
||||||
|
updatedData.interceptWarmup = updates.interceptWarmup.toString()
|
||||||
|
}
|
||||||
|
|
||||||
// ✅ 直接保存 subscriptionExpiresAt(如果提供)
|
// ✅ 直接保存 subscriptionExpiresAt(如果提供)
|
||||||
// Claude Console 没有 token 刷新逻辑,不会覆盖此字段
|
// Claude Console 没有 token 刷新逻辑,不会覆盖此字段
|
||||||
|
|||||||
@@ -210,7 +210,17 @@ class ClaudeRelayService {
|
|||||||
logger.error('❌ accountId missing for queue lock in relayRequest')
|
logger.error('❌ accountId missing for queue lock in relayRequest')
|
||||||
throw new Error('accountId missing for queue lock')
|
throw new Error('accountId missing for queue lock')
|
||||||
}
|
}
|
||||||
const queueResult = await userMessageQueueService.acquireQueueLock(accountId)
|
// 获取账户信息以检查账户级串行队列配置
|
||||||
|
const accountForQueue = await claudeAccountService.getAccount(accountId)
|
||||||
|
const accountConfig = accountForQueue
|
||||||
|
? { maxConcurrency: parseInt(accountForQueue.maxConcurrency || '0', 10) }
|
||||||
|
: null
|
||||||
|
const queueResult = await userMessageQueueService.acquireQueueLock(
|
||||||
|
accountId,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
accountConfig
|
||||||
|
)
|
||||||
if (!queueResult.acquired && !queueResult.skipped) {
|
if (!queueResult.acquired && !queueResult.skipped) {
|
||||||
// 区分 Redis 后端错误和队列超时
|
// 区分 Redis 后端错误和队列超时
|
||||||
const isBackendError = queueResult.error === 'queue_backend_error'
|
const isBackendError = queueResult.error === 'queue_backend_error'
|
||||||
@@ -323,17 +333,46 @@ class ClaudeRelayService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 发送请求到Claude API(传入回调以获取请求对象)
|
// 发送请求到Claude API(传入回调以获取请求对象)
|
||||||
const response = await this._makeClaudeRequest(
|
// 🔄 403 重试机制:仅对 claude-official 类型账户(OAuth 或 Setup Token)
|
||||||
processedBody,
|
const maxRetries = this._shouldRetryOn403(accountType) ? 2 : 0
|
||||||
accessToken,
|
let retryCount = 0
|
||||||
proxyAgent,
|
let response
|
||||||
clientHeaders,
|
let shouldRetry = false
|
||||||
accountId,
|
|
||||||
(req) => {
|
do {
|
||||||
upstreamRequest = req
|
response = await this._makeClaudeRequest(
|
||||||
},
|
processedBody,
|
||||||
options
|
accessToken,
|
||||||
)
|
proxyAgent,
|
||||||
|
clientHeaders,
|
||||||
|
accountId,
|
||||||
|
(req) => {
|
||||||
|
upstreamRequest = req
|
||||||
|
},
|
||||||
|
options
|
||||||
|
)
|
||||||
|
|
||||||
|
// 检查是否需要重试 403
|
||||||
|
shouldRetry = response.statusCode === 403 && retryCount < maxRetries
|
||||||
|
if (shouldRetry) {
|
||||||
|
retryCount++
|
||||||
|
logger.warn(
|
||||||
|
`🔄 403 error for account ${accountId}, retry ${retryCount}/${maxRetries} after 2s`
|
||||||
|
)
|
||||||
|
await this._sleep(2000)
|
||||||
|
}
|
||||||
|
} while (shouldRetry)
|
||||||
|
|
||||||
|
// 如果进行了重试,记录最终结果
|
||||||
|
if (retryCount > 0) {
|
||||||
|
if (response.statusCode === 403) {
|
||||||
|
logger.error(`🚫 403 error persists for account ${accountId} after ${retryCount} retries`)
|
||||||
|
} else {
|
||||||
|
logger.info(
|
||||||
|
`✅ 403 retry successful for account ${accountId} on attempt ${retryCount}, got status ${response.statusCode}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 📬 请求已发送成功,立即释放队列锁(无需等待响应处理完成)
|
// 📬 请求已发送成功,立即释放队列锁(无需等待响应处理完成)
|
||||||
// 因为 Claude API 限流基于请求发送时刻计算(RPM),不是请求完成时刻
|
// 因为 Claude API 限流基于请求发送时刻计算(RPM),不是请求完成时刻
|
||||||
@@ -398,9 +437,10 @@ class ClaudeRelayService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 检查是否为403状态码(禁止访问)
|
// 检查是否为403状态码(禁止访问)
|
||||||
|
// 注意:如果进行了重试,retryCount > 0;这里的 403 是重试后最终的结果
|
||||||
else if (response.statusCode === 403) {
|
else if (response.statusCode === 403) {
|
||||||
logger.error(
|
logger.error(
|
||||||
`🚫 Forbidden error (403) detected for account ${accountId}, marking as blocked`
|
`🚫 Forbidden error (403) detected for account ${accountId}${retryCount > 0 ? ` after ${retryCount} retries` : ''}, marking as blocked`
|
||||||
)
|
)
|
||||||
await unifiedClaudeScheduler.markAccountBlocked(accountId, accountType, sessionHash)
|
await unifiedClaudeScheduler.markAccountBlocked(accountId, accountType, sessionHash)
|
||||||
}
|
}
|
||||||
@@ -1314,7 +1354,17 @@ class ClaudeRelayService {
|
|||||||
logger.error('❌ accountId missing for queue lock in relayStreamRequestWithUsageCapture')
|
logger.error('❌ accountId missing for queue lock in relayStreamRequestWithUsageCapture')
|
||||||
throw new Error('accountId missing for queue lock')
|
throw new Error('accountId missing for queue lock')
|
||||||
}
|
}
|
||||||
const queueResult = await userMessageQueueService.acquireQueueLock(accountId)
|
// 获取账户信息以检查账户级串行队列配置
|
||||||
|
const accountForQueue = await claudeAccountService.getAccount(accountId)
|
||||||
|
const accountConfig = accountForQueue
|
||||||
|
? { maxConcurrency: parseInt(accountForQueue.maxConcurrency || '0', 10) }
|
||||||
|
: null
|
||||||
|
const queueResult = await userMessageQueueService.acquireQueueLock(
|
||||||
|
accountId,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
accountConfig
|
||||||
|
)
|
||||||
if (!queueResult.acquired && !queueResult.skipped) {
|
if (!queueResult.acquired && !queueResult.skipped) {
|
||||||
// 区分 Redis 后端错误和队列超时
|
// 区分 Redis 后端错误和队列超时
|
||||||
const isBackendError = queueResult.error === 'queue_backend_error'
|
const isBackendError = queueResult.error === 'queue_backend_error'
|
||||||
@@ -1497,8 +1547,10 @@ class ClaudeRelayService {
|
|||||||
streamTransformer = null,
|
streamTransformer = null,
|
||||||
requestOptions = {},
|
requestOptions = {},
|
||||||
isDedicatedOfficialAccount = false,
|
isDedicatedOfficialAccount = false,
|
||||||
onResponseStart = null // 📬 新增:收到响应头时的回调,用于提前释放队列锁
|
onResponseStart = null, // 📬 新增:收到响应头时的回调,用于提前释放队列锁
|
||||||
|
retryCount = 0 // 🔄 403 重试计数器
|
||||||
) {
|
) {
|
||||||
|
const maxRetries = 2 // 最大重试次数
|
||||||
// 获取账户信息用于统一 User-Agent
|
// 获取账户信息用于统一 User-Agent
|
||||||
const account = await claudeAccountService.getAccount(accountId)
|
const account = await claudeAccountService.getAccount(accountId)
|
||||||
|
|
||||||
@@ -1611,6 +1663,51 @@ class ClaudeRelayService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🔄 403 重试机制(必须在设置 res.on('data')/res.on('end') 之前处理)
|
||||||
|
// 否则重试时旧响应的 on('end') 会与新请求产生竞态条件
|
||||||
|
if (res.statusCode === 403) {
|
||||||
|
const canRetry =
|
||||||
|
this._shouldRetryOn403(accountType) &&
|
||||||
|
retryCount < maxRetries &&
|
||||||
|
!responseStream.headersSent
|
||||||
|
|
||||||
|
if (canRetry) {
|
||||||
|
logger.warn(
|
||||||
|
`🔄 [Stream] 403 error for account ${accountId}, retry ${retryCount + 1}/${maxRetries} after 2s`
|
||||||
|
)
|
||||||
|
// 消费当前响应并销毁请求
|
||||||
|
res.resume()
|
||||||
|
req.destroy()
|
||||||
|
|
||||||
|
// 等待 2 秒后递归重试
|
||||||
|
await this._sleep(2000)
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 递归调用自身进行重试
|
||||||
|
const retryResult = await this._makeClaudeStreamRequestWithUsageCapture(
|
||||||
|
body,
|
||||||
|
accessToken,
|
||||||
|
proxyAgent,
|
||||||
|
clientHeaders,
|
||||||
|
responseStream,
|
||||||
|
usageCallback,
|
||||||
|
accountId,
|
||||||
|
accountType,
|
||||||
|
sessionHash,
|
||||||
|
streamTransformer,
|
||||||
|
requestOptions,
|
||||||
|
isDedicatedOfficialAccount,
|
||||||
|
onResponseStart,
|
||||||
|
retryCount + 1
|
||||||
|
)
|
||||||
|
resolve(retryResult)
|
||||||
|
} catch (retryError) {
|
||||||
|
reject(retryError)
|
||||||
|
}
|
||||||
|
return // 重要:提前返回,不设置后续的错误处理器
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 将错误处理逻辑封装在一个异步函数中
|
// 将错误处理逻辑封装在一个异步函数中
|
||||||
const handleErrorResponse = async () => {
|
const handleErrorResponse = async () => {
|
||||||
if (res.statusCode === 401) {
|
if (res.statusCode === 401) {
|
||||||
@@ -1634,8 +1731,10 @@ class ClaudeRelayService {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else if (res.statusCode === 403) {
|
} else if (res.statusCode === 403) {
|
||||||
|
// 403 处理:走到这里说明重试已用尽或不适用重试,直接标记 blocked
|
||||||
|
// 注意:重试逻辑已在 handleErrorResponse 外部提前处理
|
||||||
logger.error(
|
logger.error(
|
||||||
`🚫 [Stream] Forbidden error (403) detected for account ${accountId}, marking as blocked`
|
`🚫 [Stream] Forbidden error (403) detected for account ${accountId}${retryCount > 0 ? ` after ${retryCount} retries` : ''}, marking as blocked`
|
||||||
)
|
)
|
||||||
await unifiedClaudeScheduler.markAccountBlocked(accountId, accountType, sessionHash)
|
await unifiedClaudeScheduler.markAccountBlocked(accountId, accountType, sessionHash)
|
||||||
} else if (res.statusCode === 529) {
|
} else if (res.statusCode === 529) {
|
||||||
@@ -2456,28 +2555,35 @@ class ClaudeRelayService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🔧 准备测试请求的公共逻辑(供 testAccountConnection 和 testAccountConnectionSync 共用)
|
||||||
|
async _prepareAccountForTest(accountId) {
|
||||||
|
// 获取账户信息
|
||||||
|
const account = await claudeAccountService.getAccount(accountId)
|
||||||
|
if (!account) {
|
||||||
|
throw new Error('Account not found')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取有效的访问token
|
||||||
|
const accessToken = await claudeAccountService.getValidAccessToken(accountId)
|
||||||
|
if (!accessToken) {
|
||||||
|
throw new Error('Failed to get valid access token')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取代理配置
|
||||||
|
const proxyAgent = await this._getProxyAgent(accountId)
|
||||||
|
|
||||||
|
return { account, accessToken, proxyAgent }
|
||||||
|
}
|
||||||
|
|
||||||
// 🧪 测试账号连接(供Admin API使用,直接复用 _makeClaudeStreamRequestWithUsageCapture)
|
// 🧪 测试账号连接(供Admin API使用,直接复用 _makeClaudeStreamRequestWithUsageCapture)
|
||||||
async testAccountConnection(accountId, responseStream) {
|
async testAccountConnection(accountId, responseStream, model = 'claude-sonnet-4-5-20250929') {
|
||||||
const testRequestBody = createClaudeTestPayload('claude-sonnet-4-5-20250929', { stream: true })
|
const testRequestBody = createClaudeTestPayload(model, { stream: true })
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 获取账户信息
|
const { account, accessToken, proxyAgent } = await this._prepareAccountForTest(accountId)
|
||||||
const account = await claudeAccountService.getAccount(accountId)
|
|
||||||
if (!account) {
|
|
||||||
throw new Error('Account not found')
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info(`🧪 Testing Claude account connection: ${account.name} (${accountId})`)
|
logger.info(`🧪 Testing Claude account connection: ${account.name} (${accountId})`)
|
||||||
|
|
||||||
// 获取有效的访问token
|
|
||||||
const accessToken = await claudeAccountService.getValidAccessToken(accountId)
|
|
||||||
if (!accessToken) {
|
|
||||||
throw new Error('Failed to get valid access token')
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取代理配置
|
|
||||||
const proxyAgent = await this._getProxyAgent(accountId)
|
|
||||||
|
|
||||||
// 设置响应头
|
// 设置响应头
|
||||||
if (!responseStream.headersSent) {
|
if (!responseStream.headersSent) {
|
||||||
const existingConnection = responseStream.getHeader
|
const existingConnection = responseStream.getHeader
|
||||||
@@ -2526,6 +2632,125 @@ class ClaudeRelayService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🧪 非流式测试账号连接(供定时任务使用)
|
||||||
|
// 复用流式请求方法,收集结果后返回
|
||||||
|
async testAccountConnectionSync(accountId, model = 'claude-sonnet-4-5-20250929') {
|
||||||
|
const testRequestBody = createClaudeTestPayload(model, { stream: true })
|
||||||
|
const startTime = Date.now()
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 使用公共方法准备测试所需的账户信息、token 和代理
|
||||||
|
const { account, accessToken, proxyAgent } = await this._prepareAccountForTest(accountId)
|
||||||
|
|
||||||
|
logger.info(`🧪 Testing Claude account connection (sync): ${account.name} (${accountId})`)
|
||||||
|
|
||||||
|
// 创建一个收集器来捕获流式响应
|
||||||
|
let responseText = ''
|
||||||
|
let capturedUsage = null
|
||||||
|
let capturedModel = model
|
||||||
|
let hasError = false
|
||||||
|
let errorMessage = ''
|
||||||
|
|
||||||
|
// 创建模拟的响应流对象
|
||||||
|
const mockResponseStream = {
|
||||||
|
headersSent: true, // 跳过设置响应头
|
||||||
|
write: (data) => {
|
||||||
|
// 解析 SSE 数据
|
||||||
|
if (typeof data === 'string' && data.startsWith('data: ')) {
|
||||||
|
try {
|
||||||
|
const jsonStr = data.replace('data: ', '').trim()
|
||||||
|
if (jsonStr && jsonStr !== '[DONE]') {
|
||||||
|
const parsed = JSON.parse(jsonStr)
|
||||||
|
// 提取文本内容
|
||||||
|
if (parsed.type === 'content_block_delta' && parsed.delta?.text) {
|
||||||
|
responseText += parsed.delta.text
|
||||||
|
}
|
||||||
|
// 提取 usage 信息
|
||||||
|
if (parsed.type === 'message_delta' && parsed.usage) {
|
||||||
|
capturedUsage = parsed.usage
|
||||||
|
}
|
||||||
|
// 提取模型信息
|
||||||
|
if (parsed.type === 'message_start' && parsed.message?.model) {
|
||||||
|
capturedModel = parsed.message.model
|
||||||
|
}
|
||||||
|
// 检测错误
|
||||||
|
if (parsed.type === 'error') {
|
||||||
|
hasError = true
|
||||||
|
errorMessage = parsed.error?.message || 'Unknown error'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 忽略解析错误
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
end: () => {},
|
||||||
|
on: () => {},
|
||||||
|
once: () => {},
|
||||||
|
emit: () => {},
|
||||||
|
writable: true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 复用流式请求方法
|
||||||
|
await this._makeClaudeStreamRequestWithUsageCapture(
|
||||||
|
testRequestBody,
|
||||||
|
accessToken,
|
||||||
|
proxyAgent,
|
||||||
|
{}, // clientHeaders - 测试不需要客户端headers
|
||||||
|
mockResponseStream,
|
||||||
|
null, // usageCallback - 测试不需要统计
|
||||||
|
accountId,
|
||||||
|
'claude-official', // accountType
|
||||||
|
null, // sessionHash - 测试不需要会话
|
||||||
|
null, // streamTransformer - 不需要转换,直接解析原始格式
|
||||||
|
{}, // requestOptions
|
||||||
|
false // isDedicatedOfficialAccount
|
||||||
|
)
|
||||||
|
|
||||||
|
const latencyMs = Date.now() - startTime
|
||||||
|
|
||||||
|
if (hasError) {
|
||||||
|
logger.warn(`⚠️ Test completed with error for account: ${account.name} - ${errorMessage}`)
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: errorMessage,
|
||||||
|
latencyMs,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`✅ Test completed for account: ${account.name} (${latencyMs}ms)`)
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: responseText.substring(0, 200), // 截取前200字符
|
||||||
|
latencyMs,
|
||||||
|
model: capturedModel,
|
||||||
|
usage: capturedUsage,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const latencyMs = Date.now() - startTime
|
||||||
|
logger.error(`❌ Test account connection (sync) failed:`, error.message)
|
||||||
|
|
||||||
|
// 提取错误详情
|
||||||
|
let errorMessage = error.message
|
||||||
|
if (error.response) {
|
||||||
|
errorMessage =
|
||||||
|
error.response.data?.error?.message || error.response.statusText || error.message
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: errorMessage,
|
||||||
|
statusCode: error.response?.status,
|
||||||
|
latencyMs,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 🎯 健康检查
|
// 🎯 健康检查
|
||||||
async healthCheck() {
|
async healthCheck() {
|
||||||
try {
|
try {
|
||||||
@@ -2547,6 +2772,17 @@ class ClaudeRelayService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🔄 判断账户是否应该在 403 错误时进行重试
|
||||||
|
// 仅 claude-official 类型账户(OAuth 或 Setup Token 授权)需要重试
|
||||||
|
_shouldRetryOn403(accountType) {
|
||||||
|
return accountType === 'claude-official'
|
||||||
|
}
|
||||||
|
|
||||||
|
// ⏱️ 等待指定毫秒数
|
||||||
|
_sleep(ms) {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = new ClaudeRelayService()
|
module.exports = new ClaudeRelayService()
|
||||||
|
|||||||
@@ -121,12 +121,23 @@ class UserMessageQueueService {
|
|||||||
* @param {string} accountId - 账户ID
|
* @param {string} accountId - 账户ID
|
||||||
* @param {string} requestId - 请求ID(可选,会自动生成)
|
* @param {string} requestId - 请求ID(可选,会自动生成)
|
||||||
* @param {number} timeoutMs - 超时时间(可选,使用配置默认值)
|
* @param {number} timeoutMs - 超时时间(可选,使用配置默认值)
|
||||||
|
* @param {Object} accountConfig - 账户级配置(可选),优先级高于全局配置
|
||||||
|
* @param {number} accountConfig.maxConcurrency - 账户级串行队列开关:>0启用,=0使用全局配置
|
||||||
* @returns {Promise<{acquired: boolean, requestId: string, error?: string}>}
|
* @returns {Promise<{acquired: boolean, requestId: string, error?: string}>}
|
||||||
*/
|
*/
|
||||||
async acquireQueueLock(accountId, requestId = null, timeoutMs = null) {
|
async acquireQueueLock(accountId, requestId = null, timeoutMs = null, accountConfig = null) {
|
||||||
const cfg = await this.getConfig()
|
const cfg = await this.getConfig()
|
||||||
|
|
||||||
if (!cfg.enabled) {
|
// 账户级配置优先:maxConcurrency > 0 时强制启用,忽略全局开关
|
||||||
|
let queueEnabled = cfg.enabled
|
||||||
|
if (accountConfig && accountConfig.maxConcurrency > 0) {
|
||||||
|
queueEnabled = true
|
||||||
|
logger.debug(
|
||||||
|
`📬 User message queue: account-level queue enabled for account ${accountId} (maxConcurrency=${accountConfig.maxConcurrency})`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!queueEnabled) {
|
||||||
return { acquired: true, requestId: requestId || uuidv4(), skipped: true }
|
return { acquired: true, requestId: requestId || uuidv4(), skipped: true }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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
|
||||||
|
}
|
||||||
202
src/utils/warmupInterceptor.js
Normal file
202
src/utils/warmupInterceptor.js
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
'use strict'
|
||||||
|
|
||||||
|
const { v4: uuidv4 } = require('uuid')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 预热请求拦截器
|
||||||
|
* 检测并拦截低价值请求(标题生成、Warmup等),直接返回模拟响应
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检测是否为预热请求
|
||||||
|
* @param {Object} body - 请求体
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
function isWarmupRequest(body) {
|
||||||
|
if (!body) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查 messages
|
||||||
|
if (body.messages && Array.isArray(body.messages)) {
|
||||||
|
for (const msg of body.messages) {
|
||||||
|
// 处理 content 为数组的情况
|
||||||
|
if (Array.isArray(msg.content)) {
|
||||||
|
for (const content of msg.content) {
|
||||||
|
if (content.type === 'text' && typeof content.text === 'string') {
|
||||||
|
if (isTitleOrWarmupText(content.text)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 处理 content 为字符串的情况
|
||||||
|
if (typeof msg.content === 'string') {
|
||||||
|
if (isTitleOrWarmupText(msg.content)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查 system prompt
|
||||||
|
if (body.system) {
|
||||||
|
const systemText = extractSystemText(body.system)
|
||||||
|
if (isTitleExtractionSystemPrompt(systemText)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查文本是否为标题生成或Warmup请求
|
||||||
|
*/
|
||||||
|
function isTitleOrWarmupText(text) {
|
||||||
|
if (!text) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
text.includes('Please write a 5-10 word title for the following conversation:') ||
|
||||||
|
text === 'Warmup'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查system prompt是否为标题提取类型
|
||||||
|
*/
|
||||||
|
function isTitleExtractionSystemPrompt(systemText) {
|
||||||
|
if (!systemText) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return systemText.includes(
|
||||||
|
'nalyze if this message indicates a new conversation topic. If it does, extract a 2-3 word title'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从system字段提取文本
|
||||||
|
*/
|
||||||
|
function extractSystemText(system) {
|
||||||
|
if (typeof system === 'string') {
|
||||||
|
return system
|
||||||
|
}
|
||||||
|
if (Array.isArray(system)) {
|
||||||
|
return system.map((s) => (typeof s === 'object' ? s.text || '' : String(s))).join('')
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成模拟的非流式响应
|
||||||
|
* @param {string} model - 模型名称
|
||||||
|
* @returns {Object}
|
||||||
|
*/
|
||||||
|
function buildMockWarmupResponse(model) {
|
||||||
|
return {
|
||||||
|
id: `msg_warmup_${uuidv4().replace(/-/g, '').slice(0, 20)}`,
|
||||||
|
type: 'message',
|
||||||
|
role: 'assistant',
|
||||||
|
content: [{ type: 'text', text: 'New Conversation' }],
|
||||||
|
model: model || 'claude-3-5-sonnet-20241022',
|
||||||
|
stop_reason: 'end_turn',
|
||||||
|
stop_sequence: null,
|
||||||
|
usage: {
|
||||||
|
input_tokens: 10,
|
||||||
|
output_tokens: 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发送模拟的流式响应
|
||||||
|
* @param {Object} res - Express response对象
|
||||||
|
* @param {string} model - 模型名称
|
||||||
|
*/
|
||||||
|
function sendMockWarmupStream(res, model) {
|
||||||
|
const effectiveModel = model || 'claude-3-5-sonnet-20241022'
|
||||||
|
const messageId = `msg_warmup_${uuidv4().replace(/-/g, '').slice(0, 20)}`
|
||||||
|
|
||||||
|
const events = [
|
||||||
|
{
|
||||||
|
event: 'message_start',
|
||||||
|
data: {
|
||||||
|
message: {
|
||||||
|
content: [],
|
||||||
|
id: messageId,
|
||||||
|
model: effectiveModel,
|
||||||
|
role: 'assistant',
|
||||||
|
stop_reason: null,
|
||||||
|
stop_sequence: null,
|
||||||
|
type: 'message',
|
||||||
|
usage: { input_tokens: 10, output_tokens: 0 }
|
||||||
|
},
|
||||||
|
type: 'message_start'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
event: 'content_block_start',
|
||||||
|
data: {
|
||||||
|
content_block: { text: '', type: 'text' },
|
||||||
|
index: 0,
|
||||||
|
type: 'content_block_start'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
event: 'content_block_delta',
|
||||||
|
data: {
|
||||||
|
delta: { text: 'New', type: 'text_delta' },
|
||||||
|
index: 0,
|
||||||
|
type: 'content_block_delta'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
event: 'content_block_delta',
|
||||||
|
data: {
|
||||||
|
delta: { text: ' Conversation', type: 'text_delta' },
|
||||||
|
index: 0,
|
||||||
|
type: 'content_block_delta'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
event: 'content_block_stop',
|
||||||
|
data: { index: 0, type: 'content_block_stop' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
event: 'message_delta',
|
||||||
|
data: {
|
||||||
|
delta: { stop_reason: 'end_turn', stop_sequence: null },
|
||||||
|
type: 'message_delta',
|
||||||
|
usage: { input_tokens: 10, output_tokens: 2 }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
event: 'message_stop',
|
||||||
|
data: { type: 'message_stop' }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
let index = 0
|
||||||
|
const sendNext = () => {
|
||||||
|
if (index >= events.length) {
|
||||||
|
res.end()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const { event, data } = events[index]
|
||||||
|
res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`)
|
||||||
|
index++
|
||||||
|
|
||||||
|
// 模拟网络延迟
|
||||||
|
setTimeout(sendNext, 20)
|
||||||
|
}
|
||||||
|
|
||||||
|
sendNext()
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
isWarmupRequest,
|
||||||
|
buildMockWarmupResponse,
|
||||||
|
sendMockWarmupStream
|
||||||
|
}
|
||||||
218
tests/accountBalanceService.test.js
Normal file
218
tests/accountBalanceService.test.js
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
// Mock logger,避免测试输出污染控制台
|
||||||
|
jest.mock('../src/utils/logger', () => ({
|
||||||
|
debug: jest.fn(),
|
||||||
|
info: jest.fn(),
|
||||||
|
warn: jest.fn(),
|
||||||
|
error: jest.fn()
|
||||||
|
}))
|
||||||
|
|
||||||
|
const accountBalanceServiceModule = require('../src/services/accountBalanceService')
|
||||||
|
|
||||||
|
const { AccountBalanceService } = accountBalanceServiceModule
|
||||||
|
|
||||||
|
describe('AccountBalanceService', () => {
|
||||||
|
const originalBalanceScriptEnabled = process.env.BALANCE_SCRIPT_ENABLED
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (originalBalanceScriptEnabled === undefined) {
|
||||||
|
delete process.env.BALANCE_SCRIPT_ENABLED
|
||||||
|
} else {
|
||||||
|
process.env.BALANCE_SCRIPT_ENABLED = originalBalanceScriptEnabled
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const mockLogger = {
|
||||||
|
debug: jest.fn(),
|
||||||
|
info: jest.fn(),
|
||||||
|
warn: jest.fn(),
|
||||||
|
error: jest.fn()
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildMockRedis = () => ({
|
||||||
|
getLocalBalance: jest.fn().mockResolvedValue(null),
|
||||||
|
setLocalBalance: jest.fn().mockResolvedValue(undefined),
|
||||||
|
getAccountBalance: jest.fn().mockResolvedValue(null),
|
||||||
|
setAccountBalance: jest.fn().mockResolvedValue(undefined),
|
||||||
|
deleteAccountBalance: jest.fn().mockResolvedValue(undefined),
|
||||||
|
getBalanceScriptConfig: jest.fn().mockResolvedValue(null),
|
||||||
|
getAccountUsageStats: jest.fn().mockResolvedValue({
|
||||||
|
total: { requests: 10 },
|
||||||
|
daily: { requests: 2, cost: 20 },
|
||||||
|
monthly: { requests: 5 }
|
||||||
|
}),
|
||||||
|
getDateInTimezone: (date) => new Date(date.getTime() + 8 * 3600 * 1000)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should normalize platform aliases', () => {
|
||||||
|
const service = new AccountBalanceService({ redis: buildMockRedis(), logger: mockLogger })
|
||||||
|
expect(service.normalizePlatform('claude-official')).toBe('claude')
|
||||||
|
expect(service.normalizePlatform('azure-openai')).toBe('azure_openai')
|
||||||
|
expect(service.normalizePlatform('gemini-api')).toBe('gemini-api')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should build local quota/balance from dailyQuota and local dailyCost', async () => {
|
||||||
|
const mockRedis = buildMockRedis()
|
||||||
|
const service = new AccountBalanceService({ redis: mockRedis, logger: mockLogger })
|
||||||
|
|
||||||
|
service._computeMonthlyCost = jest.fn().mockResolvedValue(30)
|
||||||
|
service._computeTotalCost = jest.fn().mockResolvedValue(123.45)
|
||||||
|
|
||||||
|
const account = { id: 'acct-1', name: 'A', dailyQuota: '100', quotaResetTime: '00:00' }
|
||||||
|
const result = await service._getAccountBalanceForAccount(account, 'claude-console', {
|
||||||
|
queryApi: false,
|
||||||
|
useCache: true
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
expect(result.data.source).toBe('local')
|
||||||
|
expect(result.data.balance.amount).toBeCloseTo(80, 6)
|
||||||
|
expect(result.data.quota.percentage).toBeCloseTo(20, 6)
|
||||||
|
expect(result.data.statistics.totalCost).toBeCloseTo(123.45, 6)
|
||||||
|
expect(mockRedis.setLocalBalance).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should use cached balance when account has no dailyQuota', async () => {
|
||||||
|
const mockRedis = buildMockRedis()
|
||||||
|
mockRedis.getAccountBalance.mockResolvedValue({
|
||||||
|
status: 'success',
|
||||||
|
balance: 12.34,
|
||||||
|
currency: 'USD',
|
||||||
|
quota: null,
|
||||||
|
errorMessage: '',
|
||||||
|
lastRefreshAt: '2025-01-01T00:00:00Z',
|
||||||
|
ttlSeconds: 120
|
||||||
|
})
|
||||||
|
|
||||||
|
const service = new AccountBalanceService({ redis: mockRedis, logger: mockLogger })
|
||||||
|
service._computeMonthlyCost = jest.fn().mockResolvedValue(0)
|
||||||
|
service._computeTotalCost = jest.fn().mockResolvedValue(0)
|
||||||
|
|
||||||
|
const account = { id: 'acct-2', name: 'B' }
|
||||||
|
const result = await service._getAccountBalanceForAccount(account, 'openai', {
|
||||||
|
queryApi: false,
|
||||||
|
useCache: true
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.data.source).toBe('cache')
|
||||||
|
expect(result.data.balance.amount).toBeCloseTo(12.34, 6)
|
||||||
|
expect(result.data.lastRefreshAt).toBe('2025-01-01T00:00:00Z')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not cache provider errors and fallback to local when queryApi=true', async () => {
|
||||||
|
const mockRedis = buildMockRedis()
|
||||||
|
const service = new AccountBalanceService({ redis: mockRedis, logger: mockLogger })
|
||||||
|
|
||||||
|
service._computeMonthlyCost = jest.fn().mockResolvedValue(0)
|
||||||
|
service._computeTotalCost = jest.fn().mockResolvedValue(0)
|
||||||
|
|
||||||
|
service.registerProvider('openai', {
|
||||||
|
queryBalance: () => {
|
||||||
|
throw new Error('boom')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const account = { id: 'acct-3', name: 'C' }
|
||||||
|
const result = await service._getAccountBalanceForAccount(account, 'openai', {
|
||||||
|
queryApi: true,
|
||||||
|
useCache: false
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(mockRedis.setAccountBalance).not.toHaveBeenCalled()
|
||||||
|
expect(result.data.source).toBe('local')
|
||||||
|
expect(result.data.status).toBe('error')
|
||||||
|
expect(result.data.error).toBe('boom')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should ignore script config when balance script is disabled', async () => {
|
||||||
|
process.env.BALANCE_SCRIPT_ENABLED = 'false'
|
||||||
|
|
||||||
|
const mockRedis = buildMockRedis()
|
||||||
|
mockRedis.getBalanceScriptConfig.mockResolvedValue({
|
||||||
|
scriptBody: '({ request: { url: "http://example.com" }, extractor: function(){ return {} } })'
|
||||||
|
})
|
||||||
|
|
||||||
|
const service = new AccountBalanceService({ redis: mockRedis, logger: mockLogger })
|
||||||
|
service._computeMonthlyCost = jest.fn().mockResolvedValue(0)
|
||||||
|
service._computeTotalCost = jest.fn().mockResolvedValue(0)
|
||||||
|
|
||||||
|
const provider = { queryBalance: jest.fn().mockResolvedValue({ balance: 1, currency: 'USD' }) }
|
||||||
|
service.registerProvider('openai', provider)
|
||||||
|
|
||||||
|
const scriptSpy = jest.spyOn(service, '_getBalanceFromScript')
|
||||||
|
|
||||||
|
const account = { id: 'acct-script-off', name: 'S' }
|
||||||
|
const result = await service._getAccountBalanceForAccount(account, 'openai', {
|
||||||
|
queryApi: true,
|
||||||
|
useCache: false
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(provider.queryBalance).toHaveBeenCalled()
|
||||||
|
expect(scriptSpy).not.toHaveBeenCalled()
|
||||||
|
expect(result.data.source).toBe('api')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should prefer script when configured and enabled', async () => {
|
||||||
|
process.env.BALANCE_SCRIPT_ENABLED = 'true'
|
||||||
|
|
||||||
|
const mockRedis = buildMockRedis()
|
||||||
|
mockRedis.getBalanceScriptConfig.mockResolvedValue({
|
||||||
|
scriptBody: '({ request: { url: "http://example.com" }, extractor: function(){ return {} } })'
|
||||||
|
})
|
||||||
|
|
||||||
|
const service = new AccountBalanceService({ redis: mockRedis, logger: mockLogger })
|
||||||
|
service._computeMonthlyCost = jest.fn().mockResolvedValue(0)
|
||||||
|
service._computeTotalCost = jest.fn().mockResolvedValue(0)
|
||||||
|
|
||||||
|
const provider = { queryBalance: jest.fn().mockResolvedValue({ balance: 2, currency: 'USD' }) }
|
||||||
|
service.registerProvider('openai', provider)
|
||||||
|
|
||||||
|
jest.spyOn(service, '_getBalanceFromScript').mockResolvedValue({
|
||||||
|
status: 'success',
|
||||||
|
balance: 3,
|
||||||
|
currency: 'USD',
|
||||||
|
quota: null,
|
||||||
|
queryMethod: 'script',
|
||||||
|
rawData: { ok: true },
|
||||||
|
lastRefreshAt: '2025-01-01T00:00:00Z',
|
||||||
|
errorMessage: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const account = { id: 'acct-script-on', name: 'T' }
|
||||||
|
const result = await service._getAccountBalanceForAccount(account, 'openai', {
|
||||||
|
queryApi: true,
|
||||||
|
useCache: false
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(provider.queryBalance).not.toHaveBeenCalled()
|
||||||
|
expect(result.data.source).toBe('api')
|
||||||
|
expect(result.data.balance.amount).toBeCloseTo(3, 6)
|
||||||
|
expect(result.data.lastRefreshAt).toBe('2025-01-01T00:00:00Z')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should count low balance once per account in summary', async () => {
|
||||||
|
const mockRedis = buildMockRedis()
|
||||||
|
const service = new AccountBalanceService({ redis: mockRedis, logger: mockLogger })
|
||||||
|
|
||||||
|
service.getSupportedPlatforms = () => ['claude-console']
|
||||||
|
service.getAllAccountsByPlatform = async () => [{ id: 'acct-4', name: 'D' }]
|
||||||
|
service._getAccountBalanceForAccount = async () => ({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
accountId: 'acct-4',
|
||||||
|
platform: 'claude-console',
|
||||||
|
balance: { amount: 5, currency: 'USD', formattedAmount: '$5.00' },
|
||||||
|
quota: { percentage: 95 },
|
||||||
|
statistics: { totalCost: 1 },
|
||||||
|
source: 'local',
|
||||||
|
lastRefreshAt: '2025-01-01T00:00:00Z',
|
||||||
|
cacheExpiresAt: null,
|
||||||
|
status: 'success',
|
||||||
|
error: null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const summary = await service.getBalanceSummary()
|
||||||
|
expect(summary.lowBalanceCount).toBe(1)
|
||||||
|
expect(summary.platforms['claude-console'].lowBalanceCount).toBe(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,302 @@
|
|||||||
|
<template>
|
||||||
|
<el-dialog
|
||||||
|
:append-to-body="true"
|
||||||
|
class="balance-script-dialog"
|
||||||
|
:close-on-click-modal="false"
|
||||||
|
:destroy-on-close="true"
|
||||||
|
:model-value="show"
|
||||||
|
:title="`配置余额脚本 - ${account?.name || ''}`"
|
||||||
|
top="5vh"
|
||||||
|
width="720px"
|
||||||
|
@close="emitClose"
|
||||||
|
>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="grid gap-3 md:grid-cols-2">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<label class="text-sm font-medium text-gray-700 dark:text-gray-200">API Key</label>
|
||||||
|
<input v-model="form.apiKey" class="input-text" placeholder="access token / key" />
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<label class="text-sm font-medium text-gray-700 dark:text-gray-200"
|
||||||
|
>请求地址(baseUrl)</label
|
||||||
|
>
|
||||||
|
<input v-model="form.baseUrl" class="input-text" placeholder="https://api.example.com" />
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<label class="text-sm font-medium text-gray-700 dark:text-gray-200">Token(可选)</label>
|
||||||
|
<input v-model="form.token" class="input-text" placeholder="Bearer token" />
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<label class="text-sm font-medium text-gray-700 dark:text-gray-200"
|
||||||
|
>额外参数 (extra / userId)</label
|
||||||
|
>
|
||||||
|
<input v-model="form.extra" class="input-text" placeholder="用户ID等" />
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<label class="text-sm font-medium text-gray-700 dark:text-gray-200">超时时间(秒)</label>
|
||||||
|
<input v-model.number="form.timeoutSeconds" class="input-text" min="1" type="number" />
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<label class="text-sm font-medium text-gray-700 dark:text-gray-200"
|
||||||
|
>自动查询间隔(分钟)</label
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
v-model.number="form.autoIntervalMinutes"
|
||||||
|
class="input-text"
|
||||||
|
min="0"
|
||||||
|
type="number"
|
||||||
|
/>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400">0 表示仅手动刷新</p>
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-gray-500 dark:text-gray-400 md:col-span-2">
|
||||||
|
可用变量:{{ '{' }}{{ '{' }}baseUrl{{ '}' }}{{ '}' }}、{{ '{' }}{{ '{' }}apiKey{{ '}'
|
||||||
|
}}{{ '}' }}、{{ '{' }}{{ '{' }}token{{ '}' }}{{ '}' }}、{{ '{' }}{{ '{' }}accountId{{ '}'
|
||||||
|
}}{{ '}' }}、{{ '{' }}{{ '{' }}platform{{ '}' }}{{ '}' }}、{{ '{' }}{{ '{' }}extra{{ '}'
|
||||||
|
}}{{ '}' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div class="mb-2 flex items-center justify-between">
|
||||||
|
<div class="text-sm font-semibold text-gray-800 dark:text-gray-100">提取器代码</div>
|
||||||
|
<button
|
||||||
|
class="rounded bg-gray-200 px-2 py-1 text-xs dark:bg-gray-700"
|
||||||
|
@click="applyPreset"
|
||||||
|
>
|
||||||
|
使用示例
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
v-model="form.scriptBody"
|
||||||
|
class="min-h-[260px] w-full rounded-xl bg-gray-900 font-mono text-sm text-gray-100 shadow-inner focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||||
|
spellcheck="false"
|
||||||
|
></textarea>
|
||||||
|
<div class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
extractor 可返回:isValid、invalidMessage、remaining、unit、planName、total、used、extra
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="testResult" class="rounded-lg bg-gray-50 p-3 text-sm dark:bg-gray-800/60">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="font-semibold">测试结果</span>
|
||||||
|
<span
|
||||||
|
:class="[
|
||||||
|
'rounded px-2 py-0.5 text-xs',
|
||||||
|
testResult.mapped?.status === 'success'
|
||||||
|
? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-200'
|
||||||
|
: 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-200'
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
{{ testResult.mapped?.status || 'unknown' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="mt-2 text-xs text-gray-600 dark:text-gray-300">
|
||||||
|
<div>余额: {{ displayAmount(testResult.mapped?.balance) }}</div>
|
||||||
|
<div>单位: {{ testResult.mapped?.currency || '—' }}</div>
|
||||||
|
<div v-if="testResult.mapped?.planName">套餐: {{ testResult.mapped.planName }}</div>
|
||||||
|
<div v-if="testResult.mapped?.errorMessage" class="text-red-500">
|
||||||
|
错误: {{ testResult.mapped.errorMessage }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<details class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
<summary class="cursor-pointer">查看 extractor 输出</summary>
|
||||||
|
<pre class="mt-1 whitespace-pre-wrap break-all">{{
|
||||||
|
formatJson(testResult.extracted)
|
||||||
|
}}</pre>
|
||||||
|
</details>
|
||||||
|
<details class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
<summary class="cursor-pointer">查看原始响应</summary>
|
||||||
|
<pre class="mt-1 whitespace-pre-wrap break-all">{{
|
||||||
|
formatJson(testResult.response)
|
||||||
|
}}</pre>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<el-button :loading="testing" @click="testScript">测试脚本</el-button>
|
||||||
|
<el-button :loading="saving" type="primary" @click="saveConfig">保存配置</el-button>
|
||||||
|
<el-button @click="emitClose">取消</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { reactive, ref, watch } from 'vue'
|
||||||
|
import { apiClient } from '@/config/api'
|
||||||
|
import { showToast } from '@/utils/toast'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
show: { type: Boolean, default: false },
|
||||||
|
account: { type: Object, default: () => ({}) }
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['close', 'saved'])
|
||||||
|
|
||||||
|
const saving = ref(false)
|
||||||
|
const testing = ref(false)
|
||||||
|
const testResult = ref(null)
|
||||||
|
|
||||||
|
const presetScript = `({
|
||||||
|
request: {
|
||||||
|
url: "{{baseUrl}}/api/user/self",
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Authorization": "Bearer {{apiKey}}",
|
||||||
|
"New-Api-User": "{{extra}}"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
extractor: function (response) {
|
||||||
|
if (response && response.success && response.data) {
|
||||||
|
const quota = response.data.quota || 0;
|
||||||
|
const used = response.data.used_quota || 0;
|
||||||
|
return {
|
||||||
|
planName: response.data.group || "默认套餐",
|
||||||
|
remaining: quota / 500000,
|
||||||
|
used: used / 500000,
|
||||||
|
total: (quota + used) / 500000,
|
||||||
|
unit: "USD"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
isValid: false,
|
||||||
|
invalidMessage: (response && response.message) || "查询失败"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
})`
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
baseUrl: '',
|
||||||
|
apiKey: '',
|
||||||
|
token: '',
|
||||||
|
extra: '',
|
||||||
|
timeoutSeconds: 10,
|
||||||
|
autoIntervalMinutes: 0,
|
||||||
|
scriptBody: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const buildDefaultForm = () => ({
|
||||||
|
baseUrl: '',
|
||||||
|
apiKey: '',
|
||||||
|
token: '',
|
||||||
|
extra: '',
|
||||||
|
timeoutSeconds: 10,
|
||||||
|
autoIntervalMinutes: 0,
|
||||||
|
// 默认给出示例脚本,字段保持清空,避免“上一个账户的配置污染当前账户”
|
||||||
|
scriptBody: presetScript
|
||||||
|
})
|
||||||
|
|
||||||
|
const emitClose = () => emit('close')
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
Object.assign(form, buildDefaultForm())
|
||||||
|
testResult.value = null
|
||||||
|
saving.value = false
|
||||||
|
testing.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadConfig = async () => {
|
||||||
|
if (!props.account?.id || !props.account?.platform) return
|
||||||
|
try {
|
||||||
|
const res = await apiClient.get(
|
||||||
|
`/admin/accounts/${props.account.id}/balance/script?platform=${props.account.platform}`
|
||||||
|
)
|
||||||
|
if (res?.success && res.data) {
|
||||||
|
Object.assign(form, res.data)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showToast('加载脚本配置失败', 'error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveConfig = async () => {
|
||||||
|
if (!props.account?.id || !props.account?.platform) return
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
await apiClient.put(
|
||||||
|
`/admin/accounts/${props.account.id}/balance/script?platform=${props.account.platform}`,
|
||||||
|
{ ...form }
|
||||||
|
)
|
||||||
|
showToast('已保存', 'success')
|
||||||
|
emit('saved')
|
||||||
|
} catch (error) {
|
||||||
|
showToast(error.message || '保存失败', 'error')
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const testScript = async () => {
|
||||||
|
if (!props.account?.id || !props.account?.platform) return
|
||||||
|
testing.value = true
|
||||||
|
testResult.value = null
|
||||||
|
try {
|
||||||
|
const res = await apiClient.post(
|
||||||
|
`/admin/accounts/${props.account.id}/balance/script/test?platform=${props.account.platform}`,
|
||||||
|
{ ...form }
|
||||||
|
)
|
||||||
|
if (res?.success) {
|
||||||
|
testResult.value = res.data
|
||||||
|
showToast('测试完成', 'success')
|
||||||
|
} else {
|
||||||
|
showToast(res?.error || '测试失败', 'error')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showToast(error.message || '测试失败', 'error')
|
||||||
|
} finally {
|
||||||
|
testing.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const applyPreset = () => {
|
||||||
|
form.scriptBody = presetScript
|
||||||
|
}
|
||||||
|
|
||||||
|
const displayAmount = (val) => {
|
||||||
|
if (val === null || val === undefined || Number.isNaN(Number(val))) return '—'
|
||||||
|
return Number(val).toFixed(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatJson = (data) => {
|
||||||
|
try {
|
||||||
|
return JSON.stringify(data, null, 2)
|
||||||
|
} catch (error) {
|
||||||
|
return String(data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.show,
|
||||||
|
(val) => {
|
||||||
|
if (val) {
|
||||||
|
resetForm()
|
||||||
|
loadConfig()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
:deep(.balance-script-dialog) {
|
||||||
|
max-height: 90vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.balance-script-dialog .el-dialog__body) {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.balance-script-dialog .el-dialog__footer) {
|
||||||
|
border-top: 1px solid rgba(229, 231, 235, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-text {
|
||||||
|
@apply w-full rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-800 shadow-sm transition focus:border-indigo-400 focus:outline-none focus:ring-2 focus:ring-indigo-200 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100 dark:focus:border-indigo-500 dark:focus:ring-indigo-600;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1662,6 +1662,47 @@
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Claude 账户级串行队列开关 -->
|
||||||
|
<div v-if="form.platform === 'claude'" class="mt-4">
|
||||||
|
<label class="flex items-start">
|
||||||
|
<input
|
||||||
|
v-model="form.serialQueueEnabled"
|
||||||
|
class="mt-1 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
|
||||||
|
type="checkbox"
|
||||||
|
/>
|
||||||
|
<div class="ml-3">
|
||||||
|
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
启用账户级串行队列
|
||||||
|
</span>
|
||||||
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
开启后强制该账户的用户消息串行处理,忽略全局串行队列设置。适用于并发限制较低的账户。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 拦截预热请求开关(Claude 和 Claude Console) -->
|
||||||
|
<div
|
||||||
|
v-if="form.platform === 'claude' || form.platform === 'claude-console'"
|
||||||
|
class="mt-4"
|
||||||
|
>
|
||||||
|
<label class="flex items-start">
|
||||||
|
<input
|
||||||
|
v-model="form.interceptWarmup"
|
||||||
|
class="mt-1 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
|
||||||
|
type="checkbox"
|
||||||
|
/>
|
||||||
|
<div class="ml-3">
|
||||||
|
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
拦截预热请求
|
||||||
|
</span>
|
||||||
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
启用后,对标题生成、Warmup 等低价值请求直接返回模拟响应,不消耗上游 API 额度
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Claude User-Agent 版本配置 -->
|
<!-- Claude User-Agent 版本配置 -->
|
||||||
<div v-if="form.platform === 'claude'" class="mt-4">
|
<div v-if="form.platform === 'claude'" class="mt-4">
|
||||||
<label class="flex items-start">
|
<label class="flex items-start">
|
||||||
@@ -2647,6 +2688,44 @@
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Claude 账户级串行队列开关(编辑模式) -->
|
||||||
|
<div v-if="form.platform === 'claude'" class="mt-4">
|
||||||
|
<label class="flex items-start">
|
||||||
|
<input
|
||||||
|
v-model="form.serialQueueEnabled"
|
||||||
|
class="mt-1 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
|
||||||
|
type="checkbox"
|
||||||
|
/>
|
||||||
|
<div class="ml-3">
|
||||||
|
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
启用账户级串行队列
|
||||||
|
</span>
|
||||||
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
开启后强制该账户的用户消息串行处理,忽略全局串行队列设置。适用于并发限制较低的账户。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 拦截预热请求开关(Claude 和 Claude Console 编辑模式) -->
|
||||||
|
<div v-if="form.platform === 'claude' || form.platform === 'claude-console'" class="mt-4">
|
||||||
|
<label class="flex items-start">
|
||||||
|
<input
|
||||||
|
v-model="form.interceptWarmup"
|
||||||
|
class="mt-1 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
|
||||||
|
type="checkbox"
|
||||||
|
/>
|
||||||
|
<div class="ml-3">
|
||||||
|
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
拦截预热请求
|
||||||
|
</span>
|
||||||
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
启用后,对标题生成、Warmup 等低价值请求直接返回模拟响应,不消耗上游 API 额度
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Claude User-Agent 版本配置(编辑模式) -->
|
<!-- Claude User-Agent 版本配置(编辑模式) -->
|
||||||
<div v-if="form.platform === 'claude'" class="mt-4">
|
<div v-if="form.platform === 'claude'" class="mt-4">
|
||||||
<label class="flex items-start">
|
<label class="flex items-start">
|
||||||
@@ -3982,6 +4061,9 @@ const form = ref({
|
|||||||
useUnifiedUserAgent: props.account?.useUnifiedUserAgent || false, // 使用统一Claude Code版本
|
useUnifiedUserAgent: props.account?.useUnifiedUserAgent || false, // 使用统一Claude Code版本
|
||||||
useUnifiedClientId: props.account?.useUnifiedClientId || false, // 使用统一的客户端标识
|
useUnifiedClientId: props.account?.useUnifiedClientId || false, // 使用统一的客户端标识
|
||||||
unifiedClientId: props.account?.unifiedClientId || '', // 统一的客户端标识
|
unifiedClientId: props.account?.unifiedClientId || '', // 统一的客户端标识
|
||||||
|
serialQueueEnabled: (props.account?.maxConcurrency || 0) > 0, // 账户级串行队列开关
|
||||||
|
interceptWarmup:
|
||||||
|
props.account?.interceptWarmup === true || props.account?.interceptWarmup === 'true', // 拦截预热请求
|
||||||
groupId: '',
|
groupId: '',
|
||||||
groupIds: [],
|
groupIds: [],
|
||||||
projectId: props.account?.projectId || '',
|
projectId: props.account?.projectId || '',
|
||||||
@@ -4572,9 +4654,11 @@ const buildClaudeAccountData = (tokenInfo, accountName, clientId) => {
|
|||||||
claudeAiOauth: claudeOauthPayload,
|
claudeAiOauth: claudeOauthPayload,
|
||||||
priority: form.value.priority || 50,
|
priority: form.value.priority || 50,
|
||||||
autoStopOnWarning: form.value.autoStopOnWarning || false,
|
autoStopOnWarning: form.value.autoStopOnWarning || false,
|
||||||
|
interceptWarmup: form.value.interceptWarmup || false,
|
||||||
useUnifiedUserAgent: form.value.useUnifiedUserAgent || false,
|
useUnifiedUserAgent: form.value.useUnifiedUserAgent || false,
|
||||||
useUnifiedClientId: form.value.useUnifiedClientId || false,
|
useUnifiedClientId: form.value.useUnifiedClientId || false,
|
||||||
unifiedClientId: clientId,
|
unifiedClientId: clientId,
|
||||||
|
maxConcurrency: form.value.serialQueueEnabled ? 1 : 0,
|
||||||
subscriptionInfo: {
|
subscriptionInfo: {
|
||||||
accountType: form.value.subscriptionType || 'claude_max',
|
accountType: form.value.subscriptionType || 'claude_max',
|
||||||
hasClaudeMax: form.value.subscriptionType === 'claude_max',
|
hasClaudeMax: form.value.subscriptionType === 'claude_max',
|
||||||
@@ -4712,6 +4796,7 @@ const handleOAuthSuccess = async (tokenInfoOrList) => {
|
|||||||
data.useUnifiedUserAgent = form.value.useUnifiedUserAgent || false
|
data.useUnifiedUserAgent = form.value.useUnifiedUserAgent || false
|
||||||
data.useUnifiedClientId = form.value.useUnifiedClientId || false
|
data.useUnifiedClientId = form.value.useUnifiedClientId || false
|
||||||
data.unifiedClientId = form.value.unifiedClientId || ''
|
data.unifiedClientId = form.value.unifiedClientId || ''
|
||||||
|
data.maxConcurrency = form.value.serialQueueEnabled ? 1 : 0
|
||||||
// 添加订阅类型信息
|
// 添加订阅类型信息
|
||||||
data.subscriptionInfo = {
|
data.subscriptionInfo = {
|
||||||
accountType: form.value.subscriptionType || 'claude_max',
|
accountType: form.value.subscriptionType || 'claude_max',
|
||||||
@@ -5040,6 +5125,7 @@ const createAccount = async () => {
|
|||||||
data.useUnifiedUserAgent = form.value.useUnifiedUserAgent || false
|
data.useUnifiedUserAgent = form.value.useUnifiedUserAgent || false
|
||||||
data.useUnifiedClientId = form.value.useUnifiedClientId || false
|
data.useUnifiedClientId = form.value.useUnifiedClientId || false
|
||||||
data.unifiedClientId = form.value.unifiedClientId || ''
|
data.unifiedClientId = form.value.unifiedClientId || ''
|
||||||
|
data.maxConcurrency = form.value.serialQueueEnabled ? 1 : 0
|
||||||
// 添加订阅类型信息
|
// 添加订阅类型信息
|
||||||
data.subscriptionInfo = {
|
data.subscriptionInfo = {
|
||||||
accountType: form.value.subscriptionType || 'claude_max',
|
accountType: form.value.subscriptionType || 'claude_max',
|
||||||
@@ -5131,6 +5217,7 @@ const createAccount = async () => {
|
|||||||
// 上游错误处理(仅 Claude Console)
|
// 上游错误处理(仅 Claude Console)
|
||||||
if (form.value.platform === 'claude-console') {
|
if (form.value.platform === 'claude-console') {
|
||||||
data.disableAutoProtection = !!form.value.disableAutoProtection
|
data.disableAutoProtection = !!form.value.disableAutoProtection
|
||||||
|
data.interceptWarmup = !!form.value.interceptWarmup
|
||||||
}
|
}
|
||||||
// 额度管理字段
|
// 额度管理字段
|
||||||
data.dailyQuota = form.value.dailyQuota || 0
|
data.dailyQuota = form.value.dailyQuota || 0
|
||||||
@@ -5431,9 +5518,11 @@ const updateAccount = async () => {
|
|||||||
|
|
||||||
data.priority = form.value.priority || 50
|
data.priority = form.value.priority || 50
|
||||||
data.autoStopOnWarning = form.value.autoStopOnWarning || false
|
data.autoStopOnWarning = form.value.autoStopOnWarning || false
|
||||||
|
data.interceptWarmup = form.value.interceptWarmup || false
|
||||||
data.useUnifiedUserAgent = form.value.useUnifiedUserAgent || false
|
data.useUnifiedUserAgent = form.value.useUnifiedUserAgent || false
|
||||||
data.useUnifiedClientId = form.value.useUnifiedClientId || false
|
data.useUnifiedClientId = form.value.useUnifiedClientId || false
|
||||||
data.unifiedClientId = form.value.unifiedClientId || ''
|
data.unifiedClientId = form.value.unifiedClientId || ''
|
||||||
|
data.maxConcurrency = form.value.serialQueueEnabled ? 1 : 0
|
||||||
// 更新订阅类型信息
|
// 更新订阅类型信息
|
||||||
data.subscriptionInfo = {
|
data.subscriptionInfo = {
|
||||||
accountType: form.value.subscriptionType || 'claude_max',
|
accountType: form.value.subscriptionType || 'claude_max',
|
||||||
@@ -5466,6 +5555,8 @@ const updateAccount = async () => {
|
|||||||
data.rateLimitDuration = form.value.enableRateLimit ? form.value.rateLimitDuration || 60 : 0
|
data.rateLimitDuration = form.value.enableRateLimit ? form.value.rateLimitDuration || 60 : 0
|
||||||
// 上游错误处理
|
// 上游错误处理
|
||||||
data.disableAutoProtection = !!form.value.disableAutoProtection
|
data.disableAutoProtection = !!form.value.disableAutoProtection
|
||||||
|
// 拦截预热请求
|
||||||
|
data.interceptWarmup = !!form.value.interceptWarmup
|
||||||
// 额度管理字段
|
// 额度管理字段
|
||||||
data.dailyQuota = form.value.dailyQuota || 0
|
data.dailyQuota = form.value.dailyQuota || 0
|
||||||
data.quotaResetTime = form.value.quotaResetTime || '00:00'
|
data.quotaResetTime = form.value.quotaResetTime || '00:00'
|
||||||
@@ -6034,9 +6125,12 @@ watch(
|
|||||||
accountType: newAccount.accountType || 'shared',
|
accountType: newAccount.accountType || 'shared',
|
||||||
subscriptionType: subscriptionType,
|
subscriptionType: subscriptionType,
|
||||||
autoStopOnWarning: newAccount.autoStopOnWarning || false,
|
autoStopOnWarning: newAccount.autoStopOnWarning || false,
|
||||||
|
interceptWarmup:
|
||||||
|
newAccount.interceptWarmup === true || newAccount.interceptWarmup === 'true',
|
||||||
useUnifiedUserAgent: newAccount.useUnifiedUserAgent || false,
|
useUnifiedUserAgent: newAccount.useUnifiedUserAgent || false,
|
||||||
useUnifiedClientId: newAccount.useUnifiedClientId || false,
|
useUnifiedClientId: newAccount.useUnifiedClientId || false,
|
||||||
unifiedClientId: newAccount.unifiedClientId || '',
|
unifiedClientId: newAccount.unifiedClientId || '',
|
||||||
|
serialQueueEnabled: (newAccount.maxConcurrency || 0) > 0,
|
||||||
groupId: groupId,
|
groupId: groupId,
|
||||||
groupIds: [],
|
groupIds: [],
|
||||||
projectId: newAccount.projectId || '',
|
projectId: newAccount.projectId || '',
|
||||||
|
|||||||
@@ -0,0 +1,402 @@
|
|||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<div
|
||||||
|
v-if="show"
|
||||||
|
class="fixed inset-0 z-[1050] flex items-center justify-center bg-gray-900/40 backdrop-blur-sm"
|
||||||
|
>
|
||||||
|
<div class="absolute inset-0" @click="handleClose" />
|
||||||
|
<div
|
||||||
|
class="relative z-10 mx-3 flex w-full max-w-lg flex-col overflow-hidden rounded-2xl border border-gray-200/70 bg-white/95 shadow-2xl ring-1 ring-black/5 transition-all dark:border-gray-700/60 dark:bg-gray-900/95 dark:ring-white/10 sm:mx-4"
|
||||||
|
>
|
||||||
|
<!-- 顶部栏 -->
|
||||||
|
<div
|
||||||
|
class="flex items-center justify-between border-b border-gray-100 bg-white/80 px-5 py-4 backdrop-blur dark:border-gray-800 dark:bg-gray-900/80"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div
|
||||||
|
class="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-amber-500 to-orange-500 text-white shadow-lg"
|
||||||
|
>
|
||||||
|
<i class="fas fa-clock" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100">定时测试配置</h3>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{{ account?.name || '未知账户' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="flex h-9 w-9 items-center justify-center rounded-full bg-gray-100 text-gray-500 transition hover:bg-gray-200 hover:text-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-gray-200"
|
||||||
|
:disabled="saving"
|
||||||
|
@click="handleClose"
|
||||||
|
>
|
||||||
|
<i class="fas fa-times text-sm" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 内容区域 -->
|
||||||
|
<div class="px-5 py-4">
|
||||||
|
<!-- 加载状态 -->
|
||||||
|
<div v-if="loading" class="flex items-center justify-center py-8">
|
||||||
|
<i class="fas fa-spinner fa-spin mr-2 text-blue-500" />
|
||||||
|
<span class="text-gray-500 dark:text-gray-400">加载配置中...</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<!-- 启用开关 -->
|
||||||
|
<div class="mb-5 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="font-medium text-gray-700 dark:text-gray-300">启用定时测试</p>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400">按计划自动测试账户连通性</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
:class="[
|
||||||
|
'relative h-6 w-11 rounded-full transition-colors duration-200',
|
||||||
|
config.enabled ? 'bg-green-500' : 'bg-gray-300 dark:bg-gray-600'
|
||||||
|
]"
|
||||||
|
@click="config.enabled = !config.enabled"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
:class="[
|
||||||
|
'absolute top-0.5 h-5 w-5 rounded-full bg-white shadow-md transition-transform duration-200',
|
||||||
|
config.enabled ? 'left-5' : 'left-0.5'
|
||||||
|
]"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Cron 表达式配置 -->
|
||||||
|
<div class="mb-5">
|
||||||
|
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
Cron 表达式
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-model="config.cronExpression"
|
||||||
|
class="w-full rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-700 placeholder-gray-400 transition focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-300 dark:placeholder-gray-500"
|
||||||
|
:disabled="!config.enabled"
|
||||||
|
placeholder="0 8 * * *"
|
||||||
|
type="text"
|
||||||
|
/>
|
||||||
|
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
格式: 分 时 日 月 周 (例: "0 8 * * *" = 每天8:00)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 快捷选项 -->
|
||||||
|
<div class="mb-5">
|
||||||
|
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
快捷设置
|
||||||
|
</label>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
v-for="preset in cronPresets"
|
||||||
|
:key="preset.value"
|
||||||
|
:class="[
|
||||||
|
'rounded-lg border px-3 py-1.5 text-xs font-medium transition',
|
||||||
|
config.cronExpression === preset.value
|
||||||
|
? 'border-blue-500 bg-blue-50 text-blue-700 dark:border-blue-400 dark:bg-blue-900/30 dark:text-blue-300'
|
||||||
|
: 'border-gray-200 bg-gray-50 text-gray-600 hover:bg-gray-100 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700',
|
||||||
|
!config.enabled && 'cursor-not-allowed opacity-50'
|
||||||
|
]"
|
||||||
|
:disabled="!config.enabled"
|
||||||
|
@click="config.cronExpression = preset.value"
|
||||||
|
>
|
||||||
|
{{ preset.label }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 测试模型选择 -->
|
||||||
|
<div class="mb-5">
|
||||||
|
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
测试模型
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-model="config.model"
|
||||||
|
class="w-full rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-700 placeholder-gray-400 transition focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-300 dark:placeholder-gray-500"
|
||||||
|
:disabled="!config.enabled"
|
||||||
|
placeholder="claude-sonnet-4-5-20250929"
|
||||||
|
type="text"
|
||||||
|
/>
|
||||||
|
<div class="mt-2 flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
v-for="modelOption in modelOptions"
|
||||||
|
:key="modelOption.value"
|
||||||
|
:class="[
|
||||||
|
'rounded-lg border px-3 py-1.5 text-xs font-medium transition',
|
||||||
|
config.model === modelOption.value
|
||||||
|
? 'border-blue-500 bg-blue-50 text-blue-700 dark:border-blue-400 dark:bg-blue-900/30 dark:text-blue-300'
|
||||||
|
: 'border-gray-200 bg-gray-50 text-gray-600 hover:bg-gray-100 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700',
|
||||||
|
!config.enabled && 'cursor-not-allowed opacity-50'
|
||||||
|
]"
|
||||||
|
:disabled="!config.enabled"
|
||||||
|
@click="config.model = modelOption.value"
|
||||||
|
>
|
||||||
|
{{ modelOption.label }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 测试历史 -->
|
||||||
|
<div v-if="testHistory.length > 0" class="mb-4">
|
||||||
|
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
最近测试记录
|
||||||
|
</label>
|
||||||
|
<div
|
||||||
|
class="max-h-40 space-y-2 overflow-y-auto rounded-lg border border-gray-200 bg-gray-50 p-3 dark:border-gray-700 dark:bg-gray-800/50"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="(record, index) in testHistory"
|
||||||
|
:key="index"
|
||||||
|
class="flex items-center justify-between text-xs"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<i
|
||||||
|
:class="[
|
||||||
|
'fas',
|
||||||
|
record.success
|
||||||
|
? 'fa-check-circle text-green-500'
|
||||||
|
: 'fa-times-circle text-red-500'
|
||||||
|
]"
|
||||||
|
/>
|
||||||
|
<span class="text-gray-600 dark:text-gray-400">
|
||||||
|
{{ formatTimestamp(record.timestamp) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span v-if="record.latencyMs" class="text-gray-500 dark:text-gray-500">
|
||||||
|
{{ record.latencyMs }}ms
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-else-if="record.error"
|
||||||
|
class="max-w-[150px] truncate text-red-500"
|
||||||
|
:title="record.error"
|
||||||
|
>
|
||||||
|
{{ record.error }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 无历史记录 -->
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="mb-4 rounded-lg border border-gray-200 bg-gray-50 p-4 text-center text-sm text-gray-500 dark:border-gray-700 dark:bg-gray-800/50 dark:text-gray-400"
|
||||||
|
>
|
||||||
|
<i class="fas fa-history mb-2 text-2xl text-gray-300 dark:text-gray-600" />
|
||||||
|
<p>暂无测试记录</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 底部操作栏 -->
|
||||||
|
<div
|
||||||
|
class="flex items-center justify-end gap-3 border-t border-gray-100 bg-gray-50/80 px-5 py-3 dark:border-gray-800 dark:bg-gray-900/50"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm transition hover:bg-gray-50 hover:shadow dark:border-gray-700 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
|
||||||
|
:disabled="saving"
|
||||||
|
@click="handleClose"
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
:class="[
|
||||||
|
'flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium shadow-sm transition',
|
||||||
|
saving
|
||||||
|
? 'cursor-not-allowed bg-gray-200 text-gray-400 dark:bg-gray-700 dark:text-gray-500'
|
||||||
|
: 'bg-gradient-to-r from-blue-500 to-indigo-500 text-white hover:from-blue-600 hover:to-indigo-600 hover:shadow-md'
|
||||||
|
]"
|
||||||
|
:disabled="saving || loading"
|
||||||
|
@click="saveConfig"
|
||||||
|
>
|
||||||
|
<i :class="['fas', saving ? 'fa-spinner fa-spin' : 'fa-save']" />
|
||||||
|
{{ saving ? '保存中...' : '保存配置' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, watch } from 'vue'
|
||||||
|
import { API_PREFIX } from '@/config/api'
|
||||||
|
import { showToast } from '@/utils/toast'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
show: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
account: {
|
||||||
|
type: Object,
|
||||||
|
default: null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['close', 'saved'])
|
||||||
|
|
||||||
|
// 状态
|
||||||
|
const loading = ref(false)
|
||||||
|
const saving = ref(false)
|
||||||
|
const config = ref({
|
||||||
|
enabled: false,
|
||||||
|
cronExpression: '0 8 * * *',
|
||||||
|
model: 'claude-sonnet-4-5-20250929'
|
||||||
|
})
|
||||||
|
const testHistory = ref([])
|
||||||
|
|
||||||
|
// Cron 预设选项
|
||||||
|
const cronPresets = [
|
||||||
|
{ label: '每天 8:00', value: '0 8 * * *' },
|
||||||
|
{ label: '每天 12:00', value: '0 12 * * *' },
|
||||||
|
{ label: '每天 18:00', value: '0 18 * * *' },
|
||||||
|
{ label: '每6小时', value: '0 */6 * * *' },
|
||||||
|
{ label: '每12小时', value: '0 */12 * * *' },
|
||||||
|
{ label: '工作日 9:00', value: '0 9 * * 1-5' }
|
||||||
|
]
|
||||||
|
|
||||||
|
// 模型选项
|
||||||
|
const modelOptions = [
|
||||||
|
{ label: 'Claude Sonnet 4.5', value: 'claude-sonnet-4-5-20250929' },
|
||||||
|
{ label: 'Claude Haiku 4.5', value: 'claude-haiku-4-5-20251001' },
|
||||||
|
{ label: 'Claude Opus 4.5', value: 'claude-opus-4-5-20251101' }
|
||||||
|
]
|
||||||
|
|
||||||
|
// 格式化时间戳
|
||||||
|
function formatTimestamp(timestamp) {
|
||||||
|
if (!timestamp) return '未知'
|
||||||
|
const date = new Date(timestamp)
|
||||||
|
return date.toLocaleString('zh-CN', {
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载配置
|
||||||
|
async function loadConfig() {
|
||||||
|
if (!props.account) return
|
||||||
|
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const authToken = localStorage.getItem('authToken')
|
||||||
|
const platform = props.account.platform
|
||||||
|
|
||||||
|
// 根据平台获取配置端点
|
||||||
|
let endpoint = ''
|
||||||
|
if (platform === 'claude') {
|
||||||
|
endpoint = `${API_PREFIX}/admin/claude-accounts/${props.account.id}/test-config`
|
||||||
|
} else {
|
||||||
|
// 其他平台暂不支持
|
||||||
|
loading.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取配置
|
||||||
|
const configRes = await fetch(endpoint, {
|
||||||
|
headers: {
|
||||||
|
Authorization: authToken ? `Bearer ${authToken}` : ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (configRes.ok) {
|
||||||
|
const data = await configRes.json()
|
||||||
|
if (data.success && data.data?.config) {
|
||||||
|
config.value = {
|
||||||
|
enabled: data.data.config.enabled || false,
|
||||||
|
cronExpression: data.data.config.cronExpression || '0 8 * * *',
|
||||||
|
model: data.data.config.model || 'claude-sonnet-4-5-20250929'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取测试历史
|
||||||
|
const historyEndpoint = endpoint.replace('/test-config', '/test-history')
|
||||||
|
const historyRes = await fetch(historyEndpoint, {
|
||||||
|
headers: {
|
||||||
|
Authorization: authToken ? `Bearer ${authToken}` : ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (historyRes.ok) {
|
||||||
|
const historyData = await historyRes.json()
|
||||||
|
if (historyData.success && historyData.data?.history) {
|
||||||
|
testHistory.value = historyData.data.history
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
showToast('加载配置失败: ' + err.message, 'error')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存配置
|
||||||
|
async function saveConfig() {
|
||||||
|
if (!props.account) return
|
||||||
|
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
const authToken = localStorage.getItem('authToken')
|
||||||
|
const platform = props.account.platform
|
||||||
|
|
||||||
|
let endpoint = ''
|
||||||
|
if (platform === 'claude') {
|
||||||
|
endpoint = `${API_PREFIX}/admin/claude-accounts/${props.account.id}/test-config`
|
||||||
|
} else {
|
||||||
|
saving.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(endpoint, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: authToken ? `Bearer ${authToken}` : ''
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
enabled: config.value.enabled,
|
||||||
|
cronExpression: config.value.cronExpression,
|
||||||
|
model: config.value.model
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
showToast('配置已保存', 'success')
|
||||||
|
emit('saved')
|
||||||
|
handleClose()
|
||||||
|
} else {
|
||||||
|
const errorData = await res.json().catch(() => ({}))
|
||||||
|
showToast(errorData.message || '保存失败', 'error')
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
showToast('保存失败: ' + err.message, 'error')
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 关闭模态框
|
||||||
|
function handleClose() {
|
||||||
|
if (saving.value) return
|
||||||
|
emit('close')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 监听 show 变化,加载配置
|
||||||
|
watch(
|
||||||
|
() => props.show,
|
||||||
|
(newVal) => {
|
||||||
|
if (newVal) {
|
||||||
|
config.value = {
|
||||||
|
enabled: false,
|
||||||
|
cronExpression: '0 8 * * *',
|
||||||
|
model: 'claude-sonnet-4-5-20250929'
|
||||||
|
}
|
||||||
|
testHistory.value = []
|
||||||
|
loadConfig()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
</script>
|
||||||
281
web/admin-spa/src/components/accounts/BalanceDisplay.vue
Normal file
281
web/admin-spa/src/components/accounts/BalanceDisplay.vue
Normal file
@@ -0,0 +1,281 @@
|
|||||||
|
<template>
|
||||||
|
<div class="min-w-[200px] space-y-1">
|
||||||
|
<div v-if="loading" class="flex items-center gap-2">
|
||||||
|
<i class="fas fa-spinner fa-spin text-gray-400 dark:text-gray-500"></i>
|
||||||
|
<span class="text-xs text-gray-500 dark:text-gray-400">加载中...</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="requestError" class="flex items-center gap-2">
|
||||||
|
<i class="fas fa-exclamation-circle text-red-500"></i>
|
||||||
|
<span class="text-xs text-red-600 dark:text-red-400">{{ requestError }}</span>
|
||||||
|
<button
|
||||||
|
class="text-xs text-blue-500 hover:text-blue-600 dark:text-blue-400"
|
||||||
|
:disabled="refreshing"
|
||||||
|
@click="reload"
|
||||||
|
>
|
||||||
|
重试
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="balanceData" class="space-y-1">
|
||||||
|
<div v-if="balanceData.status === 'error' && balanceData.error" class="text-xs text-red-500">
|
||||||
|
{{ balanceData.error }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between gap-2">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<i
|
||||||
|
class="fas"
|
||||||
|
:class="
|
||||||
|
balanceData.balance
|
||||||
|
? 'fa-wallet text-green-600 dark:text-green-400'
|
||||||
|
: 'fa-chart-line text-gray-500 dark:text-gray-400'
|
||||||
|
"
|
||||||
|
></i>
|
||||||
|
<span class="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
{{ primaryText }}
|
||||||
|
</span>
|
||||||
|
<span class="rounded px-1.5 py-0.5 text-xs" :class="sourceClass">
|
||||||
|
{{ sourceLabel }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
v-if="!hideRefresh"
|
||||||
|
class="text-xs text-gray-500 hover:text-blue-600 disabled:cursor-not-allowed disabled:opacity-40 dark:text-gray-400 dark:hover:text-blue-400"
|
||||||
|
:disabled="refreshing || !canRefresh"
|
||||||
|
:title="refreshTitle"
|
||||||
|
@click="refresh"
|
||||||
|
>
|
||||||
|
<i class="fas fa-sync-alt" :class="{ 'fa-spin': refreshing }"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 配额(如适用) -->
|
||||||
|
<div v-if="quotaInfo" class="space-y-1">
|
||||||
|
<div class="flex items-center justify-between text-xs text-gray-600 dark:text-gray-400">
|
||||||
|
<span>已用: {{ formatNumber(quotaInfo.used) }}</span>
|
||||||
|
<span>剩余: {{ formatNumber(quotaInfo.remaining) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="h-1.5 w-full rounded-full bg-gray-200 dark:bg-gray-700">
|
||||||
|
<div
|
||||||
|
class="h-1.5 rounded-full transition-all"
|
||||||
|
:class="quotaBarClass"
|
||||||
|
:style="{ width: `${Math.min(100, quotaInfo.percentage)}%` }"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between text-xs">
|
||||||
|
<span class="text-gray-500 dark:text-gray-400">
|
||||||
|
{{ quotaInfo.percentage.toFixed(1) }}% 已使用
|
||||||
|
</span>
|
||||||
|
<span v-if="quotaInfo.resetAt" class="text-gray-400 dark:text-gray-500">
|
||||||
|
重置: {{ formatResetTime(quotaInfo.resetAt) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="balanceData.quota?.unlimited" class="flex items-center gap-2">
|
||||||
|
<i class="fas fa-infinity text-blue-500 dark:text-blue-400"></i>
|
||||||
|
<span class="text-xs text-gray-600 dark:text-gray-400">无限制</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="balanceData.cacheExpiresAt && balanceData.source === 'cache'"
|
||||||
|
class="text-xs text-gray-400 dark:text-gray-500"
|
||||||
|
>
|
||||||
|
缓存至: {{ formatCacheExpiry(balanceData.cacheExpiresAt) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="text-xs text-gray-400 dark:text-gray-500">暂无余额数据</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, onMounted, watch } from 'vue'
|
||||||
|
import { apiClient } from '@/config/api'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
accountId: { type: String, required: true },
|
||||||
|
platform: { type: String, required: true },
|
||||||
|
initialBalance: { type: Object, default: null },
|
||||||
|
hideRefresh: { type: Boolean, default: false },
|
||||||
|
autoLoad: { type: Boolean, default: true }
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['refreshed', 'error'])
|
||||||
|
|
||||||
|
const balanceData = ref(props.initialBalance)
|
||||||
|
const loading = ref(false)
|
||||||
|
const refreshing = ref(false)
|
||||||
|
const requestError = ref(null)
|
||||||
|
|
||||||
|
const sourceClass = computed(() => {
|
||||||
|
const source = balanceData.value?.source
|
||||||
|
return {
|
||||||
|
'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300': source === 'api',
|
||||||
|
'bg-gray-100 text-gray-600 dark:bg-gray-700/60 dark:text-gray-300': source === 'cache',
|
||||||
|
'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/40 dark:text-yellow-300': source === 'local'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const sourceLabel = computed(() => {
|
||||||
|
const source = balanceData.value?.source
|
||||||
|
return { api: 'API', cache: '缓存', local: '本地' }[source] || '未知'
|
||||||
|
})
|
||||||
|
|
||||||
|
const quotaInfo = computed(() => {
|
||||||
|
const quota = balanceData.value?.quota
|
||||||
|
if (!quota || quota.unlimited) return null
|
||||||
|
if (typeof quota.percentage !== 'number' || !Number.isFinite(quota.percentage)) return null
|
||||||
|
return {
|
||||||
|
used: quota.used ?? 0,
|
||||||
|
remaining: quota.remaining ?? 0,
|
||||||
|
percentage: quota.percentage,
|
||||||
|
resetAt: quota.resetAt || null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const quotaBarClass = computed(() => {
|
||||||
|
const percentage = quotaInfo.value?.percentage || 0
|
||||||
|
if (percentage >= 90) return 'bg-red-500 dark:bg-red-600'
|
||||||
|
if (percentage >= 70) return 'bg-yellow-500 dark:bg-yellow-600'
|
||||||
|
return 'bg-green-500 dark:bg-green-600'
|
||||||
|
})
|
||||||
|
|
||||||
|
const canRefresh = computed(() => {
|
||||||
|
// 仅在“已启用脚本且该账户配置了脚本”时允许刷新,避免误导(非脚本 Provider 多为降级策略)
|
||||||
|
const data = balanceData.value
|
||||||
|
if (!data) return false
|
||||||
|
if (data.scriptEnabled === false) return false
|
||||||
|
return !!data.scriptConfigured
|
||||||
|
})
|
||||||
|
|
||||||
|
const refreshTitle = computed(() => {
|
||||||
|
if (refreshing.value) return '刷新中...'
|
||||||
|
if (!canRefresh.value) {
|
||||||
|
if (balanceData.value?.scriptEnabled === false) {
|
||||||
|
return '余额脚本功能已禁用'
|
||||||
|
}
|
||||||
|
return '请先配置余额脚本'
|
||||||
|
}
|
||||||
|
return '刷新余额(调用脚本配置的余额 API)'
|
||||||
|
})
|
||||||
|
|
||||||
|
const primaryText = computed(() => {
|
||||||
|
if (balanceData.value?.balance?.formattedAmount) {
|
||||||
|
return balanceData.value.balance.formattedAmount
|
||||||
|
}
|
||||||
|
const dailyCost = Number(balanceData.value?.statistics?.dailyCost || 0)
|
||||||
|
return `今日成本 ${formatCurrency(dailyCost)}`
|
||||||
|
})
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
if (!props.autoLoad) return
|
||||||
|
if (!props.accountId || !props.platform) return
|
||||||
|
|
||||||
|
loading.value = true
|
||||||
|
requestError.value = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get(`/admin/accounts/${props.accountId}/balance`, {
|
||||||
|
params: { platform: props.platform, queryApi: false }
|
||||||
|
})
|
||||||
|
if (response?.success) {
|
||||||
|
balanceData.value = response.data
|
||||||
|
} else {
|
||||||
|
requestError.value = response?.error || '加载失败'
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
requestError.value = error.message || '网络错误'
|
||||||
|
emit('error', error)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const refresh = async () => {
|
||||||
|
if (!props.accountId || !props.platform) return
|
||||||
|
if (refreshing.value) return
|
||||||
|
if (!canRefresh.value) return
|
||||||
|
|
||||||
|
refreshing.value = true
|
||||||
|
requestError.value = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await apiClient.post(`/admin/accounts/${props.accountId}/balance/refresh`, {
|
||||||
|
platform: props.platform
|
||||||
|
})
|
||||||
|
if (response?.success) {
|
||||||
|
balanceData.value = response.data
|
||||||
|
emit('refreshed', response.data)
|
||||||
|
} else {
|
||||||
|
requestError.value = response?.error || '刷新失败'
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
requestError.value = error.message || '网络错误'
|
||||||
|
emit('error', error)
|
||||||
|
} finally {
|
||||||
|
refreshing.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const reload = async () => {
|
||||||
|
await load()
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatNumber = (num) => {
|
||||||
|
if (num === Infinity) return '∞'
|
||||||
|
const value = Number(num)
|
||||||
|
if (!Number.isFinite(value)) return 'N/A'
|
||||||
|
return value.toLocaleString('zh-CN', { maximumFractionDigits: 2 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatCurrency = (amount) => {
|
||||||
|
const value = Number(amount)
|
||||||
|
if (!Number.isFinite(value)) return '$0.00'
|
||||||
|
if (value >= 1) return `$${value.toFixed(2)}`
|
||||||
|
if (value >= 0.01) return `$${value.toFixed(3)}`
|
||||||
|
return `$${value.toFixed(6)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatResetTime = (isoString) => {
|
||||||
|
const date = new Date(isoString)
|
||||||
|
const now = new Date()
|
||||||
|
const diff = date.getTime() - now.getTime()
|
||||||
|
if (!Number.isFinite(diff)) return '未知'
|
||||||
|
if (diff < 0) return '已过期'
|
||||||
|
|
||||||
|
const minutes = Math.floor(diff / (1000 * 60))
|
||||||
|
const hours = Math.floor(minutes / 60)
|
||||||
|
const remainMinutes = minutes % 60
|
||||||
|
if (hours >= 24) {
|
||||||
|
const days = Math.floor(hours / 24)
|
||||||
|
return `${days}天后`
|
||||||
|
}
|
||||||
|
return `${hours}小时${remainMinutes}分钟`
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatCacheExpiry = (isoString) => {
|
||||||
|
const date = new Date(isoString)
|
||||||
|
if (Number.isNaN(date.getTime())) return '未知'
|
||||||
|
return date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.initialBalance,
|
||||||
|
(newVal) => {
|
||||||
|
if (newVal) {
|
||||||
|
balanceData.value = newVal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (!props.initialBalance) {
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
defineExpose({ refresh, reload })
|
||||||
|
</script>
|
||||||
@@ -579,55 +579,46 @@
|
|||||||
<label class="mb-2 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
<label class="mb-2 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||||
>服务权限</label
|
>服务权限</label
|
||||||
>
|
>
|
||||||
<div class="flex gap-4">
|
<div class="flex flex-wrap gap-4">
|
||||||
<label class="flex cursor-pointer items-center">
|
<label class="flex cursor-pointer items-center">
|
||||||
<input
|
<input
|
||||||
v-model="form.permissions"
|
v-model="form.permissions"
|
||||||
class="mr-2 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
|
class="mr-2 rounded text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
|
||||||
type="radio"
|
type="checkbox"
|
||||||
value="all"
|
|
||||||
/>
|
|
||||||
<span class="text-sm text-gray-700 dark:text-gray-300">全部服务</span>
|
|
||||||
</label>
|
|
||||||
<label class="flex cursor-pointer items-center">
|
|
||||||
<input
|
|
||||||
v-model="form.permissions"
|
|
||||||
class="mr-2 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
|
|
||||||
type="radio"
|
|
||||||
value="claude"
|
value="claude"
|
||||||
/>
|
/>
|
||||||
<span class="text-sm text-gray-700 dark:text-gray-300">仅 Claude</span>
|
<span class="text-sm text-gray-700 dark:text-gray-300">Claude</span>
|
||||||
</label>
|
</label>
|
||||||
<label class="flex cursor-pointer items-center">
|
<label class="flex cursor-pointer items-center">
|
||||||
<input
|
<input
|
||||||
v-model="form.permissions"
|
v-model="form.permissions"
|
||||||
class="mr-2 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
|
class="mr-2 rounded text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
|
||||||
type="radio"
|
type="checkbox"
|
||||||
value="gemini"
|
value="gemini"
|
||||||
/>
|
/>
|
||||||
<span class="text-sm text-gray-700 dark:text-gray-300">仅 Gemini</span>
|
<span class="text-sm text-gray-700 dark:text-gray-300">Gemini</span>
|
||||||
</label>
|
</label>
|
||||||
<label class="flex cursor-pointer items-center">
|
<label class="flex cursor-pointer items-center">
|
||||||
<input
|
<input
|
||||||
v-model="form.permissions"
|
v-model="form.permissions"
|
||||||
class="mr-2 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
|
class="mr-2 rounded text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
|
||||||
type="radio"
|
type="checkbox"
|
||||||
value="openai"
|
value="openai"
|
||||||
/>
|
/>
|
||||||
<span class="text-sm text-gray-700 dark:text-gray-300">仅 OpenAI</span>
|
<span class="text-sm text-gray-700 dark:text-gray-300">OpenAI</span>
|
||||||
</label>
|
</label>
|
||||||
<label class="flex cursor-pointer items-center">
|
<label class="flex cursor-pointer items-center">
|
||||||
<input
|
<input
|
||||||
v-model="form.permissions"
|
v-model="form.permissions"
|
||||||
class="mr-2 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
|
class="mr-2 rounded text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
|
||||||
type="radio"
|
type="checkbox"
|
||||||
value="droid"
|
value="droid"
|
||||||
/>
|
/>
|
||||||
<span class="text-sm text-gray-700 dark:text-gray-300">仅 Droid</span>
|
<span class="text-sm text-gray-700 dark:text-gray-300">Droid</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||||
控制此 API Key 可以访问哪些服务
|
不选择任何服务表示允许访问全部服务
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -662,7 +653,7 @@
|
|||||||
v-model="form.claudeAccountId"
|
v-model="form.claudeAccountId"
|
||||||
:accounts="localAccounts.claude"
|
:accounts="localAccounts.claude"
|
||||||
default-option-text="使用共享账号池"
|
default-option-text="使用共享账号池"
|
||||||
:disabled="form.permissions !== 'all' && form.permissions !== 'claude'"
|
:disabled="form.permissions.length > 0 && !form.permissions.includes('claude')"
|
||||||
:groups="localAccounts.claudeGroups"
|
:groups="localAccounts.claudeGroups"
|
||||||
placeholder="请选择Claude账号"
|
placeholder="请选择Claude账号"
|
||||||
platform="claude"
|
platform="claude"
|
||||||
@@ -676,7 +667,7 @@
|
|||||||
v-model="form.geminiAccountId"
|
v-model="form.geminiAccountId"
|
||||||
:accounts="localAccounts.gemini"
|
:accounts="localAccounts.gemini"
|
||||||
default-option-text="使用共享账号池"
|
default-option-text="使用共享账号池"
|
||||||
:disabled="form.permissions !== 'all' && form.permissions !== 'gemini'"
|
:disabled="form.permissions.length > 0 && !form.permissions.includes('gemini')"
|
||||||
:groups="localAccounts.geminiGroups"
|
:groups="localAccounts.geminiGroups"
|
||||||
placeholder="请选择Gemini账号"
|
placeholder="请选择Gemini账号"
|
||||||
platform="gemini"
|
platform="gemini"
|
||||||
@@ -690,7 +681,7 @@
|
|||||||
v-model="form.openaiAccountId"
|
v-model="form.openaiAccountId"
|
||||||
:accounts="localAccounts.openai"
|
:accounts="localAccounts.openai"
|
||||||
default-option-text="使用共享账号池"
|
default-option-text="使用共享账号池"
|
||||||
:disabled="form.permissions !== 'all' && form.permissions !== 'openai'"
|
:disabled="form.permissions.length > 0 && !form.permissions.includes('openai')"
|
||||||
:groups="localAccounts.openaiGroups"
|
:groups="localAccounts.openaiGroups"
|
||||||
placeholder="请选择OpenAI账号"
|
placeholder="请选择OpenAI账号"
|
||||||
platform="openai"
|
platform="openai"
|
||||||
@@ -704,7 +695,7 @@
|
|||||||
v-model="form.bedrockAccountId"
|
v-model="form.bedrockAccountId"
|
||||||
:accounts="localAccounts.bedrock"
|
:accounts="localAccounts.bedrock"
|
||||||
default-option-text="使用共享账号池"
|
default-option-text="使用共享账号池"
|
||||||
:disabled="form.permissions !== 'all' && form.permissions !== 'openai'"
|
:disabled="form.permissions.length > 0 && !form.permissions.includes('claude')"
|
||||||
:groups="[]"
|
:groups="[]"
|
||||||
placeholder="请选择Bedrock账号"
|
placeholder="请选择Bedrock账号"
|
||||||
platform="bedrock"
|
platform="bedrock"
|
||||||
@@ -718,7 +709,7 @@
|
|||||||
v-model="form.droidAccountId"
|
v-model="form.droidAccountId"
|
||||||
:accounts="localAccounts.droid"
|
:accounts="localAccounts.droid"
|
||||||
default-option-text="使用共享账号池"
|
default-option-text="使用共享账号池"
|
||||||
:disabled="form.permissions !== 'all' && form.permissions !== 'droid'"
|
:disabled="form.permissions.length > 0 && !form.permissions.includes('droid')"
|
||||||
:groups="localAccounts.droidGroups"
|
:groups="localAccounts.droidGroups"
|
||||||
placeholder="请选择Droid账号"
|
placeholder="请选择Droid账号"
|
||||||
platform="droid"
|
platform="droid"
|
||||||
@@ -966,7 +957,7 @@ const form = reactive({
|
|||||||
expirationMode: 'fixed', // 过期模式:fixed(固定) 或 activation(激活)
|
expirationMode: 'fixed', // 过期模式:fixed(固定) 或 activation(激活)
|
||||||
activationDays: 30, // 激活后有效天数
|
activationDays: 30, // 激活后有效天数
|
||||||
activationUnit: 'days', // 激活时间单位:hours 或 days
|
activationUnit: 'days', // 激活时间单位:hours 或 days
|
||||||
permissions: 'all',
|
permissions: [], // 数组格式,空数组表示全部服务
|
||||||
claudeAccountId: '',
|
claudeAccountId: '',
|
||||||
geminiAccountId: '',
|
geminiAccountId: '',
|
||||||
openaiAccountId: '',
|
openaiAccountId: '',
|
||||||
|
|||||||
@@ -412,55 +412,46 @@
|
|||||||
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||||
>服务权限</label
|
>服务权限</label
|
||||||
>
|
>
|
||||||
<div class="flex gap-4">
|
<div class="flex flex-wrap gap-4">
|
||||||
<label class="flex cursor-pointer items-center">
|
<label class="flex cursor-pointer items-center">
|
||||||
<input
|
<input
|
||||||
v-model="form.permissions"
|
v-model="form.permissions"
|
||||||
class="mr-2 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
|
class="mr-2 rounded text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
|
||||||
type="radio"
|
type="checkbox"
|
||||||
value="all"
|
|
||||||
/>
|
|
||||||
<span class="text-sm text-gray-700 dark:text-gray-300">全部服务</span>
|
|
||||||
</label>
|
|
||||||
<label class="flex cursor-pointer items-center">
|
|
||||||
<input
|
|
||||||
v-model="form.permissions"
|
|
||||||
class="mr-2 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
|
|
||||||
type="radio"
|
|
||||||
value="claude"
|
value="claude"
|
||||||
/>
|
/>
|
||||||
<span class="text-sm text-gray-700 dark:text-gray-300">仅 Claude</span>
|
<span class="text-sm text-gray-700 dark:text-gray-300">Claude</span>
|
||||||
</label>
|
</label>
|
||||||
<label class="flex cursor-pointer items-center">
|
<label class="flex cursor-pointer items-center">
|
||||||
<input
|
<input
|
||||||
v-model="form.permissions"
|
v-model="form.permissions"
|
||||||
class="mr-2 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
|
class="mr-2 rounded text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
|
||||||
type="radio"
|
type="checkbox"
|
||||||
value="gemini"
|
value="gemini"
|
||||||
/>
|
/>
|
||||||
<span class="text-sm text-gray-700 dark:text-gray-300">仅 Gemini</span>
|
<span class="text-sm text-gray-700 dark:text-gray-300">Gemini</span>
|
||||||
</label>
|
</label>
|
||||||
<label class="flex cursor-pointer items-center">
|
<label class="flex cursor-pointer items-center">
|
||||||
<input
|
<input
|
||||||
v-model="form.permissions"
|
v-model="form.permissions"
|
||||||
class="mr-2 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
|
class="mr-2 rounded text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
|
||||||
type="radio"
|
type="checkbox"
|
||||||
value="openai"
|
value="openai"
|
||||||
/>
|
/>
|
||||||
<span class="text-sm text-gray-700 dark:text-gray-300">仅 OpenAI</span>
|
<span class="text-sm text-gray-700 dark:text-gray-300">OpenAI</span>
|
||||||
</label>
|
</label>
|
||||||
<label class="flex cursor-pointer items-center">
|
<label class="flex cursor-pointer items-center">
|
||||||
<input
|
<input
|
||||||
v-model="form.permissions"
|
v-model="form.permissions"
|
||||||
class="mr-2 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
|
class="mr-2 rounded text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
|
||||||
type="radio"
|
type="checkbox"
|
||||||
value="droid"
|
value="droid"
|
||||||
/>
|
/>
|
||||||
<span class="text-sm text-gray-700 dark:text-gray-300">仅 Droid</span>
|
<span class="text-sm text-gray-700 dark:text-gray-300">Droid</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||||
控制此 API Key 可以访问哪些服务
|
不选择任何服务表示允许访问全部服务
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -495,7 +486,7 @@
|
|||||||
v-model="form.claudeAccountId"
|
v-model="form.claudeAccountId"
|
||||||
:accounts="localAccounts.claude"
|
:accounts="localAccounts.claude"
|
||||||
default-option-text="使用共享账号池"
|
default-option-text="使用共享账号池"
|
||||||
:disabled="form.permissions !== 'all' && form.permissions !== 'claude'"
|
:disabled="form.permissions.length > 0 && !form.permissions.includes('claude')"
|
||||||
:groups="localAccounts.claudeGroups"
|
:groups="localAccounts.claudeGroups"
|
||||||
placeholder="请选择Claude账号"
|
placeholder="请选择Claude账号"
|
||||||
platform="claude"
|
platform="claude"
|
||||||
@@ -509,7 +500,7 @@
|
|||||||
v-model="form.geminiAccountId"
|
v-model="form.geminiAccountId"
|
||||||
:accounts="localAccounts.gemini"
|
:accounts="localAccounts.gemini"
|
||||||
default-option-text="使用共享账号池"
|
default-option-text="使用共享账号池"
|
||||||
:disabled="form.permissions !== 'all' && form.permissions !== 'gemini'"
|
:disabled="form.permissions.length > 0 && !form.permissions.includes('gemini')"
|
||||||
:groups="localAccounts.geminiGroups"
|
:groups="localAccounts.geminiGroups"
|
||||||
placeholder="请选择Gemini账号"
|
placeholder="请选择Gemini账号"
|
||||||
platform="gemini"
|
platform="gemini"
|
||||||
@@ -523,7 +514,7 @@
|
|||||||
v-model="form.openaiAccountId"
|
v-model="form.openaiAccountId"
|
||||||
:accounts="localAccounts.openai"
|
:accounts="localAccounts.openai"
|
||||||
default-option-text="使用共享账号池"
|
default-option-text="使用共享账号池"
|
||||||
:disabled="form.permissions !== 'all' && form.permissions !== 'openai'"
|
:disabled="form.permissions.length > 0 && !form.permissions.includes('openai')"
|
||||||
:groups="localAccounts.openaiGroups"
|
:groups="localAccounts.openaiGroups"
|
||||||
placeholder="请选择OpenAI账号"
|
placeholder="请选择OpenAI账号"
|
||||||
platform="openai"
|
platform="openai"
|
||||||
@@ -537,7 +528,7 @@
|
|||||||
v-model="form.bedrockAccountId"
|
v-model="form.bedrockAccountId"
|
||||||
:accounts="localAccounts.bedrock"
|
:accounts="localAccounts.bedrock"
|
||||||
default-option-text="使用共享账号池"
|
default-option-text="使用共享账号池"
|
||||||
:disabled="form.permissions !== 'all' && form.permissions !== 'openai'"
|
:disabled="form.permissions.length > 0 && !form.permissions.includes('claude')"
|
||||||
:groups="[]"
|
:groups="[]"
|
||||||
placeholder="请选择Bedrock账号"
|
placeholder="请选择Bedrock账号"
|
||||||
platform="bedrock"
|
platform="bedrock"
|
||||||
@@ -551,7 +542,7 @@
|
|||||||
v-model="form.droidAccountId"
|
v-model="form.droidAccountId"
|
||||||
:accounts="localAccounts.droid"
|
:accounts="localAccounts.droid"
|
||||||
default-option-text="使用共享账号池"
|
default-option-text="使用共享账号池"
|
||||||
:disabled="form.permissions !== 'all' && form.permissions !== 'droid'"
|
:disabled="form.permissions.length > 0 && !form.permissions.includes('droid')"
|
||||||
:groups="localAccounts.droidGroups"
|
:groups="localAccounts.droidGroups"
|
||||||
placeholder="请选择Droid账号"
|
placeholder="请选择Droid账号"
|
||||||
platform="droid"
|
platform="droid"
|
||||||
@@ -800,7 +791,7 @@ const form = reactive({
|
|||||||
dailyCostLimit: '',
|
dailyCostLimit: '',
|
||||||
totalCostLimit: '',
|
totalCostLimit: '',
|
||||||
weeklyOpusCostLimit: '',
|
weeklyOpusCostLimit: '',
|
||||||
permissions: 'all',
|
permissions: [], // 数组格式,空数组表示全部服务
|
||||||
claudeAccountId: '',
|
claudeAccountId: '',
|
||||||
geminiAccountId: '',
|
geminiAccountId: '',
|
||||||
openaiAccountId: '',
|
openaiAccountId: '',
|
||||||
@@ -1241,7 +1232,17 @@ onMounted(async () => {
|
|||||||
form.dailyCostLimit = props.apiKey.dailyCostLimit || ''
|
form.dailyCostLimit = props.apiKey.dailyCostLimit || ''
|
||||||
form.totalCostLimit = props.apiKey.totalCostLimit || ''
|
form.totalCostLimit = props.apiKey.totalCostLimit || ''
|
||||||
form.weeklyOpusCostLimit = props.apiKey.weeklyOpusCostLimit || ''
|
form.weeklyOpusCostLimit = props.apiKey.weeklyOpusCostLimit || ''
|
||||||
form.permissions = props.apiKey.permissions || 'all'
|
// 处理权限数据,兼容旧格式(字符串)和新格式(数组)
|
||||||
|
const perms = props.apiKey.permissions
|
||||||
|
if (Array.isArray(perms)) {
|
||||||
|
form.permissions = perms
|
||||||
|
} else if (perms === 'all' || !perms) {
|
||||||
|
form.permissions = []
|
||||||
|
} else if (typeof perms === 'string') {
|
||||||
|
form.permissions = [perms]
|
||||||
|
} else {
|
||||||
|
form.permissions = []
|
||||||
|
}
|
||||||
// 处理 Claude 账号(区分 OAuth 和 Console)
|
// 处理 Claude 账号(区分 OAuth 和 Console)
|
||||||
if (props.apiKey.claudeConsoleAccountId) {
|
if (props.apiKey.claudeConsoleAccountId) {
|
||||||
form.claudeAccountId = `console:${props.apiKey.claudeConsoleAccountId}`
|
form.claudeAccountId = `console:${props.apiKey.claudeConsoleAccountId}`
|
||||||
|
|||||||
@@ -141,6 +141,28 @@
|
|||||||
</el-tooltip>
|
</el-tooltip>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 刷新余额按钮 -->
|
||||||
|
<div class="relative">
|
||||||
|
<el-tooltip :content="refreshBalanceTooltip" effect="dark" placement="bottom">
|
||||||
|
<button
|
||||||
|
class="group relative flex items-center justify-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm transition-all duration-200 hover:border-gray-300 hover:shadow-md disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:border-gray-500 sm:w-auto"
|
||||||
|
:disabled="accountsLoading || refreshingBalances || !canRefreshVisibleBalances"
|
||||||
|
@click="refreshVisibleBalances"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="absolute -inset-0.5 rounded-lg bg-gradient-to-r from-blue-500 to-indigo-500 opacity-0 blur transition duration-300 group-hover:opacity-20"
|
||||||
|
></div>
|
||||||
|
<i
|
||||||
|
:class="[
|
||||||
|
'fas relative text-blue-500',
|
||||||
|
refreshingBalances ? 'fa-spinner fa-spin' : 'fa-wallet'
|
||||||
|
]"
|
||||||
|
/>
|
||||||
|
<span class="relative">刷新余额</span>
|
||||||
|
</button>
|
||||||
|
</el-tooltip>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 选择/取消选择按钮 -->
|
<!-- 选择/取消选择按钮 -->
|
||||||
<button
|
<button
|
||||||
class="flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm transition-all duration-200 hover:border-gray-300 hover:bg-gray-50 hover:shadow-md dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
|
class="flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm transition-all duration-200 hover:border-gray-300 hover:bg-gray-50 hover:shadow-md dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
|
||||||
@@ -263,6 +285,11 @@
|
|||||||
>
|
>
|
||||||
今日使用
|
今日使用
|
||||||
</th>
|
</th>
|
||||||
|
<th
|
||||||
|
class="min-w-[220px] px-3 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-700 dark:text-gray-300"
|
||||||
|
>
|
||||||
|
余额/配额
|
||||||
|
</th>
|
||||||
<th
|
<th
|
||||||
class="min-w-[210px] px-3 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-700 dark:text-gray-300"
|
class="min-w-[210px] px-3 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-700 dark:text-gray-300"
|
||||||
>
|
>
|
||||||
@@ -765,6 +792,23 @@
|
|||||||
</div>
|
</div>
|
||||||
<div v-else class="text-xs text-gray-400">暂无数据</div>
|
<div v-else class="text-xs text-gray-400">暂无数据</div>
|
||||||
</td>
|
</td>
|
||||||
|
<td class="whitespace-nowrap px-3 py-4">
|
||||||
|
<BalanceDisplay
|
||||||
|
:account-id="account.id"
|
||||||
|
:initial-balance="account.balanceInfo"
|
||||||
|
:platform="account.platform"
|
||||||
|
@error="(error) => handleBalanceError(account.id, error)"
|
||||||
|
@refreshed="(data) => handleBalanceRefreshed(account.id, data)"
|
||||||
|
/>
|
||||||
|
<div class="mt-1 text-xs">
|
||||||
|
<button
|
||||||
|
class="text-blue-500 hover:underline dark:text-blue-300"
|
||||||
|
@click="openBalanceScriptModal(account)"
|
||||||
|
>
|
||||||
|
配置余额脚本
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
<td class="whitespace-nowrap px-3 py-4">
|
<td class="whitespace-nowrap px-3 py-4">
|
||||||
<div v-if="account.platform === 'claude'" class="space-y-2">
|
<div v-if="account.platform === 'claude'" class="space-y-2">
|
||||||
<!-- OAuth 账户:显示三窗口 OAuth usage -->
|
<!-- OAuth 账户:显示三窗口 OAuth usage -->
|
||||||
@@ -1238,6 +1282,15 @@
|
|||||||
<i class="fas fa-vial" />
|
<i class="fas fa-vial" />
|
||||||
<span class="ml-1">测试</span>
|
<span class="ml-1">测试</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="canTestAccount(account)"
|
||||||
|
class="rounded bg-amber-100 px-2.5 py-1 text-xs font-medium text-amber-700 transition-colors hover:bg-amber-200 dark:bg-amber-900/40 dark:text-amber-300 dark:hover:bg-amber-800/50"
|
||||||
|
title="定时测试配置"
|
||||||
|
@click="openScheduledTestModal(account)"
|
||||||
|
>
|
||||||
|
<i class="fas fa-clock" />
|
||||||
|
<span class="ml-1">定时</span>
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
class="rounded bg-blue-100 px-2.5 py-1 text-xs font-medium text-blue-700 transition-colors hover:bg-blue-200"
|
class="rounded bg-blue-100 px-2.5 py-1 text-xs font-medium text-blue-700 transition-colors hover:bg-blue-200"
|
||||||
title="编辑账户"
|
title="编辑账户"
|
||||||
@@ -1416,6 +1469,26 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 余额/配额 -->
|
||||||
|
<div class="mb-3">
|
||||||
|
<p class="mb-1 text-xs text-gray-500 dark:text-gray-400">余额/配额</p>
|
||||||
|
<BalanceDisplay
|
||||||
|
:account-id="account.id"
|
||||||
|
:initial-balance="account.balanceInfo"
|
||||||
|
:platform="account.platform"
|
||||||
|
@error="(error) => handleBalanceError(account.id, error)"
|
||||||
|
@refreshed="(data) => handleBalanceRefreshed(account.id, data)"
|
||||||
|
/>
|
||||||
|
<div class="mt-1 text-xs">
|
||||||
|
<button
|
||||||
|
class="text-blue-500 hover:underline dark:text-blue-300"
|
||||||
|
@click="openBalanceScriptModal(account)"
|
||||||
|
>
|
||||||
|
配置余额脚本
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 状态信息 -->
|
<!-- 状态信息 -->
|
||||||
<div class="mb-3 space-y-2">
|
<div class="mb-3 space-y-2">
|
||||||
<!-- 会话窗口 -->
|
<!-- 会话窗口 -->
|
||||||
@@ -1707,6 +1780,15 @@
|
|||||||
测试
|
测试
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
v-if="canTestAccount(account)"
|
||||||
|
class="flex flex-1 items-center justify-center gap-1 rounded-lg bg-amber-50 px-3 py-2 text-xs text-amber-600 transition-colors hover:bg-amber-100 dark:bg-amber-900/40 dark:text-amber-300 dark:hover:bg-amber-800/50"
|
||||||
|
@click="openScheduledTestModal(account)"
|
||||||
|
>
|
||||||
|
<i class="fas fa-clock" />
|
||||||
|
定时
|
||||||
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
class="flex-1 rounded-lg bg-gray-50 px-3 py-2 text-xs text-gray-600 transition-colors hover:bg-gray-100"
|
class="flex-1 rounded-lg bg-gray-50 px-3 py-2 text-xs text-gray-600 transition-colors hover:bg-gray-100"
|
||||||
@click="editAccount(account)"
|
@click="editAccount(account)"
|
||||||
@@ -1880,6 +1962,21 @@
|
|||||||
@close="closeAccountTestModal"
|
@close="closeAccountTestModal"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<!-- 定时测试配置弹窗 -->
|
||||||
|
<AccountScheduledTestModal
|
||||||
|
:account="scheduledTestAccount"
|
||||||
|
:show="showScheduledTestModal"
|
||||||
|
@close="closeScheduledTestModal"
|
||||||
|
@saved="handleScheduledTestSaved"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<AccountBalanceScriptModal
|
||||||
|
:account="selectedAccountForScript"
|
||||||
|
:show="showBalanceScriptModal"
|
||||||
|
@close="closeBalanceScriptModal"
|
||||||
|
@saved="handleBalanceScriptSaved"
|
||||||
|
/>
|
||||||
|
|
||||||
<!-- 账户统计弹窗 -->
|
<!-- 账户统计弹窗 -->
|
||||||
<el-dialog
|
<el-dialog
|
||||||
v-model="showAccountStatsModal"
|
v-model="showAccountStatsModal"
|
||||||
@@ -2032,9 +2129,12 @@ import CcrAccountForm from '@/components/accounts/CcrAccountForm.vue'
|
|||||||
import AccountUsageDetailModal from '@/components/accounts/AccountUsageDetailModal.vue'
|
import AccountUsageDetailModal from '@/components/accounts/AccountUsageDetailModal.vue'
|
||||||
import AccountExpiryEditModal from '@/components/accounts/AccountExpiryEditModal.vue'
|
import AccountExpiryEditModal from '@/components/accounts/AccountExpiryEditModal.vue'
|
||||||
import AccountTestModal from '@/components/accounts/AccountTestModal.vue'
|
import AccountTestModal from '@/components/accounts/AccountTestModal.vue'
|
||||||
|
import AccountScheduledTestModal from '@/components/accounts/AccountScheduledTestModal.vue'
|
||||||
import ConfirmModal from '@/components/common/ConfirmModal.vue'
|
import ConfirmModal from '@/components/common/ConfirmModal.vue'
|
||||||
import CustomDropdown from '@/components/common/CustomDropdown.vue'
|
import CustomDropdown from '@/components/common/CustomDropdown.vue'
|
||||||
import ActionDropdown from '@/components/common/ActionDropdown.vue'
|
import ActionDropdown from '@/components/common/ActionDropdown.vue'
|
||||||
|
import BalanceDisplay from '@/components/accounts/BalanceDisplay.vue'
|
||||||
|
import AccountBalanceScriptModal from '@/components/accounts/AccountBalanceScriptModal.vue'
|
||||||
|
|
||||||
// 使用确认弹窗
|
// 使用确认弹窗
|
||||||
const { showConfirmModal, confirmOptions, showConfirm, handleConfirm, handleCancel } = useConfirm()
|
const { showConfirmModal, confirmOptions, showConfirm, handleConfirm, handleCancel } = useConfirm()
|
||||||
@@ -2042,6 +2142,7 @@ const { showConfirmModal, confirmOptions, showConfirm, handleConfirm, handleCanc
|
|||||||
// 数据状态
|
// 数据状态
|
||||||
const accounts = ref([])
|
const accounts = ref([])
|
||||||
const accountsLoading = ref(false)
|
const accountsLoading = ref(false)
|
||||||
|
const refreshingBalances = ref(false)
|
||||||
const accountsSortBy = ref('name')
|
const accountsSortBy = ref('name')
|
||||||
const accountsSortOrder = ref('asc')
|
const accountsSortOrder = ref('asc')
|
||||||
const apiKeys = ref([]) // 保留用于其他功能(如删除账户时显示绑定信息)
|
const apiKeys = ref([]) // 保留用于其他功能(如删除账户时显示绑定信息)
|
||||||
@@ -2099,6 +2200,10 @@ const expiryEditModalRef = ref(null)
|
|||||||
const showAccountTestModal = ref(false)
|
const showAccountTestModal = ref(false)
|
||||||
const testingAccount = ref(null)
|
const testingAccount = ref(null)
|
||||||
|
|
||||||
|
// 定时测试配置弹窗状态
|
||||||
|
const showScheduledTestModal = ref(false)
|
||||||
|
const scheduledTestAccount = ref(null)
|
||||||
|
|
||||||
// 账户统计弹窗状态
|
// 账户统计弹窗状态
|
||||||
const showAccountStatsModal = ref(false)
|
const showAccountStatsModal = ref(false)
|
||||||
|
|
||||||
@@ -2365,6 +2470,13 @@ const getAccountActions = (account) => {
|
|||||||
color: 'blue',
|
color: 'blue',
|
||||||
handler: () => openAccountTestModal(account)
|
handler: () => openAccountTestModal(account)
|
||||||
})
|
})
|
||||||
|
actions.push({
|
||||||
|
key: 'scheduled-test',
|
||||||
|
label: '定时测试',
|
||||||
|
icon: 'fa-clock',
|
||||||
|
color: 'amber',
|
||||||
|
handler: () => openScheduledTestModal(account)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 删除
|
// 删除
|
||||||
@@ -2441,6 +2553,61 @@ const closeAccountTestModal = () => {
|
|||||||
testingAccount.value = null
|
testingAccount.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 定时测试配置相关函数
|
||||||
|
const openScheduledTestModal = (account) => {
|
||||||
|
if (!canTestAccount(account)) {
|
||||||
|
showToast('该账户类型暂不支持定时测试', 'warning')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
scheduledTestAccount.value = account
|
||||||
|
showScheduledTestModal.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeScheduledTestModal = () => {
|
||||||
|
showScheduledTestModal.value = false
|
||||||
|
scheduledTestAccount.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
const 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 = async () => {
|
||||||
|
showToast('余额脚本已保存', 'success')
|
||||||
|
const account = selectedAccountForScript.value
|
||||||
|
closeBalanceScriptModal()
|
||||||
|
|
||||||
|
if (!account?.id || !account?.platform) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重新拉取一次余额信息,用于刷新 scriptConfigured 状态(启用“刷新余额”按钮)
|
||||||
|
try {
|
||||||
|
const res = await apiClient.get(`/admin/accounts/${account.id}/balance`, {
|
||||||
|
params: { platform: account.platform, queryApi: false }
|
||||||
|
})
|
||||||
|
if (res?.success && res.data) {
|
||||||
|
handleBalanceRefreshed(account.id, res.data)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.debug('Failed to reload balance after saving script:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 计算排序后的账户列表
|
// 计算排序后的账户列表
|
||||||
const sortedAccounts = computed(() => {
|
const sortedAccounts = computed(() => {
|
||||||
let sourceAccounts = accounts.value
|
let sourceAccounts = accounts.value
|
||||||
@@ -2711,6 +2878,104 @@ const paginatedAccounts = computed(() => {
|
|||||||
return sortedAccounts.value.slice(start, end)
|
return sortedAccounts.value.slice(start, end)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const canRefreshVisibleBalances = computed(() => {
|
||||||
|
const targets = paginatedAccounts.value
|
||||||
|
if (!Array.isArray(targets) || targets.length === 0) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return targets.some((account) => {
|
||||||
|
const info = account?.balanceInfo
|
||||||
|
return info?.scriptEnabled !== false && !!info?.scriptConfigured
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const refreshBalanceTooltip = computed(() => {
|
||||||
|
if (accountsLoading.value) return '正在加载账户...'
|
||||||
|
if (refreshingBalances.value) return '刷新中...'
|
||||||
|
if (!canRefreshVisibleBalances.value) return '当前页未配置余额脚本,无法刷新'
|
||||||
|
return '刷新当前页余额(仅对已配置余额脚本的账户生效)'
|
||||||
|
})
|
||||||
|
|
||||||
|
// 余额刷新成功回调
|
||||||
|
const handleBalanceRefreshed = (accountId, balanceInfo) => {
|
||||||
|
accounts.value = accounts.value.map((account) => {
|
||||||
|
if (account.id !== accountId) return account
|
||||||
|
return { ...account, balanceInfo }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 余额请求错误回调(仅提示,不中断页面)
|
||||||
|
const handleBalanceError = (_accountId, error) => {
|
||||||
|
const message = error?.message || '余额查询失败'
|
||||||
|
showToast(message, 'error')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 批量刷新当前页余额(触发查询)
|
||||||
|
const refreshVisibleBalances = async () => {
|
||||||
|
if (refreshingBalances.value) return
|
||||||
|
|
||||||
|
const targets = paginatedAccounts.value
|
||||||
|
if (!targets || targets.length === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const eligibleTargets = targets.filter((account) => {
|
||||||
|
const info = account?.balanceInfo
|
||||||
|
return info?.scriptEnabled !== false && !!info?.scriptConfigured
|
||||||
|
})
|
||||||
|
|
||||||
|
if (eligibleTargets.length === 0) {
|
||||||
|
showToast('当前页没有配置余额脚本的账户', 'warning')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const skippedCount = targets.length - eligibleTargets.length
|
||||||
|
|
||||||
|
refreshingBalances.value = true
|
||||||
|
try {
|
||||||
|
const results = await Promise.all(
|
||||||
|
eligibleTargets.map(async (account) => {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.post(`/admin/accounts/${account.id}/balance/refresh`, {
|
||||||
|
platform: account.platform
|
||||||
|
})
|
||||||
|
return { id: account.id, success: !!response?.success, data: response?.data || null }
|
||||||
|
} catch (error) {
|
||||||
|
return { id: account.id, success: false, error: error?.message || '刷新失败' }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
const updatedMap = results.reduce((map, item) => {
|
||||||
|
if (item.success && item.data) {
|
||||||
|
map[item.id] = item.data
|
||||||
|
}
|
||||||
|
return map
|
||||||
|
}, {})
|
||||||
|
|
||||||
|
const successCount = results.filter((r) => r.success).length
|
||||||
|
const failCount = results.length - successCount
|
||||||
|
|
||||||
|
const skippedText = skippedCount > 0 ? `,跳过 ${skippedCount} 个未配置脚本` : ''
|
||||||
|
if (Object.keys(updatedMap).length > 0) {
|
||||||
|
accounts.value = accounts.value.map((account) => {
|
||||||
|
const balanceInfo = updatedMap[account.id]
|
||||||
|
if (!balanceInfo) return account
|
||||||
|
return { ...account, balanceInfo }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (failCount === 0) {
|
||||||
|
showToast(`成功刷新 ${successCount} 个账户余额${skippedText}`, 'success')
|
||||||
|
} else {
|
||||||
|
showToast(`刷新完成:${successCount} 成功,${failCount} 失败${skippedText}`, 'warning')
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
refreshingBalances.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const updateSelectAllState = () => {
|
const updateSelectAllState = () => {
|
||||||
const currentIds = paginatedAccounts.value.map((account) => account.id)
|
const currentIds = paginatedAccounts.value.map((account) => account.id)
|
||||||
const selectedInCurrentPage = currentIds.filter((id) =>
|
const selectedInCurrentPage = currentIds.filter((id) =>
|
||||||
@@ -2761,6 +3026,54 @@ const cleanupSelectedAccounts = () => {
|
|||||||
updateSelectAllState()
|
updateSelectAllState()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 异步加载余额缓存(按平台批量拉取,避免逐行请求)
|
||||||
|
const loadBalanceCacheForAccounts = async () => {
|
||||||
|
const current = accounts.value
|
||||||
|
if (!Array.isArray(current) || current.length === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const platforms = Array.from(new Set(current.map((acc) => acc.platform).filter(Boolean)))
|
||||||
|
if (platforms.length === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const responses = await Promise.all(
|
||||||
|
platforms.map(async (platform) => {
|
||||||
|
try {
|
||||||
|
const res = await apiClient.get(`/admin/accounts/balance/platform/${platform}`, {
|
||||||
|
params: { queryApi: false }
|
||||||
|
})
|
||||||
|
return { platform, success: !!res?.success, data: res?.data || [] }
|
||||||
|
} catch (error) {
|
||||||
|
console.debug(`Failed to load balance cache for ${platform}:`, error)
|
||||||
|
return { platform, success: false, data: [] }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
const balanceMap = responses.reduce((map, item) => {
|
||||||
|
if (!item.success) return map
|
||||||
|
const list = Array.isArray(item.data) ? item.data : []
|
||||||
|
list.forEach((entry) => {
|
||||||
|
const accountId = entry?.data?.accountId
|
||||||
|
if (accountId) {
|
||||||
|
map[accountId] = entry.data
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return map
|
||||||
|
}, {})
|
||||||
|
|
||||||
|
if (Object.keys(balanceMap).length === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
accounts.value = accounts.value.map((account) => ({
|
||||||
|
...account,
|
||||||
|
balanceInfo: balanceMap[account.id] || account.balanceInfo || null
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
// 加载账户列表
|
// 加载账户列表
|
||||||
const loadAccounts = async (forceReload = false) => {
|
const loadAccounts = async (forceReload = false) => {
|
||||||
accountsLoading.value = true
|
accountsLoading.value = true
|
||||||
@@ -2953,6 +3266,11 @@ const loadAccounts = async (forceReload = false) => {
|
|||||||
console.debug('Claude usage loading failed:', err)
|
console.debug('Claude usage loading failed:', err)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 异步加载余额缓存(按平台批量)
|
||||||
|
loadBalanceCacheForAccounts().catch((err) => {
|
||||||
|
console.debug('Balance cache loading failed:', err)
|
||||||
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showToast('加载账户失败', 'error')
|
showToast('加载账户失败', 'error')
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
312
web/admin-spa/src/views/BalanceScriptsView.vue
Normal file
312
web/admin-spa/src/views/BalanceScriptsView.vue
Normal file
@@ -0,0 +1,312 @@
|
|||||||
|
<template>
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div class="flex flex-col gap-4 lg:flex-row">
|
||||||
|
<div class="glass-strong flex-1 rounded-2xl p-4 shadow-lg">
|
||||||
|
<div class="mb-3 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div class="text-lg font-semibold text-gray-900 dark:text-gray-100">脚本余额配置</div>
|
||||||
|
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
使用自定义脚本 + 模板变量适配任意余额接口
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button
|
||||||
|
class="rounded-lg bg-gray-100 px-3 py-2 text-sm font-medium text-gray-700 transition hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-200 dark:hover:bg-gray-600"
|
||||||
|
@click="loadConfig"
|
||||||
|
>
|
||||||
|
重新加载
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-indigo-700"
|
||||||
|
:disabled="saving"
|
||||||
|
@click="saveConfig"
|
||||||
|
>
|
||||||
|
<span v-if="saving">保存中...</span>
|
||||||
|
<span v-else>保存配置</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-4 md:grid-cols-2">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<label class="text-sm font-medium text-gray-700 dark:text-gray-200">API Key</label>
|
||||||
|
<input v-model="form.apiKey" class="input-text" placeholder="sk-xxxx" type="text" />
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<label class="text-sm font-medium text-gray-700 dark:text-gray-200">
|
||||||
|
请求地址(baseUrl)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-model="form.baseUrl"
|
||||||
|
class="input-text"
|
||||||
|
placeholder="https://api.example.com"
|
||||||
|
type="text"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<label class="text-sm font-medium text-gray-700 dark:text-gray-200"
|
||||||
|
>Token(可选)</label
|
||||||
|
>
|
||||||
|
<input v-model="form.token" class="input-text" placeholder="Bearer token" type="text" />
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-2 gap-3">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<label class="text-sm font-medium text-gray-700 dark:text-gray-200"
|
||||||
|
>超时时间(秒)</label
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
v-model.number="form.timeoutSeconds"
|
||||||
|
class="input-text"
|
||||||
|
min="1"
|
||||||
|
type="number"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<label class="text-sm font-medium text-gray-700 dark:text-gray-200">
|
||||||
|
自动查询间隔(分钟)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-model.number="form.autoIntervalMinutes"
|
||||||
|
class="input-text"
|
||||||
|
min="0"
|
||||||
|
type="number"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="md:col-span-2">
|
||||||
|
<label class="text-sm font-medium text-gray-700 dark:text-gray-200">模板变量</label>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
可用变量:{{ '{' }}{{ '{' }}baseUrl{{ '}' }}{{ '}' }}、{{ '{' }}{{ '{' }}apiKey{{ '}'
|
||||||
|
}}{{ '}' }}、{{ '{' }}{{ '{' }}token{{ '}' }}{{ '}' }}、{{ '{' }}{{ '{' }}accountId{{
|
||||||
|
'}'
|
||||||
|
}}{{ '}' }}、{{ '{' }}{{ '{' }}platform{{ '}' }}{{ '}' }}、{{ '{' }}{{ '{' }}extra{{
|
||||||
|
'}'
|
||||||
|
}}{{ '}' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="glass-strong w-full max-w-xl rounded-2xl p-4 shadow-lg">
|
||||||
|
<div class="mb-3 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div class="text-lg font-semibold text-gray-900 dark:text-gray-100">测试脚本</div>
|
||||||
|
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
填入账号上下文(可选),调试 extractor 输出
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="rounded-lg bg-blue-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-blue-700"
|
||||||
|
:disabled="testing"
|
||||||
|
@click="testScript"
|
||||||
|
>
|
||||||
|
<span v-if="testing">测试中...</span>
|
||||||
|
<span v-else>测试脚本</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="grid gap-3">
|
||||||
|
<div class="space-y-1">
|
||||||
|
<label class="text-sm font-medium text-gray-700 dark:text-gray-200">平台</label>
|
||||||
|
<input v-model="testForm.platform" class="input-text" placeholder="例如 claude" />
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<label class="text-sm font-medium text-gray-700 dark:text-gray-200">账号ID</label>
|
||||||
|
<input v-model="testForm.accountId" class="input-text" placeholder="账号标识,可选" />
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<label class="text-sm font-medium text-gray-700 dark:text-gray-200"
|
||||||
|
>额外参数 (extra)</label
|
||||||
|
>
|
||||||
|
<input v-model="testForm.extra" class="input-text" placeholder="可选" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="testResult" class="mt-4 space-y-2 rounded-xl bg-gray-50 p-3 dark:bg-gray-800/60">
|
||||||
|
<div class="flex items-center justify-between text-sm">
|
||||||
|
<span class="font-semibold text-gray-800 dark:text-gray-100">测试结果</span>
|
||||||
|
<span
|
||||||
|
:class="[
|
||||||
|
'rounded px-2 py-0.5 text-xs',
|
||||||
|
testResult.mapped?.status === 'success'
|
||||||
|
? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-200'
|
||||||
|
: 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-200'
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
{{ testResult.mapped?.status || 'unknown' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-gray-600 dark:text-gray-300">
|
||||||
|
<div>余额: {{ displayAmount(testResult.mapped?.balance) }}</div>
|
||||||
|
<div>单位: {{ testResult.mapped?.currency || '—' }}</div>
|
||||||
|
<div v-if="testResult.mapped?.planName">套餐: {{ testResult.mapped.planName }}</div>
|
||||||
|
<div v-if="testResult.mapped?.errorMessage" class="text-red-500">
|
||||||
|
错误: {{ testResult.mapped.errorMessage }}
|
||||||
|
</div>
|
||||||
|
<div v-if="testResult.mapped?.quota">
|
||||||
|
配额: {{ JSON.stringify(testResult.mapped.quota) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<details class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
<summary class="cursor-pointer">查看 extractor 输出</summary>
|
||||||
|
<pre class="mt-2 overflow-auto rounded bg-black/70 p-2 text-[11px] text-gray-100"
|
||||||
|
>{{ formatJson(testResult.extracted) }}
|
||||||
|
</pre
|
||||||
|
>
|
||||||
|
</details>
|
||||||
|
<details class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
<summary class="cursor-pointer">查看原始响应</summary>
|
||||||
|
<pre class="mt-2 overflow-auto rounded bg-black/70 p-2 text-[11px] text-gray-100"
|
||||||
|
>{{ formatJson(testResult.response) }}
|
||||||
|
</pre
|
||||||
|
>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="glass-strong rounded-2xl p-4 shadow-lg">
|
||||||
|
<div class="mb-2 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div class="text-lg font-semibold text-gray-900 dark:text-gray-100">提取器代码</div>
|
||||||
|
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
返回对象需包含 request、extractor;支持模板变量替换
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="rounded-lg bg-gray-100 px-3 py-2 text-sm font-medium text-gray-700 transition hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-200 dark:hover:bg-gray-600"
|
||||||
|
@click="applyPreset"
|
||||||
|
>
|
||||||
|
使用示例模板
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
v-model="form.scriptBody"
|
||||||
|
class="min-h-[320px] w-full rounded-xl bg-gray-900 font-mono text-sm text-gray-100 shadow-inner focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||||
|
spellcheck="false"
|
||||||
|
></textarea>
|
||||||
|
<div class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
extractor
|
||||||
|
返回字段(可选):isValid、invalidMessage、remaining、unit、planName、total、used、extra
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { onMounted, reactive, ref } from 'vue'
|
||||||
|
import { apiClient } from '@/config/api'
|
||||||
|
import { showToast } from '@/utils/toast'
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
baseUrl: '',
|
||||||
|
apiKey: '',
|
||||||
|
token: '',
|
||||||
|
timeoutSeconds: 10,
|
||||||
|
autoIntervalMinutes: 0,
|
||||||
|
scriptBody: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const testForm = reactive({
|
||||||
|
platform: '',
|
||||||
|
accountId: '',
|
||||||
|
extra: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const saving = ref(false)
|
||||||
|
const testing = ref(false)
|
||||||
|
const testResult = ref(null)
|
||||||
|
|
||||||
|
const presetScript = `({
|
||||||
|
request: {
|
||||||
|
url: "{{baseUrl}}/user/balance",
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
"Authorization": "Bearer {{apiKey}}",
|
||||||
|
"User-Agent": "cc-switch/1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
extractor: function(response) {
|
||||||
|
return {
|
||||||
|
isValid: response.is_active || true,
|
||||||
|
remaining: response.balance,
|
||||||
|
unit: "USD",
|
||||||
|
planName: response.plan || "默认套餐"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
})`
|
||||||
|
|
||||||
|
const loadConfig = async () => {
|
||||||
|
try {
|
||||||
|
const res = await apiClient.get('/admin/balance-scripts/default')
|
||||||
|
if (res?.success && res.data) {
|
||||||
|
Object.assign(form, res.data)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showToast('加载配置失败', 'error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveConfig = async () => {
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
const payload = { ...form }
|
||||||
|
await apiClient.put('/admin/balance-scripts/default', payload)
|
||||||
|
showToast('配置已保存', 'success')
|
||||||
|
} catch (error) {
|
||||||
|
showToast(error.message || '保存失败', 'error')
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const testScript = async () => {
|
||||||
|
testing.value = true
|
||||||
|
testResult.value = null
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
...form,
|
||||||
|
...testForm,
|
||||||
|
scriptBody: form.scriptBody
|
||||||
|
}
|
||||||
|
const res = await apiClient.post('/admin/balance-scripts/default/test', payload)
|
||||||
|
if (res?.success) {
|
||||||
|
testResult.value = res.data
|
||||||
|
showToast('测试完成', 'success')
|
||||||
|
} else {
|
||||||
|
showToast(res?.error || '测试失败', 'error')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showToast(error.message || '测试失败', 'error')
|
||||||
|
} finally {
|
||||||
|
testing.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const applyPreset = () => {
|
||||||
|
form.scriptBody = presetScript
|
||||||
|
}
|
||||||
|
|
||||||
|
const displayAmount = (val) => {
|
||||||
|
if (val === null || val === undefined || Number.isNaN(Number(val))) return '—'
|
||||||
|
return Number(val).toFixed(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatJson = (data) => {
|
||||||
|
try {
|
||||||
|
return JSON.stringify(data, null, 2)
|
||||||
|
} catch (error) {
|
||||||
|
return String(data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
applyPreset()
|
||||||
|
loadConfig()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.input-text {
|
||||||
|
@apply w-full rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-800 shadow-sm transition focus:border-indigo-400 focus:outline-none focus:ring-2 focus:ring-indigo-200 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100 dark:focus:border-indigo-500 dark:focus:ring-indigo-600;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -196,6 +196,105 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 账户余额/配额汇总 -->
|
||||||
|
<div class="mb-4 grid grid-cols-1 gap-3 sm:mb-6 sm:grid-cols-2 sm:gap-4 md:mb-8 md:gap-6">
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="mb-1 text-xs font-semibold text-gray-600 dark:text-gray-400 sm:text-sm">
|
||||||
|
账户余额/配额
|
||||||
|
</p>
|
||||||
|
<p class="text-2xl font-bold text-gray-900 dark:text-gray-100 sm:text-3xl">
|
||||||
|
{{ formatCurrencyUsd(balanceSummary.totalBalance || 0) }}
|
||||||
|
</p>
|
||||||
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
低余额: {{ balanceSummary.lowBalanceCount || 0 }} | 总成本:
|
||||||
|
{{ formatCurrencyUsd(balanceSummary.totalCost || 0) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="stat-icon flex-shrink-0 bg-gradient-to-br from-emerald-500 to-green-600">
|
||||||
|
<i class="fas fa-wallet" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-3 flex items-center justify-between gap-3">
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
更新时间: {{ formatLastUpdate(balanceSummaryUpdatedAt) }}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
class="flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-1.5 text-xs font-medium text-gray-700 shadow-sm transition-all duration-200 hover:border-gray-300 hover:shadow-md disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:border-gray-500"
|
||||||
|
:disabled="loadingBalanceSummary"
|
||||||
|
@click="loadBalanceSummary"
|
||||||
|
>
|
||||||
|
<i :class="['fas', loadingBalanceSummary ? 'fa-spinner fa-spin' : 'fa-sync-alt']" />
|
||||||
|
刷新
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card p-4 sm:p-6">
|
||||||
|
<div class="mb-3 flex items-center justify-between">
|
||||||
|
<h3 class="text-sm font-semibold text-gray-900 dark:text-gray-100">低余额账户</h3>
|
||||||
|
<span class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{{ lowBalanceAccounts.length }} 个
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="loadingBalanceSummary"
|
||||||
|
class="py-6 text-center text-sm text-gray-500 dark:text-gray-400"
|
||||||
|
>
|
||||||
|
正在加载...
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-else-if="lowBalanceAccounts.length === 0"
|
||||||
|
class="py-6 text-center text-sm text-green-600 dark:text-green-400"
|
||||||
|
>
|
||||||
|
全部正常
|
||||||
|
</div>
|
||||||
|
<div v-else class="max-h-64 space-y-2 overflow-y-auto">
|
||||||
|
<div
|
||||||
|
v-for="account in lowBalanceAccounts"
|
||||||
|
:key="account.accountId"
|
||||||
|
class="rounded-lg border border-red-200 bg-red-50 p-3 dark:border-red-900/60 dark:bg-red-900/20"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between gap-2">
|
||||||
|
<div class="truncate text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||||
|
{{ account.name || account.accountId }}
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
class="rounded bg-gray-100 px-2 py-0.5 text-xs text-gray-600 dark:bg-gray-700 dark:text-gray-300"
|
||||||
|
>
|
||||||
|
{{ getBalancePlatformLabel(account.platform) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="mt-1 text-xs text-gray-600 dark:text-gray-400">
|
||||||
|
<span v-if="account.balance">余额: {{ account.balance.formattedAmount }}</span>
|
||||||
|
<span v-else
|
||||||
|
>今日成本: {{ formatCurrencyUsd(account.statistics?.dailyCost || 0) }}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div v-if="account.quota && typeof account.quota.percentage === 'number'" class="mt-2">
|
||||||
|
<div
|
||||||
|
class="mb-1 flex items-center justify-between text-xs text-gray-600 dark:text-gray-400"
|
||||||
|
>
|
||||||
|
<span>配额使用</span>
|
||||||
|
<span class="text-red-600 dark:text-red-400">
|
||||||
|
{{ account.quota.percentage.toFixed(1) }}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="h-2 w-full rounded-full bg-gray-200 dark:bg-gray-700">
|
||||||
|
<div
|
||||||
|
class="h-2 rounded-full bg-red-500"
|
||||||
|
:style="{ width: `${Math.min(100, account.quota.percentage)}%` }"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Token统计和性能指标 -->
|
<!-- Token统计和性能指标 -->
|
||||||
<div
|
<div
|
||||||
class="mb-4 grid grid-cols-1 gap-3 sm:mb-6 sm:grid-cols-2 sm:gap-4 md:mb-8 md:gap-6 lg:grid-cols-4"
|
class="mb-4 grid grid-cols-1 gap-3 sm:mb-6 sm:grid-cols-2 sm:gap-4 md:mb-8 md:gap-6 lg:grid-cols-4"
|
||||||
@@ -681,6 +780,8 @@ import { ref, onMounted, onUnmounted, watch, nextTick, computed } from 'vue'
|
|||||||
import { storeToRefs } from 'pinia'
|
import { storeToRefs } from 'pinia'
|
||||||
import { useDashboardStore } from '@/stores/dashboard'
|
import { useDashboardStore } from '@/stores/dashboard'
|
||||||
import { useThemeStore } from '@/stores/theme'
|
import { useThemeStore } from '@/stores/theme'
|
||||||
|
import { apiClient } from '@/config/api'
|
||||||
|
import { showToast } from '@/utils/toast'
|
||||||
import Chart from 'chart.js/auto'
|
import Chart from 'chart.js/auto'
|
||||||
|
|
||||||
const dashboardStore = useDashboardStore()
|
const dashboardStore = useDashboardStore()
|
||||||
@@ -732,6 +833,97 @@ const accountGroupOptions = [
|
|||||||
|
|
||||||
const accountTrendUpdating = ref(false)
|
const accountTrendUpdating = ref(false)
|
||||||
|
|
||||||
|
// 余额/配额汇总
|
||||||
|
const balanceSummary = ref({
|
||||||
|
totalBalance: 0,
|
||||||
|
totalCost: 0,
|
||||||
|
lowBalanceCount: 0,
|
||||||
|
platforms: {}
|
||||||
|
})
|
||||||
|
const loadingBalanceSummary = ref(false)
|
||||||
|
const balanceSummaryUpdatedAt = ref(null)
|
||||||
|
|
||||||
|
const getBalancePlatformLabel = (platform) => {
|
||||||
|
const map = {
|
||||||
|
claude: 'Claude',
|
||||||
|
'claude-console': 'Claude Console',
|
||||||
|
gemini: 'Gemini',
|
||||||
|
'gemini-api': 'Gemini API',
|
||||||
|
openai: 'OpenAI',
|
||||||
|
'openai-responses': 'OpenAI Responses',
|
||||||
|
azure_openai: 'Azure OpenAI',
|
||||||
|
bedrock: 'Bedrock',
|
||||||
|
droid: 'Droid',
|
||||||
|
ccr: 'CCR'
|
||||||
|
}
|
||||||
|
return map[platform] || platform
|
||||||
|
}
|
||||||
|
|
||||||
|
const lowBalanceAccounts = computed(() => {
|
||||||
|
const result = []
|
||||||
|
const platforms = balanceSummary.value?.platforms || {}
|
||||||
|
|
||||||
|
Object.entries(platforms).forEach(([platform, data]) => {
|
||||||
|
const list = Array.isArray(data?.accounts) ? data.accounts : []
|
||||||
|
list.forEach((entry) => {
|
||||||
|
const accountData = entry?.data
|
||||||
|
if (!accountData) return
|
||||||
|
|
||||||
|
const amount = accountData.balance?.amount
|
||||||
|
const percentage = accountData.quota?.percentage
|
||||||
|
|
||||||
|
const isLowBalance = typeof amount === 'number' && amount < 10
|
||||||
|
const isHighUsage = typeof percentage === 'number' && percentage > 90
|
||||||
|
|
||||||
|
if (isLowBalance || isHighUsage) {
|
||||||
|
result.push({
|
||||||
|
...accountData,
|
||||||
|
name: entry?.name || accountData.accountId,
|
||||||
|
platform: accountData.platform || platform
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
|
||||||
|
const formatCurrencyUsd = (amount) => {
|
||||||
|
const value = Number(amount)
|
||||||
|
if (!Number.isFinite(value)) return '$0.00'
|
||||||
|
if (value >= 1) return `$${value.toFixed(2)}`
|
||||||
|
if (value >= 0.01) return `$${value.toFixed(3)}`
|
||||||
|
return `$${value.toFixed(6)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatLastUpdate = (isoString) => {
|
||||||
|
if (!isoString) return '未知'
|
||||||
|
const date = new Date(isoString)
|
||||||
|
if (Number.isNaN(date.getTime())) return '未知'
|
||||||
|
return date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadBalanceSummary = async () => {
|
||||||
|
loadingBalanceSummary.value = true
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get('/admin/accounts/balance/summary')
|
||||||
|
if (response?.success) {
|
||||||
|
balanceSummary.value = response.data || {
|
||||||
|
totalBalance: 0,
|
||||||
|
totalCost: 0,
|
||||||
|
lowBalanceCount: 0,
|
||||||
|
platforms: {}
|
||||||
|
}
|
||||||
|
balanceSummaryUpdatedAt.value = new Date().toISOString()
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.debug('加载余额汇总失败:', error)
|
||||||
|
showToast('加载余额汇总失败', 'error')
|
||||||
|
} finally {
|
||||||
|
loadingBalanceSummary.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 自动刷新相关
|
// 自动刷新相关
|
||||||
const autoRefreshEnabled = ref(false)
|
const autoRefreshEnabled = ref(false)
|
||||||
const autoRefreshInterval = ref(30) // 秒
|
const autoRefreshInterval = ref(30) // 秒
|
||||||
@@ -1488,7 +1680,7 @@ async function refreshAllData() {
|
|||||||
|
|
||||||
isRefreshing.value = true
|
isRefreshing.value = true
|
||||||
try {
|
try {
|
||||||
await Promise.all([loadDashboardData(), refreshChartsData()])
|
await Promise.all([loadDashboardData(), refreshChartsData(), loadBalanceSummary()])
|
||||||
} finally {
|
} finally {
|
||||||
isRefreshing.value = false
|
isRefreshing.value = false
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user