mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-22 16:43:35 +00:00
Compare commits
68 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d81a16b98d | ||
|
|
30727be92f | ||
|
|
b8a6cc627a | ||
|
|
01c63bf5df | ||
|
|
4317962955 | ||
|
|
b66fd7f655 | ||
|
|
ac280ef563 | ||
|
|
c70070d912 | ||
|
|
849d8e047b | ||
|
|
065aa6d35e | ||
|
|
10a1d61427 | ||
|
|
cfdcc97cc7 | ||
|
|
ea053c6a16 | ||
|
|
84a8fdeaba | ||
|
|
c1c941aa4c | ||
|
|
f78e376dea | ||
|
|
530d38e4a4 | ||
|
|
0bf7bfae04 | ||
|
|
fbb660138c | ||
|
|
9c970fda3b | ||
|
|
bfa3f528a2 | ||
|
|
9b0d0bee96 | ||
|
|
ff30bfab82 | ||
|
|
a03753030c | ||
|
|
94aca4dc22 | ||
|
|
6bfef2525a | ||
|
|
5a636a36f6 | ||
|
|
b61e1062bf | ||
|
|
6ab91c0c75 | ||
|
|
675e7b9111 | ||
|
|
f82db11e7d | ||
|
|
06b18b7186 | ||
|
|
12cb841a64 | ||
|
|
dc868522cf | ||
|
|
b1dc27b5d7 | ||
|
|
b94bd2b822 | ||
|
|
827c0f6207 | ||
|
|
0b3cf5112b | ||
|
|
3db268fff7 | ||
|
|
81971436e6 | ||
|
|
69a1006f4c | ||
|
|
4cf1762467 | ||
|
|
0d64d40654 | ||
|
|
1b18a1226d | ||
|
|
0b2372abab | ||
|
|
8aca1f9dd1 | ||
|
|
95ef04c1a3 | ||
|
|
4919e392a5 | ||
|
|
354d8da13f | ||
|
|
3df0c7c650 | ||
|
|
6a3dce523b | ||
|
|
9fe2918a54 | ||
|
|
92b30e1924 | ||
|
|
b63f2f78fc | ||
|
|
c971d239ff | ||
|
|
01d6e30e82 | ||
|
|
5fd78b6411 | ||
|
|
9ad5c85c2c | ||
|
|
279cd72f23 | ||
|
|
81e89d2dc4 | ||
|
|
c38b3d2a78 | ||
|
|
e8e6f972b4 | ||
|
|
d3155b82ea | ||
|
|
02018e10f3 | ||
|
|
e17cd1d61b | ||
|
|
b9d53647bd | ||
|
|
392601efd5 | ||
|
|
249e256360 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -26,6 +26,7 @@ redis_data/
|
||||
|
||||
# Logs directory
|
||||
logs/
|
||||
logs1/
|
||||
*.log
|
||||
startup.log
|
||||
app.log
|
||||
|
||||
88
package-lock.json
generated
88
package-lock.json
generated
@@ -44,6 +44,7 @@
|
||||
"jest": "^29.7.0",
|
||||
"nodemon": "^3.0.1",
|
||||
"prettier": "^3.6.2",
|
||||
"prettier-plugin-tailwindcss": "^0.7.2",
|
||||
"supertest": "^6.3.3"
|
||||
},
|
||||
"engines": {
|
||||
@@ -890,6 +891,7 @@
|
||||
"integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.27.1",
|
||||
"@babel/generator": "^7.28.3",
|
||||
@@ -2998,6 +3000,7 @@
|
||||
"integrity": "sha512-yCAeZl7a0DxgNVteXFHt9+uyFbqXGy/ShC4BlcHkoE0AfGXYv/BUiplV72DjMYXHDBXFjhvr6DD1NiRVfB4j8g==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
@@ -3079,6 +3082,7 @@
|
||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -3534,6 +3538,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"caniuse-lite": "^1.0.30001737",
|
||||
"electron-to-chromium": "^1.5.211",
|
||||
@@ -4421,6 +4426,7 @@
|
||||
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.2.0",
|
||||
"@eslint-community/regexpp": "^4.6.1",
|
||||
@@ -4477,6 +4483,7 @@
|
||||
"integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"eslint-config-prettier": "bin/cli.js"
|
||||
},
|
||||
@@ -7575,6 +7582,7 @@
|
||||
"integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"prettier": "bin/prettier.cjs"
|
||||
},
|
||||
@@ -7598,6 +7606,85 @@
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prettier-plugin-tailwindcss": {
|
||||
"version": "0.7.2",
|
||||
"resolved": "https://registry.npmjs.org/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.7.2.tgz",
|
||||
"integrity": "sha512-LkphyK3Fw+q2HdMOoiEHWf93fNtYJwfamoKPl7UwtjFQdei/iIBoX11G6j706FzN3ymX9mPVi97qIY8328vdnA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"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
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/pretty-format": {
|
||||
"version": "29.7.0",
|
||||
"resolved": "https://registry.npmmirror.com/pretty-format/-/pretty-format-29.7.0.tgz",
|
||||
@@ -9014,6 +9101,7 @@
|
||||
"resolved": "https://registry.npmmirror.com/winston/-/winston-3.17.0.tgz",
|
||||
"integrity": "sha512-DLiFIXYC5fMPxaRg832S6F5mJYvePtmO5G9v9IgUFPhXm9/GkXarH/TUrBAVzhTCzAj9anE/+GjrgXp/54nOgw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@colors/colors": "^1.6.0",
|
||||
"@dabh/diagnostics": "^2.0.2",
|
||||
|
||||
@@ -83,6 +83,7 @@
|
||||
"jest": "^29.7.0",
|
||||
"nodemon": "^3.0.1",
|
||||
"prettier": "^3.6.2",
|
||||
"prettier-plugin-tailwindcss": "^0.7.2",
|
||||
"supertest": "^6.3.3"
|
||||
},
|
||||
"engines": {
|
||||
|
||||
6357
pnpm-lock.yaml
generated
Normal file
6357
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
108
scripts/test-official-models.js
Normal file
108
scripts/test-official-models.js
Normal file
@@ -0,0 +1,108 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* 官方模型版本识别测试 - 最终版 v2
|
||||
*/
|
||||
|
||||
const { isOpus45OrNewer } = require('../src/utils/modelHelper')
|
||||
|
||||
// 官方模型
|
||||
const officialModels = [
|
||||
{ name: 'claude-3-opus-20240229', desc: 'Opus 3 (已弃用)', expectPro: false },
|
||||
{ name: 'claude-opus-4-20250514', desc: 'Opus 4.0', expectPro: false },
|
||||
{ name: 'claude-opus-4-1-20250805', desc: 'Opus 4.1', expectPro: false },
|
||||
{ name: 'claude-opus-4-5-20251101', desc: 'Opus 4.5', expectPro: true }
|
||||
]
|
||||
|
||||
// 非 Opus 模型
|
||||
const nonOpusModels = [
|
||||
{ name: 'claude-sonnet-4-20250514', desc: 'Sonnet 4' },
|
||||
{ name: 'claude-sonnet-4-5-20250929', desc: 'Sonnet 4.5' },
|
||||
{ name: 'claude-haiku-4-5-20251001', desc: 'Haiku 4.5' },
|
||||
{ name: 'claude-3-5-haiku-20241022', desc: 'Haiku 3.5' },
|
||||
{ name: 'claude-3-haiku-20240307', desc: 'Haiku 3' },
|
||||
{ name: 'claude-3-7-sonnet-20250219', desc: 'Sonnet 3.7 (已弃用)' }
|
||||
]
|
||||
|
||||
// 其他格式测试
|
||||
const otherFormats = [
|
||||
{ name: 'claude-opus-4.5', expected: true, desc: 'Opus 4.5 点分隔' },
|
||||
{ name: 'claude-opus-4-5', expected: true, desc: 'Opus 4.5 横线分隔' },
|
||||
{ name: 'opus-4.5', expected: true, desc: 'Opus 4.5 无前缀' },
|
||||
{ name: 'opus-4-5', expected: true, desc: 'Opus 4-5 无前缀' },
|
||||
{ name: 'opus-latest', expected: true, desc: 'Opus latest' },
|
||||
{ name: 'claude-opus-5', expected: true, desc: 'Opus 5 (未来)' },
|
||||
{ name: 'claude-opus-5-0', expected: true, desc: 'Opus 5.0 (未来)' },
|
||||
{ name: 'opus-4.0', expected: false, desc: 'Opus 4.0' },
|
||||
{ name: 'opus-4.1', expected: false, desc: 'Opus 4.1' },
|
||||
{ name: 'opus-4.4', expected: false, desc: 'Opus 4.4' },
|
||||
{ name: 'opus-4', expected: false, desc: 'Opus 4' },
|
||||
{ name: 'opus-4-0', expected: false, desc: 'Opus 4-0' },
|
||||
{ name: 'opus-4-1', expected: false, desc: 'Opus 4-1' },
|
||||
{ name: 'opus-4-4', expected: false, desc: 'Opus 4-4' },
|
||||
{ name: 'opus', expected: false, desc: '仅 opus' },
|
||||
{ name: null, expected: false, desc: 'null' },
|
||||
{ name: '', expected: false, desc: '空字符串' }
|
||||
]
|
||||
|
||||
console.log('='.repeat(90))
|
||||
console.log('官方模型版本识别测试 - 最终版 v2')
|
||||
console.log('='.repeat(90))
|
||||
console.log()
|
||||
|
||||
let passed = 0
|
||||
let failed = 0
|
||||
|
||||
// 测试官方 Opus 模型
|
||||
console.log('📌 官方 Opus 模型:')
|
||||
for (const m of officialModels) {
|
||||
const result = isOpus45OrNewer(m.name)
|
||||
const status = result === m.expectPro ? '✅ PASS' : '❌ FAIL'
|
||||
if (result === m.expectPro) {
|
||||
passed++
|
||||
} else {
|
||||
failed++
|
||||
}
|
||||
const proSupport = result ? 'Pro 可用 ✅' : 'Pro 不可用 ❌'
|
||||
console.log(` ${status} | ${m.name.padEnd(32)} | ${m.desc.padEnd(18)} | ${proSupport}`)
|
||||
}
|
||||
|
||||
console.log()
|
||||
console.log('📌 非 Opus 模型 (不受此函数影响):')
|
||||
for (const m of nonOpusModels) {
|
||||
const result = isOpus45OrNewer(m.name)
|
||||
console.log(
|
||||
` ➖ | ${m.name.padEnd(32)} | ${m.desc.padEnd(18)} | ${result ? '⚠️ 异常' : '正确跳过'}`
|
||||
)
|
||||
if (result) {
|
||||
failed++ // 非 Opus 模型不应返回 true
|
||||
}
|
||||
}
|
||||
|
||||
console.log()
|
||||
console.log('📌 其他格式测试:')
|
||||
for (const m of otherFormats) {
|
||||
const result = isOpus45OrNewer(m.name)
|
||||
const status = result === m.expected ? '✅ PASS' : '❌ FAIL'
|
||||
if (result === m.expected) {
|
||||
passed++
|
||||
} else {
|
||||
failed++
|
||||
}
|
||||
const display = m.name === null ? 'null' : m.name === '' ? '""' : m.name
|
||||
console.log(
|
||||
` ${status} | ${display.padEnd(25)} | ${m.desc.padEnd(18)} | ${result ? 'Pro 可用' : 'Pro 不可用'}`
|
||||
)
|
||||
}
|
||||
|
||||
console.log()
|
||||
console.log('='.repeat(90))
|
||||
console.log('测试结果:', passed, '通过,', failed, '失败')
|
||||
console.log('='.repeat(90))
|
||||
|
||||
if (failed > 0) {
|
||||
console.log('\n❌ 有测试失败,请检查函数逻辑')
|
||||
process.exit(1)
|
||||
} else {
|
||||
console.log('\n✅ 所有测试通过!函数可以安全使用')
|
||||
process.exit(0)
|
||||
}
|
||||
@@ -449,9 +449,8 @@ async function handleMessages(req, res) {
|
||||
|
||||
// 添加代理配置
|
||||
if (proxyConfig) {
|
||||
const proxyHelper = new ProxyHelper()
|
||||
axiosConfig.httpsAgent = proxyHelper.createProxyAgent(proxyConfig)
|
||||
axiosConfig.httpAgent = proxyHelper.createProxyAgent(proxyConfig)
|
||||
axiosConfig.httpsAgent = ProxyHelper.createProxyAgent(proxyConfig)
|
||||
axiosConfig.httpAgent = ProxyHelper.createProxyAgent(proxyConfig)
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -732,9 +731,8 @@ async function handleModels(req, res) {
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
}
|
||||
if (proxyConfig) {
|
||||
const proxyHelper = new ProxyHelper()
|
||||
axiosConfig.httpsAgent = proxyHelper.createProxyAgent(proxyConfig)
|
||||
axiosConfig.httpAgent = proxyHelper.createProxyAgent(proxyConfig)
|
||||
axiosConfig.httpsAgent = ProxyHelper.createProxyAgent(proxyConfig)
|
||||
axiosConfig.httpAgent = ProxyHelper.createProxyAgent(proxyConfig)
|
||||
}
|
||||
const response = await axios(axiosConfig)
|
||||
models = (response.data.models || []).map((m) => ({
|
||||
@@ -1234,9 +1232,8 @@ async function handleCountTokens(req, res) {
|
||||
}
|
||||
|
||||
if (proxyConfig) {
|
||||
const proxyHelper = new ProxyHelper()
|
||||
axiosConfig.httpsAgent = proxyHelper.createProxyAgent(proxyConfig)
|
||||
axiosConfig.httpAgent = proxyHelper.createProxyAgent(proxyConfig)
|
||||
axiosConfig.httpsAgent = ProxyHelper.createProxyAgent(proxyConfig)
|
||||
axiosConfig.httpAgent = ProxyHelper.createProxyAgent(proxyConfig)
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -1963,9 +1960,8 @@ async function handleStandardGenerateContent(req, res) {
|
||||
}
|
||||
|
||||
if (proxyConfig) {
|
||||
const proxyHelper = new ProxyHelper()
|
||||
axiosConfig.httpsAgent = proxyHelper.createProxyAgent(proxyConfig)
|
||||
axiosConfig.httpAgent = proxyHelper.createProxyAgent(proxyConfig)
|
||||
axiosConfig.httpsAgent = ProxyHelper.createProxyAgent(proxyConfig)
|
||||
axiosConfig.httpAgent = ProxyHelper.createProxyAgent(proxyConfig)
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -2246,9 +2242,8 @@ async function handleStandardStreamGenerateContent(req, res) {
|
||||
}
|
||||
|
||||
if (proxyConfig) {
|
||||
const proxyHelper = new ProxyHelper()
|
||||
axiosConfig.httpsAgent = proxyHelper.createProxyAgent(proxyConfig)
|
||||
axiosConfig.httpAgent = proxyHelper.createProxyAgent(proxyConfig)
|
||||
axiosConfig.httpsAgent = ProxyHelper.createProxyAgent(proxyConfig)
|
||||
axiosConfig.httpAgent = ProxyHelper.createProxyAgent(proxyConfig)
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
@@ -255,6 +255,108 @@ router.post('/claude-accounts/exchange-setup-token-code', authenticateAdmin, asy
|
||||
}
|
||||
})
|
||||
|
||||
// =============================================================================
|
||||
// Cookie自动授权端点 (基于sessionKey自动完成OAuth流程)
|
||||
// =============================================================================
|
||||
|
||||
// 普通OAuth的Cookie自动授权
|
||||
router.post('/claude-accounts/oauth-with-cookie', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { sessionKey, proxy } = req.body
|
||||
|
||||
// 验证sessionKey参数
|
||||
if (!sessionKey || typeof sessionKey !== 'string' || sessionKey.trim().length === 0) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'sessionKey不能为空',
|
||||
message: '请提供有效的sessionKey值'
|
||||
})
|
||||
}
|
||||
|
||||
const trimmedSessionKey = sessionKey.trim()
|
||||
|
||||
logger.info('🍪 Starting Cookie-based OAuth authorization', {
|
||||
sessionKeyLength: trimmedSessionKey.length,
|
||||
sessionKeyPrefix: trimmedSessionKey.substring(0, 10) + '...',
|
||||
hasProxy: !!proxy
|
||||
})
|
||||
|
||||
// 执行Cookie自动授权流程
|
||||
const result = await oauthHelper.oauthWithCookie(trimmedSessionKey, proxy, false)
|
||||
|
||||
logger.success('🎉 Cookie-based OAuth authorization completed successfully')
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: {
|
||||
claudeAiOauth: result.claudeAiOauth,
|
||||
organizationUuid: result.organizationUuid,
|
||||
capabilities: result.capabilities
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('❌ Cookie-based OAuth authorization failed:', {
|
||||
error: error.message,
|
||||
sessionKeyLength: req.body.sessionKey ? req.body.sessionKey.length : 0
|
||||
})
|
||||
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
error: 'Cookie授权失败',
|
||||
message: error.message
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// Setup Token的Cookie自动授权
|
||||
router.post('/claude-accounts/setup-token-with-cookie', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { sessionKey, proxy } = req.body
|
||||
|
||||
// 验证sessionKey参数
|
||||
if (!sessionKey || typeof sessionKey !== 'string' || sessionKey.trim().length === 0) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'sessionKey不能为空',
|
||||
message: '请提供有效的sessionKey值'
|
||||
})
|
||||
}
|
||||
|
||||
const trimmedSessionKey = sessionKey.trim()
|
||||
|
||||
logger.info('🍪 Starting Cookie-based Setup Token authorization', {
|
||||
sessionKeyLength: trimmedSessionKey.length,
|
||||
sessionKeyPrefix: trimmedSessionKey.substring(0, 10) + '...',
|
||||
hasProxy: !!proxy
|
||||
})
|
||||
|
||||
// 执行Cookie自动授权流程(Setup Token模式)
|
||||
const result = await oauthHelper.oauthWithCookie(trimmedSessionKey, proxy, true)
|
||||
|
||||
logger.success('🎉 Cookie-based Setup Token authorization completed successfully')
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: {
|
||||
claudeAiOauth: result.claudeAiOauth,
|
||||
organizationUuid: result.organizationUuid,
|
||||
capabilities: result.capabilities
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('❌ Cookie-based Setup Token authorization failed:', {
|
||||
error: error.message,
|
||||
sessionKeyLength: req.body.sessionKey ? req.body.sessionKey.length : 0
|
||||
})
|
||||
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
error: 'Cookie授权失败',
|
||||
message: error.message
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 获取所有Claude账户
|
||||
router.get('/claude-accounts', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
|
||||
@@ -131,7 +131,8 @@ router.post('/claude-console-accounts', authenticateAdmin, async (req, res) => {
|
||||
groupId,
|
||||
dailyQuota,
|
||||
quotaResetTime,
|
||||
maxConcurrentTasks
|
||||
maxConcurrentTasks,
|
||||
disableAutoProtection
|
||||
} = req.body
|
||||
|
||||
if (!name || !apiUrl || !apiKey) {
|
||||
@@ -151,6 +152,10 @@ router.post('/claude-console-accounts', authenticateAdmin, async (req, res) => {
|
||||
}
|
||||
}
|
||||
|
||||
// 校验上游错误自动防护开关
|
||||
const normalizedDisableAutoProtection =
|
||||
disableAutoProtection === true || disableAutoProtection === 'true'
|
||||
|
||||
// 验证accountType的有效性
|
||||
if (accountType && !['shared', 'dedicated', 'group'].includes(accountType)) {
|
||||
return res
|
||||
@@ -180,7 +185,8 @@ router.post('/claude-console-accounts', authenticateAdmin, async (req, res) => {
|
||||
maxConcurrentTasks:
|
||||
maxConcurrentTasks !== undefined && maxConcurrentTasks !== null
|
||||
? Number(maxConcurrentTasks)
|
||||
: 0
|
||||
: 0,
|
||||
disableAutoProtection: normalizedDisableAutoProtection
|
||||
})
|
||||
|
||||
// 如果是分组类型,将账户添加到分组(CCR 归属 Claude 平台分组)
|
||||
@@ -250,6 +256,13 @@ router.put('/claude-console-accounts/:accountId', authenticateAdmin, async (req,
|
||||
return res.status(404).json({ error: 'Account not found' })
|
||||
}
|
||||
|
||||
// 规范化上游错误自动防护开关
|
||||
if (mappedUpdates.disableAutoProtection !== undefined) {
|
||||
mappedUpdates.disableAutoProtection =
|
||||
mappedUpdates.disableAutoProtection === true ||
|
||||
mappedUpdates.disableAutoProtection === 'true'
|
||||
}
|
||||
|
||||
// 处理分组的变更
|
||||
if (mappedUpdates.accountType !== undefined) {
|
||||
// 如果之前是分组类型,需要从所有分组中移除
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
const express = require('express')
|
||||
const apiKeyService = require('../../services/apiKeyService')
|
||||
const ccrAccountService = require('../../services/ccrAccountService')
|
||||
const claudeAccountService = require('../../services/claudeAccountService')
|
||||
const claudeConsoleAccountService = require('../../services/claudeConsoleAccountService')
|
||||
const geminiAccountService = require('../../services/geminiAccountService')
|
||||
const geminiApiAccountService = require('../../services/geminiApiAccountService')
|
||||
const openaiAccountService = require('../../services/openaiAccountService')
|
||||
const openaiResponsesAccountService = require('../../services/openaiResponsesAccountService')
|
||||
const droidAccountService = require('../../services/droidAccountService')
|
||||
@@ -14,6 +16,65 @@ const pricingService = require('../../services/pricingService')
|
||||
|
||||
const router = express.Router()
|
||||
|
||||
const accountTypeNames = {
|
||||
claude: 'Claude官方',
|
||||
'claude-console': 'Claude Console',
|
||||
ccr: 'Claude Console Relay',
|
||||
openai: 'OpenAI',
|
||||
'openai-responses': 'OpenAI Responses',
|
||||
gemini: 'Gemini',
|
||||
'gemini-api': 'Gemini API',
|
||||
droid: 'Droid',
|
||||
unknown: '未知渠道'
|
||||
}
|
||||
|
||||
const resolveAccountByPlatform = async (accountId, platform) => {
|
||||
const serviceMap = {
|
||||
claude: claudeAccountService,
|
||||
'claude-console': claudeConsoleAccountService,
|
||||
gemini: geminiAccountService,
|
||||
'gemini-api': geminiApiAccountService,
|
||||
openai: openaiAccountService,
|
||||
'openai-responses': openaiResponsesAccountService,
|
||||
droid: droidAccountService,
|
||||
ccr: ccrAccountService
|
||||
}
|
||||
|
||||
if (platform && serviceMap[platform]) {
|
||||
try {
|
||||
const account = await serviceMap[platform].getAccount(accountId)
|
||||
if (account) {
|
||||
return { ...account, platform }
|
||||
}
|
||||
} catch (error) {
|
||||
logger.debug(`⚠️ Failed to get account ${accountId} from ${platform}: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
for (const [platformName, service] of Object.entries(serviceMap)) {
|
||||
try {
|
||||
const account = await service.getAccount(accountId)
|
||||
if (account) {
|
||||
return { ...account, platform: platformName }
|
||||
}
|
||||
} catch (error) {
|
||||
logger.debug(`⚠️ Failed to get account ${accountId} from ${platformName}: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const getApiKeyName = async (keyId) => {
|
||||
try {
|
||||
const keyData = await redis.getApiKey(keyId)
|
||||
return keyData?.name || keyData?.label || keyId
|
||||
} catch (error) {
|
||||
logger.debug(`⚠️ Failed to get API key name for ${keyId}: ${error.message}`)
|
||||
return keyId
|
||||
}
|
||||
}
|
||||
|
||||
// 📊 账户使用统计
|
||||
|
||||
// 获取所有账户的使用统计
|
||||
@@ -148,7 +209,6 @@ router.get('/accounts/:accountId/usage-history', authenticateAdmin, async (req,
|
||||
accountData = await geminiAccountService.getAccount(accountId)
|
||||
break
|
||||
case 'gemini-api': {
|
||||
const geminiApiAccountService = require('../../services/geminiApiAccountService')
|
||||
accountData = await geminiApiAccountService.getAccount(accountId)
|
||||
break
|
||||
}
|
||||
@@ -369,7 +429,9 @@ router.get('/usage-trend', authenticateAdmin, async (req, res) => {
|
||||
logger.info(` endDate (raw): ${endDate}`)
|
||||
logger.info(` startTime (parsed): ${startTime.toISOString()}`)
|
||||
logger.info(` endTime (parsed): ${endTime.toISOString()}`)
|
||||
logger.info(` System timezone offset: ${require('../../../config/config').system.timezoneOffset || 8}`)
|
||||
logger.info(
|
||||
` System timezone offset: ${require('../../../config/config').system.timezoneOffset || 8}`
|
||||
)
|
||||
} else {
|
||||
// 默认最近24小时
|
||||
endTime = new Date()
|
||||
@@ -890,7 +952,6 @@ router.get('/account-usage-trend', authenticateAdmin, async (req, res) => {
|
||||
})
|
||||
]
|
||||
} else if (group === 'gemini') {
|
||||
const geminiApiAccountService = require('../../services/geminiApiAccountService')
|
||||
const [geminiAccounts, geminiApiAccounts] = await Promise.all([
|
||||
geminiAccountService.getAllAccounts(),
|
||||
geminiApiAccountService.getAllAccounts(true)
|
||||
@@ -1818,4 +1879,628 @@ router.get('/usage-costs', authenticateAdmin, async (req, res) => {
|
||||
}
|
||||
})
|
||||
|
||||
// 获取 API Key 的请求记录时间线
|
||||
router.get('/api-keys/:keyId/usage-records', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { keyId } = req.params
|
||||
const {
|
||||
page = 1,
|
||||
pageSize = 50,
|
||||
startDate,
|
||||
endDate,
|
||||
model,
|
||||
accountId,
|
||||
sortOrder = 'desc'
|
||||
} = req.query
|
||||
|
||||
const pageNumber = Math.max(parseInt(page, 10) || 1, 1)
|
||||
const pageSizeNumber = Math.min(Math.max(parseInt(pageSize, 10) || 50, 1), 200)
|
||||
const normalizedSortOrder = sortOrder === 'asc' ? 'asc' : 'desc'
|
||||
|
||||
const startTime = startDate ? new Date(startDate) : null
|
||||
const endTime = endDate ? new Date(endDate) : null
|
||||
|
||||
if (
|
||||
(startDate && Number.isNaN(startTime?.getTime())) ||
|
||||
(endDate && Number.isNaN(endTime?.getTime()))
|
||||
) {
|
||||
return res.status(400).json({ success: false, error: 'Invalid date range' })
|
||||
}
|
||||
|
||||
if (startTime && endTime && startTime > endTime) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ success: false, error: 'Start date must be before or equal to end date' })
|
||||
}
|
||||
|
||||
const apiKeyInfo = await redis.getApiKey(keyId)
|
||||
if (!apiKeyInfo || Object.keys(apiKeyInfo).length === 0) {
|
||||
return res.status(404).json({ success: false, error: 'API key not found' })
|
||||
}
|
||||
|
||||
const rawRecords = await redis.getUsageRecords(keyId, 5000)
|
||||
|
||||
const accountServices = [
|
||||
{ type: 'claude', getter: (id) => claudeAccountService.getAccount(id) },
|
||||
{ type: 'claude-console', getter: (id) => claudeConsoleAccountService.getAccount(id) },
|
||||
{ type: 'ccr', getter: (id) => ccrAccountService.getAccount(id) },
|
||||
{ type: 'openai', getter: (id) => openaiAccountService.getAccount(id) },
|
||||
{ type: 'openai-responses', getter: (id) => openaiResponsesAccountService.getAccount(id) },
|
||||
{ type: 'gemini', getter: (id) => geminiAccountService.getAccount(id) },
|
||||
{ type: 'gemini-api', getter: (id) => geminiApiAccountService.getAccount(id) },
|
||||
{ type: 'droid', getter: (id) => droidAccountService.getAccount(id) }
|
||||
]
|
||||
|
||||
const accountCache = new Map()
|
||||
const resolveAccountInfo = async (id, type) => {
|
||||
if (!id) {
|
||||
return null
|
||||
}
|
||||
|
||||
const cacheKey = `${type || 'any'}:${id}`
|
||||
if (accountCache.has(cacheKey)) {
|
||||
return accountCache.get(cacheKey)
|
||||
}
|
||||
|
||||
let servicesToTry = type
|
||||
? accountServices.filter((svc) => svc.type === type)
|
||||
: accountServices
|
||||
|
||||
// 若渠道改名或传入未知类型,回退尝试全量服务,避免漏解析历史账号
|
||||
if (!servicesToTry.length) {
|
||||
servicesToTry = accountServices
|
||||
}
|
||||
|
||||
for (const service of servicesToTry) {
|
||||
try {
|
||||
const account = await service.getter(id)
|
||||
if (account) {
|
||||
const info = {
|
||||
id,
|
||||
name: account.name || account.email || id,
|
||||
type: service.type,
|
||||
status: account.status || account.isActive
|
||||
}
|
||||
accountCache.set(cacheKey, info)
|
||||
return info
|
||||
}
|
||||
} catch (error) {
|
||||
logger.debug(`⚠️ Failed to resolve account ${id} via ${service.type}: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
accountCache.set(cacheKey, null)
|
||||
return null
|
||||
}
|
||||
|
||||
const toUsageObject = (record) => ({
|
||||
input_tokens: record.inputTokens || 0,
|
||||
output_tokens: record.outputTokens || 0,
|
||||
cache_creation_input_tokens: record.cacheCreateTokens || 0,
|
||||
cache_read_input_tokens: record.cacheReadTokens || 0,
|
||||
cache_creation: record.cacheCreation || record.cache_creation || null
|
||||
})
|
||||
|
||||
const withinRange = (record) => {
|
||||
if (!record.timestamp) {
|
||||
return false
|
||||
}
|
||||
const ts = new Date(record.timestamp)
|
||||
if (Number.isNaN(ts.getTime())) {
|
||||
return false
|
||||
}
|
||||
if (startTime && ts < startTime) {
|
||||
return false
|
||||
}
|
||||
if (endTime && ts > endTime) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
const filteredRecords = rawRecords.filter((record) => {
|
||||
if (!withinRange(record)) {
|
||||
return false
|
||||
}
|
||||
if (model && record.model !== model) {
|
||||
return false
|
||||
}
|
||||
if (accountId && record.accountId !== accountId) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
filteredRecords.sort((a, b) => {
|
||||
const aTime = new Date(a.timestamp).getTime()
|
||||
const bTime = new Date(b.timestamp).getTime()
|
||||
if (Number.isNaN(aTime) || Number.isNaN(bTime)) {
|
||||
return 0
|
||||
}
|
||||
return normalizedSortOrder === 'asc' ? aTime - bTime : bTime - aTime
|
||||
})
|
||||
|
||||
const summary = {
|
||||
totalRequests: 0,
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cacheCreateTokens: 0,
|
||||
cacheReadTokens: 0,
|
||||
totalTokens: 0,
|
||||
totalCost: 0
|
||||
}
|
||||
|
||||
const modelSet = new Set()
|
||||
const accountOptionMap = new Map()
|
||||
let earliestTimestamp = null
|
||||
let latestTimestamp = null
|
||||
|
||||
for (const record of filteredRecords) {
|
||||
const usage = toUsageObject(record)
|
||||
const costData = CostCalculator.calculateCost(usage, record.model || 'unknown')
|
||||
const computedCost =
|
||||
typeof record.cost === 'number' ? record.cost : costData?.costs?.total || 0
|
||||
const totalTokens =
|
||||
record.totalTokens ||
|
||||
usage.input_tokens +
|
||||
usage.output_tokens +
|
||||
usage.cache_creation_input_tokens +
|
||||
usage.cache_read_input_tokens
|
||||
|
||||
summary.totalRequests += 1
|
||||
summary.inputTokens += usage.input_tokens
|
||||
summary.outputTokens += usage.output_tokens
|
||||
summary.cacheCreateTokens += usage.cache_creation_input_tokens
|
||||
summary.cacheReadTokens += usage.cache_read_input_tokens
|
||||
summary.totalTokens += totalTokens
|
||||
summary.totalCost += computedCost
|
||||
|
||||
if (record.model) {
|
||||
modelSet.add(record.model)
|
||||
}
|
||||
|
||||
if (record.accountId) {
|
||||
const normalizedType = record.accountType || 'unknown'
|
||||
if (!accountOptionMap.has(record.accountId)) {
|
||||
accountOptionMap.set(record.accountId, {
|
||||
id: record.accountId,
|
||||
accountTypes: new Set([normalizedType])
|
||||
})
|
||||
} else {
|
||||
accountOptionMap.get(record.accountId).accountTypes.add(normalizedType)
|
||||
}
|
||||
}
|
||||
|
||||
if (record.timestamp) {
|
||||
const ts = new Date(record.timestamp)
|
||||
if (!Number.isNaN(ts.getTime())) {
|
||||
if (!earliestTimestamp || ts < earliestTimestamp) {
|
||||
earliestTimestamp = ts
|
||||
}
|
||||
if (!latestTimestamp || ts > latestTimestamp) {
|
||||
latestTimestamp = ts
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const totalRecords = filteredRecords.length
|
||||
const totalPages = totalRecords > 0 ? Math.ceil(totalRecords / pageSizeNumber) : 0
|
||||
const safePage = totalPages > 0 ? Math.min(pageNumber, totalPages) : 1
|
||||
const startIndex = (safePage - 1) * pageSizeNumber
|
||||
const pageRecords =
|
||||
totalRecords === 0 ? [] : filteredRecords.slice(startIndex, startIndex + pageSizeNumber)
|
||||
|
||||
const enrichedRecords = []
|
||||
for (const record of pageRecords) {
|
||||
const usage = toUsageObject(record)
|
||||
const costData = CostCalculator.calculateCost(usage, record.model || 'unknown')
|
||||
const computedCost =
|
||||
typeof record.cost === 'number' ? record.cost : costData?.costs?.total || 0
|
||||
const totalTokens =
|
||||
record.totalTokens ||
|
||||
usage.input_tokens +
|
||||
usage.output_tokens +
|
||||
usage.cache_creation_input_tokens +
|
||||
usage.cache_read_input_tokens
|
||||
|
||||
const accountInfo = await resolveAccountInfo(record.accountId, record.accountType)
|
||||
const resolvedAccountType = accountInfo?.type || record.accountType || 'unknown'
|
||||
|
||||
enrichedRecords.push({
|
||||
timestamp: record.timestamp,
|
||||
model: record.model || 'unknown',
|
||||
accountId: record.accountId || null,
|
||||
accountName: accountInfo?.name || null,
|
||||
accountStatus: accountInfo?.status ?? null,
|
||||
accountType: resolvedAccountType,
|
||||
accountTypeName: accountTypeNames[resolvedAccountType] || '未知渠道',
|
||||
inputTokens: usage.input_tokens,
|
||||
outputTokens: usage.output_tokens,
|
||||
cacheCreateTokens: usage.cache_creation_input_tokens,
|
||||
cacheReadTokens: usage.cache_read_input_tokens,
|
||||
ephemeral5mTokens: record.ephemeral5mTokens || 0,
|
||||
ephemeral1hTokens: record.ephemeral1hTokens || 0,
|
||||
totalTokens,
|
||||
isLongContextRequest: record.isLongContext || record.isLongContextRequest || false,
|
||||
cost: Number(computedCost.toFixed(6)),
|
||||
costFormatted:
|
||||
record.costFormatted ||
|
||||
costData?.formatted?.total ||
|
||||
CostCalculator.formatCost(computedCost),
|
||||
costBreakdown: record.costBreakdown || {
|
||||
input: costData?.costs?.input || 0,
|
||||
output: costData?.costs?.output || 0,
|
||||
cacheCreate: costData?.costs?.cacheWrite || 0,
|
||||
cacheRead: costData?.costs?.cacheRead || 0,
|
||||
total: costData?.costs?.total || computedCost
|
||||
},
|
||||
responseTime: record.responseTime || null
|
||||
})
|
||||
}
|
||||
|
||||
const accountOptions = []
|
||||
for (const option of accountOptionMap.values()) {
|
||||
const types = Array.from(option.accountTypes || [])
|
||||
|
||||
// 优先按历史出现的 accountType 解析,若失败则回退全量解析
|
||||
let resolvedInfo = null
|
||||
for (const type of types) {
|
||||
resolvedInfo = await resolveAccountInfo(option.id, type)
|
||||
if (resolvedInfo && resolvedInfo.name) {
|
||||
break
|
||||
}
|
||||
}
|
||||
if (!resolvedInfo) {
|
||||
resolvedInfo = await resolveAccountInfo(option.id)
|
||||
}
|
||||
|
||||
const chosenType = resolvedInfo?.type || types[0] || 'unknown'
|
||||
const chosenTypeName = accountTypeNames[chosenType] || '未知渠道'
|
||||
|
||||
if (!resolvedInfo) {
|
||||
logger.warn(`⚠️ 保留无法解析的账户筛选项: ${option.id}, types=${types.join(',') || 'none'}`)
|
||||
}
|
||||
|
||||
accountOptions.push({
|
||||
id: option.id,
|
||||
name: resolvedInfo?.name || option.id,
|
||||
accountType: chosenType,
|
||||
accountTypeName: chosenTypeName,
|
||||
rawTypes: types
|
||||
})
|
||||
}
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: {
|
||||
records: enrichedRecords,
|
||||
pagination: {
|
||||
currentPage: safePage,
|
||||
pageSize: pageSizeNumber,
|
||||
totalRecords,
|
||||
totalPages,
|
||||
hasNextPage: totalPages > 0 && safePage < totalPages,
|
||||
hasPreviousPage: totalPages > 0 && safePage > 1
|
||||
},
|
||||
filters: {
|
||||
startDate: startTime ? startTime.toISOString() : null,
|
||||
endDate: endTime ? endTime.toISOString() : null,
|
||||
model: model || null,
|
||||
accountId: accountId || null,
|
||||
sortOrder: normalizedSortOrder
|
||||
},
|
||||
apiKeyInfo: {
|
||||
id: keyId,
|
||||
name: apiKeyInfo.name || apiKeyInfo.label || keyId
|
||||
},
|
||||
summary: {
|
||||
...summary,
|
||||
totalCost: Number(summary.totalCost.toFixed(6)),
|
||||
avgCost:
|
||||
summary.totalRequests > 0
|
||||
? Number((summary.totalCost / summary.totalRequests).toFixed(6))
|
||||
: 0
|
||||
},
|
||||
availableFilters: {
|
||||
models: Array.from(modelSet),
|
||||
accounts: accountOptions,
|
||||
dateRange: {
|
||||
earliest: earliestTimestamp ? earliestTimestamp.toISOString() : null,
|
||||
latest: latestTimestamp ? latestTimestamp.toISOString() : null
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to get API key usage records:', error)
|
||||
return res
|
||||
.status(500)
|
||||
.json({ error: 'Failed to get API key usage records', message: error.message })
|
||||
}
|
||||
})
|
||||
|
||||
// 获取账户的请求记录时间线
|
||||
router.get('/accounts/:accountId/usage-records', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { accountId } = req.params
|
||||
const {
|
||||
platform,
|
||||
page = 1,
|
||||
pageSize = 50,
|
||||
startDate,
|
||||
endDate,
|
||||
model,
|
||||
apiKeyId,
|
||||
sortOrder = 'desc'
|
||||
} = req.query
|
||||
|
||||
const pageNumber = Math.max(parseInt(page, 10) || 1, 1)
|
||||
const pageSizeNumber = Math.min(Math.max(parseInt(pageSize, 10) || 50, 1), 200)
|
||||
const normalizedSortOrder = sortOrder === 'asc' ? 'asc' : 'desc'
|
||||
|
||||
const startTime = startDate ? new Date(startDate) : null
|
||||
const endTime = endDate ? new Date(endDate) : null
|
||||
|
||||
if (
|
||||
(startDate && Number.isNaN(startTime?.getTime())) ||
|
||||
(endDate && Number.isNaN(endTime?.getTime()))
|
||||
) {
|
||||
return res.status(400).json({ success: false, error: 'Invalid date range' })
|
||||
}
|
||||
|
||||
if (startTime && endTime && startTime > endTime) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ success: false, error: 'Start date must be before or equal to end date' })
|
||||
}
|
||||
|
||||
const accountInfo = await resolveAccountByPlatform(accountId, platform)
|
||||
if (!accountInfo) {
|
||||
return res.status(404).json({ success: false, error: 'Account not found' })
|
||||
}
|
||||
|
||||
const allApiKeys = await apiKeyService.getAllApiKeys(true)
|
||||
const apiKeyNameCache = new Map(
|
||||
allApiKeys.map((key) => [key.id, key.name || key.label || key.id])
|
||||
)
|
||||
|
||||
let keysToUse = apiKeyId ? allApiKeys.filter((key) => key.id === apiKeyId) : allApiKeys
|
||||
if (apiKeyId && keysToUse.length === 0) {
|
||||
keysToUse = [{ id: apiKeyId }]
|
||||
}
|
||||
|
||||
const toUsageObject = (record) => ({
|
||||
input_tokens: record.inputTokens || 0,
|
||||
output_tokens: record.outputTokens || 0,
|
||||
cache_creation_input_tokens: record.cacheCreateTokens || 0,
|
||||
cache_read_input_tokens: record.cacheReadTokens || 0,
|
||||
cache_creation: record.cacheCreation || record.cache_creation || null
|
||||
})
|
||||
|
||||
const withinRange = (record) => {
|
||||
if (!record.timestamp) {
|
||||
return false
|
||||
}
|
||||
const ts = new Date(record.timestamp)
|
||||
if (Number.isNaN(ts.getTime())) {
|
||||
return false
|
||||
}
|
||||
if (startTime && ts < startTime) {
|
||||
return false
|
||||
}
|
||||
if (endTime && ts > endTime) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
const filteredRecords = []
|
||||
const modelSet = new Set()
|
||||
const apiKeyOptionMap = new Map()
|
||||
let earliestTimestamp = null
|
||||
let latestTimestamp = null
|
||||
|
||||
const batchSize = 10
|
||||
for (let i = 0; i < keysToUse.length; i += batchSize) {
|
||||
const batch = keysToUse.slice(i, i + batchSize)
|
||||
const batchResults = await Promise.all(
|
||||
batch.map(async (key) => {
|
||||
try {
|
||||
const records = await redis.getUsageRecords(key.id, 5000)
|
||||
return { keyId: key.id, records: records || [] }
|
||||
} catch (error) {
|
||||
logger.debug(`⚠️ Failed to get usage records for key ${key.id}: ${error.message}`)
|
||||
return { keyId: key.id, records: [] }
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
for (const { keyId, records } of batchResults) {
|
||||
const apiKeyName = apiKeyNameCache.get(keyId) || (await getApiKeyName(keyId))
|
||||
for (const record of records) {
|
||||
if (record.accountId !== accountId) {
|
||||
continue
|
||||
}
|
||||
if (!withinRange(record)) {
|
||||
continue
|
||||
}
|
||||
if (model && record.model !== model) {
|
||||
continue
|
||||
}
|
||||
|
||||
const accountType = record.accountType || accountInfo.platform || 'unknown'
|
||||
const normalizedModel = record.model || 'unknown'
|
||||
|
||||
modelSet.add(normalizedModel)
|
||||
apiKeyOptionMap.set(keyId, { id: keyId, name: apiKeyName })
|
||||
|
||||
if (record.timestamp) {
|
||||
const ts = new Date(record.timestamp)
|
||||
if (!Number.isNaN(ts.getTime())) {
|
||||
if (!earliestTimestamp || ts < earliestTimestamp) {
|
||||
earliestTimestamp = ts
|
||||
}
|
||||
if (!latestTimestamp || ts > latestTimestamp) {
|
||||
latestTimestamp = ts
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
filteredRecords.push({
|
||||
...record,
|
||||
model: normalizedModel,
|
||||
accountType,
|
||||
apiKeyId: keyId,
|
||||
apiKeyName
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
filteredRecords.sort((a, b) => {
|
||||
const aTime = new Date(a.timestamp).getTime()
|
||||
const bTime = new Date(b.timestamp).getTime()
|
||||
if (Number.isNaN(aTime) || Number.isNaN(bTime)) {
|
||||
return 0
|
||||
}
|
||||
return normalizedSortOrder === 'asc' ? aTime - bTime : bTime - aTime
|
||||
})
|
||||
|
||||
const summary = {
|
||||
totalRequests: 0,
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cacheCreateTokens: 0,
|
||||
cacheReadTokens: 0,
|
||||
totalTokens: 0,
|
||||
totalCost: 0
|
||||
}
|
||||
|
||||
for (const record of filteredRecords) {
|
||||
const usage = toUsageObject(record)
|
||||
const costData = CostCalculator.calculateCost(usage, record.model || 'unknown')
|
||||
const computedCost =
|
||||
typeof record.cost === 'number' ? record.cost : costData?.costs?.total || 0
|
||||
const totalTokens =
|
||||
record.totalTokens ||
|
||||
usage.input_tokens +
|
||||
usage.output_tokens +
|
||||
usage.cache_creation_input_tokens +
|
||||
usage.cache_read_input_tokens
|
||||
|
||||
summary.totalRequests += 1
|
||||
summary.inputTokens += usage.input_tokens
|
||||
summary.outputTokens += usage.output_tokens
|
||||
summary.cacheCreateTokens += usage.cache_creation_input_tokens
|
||||
summary.cacheReadTokens += usage.cache_read_input_tokens
|
||||
summary.totalTokens += totalTokens
|
||||
summary.totalCost += computedCost
|
||||
}
|
||||
|
||||
const totalRecords = filteredRecords.length
|
||||
const totalPages = totalRecords > 0 ? Math.ceil(totalRecords / pageSizeNumber) : 0
|
||||
const safePage = totalPages > 0 ? Math.min(pageNumber, totalPages) : 1
|
||||
const startIndex = (safePage - 1) * pageSizeNumber
|
||||
const pageRecords =
|
||||
totalRecords === 0 ? [] : filteredRecords.slice(startIndex, startIndex + pageSizeNumber)
|
||||
|
||||
const enrichedRecords = []
|
||||
for (const record of pageRecords) {
|
||||
const usage = toUsageObject(record)
|
||||
const costData = CostCalculator.calculateCost(usage, record.model || 'unknown')
|
||||
const computedCost =
|
||||
typeof record.cost === 'number' ? record.cost : costData?.costs?.total || 0
|
||||
const totalTokens =
|
||||
record.totalTokens ||
|
||||
usage.input_tokens +
|
||||
usage.output_tokens +
|
||||
usage.cache_creation_input_tokens +
|
||||
usage.cache_read_input_tokens
|
||||
|
||||
enrichedRecords.push({
|
||||
timestamp: record.timestamp,
|
||||
model: record.model || 'unknown',
|
||||
apiKeyId: record.apiKeyId,
|
||||
apiKeyName: record.apiKeyName,
|
||||
accountId,
|
||||
accountName: accountInfo.name || accountInfo.email || accountId,
|
||||
accountType: record.accountType,
|
||||
accountTypeName: accountTypeNames[record.accountType] || '未知渠道',
|
||||
inputTokens: usage.input_tokens,
|
||||
outputTokens: usage.output_tokens,
|
||||
cacheCreateTokens: usage.cache_creation_input_tokens,
|
||||
cacheReadTokens: usage.cache_read_input_tokens,
|
||||
ephemeral5mTokens: record.ephemeral5mTokens || 0,
|
||||
ephemeral1hTokens: record.ephemeral1hTokens || 0,
|
||||
totalTokens,
|
||||
isLongContextRequest: record.isLongContext || record.isLongContextRequest || false,
|
||||
cost: Number(computedCost.toFixed(6)),
|
||||
costFormatted:
|
||||
record.costFormatted ||
|
||||
costData?.formatted?.total ||
|
||||
CostCalculator.formatCost(computedCost),
|
||||
costBreakdown: record.costBreakdown || {
|
||||
input: costData?.costs?.input || 0,
|
||||
output: costData?.costs?.output || 0,
|
||||
cacheCreate: costData?.costs?.cacheWrite || 0,
|
||||
cacheRead: costData?.costs?.cacheRead || 0,
|
||||
total: costData?.costs?.total || computedCost
|
||||
},
|
||||
responseTime: record.responseTime || null
|
||||
})
|
||||
}
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: {
|
||||
records: enrichedRecords,
|
||||
pagination: {
|
||||
currentPage: safePage,
|
||||
pageSize: pageSizeNumber,
|
||||
totalRecords,
|
||||
totalPages,
|
||||
hasNextPage: totalPages > 0 && safePage < totalPages,
|
||||
hasPreviousPage: totalPages > 0 && safePage > 1
|
||||
},
|
||||
filters: {
|
||||
startDate: startTime ? startTime.toISOString() : null,
|
||||
endDate: endTime ? endTime.toISOString() : null,
|
||||
model: model || null,
|
||||
apiKeyId: apiKeyId || null,
|
||||
platform: accountInfo.platform,
|
||||
sortOrder: normalizedSortOrder
|
||||
},
|
||||
accountInfo: {
|
||||
id: accountId,
|
||||
name: accountInfo.name || accountInfo.email || accountId,
|
||||
platform: accountInfo.platform || platform || 'unknown',
|
||||
status: accountInfo.status ?? accountInfo.isActive ?? null
|
||||
},
|
||||
summary: {
|
||||
...summary,
|
||||
totalCost: Number(summary.totalCost.toFixed(6)),
|
||||
avgCost:
|
||||
summary.totalRequests > 0
|
||||
? Number((summary.totalCost / summary.totalRequests).toFixed(6))
|
||||
: 0
|
||||
},
|
||||
availableFilters: {
|
||||
models: Array.from(modelSet),
|
||||
apiKeys: Array.from(apiKeyOptionMap.values()),
|
||||
dateRange: {
|
||||
earliest: earliestTimestamp ? earliestTimestamp.toISOString() : null,
|
||||
latest: latestTimestamp ? latestTimestamp.toISOString() : null
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to get account usage records:', error)
|
||||
return res
|
||||
.status(500)
|
||||
.json({ error: 'Failed to get account usage records', message: error.message })
|
||||
}
|
||||
})
|
||||
|
||||
module.exports = router
|
||||
|
||||
@@ -33,7 +33,9 @@ function mapExpiryField(updates, accountType, accountId) {
|
||||
if ('expiresAt' in mappedUpdates) {
|
||||
mappedUpdates.subscriptionExpiresAt = mappedUpdates.expiresAt
|
||||
delete mappedUpdates.expiresAt
|
||||
logger.info(`Mapping expiresAt to subscriptionExpiresAt for ${accountType} account ${accountId}`)
|
||||
logger.info(
|
||||
`Mapping expiresAt to subscriptionExpiresAt for ${accountType} account ${accountId}`
|
||||
)
|
||||
}
|
||||
return mappedUpdates
|
||||
}
|
||||
|
||||
@@ -972,6 +972,9 @@ router.post('/v1/messages/count_tokens', authenticateApiKey, async (req, res) =>
|
||||
const maxAttempts = 2
|
||||
let attempt = 0
|
||||
|
||||
// 引入 claudeConsoleAccountService 用于检查 count_tokens 可用性
|
||||
const claudeConsoleAccountService = require('../services/claudeConsoleAccountService')
|
||||
|
||||
const processRequest = async () => {
|
||||
const { accountId, accountType } = await unifiedClaudeScheduler.selectAccountForApiKey(
|
||||
req.apiKey,
|
||||
@@ -1003,6 +1006,17 @@ router.post('/v1/messages/count_tokens', authenticateApiKey, async (req, res) =>
|
||||
})
|
||||
}
|
||||
|
||||
// 🔍 claude-console 账户特殊处理:检查 count_tokens 端点是否可用
|
||||
if (accountType === 'claude-console') {
|
||||
const isUnavailable = await claudeConsoleAccountService.isCountTokensUnavailable(accountId)
|
||||
if (isUnavailable) {
|
||||
logger.info(
|
||||
`⏭️ count_tokens unavailable for Claude Console account ${accountId}, returning fallback response`
|
||||
)
|
||||
return { fallbackResponse: true }
|
||||
}
|
||||
}
|
||||
|
||||
const relayOptions = {
|
||||
skipUsageRecord: true,
|
||||
customPath: '/v1/messages/count_tokens'
|
||||
@@ -1028,6 +1042,23 @@ router.post('/v1/messages/count_tokens', authenticateApiKey, async (req, res) =>
|
||||
relayOptions
|
||||
)
|
||||
|
||||
// 🔍 claude-console 账户:检测上游 404 响应并标记
|
||||
if (accountType === 'claude-console' && response.statusCode === 404) {
|
||||
logger.warn(
|
||||
`⚠️ count_tokens endpoint returned 404 for Claude Console account ${accountId}, marking as unavailable`
|
||||
)
|
||||
// 标记失败不应影响 fallback 响应
|
||||
try {
|
||||
await claudeConsoleAccountService.markCountTokensUnavailable(accountId)
|
||||
} catch (markError) {
|
||||
logger.error(
|
||||
`❌ Failed to mark count_tokens unavailable for account ${accountId}, but will still return fallback:`,
|
||||
markError
|
||||
)
|
||||
}
|
||||
return { fallbackResponse: true }
|
||||
}
|
||||
|
||||
res.status(response.statusCode)
|
||||
|
||||
const skipHeaders = ['content-encoding', 'transfer-encoding', 'content-length']
|
||||
@@ -1050,11 +1081,21 @@ router.post('/v1/messages/count_tokens', authenticateApiKey, async (req, res) =>
|
||||
}
|
||||
|
||||
logger.info(`✅ Token count request completed for key: ${req.apiKey.name}`)
|
||||
return { fallbackResponse: false }
|
||||
}
|
||||
|
||||
while (attempt < maxAttempts) {
|
||||
try {
|
||||
await processRequest()
|
||||
const result = await processRequest()
|
||||
|
||||
// 🔍 处理 fallback 响应(claude-console 账户 count_tokens 不可用)
|
||||
if (result && result.fallbackResponse) {
|
||||
if (!res.headersSent) {
|
||||
return res.status(200).json({ input_tokens: 0 })
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
return
|
||||
} catch (error) {
|
||||
if (error.code === 'CONSOLE_ACCOUNT_CONCURRENCY_FULL') {
|
||||
|
||||
@@ -5,6 +5,7 @@ const apiKeyService = require('../services/apiKeyService')
|
||||
const CostCalculator = require('../utils/costCalculator')
|
||||
const claudeAccountService = require('../services/claudeAccountService')
|
||||
const openaiAccountService = require('../services/openaiAccountService')
|
||||
const { createClaudeTestPayload } = require('../utils/testPayloadHelper')
|
||||
|
||||
const router = express.Router()
|
||||
|
||||
@@ -792,8 +793,8 @@ router.post('/api/batch-model-stats', async (req, res) => {
|
||||
|
||||
// 🧪 API Key 端点测试接口 - 测试API Key是否能正常访问服务
|
||||
router.post('/api-key/test', async (req, res) => {
|
||||
const axios = require('axios')
|
||||
const config = require('../../config/config')
|
||||
const { sendStreamTestRequest } = require('../utils/testPayloadHelper')
|
||||
|
||||
try {
|
||||
const { apiKey, model = 'claude-sonnet-4-5-20250929' } = req.body
|
||||
@@ -805,7 +806,6 @@ router.post('/api-key/test', async (req, res) => {
|
||||
})
|
||||
}
|
||||
|
||||
// 基本格式验证
|
||||
if (typeof apiKey !== 'string' || apiKey.length < 10 || apiKey.length > 512) {
|
||||
return res.status(400).json({
|
||||
error: 'Invalid API key format',
|
||||
@@ -813,7 +813,6 @@ router.post('/api-key/test', async (req, res) => {
|
||||
})
|
||||
}
|
||||
|
||||
// 首先验证API Key是否有效(不触发激活)
|
||||
const validation = await apiKeyService.validateApiKeyForStats(apiKey)
|
||||
if (!validation.valid) {
|
||||
return res.status(401).json({
|
||||
@@ -824,244 +823,29 @@ router.post('/api-key/test', async (req, res) => {
|
||||
|
||||
logger.api(`🧪 API Key test started for: ${validation.keyData.name} (${validation.keyData.id})`)
|
||||
|
||||
// 设置SSE响应头
|
||||
res.setHeader('Content-Type', 'text/event-stream')
|
||||
res.setHeader('Cache-Control', 'no-cache')
|
||||
res.setHeader('Connection', 'keep-alive')
|
||||
res.setHeader('X-Accel-Buffering', 'no')
|
||||
|
||||
// 发送测试开始事件
|
||||
res.write(`data: ${JSON.stringify({ type: 'test_start', message: 'Test started' })}\n\n`)
|
||||
|
||||
// 构建测试请求,模拟 Claude CLI 客户端
|
||||
const port = config.server.port || 3000
|
||||
const baseURL = `http://127.0.0.1:${port}`
|
||||
const apiUrl = `http://127.0.0.1:${port}/api/v1/messages?beta=true`
|
||||
|
||||
const testPayload = {
|
||||
model,
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: 'hi'
|
||||
}
|
||||
],
|
||||
system: [
|
||||
{
|
||||
type: 'text',
|
||||
text: "You are Claude Code, Anthropic's official CLI for Claude."
|
||||
}
|
||||
],
|
||||
max_tokens: 32000,
|
||||
temperature: 1,
|
||||
stream: true
|
||||
}
|
||||
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': 'claude-cli/2.0.52 (external, cli)',
|
||||
'x-api-key': apiKey,
|
||||
'anthropic-version': config.claude.apiVersion || '2023-06-01'
|
||||
}
|
||||
|
||||
// 向自身服务发起测试请求
|
||||
// 使用 validateStatus 允许所有状态码通过,以便我们可以处理流式错误响应
|
||||
const response = await axios.post(`${baseURL}/api/v1/messages`, testPayload, {
|
||||
headers,
|
||||
responseType: 'stream',
|
||||
timeout: 60000, // 60秒超时
|
||||
validateStatus: () => true // 接受所有状态码,自行处理错误
|
||||
})
|
||||
|
||||
// 检查响应状态码,如果不是2xx,尝试读取错误信息
|
||||
if (response.status >= 400) {
|
||||
logger.error(
|
||||
`🧪 API Key test received error status ${response.status} for: ${validation.keyData.name}`
|
||||
)
|
||||
|
||||
// 尝试从流中读取错误信息
|
||||
let errorBody = ''
|
||||
for await (const chunk of response.data) {
|
||||
errorBody += chunk.toString()
|
||||
}
|
||||
|
||||
let errorMessage = `HTTP ${response.status}`
|
||||
try {
|
||||
// 尝试解析SSE格式的错误
|
||||
const lines = errorBody.split('\n')
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data: ')) {
|
||||
const dataStr = line.substring(6).trim()
|
||||
if (dataStr && dataStr !== '[DONE]') {
|
||||
const data = JSON.parse(dataStr)
|
||||
if (data.error?.message) {
|
||||
errorMessage = data.error.message
|
||||
break
|
||||
} else if (data.message) {
|
||||
errorMessage = data.message
|
||||
break
|
||||
} else if (typeof data.error === 'string') {
|
||||
errorMessage = data.error
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// 如果不是SSE格式,尝试直接解析JSON
|
||||
if (errorMessage === `HTTP ${response.status}`) {
|
||||
const jsonError = JSON.parse(errorBody)
|
||||
errorMessage =
|
||||
jsonError.error?.message || jsonError.message || jsonError.error || errorMessage
|
||||
}
|
||||
} catch {
|
||||
// 解析失败,使用原始错误体或默认消息
|
||||
if (errorBody && errorBody.length < 500) {
|
||||
errorMessage = errorBody
|
||||
}
|
||||
}
|
||||
|
||||
res.write(`data: ${JSON.stringify({ type: 'error', error: errorMessage })}\n\n`)
|
||||
res.write(
|
||||
`data: ${JSON.stringify({ type: 'test_complete', success: false, error: errorMessage })}\n\n`
|
||||
)
|
||||
res.end()
|
||||
return
|
||||
}
|
||||
|
||||
let receivedContent = ''
|
||||
let testSuccess = false
|
||||
let upstreamError = null
|
||||
|
||||
// 处理流式响应
|
||||
response.data.on('data', (chunk) => {
|
||||
const lines = chunk.toString().split('\n')
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data: ')) {
|
||||
const dataStr = line.substring(6).trim()
|
||||
if (dataStr === '[DONE]') {
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
const data = JSON.parse(dataStr)
|
||||
|
||||
// 检查上游返回的错误事件
|
||||
if (data.type === 'error' || data.error) {
|
||||
let errorMsg = 'Unknown upstream error'
|
||||
|
||||
// 优先从 data.error 提取(如果是对象,获取其 message)
|
||||
if (typeof data.error === 'object' && data.error?.message) {
|
||||
errorMsg = data.error.message
|
||||
} else if (typeof data.error === 'string' && data.error !== 'Claude API error') {
|
||||
// 如果 error 是字符串且不是通用错误,直接使用
|
||||
errorMsg = data.error
|
||||
} else if (data.details) {
|
||||
// 尝试从 details 字段解析详细错误(claudeRelayService 格式)
|
||||
try {
|
||||
const details =
|
||||
typeof data.details === 'string' ? JSON.parse(data.details) : data.details
|
||||
if (details.error?.message) {
|
||||
errorMsg = details.error.message
|
||||
} else if (details.message) {
|
||||
errorMsg = details.message
|
||||
}
|
||||
} catch {
|
||||
// details 不是有效 JSON,尝试直接使用
|
||||
if (typeof data.details === 'string' && data.details.length < 500) {
|
||||
errorMsg = data.details
|
||||
}
|
||||
}
|
||||
} else if (data.message) {
|
||||
errorMsg = data.message
|
||||
}
|
||||
|
||||
// 添加状态码信息(如果有)
|
||||
if (data.status && errorMsg !== 'Unknown upstream error') {
|
||||
errorMsg = `[${data.status}] ${errorMsg}`
|
||||
}
|
||||
|
||||
upstreamError = errorMsg
|
||||
logger.error(`🧪 Upstream error in test for: ${validation.keyData.name}:`, errorMsg)
|
||||
res.write(`data: ${JSON.stringify({ type: 'error', error: errorMsg })}\n\n`)
|
||||
continue
|
||||
}
|
||||
|
||||
// 提取文本内容
|
||||
if (data.type === 'content_block_delta' && data.delta?.text) {
|
||||
receivedContent += data.delta.text
|
||||
res.write(`data: ${JSON.stringify({ type: 'content', text: data.delta.text })}\n\n`)
|
||||
}
|
||||
|
||||
// 消息结束
|
||||
if (data.type === 'message_stop') {
|
||||
testSuccess = true
|
||||
res.write(`data: ${JSON.stringify({ type: 'message_stop' })}\n\n`)
|
||||
}
|
||||
} catch {
|
||||
// 忽略解析错误
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
response.data.on('end', () => {
|
||||
// 如果有上游错误,标记为失败
|
||||
if (upstreamError) {
|
||||
testSuccess = false
|
||||
}
|
||||
|
||||
logger.api(
|
||||
`🧪 API Key test completed for: ${validation.keyData.name}, success: ${testSuccess}, content length: ${receivedContent.length}${upstreamError ? `, error: ${upstreamError}` : ''}`
|
||||
)
|
||||
res.write(
|
||||
`data: ${JSON.stringify({
|
||||
type: 'test_complete',
|
||||
success: testSuccess,
|
||||
contentLength: receivedContent.length,
|
||||
error: upstreamError || undefined
|
||||
})}\n\n`
|
||||
)
|
||||
res.end()
|
||||
})
|
||||
|
||||
response.data.on('error', (err) => {
|
||||
logger.error(`🧪 API Key test stream error for: ${validation.keyData.name}`, err)
|
||||
|
||||
// 如果已经捕获了上游错误,优先使用那个
|
||||
let errorMsg = upstreamError || err.message || 'Stream error'
|
||||
|
||||
// 如果错误消息是通用的 "Claude API error: xxx",提供更友好的提示
|
||||
if (errorMsg.startsWith('Claude API error:') && upstreamError) {
|
||||
errorMsg = upstreamError
|
||||
}
|
||||
|
||||
res.write(`data: ${JSON.stringify({ type: 'error', error: errorMsg })}\n\n`)
|
||||
res.write(
|
||||
`data: ${JSON.stringify({ type: 'test_complete', success: false, error: errorMsg })}\n\n`
|
||||
)
|
||||
res.end()
|
||||
})
|
||||
|
||||
// 处理客户端断开连接
|
||||
req.on('close', () => {
|
||||
if (!res.writableEnded) {
|
||||
response.data.destroy()
|
||||
}
|
||||
await sendStreamTestRequest({
|
||||
apiUrl,
|
||||
authorization: apiKey,
|
||||
responseStream: res,
|
||||
payload: createClaudeTestPayload(model, { stream: true }),
|
||||
timeout: 60000,
|
||||
extraHeaders: { 'x-api-key': apiKey }
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('❌ API Key test failed:', error)
|
||||
|
||||
// 如果还未发送响应头,返回JSON错误
|
||||
if (!res.headersSent) {
|
||||
return res.status(500).json({
|
||||
error: 'Test failed',
|
||||
message: error.response?.data?.error?.message || error.message || 'Internal server error'
|
||||
message: error.message || 'Internal server error'
|
||||
})
|
||||
}
|
||||
|
||||
// 如果已经是SSE流,发送错误事件
|
||||
res.write(
|
||||
`data: ${JSON.stringify({ type: 'error', error: error.response?.data?.error?.message || error.message || 'Test failed' })}\n\n`
|
||||
`data: ${JSON.stringify({ type: 'error', error: error.message || 'Test failed' })}\n\n`
|
||||
)
|
||||
res.end()
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ const claudeCodeHeadersService = require('../services/claudeCodeHeadersService')
|
||||
const sessionHelper = require('../utils/sessionHelper')
|
||||
const { updateRateLimitCounters } = require('../utils/rateLimitHelper')
|
||||
const pricingService = require('../services/pricingService')
|
||||
const { getEffectiveModel } = require('../utils/modelHelper')
|
||||
|
||||
// 🔧 辅助函数:检查 API Key 权限
|
||||
function checkPermissions(apiKeyData, requiredPermission = 'claude') {
|
||||
@@ -75,9 +76,9 @@ router.get('/v1/models', authenticateApiKey, async (req, res) => {
|
||||
}
|
||||
]
|
||||
|
||||
// 如果启用了模型限制,过滤模型列表
|
||||
// 如果启用了模型限制,视为黑名单:过滤掉受限模型
|
||||
if (apiKeyData.enableModelRestriction && apiKeyData.restrictedModels?.length > 0) {
|
||||
models = models.filter((model) => apiKeyData.restrictedModels.includes(model.id))
|
||||
models = models.filter((model) => !apiKeyData.restrictedModels.includes(model.id))
|
||||
}
|
||||
|
||||
res.json({
|
||||
@@ -114,9 +115,9 @@ router.get('/v1/models/:model', authenticateApiKey, async (req, res) => {
|
||||
})
|
||||
}
|
||||
|
||||
// 检查模型限制
|
||||
// 模型限制(黑名单):命中则直接拒绝
|
||||
if (apiKeyData.enableModelRestriction && apiKeyData.restrictedModels?.length > 0) {
|
||||
if (!apiKeyData.restrictedModels.includes(modelId)) {
|
||||
if (apiKeyData.restrictedModels.includes(modelId)) {
|
||||
return res.status(404).json({
|
||||
error: {
|
||||
message: `Model '${modelId}' not found`,
|
||||
@@ -199,9 +200,10 @@ async function handleChatCompletion(req, res, apiKeyData) {
|
||||
// 转换 OpenAI 请求为 Claude 格式
|
||||
const claudeRequest = openaiToClaude.convertRequest(req.body)
|
||||
|
||||
// 检查模型限制
|
||||
// 模型限制(黑名单):命中受限模型则拒绝
|
||||
if (apiKeyData.enableModelRestriction && apiKeyData.restrictedModels?.length > 0) {
|
||||
if (!apiKeyData.restrictedModels.includes(claudeRequest.model)) {
|
||||
const effectiveModel = getEffectiveModel(claudeRequest.model || '')
|
||||
if (apiKeyData.restrictedModels.includes(effectiveModel)) {
|
||||
return res.status(403).json({
|
||||
error: {
|
||||
message: `Model ${req.body.model} is not allowed for this API key`,
|
||||
|
||||
@@ -247,9 +247,11 @@ const handleResponses = async (req, res) => {
|
||||
|
||||
// 从请求体中提取模型和流式标志
|
||||
let requestedModel = req.body?.model || null
|
||||
const isCodexModel =
|
||||
typeof requestedModel === 'string' && requestedModel.toLowerCase().includes('codex')
|
||||
|
||||
// 如果模型是 gpt-5 开头且后面还有内容(如 gpt-5-2025-08-07),则覆盖为 gpt-5
|
||||
if (requestedModel && requestedModel.startsWith('gpt-5-') && requestedModel !== 'gpt-5-codex') {
|
||||
// 如果模型是 gpt-5 开头且后面还有内容(如 gpt-5-2025-08-07),并且不是 Codex 系列,则覆盖为 gpt-5
|
||||
if (requestedModel && requestedModel.startsWith('gpt-5-') && !isCodexModel) {
|
||||
logger.info(`📝 Model ${requestedModel} detected, normalizing to gpt-5 for Codex API`)
|
||||
requestedModel = 'gpt-5'
|
||||
req.body.model = 'gpt-5' // 同时更新请求体中的模型
|
||||
|
||||
@@ -16,6 +16,22 @@ const {
|
||||
const tokenRefreshService = require('./tokenRefreshService')
|
||||
const LRUCache = require('../utils/lruCache')
|
||||
const { formatDateWithTimezone, getISOStringWithTimezone } = require('../utils/dateHelper')
|
||||
const { isOpus45OrNewer } = require('../utils/modelHelper')
|
||||
|
||||
/**
|
||||
* Check if account is Pro (not Max)
|
||||
* Compatible with both API real-time data (hasClaudePro) and local config (accountType)
|
||||
* @param {Object} info - Subscription info object
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isProAccount(info) {
|
||||
// API real-time status takes priority
|
||||
if (info.hasClaudePro === true && info.hasClaudeMax !== true) {
|
||||
return true
|
||||
}
|
||||
// Local configured account type
|
||||
return info.accountType === 'claude_pro'
|
||||
}
|
||||
|
||||
class ClaudeAccountService {
|
||||
constructor() {
|
||||
@@ -852,31 +868,39 @@ class ClaudeAccountService {
|
||||
!this.isSubscriptionExpired(account)
|
||||
)
|
||||
|
||||
// 如果请求的是 Opus 模型,过滤掉 Pro 和 Free 账号
|
||||
// Filter Opus models based on account type and model version
|
||||
if (modelName && modelName.toLowerCase().includes('opus')) {
|
||||
const isNewOpus = isOpus45OrNewer(modelName)
|
||||
|
||||
activeAccounts = activeAccounts.filter((account) => {
|
||||
// 检查账号的订阅信息
|
||||
if (account.subscriptionInfo) {
|
||||
try {
|
||||
const info = JSON.parse(account.subscriptionInfo)
|
||||
// Pro 和 Free 账号不支持 Opus
|
||||
if (info.hasClaudePro === true && info.hasClaudeMax !== true) {
|
||||
return false // Claude Pro 不支持 Opus
|
||||
|
||||
// Free account: does not support any Opus model
|
||||
if (info.accountType === 'free') {
|
||||
return false
|
||||
}
|
||||
if (info.accountType === 'claude_pro' || info.accountType === 'claude_free') {
|
||||
return false // 明确标记为 Pro 或 Free 的账号不支持
|
||||
|
||||
// Pro account: only supports Opus 4.5+
|
||||
if (isProAccount(info)) {
|
||||
return isNewOpus
|
||||
}
|
||||
|
||||
// Max account: supports all Opus versions
|
||||
return true
|
||||
} catch (e) {
|
||||
// 解析失败,假设为旧数据,默认支持(兼容旧数据为 Max)
|
||||
// Parse failed, assume legacy data (Max), default support
|
||||
return true
|
||||
}
|
||||
}
|
||||
// 没有订阅信息的账号,默认当作支持(兼容旧数据)
|
||||
// Account without subscription info, default to supported (legacy data compatibility)
|
||||
return true
|
||||
})
|
||||
|
||||
if (activeAccounts.length === 0) {
|
||||
throw new Error('No Claude accounts available that support Opus model')
|
||||
const modelDesc = isNewOpus ? 'Opus 4.5+' : 'legacy Opus (requires Max subscription)'
|
||||
throw new Error(`No Claude accounts available that support ${modelDesc} model`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -970,31 +994,39 @@ class ClaudeAccountService {
|
||||
!this.isSubscriptionExpired(account)
|
||||
)
|
||||
|
||||
// 如果请求的是 Opus 模型,过滤掉 Pro 和 Free 账号
|
||||
// Filter Opus models based on account type and model version
|
||||
if (modelName && modelName.toLowerCase().includes('opus')) {
|
||||
const isNewOpus = isOpus45OrNewer(modelName)
|
||||
|
||||
sharedAccounts = sharedAccounts.filter((account) => {
|
||||
// 检查账号的订阅信息
|
||||
if (account.subscriptionInfo) {
|
||||
try {
|
||||
const info = JSON.parse(account.subscriptionInfo)
|
||||
// Pro 和 Free 账号不支持 Opus
|
||||
if (info.hasClaudePro === true && info.hasClaudeMax !== true) {
|
||||
return false // Claude Pro 不支持 Opus
|
||||
|
||||
// Free account: does not support any Opus model
|
||||
if (info.accountType === 'free') {
|
||||
return false
|
||||
}
|
||||
if (info.accountType === 'claude_pro' || info.accountType === 'claude_free') {
|
||||
return false // 明确标记为 Pro 或 Free 的账号不支持
|
||||
|
||||
// Pro account: only supports Opus 4.5+
|
||||
if (isProAccount(info)) {
|
||||
return isNewOpus
|
||||
}
|
||||
|
||||
// Max account: supports all Opus versions
|
||||
return true
|
||||
} catch (e) {
|
||||
// 解析失败,假设为旧数据,默认支持(兼容旧数据为 Max)
|
||||
// Parse failed, assume legacy data (Max), default support
|
||||
return true
|
||||
}
|
||||
}
|
||||
// 没有订阅信息的账号,默认当作支持(兼容旧数据)
|
||||
// Account without subscription info, default to supported (legacy data compatibility)
|
||||
return true
|
||||
})
|
||||
|
||||
if (sharedAccounts.length === 0) {
|
||||
throw new Error('No shared Claude accounts available that support Opus model')
|
||||
const modelDesc = isNewOpus ? 'Opus 4.5+' : 'legacy Opus (requires Max subscription)'
|
||||
throw new Error(`No shared Claude accounts available that support ${modelDesc} model`)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -67,7 +67,8 @@ class ClaudeConsoleAccountService {
|
||||
schedulable = true, // 是否可被调度
|
||||
dailyQuota = 0, // 每日额度限制(美元),0表示不限制
|
||||
quotaResetTime = '00:00', // 额度重置时间(HH:mm格式)
|
||||
maxConcurrentTasks = 0 // 最大并发任务数,0表示无限制
|
||||
maxConcurrentTasks = 0, // 最大并发任务数,0表示无限制
|
||||
disableAutoProtection = false // 是否关闭自动防护(429/401/400/529 不自动禁用)
|
||||
} = options
|
||||
|
||||
// 验证必填字段
|
||||
@@ -115,7 +116,8 @@ class ClaudeConsoleAccountService {
|
||||
lastResetDate: redis.getDateStringInTimezone(), // 最后重置日期(按配置时区)
|
||||
quotaResetTime, // 额度重置时间
|
||||
quotaStoppedAt: '', // 因额度停用的时间
|
||||
maxConcurrentTasks: maxConcurrentTasks.toString() // 最大并发任务数,0表示无限制
|
||||
maxConcurrentTasks: maxConcurrentTasks.toString(), // 最大并发任务数,0表示无限制
|
||||
disableAutoProtection: disableAutoProtection.toString() // 关闭自动防护
|
||||
}
|
||||
|
||||
const client = redis.getClientSafe()
|
||||
@@ -153,6 +155,7 @@ class ClaudeConsoleAccountService {
|
||||
quotaResetTime,
|
||||
quotaStoppedAt: null,
|
||||
maxConcurrentTasks, // 新增:返回并发限制配置
|
||||
disableAutoProtection, // 新增:返回自动防护开关
|
||||
activeTaskCount: 0 // 新增:新建账户当前并发数为0
|
||||
}
|
||||
}
|
||||
@@ -213,7 +216,8 @@ class ClaudeConsoleAccountService {
|
||||
|
||||
// 并发控制相关
|
||||
maxConcurrentTasks: parseInt(accountData.maxConcurrentTasks) || 0,
|
||||
activeTaskCount
|
||||
activeTaskCount,
|
||||
disableAutoProtection: accountData.disableAutoProtection === 'true'
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -259,6 +263,7 @@ class ClaudeConsoleAccountService {
|
||||
}
|
||||
accountData.isActive = accountData.isActive === 'true'
|
||||
accountData.schedulable = accountData.schedulable !== 'false' // 默认为true
|
||||
accountData.disableAutoProtection = accountData.disableAutoProtection === 'true'
|
||||
|
||||
if (accountData.proxy) {
|
||||
accountData.proxy = JSON.parse(accountData.proxy)
|
||||
@@ -367,6 +372,9 @@ class ClaudeConsoleAccountService {
|
||||
if (updates.maxConcurrentTasks !== undefined) {
|
||||
updatedData.maxConcurrentTasks = updates.maxConcurrentTasks.toString()
|
||||
}
|
||||
if (updates.disableAutoProtection !== undefined) {
|
||||
updatedData.disableAutoProtection = updates.disableAutoProtection.toString()
|
||||
}
|
||||
|
||||
// ✅ 直接保存 subscriptionExpiresAt(如果提供)
|
||||
// Claude Console 没有 token 刷新逻辑,不会覆盖此字段
|
||||
@@ -1510,6 +1518,71 @@ class ClaudeConsoleAccountService {
|
||||
const expiryDate = new Date(account.subscriptionExpiresAt)
|
||||
return expiryDate <= new Date()
|
||||
}
|
||||
|
||||
// 🚫 标记账户的 count_tokens 端点不可用
|
||||
async markCountTokensUnavailable(accountId) {
|
||||
try {
|
||||
const client = redis.getClientSafe()
|
||||
const accountKey = `${this.ACCOUNT_KEY_PREFIX}${accountId}`
|
||||
|
||||
// 检查账户是否存在
|
||||
const exists = await client.exists(accountKey)
|
||||
if (!exists) {
|
||||
logger.warn(
|
||||
`⚠️ Cannot mark count_tokens unavailable for non-existent account: ${accountId}`
|
||||
)
|
||||
return { success: false, reason: 'Account not found' }
|
||||
}
|
||||
|
||||
await client.hset(accountKey, {
|
||||
countTokensUnavailable: 'true',
|
||||
countTokensUnavailableAt: new Date().toISOString()
|
||||
})
|
||||
|
||||
logger.info(
|
||||
`🚫 Marked count_tokens endpoint as unavailable for Claude Console account: ${accountId}`
|
||||
)
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
logger.error(`❌ Failed to mark count_tokens unavailable for account ${accountId}:`, error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ 移除账户的 count_tokens 不可用标记
|
||||
async removeCountTokensUnavailable(accountId) {
|
||||
try {
|
||||
const client = redis.getClientSafe()
|
||||
const accountKey = `${this.ACCOUNT_KEY_PREFIX}${accountId}`
|
||||
|
||||
await client.hdel(accountKey, 'countTokensUnavailable', 'countTokensUnavailableAt')
|
||||
|
||||
logger.info(
|
||||
`✅ Removed count_tokens unavailable mark for Claude Console account: ${accountId}`
|
||||
)
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`❌ Failed to remove count_tokens unavailable mark for account ${accountId}:`,
|
||||
error
|
||||
)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 🔍 检查账户的 count_tokens 端点是否不可用
|
||||
async isCountTokensUnavailable(accountId) {
|
||||
try {
|
||||
const client = redis.getClientSafe()
|
||||
const accountKey = `${this.ACCOUNT_KEY_PREFIX}${accountId}`
|
||||
|
||||
const value = await client.hget(accountKey, 'countTokensUnavailable')
|
||||
return value === 'true'
|
||||
} catch (error) {
|
||||
logger.error(`❌ Failed to check count_tokens availability for account ${accountId}:`, error)
|
||||
return false // 出错时默认返回可用,避免误阻断
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new ClaudeConsoleAccountService()
|
||||
|
||||
@@ -37,6 +37,8 @@ class ClaudeConsoleRelayService {
|
||||
throw new Error('Claude Console Claude account not found')
|
||||
}
|
||||
|
||||
const autoProtectionDisabled = account.disableAutoProtection === true
|
||||
|
||||
logger.info(
|
||||
`📤 Processing Claude Console API request for key: ${apiKeyData.name || apiKeyData.id}, account: ${account.name} (${accountId}), request: ${requestId}`
|
||||
)
|
||||
@@ -248,27 +250,41 @@ class ClaudeConsoleRelayService {
|
||||
|
||||
// 检查错误状态并相应处理
|
||||
if (response.status === 401) {
|
||||
logger.warn(`🚫 Unauthorized error detected for Claude Console account ${accountId}`)
|
||||
await claudeConsoleAccountService.markAccountUnauthorized(accountId)
|
||||
logger.warn(
|
||||
`🚫 Unauthorized error detected for Claude Console account ${accountId}${autoProtectionDisabled ? ' (auto-protection disabled, skipping status change)' : ''}`
|
||||
)
|
||||
if (!autoProtectionDisabled) {
|
||||
await claudeConsoleAccountService.markAccountUnauthorized(accountId)
|
||||
}
|
||||
} else if (accountDisabledError) {
|
||||
logger.error(
|
||||
`🚫 Account disabled error (400) detected for Claude Console account ${accountId}, marking as blocked`
|
||||
`🚫 Account disabled error (400) detected for Claude Console account ${accountId}${autoProtectionDisabled ? ' (auto-protection disabled, skipping status change)' : ''}`
|
||||
)
|
||||
// 传入完整的错误详情到 webhook
|
||||
const errorDetails =
|
||||
typeof response.data === 'string' ? response.data : JSON.stringify(response.data)
|
||||
await claudeConsoleAccountService.markConsoleAccountBlocked(accountId, errorDetails)
|
||||
if (!autoProtectionDisabled) {
|
||||
await claudeConsoleAccountService.markConsoleAccountBlocked(accountId, errorDetails)
|
||||
}
|
||||
} else if (response.status === 429) {
|
||||
logger.warn(`🚫 Rate limit detected for Claude Console account ${accountId}`)
|
||||
logger.warn(
|
||||
`🚫 Rate limit detected for Claude Console account ${accountId}${autoProtectionDisabled ? ' (auto-protection disabled, skipping status change)' : ''}`
|
||||
)
|
||||
// 收到429先检查是否因为超过了手动配置的每日额度
|
||||
await claudeConsoleAccountService.checkQuotaUsage(accountId).catch((err) => {
|
||||
logger.error('❌ Failed to check quota after 429 error:', err)
|
||||
})
|
||||
|
||||
await claudeConsoleAccountService.markAccountRateLimited(accountId)
|
||||
if (!autoProtectionDisabled) {
|
||||
await claudeConsoleAccountService.markAccountRateLimited(accountId)
|
||||
}
|
||||
} else if (response.status === 529) {
|
||||
logger.warn(`🚫 Overload error detected for Claude Console account ${accountId}`)
|
||||
await claudeConsoleAccountService.markAccountOverloaded(accountId)
|
||||
logger.warn(
|
||||
`🚫 Overload error detected for Claude Console account ${accountId}${autoProtectionDisabled ? ' (auto-protection disabled, skipping status change)' : ''}`
|
||||
)
|
||||
if (!autoProtectionDisabled) {
|
||||
await claudeConsoleAccountService.markAccountOverloaded(accountId)
|
||||
}
|
||||
} else if (response.status === 200 || response.status === 201) {
|
||||
// 如果请求成功,检查并移除错误状态
|
||||
const isRateLimited = await claudeConsoleAccountService.isAccountRateLimited(accountId)
|
||||
@@ -597,6 +613,7 @@ class ClaudeConsoleRelayService {
|
||||
})
|
||||
|
||||
response.data.on('end', async () => {
|
||||
const autoProtectionDisabled = account.disableAutoProtection === true
|
||||
// 记录原始错误消息到日志(方便调试,包含供应商信息)
|
||||
logger.error(
|
||||
`📝 [Stream] Upstream error response from ${account?.name || accountId}: ${errorDataForCheck.substring(0, 500)}`
|
||||
@@ -609,24 +626,41 @@ class ClaudeConsoleRelayService {
|
||||
)
|
||||
|
||||
if (response.status === 401) {
|
||||
await claudeConsoleAccountService.markAccountUnauthorized(accountId)
|
||||
logger.warn(
|
||||
`🚫 [Stream] Unauthorized error detected for Claude Console account ${accountId}${autoProtectionDisabled ? ' (auto-protection disabled, skipping status change)' : ''}`
|
||||
)
|
||||
if (!autoProtectionDisabled) {
|
||||
await claudeConsoleAccountService.markAccountUnauthorized(accountId)
|
||||
}
|
||||
} else if (accountDisabledError) {
|
||||
logger.error(
|
||||
`🚫 [Stream] Account disabled error (400) detected for Claude Console account ${accountId}, marking as blocked`
|
||||
`🚫 [Stream] Account disabled error (400) detected for Claude Console account ${accountId}${autoProtectionDisabled ? ' (auto-protection disabled, skipping status change)' : ''}`
|
||||
)
|
||||
// 传入完整的错误详情到 webhook
|
||||
await claudeConsoleAccountService.markConsoleAccountBlocked(
|
||||
accountId,
|
||||
errorDataForCheck
|
||||
)
|
||||
if (!autoProtectionDisabled) {
|
||||
await claudeConsoleAccountService.markConsoleAccountBlocked(
|
||||
accountId,
|
||||
errorDataForCheck
|
||||
)
|
||||
}
|
||||
} else if (response.status === 429) {
|
||||
await claudeConsoleAccountService.markAccountRateLimited(accountId)
|
||||
logger.warn(
|
||||
`🚫 [Stream] Rate limit detected for Claude Console account ${accountId}${autoProtectionDisabled ? ' (auto-protection disabled, skipping status change)' : ''}`
|
||||
)
|
||||
// 检查是否因为超过每日额度
|
||||
claudeConsoleAccountService.checkQuotaUsage(accountId).catch((err) => {
|
||||
logger.error('❌ Failed to check quota after 429 error:', err)
|
||||
})
|
||||
if (!autoProtectionDisabled) {
|
||||
await claudeConsoleAccountService.markAccountRateLimited(accountId)
|
||||
}
|
||||
} else if (response.status === 529) {
|
||||
await claudeConsoleAccountService.markAccountOverloaded(accountId)
|
||||
logger.warn(
|
||||
`🚫 [Stream] Overload error detected for Claude Console account ${accountId}${autoProtectionDisabled ? ' (auto-protection disabled, skipping status change)' : ''}`
|
||||
)
|
||||
if (!autoProtectionDisabled) {
|
||||
await claudeConsoleAccountService.markAccountOverloaded(accountId)
|
||||
}
|
||||
}
|
||||
|
||||
// 设置响应头
|
||||
@@ -1113,55 +1147,11 @@ class ClaudeConsoleRelayService {
|
||||
}
|
||||
}
|
||||
|
||||
// 🧪 测试账号连接(供Admin API使用,独立处理以确保错误时也返回SSE格式)
|
||||
// 🧪 测试账号连接(供Admin API使用)
|
||||
async testAccountConnection(accountId, responseStream) {
|
||||
const testRequestBody = {
|
||||
model: 'claude-sonnet-4-5-20250929',
|
||||
max_tokens: 32000,
|
||||
stream: true,
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: 'hi'
|
||||
}
|
||||
],
|
||||
system: [
|
||||
{
|
||||
type: 'text',
|
||||
text: "You are Claude Code, Anthropic's official CLI for Claude."
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
// 辅助函数:发送 SSE 事件
|
||||
const sendSSEEvent = (type, data) => {
|
||||
if (!responseStream.destroyed && !responseStream.writableEnded) {
|
||||
try {
|
||||
responseStream.write(`data: ${JSON.stringify({ type, ...data })}\n\n`)
|
||||
} catch {
|
||||
// 忽略写入错误
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 辅助函数:结束测试并关闭流
|
||||
const endTest = (success, error = null) => {
|
||||
if (!responseStream.destroyed && !responseStream.writableEnded) {
|
||||
try {
|
||||
if (success) {
|
||||
sendSSEEvent('test_complete', { success: true })
|
||||
} else {
|
||||
sendSSEEvent('test_complete', { success: false, error: error || '测试失败' })
|
||||
}
|
||||
responseStream.end()
|
||||
} catch {
|
||||
// 忽略写入错误
|
||||
}
|
||||
}
|
||||
}
|
||||
const { sendStreamTestRequest } = require('../utils/testPayloadHelper')
|
||||
|
||||
try {
|
||||
// 获取账户信息
|
||||
const account = await claudeConsoleAccountService.getAccount(accountId)
|
||||
if (!account) {
|
||||
throw new Error('Account not found')
|
||||
@@ -1169,178 +1159,32 @@ class ClaudeConsoleRelayService {
|
||||
|
||||
logger.info(`🧪 Testing Claude Console account connection: ${account.name} (${accountId})`)
|
||||
|
||||
// 创建代理agent
|
||||
const proxyAgent = claudeConsoleAccountService._createProxyAgent(account.proxy)
|
||||
|
||||
// 设置响应头
|
||||
if (!responseStream.headersSent) {
|
||||
responseStream.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
Connection: 'keep-alive',
|
||||
'X-Accel-Buffering': 'no'
|
||||
})
|
||||
}
|
||||
|
||||
// 发送测试开始事件
|
||||
sendSSEEvent('test_start', {})
|
||||
|
||||
// 构建完整的API URL
|
||||
const cleanUrl = account.apiUrl.replace(/\/$/, '')
|
||||
const apiEndpoint = cleanUrl.endsWith('/v1/messages') ? cleanUrl : `${cleanUrl}/v1/messages`
|
||||
const apiUrl = cleanUrl.endsWith('/v1/messages')
|
||||
? cleanUrl
|
||||
: `${cleanUrl}/v1/messages?beta=true`
|
||||
|
||||
// 决定使用的 User-Agent
|
||||
const userAgent = account.userAgent || this.defaultUserAgent
|
||||
|
||||
// 准备请求配置
|
||||
const requestConfig = {
|
||||
method: 'POST',
|
||||
url: apiEndpoint,
|
||||
data: testRequestBody,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'anthropic-version': '2023-06-01',
|
||||
'User-Agent': userAgent
|
||||
},
|
||||
timeout: 30000, // 测试请求使用较短超时
|
||||
responseType: 'stream',
|
||||
validateStatus: () => true
|
||||
}
|
||||
|
||||
if (proxyAgent) {
|
||||
requestConfig.httpAgent = proxyAgent
|
||||
requestConfig.httpsAgent = proxyAgent
|
||||
requestConfig.proxy = false
|
||||
}
|
||||
|
||||
// 设置认证方式
|
||||
if (account.apiKey && account.apiKey.startsWith('sk-ant-')) {
|
||||
requestConfig.headers['x-api-key'] = account.apiKey
|
||||
} else {
|
||||
requestConfig.headers['Authorization'] = `Bearer ${account.apiKey}`
|
||||
}
|
||||
|
||||
// 发送请求
|
||||
const response = await axios(requestConfig)
|
||||
|
||||
logger.debug(`🌊 Claude Console test response status: ${response.status}`)
|
||||
|
||||
// 处理非200响应
|
||||
if (response.status !== 200) {
|
||||
logger.error(
|
||||
`❌ Claude Console API returned error status: ${response.status} | Account: ${account?.name || accountId}`
|
||||
)
|
||||
|
||||
// 收集错误响应数据
|
||||
return new Promise((resolve) => {
|
||||
const errorChunks = []
|
||||
|
||||
response.data.on('data', (chunk) => {
|
||||
errorChunks.push(chunk)
|
||||
})
|
||||
|
||||
response.data.on('end', () => {
|
||||
try {
|
||||
const fullErrorData = Buffer.concat(errorChunks).toString()
|
||||
logger.error(
|
||||
`📝 [Test] Upstream error response from ${account?.name || accountId}: ${fullErrorData.substring(0, 500)}`
|
||||
)
|
||||
|
||||
// 尝试解析错误信息
|
||||
let errorMessage = `API Error: ${response.status}`
|
||||
try {
|
||||
const errorJson = JSON.parse(fullErrorData)
|
||||
// 直接提取所有可能的错误信息字段
|
||||
errorMessage =
|
||||
errorJson.message ||
|
||||
errorJson.error?.message ||
|
||||
errorJson.statusMessage ||
|
||||
errorJson.error ||
|
||||
(typeof errorJson === 'string' ? errorJson : JSON.stringify(errorJson))
|
||||
} catch {
|
||||
errorMessage = fullErrorData.substring(0, 200) || `API Error: ${response.status}`
|
||||
}
|
||||
|
||||
endTest(false, errorMessage)
|
||||
resolve()
|
||||
} catch {
|
||||
endTest(false, `API Error: ${response.status}`)
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
|
||||
response.data.on('error', (err) => {
|
||||
endTest(false, err.message || '流读取错误')
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// 处理成功的流式响应
|
||||
return new Promise((resolve) => {
|
||||
let buffer = ''
|
||||
|
||||
response.data.on('data', (chunk) => {
|
||||
try {
|
||||
buffer += chunk.toString()
|
||||
const lines = buffer.split('\n')
|
||||
buffer = lines.pop() || ''
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith('data: ')) {
|
||||
continue
|
||||
}
|
||||
|
||||
const jsonStr = line.substring(6).trim()
|
||||
if (!jsonStr || jsonStr === '[DONE]') {
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
const data = JSON.parse(jsonStr)
|
||||
|
||||
// 转换 content_block_delta 为 content
|
||||
if (data.type === 'content_block_delta' && data.delta && data.delta.text) {
|
||||
sendSSEEvent('content', { text: data.delta.text })
|
||||
}
|
||||
|
||||
// 处理消息完成
|
||||
if (data.type === 'message_stop') {
|
||||
endTest(true)
|
||||
}
|
||||
|
||||
// 处理错误事件
|
||||
if (data.type === 'error') {
|
||||
const errorMsg = data.error?.message || data.message || '未知错误'
|
||||
endTest(false, errorMsg)
|
||||
}
|
||||
} catch {
|
||||
// 忽略解析错误
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// 忽略处理错误
|
||||
}
|
||||
})
|
||||
|
||||
response.data.on('end', () => {
|
||||
logger.info(`✅ Test request completed for account: ${account.name}`)
|
||||
// 如果还没结束,发送完成事件
|
||||
if (!responseStream.destroyed && !responseStream.writableEnded) {
|
||||
endTest(true)
|
||||
}
|
||||
resolve()
|
||||
})
|
||||
|
||||
response.data.on('error', (err) => {
|
||||
logger.error(`❌ Test stream error:`, err)
|
||||
endTest(false, err.message || '流处理错误')
|
||||
resolve()
|
||||
})
|
||||
await sendStreamTestRequest({
|
||||
apiUrl,
|
||||
authorization: `Bearer ${account.apiKey}`,
|
||||
responseStream,
|
||||
proxyAgent: claudeConsoleAccountService._createProxyAgent(account.proxy),
|
||||
extraHeaders: account.userAgent ? { 'User-Agent': account.userAgent } : {}
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error(`❌ Test account connection failed:`, error)
|
||||
endTest(false, error.message || '测试失败')
|
||||
if (!responseStream.headersSent) {
|
||||
responseStream.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache'
|
||||
})
|
||||
}
|
||||
if (!responseStream.destroyed && !responseStream.writableEnded) {
|
||||
responseStream.write(
|
||||
`data: ${JSON.stringify({ type: 'test_complete', success: false, error: error.message })}\n\n`
|
||||
)
|
||||
responseStream.end()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ const zlib = require('zlib')
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
const ProxyHelper = require('../utils/proxyHelper')
|
||||
const { filterForClaude } = require('../utils/headerFilter')
|
||||
const claudeAccountService = require('./claudeAccountService')
|
||||
const unifiedClaudeScheduler = require('./unifiedClaudeScheduler')
|
||||
const sessionHelper = require('../utils/sessionHelper')
|
||||
@@ -13,6 +14,7 @@ const redis = require('../models/redis')
|
||||
const ClaudeCodeValidator = require('../validators/clients/claudeCodeValidator')
|
||||
const { formatDateWithTimezone } = require('../utils/dateHelper')
|
||||
const requestIdentityService = require('./requestIdentityService')
|
||||
const { createClaudeTestPayload } = require('../utils/testPayloadHelper')
|
||||
|
||||
class ClaudeRelayService {
|
||||
constructor() {
|
||||
@@ -789,7 +791,8 @@ class ClaudeRelayService {
|
||||
return total
|
||||
}
|
||||
|
||||
const removeFromMessages = () => {
|
||||
// 只移除 cache_control 属性,保留内容本身,避免丢失用户消息
|
||||
const removeCacheControlFromMessages = () => {
|
||||
if (!Array.isArray(body.messages)) {
|
||||
return false
|
||||
}
|
||||
@@ -803,12 +806,8 @@ class ClaudeRelayService {
|
||||
for (let contentIndex = 0; contentIndex < message.content.length; contentIndex += 1) {
|
||||
const contentItem = message.content[contentIndex]
|
||||
if (contentItem && contentItem.cache_control) {
|
||||
message.content.splice(contentIndex, 1)
|
||||
|
||||
if (message.content.length === 0) {
|
||||
body.messages.splice(messageIndex, 1)
|
||||
}
|
||||
|
||||
// 只删除 cache_control 属性,保留内容
|
||||
delete contentItem.cache_control
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -817,7 +816,8 @@ class ClaudeRelayService {
|
||||
return false
|
||||
}
|
||||
|
||||
const removeFromSystem = () => {
|
||||
// 只移除 cache_control 属性,保留 system 内容
|
||||
const removeCacheControlFromSystem = () => {
|
||||
if (!Array.isArray(body.system)) {
|
||||
return false
|
||||
}
|
||||
@@ -825,12 +825,8 @@ class ClaudeRelayService {
|
||||
for (let index = 0; index < body.system.length; index += 1) {
|
||||
const systemItem = body.system[index]
|
||||
if (systemItem && systemItem.cache_control) {
|
||||
body.system.splice(index, 1)
|
||||
|
||||
if (body.system.length === 0) {
|
||||
delete body.system
|
||||
}
|
||||
|
||||
// 只删除 cache_control 属性,保留内容
|
||||
delete systemItem.cache_control
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -841,12 +837,13 @@ class ClaudeRelayService {
|
||||
let total = countCacheControlBlocks()
|
||||
|
||||
while (total > MAX_CACHE_CONTROL_BLOCKS) {
|
||||
if (removeFromMessages()) {
|
||||
// 优先从 messages 中移除 cache_control,再从 system 中移除
|
||||
if (removeCacheControlFromMessages()) {
|
||||
total -= 1
|
||||
continue
|
||||
}
|
||||
|
||||
if (removeFromSystem()) {
|
||||
if (removeCacheControlFromSystem()) {
|
||||
total -= 1
|
||||
continue
|
||||
}
|
||||
@@ -881,62 +878,9 @@ class ClaudeRelayService {
|
||||
|
||||
// 🔧 过滤客户端请求头
|
||||
_filterClientHeaders(clientHeaders) {
|
||||
// 需要移除的敏感 headers
|
||||
const sensitiveHeaders = [
|
||||
'content-type',
|
||||
'user-agent',
|
||||
'x-api-key',
|
||||
'authorization',
|
||||
'x-authorization',
|
||||
'host',
|
||||
'content-length',
|
||||
'connection',
|
||||
'proxy-authorization',
|
||||
'content-encoding',
|
||||
'transfer-encoding'
|
||||
]
|
||||
|
||||
// 🆕 需要移除的浏览器相关 headers(避免CORS问题)
|
||||
const browserHeaders = [
|
||||
'origin',
|
||||
'referer',
|
||||
'sec-fetch-mode',
|
||||
'sec-fetch-site',
|
||||
'sec-fetch-dest',
|
||||
'sec-ch-ua',
|
||||
'sec-ch-ua-mobile',
|
||||
'sec-ch-ua-platform',
|
||||
'accept-language',
|
||||
'accept-encoding',
|
||||
'accept',
|
||||
'cache-control',
|
||||
'pragma',
|
||||
'anthropic-dangerous-direct-browser-access' // 这个头可能触发CORS检查
|
||||
]
|
||||
|
||||
// 应该保留的 headers(用于会话一致性和追踪)
|
||||
const allowedHeaders = [
|
||||
'x-request-id',
|
||||
'anthropic-version', // 保留API版本
|
||||
'anthropic-beta' // 保留beta功能
|
||||
]
|
||||
|
||||
const filteredHeaders = {}
|
||||
|
||||
// 转发客户端的非敏感 headers
|
||||
Object.keys(clientHeaders || {}).forEach((key) => {
|
||||
const lowerKey = key.toLowerCase()
|
||||
// 如果在允许列表中,直接保留
|
||||
if (allowedHeaders.includes(lowerKey)) {
|
||||
filteredHeaders[key] = clientHeaders[key]
|
||||
}
|
||||
// 如果不在敏感列表和浏览器列表中,也保留
|
||||
else if (!sensitiveHeaders.includes(lowerKey) && !browserHeaders.includes(lowerKey)) {
|
||||
filteredHeaders[key] = clientHeaders[key]
|
||||
}
|
||||
})
|
||||
|
||||
return filteredHeaders
|
||||
// 使用统一的 headerFilter 工具类 - 移除 CDN、浏览器和代理相关 headers
|
||||
// 同时伪装成正常的直接客户端请求,避免触发上游 API 的安全检查
|
||||
return filterForClaude(clientHeaders)
|
||||
}
|
||||
|
||||
_applyRequestIdentityTransform(body, headers, context = {}) {
|
||||
@@ -2004,7 +1948,13 @@ class ClaudeRelayService {
|
||||
}
|
||||
|
||||
// 🛠️ 统一的错误处理方法
|
||||
async _handleServerError(accountId, statusCode, _sessionHash = null, context = '') {
|
||||
async _handleServerError(
|
||||
accountId,
|
||||
statusCode,
|
||||
sessionHash = null,
|
||||
context = '',
|
||||
accountType = 'claude-official'
|
||||
) {
|
||||
try {
|
||||
await claudeAccountService.recordServerError(accountId, statusCode)
|
||||
const errorCount = await claudeAccountService.getServerErrorCount(accountId)
|
||||
@@ -2018,6 +1968,18 @@ class ClaudeRelayService {
|
||||
`⏱️ ${prefix}${isTimeout ? 'Timeout' : 'Server'} error for account ${accountId}, error count: ${errorCount}/${threshold}`
|
||||
)
|
||||
|
||||
// 标记账户为临时不可用(5分钟)
|
||||
try {
|
||||
await unifiedClaudeScheduler.markAccountTemporarilyUnavailable(
|
||||
accountId,
|
||||
accountType,
|
||||
sessionHash,
|
||||
300
|
||||
)
|
||||
} catch (markError) {
|
||||
logger.error(`❌ Failed to mark account temporarily unavailable: ${accountId}`, markError)
|
||||
}
|
||||
|
||||
if (errorCount > threshold) {
|
||||
const errorTypeLabel = isTimeout ? 'timeout' : '5xx'
|
||||
// ⚠️ 只记录5xx/504告警,不再自动停止调度,避免上游抖动导致误停
|
||||
@@ -2249,26 +2211,7 @@ class ClaudeRelayService {
|
||||
|
||||
// 🧪 测试账号连接(供Admin API使用,直接复用 _makeClaudeStreamRequestWithUsageCapture)
|
||||
async testAccountConnection(accountId, responseStream) {
|
||||
const testRequestBody = {
|
||||
model: 'claude-sonnet-4-5-20250929',
|
||||
max_tokens: 100,
|
||||
stream: true,
|
||||
system: [
|
||||
{
|
||||
type: 'text',
|
||||
text: this.claudeCodeSystemPrompt,
|
||||
cache_control: {
|
||||
type: 'ephemeral'
|
||||
}
|
||||
}
|
||||
],
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: 'hi'
|
||||
}
|
||||
]
|
||||
}
|
||||
const testRequestBody = createClaudeTestPayload('claude-sonnet-4-5-20250929', { stream: true })
|
||||
|
||||
try {
|
||||
// 获取账户信息
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
const axios = require('axios')
|
||||
const ProxyHelper = require('../utils/proxyHelper')
|
||||
const logger = require('../utils/logger')
|
||||
const { filterForOpenAI } = require('../utils/headerFilter')
|
||||
const openaiResponsesAccountService = require('./openaiResponsesAccountService')
|
||||
const apiKeyService = require('./apiKeyService')
|
||||
const unifiedOpenAIScheduler = require('./unifiedOpenAIScheduler')
|
||||
@@ -73,9 +74,9 @@ class OpenAIResponsesRelayService {
|
||||
const targetUrl = `${fullAccount.baseApi}${req.path}`
|
||||
logger.info(`🎯 Forwarding to: ${targetUrl}`)
|
||||
|
||||
// 构建请求头
|
||||
// 构建请求头 - 使用统一的 headerFilter 移除 CDN headers
|
||||
const headers = {
|
||||
...this._filterRequestHeaders(req.headers),
|
||||
...filterForOpenAI(req.headers),
|
||||
Authorization: `Bearer ${fullAccount.apiKey}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
@@ -810,29 +811,10 @@ class OpenAIResponsesRelayService {
|
||||
return { resetsInSeconds, errorData }
|
||||
}
|
||||
|
||||
// 过滤请求头
|
||||
// 过滤请求头 - 已迁移到 headerFilter 工具类
|
||||
// 此方法保留用于向后兼容,实际使用 filterForOpenAI()
|
||||
_filterRequestHeaders(headers) {
|
||||
const filtered = {}
|
||||
const skipHeaders = [
|
||||
'host',
|
||||
'content-length',
|
||||
'authorization',
|
||||
'x-api-key',
|
||||
'x-cr-api-key',
|
||||
'connection',
|
||||
'upgrade',
|
||||
'sec-websocket-key',
|
||||
'sec-websocket-version',
|
||||
'sec-websocket-extensions'
|
||||
]
|
||||
|
||||
for (const [key, value] of Object.entries(headers)) {
|
||||
if (!skipHeaders.includes(key.toLowerCase())) {
|
||||
filtered[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
return filtered
|
||||
return filterForOpenAI(headers)
|
||||
}
|
||||
|
||||
// 估算费用(简化版本,实际应该根据不同的定价模型)
|
||||
|
||||
@@ -5,7 +5,33 @@ const ccrAccountService = require('./ccrAccountService')
|
||||
const accountGroupService = require('./accountGroupService')
|
||||
const redis = require('../models/redis')
|
||||
const logger = require('../utils/logger')
|
||||
const { parseVendorPrefixedModel } = require('../utils/modelHelper')
|
||||
const { parseVendorPrefixedModel, isOpus45OrNewer } = require('../utils/modelHelper')
|
||||
|
||||
/**
|
||||
* Check if account is Pro (not Max)
|
||||
*
|
||||
* ACCOUNT TYPE LOGIC (as of 2025-12-05):
|
||||
* Pro accounts can be identified by either:
|
||||
* 1. API real-time data: hasClaudePro=true && hasClaudeMax=false
|
||||
* 2. Local config data: accountType='claude_pro'
|
||||
*
|
||||
* Account type restrictions for Opus models:
|
||||
* - Free account: No Opus access at all
|
||||
* - Pro account: Only Opus 4.5+ (new versions)
|
||||
* - Max account: All Opus versions (legacy 3.x, 4.0, 4.1 and new 4.5+)
|
||||
*
|
||||
* Compatible with both API real-time data (hasClaudePro) and local config (accountType)
|
||||
* @param {Object} info - Subscription info object
|
||||
* @returns {boolean} - true if Pro account (not Free, not Max)
|
||||
*/
|
||||
function isProAccount(info) {
|
||||
// API real-time status takes priority
|
||||
if (info.hasClaudePro === true && info.hasClaudeMax !== true) {
|
||||
return true
|
||||
}
|
||||
// Local configured account type
|
||||
return info.accountType === 'claude_pro'
|
||||
}
|
||||
|
||||
class UnifiedClaudeScheduler {
|
||||
constructor() {
|
||||
@@ -46,8 +72,14 @@ class UnifiedClaudeScheduler {
|
||||
return false
|
||||
}
|
||||
|
||||
// 2. Opus 模型的订阅级别检查
|
||||
// 2. Opus model subscription level check
|
||||
// VERSION RESTRICTION LOGIC:
|
||||
// - Free: No Opus models
|
||||
// - Pro: Only Opus 4.5+ (isOpus45OrNewer = true)
|
||||
// - Max: All Opus versions
|
||||
if (requestedModel.toLowerCase().includes('opus')) {
|
||||
const isNewOpus = isOpus45OrNewer(requestedModel)
|
||||
|
||||
if (account.subscriptionInfo) {
|
||||
try {
|
||||
const info =
|
||||
@@ -55,27 +87,36 @@ class UnifiedClaudeScheduler {
|
||||
? JSON.parse(account.subscriptionInfo)
|
||||
: account.subscriptionInfo
|
||||
|
||||
// Pro 和 Free 账号不支持 Opus
|
||||
if (info.hasClaudePro === true && info.hasClaudeMax !== true) {
|
||||
// Free account: does not support any Opus model
|
||||
if (info.accountType === 'free') {
|
||||
logger.info(
|
||||
`🚫 Claude account ${account.name} (Pro) does not support Opus model${context ? ` ${context}` : ''}`
|
||||
`🚫 Claude account ${account.name} (Free) does not support Opus model${context ? ` ${context}` : ''}`
|
||||
)
|
||||
return false
|
||||
}
|
||||
if (info.accountType === 'claude_pro' || info.accountType === 'claude_free') {
|
||||
logger.info(
|
||||
`🚫 Claude account ${account.name} (${info.accountType}) does not support Opus model${context ? ` ${context}` : ''}`
|
||||
)
|
||||
return false
|
||||
|
||||
// Pro account: only supports Opus 4.5+
|
||||
// Reject legacy Opus (3.x, 4.0-4.4) but allow new Opus (4.5+)
|
||||
if (isProAccount(info)) {
|
||||
if (!isNewOpus) {
|
||||
logger.info(
|
||||
`🚫 Claude account ${account.name} (Pro) does not support legacy Opus model${context ? ` ${context}` : ''}`
|
||||
)
|
||||
return false
|
||||
}
|
||||
// Opus 4.5+ supported
|
||||
return true
|
||||
}
|
||||
|
||||
// Max account: supports all Opus versions (no restriction)
|
||||
} catch (e) {
|
||||
// 解析失败,假设为旧数据,默认支持(兼容旧数据为 Max)
|
||||
// Parse failed, assume legacy data (Max), default support
|
||||
logger.debug(
|
||||
`Account ${account.name} has invalid subscriptionInfo${context ? ` ${context}` : ''}, assuming Max`
|
||||
)
|
||||
}
|
||||
}
|
||||
// 没有订阅信息的账号,默认当作支持(兼容旧数据)
|
||||
// Account without subscription info, default to supported (legacy data compatibility)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -177,30 +218,41 @@ class UnifiedClaudeScheduler {
|
||||
// 普通专属账户
|
||||
const boundAccount = await redis.getClaudeAccount(apiKeyData.claudeAccountId)
|
||||
if (boundAccount && boundAccount.isActive === 'true' && boundAccount.status !== 'error') {
|
||||
const isRateLimited = await claudeAccountService.isAccountRateLimited(boundAccount.id)
|
||||
if (isRateLimited) {
|
||||
const rateInfo = await claudeAccountService.getAccountRateLimitInfo(boundAccount.id)
|
||||
const error = new Error('Dedicated Claude account is rate limited')
|
||||
error.code = 'CLAUDE_DEDICATED_RATE_LIMITED'
|
||||
error.accountId = boundAccount.id
|
||||
error.rateLimitEndAt = rateInfo?.rateLimitEndAt || boundAccount.rateLimitEndAt || null
|
||||
throw error
|
||||
}
|
||||
|
||||
if (!this._isSchedulable(boundAccount.schedulable)) {
|
||||
// 检查是否临时不可用
|
||||
const isTempUnavailable = await this.isAccountTemporarilyUnavailable(
|
||||
boundAccount.id,
|
||||
'claude-official'
|
||||
)
|
||||
if (isTempUnavailable) {
|
||||
logger.warn(
|
||||
`⚠️ Bound Claude OAuth account ${apiKeyData.claudeAccountId} is not schedulable (schedulable: ${boundAccount?.schedulable}), falling back to pool`
|
||||
`⏱️ Bound Claude OAuth account ${boundAccount.id} is temporarily unavailable, falling back to pool`
|
||||
)
|
||||
} else {
|
||||
if (isOpusRequest) {
|
||||
await claudeAccountService.clearExpiredOpusRateLimit(boundAccount.id)
|
||||
const isRateLimited = await claudeAccountService.isAccountRateLimited(boundAccount.id)
|
||||
if (isRateLimited) {
|
||||
const rateInfo = await claudeAccountService.getAccountRateLimitInfo(boundAccount.id)
|
||||
const error = new Error('Dedicated Claude account is rate limited')
|
||||
error.code = 'CLAUDE_DEDICATED_RATE_LIMITED'
|
||||
error.accountId = boundAccount.id
|
||||
error.rateLimitEndAt = rateInfo?.rateLimitEndAt || boundAccount.rateLimitEndAt || null
|
||||
throw error
|
||||
}
|
||||
logger.info(
|
||||
`🎯 Using bound dedicated Claude OAuth account: ${boundAccount.name} (${apiKeyData.claudeAccountId}) for API key ${apiKeyData.name}`
|
||||
)
|
||||
return {
|
||||
accountId: apiKeyData.claudeAccountId,
|
||||
accountType: 'claude-official'
|
||||
|
||||
if (!this._isSchedulable(boundAccount.schedulable)) {
|
||||
logger.warn(
|
||||
`⚠️ Bound Claude OAuth account ${apiKeyData.claudeAccountId} is not schedulable (schedulable: ${boundAccount?.schedulable}), falling back to pool`
|
||||
)
|
||||
} else {
|
||||
if (isOpusRequest) {
|
||||
await claudeAccountService.clearExpiredOpusRateLimit(boundAccount.id)
|
||||
}
|
||||
logger.info(
|
||||
`🎯 Using bound dedicated Claude OAuth account: ${boundAccount.name} (${apiKeyData.claudeAccountId}) for API key ${apiKeyData.name}`
|
||||
)
|
||||
return {
|
||||
accountId: apiKeyData.claudeAccountId,
|
||||
accountType: 'claude-official'
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -221,12 +273,23 @@ class UnifiedClaudeScheduler {
|
||||
boundConsoleAccount.status === 'active' &&
|
||||
this._isSchedulable(boundConsoleAccount.schedulable)
|
||||
) {
|
||||
logger.info(
|
||||
`🎯 Using bound dedicated Claude Console account: ${boundConsoleAccount.name} (${apiKeyData.claudeConsoleAccountId}) for API key ${apiKeyData.name}`
|
||||
// 检查是否临时不可用
|
||||
const isTempUnavailable = await this.isAccountTemporarilyUnavailable(
|
||||
boundConsoleAccount.id,
|
||||
'claude-console'
|
||||
)
|
||||
return {
|
||||
accountId: apiKeyData.claudeConsoleAccountId,
|
||||
accountType: 'claude-console'
|
||||
if (isTempUnavailable) {
|
||||
logger.warn(
|
||||
`⏱️ Bound Claude Console account ${boundConsoleAccount.id} is temporarily unavailable, falling back to pool`
|
||||
)
|
||||
} else {
|
||||
logger.info(
|
||||
`🎯 Using bound dedicated Claude Console account: ${boundConsoleAccount.name} (${apiKeyData.claudeConsoleAccountId}) for API key ${apiKeyData.name}`
|
||||
)
|
||||
return {
|
||||
accountId: apiKeyData.claudeConsoleAccountId,
|
||||
accountType: 'claude-console'
|
||||
}
|
||||
}
|
||||
} else {
|
||||
logger.warn(
|
||||
@@ -245,12 +308,23 @@ class UnifiedClaudeScheduler {
|
||||
boundBedrockAccountResult.data.isActive === true &&
|
||||
this._isSchedulable(boundBedrockAccountResult.data.schedulable)
|
||||
) {
|
||||
logger.info(
|
||||
`🎯 Using bound dedicated Bedrock account: ${boundBedrockAccountResult.data.name} (${apiKeyData.bedrockAccountId}) for API key ${apiKeyData.name}`
|
||||
// 检查是否临时不可用
|
||||
const isTempUnavailable = await this.isAccountTemporarilyUnavailable(
|
||||
apiKeyData.bedrockAccountId,
|
||||
'bedrock'
|
||||
)
|
||||
return {
|
||||
accountId: apiKeyData.bedrockAccountId,
|
||||
accountType: 'bedrock'
|
||||
if (isTempUnavailable) {
|
||||
logger.warn(
|
||||
`⏱️ Bound Bedrock account ${apiKeyData.bedrockAccountId} is temporarily unavailable, falling back to pool`
|
||||
)
|
||||
} else {
|
||||
logger.info(
|
||||
`🎯 Using bound dedicated Bedrock account: ${boundBedrockAccountResult.data.name} (${apiKeyData.bedrockAccountId}) for API key ${apiKeyData.name}`
|
||||
)
|
||||
return {
|
||||
accountId: apiKeyData.bedrockAccountId,
|
||||
accountType: 'bedrock'
|
||||
}
|
||||
}
|
||||
} else {
|
||||
logger.warn(
|
||||
@@ -496,6 +570,18 @@ class UnifiedClaudeScheduler {
|
||||
continue
|
||||
}
|
||||
|
||||
// 检查是否临时不可用
|
||||
const isTempUnavailable = await this.isAccountTemporarilyUnavailable(
|
||||
account.id,
|
||||
'claude-official'
|
||||
)
|
||||
if (isTempUnavailable) {
|
||||
logger.debug(
|
||||
`⏭️ Skipping Claude Official account ${account.name} - temporarily unavailable`
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
// 检查是否被限流
|
||||
const isRateLimited = await claudeAccountService.isAccountRateLimited(account.id)
|
||||
if (isRateLimited) {
|
||||
@@ -584,6 +670,18 @@ class UnifiedClaudeScheduler {
|
||||
// 继续处理该账号
|
||||
}
|
||||
|
||||
// 检查是否临时不可用
|
||||
const isTempUnavailable = await this.isAccountTemporarilyUnavailable(
|
||||
currentAccount.id,
|
||||
'claude-console'
|
||||
)
|
||||
if (isTempUnavailable) {
|
||||
logger.debug(
|
||||
`⏭️ Skipping Claude Console account ${currentAccount.name} - temporarily unavailable`
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
// 检查是否被限流
|
||||
const isRateLimited = await claudeConsoleAccountService.isAccountRateLimited(
|
||||
currentAccount.id
|
||||
@@ -682,7 +780,15 @@ class UnifiedClaudeScheduler {
|
||||
account.accountType === 'shared' &&
|
||||
this._isSchedulable(account.schedulable)
|
||||
) {
|
||||
// 检查是否可调度
|
||||
// 检查是否临时不可用
|
||||
const isTempUnavailable = await this.isAccountTemporarilyUnavailable(
|
||||
account.id,
|
||||
'bedrock'
|
||||
)
|
||||
if (isTempUnavailable) {
|
||||
logger.debug(`⏭️ Skipping Bedrock account ${account.name} - temporarily unavailable`)
|
||||
continue
|
||||
}
|
||||
|
||||
availableAccounts.push({
|
||||
...account,
|
||||
@@ -731,6 +837,13 @@ class UnifiedClaudeScheduler {
|
||||
continue
|
||||
}
|
||||
|
||||
// 检查是否临时不可用
|
||||
const isTempUnavailable = await this.isAccountTemporarilyUnavailable(account.id, 'ccr')
|
||||
if (isTempUnavailable) {
|
||||
logger.debug(`⏭️ Skipping CCR account ${account.name} - temporarily unavailable`)
|
||||
continue
|
||||
}
|
||||
|
||||
// 检查是否被限流
|
||||
const isRateLimited = await ccrAccountService.isAccountRateLimited(account.id)
|
||||
const isQuotaExceeded = await ccrAccountService.isAccountQuotaExceeded(account.id)
|
||||
@@ -1099,6 +1212,42 @@ class UnifiedClaudeScheduler {
|
||||
}
|
||||
}
|
||||
|
||||
// ⏱️ 标记账户为临时不可用状态(用于5xx等临时故障,默认5分钟后自动恢复)
|
||||
async markAccountTemporarilyUnavailable(
|
||||
accountId,
|
||||
accountType,
|
||||
sessionHash = null,
|
||||
ttlSeconds = 300
|
||||
) {
|
||||
try {
|
||||
const client = redis.getClientSafe()
|
||||
const key = `temp_unavailable:${accountType}:${accountId}`
|
||||
await client.setex(key, ttlSeconds, '1')
|
||||
if (sessionHash) {
|
||||
await this._deleteSessionMapping(sessionHash)
|
||||
}
|
||||
logger.warn(
|
||||
`⏱️ Account ${accountId} (${accountType}) marked temporarily unavailable for ${ttlSeconds}s`
|
||||
)
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
logger.error(`❌ Failed to mark account temporarily unavailable: ${accountId}`, error)
|
||||
return { success: false }
|
||||
}
|
||||
}
|
||||
|
||||
// 🔍 检查账户是否临时不可用
|
||||
async isAccountTemporarilyUnavailable(accountId, accountType) {
|
||||
try {
|
||||
const client = redis.getClientSafe()
|
||||
const key = `temp_unavailable:${accountType}:${accountId}`
|
||||
return (await client.exists(key)) === 1
|
||||
} catch (error) {
|
||||
logger.error(`❌ Failed to check temp unavailable status: ${accountId}`, error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// 🚫 标记账户为限流状态
|
||||
async markAccountRateLimited(
|
||||
accountId,
|
||||
|
||||
133
src/utils/headerFilter.js
Normal file
133
src/utils/headerFilter.js
Normal file
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* 统一的 CDN Headers 过滤列表
|
||||
*
|
||||
* 用于各服务在原有过滤逻辑基础上,额外移除 Cloudflare CDN 和代理相关的 headers
|
||||
* 避免触发上游 API(如 88code)的安全检查
|
||||
*/
|
||||
|
||||
// Cloudflare CDN headers(橙色云代理模式会添加这些)
|
||||
const cdnHeaders = [
|
||||
'x-real-ip',
|
||||
'x-forwarded-for',
|
||||
'x-forwarded-proto',
|
||||
'x-forwarded-host',
|
||||
'x-forwarded-port',
|
||||
'x-accel-buffering',
|
||||
'cf-ray',
|
||||
'cf-connecting-ip',
|
||||
'cf-ipcountry',
|
||||
'cf-visitor',
|
||||
'cf-request-id',
|
||||
'cdn-loop',
|
||||
'true-client-ip'
|
||||
]
|
||||
|
||||
/**
|
||||
* 为 OpenAI/Responses API 过滤 headers
|
||||
* 在原有 skipHeaders 基础上添加 CDN headers
|
||||
*/
|
||||
function filterForOpenAI(headers) {
|
||||
const skipHeaders = [
|
||||
'host',
|
||||
'content-length',
|
||||
'authorization',
|
||||
'x-api-key',
|
||||
'x-cr-api-key',
|
||||
'connection',
|
||||
'upgrade',
|
||||
'sec-websocket-key',
|
||||
'sec-websocket-version',
|
||||
'sec-websocket-extensions',
|
||||
...cdnHeaders // 添加 CDN headers
|
||||
]
|
||||
|
||||
const filtered = {}
|
||||
for (const [key, value] of Object.entries(headers)) {
|
||||
if (!skipHeaders.includes(key.toLowerCase())) {
|
||||
filtered[key] = value
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
/**
|
||||
* 为 Claude/Anthropic API 过滤 headers
|
||||
* 在原有逻辑基础上添加 CDN headers 到敏感列表
|
||||
*/
|
||||
function filterForClaude(headers) {
|
||||
const sensitiveHeaders = [
|
||||
'content-type',
|
||||
'user-agent',
|
||||
'x-api-key',
|
||||
'authorization',
|
||||
'x-authorization',
|
||||
'host',
|
||||
'content-length',
|
||||
'connection',
|
||||
'proxy-authorization',
|
||||
'content-encoding',
|
||||
'transfer-encoding',
|
||||
...cdnHeaders // 添加 CDN headers
|
||||
]
|
||||
|
||||
const browserHeaders = [
|
||||
'origin',
|
||||
'referer',
|
||||
'sec-fetch-mode',
|
||||
'sec-fetch-site',
|
||||
'sec-fetch-dest',
|
||||
'sec-ch-ua',
|
||||
'sec-ch-ua-mobile',
|
||||
'sec-ch-ua-platform',
|
||||
'accept-language',
|
||||
'accept-encoding',
|
||||
'accept',
|
||||
'cache-control',
|
||||
'pragma',
|
||||
'anthropic-dangerous-direct-browser-access'
|
||||
]
|
||||
|
||||
const allowedHeaders = ['x-request-id', 'anthropic-version', 'anthropic-beta']
|
||||
|
||||
const filtered = {}
|
||||
Object.keys(headers || {}).forEach((key) => {
|
||||
const lowerKey = key.toLowerCase()
|
||||
if (allowedHeaders.includes(lowerKey)) {
|
||||
filtered[key] = headers[key]
|
||||
} else if (!sensitiveHeaders.includes(lowerKey) && !browserHeaders.includes(lowerKey)) {
|
||||
filtered[key] = headers[key]
|
||||
}
|
||||
})
|
||||
|
||||
return filtered
|
||||
}
|
||||
|
||||
/**
|
||||
* 为 Gemini API 过滤 headers(如果需要转发客户端 headers 时使用)
|
||||
* 目前 Gemini 服务不转发客户端 headers,仅提供此方法备用
|
||||
*/
|
||||
function filterForGemini(headers) {
|
||||
const skipHeaders = [
|
||||
'host',
|
||||
'content-length',
|
||||
'authorization',
|
||||
'x-api-key',
|
||||
'connection',
|
||||
...cdnHeaders // 添加 CDN headers
|
||||
]
|
||||
|
||||
const filtered = {}
|
||||
for (const [key, value] of Object.entries(headers)) {
|
||||
if (!skipHeaders.includes(key.toLowerCase())) {
|
||||
filtered[key] = value
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
cdnHeaders,
|
||||
filterForOpenAI,
|
||||
filterForClaude,
|
||||
filterForGemini
|
||||
}
|
||||
@@ -70,9 +70,119 @@ function getVendorType(modelStr) {
|
||||
return vendor
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the model is Opus 4.5 or newer.
|
||||
*
|
||||
* VERSION LOGIC (as of 2025-12-05):
|
||||
* - Opus 4.5+ (including 5.0, 6.0, etc.) → returns true (Pro account eligible)
|
||||
* - Opus 4.4 and below (including 3.x, 4.0, 4.1) → returns false (Max account only)
|
||||
*
|
||||
* Supported naming formats:
|
||||
* - New format: claude-opus-{major}[-{minor}][-date], e.g., claude-opus-4-5-20251101
|
||||
* - New format: claude-opus-{major}.{minor}, e.g., claude-opus-4.5
|
||||
* - Old format: claude-{version}-opus[-date], e.g., claude-3-opus-20240229
|
||||
* - Special: opus-latest, claude-opus-latest → always returns true
|
||||
*
|
||||
* @param {string} modelName - Model name
|
||||
* @returns {boolean} - Whether the model is Opus 4.5 or newer
|
||||
*/
|
||||
function isOpus45OrNewer(modelName) {
|
||||
if (!modelName) {
|
||||
return false
|
||||
}
|
||||
|
||||
const lowerModel = modelName.toLowerCase()
|
||||
if (!lowerModel.includes('opus')) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Handle 'latest' special case
|
||||
if (lowerModel.includes('opus-latest') || lowerModel.includes('opus_latest')) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Old format: claude-{version}-opus (version before opus)
|
||||
// e.g., claude-3-opus-20240229, claude-3.5-opus
|
||||
const oldFormatMatch = lowerModel.match(/claude[- ](\d+)(?:[.-](\d+))?[- ]opus/)
|
||||
if (oldFormatMatch) {
|
||||
const majorVersion = parseInt(oldFormatMatch[1], 10)
|
||||
const minorVersion = oldFormatMatch[2] ? parseInt(oldFormatMatch[2], 10) : 0
|
||||
|
||||
// Old format version refers to Claude major version
|
||||
// majorVersion > 4: 5.x, 6.x, ... → true
|
||||
// majorVersion === 4 && minorVersion >= 5: 4.5, 4.6, ... → true
|
||||
// Others (3.x, 4.0-4.4): → false
|
||||
if (majorVersion > 4) {
|
||||
return true
|
||||
}
|
||||
if (majorVersion === 4 && minorVersion >= 5) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// New format 1: opus-{major}.{minor} (dot-separated)
|
||||
// e.g., claude-opus-4.5, opus-4.5
|
||||
const dotFormatMatch = lowerModel.match(/opus[- ]?(\d+)\.(\d+)/)
|
||||
if (dotFormatMatch) {
|
||||
const majorVersion = parseInt(dotFormatMatch[1], 10)
|
||||
const minorVersion = parseInt(dotFormatMatch[2], 10)
|
||||
|
||||
// Same version logic as old format
|
||||
// opus-5.0, opus-6.0 → true
|
||||
// opus-4.5, opus-4.6 → true
|
||||
// opus-4.0, opus-4.4 → false
|
||||
if (majorVersion > 4) {
|
||||
return true
|
||||
}
|
||||
if (majorVersion === 4 && minorVersion >= 5) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// New format 2: opus-{major}[-{minor}][-date] (hyphen-separated)
|
||||
// e.g., claude-opus-4-5-20251101, claude-opus-4-20250514, claude-opus-4-1-20250805
|
||||
// If opus-{major} is followed by 8-digit date, there's no minor version
|
||||
|
||||
// Extract content after 'opus'
|
||||
const opusIndex = lowerModel.indexOf('opus')
|
||||
const afterOpus = lowerModel.substring(opusIndex + 4)
|
||||
|
||||
// Match: -{major}-{minor}-{date} or -{major}-{date} or -{major}
|
||||
// IMPORTANT: Minor version regex is (\d{1,2}) not (\d+)
|
||||
// This prevents matching 8-digit dates as minor version
|
||||
// Example: opus-4-20250514 → major=4, minor=undefined (not 20250514)
|
||||
// Example: opus-4-5-20251101 → major=4, minor=5
|
||||
// Future-proof: Supports up to 2-digit minor versions (0-99)
|
||||
const versionMatch = afterOpus.match(/^[- ](\d+)(?:[- ](\d{1,2})(?=[- ]\d{8}|$))?/)
|
||||
|
||||
if (versionMatch) {
|
||||
const majorVersion = parseInt(versionMatch[1], 10)
|
||||
const minorVersion = versionMatch[2] ? parseInt(versionMatch[2], 10) : 0
|
||||
|
||||
// Same version logic: >= 4.5 returns true
|
||||
// opus-5-0-date, opus-6-date → true
|
||||
// opus-4-5-date, opus-4-10-date → true (supports 2-digit minor)
|
||||
// opus-4-date (no minor, treated as 4.0) → false
|
||||
// opus-4-1-date, opus-4-4-date → false
|
||||
if (majorVersion > 4) {
|
||||
return true
|
||||
}
|
||||
if (majorVersion === 4 && minorVersion >= 5) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Other cases containing 'opus' but cannot parse version, assume legacy
|
||||
return false
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
parseVendorPrefixedModel,
|
||||
hasVendorPrefix,
|
||||
getEffectiveModel,
|
||||
getVendorType
|
||||
getVendorType,
|
||||
isOpus45OrNewer
|
||||
}
|
||||
|
||||
@@ -18,6 +18,13 @@ const OAUTH_CONFIG = {
|
||||
SCOPES_SETUP: 'user:inference' // Setup Token 只需要推理权限
|
||||
}
|
||||
|
||||
// Cookie自动授权配置常量
|
||||
const COOKIE_OAUTH_CONFIG = {
|
||||
CLAUDE_AI_URL: 'https://claude.ai',
|
||||
ORGANIZATIONS_URL: 'https://claude.ai/api/organizations',
|
||||
AUTHORIZE_URL_TEMPLATE: 'https://claude.ai/v1/oauth/{organization_uuid}/authorize'
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成随机的 state 参数
|
||||
* @returns {string} 随机生成的 state (base64url编码)
|
||||
@@ -570,8 +577,299 @@ function extractExtInfo(data) {
|
||||
return Object.keys(ext).length > 0 ? ext : null
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Cookie自动授权相关方法 (基于Clove项目实现)
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* 构建带Cookie的请求头
|
||||
* @param {string} sessionKey - sessionKey值
|
||||
* @returns {object} 请求头对象
|
||||
*/
|
||||
function buildCookieHeaders(sessionKey) {
|
||||
return {
|
||||
Accept: 'application/json',
|
||||
'Accept-Language': 'en-US,en;q=0.9',
|
||||
'Cache-Control': 'no-cache',
|
||||
Cookie: `sessionKey=${sessionKey}`,
|
||||
Origin: COOKIE_OAUTH_CONFIG.CLAUDE_AI_URL,
|
||||
Referer: `${COOKIE_OAUTH_CONFIG.CLAUDE_AI_URL}/new`,
|
||||
'User-Agent':
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用Cookie获取组织UUID和能力列表
|
||||
* @param {string} sessionKey - sessionKey值
|
||||
* @param {object|null} proxyConfig - 代理配置(可选)
|
||||
* @returns {Promise<{organizationUuid: string, capabilities: string[]}>}
|
||||
*/
|
||||
async function getOrganizationInfo(sessionKey, proxyConfig = null) {
|
||||
const headers = buildCookieHeaders(sessionKey)
|
||||
const agent = createProxyAgent(proxyConfig)
|
||||
|
||||
try {
|
||||
if (agent) {
|
||||
logger.info(`🌐 Using proxy for organization info: ${ProxyHelper.maskProxyInfo(proxyConfig)}`)
|
||||
}
|
||||
|
||||
logger.debug('🔄 Fetching organization info with Cookie', {
|
||||
url: COOKIE_OAUTH_CONFIG.ORGANIZATIONS_URL,
|
||||
hasProxy: !!proxyConfig
|
||||
})
|
||||
|
||||
const axiosConfig = {
|
||||
headers,
|
||||
timeout: 30000,
|
||||
maxRedirects: 0 // 禁止自动重定向,以便检测Cloudflare拦截(302)
|
||||
}
|
||||
|
||||
if (agent) {
|
||||
axiosConfig.httpAgent = agent
|
||||
axiosConfig.httpsAgent = agent
|
||||
axiosConfig.proxy = false
|
||||
}
|
||||
|
||||
const response = await axios.get(COOKIE_OAUTH_CONFIG.ORGANIZATIONS_URL, axiosConfig)
|
||||
|
||||
if (!response.data || !Array.isArray(response.data)) {
|
||||
throw new Error('获取组织信息失败:响应格式无效')
|
||||
}
|
||||
|
||||
// 找到具有chat能力且能力最多的组织
|
||||
let bestOrg = null
|
||||
let maxCapabilities = []
|
||||
|
||||
for (const org of response.data) {
|
||||
const capabilities = org.capabilities || []
|
||||
|
||||
// 必须有chat能力
|
||||
if (!capabilities.includes('chat')) {
|
||||
continue
|
||||
}
|
||||
|
||||
// 选择能力最多的组织
|
||||
if (capabilities.length > maxCapabilities.length) {
|
||||
bestOrg = org
|
||||
maxCapabilities = capabilities
|
||||
}
|
||||
}
|
||||
|
||||
if (!bestOrg || !bestOrg.uuid) {
|
||||
throw new Error('未找到具有chat能力的组织')
|
||||
}
|
||||
|
||||
logger.success('✅ Found organization', {
|
||||
uuid: bestOrg.uuid,
|
||||
capabilities: maxCapabilities
|
||||
})
|
||||
|
||||
return {
|
||||
organizationUuid: bestOrg.uuid,
|
||||
capabilities: maxCapabilities
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.response) {
|
||||
const { status } = error.response
|
||||
|
||||
if (status === 403 || status === 401) {
|
||||
throw new Error('Cookie授权失败:无效的sessionKey或已过期')
|
||||
}
|
||||
|
||||
if (status === 302) {
|
||||
throw new Error('请求被Cloudflare拦截,请稍后重试')
|
||||
}
|
||||
|
||||
throw new Error(`获取组织信息失败:HTTP ${status}`)
|
||||
} else if (error.request) {
|
||||
throw new Error('获取组织信息失败:网络错误或超时')
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用Cookie自动获取授权code
|
||||
* @param {string} sessionKey - sessionKey值
|
||||
* @param {string} organizationUuid - 组织UUID
|
||||
* @param {string} scope - 授权scope
|
||||
* @param {object|null} proxyConfig - 代理配置(可选)
|
||||
* @returns {Promise<{authorizationCode: string, codeVerifier: string, state: string}>}
|
||||
*/
|
||||
async function authorizeWithCookie(sessionKey, organizationUuid, scope, proxyConfig = null) {
|
||||
// 生成PKCE参数
|
||||
const codeVerifier = generateCodeVerifier()
|
||||
const codeChallenge = generateCodeChallenge(codeVerifier)
|
||||
const state = generateState()
|
||||
|
||||
// 构建授权URL
|
||||
const authorizeUrl = COOKIE_OAUTH_CONFIG.AUTHORIZE_URL_TEMPLATE.replace(
|
||||
'{organization_uuid}',
|
||||
organizationUuid
|
||||
)
|
||||
|
||||
// 构建请求payload
|
||||
const payload = {
|
||||
response_type: 'code',
|
||||
client_id: OAUTH_CONFIG.CLIENT_ID,
|
||||
organization_uuid: organizationUuid,
|
||||
redirect_uri: OAUTH_CONFIG.REDIRECT_URI,
|
||||
scope,
|
||||
state,
|
||||
code_challenge: codeChallenge,
|
||||
code_challenge_method: 'S256'
|
||||
}
|
||||
|
||||
const headers = {
|
||||
...buildCookieHeaders(sessionKey),
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
|
||||
const agent = createProxyAgent(proxyConfig)
|
||||
|
||||
try {
|
||||
if (agent) {
|
||||
logger.info(
|
||||
`🌐 Using proxy for Cookie authorization: ${ProxyHelper.maskProxyInfo(proxyConfig)}`
|
||||
)
|
||||
}
|
||||
|
||||
logger.debug('🔄 Requesting authorization with Cookie', {
|
||||
url: authorizeUrl,
|
||||
scope,
|
||||
hasProxy: !!proxyConfig
|
||||
})
|
||||
|
||||
const axiosConfig = {
|
||||
headers,
|
||||
timeout: 30000,
|
||||
maxRedirects: 0 // 禁止自动重定向,以便检测Cloudflare拦截(302)
|
||||
}
|
||||
|
||||
if (agent) {
|
||||
axiosConfig.httpAgent = agent
|
||||
axiosConfig.httpsAgent = agent
|
||||
axiosConfig.proxy = false
|
||||
}
|
||||
|
||||
const response = await axios.post(authorizeUrl, payload, axiosConfig)
|
||||
|
||||
// 从响应中获取redirect_uri
|
||||
const redirectUri = response.data?.redirect_uri
|
||||
|
||||
if (!redirectUri) {
|
||||
throw new Error('授权响应中未找到redirect_uri')
|
||||
}
|
||||
|
||||
logger.debug('📎 Got redirect URI', { redirectUri: `${redirectUri.substring(0, 80)}...` })
|
||||
|
||||
// 解析redirect_uri获取authorization code
|
||||
const url = new URL(redirectUri)
|
||||
const authorizationCode = url.searchParams.get('code')
|
||||
const responseState = url.searchParams.get('state')
|
||||
|
||||
if (!authorizationCode) {
|
||||
throw new Error('redirect_uri中未找到授权码')
|
||||
}
|
||||
|
||||
// 构建完整的授权码(包含state,如果有的话)
|
||||
const fullCode = responseState ? `${authorizationCode}#${responseState}` : authorizationCode
|
||||
|
||||
logger.success('✅ Got authorization code via Cookie', {
|
||||
codeLength: authorizationCode.length,
|
||||
codePrefix: `${authorizationCode.substring(0, 10)}...`
|
||||
})
|
||||
|
||||
return {
|
||||
authorizationCode: fullCode,
|
||||
codeVerifier,
|
||||
state
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.response) {
|
||||
const { status } = error.response
|
||||
|
||||
if (status === 403 || status === 401) {
|
||||
throw new Error('Cookie授权失败:无效的sessionKey或已过期')
|
||||
}
|
||||
|
||||
if (status === 302) {
|
||||
throw new Error('请求被Cloudflare拦截,请稍后重试')
|
||||
}
|
||||
|
||||
const errorData = error.response.data
|
||||
let errorMessage = `HTTP ${status}`
|
||||
|
||||
if (errorData) {
|
||||
if (typeof errorData === 'string') {
|
||||
errorMessage += `: ${errorData}`
|
||||
} else if (errorData.error) {
|
||||
errorMessage += `: ${errorData.error}`
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`授权请求失败:${errorMessage}`)
|
||||
} else if (error.request) {
|
||||
throw new Error('授权请求失败:网络错误或超时')
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 完整的Cookie自动授权流程
|
||||
* @param {string} sessionKey - sessionKey值
|
||||
* @param {object|null} proxyConfig - 代理配置(可选)
|
||||
* @param {boolean} isSetupToken - 是否为Setup Token模式
|
||||
* @returns {Promise<{claudeAiOauth: object, organizationUuid: string, capabilities: string[]}>}
|
||||
*/
|
||||
async function oauthWithCookie(sessionKey, proxyConfig = null, isSetupToken = false) {
|
||||
logger.info('🍪 Starting Cookie-based OAuth flow', {
|
||||
isSetupToken,
|
||||
hasProxy: !!proxyConfig
|
||||
})
|
||||
|
||||
// 步骤1:获取组织信息
|
||||
logger.debug('Step 1/3: Fetching organization info...')
|
||||
const { organizationUuid, capabilities } = await getOrganizationInfo(sessionKey, proxyConfig)
|
||||
|
||||
// 步骤2:确定scope并获取授权code
|
||||
const scope = isSetupToken ? OAUTH_CONFIG.SCOPES_SETUP : 'user:profile user:inference'
|
||||
|
||||
logger.debug('Step 2/3: Getting authorization code...', { scope })
|
||||
const { authorizationCode, codeVerifier, state } = await authorizeWithCookie(
|
||||
sessionKey,
|
||||
organizationUuid,
|
||||
scope,
|
||||
proxyConfig
|
||||
)
|
||||
|
||||
// 步骤3:交换token
|
||||
logger.debug('Step 3/3: Exchanging token...')
|
||||
const tokenData = isSetupToken
|
||||
? await exchangeSetupTokenCode(authorizationCode, codeVerifier, state, proxyConfig)
|
||||
: await exchangeCodeForTokens(authorizationCode, codeVerifier, state, proxyConfig)
|
||||
|
||||
logger.success('✅ Cookie-based OAuth flow completed', {
|
||||
isSetupToken,
|
||||
organizationUuid,
|
||||
hasAccessToken: !!tokenData.accessToken,
|
||||
hasRefreshToken: !!tokenData.refreshToken
|
||||
})
|
||||
|
||||
return {
|
||||
claudeAiOauth: tokenData,
|
||||
organizationUuid,
|
||||
capabilities
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
OAUTH_CONFIG,
|
||||
COOKIE_OAUTH_CONFIG,
|
||||
generateOAuthParams,
|
||||
generateSetupTokenParams,
|
||||
exchangeCodeForTokens,
|
||||
@@ -584,5 +882,10 @@ module.exports = {
|
||||
generateCodeChallenge,
|
||||
generateAuthUrl,
|
||||
generateSetupTokenAuthUrl,
|
||||
createProxyAgent
|
||||
createProxyAgent,
|
||||
// Cookie自动授权相关方法
|
||||
buildCookieHeaders,
|
||||
getOrganizationInfo,
|
||||
authorizeWithCookie,
|
||||
oauthWithCookie
|
||||
}
|
||||
|
||||
242
src/utils/testPayloadHelper.js
Normal file
242
src/utils/testPayloadHelper.js
Normal file
@@ -0,0 +1,242 @@
|
||||
const crypto = require('crypto')
|
||||
|
||||
/**
|
||||
* 生成随机十六进制字符串
|
||||
* @param {number} bytes - 字节数
|
||||
* @returns {string} 十六进制字符串
|
||||
*/
|
||||
function randomHex(bytes = 32) {
|
||||
return crypto.randomBytes(bytes).toString('hex')
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 Claude Code 风格的会话字符串
|
||||
* @returns {string} 会话字符串,格式: user_{64位hex}_account__session_{uuid}
|
||||
*/
|
||||
function generateSessionString() {
|
||||
const hex64 = randomHex(32) // 32 bytes => 64 hex characters
|
||||
const uuid = crypto.randomUUID()
|
||||
return `user_${hex64}_account__session_${uuid}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 Claude 测试请求体
|
||||
* @param {string} model - 模型名称
|
||||
* @param {object} options - 可选配置
|
||||
* @param {boolean} options.stream - 是否流式(默认false)
|
||||
* @returns {object} 测试请求体
|
||||
*/
|
||||
function createClaudeTestPayload(model = 'claude-sonnet-4-5-20250929', options = {}) {
|
||||
const payload = {
|
||||
model,
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'hi',
|
||||
cache_control: {
|
||||
type: 'ephemeral'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
system: [
|
||||
{
|
||||
type: 'text',
|
||||
text: "You are Claude Code, Anthropic's official CLI for Claude.",
|
||||
cache_control: {
|
||||
type: 'ephemeral'
|
||||
}
|
||||
}
|
||||
],
|
||||
metadata: {
|
||||
user_id: generateSessionString()
|
||||
},
|
||||
max_tokens: 21333,
|
||||
temperature: 1
|
||||
}
|
||||
|
||||
if (options.stream) {
|
||||
payload.stream = true
|
||||
}
|
||||
|
||||
return payload
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送流式测试请求并处理SSE响应
|
||||
* @param {object} options - 配置选项
|
||||
* @param {string} options.apiUrl - API URL
|
||||
* @param {string} options.authorization - Authorization header值
|
||||
* @param {object} options.responseStream - Express响应流
|
||||
* @param {object} [options.payload] - 请求体(默认使用createClaudeTestPayload)
|
||||
* @param {object} [options.proxyAgent] - 代理agent
|
||||
* @param {number} [options.timeout] - 超时时间(默认30000)
|
||||
* @param {object} [options.extraHeaders] - 额外的请求头
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function sendStreamTestRequest(options) {
|
||||
const axios = require('axios')
|
||||
const logger = require('./logger')
|
||||
|
||||
const {
|
||||
apiUrl,
|
||||
authorization,
|
||||
responseStream,
|
||||
payload = createClaudeTestPayload('claude-sonnet-4-5-20250929', { stream: true }),
|
||||
proxyAgent = null,
|
||||
timeout = 30000,
|
||||
extraHeaders = {}
|
||||
} = options
|
||||
|
||||
const sendSSE = (type, data = {}) => {
|
||||
if (!responseStream.destroyed && !responseStream.writableEnded) {
|
||||
try {
|
||||
responseStream.write(`data: ${JSON.stringify({ type, ...data })}\n\n`)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const endTest = (success, error = null) => {
|
||||
if (!responseStream.destroyed && !responseStream.writableEnded) {
|
||||
try {
|
||||
responseStream.write(
|
||||
`data: ${JSON.stringify({ type: 'test_complete', success, error: error || undefined })}\n\n`
|
||||
)
|
||||
responseStream.end()
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 设置响应头
|
||||
if (!responseStream.headersSent) {
|
||||
responseStream.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
Connection: 'keep-alive',
|
||||
'X-Accel-Buffering': 'no'
|
||||
})
|
||||
}
|
||||
|
||||
sendSSE('test_start', { message: 'Test started' })
|
||||
|
||||
const requestConfig = {
|
||||
method: 'POST',
|
||||
url: apiUrl,
|
||||
data: payload,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'anthropic-version': '2023-06-01',
|
||||
'User-Agent': 'claude-cli/2.0.52 (external, cli)',
|
||||
authorization,
|
||||
...extraHeaders
|
||||
},
|
||||
timeout,
|
||||
responseType: 'stream',
|
||||
validateStatus: () => true
|
||||
}
|
||||
|
||||
if (proxyAgent) {
|
||||
requestConfig.httpAgent = proxyAgent
|
||||
requestConfig.httpsAgent = proxyAgent
|
||||
requestConfig.proxy = false
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await axios(requestConfig)
|
||||
logger.debug(`🌊 Test response status: ${response.status}`)
|
||||
|
||||
// 处理非200响应
|
||||
if (response.status !== 200) {
|
||||
return new Promise((resolve) => {
|
||||
const chunks = []
|
||||
response.data.on('data', (chunk) => chunks.push(chunk))
|
||||
response.data.on('end', () => {
|
||||
const errorData = Buffer.concat(chunks).toString()
|
||||
let errorMsg = `API Error: ${response.status}`
|
||||
try {
|
||||
const json = JSON.parse(errorData)
|
||||
errorMsg = json.message || json.error?.message || json.error || errorMsg
|
||||
} catch {
|
||||
if (errorData.length < 200) {
|
||||
errorMsg = errorData || errorMsg
|
||||
}
|
||||
}
|
||||
endTest(false, errorMsg)
|
||||
resolve()
|
||||
})
|
||||
response.data.on('error', (err) => {
|
||||
endTest(false, err.message)
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// 处理成功的流式响应
|
||||
return new Promise((resolve) => {
|
||||
let buffer = ''
|
||||
|
||||
response.data.on('data', (chunk) => {
|
||||
buffer += chunk.toString()
|
||||
const lines = buffer.split('\n')
|
||||
buffer = lines.pop() || ''
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith('data:')) {
|
||||
continue
|
||||
}
|
||||
const jsonStr = line.substring(5).trim()
|
||||
if (!jsonStr || jsonStr === '[DONE]') {
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
const data = JSON.parse(jsonStr)
|
||||
|
||||
if (data.type === 'content_block_delta' && data.delta?.text) {
|
||||
sendSSE('content', { text: data.delta.text })
|
||||
}
|
||||
if (data.type === 'message_stop') {
|
||||
sendSSE('message_stop')
|
||||
}
|
||||
if (data.type === 'error' || data.error) {
|
||||
const errMsg = data.error?.message || data.message || data.error || 'Unknown error'
|
||||
sendSSE('error', { error: errMsg })
|
||||
}
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
response.data.on('end', () => {
|
||||
if (!responseStream.destroyed && !responseStream.writableEnded) {
|
||||
endTest(true)
|
||||
}
|
||||
resolve()
|
||||
})
|
||||
|
||||
response.data.on('error', (err) => {
|
||||
endTest(false, err.message)
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('❌ Stream test request failed:', error.message)
|
||||
endTest(false, error.message)
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
randomHex,
|
||||
generateSessionString,
|
||||
createClaudeTestPayload,
|
||||
sendStreamTestRequest
|
||||
}
|
||||
81
src/utils/unstableUpstreamHelper.js
Normal file
81
src/utils/unstableUpstreamHelper.js
Normal file
@@ -0,0 +1,81 @@
|
||||
const logger = require('./logger')
|
||||
|
||||
function parseList(envValue) {
|
||||
if (!envValue) {
|
||||
return []
|
||||
}
|
||||
return envValue
|
||||
.split(',')
|
||||
.map((s) => s.trim().toLowerCase())
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
const unstableTypes = new Set(parseList(process.env.UNSTABLE_ERROR_TYPES))
|
||||
const unstableKeywords = parseList(process.env.UNSTABLE_ERROR_KEYWORDS)
|
||||
const unstableStatusCodes = new Set([408, 499, 502, 503, 504, 522])
|
||||
|
||||
function normalizeErrorPayload(payload) {
|
||||
if (!payload) {
|
||||
return {}
|
||||
}
|
||||
|
||||
if (typeof payload === 'string') {
|
||||
try {
|
||||
return normalizeErrorPayload(JSON.parse(payload))
|
||||
} catch (e) {
|
||||
return { message: payload }
|
||||
}
|
||||
}
|
||||
|
||||
if (payload.error && typeof payload.error === 'object') {
|
||||
return {
|
||||
type: payload.error.type || payload.error.error || payload.error.code,
|
||||
code: payload.error.code || payload.error.error || payload.error.type,
|
||||
message: payload.error.message || payload.error.msg || payload.message || payload.error.error
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
type: payload.type || payload.code,
|
||||
code: payload.code || payload.type,
|
||||
message: payload.message || ''
|
||||
}
|
||||
}
|
||||
|
||||
function isUnstableUpstreamError(statusCode, payload) {
|
||||
const normalizedStatus = Number(statusCode)
|
||||
if (Number.isFinite(normalizedStatus) && normalizedStatus >= 500) {
|
||||
return true
|
||||
}
|
||||
if (Number.isFinite(normalizedStatus) && unstableStatusCodes.has(normalizedStatus)) {
|
||||
return true
|
||||
}
|
||||
|
||||
const { type, code, message } = normalizeErrorPayload(payload)
|
||||
const lowerType = (type || '').toString().toLowerCase()
|
||||
const lowerCode = (code || '').toString().toLowerCase()
|
||||
const lowerMessage = (message || '').toString().toLowerCase()
|
||||
|
||||
if (lowerType === 'server_error' || lowerCode === 'server_error') {
|
||||
return true
|
||||
}
|
||||
if (unstableTypes.has(lowerType) || unstableTypes.has(lowerCode)) {
|
||||
return true
|
||||
}
|
||||
if (unstableKeywords.length > 0) {
|
||||
return unstableKeywords.some((kw) => lowerMessage.includes(kw))
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
function logUnstable(accountLabel, statusCode) {
|
||||
logger.warn(
|
||||
`Detected unstable upstream error (${statusCode}) for account ${accountLabel}, marking temporarily unavailable`
|
||||
)
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
isUnstableUpstreamError,
|
||||
logUnstable
|
||||
}
|
||||
17
web/admin-spa/package-lock.json
generated
17
web/admin-spa/package-lock.json
generated
@@ -1157,6 +1157,7 @@
|
||||
"resolved": "https://registry.npmmirror.com/@types/lodash-es/-/lodash-es-4.17.12.tgz",
|
||||
"integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/lodash": "*"
|
||||
}
|
||||
@@ -1351,6 +1352,7 @@
|
||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -1587,6 +1589,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"caniuse-lite": "^1.0.30001726",
|
||||
"electron-to-chromium": "^1.5.173",
|
||||
@@ -3060,13 +3063,15 @@
|
||||
"version": "4.17.21",
|
||||
"resolved": "https://registry.npmmirror.com/lodash/-/lodash-4.17.21.tgz",
|
||||
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/lodash-es": {
|
||||
"version": "4.17.21",
|
||||
"resolved": "https://registry.npmmirror.com/lodash-es/-/lodash-es-4.17.21.tgz",
|
||||
"integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/lodash-unified": {
|
||||
"version": "1.0.3",
|
||||
@@ -3618,6 +3623,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.11",
|
||||
"picocolors": "^1.1.1",
|
||||
@@ -3764,6 +3770,7 @@
|
||||
"integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"prettier": "bin/prettier.cjs"
|
||||
},
|
||||
@@ -3789,7 +3796,7 @@
|
||||
},
|
||||
"node_modules/prettier-plugin-tailwindcss": {
|
||||
"version": "0.6.14",
|
||||
"resolved": "https://registry.npmmirror.com/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.6.14.tgz",
|
||||
"resolved": "https://registry.npmjs.org/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.6.14.tgz",
|
||||
"integrity": "sha512-pi2e/+ZygeIqntN+vC573BcW5Cve8zUB0SSAGxqpB4f96boZF4M3phPVoOFCeypwkpRYdi7+jQ5YJJUwrkGUAg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
@@ -4028,6 +4035,7 @@
|
||||
"integrity": "sha512-33xGNBsDJAkzt0PvninskHlWnTIPgDtTwhg0U38CUoNP/7H6wI2Cz6dUeoNPbjdTdsYTGuiFFASuUOWovH0SyQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/estree": "1.0.8"
|
||||
},
|
||||
@@ -4525,6 +4533,7 @@
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -4915,6 +4924,7 @@
|
||||
"integrity": "sha512-qO3aKv3HoQC8QKiNSTuUM1l9o/XX3+c+VTgLHbJWHZGeTPVAg2XwazI9UWzoxjIJCGCV2zU60uqMzjeLZuULqA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.21.3",
|
||||
"postcss": "^8.4.43",
|
||||
@@ -5115,6 +5125,7 @@
|
||||
"resolved": "https://registry.npmmirror.com/vue/-/vue-3.5.18.tgz",
|
||||
"integrity": "sha512-7W4Y4ZbMiQ3SEo+m9lnoNpV9xG7QVMLa+/0RFwwiAVkeYoyGXqWE85jabU4pllJNUzqfLShJ5YLptewhCWUgNA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@vue/compiler-dom": "3.5.18",
|
||||
"@vue/compiler-sfc": "3.5.18",
|
||||
|
||||
@@ -1451,6 +1451,26 @@
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 上游错误处理 -->
|
||||
<div v-if="form.platform === 'claude-console'">
|
||||
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||
>上游错误处理</label
|
||||
>
|
||||
<label class="inline-flex cursor-pointer items-center">
|
||||
<input
|
||||
v-model="form.disableAutoProtection"
|
||||
class="mr-2 rounded border-gray-300 text-blue-600 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:border-gray-600 dark:bg-gray-700"
|
||||
type="checkbox"
|
||||
/>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">
|
||||
上游错误不自动暂停调度
|
||||
</span>
|
||||
</label>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
勾选后遇到 401/400/429/529 等上游错误仅记录日志并透传,不自动禁用或限流
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- OpenAI-Responses 特定字段 -->
|
||||
@@ -2029,6 +2049,7 @@
|
||||
<!-- 步骤2: OAuth授权 -->
|
||||
<OAuthFlow
|
||||
v-if="oauthStep === 2 && form.addType === 'oauth'"
|
||||
ref="oauthFlowRef"
|
||||
:platform="form.platform"
|
||||
:proxy="form.proxy"
|
||||
@back="oauthStep = 1"
|
||||
@@ -2052,11 +2073,45 @@
|
||||
<h4 class="mb-3 font-semibold text-blue-900 dark:text-blue-200">
|
||||
Claude Setup Token 授权
|
||||
</h4>
|
||||
<p class="mb-4 text-sm text-blue-800 dark:text-blue-300">
|
||||
请按照以下步骤通过 Setup Token 完成 Claude 账户的授权:
|
||||
</p>
|
||||
|
||||
<div class="space-y-4">
|
||||
<!-- 授权方式选择 -->
|
||||
<div class="mb-4">
|
||||
<p class="mb-3 text-sm font-medium text-blue-800 dark:text-blue-300">
|
||||
选择授权方式:
|
||||
</p>
|
||||
<div class="flex flex-wrap gap-4">
|
||||
<label class="flex cursor-pointer items-center">
|
||||
<input
|
||||
v-model="authMethod"
|
||||
class="mr-2 text-blue-600 focus:ring-blue-500"
|
||||
type="radio"
|
||||
value="manual"
|
||||
@change="onAuthMethodChange"
|
||||
/>
|
||||
<span class="text-sm text-blue-800 dark:text-blue-300">
|
||||
<i class="fas fa-link mr-1" />手动授权
|
||||
</span>
|
||||
</label>
|
||||
<label class="flex cursor-pointer items-center">
|
||||
<input
|
||||
v-model="authMethod"
|
||||
class="mr-2 text-blue-600 focus:ring-blue-500"
|
||||
type="radio"
|
||||
value="cookie"
|
||||
@change="onAuthMethodChange"
|
||||
/>
|
||||
<span class="text-sm text-blue-800 dark:text-blue-300">
|
||||
<i class="fas fa-cookie mr-1" />Cookie 自动授权
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 手动授权流程 -->
|
||||
<div v-if="authMethod === 'manual'" class="space-y-4">
|
||||
<p class="mb-4 text-sm text-blue-800 dark:text-blue-300">
|
||||
请按照以下步骤通过 Setup Token 完成 Claude 账户的授权:
|
||||
</p>
|
||||
<!-- 步骤1: 生成授权链接 -->
|
||||
<div
|
||||
class="rounded-lg border border-blue-300 bg-white/80 p-4 dark:border-blue-600 dark:bg-gray-800/80"
|
||||
@@ -2182,6 +2237,113 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cookie自动授权流程 -->
|
||||
<div v-if="authMethod === 'cookie'" class="space-y-4">
|
||||
<p class="mb-4 text-sm text-blue-800 dark:text-blue-300">
|
||||
使用 sessionKey 自动完成授权,无需手动打开链接。
|
||||
</p>
|
||||
|
||||
<div
|
||||
class="rounded-lg border border-blue-300 bg-white/80 p-4 dark:border-blue-600 dark:bg-gray-800/80"
|
||||
>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label
|
||||
class="mb-2 flex items-center gap-2 text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
<i class="fas fa-cookie text-blue-500" />sessionKey
|
||||
<span
|
||||
v-if="parsedSessionKeyCount > 1"
|
||||
class="rounded-full bg-blue-500 px-2 py-0.5 text-xs text-white"
|
||||
>
|
||||
{{ parsedSessionKeyCount }} 个
|
||||
</span>
|
||||
</label>
|
||||
<textarea
|
||||
v-model="sessionKey"
|
||||
class="form-input w-full resize-y border-gray-300 font-mono text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
|
||||
:class="{ 'border-red-500': cookieAuthError }"
|
||||
placeholder="每行一个 sessionKey,例如: sk-ant-sid01-xxxxx... sk-ant-sid01-yyyyy..."
|
||||
rows="3"
|
||||
/>
|
||||
<p
|
||||
v-if="parsedSessionKeyCount > 1"
|
||||
class="mt-1 text-xs text-blue-600 dark:text-blue-400"
|
||||
>
|
||||
<i class="fas fa-info-circle mr-1" />
|
||||
将批量创建 {{ parsedSessionKeyCount }} 个账户
|
||||
</p>
|
||||
<p v-if="cookieAuthError" class="mt-1 text-xs text-red-500">
|
||||
{{ cookieAuthError }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 帮助说明 -->
|
||||
<div>
|
||||
<button
|
||||
class="flex items-center text-xs text-blue-600 hover:text-blue-700"
|
||||
type="button"
|
||||
@click="showSessionKeyHelp = !showSessionKeyHelp"
|
||||
>
|
||||
<i
|
||||
:class="
|
||||
showSessionKeyHelp
|
||||
? 'fas fa-chevron-down mr-1'
|
||||
: 'fas fa-chevron-right mr-1'
|
||||
"
|
||||
/>
|
||||
如何获取 sessionKey?
|
||||
</button>
|
||||
<div
|
||||
v-if="showSessionKeyHelp"
|
||||
class="mt-3 rounded border border-gray-200 bg-gray-50 p-3 dark:border-gray-600 dark:bg-gray-700"
|
||||
>
|
||||
<ol class="space-y-2 text-xs text-gray-600 dark:text-gray-300">
|
||||
<li>1. 在浏览器中登录 <strong>claude.ai</strong></li>
|
||||
<li>2. 按 <strong>F12</strong> 打开开发者工具</li>
|
||||
<li>3. 切换到 <strong>"Application"</strong> (应用) 标签页</li>
|
||||
<li>
|
||||
4. 在左侧选择 <strong>"Cookies"</strong> →
|
||||
<strong>"https://claude.ai"</strong>
|
||||
</li>
|
||||
<li>5. 找到键为 <strong>"sessionKey"</strong> 的那一行</li>
|
||||
<li>6. 复制其 <strong>"Value"</strong> (值) 列的内容</li>
|
||||
</ol>
|
||||
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
<i class="fas fa-info-circle mr-1" />
|
||||
sessionKey 通常以 "sk-ant-" 开头
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 开始授权按钮 -->
|
||||
<button
|
||||
class="btn btn-primary w-full px-4 py-3"
|
||||
:disabled="cookieAuthLoading || !sessionKey.trim()"
|
||||
type="button"
|
||||
@click="handleCookieAuth"
|
||||
>
|
||||
<div v-if="cookieAuthLoading" class="loading-spinner mr-2" />
|
||||
<i v-else class="fas fa-magic mr-2" />
|
||||
<template v-if="cookieAuthLoading && batchProgress.total > 1">
|
||||
正在授权 {{ batchProgress.current }}/{{ batchProgress.total }}...
|
||||
</template>
|
||||
<template v-else-if="cookieAuthLoading"> 授权中... </template>
|
||||
<template v-else> 开始自动授权 </template>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="rounded border border-yellow-300 bg-yellow-50 p-3 dark:border-yellow-700 dark:bg-yellow-900/30"
|
||||
>
|
||||
<p class="text-xs text-yellow-800 dark:text-yellow-300">
|
||||
<i class="fas fa-exclamation-triangle mr-1" />
|
||||
<strong>提示:</strong>如果您设置了代理,Cookie授权也会使用相同的代理配置。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2196,6 +2358,7 @@
|
||||
上一步
|
||||
</button>
|
||||
<button
|
||||
v-if="authMethod === 'manual'"
|
||||
class="btn btn-primary flex-1 px-6 py-3 font-semibold"
|
||||
:disabled="!canExchangeSetupToken || setupTokenExchanging"
|
||||
type="button"
|
||||
@@ -2927,6 +3090,26 @@
|
||||
<p class="mt-1 text-xs text-gray-500">账号被限流后暂停调度的时间(分钟)</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 上游错误处理(编辑模式)-->
|
||||
<div v-if="form.platform === 'claude-console'">
|
||||
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300">
|
||||
上游错误处理
|
||||
</label>
|
||||
<label class="inline-flex cursor-pointer items-center">
|
||||
<input
|
||||
v-model="form.disableAutoProtection"
|
||||
class="mr-2 rounded border-gray-300 text-blue-600 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:border-gray-600 dark:bg-gray-700"
|
||||
type="checkbox"
|
||||
/>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">
|
||||
上游错误不自动暂停调度
|
||||
</span>
|
||||
</label>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
勾选后遇到 401/400/429/529 等上游错误仅记录日志并透传,不自动禁用或限流
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- OpenAI-Responses 特定字段(编辑模式)-->
|
||||
@@ -3538,6 +3721,9 @@ const { showConfirmModal, confirmOptions, showConfirm, handleConfirm, handleCanc
|
||||
const isEdit = computed(() => !!props.account)
|
||||
const show = ref(true)
|
||||
|
||||
// OAuthFlow 组件引用
|
||||
const oauthFlowRef = ref(null)
|
||||
|
||||
// OAuth步骤
|
||||
const oauthStep = ref(1)
|
||||
const loading = ref(false)
|
||||
@@ -3551,6 +3737,22 @@ const setupTokenAuthCode = ref('')
|
||||
const setupTokenCopied = ref(false)
|
||||
const setupTokenSessionId = ref('')
|
||||
|
||||
// Cookie自动授权相关状态
|
||||
const authMethod = ref('manual') // 'manual' | 'cookie'
|
||||
const sessionKey = ref('')
|
||||
const cookieAuthLoading = ref(false)
|
||||
const cookieAuthError = ref('')
|
||||
const showSessionKeyHelp = ref(false)
|
||||
const batchProgress = ref({ current: 0, total: 0 }) // 批量进度
|
||||
|
||||
// 解析后的 sessionKey 数量
|
||||
const parsedSessionKeyCount = computed(() => {
|
||||
return sessionKey.value
|
||||
.split('\n')
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s.length > 0).length
|
||||
})
|
||||
|
||||
// Claude Code 统一 User-Agent 信息
|
||||
const unifiedUserAgent = ref('')
|
||||
const clearingCache = ref(false)
|
||||
@@ -3750,6 +3952,7 @@ const form = ref({
|
||||
})(),
|
||||
userAgent: props.account?.userAgent || '',
|
||||
enableRateLimit: props.account ? props.account.rateLimitDuration > 0 : true,
|
||||
disableAutoProtection: props.account?.disableAutoProtection === true,
|
||||
// 额度管理字段
|
||||
dailyQuota: props.account?.dailyQuota || 0,
|
||||
dailyUsage: props.account?.dailyUsage || 0,
|
||||
@@ -4193,10 +4396,198 @@ const exchangeSetupTokenCode = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
// 处理OAuth成功
|
||||
const handleOAuthSuccess = async (tokenInfo) => {
|
||||
// =============================================================================
|
||||
// Cookie自动授权相关方法
|
||||
// =============================================================================
|
||||
|
||||
// Cookie自动授权(支持批量)
|
||||
const handleCookieAuth = async () => {
|
||||
// 解析多行输入
|
||||
const sessionKeys = sessionKey.value
|
||||
.split('\n')
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s.length > 0)
|
||||
|
||||
if (sessionKeys.length === 0) {
|
||||
cookieAuthError.value = '请输入至少一个 sessionKey'
|
||||
return
|
||||
}
|
||||
|
||||
cookieAuthLoading.value = true
|
||||
cookieAuthError.value = ''
|
||||
batchProgress.value = { current: 0, total: sessionKeys.length }
|
||||
|
||||
const isSetupToken = form.value.addType === 'setup-token'
|
||||
const proxyPayload = buildProxyPayload(form.value.proxy)
|
||||
|
||||
const results = []
|
||||
const errors = []
|
||||
|
||||
for (let i = 0; i < sessionKeys.length; i++) {
|
||||
batchProgress.value.current = i + 1
|
||||
try {
|
||||
const payload = {
|
||||
sessionKey: sessionKeys[i],
|
||||
...(proxyPayload && { proxy: proxyPayload })
|
||||
}
|
||||
|
||||
let result
|
||||
if (isSetupToken) {
|
||||
result = await accountsStore.oauthSetupTokenWithCookie(payload)
|
||||
} else {
|
||||
result = await accountsStore.oauthWithCookie(payload)
|
||||
}
|
||||
results.push(result)
|
||||
} catch (error) {
|
||||
errors.push({
|
||||
index: i + 1,
|
||||
key: sessionKeys[i].substring(0, 20) + '...',
|
||||
error: error.message
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
batchProgress.value = { current: 0, total: 0 }
|
||||
|
||||
if (results.length > 0) {
|
||||
try {
|
||||
// 成功后处理OAuth数据(传递数组)
|
||||
// cookieAuthLoading 保持 true,直到账号创建完成
|
||||
await handleOAuthSuccess(results)
|
||||
} finally {
|
||||
cookieAuthLoading.value = false
|
||||
}
|
||||
} else {
|
||||
cookieAuthLoading.value = false
|
||||
}
|
||||
|
||||
if (errors.length > 0 && results.length === 0) {
|
||||
cookieAuthError.value = '全部授权失败,请检查 sessionKey 是否有效'
|
||||
} else if (errors.length > 0) {
|
||||
cookieAuthError.value = `${errors.length} 个授权失败`
|
||||
}
|
||||
}
|
||||
|
||||
// 重置Cookie授权状态
|
||||
const resetCookieAuth = () => {
|
||||
sessionKey.value = ''
|
||||
cookieAuthError.value = ''
|
||||
showSessionKeyHelp.value = false
|
||||
batchProgress.value = { current: 0, total: 0 }
|
||||
}
|
||||
|
||||
// 切换授权方式时重置状态
|
||||
const onAuthMethodChange = () => {
|
||||
// 切换到手动模式时清除Cookie相关状态
|
||||
if (authMethod.value === 'manual') {
|
||||
resetCookieAuth()
|
||||
} else {
|
||||
// 切换到Cookie模式时清除手动授权状态
|
||||
setupTokenAuthUrl.value = ''
|
||||
setupTokenAuthCode.value = ''
|
||||
setupTokenSessionId.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
// 构建 Claude 账户数据(辅助函数)
|
||||
const buildClaudeAccountData = (tokenInfo, accountName, clientId) => {
|
||||
const proxyPayload = buildProxyPayload(form.value.proxy)
|
||||
const claudeOauthPayload = tokenInfo.claudeAiOauth || tokenInfo
|
||||
|
||||
const data = {
|
||||
name: accountName,
|
||||
description: form.value.description,
|
||||
accountType: form.value.accountType,
|
||||
groupId: form.value.accountType === 'group' ? form.value.groupId : undefined,
|
||||
groupIds: form.value.accountType === 'group' ? form.value.groupIds : undefined,
|
||||
expiresAt: form.value.expiresAt || undefined,
|
||||
proxy: proxyPayload,
|
||||
claudeAiOauth: claudeOauthPayload,
|
||||
priority: form.value.priority || 50,
|
||||
autoStopOnWarning: form.value.autoStopOnWarning || false,
|
||||
useUnifiedUserAgent: form.value.useUnifiedUserAgent || false,
|
||||
useUnifiedClientId: form.value.useUnifiedClientId || false,
|
||||
unifiedClientId: clientId,
|
||||
subscriptionInfo: {
|
||||
accountType: form.value.subscriptionType || 'claude_max',
|
||||
hasClaudeMax: form.value.subscriptionType === 'claude_max',
|
||||
hasClaudePro: form.value.subscriptionType === 'claude_pro',
|
||||
manuallySet: true
|
||||
}
|
||||
}
|
||||
|
||||
// 处理 extInfo
|
||||
if (claudeOauthPayload) {
|
||||
const extInfoPayload = {}
|
||||
const extSource = claudeOauthPayload.extInfo
|
||||
if (extSource?.org_uuid) extInfoPayload.org_uuid = extSource.org_uuid
|
||||
if (extSource?.account_uuid) extInfoPayload.account_uuid = extSource.account_uuid
|
||||
|
||||
if (!extSource) {
|
||||
if (claudeOauthPayload.organization?.uuid) {
|
||||
extInfoPayload.org_uuid = claudeOauthPayload.organization.uuid
|
||||
}
|
||||
if (claudeOauthPayload.account?.uuid) {
|
||||
extInfoPayload.account_uuid = claudeOauthPayload.account.uuid
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(extInfoPayload).length > 0) {
|
||||
data.extInfo = extInfoPayload
|
||||
}
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
// 处理OAuth成功(支持批量)
|
||||
const handleOAuthSuccess = async (tokenInfoOrList) => {
|
||||
loading.value = true
|
||||
try {
|
||||
const currentPlatform = form.value.platform
|
||||
|
||||
// Claude 平台支持批量创建
|
||||
if (currentPlatform === 'claude' && Array.isArray(tokenInfoOrList)) {
|
||||
const tokenInfoList = tokenInfoOrList
|
||||
const isBatch = tokenInfoList.length > 1
|
||||
const baseName = form.value.name
|
||||
|
||||
const results = []
|
||||
const errors = []
|
||||
|
||||
for (let i = 0; i < tokenInfoList.length; i++) {
|
||||
const tokenInfo = tokenInfoList[i]
|
||||
// 批量时自动命名
|
||||
const accountName = isBatch ? `${baseName}_${i + 1}` : baseName
|
||||
// 如果启用统一客户端标识,为每个账户生成独立 ID
|
||||
const clientId = form.value.useUnifiedClientId ? generateClientId() : ''
|
||||
const data = buildClaudeAccountData(tokenInfo, accountName, clientId)
|
||||
|
||||
try {
|
||||
const result = await accountsStore.createClaudeAccount(data)
|
||||
results.push(result)
|
||||
} catch (error) {
|
||||
errors.push({ name: accountName, error: error.message })
|
||||
}
|
||||
}
|
||||
|
||||
// 处理结果
|
||||
if (results.length > 0) {
|
||||
const msg = isBatch
|
||||
? `成功创建 ${results.length}/${tokenInfoList.length} 个账户`
|
||||
: '账户创建成功'
|
||||
showToast(msg, 'success')
|
||||
emit('success', results[0]) // 兼容单个创建的返回
|
||||
}
|
||||
if (errors.length > 0) {
|
||||
showToast(`${errors.length} 个账户创建失败`, 'error')
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 单个 tokenInfo 或其他平台的处理(保持原有逻辑)
|
||||
const tokenInfo = Array.isArray(tokenInfoOrList) ? tokenInfoOrList[0] : tokenInfoOrList
|
||||
|
||||
// OAuth模式也需要确保生成客户端ID
|
||||
if (
|
||||
form.value.platform === 'claude' &&
|
||||
@@ -4218,8 +4609,6 @@ const handleOAuthSuccess = async (tokenInfo) => {
|
||||
proxy: proxyPayload
|
||||
}
|
||||
|
||||
const currentPlatform = form.value.platform
|
||||
|
||||
if (currentPlatform === 'claude') {
|
||||
// Claude使用claudeAiOauth字段
|
||||
const claudeOauthPayload = tokenInfo.claudeAiOauth || tokenInfo
|
||||
@@ -4380,6 +4769,8 @@ const handleOAuthSuccess = async (tokenInfo) => {
|
||||
// 错误已通过 toast 显示给用户
|
||||
} finally {
|
||||
loading.value = false
|
||||
// 重置 OAuthFlow 组件的加载状态(如果是通过 OAuth 模式调用)
|
||||
oauthFlowRef.value?.resetCookieAuth()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4665,6 +5056,10 @@ const createAccount = async () => {
|
||||
data.userAgent = form.value.userAgent || null
|
||||
// 如果不启用限流,传递 0 表示不限流
|
||||
data.rateLimitDuration = form.value.enableRateLimit ? form.value.rateLimitDuration || 60 : 0
|
||||
// 上游错误处理(仅 Claude Console)
|
||||
if (form.value.platform === 'claude-console') {
|
||||
data.disableAutoProtection = !!form.value.disableAutoProtection
|
||||
}
|
||||
// 额度管理字段
|
||||
data.dailyQuota = form.value.dailyQuota || 0
|
||||
data.quotaResetTime = form.value.quotaResetTime || '00:00'
|
||||
@@ -4993,6 +5388,8 @@ const updateAccount = async () => {
|
||||
data.userAgent = form.value.userAgent || null
|
||||
// 如果不启用限流,传递 0 表示不限流
|
||||
data.rateLimitDuration = form.value.enableRateLimit ? form.value.rateLimitDuration || 60 : 0
|
||||
// 上游错误处理
|
||||
data.disableAutoProtection = !!form.value.disableAutoProtection
|
||||
// 额度管理字段
|
||||
data.dailyQuota = form.value.dailyQuota || 0
|
||||
data.quotaResetTime = form.value.quotaResetTime || '00:00'
|
||||
@@ -5614,7 +6011,9 @@ watch(
|
||||
dailyUsage: newAccount.dailyUsage || 0,
|
||||
quotaResetTime: newAccount.quotaResetTime || '00:00',
|
||||
// 并发控制字段
|
||||
maxConcurrentTasks: newAccount.maxConcurrentTasks || 0
|
||||
maxConcurrentTasks: newAccount.maxConcurrentTasks || 0,
|
||||
// 上游错误处理
|
||||
disableAutoProtection: newAccount.disableAutoProtection === true
|
||||
}
|
||||
|
||||
// 如果是Claude Console账户,加载实时使用情况
|
||||
|
||||
@@ -44,12 +44,20 @@
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
class="flex h-10 w-10 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"
|
||||
@click="handleClose"
|
||||
>
|
||||
<i class="fas fa-times" />
|
||||
</button>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
class="flex items-center gap-2 rounded-full bg-purple-100 px-3 py-2 text-xs font-semibold text-purple-700 transition hover:bg-purple-200 dark:bg-purple-500/10 dark:text-purple-200 dark:hover:bg-purple-500/20"
|
||||
@click="goTimeline"
|
||||
>
|
||||
<i class="fas fa-clock" /> 请求时间线
|
||||
</button>
|
||||
<button
|
||||
class="flex h-10 w-10 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"
|
||||
@click="handleClose"
|
||||
>
|
||||
<i class="fas fa-times" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 内容区域 -->
|
||||
@@ -325,6 +333,7 @@
|
||||
|
||||
<script setup>
|
||||
import { computed, nextTick, onUnmounted, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import Chart from 'chart.js/auto'
|
||||
import { useThemeStore } from '@/stores/theme'
|
||||
@@ -343,6 +352,7 @@ const emit = defineEmits(['close'])
|
||||
|
||||
const themeStore = useThemeStore()
|
||||
const { isDarkMode } = storeToRefs(themeStore)
|
||||
const router = useRouter()
|
||||
|
||||
const chartCanvas = ref(null)
|
||||
let chartInstance = null
|
||||
@@ -579,6 +589,14 @@ const handleClose = () => {
|
||||
emit('close')
|
||||
}
|
||||
|
||||
const goTimeline = () => {
|
||||
if (!props.account?.id) return
|
||||
router.push({
|
||||
path: `/accounts/${props.account.id}/usage-records`,
|
||||
query: { platform: props.account.platform || props.account.accountType }
|
||||
})
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.show,
|
||||
(visible) => {
|
||||
|
||||
@@ -13,128 +13,269 @@
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h4 class="mb-3 font-semibold text-blue-900 dark:text-blue-200">Claude 账户授权</h4>
|
||||
<p class="mb-4 text-sm text-blue-800 dark:text-blue-300">
|
||||
请按照以下步骤完成 Claude 账户的授权:
|
||||
</p>
|
||||
|
||||
<div class="space-y-4">
|
||||
<!-- 步骤1: 生成授权链接 -->
|
||||
<!-- 授权方式选择 -->
|
||||
<div class="mb-4">
|
||||
<label class="mb-2 block text-sm font-medium text-blue-800 dark:text-blue-300">
|
||||
选择授权方式
|
||||
</label>
|
||||
<div class="flex gap-4">
|
||||
<label class="flex cursor-pointer items-center gap-2">
|
||||
<input
|
||||
v-model="authMethod"
|
||||
class="text-blue-600 focus:ring-blue-500"
|
||||
name="claude-auth-method"
|
||||
type="radio"
|
||||
value="manual"
|
||||
@change="onAuthMethodChange"
|
||||
/>
|
||||
<span class="text-sm text-blue-900 dark:text-blue-200">手动授权</span>
|
||||
</label>
|
||||
<label class="flex cursor-pointer items-center gap-2">
|
||||
<input
|
||||
v-model="authMethod"
|
||||
class="text-blue-600 focus:ring-blue-500"
|
||||
name="claude-auth-method"
|
||||
type="radio"
|
||||
value="cookie"
|
||||
@change="onAuthMethodChange"
|
||||
/>
|
||||
<span class="text-sm text-blue-900 dark:text-blue-200">Cookie自动授权</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cookie自动授权表单 -->
|
||||
<div v-if="authMethod === 'cookie'" class="space-y-4">
|
||||
<div
|
||||
class="rounded-lg border border-blue-300 bg-white/80 p-4 dark:border-blue-600 dark:bg-gray-800/80"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<div
|
||||
class="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full bg-blue-600 text-xs font-bold text-white"
|
||||
<p class="mb-3 text-sm text-blue-700 dark:text-blue-300">
|
||||
使用 claude.ai 的 sessionKey 自动完成 OAuth 授权流程,无需手动打开浏览器。
|
||||
</p>
|
||||
|
||||
<!-- sessionKey输入 -->
|
||||
<div class="mb-4">
|
||||
<label
|
||||
class="mb-2 flex items-center gap-2 text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
1
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<p class="mb-2 font-medium text-blue-900 dark:text-blue-200">
|
||||
点击下方按钮生成授权链接
|
||||
</p>
|
||||
<button
|
||||
v-if="!authUrl"
|
||||
class="btn btn-primary px-4 py-2 text-sm"
|
||||
:disabled="loading"
|
||||
@click="generateAuthUrl"
|
||||
<i class="fas fa-cookie text-blue-500" />
|
||||
sessionKey
|
||||
<span
|
||||
v-if="parsedSessionKeyCount > 1"
|
||||
class="rounded-full bg-blue-500 px-2 py-0.5 text-xs text-white"
|
||||
>
|
||||
<i v-if="!loading" class="fas fa-link mr-2" />
|
||||
<div v-else class="loading-spinner mr-2" />
|
||||
{{ loading ? '生成中...' : '生成授权链接' }}
|
||||
{{ parsedSessionKeyCount }} 个
|
||||
</span>
|
||||
<button
|
||||
class="text-blue-500 hover:text-blue-600"
|
||||
type="button"
|
||||
@click="showSessionKeyHelp = !showSessionKeyHelp"
|
||||
>
|
||||
<i class="fas fa-question-circle" />
|
||||
</button>
|
||||
<div v-else class="space-y-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
class="form-input flex-1 bg-gray-50 font-mono text-xs dark:bg-gray-700"
|
||||
readonly
|
||||
type="text"
|
||||
:value="authUrl"
|
||||
/>
|
||||
</label>
|
||||
<textarea
|
||||
v-model="sessionKey"
|
||||
class="form-input w-full resize-y font-mono text-sm"
|
||||
placeholder="每行一个 sessionKey,例如: sk-ant-sid01-xxxxx... sk-ant-sid01-yyyyy..."
|
||||
rows="3"
|
||||
/>
|
||||
<p
|
||||
v-if="parsedSessionKeyCount > 1"
|
||||
class="mt-1 text-xs text-blue-600 dark:text-blue-400"
|
||||
>
|
||||
<i class="fas fa-info-circle mr-1" />
|
||||
将批量创建 {{ parsedSessionKeyCount }} 个账户
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 帮助说明 -->
|
||||
<div
|
||||
v-if="showSessionKeyHelp"
|
||||
class="mb-4 rounded-lg border border-amber-200 bg-amber-50 p-3 dark:border-amber-700 dark:bg-amber-900/30"
|
||||
>
|
||||
<h5 class="mb-2 font-semibold text-amber-800 dark:text-amber-200">
|
||||
<i class="fas fa-lightbulb mr-1" />如何获取 sessionKey
|
||||
</h5>
|
||||
<ol
|
||||
class="list-inside list-decimal space-y-1 text-xs text-amber-700 dark:text-amber-300"
|
||||
>
|
||||
<li>在浏览器中登录 <strong>claude.ai</strong></li>
|
||||
<li>
|
||||
按
|
||||
<kbd class="rounded bg-gray-200 px-1 dark:bg-gray-700">F12</kbd>
|
||||
打开开发者工具
|
||||
</li>
|
||||
<li>切换到 <strong>Application</strong>(应用)标签页</li>
|
||||
<li>
|
||||
在左侧找到 <strong>Cookies</strong> → <strong>https://claude.ai</strong>
|
||||
</li>
|
||||
<li>找到键为 <strong>sessionKey</strong> 的那一行</li>
|
||||
<li>复制其 <strong>Value</strong>(值)列的内容</li>
|
||||
</ol>
|
||||
<p class="mt-2 text-xs text-amber-600 dark:text-amber-400">
|
||||
<i class="fas fa-info-circle mr-1" />
|
||||
sessionKey 通常以
|
||||
<code class="rounded bg-gray-200 px-1 dark:bg-gray-700">sk-ant-sid01-</code>
|
||||
开头
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 错误信息 -->
|
||||
<div
|
||||
v-if="cookieAuthError"
|
||||
class="mb-4 rounded-lg border border-red-200 bg-red-50 p-3 dark:border-red-700 dark:bg-red-900/30"
|
||||
>
|
||||
<p class="text-sm text-red-600 dark:text-red-400">
|
||||
<i class="fas fa-exclamation-circle mr-1" />
|
||||
{{ cookieAuthError }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 授权按钮 -->
|
||||
<button
|
||||
class="btn btn-primary w-full px-4 py-3 text-base font-semibold"
|
||||
:disabled="cookieAuthLoading || !sessionKey.trim()"
|
||||
type="button"
|
||||
@click="handleCookieAuth"
|
||||
>
|
||||
<div v-if="cookieAuthLoading" class="loading-spinner mr-2" />
|
||||
<i v-else class="fas fa-magic mr-2" />
|
||||
<template v-if="cookieAuthLoading && batchProgress.total > 1">
|
||||
正在授权 {{ batchProgress.current }}/{{ batchProgress.total }}...
|
||||
</template>
|
||||
<template v-else-if="cookieAuthLoading"> 正在授权... </template>
|
||||
<template v-else> 开始自动授权 </template>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 手动授权流程 -->
|
||||
<div v-else>
|
||||
<p class="mb-4 text-sm text-blue-800 dark:text-blue-300">
|
||||
请按照以下步骤完成 Claude 账户的授权:
|
||||
</p>
|
||||
|
||||
<div class="space-y-4">
|
||||
<!-- 步骤1: 生成授权链接 -->
|
||||
<div
|
||||
class="rounded-lg border border-blue-300 bg-white/80 p-4 dark:border-blue-600 dark:bg-gray-800/80"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<div
|
||||
class="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full bg-blue-600 text-xs font-bold text-white"
|
||||
>
|
||||
1
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<p class="mb-2 font-medium text-blue-900 dark:text-blue-200">
|
||||
点击下方按钮生成授权链接
|
||||
</p>
|
||||
<button
|
||||
v-if="!authUrl"
|
||||
class="btn btn-primary px-4 py-2 text-sm"
|
||||
:disabled="loading"
|
||||
@click="generateAuthUrl"
|
||||
>
|
||||
<i v-if="!loading" class="fas fa-link mr-2" />
|
||||
<div v-else class="loading-spinner mr-2" />
|
||||
{{ loading ? '生成中...' : '生成授权链接' }}
|
||||
</button>
|
||||
<div v-else class="space-y-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
class="form-input flex-1 bg-gray-50 font-mono text-xs dark:bg-gray-700"
|
||||
readonly
|
||||
type="text"
|
||||
:value="authUrl"
|
||||
/>
|
||||
<button
|
||||
class="rounded-lg bg-gray-100 px-3 py-2 transition-colors hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600"
|
||||
title="复制链接"
|
||||
@click="copyAuthUrl"
|
||||
>
|
||||
<i :class="copied ? 'fas fa-check text-green-500' : 'fas fa-copy'" />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
class="rounded-lg bg-gray-100 px-3 py-2 transition-colors hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600"
|
||||
title="复制链接"
|
||||
@click="copyAuthUrl"
|
||||
class="text-xs text-blue-600 hover:text-blue-700"
|
||||
@click="regenerateAuthUrl"
|
||||
>
|
||||
<i :class="copied ? 'fas fa-check text-green-500' : 'fas fa-copy'" />
|
||||
<i class="fas fa-sync-alt mr-1" />重新生成
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
class="text-xs text-blue-600 hover:text-blue-700"
|
||||
@click="regenerateAuthUrl"
|
||||
>
|
||||
<i class="fas fa-sync-alt mr-1" />重新生成
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 步骤2: 访问链接并授权 -->
|
||||
<div
|
||||
class="rounded-lg border border-blue-300 bg-white/80 p-4 dark:border-blue-600 dark:bg-gray-800/80"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<div
|
||||
class="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full bg-blue-600 text-xs font-bold text-white"
|
||||
>
|
||||
2
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<p class="mb-2 font-medium text-blue-900 dark:text-blue-200">
|
||||
在浏览器中打开链接并完成授权
|
||||
</p>
|
||||
<p class="mb-2 text-sm text-blue-700 dark:text-blue-300">
|
||||
请在新标签页中打开授权链接,登录您的 Claude 账户并授权。
|
||||
</p>
|
||||
<!-- 步骤2: 访问链接并授权 -->
|
||||
<div
|
||||
class="rounded-lg border border-blue-300 bg-white/80 p-4 dark:border-blue-600 dark:bg-gray-800/80"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<div
|
||||
class="rounded border border-yellow-300 bg-yellow-50 p-3 dark:border-yellow-700 dark:bg-yellow-900/30"
|
||||
class="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full bg-blue-600 text-xs font-bold text-white"
|
||||
>
|
||||
<p class="text-xs text-yellow-800 dark:text-yellow-300">
|
||||
<i class="fas fa-exclamation-triangle mr-1" />
|
||||
<strong>注意:</strong
|
||||
>如果您设置了代理,请确保浏览器也使用相同的代理访问授权页面。
|
||||
2
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<p class="mb-2 font-medium text-blue-900 dark:text-blue-200">
|
||||
在浏览器中打开链接并完成授权
|
||||
</p>
|
||||
<p class="mb-2 text-sm text-blue-700 dark:text-blue-300">
|
||||
请在新标签页中打开授权链接,登录您的 Claude 账户并授权。
|
||||
</p>
|
||||
<div
|
||||
class="rounded border border-yellow-300 bg-yellow-50 p-3 dark:border-yellow-700 dark:bg-yellow-900/30"
|
||||
>
|
||||
<p class="text-xs text-yellow-800 dark:text-yellow-300">
|
||||
<i class="fas fa-exclamation-triangle mr-1" />
|
||||
<strong>注意:</strong
|
||||
>如果您设置了代理,请确保浏览器也使用相同的代理访问授权页面。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 步骤3: 输入授权码 -->
|
||||
<div
|
||||
class="rounded-lg border border-blue-300 bg-white/80 p-4 dark:border-blue-600 dark:bg-gray-800/80"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<div
|
||||
class="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full bg-blue-600 text-xs font-bold text-white"
|
||||
>
|
||||
3
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<p class="mb-2 font-medium text-blue-900 dark:text-blue-200">
|
||||
输入 Authorization Code
|
||||
</p>
|
||||
<p class="mb-3 text-sm text-blue-700 dark:text-blue-300">
|
||||
授权完成后,页面会显示一个
|
||||
<strong>Authorization Code</strong>,请将其复制并粘贴到下方输入框:
|
||||
</p>
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<label
|
||||
class="mb-2 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
<i class="fas fa-key mr-2 text-blue-500" />Authorization Code
|
||||
</label>
|
||||
<textarea
|
||||
v-model="authCode"
|
||||
class="form-input w-full resize-none font-mono text-sm"
|
||||
placeholder="粘贴从Claude页面获取的Authorization Code..."
|
||||
rows="3"
|
||||
/>
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
<i class="fas fa-info-circle mr-1" />
|
||||
请粘贴从Claude页面复制的Authorization Code
|
||||
<!-- 步骤3: 输入授权码 -->
|
||||
<div
|
||||
class="rounded-lg border border-blue-300 bg-white/80 p-4 dark:border-blue-600 dark:bg-gray-800/80"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<div
|
||||
class="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full bg-blue-600 text-xs font-bold text-white"
|
||||
>
|
||||
3
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<p class="mb-2 font-medium text-blue-900 dark:text-blue-200">
|
||||
输入 Authorization Code
|
||||
</p>
|
||||
<p class="mb-3 text-sm text-blue-700 dark:text-blue-300">
|
||||
授权完成后,页面会显示一个
|
||||
<strong>Authorization Code</strong>,请将其复制并粘贴到下方输入框:
|
||||
</p>
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<label
|
||||
class="mb-2 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
<i class="fas fa-key mr-2 text-blue-500" />Authorization Code
|
||||
</label>
|
||||
<textarea
|
||||
v-model="authCode"
|
||||
class="form-input w-full resize-none font-mono text-sm"
|
||||
placeholder="粘贴从Claude页面获取的Authorization Code..."
|
||||
rows="3"
|
||||
/>
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
<i class="fas fa-info-circle mr-1" />
|
||||
请粘贴从Claude页面复制的Authorization Code
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -636,7 +777,9 @@
|
||||
>
|
||||
上一步
|
||||
</button>
|
||||
<!-- Cookie自动授权模式不显示此按钮(Claude平台) -->
|
||||
<button
|
||||
v-if="!(platform === 'claude' && authMethod === 'cookie')"
|
||||
class="btn btn-primary flex-1 px-6 py-3 font-semibold"
|
||||
:disabled="!canExchange || exchanging"
|
||||
type="button"
|
||||
@@ -682,6 +825,22 @@ const verificationUriComplete = ref('')
|
||||
const remainingSeconds = ref(0)
|
||||
let countdownTimer = null
|
||||
|
||||
// Cookie自动授权相关状态
|
||||
const authMethod = ref('manual') // 'manual' | 'cookie'
|
||||
const sessionKey = ref('')
|
||||
const cookieAuthLoading = ref(false)
|
||||
const cookieAuthError = ref('')
|
||||
const showSessionKeyHelp = ref(false)
|
||||
const batchProgress = ref({ current: 0, total: 0 }) // 批量进度
|
||||
|
||||
// 解析后的 sessionKey 数量
|
||||
const parsedSessionKeyCount = computed(() => {
|
||||
return sessionKey.value
|
||||
.split('\n')
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s.length > 0).length
|
||||
})
|
||||
|
||||
// 计算是否可以交换code
|
||||
const canExchange = computed(() => {
|
||||
if (props.platform === 'droid') {
|
||||
@@ -984,4 +1143,93 @@ const exchangeCode = async () => {
|
||||
onBeforeUnmount(() => {
|
||||
stopCountdown()
|
||||
})
|
||||
|
||||
// Cookie自动授权处理(支持批量)
|
||||
const handleCookieAuth = async () => {
|
||||
// 解析多行输入
|
||||
const sessionKeys = sessionKey.value
|
||||
.split('\n')
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s.length > 0)
|
||||
|
||||
if (sessionKeys.length === 0) {
|
||||
cookieAuthError.value = '请输入至少一个 sessionKey'
|
||||
return
|
||||
}
|
||||
|
||||
cookieAuthLoading.value = true
|
||||
cookieAuthError.value = ''
|
||||
batchProgress.value = { current: 0, total: sessionKeys.length }
|
||||
|
||||
// 构建代理配置
|
||||
const proxyConfig = props.proxy?.enabled
|
||||
? {
|
||||
type: props.proxy.type,
|
||||
host: props.proxy.host,
|
||||
port: parseInt(props.proxy.port),
|
||||
username: props.proxy.username || null,
|
||||
password: props.proxy.password || null
|
||||
}
|
||||
: null
|
||||
|
||||
const results = []
|
||||
const errors = []
|
||||
|
||||
for (let i = 0; i < sessionKeys.length; i++) {
|
||||
batchProgress.value.current = i + 1
|
||||
try {
|
||||
const result = await accountsStore.oauthWithCookie({
|
||||
sessionKey: sessionKeys[i],
|
||||
proxy: proxyConfig
|
||||
})
|
||||
results.push(result)
|
||||
} catch (error) {
|
||||
errors.push({
|
||||
index: i + 1,
|
||||
key: sessionKeys[i].substring(0, 20) + '...',
|
||||
error: error.message
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
batchProgress.value = { current: 0, total: 0 }
|
||||
|
||||
if (results.length > 0) {
|
||||
// emit 后父组件会调用 handleOAuthSuccess 创建账号
|
||||
// cookieAuthLoading 保持 true,成功后表单会关闭,失败时父组件会处理
|
||||
emit('success', results) // 返回数组(单个时也是数组)
|
||||
// 注意:不在这里设置 cookieAuthLoading = false
|
||||
// 父组件创建账号完成后表单会关闭/重置
|
||||
} else {
|
||||
// 全部授权失败时才恢复按钮状态
|
||||
cookieAuthLoading.value = false
|
||||
}
|
||||
|
||||
if (errors.length > 0 && results.length === 0) {
|
||||
cookieAuthError.value = '全部授权失败,请检查 sessionKey 是否有效'
|
||||
} else if (errors.length > 0) {
|
||||
cookieAuthError.value = `${errors.length} 个授权失败`
|
||||
}
|
||||
}
|
||||
|
||||
// 重置Cookie授权状态
|
||||
const resetCookieAuth = () => {
|
||||
sessionKey.value = ''
|
||||
cookieAuthError.value = ''
|
||||
cookieAuthLoading.value = false
|
||||
batchProgress.value = { current: 0, total: 0 }
|
||||
}
|
||||
|
||||
// 切换授权方式时重置状态
|
||||
const onAuthMethodChange = () => {
|
||||
resetCookieAuth()
|
||||
authUrl.value = ''
|
||||
authCode.value = ''
|
||||
sessionId.value = ''
|
||||
}
|
||||
|
||||
// 暴露方法供父组件调用
|
||||
defineExpose({
|
||||
resetCookieAuth
|
||||
})
|
||||
</script>
|
||||
|
||||
211
web/admin-spa/src/components/apikeys/RecordDetailModal.vue
Normal file
211
web/admin-spa/src/components/apikeys/RecordDetailModal.vue
Normal file
@@ -0,0 +1,211 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
:append-to-body="true"
|
||||
class="record-detail-modal"
|
||||
:close-on-click-modal="false"
|
||||
:destroy-on-close="true"
|
||||
:model-value="show"
|
||||
:show-close="false"
|
||||
top="10vh"
|
||||
width="720px"
|
||||
@close="emitClose"
|
||||
>
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
请求详情
|
||||
</p>
|
||||
<p class="text-lg font-bold text-gray-900 dark:text-gray-100">
|
||||
{{ record?.model || '未知模型' }}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
aria-label="关闭"
|
||||
class="rounded-full p-2 text-gray-500 transition hover:bg-gray-100 hover:text-gray-700 dark:text-gray-300 dark:hover:bg-gray-800 dark:hover:text-gray-100"
|
||||
@click="emitClose"
|
||||
>
|
||||
<i class="fas fa-times" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="grid gap-3 md:grid-cols-2">
|
||||
<div
|
||||
class="rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-gray-800 dark:bg-gray-900"
|
||||
>
|
||||
<h4 class="mb-3 text-sm font-semibold text-gray-800 dark:text-gray-200">基本信息</h4>
|
||||
<ul class="space-y-2 text-sm text-gray-700 dark:text-gray-300">
|
||||
<li class="flex items-center justify-between">
|
||||
<span class="text-gray-500 dark:text-gray-400">时间</span>
|
||||
<span class="font-medium">{{ formattedTime }}</span>
|
||||
</li>
|
||||
<li class="flex items-center justify-between">
|
||||
<span class="text-gray-500 dark:text-gray-400">模型</span>
|
||||
<span class="font-medium">{{ record?.model || '未知模型' }}</span>
|
||||
</li>
|
||||
<li class="flex items-center justify-between">
|
||||
<span class="text-gray-500 dark:text-gray-400">账户</span>
|
||||
<span class="font-medium">{{ record?.accountName || '未知账户' }}</span>
|
||||
</li>
|
||||
<li class="flex items-center justify-between">
|
||||
<span class="text-gray-500 dark:text-gray-400">渠道</span>
|
||||
<span class="font-medium">{{ record?.accountTypeName || '未知渠道' }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-gray-800 dark:bg-gray-900"
|
||||
>
|
||||
<h4 class="mb-3 text-sm font-semibold text-gray-800 dark:text-gray-200">Token 使用</h4>
|
||||
<ul class="space-y-2 text-sm text-gray-700 dark:text-gray-300">
|
||||
<li class="flex items-center justify-between">
|
||||
<span class="text-gray-500 dark:text-gray-400">输入 Token</span>
|
||||
<span class="font-semibold text-blue-600 dark:text-blue-400">
|
||||
{{ formatNumber(record?.inputTokens) }}
|
||||
</span>
|
||||
</li>
|
||||
<li class="flex items-center justify-between">
|
||||
<span class="text-gray-500 dark:text-gray-400">输出 Token</span>
|
||||
<span class="font-semibold text-green-600 dark:text-green-400">
|
||||
{{ formatNumber(record?.outputTokens) }}
|
||||
</span>
|
||||
</li>
|
||||
<li class="flex items-center justify-between">
|
||||
<span class="text-gray-500 dark:text-gray-400">缓存创建</span>
|
||||
<span class="font-semibold text-purple-600 dark:text-purple-400">
|
||||
{{ formatNumber(record?.cacheCreateTokens) }}
|
||||
</span>
|
||||
</li>
|
||||
<li class="flex items-center justify-between">
|
||||
<span class="text-gray-500 dark:text-gray-400">缓存读取</span>
|
||||
<span class="font-semibold text-orange-600 dark:text-orange-400">
|
||||
{{ formatNumber(record?.cacheReadTokens) }}
|
||||
</span>
|
||||
</li>
|
||||
<li class="flex items-center justify-between">
|
||||
<span class="text-gray-500 dark:text-gray-400">总计</span>
|
||||
<span class="font-semibold text-gray-900 dark:text-gray-100">
|
||||
{{ formatNumber(record?.totalTokens) }}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-800 dark:bg-gray-900"
|
||||
>
|
||||
<h4 class="mb-3 text-sm font-semibold text-gray-800 dark:text-gray-200">费用详情</h4>
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<div
|
||||
class="flex items-center justify-between rounded-md bg-gray-50 px-3 py-2 dark:bg-gray-800"
|
||||
>
|
||||
<span class="text-sm text-gray-500 dark:text-gray-400">输入费用</span>
|
||||
<span class="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||
{{ formattedCosts.input }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="flex items-center justify-between rounded-md bg-gray-50 px-3 py-2 dark:bg-gray-800"
|
||||
>
|
||||
<span class="text-sm text-gray-500 dark:text-gray-400">输出费用</span>
|
||||
<span class="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||
{{ formattedCosts.output }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="flex items-center justify-between rounded-md bg-gray-50 px-3 py-2 dark:bg-gray-800"
|
||||
>
|
||||
<span class="text-sm text-gray-500 dark:text-gray-400">缓存创建</span>
|
||||
<span class="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||
{{ formattedCosts.cacheCreate }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="flex items-center justify-between rounded-md bg-gray-50 px-3 py-2 dark:bg-gray-800"
|
||||
>
|
||||
<span class="text-sm text-gray-500 dark:text-gray-400">缓存读取</span>
|
||||
<span class="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||
{{ formattedCosts.cacheRead }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="mt-4 flex items-center justify-between rounded-md border border-gray-200 bg-gray-50 px-4 py-3 dark:border-gray-800 dark:bg-gray-800"
|
||||
>
|
||||
<span class="text-sm font-semibold text-gray-700 dark:text-gray-200">总费用</span>
|
||||
<div class="text-base font-bold text-yellow-600 dark:text-yellow-400">
|
||||
{{ record?.costFormatted || formattedCosts.total }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex justify-end">
|
||||
<el-button type="primary" @click="emitClose">关闭</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import dayjs from 'dayjs'
|
||||
import { formatNumber } from '@/utils/format'
|
||||
|
||||
const props = defineProps({
|
||||
show: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
record: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close'])
|
||||
const emitClose = () => emit('close')
|
||||
|
||||
const formattedTime = computed(() => {
|
||||
if (!props.record?.timestamp) return '未知时间'
|
||||
return dayjs(props.record.timestamp).format('YYYY-MM-DD HH:mm:ss')
|
||||
})
|
||||
|
||||
const formattedCosts = computed(() => {
|
||||
const breakdown = props.record?.costBreakdown || {}
|
||||
const formatValue = (value) => {
|
||||
const num = typeof value === 'number' ? value : 0
|
||||
if (num >= 1) return `$${num.toFixed(2)}`
|
||||
if (num >= 0.001) return `$${num.toFixed(4)}`
|
||||
return `$${num.toFixed(6)}`
|
||||
}
|
||||
|
||||
return {
|
||||
input: formatValue(breakdown.input),
|
||||
output: formatValue(breakdown.output),
|
||||
cacheCreate: formatValue(breakdown.cacheCreate),
|
||||
cacheRead: formatValue(breakdown.cacheRead),
|
||||
total: formatValue(breakdown.total)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.record-detail-modal :deep(.el-dialog__header) {
|
||||
margin: 0;
|
||||
padding: 16px 16px 0;
|
||||
}
|
||||
|
||||
.record-detail-modal :deep(.el-dialog__body) {
|
||||
padding: 12px 16px 4px;
|
||||
}
|
||||
|
||||
.record-detail-modal :deep(.el-dialog__footer) {
|
||||
padding: 8px 16px 16px;
|
||||
}
|
||||
</style>
|
||||
@@ -231,6 +231,9 @@
|
||||
|
||||
<!-- 底部按钮 -->
|
||||
<div class="mt-4 flex justify-end gap-2 sm:mt-6 sm:gap-3">
|
||||
<button class="btn btn-primary px-4 py-2 text-sm" type="button" @click="openTimeline">
|
||||
查看请求时间线
|
||||
</button>
|
||||
<button class="btn btn-secondary px-4 py-2 text-sm" type="button" @click="close">
|
||||
关闭
|
||||
</button>
|
||||
@@ -256,7 +259,7 @@ const props = defineProps({
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close'])
|
||||
const emit = defineEmits(['close', 'open-timeline'])
|
||||
|
||||
// 计算属性
|
||||
const totalRequests = computed(() => props.apiKey.usage?.total?.requests || 0)
|
||||
@@ -320,6 +323,10 @@ const formatTokenCount = (count) => {
|
||||
const close = () => {
|
||||
emit('close')
|
||||
}
|
||||
|
||||
const openTimeline = () => {
|
||||
emit('open-timeline', props.apiKey?.id)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -77,7 +77,21 @@ const getActionClass = (action) => {
|
||||
return colorMap[action.color] || colorMap.gray
|
||||
}
|
||||
|
||||
const instanceId = Symbol('action-dropdown')
|
||||
const handleGlobalOpen = (event) => {
|
||||
if (event?.detail?.id !== instanceId) {
|
||||
closeDropdown()
|
||||
}
|
||||
}
|
||||
|
||||
const toggleDropdown = async () => {
|
||||
if (!isOpen.value) {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('action-dropdown-open', {
|
||||
detail: { id: instanceId }
|
||||
})
|
||||
)
|
||||
}
|
||||
isOpen.value = !isOpen.value
|
||||
if (isOpen.value) {
|
||||
await nextTick()
|
||||
@@ -102,7 +116,8 @@ const updateDropdownPosition = () => {
|
||||
|
||||
const trigger = triggerRef.value.getBoundingClientRect()
|
||||
const dropdownHeight = 200 // 预估高度
|
||||
const dropdownWidth = 160 // 预估宽度
|
||||
const dropdownWidth = 180 // 预估宽度,略大以减少遮挡
|
||||
const gap = 8
|
||||
const spaceBelow = window.innerHeight - trigger.bottom
|
||||
const spaceAbove = trigger.top
|
||||
const spaceRight = window.innerWidth - trigger.right
|
||||
@@ -112,16 +127,16 @@ const updateDropdownPosition = () => {
|
||||
|
||||
// 计算垂直位置
|
||||
if (spaceBelow >= dropdownHeight || spaceBelow >= spaceAbove) {
|
||||
top = trigger.bottom + 4
|
||||
top = trigger.bottom + gap
|
||||
} else {
|
||||
top = trigger.top - dropdownHeight - 4
|
||||
top = trigger.top - dropdownHeight - gap
|
||||
}
|
||||
|
||||
// 计算水平位置 - 优先显示在左侧(因为按钮在右侧固定列)
|
||||
if (spaceLeft >= dropdownWidth) {
|
||||
left = trigger.left - dropdownWidth + trigger.width
|
||||
} else if (spaceRight >= dropdownWidth) {
|
||||
left = trigger.left
|
||||
// 计算水平位置 - 优先向右展开,避免遮挡左侧内容
|
||||
if (spaceRight >= dropdownWidth + gap) {
|
||||
left = trigger.right + gap
|
||||
} else if (spaceLeft >= dropdownWidth + gap) {
|
||||
left = trigger.left - dropdownWidth - gap + trigger.width
|
||||
} else {
|
||||
left = window.innerWidth - dropdownWidth - 10
|
||||
}
|
||||
@@ -164,11 +179,13 @@ onMounted(() => {
|
||||
window.addEventListener('scroll', handleScroll, true)
|
||||
window.addEventListener('resize', handleResize)
|
||||
document.addEventListener('click', handleClickOutside)
|
||||
window.addEventListener('action-dropdown-open', handleGlobalOpen)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('scroll', handleScroll, true)
|
||||
window.removeEventListener('resize', handleResize)
|
||||
document.removeEventListener('click', handleClickOutside)
|
||||
window.removeEventListener('action-dropdown-open', handleGlobalOpen)
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -41,19 +41,25 @@
|
||||
<div
|
||||
v-for="option in options"
|
||||
:key="option.value"
|
||||
class="flex cursor-pointer items-center gap-2 whitespace-nowrap px-3 py-2 text-sm transition-colors duration-150"
|
||||
class="flex cursor-pointer items-center gap-2 whitespace-nowrap py-2 text-sm transition-colors duration-150"
|
||||
:class="[
|
||||
option.value === modelValue
|
||||
? 'bg-blue-50 font-medium text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'
|
||||
: 'text-gray-700 hover:bg-gray-50 dark:text-gray-300 dark:hover:bg-gray-700'
|
||||
: option.isGroup
|
||||
? 'bg-gray-50 font-semibold text-gray-800 dark:bg-gray-700/50 dark:text-gray-200'
|
||||
: 'text-gray-700 hover:bg-gray-50 dark:text-gray-300 dark:hover:bg-gray-700'
|
||||
]"
|
||||
:style="{
|
||||
paddingLeft: option.indent ? `${12 + option.indent * 16}px` : '12px',
|
||||
paddingRight: '12px'
|
||||
}"
|
||||
@click="selectOption(option)"
|
||||
>
|
||||
<i v-if="option.icon" :class="['fas', option.icon, 'text-xs']"></i>
|
||||
<span>{{ option.label }}</span>
|
||||
<i
|
||||
v-if="option.value === modelValue"
|
||||
class="fas fa-check ml-auto pl-3 text-xs text-blue-600"
|
||||
class="fas fa-check ml-auto pl-3 text-xs text-blue-600 dark:text-blue-400"
|
||||
></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -11,7 +11,9 @@ const UserManagementView = () => import('@/views/UserManagementView.vue')
|
||||
const MainLayout = () => import('@/components/layout/MainLayout.vue')
|
||||
const DashboardView = () => import('@/views/DashboardView.vue')
|
||||
const ApiKeysView = () => import('@/views/ApiKeysView.vue')
|
||||
const ApiKeyUsageRecordsView = () => import('@/views/ApiKeyUsageRecordsView.vue')
|
||||
const AccountsView = () => import('@/views/AccountsView.vue')
|
||||
const AccountUsageRecordsView = () => import('@/views/AccountUsageRecordsView.vue')
|
||||
const TutorialView = () => import('@/views/TutorialView.vue')
|
||||
const SettingsView = () => import('@/views/SettingsView.vue')
|
||||
const ApiStatsView = () => import('@/views/ApiStatsView.vue')
|
||||
@@ -85,6 +87,18 @@ const routes = [
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/api-keys/:keyId/usage-records',
|
||||
component: MainLayout,
|
||||
meta: { requiresAuth: true },
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
name: 'ApiKeyUsageRecords',
|
||||
component: ApiKeyUsageRecordsView
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/accounts',
|
||||
component: MainLayout,
|
||||
@@ -97,6 +111,18 @@ const routes = [
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/accounts/:accountId/usage-records',
|
||||
component: MainLayout,
|
||||
meta: { requiresAuth: true },
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
name: 'AccountUsageRecords',
|
||||
component: AccountUsageRecordsView
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/tutorial',
|
||||
component: MainLayout,
|
||||
|
||||
@@ -750,6 +750,39 @@ export const useAccountsStore = defineStore('accounts', () => {
|
||||
}
|
||||
}
|
||||
|
||||
// Cookie自动授权 - 普通OAuth
|
||||
const oauthWithCookie = async (payload) => {
|
||||
try {
|
||||
const response = await apiClient.post('/admin/claude-accounts/oauth-with-cookie', payload)
|
||||
if (response.success) {
|
||||
return response.data
|
||||
} else {
|
||||
throw new Error(response.message || 'Cookie授权失败')
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = err.message
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
// Cookie自动授权 - Setup Token
|
||||
const oauthSetupTokenWithCookie = async (payload) => {
|
||||
try {
|
||||
const response = await apiClient.post(
|
||||
'/admin/claude-accounts/setup-token-with-cookie',
|
||||
payload
|
||||
)
|
||||
if (response.success) {
|
||||
return response.data
|
||||
} else {
|
||||
throw new Error(response.message || 'Cookie授权失败')
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = err.message
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
// 生成Gemini OAuth URL
|
||||
const generateGeminiAuthUrl = async (proxyConfig) => {
|
||||
try {
|
||||
@@ -914,6 +947,8 @@ export const useAccountsStore = defineStore('accounts', () => {
|
||||
exchangeClaudeCode,
|
||||
generateClaudeSetupTokenUrl,
|
||||
exchangeClaudeSetupTokenCode,
|
||||
oauthWithCookie,
|
||||
oauthSetupTokenWithCookie,
|
||||
generateGeminiAuthUrl,
|
||||
exchangeGeminiCode,
|
||||
generateOpenAIAuthUrl,
|
||||
|
||||
@@ -67,22 +67,72 @@ export const useDashboardStore = defineStore('dashboard', () => {
|
||||
groupLabel: 'Claude账户'
|
||||
})
|
||||
|
||||
// 本地偏好
|
||||
const STORAGE_KEYS = {
|
||||
preset: 'dashboard:date:preset',
|
||||
granularity: 'dashboard:trend:granularity'
|
||||
}
|
||||
const defaultPreset = 'today'
|
||||
const defaultGranularity = 'day'
|
||||
|
||||
const getPresetOptions = (granularity) =>
|
||||
granularity === 'hour'
|
||||
? [
|
||||
{ value: 'last24h', label: '近24小时', hours: 24 },
|
||||
{ value: 'yesterday', label: '昨天', hours: 24 },
|
||||
{ value: 'dayBefore', label: '前天', hours: 24 }
|
||||
]
|
||||
: [
|
||||
{ value: 'today', label: '今日', days: 1 },
|
||||
{ value: '7days', label: '7天', days: 7 },
|
||||
{ value: '30days', label: '30天', days: 30 }
|
||||
]
|
||||
|
||||
const readFromStorage = (key, fallback) => {
|
||||
try {
|
||||
const value = localStorage.getItem(key)
|
||||
return value || fallback
|
||||
} catch (error) {
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
const saveToStorage = (key, value) => {
|
||||
try {
|
||||
localStorage.setItem(key, value)
|
||||
} catch (error) {
|
||||
// 忽略存储错误,避免影响渲染
|
||||
}
|
||||
}
|
||||
|
||||
const normalizePresetForGranularity = (preset, granularity) => {
|
||||
const options = getPresetOptions(granularity)
|
||||
const hasPreset = options.some((opt) => opt.value === preset)
|
||||
if (hasPreset) return preset
|
||||
return granularity === 'hour' ? 'last24h' : defaultPreset
|
||||
}
|
||||
|
||||
const storedGranularity = readFromStorage(STORAGE_KEYS.granularity, defaultGranularity)
|
||||
const initialGranularity = ['day', 'hour'].includes(storedGranularity)
|
||||
? storedGranularity
|
||||
: defaultGranularity
|
||||
const initialPreset = normalizePresetForGranularity(
|
||||
readFromStorage(STORAGE_KEYS.preset, defaultPreset),
|
||||
initialGranularity
|
||||
)
|
||||
|
||||
// 日期筛选
|
||||
const dateFilter = ref({
|
||||
type: 'preset', // preset 或 custom
|
||||
preset: '7days', // today, 7days, 30days
|
||||
preset: initialPreset, // today, 7days, 30days
|
||||
customStart: '',
|
||||
customEnd: '',
|
||||
customRange: null,
|
||||
presetOptions: [
|
||||
{ value: 'today', label: '今日', days: 1 },
|
||||
{ value: '7days', label: '7天', days: 7 },
|
||||
{ value: '30days', label: '30天', days: 30 }
|
||||
]
|
||||
presetOptions: getPresetOptions(initialGranularity)
|
||||
})
|
||||
|
||||
// 趋势图粒度
|
||||
const trendGranularity = ref('day') // 'day' 或 'hour'
|
||||
const trendGranularity = ref(initialGranularity) // 'day' 或 'hour'
|
||||
const apiKeysTrendMetric = ref('requests') // 'requests' 或 'tokens'
|
||||
const accountUsageGroup = ref('claude') // claude | openai | gemini
|
||||
|
||||
@@ -138,6 +188,21 @@ export const useDashboardStore = defineStore('dashboard', () => {
|
||||
}
|
||||
}
|
||||
|
||||
const persistDatePreferences = (
|
||||
preset = dateFilter.value.preset,
|
||||
granularity = trendGranularity.value
|
||||
) => {
|
||||
saveToStorage(STORAGE_KEYS.preset, preset)
|
||||
saveToStorage(STORAGE_KEYS.granularity, granularity)
|
||||
}
|
||||
|
||||
const getEffectiveGranularity = () =>
|
||||
dateFilter.value.type === 'preset' &&
|
||||
dateFilter.value.preset === 'today' &&
|
||||
trendGranularity.value === 'day'
|
||||
? 'hour'
|
||||
: trendGranularity.value
|
||||
|
||||
// 方法
|
||||
async function loadDashboardData(timeRange = null) {
|
||||
loading.value = true
|
||||
@@ -232,7 +297,7 @@ export const useDashboardStore = defineStore('dashboard', () => {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadUsageTrend(days = 7, granularity = 'day') {
|
||||
async function loadUsageTrend(days = 7, granularity = getEffectiveGranularity()) {
|
||||
try {
|
||||
let url = '/admin/usage-trend?'
|
||||
|
||||
@@ -241,26 +306,9 @@ export const useDashboardStore = defineStore('dashboard', () => {
|
||||
url += `granularity=hour`
|
||||
|
||||
if (dateFilter.value.customRange && dateFilter.value.customRange.length === 2) {
|
||||
// 使用自定义时间范围 - 需要将系统时区时间转换为UTC
|
||||
const convertToUTC = (systemTzTimeStr) => {
|
||||
// 固定使用UTC+8,因为后端系统时区是UTC+8
|
||||
const systemTz = 8
|
||||
// 解析系统时区时间字符串
|
||||
const [datePart, timePart] = systemTzTimeStr.split(' ')
|
||||
const [year, month, day] = datePart.split('-').map(Number)
|
||||
const [hours, minutes, seconds] = timePart.split(':').map(Number)
|
||||
|
||||
// 创建UTC时间,使其在系统时区显示为用户选择的时间
|
||||
// 例如:用户选择 UTC+8 的 2025-07-25 00:00:00
|
||||
// 对应的UTC时间是 2025-07-24 16:00:00
|
||||
const utcDate = new Date(
|
||||
Date.UTC(year, month - 1, day, hours - systemTz, minutes, seconds)
|
||||
)
|
||||
return utcDate.toISOString()
|
||||
}
|
||||
|
||||
url += `&startDate=${encodeURIComponent(convertToUTC(dateFilter.value.customRange[0]))}`
|
||||
url += `&endDate=${encodeURIComponent(convertToUTC(dateFilter.value.customRange[1]))}`
|
||||
// 使用自定义时间范围 - 直接按系统时区字符串传递,避免额外时区偏移导致窗口错位
|
||||
url += `&startDate=${encodeURIComponent(dateFilter.value.customRange[0])}`
|
||||
url += `&endDate=${encodeURIComponent(dateFilter.value.customRange[1])}`
|
||||
} else {
|
||||
// 使用预设计算时间范围,与loadApiKeysTrend保持一致
|
||||
const now = new Date()
|
||||
@@ -268,6 +316,12 @@ export const useDashboardStore = defineStore('dashboard', () => {
|
||||
|
||||
if (dateFilter.value.type === 'preset') {
|
||||
switch (dateFilter.value.preset) {
|
||||
case 'today': {
|
||||
// 今日:使用系统时区的当日0点-23:59
|
||||
startTime = getSystemTimezoneDay(now, true)
|
||||
endTime = getSystemTimezoneDay(now, false)
|
||||
break
|
||||
}
|
||||
case 'last24h': {
|
||||
// 近24小时:从当前时间往前推24小时
|
||||
endTime = new Date(now)
|
||||
@@ -318,34 +372,28 @@ export const useDashboardStore = defineStore('dashboard', () => {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadModelStats(period = 'daily') {
|
||||
async function loadModelStats(period = 'daily', granularity = null) {
|
||||
const currentGranularity = granularity || getEffectiveGranularity()
|
||||
try {
|
||||
let url = `/admin/model-stats?period=${period}`
|
||||
|
||||
// 如果是自定义时间范围或小时粒度,传递具体的时间参数
|
||||
if (dateFilter.value.type === 'custom' || trendGranularity.value === 'hour') {
|
||||
if (dateFilter.value.type === 'custom' || currentGranularity === 'hour') {
|
||||
if (dateFilter.value.customRange && dateFilter.value.customRange.length === 2) {
|
||||
// 将系统时区时间转换为UTC
|
||||
const convertToUTC = (systemTzTimeStr) => {
|
||||
const systemTz = 8
|
||||
const [datePart, timePart] = systemTzTimeStr.split(' ')
|
||||
const [year, month, day] = datePart.split('-').map(Number)
|
||||
const [hours, minutes, seconds] = timePart.split(':').map(Number)
|
||||
|
||||
const utcDate = new Date(
|
||||
Date.UTC(year, month - 1, day, hours - systemTz, minutes, seconds)
|
||||
)
|
||||
return utcDate.toISOString()
|
||||
}
|
||||
|
||||
url += `&startDate=${encodeURIComponent(convertToUTC(dateFilter.value.customRange[0]))}`
|
||||
url += `&endDate=${encodeURIComponent(convertToUTC(dateFilter.value.customRange[1]))}`
|
||||
} else if (trendGranularity.value === 'hour' && dateFilter.value.type === 'preset') {
|
||||
// 按系统时区字符串直传,避免额外时区转换
|
||||
url += `&startDate=${encodeURIComponent(dateFilter.value.customRange[0])}`
|
||||
url += `&endDate=${encodeURIComponent(dateFilter.value.customRange[1])}`
|
||||
} else if (currentGranularity === 'hour' && dateFilter.value.type === 'preset') {
|
||||
// 小时粒度的预设时间范围
|
||||
const now = new Date()
|
||||
let startTime, endTime
|
||||
|
||||
switch (dateFilter.value.preset) {
|
||||
case 'today': {
|
||||
startTime = getSystemTimezoneDay(now, true)
|
||||
endTime = getSystemTimezoneDay(now, false)
|
||||
break
|
||||
}
|
||||
case 'last24h': {
|
||||
endTime = new Date(now)
|
||||
startTime = new Date(now.getTime() - 24 * 60 * 60 * 1000)
|
||||
@@ -374,7 +422,7 @@ export const useDashboardStore = defineStore('dashboard', () => {
|
||||
url += `&startDate=${encodeURIComponent(startTime.toISOString())}`
|
||||
url += `&endDate=${encodeURIComponent(endTime.toISOString())}`
|
||||
}
|
||||
} else if (dateFilter.value.type === 'preset' && trendGranularity.value === 'day') {
|
||||
} else if (dateFilter.value.type === 'preset' && currentGranularity === 'day') {
|
||||
// 天粒度的预设时间范围,需要传递startDate和endDate参数
|
||||
const now = new Date()
|
||||
let startDate, endDate
|
||||
@@ -409,36 +457,20 @@ export const useDashboardStore = defineStore('dashboard', () => {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadApiKeysTrend(metric = 'requests') {
|
||||
async function loadApiKeysTrend(metric = 'requests', granularity = null) {
|
||||
const currentGranularity = granularity || getEffectiveGranularity()
|
||||
try {
|
||||
let url = '/admin/api-keys-usage-trend?'
|
||||
let days = 7
|
||||
|
||||
if (trendGranularity.value === 'hour') {
|
||||
if (currentGranularity === 'hour') {
|
||||
// 小时粒度,计算时间范围
|
||||
url += `granularity=hour`
|
||||
|
||||
if (dateFilter.value.customRange && dateFilter.value.customRange.length === 2) {
|
||||
// 使用自定义时间范围 - 需要将系统时区时间转换为UTC
|
||||
const convertToUTC = (systemTzTimeStr) => {
|
||||
// 固定使用UTC+8,因为后端系统时区是UTC+8
|
||||
const systemTz = 8
|
||||
// 解析系统时区时间字符串
|
||||
const [datePart, timePart] = systemTzTimeStr.split(' ')
|
||||
const [year, month, day] = datePart.split('-').map(Number)
|
||||
const [hours, minutes, seconds] = timePart.split(':').map(Number)
|
||||
|
||||
// 创建UTC时间,使其在系统时区显示为用户选择的时间
|
||||
// 例如:用户选择 UTC+8 的 2025-07-25 00:00:00
|
||||
// 对应的UTC时间是 2025-07-24 16:00:00
|
||||
const utcDate = new Date(
|
||||
Date.UTC(year, month - 1, day, hours - systemTz, minutes, seconds)
|
||||
)
|
||||
return utcDate.toISOString()
|
||||
}
|
||||
|
||||
url += `&startDate=${encodeURIComponent(convertToUTC(dateFilter.value.customRange[0]))}`
|
||||
url += `&endDate=${encodeURIComponent(convertToUTC(dateFilter.value.customRange[1]))}`
|
||||
// 使用自定义时间范围 - 按系统时区字符串直传
|
||||
url += `&startDate=${encodeURIComponent(dateFilter.value.customRange[0])}`
|
||||
url += `&endDate=${encodeURIComponent(dateFilter.value.customRange[1])}`
|
||||
} else {
|
||||
// 使用预设计算时间范围,与setDateFilterPreset保持一致
|
||||
const now = new Date()
|
||||
@@ -446,6 +478,11 @@ export const useDashboardStore = defineStore('dashboard', () => {
|
||||
|
||||
if (dateFilter.value.type === 'preset') {
|
||||
switch (dateFilter.value.preset) {
|
||||
case 'today': {
|
||||
startTime = getSystemTimezoneDay(now, true)
|
||||
endTime = getSystemTimezoneDay(now, false)
|
||||
break
|
||||
}
|
||||
case 'last24h': {
|
||||
// 近24小时:从当前时间往前推24小时
|
||||
endTime = new Date(now)
|
||||
@@ -511,29 +548,18 @@ export const useDashboardStore = defineStore('dashboard', () => {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAccountUsageTrend(group = accountUsageGroup.value) {
|
||||
async function loadAccountUsageTrend(group = accountUsageGroup.value, granularity = null) {
|
||||
const currentGranularity = granularity || getEffectiveGranularity()
|
||||
try {
|
||||
let url = '/admin/account-usage-trend?'
|
||||
let days = 7
|
||||
|
||||
if (trendGranularity.value === 'hour') {
|
||||
if (currentGranularity === 'hour') {
|
||||
url += `granularity=hour`
|
||||
|
||||
if (dateFilter.value.customRange && dateFilter.value.customRange.length === 2) {
|
||||
const convertToUTC = (systemTzTimeStr) => {
|
||||
const systemTz = 8
|
||||
const [datePart, timePart] = systemTzTimeStr.split(' ')
|
||||
const [year, month, day] = datePart.split('-').map(Number)
|
||||
const [hours, minutes, seconds] = timePart.split(':').map(Number)
|
||||
|
||||
const utcDate = new Date(
|
||||
Date.UTC(year, month - 1, day, hours - systemTz, minutes, seconds)
|
||||
)
|
||||
return utcDate.toISOString()
|
||||
}
|
||||
|
||||
url += `&startDate=${encodeURIComponent(convertToUTC(dateFilter.value.customRange[0]))}`
|
||||
url += `&endDate=${encodeURIComponent(convertToUTC(dateFilter.value.customRange[1]))}`
|
||||
url += `&startDate=${encodeURIComponent(dateFilter.value.customRange[0])}`
|
||||
url += `&endDate=${encodeURIComponent(dateFilter.value.customRange[1])}`
|
||||
} else {
|
||||
const now = new Date()
|
||||
let startTime
|
||||
@@ -541,6 +567,11 @@ export const useDashboardStore = defineStore('dashboard', () => {
|
||||
|
||||
if (dateFilter.value.type === 'preset') {
|
||||
switch (dateFilter.value.preset) {
|
||||
case 'today': {
|
||||
startTime = getSystemTimezoneDay(now, true)
|
||||
endTime = getSystemTimezoneDay(now, false)
|
||||
break
|
||||
}
|
||||
case 'last24h': {
|
||||
endTime = new Date(now)
|
||||
startTime = new Date(now.getTime() - 24 * 60 * 60 * 1000)
|
||||
@@ -603,110 +634,87 @@ export const useDashboardStore = defineStore('dashboard', () => {
|
||||
}
|
||||
|
||||
// 日期筛选相关方法
|
||||
function setDateFilterPreset(preset) {
|
||||
function setDateFilterPreset(preset, options = {}) {
|
||||
const { silent = false, skipSave = false } = options
|
||||
const normalizedPreset = normalizePresetForGranularity(preset, trendGranularity.value)
|
||||
|
||||
dateFilter.value.type = 'preset'
|
||||
dateFilter.value.preset = preset
|
||||
dateFilter.value.preset = normalizedPreset
|
||||
|
||||
// 根据预设计算并设置具体的日期范围
|
||||
const option = dateFilter.value.presetOptions.find((opt) => opt.value === preset)
|
||||
if (option) {
|
||||
const now = new Date()
|
||||
let startDate, endDate
|
||||
const option = dateFilter.value.presetOptions.find((opt) => opt.value === normalizedPreset)
|
||||
const now = new Date()
|
||||
let startDate
|
||||
let endDate
|
||||
|
||||
if (trendGranularity.value === 'hour') {
|
||||
// 小时粒度的预设
|
||||
switch (preset) {
|
||||
case 'last24h': {
|
||||
// 近24小时:从当前时间往前推24小时
|
||||
endDate = new Date(now)
|
||||
startDate = new Date(now.getTime() - 24 * 60 * 60 * 1000)
|
||||
break
|
||||
}
|
||||
case 'yesterday': {
|
||||
// 昨天:获取本地时间的昨天
|
||||
const yesterday = new Date()
|
||||
yesterday.setDate(yesterday.getDate() - 1)
|
||||
// 转换为系统时区的昨天0点和23:59
|
||||
startDate = getSystemTimezoneDay(yesterday, true)
|
||||
endDate = getSystemTimezoneDay(yesterday, false)
|
||||
break
|
||||
}
|
||||
case 'dayBefore': {
|
||||
// 前天:获取本地时间的前天
|
||||
const dayBefore = new Date()
|
||||
dayBefore.setDate(dayBefore.getDate() - 2)
|
||||
// 转换为系统时区的前天0点和23:59
|
||||
startDate = getSystemTimezoneDay(dayBefore, true)
|
||||
endDate = getSystemTimezoneDay(dayBefore, false)
|
||||
break
|
||||
}
|
||||
if (trendGranularity.value === 'hour') {
|
||||
switch (normalizedPreset) {
|
||||
case 'today': {
|
||||
startDate = getSystemTimezoneDay(now, true)
|
||||
endDate = getSystemTimezoneDay(now, false)
|
||||
break
|
||||
}
|
||||
} else {
|
||||
// 天粒度的预设
|
||||
startDate = new Date(now)
|
||||
endDate = new Date(now)
|
||||
|
||||
if (preset === 'today') {
|
||||
// 今日:从凌晨开始
|
||||
startDate.setHours(0, 0, 0, 0)
|
||||
endDate.setHours(23, 59, 59, 999)
|
||||
} else {
|
||||
// 其他预设:按天数计算
|
||||
startDate.setDate(now.getDate() - (option.days - 1))
|
||||
startDate.setHours(0, 0, 0, 0)
|
||||
endDate.setHours(23, 59, 59, 999)
|
||||
case 'last24h': {
|
||||
endDate = new Date(now)
|
||||
startDate = new Date(now.getTime() - 24 * 60 * 60 * 1000)
|
||||
break
|
||||
}
|
||||
case 'yesterday': {
|
||||
const yesterday = new Date()
|
||||
yesterday.setDate(yesterday.getDate() - 1)
|
||||
startDate = getSystemTimezoneDay(yesterday, true)
|
||||
endDate = getSystemTimezoneDay(yesterday, false)
|
||||
break
|
||||
}
|
||||
case 'dayBefore': {
|
||||
const dayBefore = new Date()
|
||||
dayBefore.setDate(dayBefore.getDate() - 2)
|
||||
startDate = getSystemTimezoneDay(dayBefore, true)
|
||||
endDate = getSystemTimezoneDay(dayBefore, false)
|
||||
break
|
||||
}
|
||||
default: {
|
||||
endDate = new Date(now)
|
||||
startDate = new Date(now.getTime() - 24 * 60 * 60 * 1000)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
startDate = new Date(now)
|
||||
endDate = new Date(now)
|
||||
|
||||
dateFilter.value.customStart = startDate.toISOString().split('T')[0]
|
||||
dateFilter.value.customEnd = endDate.toISOString().split('T')[0]
|
||||
|
||||
// 设置 customRange 为 Element Plus 需要的格式
|
||||
// 对于小时粒度的昨天/前天,需要特殊处理显示
|
||||
if (trendGranularity.value === 'hour' && (preset === 'yesterday' || preset === 'dayBefore')) {
|
||||
// 获取本地日期
|
||||
const targetDate = new Date()
|
||||
if (preset === 'yesterday') {
|
||||
targetDate.setDate(targetDate.getDate() - 1)
|
||||
} else {
|
||||
targetDate.setDate(targetDate.getDate() - 2)
|
||||
}
|
||||
|
||||
// 显示系统时区的完整一天
|
||||
const year = targetDate.getFullYear()
|
||||
const month = String(targetDate.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(targetDate.getDate()).padStart(2, '0')
|
||||
|
||||
dateFilter.value.customRange = [
|
||||
`${year}-${month}-${day} 00:00:00`,
|
||||
`${year}-${month}-${day} 23:59:59`
|
||||
]
|
||||
} else {
|
||||
// 其他情况:近24小时或天粒度
|
||||
const formatDateForDisplay = (date) => {
|
||||
// 固定使用UTC+8来显示时间
|
||||
const systemTz = 8
|
||||
const tzOffset = systemTz * 60 * 60 * 1000
|
||||
const localTime = new Date(date.getTime() + tzOffset)
|
||||
|
||||
const year = localTime.getUTCFullYear()
|
||||
const month = String(localTime.getUTCMonth() + 1).padStart(2, '0')
|
||||
const day = String(localTime.getUTCDate()).padStart(2, '0')
|
||||
const hours = String(localTime.getUTCHours()).padStart(2, '0')
|
||||
const minutes = String(localTime.getUTCMinutes()).padStart(2, '0')
|
||||
const seconds = String(localTime.getUTCSeconds()).padStart(2, '0')
|
||||
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
|
||||
}
|
||||
|
||||
dateFilter.value.customRange = [
|
||||
formatDateForDisplay(startDate),
|
||||
formatDateForDisplay(endDate)
|
||||
]
|
||||
if (normalizedPreset === 'today') {
|
||||
startDate.setHours(0, 0, 0, 0)
|
||||
endDate.setHours(23, 59, 59, 999)
|
||||
} else if (option?.days) {
|
||||
startDate.setDate(now.getDate() - (option.days - 1))
|
||||
startDate.setHours(0, 0, 0, 0)
|
||||
endDate.setHours(23, 59, 59, 999)
|
||||
}
|
||||
}
|
||||
|
||||
// 触发数据刷新
|
||||
refreshChartsData()
|
||||
const formatDateForDisplay = (date) => {
|
||||
// 使用本地时间直接格式化,避免多余的时区偏移导致日期错位
|
||||
const localTime = new Date(date)
|
||||
const year = localTime.getFullYear()
|
||||
const month = String(localTime.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(localTime.getDate()).padStart(2, '0')
|
||||
const hours = String(localTime.getHours()).padStart(2, '0')
|
||||
const minutes = String(localTime.getMinutes()).padStart(2, '0')
|
||||
const seconds = String(localTime.getSeconds()).padStart(2, '0')
|
||||
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
|
||||
}
|
||||
|
||||
dateFilter.value.customStart = startDate ? startDate.toISOString().split('T')[0] : ''
|
||||
dateFilter.value.customEnd = endDate ? endDate.toISOString().split('T')[0] : ''
|
||||
dateFilter.value.customRange =
|
||||
startDate && endDate ? [formatDateForDisplay(startDate), formatDateForDisplay(endDate)] : null
|
||||
|
||||
if (!skipSave) {
|
||||
persistDatePreferences(dateFilter.value.preset, trendGranularity.value)
|
||||
}
|
||||
|
||||
if (!silent) {
|
||||
refreshChartsData()
|
||||
}
|
||||
}
|
||||
|
||||
function onCustomDateRangeChange(value) {
|
||||
@@ -751,20 +759,17 @@ export const useDashboardStore = defineStore('dashboard', () => {
|
||||
refreshChartsData()
|
||||
} else if (value === null) {
|
||||
// 清空时恢复默认
|
||||
setDateFilterPreset(trendGranularity.value === 'hour' ? 'last24h' : '7days')
|
||||
setDateFilterPreset(trendGranularity.value === 'hour' ? 'last24h' : defaultPreset)
|
||||
}
|
||||
}
|
||||
|
||||
function setTrendGranularity(granularity) {
|
||||
function setTrendGranularity(granularity, options = {}) {
|
||||
const { silent = false, skipSave = false, presetOverride } = options
|
||||
trendGranularity.value = granularity
|
||||
|
||||
// 根据粒度更新预设选项
|
||||
if (granularity === 'hour') {
|
||||
dateFilter.value.presetOptions = [
|
||||
{ value: 'last24h', label: '近24小时', hours: 24 },
|
||||
{ value: 'yesterday', label: '昨天', hours: 24 },
|
||||
{ value: 'dayBefore', label: '前天', hours: 24 }
|
||||
]
|
||||
dateFilter.value.presetOptions = getPresetOptions('hour')
|
||||
|
||||
// 检查当前自定义日期范围是否超过24小时
|
||||
if (
|
||||
@@ -777,46 +782,53 @@ export const useDashboardStore = defineStore('dashboard', () => {
|
||||
const hoursDiff = (end - start) / (1000 * 60 * 60)
|
||||
if (hoursDiff > 24) {
|
||||
showToast('小时粒度下日期范围不能超过24小时,已切换到近24小时', 'warning')
|
||||
setDateFilterPreset('last24h')
|
||||
setDateFilterPreset('last24h', { silent, skipSave })
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 如果当前是天粒度的预设,切换到小时粒度的默认预设
|
||||
if (['today', '7days', '30days'].includes(dateFilter.value.preset)) {
|
||||
setDateFilterPreset('last24h')
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// 天粒度
|
||||
dateFilter.value.presetOptions = [
|
||||
{ value: 'today', label: '今日', days: 1 },
|
||||
{ value: '7days', label: '7天', days: 7 },
|
||||
{ value: '30days', label: '30天', days: 30 }
|
||||
]
|
||||
|
||||
// 如果当前是小时粒度的预设,切换到天粒度的默认预设
|
||||
if (['last24h', 'yesterday', 'dayBefore'].includes(dateFilter.value.preset)) {
|
||||
setDateFilterPreset('7days')
|
||||
return
|
||||
}
|
||||
dateFilter.value.presetOptions = getPresetOptions('day')
|
||||
}
|
||||
|
||||
// 触发数据刷新
|
||||
refreshChartsData()
|
||||
if (dateFilter.value.type === 'custom') {
|
||||
if (!skipSave) {
|
||||
persistDatePreferences(dateFilter.value.preset || defaultPreset, trendGranularity.value)
|
||||
}
|
||||
|
||||
if (!silent) {
|
||||
refreshChartsData()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const nextPreset =
|
||||
presetOverride ||
|
||||
normalizePresetForGranularity(dateFilter.value.preset, trendGranularity.value)
|
||||
|
||||
setDateFilterPreset(nextPreset, { silent: true, skipSave: true })
|
||||
|
||||
if (!skipSave) {
|
||||
persistDatePreferences(dateFilter.value.preset, trendGranularity.value)
|
||||
}
|
||||
|
||||
if (!silent) {
|
||||
refreshChartsData()
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshChartsData() {
|
||||
// 根据当前筛选条件刷新数据
|
||||
let days
|
||||
let modelPeriod = 'monthly'
|
||||
const effectiveGranularity = getEffectiveGranularity()
|
||||
|
||||
if (dateFilter.value.type === 'preset') {
|
||||
const option = dateFilter.value.presetOptions.find(
|
||||
(opt) => opt.value === dateFilter.value.preset
|
||||
)
|
||||
|
||||
if (trendGranularity.value === 'hour') {
|
||||
if (effectiveGranularity === 'hour') {
|
||||
// 小时粒度
|
||||
days = 1 // 小时粒度默认查看1天的数据
|
||||
modelPeriod = 'daily' // 小时粒度使用日统计
|
||||
@@ -832,7 +844,7 @@ export const useDashboardStore = defineStore('dashboard', () => {
|
||||
}
|
||||
} else {
|
||||
// 自定义日期范围
|
||||
if (trendGranularity.value === 'hour') {
|
||||
if (effectiveGranularity === 'hour') {
|
||||
// 小时粒度下的自定义范围,计算小时数
|
||||
const start = new Date(dateFilter.value.customRange[0])
|
||||
const end = new Date(dateFilter.value.customRange[1])
|
||||
@@ -845,16 +857,16 @@ export const useDashboardStore = defineStore('dashboard', () => {
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
loadUsageTrend(days, trendGranularity.value),
|
||||
loadModelStats(modelPeriod),
|
||||
loadApiKeysTrend(apiKeysTrendMetric.value),
|
||||
loadAccountUsageTrend(accountUsageGroup.value)
|
||||
loadUsageTrend(days, effectiveGranularity),
|
||||
loadModelStats(modelPeriod, effectiveGranularity),
|
||||
loadApiKeysTrend(apiKeysTrendMetric.value, effectiveGranularity),
|
||||
loadAccountUsageTrend(accountUsageGroup.value, effectiveGranularity)
|
||||
])
|
||||
}
|
||||
|
||||
function setAccountUsageGroup(group) {
|
||||
accountUsageGroup.value = group
|
||||
return loadAccountUsageTrend(group)
|
||||
return loadAccountUsageTrend(group, getEffectiveGranularity())
|
||||
}
|
||||
|
||||
function calculateDaysBetween(start, end) {
|
||||
@@ -870,6 +882,10 @@ export const useDashboardStore = defineStore('dashboard', () => {
|
||||
return date > new Date()
|
||||
}
|
||||
|
||||
// 初始化日期筛选:同步本地偏好并填充范围
|
||||
setDateFilterPreset(dateFilter.value.preset, { silent: true, skipSave: true })
|
||||
persistDatePreferences(dateFilter.value.preset, trendGranularity.value)
|
||||
|
||||
return {
|
||||
// 状态
|
||||
loading,
|
||||
|
||||
574
web/admin-spa/src/views/AccountUsageRecordsView.vue
Normal file
574
web/admin-spa/src/views/AccountUsageRecordsView.vue
Normal file
@@ -0,0 +1,574 @@
|
||||
<template>
|
||||
<div class="space-y-4 p-4 lg:p-6">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
class="rounded-full border border-gray-200 px-3 py-2 text-sm text-gray-700 transition hover:bg-gray-100 dark:border-gray-700 dark:text-gray-200 dark:hover:bg-gray-800"
|
||||
@click="goBack"
|
||||
>
|
||||
← 返回
|
||||
</button>
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-wide text-blue-600 dark:text-blue-400">
|
||||
账户请求详情时间线
|
||||
</p>
|
||||
<h2 class="text-xl font-bold text-gray-900 dark:text-gray-100">
|
||||
{{ accountDisplayName }}
|
||||
</h2>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">ID: {{ accountId }}</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">渠道:{{ platformDisplayName }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
<i class="fas fa-clock text-blue-500" />
|
||||
<span v-if="dateRangeHint">{{ dateRangeHint }}</span>
|
||||
<span v-else>显示近 5000 条记录</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
||||
<div
|
||||
class="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900"
|
||||
>
|
||||
<p class="text-xs uppercase text-gray-500 dark:text-gray-400">总请求</p>
|
||||
<p class="mt-1 text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||
{{ formatNumber(summary.totalRequests) }}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
class="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900"
|
||||
>
|
||||
<p class="text-xs uppercase text-gray-500 dark:text-gray-400">总 Token</p>
|
||||
<p class="mt-1 text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||
{{ formatNumber(summary.totalTokens) }}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
class="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900"
|
||||
>
|
||||
<p class="text-xs uppercase text-gray-500 dark:text-gray-400">总费用</p>
|
||||
<p class="mt-1 text-2xl font-bold text-yellow-600 dark:text-yellow-400">
|
||||
{{ formatCost(summary.totalCost) }}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
class="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900"
|
||||
>
|
||||
<p class="text-xs uppercase text-gray-500 dark:text-gray-400">平均费用/次</p>
|
||||
<p class="mt-1 text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||
{{ formatCost(summary.avgCost) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900"
|
||||
>
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<el-date-picker
|
||||
v-model="filters.dateRange"
|
||||
class="max-w-[320px]"
|
||||
clearable
|
||||
end-placeholder="结束时间"
|
||||
format="YYYY-MM-DD HH:mm:ss"
|
||||
start-placeholder="开始时间"
|
||||
type="datetimerange"
|
||||
unlink-panels
|
||||
value-format="YYYY-MM-DDTHH:mm:ss[Z]"
|
||||
/>
|
||||
|
||||
<el-select
|
||||
v-model="filters.model"
|
||||
class="w-[180px]"
|
||||
clearable
|
||||
filterable
|
||||
placeholder="所有模型"
|
||||
>
|
||||
<el-option
|
||||
v-for="modelOption in availableModels"
|
||||
:key="modelOption"
|
||||
:label="modelOption"
|
||||
:value="modelOption"
|
||||
/>
|
||||
</el-select>
|
||||
|
||||
<el-select
|
||||
v-model="filters.apiKeyId"
|
||||
class="w-[220px]"
|
||||
clearable
|
||||
filterable
|
||||
placeholder="所有 API Key"
|
||||
>
|
||||
<el-option
|
||||
v-for="apiKey in availableApiKeys"
|
||||
:key="apiKey.id"
|
||||
:label="apiKey.name || apiKey.id"
|
||||
:value="apiKey.id"
|
||||
/>
|
||||
</el-select>
|
||||
|
||||
<el-select v-model="filters.sortOrder" class="w-[140px]" placeholder="排序">
|
||||
<el-option label="时间降序" value="desc" />
|
||||
<el-option label="时间升序" value="asc" />
|
||||
</el-select>
|
||||
|
||||
<el-button @click="resetFilters"> <i class="fas fa-undo mr-2" /> 重置 </el-button>
|
||||
<el-button :loading="exporting" type="primary" @click="exportCsv">
|
||||
<i class="fas fa-file-export mr-2" /> 导出 CSV
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="rounded-xl border border-gray-200 bg-white shadow-sm dark:border-gray-800 dark:bg-gray-900"
|
||||
>
|
||||
<div
|
||||
v-if="loading"
|
||||
class="flex items-center justify-center p-10 text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
<i class="fas fa-spinner fa-spin mr-2" /> 加载中...
|
||||
</div>
|
||||
<div v-else>
|
||||
<div
|
||||
v-if="records.length === 0"
|
||||
class="flex flex-col items-center gap-2 p-10 text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
<i class="fas fa-inbox text-2xl" />
|
||||
<p>暂无记录</p>
|
||||
</div>
|
||||
<div v-else class="space-y-4">
|
||||
<div class="hidden overflow-x-auto md:block">
|
||||
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-800">
|
||||
<thead class="bg-gray-50 dark:bg-gray-800">
|
||||
<tr>
|
||||
<th
|
||||
class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-300"
|
||||
>
|
||||
时间
|
||||
</th>
|
||||
<th
|
||||
class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-300"
|
||||
>
|
||||
API Key
|
||||
</th>
|
||||
<th
|
||||
class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-300"
|
||||
>
|
||||
模型
|
||||
</th>
|
||||
<th
|
||||
class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-300"
|
||||
>
|
||||
输入
|
||||
</th>
|
||||
<th
|
||||
class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-300"
|
||||
>
|
||||
输出
|
||||
</th>
|
||||
<th
|
||||
class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-300"
|
||||
>
|
||||
缓存(创/读)
|
||||
</th>
|
||||
<th
|
||||
class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-300"
|
||||
>
|
||||
总 Token
|
||||
</th>
|
||||
<th
|
||||
class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-300"
|
||||
>
|
||||
费用
|
||||
</th>
|
||||
<th
|
||||
class="px-4 py-3 text-right text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-300"
|
||||
>
|
||||
操作
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody
|
||||
class="divide-y divide-gray-200 bg-white dark:divide-gray-800 dark:bg-gray-900"
|
||||
>
|
||||
<tr v-for="record in records" :key="record.timestamp + record.model">
|
||||
<td class="whitespace-nowrap px-4 py-3 text-sm text-gray-800 dark:text-gray-100">
|
||||
{{ formatDate(record.timestamp) }}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-800 dark:text-gray-100">
|
||||
<div class="flex flex-col">
|
||||
<span class="font-semibold">
|
||||
{{ record.apiKeyName || record.apiKeyId || '未知 Key' }}
|
||||
</span>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">
|
||||
ID: {{ record.apiKeyId }}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-4 py-3 text-sm text-gray-800 dark:text-gray-100">
|
||||
{{ record.model }}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-4 py-3 text-sm text-blue-600 dark:text-blue-400">
|
||||
{{ formatNumber(record.inputTokens) }}
|
||||
</td>
|
||||
<td
|
||||
class="whitespace-nowrap px-4 py-3 text-sm text-green-600 dark:text-green-400"
|
||||
>
|
||||
{{ formatNumber(record.outputTokens) }}
|
||||
</td>
|
||||
<td
|
||||
class="whitespace-nowrap px-4 py-3 text-sm text-purple-600 dark:text-purple-400"
|
||||
>
|
||||
{{ formatNumber(record.cacheCreateTokens) }} /
|
||||
{{ formatNumber(record.cacheReadTokens) }}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-4 py-3 text-sm text-gray-800 dark:text-gray-100">
|
||||
{{ formatNumber(record.totalTokens) }}
|
||||
</td>
|
||||
<td
|
||||
class="whitespace-nowrap px-4 py-3 text-sm text-yellow-600 dark:text-yellow-400"
|
||||
>
|
||||
{{ record.costFormatted || formatCost(record.cost) }}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-4 py-3 text-right text-sm">
|
||||
<el-button size="small" @click="openDetail(record)">详情</el-button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3 md:hidden">
|
||||
<div
|
||||
v-for="record in records"
|
||||
:key="record.timestamp + record.model"
|
||||
class="rounded-lg border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||
{{ record.apiKeyName || record.apiKeyId || '未知 Key' }}
|
||||
</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
ID: {{ record.apiKeyId }} · {{ formatDate(record.timestamp) }}
|
||||
</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
渠道:{{ platformDisplayName }}
|
||||
</p>
|
||||
</div>
|
||||
<el-button size="small" @click="openDetail(record)">详情</el-button>
|
||||
</div>
|
||||
<div class="mt-3 grid grid-cols-2 gap-2 text-sm text-gray-700 dark:text-gray-300">
|
||||
<div>模型:{{ record.model }}</div>
|
||||
<div>总 Token:{{ formatNumber(record.totalTokens) }}</div>
|
||||
<div>输入:{{ formatNumber(record.inputTokens) }}</div>
|
||||
<div>输出:{{ formatNumber(record.outputTokens) }}</div>
|
||||
<div>
|
||||
缓存创/读:{{ formatNumber(record.cacheCreateTokens) }} /
|
||||
{{ formatNumber(record.cacheReadTokens) }}
|
||||
</div>
|
||||
<div class="text-yellow-600 dark:text-yellow-400">
|
||||
费用:{{ record.costFormatted || formatCost(record.cost) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between px-4 pb-4">
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400">
|
||||
共 {{ pagination.totalRecords }} 条记录
|
||||
</div>
|
||||
<el-pagination
|
||||
background
|
||||
:current-page="pagination.currentPage"
|
||||
layout="prev, pager, next, sizes"
|
||||
:page-size="pagination.pageSize"
|
||||
:page-sizes="[20, 50, 100, 200]"
|
||||
:total="pagination.totalRecords"
|
||||
@current-change="handlePageChange"
|
||||
@size-change="handleSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<RecordDetailModal :record="activeRecord" :show="detailVisible" @close="closeDetail" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import dayjs from 'dayjs'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { apiClient } from '@/config/api'
|
||||
import { showToast } from '@/utils/toast'
|
||||
import { formatNumber } from '@/utils/format'
|
||||
import RecordDetailModal from '@/components/apikeys/RecordDetailModal.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const accountId = computed(() => route.params.accountId)
|
||||
const platform = computed(() => route.query.platform)
|
||||
const loading = ref(false)
|
||||
const exporting = ref(false)
|
||||
const records = ref([])
|
||||
const availableModels = ref([])
|
||||
const availableApiKeys = ref([])
|
||||
|
||||
const pagination = reactive({
|
||||
currentPage: 1,
|
||||
pageSize: 50,
|
||||
totalRecords: 0
|
||||
})
|
||||
|
||||
const filters = reactive({
|
||||
dateRange: null,
|
||||
model: '',
|
||||
apiKeyId: '',
|
||||
sortOrder: 'desc'
|
||||
})
|
||||
|
||||
const summary = reactive({
|
||||
totalRequests: 0,
|
||||
totalTokens: 0,
|
||||
totalCost: 0,
|
||||
avgCost: 0
|
||||
})
|
||||
|
||||
const accountInfo = reactive({
|
||||
id: accountId.value,
|
||||
name: '',
|
||||
platform: platform.value || ''
|
||||
})
|
||||
|
||||
const detailVisible = ref(false)
|
||||
const activeRecord = ref(null)
|
||||
|
||||
const accountDisplayName = computed(() => accountInfo.name || accountInfo.id || accountId.value)
|
||||
const platformDisplayName = computed(() => {
|
||||
const map = {
|
||||
claude: 'Claude官方',
|
||||
'claude-console': 'Claude Console',
|
||||
ccr: 'Claude Console Relay',
|
||||
openai: 'OpenAI',
|
||||
'openai-responses': 'OpenAI Responses',
|
||||
gemini: 'Gemini',
|
||||
'gemini-api': 'Gemini API',
|
||||
droid: 'Droid',
|
||||
unknown: '未知渠道'
|
||||
}
|
||||
const key = accountInfo.platform || platform.value || 'unknown'
|
||||
return map[key] || '未知渠道'
|
||||
})
|
||||
|
||||
const dateRangeHint = computed(() => {
|
||||
if (!filters.dateRange || filters.dateRange.length !== 2) return ''
|
||||
return `${formatDate(filters.dateRange[0])} ~ ${formatDate(filters.dateRange[1])}`
|
||||
})
|
||||
|
||||
const formatDate = (value) => {
|
||||
if (!value) return '--'
|
||||
return dayjs(value).format('YYYY-MM-DD HH:mm:ss')
|
||||
}
|
||||
|
||||
const formatCost = (value) => {
|
||||
const num = typeof value === 'number' ? value : 0
|
||||
if (num >= 1) return `$${num.toFixed(2)}`
|
||||
if (num >= 0.001) return `$${num.toFixed(4)}`
|
||||
return `$${num.toFixed(6)}`
|
||||
}
|
||||
|
||||
const buildParams = (page) => {
|
||||
const params = {
|
||||
page,
|
||||
pageSize: pagination.pageSize,
|
||||
sortOrder: filters.sortOrder
|
||||
}
|
||||
|
||||
if (filters.model) params.model = filters.model
|
||||
if (filters.apiKeyId) params.apiKeyId = filters.apiKeyId
|
||||
if (filters.dateRange && filters.dateRange.length === 2) {
|
||||
params.startDate = dayjs(filters.dateRange[0]).toISOString()
|
||||
params.endDate = dayjs(filters.dateRange[1]).toISOString()
|
||||
}
|
||||
if (platform.value) {
|
||||
params.platform = platform.value
|
||||
}
|
||||
|
||||
return params
|
||||
}
|
||||
|
||||
const syncResponseState = (data) => {
|
||||
records.value = data.records || []
|
||||
|
||||
const pageInfo = data.pagination || {}
|
||||
pagination.currentPage = pageInfo.currentPage || 1
|
||||
pagination.pageSize = pageInfo.pageSize || pagination.pageSize
|
||||
pagination.totalRecords = pageInfo.totalRecords || 0
|
||||
|
||||
const filterEcho = data.filters || {}
|
||||
if (filterEcho.model !== undefined) filters.model = filterEcho.model || ''
|
||||
if (filterEcho.apiKeyId !== undefined) filters.apiKeyId = filterEcho.apiKeyId || ''
|
||||
if (filterEcho.sortOrder) filters.sortOrder = filterEcho.sortOrder
|
||||
if (filterEcho.startDate && filterEcho.endDate) {
|
||||
const nextRange = [filterEcho.startDate, filterEcho.endDate]
|
||||
const currentRange = filters.dateRange || []
|
||||
if (currentRange[0] !== nextRange[0] || currentRange[1] !== nextRange[1]) {
|
||||
filters.dateRange = nextRange
|
||||
}
|
||||
}
|
||||
|
||||
const summaryData = data.summary || {}
|
||||
summary.totalRequests = summaryData.totalRequests || 0
|
||||
summary.totalTokens = summaryData.totalTokens || 0
|
||||
summary.totalCost = summaryData.totalCost || 0
|
||||
summary.avgCost = summaryData.avgCost || 0
|
||||
|
||||
accountInfo.id = data.accountInfo?.id || accountId.value
|
||||
accountInfo.name = data.accountInfo?.name || ''
|
||||
accountInfo.platform = data.accountInfo?.platform || platform.value || ''
|
||||
|
||||
availableModels.value = data.availableFilters?.models || []
|
||||
availableApiKeys.value = data.availableFilters?.apiKeys || []
|
||||
}
|
||||
|
||||
const fetchRecords = async (page = pagination.currentPage) => {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await apiClient.get(`/admin/accounts/${accountId.value}/usage-records`, {
|
||||
params: buildParams(page)
|
||||
})
|
||||
syncResponseState(response.data || {})
|
||||
} catch (error) {
|
||||
showToast(`加载请求记录失败:${error.message || '未知错误'}`, 'error')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handlePageChange = (page) => {
|
||||
pagination.currentPage = page
|
||||
fetchRecords(page)
|
||||
}
|
||||
|
||||
const handleSizeChange = (size) => {
|
||||
pagination.pageSize = size
|
||||
pagination.currentPage = 1
|
||||
fetchRecords(1)
|
||||
}
|
||||
|
||||
const resetFilters = () => {
|
||||
filters.model = ''
|
||||
filters.apiKeyId = ''
|
||||
filters.dateRange = null
|
||||
filters.sortOrder = 'desc'
|
||||
pagination.currentPage = 1
|
||||
fetchRecords(1)
|
||||
}
|
||||
|
||||
const openDetail = (record) => {
|
||||
activeRecord.value = record
|
||||
detailVisible.value = true
|
||||
}
|
||||
|
||||
const closeDetail = () => {
|
||||
detailVisible.value = false
|
||||
activeRecord.value = null
|
||||
}
|
||||
|
||||
const goBack = () => {
|
||||
router.push('/accounts')
|
||||
}
|
||||
|
||||
const exportCsv = async () => {
|
||||
if (exporting.value) return
|
||||
exporting.value = true
|
||||
try {
|
||||
const aggregated = []
|
||||
let page = 1
|
||||
let totalPages = 1
|
||||
const maxPages = 50 // 50 * 200 = 10000,超过后端 5000 上限已足够
|
||||
|
||||
while (page <= totalPages && page <= maxPages) {
|
||||
const response = await apiClient.get(`/admin/accounts/${accountId.value}/usage-records`, {
|
||||
params: { ...buildParams(page), pageSize: 200 }
|
||||
})
|
||||
const payload = response.data || {}
|
||||
aggregated.push(...(payload.records || []))
|
||||
totalPages = payload.pagination?.totalPages || 1
|
||||
page += 1
|
||||
}
|
||||
|
||||
if (aggregated.length === 0) {
|
||||
showToast('没有可导出的记录', 'info')
|
||||
return
|
||||
}
|
||||
|
||||
const headers = [
|
||||
'时间',
|
||||
'API Key',
|
||||
'模型',
|
||||
'输入Token',
|
||||
'输出Token',
|
||||
'缓存创建Token',
|
||||
'缓存读取Token',
|
||||
'总Token',
|
||||
'费用'
|
||||
]
|
||||
|
||||
const csvRows = [headers.join(',')]
|
||||
aggregated.forEach((record) => {
|
||||
const row = [
|
||||
formatDate(record.timestamp),
|
||||
record.apiKeyName || record.apiKeyId || '',
|
||||
record.model || '',
|
||||
record.inputTokens || 0,
|
||||
record.outputTokens || 0,
|
||||
record.cacheCreateTokens || 0,
|
||||
record.cacheReadTokens || 0,
|
||||
record.totalTokens || 0,
|
||||
record.costFormatted || formatCost(record.cost)
|
||||
]
|
||||
csvRows.push(row.map((cell) => `"${String(cell).replace(/"/g, '""')}"`).join(','))
|
||||
})
|
||||
|
||||
const blob = new Blob([csvRows.join('\n')], {
|
||||
type: 'text/csv;charset=utf-8;'
|
||||
})
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = `account-${accountId.value}-usage-records.csv`
|
||||
link.click()
|
||||
URL.revokeObjectURL(url)
|
||||
showToast('导出 CSV 成功', 'success')
|
||||
} catch (error) {
|
||||
showToast(`导出失败:${error.message || '未知错误'}`, 'error')
|
||||
} finally {
|
||||
exporting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [filters.model, filters.apiKeyId, filters.sortOrder],
|
||||
() => {
|
||||
pagination.currentPage = 1
|
||||
fetchRecords(1)
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => filters.dateRange,
|
||||
() => {
|
||||
pagination.currentPage = 1
|
||||
fetchRecords(1)
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
fetchRecords()
|
||||
})
|
||||
</script>
|
||||
File diff suppressed because it is too large
Load Diff
549
web/admin-spa/src/views/ApiKeyUsageRecordsView.vue
Normal file
549
web/admin-spa/src/views/ApiKeyUsageRecordsView.vue
Normal file
@@ -0,0 +1,549 @@
|
||||
<template>
|
||||
<div class="space-y-4 p-4 lg:p-6">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
class="rounded-full border border-gray-200 px-3 py-2 text-sm text-gray-700 transition hover:bg-gray-100 dark:border-gray-700 dark:text-gray-200 dark:hover:bg-gray-800"
|
||||
@click="goBack"
|
||||
>
|
||||
← 返回
|
||||
</button>
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-wide text-blue-600 dark:text-blue-400">
|
||||
API Key 请求详情时间线
|
||||
</p>
|
||||
<h2 class="text-xl font-bold text-gray-900 dark:text-gray-100">
|
||||
{{ apiKeyDisplayName }}
|
||||
</h2>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">ID: {{ keyId }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
<i class="fas fa-clock text-blue-500" />
|
||||
<span v-if="dateRangeHint">{{ dateRangeHint }}</span>
|
||||
<span v-else>显示近 5000 条记录</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
||||
<div
|
||||
class="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900"
|
||||
>
|
||||
<p class="text-xs uppercase text-gray-500 dark:text-gray-400">总请求</p>
|
||||
<p class="mt-1 text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||
{{ formatNumber(summary.totalRequests) }}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
class="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900"
|
||||
>
|
||||
<p class="text-xs uppercase text-gray-500 dark:text-gray-400">总 Token</p>
|
||||
<p class="mt-1 text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||
{{ formatNumber(summary.totalTokens) }}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
class="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900"
|
||||
>
|
||||
<p class="text-xs uppercase text-gray-500 dark:text-gray-400">总费用</p>
|
||||
<p class="mt-1 text-2xl font-bold text-yellow-600 dark:text-yellow-400">
|
||||
{{ formatCost(summary.totalCost) }}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
class="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900"
|
||||
>
|
||||
<p class="text-xs uppercase text-gray-500 dark:text-gray-400">平均费用/次</p>
|
||||
<p class="mt-1 text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||
{{ formatCost(summary.avgCost) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900"
|
||||
>
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<el-date-picker
|
||||
v-model="filters.dateRange"
|
||||
class="max-w-[320px]"
|
||||
clearable
|
||||
end-placeholder="结束时间"
|
||||
format="YYYY-MM-DD HH:mm:ss"
|
||||
start-placeholder="开始时间"
|
||||
type="datetimerange"
|
||||
unlink-panels
|
||||
value-format="YYYY-MM-DDTHH:mm:ss[Z]"
|
||||
/>
|
||||
|
||||
<el-select
|
||||
v-model="filters.model"
|
||||
class="w-[180px]"
|
||||
clearable
|
||||
filterable
|
||||
placeholder="所有模型"
|
||||
>
|
||||
<el-option
|
||||
v-for="modelOption in availableModels"
|
||||
:key="modelOption"
|
||||
:label="modelOption"
|
||||
:value="modelOption"
|
||||
/>
|
||||
</el-select>
|
||||
|
||||
<el-select
|
||||
v-model="filters.accountId"
|
||||
class="w-[220px]"
|
||||
clearable
|
||||
filterable
|
||||
placeholder="所有账户"
|
||||
>
|
||||
<el-option
|
||||
v-for="account in availableAccounts"
|
||||
:key="account.id"
|
||||
:label="`${account.name}(${account.accountTypeName})`"
|
||||
:value="account.id"
|
||||
/>
|
||||
</el-select>
|
||||
|
||||
<el-select v-model="filters.sortOrder" class="w-[140px]" placeholder="排序">
|
||||
<el-option label="时间降序" value="desc" />
|
||||
<el-option label="时间升序" value="asc" />
|
||||
</el-select>
|
||||
|
||||
<el-button @click="resetFilters"> <i class="fas fa-undo mr-2" /> 重置 </el-button>
|
||||
<el-button :loading="exporting" type="primary" @click="exportCsv">
|
||||
<i class="fas fa-file-export mr-2" /> 导出 CSV
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="rounded-xl border border-gray-200 bg-white shadow-sm dark:border-gray-800 dark:bg-gray-900"
|
||||
>
|
||||
<div
|
||||
v-if="loading"
|
||||
class="flex items-center justify-center p-10 text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
<i class="fas fa-spinner fa-spin mr-2" /> 加载中...
|
||||
</div>
|
||||
<div v-else>
|
||||
<div
|
||||
v-if="records.length === 0"
|
||||
class="flex flex-col items-center gap-2 p-10 text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
<i class="fas fa-inbox text-2xl" />
|
||||
<p>暂无记录</p>
|
||||
</div>
|
||||
<div v-else class="space-y-4">
|
||||
<div class="hidden overflow-x-auto md:block">
|
||||
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-800">
|
||||
<thead class="bg-gray-50 dark:bg-gray-800">
|
||||
<tr>
|
||||
<th
|
||||
class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-300"
|
||||
>
|
||||
时间
|
||||
</th>
|
||||
<th
|
||||
class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-300"
|
||||
>
|
||||
账户
|
||||
</th>
|
||||
<th
|
||||
class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-300"
|
||||
>
|
||||
模型
|
||||
</th>
|
||||
<th
|
||||
class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-300"
|
||||
>
|
||||
输入
|
||||
</th>
|
||||
<th
|
||||
class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-300"
|
||||
>
|
||||
输出
|
||||
</th>
|
||||
<th
|
||||
class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-300"
|
||||
>
|
||||
缓存(创/读)
|
||||
</th>
|
||||
<th
|
||||
class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-300"
|
||||
>
|
||||
总 Token
|
||||
</th>
|
||||
<th
|
||||
class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-300"
|
||||
>
|
||||
费用
|
||||
</th>
|
||||
<th
|
||||
class="px-4 py-3 text-right text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-300"
|
||||
>
|
||||
操作
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody
|
||||
class="divide-y divide-gray-200 bg-white dark:divide-gray-800 dark:bg-gray-900"
|
||||
>
|
||||
<tr v-for="record in records" :key="record.timestamp + record.model">
|
||||
<td class="whitespace-nowrap px-4 py-3 text-sm text-gray-800 dark:text-gray-100">
|
||||
{{ formatDate(record.timestamp) }}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-800 dark:text-gray-100">
|
||||
<div class="flex flex-col">
|
||||
<span class="font-semibold">{{ record.accountName || '未知账户' }}</span>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ record.accountTypeName || '未知渠道' }}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-4 py-3 text-sm text-gray-800 dark:text-gray-100">
|
||||
{{ record.model }}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-4 py-3 text-sm text-blue-600 dark:text-blue-400">
|
||||
{{ formatNumber(record.inputTokens) }}
|
||||
</td>
|
||||
<td
|
||||
class="whitespace-nowrap px-4 py-3 text-sm text-green-600 dark:text-green-400"
|
||||
>
|
||||
{{ formatNumber(record.outputTokens) }}
|
||||
</td>
|
||||
<td
|
||||
class="whitespace-nowrap px-4 py-3 text-sm text-purple-600 dark:text-purple-400"
|
||||
>
|
||||
{{ formatNumber(record.cacheCreateTokens) }} /
|
||||
{{ formatNumber(record.cacheReadTokens) }}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-4 py-3 text-sm text-gray-800 dark:text-gray-100">
|
||||
{{ formatNumber(record.totalTokens) }}
|
||||
</td>
|
||||
<td
|
||||
class="whitespace-nowrap px-4 py-3 text-sm text-yellow-600 dark:text-yellow-400"
|
||||
>
|
||||
{{ record.costFormatted || formatCost(record.cost) }}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-4 py-3 text-right text-sm">
|
||||
<el-button size="small" @click="openDetail(record)">详情</el-button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3 md:hidden">
|
||||
<div
|
||||
v-for="record in records"
|
||||
:key="record.timestamp + record.model"
|
||||
class="rounded-lg border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||
{{ record.accountName || '未知账户' }}
|
||||
</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ formatDate(record.timestamp) }}
|
||||
</p>
|
||||
</div>
|
||||
<el-button size="small" @click="openDetail(record)">详情</el-button>
|
||||
</div>
|
||||
<div class="mt-3 grid grid-cols-2 gap-2 text-sm text-gray-700 dark:text-gray-300">
|
||||
<div>模型:{{ record.model }}</div>
|
||||
<div>总 Token:{{ formatNumber(record.totalTokens) }}</div>
|
||||
<div>输入:{{ formatNumber(record.inputTokens) }}</div>
|
||||
<div>输出:{{ formatNumber(record.outputTokens) }}</div>
|
||||
<div>
|
||||
缓存创/读:{{ formatNumber(record.cacheCreateTokens) }} /
|
||||
{{ formatNumber(record.cacheReadTokens) }}
|
||||
</div>
|
||||
<div class="text-yellow-600 dark:text-yellow-400">
|
||||
费用:{{ record.costFormatted || formatCost(record.cost) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between px-4 pb-4">
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400">
|
||||
共 {{ pagination.totalRecords }} 条记录
|
||||
</div>
|
||||
<el-pagination
|
||||
background
|
||||
:current-page="pagination.currentPage"
|
||||
layout="prev, pager, next, sizes"
|
||||
:page-size="pagination.pageSize"
|
||||
:page-sizes="[20, 50, 100, 200]"
|
||||
:total="pagination.totalRecords"
|
||||
@current-change="handlePageChange"
|
||||
@size-change="handleSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<RecordDetailModal :record="activeRecord" :show="detailVisible" @close="closeDetail" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import dayjs from 'dayjs'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { apiClient } from '@/config/api'
|
||||
import { showToast } from '@/utils/toast'
|
||||
import { formatNumber } from '@/utils/format'
|
||||
import RecordDetailModal from '@/components/apikeys/RecordDetailModal.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const keyId = computed(() => route.params.keyId)
|
||||
const loading = ref(false)
|
||||
const exporting = ref(false)
|
||||
const records = ref([])
|
||||
const availableModels = ref([])
|
||||
const availableAccounts = ref([])
|
||||
|
||||
const pagination = reactive({
|
||||
currentPage: 1,
|
||||
pageSize: 50,
|
||||
totalRecords: 0
|
||||
})
|
||||
|
||||
const filters = reactive({
|
||||
dateRange: null,
|
||||
model: '',
|
||||
accountId: '',
|
||||
sortOrder: 'desc'
|
||||
})
|
||||
|
||||
const summary = reactive({
|
||||
totalRequests: 0,
|
||||
totalTokens: 0,
|
||||
totalCost: 0,
|
||||
avgCost: 0
|
||||
})
|
||||
|
||||
const apiKeyInfo = reactive({
|
||||
id: keyId.value,
|
||||
name: ''
|
||||
})
|
||||
|
||||
const detailVisible = ref(false)
|
||||
const activeRecord = ref(null)
|
||||
|
||||
const apiKeyDisplayName = computed(() => apiKeyInfo.name || apiKeyInfo.id || keyId.value)
|
||||
|
||||
const dateRangeHint = computed(() => {
|
||||
if (!filters.dateRange || filters.dateRange.length !== 2) return ''
|
||||
return `${formatDate(filters.dateRange[0])} ~ ${formatDate(filters.dateRange[1])}`
|
||||
})
|
||||
|
||||
const formatDate = (value) => {
|
||||
if (!value) return '--'
|
||||
return dayjs(value).format('YYYY-MM-DD HH:mm:ss')
|
||||
}
|
||||
|
||||
const formatCost = (value) => {
|
||||
const num = typeof value === 'number' ? value : 0
|
||||
if (num >= 1) return `$${num.toFixed(2)}`
|
||||
if (num >= 0.001) return `$${num.toFixed(4)}`
|
||||
return `$${num.toFixed(6)}`
|
||||
}
|
||||
|
||||
const buildParams = (page) => {
|
||||
const params = {
|
||||
page,
|
||||
pageSize: pagination.pageSize,
|
||||
sortOrder: filters.sortOrder
|
||||
}
|
||||
|
||||
if (filters.model) params.model = filters.model
|
||||
if (filters.accountId) params.accountId = filters.accountId
|
||||
if (filters.dateRange && filters.dateRange.length === 2) {
|
||||
params.startDate = dayjs(filters.dateRange[0]).toISOString()
|
||||
params.endDate = dayjs(filters.dateRange[1]).toISOString()
|
||||
}
|
||||
|
||||
return params
|
||||
}
|
||||
|
||||
const syncResponseState = (data) => {
|
||||
records.value = data.records || []
|
||||
|
||||
const pageInfo = data.pagination || {}
|
||||
pagination.currentPage = pageInfo.currentPage || 1
|
||||
pagination.pageSize = pageInfo.pageSize || pagination.pageSize
|
||||
pagination.totalRecords = pageInfo.totalRecords || 0
|
||||
|
||||
const filterEcho = data.filters || {}
|
||||
if (filterEcho.model !== undefined) filters.model = filterEcho.model || ''
|
||||
if (filterEcho.accountId !== undefined) filters.accountId = filterEcho.accountId || ''
|
||||
if (filterEcho.sortOrder) filters.sortOrder = filterEcho.sortOrder
|
||||
if (filterEcho.startDate && filterEcho.endDate) {
|
||||
const nextRange = [filterEcho.startDate, filterEcho.endDate]
|
||||
const currentRange = filters.dateRange || []
|
||||
if (currentRange[0] !== nextRange[0] || currentRange[1] !== nextRange[1]) {
|
||||
filters.dateRange = nextRange
|
||||
}
|
||||
}
|
||||
|
||||
const summaryData = data.summary || {}
|
||||
summary.totalRequests = summaryData.totalRequests || 0
|
||||
summary.totalTokens = summaryData.totalTokens || 0
|
||||
summary.totalCost = summaryData.totalCost || 0
|
||||
summary.avgCost = summaryData.avgCost || 0
|
||||
|
||||
apiKeyInfo.id = data.apiKeyInfo?.id || keyId.value
|
||||
apiKeyInfo.name = data.apiKeyInfo?.name || ''
|
||||
|
||||
availableModels.value = data.availableFilters?.models || []
|
||||
availableAccounts.value = data.availableFilters?.accounts || []
|
||||
}
|
||||
|
||||
const fetchRecords = async (page = pagination.currentPage) => {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await apiClient.get(`/admin/api-keys/${keyId.value}/usage-records`, {
|
||||
params: buildParams(page)
|
||||
})
|
||||
syncResponseState(response.data || {})
|
||||
} catch (error) {
|
||||
showToast(`加载请求记录失败:${error.message || '未知错误'}`, 'error')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handlePageChange = (page) => {
|
||||
pagination.currentPage = page
|
||||
fetchRecords(page)
|
||||
}
|
||||
|
||||
const handleSizeChange = (size) => {
|
||||
pagination.pageSize = size
|
||||
pagination.currentPage = 1
|
||||
fetchRecords(1)
|
||||
}
|
||||
|
||||
const resetFilters = () => {
|
||||
filters.model = ''
|
||||
filters.accountId = ''
|
||||
filters.dateRange = null
|
||||
filters.sortOrder = 'desc'
|
||||
pagination.currentPage = 1
|
||||
fetchRecords(1)
|
||||
}
|
||||
|
||||
const openDetail = (record) => {
|
||||
activeRecord.value = record
|
||||
detailVisible.value = true
|
||||
}
|
||||
|
||||
const closeDetail = () => {
|
||||
detailVisible.value = false
|
||||
activeRecord.value = null
|
||||
}
|
||||
|
||||
const goBack = () => {
|
||||
router.push('/api-keys')
|
||||
}
|
||||
|
||||
const exportCsv = async () => {
|
||||
if (exporting.value) return
|
||||
exporting.value = true
|
||||
try {
|
||||
const aggregated = []
|
||||
let page = 1
|
||||
let totalPages = 1
|
||||
const maxPages = 50 // 50 * 200 = 10000,超过后端 5000 上限已足够
|
||||
|
||||
while (page <= totalPages && page <= maxPages) {
|
||||
const response = await apiClient.get(`/admin/api-keys/${keyId.value}/usage-records`, {
|
||||
params: { ...buildParams(page), pageSize: 200 }
|
||||
})
|
||||
const payload = response.data || {}
|
||||
aggregated.push(...(payload.records || []))
|
||||
totalPages = payload.pagination?.totalPages || 1
|
||||
page += 1
|
||||
}
|
||||
|
||||
if (aggregated.length === 0) {
|
||||
showToast('没有可导出的记录', 'info')
|
||||
return
|
||||
}
|
||||
|
||||
const headers = [
|
||||
'时间',
|
||||
'账户',
|
||||
'渠道',
|
||||
'模型',
|
||||
'输入Token',
|
||||
'输出Token',
|
||||
'缓存创建Token',
|
||||
'缓存读取Token',
|
||||
'总Token',
|
||||
'费用'
|
||||
]
|
||||
|
||||
const csvRows = [headers.join(',')]
|
||||
aggregated.forEach((record) => {
|
||||
const row = [
|
||||
formatDate(record.timestamp),
|
||||
record.accountName || '',
|
||||
record.accountTypeName || '',
|
||||
record.model || '',
|
||||
record.inputTokens || 0,
|
||||
record.outputTokens || 0,
|
||||
record.cacheCreateTokens || 0,
|
||||
record.cacheReadTokens || 0,
|
||||
record.totalTokens || 0,
|
||||
record.costFormatted || formatCost(record.cost)
|
||||
]
|
||||
csvRows.push(row.map((cell) => `"${String(cell).replace(/"/g, '""')}"`).join(','))
|
||||
})
|
||||
|
||||
const blob = new Blob([csvRows.join('\n')], {
|
||||
type: 'text/csv;charset=utf-8;'
|
||||
})
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = `api-key-${keyId.value}-usage-records.csv`
|
||||
link.click()
|
||||
URL.revokeObjectURL(url)
|
||||
showToast('导出 CSV 成功', 'success')
|
||||
} catch (error) {
|
||||
showToast(`导出失败:${error.message || '未知错误'}`, 'error')
|
||||
} finally {
|
||||
exporting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [filters.model, filters.accountId, filters.sortOrder],
|
||||
() => {
|
||||
pagination.currentPage = 1
|
||||
fetchRecords(1)
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => filters.dateRange,
|
||||
() => {
|
||||
pagination.currentPage = 1
|
||||
fetchRecords(1)
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
fetchRecords()
|
||||
})
|
||||
</script>
|
||||
@@ -2085,17 +2085,18 @@
|
||||
@save="handleSaveExpiry"
|
||||
/>
|
||||
|
||||
<!-- 使用详情弹窗 -->
|
||||
<UsageDetailModal
|
||||
:api-key="selectedApiKeyForDetail || {}"
|
||||
:show="showUsageDetailModal"
|
||||
@close="showUsageDetailModal = false"
|
||||
@open-timeline="openTimeline"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { showToast } from '@/utils/toast'
|
||||
import { apiClient } from '@/config/api'
|
||||
import { useClientsStore } from '@/stores/clients'
|
||||
@@ -2114,6 +2115,7 @@ import CustomDropdown from '@/components/common/CustomDropdown.vue'
|
||||
import ActionDropdown from '@/components/common/ActionDropdown.vue'
|
||||
|
||||
// 响应式数据
|
||||
const router = useRouter()
|
||||
const clientsStore = useClientsStore()
|
||||
const authStore = useAuthStore()
|
||||
const apiKeys = ref([])
|
||||
@@ -4193,19 +4195,15 @@ const formatWindowTime = (seconds) => {
|
||||
|
||||
// 显示使用详情
|
||||
const showUsageDetails = (apiKey) => {
|
||||
// 获取异步加载的统计数据
|
||||
const cachedStats = getCachedStats(apiKey.id)
|
||||
|
||||
// 合并异步统计数据到 apiKey 对象
|
||||
const enrichedApiKey = {
|
||||
...apiKey,
|
||||
// 合并实时限制数据
|
||||
dailyCost: cachedStats?.dailyCost ?? apiKey.dailyCost ?? 0,
|
||||
currentWindowCost: cachedStats?.currentWindowCost ?? apiKey.currentWindowCost ?? 0,
|
||||
windowRemainingSeconds: cachedStats?.windowRemainingSeconds ?? apiKey.windowRemainingSeconds,
|
||||
windowStartTime: cachedStats?.windowStartTime ?? apiKey.windowStartTime ?? null,
|
||||
windowEndTime: cachedStats?.windowEndTime ?? apiKey.windowEndTime ?? null,
|
||||
// 合并 usage 数据(用于详情弹窗中的统计卡片)
|
||||
usage: {
|
||||
...apiKey.usage,
|
||||
total: {
|
||||
@@ -4226,6 +4224,13 @@ const showUsageDetails = (apiKey) => {
|
||||
showUsageDetailModal.value = true
|
||||
}
|
||||
|
||||
const openTimeline = (keyId) => {
|
||||
const id = keyId || selectedApiKeyForDetail.value?.id
|
||||
if (!id) return
|
||||
showUsageDetailModal.value = false
|
||||
router.push(`/api-keys/${id}/usage-records`)
|
||||
}
|
||||
|
||||
// 格式化时间(秒转换为可读格式) - 已移到 WindowLimitBar 组件中
|
||||
// const formatTime = (seconds) => {
|
||||
// if (seconds === null || seconds === undefined) return '--:--'
|
||||
@@ -4841,40 +4846,40 @@ onUnmounted(() => {
|
||||
z-index: 12;
|
||||
}
|
||||
|
||||
/* 确保操作列在浅色模式下有正确的背景 */
|
||||
/* 确保操作列在浅色模式下有正确的背景 - 使用纯色避免滚动时重叠 */
|
||||
.table-container thead .operations-column {
|
||||
z-index: 30;
|
||||
background: linear-gradient(to bottom, #f9fafb, rgba(243, 244, 246, 0.9));
|
||||
background: linear-gradient(to bottom, #f9fafb, #f3f4f6);
|
||||
}
|
||||
|
||||
.dark .table-container thead .operations-column {
|
||||
background: linear-gradient(to bottom, #374151, rgba(31, 41, 55, 0.9));
|
||||
background: linear-gradient(to bottom, #374151, #1f2937);
|
||||
}
|
||||
|
||||
/* tbody 中的操作列背景处理 */
|
||||
/* tbody 中的操作列背景处理 - 使用纯色避免滚动时重叠 */
|
||||
.table-container tbody tr:nth-child(odd) .operations-column {
|
||||
background-color: #ffffff;
|
||||
}
|
||||
|
||||
.table-container tbody tr:nth-child(even) .operations-column {
|
||||
background-color: rgba(249, 250, 251, 0.7);
|
||||
background-color: #f9fafb;
|
||||
}
|
||||
|
||||
.dark .table-container tbody tr:nth-child(odd) .operations-column {
|
||||
background-color: rgba(31, 41, 55, 0.4);
|
||||
background-color: #1f2937;
|
||||
}
|
||||
|
||||
.dark .table-container tbody tr:nth-child(even) .operations-column {
|
||||
background-color: rgba(55, 65, 81, 0.3);
|
||||
background-color: #374151;
|
||||
}
|
||||
|
||||
/* hover 状态下的操作列背景 */
|
||||
.table-container tbody tr:hover .operations-column {
|
||||
background-color: rgba(239, 246, 255, 0.6);
|
||||
background-color: #eff6ff;
|
||||
}
|
||||
|
||||
.dark .table-container tbody tr:hover .operations-column {
|
||||
background-color: rgba(30, 58, 138, 0.2);
|
||||
background-color: #1e3a5f;
|
||||
}
|
||||
|
||||
.table-container tbody .operations-column {
|
||||
@@ -4892,19 +4897,19 @@ onUnmounted(() => {
|
||||
z-index: 12;
|
||||
}
|
||||
|
||||
/* 表头左侧固定列背景 */
|
||||
/* 表头左侧固定列背景 - 使用纯色避免滚动时重叠 */
|
||||
.table-container thead .checkbox-column,
|
||||
.table-container thead .name-column {
|
||||
z-index: 30;
|
||||
background: linear-gradient(to bottom, #f9fafb, rgba(243, 244, 246, 0.9));
|
||||
background: linear-gradient(to bottom, #f9fafb, #f3f4f6);
|
||||
}
|
||||
|
||||
.dark .table-container thead .checkbox-column,
|
||||
.dark .table-container thead .name-column {
|
||||
background: linear-gradient(to bottom, #374151, rgba(31, 41, 55, 0.9));
|
||||
background: linear-gradient(to bottom, #374151, #1f2937);
|
||||
}
|
||||
|
||||
/* tbody 中的左侧固定列背景处理 */
|
||||
/* tbody 中的左侧固定列背景处理 - 使用纯色避免滚动时重叠 */
|
||||
.table-container tbody tr:nth-child(odd) .checkbox-column,
|
||||
.table-container tbody tr:nth-child(odd) .name-column {
|
||||
background-color: #ffffff;
|
||||
@@ -4912,28 +4917,28 @@ onUnmounted(() => {
|
||||
|
||||
.table-container tbody tr:nth-child(even) .checkbox-column,
|
||||
.table-container tbody tr:nth-child(even) .name-column {
|
||||
background-color: rgba(249, 250, 251, 0.7);
|
||||
background-color: #f9fafb;
|
||||
}
|
||||
|
||||
.dark .table-container tbody tr:nth-child(odd) .checkbox-column,
|
||||
.dark .table-container tbody tr:nth-child(odd) .name-column {
|
||||
background-color: rgba(31, 41, 55, 0.4);
|
||||
background-color: #1f2937;
|
||||
}
|
||||
|
||||
.dark .table-container tbody tr:nth-child(even) .checkbox-column,
|
||||
.dark .table-container tbody tr:nth-child(even) .name-column {
|
||||
background-color: rgba(55, 65, 81, 0.3);
|
||||
background-color: #374151;
|
||||
}
|
||||
|
||||
/* hover 状态下的左侧固定列背景 */
|
||||
.table-container tbody tr:hover .checkbox-column,
|
||||
.table-container tbody tr:hover .name-column {
|
||||
background-color: rgba(239, 246, 255, 0.6);
|
||||
background-color: #eff6ff;
|
||||
}
|
||||
|
||||
.dark .table-container tbody tr:hover .checkbox-column,
|
||||
.dark .table-container tbody tr:hover .name-column {
|
||||
background-color: rgba(30, 58, 138, 0.2);
|
||||
background-color: #1e3a5f;
|
||||
}
|
||||
|
||||
/* 名称列右侧阴影(分隔效果) */
|
||||
|
||||
@@ -1036,6 +1036,7 @@ function createUsageTrendChart() {
|
||||
type: 'linear',
|
||||
display: true,
|
||||
position: 'left',
|
||||
min: 0,
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Token数量',
|
||||
@@ -1055,6 +1056,7 @@ function createUsageTrendChart() {
|
||||
type: 'linear',
|
||||
display: true,
|
||||
position: 'right',
|
||||
min: 0,
|
||||
title: {
|
||||
display: true,
|
||||
text: '请求数',
|
||||
@@ -1073,7 +1075,8 @@ function createUsageTrendChart() {
|
||||
y2: {
|
||||
type: 'linear',
|
||||
display: false, // 隐藏费用轴,在tooltip中显示
|
||||
position: 'right'
|
||||
position: 'right',
|
||||
min: 0
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1253,6 +1256,7 @@ function createApiKeysUsageTrendChart() {
|
||||
},
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
min: 0,
|
||||
title: {
|
||||
display: true,
|
||||
text: apiKeysTrendMetric.value === 'tokens' ? 'Token 数量' : '请求次数',
|
||||
@@ -1428,6 +1432,7 @@ function createAccountUsageTrendChart() {
|
||||
},
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
min: 0,
|
||||
title: {
|
||||
display: true,
|
||||
text: '消耗金额 (USD)',
|
||||
|
||||
Reference in New Issue
Block a user