Compare commits

..

1 Commits

Author SHA1 Message Date
Wesley Liddick
af06fe0f35 Revert "feat: 新增AD域控用户认证系统" 2025-08-28 08:49:05 +08:00
94 changed files with 3907 additions and 21702 deletions

View File

@@ -22,30 +22,17 @@ REDIS_PASSWORD=
REDIS_DB=0 REDIS_DB=0
REDIS_ENABLE_TLS= REDIS_ENABLE_TLS=
# 🔗 会话管理配置
# 粘性会话TTL配置小时默认1小时
STICKY_SESSION_TTL_HOURS=1
# 续期阈值分钟默认0分钟不续期
STICKY_SESSION_RENEWAL_THRESHOLD_MINUTES=0
# 🎯 Claude API 配置 # 🎯 Claude API 配置
CLAUDE_API_URL=https://api.anthropic.com/v1/messages CLAUDE_API_URL=https://api.anthropic.com/v1/messages
CLAUDE_API_VERSION=2023-06-01 CLAUDE_API_VERSION=2023-06-01
CLAUDE_BETA_HEADER=claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14 CLAUDE_BETA_HEADER=claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14
# 🚫 529错误处理配置
# 启用529错误处理0表示禁用>0表示过载状态持续时间分钟
CLAUDE_OVERLOAD_HANDLING_MINUTES=0
# 🌐 代理配置 # 🌐 代理配置
DEFAULT_PROXY_TIMEOUT=600000 DEFAULT_PROXY_TIMEOUT=60000
MAX_PROXY_RETRIES=3 MAX_PROXY_RETRIES=3
# IP协议族配置true=IPv4, false=IPv6, 默认IPv4兼容性更好 # IP协议族配置true=IPv4, false=IPv6, 默认IPv4兼容性更好
PROXY_USE_IPV4=true PROXY_USE_IPV4=true
# ⏱️ 请求超时配置
REQUEST_TIMEOUT=600000 # 请求超时设置毫秒默认10分钟
# 📈 使用限制 # 📈 使用限制
DEFAULT_TOKEN_LIMIT=1000000 DEFAULT_TOKEN_LIMIT=1000000
@@ -68,46 +55,14 @@ WEB_LOGO_URL=/assets/logo.png
# 🛠️ 开发配置 # 🛠️ 开发配置
DEBUG=false DEBUG=false
DEBUG_HTTP_TRAFFIC=false # 启用HTTP请求/响应调试日志(仅开发环境)
ENABLE_CORS=true ENABLE_CORS=true
TRUST_PROXY=true TRUST_PROXY=true
# 🔒 客户端限制(可选) # 🔒 客户端限制(可选)
# ALLOW_CUSTOM_CLIENTS=false # ALLOW_CUSTOM_CLIENTS=false
# 🔐 LDAP 认证配置 # 📢 Webhook 通知配置
LDAP_ENABLED=false WEBHOOK_ENABLED=true
LDAP_URL=ldaps://ldap-1.test1.bj.yxops.net:636 WEBHOOK_URLS=https://your-webhook-url.com/notify,https://backup-webhook.com/notify
LDAP_BIND_DN=cn=admin,dc=example,dc=com WEBHOOK_TIMEOUT=10000
LDAP_BIND_PASSWORD=admin_password WEBHOOK_RETRIES=3
LDAP_SEARCH_BASE=dc=example,dc=com
LDAP_SEARCH_FILTER=(uid={{username}})
LDAP_SEARCH_ATTRIBUTES=dn,uid,cn,mail,givenName,sn
LDAP_TIMEOUT=5000
LDAP_CONNECT_TIMEOUT=10000
# 🔒 LDAP TLS/SSL 配置 (用于 ldaps:// URL)
# 是否忽略证书验证错误 (设置为false可忽略自签名证书错误)
LDAP_TLS_REJECT_UNAUTHORIZED=true
# CA 证书文件路径 (可选用于自定义CA证书)
# LDAP_TLS_CA_FILE=/path/to/ca-cert.pem
# 客户端证书文件路径 (可选,用于双向认证)
# LDAP_TLS_CERT_FILE=/path/to/client-cert.pem
# 客户端私钥文件路径 (可选,用于双向认证)
# LDAP_TLS_KEY_FILE=/path/to/client-key.pem
# 服务器名称 (可选,用于 SNI)
# LDAP_TLS_SERVERNAME=ldap.example.com
# 🗺️ LDAP 用户属性映射
LDAP_USER_ATTR_USERNAME=uid
LDAP_USER_ATTR_DISPLAY_NAME=cn
LDAP_USER_ATTR_EMAIL=mail
LDAP_USER_ATTR_FIRST_NAME=givenName
LDAP_USER_ATTR_LAST_NAME=sn
# 👥 用户管理配置
USER_MANAGEMENT_ENABLED=false
DEFAULT_USER_ROLE=user
USER_SESSION_TIMEOUT=86400000
MAX_API_KEYS_PER_USER=1
ALLOW_USER_DELETE_API_KEYS=false

4
.gitignore vendored
View File

@@ -216,10 +216,6 @@ local/
debug.log debug.log
error.log error.log
access.log access.log
http-debug*.log
logs/http-debug-*.log
src/middleware/debugInterceptor.js
# Session files # Session files
sessions/ sessions/

160
README.md
View File

@@ -250,6 +250,11 @@ REDIS_HOST=localhost
REDIS_PORT=6379 REDIS_PORT=6379
REDIS_PASSWORD= REDIS_PASSWORD=
# Webhook通知配置可选
WEBHOOK_ENABLED=true
WEBHOOK_URLS=https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=your-key
WEBHOOK_TIMEOUT=10000
WEBHOOK_RETRIES=3
``` ```
**编辑 `config/config.js` 文件:** **编辑 `config/config.js` 文件:**
@@ -474,102 +479,103 @@ claude
gemini # 或其他 Gemini CLI 命令 gemini # 或其他 Gemini CLI 命令
``` ```
**Codex 配置** **Codex 设置环境变量**
在 `~/.codex/config.toml` 文件中添加以下配置: ```bash
export OPENAI_BASE_URL="http://127.0.0.1:3000/openai" # 根据实际填写你服务器的ip地址或者域名
```toml export OPENAI_API_KEY="后台创建的API密钥" # 使用后台创建的API密钥
model_provider = "crs"
model = "gpt-5"
model_reasoning_effort = "high"
disable_response_storage = true
preferred_auth_method = "apikey"
[model_providers.crs]
name = "crs"
base_url = "http://127.0.0.1:3000/openai" # 根据实际填写你服务器的ip地址或者域名
wire_api = "responses"
```
在 `~/.codex/auth.json` 文件中配置API密钥
```json
{
"OPENAI_API_KEY": "你的后台创建的API密钥"
}
``` ```
### 5. 第三方工具API接入 ### 5. 第三方工具API接入
本服务支持多种API端点格式方便接入不同的第三方工具如Cherry Studio等 本服务支持多种API端点格式方便接入不同的第三方工具如Cherry Studio等
#### Cherry Studio 接入示例 **Claude标准格式**
Cherry Studio支持多种AI服务的接入下面是不同账号类型的详细配置
**1. Claude账号接入**
``` ```
# API地址 # 如果工具支持Claude标准格式请使用该接口
http://你的服务器:3000/claude/ http://你的服务器:3000/claude/
# 模型ID示例
claude-sonnet-4-20250514 # Claude Sonnet 4
claude-opus-4-20250514 # Claude Opus 4
``` ```
配置步骤: **OpenAI兼容格式**
- 供应商类型选择"Anthropic"
- API地址填入`http://你的服务器:3000/claude/`
- API Key填入后台创建的API密钥cr_开头
**2. Gemini账号接入**
``` ```
# API地址 # 适用于需要OpenAI格式的第三方工具
http://你的服务器:3000/gemini/ http://你的服务器:3000/openai/claude/v1/
# 模型ID示例
gemini-2.5-pro # Gemini 2.5 Pro
``` ```
配置步骤: **接入示例:**
- 供应商类型选择"Gemini"
- API地址填入`http://你的服务器:3000/gemini/`
- API Key填入后台创建的API密钥cr_开头
**3. Codex接入** - **Cherry Studio**: 使用OpenAI格式 `http://你的服务器:3000/openai/claude/v1/` 使用Codex cli API `http://你的服务器:3000/openai/responses`
- **其他支持自定义API的工具**: 根据工具要求选择合适的格式
```
# API地址
http://你的服务器:3000/openai/
# 模型ID固定
gpt-5 # Codex使用固定模型ID
```
配置步骤:
- 供应商类型选择"Openai-Response"
- API地址填入`http://你的服务器:3000/openai/`
- API Key填入后台创建的API密钥cr_开头
- **重要**Codex只支持Openai-Response标准
#### 其他第三方工具接入
**接入要点:**
- 所有账号类型都使用相同的API密钥在后台统一创建
- 根据不同的路由前缀自动识别账号类型
- `/claude/` - 使用Claude账号池
- `/gemini/` - 使用Gemini账号池
- `/openai/` - 使用Codex账号只支持Openai-Response格式
- 支持所有标准API端点messages、models等
**重要说明:** **重要说明:**
- 确保在后台已添加对应类型的账号Claude/Gemini/Codex - 所有格式都支持相同的功能,仅是路径不同
- API密钥可以通用系统会根据路由自动选择账号类型 - `/api/v1/messages` = `/claude/v1/messages` = `/openai/claude/v1/messages`
- 建议为不同用户创建不同的API密钥便于使用统计 - 选择适合你使用工具的格式即可
- 支持所有Claude API端点messages、models等
---
## 📢 Webhook 通知功能
### 功能说明
当系统检测到账号异常时,会自动发送 webhook 通知支持企业微信、钉钉、Slack 等平台。
### 通知触发场景
- **Claude OAuth 账户**: token 过期或未授权时
- **Claude Console 账户**: 系统检测到账户被封锁时
- **Gemini 账户**: token 刷新失败时
- **手动禁用账户**: 管理员手动禁用账户时
### 配置方法
**1. 环境变量配置**
```bash
# 启用 webhook 通知
WEBHOOK_ENABLED=true
# 企业微信 webhook 地址(替换为你的实际地址)
WEBHOOK_URLS=https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=your-key
# 多个地址用逗号分隔
WEBHOOK_URLS=https://webhook1.com,https://webhook2.com
# 请求超时时间毫秒默认10秒
WEBHOOK_TIMEOUT=10000
# 重试次数默认3次
WEBHOOK_RETRIES=3
```
**2. 企业微信设置**
1. 在企业微信群中添加「群机器人」
2. 获取 webhook 地址:`https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx`
3. 将地址配置到 `WEBHOOK_URLS` 环境变量
### 通知内容格式
系统会发送结构化的通知消息:
```
账户名称 账号异常,异常代码 ERROR_CODE
平台claude-oauth
时间2025-08-14 17:30:00
原因Token expired
```
### 测试 Webhook
可以通过管理后台测试 webhook 连通性:
1. 登录管理后台:`http://你的服务器:3000/web`
2. 访问:`/admin/webhook/test`
3. 发送测试通知确认配置正确
--- ---

View File

@@ -1 +1 @@
1.1.134 1.1.120

View File

@@ -32,28 +32,13 @@ const config = {
enableTLS: process.env.REDIS_ENABLE_TLS === 'true' enableTLS: process.env.REDIS_ENABLE_TLS === 'true'
}, },
// 🔗 会话管理配置
session: {
// 粘性会话TTL配置小时默认1小时
stickyTtlHours: parseFloat(process.env.STICKY_SESSION_TTL_HOURS) || 1,
// 续期阈值分钟默认0分钟不续期
renewalThresholdMinutes: parseInt(process.env.STICKY_SESSION_RENEWAL_THRESHOLD_MINUTES) || 0
},
// 🎯 Claude API配置 // 🎯 Claude API配置
claude: { claude: {
apiUrl: process.env.CLAUDE_API_URL || 'https://api.anthropic.com/v1/messages', apiUrl: process.env.CLAUDE_API_URL || 'https://api.anthropic.com/v1/messages',
apiVersion: process.env.CLAUDE_API_VERSION || '2023-06-01', apiVersion: process.env.CLAUDE_API_VERSION || '2023-06-01',
betaHeader: betaHeader:
process.env.CLAUDE_BETA_HEADER || process.env.CLAUDE_BETA_HEADER ||
'claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14', 'claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14'
overloadHandling: {
enabled: (() => {
const minutes = parseInt(process.env.CLAUDE_OVERLOAD_HANDLING_MINUTES) || 0
// 验证配置值限制在0-1440分钟(24小时)内
return Math.max(0, Math.min(minutes, 1440))
})()
}
}, },
// ☁️ Bedrock API配置 // ☁️ Bedrock API配置
@@ -71,15 +56,12 @@ const config = {
// 🌐 代理配置 // 🌐 代理配置
proxy: { proxy: {
timeout: parseInt(process.env.DEFAULT_PROXY_TIMEOUT) || 600000, // 10分钟 timeout: parseInt(process.env.DEFAULT_PROXY_TIMEOUT) || 30000,
maxRetries: parseInt(process.env.MAX_PROXY_RETRIES) || 3, maxRetries: parseInt(process.env.MAX_PROXY_RETRIES) || 3,
// IP协议族配置true=IPv4, false=IPv6, 默认IPv4兼容性更好 // IP协议族配置true=IPv4, false=IPv6, 默认IPv4兼容性更好
useIPv4: process.env.PROXY_USE_IPV4 !== 'false' // 默认 true只有明确设置为 'false' 才使用 IPv6 useIPv4: process.env.PROXY_USE_IPV4 !== 'false' // 默认 true只有明确设置为 'false' 才使用 IPv6
}, },
// ⏱️ 请求超时配置
requestTimeout: parseInt(process.env.REQUEST_TIMEOUT) || 600000, // 默认 10 分钟
// 📈 使用限制 // 📈 使用限制
limits: { limits: {
defaultTokenLimit: parseInt(process.env.DEFAULT_TOKEN_LIMIT) || 1000000 defaultTokenLimit: parseInt(process.env.DEFAULT_TOKEN_LIMIT) || 1000000
@@ -145,58 +127,6 @@ const config = {
allowCustomClients: process.env.ALLOW_CUSTOM_CLIENTS === 'true' allowCustomClients: process.env.ALLOW_CUSTOM_CLIENTS === 'true'
}, },
// 🔐 LDAP 认证配置
ldap: {
enabled: process.env.LDAP_ENABLED === 'true',
server: {
url: process.env.LDAP_URL || 'ldap://localhost:389',
bindDN: process.env.LDAP_BIND_DN || 'cn=admin,dc=example,dc=com',
bindCredentials: process.env.LDAP_BIND_PASSWORD || 'admin',
searchBase: process.env.LDAP_SEARCH_BASE || 'dc=example,dc=com',
searchFilter: process.env.LDAP_SEARCH_FILTER || '(uid={{username}})',
searchAttributes: process.env.LDAP_SEARCH_ATTRIBUTES
? process.env.LDAP_SEARCH_ATTRIBUTES.split(',')
: ['dn', 'uid', 'cn', 'mail', 'givenName', 'sn'],
timeout: parseInt(process.env.LDAP_TIMEOUT) || 5000,
connectTimeout: parseInt(process.env.LDAP_CONNECT_TIMEOUT) || 10000,
// TLS/SSL 配置
tls: {
// 是否忽略证书错误 (用于自签名证书)
rejectUnauthorized: process.env.LDAP_TLS_REJECT_UNAUTHORIZED !== 'false', // 默认验证证书设置为false则忽略
// CA证书文件路径 (可选用于自定义CA证书)
ca: process.env.LDAP_TLS_CA_FILE
? require('fs').readFileSync(process.env.LDAP_TLS_CA_FILE)
: undefined,
// 客户端证书文件路径 (可选,用于双向认证)
cert: process.env.LDAP_TLS_CERT_FILE
? require('fs').readFileSync(process.env.LDAP_TLS_CERT_FILE)
: undefined,
// 客户端私钥文件路径 (可选,用于双向认证)
key: process.env.LDAP_TLS_KEY_FILE
? require('fs').readFileSync(process.env.LDAP_TLS_KEY_FILE)
: undefined,
// 服务器名称 (用于SNI可选)
servername: process.env.LDAP_TLS_SERVERNAME || undefined
}
},
userMapping: {
username: process.env.LDAP_USER_ATTR_USERNAME || 'uid',
displayName: process.env.LDAP_USER_ATTR_DISPLAY_NAME || 'cn',
email: process.env.LDAP_USER_ATTR_EMAIL || 'mail',
firstName: process.env.LDAP_USER_ATTR_FIRST_NAME || 'givenName',
lastName: process.env.LDAP_USER_ATTR_LAST_NAME || 'sn'
}
},
// 👥 用户管理配置
userManagement: {
enabled: process.env.USER_MANAGEMENT_ENABLED === 'true',
defaultUserRole: process.env.DEFAULT_USER_ROLE || 'user',
userSessionTimeout: parseInt(process.env.USER_SESSION_TIMEOUT) || 86400000, // 24小时
maxApiKeysPerUser: parseInt(process.env.MAX_API_KEYS_PER_USER) || 1,
allowUserDeleteApiKeys: process.env.ALLOW_USER_DELETE_API_KEYS === 'true' // 默认不允许用户删除自己的API Keys
},
// 📢 Webhook通知配置 // 📢 Webhook通知配置
webhook: { webhook: {
enabled: process.env.WEBHOOK_ENABLED !== 'false', // 默认启用 enabled: process.env.WEBHOOK_ENABLED !== 'false', // 默认启用

1923
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -63,9 +63,7 @@
"https-proxy-agent": "^7.0.2", "https-proxy-agent": "^7.0.2",
"inquirer": "^8.2.6", "inquirer": "^8.2.6",
"ioredis": "^5.3.2", "ioredis": "^5.3.2",
"ldapjs": "^3.0.7",
"morgan": "^1.10.0", "morgan": "^1.10.0",
"nodemailer": "^7.0.6",
"ora": "^5.4.1", "ora": "^5.4.1",
"rate-limiter-flexible": "^5.0.5", "rate-limiter-flexible": "^5.0.5",
"socks-proxy-agent": "^8.0.2", "socks-proxy-agent": "^8.0.2",

View File

@@ -86,33 +86,6 @@ function decryptGeminiData(encryptedData) {
} }
} }
// API Key 哈希函数与apiKeyService保持一致
function hashApiKey(apiKey) {
if (!apiKey || !config.security.encryptionKey) {
return apiKey
}
return crypto
.createHash('sha256')
.update(apiKey + config.security.encryptionKey)
.digest('hex')
}
// 检查是否为明文API Key通过格式判断不依赖前缀
function isPlaintextApiKey(apiKey) {
if (!apiKey || typeof apiKey !== 'string') {
return false
}
// SHA256哈希值固定为64个十六进制字符如果是哈希值则返回false
if (apiKey.length === 64 && /^[a-f0-9]+$/i.test(apiKey)) {
return false // 已经是哈希值
}
// 其他情况都认为是明文API Key包括sk-ant-、cr_、自定义前缀等
return true
}
// 数据加密函数(用于导入) // 数据加密函数(用于导入)
function encryptClaudeData(data) { function encryptClaudeData(data) {
if (!data || !config.security.encryptionKey) { if (!data || !config.security.encryptionKey) {
@@ -678,13 +651,6 @@ Important Notes:
- If importing decrypted data, it will be re-encrypted automatically - If importing decrypted data, it will be re-encrypted automatically
- If importing encrypted data, it will be stored as-is - If importing encrypted data, it will be stored as-is
- Sanitized exports cannot be properly imported (missing sensitive data) - Sanitized exports cannot be properly imported (missing sensitive data)
- Automatic handling of plaintext API Keys
* Uses your configured API_KEY_PREFIX from config (sk-, cr_, etc.)
* Automatically detects plaintext vs hashed API Keys by format
* Plaintext API Keys are automatically hashed during import
* Hash mappings are created correctly for plaintext keys
* Supports custom prefixes and legacy format detection
* No manual conversion needed - just import your backup file
Examples: Examples:
# Export all data with decryption (for migration) # Export all data with decryption (for migration)
@@ -693,7 +659,7 @@ Examples:
# Export without decrypting (for backup) # Export without decrypting (for backup)
node scripts/data-transfer-enhanced.js export --decrypt=false node scripts/data-transfer-enhanced.js export --decrypt=false
# Import data (auto-handles encryption and plaintext API keys) # Import data (auto-handles encryption)
node scripts/data-transfer-enhanced.js import --input=backup.json node scripts/data-transfer-enhanced.js import --input=backup.json
# Import with force overwrite # Import with force overwrite
@@ -807,26 +773,6 @@ async function importData() {
const apiKeyData = { ...apiKey } const apiKeyData = { ...apiKey }
delete apiKeyData.usageStats delete apiKeyData.usageStats
// 检查并处理API Key哈希
let plainTextApiKey = null
let hashedApiKey = null
if (apiKeyData.apiKey && isPlaintextApiKey(apiKeyData.apiKey)) {
// 如果是明文API Key保存明文并计算哈希
plainTextApiKey = apiKeyData.apiKey
hashedApiKey = hashApiKey(plainTextApiKey)
logger.info(`🔐 Detected plaintext API Key for: ${apiKey.name} (${apiKey.id})`)
} else if (apiKeyData.apiKey) {
// 如果已经是哈希值,直接使用
hashedApiKey = apiKeyData.apiKey
logger.info(`🔍 Using existing hashed API Key for: ${apiKey.name} (${apiKey.id})`)
}
// API Key字段始终存储哈希值
if (hashedApiKey) {
apiKeyData.apiKey = hashedApiKey
}
// 使用 hset 存储到哈希表 // 使用 hset 存储到哈希表
const pipeline = redis.client.pipeline() const pipeline = redis.client.pipeline()
for (const [field, value] of Object.entries(apiKeyData)) { for (const [field, value] of Object.entries(apiKeyData)) {
@@ -834,12 +780,9 @@ async function importData() {
} }
await pipeline.exec() await pipeline.exec()
// 更新哈希映射hash_map的key必须是哈希值 // 更新哈希映射
if (!importDataObj.metadata.sanitized && hashedApiKey) { if (apiKey.apiKey && !importDataObj.metadata.sanitized) {
await redis.client.hset('apikey:hash_map', hashedApiKey, apiKey.id) await redis.client.hset('apikey:hash_map', apiKey.apiKey, apiKey.id)
logger.info(
`📝 Updated hash mapping: ${hashedApiKey.substring(0, 8)}... -> ${apiKey.id}`
)
} }
// 导入使用统计数据 // 导入使用统计数据

View File

@@ -185,7 +185,7 @@ class ServiceManager {
restart(daemon = false) { restart(daemon = false) {
console.log('🔄 重启服务...') console.log('🔄 重启服务...')
this.stop()
// 等待停止完成 // 等待停止完成
setTimeout(() => { setTimeout(() => {
this.start(daemon) this.start(daemon)

View File

@@ -937,61 +937,15 @@ stop_service() {
# 强制停止所有相关进程 # 强制停止所有相关进程
pkill -f "node.*src/app.js" 2>/dev/null || true pkill -f "node.*src/app.js" 2>/dev/null || true
# 等待进程完全退出最多等待10秒
local wait_count=0
while pgrep -f "node.*src/app.js" > /dev/null; do
if [ $wait_count -ge 10 ]; then
print_warning "进程停止超时,尝试强制终止..."
pkill -9 -f "node.*src/app.js" 2>/dev/null || true
sleep 1
break
fi
sleep 1
wait_count=$((wait_count + 1))
done
# 最终确认进程已停止
if pgrep -f "node.*src/app.js" > /dev/null; then
print_error "无法完全停止服务进程"
return 1
fi
print_success "服务已停止" print_success "服务已停止"
} }
# 重启服务 # 重启服务
restart_service() { restart_service() {
print_info "重启服务..." print_info "重启服务..."
stop_service
# 停止服务并检查结果 sleep 2
if ! stop_service; then start_service
print_error "停止服务失败"
return 1
fi
# 短暂等待,确保端口释放
sleep 1
# 启动服务,如果失败则重试
local retry_count=0
while [ $retry_count -lt 3 ]; do
# 清除可能的僵尸进程检测
if ! pgrep -f "node.*src/app.js" > /dev/null; then
# 进程确实已停止,可以启动
if start_service; then
return 0
fi
fi
retry_count=$((retry_count + 1))
if [ $retry_count -lt 3 ]; then
print_warning "启动失败等待2秒后重试$retry_count 次)..."
sleep 2
fi
done
print_error "重启服务失败"
return 1
} }
# 更新模型价格 # 更新模型价格

379
scripts/test-multi-group.js Normal file
View File

@@ -0,0 +1,379 @@
/**
* 多分组功能测试脚本
* 测试一个账户可以属于多个分组的功能
*/
require('dotenv').config()
const redis = require('../src/models/redis')
const accountGroupService = require('../src/services/accountGroupService')
const claudeAccountService = require('../src/services/claudeAccountService')
// 测试配置
const TEST_PREFIX = 'multi_group_test_'
const CLEANUP_ON_FINISH = true
// 测试数据存储
const testData = {
groups: [],
accounts: []
}
// 颜色输出
const colors = {
green: '\x1b[32m',
red: '\x1b[31m',
yellow: '\x1b[33m',
blue: '\x1b[34m',
reset: '\x1b[0m'
}
function log(message, type = 'info') {
const color =
{
success: colors.green,
error: colors.red,
warning: colors.yellow,
info: colors.blue
}[type] || colors.reset
console.log(`${color}${message}${colors.reset}`)
}
async function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms))
}
// 清理测试数据
async function cleanup() {
log('\n🧹 清理测试数据...', 'info')
// 删除测试账户
for (const account of testData.accounts) {
try {
await claudeAccountService.deleteAccount(account.id)
log(`✅ 删除测试账户: ${account.name}`, 'success')
} catch (error) {
log(`❌ 删除账户失败: ${error.message}`, 'error')
}
}
// 删除测试分组
for (const group of testData.groups) {
try {
// 先移除所有成员
const members = await accountGroupService.getGroupMembers(group.id)
for (const memberId of members) {
await accountGroupService.removeAccountFromGroup(memberId, group.id)
}
await accountGroupService.deleteGroup(group.id)
log(`✅ 删除测试分组: ${group.name}`, 'success')
} catch (error) {
log(`❌ 删除分组失败: ${error.message}`, 'error')
}
}
}
// 测试1: 创建测试数据
async function test1_createTestData() {
log('\n📝 测试1: 创建测试数据', 'info')
try {
// 创建3个测试分组
const group1 = await accountGroupService.createGroup({
name: `${TEST_PREFIX}高优先级组`,
platform: 'claude',
description: '高优先级账户分组'
})
testData.groups.push(group1)
log(`✅ 创建分组1: ${group1.name}`, 'success')
const group2 = await accountGroupService.createGroup({
name: `${TEST_PREFIX}备用组`,
platform: 'claude',
description: '备用账户分组'
})
testData.groups.push(group2)
log(`✅ 创建分组2: ${group2.name}`, 'success')
const group3 = await accountGroupService.createGroup({
name: `${TEST_PREFIX}专用组`,
platform: 'claude',
description: '专用账户分组'
})
testData.groups.push(group3)
log(`✅ 创建分组3: ${group3.name}`, 'success')
// 创建测试账户
const account1 = await claudeAccountService.createAccount({
name: `${TEST_PREFIX}测试账户1`,
email: 'test1@example.com',
refreshToken: 'test_refresh_token_1',
accountType: 'group'
})
testData.accounts.push(account1)
log(`✅ 创建测试账户1: ${account1.name}`, 'success')
const account2 = await claudeAccountService.createAccount({
name: `${TEST_PREFIX}测试账户2`,
email: 'test2@example.com',
refreshToken: 'test_refresh_token_2',
accountType: 'group'
})
testData.accounts.push(account2)
log(`✅ 创建测试账户2: ${account2.name}`, 'success')
log(`✅ 测试数据创建完成: 3个分组, 2个账户`, 'success')
} catch (error) {
log(`❌ 测试1失败: ${error.message}`, 'error')
throw error
}
}
// 测试2: 账户加入多个分组
async function test2_addAccountToMultipleGroups() {
log('\n📝 测试2: 账户加入多个分组', 'info')
try {
const [group1, group2, group3] = testData.groups
const [account1, account2] = testData.accounts
// 账户1加入分组1和分组2
await accountGroupService.addAccountToGroup(account1.id, group1.id, 'claude')
log(`✅ 账户1加入分组1: ${group1.name}`, 'success')
await accountGroupService.addAccountToGroup(account1.id, group2.id, 'claude')
log(`✅ 账户1加入分组2: ${group2.name}`, 'success')
// 账户2加入分组2和分组3
await accountGroupService.addAccountToGroup(account2.id, group2.id, 'claude')
log(`✅ 账户2加入分组2: ${group2.name}`, 'success')
await accountGroupService.addAccountToGroup(account2.id, group3.id, 'claude')
log(`✅ 账户2加入分组3: ${group3.name}`, 'success')
log(`✅ 多分组关系建立完成`, 'success')
} catch (error) {
log(`❌ 测试2失败: ${error.message}`, 'error')
throw error
}
}
// 测试3: 验证多分组关系
async function test3_verifyMultiGroupRelationships() {
log('\n📝 测试3: 验证多分组关系', 'info')
try {
const [group1, group2, group3] = testData.groups
const [account1, account2] = testData.accounts
// 验证账户1的分组关系
const account1Groups = await accountGroupService.getAccountGroup(account1.id)
log(`📊 账户1所属分组数量: ${account1Groups.length}`, 'info')
const account1GroupNames = account1Groups.map((g) => g.name).sort()
const expectedAccount1Groups = [group1.name, group2.name].sort()
if (JSON.stringify(account1GroupNames) === JSON.stringify(expectedAccount1Groups)) {
log(`✅ 账户1分组关系正确: [${account1GroupNames.join(', ')}]`, 'success')
} else {
throw new Error(
`账户1分组关系错误期望: [${expectedAccount1Groups.join(', ')}], 实际: [${account1GroupNames.join(', ')}]`
)
}
// 验证账户2的分组关系
const account2Groups = await accountGroupService.getAccountGroup(account2.id)
log(`📊 账户2所属分组数量: ${account2Groups.length}`, 'info')
const account2GroupNames = account2Groups.map((g) => g.name).sort()
const expectedAccount2Groups = [group2.name, group3.name].sort()
if (JSON.stringify(account2GroupNames) === JSON.stringify(expectedAccount2Groups)) {
log(`✅ 账户2分组关系正确: [${account2GroupNames.join(', ')}]`, 'success')
} else {
throw new Error(
`账户2分组关系错误期望: [${expectedAccount2Groups.join(', ')}], 实际: [${account2GroupNames.join(', ')}]`
)
}
log(`✅ 多分组关系验证通过`, 'success')
} catch (error) {
log(`❌ 测试3失败: ${error.message}`, 'error')
throw error
}
}
// 测试4: 验证分组成员关系
async function test4_verifyGroupMemberships() {
log('\n📝 测试4: 验证分组成员关系', 'info')
try {
const [group1, group2, group3] = testData.groups
const [account1, account2] = testData.accounts
// 验证分组1的成员
const group1Members = await accountGroupService.getGroupMembers(group1.id)
if (group1Members.includes(account1.id) && group1Members.length === 1) {
log(`✅ 分组1成员正确: [${account1.name}]`, 'success')
} else {
throw new Error(`分组1成员错误期望: [${account1.id}], 实际: [${group1Members.join(', ')}]`)
}
// 验证分组2的成员应该包含两个账户
const group2Members = await accountGroupService.getGroupMembers(group2.id)
const expectedGroup2Members = [account1.id, account2.id].sort()
const actualGroup2Members = group2Members.sort()
if (JSON.stringify(actualGroup2Members) === JSON.stringify(expectedGroup2Members)) {
log(`✅ 分组2成员正确: [${account1.name}, ${account2.name}]`, 'success')
} else {
throw new Error(
`分组2成员错误期望: [${expectedGroup2Members.join(', ')}], 实际: [${actualGroup2Members.join(', ')}]`
)
}
// 验证分组3的成员
const group3Members = await accountGroupService.getGroupMembers(group3.id)
if (group3Members.includes(account2.id) && group3Members.length === 1) {
log(`✅ 分组3成员正确: [${account2.name}]`, 'success')
} else {
throw new Error(`分组3成员错误期望: [${account2.id}], 实际: [${group3Members.join(', ')}]`)
}
log(`✅ 分组成员关系验证通过`, 'success')
} catch (error) {
log(`❌ 测试4失败: ${error.message}`, 'error')
throw error
}
}
// 测试5: 从部分分组中移除账户
async function test5_removeFromPartialGroups() {
log('\n📝 测试5: 从部分分组中移除账户', 'info')
try {
const [group1, group2] = testData.groups
const [account1] = testData.accounts
// 将账户1从分组1中移除但仍在分组2中
await accountGroupService.removeAccountFromGroup(account1.id, group1.id)
log(`✅ 从分组1中移除账户1`, 'success')
// 验证账户1现在只属于分组2
const account1Groups = await accountGroupService.getAccountGroup(account1.id)
if (account1Groups.length === 1 && account1Groups[0].id === group2.id) {
log(`✅ 账户1现在只属于分组2: ${account1Groups[0].name}`, 'success')
} else {
const groupNames = account1Groups.map((g) => g.name)
throw new Error(`账户1分组状态错误期望只在分组2中实际: [${groupNames.join(', ')}]`)
}
// 验证分组1现在为空
const group1Members = await accountGroupService.getGroupMembers(group1.id)
if (group1Members.length === 0) {
log(`✅ 分组1现在为空`, 'success')
} else {
throw new Error(`分组1应该为空但还有成员: [${group1Members.join(', ')}]`)
}
// 验证分组2仍有两个成员
const group2Members = await accountGroupService.getGroupMembers(group2.id)
if (group2Members.length === 2) {
log(`✅ 分组2仍有两个成员`, 'success')
} else {
throw new Error(`分组2应该有2个成员实际: ${group2Members.length}`)
}
log(`✅ 部分移除测试通过`, 'success')
} catch (error) {
log(`❌ 测试5失败: ${error.message}`, 'error')
throw error
}
}
// 测试6: 账户完全移除时的分组清理
async function test6_accountDeletionGroupCleanup() {
log('\n📝 测试6: 账户删除时的分组清理', 'info')
try {
const [, group2, group3] = testData.groups // 跳过第一个元素
const [account1, account2] = testData.accounts
// 记录删除前的状态
const beforeGroup2Members = await accountGroupService.getGroupMembers(group2.id)
const beforeGroup3Members = await accountGroupService.getGroupMembers(group3.id)
log(`📊 删除前分组2成员数: ${beforeGroup2Members.length}`, 'info')
log(`📊 删除前分组3成员数: ${beforeGroup3Members.length}`, 'info')
// 删除账户2这应该会触发从所有分组中移除的逻辑
await claudeAccountService.deleteAccount(account2.id)
log(`✅ 删除账户2: ${account2.name}`, 'success')
// 从测试数据中移除避免cleanup时重复删除
testData.accounts = testData.accounts.filter((acc) => acc.id !== account2.id)
// 等待一下确保删除操作完成
await sleep(500)
// 验证分组2现在只有账户1
const afterGroup2Members = await accountGroupService.getGroupMembers(group2.id)
if (afterGroup2Members.length === 1 && afterGroup2Members[0] === account1.id) {
log(`✅ 分组2现在只有账户1`, 'success')
} else {
throw new Error(`分组2成员状态错误期望只有账户1实际: [${afterGroup2Members.join(', ')}]`)
}
// 验证分组3现在为空
const afterGroup3Members = await accountGroupService.getGroupMembers(group3.id)
if (afterGroup3Members.length === 0) {
log(`✅ 分组3现在为空`, 'success')
} else {
throw new Error(`分组3应该为空但还有成员: [${afterGroup3Members.join(', ')}]`)
}
log(`✅ 账户删除的分组清理测试通过`, 'success')
} catch (error) {
log(`❌ 测试6失败: ${error.message}`, 'error')
throw error
}
}
// 主测试函数
async function runTests() {
log('\n🚀 开始多分组功能测试\n', 'info')
try {
// 连接Redis
await redis.connect()
log('✅ Redis连接成功', 'success')
// 执行测试
await test1_createTestData()
await test2_addAccountToMultipleGroups()
await test3_verifyMultiGroupRelationships()
await test4_verifyGroupMemberships()
await test5_removeFromPartialGroups()
await test6_accountDeletionGroupCleanup()
log('\n🎉 所有测试通过!多分组功能工作正常', 'success')
} catch (error) {
log(`\n❌ 测试失败: ${error.message}`, 'error')
console.error(error)
} finally {
// 清理测试数据
if (CLEANUP_ON_FINISH) {
await cleanup()
} else {
log('\n⚠ 测试数据未清理,请手动清理', 'warning')
}
// 关闭Redis连接
await redis.disconnect()
process.exit(0)
}
}
// 运行测试
runTests()

View File

@@ -21,7 +21,6 @@ const geminiRoutes = require('./routes/geminiRoutes')
const openaiGeminiRoutes = require('./routes/openaiGeminiRoutes') const openaiGeminiRoutes = require('./routes/openaiGeminiRoutes')
const openaiClaudeRoutes = require('./routes/openaiClaudeRoutes') const openaiClaudeRoutes = require('./routes/openaiClaudeRoutes')
const openaiRoutes = require('./routes/openaiRoutes') const openaiRoutes = require('./routes/openaiRoutes')
const userRoutes = require('./routes/userRoutes')
const azureOpenaiRoutes = require('./routes/azureOpenaiRoutes') const azureOpenaiRoutes = require('./routes/azureOpenaiRoutes')
const webhookRoutes = require('./routes/webhook') const webhookRoutes = require('./routes/webhook')
@@ -134,17 +133,6 @@ class Application {
// 📝 请求日志使用自定义logger而不是morgan // 📝 请求日志使用自定义logger而不是morgan
this.app.use(requestLogger) this.app.use(requestLogger)
// 🐛 HTTP调试拦截器仅在启用调试时生效
if (process.env.DEBUG_HTTP_TRAFFIC === 'true') {
try {
const { debugInterceptor } = require('./middleware/debugInterceptor')
this.app.use(debugInterceptor)
logger.info('🐛 HTTP调试拦截器已启用 - 日志输出到 logs/http-debug-*.log')
} catch (error) {
logger.warn('⚠️ 无法加载HTTP调试拦截器:', error.message)
}
}
// 🔧 基础中间件 // 🔧 基础中间件
this.app.use( this.app.use(
express.json({ express.json({
@@ -247,7 +235,6 @@ class Application {
this.app.use('/api', apiRoutes) this.app.use('/api', apiRoutes)
this.app.use('/claude', apiRoutes) // /claude 路由别名,与 /api 功能相同 this.app.use('/claude', apiRoutes) // /claude 路由别名,与 /api 功能相同
this.app.use('/admin', adminRoutes) this.app.use('/admin', adminRoutes)
this.app.use('/users', userRoutes)
// 使用 web 路由(包含 auth 和页面重定向) // 使用 web 路由(包含 auth 和页面重定向)
this.app.use('/web', webRoutes) this.app.use('/web', webRoutes)
this.app.use('/apiStats', apiStatsRoutes) this.app.use('/apiStats', apiStatsRoutes)
@@ -537,15 +524,6 @@ class Application {
logger.info( logger.info(
`🔄 Cleanup tasks scheduled every ${config.system.cleanupInterval / 1000 / 60} minutes` `🔄 Cleanup tasks scheduled every ${config.system.cleanupInterval / 1000 / 60} minutes`
) )
// 🚨 启动限流状态自动清理服务
// 每5分钟检查一次过期的限流状态确保账号能及时恢复调度
const rateLimitCleanupService = require('./services/rateLimitCleanupService')
const cleanupIntervalMinutes = config.system.rateLimitCleanupInterval || 5 // 默认5分钟
rateLimitCleanupService.start(cleanupIntervalMinutes)
logger.info(
`🚨 Rate limit cleanup service started (checking every ${cleanupIntervalMinutes} minutes)`
)
} }
setupGracefulShutdown() { setupGracefulShutdown() {
@@ -564,15 +542,6 @@ class Application {
logger.error('❌ Error cleaning up pricing service:', error) logger.error('❌ Error cleaning up pricing service:', error)
} }
// 停止限流清理服务
try {
const rateLimitCleanupService = require('./services/rateLimitCleanupService')
rateLimitCleanupService.stop()
logger.info('🚨 Rate limit cleanup service stopped')
} catch (error) {
logger.error('❌ Error stopping rate limit cleanup service:', error)
}
try { try {
await redis.disconnect() await redis.disconnect()
logger.info('👋 Redis disconnected') logger.info('👋 Redis disconnected')

View File

@@ -1,8 +1,7 @@
const apiKeyService = require('../services/apiKeyService') const apiKeyService = require('../services/apiKeyService')
const userService = require('../services/userService')
const logger = require('../utils/logger') const logger = require('../utils/logger')
const redis = require('../models/redis') const redis = require('../models/redis')
// const { RateLimiterRedis } = require('rate-limiter-flexible') // 暂时未使用 const { RateLimiterRedis } = require('rate-limiter-flexible')
const config = require('../../config/config') const config = require('../../config/config')
// 🔑 API Key验证中间件优化版 // 🔑 API Key验证中间件优化版
@@ -183,18 +182,11 @@ const authenticateApiKey = async (req, res, next) => {
// 检查时间窗口限流 // 检查时间窗口限流
const rateLimitWindow = validation.keyData.rateLimitWindow || 0 const rateLimitWindow = validation.keyData.rateLimitWindow || 0
const rateLimitRequests = validation.keyData.rateLimitRequests || 0 const rateLimitRequests = validation.keyData.rateLimitRequests || 0
const rateLimitCost = validation.keyData.rateLimitCost || 0 // 新增:费用限制
// 兼容性检查如果tokenLimit仍有值使用tokenLimit否则使用rateLimitCost if (rateLimitWindow > 0 && (rateLimitRequests > 0 || validation.keyData.tokenLimit > 0)) {
const hasRateLimits =
rateLimitWindow > 0 &&
(rateLimitRequests > 0 || validation.keyData.tokenLimit > 0 || rateLimitCost > 0)
if (hasRateLimits) {
const windowStartKey = `rate_limit:window_start:${validation.keyData.id}` const windowStartKey = `rate_limit:window_start:${validation.keyData.id}`
const requestCountKey = `rate_limit:requests:${validation.keyData.id}` const requestCountKey = `rate_limit:requests:${validation.keyData.id}`
const tokenCountKey = `rate_limit:tokens:${validation.keyData.id}` const tokenCountKey = `rate_limit:tokens:${validation.keyData.id}`
const costCountKey = `rate_limit:cost:${validation.keyData.id}` // 新增:费用计数器
const now = Date.now() const now = Date.now()
const windowDuration = rateLimitWindow * 60 * 1000 // 转换为毫秒 const windowDuration = rateLimitWindow * 60 * 1000 // 转换为毫秒
@@ -207,7 +199,6 @@ const authenticateApiKey = async (req, res, next) => {
await redis.getClient().set(windowStartKey, now, 'PX', windowDuration) await redis.getClient().set(windowStartKey, now, 'PX', windowDuration)
await redis.getClient().set(requestCountKey, 0, 'PX', windowDuration) await redis.getClient().set(requestCountKey, 0, 'PX', windowDuration)
await redis.getClient().set(tokenCountKey, 0, 'PX', windowDuration) await redis.getClient().set(tokenCountKey, 0, 'PX', windowDuration)
await redis.getClient().set(costCountKey, 0, 'PX', windowDuration) // 新增:重置费用
windowStart = now windowStart = now
} else { } else {
windowStart = parseInt(windowStart) windowStart = parseInt(windowStart)
@@ -218,7 +209,6 @@ const authenticateApiKey = async (req, res, next) => {
await redis.getClient().set(windowStartKey, now, 'PX', windowDuration) await redis.getClient().set(windowStartKey, now, 'PX', windowDuration)
await redis.getClient().set(requestCountKey, 0, 'PX', windowDuration) await redis.getClient().set(requestCountKey, 0, 'PX', windowDuration)
await redis.getClient().set(tokenCountKey, 0, 'PX', windowDuration) await redis.getClient().set(tokenCountKey, 0, 'PX', windowDuration)
await redis.getClient().set(costCountKey, 0, 'PX', windowDuration) // 新增:重置费用
windowStart = now windowStart = now
} }
} }
@@ -226,7 +216,6 @@ const authenticateApiKey = async (req, res, next) => {
// 获取当前计数 // 获取当前计数
const currentRequests = parseInt((await redis.getClient().get(requestCountKey)) || '0') const currentRequests = parseInt((await redis.getClient().get(requestCountKey)) || '0')
const currentTokens = parseInt((await redis.getClient().get(tokenCountKey)) || '0') const currentTokens = parseInt((await redis.getClient().get(tokenCountKey)) || '0')
const currentCost = parseFloat((await redis.getClient().get(costCountKey)) || '0') // 新增:当前费用
// 检查请求次数限制 // 检查请求次数限制
if (rateLimitRequests > 0 && currentRequests >= rateLimitRequests) { if (rateLimitRequests > 0 && currentRequests >= rateLimitRequests) {
@@ -247,46 +236,24 @@ const authenticateApiKey = async (req, res, next) => {
}) })
} }
// 兼容性检查优先使用Token限制历史数据否则使用费用限制 // 检查Token使用量限制
const tokenLimit = parseInt(validation.keyData.tokenLimit) const tokenLimit = parseInt(validation.keyData.tokenLimit)
if (tokenLimit > 0) { if (tokenLimit > 0 && currentTokens >= tokenLimit) {
// 使用Token限制向后兼容 const resetTime = new Date(windowStart + windowDuration)
if (currentTokens >= tokenLimit) { const remainingMinutes = Math.ceil((resetTime - now) / 60000)
const resetTime = new Date(windowStart + windowDuration)
const remainingMinutes = Math.ceil((resetTime - now) / 60000)
logger.security( logger.security(
`🚦 Rate limit exceeded (tokens) for key: ${validation.keyData.id} (${validation.keyData.name}), tokens: ${currentTokens}/${tokenLimit}` `🚦 Rate limit exceeded (tokens) for key: ${validation.keyData.id} (${validation.keyData.name}), tokens: ${currentTokens}/${tokenLimit}`
) )
return res.status(429).json({ return res.status(429).json({
error: 'Rate limit exceeded', error: 'Rate limit exceeded',
message: `已达到 Token 使用限制 (${tokenLimit} tokens),将在 ${remainingMinutes} 分钟后重置`, message: `已达到 Token 使用限制 (${tokenLimit} tokens),将在 ${remainingMinutes} 分钟后重置`,
currentTokens, currentTokens,
tokenLimit, tokenLimit,
resetAt: resetTime.toISOString(), resetAt: resetTime.toISOString(),
remainingMinutes remainingMinutes
}) })
}
} else if (rateLimitCost > 0) {
// 使用费用限制(新功能)
if (currentCost >= rateLimitCost) {
const resetTime = new Date(windowStart + windowDuration)
const remainingMinutes = Math.ceil((resetTime - now) / 60000)
logger.security(
`💰 Rate limit exceeded (cost) for key: ${validation.keyData.id} (${validation.keyData.name}), cost: $${currentCost.toFixed(2)}/$${rateLimitCost}`
)
return res.status(429).json({
error: 'Rate limit exceeded',
message: `已达到费用限制 ($${rateLimitCost}),将在 ${remainingMinutes} 分钟后重置`,
currentCost,
costLimit: rateLimitCost,
resetAt: resetTime.toISOString(),
remainingMinutes
})
}
} }
// 增加请求计数 // 增加请求计数
@@ -298,13 +265,10 @@ const authenticateApiKey = async (req, res, next) => {
windowDuration, windowDuration,
requestCountKey, requestCountKey,
tokenCountKey, tokenCountKey,
costCountKey, // 新增:费用计数器
currentRequests: currentRequests + 1, currentRequests: currentRequests + 1,
currentTokens, currentTokens,
currentCost, // 新增:当前费用
rateLimitRequests, rateLimitRequests,
tokenLimit, tokenLimit
rateLimitCost // 新增:费用限制
} }
} }
@@ -333,46 +297,6 @@ const authenticateApiKey = async (req, res, next) => {
) )
} }
// 检查 Opus 周费用限制(仅对 Opus 模型生效)
const weeklyOpusCostLimit = validation.keyData.weeklyOpusCostLimit || 0
if (weeklyOpusCostLimit > 0) {
// 从请求中获取模型信息
const requestBody = req.body || {}
const model = requestBody.model || ''
// 判断是否为 Opus 模型
if (model && model.toLowerCase().includes('claude-opus')) {
const weeklyOpusCost = validation.keyData.weeklyOpusCost || 0
if (weeklyOpusCost >= weeklyOpusCostLimit) {
logger.security(
`💰 Weekly Opus cost limit exceeded for key: ${validation.keyData.id} (${validation.keyData.name}), cost: $${weeklyOpusCost.toFixed(2)}/$${weeklyOpusCostLimit}`
)
// 计算下周一的重置时间
const now = new Date()
const dayOfWeek = now.getDay()
const daysUntilMonday = dayOfWeek === 0 ? 1 : (8 - dayOfWeek) % 7 || 7
const resetDate = new Date(now)
resetDate.setDate(now.getDate() + daysUntilMonday)
resetDate.setHours(0, 0, 0, 0)
return res.status(429).json({
error: 'Weekly Opus cost limit exceeded',
message: `已达到 Opus 模型周费用限制 ($${weeklyOpusCostLimit})`,
currentCost: weeklyOpusCost,
costLimit: weeklyOpusCostLimit,
resetAt: resetDate.toISOString() // 下周一重置
})
}
// 记录当前 Opus 费用使用情况
logger.api(
`💰 Opus weekly cost usage for key: ${validation.keyData.id} (${validation.keyData.name}), current: $${weeklyOpusCost.toFixed(2)}/$${weeklyOpusCostLimit}`
)
}
}
// 将验证信息添加到请求对象(只包含必要信息) // 将验证信息添加到请求对象(只包含必要信息)
req.apiKey = { req.apiKey = {
id: validation.keyData.id, id: validation.keyData.id,
@@ -387,7 +311,6 @@ const authenticateApiKey = async (req, res, next) => {
concurrencyLimit: validation.keyData.concurrencyLimit, concurrencyLimit: validation.keyData.concurrencyLimit,
rateLimitWindow: validation.keyData.rateLimitWindow, rateLimitWindow: validation.keyData.rateLimitWindow,
rateLimitRequests: validation.keyData.rateLimitRequests, rateLimitRequests: validation.keyData.rateLimitRequests,
rateLimitCost: validation.keyData.rateLimitCost, // 新增:费用限制
enableModelRestriction: validation.keyData.enableModelRestriction, enableModelRestriction: validation.keyData.enableModelRestriction,
restrictedModels: validation.keyData.restrictedModels, restrictedModels: validation.keyData.restrictedModels,
enableClientRestriction: validation.keyData.enableClientRestriction, enableClientRestriction: validation.keyData.enableClientRestriction,
@@ -526,234 +449,6 @@ const authenticateAdmin = async (req, res, next) => {
} }
} }
// 👤 用户验证中间件
const authenticateUser = async (req, res, next) => {
const startTime = Date.now()
try {
// 安全提取用户session token支持多种方式
const sessionToken =
req.headers['authorization']?.replace(/^Bearer\s+/i, '') ||
req.cookies?.userToken ||
req.headers['x-user-token']
if (!sessionToken) {
logger.security(`🔒 Missing user session token attempt from ${req.ip || 'unknown'}`)
return res.status(401).json({
error: 'Missing user session token',
message: 'Please login to access this resource'
})
}
// 基本token格式验证
if (typeof sessionToken !== 'string' || sessionToken.length < 32 || sessionToken.length > 128) {
logger.security(`🔒 Invalid user session token format from ${req.ip || 'unknown'}`)
return res.status(401).json({
error: 'Invalid session token format',
message: 'Session token format is invalid'
})
}
// 验证用户会话
const sessionValidation = await userService.validateUserSession(sessionToken)
if (!sessionValidation) {
logger.security(`🔒 Invalid user session token attempt from ${req.ip || 'unknown'}`)
return res.status(401).json({
error: 'Invalid session token',
message: 'Invalid or expired user session'
})
}
const { session, user } = sessionValidation
// 检查用户是否被禁用
if (!user.isActive) {
logger.security(
`🔒 Disabled user login attempt: ${user.username} from ${req.ip || 'unknown'}`
)
return res.status(403).json({
error: 'Account disabled',
message: 'Your account has been disabled. Please contact administrator.'
})
}
// 设置用户信息(只包含必要信息)
req.user = {
id: user.id,
username: user.username,
email: user.email,
displayName: user.displayName,
firstName: user.firstName,
lastName: user.lastName,
role: user.role,
sessionToken,
sessionCreatedAt: session.createdAt
}
const authDuration = Date.now() - startTime
logger.info(`👤 User authenticated: ${user.username} (${user.id}) in ${authDuration}ms`)
return next()
} catch (error) {
const authDuration = Date.now() - startTime
logger.error(`❌ User authentication error (${authDuration}ms):`, {
error: error.message,
ip: req.ip,
userAgent: req.get('User-Agent'),
url: req.originalUrl
})
return res.status(500).json({
error: 'Authentication error',
message: 'Internal server error during user authentication'
})
}
}
// 👤 用户或管理员验证中间件(支持两种身份)
const authenticateUserOrAdmin = async (req, res, next) => {
const startTime = Date.now()
try {
// 检查是否有管理员token
const adminToken =
req.headers['authorization']?.replace(/^Bearer\s+/i, '') ||
req.cookies?.adminToken ||
req.headers['x-admin-token']
// 检查是否有用户session token
const userToken =
req.headers['x-user-token'] ||
req.cookies?.userToken ||
(!adminToken ? req.headers['authorization']?.replace(/^Bearer\s+/i, '') : null)
// 优先尝试管理员认证
if (adminToken) {
try {
const adminSession = await redis.getSession(adminToken)
if (adminSession && Object.keys(adminSession).length > 0) {
req.admin = {
id: adminSession.adminId || 'admin',
username: adminSession.username,
sessionId: adminToken,
loginTime: adminSession.loginTime
}
req.userType = 'admin'
const authDuration = Date.now() - startTime
logger.security(`🔐 Admin authenticated: ${adminSession.username} in ${authDuration}ms`)
return next()
}
} catch (error) {
logger.debug('Admin authentication failed, trying user authentication:', error.message)
}
}
// 尝试用户认证
if (userToken) {
try {
const sessionValidation = await userService.validateUserSession(userToken)
if (sessionValidation) {
const { session, user } = sessionValidation
if (user.isActive) {
req.user = {
id: user.id,
username: user.username,
email: user.email,
displayName: user.displayName,
firstName: user.firstName,
lastName: user.lastName,
role: user.role,
sessionToken: userToken,
sessionCreatedAt: session.createdAt
}
req.userType = 'user'
const authDuration = Date.now() - startTime
logger.info(`👤 User authenticated: ${user.username} (${user.id}) in ${authDuration}ms`)
return next()
}
}
} catch (error) {
logger.debug('User authentication failed:', error.message)
}
}
// 如果都失败了,返回未授权
logger.security(`🔒 Authentication failed from ${req.ip || 'unknown'}`)
return res.status(401).json({
error: 'Authentication required',
message: 'Please login as user or admin to access this resource'
})
} catch (error) {
const authDuration = Date.now() - startTime
logger.error(`❌ User/Admin authentication error (${authDuration}ms):`, {
error: error.message,
ip: req.ip,
userAgent: req.get('User-Agent'),
url: req.originalUrl
})
return res.status(500).json({
error: 'Authentication error',
message: 'Internal server error during authentication'
})
}
}
// 🛡️ 权限检查中间件
const requireRole = (allowedRoles) => (req, res, next) => {
// 管理员始终有权限
if (req.admin) {
return next()
}
// 检查用户角色
if (req.user) {
const userRole = req.user.role
const allowed = Array.isArray(allowedRoles) ? allowedRoles : [allowedRoles]
if (allowed.includes(userRole)) {
return next()
} else {
logger.security(
`🚫 Access denied for user ${req.user.username} (role: ${userRole}) to ${req.originalUrl}`
)
return res.status(403).json({
error: 'Insufficient permissions',
message: `This resource requires one of the following roles: ${allowed.join(', ')}`
})
}
}
return res.status(401).json({
error: 'Authentication required',
message: 'Please login to access this resource'
})
}
// 🔒 管理员权限检查中间件
const requireAdmin = (req, res, next) => {
if (req.admin) {
return next()
}
// 检查是否是admin角色的用户
if (req.user && req.user.role === 'admin') {
return next()
}
logger.security(
`🚫 Admin access denied for ${req.user?.username || 'unknown'} from ${req.ip || 'unknown'}`
)
return res.status(403).json({
error: 'Admin access required',
message: 'This resource requires administrator privileges'
})
}
// 注意:使用统计现在直接在/api/v1/messages路由中处理 // 注意:使用统计现在直接在/api/v1/messages路由中处理
// 以便从Claude API响应中提取真实的usage数据 // 以便从Claude API响应中提取真实的usage数据
@@ -1018,41 +713,35 @@ const errorHandler = (error, req, res, _next) => {
} }
// 🌐 全局速率限制中间件(延迟初始化) // 🌐 全局速率限制中间件(延迟初始化)
// const rateLimiter = null // 暂时未使用 let rateLimiter = null
// 暂时注释掉未使用的函数 const getRateLimiter = () => {
// const getRateLimiter = () => { if (!rateLimiter) {
// if (!rateLimiter) { try {
// try { const client = redis.getClient()
// const client = redis.getClient() if (!client) {
// if (!client) { logger.warn('⚠️ Redis client not available for rate limiter')
// logger.warn('⚠️ Redis client not available for rate limiter') return null
// return null }
// }
//
// rateLimiter = new RateLimiterRedis({
// storeClient: client,
// keyPrefix: 'global_rate_limit',
// points: 1000, // 请求数量
// duration: 900, // 15分钟 (900秒)
// blockDuration: 900 // 阻塞时间15分钟
// })
//
// logger.info('✅ Rate limiter initialized successfully')
// } catch (error) {
// logger.warn('⚠️ Rate limiter initialization failed, using fallback', { error: error.message })
// return null
// }
// }
// return rateLimiter
// }
const globalRateLimit = async (req, res, next) => rateLimiter = new RateLimiterRedis({
// 已禁用全局IP限流 - 直接跳过所有请求 storeClient: client,
next() keyPrefix: 'global_rate_limit',
points: 1000, // 请求数量
duration: 900, // 15分钟 (900秒)
blockDuration: 900 // 阻塞时间15分钟
})
// 以下代码已被禁用 logger.info('✅ Rate limiter initialized successfully')
/* } catch (error) {
logger.warn('⚠️ Rate limiter initialization failed, using fallback', { error: error.message })
return null
}
}
return rateLimiter
}
const globalRateLimit = async (req, res, next) => {
// 跳过健康检查和内部请求 // 跳过健康检查和内部请求
if (req.path === '/health' || req.path === '/api/health') { if (req.path === '/health' || req.path === '/api/health') {
return next() return next()
@@ -1088,11 +777,11 @@ const globalRateLimit = async (req, res, next) =>
retryAfter: Math.round(msBeforeNext / 1000) retryAfter: Math.round(msBeforeNext / 1000)
}) })
} }
*/ }
// 📊 请求大小限制中间件 // 📊 请求大小限制中间件
const requestSizeLimit = (req, res, next) => { const requestSizeLimit = (req, res, next) => {
const maxSize = 60 * 1024 * 1024 // 60MB const maxSize = 10 * 1024 * 1024 // 10MB
const contentLength = parseInt(req.headers['content-length'] || '0') const contentLength = parseInt(req.headers['content-length'] || '0')
if (contentLength > maxSize) { if (contentLength > maxSize) {
@@ -1110,10 +799,6 @@ const requestSizeLimit = (req, res, next) => {
module.exports = { module.exports = {
authenticateApiKey, authenticateApiKey,
authenticateAdmin, authenticateAdmin,
authenticateUser,
authenticateUserOrAdmin,
requireRole,
requireAdmin,
corsMiddleware, corsMiddleware,
requestLogger, requestLogger,
securityMiddleware, securityMiddleware,

View File

@@ -29,25 +29,6 @@ function getHourInTimezone(date = new Date()) {
return tzDate.getUTCHours() return tzDate.getUTCHours()
} }
// 获取配置时区的 ISO 周YYYY-Wxx 格式,周一到周日)
function getWeekStringInTimezone(date = new Date()) {
const tzDate = getDateInTimezone(date)
// 获取年份
const year = tzDate.getUTCFullYear()
// 计算 ISO 周数(周一为第一天)
const dateObj = new Date(tzDate)
const dayOfWeek = dateObj.getUTCDay() || 7 // 将周日(0)转换为7
const firstThursday = new Date(dateObj)
firstThursday.setUTCDate(dateObj.getUTCDate() + 4 - dayOfWeek) // 找到这周的周四
const yearStart = new Date(firstThursday.getUTCFullYear(), 0, 1)
const weekNumber = Math.ceil(((firstThursday - yearStart) / 86400000 + 1) / 7)
return `${year}-W${String(weekNumber).padStart(2, '0')}`
}
class RedisClient { class RedisClient {
constructor() { constructor() {
this.client = null this.client = null
@@ -212,8 +193,7 @@ class RedisClient {
cacheReadTokens = 0, cacheReadTokens = 0,
model = 'unknown', model = 'unknown',
ephemeral5mTokens = 0, // 新增5分钟缓存 tokens ephemeral5mTokens = 0, // 新增5分钟缓存 tokens
ephemeral1hTokens = 0, // 新增1小时缓存 tokens ephemeral1hTokens = 0 // 新增1小时缓存 tokens
isLongContextRequest = false // 新增:是否为 1M 上下文请求超过200k
) { ) {
const key = `usage:${keyId}` const key = `usage:${keyId}`
const now = new Date() const now = new Date()
@@ -270,12 +250,6 @@ class RedisClient {
// 详细缓存类型统计(新增) // 详细缓存类型统计(新增)
pipeline.hincrby(key, 'totalEphemeral5mTokens', ephemeral5mTokens) pipeline.hincrby(key, 'totalEphemeral5mTokens', ephemeral5mTokens)
pipeline.hincrby(key, 'totalEphemeral1hTokens', ephemeral1hTokens) pipeline.hincrby(key, 'totalEphemeral1hTokens', ephemeral1hTokens)
// 1M 上下文请求统计(新增)
if (isLongContextRequest) {
pipeline.hincrby(key, 'totalLongContextInputTokens', finalInputTokens)
pipeline.hincrby(key, 'totalLongContextOutputTokens', finalOutputTokens)
pipeline.hincrby(key, 'totalLongContextRequests', 1)
}
// 请求计数 // 请求计数
pipeline.hincrby(key, 'totalRequests', 1) pipeline.hincrby(key, 'totalRequests', 1)
@@ -290,12 +264,6 @@ class RedisClient {
// 详细缓存类型统计 // 详细缓存类型统计
pipeline.hincrby(daily, 'ephemeral5mTokens', ephemeral5mTokens) pipeline.hincrby(daily, 'ephemeral5mTokens', ephemeral5mTokens)
pipeline.hincrby(daily, 'ephemeral1hTokens', ephemeral1hTokens) pipeline.hincrby(daily, 'ephemeral1hTokens', ephemeral1hTokens)
// 1M 上下文请求统计
if (isLongContextRequest) {
pipeline.hincrby(daily, 'longContextInputTokens', finalInputTokens)
pipeline.hincrby(daily, 'longContextOutputTokens', finalOutputTokens)
pipeline.hincrby(daily, 'longContextRequests', 1)
}
// 每月统计 // 每月统计
pipeline.hincrby(monthly, 'tokens', coreTokens) pipeline.hincrby(monthly, 'tokens', coreTokens)
@@ -408,8 +376,7 @@ class RedisClient {
outputTokens = 0, outputTokens = 0,
cacheCreateTokens = 0, cacheCreateTokens = 0,
cacheReadTokens = 0, cacheReadTokens = 0,
model = 'unknown', model = 'unknown'
isLongContextRequest = false
) { ) {
const now = new Date() const now = new Date()
const today = getDateStringInTimezone(now) const today = getDateStringInTimezone(now)
@@ -440,8 +407,7 @@ class RedisClient {
finalInputTokens + finalOutputTokens + finalCacheCreateTokens + finalCacheReadTokens finalInputTokens + finalOutputTokens + finalCacheCreateTokens + finalCacheReadTokens
const coreTokens = finalInputTokens + finalOutputTokens const coreTokens = finalInputTokens + finalOutputTokens
// 构建统计操作数组 await Promise.all([
const operations = [
// 账户总体统计 // 账户总体统计
this.client.hincrby(accountKey, 'totalTokens', coreTokens), this.client.hincrby(accountKey, 'totalTokens', coreTokens),
this.client.hincrby(accountKey, 'totalInputTokens', finalInputTokens), this.client.hincrby(accountKey, 'totalInputTokens', finalInputTokens),
@@ -478,26 +444,6 @@ class RedisClient {
this.client.hincrby(accountHourly, 'allTokens', actualTotalTokens), this.client.hincrby(accountHourly, 'allTokens', actualTotalTokens),
this.client.hincrby(accountHourly, 'requests', 1), this.client.hincrby(accountHourly, 'requests', 1),
// 添加模型级别的数据到hourly键中以支持会话窗口的统计
this.client.hincrby(accountHourly, `model:${normalizedModel}:inputTokens`, finalInputTokens),
this.client.hincrby(
accountHourly,
`model:${normalizedModel}:outputTokens`,
finalOutputTokens
),
this.client.hincrby(
accountHourly,
`model:${normalizedModel}:cacheCreateTokens`,
finalCacheCreateTokens
),
this.client.hincrby(
accountHourly,
`model:${normalizedModel}:cacheReadTokens`,
finalCacheReadTokens
),
this.client.hincrby(accountHourly, `model:${normalizedModel}:allTokens`, actualTotalTokens),
this.client.hincrby(accountHourly, `model:${normalizedModel}:requests`, 1),
// 账户按模型统计 - 每日 // 账户按模型统计 - 每日
this.client.hincrby(accountModelDaily, 'inputTokens', finalInputTokens), this.client.hincrby(accountModelDaily, 'inputTokens', finalInputTokens),
this.client.hincrby(accountModelDaily, 'outputTokens', finalOutputTokens), this.client.hincrby(accountModelDaily, 'outputTokens', finalOutputTokens),
@@ -529,21 +475,7 @@ class RedisClient {
this.client.expire(accountModelDaily, 86400 * 32), // 32天过期 this.client.expire(accountModelDaily, 86400 * 32), // 32天过期
this.client.expire(accountModelMonthly, 86400 * 365), // 1年过期 this.client.expire(accountModelMonthly, 86400 * 365), // 1年过期
this.client.expire(accountModelHourly, 86400 * 7) // 7天过期 this.client.expire(accountModelHourly, 86400 * 7) // 7天过期
] ])
// 如果是 1M 上下文请求,添加额外的统计
if (isLongContextRequest) {
operations.push(
this.client.hincrby(accountKey, 'totalLongContextInputTokens', finalInputTokens),
this.client.hincrby(accountKey, 'totalLongContextOutputTokens', finalOutputTokens),
this.client.hincrby(accountKey, 'totalLongContextRequests', 1),
this.client.hincrby(accountDaily, 'longContextInputTokens', finalInputTokens),
this.client.hincrby(accountDaily, 'longContextOutputTokens', finalOutputTokens),
this.client.hincrby(accountDaily, 'longContextRequests', 1)
)
}
await Promise.all(operations)
} }
async getUsageStats(keyId) { async getUsageStats(keyId) {
@@ -700,85 +632,6 @@ class RedisClient {
} }
} }
// 💰 获取本周 Opus 费用
async getWeeklyOpusCost(keyId) {
const currentWeek = getWeekStringInTimezone()
const costKey = `usage:opus:weekly:${keyId}:${currentWeek}`
const cost = await this.client.get(costKey)
const result = parseFloat(cost || 0)
logger.debug(
`💰 Getting weekly Opus cost for ${keyId}, week: ${currentWeek}, key: ${costKey}, value: ${cost}, result: ${result}`
)
return result
}
// 💰 增加本周 Opus 费用
async incrementWeeklyOpusCost(keyId, amount) {
const currentWeek = getWeekStringInTimezone()
const weeklyKey = `usage:opus:weekly:${keyId}:${currentWeek}`
const totalKey = `usage:opus:total:${keyId}`
logger.debug(
`💰 Incrementing weekly Opus cost for ${keyId}, week: ${currentWeek}, amount: $${amount}`
)
// 使用 pipeline 批量执行,提高性能
const pipeline = this.client.pipeline()
pipeline.incrbyfloat(weeklyKey, amount)
pipeline.incrbyfloat(totalKey, amount)
// 设置周费用键的过期时间为 2 周
pipeline.expire(weeklyKey, 14 * 24 * 3600)
const results = await pipeline.exec()
logger.debug(`💰 Opus cost incremented successfully, new weekly total: $${results[0][1]}`)
}
// 💰 计算账户的每日费用(基于模型使用)
async getAccountDailyCost(accountId) {
const CostCalculator = require('../utils/costCalculator')
const today = getDateStringInTimezone()
// 获取账户今日所有模型的使用数据
const pattern = `account_usage:model:daily:${accountId}:*:${today}`
const modelKeys = await this.client.keys(pattern)
if (!modelKeys || modelKeys.length === 0) {
return 0
}
let totalCost = 0
for (const key of modelKeys) {
// 从key中解析模型名称
// 格式account_usage:model:daily:{accountId}:{model}:{date}
const parts = key.split(':')
const model = parts[4] // 模型名在第5个位置索引4
// 获取该模型的使用数据
const modelUsage = await this.client.hgetall(key)
if (modelUsage && (modelUsage.inputTokens || modelUsage.outputTokens)) {
const usage = {
input_tokens: parseInt(modelUsage.inputTokens || 0),
output_tokens: parseInt(modelUsage.outputTokens || 0),
cache_creation_input_tokens: parseInt(modelUsage.cacheCreateTokens || 0),
cache_read_input_tokens: parseInt(modelUsage.cacheReadTokens || 0)
}
// 使用CostCalculator计算费用
const costResult = CostCalculator.calculateCost(usage, model)
totalCost += costResult.costs.total
logger.debug(
`💰 Account ${accountId} daily cost for model ${model}: $${costResult.costs.total}`
)
}
}
logger.debug(`💰 Account ${accountId} total daily cost: $${totalCost}`)
return totalCost
}
// 📊 获取账户使用统计 // 📊 获取账户使用统计
async getAccountUsageStats(accountId) { async getAccountUsageStats(accountId) {
const accountKey = `account_usage:${accountId}` const accountKey = `account_usage:${accountId}`
@@ -838,16 +691,10 @@ class RedisClient {
const dailyData = handleAccountData(daily) const dailyData = handleAccountData(daily)
const monthlyData = handleAccountData(monthly) const monthlyData = handleAccountData(monthly)
// 获取每日费用(基于模型使用)
const dailyCost = await this.getAccountDailyCost(accountId)
return { return {
accountId, accountId,
total: totalData, total: totalData,
daily: { daily: dailyData,
...dailyData,
cost: dailyCost
},
monthly: monthlyData, monthly: monthlyData,
averages: { averages: {
rpm: Math.round(avgRPM * 100) / 100, rpm: Math.round(avgRPM * 100) / 100,
@@ -1356,12 +1203,9 @@ class RedisClient {
} }
// 🔗 会话sticky映射管理 // 🔗 会话sticky映射管理
async setSessionAccountMapping(sessionHash, accountId, ttl = null) { async setSessionAccountMapping(sessionHash, accountId, ttl = 3600) {
const appConfig = require('../../config/config')
// 从配置读取TTL小时转换为秒默认1小时
const defaultTTL = ttl !== null ? ttl : (appConfig.session?.stickyTtlHours || 1) * 60 * 60
const key = `sticky_session:${sessionHash}` const key = `sticky_session:${sessionHash}`
await this.client.set(key, accountId, 'EX', defaultTTL) await this.client.set(key, accountId, 'EX', ttl)
} }
async getSessionAccountMapping(sessionHash) { async getSessionAccountMapping(sessionHash) {
@@ -1369,57 +1213,6 @@ class RedisClient {
return await this.client.get(key) return await this.client.get(key)
} }
// 🚀 智能会话TTL续期剩余时间少于阈值时自动续期
async extendSessionAccountMappingTTL(sessionHash) {
const appConfig = require('../../config/config')
const key = `sticky_session:${sessionHash}`
// 📊 从配置获取参数
const ttlHours = appConfig.session?.stickyTtlHours || 1 // 小时默认1小时
const thresholdMinutes = appConfig.session?.renewalThresholdMinutes || 0 // 分钟默认0不续期
// 如果阈值为0不执行续期
if (thresholdMinutes === 0) {
return true
}
const fullTTL = ttlHours * 60 * 60 // 转换为秒
const renewalThreshold = thresholdMinutes * 60 // 转换为秒
try {
// 获取当前剩余TTL
const remainingTTL = await this.client.ttl(key)
// 键不存在或已过期
if (remainingTTL === -2) {
return false
}
// 键存在但没有TTL永不过期不需要处理
if (remainingTTL === -1) {
return true
}
// 🎯 智能续期策略:仅在剩余时间少于阈值时才续期
if (remainingTTL < renewalThreshold) {
await this.client.expire(key, fullTTL)
logger.debug(
`🔄 Renewed sticky session TTL: ${sessionHash} (was ${Math.round(remainingTTL / 60)}min, renewed to ${ttlHours}h)`
)
return true
}
// 剩余时间充足,无需续期
logger.debug(
`✅ Sticky session TTL sufficient: ${sessionHash} (remaining ${Math.round(remainingTTL / 60)}min)`
)
return true
} catch (error) {
logger.error('❌ Failed to extend session TTL:', error)
return false
}
}
async deleteSessionAccountMapping(sessionHash) { async deleteSessionAccountMapping(sessionHash) {
const key = `sticky_session:${sessionHash}` const key = `sticky_session:${sessionHash}`
return await this.client.del(key) return await this.client.del(key)
@@ -1518,185 +1311,6 @@ class RedisClient {
return 0 return 0
} }
} }
// 🔧 Basic Redis operations wrapper methods for convenience
async get(key) {
const client = this.getClientSafe()
return await client.get(key)
}
async set(key, value, ...args) {
const client = this.getClientSafe()
return await client.set(key, value, ...args)
}
async setex(key, ttl, value) {
const client = this.getClientSafe()
return await client.setex(key, ttl, value)
}
async del(...keys) {
const client = this.getClientSafe()
return await client.del(...keys)
}
async keys(pattern) {
const client = this.getClientSafe()
return await client.keys(pattern)
}
// 📊 获取账户会话窗口内的使用统计(包含模型细分)
async getAccountSessionWindowUsage(accountId, windowStart, windowEnd) {
try {
if (!windowStart || !windowEnd) {
return {
totalInputTokens: 0,
totalOutputTokens: 0,
totalCacheCreateTokens: 0,
totalCacheReadTokens: 0,
totalAllTokens: 0,
totalRequests: 0,
modelUsage: {}
}
}
const startDate = new Date(windowStart)
const endDate = new Date(windowEnd)
// 添加日志以调试时间窗口
logger.debug(`📊 Getting session window usage for account ${accountId}`)
logger.debug(` Window: ${windowStart} to ${windowEnd}`)
logger.debug(` Start UTC: ${startDate.toISOString()}, End UTC: ${endDate.toISOString()}`)
// 获取窗口内所有可能的小时键
// 重要:需要使用配置的时区来构建键名,因为数据存储时使用的是配置时区
const hourlyKeys = []
const currentHour = new Date(startDate)
currentHour.setMinutes(0)
currentHour.setSeconds(0)
currentHour.setMilliseconds(0)
while (currentHour <= endDate) {
// 使用时区转换函数来获取正确的日期和小时
const tzDateStr = getDateStringInTimezone(currentHour)
const tzHour = String(getHourInTimezone(currentHour)).padStart(2, '0')
const key = `account_usage:hourly:${accountId}:${tzDateStr}:${tzHour}`
logger.debug(` Adding hourly key: ${key}`)
hourlyKeys.push(key)
currentHour.setHours(currentHour.getHours() + 1)
}
// 批量获取所有小时的数据
const pipeline = this.client.pipeline()
for (const key of hourlyKeys) {
pipeline.hgetall(key)
}
const results = await pipeline.exec()
// 聚合所有数据
let totalInputTokens = 0
let totalOutputTokens = 0
let totalCacheCreateTokens = 0
let totalCacheReadTokens = 0
let totalAllTokens = 0
let totalRequests = 0
const modelUsage = {}
logger.debug(` Processing ${results.length} hourly results`)
for (const [error, data] of results) {
if (error || !data || Object.keys(data).length === 0) {
continue
}
// 处理总计数据
const hourInputTokens = parseInt(data.inputTokens || 0)
const hourOutputTokens = parseInt(data.outputTokens || 0)
const hourCacheCreateTokens = parseInt(data.cacheCreateTokens || 0)
const hourCacheReadTokens = parseInt(data.cacheReadTokens || 0)
const hourAllTokens = parseInt(data.allTokens || 0)
const hourRequests = parseInt(data.requests || 0)
totalInputTokens += hourInputTokens
totalOutputTokens += hourOutputTokens
totalCacheCreateTokens += hourCacheCreateTokens
totalCacheReadTokens += hourCacheReadTokens
totalAllTokens += hourAllTokens
totalRequests += hourRequests
if (hourAllTokens > 0) {
logger.debug(` Hour data: allTokens=${hourAllTokens}, requests=${hourRequests}`)
}
// 处理每个模型的数据
for (const [key, value] of Object.entries(data)) {
// 查找模型相关的键(格式: model:{modelName}:{metric}
if (key.startsWith('model:')) {
const parts = key.split(':')
if (parts.length >= 3) {
const modelName = parts[1]
const metric = parts.slice(2).join(':')
if (!modelUsage[modelName]) {
modelUsage[modelName] = {
inputTokens: 0,
outputTokens: 0,
cacheCreateTokens: 0,
cacheReadTokens: 0,
allTokens: 0,
requests: 0
}
}
if (metric === 'inputTokens') {
modelUsage[modelName].inputTokens += parseInt(value || 0)
} else if (metric === 'outputTokens') {
modelUsage[modelName].outputTokens += parseInt(value || 0)
} else if (metric === 'cacheCreateTokens') {
modelUsage[modelName].cacheCreateTokens += parseInt(value || 0)
} else if (metric === 'cacheReadTokens') {
modelUsage[modelName].cacheReadTokens += parseInt(value || 0)
} else if (metric === 'allTokens') {
modelUsage[modelName].allTokens += parseInt(value || 0)
} else if (metric === 'requests') {
modelUsage[modelName].requests += parseInt(value || 0)
}
}
}
}
}
logger.debug(`📊 Session window usage summary:`)
logger.debug(` Total allTokens: ${totalAllTokens}`)
logger.debug(` Total requests: ${totalRequests}`)
logger.debug(` Input: ${totalInputTokens}, Output: ${totalOutputTokens}`)
logger.debug(
` Cache Create: ${totalCacheCreateTokens}, Cache Read: ${totalCacheReadTokens}`
)
return {
totalInputTokens,
totalOutputTokens,
totalCacheCreateTokens,
totalCacheReadTokens,
totalAllTokens,
totalRequests,
modelUsage
}
} catch (error) {
logger.error(`❌ Failed to get session window usage for account ${accountId}:`, error)
return {
totalInputTokens: 0,
totalOutputTokens: 0,
totalCacheCreateTokens: 0,
totalCacheReadTokens: 0,
totalAllTokens: 0,
totalRequests: 0,
modelUsage: {}
}
}
}
} }
const redisClient = new RedisClient() const redisClient = new RedisClient()
@@ -1705,6 +1319,5 @@ const redisClient = new RedisClient()
redisClient.getDateInTimezone = getDateInTimezone redisClient.getDateInTimezone = getDateInTimezone
redisClient.getDateStringInTimezone = getDateStringInTimezone redisClient.getDateStringInTimezone = getDateStringInTimezone
redisClient.getHourInTimezone = getHourInTimezone redisClient.getHourInTimezone = getHourInTimezone
redisClient.getWeekStringInTimezone = getWeekStringInTimezone
module.exports = redisClient module.exports = redisClient

File diff suppressed because it is too large Load Diff

View File

@@ -5,7 +5,6 @@ const bedrockRelayService = require('../services/bedrockRelayService')
const bedrockAccountService = require('../services/bedrockAccountService') const bedrockAccountService = require('../services/bedrockAccountService')
const unifiedClaudeScheduler = require('../services/unifiedClaudeScheduler') const unifiedClaudeScheduler = require('../services/unifiedClaudeScheduler')
const apiKeyService = require('../services/apiKeyService') const apiKeyService = require('../services/apiKeyService')
const pricingService = require('../services/pricingService')
const { authenticateApiKey } = require('../middleware/auth') const { authenticateApiKey } = require('../middleware/auth')
const logger = require('../utils/logger') const logger = require('../utils/logger')
const redis = require('../models/redis') const redis = require('../models/redis')
@@ -132,16 +131,14 @@ async function handleMessagesRequest(req, res) {
} }
apiKeyService apiKeyService
.recordUsageWithDetails(req.apiKey.id, usageObject, model, usageAccountId, 'claude') .recordUsageWithDetails(req.apiKey.id, usageObject, model, usageAccountId)
.catch((error) => { .catch((error) => {
logger.error('❌ Failed to record stream usage:', error) logger.error('❌ Failed to record stream usage:', error)
}) })
// 更新时间窗口内的token计数和费用 // 更新时间窗口内的token计数
if (req.rateLimitInfo) { if (req.rateLimitInfo) {
const totalTokens = inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens const totalTokens = inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens
// 更新Token计数向后兼容
redis redis
.getClient() .getClient()
.incrby(req.rateLimitInfo.tokenCountKey, totalTokens) .incrby(req.rateLimitInfo.tokenCountKey, totalTokens)
@@ -149,22 +146,6 @@ async function handleMessagesRequest(req, res) {
logger.error('❌ Failed to update rate limit token count:', error) logger.error('❌ Failed to update rate limit token count:', error)
}) })
logger.api(`📊 Updated rate limit token count: +${totalTokens} tokens`) logger.api(`📊 Updated rate limit token count: +${totalTokens} tokens`)
// 计算并更新费用计数(新功能)
if (req.rateLimitInfo.costCountKey) {
const costInfo = pricingService.calculateCost(usageData, model)
if (costInfo.totalCost > 0) {
redis
.getClient()
.incrbyfloat(req.rateLimitInfo.costCountKey, costInfo.totalCost)
.catch((error) => {
logger.error('❌ Failed to update rate limit cost count:', error)
})
logger.api(
`💰 Updated rate limit cost count: +$${costInfo.totalCost.toFixed(6)}`
)
}
}
} }
usageDataCaptured = true usageDataCaptured = true
@@ -235,22 +216,14 @@ async function handleMessagesRequest(req, res) {
} }
apiKeyService apiKeyService
.recordUsageWithDetails( .recordUsageWithDetails(req.apiKey.id, usageObject, model, usageAccountId)
req.apiKey.id,
usageObject,
model,
usageAccountId,
'claude-console'
)
.catch((error) => { .catch((error) => {
logger.error('❌ Failed to record stream usage:', error) logger.error('❌ Failed to record stream usage:', error)
}) })
// 更新时间窗口内的token计数和费用 // 更新时间窗口内的token计数
if (req.rateLimitInfo) { if (req.rateLimitInfo) {
const totalTokens = inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens const totalTokens = inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens
// 更新Token计数向后兼容
redis redis
.getClient() .getClient()
.incrby(req.rateLimitInfo.tokenCountKey, totalTokens) .incrby(req.rateLimitInfo.tokenCountKey, totalTokens)
@@ -258,22 +231,6 @@ async function handleMessagesRequest(req, res) {
logger.error('❌ Failed to update rate limit token count:', error) logger.error('❌ Failed to update rate limit token count:', error)
}) })
logger.api(`📊 Updated rate limit token count: +${totalTokens} tokens`) logger.api(`📊 Updated rate limit token count: +${totalTokens} tokens`)
// 计算并更新费用计数(新功能)
if (req.rateLimitInfo.costCountKey) {
const costInfo = pricingService.calculateCost(usageData, model)
if (costInfo.totalCost > 0) {
redis
.getClient()
.incrbyfloat(req.rateLimitInfo.costCountKey, costInfo.totalCost)
.catch((error) => {
logger.error('❌ Failed to update rate limit cost count:', error)
})
logger.api(
`💰 Updated rate limit cost count: +$${costInfo.totalCost.toFixed(6)}`
)
}
}
} }
usageDataCaptured = true usageDataCaptured = true
@@ -314,11 +271,9 @@ async function handleMessagesRequest(req, res) {
logger.error('❌ Failed to record Bedrock stream usage:', error) logger.error('❌ Failed to record Bedrock stream usage:', error)
}) })
// 更新时间窗口内的token计数和费用 // 更新时间窗口内的token计数
if (req.rateLimitInfo) { if (req.rateLimitInfo) {
const totalTokens = inputTokens + outputTokens const totalTokens = inputTokens + outputTokens
// 更新Token计数向后兼容
redis redis
.getClient() .getClient()
.incrby(req.rateLimitInfo.tokenCountKey, totalTokens) .incrby(req.rateLimitInfo.tokenCountKey, totalTokens)
@@ -326,20 +281,6 @@ async function handleMessagesRequest(req, res) {
logger.error('❌ Failed to update rate limit token count:', error) logger.error('❌ Failed to update rate limit token count:', error)
}) })
logger.api(`📊 Updated rate limit token count: +${totalTokens} tokens`) logger.api(`📊 Updated rate limit token count: +${totalTokens} tokens`)
// 计算并更新费用计数(新功能)
if (req.rateLimitInfo.costCountKey) {
const costInfo = pricingService.calculateCost(result.usage, result.model)
if (costInfo.totalCost > 0) {
redis
.getClient()
.incrbyfloat(req.rateLimitInfo.costCountKey, costInfo.totalCost)
.catch((error) => {
logger.error('❌ Failed to update rate limit cost count:', error)
})
logger.api(`💰 Updated rate limit cost count: +$${costInfo.totalCost.toFixed(6)}`)
}
}
} }
usageDataCaptured = true usageDataCaptured = true
@@ -497,24 +438,11 @@ async function handleMessagesRequest(req, res) {
responseAccountId responseAccountId
) )
// 更新时间窗口内的token计数和费用 // 更新时间窗口内的token计数
if (req.rateLimitInfo) { if (req.rateLimitInfo) {
const totalTokens = inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens const totalTokens = inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens
// 更新Token计数向后兼容
await redis.getClient().incrby(req.rateLimitInfo.tokenCountKey, totalTokens) await redis.getClient().incrby(req.rateLimitInfo.tokenCountKey, totalTokens)
logger.api(`📊 Updated rate limit token count: +${totalTokens} tokens`) logger.api(`📊 Updated rate limit token count: +${totalTokens} tokens`)
// 计算并更新费用计数(新功能)
if (req.rateLimitInfo.costCountKey) {
const costInfo = pricingService.calculateCost(jsonData.usage, model)
if (costInfo.totalCost > 0) {
await redis
.getClient()
.incrbyfloat(req.rateLimitInfo.costCountKey, costInfo.totalCost)
logger.api(`💰 Updated rate limit cost count: +$${costInfo.totalCost.toFixed(6)}`)
}
}
} }
usageRecorded = true usageRecorded = true

View File

@@ -31,8 +31,8 @@ router.post('/api/get-key-id', async (req, res) => {
}) })
} }
// 验证API Key(使用不触发激活的验证方法) // 验证API Key
const validation = await apiKeyService.validateApiKeyForStats(apiKey) const validation = await apiKeyService.validateApiKey(apiKey)
if (!validation.valid) { if (!validation.valid) {
const clientIP = req.ip || req.connection?.remoteAddress || 'unknown' const clientIP = req.ip || req.connection?.remoteAddress || 'unknown'
@@ -146,11 +146,6 @@ router.post('/api/user-stats', async (req, res) => {
enableClientRestriction: keyData.enableClientRestriction === 'true', enableClientRestriction: keyData.enableClientRestriction === 'true',
allowedClients, allowedClients,
permissions: keyData.permissions || 'all', permissions: keyData.permissions || 'all',
// 添加激活相关字段
expirationMode: keyData.expirationMode || 'fixed',
isActivated: keyData.isActivated === 'true',
activationDays: parseInt(keyData.activationDays || 0),
activatedAt: keyData.activatedAt || null,
usage // 使用完整的 usage 数据,而不是只有 total usage // 使用完整的 usage 数据,而不是只有 total
} }
} else if (apiKey) { } else if (apiKey) {
@@ -163,8 +158,8 @@ router.post('/api/user-stats', async (req, res) => {
}) })
} }
// 验证API Key使用不触发激活的验证方法 // 验证API Key重用现有的验证逻辑
const validation = await apiKeyService.validateApiKeyForStats(apiKey) const validation = await apiKeyService.validateApiKey(apiKey)
if (!validation.valid) { if (!validation.valid) {
const clientIP = req.ip || req.connection?.remoteAddress || 'unknown' const clientIP = req.ip || req.connection?.remoteAddress || 'unknown'
@@ -283,24 +278,21 @@ router.post('/api/user-stats', async (req, res) => {
// 获取当前使用量 // 获取当前使用量
let currentWindowRequests = 0 let currentWindowRequests = 0
let currentWindowTokens = 0 let currentWindowTokens = 0
let currentWindowCost = 0 // 新增:当前窗口费用
let currentDailyCost = 0 let currentDailyCost = 0
let windowStartTime = null let windowStartTime = null
let windowEndTime = null let windowEndTime = null
let windowRemainingSeconds = null let windowRemainingSeconds = null
try { try {
// 获取当前时间窗口的请求次数Token使用量和费用 // 获取当前时间窗口的请求次数Token使用量
if (fullKeyData.rateLimitWindow > 0) { if (fullKeyData.rateLimitWindow > 0) {
const client = redis.getClientSafe() const client = redis.getClientSafe()
const requestCountKey = `rate_limit:requests:${keyId}` const requestCountKey = `rate_limit:requests:${keyId}`
const tokenCountKey = `rate_limit:tokens:${keyId}` const tokenCountKey = `rate_limit:tokens:${keyId}`
const costCountKey = `rate_limit:cost:${keyId}` // 新增费用计数key
const windowStartKey = `rate_limit:window_start:${keyId}` const windowStartKey = `rate_limit:window_start:${keyId}`
currentWindowRequests = parseInt((await client.get(requestCountKey)) || '0') currentWindowRequests = parseInt((await client.get(requestCountKey)) || '0')
currentWindowTokens = parseInt((await client.get(tokenCountKey)) || '0') currentWindowTokens = parseInt((await client.get(tokenCountKey)) || '0')
currentWindowCost = parseFloat((await client.get(costCountKey)) || '0') // 新增:获取当前窗口费用
// 获取窗口开始时间和计算剩余时间 // 获取窗口开始时间和计算剩余时间
const windowStart = await client.get(windowStartKey) const windowStart = await client.get(windowStartKey)
@@ -321,7 +313,6 @@ router.post('/api/user-stats', async (req, res) => {
// 重置计数为0因为窗口已过期 // 重置计数为0因为窗口已过期
currentWindowRequests = 0 currentWindowRequests = 0
currentWindowTokens = 0 currentWindowTokens = 0
currentWindowCost = 0 // 新增:重置窗口费用
} }
} }
} }
@@ -340,11 +331,6 @@ router.post('/api/user-stats', async (req, res) => {
isActive: true, // 如果能通过validateApiKey验证说明一定是激活的 isActive: true, // 如果能通过validateApiKey验证说明一定是激活的
createdAt: keyData.createdAt, createdAt: keyData.createdAt,
expiresAt: keyData.expiresAt, expiresAt: keyData.expiresAt,
// 添加激活相关字段
expirationMode: keyData.expirationMode || 'fixed',
isActivated: keyData.isActivated === 'true',
activationDays: parseInt(keyData.activationDays || 0),
activatedAt: keyData.activatedAt || null,
permissions: fullKeyData.permissions, permissions: fullKeyData.permissions,
// 使用统计(使用验证结果中的完整数据) // 使用统计(使用验证结果中的完整数据)
@@ -370,12 +356,10 @@ router.post('/api/user-stats', async (req, res) => {
concurrencyLimit: fullKeyData.concurrencyLimit || 0, concurrencyLimit: fullKeyData.concurrencyLimit || 0,
rateLimitWindow: fullKeyData.rateLimitWindow || 0, rateLimitWindow: fullKeyData.rateLimitWindow || 0,
rateLimitRequests: fullKeyData.rateLimitRequests || 0, rateLimitRequests: fullKeyData.rateLimitRequests || 0,
rateLimitCost: parseFloat(fullKeyData.rateLimitCost) || 0, // 新增:费用限制
dailyCostLimit: fullKeyData.dailyCostLimit || 0, dailyCostLimit: fullKeyData.dailyCostLimit || 0,
// 当前使用量 // 当前使用量
currentWindowRequests, currentWindowRequests,
currentWindowTokens, currentWindowTokens,
currentWindowCost, // 新增:当前窗口费用
currentDailyCost, currentDailyCost,
// 时间窗口信息 // 时间窗口信息
windowStartTime, windowStartTime,
@@ -417,317 +401,6 @@ router.post('/api/user-stats', async (req, res) => {
} }
}) })
// 📊 批量查询统计数据接口
router.post('/api/batch-stats', async (req, res) => {
try {
const { apiIds } = req.body
// 验证输入
if (!apiIds || !Array.isArray(apiIds) || apiIds.length === 0) {
return res.status(400).json({
error: 'Invalid input',
message: 'API IDs array is required'
})
}
// 限制最多查询 30 个
if (apiIds.length > 30) {
return res.status(400).json({
error: 'Too many keys',
message: 'Maximum 30 API keys can be queried at once'
})
}
// 验证所有 ID 格式
const uuidRegex = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i
const invalidIds = apiIds.filter((id) => !uuidRegex.test(id))
if (invalidIds.length > 0) {
return res.status(400).json({
error: 'Invalid API ID format',
message: `Invalid API IDs: ${invalidIds.join(', ')}`
})
}
const individualStats = []
const aggregated = {
totalKeys: apiIds.length,
activeKeys: 0,
usage: {
requests: 0,
inputTokens: 0,
outputTokens: 0,
cacheCreateTokens: 0,
cacheReadTokens: 0,
allTokens: 0,
cost: 0,
formattedCost: '$0.000000'
},
dailyUsage: {
requests: 0,
inputTokens: 0,
outputTokens: 0,
cacheCreateTokens: 0,
cacheReadTokens: 0,
allTokens: 0,
cost: 0,
formattedCost: '$0.000000'
},
monthlyUsage: {
requests: 0,
inputTokens: 0,
outputTokens: 0,
cacheCreateTokens: 0,
cacheReadTokens: 0,
allTokens: 0,
cost: 0,
formattedCost: '$0.000000'
}
}
// 并行查询所有 API Key 数据复用单key查询逻辑
const results = await Promise.allSettled(
apiIds.map(async (apiId) => {
const keyData = await redis.getApiKey(apiId)
if (!keyData || Object.keys(keyData).length === 0) {
return { error: 'Not found', apiId }
}
// 检查是否激活
if (keyData.isActive !== 'true') {
return { error: 'Disabled', apiId }
}
// 检查是否过期
if (keyData.expiresAt && new Date() > new Date(keyData.expiresAt)) {
return { error: 'Expired', apiId }
}
// 复用单key查询的逻辑获取使用统计
const usage = await redis.getUsageStats(apiId)
// 获取费用统计与单key查询一致
const costStats = await redis.getCostStats(apiId)
return {
apiId,
name: keyData.name,
description: keyData.description || '',
isActive: true,
createdAt: keyData.createdAt,
usage: usage.total || {},
dailyStats: {
...usage.daily,
cost: costStats.daily
},
monthlyStats: {
...usage.monthly,
cost: costStats.monthly
},
totalCost: costStats.total
}
})
)
// 处理结果并聚合
results.forEach((result) => {
if (result.status === 'fulfilled' && result.value && !result.value.error) {
const stats = result.value
aggregated.activeKeys++
// 聚合总使用量
if (stats.usage) {
aggregated.usage.requests += stats.usage.requests || 0
aggregated.usage.inputTokens += stats.usage.inputTokens || 0
aggregated.usage.outputTokens += stats.usage.outputTokens || 0
aggregated.usage.cacheCreateTokens += stats.usage.cacheCreateTokens || 0
aggregated.usage.cacheReadTokens += stats.usage.cacheReadTokens || 0
aggregated.usage.allTokens += stats.usage.allTokens || 0
}
// 聚合总费用
aggregated.usage.cost += stats.totalCost || 0
// 聚合今日使用量
aggregated.dailyUsage.requests += stats.dailyStats.requests || 0
aggregated.dailyUsage.inputTokens += stats.dailyStats.inputTokens || 0
aggregated.dailyUsage.outputTokens += stats.dailyStats.outputTokens || 0
aggregated.dailyUsage.cacheCreateTokens += stats.dailyStats.cacheCreateTokens || 0
aggregated.dailyUsage.cacheReadTokens += stats.dailyStats.cacheReadTokens || 0
aggregated.dailyUsage.allTokens += stats.dailyStats.allTokens || 0
aggregated.dailyUsage.cost += stats.dailyStats.cost || 0
// 聚合本月使用量
aggregated.monthlyUsage.requests += stats.monthlyStats.requests || 0
aggregated.monthlyUsage.inputTokens += stats.monthlyStats.inputTokens || 0
aggregated.monthlyUsage.outputTokens += stats.monthlyStats.outputTokens || 0
aggregated.monthlyUsage.cacheCreateTokens += stats.monthlyStats.cacheCreateTokens || 0
aggregated.monthlyUsage.cacheReadTokens += stats.monthlyStats.cacheReadTokens || 0
aggregated.monthlyUsage.allTokens += stats.monthlyStats.allTokens || 0
aggregated.monthlyUsage.cost += stats.monthlyStats.cost || 0
// 添加到个体统计
individualStats.push({
apiId: stats.apiId,
name: stats.name,
isActive: true,
usage: stats.usage,
dailyUsage: {
...stats.dailyStats,
formattedCost: CostCalculator.formatCost(stats.dailyStats.cost || 0)
},
monthlyUsage: {
...stats.monthlyStats,
formattedCost: CostCalculator.formatCost(stats.monthlyStats.cost || 0)
}
})
}
})
// 格式化费用显示
aggregated.usage.formattedCost = CostCalculator.formatCost(aggregated.usage.cost)
aggregated.dailyUsage.formattedCost = CostCalculator.formatCost(aggregated.dailyUsage.cost)
aggregated.monthlyUsage.formattedCost = CostCalculator.formatCost(aggregated.monthlyUsage.cost)
logger.api(`📊 Batch stats query for ${apiIds.length} keys from ${req.ip || 'unknown'}`)
return res.json({
success: true,
data: {
aggregated,
individual: individualStats
}
})
} catch (error) {
logger.error('❌ Failed to process batch stats query:', error)
return res.status(500).json({
error: 'Internal server error',
message: 'Failed to retrieve batch statistics'
})
}
})
// 📊 批量模型统计查询接口
router.post('/api/batch-model-stats', async (req, res) => {
try {
const { apiIds, period = 'daily' } = req.body
// 验证输入
if (!apiIds || !Array.isArray(apiIds) || apiIds.length === 0) {
return res.status(400).json({
error: 'Invalid input',
message: 'API IDs array is required'
})
}
// 限制最多查询 30 个
if (apiIds.length > 30) {
return res.status(400).json({
error: 'Too many keys',
message: 'Maximum 30 API keys can be queried at once'
})
}
const client = redis.getClientSafe()
const tzDate = redis.getDateInTimezone()
const today = redis.getDateStringInTimezone()
const currentMonth = `${tzDate.getFullYear()}-${String(tzDate.getMonth() + 1).padStart(2, '0')}`
const modelUsageMap = new Map()
// 并行查询所有 API Key 的模型统计
await Promise.all(
apiIds.map(async (apiId) => {
const pattern =
period === 'daily'
? `usage:${apiId}:model:daily:*:${today}`
: `usage:${apiId}:model:monthly:*:${currentMonth}`
const keys = await client.keys(pattern)
for (const key of keys) {
const match = key.match(
period === 'daily'
? /usage:.+:model:daily:(.+):\d{4}-\d{2}-\d{2}$/
: /usage:.+:model:monthly:(.+):\d{4}-\d{2}$/
)
if (!match) {
continue
}
const model = match[1]
const data = await client.hgetall(key)
if (data && Object.keys(data).length > 0) {
if (!modelUsageMap.has(model)) {
modelUsageMap.set(model, {
requests: 0,
inputTokens: 0,
outputTokens: 0,
cacheCreateTokens: 0,
cacheReadTokens: 0,
allTokens: 0
})
}
const modelUsage = modelUsageMap.get(model)
modelUsage.requests += parseInt(data.requests) || 0
modelUsage.inputTokens += parseInt(data.inputTokens) || 0
modelUsage.outputTokens += parseInt(data.outputTokens) || 0
modelUsage.cacheCreateTokens += parseInt(data.cacheCreateTokens) || 0
modelUsage.cacheReadTokens += parseInt(data.cacheReadTokens) || 0
modelUsage.allTokens += parseInt(data.allTokens) || 0
}
}
})
)
// 转换为数组并计算费用
const modelStats = []
for (const [model, usage] of modelUsageMap) {
const usageData = {
input_tokens: usage.inputTokens,
output_tokens: usage.outputTokens,
cache_creation_input_tokens: usage.cacheCreateTokens,
cache_read_input_tokens: usage.cacheReadTokens
}
const costData = CostCalculator.calculateCost(usageData, model)
modelStats.push({
model,
requests: usage.requests,
inputTokens: usage.inputTokens,
outputTokens: usage.outputTokens,
cacheCreateTokens: usage.cacheCreateTokens,
cacheReadTokens: usage.cacheReadTokens,
allTokens: usage.allTokens,
costs: costData.costs,
formatted: costData.formatted,
pricing: costData.pricing
})
}
// 按总 token 数降序排列
modelStats.sort((a, b) => b.allTokens - a.allTokens)
logger.api(`📊 Batch model stats query for ${apiIds.length} keys, period: ${period}`)
return res.json({
success: true,
data: modelStats,
period
})
} catch (error) {
logger.error('❌ Failed to process batch model stats query:', error)
return res.status(500).json({
error: 'Internal server error',
message: 'Failed to retrieve batch model statistics'
})
}
})
// 📊 用户模型统计查询接口 - 安全的自查询接口 // 📊 用户模型统计查询接口 - 安全的自查询接口
router.post('/api/user-model-stats', async (req, res) => { router.post('/api/user-model-stats', async (req, res) => {
try { try {

View File

@@ -14,11 +14,8 @@ const ALLOWED_MODELS = {
'gpt-4-turbo', 'gpt-4-turbo',
'gpt-4o', 'gpt-4o',
'gpt-4o-mini', 'gpt-4o-mini',
'gpt-5',
'gpt-5-mini',
'gpt-35-turbo', 'gpt-35-turbo',
'gpt-35-turbo-16k', 'gpt-35-turbo-16k'
'codex-mini'
], ],
EMBEDDING_MODELS: ['text-embedding-ada-002', 'text-embedding-3-small', 'text-embedding-3-large'] EMBEDDING_MODELS: ['text-embedding-ada-002', 'text-embedding-3-small', 'text-embedding-3-large']
} }
@@ -237,99 +234,6 @@ router.post('/chat/completions', authenticateApiKey, async (req, res) => {
} }
}) })
// 处理响应请求 (gpt-5, gpt-5-mini, codex-mini models)
router.post('/responses', authenticateApiKey, async (req, res) => {
const requestId = `azure_resp_${Date.now()}_${crypto.randomBytes(8).toString('hex')}`
const sessionId = req.sessionId || req.headers['x-session-id'] || null
logger.info(`🚀 Azure OpenAI Responses Request ${requestId}`, {
apiKeyId: req.apiKey?.id,
sessionId,
model: req.body.model,
stream: req.body.stream || false,
messages: req.body.messages?.length || 0
})
try {
// 获取绑定的 Azure OpenAI 账户
let account = null
if (req.apiKey?.azureOpenaiAccountId) {
account = await azureOpenaiAccountService.getAccount(req.apiKey.azureOpenaiAccountId)
if (!account) {
logger.warn(`Bound Azure OpenAI account not found: ${req.apiKey.azureOpenaiAccountId}`)
}
}
// 如果没有绑定账户或账户不可用,选择一个可用账户
if (!account || account.isActive !== 'true') {
account = await azureOpenaiAccountService.selectAvailableAccount(sessionId)
}
// 发送请求到 Azure OpenAI
const response = await azureOpenaiRelayService.handleAzureOpenAIRequest({
account,
requestBody: req.body,
headers: req.headers,
isStream: req.body.stream || false,
endpoint: 'responses'
})
// 处理流式响应
if (req.body.stream) {
await azureOpenaiRelayService.handleStreamResponse(response, res, {
onEnd: async ({ usageData, actualModel }) => {
if (usageData) {
const modelToRecord = actualModel || req.body.model || 'unknown'
await usageReporter.reportOnce(
requestId,
usageData,
req.apiKey.id,
modelToRecord,
account.id
)
}
},
onError: (error) => {
logger.error(`Stream error for request ${requestId}:`, error)
}
})
} else {
// 处理非流式响应
const { usageData, actualModel } = azureOpenaiRelayService.handleNonStreamResponse(
response,
res
)
if (usageData) {
const modelToRecord = actualModel || req.body.model || 'unknown'
await usageReporter.reportOnce(
requestId,
usageData,
req.apiKey.id,
modelToRecord,
account.id
)
}
}
} catch (error) {
logger.error(`Azure OpenAI responses request failed ${requestId}:`, error)
if (!res.headersSent) {
const statusCode = error.response?.status || 500
const errorMessage =
error.response?.data?.error?.message || error.message || 'Internal server error'
res.status(statusCode).json({
error: {
message: errorMessage,
type: 'azure_openai_error',
code: error.code || 'unknown'
}
})
}
}
})
// 处理嵌入请求 // 处理嵌入请求
router.post('/embeddings', authenticateApiKey, async (req, res) => { router.post('/embeddings', authenticateApiKey, async (req, res) => {
const requestId = `azure_embed_${Date.now()}_${crypto.randomBytes(8).toString('hex')}` const requestId = `azure_embed_${Date.now()}_${crypto.randomBytes(8).toString('hex')}`

View File

@@ -50,7 +50,7 @@ router.post('/messages', authenticateApiKey, async (req, res) => {
// 提取请求参数 // 提取请求参数
const { const {
messages, messages,
model = 'gemini-2.5-flash', model = 'gemini-2.0-flash-exp',
temperature = 0.7, temperature = 0.7,
max_tokens = 4096, max_tokens = 4096,
stream = false stream = false
@@ -217,7 +217,7 @@ router.get('/models', authenticateApiKey, async (req, res) => {
object: 'list', object: 'list',
data: [ data: [
{ {
id: 'gemini-2.5-flash', id: 'gemini-2.0-flash-exp',
object: 'model', object: 'model',
created: Date.now() / 1000, created: Date.now() / 1000,
owned_by: 'google' owned_by: 'google'
@@ -311,8 +311,8 @@ async function handleLoadCodeAssist(req, res) {
try { try {
const sessionHash = sessionHelper.generateSessionHash(req.body) const sessionHash = sessionHelper.generateSessionHash(req.body)
// 从路径参数或请求体中获取模型 // 使用统一调度选择账号(传递请求的模型
const requestedModel = req.body.model || req.params.modelName || 'gemini-2.5-flash' const requestedModel = req.body.model
const { accountId } = await unifiedGeminiScheduler.selectAccountForApiKey( const { accountId } = await unifiedGeminiScheduler.selectAccountForApiKey(
req.apiKey, req.apiKey,
sessionHash, sessionHash,
@@ -331,40 +331,24 @@ async function handleLoadCodeAssist(req, res) {
apiKeyId: req.apiKey?.id || 'unknown' apiKeyId: req.apiKey?.id || 'unknown'
}) })
// 解析账户的代理配置 const client = await geminiAccountService.getOauthClient(accessToken, refreshToken)
let proxyConfig = null
if (account.proxy) { // 根据账户配置决定项目ID
try { // 1. 如果账户有项目ID -> 使用账户的项目ID强制覆盖
proxyConfig = typeof account.proxy === 'string' ? JSON.parse(account.proxy) : account.proxy // 2. 如果账户没有项目ID -> 传递 null移除项目ID
} catch (e) { let effectiveProjectId = null
logger.warn('Failed to parse proxy configuration:', e)
} if (projectId) {
// 账户配置了项目ID强制使用它
effectiveProjectId = projectId
logger.info('Using account project ID for loadCodeAssist:', effectiveProjectId)
} else {
// 账户没有配置项目ID确保不传递项目ID
effectiveProjectId = null
logger.info('No project ID in account for loadCodeAssist, removing project parameter')
} }
const client = await geminiAccountService.getOauthClient(accessToken, refreshToken, proxyConfig) const response = await geminiAccountService.loadCodeAssist(client, effectiveProjectId)
// 智能处理项目ID
// 1. 如果账户配置了项目ID -> 使用账户的项目ID覆盖请求中的
// 2. 如果账户没有项目ID -> 使用请求中的cloudaicompanionProject
// 3. 都没有 -> 传null
const effectiveProjectId = projectId || cloudaicompanionProject || null
logger.info('📋 loadCodeAssist项目ID处理逻辑', {
accountProjectId: projectId,
requestProjectId: cloudaicompanionProject,
effectiveProjectId,
decision: projectId
? '使用账户配置'
: cloudaicompanionProject
? '使用请求参数'
: '不使用项目ID'
})
const response = await geminiAccountService.loadCodeAssist(
client,
effectiveProjectId,
proxyConfig
)
res.json(response) res.json(response)
} catch (error) { } catch (error) {
@@ -384,8 +368,8 @@ async function handleOnboardUser(req, res) {
const { tierId, cloudaicompanionProject, metadata } = req.body const { tierId, cloudaicompanionProject, metadata } = req.body
const sessionHash = sessionHelper.generateSessionHash(req.body) const sessionHash = sessionHelper.generateSessionHash(req.body)
// 从路径参数或请求体中获取模型 // 使用统一调度选择账号(传递请求的模型
const requestedModel = req.body.model || req.params.modelName || 'gemini-2.5-flash' const requestedModel = req.body.model
const { accountId } = await unifiedGeminiScheduler.selectAccountForApiKey( const { accountId } = await unifiedGeminiScheduler.selectAccountForApiKey(
req.apiKey, req.apiKey,
sessionHash, sessionHash,
@@ -403,43 +387,30 @@ async function handleOnboardUser(req, res) {
apiKeyId: req.apiKey?.id || 'unknown' apiKeyId: req.apiKey?.id || 'unknown'
}) })
// 解析账户的代理配置 const client = await geminiAccountService.getOauthClient(accessToken, refreshToken)
let proxyConfig = null
if (account.proxy) { // 根据账户配置决定项目ID
try { // 1. 如果账户有项目ID -> 使用账户的项目ID强制覆盖
proxyConfig = typeof account.proxy === 'string' ? JSON.parse(account.proxy) : account.proxy // 2. 如果账户没有项目ID -> 传递 null移除项目ID
} catch (e) { let effectiveProjectId = null
logger.warn('Failed to parse proxy configuration:', e)
} if (projectId) {
// 账户配置了项目ID强制使用它
effectiveProjectId = projectId
logger.info('Using account project ID:', effectiveProjectId)
} else {
// 账户没有配置项目ID确保不传递项目ID即使客户端传了也要移除
effectiveProjectId = null
logger.info('No project ID in account, removing project parameter')
} }
const client = await geminiAccountService.getOauthClient(accessToken, refreshToken, proxyConfig)
// 智能处理项目ID
// 1. 如果账户配置了项目ID -> 使用账户的项目ID覆盖请求中的
// 2. 如果账户没有项目ID -> 使用请求中的cloudaicompanionProject
// 3. 都没有 -> 传null
const effectiveProjectId = projectId || cloudaicompanionProject || null
logger.info('📋 onboardUser项目ID处理逻辑', {
accountProjectId: projectId,
requestProjectId: cloudaicompanionProject,
effectiveProjectId,
decision: projectId
? '使用账户配置'
: cloudaicompanionProject
? '使用请求参数'
: '不使用项目ID'
})
// 如果提供了 tierId直接调用 onboardUser // 如果提供了 tierId直接调用 onboardUser
if (tierId) { if (tierId) {
const response = await geminiAccountService.onboardUser( const response = await geminiAccountService.onboardUser(
client, client,
tierId, tierId,
effectiveProjectId, // 使用处理后的项目ID effectiveProjectId, // 使用处理后的项目ID
metadata, metadata
proxyConfig
) )
res.json(response) res.json(response)
@@ -448,8 +419,7 @@ async function handleOnboardUser(req, res) {
const response = await geminiAccountService.setupUser( const response = await geminiAccountService.setupUser(
client, client,
effectiveProjectId, // 使用处理后的项目ID effectiveProjectId, // 使用处理后的项目ID
metadata, metadata
proxyConfig
) )
res.json(response) res.json(response)
@@ -469,9 +439,7 @@ async function handleCountTokens(req, res) {
try { try {
// 处理请求体结构,支持直接 contents 或 request.contents // 处理请求体结构,支持直接 contents 或 request.contents
const requestData = req.body.request || req.body const requestData = req.body.request || req.body
const { contents } = requestData const { contents, model = 'gemini-2.0-flash-exp' } = requestData
// 从路径参数或请求体中获取模型名
const model = requestData.model || req.params.modelName || 'gemini-2.5-flash'
const sessionHash = sessionHelper.generateSessionHash(req.body) const sessionHash = sessionHelper.generateSessionHash(req.body)
// 验证必需参数 // 验证必需参数
@@ -490,8 +458,7 @@ async function handleCountTokens(req, res) {
sessionHash, sessionHash,
model model
) )
const account = await geminiAccountService.getAccount(accountId) const { accessToken, refreshToken } = await geminiAccountService.getAccount(accountId)
const { accessToken, refreshToken } = account
const version = req.path.includes('v1beta') ? 'v1beta' : 'v1internal' const version = req.path.includes('v1beta') ? 'v1beta' : 'v1internal'
logger.info(`CountTokens request (${version})`, { logger.info(`CountTokens request (${version})`, {
@@ -500,18 +467,8 @@ async function handleCountTokens(req, res) {
apiKeyId: req.apiKey?.id || 'unknown' apiKeyId: req.apiKey?.id || 'unknown'
}) })
// 解析账户的代理配置 const client = await geminiAccountService.getOauthClient(accessToken, refreshToken)
let proxyConfig = null const response = await geminiAccountService.countTokens(client, contents, model)
if (account.proxy) {
try {
proxyConfig = typeof account.proxy === 'string' ? JSON.parse(account.proxy) : account.proxy
} catch (e) {
logger.warn('Failed to parse proxy configuration:', e)
}
}
const client = await geminiAccountService.getOauthClient(accessToken, refreshToken, proxyConfig)
const response = await geminiAccountService.countTokens(client, contents, model, proxyConfig)
res.json(response) res.json(response)
} catch (error) { } catch (error) {
@@ -530,9 +487,7 @@ async function handleCountTokens(req, res) {
// 共用的 generateContent 处理函数 // 共用的 generateContent 处理函数
async function handleGenerateContent(req, res) { async function handleGenerateContent(req, res) {
try { try {
const { project, user_prompt_id, request: requestData } = req.body const { model, project, user_prompt_id, request: requestData } = req.body
// 从路径参数或请求体中获取模型名
const model = req.body.model || req.params.modelName || 'gemini-2.5-flash'
const sessionHash = sessionHelper.generateSessionHash(req.body) const sessionHash = sessionHelper.generateSessionHash(req.body)
// 处理不同格式的请求 // 处理不同格式的请求
@@ -585,6 +540,8 @@ async function handleGenerateContent(req, res) {
apiKeyId: req.apiKey?.id || 'unknown' apiKeyId: req.apiKey?.id || 'unknown'
}) })
const client = await geminiAccountService.getOauthClient(accessToken, refreshToken)
// 解析账户的代理配置 // 解析账户的代理配置
let proxyConfig = null let proxyConfig = null
if (account.proxy) { if (account.proxy) {
@@ -595,26 +552,11 @@ async function handleGenerateContent(req, res) {
} }
} }
const client = await geminiAccountService.getOauthClient(accessToken, refreshToken, proxyConfig)
// 智能处理项目ID
// 1. 如果账户配置了项目ID -> 使用账户的项目ID覆盖请求中的
// 2. 如果账户没有项目ID -> 使用请求中的项目ID如果有的话
// 3. 都没有 -> 传null
const effectiveProjectId = account.projectId || project || null
logger.info('📋 项目ID处理逻辑', {
accountProjectId: account.projectId,
requestProjectId: project,
effectiveProjectId,
decision: account.projectId ? '使用账户配置' : project ? '使用请求参数' : '不使用项目ID'
})
const response = await geminiAccountService.generateContent( const response = await geminiAccountService.generateContent(
client, client,
{ model, request: actualRequestData }, { model, request: actualRequestData },
user_prompt_id, user_prompt_id,
effectiveProjectId, // 使用智能决策的项目ID account.projectId, // 始终使用账户配置的项目ID忽略请求中的project
req.apiKey?.id, // 使用 API Key ID 作为 session ID req.apiKey?.id, // 使用 API Key ID 作为 session ID
proxyConfig // 传递代理配置 proxyConfig // 传递代理配置
) )
@@ -640,7 +582,7 @@ async function handleGenerateContent(req, res) {
} }
} }
res.json(version === 'v1beta' ? response.response : response) res.json(response)
} catch (error) { } catch (error) {
const version = req.path.includes('v1beta') ? 'v1beta' : 'v1internal' const version = req.path.includes('v1beta') ? 'v1beta' : 'v1internal'
// 打印详细的错误信息 // 打印详细的错误信息
@@ -668,9 +610,7 @@ async function handleStreamGenerateContent(req, res) {
let abortController = null let abortController = null
try { try {
const { project, user_prompt_id, request: requestData } = req.body const { model, project, user_prompt_id, request: requestData } = req.body
// 从路径参数或请求体中获取模型名
const model = req.body.model || req.params.modelName || 'gemini-2.5-flash'
const sessionHash = sessionHelper.generateSessionHash(req.body) const sessionHash = sessionHelper.generateSessionHash(req.body)
// 处理不同格式的请求 // 处理不同格式的请求
@@ -734,6 +674,8 @@ async function handleStreamGenerateContent(req, res) {
} }
}) })
const client = await geminiAccountService.getOauthClient(accessToken, refreshToken)
// 解析账户的代理配置 // 解析账户的代理配置
let proxyConfig = null let proxyConfig = null
if (account.proxy) { if (account.proxy) {
@@ -744,26 +686,11 @@ async function handleStreamGenerateContent(req, res) {
} }
} }
const client = await geminiAccountService.getOauthClient(accessToken, refreshToken, proxyConfig)
// 智能处理项目ID
// 1. 如果账户配置了项目ID -> 使用账户的项目ID覆盖请求中的
// 2. 如果账户没有项目ID -> 使用请求中的项目ID如果有的话
// 3. 都没有 -> 传null
const effectiveProjectId = account.projectId || project || null
logger.info('📋 流式请求项目ID处理逻辑', {
accountProjectId: account.projectId,
requestProjectId: project,
effectiveProjectId,
decision: account.projectId ? '使用账户配置' : project ? '使用请求参数' : '不使用项目ID'
})
const streamResponse = await geminiAccountService.generateContentStream( const streamResponse = await geminiAccountService.generateContentStream(
client, client,
{ model, request: actualRequestData }, { model, request: actualRequestData },
user_prompt_id, user_prompt_id,
effectiveProjectId, // 使用智能决策的项目ID account.projectId, // 始终使用账户配置的项目ID忽略请求中的project
req.apiKey?.id, // 使用 API Key ID 作为 session ID req.apiKey?.id, // 使用 API Key ID 作为 session ID
abortController.signal, // 传递中止信号 abortController.signal, // 传递中止信号
proxyConfig // 传递代理配置 proxyConfig // 传递代理配置
@@ -775,28 +702,8 @@ async function handleStreamGenerateContent(req, res) {
res.setHeader('Connection', 'keep-alive') res.setHeader('Connection', 'keep-alive')
res.setHeader('X-Accel-Buffering', 'no') res.setHeader('X-Accel-Buffering', 'no')
// SSE 解析函数
const parseSSELine = (line) => {
if (!line.startsWith('data: ')) {
return { type: 'other', line, data: null }
}
const jsonStr = line.substring(6).trim()
if (!jsonStr || jsonStr === '[DONE]') {
return { type: 'control', line, data: null, jsonStr }
}
try {
const data = JSON.parse(jsonStr)
return { type: 'data', line, data, jsonStr }
} catch (e) {
return { type: 'invalid', line, data: null, jsonStr, error: e }
}
}
// 处理流式响应并捕获usage数据 // 处理流式响应并捕获usage数据
let streamBuffer = '' // 统一的流处理缓冲区 let buffer = ''
let totalUsage = { let totalUsage = {
promptTokenCount: 0, promptTokenCount: 0,
candidatesTokenCount: 0, candidatesTokenCount: 0,
@@ -808,60 +715,32 @@ async function handleStreamGenerateContent(req, res) {
try { try {
const chunkStr = chunk.toString() const chunkStr = chunk.toString()
if (!chunkStr.trim()) { // 直接转发数据到客户端
return if (!res.destroyed) {
res.write(chunkStr)
} }
// 使用统一缓冲区处理不完整的行 // 同时解析数据以捕获usage信息
streamBuffer += chunkStr buffer += chunkStr
const lines = streamBuffer.split('\n') const lines = buffer.split('\n')
streamBuffer = lines.pop() || '' // 保留最后一个不完整的行 buffer = lines.pop() || ''
const processedLines = []
for (const line of lines) { for (const line of lines) {
if (!line.trim()) { if (line.startsWith('data: ') && line.length > 6) {
continue // 跳过空行,不添加到处理队列 try {
} const jsonStr = line.slice(6)
if (jsonStr && jsonStr !== '[DONE]') {
const data = JSON.parse(jsonStr)
// 解析 SSE 行 // 从响应中提取usage数据
const parsed = parseSSELine(line) if (data.response?.usageMetadata) {
totalUsage = data.response.usageMetadata
// 提取 usage 数据(适用于所有版本) logger.debug('📊 Captured Gemini usage data:', totalUsage)
if (parsed.type === 'data' && parsed.data.response?.usageMetadata) { }
totalUsage = parsed.data.response.usageMetadata
logger.debug('📊 Captured Gemini usage data:', totalUsage)
}
// 根据版本处理输出
if (version === 'v1beta') {
if (parsed.type === 'data') {
if (parsed.data.response) {
// 有 response 字段,只返回 response 的内容
processedLines.push(`data: ${JSON.stringify(parsed.data.response)}`)
} else {
// 没有 response 字段,返回整个数据对象
processedLines.push(`data: ${JSON.stringify(parsed.data)}`)
} }
} else if (parsed.type === 'control') { } catch (e) {
// 控制消息(如 [DONE])保持原样 // 忽略解析错误
processedLines.push(line)
} }
// 跳过其他类型的行('other', 'invalid'
}
}
// 发送数据到客户端
if (version === 'v1beta') {
for (const line of processedLines) {
if (!res.destroyed) {
res.write(`${line}\n\n`)
}
}
} else {
// v1internal 直接转发原始数据
if (!res.destroyed) {
res.write(chunkStr)
} }
} }
} catch (error) { } catch (error) {

View File

@@ -311,16 +311,6 @@ router.post('/v1/chat/completions', authenticateApiKey, async (req, res) => {
// 标记账户被使用 // 标记账户被使用
await geminiAccountService.markAccountUsed(account.id) await geminiAccountService.markAccountUsed(account.id)
// 解析账户的代理配置
let proxyConfig = null
if (account.proxy) {
try {
proxyConfig = typeof account.proxy === 'string' ? JSON.parse(account.proxy) : account.proxy
} catch (e) {
logger.warn('Failed to parse proxy configuration:', e)
}
}
// 创建中止控制器 // 创建中止控制器
abortController = new AbortController() abortController = new AbortController()
@@ -335,8 +325,7 @@ router.post('/v1/chat/completions', authenticateApiKey, async (req, res) => {
// 获取OAuth客户端 // 获取OAuth客户端
const client = await geminiAccountService.getOauthClient( const client = await geminiAccountService.getOauthClient(
account.accessToken, account.accessToken,
account.refreshToken, account.refreshToken
proxyConfig
) )
if (actualStream) { if (actualStream) {
// 流式响应 // 流式响应
@@ -352,8 +341,7 @@ router.post('/v1/chat/completions', authenticateApiKey, async (req, res) => {
null, // user_prompt_id null, // user_prompt_id
account.projectId, // 使用有权限的项目ID account.projectId, // 使用有权限的项目ID
apiKeyData.id, // 使用 API Key ID 作为 session ID apiKeyData.id, // 使用 API Key ID 作为 session ID
abortController.signal, // 传递中止信号 abortController.signal // 传递中止信号
proxyConfig // 传递代理配置
) )
// 设置流式响应头 // 设置流式响应头
@@ -553,8 +541,7 @@ router.post('/v1/chat/completions', authenticateApiKey, async (req, res) => {
{ model, request: geminiRequestBody }, { model, request: geminiRequestBody },
null, // user_prompt_id null, // user_prompt_id
account.projectId, // 使用有权限的项目ID account.projectId, // 使用有权限的项目ID
apiKeyData.id, // 使用 API Key ID 作为 session ID apiKeyData.id // 使用 API Key ID 作为 session ID
proxyConfig // 传递代理配置
) )
// 转换为 OpenAI 格式并返回 // 转换为 OpenAI 格式并返回

View File

@@ -2,8 +2,8 @@ const express = require('express')
const axios = require('axios') const axios = require('axios')
const router = express.Router() const router = express.Router()
const logger = require('../utils/logger') const logger = require('../utils/logger')
const config = require('../../config/config')
const { authenticateApiKey } = require('../middleware/auth') const { authenticateApiKey } = require('../middleware/auth')
const claudeAccountService = require('../services/claudeAccountService')
const unifiedOpenAIScheduler = require('../services/unifiedOpenAIScheduler') const unifiedOpenAIScheduler = require('../services/unifiedOpenAIScheduler')
const openaiAccountService = require('../services/openaiAccountService') const openaiAccountService = require('../services/openaiAccountService')
const apiKeyService = require('../services/apiKeyService') const apiKeyService = require('../services/apiKeyService')
@@ -35,31 +35,13 @@ async function getOpenAIAuthToken(apiKeyData, sessionId = null, requestedModel =
} }
// 获取账户详情 // 获取账户详情
let account = await openaiAccountService.getAccount(result.accountId) const account = await openaiAccountService.getAccount(result.accountId)
if (!account || !account.accessToken) { if (!account || !account.accessToken) {
throw new Error(`OpenAI account ${result.accountId} has no valid accessToken`) throw new Error(`OpenAI account ${result.accountId} has no valid accessToken`)
} }
// 检查 token 是否过期并自动刷新(双重保护) // 解密 accessToken
if (openaiAccountService.isTokenExpired(account)) { const accessToken = claudeAccountService._decryptSensitiveData(account.accessToken)
if (account.refreshToken) {
logger.info(`🔄 Token expired, auto-refreshing for account ${account.name} (fallback)`)
try {
await openaiAccountService.refreshAccountToken(result.accountId)
// 重新获取更新后的账户
account = await openaiAccountService.getAccount(result.accountId)
logger.info(`✅ Token refreshed successfully in route handler`)
} catch (refreshError) {
logger.error(`Failed to refresh token for ${account.name}:`, refreshError)
throw new Error(`Token expired and refresh failed: ${refreshError.message}`)
}
} else {
throw new Error(`Token expired and no refresh token available for account ${account.name}`)
}
}
// 解密 accessTokenaccount.accessToken 是加密的)
const accessToken = openaiAccountService.decrypt(account.accessToken)
if (!accessToken) { if (!accessToken) {
throw new Error('Failed to decrypt OpenAI accessToken') throw new Error('Failed to decrypt OpenAI accessToken')
} }
@@ -88,8 +70,7 @@ async function getOpenAIAuthToken(apiKeyData, sessionId = null, requestedModel =
} }
} }
// 主处理函数,供两个路由共享 router.post('/responses', authenticateApiKey, async (req, res) => {
const handleResponses = async (req, res) => {
let upstream = null let upstream = null
try { try {
// 从中间件获取 API Key 数据 // 从中间件获取 API Key 数据
@@ -180,7 +161,7 @@ const handleResponses = async (req, res) => {
// 配置请求选项 // 配置请求选项
const axiosConfig = { const axiosConfig = {
headers, headers,
timeout: config.requestTimeout || 600000, timeout: 60000,
validateStatus: () => true validateStatus: () => true
} }
@@ -207,96 +188,6 @@ const handleResponses = async (req, res) => {
axiosConfig axiosConfig
) )
} }
// 处理 429 限流错误
if (upstream.status === 429) {
logger.warn(`🚫 Rate limit detected for OpenAI account ${accountId} (Codex API)`)
// 解析响应体中的限流信息
let resetsInSeconds = null
let errorData = null
try {
// 对于429错误无论是否是流式请求响应都会是完整的JSON错误对象
if (isStream && upstream.data) {
// 流式响应需要先收集数据
const chunks = []
await new Promise((resolve, reject) => {
upstream.data.on('data', (chunk) => chunks.push(chunk))
upstream.data.on('end', resolve)
upstream.data.on('error', reject)
// 设置超时防止无限等待
setTimeout(resolve, 5000)
})
const fullResponse = Buffer.concat(chunks).toString()
try {
errorData = JSON.parse(fullResponse)
} catch (e) {
logger.error('Failed to parse 429 error response:', e)
logger.debug('Raw response:', fullResponse)
}
} else {
// 非流式响应直接使用data
errorData = upstream.data
}
// 提取重置时间
if (errorData && errorData.error && errorData.error.resets_in_seconds) {
resetsInSeconds = errorData.error.resets_in_seconds
logger.info(
`🕐 Codex rate limit will reset in ${resetsInSeconds} seconds (${Math.ceil(resetsInSeconds / 60)} minutes / ${Math.ceil(resetsInSeconds / 3600)} hours)`
)
} else {
logger.warn(
'⚠️ Could not extract resets_in_seconds from 429 response, using default 60 minutes'
)
}
} catch (e) {
logger.error('⚠️ Failed to parse rate limit error:', e)
}
// 标记账户为限流状态
await unifiedOpenAIScheduler.markAccountRateLimited(
accountId,
'openai',
sessionId ? crypto.createHash('sha256').update(sessionId).digest('hex') : null,
resetsInSeconds
)
// 返回错误响应给客户端
const errorResponse = errorData || {
error: {
type: 'usage_limit_reached',
message: 'The usage limit has been reached',
resets_in_seconds: resetsInSeconds
}
}
if (isStream) {
// 流式响应也需要设置正确的状态码
res.status(429)
res.setHeader('Content-Type', 'text/event-stream')
res.setHeader('Cache-Control', 'no-cache')
res.setHeader('Connection', 'keep-alive')
res.write(`data: ${JSON.stringify(errorResponse)}\n\n`)
res.end()
} else {
res.status(429).json(errorResponse)
}
return
} else if (upstream.status === 200 || upstream.status === 201) {
// 请求成功,检查并移除限流状态
const isRateLimited = await unifiedOpenAIScheduler.isAccountRateLimited(accountId)
if (isRateLimited) {
logger.info(
`✅ Removing rate limit for OpenAI account ${accountId} after successful request`
)
await unifiedOpenAIScheduler.removeAccountRateLimit(accountId, 'openai')
}
}
res.status(upstream.status) res.status(upstream.status)
if (isStream) { if (isStream) {
@@ -331,8 +222,6 @@ const handleResponses = async (req, res) => {
let usageData = null let usageData = null
let actualModel = null let actualModel = null
let usageReported = false let usageReported = false
let rateLimitDetected = false
let rateLimitResetsInSeconds = null
if (!isStream) { if (!isStream) {
// 非流式响应处理 // 非流式响应处理
@@ -411,17 +300,6 @@ const handleResponses = async (req, res) => {
logger.debug('📊 Captured OpenAI usage data:', usageData) logger.debug('📊 Captured OpenAI usage data:', usageData)
} }
} }
// 检查是否有限流错误
if (eventData.error && eventData.error.type === 'usage_limit_reached') {
rateLimitDetected = true
if (eventData.error.resets_in_seconds) {
rateLimitResetsInSeconds = eventData.error.resets_in_seconds
logger.warn(
`🚫 Rate limit detected in stream, resets in ${rateLimitResetsInSeconds} seconds`
)
}
}
} catch (e) { } catch (e) {
// 忽略解析错误 // 忽略解析错误
} }
@@ -493,26 +371,6 @@ const handleResponses = async (req, res) => {
} }
} }
// 如果在流式响应中检测到限流
if (rateLimitDetected) {
logger.warn(`🚫 Processing rate limit for OpenAI account ${accountId} from stream`)
await unifiedOpenAIScheduler.markAccountRateLimited(
accountId,
'openai',
sessionId ? crypto.createHash('sha256').update(sessionId).digest('hex') : null,
rateLimitResetsInSeconds
)
} else if (upstream.status === 200) {
// 流式请求成功,检查并移除限流状态
const isRateLimited = await unifiedOpenAIScheduler.isAccountRateLimited(accountId)
if (isRateLimited) {
logger.info(
`✅ Removing rate limit for OpenAI account ${accountId} after successful stream`
)
await unifiedOpenAIScheduler.removeAccountRateLimit(accountId, 'openai')
}
}
res.end() res.end()
}) })
@@ -544,11 +402,7 @@ const handleResponses = async (req, res) => {
res.status(status).json({ error: { message } }) res.status(status).json({ error: { message } })
} }
} }
} })
// 注册两个路由路径,都使用相同的处理函数
router.post('/responses', authenticateApiKey, handleResponses)
router.post('/v1/responses', authenticateApiKey, handleResponses)
// 使用情况统计端点 // 使用情况统计端点
router.get('/usage', authenticateApiKey, async (req, res) => { router.get('/usage', authenticateApiKey, async (req, res) => {

View File

@@ -1,748 +0,0 @@
const express = require('express')
const router = express.Router()
const ldapService = require('../services/ldapService')
const userService = require('../services/userService')
const apiKeyService = require('../services/apiKeyService')
const logger = require('../utils/logger')
const config = require('../../config/config')
const inputValidator = require('../utils/inputValidator')
const { RateLimiterRedis } = require('rate-limiter-flexible')
const redis = require('../models/redis')
const { authenticateUser, authenticateUserOrAdmin, requireAdmin } = require('../middleware/auth')
// 🚦 配置登录速率限制
// 只基于IP地址限制避免攻击者恶意锁定特定账户
// 延迟初始化速率限制器,确保 Redis 已连接
let ipRateLimiter = null
let strictIpRateLimiter = null
// 初始化速率限制器函数
function initRateLimiters() {
if (!ipRateLimiter) {
try {
const redisClient = redis.getClientSafe()
// IP地址速率限制 - 正常限制
ipRateLimiter = new RateLimiterRedis({
storeClient: redisClient,
keyPrefix: 'login_ip_limiter',
points: 30, // 每个IP允许30次尝试
duration: 900, // 15分钟窗口期
blockDuration: 900 // 超限后封禁15分钟
})
// IP地址速率限制 - 严格限制(用于检测暴力破解)
strictIpRateLimiter = new RateLimiterRedis({
storeClient: redisClient,
keyPrefix: 'login_ip_strict',
points: 100, // 每个IP允许100次尝试
duration: 3600, // 1小时窗口期
blockDuration: 3600 // 超限后封禁1小时
})
} catch (error) {
logger.error('❌ 初始化速率限制器失败:', error)
// 速率限制器初始化失败时继续运行,但记录错误
}
}
return { ipRateLimiter, strictIpRateLimiter }
}
// 🔐 用户登录端点
router.post('/login', async (req, res) => {
try {
const { username, password } = req.body
const clientIp = req.ip || req.connection.remoteAddress || 'unknown'
// 初始化速率限制器(如果尚未初始化)
const limiters = initRateLimiters()
// 检查IP速率限制 - 基础限制
if (limiters.ipRateLimiter) {
try {
await limiters.ipRateLimiter.consume(clientIp)
} catch (rateLimiterRes) {
const retryAfter = Math.round(rateLimiterRes.msBeforeNext / 1000) || 900
logger.security(`🚫 Login rate limit exceeded for IP: ${clientIp}`)
res.set('Retry-After', String(retryAfter))
return res.status(429).json({
error: 'Too many requests',
message: `Too many login attempts from this IP. Please try again later.`
})
}
}
// 检查IP速率限制 - 严格限制(防止暴力破解)
if (limiters.strictIpRateLimiter) {
try {
await limiters.strictIpRateLimiter.consume(clientIp)
} catch (rateLimiterRes) {
const retryAfter = Math.round(rateLimiterRes.msBeforeNext / 1000) || 3600
logger.security(`🚫 Strict rate limit exceeded for IP: ${clientIp} - possible brute force`)
res.set('Retry-After', String(retryAfter))
return res.status(429).json({
error: 'Too many requests',
message: 'Too many login attempts detected. Access temporarily blocked.'
})
}
}
if (!username || !password) {
return res.status(400).json({
error: 'Missing credentials',
message: 'Username and password are required'
})
}
// 验证输入格式
let validatedUsername
try {
validatedUsername = inputValidator.validateUsername(username)
inputValidator.validatePassword(password)
} catch (validationError) {
return res.status(400).json({
error: 'Invalid input',
message: validationError.message
})
}
// 检查用户管理是否启用
if (!config.userManagement.enabled) {
return res.status(503).json({
error: 'Service unavailable',
message: 'User management is not enabled'
})
}
// 检查LDAP是否启用
if (!config.ldap || !config.ldap.enabled) {
return res.status(503).json({
error: 'Service unavailable',
message: 'LDAP authentication is not enabled'
})
}
// 尝试LDAP认证
const authResult = await ldapService.authenticateUserCredentials(validatedUsername, password)
if (!authResult.success) {
// 登录失败
logger.info(`🚫 Failed login attempt for user: ${validatedUsername} from IP: ${clientIp}`)
return res.status(401).json({
error: 'Authentication failed',
message: authResult.message
})
}
// 登录成功
logger.info(`✅ User login successful: ${validatedUsername} from IP: ${clientIp}`)
res.json({
success: true,
message: 'Login successful',
user: {
id: authResult.user.id,
username: authResult.user.username,
email: authResult.user.email,
displayName: authResult.user.displayName,
firstName: authResult.user.firstName,
lastName: authResult.user.lastName,
role: authResult.user.role
},
sessionToken: authResult.sessionToken
})
} catch (error) {
logger.error('❌ User login error:', error)
res.status(500).json({
error: 'Login error',
message: 'Internal server error during login'
})
}
})
// 🚪 用户登出端点
router.post('/logout', authenticateUser, async (req, res) => {
try {
await userService.invalidateUserSession(req.user.sessionToken)
logger.info(`👋 User logout: ${req.user.username}`)
res.json({
success: true,
message: 'Logout successful'
})
} catch (error) {
logger.error('❌ User logout error:', error)
res.status(500).json({
error: 'Logout error',
message: 'Internal server error during logout'
})
}
})
// 👤 获取当前用户信息
router.get('/profile', authenticateUser, async (req, res) => {
try {
const user = await userService.getUserById(req.user.id)
if (!user) {
return res.status(404).json({
error: 'User not found',
message: 'User profile not found'
})
}
res.json({
success: true,
user: {
id: user.id,
username: user.username,
email: user.email,
displayName: user.displayName,
firstName: user.firstName,
lastName: user.lastName,
role: user.role,
isActive: user.isActive,
createdAt: user.createdAt,
lastLoginAt: user.lastLoginAt,
apiKeyCount: user.apiKeyCount,
totalUsage: user.totalUsage
},
config: {
maxApiKeysPerUser: config.userManagement.maxApiKeysPerUser,
allowUserDeleteApiKeys: config.userManagement.allowUserDeleteApiKeys
}
})
} catch (error) {
logger.error('❌ Get user profile error:', error)
res.status(500).json({
error: 'Profile error',
message: 'Failed to retrieve user profile'
})
}
})
// 🔑 获取用户的API Keys
router.get('/api-keys', authenticateUser, async (req, res) => {
try {
const { includeDeleted = 'false' } = req.query
const apiKeys = await apiKeyService.getUserApiKeys(req.user.id, includeDeleted === 'true')
// 移除敏感信息并格式化usage数据
const safeApiKeys = apiKeys.map((key) => {
// Flatten usage structure for frontend compatibility
let flatUsage = {
requests: 0,
inputTokens: 0,
outputTokens: 0,
totalCost: 0
}
if (key.usage && key.usage.total) {
flatUsage = {
requests: key.usage.total.requests || 0,
inputTokens: key.usage.total.inputTokens || 0,
outputTokens: key.usage.total.outputTokens || 0,
totalCost: key.totalCost || 0
}
}
return {
id: key.id,
name: key.name,
description: key.description,
tokenLimit: key.tokenLimit,
isActive: key.isActive,
createdAt: key.createdAt,
lastUsedAt: key.lastUsedAt,
expiresAt: key.expiresAt,
usage: flatUsage,
dailyCost: key.dailyCost,
dailyCostLimit: key.dailyCostLimit,
// 不返回实际的key值只返回前缀和后几位
keyPreview: key.key
? `${key.key.substring(0, 8)}...${key.key.substring(key.key.length - 4)}`
: null,
// Include deletion fields for deleted keys
isDeleted: key.isDeleted,
deletedAt: key.deletedAt,
deletedBy: key.deletedBy,
deletedByType: key.deletedByType
}
})
res.json({
success: true,
apiKeys: safeApiKeys,
total: safeApiKeys.length
})
} catch (error) {
logger.error('❌ Get user API keys error:', error)
res.status(500).json({
error: 'API Keys error',
message: 'Failed to retrieve API keys'
})
}
})
// 🔑 创建新的API Key
router.post('/api-keys', authenticateUser, async (req, res) => {
try {
const { name, description, tokenLimit, expiresAt, dailyCostLimit } = req.body
if (!name || !name.trim()) {
return res.status(400).json({
error: 'Missing name',
message: 'API key name is required'
})
}
// 检查用户API Key数量限制
const userApiKeys = await apiKeyService.getUserApiKeys(req.user.id)
if (userApiKeys.length >= config.userManagement.maxApiKeysPerUser) {
return res.status(400).json({
error: 'API key limit exceeded',
message: `You can only have up to ${config.userManagement.maxApiKeysPerUser} API keys`
})
}
// 创建API Key数据
const apiKeyData = {
name: name.trim(),
description: description?.trim() || '',
userId: req.user.id,
userUsername: req.user.username,
tokenLimit: tokenLimit || null,
expiresAt: expiresAt || null,
dailyCostLimit: dailyCostLimit || null,
createdBy: 'user',
// 设置服务权限为全部服务,确保前端显示“服务权限”为“全部服务”且具备完整访问权限
permissions: 'all'
}
const newApiKey = await apiKeyService.createApiKey(apiKeyData)
// 更新用户API Key数量
await userService.updateUserApiKeyCount(req.user.id, userApiKeys.length + 1)
logger.info(`🔑 User ${req.user.username} created API key: ${name}`)
res.status(201).json({
success: true,
message: 'API key created successfully',
apiKey: {
id: newApiKey.id,
name: newApiKey.name,
description: newApiKey.description,
key: newApiKey.apiKey, // 只在创建时返回完整key
tokenLimit: newApiKey.tokenLimit,
expiresAt: newApiKey.expiresAt,
dailyCostLimit: newApiKey.dailyCostLimit,
createdAt: newApiKey.createdAt
}
})
} catch (error) {
logger.error('❌ Create user API key error:', error)
res.status(500).json({
error: 'API Key creation error',
message: 'Failed to create API key'
})
}
})
// 🗑️ 删除API Key
router.delete('/api-keys/:keyId', authenticateUser, async (req, res) => {
try {
const { keyId } = req.params
// 检查是否允许用户删除自己的API Keys
if (!config.userManagement.allowUserDeleteApiKeys) {
return res.status(403).json({
error: 'Operation not allowed',
message:
'Users are not allowed to delete their own API keys. Please contact an administrator.'
})
}
// 检查API Key是否属于当前用户
const existingKey = await apiKeyService.getApiKeyById(keyId)
if (!existingKey || existingKey.userId !== req.user.id) {
return res.status(404).json({
error: 'API key not found',
message: 'API key not found or you do not have permission to access it'
})
}
await apiKeyService.deleteApiKey(keyId, req.user.username, 'user')
// 更新用户API Key数量
const userApiKeys = await apiKeyService.getUserApiKeys(req.user.id)
await userService.updateUserApiKeyCount(req.user.id, userApiKeys.length)
logger.info(`🗑️ User ${req.user.username} deleted API key: ${existingKey.name}`)
res.json({
success: true,
message: 'API key deleted successfully'
})
} catch (error) {
logger.error('❌ Delete user API key error:', error)
res.status(500).json({
error: 'API Key deletion error',
message: 'Failed to delete API key'
})
}
})
// 📊 获取用户使用统计
router.get('/usage-stats', authenticateUser, async (req, res) => {
try {
const { period = 'week', model } = req.query
// 获取用户的API Keys (including deleted ones for complete usage stats)
const userApiKeys = await apiKeyService.getUserApiKeys(req.user.id, true)
const apiKeyIds = userApiKeys.map((key) => key.id)
if (apiKeyIds.length === 0) {
return res.json({
success: true,
stats: {
totalRequests: 0,
totalInputTokens: 0,
totalOutputTokens: 0,
totalCost: 0,
dailyStats: [],
modelStats: []
}
})
}
// 获取使用统计
const stats = await apiKeyService.getAggregatedUsageStats(apiKeyIds, { period, model })
res.json({
success: true,
stats
})
} catch (error) {
logger.error('❌ Get user usage stats error:', error)
res.status(500).json({
error: 'Usage stats error',
message: 'Failed to retrieve usage statistics'
})
}
})
// === 管理员用户管理端点 ===
// 📋 获取用户列表(管理员)
router.get('/', authenticateUserOrAdmin, requireAdmin, async (req, res) => {
try {
const { page = 1, limit = 20, role, isActive, search } = req.query
const options = {
page: parseInt(page),
limit: parseInt(limit),
role,
isActive: isActive === 'true' ? true : isActive === 'false' ? false : undefined
}
const result = await userService.getAllUsers(options)
// 如果有搜索条件,进行过滤
let filteredUsers = result.users
if (search) {
const searchLower = search.toLowerCase()
filteredUsers = result.users.filter(
(user) =>
user.username.toLowerCase().includes(searchLower) ||
user.displayName.toLowerCase().includes(searchLower) ||
user.email.toLowerCase().includes(searchLower)
)
}
res.json({
success: true,
users: filteredUsers,
pagination: {
total: result.total,
page: result.page,
limit: result.limit,
totalPages: result.totalPages
}
})
} catch (error) {
logger.error('❌ Get users list error:', error)
res.status(500).json({
error: 'Users list error',
message: 'Failed to retrieve users list'
})
}
})
// 👤 获取特定用户信息(管理员)
router.get('/:userId', authenticateUserOrAdmin, requireAdmin, async (req, res) => {
try {
const { userId } = req.params
const user = await userService.getUserById(userId)
if (!user) {
return res.status(404).json({
error: 'User not found',
message: 'User not found'
})
}
// 获取用户的API Keys包括已删除的以保留统计数据
const apiKeys = await apiKeyService.getUserApiKeys(userId, true)
res.json({
success: true,
user: {
...user,
apiKeys: apiKeys.map((key) => {
// Flatten usage structure for frontend compatibility
let flatUsage = {
requests: 0,
inputTokens: 0,
outputTokens: 0,
totalCost: 0
}
if (key.usage && key.usage.total) {
flatUsage = {
requests: key.usage.total.requests || 0,
inputTokens: key.usage.total.inputTokens || 0,
outputTokens: key.usage.total.outputTokens || 0,
totalCost: key.totalCost || 0
}
}
return {
id: key.id,
name: key.name,
description: key.description,
isActive: key.isActive,
createdAt: key.createdAt,
lastUsedAt: key.lastUsedAt,
usage: flatUsage,
keyPreview: key.key
? `${key.key.substring(0, 8)}...${key.key.substring(key.key.length - 4)}`
: null
}
})
}
})
} catch (error) {
logger.error('❌ Get user details error:', error)
res.status(500).json({
error: 'User details error',
message: 'Failed to retrieve user details'
})
}
})
// 🔄 更新用户状态(管理员)
router.patch('/:userId/status', authenticateUserOrAdmin, requireAdmin, async (req, res) => {
try {
const { userId } = req.params
const { isActive } = req.body
if (typeof isActive !== 'boolean') {
return res.status(400).json({
error: 'Invalid status',
message: 'isActive must be a boolean value'
})
}
const updatedUser = await userService.updateUserStatus(userId, isActive)
const adminUser = req.admin?.username || req.user?.username
logger.info(
`🔄 Admin ${adminUser} ${isActive ? 'enabled' : 'disabled'} user: ${updatedUser.username}`
)
res.json({
success: true,
message: `User ${isActive ? 'enabled' : 'disabled'} successfully`,
user: {
id: updatedUser.id,
username: updatedUser.username,
isActive: updatedUser.isActive,
updatedAt: updatedUser.updatedAt
}
})
} catch (error) {
logger.error('❌ Update user status error:', error)
res.status(500).json({
error: 'Update status error',
message: error.message || 'Failed to update user status'
})
}
})
// 🔄 更新用户角色(管理员)
router.patch('/:userId/role', authenticateUserOrAdmin, requireAdmin, async (req, res) => {
try {
const { userId } = req.params
const { role } = req.body
const validRoles = ['user', 'admin']
if (!role || !validRoles.includes(role)) {
return res.status(400).json({
error: 'Invalid role',
message: `Role must be one of: ${validRoles.join(', ')}`
})
}
const updatedUser = await userService.updateUserRole(userId, role)
const adminUser = req.admin?.username || req.user?.username
logger.info(`🔄 Admin ${adminUser} changed user ${updatedUser.username} role to: ${role}`)
res.json({
success: true,
message: `User role updated to ${role} successfully`,
user: {
id: updatedUser.id,
username: updatedUser.username,
role: updatedUser.role,
updatedAt: updatedUser.updatedAt
}
})
} catch (error) {
logger.error('❌ Update user role error:', error)
res.status(500).json({
error: 'Update role error',
message: error.message || 'Failed to update user role'
})
}
})
// 🔑 禁用用户的所有API Keys管理员
router.post('/:userId/disable-keys', authenticateUserOrAdmin, requireAdmin, async (req, res) => {
try {
const { userId } = req.params
const user = await userService.getUserById(userId)
if (!user) {
return res.status(404).json({
error: 'User not found',
message: 'User not found'
})
}
const result = await apiKeyService.disableUserApiKeys(userId)
const adminUser = req.admin?.username || req.user?.username
logger.info(`🔑 Admin ${adminUser} disabled all API keys for user: ${user.username}`)
res.json({
success: true,
message: `Disabled ${result.count} API keys for user ${user.username}`,
disabledCount: result.count
})
} catch (error) {
logger.error('❌ Disable user API keys error:', error)
res.status(500).json({
error: 'Disable keys error',
message: 'Failed to disable user API keys'
})
}
})
// 📊 获取用户使用统计(管理员)
router.get('/:userId/usage-stats', authenticateUserOrAdmin, requireAdmin, async (req, res) => {
try {
const { userId } = req.params
const { period = 'week', model } = req.query
const user = await userService.getUserById(userId)
if (!user) {
return res.status(404).json({
error: 'User not found',
message: 'User not found'
})
}
// 获取用户的API Keys包括已删除的以保留统计数据
const userApiKeys = await apiKeyService.getUserApiKeys(userId, true)
const apiKeyIds = userApiKeys.map((key) => key.id)
if (apiKeyIds.length === 0) {
return res.json({
success: true,
user: {
id: user.id,
username: user.username,
displayName: user.displayName
},
stats: {
totalRequests: 0,
totalInputTokens: 0,
totalOutputTokens: 0,
totalCost: 0,
dailyStats: [],
modelStats: []
}
})
}
// 获取使用统计
const stats = await apiKeyService.getAggregatedUsageStats(apiKeyIds, { period, model })
res.json({
success: true,
user: {
id: user.id,
username: user.username,
displayName: user.displayName
},
stats
})
} catch (error) {
logger.error('❌ Get user usage stats (admin) error:', error)
res.status(500).json({
error: 'Usage stats error',
message: 'Failed to retrieve user usage statistics'
})
}
})
// 📊 获取用户管理统计(管理员)
router.get('/stats/overview', authenticateUserOrAdmin, requireAdmin, async (req, res) => {
try {
const stats = await userService.getUserStats()
res.json({
success: true,
stats
})
} catch (error) {
logger.error('❌ Get user stats overview error:', error)
res.status(500).json({
error: 'Stats error',
message: 'Failed to retrieve user statistics'
})
}
})
// 🔧 测试LDAP连接管理员
router.get('/admin/ldap-test', authenticateUserOrAdmin, requireAdmin, async (req, res) => {
try {
const testResult = await ldapService.testConnection()
res.json({
success: true,
ldapTest: testResult,
config: ldapService.getConfigInfo()
})
} catch (error) {
logger.error('❌ LDAP test error:', error)
res.status(500).json({
error: 'LDAP test error',
message: 'Failed to test LDAP connection'
})
}
})
module.exports = router

View File

@@ -4,7 +4,6 @@ const logger = require('../utils/logger')
const webhookService = require('../services/webhookService') const webhookService = require('../services/webhookService')
const webhookConfigService = require('../services/webhookConfigService') const webhookConfigService = require('../services/webhookConfigService')
const { authenticateAdmin } = require('../middleware/auth') const { authenticateAdmin } = require('../middleware/auth')
const { getISOStringWithTimezone } = require('../utils/dateHelper')
// 获取webhook配置 // 获取webhook配置
router.get('/config', authenticateAdmin, async (req, res) => { router.get('/config', authenticateAdmin, async (req, res) => {
@@ -115,99 +114,27 @@ router.post('/platforms/:id/toggle', authenticateAdmin, async (req, res) => {
// 测试Webhook连通性 // 测试Webhook连通性
router.post('/test', authenticateAdmin, async (req, res) => { router.post('/test', authenticateAdmin, async (req, res) => {
try { try {
const { const { url, type = 'custom', secret, enableSign } = req.body
url,
type = 'custom',
secret,
enableSign,
deviceKey,
serverUrl,
level,
sound,
group,
// SMTP 相关字段
host,
port,
secure,
user,
pass,
from,
to,
ignoreTLS
} = req.body
// Bark平台特殊处理 if (!url) {
if (type === 'bark') { return res.status(400).json({
if (!deviceKey) { error: 'Missing webhook URL',
return res.status(400).json({ message: '请提供webhook URL'
error: 'Missing device key', })
message: '请提供Bark设备密钥'
})
}
// 验证服务器URL如果提供
if (serverUrl) {
try {
new URL(serverUrl)
} catch (urlError) {
return res.status(400).json({
error: 'Invalid server URL format',
message: '请提供有效的Bark服务器URL'
})
}
}
logger.info(`🧪 测试webhook: ${type} - Device Key: ${deviceKey.substring(0, 8)}...`)
} else if (type === 'smtp') {
// SMTP平台验证
if (!host) {
return res.status(400).json({
error: 'Missing SMTP host',
message: '请提供SMTP服务器地址'
})
}
if (!user) {
return res.status(400).json({
error: 'Missing SMTP user',
message: '请提供SMTP用户名'
})
}
if (!pass) {
return res.status(400).json({
error: 'Missing SMTP password',
message: '请提供SMTP密码'
})
}
if (!to) {
return res.status(400).json({
error: 'Missing recipient email',
message: '请提供收件人邮箱'
})
}
logger.info(`🧪 测试webhook: ${type} - ${host}:${port || 587} -> ${to}`)
} else {
// 其他平台验证URL
if (!url) {
return res.status(400).json({
error: 'Missing webhook URL',
message: '请提供webhook URL'
})
}
// 验证URL格式
try {
new URL(url)
} catch (urlError) {
return res.status(400).json({
error: 'Invalid URL format',
message: '请提供有效的webhook URL'
})
}
logger.info(`🧪 测试webhook: ${type} - ${url}`)
} }
// 验证URL格式
try {
new URL(url)
} catch (urlError) {
return res.status(400).json({
error: 'Invalid URL format',
message: '请提供有效的webhook URL'
})
}
logger.info(`🧪 测试webhook: ${type} - ${url}`)
// 创建临时平台配置 // 创建临时平台配置
const platform = { const platform = {
type, type,
@@ -218,44 +145,21 @@ router.post('/test', authenticateAdmin, async (req, res) => {
timeout: 10000 timeout: 10000
} }
// 添加Bark特有字段
if (type === 'bark') {
platform.deviceKey = deviceKey
platform.serverUrl = serverUrl
platform.level = level
platform.sound = sound
platform.group = group
} else if (type === 'smtp') {
// 添加SMTP特有字段
platform.host = host
platform.port = port || 587
platform.secure = secure || false
platform.user = user
platform.pass = pass
platform.from = from
platform.to = to
platform.ignoreTLS = ignoreTLS || false
}
const result = await webhookService.testWebhook(platform) const result = await webhookService.testWebhook(platform)
if (result.success) { if (result.success) {
const identifier = type === 'bark' ? `Device: ${deviceKey.substring(0, 8)}...` : url logger.info(`✅ Webhook测试成功: ${url}`)
logger.info(`✅ Webhook测试成功: ${identifier}`)
res.json({ res.json({
success: true, success: true,
message: 'Webhook测试成功', message: 'Webhook测试成功',
url: type === 'bark' ? undefined : url, url
deviceKey: type === 'bark' ? `${deviceKey.substring(0, 8)}...` : undefined
}) })
} else { } else {
const identifier = type === 'bark' ? `Device: ${deviceKey.substring(0, 8)}...` : url logger.warn(`❌ Webhook测试失败: ${url} - ${result.error}`)
logger.warn(`❌ Webhook测试失败: ${identifier} - ${result.error}`)
res.status(400).json({ res.status(400).json({
success: false, success: false,
message: 'Webhook测试失败', message: 'Webhook测试失败',
url: type === 'bark' ? undefined : url, url,
deviceKey: type === 'bark' ? `${deviceKey.substring(0, 8)}...` : undefined,
error: result.error error: result.error
}) })
} }
@@ -314,7 +218,7 @@ router.post('/test-notification', authenticateAdmin, async (req, res) => {
errorCode, errorCode,
reason, reason,
message, message,
timestamp: getISOStringWithTimezone(new Date()) timestamp: new Date().toISOString()
} }
const result = await webhookService.sendNotification(type, testData) const result = await webhookService.sendNotification(type, testData)

View File

@@ -13,7 +13,7 @@ class AccountGroupService {
* 创建账户分组 * 创建账户分组
* @param {Object} groupData - 分组数据 * @param {Object} groupData - 分组数据
* @param {string} groupData.name - 分组名称 * @param {string} groupData.name - 分组名称
* @param {string} groupData.platform - 平台类型 (claude/gemini/openai) * @param {string} groupData.platform - 平台类型 (claude/gemini)
* @param {string} groupData.description - 分组描述 * @param {string} groupData.description - 分组描述
* @returns {Object} 创建的分组 * @returns {Object} 创建的分组
*/ */
@@ -327,36 +327,12 @@ class AccountGroupService {
} }
} }
/**
* 根据账户ID获取其所属的分组兼容性方法返回单个分组
* @param {string} accountId - 账户ID
* @returns {Object|null} 分组信息
*/
async getAccountGroup(accountId) {
try {
const client = redis.getClientSafe()
const allGroupIds = await client.smembers(this.GROUPS_KEY)
for (const groupId of allGroupIds) {
const isMember = await client.sismember(`${this.GROUP_MEMBERS_PREFIX}${groupId}`, accountId)
if (isMember) {
return await this.getGroup(groupId)
}
}
return null
} catch (error) {
logger.error('❌ 获取账户所属分组失败:', error)
throw error
}
}
/** /**
* 根据账户ID获取其所属的所有分组 * 根据账户ID获取其所属的所有分组
* @param {string} accountId - 账户ID * @param {string} accountId - 账户ID
* @returns {Array} 分组信息数组 * @returns {Array} 分组信息数组
*/ */
async getAccountGroups(accountId) { async getAccountGroup(accountId) {
try { try {
const client = redis.getClientSafe() const client = redis.getClientSafe()
const allGroupIds = await client.smembers(this.GROUPS_KEY) const allGroupIds = await client.smembers(this.GROUPS_KEY)
@@ -381,49 +357,6 @@ class AccountGroupService {
throw error throw error
} }
} }
/**
* 批量设置账户的分组
* @param {string} accountId - 账户ID
* @param {Array} groupIds - 分组ID数组
* @param {string} accountPlatform - 账户平台
*/
async setAccountGroups(accountId, groupIds, accountPlatform) {
try {
// 首先移除账户的所有现有分组
await this.removeAccountFromAllGroups(accountId)
// 然后添加到新的分组中
for (const groupId of groupIds) {
await this.addAccountToGroup(accountId, groupId, accountPlatform)
}
logger.success(`✅ 批量设置账户分组成功: ${accountId} -> [${groupIds.join(', ')}]`)
} catch (error) {
logger.error('❌ 批量设置账户分组失败:', error)
throw error
}
}
/**
* 从所有分组中移除账户
* @param {string} accountId - 账户ID
*/
async removeAccountFromAllGroups(accountId) {
try {
const client = redis.getClientSafe()
const allGroupIds = await client.smembers(this.GROUPS_KEY)
for (const groupId of allGroupIds) {
await client.srem(`${this.GROUP_MEMBERS_PREFIX}${groupId}`, accountId)
}
logger.success(`✅ 从所有分组移除账户成功: ${accountId}`)
} catch (error) {
logger.error('❌ 从所有分组移除账户失败:', error)
throw error
}
}
} }
module.exports = new AccountGroupService() module.exports = new AccountGroupService()

View File

@@ -14,7 +14,7 @@ class ApiKeyService {
const { const {
name = 'Unnamed Key', name = 'Unnamed Key',
description = '', description = '',
tokenLimit = 0, // 默认为0不再使用token限制 tokenLimit = config.limits.defaultTokenLimit,
expiresAt = null, expiresAt = null,
claudeAccountId = null, claudeAccountId = null,
claudeConsoleAccountId = null, claudeConsoleAccountId = null,
@@ -27,17 +27,12 @@ class ApiKeyService {
concurrencyLimit = 0, concurrencyLimit = 0,
rateLimitWindow = null, rateLimitWindow = null,
rateLimitRequests = null, rateLimitRequests = null,
rateLimitCost = null, // 新增:速率限制费用字段
enableModelRestriction = false, enableModelRestriction = false,
restrictedModels = [], restrictedModels = [],
enableClientRestriction = false, enableClientRestriction = false,
allowedClients = [], allowedClients = [],
dailyCostLimit = 0, dailyCostLimit = 0,
weeklyOpusCostLimit = 0, tags = []
tags = [],
activationDays = 0, // 新增激活后有效天数0表示不使用此功能
expirationMode = 'fixed', // 新增:过期模式 'fixed'(固定时间) 或 'activation'(首次使用后激活)
icon = '' // 新增图标base64编码
} = options } = options
// 生成简单的API Key (64字符十六进制) // 生成简单的API Key (64字符十六进制)
@@ -54,7 +49,6 @@ class ApiKeyService {
concurrencyLimit: String(concurrencyLimit ?? 0), concurrencyLimit: String(concurrencyLimit ?? 0),
rateLimitWindow: String(rateLimitWindow ?? 0), rateLimitWindow: String(rateLimitWindow ?? 0),
rateLimitRequests: String(rateLimitRequests ?? 0), rateLimitRequests: String(rateLimitRequests ?? 0),
rateLimitCost: String(rateLimitCost ?? 0), // 新增:速率限制费用字段
isActive: String(isActive), isActive: String(isActive),
claudeAccountId: claudeAccountId || '', claudeAccountId: claudeAccountId || '',
claudeConsoleAccountId: claudeConsoleAccountId || '', claudeConsoleAccountId: claudeConsoleAccountId || '',
@@ -68,19 +62,11 @@ class ApiKeyService {
enableClientRestriction: String(enableClientRestriction || false), enableClientRestriction: String(enableClientRestriction || false),
allowedClients: JSON.stringify(allowedClients || []), allowedClients: JSON.stringify(allowedClients || []),
dailyCostLimit: String(dailyCostLimit || 0), dailyCostLimit: String(dailyCostLimit || 0),
weeklyOpusCostLimit: String(weeklyOpusCostLimit || 0),
tags: JSON.stringify(tags || []), tags: JSON.stringify(tags || []),
activationDays: String(activationDays || 0), // 新增:激活后有效天数
expirationMode: expirationMode || 'fixed', // 新增:过期模式
isActivated: expirationMode === 'fixed' ? 'true' : 'false', // 根据模式决定激活状态
activatedAt: expirationMode === 'fixed' ? new Date().toISOString() : '', // 激活时间
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
lastUsedAt: '', lastUsedAt: '',
expiresAt: expirationMode === 'fixed' ? expiresAt || '' : '', // 固定模式才设置过期时间 expiresAt: expiresAt || '',
createdBy: options.createdBy || 'admin', createdBy: 'admin' // 可以根据需要扩展用户系统
userId: options.userId || '',
userUsername: options.userUsername || '',
icon: icon || '' // 新增图标base64编码
} }
// 保存API Key数据并建立哈希映射 // 保存API Key数据并建立哈希映射
@@ -97,7 +83,6 @@ class ApiKeyService {
concurrencyLimit: parseInt(keyData.concurrencyLimit), concurrencyLimit: parseInt(keyData.concurrencyLimit),
rateLimitWindow: parseInt(keyData.rateLimitWindow || 0), rateLimitWindow: parseInt(keyData.rateLimitWindow || 0),
rateLimitRequests: parseInt(keyData.rateLimitRequests || 0), rateLimitRequests: parseInt(keyData.rateLimitRequests || 0),
rateLimitCost: parseFloat(keyData.rateLimitCost || 0), // 新增:速率限制费用字段
isActive: keyData.isActive === 'true', isActive: keyData.isActive === 'true',
claudeAccountId: keyData.claudeAccountId, claudeAccountId: keyData.claudeAccountId,
claudeConsoleAccountId: keyData.claudeConsoleAccountId, claudeConsoleAccountId: keyData.claudeConsoleAccountId,
@@ -111,12 +96,7 @@ class ApiKeyService {
enableClientRestriction: keyData.enableClientRestriction === 'true', enableClientRestriction: keyData.enableClientRestriction === 'true',
allowedClients: JSON.parse(keyData.allowedClients || '[]'), allowedClients: JSON.parse(keyData.allowedClients || '[]'),
dailyCostLimit: parseFloat(keyData.dailyCostLimit || 0), dailyCostLimit: parseFloat(keyData.dailyCostLimit || 0),
weeklyOpusCostLimit: parseFloat(keyData.weeklyOpusCostLimit || 0),
tags: JSON.parse(keyData.tags || '[]'), tags: JSON.parse(keyData.tags || '[]'),
activationDays: parseInt(keyData.activationDays || 0),
expirationMode: keyData.expirationMode || 'fixed',
isActivated: keyData.isActivated === 'true',
activatedAt: keyData.activatedAt,
createdAt: keyData.createdAt, createdAt: keyData.createdAt,
expiresAt: keyData.expiresAt, expiresAt: keyData.expiresAt,
createdBy: keyData.createdBy createdBy: keyData.createdBy
@@ -145,46 +125,11 @@ class ApiKeyService {
return { valid: false, error: 'API key is disabled' } return { valid: false, error: 'API key is disabled' }
} }
// 处理激活逻辑(仅在 activation 模式下)
if (keyData.expirationMode === 'activation' && keyData.isActivated !== 'true') {
// 首次使用,需要激活
const now = new Date()
const activationDays = parseInt(keyData.activationDays || 30) // 默认30天
const expiresAt = new Date(now.getTime() + activationDays * 24 * 60 * 60 * 1000)
// 更新激活状态和过期时间
keyData.isActivated = 'true'
keyData.activatedAt = now.toISOString()
keyData.expiresAt = expiresAt.toISOString()
keyData.lastUsedAt = now.toISOString()
// 保存到Redis
await redis.setApiKey(keyData.id, keyData)
logger.success(
`🔓 API key activated: ${keyData.id} (${keyData.name}), will expire in ${activationDays} days at ${expiresAt.toISOString()}`
)
}
// 检查是否过期 // 检查是否过期
if (keyData.expiresAt && new Date() > new Date(keyData.expiresAt)) { if (keyData.expiresAt && new Date() > new Date(keyData.expiresAt)) {
return { valid: false, error: 'API key has expired' } return { valid: false, error: 'API key has expired' }
} }
// 如果API Key属于某个用户检查用户是否被禁用
if (keyData.userId) {
try {
const userService = require('./userService')
const user = await userService.getUserById(keyData.userId, false)
if (!user || !user.isActive) {
return { valid: false, error: 'User account is disabled' }
}
} catch (error) {
logger.error('❌ Error checking user status during API key validation:', error)
return { valid: false, error: 'Unable to validate user status' }
}
}
// 获取使用统计(供返回数据使用) // 获取使用统计(供返回数据使用)
const usage = await redis.getUsageStats(keyData.id) const usage = await redis.getUsageStats(keyData.id)
@@ -239,15 +184,12 @@ class ApiKeyService {
concurrencyLimit: parseInt(keyData.concurrencyLimit || 0), concurrencyLimit: parseInt(keyData.concurrencyLimit || 0),
rateLimitWindow: parseInt(keyData.rateLimitWindow || 0), rateLimitWindow: parseInt(keyData.rateLimitWindow || 0),
rateLimitRequests: parseInt(keyData.rateLimitRequests || 0), rateLimitRequests: parseInt(keyData.rateLimitRequests || 0),
rateLimitCost: parseFloat(keyData.rateLimitCost || 0), // 新增:速率限制费用字段
enableModelRestriction: keyData.enableModelRestriction === 'true', enableModelRestriction: keyData.enableModelRestriction === 'true',
restrictedModels, restrictedModels,
enableClientRestriction: keyData.enableClientRestriction === 'true', enableClientRestriction: keyData.enableClientRestriction === 'true',
allowedClients, allowedClients,
dailyCostLimit: parseFloat(keyData.dailyCostLimit || 0), dailyCostLimit: parseFloat(keyData.dailyCostLimit || 0),
weeklyOpusCostLimit: parseFloat(keyData.weeklyOpusCostLimit || 0),
dailyCost: dailyCost || 0, dailyCost: dailyCost || 0,
weeklyOpusCost: (await redis.getWeeklyOpusCost(keyData.id)) || 0,
tags, tags,
usage usage
} }
@@ -258,177 +200,35 @@ class ApiKeyService {
} }
} }
// 🔍 验证API Key仅用于统计查询不触发激活
async validateApiKeyForStats(apiKey) {
try {
if (!apiKey || !apiKey.startsWith(this.prefix)) {
return { valid: false, error: 'Invalid API key format' }
}
// 计算API Key的哈希值
const hashedKey = this._hashApiKey(apiKey)
// 通过哈希值直接查找API Key性能优化
const keyData = await redis.findApiKeyByHash(hashedKey)
if (!keyData) {
return { valid: false, error: 'API key not found' }
}
// 检查是否激活
if (keyData.isActive !== 'true') {
return { valid: false, error: 'API key is disabled' }
}
// 注意:这里不处理激活逻辑,保持 API Key 的未激活状态
// 检查是否过期(仅对已激活的 Key 检查)
if (
keyData.isActivated === 'true' &&
keyData.expiresAt &&
new Date() > new Date(keyData.expiresAt)
) {
return { valid: false, error: 'API key has expired' }
}
// 如果API Key属于某个用户检查用户是否被禁用
if (keyData.userId) {
try {
const userService = require('./userService')
const user = await userService.getUserById(keyData.userId, false)
if (!user || !user.isActive) {
return { valid: false, error: 'User account is disabled' }
}
} catch (userError) {
// 如果用户服务出错记录但不影响API Key验证
logger.warn(`Failed to check user status for API key ${keyData.id}:`, userError)
}
}
// 获取当日费用
const dailyCost = (await redis.getDailyCost(keyData.id)) || 0
// 获取使用统计
const usage = await redis.getUsageStats(keyData.id)
// 解析限制模型数据
let restrictedModels = []
try {
restrictedModels = keyData.restrictedModels ? JSON.parse(keyData.restrictedModels) : []
} catch (e) {
restrictedModels = []
}
// 解析允许的客户端
let allowedClients = []
try {
allowedClients = keyData.allowedClients ? JSON.parse(keyData.allowedClients) : []
} catch (e) {
allowedClients = []
}
// 解析标签
let tags = []
try {
tags = keyData.tags ? JSON.parse(keyData.tags) : []
} catch (e) {
tags = []
}
return {
valid: true,
keyData: {
id: keyData.id,
name: keyData.name,
description: keyData.description,
createdAt: keyData.createdAt,
expiresAt: keyData.expiresAt,
// 添加激活相关字段
expirationMode: keyData.expirationMode || 'fixed',
isActivated: keyData.isActivated === 'true',
activationDays: parseInt(keyData.activationDays || 0),
activatedAt: keyData.activatedAt || null,
claudeAccountId: keyData.claudeAccountId,
claudeConsoleAccountId: keyData.claudeConsoleAccountId,
geminiAccountId: keyData.geminiAccountId,
openaiAccountId: keyData.openaiAccountId,
azureOpenaiAccountId: keyData.azureOpenaiAccountId,
bedrockAccountId: keyData.bedrockAccountId,
permissions: keyData.permissions || 'all',
tokenLimit: parseInt(keyData.tokenLimit),
concurrencyLimit: parseInt(keyData.concurrencyLimit || 0),
rateLimitWindow: parseInt(keyData.rateLimitWindow || 0),
rateLimitRequests: parseInt(keyData.rateLimitRequests || 0),
rateLimitCost: parseFloat(keyData.rateLimitCost || 0),
enableModelRestriction: keyData.enableModelRestriction === 'true',
restrictedModels,
enableClientRestriction: keyData.enableClientRestriction === 'true',
allowedClients,
dailyCostLimit: parseFloat(keyData.dailyCostLimit || 0),
weeklyOpusCostLimit: parseFloat(keyData.weeklyOpusCostLimit || 0),
dailyCost: dailyCost || 0,
weeklyOpusCost: (await redis.getWeeklyOpusCost(keyData.id)) || 0,
tags,
usage
}
}
} catch (error) {
logger.error('❌ API key validation error (stats):', error)
return { valid: false, error: 'Internal validation error' }
}
}
// 📋 获取所有API Keys // 📋 获取所有API Keys
async getAllApiKeys(includeDeleted = false) { async getAllApiKeys() {
try { try {
let apiKeys = await redis.getAllApiKeys() const apiKeys = await redis.getAllApiKeys()
const client = redis.getClientSafe() const client = redis.getClientSafe()
// 默认过滤掉已删除的API Keys
if (!includeDeleted) {
apiKeys = apiKeys.filter((key) => key.isDeleted !== 'true')
}
// 为每个key添加使用统计和当前并发数 // 为每个key添加使用统计和当前并发数
for (const key of apiKeys) { for (const key of apiKeys) {
key.usage = await redis.getUsageStats(key.id) key.usage = await redis.getUsageStats(key.id)
const costStats = await redis.getCostStats(key.id)
// Add cost information to usage object for frontend compatibility
if (key.usage && costStats) {
key.usage.total = key.usage.total || {}
key.usage.total.cost = costStats.total
key.usage.totalCost = costStats.total
}
key.totalCost = costStats ? costStats.total : 0
key.tokenLimit = parseInt(key.tokenLimit) key.tokenLimit = parseInt(key.tokenLimit)
key.concurrencyLimit = parseInt(key.concurrencyLimit || 0) key.concurrencyLimit = parseInt(key.concurrencyLimit || 0)
key.rateLimitWindow = parseInt(key.rateLimitWindow || 0) key.rateLimitWindow = parseInt(key.rateLimitWindow || 0)
key.rateLimitRequests = parseInt(key.rateLimitRequests || 0) key.rateLimitRequests = parseInt(key.rateLimitRequests || 0)
key.rateLimitCost = parseFloat(key.rateLimitCost || 0) // 新增:速率限制费用字段
key.currentConcurrency = await redis.getConcurrency(key.id) key.currentConcurrency = await redis.getConcurrency(key.id)
key.isActive = key.isActive === 'true' key.isActive = key.isActive === 'true'
key.enableModelRestriction = key.enableModelRestriction === 'true' key.enableModelRestriction = key.enableModelRestriction === 'true'
key.enableClientRestriction = key.enableClientRestriction === 'true' key.enableClientRestriction = key.enableClientRestriction === 'true'
key.permissions = key.permissions || 'all' // 兼容旧数据 key.permissions = key.permissions || 'all' // 兼容旧数据
key.dailyCostLimit = parseFloat(key.dailyCostLimit || 0) key.dailyCostLimit = parseFloat(key.dailyCostLimit || 0)
key.weeklyOpusCostLimit = parseFloat(key.weeklyOpusCostLimit || 0)
key.dailyCost = (await redis.getDailyCost(key.id)) || 0 key.dailyCost = (await redis.getDailyCost(key.id)) || 0
key.weeklyOpusCost = (await redis.getWeeklyOpusCost(key.id)) || 0
key.activationDays = parseInt(key.activationDays || 0)
key.expirationMode = key.expirationMode || 'fixed'
key.isActivated = key.isActivated === 'true'
key.activatedAt = key.activatedAt || null
// 获取当前时间窗口的请求次数Token使用量和费用 // 获取当前时间窗口的请求次数Token使用量
if (key.rateLimitWindow > 0) { if (key.rateLimitWindow > 0) {
const requestCountKey = `rate_limit:requests:${key.id}` const requestCountKey = `rate_limit:requests:${key.id}`
const tokenCountKey = `rate_limit:tokens:${key.id}` const tokenCountKey = `rate_limit:tokens:${key.id}`
const costCountKey = `rate_limit:cost:${key.id}` // 新增:费用计数器
const windowStartKey = `rate_limit:window_start:${key.id}` const windowStartKey = `rate_limit:window_start:${key.id}`
key.currentWindowRequests = parseInt((await client.get(requestCountKey)) || '0') key.currentWindowRequests = parseInt((await client.get(requestCountKey)) || '0')
key.currentWindowTokens = parseInt((await client.get(tokenCountKey)) || '0') key.currentWindowTokens = parseInt((await client.get(tokenCountKey)) || '0')
key.currentWindowCost = parseFloat((await client.get(costCountKey)) || '0') // 新增:当前窗口费用
// 获取窗口开始时间和计算剩余时间 // 获取窗口开始时间和计算剩余时间
const windowStart = await client.get(windowStartKey) const windowStart = await client.get(windowStartKey)
@@ -451,7 +251,6 @@ class ApiKeyService {
// 重置计数为0因为窗口已过期 // 重置计数为0因为窗口已过期
key.currentWindowRequests = 0 key.currentWindowRequests = 0
key.currentWindowTokens = 0 key.currentWindowTokens = 0
key.currentWindowCost = 0 // 新增:重置费用
} }
} else { } else {
// 窗口还未开始(没有任何请求) // 窗口还未开始(没有任何请求)
@@ -462,7 +261,6 @@ class ApiKeyService {
} else { } else {
key.currentWindowRequests = 0 key.currentWindowRequests = 0
key.currentWindowTokens = 0 key.currentWindowTokens = 0
key.currentWindowCost = 0 // 新增:重置费用
key.windowStartTime = null key.windowStartTime = null
key.windowEndTime = null key.windowEndTime = null
key.windowRemainingSeconds = null key.windowRemainingSeconds = null
@@ -509,7 +307,6 @@ class ApiKeyService {
'concurrencyLimit', 'concurrencyLimit',
'rateLimitWindow', 'rateLimitWindow',
'rateLimitRequests', 'rateLimitRequests',
'rateLimitCost', // 新增:速率限制费用字段
'isActive', 'isActive',
'claudeAccountId', 'claudeAccountId',
'claudeConsoleAccountId', 'claudeConsoleAccountId',
@@ -519,20 +316,12 @@ class ApiKeyService {
'bedrockAccountId', // 添加 Bedrock 账号ID 'bedrockAccountId', // 添加 Bedrock 账号ID
'permissions', 'permissions',
'expiresAt', 'expiresAt',
'activationDays', // 新增:激活后有效天数
'expirationMode', // 新增:过期模式
'isActivated', // 新增:是否已激活
'activatedAt', // 新增:激活时间
'enableModelRestriction', 'enableModelRestriction',
'restrictedModels', 'restrictedModels',
'enableClientRestriction', 'enableClientRestriction',
'allowedClients', 'allowedClients',
'dailyCostLimit', 'dailyCostLimit',
'weeklyOpusCostLimit', 'tags'
'tags',
'userId', // 新增用户ID所有者变更
'userUsername', // 新增:用户名(所有者变更)
'createdBy' // 新增:创建者(所有者变更)
] ]
const updatedData = { ...keyData } const updatedData = { ...keyData }
@@ -541,16 +330,9 @@ class ApiKeyService {
if (field === 'restrictedModels' || field === 'allowedClients' || field === 'tags') { if (field === 'restrictedModels' || field === 'allowedClients' || field === 'tags') {
// 特殊处理数组字段 // 特殊处理数组字段
updatedData[field] = JSON.stringify(value || []) updatedData[field] = JSON.stringify(value || [])
} else if ( } else if (field === 'enableModelRestriction' || field === 'enableClientRestriction') {
field === 'enableModelRestriction' ||
field === 'enableClientRestriction' ||
field === 'isActivated'
) {
// 布尔值转字符串 // 布尔值转字符串
updatedData[field] = String(value) updatedData[field] = String(value)
} else if (field === 'expiresAt' || field === 'activatedAt') {
// 日期字段保持原样不要toString()
updatedData[field] = value || ''
} else { } else {
updatedData[field] = (value !== null && value !== undefined ? value : '').toString() updatedData[field] = (value !== null && value !== undefined ? value : '').toString()
} }
@@ -571,32 +353,16 @@ class ApiKeyService {
} }
} }
// 🗑️ 删除API Key (保留使用统计) // 🗑️ 删除API Key
async deleteApiKey(keyId, deletedBy = 'system', deletedByType = 'system') { async deleteApiKey(keyId) {
try { try {
const keyData = await redis.getApiKey(keyId) const result = await redis.deleteApiKey(keyId)
if (!keyData || Object.keys(keyData).length === 0) {
if (result === 0) {
throw new Error('API key not found') throw new Error('API key not found')
} }
// 标记为已删除,保留所有数据和统计信息 logger.success(`🗑️ Deleted API key: ${keyId}`)
const updatedData = {
...keyData,
isDeleted: 'true',
deletedAt: new Date().toISOString(),
deletedBy,
deletedByType, // 'user', 'admin', 'system'
isActive: 'false' // 同时禁用
}
await redis.setApiKey(keyId, updatedData)
// 从哈希映射中移除这样就不能再使用这个key进行API调用
if (keyData.apiKey) {
await redis.deleteApiKeyHash(keyData.apiKey)
}
logger.success(`🗑️ Soft deleted API key: ${keyId} by ${deletedBy} (${deletedByType})`)
return { success: true } return { success: true }
} catch (error) { } catch (error) {
@@ -605,139 +371,6 @@ class ApiKeyService {
} }
} }
// 🔄 恢复已删除的API Key
async restoreApiKey(keyId, restoredBy = 'system', restoredByType = 'system') {
try {
const keyData = await redis.getApiKey(keyId)
if (!keyData || Object.keys(keyData).length === 0) {
throw new Error('API key not found')
}
// 检查是否确实是已删除的key
if (keyData.isDeleted !== 'true') {
throw new Error('API key is not deleted')
}
// 准备更新的数据
const updatedData = { ...keyData }
updatedData.isActive = 'true'
updatedData.restoredAt = new Date().toISOString()
updatedData.restoredBy = restoredBy
updatedData.restoredByType = restoredByType
// 从更新的数据中移除删除相关的字段
delete updatedData.isDeleted
delete updatedData.deletedAt
delete updatedData.deletedBy
delete updatedData.deletedByType
// 保存更新后的数据
await redis.setApiKey(keyId, updatedData)
// 使用Redis的hdel命令删除不需要的字段
const keyName = `apikey:${keyId}`
await redis.client.hdel(keyName, 'isDeleted', 'deletedAt', 'deletedBy', 'deletedByType')
// 重新建立哈希映射恢复API Key的使用能力
if (keyData.apiKey) {
await redis.setApiKeyHash(keyData.apiKey, {
id: keyId,
name: keyData.name,
isActive: 'true'
})
}
logger.success(`✅ Restored API key: ${keyId} by ${restoredBy} (${restoredByType})`)
return { success: true, apiKey: updatedData }
} catch (error) {
logger.error('❌ Failed to restore API key:', error)
throw error
}
}
// 🗑️ 彻底删除API Key物理删除
async permanentDeleteApiKey(keyId) {
try {
const keyData = await redis.getApiKey(keyId)
if (!keyData || Object.keys(keyData).length === 0) {
throw new Error('API key not found')
}
// 确保只能彻底删除已经软删除的key
if (keyData.isDeleted !== 'true') {
throw new Error('只能彻底删除已经删除的API Key')
}
// 删除所有相关的使用统计数据
const today = new Date().toISOString().split('T')[0]
const yesterday = new Date(Date.now() - 86400000).toISOString().split('T')[0]
// 删除每日统计
await redis.client.del(`usage:daily:${today}:${keyId}`)
await redis.client.del(`usage:daily:${yesterday}:${keyId}`)
// 删除月度统计
const currentMonth = today.substring(0, 7)
await redis.client.del(`usage:monthly:${currentMonth}:${keyId}`)
// 删除所有相关的统计键(通过模式匹配)
const usageKeys = await redis.client.keys(`usage:*:${keyId}*`)
if (usageKeys.length > 0) {
await redis.client.del(...usageKeys)
}
// 删除API Key本身
await redis.deleteApiKey(keyId)
logger.success(`🗑️ Permanently deleted API key: ${keyId}`)
return { success: true }
} catch (error) {
logger.error('❌ Failed to permanently delete API key:', error)
throw error
}
}
// 🧹 清空所有已删除的API Keys
async clearAllDeletedApiKeys() {
try {
const allKeys = await this.getAllApiKeys(true)
const deletedKeys = allKeys.filter((key) => key.isDeleted === 'true')
let successCount = 0
let failedCount = 0
const errors = []
for (const key of deletedKeys) {
try {
await this.permanentDeleteApiKey(key.id)
successCount++
} catch (error) {
failedCount++
errors.push({
keyId: key.id,
keyName: key.name,
error: error.message
})
}
}
logger.success(`🧹 Cleared deleted API keys: ${successCount} success, ${failedCount} failed`)
return {
success: true,
total: deletedKeys.length,
successCount,
failedCount,
errors
}
} catch (error) {
logger.error('❌ Failed to clear all deleted API keys:', error)
throw error
}
}
// 📊 记录使用情况支持缓存token和账户级别统计 // 📊 记录使用情况支持缓存token和账户级别统计
async recordUsage( async recordUsage(
keyId, keyId,
@@ -763,13 +396,6 @@ class ApiKeyService {
model model
) )
// 检查是否为 1M 上下文请求
let isLongContextRequest = false
if (model && model.includes('[1m]')) {
const totalInputTokens = inputTokens + cacheCreateTokens + cacheReadTokens
isLongContextRequest = totalInputTokens > 200000
}
// 记录API Key级别的使用统计 // 记录API Key级别的使用统计
await redis.incrementTokenUsage( await redis.incrementTokenUsage(
keyId, keyId,
@@ -778,10 +404,7 @@ class ApiKeyService {
outputTokens, outputTokens,
cacheCreateTokens, cacheCreateTokens,
cacheReadTokens, cacheReadTokens,
model, model
0, // ephemeral5mTokens - 暂时为0后续处理
0, // ephemeral1hTokens - 暂时为0后续处理
isLongContextRequest
) )
// 记录费用统计 // 记录费用统计
@@ -810,8 +433,7 @@ class ApiKeyService {
outputTokens, outputTokens,
cacheCreateTokens, cacheCreateTokens,
cacheReadTokens, cacheReadTokens,
model, model
isLongContextRequest
) )
logger.database( logger.database(
`📊 Recorded account usage: ${accountId} - ${totalTokens} tokens (API Key: ${keyId})` `📊 Recorded account usage: ${accountId} - ${totalTokens} tokens (API Key: ${keyId})`
@@ -838,38 +460,8 @@ class ApiKeyService {
} }
} }
// 📊 记录 Opus 模型费用(仅限 claude 和 claude-console 账户)
async recordOpusCost(keyId, cost, model, accountType) {
try {
// 判断是否为 Opus 模型
if (!model || !model.toLowerCase().includes('claude-opus')) {
return // 不是 Opus 模型,直接返回
}
// 判断是否为 claude 或 claude-console 账户
if (!accountType || (accountType !== 'claude' && accountType !== 'claude-console')) {
logger.debug(`⚠️ Skipping Opus cost recording for non-Claude account type: ${accountType}`)
return // 不是 claude 账户,直接返回
}
// 记录 Opus 周费用
await redis.incrementWeeklyOpusCost(keyId, cost)
logger.database(
`💰 Recorded Opus weekly cost for ${keyId}: $${cost.toFixed(6)}, model: ${model}, account type: ${accountType}`
)
} catch (error) {
logger.error('❌ Failed to record Opus cost:', error)
}
}
// 📊 记录使用情况(新版本,支持详细的缓存类型) // 📊 记录使用情况(新版本,支持详细的缓存类型)
async recordUsageWithDetails( async recordUsageWithDetails(keyId, usageObject, model = 'unknown', accountId = null) {
keyId,
usageObject,
model = 'unknown',
accountId = null,
accountType = null
) {
try { try {
// 提取 token 数量 // 提取 token 数量
const inputTokens = usageObject.input_tokens || 0 const inputTokens = usageObject.input_tokens || 0
@@ -913,8 +505,7 @@ class ApiKeyService {
cacheReadTokens, cacheReadTokens,
model, model,
ephemeral5mTokens, // 传递5分钟缓存 tokens ephemeral5mTokens, // 传递5分钟缓存 tokens
ephemeral1hTokens, // 传递1小时缓存 tokens ephemeral1hTokens // 传递1小时缓存 tokens
costInfo.isLongContextRequest || false // 传递 1M 上下文请求标记
) )
// 记录费用统计 // 记录费用统计
@@ -924,9 +515,6 @@ class ApiKeyService {
`💰 Recorded cost for ${keyId}: $${costInfo.totalCost.toFixed(6)}, model: ${model}` `💰 Recorded cost for ${keyId}: $${costInfo.totalCost.toFixed(6)}, model: ${model}`
) )
// 记录 Opus 周费用(如果适用)
await this.recordOpusCost(keyId, costInfo.totalCost, model, accountType)
// 记录详细的缓存费用(如果有) // 记录详细的缓存费用(如果有)
if (costInfo.ephemeral5mCost > 0 || costInfo.ephemeral1hCost > 0) { if (costInfo.ephemeral5mCost > 0 || costInfo.ephemeral1hCost > 0) {
logger.database( logger.database(
@@ -953,8 +541,7 @@ class ApiKeyService {
outputTokens, outputTokens,
cacheCreateTokens, cacheCreateTokens,
cacheReadTokens, cacheReadTokens,
model, model
costInfo.isLongContextRequest || false
) )
logger.database( logger.database(
`📊 Recorded account usage: ${accountId} - ${totalTokens} tokens (API Key: ${keyId})` `📊 Recorded account usage: ${accountId} - ${totalTokens} tokens (API Key: ${keyId})`
@@ -1021,225 +608,6 @@ class ApiKeyService {
return await redis.getAllAccountsUsageStats() return await redis.getAllAccountsUsageStats()
} }
// === 用户相关方法 ===
// 🔑 创建API Key支持用户
async createApiKey(options = {}) {
return await this.generateApiKey(options)
}
// 👤 获取用户的API Keys
async getUserApiKeys(userId, includeDeleted = false) {
try {
const allKeys = await redis.getAllApiKeys()
let userKeys = allKeys.filter((key) => key.userId === userId)
// 默认过滤掉已删除的API Keys
if (!includeDeleted) {
userKeys = userKeys.filter((key) => key.isDeleted !== 'true')
}
// Populate usage stats for each user's API key (same as getAllApiKeys does)
const userKeysWithUsage = []
for (const key of userKeys) {
const usage = await redis.getUsageStats(key.id)
const dailyCost = (await redis.getDailyCost(key.id)) || 0
const costStats = await redis.getCostStats(key.id)
userKeysWithUsage.push({
id: key.id,
name: key.name,
description: key.description,
key: key.apiKey ? `${this.prefix}****${key.apiKey.slice(-4)}` : null, // 只显示前缀和后4位
tokenLimit: parseInt(key.tokenLimit || 0),
isActive: key.isActive === 'true',
createdAt: key.createdAt,
lastUsedAt: key.lastUsedAt,
expiresAt: key.expiresAt,
usage,
dailyCost,
totalCost: costStats.total,
dailyCostLimit: parseFloat(key.dailyCostLimit || 0),
userId: key.userId,
userUsername: key.userUsername,
createdBy: key.createdBy,
// Include deletion fields for deleted keys
isDeleted: key.isDeleted,
deletedAt: key.deletedAt,
deletedBy: key.deletedBy,
deletedByType: key.deletedByType
})
}
return userKeysWithUsage
} catch (error) {
logger.error('❌ Failed to get user API keys:', error)
return []
}
}
// 🔍 通过ID获取API Key检查权限
async getApiKeyById(keyId, userId = null) {
try {
const keyData = await redis.getApiKey(keyId)
if (!keyData) {
return null
}
// 如果指定了用户ID检查权限
if (userId && keyData.userId !== userId) {
return null
}
return {
id: keyData.id,
name: keyData.name,
description: keyData.description,
key: keyData.apiKey,
tokenLimit: parseInt(keyData.tokenLimit || 0),
isActive: keyData.isActive === 'true',
createdAt: keyData.createdAt,
lastUsedAt: keyData.lastUsedAt,
expiresAt: keyData.expiresAt,
userId: keyData.userId,
userUsername: keyData.userUsername,
createdBy: keyData.createdBy,
permissions: keyData.permissions,
dailyCostLimit: parseFloat(keyData.dailyCostLimit || 0)
}
} catch (error) {
logger.error('❌ Failed to get API key by ID:', error)
return null
}
}
// 🔄 重新生成API Key
async regenerateApiKey(keyId) {
try {
const existingKey = await redis.getApiKey(keyId)
if (!existingKey) {
throw new Error('API key not found')
}
// 生成新的key
const newApiKey = `${this.prefix}${this._generateSecretKey()}`
const newHashedKey = this._hashApiKey(newApiKey)
// 删除旧的哈希映射
const oldHashedKey = existingKey.apiKey
await redis.deleteApiKeyHash(oldHashedKey)
// 更新key数据
const updatedKeyData = {
...existingKey,
apiKey: newHashedKey,
updatedAt: new Date().toISOString()
}
// 保存新数据并建立新的哈希映射
await redis.setApiKey(keyId, updatedKeyData, newHashedKey)
logger.info(`🔄 Regenerated API key: ${existingKey.name} (${keyId})`)
return {
id: keyId,
name: existingKey.name,
key: newApiKey, // 返回完整的新key
updatedAt: updatedKeyData.updatedAt
}
} catch (error) {
logger.error('❌ Failed to regenerate API key:', error)
throw error
}
}
// 🗑️ 硬删除API Key (完全移除)
async hardDeleteApiKey(keyId) {
try {
const keyData = await redis.getApiKey(keyId)
if (!keyData) {
throw new Error('API key not found')
}
// 删除key数据和哈希映射
await redis.deleteApiKey(keyId)
await redis.deleteApiKeyHash(keyData.apiKey)
logger.info(`🗑️ Deleted API key: ${keyData.name} (${keyId})`)
return true
} catch (error) {
logger.error('❌ Failed to delete API key:', error)
throw error
}
}
// 🚫 禁用用户的所有API Keys
async disableUserApiKeys(userId) {
try {
const userKeys = await this.getUserApiKeys(userId)
let disabledCount = 0
for (const key of userKeys) {
if (key.isActive) {
await this.updateApiKey(key.id, { isActive: false })
disabledCount++
}
}
logger.info(`🚫 Disabled ${disabledCount} API keys for user: ${userId}`)
return { count: disabledCount }
} catch (error) {
logger.error('❌ Failed to disable user API keys:', error)
throw error
}
}
// 📊 获取聚合使用统计支持多个API Key
async getAggregatedUsageStats(keyIds, options = {}) {
try {
if (!Array.isArray(keyIds)) {
keyIds = [keyIds]
}
const { period: _period = 'week', model: _model } = options
const stats = {
totalRequests: 0,
totalInputTokens: 0,
totalOutputTokens: 0,
totalCost: 0,
dailyStats: [],
modelStats: []
}
// 汇总所有API Key的统计数据
for (const keyId of keyIds) {
const keyStats = await redis.getUsageStats(keyId)
const costStats = await redis.getCostStats(keyId)
if (keyStats && keyStats.total) {
stats.totalRequests += keyStats.total.requests || 0
stats.totalInputTokens += keyStats.total.inputTokens || 0
stats.totalOutputTokens += keyStats.total.outputTokens || 0
stats.totalCost += costStats?.total || 0
}
}
// TODO: 实现日期范围和模型统计
// 这里可以根据需要添加更详细的统计逻辑
return stats
} catch (error) {
logger.error('❌ Failed to get usage stats:', error)
return {
totalRequests: 0,
totalInputTokens: 0,
totalOutputTokens: 0,
totalCost: 0,
dailyStats: [],
modelStats: []
}
}
}
// 🧹 清理过期的API Keys // 🧹 清理过期的API Keys
async cleanupExpiredKeys() { async cleanupExpiredKeys() {
try { try {

View File

@@ -249,10 +249,6 @@ async function updateAccount(accountId, updates) {
// 删除账户 // 删除账户
async function deleteAccount(accountId) { async function deleteAccount(accountId) {
// 首先从所有分组中移除此账户
const accountGroupService = require('./accountGroupService')
await accountGroupService.removeAccountFromAllGroups(accountId)
const client = redisClient.getClientSafe() const client = redisClient.getClientSafe()
const accountKey = `${AZURE_OPENAI_ACCOUNT_KEY_PREFIX}${accountId}` const accountKey = `${AZURE_OPENAI_ACCOUNT_KEY_PREFIX}${accountId}`
@@ -300,11 +296,7 @@ async function getAllAccounts() {
} }
} }
accounts.push({ accounts.push(accountData)
...accountData,
isActive: accountData.isActive === 'true',
schedulable: accountData.schedulable !== 'false'
})
} }
} }

View File

@@ -1,7 +1,6 @@
const axios = require('axios') const axios = require('axios')
const ProxyHelper = require('../utils/proxyHelper') const ProxyHelper = require('../utils/proxyHelper')
const logger = require('../utils/logger') const logger = require('../utils/logger')
const config = require('../../config/config')
// 转换模型名称(去掉 azure/ 前缀) // 转换模型名称(去掉 azure/ 前缀)
function normalizeModelName(model) { function normalizeModelName(model) {
@@ -30,7 +29,7 @@ async function handleAzureOpenAIRequest({
deploymentName = account.deploymentName || 'default' deploymentName = account.deploymentName || 'default'
// Azure Responses API requires preview versions; fall back appropriately // Azure Responses API requires preview versions; fall back appropriately
const apiVersion = const apiVersion =
account.apiVersion || (endpoint === 'responses' ? '2025-04-01-preview' : '2024-02-01') account.apiVersion || (endpoint === 'responses' ? '2024-10-01-preview' : '2024-02-01')
if (endpoint === 'chat/completions') { if (endpoint === 'chat/completions') {
requestUrl = `${baseUrl}/openai/deployments/${deploymentName}/chat/completions?api-version=${apiVersion}` requestUrl = `${baseUrl}/openai/deployments/${deploymentName}/chat/completions?api-version=${apiVersion}`
} else if (endpoint === 'responses') { } else if (endpoint === 'responses') {
@@ -54,9 +53,7 @@ async function handleAzureOpenAIRequest({
const processedBody = { ...requestBody } const processedBody = { ...requestBody }
// 标准化模型名称 // 标准化模型名称
if (endpoint === 'responses') { if (processedBody.model) {
processedBody.model = deploymentName
} else if (processedBody.model) {
processedBody.model = normalizeModelName(processedBody.model) processedBody.model = normalizeModelName(processedBody.model)
} else { } else {
processedBody.model = 'gpt-4' processedBody.model = 'gpt-4'
@@ -71,7 +68,7 @@ async function handleAzureOpenAIRequest({
url: requestUrl, url: requestUrl,
headers: requestHeaders, headers: requestHeaders,
data: processedBody, data: processedBody,
timeout: config.requestTimeout || 600000, timeout: 600000, // 10 minutes for Azure OpenAI
validateStatus: () => true, validateStatus: () => true,
// 添加连接保活选项 // 添加连接保活选项
keepAlive: true, keepAlive: true,
@@ -276,11 +273,6 @@ function handleStreamResponse(upstreamResponse, clientResponse, options = {}) {
let eventCount = 0 let eventCount = 0
const maxEvents = 10000 // 最大事件数量限制 const maxEvents = 10000 // 最大事件数量限制
// 专门用于保存最后几个chunks以提取usage数据
let finalChunksBuffer = ''
const FINAL_CHUNKS_SIZE = 32 * 1024 // 32KB保留最终chunks
const allParsedEvents = [] // 存储所有解析的事件用于最终usage提取
// 设置响应头 // 设置响应头
clientResponse.setHeader('Content-Type', 'text/event-stream') clientResponse.setHeader('Content-Type', 'text/event-stream')
clientResponse.setHeader('Cache-Control', 'no-cache') clientResponse.setHeader('Cache-Control', 'no-cache')
@@ -305,8 +297,8 @@ function handleStreamResponse(upstreamResponse, clientResponse, options = {}) {
clientResponse.flushHeaders() clientResponse.flushHeaders()
} }
// 强化的SSE事件解析,保存所有事件用于最终处理 // 解析 SSE 事件以捕获 usage 数据
const parseSSEForUsage = (data, isFromFinalBuffer = false) => { const parseSSEForUsage = (data) => {
const lines = data.split('\n') const lines = data.split('\n')
for (const line of lines) { for (const line of lines) {
@@ -318,54 +310,34 @@ function handleStreamResponse(upstreamResponse, clientResponse, options = {}) {
} }
const eventData = JSON.parse(jsonStr) const eventData = JSON.parse(jsonStr)
// 保存所有成功解析的事件
allParsedEvents.push(eventData)
// 获取模型信息 // 获取模型信息
if (eventData.model) { if (eventData.model) {
actualModel = eventData.model actualModel = eventData.model
} }
// 使用强化的usage提取函数 // 获取使用统计Responses API: response.completed -> response.usage
const { usageData: extractedUsage, actualModel: extractedModel } = if (eventData.type === 'response.completed' && eventData.response) {
extractUsageDataRobust( if (eventData.response.model) {
eventData, actualModel = eventData.response.model
`stream-event-${isFromFinalBuffer ? 'final' : 'normal'}` }
) if (eventData.response.usage) {
usageData = eventData.response.usage
if (extractedUsage && !usageData) { logger.debug('Captured Azure OpenAI nested usage (response.usage):', usageData)
usageData = extractedUsage
if (extractedModel) {
actualModel = extractedModel
} }
logger.debug(`🎯 Stream usage captured via robust extraction`, {
isFromFinalBuffer,
usageData,
actualModel
})
} }
// 原有的简单提取作为备用 // 兼容 Chat Completions 风格(顶层 usage
if (!usageData) { if (!usageData && eventData.usage) {
// 获取使用统计Responses API: response.completed -> response.usage usageData = eventData.usage
if (eventData.type === 'response.completed' && eventData.response) { logger.debug('Captured Azure OpenAI usage (top-level):', usageData)
if (eventData.response.model) { }
actualModel = eventData.response.model
}
if (eventData.response.usage) {
usageData = eventData.response.usage
logger.debug('🎯 Stream usage (backup method - response.usage):', usageData)
}
}
// 兼容 Chat Completions 风格(顶层 usage // 检查是否是完成事件
if (!usageData && eventData.usage) { if (eventData.choices && eventData.choices[0] && eventData.choices[0].finish_reason) {
usageData = eventData.usage // 这是最后一个 chunk
logger.debug('🎯 Stream usage (backup method - top-level):', usageData)
}
} }
} catch (e) { } catch (e) {
logger.debug('SSE parsing error (expected for incomplete chunks):', e.message) // 忽略解析错误
} }
} }
} }
@@ -415,19 +387,10 @@ function handleStreamResponse(upstreamResponse, clientResponse, options = {}) {
// 同时解析数据以捕获 usage 信息,带缓冲区大小限制 // 同时解析数据以捕获 usage 信息,带缓冲区大小限制
buffer += chunkStr buffer += chunkStr
// 保留最后的chunks用于最终usage提取不被truncate影响 // 防止缓冲区过大
finalChunksBuffer += chunkStr
if (finalChunksBuffer.length > FINAL_CHUNKS_SIZE) {
finalChunksBuffer = finalChunksBuffer.slice(-FINAL_CHUNKS_SIZE)
}
// 防止主缓冲区过大 - 但保持最后部分用于usage解析
if (buffer.length > MAX_BUFFER_SIZE) { if (buffer.length > MAX_BUFFER_SIZE) {
logger.warn( logger.warn(`Stream ${streamId} buffer exceeded limit, truncating`)
`Stream ${streamId} buffer exceeded limit, truncating main buffer but preserving final chunks` buffer = buffer.slice(-MAX_BUFFER_SIZE / 2) // 保留后一半
)
// 保留最后1/4而不是1/2为usage数据留更多空间
buffer = buffer.slice(-MAX_BUFFER_SIZE / 4)
} }
// 处理完整的 SSE 事件 // 处理完整的 SSE 事件
@@ -463,91 +426,9 @@ function handleStreamResponse(upstreamResponse, clientResponse, options = {}) {
hasEnded = true hasEnded = true
try { try {
logger.debug(`🔚 Stream ended, performing comprehensive usage extraction for ${streamId}`, { // 处理剩余的 buffer
mainBufferSize: buffer.length, if (buffer.trim() && buffer.length <= MAX_EVENT_SIZE) {
finalChunksBufferSize: finalChunksBuffer.length, parseSSEForUsage(buffer)
parsedEventsCount: allParsedEvents.length,
hasUsageData: !!usageData
})
// 多层次的最终usage提取策略
if (!usageData) {
logger.debug('🔍 No usage found during stream, trying final extraction methods...')
// 方法1: 解析剩余的主buffer
if (buffer.trim() && buffer.length <= MAX_EVENT_SIZE) {
parseSSEForUsage(buffer, false)
}
// 方法2: 解析保留的final chunks buffer
if (!usageData && finalChunksBuffer.trim()) {
logger.debug('🔍 Trying final chunks buffer for usage extraction...')
parseSSEForUsage(finalChunksBuffer, true)
}
// 方法3: 从所有解析的事件中重新搜索usage
if (!usageData && allParsedEvents.length > 0) {
logger.debug('🔍 Searching through all parsed events for usage...')
// 倒序查找因为usage通常在最后
for (let i = allParsedEvents.length - 1; i >= 0; i--) {
const { usageData: foundUsage, actualModel: foundModel } = extractUsageDataRobust(
allParsedEvents[i],
`final-event-scan-${i}`
)
if (foundUsage) {
usageData = foundUsage
if (foundModel) {
actualModel = foundModel
}
logger.debug(`🎯 Usage found in event ${i} during final scan!`)
break
}
}
}
// 方法4: 尝试合并所有事件并搜索
if (!usageData && allParsedEvents.length > 0) {
logger.debug('🔍 Trying combined events analysis...')
const combinedData = {
events: allParsedEvents,
lastEvent: allParsedEvents[allParsedEvents.length - 1],
eventCount: allParsedEvents.length
}
const { usageData: combinedUsage } = extractUsageDataRobust(
combinedData,
'combined-events'
)
if (combinedUsage) {
usageData = combinedUsage
logger.debug('🎯 Usage found via combined events analysis!')
}
}
}
// 最终usage状态报告
if (usageData) {
logger.debug('✅ Final stream usage extraction SUCCESS', {
streamId,
usageData,
actualModel,
totalEvents: allParsedEvents.length,
finalBufferSize: finalChunksBuffer.length
})
} else {
logger.warn('❌ Final stream usage extraction FAILED', {
streamId,
totalEvents: allParsedEvents.length,
finalBufferSize: finalChunksBuffer.length,
mainBufferSize: buffer.length,
lastFewEvents: allParsedEvents.slice(-3).map((e) => ({
type: e.type,
hasUsage: !!e.usage,
hasResponse: !!e.response,
keys: Object.keys(e)
}))
})
} }
if (onEnd) { if (onEnd) {
@@ -603,120 +484,6 @@ function handleStreamResponse(upstreamResponse, clientResponse, options = {}) {
}) })
} }
// 强化的用量数据提取函数
function extractUsageDataRobust(responseData, context = 'unknown') {
logger.debug(`🔍 Attempting usage extraction for ${context}`, {
responseDataKeys: Object.keys(responseData || {}),
responseDataType: typeof responseData,
hasUsage: !!responseData?.usage,
hasResponse: !!responseData?.response
})
let usageData = null
let actualModel = null
try {
// 策略 1: 顶层 usage (标准 Chat Completions)
if (responseData?.usage) {
usageData = responseData.usage
actualModel = responseData.model
logger.debug('✅ Usage extracted via Strategy 1 (top-level)', { usageData, actualModel })
}
// 策略 2: response.usage (Responses API)
else if (responseData?.response?.usage) {
usageData = responseData.response.usage
actualModel = responseData.response.model || responseData.model
logger.debug('✅ Usage extracted via Strategy 2 (response.usage)', { usageData, actualModel })
}
// 策略 3: 嵌套搜索 - 深度查找 usage 字段
else {
const findUsageRecursive = (obj, path = '') => {
if (!obj || typeof obj !== 'object') {
return null
}
for (const [key, value] of Object.entries(obj)) {
const currentPath = path ? `${path}.${key}` : key
if (key === 'usage' && value && typeof value === 'object') {
logger.debug(`✅ Usage found at path: ${currentPath}`, value)
return { usage: value, path: currentPath }
}
if (typeof value === 'object' && value !== null) {
const nested = findUsageRecursive(value, currentPath)
if (nested) {
return nested
}
}
}
return null
}
const found = findUsageRecursive(responseData)
if (found) {
usageData = found.usage
// Try to find model in the same parent object
const pathParts = found.path.split('.')
pathParts.pop() // remove 'usage'
let modelParent = responseData
for (const part of pathParts) {
modelParent = modelParent?.[part]
}
actualModel = modelParent?.model || responseData?.model
logger.debug('✅ Usage extracted via Strategy 3 (recursive)', {
usageData,
actualModel,
foundPath: found.path
})
}
}
// 策略 4: 特殊响应格式处理
if (!usageData) {
// 检查是否有 choices 数组usage 可能在最后一个 choice 中
if (responseData?.choices?.length > 0) {
const lastChoice = responseData.choices[responseData.choices.length - 1]
if (lastChoice?.usage) {
usageData = lastChoice.usage
actualModel = responseData.model || lastChoice.model
logger.debug('✅ Usage extracted via Strategy 4 (choices)', { usageData, actualModel })
}
}
}
// 最终验证和记录
if (usageData) {
logger.debug('🎯 Final usage extraction result', {
context,
usageData,
actualModel,
inputTokens: usageData.prompt_tokens || usageData.input_tokens || 0,
outputTokens: usageData.completion_tokens || usageData.output_tokens || 0,
totalTokens: usageData.total_tokens || 0
})
} else {
logger.warn('❌ Failed to extract usage data', {
context,
responseDataStructure: `${JSON.stringify(responseData, null, 2).substring(0, 1000)}...`,
availableKeys: Object.keys(responseData || {}),
responseSize: JSON.stringify(responseData || {}).length
})
}
} catch (extractionError) {
logger.error('🚨 Error during usage extraction', {
context,
error: extractionError.message,
stack: extractionError.stack,
responseDataType: typeof responseData
})
}
return { usageData, actualModel }
}
// 处理非流式响应 // 处理非流式响应
function handleNonStreamResponse(upstreamResponse, clientResponse) { function handleNonStreamResponse(upstreamResponse, clientResponse) {
try { try {
@@ -743,8 +510,9 @@ function handleNonStreamResponse(upstreamResponse, clientResponse) {
const responseData = upstreamResponse.data const responseData = upstreamResponse.data
clientResponse.json(responseData) clientResponse.json(responseData)
// 使用强化的用量提取 // 提取 usage 数据
const { usageData, actualModel } = extractUsageDataRobust(responseData, 'non-stream') const usageData = responseData.usage
const actualModel = responseData.model
return { usageData, actualModel, responseData } return { usageData, actualModel, responseData }
} catch (error) { } catch (error) {

View File

@@ -3,8 +3,8 @@ const crypto = require('crypto')
const ProxyHelper = require('../utils/proxyHelper') const ProxyHelper = require('../utils/proxyHelper')
const axios = require('axios') const axios = require('axios')
const redis = require('../models/redis') const redis = require('../models/redis')
const config = require('../../config/config')
const logger = require('../utils/logger') const logger = require('../utils/logger')
const config = require('../../config/config')
const { maskToken } = require('../utils/tokenMask') const { maskToken } = require('../utils/tokenMask')
const { const {
logRefreshStart, logRefreshStart,
@@ -15,7 +15,6 @@ const {
} = require('../utils/tokenRefreshLogger') } = require('../utils/tokenRefreshLogger')
const tokenRefreshService = require('./tokenRefreshService') const tokenRefreshService = require('./tokenRefreshService')
const LRUCache = require('../utils/lruCache') const LRUCache = require('../utils/lruCache')
const { formatDateWithTimezone, getISOStringWithTimezone } = require('../utils/dateHelper')
class ClaudeAccountService { class ClaudeAccountService {
constructor() { constructor() {
@@ -58,11 +57,7 @@ class ClaudeAccountService {
platform = 'claude', platform = 'claude',
priority = 50, // 调度优先级 (1-100数字越小优先级越高) priority = 50, // 调度优先级 (1-100数字越小优先级越高)
schedulable = true, // 是否可被调度 schedulable = true, // 是否可被调度
subscriptionInfo = null, // 手动设置的订阅信息 subscriptionInfo = null // 手动设置的订阅信息
autoStopOnWarning = false, // 5小时使用量接近限制时自动停止调度
useUnifiedUserAgent = false, // 是否使用统一Claude Code版本的User-Agent
useUnifiedClientId = false, // 是否使用统一的客户端标识
unifiedClientId = '' // 统一的客户端标识
} = options } = options
const accountId = uuidv4() const accountId = uuidv4()
@@ -93,10 +88,6 @@ class ClaudeAccountService {
status: 'active', // 有OAuth数据的账户直接设为active status: 'active', // 有OAuth数据的账户直接设为active
errorMessage: '', errorMessage: '',
schedulable: schedulable.toString(), // 是否可被调度 schedulable: schedulable.toString(), // 是否可被调度
autoStopOnWarning: autoStopOnWarning.toString(), // 5小时使用量接近限制时自动停止调度
useUnifiedUserAgent: useUnifiedUserAgent.toString(), // 是否使用统一Claude Code版本的User-Agent
useUnifiedClientId: useUnifiedClientId.toString(), // 是否使用统一的客户端标识
unifiedClientId: unifiedClientId || '', // 统一的客户端标识
// 优先使用手动设置的订阅信息否则使用OAuth数据中的否则默认为空 // 优先使用手动设置的订阅信息否则使用OAuth数据中的否则默认为空
subscriptionInfo: subscriptionInfo subscriptionInfo: subscriptionInfo
? JSON.stringify(subscriptionInfo) ? JSON.stringify(subscriptionInfo)
@@ -127,8 +118,6 @@ class ClaudeAccountService {
status: 'created', // created, active, expired, error status: 'created', // created, active, expired, error
errorMessage: '', errorMessage: '',
schedulable: schedulable.toString(), // 是否可被调度 schedulable: schedulable.toString(), // 是否可被调度
autoStopOnWarning: autoStopOnWarning.toString(), // 5小时使用量接近限制时自动停止调度
useUnifiedUserAgent: useUnifiedUserAgent.toString(), // 是否使用统一Claude Code版本的User-Agent
// 手动设置的订阅信息 // 手动设置的订阅信息
subscriptionInfo: subscriptionInfo ? JSON.stringify(subscriptionInfo) : '' subscriptionInfo: subscriptionInfo ? JSON.stringify(subscriptionInfo) : ''
} }
@@ -169,11 +158,7 @@ class ClaudeAccountService {
status: accountData.status, status: accountData.status,
createdAt: accountData.createdAt, createdAt: accountData.createdAt,
expiresAt: accountData.expiresAt, expiresAt: accountData.expiresAt,
scopes: claudeAiOauth ? claudeAiOauth.scopes : [], scopes: claudeAiOauth ? claudeAiOauth.scopes : []
autoStopOnWarning,
useUnifiedUserAgent,
useUnifiedClientId,
unifiedClientId
} }
} }
@@ -494,16 +479,7 @@ class ClaudeAccountService {
lastRequestTime: null lastRequestTime: null
}, },
// 添加调度状态 // 添加调度状态
schedulable: account.schedulable !== 'false', // 默认为true兼容历史数据 schedulable: account.schedulable !== 'false' // 默认为true兼容历史数据
// 添加自动停止调度设置
autoStopOnWarning: account.autoStopOnWarning === 'true', // 默认为false
// 添加统一User-Agent设置
useUnifiedUserAgent: account.useUnifiedUserAgent === 'true', // 默认为false
// 添加统一客户端标识设置
useUnifiedClientId: account.useUnifiedClientId === 'true', // 默认为false
unifiedClientId: account.unifiedClientId || '', // 统一的客户端标识
// 添加停止原因
stoppedReason: account.stoppedReason || null
} }
}) })
) )
@@ -536,11 +512,7 @@ class ClaudeAccountService {
'accountType', 'accountType',
'priority', 'priority',
'schedulable', 'schedulable',
'subscriptionInfo', 'subscriptionInfo'
'autoStopOnWarning',
'useUnifiedUserAgent',
'useUnifiedClientId',
'unifiedClientId'
] ]
const updatedData = { ...accountData } const updatedData = { ...accountData }
@@ -639,7 +611,10 @@ class ClaudeAccountService {
try { try {
// 首先从所有分组中移除此账户 // 首先从所有分组中移除此账户
const accountGroupService = require('./accountGroupService') const accountGroupService = require('./accountGroupService')
await accountGroupService.removeAccountFromAllGroups(accountId) const groups = await accountGroupService.getAccountGroup(accountId)
for (const group of groups) {
await accountGroupService.removeAccountFromGroup(accountId, group.id)
}
const result = await redis.deleteClaudeAccount(accountId) const result = await redis.deleteClaudeAccount(accountId)
@@ -707,8 +682,6 @@ class ClaudeAccountService {
// 验证映射的账户是否仍然可用 // 验证映射的账户是否仍然可用
const mappedAccount = activeAccounts.find((acc) => acc.id === mappedAccountId) const mappedAccount = activeAccounts.find((acc) => acc.id === mappedAccountId)
if (mappedAccount) { if (mappedAccount) {
// 🚀 智能会话续期剩余时间少于14天时自动续期到15天
await redis.extendSessionAccountMappingTTL(sessionHash)
logger.info( logger.info(
`🎯 Using sticky session account: ${mappedAccount.name} (${mappedAccountId}) for session ${sessionHash}` `🎯 Using sticky session account: ${mappedAccount.name} (${mappedAccountId}) for session ${sessionHash}`
) )
@@ -735,9 +708,7 @@ class ClaudeAccountService {
// 如果有会话哈希,建立新的映射 // 如果有会话哈希,建立新的映射
if (sessionHash) { if (sessionHash) {
// 从配置获取TTL小时转换为秒 await redis.setSessionAccountMapping(sessionHash, selectedAccountId, 3600) // 1小时过期
const ttlSeconds = (config.session?.stickyTtlHours || 1) * 60 * 60
await redis.setSessionAccountMapping(sessionHash, selectedAccountId, ttlSeconds)
logger.info( logger.info(
`🎯 Created new sticky session mapping: ${sortedAccounts[0].name} (${selectedAccountId}) for session ${sessionHash}` `🎯 Created new sticky session mapping: ${sortedAccounts[0].name} (${selectedAccountId}) for session ${sessionHash}`
) )
@@ -831,8 +802,6 @@ class ClaudeAccountService {
) )
await redis.deleteSessionAccountMapping(sessionHash) await redis.deleteSessionAccountMapping(sessionHash)
} else { } else {
// 🚀 智能会话续期剩余时间少于14天时自动续期到15天
await redis.extendSessionAccountMappingTTL(sessionHash)
logger.info( logger.info(
`🎯 Using sticky session shared account: ${mappedAccount.name} (${mappedAccountId}) for session ${sessionHash}` `🎯 Using sticky session shared account: ${mappedAccount.name} (${mappedAccountId}) for session ${sessionHash}`
) )
@@ -891,9 +860,7 @@ class ClaudeAccountService {
// 如果有会话哈希,建立新的映射 // 如果有会话哈希,建立新的映射
if (sessionHash) { if (sessionHash) {
// 从配置获取TTL小时转换为秒 await redis.setSessionAccountMapping(sessionHash, selectedAccountId, 3600) // 1小时过期
const ttlSeconds = (config.session?.stickyTtlHours || 1) * 60 * 60
await redis.setSessionAccountMapping(sessionHash, selectedAccountId, ttlSeconds)
logger.info( logger.info(
`🎯 Created new sticky session mapping for shared account: ${candidateAccounts[0].name} (${selectedAccountId}) for session ${sessionHash}` `🎯 Created new sticky session mapping for shared account: ${candidateAccounts[0].name} (${selectedAccountId}) for session ${sessionHash}`
) )
@@ -1087,8 +1054,6 @@ class ClaudeAccountService {
const updatedAccountData = { ...accountData } const updatedAccountData = { ...accountData }
updatedAccountData.rateLimitedAt = new Date().toISOString() updatedAccountData.rateLimitedAt = new Date().toISOString()
updatedAccountData.rateLimitStatus = 'limited' updatedAccountData.rateLimitStatus = 'limited'
// 限流时停止调度,与 OpenAI 账号保持一致
updatedAccountData.schedulable = false
// 如果提供了准确的限流重置时间戳来自API响应头 // 如果提供了准确的限流重置时间戳来自API响应头
if (rateLimitResetTimestamp) { if (rateLimitResetTimestamp) {
@@ -1147,8 +1112,8 @@ class ClaudeAccountService {
platform: 'claude-oauth', platform: 'claude-oauth',
status: 'error', status: 'error',
errorCode: 'CLAUDE_OAUTH_RATE_LIMITED', errorCode: 'CLAUDE_OAUTH_RATE_LIMITED',
reason: `Account rate limited (429 error). ${rateLimitResetTimestamp ? `Reset at: ${formatDateWithTimezone(rateLimitResetTimestamp)}` : 'Estimated reset in 1-5 hours'}`, reason: `Account rate limited (429 error). ${rateLimitResetTimestamp ? `Reset at: ${new Date(rateLimitResetTimestamp * 1000).toISOString()}` : 'Estimated reset in 1-5 hours'}`,
timestamp: getISOStringWithTimezone(new Date()) timestamp: new Date().toISOString()
}) })
} catch (webhookError) { } catch (webhookError) {
logger.error('Failed to send rate limit webhook notification:', webhookError) logger.error('Failed to send rate limit webhook notification:', webhookError)
@@ -1173,14 +1138,9 @@ class ClaudeAccountService {
delete accountData.rateLimitedAt delete accountData.rateLimitedAt
delete accountData.rateLimitStatus delete accountData.rateLimitStatus
delete accountData.rateLimitEndAt // 清除限流结束时间 delete accountData.rateLimitEndAt // 清除限流结束时间
// 恢复可调度状态,与 OpenAI 账号保持一致
accountData.schedulable = true
await redis.setClaudeAccount(accountId, accountData) await redis.setClaudeAccount(accountId, accountData)
logger.success( logger.success(`✅ Rate limit removed for account: ${accountData.name} (${accountId})`)
`✅ Rate limit removed for account: ${accountData.name} (${accountId}), schedulable restored`
)
return { success: true } return { success: true }
} catch (error) { } catch (error) {
logger.error(`❌ Failed to remove rate limit for account: ${accountId}`, error) logger.error(`❌ Failed to remove rate limit for account: ${accountId}`, error)
@@ -1324,42 +1284,6 @@ class ClaudeAccountService {
accountData.sessionWindowEnd = windowEnd.toISOString() accountData.sessionWindowEnd = windowEnd.toISOString()
accountData.lastRequestTime = now.toISOString() accountData.lastRequestTime = now.toISOString()
// 清除会话窗口状态,因为进入了新窗口
if (accountData.sessionWindowStatus) {
delete accountData.sessionWindowStatus
delete accountData.sessionWindowStatusUpdatedAt
}
// 如果账户因为5小时限制被自动停止现在恢复调度
if (
accountData.autoStoppedAt &&
accountData.schedulable === 'false' &&
accountData.stoppedReason === '5小时使用量接近限制自动停止调度'
) {
logger.info(
`✅ Auto-resuming scheduling for account ${accountData.name} (${accountId}) - new session window started`
)
accountData.schedulable = 'true'
delete accountData.stoppedReason
delete accountData.autoStoppedAt
// 发送Webhook通知
try {
const webhookNotifier = require('../utils/webhookNotifier')
await webhookNotifier.sendAccountAnomalyNotification({
accountId,
accountName: accountData.name || 'Claude Account',
platform: 'claude',
status: 'resumed',
errorCode: 'CLAUDE_5H_LIMIT_RESUMED',
reason: '进入新的5小时窗口已自动恢复调度',
timestamp: getISOStringWithTimezone(new Date())
})
} catch (webhookError) {
logger.error('Failed to send webhook notification:', webhookError)
}
}
logger.info( logger.info(
`🕐 Created new session window for account ${accountData.name} (${accountId}): ${windowStart.toISOString()} - ${windowEnd.toISOString()} (from current time)` `🕐 Created new session window for account ${accountData.name} (${accountId}): ${windowStart.toISOString()} - ${windowEnd.toISOString()} (from current time)`
) )
@@ -1405,8 +1329,7 @@ class ClaudeAccountService {
windowEnd: null, windowEnd: null,
progress: 0, progress: 0,
remainingTime: null, remainingTime: null,
lastRequestTime: accountData.lastRequestTime || null, lastRequestTime: accountData.lastRequestTime || null
sessionWindowStatus: accountData.sessionWindowStatus || null
} }
} }
@@ -1423,8 +1346,7 @@ class ClaudeAccountService {
windowEnd: accountData.sessionWindowEnd, windowEnd: accountData.sessionWindowEnd,
progress: 100, progress: 100,
remainingTime: 0, remainingTime: 0,
lastRequestTime: accountData.lastRequestTime || null, lastRequestTime: accountData.lastRequestTime || null
sessionWindowStatus: accountData.sessionWindowStatus || null
} }
} }
@@ -1442,8 +1364,7 @@ class ClaudeAccountService {
windowEnd: accountData.sessionWindowEnd, windowEnd: accountData.sessionWindowEnd,
progress, progress,
remainingTime, remainingTime,
lastRequestTime: accountData.lastRequestTime || null, lastRequestTime: accountData.lastRequestTime || null
sessionWindowStatus: accountData.sessionWindowStatus || null
} }
} catch (error) { } catch (error) {
logger.error(`❌ Failed to get session window info for account ${accountId}:`, error) logger.error(`❌ Failed to get session window info for account ${accountId}:`, error)
@@ -1722,31 +1643,9 @@ class ClaudeAccountService {
} }
} }
// 🚫 通用的账户错误标记方法 // 🚫 标记账户为未授权状态401错误
async markAccountError(accountId, errorType, sessionHash = null) { async markAccountUnauthorized(accountId, sessionHash = null) {
const ERROR_CONFIG = {
unauthorized: {
status: 'unauthorized',
errorMessage: 'Account unauthorized (401 errors detected)',
timestampField: 'unauthorizedAt',
errorCode: 'CLAUDE_OAUTH_UNAUTHORIZED',
logMessage: 'unauthorized'
},
blocked: {
status: 'blocked',
errorMessage: 'Account blocked (403 error detected - account may be suspended by Claude)',
timestampField: 'blockedAt',
errorCode: 'CLAUDE_OAUTH_BLOCKED',
logMessage: 'blocked'
}
}
try { try {
const errorConfig = ERROR_CONFIG[errorType]
if (!errorConfig) {
throw new Error(`Unsupported error type: ${errorType}`)
}
const accountData = await redis.getClaudeAccount(accountId) const accountData = await redis.getClaudeAccount(accountId)
if (!accountData || Object.keys(accountData).length === 0) { if (!accountData || Object.keys(accountData).length === 0) {
throw new Error('Account not found') throw new Error('Account not found')
@@ -1754,10 +1653,10 @@ class ClaudeAccountService {
// 更新账户状态 // 更新账户状态
const updatedAccountData = { ...accountData } const updatedAccountData = { ...accountData }
updatedAccountData.status = errorConfig.status updatedAccountData.status = 'unauthorized'
updatedAccountData.schedulable = 'false' // 设置为不可调度 updatedAccountData.schedulable = 'false' // 设置为不可调度
updatedAccountData.errorMessage = errorConfig.errorMessage updatedAccountData.errorMessage = 'Account unauthorized (401 errors detected)'
updatedAccountData[errorConfig.timestampField] = new Date().toISOString() updatedAccountData.unauthorizedAt = new Date().toISOString()
// 保存更新后的账户数据 // 保存更新后的账户数据
await redis.setClaudeAccount(accountId, updatedAccountData) await redis.setClaudeAccount(accountId, updatedAccountData)
@@ -1769,7 +1668,7 @@ class ClaudeAccountService {
} }
logger.warn( logger.warn(
`⚠️ Account ${accountData.name} (${accountId}) marked as ${errorConfig.logMessage} and disabled for scheduling` `⚠️ Account ${accountData.name} (${accountId}) marked as unauthorized and disabled for scheduling`
) )
// 发送Webhook通知 // 发送Webhook通知
@@ -1779,10 +1678,9 @@ class ClaudeAccountService {
accountId, accountId,
accountName: accountData.name, accountName: accountData.name,
platform: 'claude-oauth', platform: 'claude-oauth',
status: errorConfig.status, status: 'unauthorized',
errorCode: errorConfig.errorCode, errorCode: 'CLAUDE_OAUTH_UNAUTHORIZED',
reason: errorConfig.errorMessage, reason: 'Account unauthorized (401 errors detected)'
timestamp: getISOStringWithTimezone(new Date())
}) })
} catch (webhookError) { } catch (webhookError) {
logger.error('Failed to send webhook notification:', webhookError) logger.error('Failed to send webhook notification:', webhookError)
@@ -1790,21 +1688,11 @@ class ClaudeAccountService {
return { success: true } return { success: true }
} catch (error) { } catch (error) {
logger.error(`❌ Failed to mark account ${accountId} as ${errorType}:`, error) logger.error(`❌ Failed to mark account ${accountId} as unauthorized:`, error)
throw error throw error
} }
} }
// 🚫 标记账户为未授权状态401错误
async markAccountUnauthorized(accountId, sessionHash = null) {
return this.markAccountError(accountId, 'unauthorized', sessionHash)
}
// 🚫 标记账户为被封锁状态403错误
async markAccountBlocked(accountId, sessionHash = null) {
return this.markAccountError(accountId, 'blocked', sessionHash)
}
// 🔄 重置账户所有异常状态 // 🔄 重置账户所有异常状态
async resetAccountStatus(accountId) { async resetAccountStatus(accountId) {
try { try {
@@ -1829,31 +1717,13 @@ class ClaudeAccountService {
// 清除错误相关字段 // 清除错误相关字段
delete updatedAccountData.errorMessage delete updatedAccountData.errorMessage
delete updatedAccountData.unauthorizedAt delete updatedAccountData.unauthorizedAt
delete updatedAccountData.blockedAt
delete updatedAccountData.rateLimitedAt delete updatedAccountData.rateLimitedAt
delete updatedAccountData.rateLimitStatus delete updatedAccountData.rateLimitStatus
delete updatedAccountData.rateLimitEndAt delete updatedAccountData.rateLimitEndAt
delete updatedAccountData.tempErrorAt
delete updatedAccountData.sessionWindowStart
delete updatedAccountData.sessionWindowEnd
// 保存更新后的账户数据 // 保存更新后的账户数据
await redis.setClaudeAccount(accountId, updatedAccountData) await redis.setClaudeAccount(accountId, updatedAccountData)
// 显式从 Redis 中删除这些字段(因为 HSET 不会删除现有字段)
const fieldsToDelete = [
'errorMessage',
'unauthorizedAt',
'blockedAt',
'rateLimitedAt',
'rateLimitStatus',
'rateLimitEndAt',
'tempErrorAt',
'sessionWindowStart',
'sessionWindowEnd'
]
await redis.client.hdel(`claude:account:${accountId}`, ...fieldsToDelete)
// 清除401错误计数 // 清除401错误计数
const errorKey = `claude_account:${accountId}:401_errors` const errorKey = `claude_account:${accountId}:401_errors`
await redis.client.del(errorKey) await redis.client.del(errorKey)
@@ -1862,10 +1732,6 @@ class ClaudeAccountService {
const rateLimitKey = `ratelimit:${accountId}` const rateLimitKey = `ratelimit:${accountId}`
await redis.client.del(rateLimitKey) await redis.client.del(rateLimitKey)
// 清除5xx错误计数
const serverErrorKey = `claude_account:${accountId}:5xx_errors`
await redis.client.del(serverErrorKey)
logger.info( logger.info(
`✅ Successfully reset all error states for account ${accountData.name} (${accountId})` `✅ Successfully reset all error states for account ${accountData.name} (${accountId})`
) )
@@ -1890,7 +1756,7 @@ class ClaudeAccountService {
try { try {
const accounts = await redis.getAllClaudeAccounts() const accounts = await redis.getAllClaudeAccounts()
let cleanedCount = 0 let cleanedCount = 0
const TEMP_ERROR_RECOVERY_MINUTES = 5 // 临时错误状态恢复时间(分钟) const TEMP_ERROR_RECOVERY_MINUTES = 60 // 临时错误状态恢复时间(分钟)
for (const account of accounts) { for (const account of accounts) {
if (account.status === 'temp_error' && account.tempErrorAt) { if (account.status === 'temp_error' && account.tempErrorAt) {
@@ -1905,10 +1771,6 @@ class ClaudeAccountService {
delete account.errorMessage delete account.errorMessage
delete account.tempErrorAt delete account.tempErrorAt
await redis.setClaudeAccount(account.id, account) await redis.setClaudeAccount(account.id, account)
// 显式从 Redis 中删除这些字段(因为 HSET 不会删除现有字段)
await redis.client.hdel(`claude:account:${account.id}`, 'errorMessage', 'tempErrorAt')
// 同时清除500错误计数 // 同时清除500错误计数
await this.clearInternalErrors(account.id) await this.clearInternalErrors(account.id)
cleanedCount++ cleanedCount++
@@ -1996,52 +1858,6 @@ class ClaudeAccountService {
// 保存更新后的账户数据 // 保存更新后的账户数据
await redis.setClaudeAccount(accountId, updatedAccountData) await redis.setClaudeAccount(accountId, updatedAccountData)
// 设置 5 分钟后自动恢复(一次性定时器)
setTimeout(
async () => {
try {
const account = await redis.getClaudeAccount(accountId)
if (account && account.status === 'temp_error' && account.tempErrorAt) {
// 验证是否确实过了 5 分钟(防止重复定时器)
const tempErrorAt = new Date(account.tempErrorAt)
const now = new Date()
const minutesSince = (now - tempErrorAt) / (1000 * 60)
if (minutesSince >= 5) {
// 恢复账户
account.status = 'active'
account.schedulable = 'true'
delete account.errorMessage
delete account.tempErrorAt
await redis.setClaudeAccount(accountId, account)
// 显式删除 Redis 字段
await redis.client.hdel(
`claude:account:${accountId}`,
'errorMessage',
'tempErrorAt'
)
// 清除 500 错误计数
await this.clearInternalErrors(accountId)
logger.success(
`✅ Auto-recovered temp_error after 5 minutes: ${account.name} (${accountId})`
)
} else {
logger.debug(
`⏰ Temp error timer triggered but only ${minutesSince.toFixed(1)} minutes passed for ${account.name} (${accountId})`
)
}
}
} catch (error) {
logger.error(`❌ Failed to auto-recover temp_error account ${accountId}:`, error)
}
},
6 * 60 * 1000
) // 6 分钟后执行,确保已过 5 分钟
// 如果有sessionHash删除粘性会话映射 // 如果有sessionHash删除粘性会话映射
if (sessionHash) { if (sessionHash) {
await redis.client.del(`sticky_session:${sessionHash}`) await redis.client.del(`sticky_session:${sessionHash}`)
@@ -2073,172 +1889,6 @@ class ClaudeAccountService {
throw error throw error
} }
} }
// 更新会话窗口状态allowed, allowed_warning, rejected
async updateSessionWindowStatus(accountId, status) {
try {
// 参数验证
if (!accountId || !status) {
logger.warn(
`Invalid parameters for updateSessionWindowStatus: accountId=${accountId}, status=${status}`
)
return
}
const accountData = await redis.getClaudeAccount(accountId)
if (!accountData || Object.keys(accountData).length === 0) {
logger.warn(`Account not found: ${accountId}`)
return
}
// 验证状态值是否有效
const validStatuses = ['allowed', 'allowed_warning', 'rejected']
if (!validStatuses.includes(status)) {
logger.warn(`Invalid session window status: ${status} for account ${accountId}`)
return
}
// 更新会话窗口状态
accountData.sessionWindowStatus = status
accountData.sessionWindowStatusUpdatedAt = new Date().toISOString()
// 如果状态是 allowed_warning 且账户设置了自动停止调度
if (status === 'allowed_warning' && accountData.autoStopOnWarning === 'true') {
logger.warn(
`⚠️ Account ${accountData.name} (${accountId}) approaching 5h limit, auto-stopping scheduling`
)
accountData.schedulable = 'false'
accountData.stoppedReason = '5小时使用量接近限制自动停止调度'
accountData.autoStoppedAt = new Date().toISOString()
// 发送Webhook通知
try {
const webhookNotifier = require('../utils/webhookNotifier')
await webhookNotifier.sendAccountAnomalyNotification({
accountId,
accountName: accountData.name || 'Claude Account',
platform: 'claude',
status: 'warning',
errorCode: 'CLAUDE_5H_LIMIT_WARNING',
reason: '5小时使用量接近限制已自动停止调度',
timestamp: getISOStringWithTimezone(new Date())
})
} catch (webhookError) {
logger.error('Failed to send webhook notification:', webhookError)
}
}
await redis.setClaudeAccount(accountId, accountData)
logger.info(
`📊 Updated session window status for account ${accountData.name} (${accountId}): ${status}`
)
} catch (error) {
logger.error(`❌ Failed to update session window status for account ${accountId}:`, error)
}
}
// 🚫 标记账号为过载状态529错误
async markAccountOverloaded(accountId) {
try {
const accountData = await redis.getClaudeAccount(accountId)
if (!accountData) {
throw new Error('Account not found')
}
// 获取配置的过载处理时间(分钟)
const overloadMinutes = config.overloadHandling?.enabled || 0
if (overloadMinutes === 0) {
logger.info('⏭️ 529 error handling is disabled')
return { success: false, error: '529 error handling is disabled' }
}
const overloadKey = `account:overload:${accountId}`
const ttl = overloadMinutes * 60 // 转换为秒
await redis.setex(
overloadKey,
ttl,
JSON.stringify({
accountId,
accountName: accountData.name,
markedAt: new Date().toISOString(),
expiresAt: new Date(Date.now() + ttl * 1000).toISOString()
})
)
logger.warn(
`🚫 Account ${accountData.name} (${accountId}) marked as overloaded for ${overloadMinutes} minutes`
)
// 在账号上记录最后一次529错误
const updates = {
lastOverloadAt: new Date().toISOString(),
errorMessage: `529错误 - 过载${overloadMinutes}分钟`
}
const updatedAccountData = { ...accountData, ...updates }
await redis.setClaudeAccount(accountId, updatedAccountData)
return { success: true, accountName: accountData.name, duration: overloadMinutes }
} catch (error) {
logger.error(`❌ Failed to mark account as overloaded: ${accountId}`, error)
// 不抛出错误,避免影响主请求流程
return { success: false, error: error.message }
}
}
// ✅ 检查账号是否过载
async isAccountOverloaded(accountId) {
try {
// 如果529处理未启用直接返回false
const overloadMinutes = config.overloadHandling?.enabled || 0
if (overloadMinutes === 0) {
return false
}
const overloadKey = `account:overload:${accountId}`
const overloadData = await redis.get(overloadKey)
if (overloadData) {
// 账号处于过载状态
return true
}
// 账号未过载
return false
} catch (error) {
logger.error(`❌ Failed to check if account is overloaded: ${accountId}`, error)
return false
}
}
// 🔄 移除账号的过载状态
async removeAccountOverload(accountId) {
try {
const accountData = await redis.getClaudeAccount(accountId)
if (!accountData) {
throw new Error('Account not found')
}
const overloadKey = `account:overload:${accountId}`
await redis.del(overloadKey)
logger.info(`✅ Account ${accountData.name} (${accountId}) overload status removed`)
// 清理账号上的错误信息
if (accountData.errorMessage && accountData.errorMessage.includes('529错误')) {
const updatedAccountData = { ...accountData }
delete updatedAccountData.errorMessage
delete updatedAccountData.lastOverloadAt
await redis.setClaudeAccount(accountId, updatedAccountData)
}
} catch (error) {
logger.error(`❌ Failed to remove overload status for account: ${accountId}`, error)
// 不抛出错误,移除过载状态失败不应该影响主流程
}
}
} }
module.exports = new ClaudeAccountService() module.exports = new ClaudeAccountService()

View File

@@ -50,7 +50,7 @@ class ClaudeCodeHeadersService {
if (!userAgent) { if (!userAgent) {
return null return null
} }
const match = userAgent.match(/claude-cli\/([\d.]+(?:[a-zA-Z0-9-]*)?)/i) const match = userAgent.match(/claude-cli\/(\d+\.\d+\.\d+)/)
return match ? match[1] : null return match ? match[1] : null
} }
@@ -113,7 +113,7 @@ class ClaudeCodeHeadersService {
// 检查是否有 user-agent // 检查是否有 user-agent
const userAgent = extractedHeaders['user-agent'] const userAgent = extractedHeaders['user-agent']
if (!userAgent || !/^claude-cli\/[\d.]+\s+\(/i.test(userAgent)) { if (!userAgent || !userAgent.includes('claude-cli')) {
// 不是 Claude Code 的请求,不存储 // 不是 Claude Code 的请求,不存储
return return
} }

View File

@@ -50,9 +50,7 @@ class ClaudeConsoleAccountService {
proxy = null, proxy = null,
isActive = true, isActive = true,
accountType = 'shared', // 'dedicated' or 'shared' accountType = 'shared', // 'dedicated' or 'shared'
schedulable = true, // 是否可被调度 schedulable = true // 是否可被调度
dailyQuota = 0, // 每日额度限制美元0表示不限制
quotaResetTime = '00:00' // 额度重置时间HH:mm格式
} = options } = options
// 验证必填字段 // 验证必填字段
@@ -87,14 +85,7 @@ class ClaudeConsoleAccountService {
rateLimitedAt: '', rateLimitedAt: '',
rateLimitStatus: '', rateLimitStatus: '',
// 调度控制 // 调度控制
schedulable: schedulable.toString(), schedulable: schedulable.toString()
// 额度管理相关
dailyQuota: dailyQuota.toString(), // 每日额度限制(美元)
dailyUsage: '0', // 当日使用金额(美元)
// 使用与统计一致的时区日期,避免边界问题
lastResetDate: redis.getDateStringInTimezone(), // 最后重置日期(按配置时区)
quotaResetTime, // 额度重置时间
quotaStoppedAt: '' // 因额度停用的时间
} }
const client = redis.getClientSafe() const client = redis.getClientSafe()
@@ -125,12 +116,7 @@ class ClaudeConsoleAccountService {
proxy, proxy,
accountType, accountType,
status: 'active', status: 'active',
createdAt: accountData.createdAt, createdAt: accountData.createdAt
dailyQuota,
dailyUsage: 0,
lastResetDate: accountData.lastResetDate,
quotaResetTime,
quotaStoppedAt: null
} }
} }
@@ -162,18 +148,12 @@ class ClaudeConsoleAccountService {
isActive: accountData.isActive === 'true', isActive: accountData.isActive === 'true',
proxy: accountData.proxy ? JSON.parse(accountData.proxy) : null, proxy: accountData.proxy ? JSON.parse(accountData.proxy) : null,
accountType: accountData.accountType || 'shared', accountType: accountData.accountType || 'shared',
status: accountData.status,
errorMessage: accountData.errorMessage,
createdAt: accountData.createdAt, createdAt: accountData.createdAt,
lastUsedAt: accountData.lastUsedAt, lastUsedAt: accountData.lastUsedAt,
status: accountData.status || 'active', rateLimitStatus: rateLimitInfo,
errorMessage: accountData.errorMessage, schedulable: accountData.schedulable !== 'false' // 默认为true只有明确设置为false才不可调度
rateLimitInfo,
schedulable: accountData.schedulable !== 'false', // 默认为true只有明确设置为false才不可调度
// 额度管理相关
dailyQuota: parseFloat(accountData.dailyQuota || '0'),
dailyUsage: parseFloat(accountData.dailyUsage || '0'),
lastResetDate: accountData.lastResetDate || '',
quotaResetTime: accountData.quotaResetTime || '00:00',
quotaStoppedAt: accountData.quotaStoppedAt || null
}) })
} }
} }
@@ -287,23 +267,6 @@ class ClaudeConsoleAccountService {
updatedData.schedulable = updates.schedulable.toString() updatedData.schedulable = updates.schedulable.toString()
} }
// 额度管理相关字段
if (updates.dailyQuota !== undefined) {
updatedData.dailyQuota = updates.dailyQuota.toString()
}
if (updates.quotaResetTime !== undefined) {
updatedData.quotaResetTime = updates.quotaResetTime
}
if (updates.dailyUsage !== undefined) {
updatedData.dailyUsage = updates.dailyUsage.toString()
}
if (updates.lastResetDate !== undefined) {
updatedData.lastResetDate = updates.lastResetDate
}
if (updates.quotaStoppedAt !== undefined) {
updatedData.quotaStoppedAt = updates.quotaStoppedAt
}
// 处理账户类型变更 // 处理账户类型变更
if (updates.accountType && updates.accountType !== existingAccount.accountType) { if (updates.accountType && updates.accountType !== existingAccount.accountType) {
updatedData.accountType = updates.accountType updatedData.accountType = updates.accountType
@@ -398,17 +361,7 @@ class ClaudeConsoleAccountService {
const updates = { const updates = {
rateLimitedAt: new Date().toISOString(), rateLimitedAt: new Date().toISOString(),
rateLimitStatus: 'limited', rateLimitStatus: 'limited'
isActive: 'false', // 禁用账户
schedulable: 'false', // 停止调度,与其他平台保持一致
errorMessage: `Rate limited at ${new Date().toISOString()}`
}
// 只有当前状态不是quota_exceeded时才设置为rate_limited
// 避免覆盖更重要的配额超限状态
const currentStatus = await client.hget(`${this.ACCOUNT_KEY_PREFIX}${accountId}`, 'status')
if (currentStatus !== 'quota_exceeded') {
updates.status = 'rate_limited'
} }
await client.hset(`${this.ACCOUNT_KEY_PREFIX}${accountId}`, updates) await client.hset(`${this.ACCOUNT_KEY_PREFIX}${accountId}`, updates)
@@ -416,15 +369,14 @@ class ClaudeConsoleAccountService {
// 发送Webhook通知 // 发送Webhook通知
try { try {
const webhookNotifier = require('../utils/webhookNotifier') const webhookNotifier = require('../utils/webhookNotifier')
const { getISOStringWithTimezone } = require('../utils/dateHelper')
await webhookNotifier.sendAccountAnomalyNotification({ await webhookNotifier.sendAccountAnomalyNotification({
accountId, accountId,
accountName: account.name || 'Claude Console Account', accountName: account.name || 'Claude Console Account',
platform: 'claude-console', platform: 'claude-console',
status: 'error', status: 'error',
errorCode: 'CLAUDE_CONSOLE_RATE_LIMITED', errorCode: 'CLAUDE_CONSOLE_RATE_LIMITED',
reason: `Account rate limited (429 error) and has been disabled. ${account.rateLimitDuration ? `Will be automatically re-enabled after ${account.rateLimitDuration} minutes` : 'Manual intervention required to re-enable'}`, reason: `Account rate limited (429 error). ${account.rateLimitDuration ? `Will be blocked for ${account.rateLimitDuration} hours` : 'Temporary rate limit'}`,
timestamp: getISOStringWithTimezone(new Date()) timestamp: new Date().toISOString()
}) })
} catch (webhookError) { } catch (webhookError) {
logger.error('Failed to send rate limit webhook notification:', webhookError) logger.error('Failed to send rate limit webhook notification:', webhookError)
@@ -444,41 +396,14 @@ class ClaudeConsoleAccountService {
async removeAccountRateLimit(accountId) { async removeAccountRateLimit(accountId) {
try { try {
const client = redis.getClientSafe() const client = redis.getClientSafe()
const accountKey = `${this.ACCOUNT_KEY_PREFIX}${accountId}`
// 获取账户当前状态和额度信息 await client.hdel(
const [currentStatus, quotaStoppedAt] = await client.hmget( `${this.ACCOUNT_KEY_PREFIX}${accountId}`,
accountKey, 'rateLimitedAt',
'status', 'rateLimitStatus'
'quotaStoppedAt'
) )
// 删除限流相关字段 logger.success(`✅ Rate limit removed for Claude Console account: ${accountId}`)
await client.hdel(accountKey, 'rateLimitedAt', 'rateLimitStatus')
// 根据不同情况决定是否恢复账户
if (currentStatus === 'rate_limited') {
if (quotaStoppedAt) {
// 还有额度限制改为quota_exceeded状态
await client.hset(accountKey, {
status: 'quota_exceeded'
// isActive保持false
})
logger.info(`⚠️ Rate limit removed but quota exceeded remains for account: ${accountId}`)
} else {
// 没有额度限制,完全恢复
await client.hset(accountKey, {
isActive: 'true',
schedulable: 'true', // 恢复调度,与其他平台保持一致
status: 'active',
errorMessage: ''
})
logger.success(`✅ Rate limit removed and account re-enabled: ${accountId}`)
}
} else {
logger.success(`✅ Rate limit removed for Claude Console account: ${accountId}`)
}
return { success: true } return { success: true }
} catch (error) { } catch (error) {
logger.error(`❌ Failed to remove rate limit for Claude Console account: ${accountId}`, error) logger.error(`❌ Failed to remove rate limit for Claude Console account: ${accountId}`, error)
@@ -528,202 +453,6 @@ class ClaudeConsoleAccountService {
} }
} }
// 🔍 检查账号是否因额度超限而被停用(懒惰检查)
async isAccountQuotaExceeded(accountId) {
try {
const account = await this.getAccount(accountId)
if (!account) {
return false
}
// 如果没有设置额度限制,不会超额
const dailyQuota = parseFloat(account.dailyQuota || '0')
if (isNaN(dailyQuota) || dailyQuota <= 0) {
return false
}
// 如果账户没有被额度停用,检查当前使用情况
if (!account.quotaStoppedAt) {
return false
}
// 检查是否应该重置额度(到了新的重置时间点)
if (this._shouldResetQuota(account)) {
await this.resetDailyUsage(accountId)
return false
}
// 仍在额度超限状态
return true
} catch (error) {
logger.error(
`❌ Failed to check quota exceeded status for Claude Console account: ${accountId}`,
error
)
return false
}
}
// 🔍 判断是否应该重置账户额度
_shouldResetQuota(account) {
// 与 Redis 统计一致:按配置时区判断“今天”与时间点
const tzNow = redis.getDateInTimezone(new Date())
const today = redis.getDateStringInTimezone(tzNow)
// 如果已经是今天重置过的,不需要重置
if (account.lastResetDate === today) {
return false
}
// 检查是否到了重置时间点(按配置时区的小时/分钟)
const resetTime = account.quotaResetTime || '00:00'
const [resetHour, resetMinute] = resetTime.split(':').map((n) => parseInt(n))
const currentHour = tzNow.getUTCHours()
const currentMinute = tzNow.getUTCMinutes()
// 如果当前时间已过重置时间且不是同一天重置的,应该重置
return currentHour > resetHour || (currentHour === resetHour && currentMinute >= resetMinute)
}
// 🚫 标记账号为未授权状态401错误
async markAccountUnauthorized(accountId) {
try {
const client = redis.getClientSafe()
const account = await this.getAccount(accountId)
if (!account) {
throw new Error('Account not found')
}
const updates = {
schedulable: 'false',
status: 'unauthorized',
errorMessage: 'API Key无效或已过期401错误',
unauthorizedAt: new Date().toISOString(),
unauthorizedCount: String((parseInt(account.unauthorizedCount || '0') || 0) + 1)
}
await client.hset(`${this.ACCOUNT_KEY_PREFIX}${accountId}`, updates)
// 发送Webhook通知
try {
const webhookNotifier = require('../utils/webhookNotifier')
await webhookNotifier.sendAccountAnomalyNotification({
accountId,
accountName: account.name || 'Claude Console Account',
platform: 'claude-console',
status: 'error',
errorCode: 'CLAUDE_CONSOLE_UNAUTHORIZED',
reason: 'API Key无效或已过期401错误账户已停止调度',
timestamp: new Date().toISOString()
})
} catch (webhookError) {
logger.error('Failed to send unauthorized webhook notification:', webhookError)
}
logger.warn(
`🚫 Claude Console account marked as unauthorized: ${account.name} (${accountId})`
)
return { success: true }
} catch (error) {
logger.error(`❌ Failed to mark Claude Console account as unauthorized: ${accountId}`, error)
throw error
}
}
// 🚫 标记账号为过载状态529错误
async markAccountOverloaded(accountId) {
try {
const client = redis.getClientSafe()
const account = await this.getAccount(accountId)
if (!account) {
throw new Error('Account not found')
}
const updates = {
overloadedAt: new Date().toISOString(),
overloadStatus: 'overloaded',
errorMessage: '服务过载529错误'
}
await client.hset(`${this.ACCOUNT_KEY_PREFIX}${accountId}`, updates)
// 发送Webhook通知
try {
const webhookNotifier = require('../utils/webhookNotifier')
await webhookNotifier.sendAccountAnomalyNotification({
accountId,
accountName: account.name || 'Claude Console Account',
platform: 'claude-console',
status: 'error',
errorCode: 'CLAUDE_CONSOLE_OVERLOADED',
reason: '服务过载529错误。账户将暂时停止调度',
timestamp: new Date().toISOString()
})
} catch (webhookError) {
logger.error('Failed to send overload webhook notification:', webhookError)
}
logger.warn(`🚫 Claude Console account marked as overloaded: ${account.name} (${accountId})`)
return { success: true }
} catch (error) {
logger.error(`❌ Failed to mark Claude Console account as overloaded: ${accountId}`, error)
throw error
}
}
// ✅ 移除账号的过载状态
async removeAccountOverload(accountId) {
try {
const client = redis.getClientSafe()
await client.hdel(`${this.ACCOUNT_KEY_PREFIX}${accountId}`, 'overloadedAt', 'overloadStatus')
logger.success(`✅ Overload status removed for Claude Console account: ${accountId}`)
return { success: true }
} catch (error) {
logger.error(
`❌ Failed to remove overload status for Claude Console account: ${accountId}`,
error
)
throw error
}
}
// 🔍 检查账号是否处于过载状态
async isAccountOverloaded(accountId) {
try {
const account = await this.getAccount(accountId)
if (!account) {
return false
}
if (account.overloadStatus === 'overloaded' && account.overloadedAt) {
const overloadedAt = new Date(account.overloadedAt)
const now = new Date()
const minutesSinceOverload = (now - overloadedAt) / (1000 * 60)
// 过载状态持续10分钟后自动恢复
if (minutesSinceOverload >= 10) {
await this.removeAccountOverload(accountId)
return false
}
return true
}
return false
} catch (error) {
logger.error(
`❌ Failed to check overload status for Claude Console account: ${accountId}`,
error
)
return false
}
}
// 🚫 标记账号为封锁状态(模型不支持等原因) // 🚫 标记账号为封锁状态(模型不支持等原因)
async blockAccount(accountId, reason) { async blockAccount(accountId, reason) {
try { try {
@@ -952,247 +681,6 @@ class ClaudeConsoleAccountService {
// 返回映射后的模型,如果不存在则返回原模型 // 返回映射后的模型,如果不存在则返回原模型
return modelMapping[requestedModel] || requestedModel return modelMapping[requestedModel] || requestedModel
} }
// 💰 检查账户使用额度(基于实时统计数据)
async checkQuotaUsage(accountId) {
try {
// 获取实时的使用统计(包含费用)
const usageStats = await redis.getAccountUsageStats(accountId)
const currentDailyCost = usageStats.daily.cost || 0
// 获取账户配置
const accountData = await this.getAccount(accountId)
if (!accountData) {
logger.warn(`Account not found: ${accountId}`)
return
}
// 解析额度配置,确保数值有效
const dailyQuota = parseFloat(accountData.dailyQuota || '0')
if (isNaN(dailyQuota) || dailyQuota <= 0) {
// 没有设置有效额度,无需检查
return
}
// 检查是否已经因额度停用(避免重复操作)
if (!accountData.isActive && accountData.quotaStoppedAt) {
return
}
// 检查是否超过额度限制
if (currentDailyCost >= dailyQuota) {
// 使用原子操作避免竞态条件 - 再次检查是否已设置quotaStoppedAt
const client = redis.getClientSafe()
const accountKey = `${this.ACCOUNT_KEY_PREFIX}${accountId}`
// double-check locking pattern - 检查quotaStoppedAt而不是status
const existingQuotaStop = await client.hget(accountKey, 'quotaStoppedAt')
if (existingQuotaStop) {
return // 已经被其他进程处理
}
// 超过额度,停用账户
const updates = {
isActive: false,
quotaStoppedAt: new Date().toISOString(),
errorMessage: `Daily quota exceeded: $${currentDailyCost.toFixed(2)} / $${dailyQuota.toFixed(2)}`
}
// 只有当前状态是active时才改为quota_exceeded
// 如果是rate_limited等其他状态保持原状态不变
const currentStatus = await client.hget(accountKey, 'status')
if (currentStatus === 'active') {
updates.status = 'quota_exceeded'
}
await this.updateAccount(accountId, updates)
logger.warn(
`💰 Account ${accountId} exceeded daily quota: $${currentDailyCost.toFixed(2)} / $${dailyQuota.toFixed(2)}`
)
// 发送webhook通知
try {
const webhookNotifier = require('../utils/webhookNotifier')
await webhookNotifier.sendAccountAnomalyNotification({
accountId,
accountName: accountData.name || 'Unknown Account',
platform: 'claude-console',
status: 'quota_exceeded',
errorCode: 'CLAUDE_CONSOLE_QUOTA_EXCEEDED',
reason: `Daily quota exceeded: $${currentDailyCost.toFixed(2)} / $${dailyQuota.toFixed(2)}`
})
} catch (webhookError) {
logger.error('Failed to send webhook notification for quota exceeded:', webhookError)
}
}
logger.debug(
`💰 Quota check for account ${accountId}: $${currentDailyCost.toFixed(4)} / $${dailyQuota.toFixed(2)}`
)
} catch (error) {
logger.error('Failed to check quota usage:', error)
}
}
// 🔄 重置账户每日使用量(恢复因额度停用的账户)
async resetDailyUsage(accountId) {
try {
const accountData = await this.getAccount(accountId)
if (!accountData) {
return
}
const today = redis.getDateStringInTimezone()
const updates = {
lastResetDate: today
}
// 如果账户是因为超额被停用的,恢复账户
// 注意:状态可能是 quota_exceeded 或 rate_limited如果429错误时也超额了
if (
accountData.quotaStoppedAt &&
accountData.isActive === false &&
(accountData.status === 'quota_exceeded' || accountData.status === 'rate_limited')
) {
updates.isActive = true
updates.status = 'active'
updates.errorMessage = ''
updates.quotaStoppedAt = ''
// 如果是rate_limited状态也清除限流相关字段
if (accountData.status === 'rate_limited') {
const client = redis.getClientSafe()
const accountKey = `${this.ACCOUNT_KEY_PREFIX}${accountId}`
await client.hdel(accountKey, 'rateLimitedAt', 'rateLimitStatus')
}
logger.info(
`✅ Restored account ${accountId} after daily reset (was ${accountData.status})`
)
}
await this.updateAccount(accountId, updates)
logger.debug(`🔄 Reset daily usage for account ${accountId}`)
} catch (error) {
logger.error('Failed to reset daily usage:', error)
}
}
// 🔄 重置所有账户的每日使用量
async resetAllDailyUsage() {
try {
const accounts = await this.getAllAccounts()
// 与统计一致使用配置时区日期
const today = redis.getDateStringInTimezone()
let resetCount = 0
for (const account of accounts) {
// 只重置需要重置的账户
if (account.lastResetDate !== today) {
await this.resetDailyUsage(account.id)
resetCount += 1
}
}
logger.success(`✅ Reset daily usage for ${resetCount} Claude Console accounts`)
} catch (error) {
logger.error('Failed to reset all daily usage:', error)
}
}
// 📊 获取账户使用统计(基于实时数据)
async getAccountUsageStats(accountId) {
try {
// 获取实时的使用统计(包含费用)
const usageStats = await redis.getAccountUsageStats(accountId)
const currentDailyCost = usageStats.daily.cost || 0
// 获取账户配置
const accountData = await this.getAccount(accountId)
if (!accountData) {
return null
}
const dailyQuota = parseFloat(accountData.dailyQuota || '0')
return {
dailyQuota,
dailyUsage: currentDailyCost, // 使用实时计算的费用
remainingQuota: dailyQuota > 0 ? Math.max(0, dailyQuota - currentDailyCost) : null,
usagePercentage: dailyQuota > 0 ? (currentDailyCost / dailyQuota) * 100 : 0,
lastResetDate: accountData.lastResetDate,
quotaStoppedAt: accountData.quotaStoppedAt,
isQuotaExceeded: dailyQuota > 0 && currentDailyCost >= dailyQuota,
// 额外返回完整的使用统计
fullUsageStats: usageStats
}
} catch (error) {
logger.error('Failed to get account usage stats:', error)
return null
}
}
// 🔄 重置账户所有异常状态
async resetAccountStatus(accountId) {
try {
const accountData = await this.getAccount(accountId)
if (!accountData) {
throw new Error('Account not found')
}
const client = redis.getClientSafe()
const accountKey = `${this.ACCOUNT_KEY_PREFIX}${accountId}`
// 准备要更新的字段
const updates = {
status: 'active',
errorMessage: '',
schedulable: 'true',
isActive: 'true' // 重要必须恢复isActive状态
}
// 删除所有异常状态相关的字段
const fieldsToDelete = [
'rateLimitedAt',
'rateLimitStatus',
'unauthorizedAt',
'unauthorizedCount',
'overloadedAt',
'overloadStatus',
'blockedAt',
'quotaStoppedAt'
]
// 执行更新
await client.hset(accountKey, updates)
await client.hdel(accountKey, ...fieldsToDelete)
logger.success(`✅ Reset all error status for Claude Console account ${accountId}`)
// 发送 Webhook 通知
try {
const webhookNotifier = require('../utils/webhookNotifier')
await webhookNotifier.sendAccountAnomalyNotification({
accountId,
accountName: accountData.name || accountId,
platform: 'claude-console',
status: 'recovered',
errorCode: 'STATUS_RESET',
reason: 'Account status manually reset',
timestamp: new Date().toISOString()
})
} catch (webhookError) {
logger.warn('Failed to send webhook notification:', webhookError)
}
return { success: true, accountId }
} catch (error) {
logger.error(`❌ Failed to reset Claude Console account status: ${accountId}`, error)
throw error
}
}
} }
module.exports = new ClaudeConsoleAccountService() module.exports = new ClaudeConsoleAccountService()

View File

@@ -19,11 +19,10 @@ class ClaudeConsoleRelayService {
options = {} options = {}
) { ) {
let abortController = null let abortController = null
let account = null
try { try {
// 获取账户信息 // 获取账户信息
account = await claudeConsoleAccountService.getAccount(accountId) const account = await claudeConsoleAccountService.getAccount(accountId)
if (!account) { if (!account) {
throw new Error('Claude Console Claude account not found') throw new Error('Claude Console Claude account not found')
} }
@@ -123,7 +122,7 @@ class ClaudeConsoleRelayService {
...filteredHeaders ...filteredHeaders
}, },
httpsAgent: proxyAgent, httpsAgent: proxyAgent,
timeout: config.requestTimeout || 600000, timeout: config.proxy.timeout || 60000,
signal: abortController.signal, signal: abortController.signal,
validateStatus: () => true // 接受所有状态码 validateStatus: () => true // 接受所有状态码
} }
@@ -176,31 +175,16 @@ class ClaudeConsoleRelayService {
`[DEBUG] Response data preview: ${typeof response.data === 'string' ? response.data.substring(0, 200) : JSON.stringify(response.data).substring(0, 200)}` `[DEBUG] Response data preview: ${typeof response.data === 'string' ? response.data.substring(0, 200) : JSON.stringify(response.data).substring(0, 200)}`
) )
// 检查错误状态并相应处理 // 检查是否为限流错误
if (response.status === 401) { if (response.status === 429) {
logger.warn(`🚫 Unauthorized error detected for Claude Console account ${accountId}`)
await claudeConsoleAccountService.markAccountUnauthorized(accountId)
} 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}`)
// 收到429先检查是否因为超过了手动配置的每日额度
await claudeConsoleAccountService.checkQuotaUsage(accountId).catch((err) => {
logger.error('❌ Failed to check quota after 429 error:', err)
})
await claudeConsoleAccountService.markAccountRateLimited(accountId) await claudeConsoleAccountService.markAccountRateLimited(accountId)
} else if (response.status === 529) {
logger.warn(`🚫 Overload error detected for Claude Console account ${accountId}`)
await claudeConsoleAccountService.markAccountOverloaded(accountId)
} else if (response.status === 200 || response.status === 201) { } else if (response.status === 200 || response.status === 201) {
// 如果请求成功,检查并移除错误状态 // 如果请求成功,检查并移除限流状态
const isRateLimited = await claudeConsoleAccountService.isAccountRateLimited(accountId) const isRateLimited = await claudeConsoleAccountService.isAccountRateLimited(accountId)
if (isRateLimited) { if (isRateLimited) {
await claudeConsoleAccountService.removeAccountRateLimit(accountId) await claudeConsoleAccountService.removeAccountRateLimit(accountId)
} }
const isOverloaded = await claudeConsoleAccountService.isAccountOverloaded(accountId)
if (isOverloaded) {
await claudeConsoleAccountService.removeAccountOverload(accountId)
}
} }
// 更新最后使用时间 // 更新最后使用时间
@@ -223,10 +207,7 @@ class ClaudeConsoleRelayService {
throw new Error('Client disconnected') throw new Error('Client disconnected')
} }
logger.error( logger.error('❌ Claude Console Claude relay request failed:', error.message)
`❌ Claude Console relay request failed (Account: ${account?.name || accountId}):`,
error.message
)
// 不再因为模型不支持而block账号 // 不再因为模型不支持而block账号
@@ -245,10 +226,9 @@ class ClaudeConsoleRelayService {
streamTransformer = null, streamTransformer = null,
options = {} options = {}
) { ) {
let account = null
try { try {
// 获取账户信息 // 获取账户信息
account = await claudeConsoleAccountService.getAccount(accountId) const account = await claudeConsoleAccountService.getAccount(accountId)
if (!account) { if (!account) {
throw new Error('Claude Console Claude account not found') throw new Error('Claude Console Claude account not found')
} }
@@ -302,10 +282,7 @@ class ClaudeConsoleRelayService {
// 更新最后使用时间 // 更新最后使用时间
await this._updateLastUsedTime(accountId) await this._updateLastUsedTime(accountId)
} catch (error) { } catch (error) {
logger.error( logger.error('❌ Claude Console Claude stream relay failed:', error)
`❌ Claude Console stream relay failed (Account: ${account?.name || accountId}):`,
error
)
throw error throw error
} }
} }
@@ -354,7 +331,7 @@ class ClaudeConsoleRelayService {
...filteredHeaders ...filteredHeaders
}, },
httpsAgent: proxyAgent, httpsAgent: proxyAgent,
timeout: config.requestTimeout || 600000, timeout: config.proxy.timeout || 60000,
responseType: 'stream', responseType: 'stream',
validateStatus: () => true // 接受所有状态码 validateStatus: () => true // 接受所有状态码
} }
@@ -384,20 +361,10 @@ class ClaudeConsoleRelayService {
// 错误响应处理 // 错误响应处理
if (response.status !== 200) { if (response.status !== 200) {
logger.error( logger.error(`❌ Claude Console API returned error status: ${response.status}`)
`❌ Claude Console API returned error status: ${response.status} | Account: ${account?.name || accountId}`
)
if (response.status === 401) { if (response.status === 429) {
claudeConsoleAccountService.markAccountUnauthorized(accountId)
} else if (response.status === 429) {
claudeConsoleAccountService.markAccountRateLimited(accountId) claudeConsoleAccountService.markAccountRateLimited(accountId)
// 检查是否因为超过每日额度
claudeConsoleAccountService.checkQuotaUsage(accountId).catch((err) => {
logger.error('❌ Failed to check quota after 429 error:', err)
})
} else if (response.status === 529) {
claudeConsoleAccountService.markAccountOverloaded(accountId)
} }
// 设置错误响应的状态码和响应头 // 设置错误响应的状态码和响应头
@@ -429,17 +396,12 @@ class ClaudeConsoleRelayService {
return return
} }
// 成功响应,检查并移除错误状态 // 成功响应,检查并移除限流状态
claudeConsoleAccountService.isAccountRateLimited(accountId).then((isRateLimited) => { claudeConsoleAccountService.isAccountRateLimited(accountId).then((isRateLimited) => {
if (isRateLimited) { if (isRateLimited) {
claudeConsoleAccountService.removeAccountRateLimit(accountId) claudeConsoleAccountService.removeAccountRateLimit(accountId)
} }
}) })
claudeConsoleAccountService.isAccountOverloaded(accountId).then((isOverloaded) => {
if (isOverloaded) {
claudeConsoleAccountService.removeAccountOverload(accountId)
}
})
// 设置响应头 // 设置响应头
if (!responseStream.headersSent) { if (!responseStream.headersSent) {
@@ -538,10 +500,7 @@ class ClaudeConsoleRelayService {
} }
} }
} catch (error) { } catch (error) {
logger.error( logger.error('❌ Error processing Claude Console stream data:', error)
`❌ Error processing Claude Console stream data (Account: ${account?.name || accountId}):`,
error
)
if (!responseStream.destroyed) { if (!responseStream.destroyed) {
responseStream.write('event: error\n') responseStream.write('event: error\n')
responseStream.write( responseStream.write(
@@ -583,10 +542,7 @@ class ClaudeConsoleRelayService {
}) })
response.data.on('error', (error) => { response.data.on('error', (error) => {
logger.error( logger.error('❌ Claude Console stream error:', error)
`❌ Claude Console stream error (Account: ${account?.name || accountId}):`,
error
)
if (!responseStream.destroyed) { if (!responseStream.destroyed) {
responseStream.write('event: error\n') responseStream.write('event: error\n')
responseStream.write( responseStream.write(
@@ -606,24 +562,11 @@ class ClaudeConsoleRelayService {
return return
} }
logger.error( logger.error('❌ Claude Console Claude stream request error:', error.message)
`❌ Claude Console stream request error (Account: ${account?.name || accountId}):`,
error.message
)
// 检查错误状态 // 检查是否是429错误
if (error.response) { if (error.response && error.response.status === 429) {
if (error.response.status === 401) { claudeConsoleAccountService.markAccountRateLimited(accountId)
claudeConsoleAccountService.markAccountUnauthorized(accountId)
} else if (error.response.status === 429) {
claudeConsoleAccountService.markAccountRateLimited(accountId)
// 检查是否因为超过每日额度
claudeConsoleAccountService.checkQuotaUsage(accountId).catch((err) => {
logger.error('❌ Failed to check quota after 429 error:', err)
})
} else if (error.response.status === 529) {
claudeConsoleAccountService.markAccountOverloaded(accountId)
}
} }
// 发送错误响应 // 发送错误响应

View File

@@ -9,7 +9,6 @@ const sessionHelper = require('../utils/sessionHelper')
const logger = require('../utils/logger') const logger = require('../utils/logger')
const config = require('../../config/config') const config = require('../../config/config')
const claudeCodeHeadersService = require('./claudeCodeHeadersService') const claudeCodeHeadersService = require('./claudeCodeHeadersService')
const redis = require('../models/redis')
class ClaudeRelayService { class ClaudeRelayService {
constructor() { constructor() {
@@ -24,7 +23,7 @@ class ClaudeRelayService {
isRealClaudeCodeRequest(requestBody, clientHeaders) { isRealClaudeCodeRequest(requestBody, clientHeaders) {
// 检查 user-agent 是否匹配 Claude Code 格式 // 检查 user-agent 是否匹配 Claude Code 格式
const userAgent = clientHeaders?.['user-agent'] || clientHeaders?.['User-Agent'] || '' const userAgent = clientHeaders?.['user-agent'] || clientHeaders?.['User-Agent'] || ''
const isClaudeCodeUserAgent = /^claude-cli\/[\d.]+\s+\(/i.test(userAgent) const isClaudeCodeUserAgent = /claude-cli\/\d+\.\d+\.\d+/.test(userAgent)
// 检查系统提示词是否包含 Claude Code 标识 // 检查系统提示词是否包含 Claude Code 标识
const hasClaudeCodeSystemPrompt = this._hasClaudeCodeSystemPrompt(requestBody) const hasClaudeCodeSystemPrompt = this._hasClaudeCodeSystemPrompt(requestBody)
@@ -126,11 +125,8 @@ class ClaudeRelayService {
// 获取有效的访问token // 获取有效的访问token
const accessToken = await claudeAccountService.getValidAccessToken(accountId) const accessToken = await claudeAccountService.getValidAccessToken(accountId)
// 获取账户信息
const account = await claudeAccountService.getAccount(accountId)
// 处理请求体(传递 clientHeaders 以判断是否需要设置 Claude Code 系统提示词) // 处理请求体(传递 clientHeaders 以判断是否需要设置 Claude Code 系统提示词)
const processedBody = this._processRequestBody(requestBody, clientHeaders, account) const processedBody = this._processRequestBody(requestBody, clientHeaders)
// 获取代理配置 // 获取代理配置
const proxyAgent = await this._getProxyAgent(accountId) const proxyAgent = await this._getProxyAgent(accountId)
@@ -184,15 +180,15 @@ class ClaudeRelayService {
// 记录401错误 // 记录401错误
await this.recordUnauthorizedError(accountId) await this.recordUnauthorizedError(accountId)
// 检查是否需要标记为异常(遇到1次401就停止调度 // 检查是否需要标记为异常(连续3次401
const errorCount = await this.getUnauthorizedErrorCount(accountId) const errorCount = await this.getUnauthorizedErrorCount(accountId)
logger.info( logger.info(
`🔐 Account ${accountId} has ${errorCount} consecutive 401 errors in the last 5 minutes` `🔐 Account ${accountId} has ${errorCount} consecutive 401 errors in the last 5 minutes`
) )
if (errorCount >= 1) { if (errorCount >= 3) {
logger.error( logger.error(
`❌ Account ${accountId} encountered 401 error (${errorCount} errors), marking as unauthorized` `❌ Account ${accountId} exceeded 401 error threshold (${errorCount} errors), marking as unauthorized`
) )
await unifiedClaudeScheduler.markAccountUnauthorized( await unifiedClaudeScheduler.markAccountUnauthorized(
accountId, accountId,
@@ -201,35 +197,22 @@ class ClaudeRelayService {
) )
} }
} }
// 检查是否为403状态码禁止访问
else if (response.statusCode === 403) {
logger.error(
`🚫 Forbidden error (403) detected for account ${accountId}, marking as blocked`
)
await unifiedClaudeScheduler.markAccountBlocked(accountId, accountType, sessionHash)
}
// 检查是否为529状态码服务过载
else if (response.statusCode === 529) {
logger.warn(`🚫 Overload error (529) detected for account ${accountId}`)
// 检查是否启用了529错误处理
if (config.claude.overloadHandling.enabled > 0) {
try {
await claudeAccountService.markAccountOverloaded(accountId)
logger.info(
`🚫 Account ${accountId} marked as overloaded for ${config.claude.overloadHandling.enabled} minutes`
)
} catch (overloadError) {
logger.error(`❌ Failed to mark account as overloaded: ${accountId}`, overloadError)
}
} else {
logger.info(`🚫 529 error handling is disabled, skipping account overload marking`)
}
}
// 检查是否为5xx状态码 // 检查是否为5xx状态码
else if (response.statusCode >= 500 && response.statusCode < 600) { else if (response.statusCode >= 500 && response.statusCode < 600) {
logger.warn(`🔥 Server error (${response.statusCode}) detected for account ${accountId}`) logger.warn(`🔥 Server error (${response.statusCode}) detected for account ${accountId}`)
await this._handleServerError(accountId, response.statusCode, sessionHash) // 记录5xx错误
await claudeAccountService.recordServerError(accountId, response.statusCode)
// 检查是否需要标记为临时错误状态连续3次500
const errorCount = await claudeAccountService.getServerErrorCount(accountId)
logger.info(
`🔥 Account ${accountId} has ${errorCount} consecutive 5xx errors in the last 5 minutes`
)
if (errorCount >= 3) {
logger.error(
`❌ Account ${accountId} exceeded 5xx error threshold (${errorCount} errors), marking as temp_error`
)
await claudeAccountService.markAccountTempError(accountId, sessionHash)
}
} }
// 检查是否为429状态码 // 检查是否为429状态码
else if (response.statusCode === 429) { else if (response.statusCode === 429) {
@@ -281,27 +264,6 @@ class ClaudeRelayService {
) )
} }
} else if (response.statusCode === 200 || response.statusCode === 201) { } else if (response.statusCode === 200 || response.statusCode === 201) {
// 提取5小时会话窗口状态
// 使用大小写不敏感的方式获取响应头
const get5hStatus = (headers) => {
if (!headers) {
return null
}
// HTTP头部名称不区分大小写需要处理不同情况
return (
headers['anthropic-ratelimit-unified-5h-status'] ||
headers['Anthropic-Ratelimit-Unified-5h-Status'] ||
headers['ANTHROPIC-RATELIMIT-UNIFIED-5H-STATUS']
)
}
const sessionWindowStatus = get5hStatus(response.headers)
if (sessionWindowStatus) {
logger.info(`📊 Session window status for account ${accountId}: ${sessionWindowStatus}`)
// 保存会话窗口状态到账户数据
await claudeAccountService.updateSessionWindowStatus(accountId, sessionWindowStatus)
}
// 请求成功清除401和500错误计数 // 请求成功清除401和500错误计数
await this.clearUnauthorizedErrors(accountId) await this.clearUnauthorizedErrors(accountId)
await claudeAccountService.clearInternalErrors(accountId) await claudeAccountService.clearInternalErrors(accountId)
@@ -314,19 +276,6 @@ class ClaudeRelayService {
await unifiedClaudeScheduler.removeAccountRateLimit(accountId, accountType) await unifiedClaudeScheduler.removeAccountRateLimit(accountId, accountType)
} }
// 如果请求成功,检查并移除过载状态
try {
const isOverloaded = await claudeAccountService.isAccountOverloaded(accountId)
if (isOverloaded) {
await claudeAccountService.removeAccountOverload(accountId)
}
} catch (overloadError) {
logger.error(
`❌ Failed to check/remove overload status for account ${accountId}:`,
overloadError
)
}
// 只有真实的 Claude Code 请求才更新 headers // 只有真实的 Claude Code 请求才更新 headers
if ( if (
clientHeaders && clientHeaders &&
@@ -378,7 +327,7 @@ class ClaudeRelayService {
} }
// 🔄 处理请求体 // 🔄 处理请求体
_processRequestBody(body, clientHeaders = {}, account = null) { _processRequestBody(body, clientHeaders = {}) {
if (!body) { if (!body) {
return body return body
} }
@@ -480,31 +429,9 @@ class ClaudeRelayService {
delete processedBody.top_p delete processedBody.top_p
} }
// 处理统一的客户端标识
if (account && account.useUnifiedClientId && account.unifiedClientId) {
this._replaceClientId(processedBody, account.unifiedClientId)
}
return processedBody return processedBody
} }
// 🔄 替换请求中的客户端标识
_replaceClientId(body, unifiedClientId) {
if (!body || !body.metadata || !body.metadata.user_id || !unifiedClientId) {
return
}
const userId = body.metadata.user_id
// user_id格式user_{64位十六进制}_account__session_{uuid}
// 只替换第一个下划线后到_account之前的部分客户端标识
const match = userId.match(/^user_[a-f0-9]{64}(_account__session_[a-f0-9-]{36})$/)
if (match && match[1]) {
// 替换客户端标识部分
body.metadata.user_id = `user_${unifiedClientId}${match[1]}`
logger.info(`🔄 Replaced client ID with unified ID: ${body.metadata.user_id}`)
}
}
// 🔢 验证并限制max_tokens参数 // 🔢 验证并限制max_tokens参数
_validateAndLimitMaxTokens(body) { _validateAndLimitMaxTokens(body) {
if (!body || !body.max_tokens) { if (!body || !body.max_tokens) {
@@ -527,10 +454,7 @@ class ClaudeRelayService {
const modelConfig = pricingData[model] const modelConfig = pricingData[model]
if (!modelConfig) { if (!modelConfig) {
// 如果找不到模型配置,直接透传客户端参数,不进行任何干预 logger.debug(`🔍 Model ${model} not found in pricing file, skipping max_tokens validation`)
logger.info(
`📝 Model ${model} not found in pricing file, passing through client parameters without modification`
)
return return
} }
@@ -662,12 +586,6 @@ class ClaudeRelayService {
) { ) {
const url = new URL(this.claudeApiUrl) const url = new URL(this.claudeApiUrl)
// 获取账户信息用于统一 User-Agent
const account = await claudeAccountService.getAccount(accountId)
// 获取统一的 User-Agent
const unifiedUA = await this.captureAndGetUnifiedUserAgent(clientHeaders, account)
// 获取过滤后的客户端 headers // 获取过滤后的客户端 headers
const filteredHeaders = this._filterClientHeaders(clientHeaders) const filteredHeaders = this._filterClientHeaders(clientHeaders)
@@ -711,19 +629,14 @@ class ClaudeRelayService {
...finalHeaders ...finalHeaders
}, },
agent: proxyAgent, agent: proxyAgent,
timeout: config.requestTimeout || 600000 timeout: config.proxy.timeout
} }
// 使用统一 User-Agent 或客户端提供的,最后使用默认值 // 如果客户端没有提供 User-Agent使用默认值
if (!options.headers['User-Agent'] && !options.headers['user-agent']) { if (!options.headers['User-Agent'] && !options.headers['user-agent']) {
const userAgent = unifiedUA || 'claude-cli/1.0.57 (external, cli)' options.headers['User-Agent'] = 'claude-cli/1.0.57 (external, cli)'
options.headers['User-Agent'] = userAgent
} }
logger.info(
`🔗 指纹是这个: ${options.headers['User-Agent'] || options.headers['user-agent']}`
)
// 使用自定义的 betaHeader 或默认值 // 使用自定义的 betaHeader 或默认值
const betaHeader = const betaHeader =
requestOptions?.betaHeader !== undefined ? requestOptions.betaHeader : this.betaHeader requestOptions?.betaHeader !== undefined ? requestOptions.betaHeader : this.betaHeader
@@ -772,7 +685,7 @@ class ClaudeRelayService {
resolve(response) resolve(response)
} catch (error) { } catch (error) {
logger.error(`❌ Failed to parse Claude API response (Account: ${accountId}):`, error) logger.error('❌ Failed to parse Claude API response:', error)
reject(error) reject(error)
} }
}) })
@@ -783,9 +696,9 @@ class ClaudeRelayService {
onRequest(req) onRequest(req)
} }
req.on('error', async (error) => { req.on('error', (error) => {
console.error(': ❌ ', error) console.error(': ❌ ', error)
logger.error(`❌ Claude API request error (Account: ${accountId}):`, error.message, { logger.error('❌ Claude API request error:', error.message, {
code: error.code, code: error.code,
errno: error.errno, errno: error.errno,
syscall: error.syscall, syscall: error.syscall,
@@ -803,19 +716,14 @@ class ClaudeRelayService {
errorMessage = 'Connection refused by Claude API server' errorMessage = 'Connection refused by Claude API server'
} else if (error.code === 'ETIMEDOUT') { } else if (error.code === 'ETIMEDOUT') {
errorMessage = 'Connection timed out to Claude API server' errorMessage = 'Connection timed out to Claude API server'
await this._handleServerError(accountId, 504, null, 'Network')
} }
reject(new Error(errorMessage)) reject(new Error(errorMessage))
}) })
req.on('timeout', async () => { req.on('timeout', () => {
req.destroy() req.destroy()
logger.error(`❌ Claude API request timeout (Account: ${accountId})`) logger.error('❌ Claude API request timeout')
await this._handleServerError(accountId, 504, null, 'Request')
reject(new Error('Request timeout')) reject(new Error('Request timeout'))
}) })
@@ -893,11 +801,8 @@ class ClaudeRelayService {
// 获取有效的访问token // 获取有效的访问token
const accessToken = await claudeAccountService.getValidAccessToken(accountId) const accessToken = await claudeAccountService.getValidAccessToken(accountId)
// 获取账户信息
const account = await claudeAccountService.getAccount(accountId)
// 处理请求体(传递 clientHeaders 以判断是否需要设置 Claude Code 系统提示词) // 处理请求体(传递 clientHeaders 以判断是否需要设置 Claude Code 系统提示词)
const processedBody = this._processRequestBody(requestBody, clientHeaders, account) const processedBody = this._processRequestBody(requestBody, clientHeaders)
// 获取代理配置 // 获取代理配置
const proxyAgent = await this._getProxyAgent(accountId) const proxyAgent = await this._getProxyAgent(accountId)
@@ -920,7 +825,7 @@ class ClaudeRelayService {
options options
) )
} catch (error) { } catch (error) {
logger.error(`❌ Claude stream relay with usage capture failed:`, error) logger.error('❌ Claude stream relay with usage capture failed:', error)
throw error throw error
} }
} }
@@ -939,12 +844,6 @@ class ClaudeRelayService {
streamTransformer = null, streamTransformer = null,
requestOptions = {} requestOptions = {}
) { ) {
// 获取账户信息用于统一 User-Agent
const account = await claudeAccountService.getAccount(accountId)
// 获取统一的 User-Agent
const unifiedUA = await this.captureAndGetUnifiedUserAgent(clientHeaders, account)
// 获取过滤后的客户端 headers // 获取过滤后的客户端 headers
const filteredHeaders = this._filterClientHeaders(clientHeaders) const filteredHeaders = this._filterClientHeaders(clientHeaders)
@@ -982,18 +881,14 @@ class ClaudeRelayService {
...finalHeaders ...finalHeaders
}, },
agent: proxyAgent, agent: proxyAgent,
timeout: config.requestTimeout || 600000 timeout: config.proxy.timeout
} }
// 使用统一 User-Agent 或客户端提供的,最后使用默认值 // 如果客户端没有提供 User-Agent使用默认值
if (!options.headers['User-Agent'] && !options.headers['user-agent']) { if (!options.headers['User-Agent'] && !options.headers['user-agent']) {
const userAgent = unifiedUA || 'claude-cli/1.0.57 (external, cli)' options.headers['User-Agent'] = 'claude-cli/1.0.57 (external, cli)'
options.headers['User-Agent'] = userAgent
} }
logger.info(
`🔗 指纹是这个: ${options.headers['User-Agent'] || options.headers['user-agent']}`
)
// 使用自定义的 betaHeader 或默认值 // 使用自定义的 betaHeader 或默认值
const betaHeader = const betaHeader =
requestOptions?.betaHeader !== undefined ? requestOptions.betaHeader : this.betaHeader requestOptions?.betaHeader !== undefined ? requestOptions.betaHeader : this.betaHeader
@@ -1008,57 +903,24 @@ class ClaudeRelayService {
if (res.statusCode !== 200) { if (res.statusCode !== 200) {
// 将错误处理逻辑封装在一个异步函数中 // 将错误处理逻辑封装在一个异步函数中
const handleErrorResponse = async () => { const handleErrorResponse = async () => {
if (res.statusCode === 401) { // 增加对5xx错误的处理
logger.warn(`🔐 [Stream] Unauthorized error (401) detected for account ${accountId}`) if (res.statusCode >= 500 && res.statusCode < 600) {
await this.recordUnauthorizedError(accountId)
const errorCount = await this.getUnauthorizedErrorCount(accountId)
logger.info(
`🔐 [Stream] Account ${accountId} has ${errorCount} consecutive 401 errors in the last 5 minutes`
)
if (errorCount >= 1) {
logger.error(
`❌ [Stream] Account ${accountId} encountered 401 error (${errorCount} errors), marking as unauthorized`
)
await unifiedClaudeScheduler.markAccountUnauthorized(
accountId,
accountType,
sessionHash
)
}
} else if (res.statusCode === 403) {
logger.error(
`🚫 [Stream] Forbidden error (403) detected for account ${accountId}, marking as blocked`
)
await unifiedClaudeScheduler.markAccountBlocked(accountId, accountType, sessionHash)
} else if (res.statusCode === 529) {
logger.warn(`🚫 [Stream] Overload error (529) detected for account ${accountId}`)
// 检查是否启用了529错误处理
if (config.claude.overloadHandling.enabled > 0) {
try {
await claudeAccountService.markAccountOverloaded(accountId)
logger.info(
`🚫 [Stream] Account ${accountId} marked as overloaded for ${config.claude.overloadHandling.enabled} minutes`
)
} catch (overloadError) {
logger.error(
`❌ [Stream] Failed to mark account as overloaded: ${accountId}`,
overloadError
)
}
} else {
logger.info(
`🚫 [Stream] 529 error handling is disabled, skipping account overload marking`
)
}
} else if (res.statusCode >= 500 && res.statusCode < 600) {
logger.warn( logger.warn(
`🔥 [Stream] Server error (${res.statusCode}) detected for account ${accountId}` `🔥 [Stream] Server error (${res.statusCode}) detected for account ${accountId}`
) )
await this._handleServerError(accountId, res.statusCode, sessionHash, '[Stream]') // 记录5xx错误
await claudeAccountService.recordServerError(accountId, res.statusCode)
// 检查是否需要标记为临时错误状态连续3次500
const errorCount = await claudeAccountService.getServerErrorCount(accountId)
logger.info(
`🔥 [Stream] Account ${accountId} has ${errorCount} consecutive 5xx errors in the last 5 minutes`
)
if (errorCount >= 3) {
logger.error(
`❌ [Stream] Account ${accountId} exceeded 5xx error threshold (${errorCount} errors), marking as temp_error`
)
await claudeAccountService.markAccountTempError(accountId, sessionHash)
}
} }
} }
@@ -1067,9 +929,7 @@ class ClaudeRelayService {
logger.error('❌ Error in stream error handler:', err) logger.error('❌ Error in stream error handler:', err)
}) })
logger.error( logger.error(`❌ Claude API returned error status: ${res.statusCode}`)
`❌ Claude API returned error status: ${res.statusCode} | Account: ${account?.name || accountId}`
)
let errorData = '' let errorData = ''
res.on('data', (chunk) => { res.on('data', (chunk) => {
@@ -1078,10 +938,7 @@ class ClaudeRelayService {
res.on('end', () => { res.on('end', () => {
console.error(': ❌ ', errorData) console.error(': ❌ ', errorData)
logger.error( logger.error('❌ Claude API error response:', errorData)
`❌ Claude API error response (Account: ${account?.name || accountId}):`,
errorData
)
if (!responseStream.destroyed) { if (!responseStream.destroyed) {
// 发送错误事件 // 发送错误事件
responseStream.write('event: error\n') responseStream.write('event: error\n')
@@ -1332,27 +1189,6 @@ class ClaudeRelayService {
usageCallback(finalUsage) usageCallback(finalUsage)
} }
// 提取5小时会话窗口状态
// 使用大小写不敏感的方式获取响应头
const get5hStatus = (headers) => {
if (!headers) {
return null
}
// HTTP头部名称不区分大小写需要处理不同情况
return (
headers['anthropic-ratelimit-unified-5h-status'] ||
headers['Anthropic-Ratelimit-Unified-5h-Status'] ||
headers['ANTHROPIC-RATELIMIT-UNIFIED-5H-STATUS']
)
}
const sessionWindowStatus = get5hStatus(res.headers)
if (sessionWindowStatus) {
logger.info(`📊 Session window status for account ${accountId}: ${sessionWindowStatus}`)
// 保存会话窗口状态到账户数据
await claudeAccountService.updateSessionWindowStatus(accountId, sessionWindowStatus)
}
// 处理限流状态 // 处理限流状态
if (rateLimitDetected || res.statusCode === 429) { if (rateLimitDetected || res.statusCode === 429) {
// 提取限流重置时间戳 // 提取限流重置时间戳
@@ -1384,19 +1220,6 @@ class ClaudeRelayService {
await unifiedClaudeScheduler.removeAccountRateLimit(accountId, accountType) await unifiedClaudeScheduler.removeAccountRateLimit(accountId, accountType)
} }
// 如果流式请求成功,检查并移除过载状态
try {
const isOverloaded = await claudeAccountService.isAccountOverloaded(accountId)
if (isOverloaded) {
await claudeAccountService.removeAccountOverload(accountId)
}
} catch (overloadError) {
logger.error(
`❌ [Stream] Failed to check/remove overload status for account ${accountId}:`,
overloadError
)
}
// 只有真实的 Claude Code 请求才更新 headers流式请求 // 只有真实的 Claude Code 请求才更新 headers流式请求
if ( if (
clientHeaders && clientHeaders &&
@@ -1412,16 +1235,12 @@ class ClaudeRelayService {
}) })
}) })
req.on('error', async (error) => { req.on('error', (error) => {
logger.error( logger.error('❌ Claude stream request error:', error.message, {
`❌ Claude stream request error (Account: ${account?.name || accountId}):`, code: error.code,
error.message, errno: error.errno,
{ syscall: error.syscall
code: error.code, })
errno: error.errno,
syscall: error.syscall
}
)
// 根据错误类型提供更具体的错误信息 // 根据错误类型提供更具体的错误信息
let errorMessage = 'Upstream request failed' let errorMessage = 'Upstream request failed'
@@ -1463,10 +1282,9 @@ class ClaudeRelayService {
reject(error) reject(error)
}) })
req.on('timeout', async () => { req.on('timeout', () => {
req.destroy() req.destroy()
logger.error(`❌ Claude stream request timeout | Account: ${account?.name || accountId}`) logger.error('❌ Claude stream request timeout')
if (!responseStream.headersSent) { if (!responseStream.headersSent) {
responseStream.writeHead(504, { responseStream.writeHead(504, {
'Content-Type': 'text/event-stream', 'Content-Type': 'text/event-stream',
@@ -1530,17 +1348,12 @@ class ClaudeRelayService {
...filteredHeaders ...filteredHeaders
}, },
agent: proxyAgent, agent: proxyAgent,
timeout: config.requestTimeout || 600000 timeout: config.proxy.timeout
} }
// 如果客户端没有提供 User-Agent使用默认值 // 如果客户端没有提供 User-Agent使用默认值
if (!filteredHeaders['User-Agent'] && !filteredHeaders['user-agent']) { if (!filteredHeaders['User-Agent'] && !filteredHeaders['user-agent']) {
// 第三个方法不支持统一 User-Agent使用简化逻辑 options.headers['User-Agent'] = 'claude-cli/1.0.53 (external, cli)'
const userAgent =
clientHeaders?.['user-agent'] ||
clientHeaders?.['User-Agent'] ||
'claude-cli/1.0.102 (external, cli)'
options.headers['User-Agent'] = userAgent
} }
// 使用自定义的 betaHeader 或默认值 // 使用自定义的 betaHeader 或默认值
@@ -1566,8 +1379,8 @@ class ClaudeRelayService {
}) })
}) })
req.on('error', async (error) => { req.on('error', (error) => {
logger.error(`❌ Claude stream request error:`, error.message, { logger.error('❌ Claude stream request error:', error.message, {
code: error.code, code: error.code,
errno: error.errno, errno: error.errno,
syscall: error.syscall syscall: error.syscall
@@ -1613,10 +1426,9 @@ class ClaudeRelayService {
reject(error) reject(error)
}) })
req.on('timeout', async () => { req.on('timeout', () => {
req.destroy() req.destroy()
logger.error(`❌ Claude stream request timeout`) logger.error('❌ Claude stream request timeout')
if (!responseStream.headersSent) { if (!responseStream.headersSent) {
responseStream.writeHead(504, { responseStream.writeHead(504, {
'Content-Type': 'text/event-stream', 'Content-Type': 'text/event-stream',
@@ -1653,33 +1465,6 @@ class ClaudeRelayService {
}) })
} }
// 🛠️ 统一的错误处理方法
async _handleServerError(accountId, statusCode, sessionHash = null, context = '') {
try {
await claudeAccountService.recordServerError(accountId, statusCode)
const errorCount = await claudeAccountService.getServerErrorCount(accountId)
// 根据错误类型设置不同的阈值和日志前缀
const isTimeout = statusCode === 504
const threshold = 3 // 统一使用3次阈值
const prefix = context ? `${context} ` : ''
logger.warn(
`⏱️ ${prefix}${isTimeout ? 'Timeout' : 'Server'} error for account ${accountId}, error count: ${errorCount}/${threshold}`
)
if (errorCount > threshold) {
const errorTypeLabel = isTimeout ? 'timeout' : '5xx'
logger.error(
`${prefix}Account ${accountId} exceeded ${errorTypeLabel} error threshold (${errorCount} errors), marking as temp_error`
)
await claudeAccountService.markAccountTempError(accountId, sessionHash)
}
} catch (handlingError) {
logger.error(`❌ Failed to handle ${context} server error:`, handlingError)
}
}
// 🔄 重试逻辑 // 🔄 重试逻辑
async _retryRequest(requestFunc, maxRetries = 3) { async _retryRequest(requestFunc, maxRetries = 3) {
let lastError let lastError
@@ -1705,6 +1490,7 @@ class ClaudeRelayService {
async recordUnauthorizedError(accountId) { async recordUnauthorizedError(accountId) {
try { try {
const key = `claude_account:${accountId}:401_errors` const key = `claude_account:${accountId}:401_errors`
const redis = require('../models/redis')
// 增加错误计数设置5分钟过期时间 // 增加错误计数设置5分钟过期时间
await redis.client.incr(key) await redis.client.incr(key)
@@ -1720,6 +1506,7 @@ class ClaudeRelayService {
async getUnauthorizedErrorCount(accountId) { async getUnauthorizedErrorCount(accountId) {
try { try {
const key = `claude_account:${accountId}:401_errors` const key = `claude_account:${accountId}:401_errors`
const redis = require('../models/redis')
const count = await redis.client.get(key) const count = await redis.client.get(key)
return parseInt(count) || 0 return parseInt(count) || 0
@@ -1733,6 +1520,7 @@ class ClaudeRelayService {
async clearUnauthorizedErrors(accountId) { async clearUnauthorizedErrors(accountId) {
try { try {
const key = `claude_account:${accountId}:401_errors` const key = `claude_account:${accountId}:401_errors`
const redis = require('../models/redis')
await redis.client.del(key) await redis.client.del(key)
logger.info(`✅ Cleared 401 error count for account ${accountId}`) logger.info(`✅ Cleared 401 error count for account ${accountId}`)
@@ -1741,103 +1529,6 @@ class ClaudeRelayService {
} }
} }
// 🔧 动态捕获并获取统一的 User-Agent
async captureAndGetUnifiedUserAgent(clientHeaders, account) {
if (account.useUnifiedUserAgent !== 'true') {
return null
}
const CACHE_KEY = 'claude_code_user_agent:daily'
const TTL = 90000 // 25小时
// ⚠️ 重要:这里通过正则表达式判断是否为 Claude Code 客户端
// 如果未来 Claude Code 的 User-Agent 格式发生变化,需要更新这个正则表达式
// 当前已知格式claude-cli/1.0.102 (external, cli)
const CLAUDE_CODE_UA_PATTERN = /^claude-cli\/[\d.]+\s+\(/i
const clientUA = clientHeaders?.['user-agent'] || clientHeaders?.['User-Agent']
let cachedUA = await redis.client.get(CACHE_KEY)
if (clientUA && CLAUDE_CODE_UA_PATTERN.test(clientUA)) {
if (!cachedUA) {
// 没有缓存,直接存储
await redis.client.setex(CACHE_KEY, TTL, clientUA)
logger.info(`📱 Captured unified Claude Code User-Agent: ${clientUA}`)
cachedUA = clientUA
} else {
// 有缓存,比较版本号,保存更新的版本
const shouldUpdate = this.compareClaudeCodeVersions(clientUA, cachedUA)
if (shouldUpdate) {
await redis.client.setex(CACHE_KEY, TTL, clientUA)
logger.info(`🔄 Updated to newer Claude Code User-Agent: ${clientUA} (was: ${cachedUA})`)
cachedUA = clientUA
} else {
// 当前版本不比缓存版本新仅刷新TTL
await redis.client.expire(CACHE_KEY, TTL)
}
}
}
return cachedUA // 没有缓存返回 null
}
// 🔄 比较Claude Code版本号判断是否需要更新
// 返回 true 表示 newUA 版本更新,需要更新缓存
compareClaudeCodeVersions(newUA, cachedUA) {
try {
// 提取版本号claude-cli/1.0.102 (external, cli) -> 1.0.102
// 支持多段版本号格式,如 1.0.102、2.1.0.beta1 等
const newVersionMatch = newUA.match(/claude-cli\/([\d.]+(?:[a-zA-Z0-9-]*)?)/i)
const cachedVersionMatch = cachedUA.match(/claude-cli\/([\d.]+(?:[a-zA-Z0-9-]*)?)/i)
if (!newVersionMatch || !cachedVersionMatch) {
// 无法解析版本号,优先使用新的
logger.warn(`⚠️ Unable to parse Claude Code versions: new=${newUA}, cached=${cachedUA}`)
return true
}
const newVersion = newVersionMatch[1]
const cachedVersion = cachedVersionMatch[1]
// 比较版本号 (semantic version)
const compareResult = this.compareSemanticVersions(newVersion, cachedVersion)
logger.debug(`🔍 Version comparison: ${newVersion} vs ${cachedVersion} = ${compareResult}`)
return compareResult > 0 // 新版本更大则返回 true
} catch (error) {
logger.warn(`⚠️ Error comparing Claude Code versions, defaulting to update: ${error.message}`)
return true // 出错时优先使用新的
}
}
// 🔢 比较版本号
// 返回1 表示 v1 > v2-1 表示 v1 < v20 表示相等
compareSemanticVersions(version1, version2) {
// 将版本号字符串按"."分割成数字数组
const arr1 = version1.split('.')
const arr2 = version2.split('.')
// 获取两个版本号数组中的最大长度
const maxLength = Math.max(arr1.length, arr2.length)
// 循环遍历,逐段比较版本号
for (let i = 0; i < maxLength; i++) {
// 如果某个版本号的某一段不存在则视为0
const num1 = parseInt(arr1[i] || 0, 10)
const num2 = parseInt(arr2[i] || 0, 10)
if (num1 > num2) {
return 1 // version1 大于 version2
}
if (num1 < num2) {
return -1 // version1 小于 version2
}
}
return 0 // 两个版本号相等
}
// 🎯 健康检查 // 🎯 健康检查
async healthCheck() { async healthCheck() {
try { try {

View File

@@ -138,19 +138,11 @@ function createOAuth2Client(redirectUri = null, proxyConfig = null) {
return new OAuth2Client(clientOptions) return new OAuth2Client(clientOptions)
} }
// 生成授权 URL (支持 PKCE 和代理) // 生成授权 URL (支持 PKCE)
async function generateAuthUrl(state = null, redirectUri = null, proxyConfig = null) { async function generateAuthUrl(state = null, redirectUri = null) {
// 使用新的 redirect URI // 使用新的 redirect URI
const finalRedirectUri = redirectUri || 'https://codeassist.google.com/authcode' const finalRedirectUri = redirectUri || 'https://codeassist.google.com/authcode'
const oAuth2Client = createOAuth2Client(finalRedirectUri, proxyConfig) const oAuth2Client = createOAuth2Client(finalRedirectUri)
if (proxyConfig) {
logger.info(
`🌐 Using proxy for Gemini auth URL generation: ${ProxyHelper.getProxyDescription(proxyConfig)}`
)
} else {
logger.debug('🌐 No proxy configured for Gemini auth URL generation')
}
// 生成 PKCE code verifier // 生成 PKCE code verifier
const codeVerifier = await oAuth2Client.generateCodeVerifierAsync() const codeVerifier = await oAuth2Client.generateCodeVerifierAsync()
@@ -973,10 +965,12 @@ async function getAccountRateLimitInfo(accountId) {
} }
} }
// 获取配置的OAuth客户端 - 参考GeminiCliSimulator的getOauthClient方法(支持代理) // 获取配置的OAuth客户端 - 参考GeminiCliSimulator的getOauthClient方法
async function getOauthClient(accessToken, refreshToken, proxyConfig = null) { async function getOauthClient(accessToken, refreshToken) {
const client = createOAuth2Client(null, proxyConfig) const client = new OAuth2Client({
clientId: OAUTH_CLIENT_ID,
clientSecret: OAUTH_CLIENT_SECRET
})
const creds = { const creds = {
access_token: accessToken, access_token: accessToken,
refresh_token: refreshToken, refresh_token: refreshToken,
@@ -986,14 +980,6 @@ async function getOauthClient(accessToken, refreshToken, proxyConfig = null) {
expiry_date: 1754269905646 expiry_date: 1754269905646
} }
if (proxyConfig) {
logger.info(
`🌐 Using proxy for Gemini OAuth client: ${ProxyHelper.getProxyDescription(proxyConfig)}`
)
} else {
logger.debug('🌐 No proxy configured for Gemini OAuth client')
}
// 设置凭据 // 设置凭据
client.setCredentials(creds) client.setCredentials(creds)
@@ -1010,8 +996,8 @@ async function getOauthClient(accessToken, refreshToken, proxyConfig = null) {
return client return client
} }
// 调用 Google Code Assist API 的 loadCodeAssist 方法(支持代理) // 调用 Google Code Assist API 的 loadCodeAssist 方法
async function loadCodeAssist(client, projectId = null, proxyConfig = null) { async function loadCodeAssist(client, projectId = null) {
const axios = require('axios') const axios = require('axios')
const CODE_ASSIST_ENDPOINT = 'https://cloudcode-pa.googleapis.com' const CODE_ASSIST_ENDPOINT = 'https://cloudcode-pa.googleapis.com'
const CODE_ASSIST_API_VERSION = 'v1internal' const CODE_ASSIST_API_VERSION = 'v1internal'
@@ -1022,24 +1008,16 @@ async function loadCodeAssist(client, projectId = null, proxyConfig = null) {
const clientMetadata = { const clientMetadata = {
ideType: 'IDE_UNSPECIFIED', ideType: 'IDE_UNSPECIFIED',
platform: 'PLATFORM_UNSPECIFIED', platform: 'PLATFORM_UNSPECIFIED',
pluginType: 'GEMINI' pluginType: 'GEMINI',
} duetProject: projectId
// 只有当projectId存在时才添加duetProject
if (projectId) {
clientMetadata.duetProject = projectId
} }
const request = { const request = {
cloudaicompanionProject: projectId,
metadata: clientMetadata metadata: clientMetadata
} }
// 只有当projectId存在时才添加cloudaicompanionProject const response = await axios({
if (projectId) {
request.cloudaicompanionProject = projectId
}
const axiosConfig = {
url: `${CODE_ASSIST_ENDPOINT}/${CODE_ASSIST_API_VERSION}:loadCodeAssist`, url: `${CODE_ASSIST_ENDPOINT}/${CODE_ASSIST_API_VERSION}:loadCodeAssist`,
method: 'POST', method: 'POST',
headers: { headers: {
@@ -1048,20 +1026,7 @@ async function loadCodeAssist(client, projectId = null, proxyConfig = null) {
}, },
data: request, data: request,
timeout: 30000 timeout: 30000
} })
// 添加代理配置
const proxyAgent = ProxyHelper.createProxyAgent(proxyConfig)
if (proxyAgent) {
axiosConfig.httpsAgent = proxyAgent
logger.info(
`🌐 Using proxy for Gemini loadCodeAssist: ${ProxyHelper.getProxyDescription(proxyConfig)}`
)
} else {
logger.debug('🌐 No proxy configured for Gemini loadCodeAssist')
}
const response = await axios(axiosConfig)
logger.info('📋 loadCodeAssist API调用成功') logger.info('📋 loadCodeAssist API调用成功')
return response.data return response.data
@@ -1094,8 +1059,8 @@ function getOnboardTier(loadRes) {
} }
} }
// 调用 Google Code Assist API 的 onboardUser 方法(包含轮询逻辑,支持代理 // 调用 Google Code Assist API 的 onboardUser 方法(包含轮询逻辑)
async function onboardUser(client, tierId, projectId, clientMetadata, proxyConfig = null) { async function onboardUser(client, tierId, projectId, clientMetadata) {
const axios = require('axios') const axios = require('axios')
const CODE_ASSIST_ENDPOINT = 'https://cloudcode-pa.googleapis.com' const CODE_ASSIST_ENDPOINT = 'https://cloudcode-pa.googleapis.com'
const CODE_ASSIST_API_VERSION = 'v1internal' const CODE_ASSIST_API_VERSION = 'v1internal'
@@ -1104,37 +1069,10 @@ async function onboardUser(client, tierId, projectId, clientMetadata, proxyConfi
const onboardReq = { const onboardReq = {
tierId, tierId,
cloudaicompanionProject: projectId,
metadata: clientMetadata metadata: clientMetadata
} }
// 只有当projectId存在时才添加cloudaicompanionProject
if (projectId) {
onboardReq.cloudaicompanionProject = projectId
}
// 创建基础axios配置
const baseAxiosConfig = {
url: `${CODE_ASSIST_ENDPOINT}/${CODE_ASSIST_API_VERSION}:onboardUser`,
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json'
},
data: onboardReq,
timeout: 30000
}
// 添加代理配置
const proxyAgent = ProxyHelper.createProxyAgent(proxyConfig)
if (proxyAgent) {
baseAxiosConfig.httpsAgent = proxyAgent
logger.info(
`🌐 Using proxy for Gemini onboardUser: ${ProxyHelper.getProxyDescription(proxyConfig)}`
)
} else {
logger.debug('🌐 No proxy configured for Gemini onboardUser')
}
logger.info('📋 开始onboardUser API调用', { logger.info('📋 开始onboardUser API调用', {
tierId, tierId,
projectId, projectId,
@@ -1143,7 +1081,16 @@ async function onboardUser(client, tierId, projectId, clientMetadata, proxyConfi
}) })
// 轮询onboardUser直到长运行操作完成 // 轮询onboardUser直到长运行操作完成
let lroRes = await axios(baseAxiosConfig) let lroRes = await axios({
url: `${CODE_ASSIST_ENDPOINT}/${CODE_ASSIST_API_VERSION}:onboardUser`,
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json'
},
data: onboardReq,
timeout: 30000
})
let attempts = 0 let attempts = 0
const maxAttempts = 12 // 最多等待1分钟5秒 * 12次 const maxAttempts = 12 // 最多等待1分钟5秒 * 12次
@@ -1152,7 +1099,17 @@ async function onboardUser(client, tierId, projectId, clientMetadata, proxyConfi
logger.info(`⏳ 等待onboardUser完成... (${attempts + 1}/${maxAttempts})`) logger.info(`⏳ 等待onboardUser完成... (${attempts + 1}/${maxAttempts})`)
await new Promise((resolve) => setTimeout(resolve, 5000)) await new Promise((resolve) => setTimeout(resolve, 5000))
lroRes = await axios(baseAxiosConfig) lroRes = await axios({
url: `${CODE_ASSIST_ENDPOINT}/${CODE_ASSIST_API_VERSION}:onboardUser`,
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json'
},
data: onboardReq,
timeout: 30000
})
attempts++ attempts++
} }
@@ -1164,13 +1121,8 @@ async function onboardUser(client, tierId, projectId, clientMetadata, proxyConfi
return lroRes.data return lroRes.data
} }
// 完整的用户设置流程 - 参考setup.ts的逻辑(支持代理) // 完整的用户设置流程 - 参考setup.ts的逻辑
async function setupUser( async function setupUser(client, initialProjectId = null, clientMetadata = null) {
client,
initialProjectId = null,
clientMetadata = null,
proxyConfig = null
) {
logger.info('🚀 setupUser 开始', { initialProjectId, hasClientMetadata: !!clientMetadata }) logger.info('🚀 setupUser 开始', { initialProjectId, hasClientMetadata: !!clientMetadata })
let projectId = initialProjectId || process.env.GOOGLE_CLOUD_PROJECT || null let projectId = initialProjectId || process.env.GOOGLE_CLOUD_PROJECT || null
@@ -1189,7 +1141,7 @@ async function setupUser(
// 调用loadCodeAssist // 调用loadCodeAssist
logger.info('📞 调用 loadCodeAssist...') logger.info('📞 调用 loadCodeAssist...')
const loadRes = await loadCodeAssist(client, projectId, proxyConfig) const loadRes = await loadCodeAssist(client, projectId)
logger.info('✅ loadCodeAssist 完成', { logger.info('✅ loadCodeAssist 完成', {
hasCloudaicompanionProject: !!loadRes.cloudaicompanionProject hasCloudaicompanionProject: !!loadRes.cloudaicompanionProject
}) })
@@ -1212,7 +1164,7 @@ async function setupUser(
// 调用onboardUser // 调用onboardUser
logger.info('📞 调用 onboardUser...', { tierId: tier.id, projectId }) logger.info('📞 调用 onboardUser...', { tierId: tier.id, projectId })
const lroRes = await onboardUser(client, tier.id, projectId, clientMetadata, proxyConfig) const lroRes = await onboardUser(client, tier.id, projectId, clientMetadata)
logger.info('✅ onboardUser 完成', { hasDone: !!lroRes.done, hasResponse: !!lroRes.response }) logger.info('✅ onboardUser 完成', { hasDone: !!lroRes.done, hasResponse: !!lroRes.response })
const result = { const result = {
@@ -1226,8 +1178,8 @@ async function setupUser(
return result return result
} }
// 调用 Code Assist API 计算 token 数量(支持代理) // 调用 Code Assist API 计算 token 数量
async function countTokens(client, contents, model = 'gemini-2.0-flash-exp', proxyConfig = null) { async function countTokens(client, contents, model = 'gemini-2.0-flash-exp') {
const axios = require('axios') const axios = require('axios')
const CODE_ASSIST_ENDPOINT = 'https://cloudcode-pa.googleapis.com' const CODE_ASSIST_ENDPOINT = 'https://cloudcode-pa.googleapis.com'
const CODE_ASSIST_API_VERSION = 'v1internal' const CODE_ASSIST_API_VERSION = 'v1internal'
@@ -1244,7 +1196,7 @@ async function countTokens(client, contents, model = 'gemini-2.0-flash-exp', pro
logger.info('📊 countTokens API调用开始', { model, contentsLength: contents.length }) logger.info('📊 countTokens API调用开始', { model, contentsLength: contents.length })
const axiosConfig = { const response = await axios({
url: `${CODE_ASSIST_ENDPOINT}/${CODE_ASSIST_API_VERSION}:countTokens`, url: `${CODE_ASSIST_ENDPOINT}/${CODE_ASSIST_API_VERSION}:countTokens`,
method: 'POST', method: 'POST',
headers: { headers: {
@@ -1253,20 +1205,7 @@ async function countTokens(client, contents, model = 'gemini-2.0-flash-exp', pro
}, },
data: request, data: request,
timeout: 30000 timeout: 30000
} })
// 添加代理配置
const proxyAgent = ProxyHelper.createProxyAgent(proxyConfig)
if (proxyAgent) {
axiosConfig.httpsAgent = proxyAgent
logger.info(
`🌐 Using proxy for Gemini countTokens: ${ProxyHelper.getProxyDescription(proxyConfig)}`
)
} else {
logger.debug('🌐 No proxy configured for Gemini countTokens')
}
const response = await axios(axiosConfig)
logger.info('✅ countTokens API调用成功', { totalTokens: response.data.totalTokens }) logger.info('✅ countTokens API调用成功', { totalTokens: response.data.totalTokens })
return response.data return response.data
@@ -1290,6 +1229,7 @@ async function generateContent(
// 按照 gemini-cli 的转换格式构造请求 // 按照 gemini-cli 的转换格式构造请求
const request = { const request = {
model: requestData.model, model: requestData.model,
project: projectId,
user_prompt_id: userPromptId, user_prompt_id: userPromptId,
request: { request: {
...requestData.request, ...requestData.request,
@@ -1297,11 +1237,6 @@ async function generateContent(
} }
} }
// 只有当projectId存在时才添加project字段
if (projectId) {
request.project = projectId
}
logger.info('🤖 generateContent API调用开始', { logger.info('🤖 generateContent API调用开始', {
model: requestData.model, model: requestData.model,
userPromptId, userPromptId,
@@ -1356,6 +1291,7 @@ async function generateContentStream(
// 按照 gemini-cli 的转换格式构造请求 // 按照 gemini-cli 的转换格式构造请求
const request = { const request = {
model: requestData.model, model: requestData.model,
project: projectId,
user_prompt_id: userPromptId, user_prompt_id: userPromptId,
request: { request: {
...requestData.request, ...requestData.request,
@@ -1363,11 +1299,6 @@ async function generateContentStream(
} }
} }
// 只有当projectId存在时才添加project字段
if (projectId) {
request.project = projectId
}
logger.info('🌊 streamGenerateContent API调用开始', { logger.info('🌊 streamGenerateContent API调用开始', {
model: requestData.model, model: requestData.model,
userPromptId, userPromptId,

View File

@@ -273,7 +273,7 @@ async function sendGeminiRequest({
'Content-Type': 'application/json' 'Content-Type': 'application/json'
}, },
data: requestBody, data: requestBody,
timeout: config.requestTimeout || 600000 timeout: config.requestTimeout || 120000
} }
// 添加代理配置 // 添加代理配置
@@ -382,7 +382,7 @@ async function getAvailableModels(accessToken, proxy, projectId, location = 'us-
headers: { headers: {
Authorization: `Bearer ${accessToken}` Authorization: `Bearer ${accessToken}`
}, },
timeout: config.requestTimeout || 600000 timeout: 30000
} }
const proxyAgent = createProxyAgent(proxy) const proxyAgent = createProxyAgent(proxy)
@@ -482,7 +482,7 @@ async function countTokens({
'X-Goog-User-Project': projectId || undefined 'X-Goog-User-Project': projectId || undefined
}, },
data: requestBody, data: requestBody,
timeout: config.requestTimeout || 600000 timeout: 30000
} }
// 添加代理配置 // 添加代理配置

View File

@@ -1,753 +0,0 @@
const ldap = require('ldapjs')
const logger = require('../utils/logger')
const config = require('../../config/config')
const userService = require('./userService')
class LdapService {
constructor() {
this.config = config.ldap || {}
this.client = null
// 验证配置 - 只有在 LDAP 配置存在且启用时才验证
if (this.config && this.config.enabled) {
this.validateConfiguration()
}
}
// 🔍 验证LDAP配置
validateConfiguration() {
const errors = []
if (!this.config.server) {
errors.push('LDAP server configuration is missing')
} else {
if (!this.config.server.url || typeof this.config.server.url !== 'string') {
errors.push('LDAP server URL is not configured or invalid')
}
if (!this.config.server.bindDN || typeof this.config.server.bindDN !== 'string') {
errors.push('LDAP bind DN is not configured or invalid')
}
if (
!this.config.server.bindCredentials ||
typeof this.config.server.bindCredentials !== 'string'
) {
errors.push('LDAP bind credentials are not configured or invalid')
}
if (!this.config.server.searchBase || typeof this.config.server.searchBase !== 'string') {
errors.push('LDAP search base is not configured or invalid')
}
if (!this.config.server.searchFilter || typeof this.config.server.searchFilter !== 'string') {
errors.push('LDAP search filter is not configured or invalid')
}
}
if (errors.length > 0) {
logger.error('❌ LDAP configuration validation failed:', errors)
// Don't throw error during initialization, just log warnings
logger.warn('⚠️ LDAP authentication may not work properly due to configuration errors')
} else {
logger.info('✅ LDAP configuration validation passed')
}
}
// 🔍 提取LDAP条目的DN
extractDN(ldapEntry) {
if (!ldapEntry) {
return null
}
// Try different ways to get the DN
let dn = null
// Method 1: Direct dn property
if (ldapEntry.dn) {
;({ dn } = ldapEntry)
}
// Method 2: objectName property (common in some LDAP implementations)
else if (ldapEntry.objectName) {
dn = ldapEntry.objectName
}
// Method 3: distinguishedName property
else if (ldapEntry.distinguishedName) {
dn = ldapEntry.distinguishedName
}
// Method 4: Check if the entry itself is a DN string
else if (typeof ldapEntry === 'string' && ldapEntry.includes('=')) {
dn = ldapEntry
}
// Convert DN to string if it's an object
if (dn && typeof dn === 'object') {
if (dn.toString && typeof dn.toString === 'function') {
dn = dn.toString()
} else if (dn.dn && typeof dn.dn === 'string') {
;({ dn } = dn)
}
}
// Validate the DN format
if (typeof dn === 'string' && dn.trim() !== '' && dn.includes('=')) {
return dn.trim()
}
return null
}
// 🌐 从DN中提取域名用于Windows AD UPN格式认证
extractDomainFromDN(dnString) {
try {
if (!dnString || typeof dnString !== 'string') {
return null
}
// 提取所有DC组件DC=test,DC=demo,DC=com
const dcMatches = dnString.match(/DC=([^,]+)/gi)
if (!dcMatches || dcMatches.length === 0) {
return null
}
// 提取DC值并连接成域名
const domainParts = dcMatches.map((match) => {
const value = match.replace(/DC=/i, '').trim()
return value
})
if (domainParts.length > 0) {
const domain = domainParts.join('.')
logger.debug(`🌐 从DN提取域名: ${domain}`)
return domain
}
return null
} catch (error) {
logger.debug('⚠️ 域名提取失败:', error.message)
return null
}
}
// 🔗 创建LDAP客户端连接
createClient() {
try {
const clientOptions = {
url: this.config.server.url,
timeout: this.config.server.timeout,
connectTimeout: this.config.server.connectTimeout,
reconnect: true
}
// 如果使用 LDAPS (SSL/TLS),添加 TLS 选项
if (this.config.server.url.toLowerCase().startsWith('ldaps://')) {
const tlsOptions = {}
// 证书验证设置
if (this.config.server.tls) {
if (typeof this.config.server.tls.rejectUnauthorized === 'boolean') {
tlsOptions.rejectUnauthorized = this.config.server.tls.rejectUnauthorized
}
// CA 证书
if (this.config.server.tls.ca) {
tlsOptions.ca = this.config.server.tls.ca
}
// 客户端证书和私钥 (双向认证)
if (this.config.server.tls.cert) {
tlsOptions.cert = this.config.server.tls.cert
}
if (this.config.server.tls.key) {
tlsOptions.key = this.config.server.tls.key
}
// 服务器名称 (SNI)
if (this.config.server.tls.servername) {
tlsOptions.servername = this.config.server.tls.servername
}
}
clientOptions.tlsOptions = tlsOptions
logger.debug('🔒 Creating LDAPS client with TLS options:', {
url: this.config.server.url,
rejectUnauthorized: tlsOptions.rejectUnauthorized,
hasCA: !!tlsOptions.ca,
hasCert: !!tlsOptions.cert,
hasKey: !!tlsOptions.key,
servername: tlsOptions.servername
})
}
const client = ldap.createClient(clientOptions)
// 设置错误处理
client.on('error', (err) => {
if (err.code === 'CERT_HAS_EXPIRED' || err.code === 'UNABLE_TO_VERIFY_LEAF_SIGNATURE') {
logger.error('🔒 LDAP TLS certificate error:', {
code: err.code,
message: err.message,
hint: 'Consider setting LDAP_TLS_REJECT_UNAUTHORIZED=false for self-signed certificates'
})
} else {
logger.error('🔌 LDAP client error:', err)
}
})
client.on('connect', () => {
if (this.config.server.url.toLowerCase().startsWith('ldaps://')) {
logger.info('🔒 LDAPS client connected successfully')
} else {
logger.info('🔗 LDAP client connected successfully')
}
})
client.on('connectTimeout', () => {
logger.warn('⏱️ LDAP connection timeout')
})
return client
} catch (error) {
logger.error('❌ Failed to create LDAP client:', error)
throw error
}
}
// 🔒 绑定LDAP连接管理员认证
async bindClient(client) {
return new Promise((resolve, reject) => {
// 验证绑定凭据
const { bindDN } = this.config.server
const { bindCredentials } = this.config.server
if (!bindDN || typeof bindDN !== 'string') {
const error = new Error('LDAP bind DN is not configured or invalid')
logger.error('❌ LDAP configuration error:', error.message)
reject(error)
return
}
if (!bindCredentials || typeof bindCredentials !== 'string') {
const error = new Error('LDAP bind credentials are not configured or invalid')
logger.error('❌ LDAP configuration error:', error.message)
reject(error)
return
}
client.bind(bindDN, bindCredentials, (err) => {
if (err) {
logger.error('❌ LDAP bind failed:', err)
reject(err)
} else {
logger.debug('🔑 LDAP bind successful')
resolve()
}
})
})
}
// 🔍 搜索用户
async searchUser(client, username) {
return new Promise((resolve, reject) => {
// 防止LDAP注入转义特殊字符
// 根据RFC 4515需要转义的特殊字符* ( ) \ NUL
const escapedUsername = username
.replace(/\\/g, '\\5c') // 反斜杠必须先转义
.replace(/\*/g, '\\2a') // 星号
.replace(/\(/g, '\\28') // 左括号
.replace(/\)/g, '\\29') // 右括号
.replace(/\0/g, '\\00') // NUL字符
.replace(/\//g, '\\2f') // 斜杠
const searchFilter = this.config.server.searchFilter.replace('{{username}}', escapedUsername)
const searchOptions = {
scope: 'sub',
filter: searchFilter,
attributes: this.config.server.searchAttributes
}
logger.debug(`🔍 Searching for user: ${username} with filter: ${searchFilter}`)
const entries = []
client.search(this.config.server.searchBase, searchOptions, (err, res) => {
if (err) {
logger.error('❌ LDAP search error:', err)
reject(err)
return
}
res.on('searchEntry', (entry) => {
logger.debug('🔍 LDAP search entry received:', {
dn: entry.dn,
objectName: entry.objectName,
type: typeof entry.dn,
entryType: typeof entry,
hasAttributes: !!entry.attributes,
attributeCount: entry.attributes ? entry.attributes.length : 0
})
entries.push(entry)
})
res.on('searchReference', (referral) => {
logger.debug('🔗 LDAP search referral:', referral.uris)
})
res.on('error', (error) => {
logger.error('❌ LDAP search result error:', error)
reject(error)
})
res.on('end', (result) => {
logger.debug(
`✅ LDAP search completed. Status: ${result.status}, Found ${entries.length} entries`
)
if (entries.length === 0) {
resolve(null)
} else {
// Log the structure of the first entry for debugging
if (entries[0]) {
logger.debug('🔍 Full LDAP entry structure:', {
entryType: typeof entries[0],
entryConstructor: entries[0].constructor?.name,
entryKeys: Object.keys(entries[0]),
entryStringified: JSON.stringify(entries[0], null, 2).substring(0, 500)
})
}
if (entries.length === 1) {
resolve(entries[0])
} else {
logger.warn(`⚠️ Multiple LDAP entries found for username: ${username}`)
resolve(entries[0]) // 使用第一个结果
}
}
})
})
})
}
// 🔐 验证用户密码
async authenticateUser(userDN, password) {
return new Promise((resolve, reject) => {
// 验证输入参数
if (!userDN || typeof userDN !== 'string') {
const error = new Error('User DN is not provided or invalid')
logger.error('❌ LDAP authentication error:', error.message)
reject(error)
return
}
if (!password || typeof password !== 'string') {
logger.debug(`🚫 Invalid or empty password for DN: ${userDN}`)
resolve(false)
return
}
const authClient = this.createClient()
authClient.bind(userDN, password, (err) => {
authClient.unbind() // 立即关闭认证客户端
if (err) {
if (err.name === 'InvalidCredentialsError') {
logger.debug(`🚫 Invalid credentials for DN: ${userDN}`)
resolve(false)
} else {
logger.error('❌ LDAP authentication error:', err)
reject(err)
}
} else {
logger.debug(`✅ Authentication successful for DN: ${userDN}`)
resolve(true)
}
})
})
}
// 🔐 Windows AD兼容认证 - 在DN认证失败时尝试多种格式
async tryWindowsADAuthentication(username, password) {
if (!username || !password) {
return false
}
// 从searchBase提取域名
const domain = this.extractDomainFromDN(this.config.server.searchBase)
const adFormats = []
if (domain) {
// UPN格式Windows AD标准
adFormats.push(`${username}@${domain}`)
// 如果域名有多个部分,也尝试简化版本
const domainParts = domain.split('.')
if (domainParts.length > 1) {
adFormats.push(`${username}@${domainParts.slice(-2).join('.')}`) // 只取后两部分
}
// 域\用户名格式
const firstDomainPart = domainParts[0]
if (firstDomainPart) {
adFormats.push(`${firstDomainPart}\\${username}`)
adFormats.push(`${firstDomainPart.toUpperCase()}\\${username}`)
}
}
// 纯用户名(最后尝试)
adFormats.push(username)
logger.info(`🔄 尝试 ${adFormats.length} 种Windows AD认证格式...`)
for (const format of adFormats) {
try {
logger.info(`🔍 尝试格式: ${format}`)
const result = await this.tryDirectBind(format, password)
if (result) {
logger.info(`✅ Windows AD认证成功: ${format}`)
return true
}
logger.debug(`❌ 认证失败: ${format}`)
} catch (error) {
logger.debug(`认证异常 ${format}:`, error.message)
}
}
logger.info(`🚫 所有Windows AD格式认证都失败了`)
return false
}
// 🔐 直接尝试绑定认证的辅助方法
async tryDirectBind(identifier, password) {
return new Promise((resolve, reject) => {
const authClient = this.createClient()
authClient.bind(identifier, password, (err) => {
authClient.unbind()
if (err) {
if (err.name === 'InvalidCredentialsError') {
resolve(false)
} else {
reject(err)
}
} else {
resolve(true)
}
})
})
}
// 📝 提取用户信息
extractUserInfo(ldapEntry, username) {
try {
const attributes = ldapEntry.attributes || []
const userInfo = { username }
// 创建属性映射
const attrMap = {}
attributes.forEach((attr) => {
const name = attr.type || attr.name
const values = Array.isArray(attr.values) ? attr.values : [attr.values]
attrMap[name] = values.length === 1 ? values[0] : values
})
// 根据配置映射用户属性
const mapping = this.config.userMapping
userInfo.displayName = attrMap[mapping.displayName] || username
userInfo.email = attrMap[mapping.email] || ''
userInfo.firstName = attrMap[mapping.firstName] || ''
userInfo.lastName = attrMap[mapping.lastName] || ''
// 如果没有displayName尝试组合firstName和lastName
if (!userInfo.displayName || userInfo.displayName === username) {
if (userInfo.firstName || userInfo.lastName) {
userInfo.displayName = `${userInfo.firstName || ''} ${userInfo.lastName || ''}`.trim()
}
}
logger.debug('📋 Extracted user info:', {
username: userInfo.username,
displayName: userInfo.displayName,
email: userInfo.email
})
return userInfo
} catch (error) {
logger.error('❌ Error extracting user info:', error)
return { username }
}
}
// 🔍 验证和清理用户名
validateAndSanitizeUsername(username) {
if (!username || typeof username !== 'string' || username.trim() === '') {
throw new Error('Username is required and must be a non-empty string')
}
const trimmedUsername = username.trim()
// 用户名只能包含字母、数字、下划线和连字符
const usernameRegex = /^[a-zA-Z0-9_-]+$/
if (!usernameRegex.test(trimmedUsername)) {
throw new Error('Username can only contain letters, numbers, underscores, and hyphens')
}
// 长度限制 (防止过长的输入)
if (trimmedUsername.length > 64) {
throw new Error('Username cannot exceed 64 characters')
}
// 不能以连字符开头或结尾
if (trimmedUsername.startsWith('-') || trimmedUsername.endsWith('-')) {
throw new Error('Username cannot start or end with a hyphen')
}
return trimmedUsername
}
// 🔐 主要的登录验证方法
async authenticateUserCredentials(username, password) {
if (!this.config.enabled) {
throw new Error('LDAP authentication is not enabled')
}
// 验证和清理用户名 (防止LDAP注入)
const sanitizedUsername = this.validateAndSanitizeUsername(username)
if (!password || typeof password !== 'string' || password.trim() === '') {
throw new Error('Password is required and must be a non-empty string')
}
// 验证LDAP服务器配置
if (!this.config.server || !this.config.server.url) {
throw new Error('LDAP server URL is not configured')
}
if (!this.config.server.bindDN || typeof this.config.server.bindDN !== 'string') {
throw new Error('LDAP bind DN is not configured')
}
if (
!this.config.server.bindCredentials ||
typeof this.config.server.bindCredentials !== 'string'
) {
throw new Error('LDAP bind credentials are not configured')
}
if (!this.config.server.searchBase || typeof this.config.server.searchBase !== 'string') {
throw new Error('LDAP search base is not configured')
}
const client = this.createClient()
try {
// 1. 使用管理员凭据绑定
await this.bindClient(client)
// 2. 搜索用户 (使用已验证的用户名)
const ldapEntry = await this.searchUser(client, sanitizedUsername)
if (!ldapEntry) {
logger.info(`🚫 User not found in LDAP: ${sanitizedUsername}`)
return { success: false, message: 'Invalid username or password' }
}
// 3. 获取用户DN
logger.debug('🔍 LDAP entry details for DN extraction:', {
hasEntry: !!ldapEntry,
entryType: typeof ldapEntry,
entryKeys: Object.keys(ldapEntry || {}),
dn: ldapEntry.dn,
objectName: ldapEntry.objectName,
dnType: typeof ldapEntry.dn,
objectNameType: typeof ldapEntry.objectName
})
// Use the helper method to extract DN
const userDN = this.extractDN(ldapEntry)
logger.debug(`👤 Extracted user DN: ${userDN} (type: ${typeof userDN})`)
// 验证用户DN
if (!userDN) {
logger.error(`❌ Invalid or missing DN for user: ${sanitizedUsername}`, {
ldapEntryDn: ldapEntry.dn,
ldapEntryObjectName: ldapEntry.objectName,
ldapEntryType: typeof ldapEntry,
extractedDN: userDN
})
return { success: false, message: 'Authentication service error' }
}
// 4. 验证用户密码 - 支持传统LDAP和Windows AD
let isPasswordValid = false
// 首先尝试传统的DN认证保持原有LDAP逻辑
try {
isPasswordValid = await this.authenticateUser(userDN, password)
if (isPasswordValid) {
logger.info(`✅ DN authentication successful for user: ${sanitizedUsername}`)
}
} catch (error) {
logger.debug(
`DN authentication failed for user: ${sanitizedUsername}, error: ${error.message}`
)
}
// 如果DN认证失败尝试Windows AD多格式认证
if (!isPasswordValid) {
logger.debug(`🔄 Trying Windows AD authentication formats for user: ${sanitizedUsername}`)
isPasswordValid = await this.tryWindowsADAuthentication(sanitizedUsername, password)
if (isPasswordValid) {
logger.info(`✅ Windows AD authentication successful for user: ${sanitizedUsername}`)
}
}
if (!isPasswordValid) {
logger.info(`🚫 All authentication methods failed for user: ${sanitizedUsername}`)
return { success: false, message: 'Invalid username or password' }
}
// 5. 提取用户信息
const userInfo = this.extractUserInfo(ldapEntry, sanitizedUsername)
// 6. 创建或更新本地用户
const user = await userService.createOrUpdateUser(userInfo)
// 7. 检查用户是否被禁用
if (!user.isActive) {
logger.security(
`🔒 Disabled user LDAP login attempt: ${sanitizedUsername} from LDAP authentication`
)
return {
success: false,
message: 'Your account has been disabled. Please contact administrator.'
}
}
// 8. 记录登录
await userService.recordUserLogin(user.id)
// 9. 创建用户会话
const sessionToken = await userService.createUserSession(user.id)
logger.info(`✅ LDAP authentication successful for user: ${sanitizedUsername}`)
return {
success: true,
user,
sessionToken,
message: 'Authentication successful'
}
} catch (error) {
// 记录详细错误供调试,但不向用户暴露
logger.error('❌ LDAP authentication error:', {
username: sanitizedUsername,
error: error.message,
stack: process.env.NODE_ENV === 'development' ? error.stack : undefined
})
// 返回通用错误消息,避免信息泄露
// 不要尝试解析具体的错误信息因为不同LDAP服务器返回的格式不同
return {
success: false,
message: 'Authentication service unavailable'
}
} finally {
// 确保客户端连接被关闭
if (client) {
client.unbind((err) => {
if (err) {
logger.debug('Error unbinding LDAP client:', err)
}
})
}
}
}
// 🔍 测试LDAP连接
async testConnection() {
if (!this.config.enabled) {
return { success: false, message: 'LDAP is not enabled' }
}
const client = this.createClient()
try {
await this.bindClient(client)
return {
success: true,
message: 'LDAP connection successful',
server: this.config.server.url,
searchBase: this.config.server.searchBase
}
} catch (error) {
logger.error('❌ LDAP connection test failed:', {
error: error.message,
server: this.config.server.url,
stack: process.env.NODE_ENV === 'development' ? error.stack : undefined
})
// 提供通用错误消息,避免泄露系统细节
let userMessage = 'LDAP connection failed'
// 对于某些已知错误类型,提供有用但不泄露细节的信息
if (error.code === 'ECONNREFUSED') {
userMessage = 'Unable to connect to LDAP server'
} else if (error.code === 'ETIMEDOUT') {
userMessage = 'LDAP server connection timeout'
} else if (error.name === 'InvalidCredentialsError') {
userMessage = 'LDAP bind credentials are invalid'
}
return {
success: false,
message: userMessage,
server: this.config.server.url.replace(/:[^:]*@/, ':***@') // 隐藏密码部分
}
} finally {
if (client) {
client.unbind((err) => {
if (err) {
logger.debug('Error unbinding test LDAP client:', err)
}
})
}
}
}
// 📊 获取LDAP配置信息不包含敏感信息
getConfigInfo() {
const configInfo = {
enabled: this.config.enabled,
server: {
url: this.config.server.url,
searchBase: this.config.server.searchBase,
searchFilter: this.config.server.searchFilter,
timeout: this.config.server.timeout,
connectTimeout: this.config.server.connectTimeout
},
userMapping: this.config.userMapping
}
// 添加 TLS 配置信息(不包含敏感数据)
if (this.config.server.url.toLowerCase().startsWith('ldaps://') && this.config.server.tls) {
configInfo.server.tls = {
rejectUnauthorized: this.config.server.tls.rejectUnauthorized,
hasCA: !!this.config.server.tls.ca,
hasCert: !!this.config.server.tls.cert,
hasKey: !!this.config.server.tls.key,
servername: this.config.server.tls.servername
}
}
return configInfo
}
}
module.exports = new LdapService()

View File

@@ -14,7 +14,7 @@ const {
logRefreshSkipped logRefreshSkipped
} = require('../utils/tokenRefreshLogger') } = require('../utils/tokenRefreshLogger')
const LRUCache = require('../utils/lruCache') const LRUCache = require('../utils/lruCache')
const tokenRefreshService = require('./tokenRefreshService') // const tokenRefreshService = require('./tokenRefreshService')
// 加密相关常量 // 加密相关常量
const ALGORITHM = 'aes-256-cbc' const ALGORITHM = 'aes-256-cbc'
@@ -57,17 +57,7 @@ function encrypt(text) {
// 解密函数 // 解密函数
function decrypt(text) { function decrypt(text) {
if (!text || text === '') { if (!text) {
return ''
}
// 检查是否是有效的加密格式(至少需要 32 个字符的 IV + 冒号 + 加密文本)
if (text.length < 33 || text.charAt(32) !== ':') {
logger.warn('Invalid encrypted text format, returning empty string', {
textLength: text ? text.length : 0,
char32: text && text.length > 32 ? text.charAt(32) : 'N/A',
first50: text ? text.substring(0, 50) : 'N/A'
})
return '' return ''
} }
@@ -138,14 +128,13 @@ async function refreshAccessToken(refreshToken, proxy = null) {
'Content-Length': requestData.length 'Content-Length': requestData.length
}, },
data: requestData, data: requestData,
timeout: config.requestTimeout || 600000 // 使用统一的请求超时配置 timeout: 30000 // 30秒超时
} }
// 配置代理(如果有) // 配置代理(如果有)
const proxyAgent = ProxyHelper.createProxyAgent(proxy) const proxyAgent = ProxyHelper.createProxyAgent(proxy)
if (proxyAgent) { if (proxyAgent) {
requestOptions.httpsAgent = proxyAgent requestOptions.httpsAgent = proxyAgent
requestOptions.proxy = false // 重要:禁用 axios 的默认代理,强制使用我们的 httpsAgent
logger.info( logger.info(
`🌐 Using proxy for OpenAI token refresh: ${ProxyHelper.getProxyDescription(proxy)}` `🌐 Using proxy for OpenAI token refresh: ${ProxyHelper.getProxyDescription(proxy)}`
) )
@@ -154,7 +143,6 @@ async function refreshAccessToken(refreshToken, proxy = null) {
} }
// 发送请求 // 发送请求
logger.info('🔍 发送 token 刷新请求,使用代理:', !!requestOptions.httpsAgent)
const response = await axios(requestOptions) const response = await axios(requestOptions)
if (response.status === 200 && response.data) { if (response.status === 200 && response.data) {
@@ -176,73 +164,22 @@ async function refreshAccessToken(refreshToken, proxy = null) {
} catch (error) { } catch (error) {
if (error.response) { if (error.response) {
// 服务器响应了错误状态码 // 服务器响应了错误状态码
const errorData = error.response.data || {}
logger.error('OpenAI token refresh failed:', { logger.error('OpenAI token refresh failed:', {
status: error.response.status, status: error.response.status,
data: errorData, data: error.response.data,
headers: error.response.headers headers: error.response.headers
}) })
throw new Error(
// 构建详细的错误信息 `Token refresh failed: ${error.response.status} - ${JSON.stringify(error.response.data)}`
let errorMessage = `OpenAI 服务器返回错误 (${error.response.status})` )
if (error.response.status === 400) {
if (errorData.error === 'invalid_grant') {
errorMessage = 'Refresh Token 无效或已过期,请重新授权'
} else if (errorData.error === 'invalid_request') {
errorMessage = `请求参数错误:${errorData.error_description || errorData.error}`
} else {
errorMessage = `请求错误:${errorData.error_description || errorData.error || '未知错误'}`
}
} else if (error.response.status === 401) {
errorMessage = '认证失败Refresh Token 无效'
} else if (error.response.status === 403) {
errorMessage = '访问被拒绝:可能是 IP 被封或账户被禁用'
} else if (error.response.status === 429) {
errorMessage = '请求过于频繁,请稍后重试'
} else if (error.response.status >= 500) {
errorMessage = 'OpenAI 服务器内部错误,请稍后重试'
} else if (errorData.error_description) {
errorMessage = errorData.error_description
} else if (errorData.error) {
errorMessage = errorData.error
} else if (errorData.message) {
errorMessage = errorData.message
}
const fullError = new Error(errorMessage)
fullError.status = error.response.status
fullError.details = errorData
throw fullError
} else if (error.request) { } else if (error.request) {
// 请求已发出但没有收到响应 // 请求已发出但没有收到响应
logger.error('OpenAI token refresh no response:', error.message) logger.error('OpenAI token refresh no response:', error.message)
throw new Error(`Token refresh failed: No response from server - ${error.message}`)
let errorMessage = '无法连接到 OpenAI 服务器'
if (proxy) {
errorMessage += `(代理: ${ProxyHelper.getProxyDescription(proxy)}`
}
if (error.code === 'ECONNREFUSED') {
errorMessage += ' - 连接被拒绝'
} else if (error.code === 'ETIMEDOUT') {
errorMessage += ' - 连接超时'
} else if (error.code === 'ENOTFOUND') {
errorMessage += ' - 无法解析域名'
} else if (error.code === 'EPROTO') {
errorMessage += ' - 协议错误(可能是代理配置问题)'
} else if (error.message) {
errorMessage += ` - ${error.message}`
}
const fullError = new Error(errorMessage)
fullError.code = error.code
throw fullError
} else { } else {
// 设置请求时发生错误 // 设置请求时发生错误
logger.error('OpenAI token refresh error:', error.message) logger.error('OpenAI token refresh error:', error.message)
const fullError = new Error(`请求设置错误: ${error.message}`) throw new Error(`Token refresh failed: ${error.message}`)
fullError.originalError = error
throw fullError
} }
} }
} }
@@ -255,71 +192,34 @@ function isTokenExpired(account) {
return new Date(account.expiresAt) <= new Date() return new Date(account.expiresAt) <= new Date()
} }
// 刷新账户的 access token(带分布式锁) // 刷新账户的 access token
async function refreshAccountToken(accountId) { async function refreshAccountToken(accountId) {
let lockAcquired = false const account = await getAccount(accountId)
let account = null if (!account) {
let accountName = accountId throw new Error('Account not found')
}
const accountName = account.name || accountId
logRefreshStart(accountId, accountName, 'openai')
// 检查是否有 refresh token
const refreshToken = account.refreshToken ? decrypt(account.refreshToken) : null
if (!refreshToken) {
logRefreshSkipped(accountId, accountName, 'openai', 'No refresh token available')
throw new Error('No refresh token available')
}
// 获取代理配置
let proxy = null
if (account.proxy) {
try {
proxy = typeof account.proxy === 'string' ? JSON.parse(account.proxy) : account.proxy
} catch (e) {
logger.warn(`Failed to parse proxy config for account ${accountId}:`, e)
}
}
try { try {
account = await getAccount(accountId)
if (!account) {
throw new Error('Account not found')
}
accountName = account.name || accountId
// 检查是否有 refresh token
// account.refreshToken 在 getAccount 中已经被解密了,直接使用即可
const refreshToken = account.refreshToken || null
if (!refreshToken) {
logRefreshSkipped(accountId, accountName, 'openai', 'No refresh token available')
throw new Error('No refresh token available')
}
// 尝试获取分布式锁
lockAcquired = await tokenRefreshService.acquireRefreshLock(accountId, 'openai')
if (!lockAcquired) {
// 如果无法获取锁,说明另一个进程正在刷新
logger.info(
`🔒 Token refresh already in progress for OpenAI account: ${accountName} (${accountId})`
)
logRefreshSkipped(accountId, accountName, 'openai', 'already_locked')
// 等待一段时间后返回,期望其他进程已完成刷新
await new Promise((resolve) => setTimeout(resolve, 2000))
// 重新获取账户数据(可能已被其他进程刷新)
const updatedAccount = await getAccount(accountId)
if (updatedAccount && !isTokenExpired(updatedAccount)) {
return {
access_token: decrypt(updatedAccount.accessToken),
id_token: updatedAccount.idToken,
refresh_token: updatedAccount.refreshToken,
expires_in: 3600,
expiry_date: new Date(updatedAccount.expiresAt).getTime()
}
}
throw new Error('Token refresh in progress by another process')
}
// 获取锁成功,开始刷新
logRefreshStart(accountId, accountName, 'openai')
logger.info(`🔄 Starting token refresh for OpenAI account: ${accountName} (${accountId})`)
// 获取代理配置
let proxy = null
if (account.proxy) {
try {
proxy = typeof account.proxy === 'string' ? JSON.parse(account.proxy) : account.proxy
} catch (e) {
logger.warn(`Failed to parse proxy config for account ${accountId}:`, e)
}
}
const newTokens = await refreshAccessToken(refreshToken, proxy) const newTokens = await refreshAccessToken(refreshToken, proxy)
if (!newTokens) { if (!newTokens) {
throw new Error('Failed to refresh token') throw new Error('Failed to refresh token')
@@ -331,51 +231,9 @@ async function refreshAccountToken(accountId) {
expiresAt: new Date(newTokens.expiry_date).toISOString() expiresAt: new Date(newTokens.expiry_date).toISOString()
} }
// 如果有新的 ID token也更新它(这对于首次未提供 ID Token 的账户特别重要) // 如果有新的 ID token也更新它
if (newTokens.id_token) { if (newTokens.id_token) {
updates.idToken = encrypt(newTokens.id_token) updates.idToken = encrypt(newTokens.id_token)
// 如果之前没有 ID Token尝试解析并更新用户信息
if (!account.idToken || account.idToken === '') {
try {
const idTokenParts = newTokens.id_token.split('.')
if (idTokenParts.length === 3) {
const payload = JSON.parse(Buffer.from(idTokenParts[1], 'base64').toString())
const authClaims = payload['https://api.openai.com/auth'] || {}
// 更新账户信息 - 使用正确的字段名
// OpenAI ID Token中用户ID在chatgpt_account_id、chatgpt_user_id和user_id字段
if (authClaims.chatgpt_account_id) {
updates.accountId = authClaims.chatgpt_account_id
}
if (authClaims.chatgpt_user_id) {
updates.chatgptUserId = authClaims.chatgpt_user_id
} else if (authClaims.user_id) {
// 有些情况下可能只有user_id字段
updates.chatgptUserId = authClaims.user_id
}
if (authClaims.organizations?.[0]?.id) {
updates.organizationId = authClaims.organizations[0].id
}
if (authClaims.organizations?.[0]?.role) {
updates.organizationRole = authClaims.organizations[0].role
}
if (authClaims.organizations?.[0]?.title) {
updates.organizationTitle = authClaims.organizations[0].title
}
if (payload.email) {
updates.email = encrypt(payload.email)
}
if (payload.email_verified !== undefined) {
updates.emailVerified = payload.email_verified
}
logger.info(`Updated user info from ID Token for account ${accountId}`)
}
} catch (e) {
logger.warn(`Failed to parse ID Token for account ${accountId}:`, e)
}
}
} }
// 如果返回了新的 refresh token更新它 // 如果返回了新的 refresh token更新它
@@ -390,34 +248,8 @@ async function refreshAccountToken(accountId) {
logRefreshSuccess(accountId, accountName, 'openai', newTokens.expiry_date) logRefreshSuccess(accountId, accountName, 'openai', newTokens.expiry_date)
return newTokens return newTokens
} catch (error) { } catch (error) {
logRefreshError(accountId, account?.name || accountName, 'openai', error.message) logRefreshError(accountId, accountName, 'openai', error.message)
// 发送 Webhook 通知(如果启用)
try {
const webhookNotifier = require('../utils/webhookNotifier')
await webhookNotifier.sendAccountAnomalyNotification({
accountId,
accountName: account?.name || accountName,
platform: 'openai',
status: 'error',
errorCode: 'OPENAI_TOKEN_REFRESH_FAILED',
reason: `Token refresh failed: ${error.message}`,
timestamp: new Date().toISOString()
})
logger.info(
`📢 Webhook notification sent for OpenAI account ${account?.name || accountName} refresh failure`
)
} catch (webhookError) {
logger.error('Failed to send webhook notification:', webhookError)
}
throw error throw error
} finally {
// 确保释放锁
if (lockAcquired) {
await tokenRefreshService.releaseRefreshLock(accountId, 'openai')
logger.debug(`🔓 Released refresh lock for OpenAI account ${accountId}`)
}
} }
} }
@@ -438,10 +270,6 @@ async function createAccount(accountData) {
// 处理账户信息 // 处理账户信息
const accountInfo = accountData.accountInfo || {} const accountInfo = accountData.accountInfo || {}
// 检查邮箱是否已经是加密格式包含冒号分隔的32位十六进制字符
const isEmailEncrypted =
accountInfo.email && accountInfo.email.length >= 33 && accountInfo.email.charAt(32) === ':'
const account = { const account = {
id: accountId, id: accountId,
name: accountData.name, name: accountData.name,
@@ -454,25 +282,19 @@ async function createAccount(accountData) {
? accountData.rateLimitDuration ? accountData.rateLimitDuration
: 60, : 60,
// OAuth相关字段加密存储 // OAuth相关字段加密存储
// ID Token 现在是可选的,如果没有提供会在首次刷新时自动获取 idToken: encrypt(oauthData.idToken || ''),
idToken: oauthData.idToken && oauthData.idToken.trim() ? encrypt(oauthData.idToken) : '', accessToken: encrypt(oauthData.accessToken || ''),
accessToken: refreshToken: encrypt(oauthData.refreshToken || ''),
oauthData.accessToken && oauthData.accessToken.trim() ? encrypt(oauthData.accessToken) : '',
refreshToken:
oauthData.refreshToken && oauthData.refreshToken.trim()
? encrypt(oauthData.refreshToken)
: '',
openaiOauth: encrypt(JSON.stringify(oauthData)), openaiOauth: encrypt(JSON.stringify(oauthData)),
// 账户信息字段 - 确保所有字段都被保存,即使是空字符串 // 账户信息字段
accountId: accountInfo.accountId || '', accountId: accountInfo.accountId || '',
chatgptUserId: accountInfo.chatgptUserId || '', chatgptUserId: accountInfo.chatgptUserId || '',
organizationId: accountInfo.organizationId || '', organizationId: accountInfo.organizationId || '',
organizationRole: accountInfo.organizationRole || '', organizationRole: accountInfo.organizationRole || '',
organizationTitle: accountInfo.organizationTitle || '', organizationTitle: accountInfo.organizationTitle || '',
planType: accountInfo.planType || '', planType: accountInfo.planType || '',
// 邮箱字段:检查是否已经加密,避免双重加密 email: encrypt(accountInfo.email || ''),
email: isEmailEncrypted ? accountInfo.email : encrypt(accountInfo.email || ''), emailVerified: accountInfo.emailVerified || false,
emailVerified: accountInfo.emailVerified === true ? 'true' : 'false',
// 过期时间 // 过期时间
expiresAt: oauthData.expires_in expiresAt: oauthData.expires_in
? new Date(Date.now() + oauthData.expires_in * 1000).toISOString() ? new Date(Date.now() + oauthData.expires_in * 1000).toISOString()
@@ -517,10 +339,9 @@ async function getAccount(accountId) {
if (accountData.idToken) { if (accountData.idToken) {
accountData.idToken = decrypt(accountData.idToken) accountData.idToken = decrypt(accountData.idToken)
} }
// 注意accessToken 在 openaiRoutes.js 中会被单独解密,这里不解密 if (accountData.accessToken) {
// if (accountData.accessToken) { accountData.accessToken = decrypt(accountData.accessToken)
// accountData.accessToken = decrypt(accountData.accessToken) }
// }
if (accountData.refreshToken) { if (accountData.refreshToken) {
accountData.refreshToken = decrypt(accountData.refreshToken) accountData.refreshToken = decrypt(accountData.refreshToken)
} }
@@ -570,7 +391,7 @@ async function updateAccount(accountId, updates) {
if (updates.accessToken) { if (updates.accessToken) {
updates.accessToken = encrypt(updates.accessToken) updates.accessToken = encrypt(updates.accessToken)
} }
if (updates.refreshToken && updates.refreshToken.trim()) { if (updates.refreshToken) {
updates.refreshToken = encrypt(updates.refreshToken) updates.refreshToken = encrypt(updates.refreshToken)
} }
if (updates.email) { if (updates.email) {
@@ -655,9 +476,6 @@ async function getAllAccounts() {
accountData.email = decrypt(accountData.email) accountData.email = decrypt(accountData.email)
} }
// 先保存 refreshToken 是否存在的标记
const hasRefreshTokenFlag = !!accountData.refreshToken
// 屏蔽敏感信息token等不应该返回给前端 // 屏蔽敏感信息token等不应该返回给前端
delete accountData.idToken delete accountData.idToken
delete accountData.accessToken delete accountData.accessToken
@@ -684,8 +502,6 @@ async function getAllAccounts() {
// 不解密敏感字段,只返回基本信息 // 不解密敏感字段,只返回基本信息
accounts.push({ accounts.push({
...accountData, ...accountData,
isActive: accountData.isActive === 'true',
schedulable: accountData.schedulable !== 'false',
openaiOauth: accountData.openaiOauth ? '[ENCRYPTED]' : '', openaiOauth: accountData.openaiOauth ? '[ENCRYPTED]' : '',
accessToken: accountData.accessToken ? '[ENCRYPTED]' : '', accessToken: accountData.accessToken ? '[ENCRYPTED]' : '',
refreshToken: accountData.refreshToken ? '[ENCRYPTED]' : '', refreshToken: accountData.refreshToken ? '[ENCRYPTED]' : '',
@@ -694,7 +510,7 @@ async function getAllAccounts() {
scopes: scopes:
accountData.scopes && accountData.scopes.trim() ? accountData.scopes.split(' ') : [], accountData.scopes && accountData.scopes.trim() ? accountData.scopes.split(' ') : [],
// 添加 hasRefreshToken 标记 // 添加 hasRefreshToken 标记
hasRefreshToken: hasRefreshTokenFlag, hasRefreshToken: !!accountData.refreshToken,
// 添加限流状态信息(统一格式) // 添加限流状态信息(统一格式)
rateLimitStatus: rateLimitInfo rateLimitStatus: rateLimitInfo
? { ? {
@@ -814,101 +630,14 @@ function isRateLimited(account) {
} }
// 设置账户限流状态 // 设置账户限流状态
async function setAccountRateLimited(accountId, isLimited, resetsInSeconds = null) { async function setAccountRateLimited(accountId, isLimited) {
const updates = { const updates = {
rateLimitStatus: isLimited ? 'limited' : 'normal', rateLimitStatus: isLimited ? 'limited' : 'normal',
rateLimitedAt: isLimited ? new Date().toISOString() : null, rateLimitedAt: isLimited ? new Date().toISOString() : null
// 限流时停止调度,解除限流时恢复调度
schedulable: isLimited ? 'false' : 'true'
}
// 如果提供了重置时间(秒数),计算重置时间戳
if (isLimited && resetsInSeconds !== null && resetsInSeconds > 0) {
const resetTime = new Date(Date.now() + resetsInSeconds * 1000).toISOString()
updates.rateLimitResetAt = resetTime
logger.info(
`🕐 Account ${accountId} will be reset at ${resetTime} (in ${resetsInSeconds} seconds / ${Math.ceil(resetsInSeconds / 60)} minutes)`
)
} else if (isLimited) {
// 如果没有提供重置时间使用默认的60分钟
const defaultResetSeconds = 60 * 60 // 1小时
const resetTime = new Date(Date.now() + defaultResetSeconds * 1000).toISOString()
updates.rateLimitResetAt = resetTime
logger.warn(
`⚠️ No reset time provided for account ${accountId}, using default 60 minutes. Reset at ${resetTime}`
)
} else if (!isLimited) {
updates.rateLimitResetAt = null
} }
await updateAccount(accountId, updates) await updateAccount(accountId, updates)
logger.info( logger.info(`Set rate limit status for OpenAI account ${accountId}: ${updates.rateLimitStatus}`)
`Set rate limit status for OpenAI account ${accountId}: ${updates.rateLimitStatus}, schedulable: ${updates.schedulable}`
)
// 如果被限流,发送 Webhook 通知
if (isLimited) {
try {
const account = await getAccount(accountId)
const webhookNotifier = require('../utils/webhookNotifier')
await webhookNotifier.sendAccountAnomalyNotification({
accountId,
accountName: account.name || accountId,
platform: 'openai',
status: 'blocked',
errorCode: 'OPENAI_RATE_LIMITED',
reason: resetsInSeconds
? `Account rate limited (429 error). Reset in ${Math.ceil(resetsInSeconds / 60)} minutes`
: 'Account rate limited (429 error). Estimated reset in 1 hour',
timestamp: new Date().toISOString()
})
logger.info(`📢 Webhook notification sent for OpenAI account ${account.name} rate limit`)
} catch (webhookError) {
logger.error('Failed to send rate limit webhook notification:', webhookError)
}
}
}
// 🔄 重置账户所有异常状态
async function resetAccountStatus(accountId) {
const account = await getAccount(accountId)
if (!account) {
throw new Error('Account not found')
}
const updates = {
// 根据是否有有效的 accessToken 来设置 status
status: account.accessToken ? 'active' : 'created',
// 恢复可调度状态
schedulable: 'true',
// 清除错误相关字段
errorMessage: null,
rateLimitedAt: null,
rateLimitStatus: 'normal',
rateLimitResetAt: null
}
await updateAccount(accountId, updates)
logger.info(`✅ Reset all error status for OpenAI account ${accountId}`)
// 发送 Webhook 通知
try {
const webhookNotifier = require('../utils/webhookNotifier')
await webhookNotifier.sendAccountAnomalyNotification({
accountId,
accountName: account.name || accountId,
platform: 'openai',
status: 'recovered',
errorCode: 'STATUS_RESET',
reason: 'Account status manually reset',
timestamp: new Date().toISOString()
})
logger.info(`📢 Webhook notification sent for OpenAI account ${account.name} status reset`)
} catch (webhookError) {
logger.error('Failed to send status reset webhook notification:', webhookError)
}
return { success: true, message: 'Account status reset successfully' }
} }
// 切换账户调度状态 // 切换账户调度状态
@@ -940,26 +669,15 @@ async function getAccountRateLimitInfo(accountId) {
return null return null
} }
if (account.rateLimitStatus === 'limited') { if (account.rateLimitStatus === 'limited' && account.rateLimitedAt) {
const limitedAt = new Date(account.rateLimitedAt).getTime()
const now = Date.now() const now = Date.now()
let remainingTime = 0 const limitDuration = 60 * 60 * 1000 // 1小时
const remainingTime = Math.max(0, limitedAt + limitDuration - now)
// 优先使用 rateLimitResetAt 字段(精确的重置时间)
if (account.rateLimitResetAt) {
const resetAt = new Date(account.rateLimitResetAt).getTime()
remainingTime = Math.max(0, resetAt - now)
}
// 回退到使用 rateLimitedAt + 默认1小时
else if (account.rateLimitedAt) {
const limitedAt = new Date(account.rateLimitedAt).getTime()
const limitDuration = 60 * 60 * 1000 // 默认1小时
remainingTime = Math.max(0, limitedAt + limitDuration - now)
}
return { return {
isRateLimited: remainingTime > 0, isRateLimited: remainingTime > 0,
rateLimitedAt: account.rateLimitedAt, rateLimitedAt: account.rateLimitedAt,
rateLimitResetAt: account.rateLimitResetAt,
minutesRemaining: Math.ceil(remainingTime / (60 * 1000)) minutesRemaining: Math.ceil(remainingTime / (60 * 1000))
} }
} }
@@ -967,7 +685,6 @@ async function getAccountRateLimitInfo(accountId) {
return { return {
isRateLimited: false, isRateLimited: false,
rateLimitedAt: null, rateLimitedAt: null,
rateLimitResetAt: null,
minutesRemaining: 0 minutesRemaining: 0
} }
} }
@@ -1005,7 +722,6 @@ module.exports = {
refreshAccountToken, refreshAccountToken,
isTokenExpired, isTokenExpired,
setAccountRateLimited, setAccountRateLimited,
resetAccountStatus,
toggleSchedulable, toggleSchedulable,
getAccountRateLimitInfo, getAccountRateLimitInfo,
updateAccountUsage, updateAccountUsage,

View File

@@ -45,7 +45,6 @@ class PricingService {
'claude-sonnet-3-5': 0.000006, 'claude-sonnet-3-5': 0.000006,
'claude-sonnet-3-7': 0.000006, 'claude-sonnet-3-7': 0.000006,
'claude-sonnet-4': 0.000006, 'claude-sonnet-4': 0.000006,
'claude-sonnet-4-20250514': 0.000006,
// Haiku 系列: $1.6/MTok // Haiku 系列: $1.6/MTok
'claude-3-5-haiku': 0.0000016, 'claude-3-5-haiku': 0.0000016,
@@ -56,17 +55,6 @@ class PricingService {
'claude-haiku-3': 0.0000016, 'claude-haiku-3': 0.0000016,
'claude-haiku-3-5': 0.0000016 'claude-haiku-3-5': 0.0000016
} }
// 硬编码的 1M 上下文模型价格(美元/token
// 当总输入 tokens 超过 200k 时使用这些价格
this.longContextPricing = {
// claude-sonnet-4-20250514[1m] 模型的 1M 上下文价格
'claude-sonnet-4-20250514[1m]': {
input: 0.000006, // $6/MTok
output: 0.0000225 // $22.50/MTok
}
// 未来可以添加更多 1M 模型的价格
}
} }
// 初始化价格服务 // 初始化价格服务
@@ -261,7 +249,6 @@ class PricingService {
// 尝试直接匹配 // 尝试直接匹配
if (this.pricingData[modelName]) { if (this.pricingData[modelName]) {
logger.debug(`💰 Found exact pricing match for ${modelName}`)
return this.pricingData[modelName] return this.pricingData[modelName]
} }
@@ -306,22 +293,6 @@ class PricingService {
return null return null
} }
// 确保价格对象包含缓存价格
ensureCachePricing(pricing) {
if (!pricing) {
return pricing
}
// 如果缺少缓存价格根据输入价格计算缓存创建价格通常是输入价格的1.25倍缓存读取是0.1倍)
if (!pricing.cache_creation_input_token_cost && pricing.input_cost_per_token) {
pricing.cache_creation_input_token_cost = pricing.input_cost_per_token * 1.25
}
if (!pricing.cache_read_input_token_cost && pricing.input_cost_per_token) {
pricing.cache_read_input_token_cost = pricing.input_cost_per_token * 0.1
}
return pricing
}
// 获取 1 小时缓存价格 // 获取 1 小时缓存价格
getEphemeral1hPricing(modelName) { getEphemeral1hPricing(modelName) {
if (!modelName) { if (!modelName) {
@@ -358,40 +329,9 @@ class PricingService {
// 计算使用费用 // 计算使用费用
calculateCost(usage, modelName) { calculateCost(usage, modelName) {
// 检查是否为 1M 上下文模型
const isLongContextModel = modelName && modelName.includes('[1m]')
let isLongContextRequest = false
let useLongContextPricing = false
if (isLongContextModel) {
// 计算总输入 tokens
const inputTokens = usage.input_tokens || 0
const cacheCreationTokens = usage.cache_creation_input_tokens || 0
const cacheReadTokens = usage.cache_read_input_tokens || 0
const totalInputTokens = inputTokens + cacheCreationTokens + cacheReadTokens
// 如果总输入超过 200k使用 1M 上下文价格
if (totalInputTokens > 200000) {
isLongContextRequest = true
// 检查是否有硬编码的 1M 价格
if (this.longContextPricing[modelName]) {
useLongContextPricing = true
} else {
// 如果没有找到硬编码价格,使用第一个 1M 模型的价格作为默认
const defaultLongContextModel = Object.keys(this.longContextPricing)[0]
if (defaultLongContextModel) {
useLongContextPricing = true
logger.warn(
`⚠️ No specific 1M pricing for ${modelName}, using default from ${defaultLongContextModel}`
)
}
}
}
}
const pricing = this.getModelPricing(modelName) const pricing = this.getModelPricing(modelName)
if (!pricing && !useLongContextPricing) { if (!pricing) {
return { return {
inputCost: 0, inputCost: 0,
outputCost: 0, outputCost: 0,
@@ -400,35 +340,14 @@ class PricingService {
ephemeral5mCost: 0, ephemeral5mCost: 0,
ephemeral1hCost: 0, ephemeral1hCost: 0,
totalCost: 0, totalCost: 0,
hasPricing: false, hasPricing: false
isLongContextRequest: false
} }
} }
let inputCost = 0 const inputCost = (usage.input_tokens || 0) * (pricing.input_cost_per_token || 0)
let outputCost = 0 const outputCost = (usage.output_tokens || 0) * (pricing.output_cost_per_token || 0)
if (useLongContextPricing) {
// 使用 1M 上下文特殊价格(仅输入和输出价格改变)
const longContextPrices =
this.longContextPricing[modelName] ||
this.longContextPricing[Object.keys(this.longContextPricing)[0]]
inputCost = (usage.input_tokens || 0) * longContextPrices.input
outputCost = (usage.output_tokens || 0) * longContextPrices.output
logger.info(
`💰 Using 1M context pricing for ${modelName}: input=$${longContextPrices.input}/token, output=$${longContextPrices.output}/token`
)
} else {
// 使用正常价格
inputCost = (usage.input_tokens || 0) * (pricing?.input_cost_per_token || 0)
outputCost = (usage.output_tokens || 0) * (pricing?.output_cost_per_token || 0)
}
// 缓存价格保持不变(即使对于 1M 模型)
const cacheReadCost = const cacheReadCost =
(usage.cache_read_input_tokens || 0) * (pricing?.cache_read_input_token_cost || 0) (usage.cache_read_input_tokens || 0) * (pricing.cache_read_input_token_cost || 0)
// 处理缓存创建费用: // 处理缓存创建费用:
// 1. 如果有详细的 cache_creation 对象,使用它 // 1. 如果有详细的 cache_creation 对象,使用它
@@ -443,7 +362,7 @@ class PricingService {
const ephemeral1hTokens = usage.cache_creation.ephemeral_1h_input_tokens || 0 const ephemeral1hTokens = usage.cache_creation.ephemeral_1h_input_tokens || 0
// 5分钟缓存使用标准的 cache_creation_input_token_cost // 5分钟缓存使用标准的 cache_creation_input_token_cost
ephemeral5mCost = ephemeral5mTokens * (pricing?.cache_creation_input_token_cost || 0) ephemeral5mCost = ephemeral5mTokens * (pricing.cache_creation_input_token_cost || 0)
// 1小时缓存使用硬编码的价格 // 1小时缓存使用硬编码的价格
const ephemeral1hPrice = this.getEphemeral1hPricing(modelName) const ephemeral1hPrice = this.getEphemeral1hPricing(modelName)
@@ -454,7 +373,7 @@ class PricingService {
} else if (usage.cache_creation_input_tokens) { } else if (usage.cache_creation_input_tokens) {
// 旧格式,所有缓存创建 tokens 都按 5 分钟价格计算(向后兼容) // 旧格式,所有缓存创建 tokens 都按 5 分钟价格计算(向后兼容)
cacheCreateCost = cacheCreateCost =
(usage.cache_creation_input_tokens || 0) * (pricing?.cache_creation_input_token_cost || 0) (usage.cache_creation_input_tokens || 0) * (pricing.cache_creation_input_token_cost || 0)
ephemeral5mCost = cacheCreateCost ephemeral5mCost = cacheCreateCost
} }
@@ -467,22 +386,11 @@ class PricingService {
ephemeral1hCost, ephemeral1hCost,
totalCost: inputCost + outputCost + cacheCreateCost + cacheReadCost, totalCost: inputCost + outputCost + cacheCreateCost + cacheReadCost,
hasPricing: true, hasPricing: true,
isLongContextRequest,
pricing: { pricing: {
input: useLongContextPricing input: pricing.input_cost_per_token || 0,
? ( output: pricing.output_cost_per_token || 0,
this.longContextPricing[modelName] || cacheCreate: pricing.cache_creation_input_token_cost || 0,
this.longContextPricing[Object.keys(this.longContextPricing)[0]] cacheRead: pricing.cache_read_input_token_cost || 0,
)?.input || 0
: pricing?.input_cost_per_token || 0,
output: useLongContextPricing
? (
this.longContextPricing[modelName] ||
this.longContextPricing[Object.keys(this.longContextPricing)[0]]
)?.output || 0
: pricing?.output_cost_per_token || 0,
cacheCreate: pricing?.cache_creation_input_token_cost || 0,
cacheRead: pricing?.cache_read_input_token_cost || 0,
ephemeral1h: this.getEphemeral1hPricing(modelName) ephemeral1h: this.getEphemeral1hPricing(modelName)
} }
} }

View File

@@ -1,351 +0,0 @@
/**
* 限流状态自动清理服务
* 定期检查并清理所有类型账号的过期限流状态
*/
const logger = require('../utils/logger')
const openaiAccountService = require('./openaiAccountService')
const claudeAccountService = require('./claudeAccountService')
const claudeConsoleAccountService = require('./claudeConsoleAccountService')
const unifiedOpenAIScheduler = require('./unifiedOpenAIScheduler')
const webhookService = require('./webhookService')
class RateLimitCleanupService {
constructor() {
this.cleanupInterval = null
this.isRunning = false
// 默认每5分钟检查一次
this.intervalMs = 5 * 60 * 1000
// 存储已清理的账户信息,用于发送恢复通知
this.clearedAccounts = []
}
/**
* 启动自动清理服务
* @param {number} intervalMinutes - 检查间隔分钟默认5分钟
*/
start(intervalMinutes = 5) {
if (this.cleanupInterval) {
logger.warn('⚠️ Rate limit cleanup service is already running')
return
}
this.intervalMs = intervalMinutes * 60 * 1000
logger.info(`🧹 Starting rate limit cleanup service (interval: ${intervalMinutes} minutes)`)
// 立即执行一次清理
this.performCleanup()
// 设置定期执行
this.cleanupInterval = setInterval(() => {
this.performCleanup()
}, this.intervalMs)
}
/**
* 停止自动清理服务
*/
stop() {
if (this.cleanupInterval) {
clearInterval(this.cleanupInterval)
this.cleanupInterval = null
logger.info('🛑 Rate limit cleanup service stopped')
}
}
/**
* 执行一次清理检查
*/
async performCleanup() {
if (this.isRunning) {
logger.debug('⏭️ Cleanup already in progress, skipping this cycle')
return
}
this.isRunning = true
const startTime = Date.now()
try {
logger.debug('🔍 Starting rate limit cleanup check...')
const results = {
openai: { checked: 0, cleared: 0, errors: [] },
claude: { checked: 0, cleared: 0, errors: [] },
claudeConsole: { checked: 0, cleared: 0, errors: [] }
}
// 清理 OpenAI 账号
await this.cleanupOpenAIAccounts(results.openai)
// 清理 Claude 账号
await this.cleanupClaudeAccounts(results.claude)
// 清理 Claude Console 账号
await this.cleanupClaudeConsoleAccounts(results.claudeConsole)
const totalChecked =
results.openai.checked + results.claude.checked + results.claudeConsole.checked
const totalCleared =
results.openai.cleared + results.claude.cleared + results.claudeConsole.cleared
const duration = Date.now() - startTime
if (totalCleared > 0) {
logger.info(
`✅ Rate limit cleanup completed: ${totalCleared} accounts cleared out of ${totalChecked} checked (${duration}ms)`
)
logger.info(` OpenAI: ${results.openai.cleared}/${results.openai.checked}`)
logger.info(` Claude: ${results.claude.cleared}/${results.claude.checked}`)
logger.info(
` Claude Console: ${results.claudeConsole.cleared}/${results.claudeConsole.checked}`
)
// 发送 webhook 恢复通知
if (this.clearedAccounts.length > 0) {
await this.sendRecoveryNotifications()
}
} else {
logger.debug(
`🔍 Rate limit cleanup check completed: no expired limits found (${duration}ms)`
)
}
// 清空已清理账户列表
this.clearedAccounts = []
// 记录错误
const allErrors = [
...results.openai.errors,
...results.claude.errors,
...results.claudeConsole.errors
]
if (allErrors.length > 0) {
logger.warn(`⚠️ Encountered ${allErrors.length} errors during cleanup:`, allErrors)
}
} catch (error) {
logger.error('❌ Rate limit cleanup failed:', error)
} finally {
this.isRunning = false
}
}
/**
* 清理 OpenAI 账号的过期限流
*/
async cleanupOpenAIAccounts(result) {
try {
const accounts = await openaiAccountService.getAllAccounts()
for (const account of accounts) {
// 只检查标记为限流的账号
if (account.rateLimitStatus === 'limited') {
result.checked++
try {
// 使用 unifiedOpenAIScheduler 的检查方法,它会自动清除过期的限流
const isStillLimited = await unifiedOpenAIScheduler.isAccountRateLimited(account.id)
if (!isStillLimited) {
result.cleared++
logger.info(
`🧹 Auto-cleared expired rate limit for OpenAI account: ${account.name} (${account.id})`
)
// 记录已清理的账户信息
this.clearedAccounts.push({
platform: 'OpenAI',
accountId: account.id,
accountName: account.name,
previousStatus: 'rate_limited',
currentStatus: 'active'
})
}
} catch (error) {
result.errors.push({
accountId: account.id,
accountName: account.name,
error: error.message
})
}
}
}
} catch (error) {
logger.error('Failed to cleanup OpenAI accounts:', error)
result.errors.push({ error: error.message })
}
}
/**
* 清理 Claude 账号的过期限流
*/
async cleanupClaudeAccounts(result) {
try {
const accounts = await claudeAccountService.getAllAccounts()
for (const account of accounts) {
// 只检查标记为限流的账号
if (account.rateLimitStatus === 'limited' || account.rateLimitedAt) {
result.checked++
try {
// 使用 claudeAccountService 的检查方法,它会自动清除过期的限流
const isStillLimited = await claudeAccountService.isAccountRateLimited(account.id)
if (!isStillLimited) {
result.cleared++
logger.info(
`🧹 Auto-cleared expired rate limit for Claude account: ${account.name} (${account.id})`
)
// 记录已清理的账户信息
this.clearedAccounts.push({
platform: 'Claude',
accountId: account.id,
accountName: account.name,
previousStatus: 'rate_limited',
currentStatus: 'active'
})
}
} catch (error) {
result.errors.push({
accountId: account.id,
accountName: account.name,
error: error.message
})
}
}
}
} catch (error) {
logger.error('Failed to cleanup Claude accounts:', error)
result.errors.push({ error: error.message })
}
}
/**
* 清理 Claude Console 账号的过期限流
*/
async cleanupClaudeConsoleAccounts(result) {
try {
const accounts = await claudeConsoleAccountService.getAllAccounts()
for (const account of accounts) {
// 检查两种状态字段rateLimitStatus 和 status
const hasRateLimitStatus = account.rateLimitStatus === 'limited'
const hasStatusRateLimited = account.status === 'rate_limited'
if (hasRateLimitStatus || hasStatusRateLimited) {
result.checked++
try {
// 使用 claudeConsoleAccountService 的检查方法,它会自动清除过期的限流
const isStillLimited = await claudeConsoleAccountService.isAccountRateLimited(
account.id
)
if (!isStillLimited) {
result.cleared++
// 如果 status 字段是 rate_limited需要额外清理
if (hasStatusRateLimited && !hasRateLimitStatus) {
await claudeConsoleAccountService.updateAccount(account.id, {
status: 'active'
})
}
logger.info(
`🧹 Auto-cleared expired rate limit for Claude Console account: ${account.name} (${account.id})`
)
// 记录已清理的账户信息
this.clearedAccounts.push({
platform: 'Claude Console',
accountId: account.id,
accountName: account.name,
previousStatus: 'rate_limited',
currentStatus: 'active'
})
}
} catch (error) {
result.errors.push({
accountId: account.id,
accountName: account.name,
error: error.message
})
}
}
}
} catch (error) {
logger.error('Failed to cleanup Claude Console accounts:', error)
result.errors.push({ error: error.message })
}
}
/**
* 手动触发一次清理(供 API 或 CLI 调用)
*/
async manualCleanup() {
logger.info('🧹 Manual rate limit cleanup triggered')
await this.performCleanup()
}
/**
* 发送限流恢复通知
*/
async sendRecoveryNotifications() {
try {
// 按平台分组账户
const groupedAccounts = {}
for (const account of this.clearedAccounts) {
if (!groupedAccounts[account.platform]) {
groupedAccounts[account.platform] = []
}
groupedAccounts[account.platform].push(account)
}
// 构建通知消息
const platforms = Object.keys(groupedAccounts)
const totalAccounts = this.clearedAccounts.length
let message = `🎉 共有 ${totalAccounts} 个账户的限流状态已恢复\n\n`
for (const platform of platforms) {
const accounts = groupedAccounts[platform]
message += `**${platform}** (${accounts.length} 个):\n`
for (const account of accounts) {
message += `${account.accountName} (ID: ${account.accountId})\n`
}
message += '\n'
}
// 发送 webhook 通知
await webhookService.sendNotification('rateLimitRecovery', {
title: '限流恢复通知',
message,
totalAccounts,
platforms: Object.keys(groupedAccounts),
accounts: this.clearedAccounts,
timestamp: new Date().toISOString()
})
logger.info(`📢 已发送限流恢复通知,涉及 ${totalAccounts} 个账户`)
} catch (error) {
logger.error('❌ 发送限流恢复通知失败:', error)
}
}
/**
* 获取服务状态
*/
getStatus() {
return {
running: !!this.cleanupInterval,
intervalMinutes: this.intervalMs / (60 * 1000),
isProcessing: this.isRunning
}
}
}
// 创建单例实例
const rateLimitCleanupService = new RateLimitCleanupService()
module.exports = rateLimitCleanupService

View File

@@ -20,77 +20,6 @@ class UnifiedClaudeScheduler {
return schedulable !== false && schedulable !== 'false' return schedulable !== false && schedulable !== 'false'
} }
// 🔍 检查账户是否支持请求的模型
_isModelSupportedByAccount(account, accountType, requestedModel, context = '') {
if (!requestedModel) {
return true // 没有指定模型时,默认支持
}
// Claude OAuth 账户的 Opus 模型检查
if (accountType === 'claude-official') {
if (requestedModel.toLowerCase().includes('opus')) {
if (account.subscriptionInfo) {
try {
const info =
typeof account.subscriptionInfo === 'string'
? JSON.parse(account.subscriptionInfo)
: account.subscriptionInfo
// Pro 和 Free 账号不支持 Opus
if (info.hasClaudePro === true && info.hasClaudeMax !== true) {
logger.info(
`🚫 Claude account ${account.name} (Pro) 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
}
} catch (e) {
// 解析失败,假设为旧数据,默认支持(兼容旧数据为 Max
logger.debug(
`Account ${account.name} has invalid subscriptionInfo${context ? ` ${context}` : ''}, assuming Max`
)
}
}
// 没有订阅信息的账号,默认当作支持(兼容旧数据)
}
}
// Claude Console 账户的模型支持检查
if (accountType === 'claude-console' && account.supportedModels) {
// 兼容旧格式(数组)和新格式(对象)
if (Array.isArray(account.supportedModels)) {
// 旧格式:数组
if (
account.supportedModels.length > 0 &&
!account.supportedModels.includes(requestedModel)
) {
logger.info(
`🚫 Claude Console account ${account.name} does not support model ${requestedModel}${context ? ` ${context}` : ''}`
)
return false
}
} else if (typeof account.supportedModels === 'object') {
// 新格式:映射表
if (
Object.keys(account.supportedModels).length > 0 &&
!claudeConsoleAccountService.isModelSupported(account.supportedModels, requestedModel)
) {
logger.info(
`🚫 Claude Console account ${account.name} does not support model ${requestedModel}${context ? ` ${context}` : ''}`
)
return false
}
}
}
return true
}
// 🎯 统一调度Claude账号官方和Console // 🎯 统一调度Claude账号官方和Console
async selectAccountForApiKey(apiKeyData, sessionHash = null, requestedModel = null) { async selectAccountForApiKey(apiKeyData, sessionHash = null, requestedModel = null) {
try { try {
@@ -107,12 +36,7 @@ class UnifiedClaudeScheduler {
// 普通专属账户 // 普通专属账户
const boundAccount = await redis.getClaudeAccount(apiKeyData.claudeAccountId) const boundAccount = await redis.getClaudeAccount(apiKeyData.claudeAccountId)
if ( if (boundAccount && boundAccount.isActive === 'true' && boundAccount.status !== 'error') {
boundAccount &&
boundAccount.isActive === 'true' &&
boundAccount.status !== 'error' &&
this._isSchedulable(boundAccount.schedulable)
) {
logger.info( logger.info(
`🎯 Using bound dedicated Claude OAuth account: ${boundAccount.name} (${apiKeyData.claudeAccountId}) for API key ${apiKeyData.name}` `🎯 Using bound dedicated Claude OAuth account: ${boundAccount.name} (${apiKeyData.claudeAccountId}) for API key ${apiKeyData.name}`
) )
@@ -122,7 +46,7 @@ class UnifiedClaudeScheduler {
} }
} else { } else {
logger.warn( logger.warn(
`⚠️ Bound Claude OAuth account ${apiKeyData.claudeAccountId} is not available (isActive: ${boundAccount?.isActive}, status: ${boundAccount?.status}, schedulable: ${boundAccount?.schedulable}), falling back to pool` `⚠️ Bound Claude OAuth account ${apiKeyData.claudeAccountId} is not available, falling back to pool`
) )
} }
} }
@@ -135,8 +59,7 @@ class UnifiedClaudeScheduler {
if ( if (
boundConsoleAccount && boundConsoleAccount &&
boundConsoleAccount.isActive === true && boundConsoleAccount.isActive === true &&
boundConsoleAccount.status === 'active' && boundConsoleAccount.status === 'active'
this._isSchedulable(boundConsoleAccount.schedulable)
) { ) {
logger.info( logger.info(
`🎯 Using bound dedicated Claude Console account: ${boundConsoleAccount.name} (${apiKeyData.claudeConsoleAccountId}) for API key ${apiKeyData.name}` `🎯 Using bound dedicated Claude Console account: ${boundConsoleAccount.name} (${apiKeyData.claudeConsoleAccountId}) for API key ${apiKeyData.name}`
@@ -147,7 +70,7 @@ class UnifiedClaudeScheduler {
} }
} else { } else {
logger.warn( logger.warn(
`⚠️ Bound Claude Console account ${apiKeyData.claudeConsoleAccountId} is not available (isActive: ${boundConsoleAccount?.isActive}, status: ${boundConsoleAccount?.status}, schedulable: ${boundConsoleAccount?.schedulable}), falling back to pool` `⚠️ Bound Claude Console account ${apiKeyData.claudeConsoleAccountId} is not available, falling back to pool`
) )
} }
} }
@@ -157,11 +80,7 @@ class UnifiedClaudeScheduler {
const boundBedrockAccountResult = await bedrockAccountService.getAccount( const boundBedrockAccountResult = await bedrockAccountService.getAccount(
apiKeyData.bedrockAccountId apiKeyData.bedrockAccountId
) )
if ( if (boundBedrockAccountResult.success && boundBedrockAccountResult.data.isActive === true) {
boundBedrockAccountResult.success &&
boundBedrockAccountResult.data.isActive === true &&
this._isSchedulable(boundBedrockAccountResult.data.schedulable)
) {
logger.info( logger.info(
`🎯 Using bound dedicated Bedrock account: ${boundBedrockAccountResult.data.name} (${apiKeyData.bedrockAccountId}) for API key ${apiKeyData.name}` `🎯 Using bound dedicated Bedrock account: ${boundBedrockAccountResult.data.name} (${apiKeyData.bedrockAccountId}) for API key ${apiKeyData.name}`
) )
@@ -171,7 +90,7 @@ class UnifiedClaudeScheduler {
} }
} else { } else {
logger.warn( logger.warn(
`⚠️ Bound Bedrock account ${apiKeyData.bedrockAccountId} is not available (isActive: ${boundBedrockAccountResult?.data?.isActive}, schedulable: ${boundBedrockAccountResult?.data?.schedulable}), falling back to pool` `⚠️ Bound Bedrock account ${apiKeyData.bedrockAccountId} is not available, falling back to pool`
) )
} }
} }
@@ -183,12 +102,9 @@ class UnifiedClaudeScheduler {
// 验证映射的账户是否仍然可用 // 验证映射的账户是否仍然可用
const isAvailable = await this._isAccountAvailable( const isAvailable = await this._isAccountAvailable(
mappedAccount.accountId, mappedAccount.accountId,
mappedAccount.accountType, mappedAccount.accountType
requestedModel
) )
if (isAvailable) { if (isAvailable) {
// 🚀 智能会话续期剩余时间少于14天时自动续期到15天
await redis.extendSessionAccountMappingTTL(sessionHash)
logger.info( logger.info(
`🎯 Using sticky session account: ${mappedAccount.accountId} (${mappedAccount.accountType}) for session ${sessionHash}` `🎯 Using sticky session account: ${mappedAccount.accountId} (${mappedAccount.accountType}) for session ${sessionHash}`
) )
@@ -261,8 +177,7 @@ class UnifiedClaudeScheduler {
boundAccount.isActive === 'true' && boundAccount.isActive === 'true' &&
boundAccount.status !== 'error' && boundAccount.status !== 'error' &&
boundAccount.status !== 'blocked' && boundAccount.status !== 'blocked' &&
boundAccount.status !== 'temp_error' && boundAccount.status !== 'temp_error'
this._isSchedulable(boundAccount.schedulable)
) { ) {
const isRateLimited = await claudeAccountService.isAccountRateLimited(boundAccount.id) const isRateLimited = await claudeAccountService.isAccountRateLimited(boundAccount.id)
if (!isRateLimited) { if (!isRateLimited) {
@@ -280,9 +195,7 @@ class UnifiedClaudeScheduler {
] ]
} }
} else { } else {
logger.warn( logger.warn(`⚠️ Bound Claude OAuth account ${apiKeyData.claudeAccountId} is not available`)
`⚠️ Bound Claude OAuth account ${apiKeyData.claudeAccountId} is not available (isActive: ${boundAccount?.isActive}, status: ${boundAccount?.status}, schedulable: ${boundAccount?.schedulable})`
)
} }
} }
@@ -294,28 +207,12 @@ class UnifiedClaudeScheduler {
if ( if (
boundConsoleAccount && boundConsoleAccount &&
boundConsoleAccount.isActive === true && boundConsoleAccount.isActive === true &&
boundConsoleAccount.status === 'active' && boundConsoleAccount.status === 'active'
this._isSchedulable(boundConsoleAccount.schedulable)
) { ) {
// 主动触发一次额度检查
try {
await claudeConsoleAccountService.checkQuotaUsage(boundConsoleAccount.id)
} catch (e) {
logger.warn(
`Failed to check quota for bound Claude Console account ${boundConsoleAccount.name}: ${e.message}`
)
// 继续使用该账号
}
// 检查限流状态和额度状态
const isRateLimited = await claudeConsoleAccountService.isAccountRateLimited( const isRateLimited = await claudeConsoleAccountService.isAccountRateLimited(
boundConsoleAccount.id boundConsoleAccount.id
) )
const isQuotaExceeded = await claudeConsoleAccountService.isAccountQuotaExceeded( if (!isRateLimited) {
boundConsoleAccount.id
)
if (!isRateLimited && !isQuotaExceeded) {
logger.info( logger.info(
`🎯 Using bound dedicated Claude Console account: ${boundConsoleAccount.name} (${apiKeyData.claudeConsoleAccountId})` `🎯 Using bound dedicated Claude Console account: ${boundConsoleAccount.name} (${apiKeyData.claudeConsoleAccountId})`
) )
@@ -331,7 +228,7 @@ class UnifiedClaudeScheduler {
} }
} else { } else {
logger.warn( logger.warn(
`⚠️ Bound Claude Console account ${apiKeyData.claudeConsoleAccountId} is not available (isActive: ${boundConsoleAccount?.isActive}, status: ${boundConsoleAccount?.status}, schedulable: ${boundConsoleAccount?.schedulable})` `⚠️ Bound Claude Console account ${apiKeyData.claudeConsoleAccountId} is not available`
) )
} }
} }
@@ -341,11 +238,7 @@ class UnifiedClaudeScheduler {
const boundBedrockAccountResult = await bedrockAccountService.getAccount( const boundBedrockAccountResult = await bedrockAccountService.getAccount(
apiKeyData.bedrockAccountId apiKeyData.bedrockAccountId
) )
if ( if (boundBedrockAccountResult.success && boundBedrockAccountResult.data.isActive === true) {
boundBedrockAccountResult.success &&
boundBedrockAccountResult.data.isActive === true &&
this._isSchedulable(boundBedrockAccountResult.data.schedulable)
) {
logger.info( logger.info(
`🎯 Using bound dedicated Bedrock account: ${boundBedrockAccountResult.data.name} (${apiKeyData.bedrockAccountId})` `🎯 Using bound dedicated Bedrock account: ${boundBedrockAccountResult.data.name} (${apiKeyData.bedrockAccountId})`
) )
@@ -359,9 +252,7 @@ class UnifiedClaudeScheduler {
} }
] ]
} else { } else {
logger.warn( logger.warn(`⚠️ Bound Bedrock account ${apiKeyData.bedrockAccountId} is not available`)
`⚠️ Bound Bedrock account ${apiKeyData.bedrockAccountId} is not available (isActive: ${boundBedrockAccountResult?.data?.isActive}, schedulable: ${boundBedrockAccountResult?.data?.schedulable})`
)
} }
} }
@@ -378,9 +269,33 @@ class UnifiedClaudeScheduler {
) { ) {
// 检查是否可调度 // 检查是否可调度
// 检查模型支持 // 检查模型支持(如果请求的是 Opus 模型)
if (!this._isModelSupportedByAccount(account, 'claude-official', requestedModel)) { if (requestedModel && requestedModel.toLowerCase().includes('opus')) {
continue // 检查账号的订阅信息
if (account.subscriptionInfo) {
try {
const info =
typeof account.subscriptionInfo === 'string'
? JSON.parse(account.subscriptionInfo)
: account.subscriptionInfo
// Pro 和 Free 账号不支持 Opus
if (info.hasClaudePro === true && info.hasClaudeMax !== true) {
logger.info(`🚫 Claude account ${account.name} (Pro) does not support Opus model`)
continue // Claude Pro 不支持 Opus
}
if (info.accountType === 'claude_pro' || info.accountType === 'claude_free') {
logger.info(
`🚫 Claude account ${account.name} (${info.accountType}) does not support Opus model`
)
continue // 明确标记为 Pro 或 Free 的账号不支持
}
} catch (e) {
// 解析失败,假设为旧数据,默认支持(兼容旧数据为 Max
logger.debug(`Account ${account.name} has invalid subscriptionInfo, assuming Max`)
}
}
// 没有订阅信息的账号,默认当作支持(兼容旧数据)
} }
// 检查是否被限流 // 检查是否被限流
@@ -415,26 +330,37 @@ class UnifiedClaudeScheduler {
) { ) {
// 检查是否可调度 // 检查是否可调度
// 检查模型支持 // 检查模型支持(如果有请求的模型)
if (!this._isModelSupportedByAccount(account, 'claude-console', requestedModel)) { if (requestedModel && account.supportedModels) {
continue // 兼容旧格式(数组)和新格式(对象)
} if (Array.isArray(account.supportedModels)) {
// 旧格式:数组
// 主动触发一次额度检查,确保状态即时生效 if (
try { account.supportedModels.length > 0 &&
await claudeConsoleAccountService.checkQuotaUsage(account.id) !account.supportedModels.includes(requestedModel)
} catch (e) { ) {
logger.warn( logger.info(
`Failed to check quota for Claude Console account ${account.name}: ${e.message}` `🚫 Claude Console account ${account.name} does not support model ${requestedModel}`
) )
// 继续处理该账号 continue
}
} else if (typeof account.supportedModels === 'object') {
// 新格式:映射表
if (
Object.keys(account.supportedModels).length > 0 &&
!claudeConsoleAccountService.isModelSupported(account.supportedModels, requestedModel)
) {
logger.info(
`🚫 Claude Console account ${account.name} does not support model ${requestedModel}`
)
continue
}
}
} }
// 检查是否被限流 // 检查是否被限流
const isRateLimited = await claudeConsoleAccountService.isAccountRateLimited(account.id) const isRateLimited = await claudeConsoleAccountService.isAccountRateLimited(account.id)
const isQuotaExceeded = await claudeConsoleAccountService.isAccountQuotaExceeded(account.id) if (!isRateLimited) {
if (!isRateLimited && !isQuotaExceeded) {
availableAccounts.push({ availableAccounts.push({
...account, ...account,
accountId: account.id, accountId: account.id,
@@ -446,12 +372,7 @@ class UnifiedClaudeScheduler {
`✅ Added Claude Console account to available pool: ${account.name} (priority: ${account.priority})` `✅ Added Claude Console account to available pool: ${account.name} (priority: ${account.priority})`
) )
} else { } else {
if (isRateLimited) { logger.warn(`⚠️ Claude Console account ${account.name} is rate limited`)
logger.warn(`⚠️ Claude Console account ${account.name} is rate limited`)
}
if (isQuotaExceeded) {
logger.warn(`💰 Claude Console account ${account.name} quota exceeded`)
}
} }
} else { } else {
logger.info( logger.info(
@@ -518,7 +439,7 @@ class UnifiedClaudeScheduler {
} }
// 🔍 检查账户是否可用 // 🔍 检查账户是否可用
async _isAccountAvailable(accountId, accountType, requestedModel = null) { async _isAccountAvailable(accountId, accountType) {
try { try {
if (accountType === 'claude-official') { if (accountType === 'claude-official') {
const account = await redis.getClaudeAccount(accountId) const account = await redis.getClaudeAccount(accountId)
@@ -535,34 +456,10 @@ class UnifiedClaudeScheduler {
logger.info(`🚫 Account ${accountId} is not schedulable`) logger.info(`🚫 Account ${accountId} is not schedulable`)
return false return false
} }
return !(await claudeAccountService.isAccountRateLimited(accountId))
// 检查模型兼容性
if (
!this._isModelSupportedByAccount(
account,
'claude-official',
requestedModel,
'in session check'
)
) {
return false
}
// 检查是否限流或过载
const isRateLimited = await claudeAccountService.isAccountRateLimited(accountId)
const isOverloaded = await claudeAccountService.isAccountOverloaded(accountId)
return !isRateLimited && !isOverloaded
} else if (accountType === 'claude-console') { } else if (accountType === 'claude-console') {
const account = await claudeConsoleAccountService.getAccount(accountId) const account = await claudeConsoleAccountService.getAccount(accountId)
if (!account || !account.isActive) { if (!account || !account.isActive || account.status !== 'active') {
return false
}
// 检查账户状态
if (
account.status !== 'active' &&
account.status !== 'unauthorized' &&
account.status !== 'overloaded'
) {
return false return false
} }
// 检查是否可调度 // 检查是否可调度
@@ -570,41 +467,7 @@ class UnifiedClaudeScheduler {
logger.info(`🚫 Claude Console account ${accountId} is not schedulable`) logger.info(`🚫 Claude Console account ${accountId} is not schedulable`)
return false return false
} }
// 检查模型支持 return !(await claudeConsoleAccountService.isAccountRateLimited(accountId))
if (
!this._isModelSupportedByAccount(
account,
'claude-console',
requestedModel,
'in session check'
)
) {
return false
}
// 检查是否超额
try {
await claudeConsoleAccountService.checkQuotaUsage(accountId)
} catch (e) {
logger.warn(`Failed to check quota for Claude Console account ${accountId}: ${e.message}`)
// 继续处理
}
// 检查是否被限流
if (await claudeConsoleAccountService.isAccountRateLimited(accountId)) {
return false
}
if (await claudeConsoleAccountService.isAccountQuotaExceeded(accountId)) {
return false
}
// 检查是否未授权401错误
if (account.status === 'unauthorized') {
return false
}
// 检查是否过载529错误
if (await claudeConsoleAccountService.isAccountOverloaded(accountId)) {
return false
}
return true
} else if (accountType === 'bedrock') { } else if (accountType === 'bedrock') {
const accountResult = await bedrockAccountService.getAccount(accountId) const accountResult = await bedrockAccountService.getAccount(accountId)
if (!accountResult.success || !accountResult.data.isActive) { if (!accountResult.success || !accountResult.data.isActive) {
@@ -753,32 +616,6 @@ class UnifiedClaudeScheduler {
} }
} }
// 🚫 标记账户为被封锁状态403错误
async markAccountBlocked(accountId, accountType, sessionHash = null) {
try {
// 只处理claude-official类型的账户不处理claude-console和gemini
if (accountType === 'claude-official') {
await claudeAccountService.markAccountBlocked(accountId, sessionHash)
// 删除会话映射
if (sessionHash) {
await this._deleteSessionMapping(sessionHash)
}
logger.warn(`🚫 Account ${accountId} marked as blocked due to 403 error`)
} else {
logger.info(
` Skipping blocked marking for non-Claude OAuth account: ${accountId} (${accountType})`
)
}
return { success: true }
} catch (error) {
logger.error(`❌ Failed to mark account as blocked: ${accountId} (${accountType})`, error)
throw error
}
}
// 🚫 标记Claude Console账户为封锁状态模型不支持 // 🚫 标记Claude Console账户为封锁状态模型不支持
async blockConsoleAccount(accountId, reason) { async blockConsoleAccount(accountId, reason) {
try { try {
@@ -810,12 +647,9 @@ class UnifiedClaudeScheduler {
if (memberIds.includes(mappedAccount.accountId)) { if (memberIds.includes(mappedAccount.accountId)) {
const isAvailable = await this._isAccountAvailable( const isAvailable = await this._isAccountAvailable(
mappedAccount.accountId, mappedAccount.accountId,
mappedAccount.accountType, mappedAccount.accountType
requestedModel
) )
if (isAvailable) { if (isAvailable) {
// 🚀 智能会话续期剩余时间少于14天时自动续期到15天
await redis.extendSessionAccountMappingTTL(sessionHash)
logger.info( logger.info(
`🎯 Using sticky session account from group: ${mappedAccount.accountId} (${mappedAccount.accountType}) for session ${sessionHash}` `🎯 Using sticky session account from group: ${mappedAccount.accountId} (${mappedAccount.accountType}) for session ${sessionHash}`
) )
@@ -876,9 +710,19 @@ class UnifiedClaudeScheduler {
: account.status === 'active' : account.status === 'active'
if (isActive && status && this._isSchedulable(account.schedulable)) { if (isActive && status && this._isSchedulable(account.schedulable)) {
// 检查模型支持 // 检查模型支持Console账户
if (!this._isModelSupportedByAccount(account, accountType, requestedModel, 'in group')) { if (
continue accountType === 'claude-console' &&
requestedModel &&
account.supportedModels &&
account.supportedModels.length > 0
) {
if (!account.supportedModels.includes(requestedModel)) {
logger.info(
`🚫 Account ${account.name} in group does not support model ${requestedModel}`
)
continue
}
} }
// 检查是否被限流 // 检查是否被限流

View File

@@ -61,8 +61,6 @@ class UnifiedGeminiScheduler {
mappedAccount.accountType mappedAccount.accountType
) )
if (isAvailable) { if (isAvailable) {
// 🚀 智能会话续期剩余时间少于14天时自动续期到15天
await redis.extendSessionAccountMappingTTL(sessionHash)
logger.info( logger.info(
`🎯 Using sticky session account: ${mappedAccount.accountId} (${mappedAccount.accountType}) for session ${sessionHash}` `🎯 Using sticky session account: ${mappedAccount.accountId} (${mappedAccount.accountType}) for session ${sessionHash}`
) )
@@ -384,8 +382,6 @@ class UnifiedGeminiScheduler {
mappedAccount.accountType mappedAccount.accountType
) )
if (isAvailable) { if (isAvailable) {
// 🚀 智能会话续期剩余时间少于14天时自动续期到15天
await redis.extendSessionAccountMappingTTL(sessionHash)
logger.info( logger.info(
`🎯 Using sticky session account from group: ${mappedAccount.accountId} (${mappedAccount.accountType}) for session ${sessionHash}` `🎯 Using sticky session account from group: ${mappedAccount.accountId} (${mappedAccount.accountType}) for session ${sessionHash}`
) )

View File

@@ -34,11 +34,7 @@ class UnifiedOpenAIScheduler {
// 普通专属账户 // 普通专属账户
const boundAccount = await openaiAccountService.getAccount(apiKeyData.openaiAccountId) const boundAccount = await openaiAccountService.getAccount(apiKeyData.openaiAccountId)
if ( if (boundAccount && boundAccount.isActive === 'true' && boundAccount.status !== 'error') {
boundAccount &&
(boundAccount.isActive === true || boundAccount.isActive === 'true') &&
boundAccount.status !== 'error'
) {
// 检查是否被限流 // 检查是否被限流
const isRateLimited = await this.isAccountRateLimited(boundAccount.id) const isRateLimited = await this.isAccountRateLimited(boundAccount.id)
if (isRateLimited) { if (isRateLimited) {
@@ -90,8 +86,6 @@ class UnifiedOpenAIScheduler {
mappedAccount.accountType mappedAccount.accountType
) )
if (isAvailable) { if (isAvailable) {
// 🚀 智能会话续期剩余时间少于14天时自动续期到15天
await redis.extendSessionAccountMappingTTL(sessionHash)
logger.info( logger.info(
`🎯 Using sticky session account: ${mappedAccount.accountId} (${mappedAccount.accountType}) for session ${sessionHash}` `🎯 Using sticky session account: ${mappedAccount.accountId} (${mappedAccount.accountType}) for session ${sessionHash}`
) )
@@ -169,36 +163,22 @@ class UnifiedOpenAIScheduler {
// 获取所有OpenAI账户共享池 // 获取所有OpenAI账户共享池
const openaiAccounts = await openaiAccountService.getAllAccounts() const openaiAccounts = await openaiAccountService.getAllAccounts()
for (let account of openaiAccounts) { for (const account of openaiAccounts) {
if ( if (
account.isActive && account.isActive === 'true' &&
account.status !== 'error' && account.status !== 'error' &&
(account.accountType === 'shared' || !account.accountType) && // 兼容旧数据 (account.accountType === 'shared' || !account.accountType) && // 兼容旧数据
this._isSchedulable(account.schedulable) this._isSchedulable(account.schedulable)
) { ) {
// 检查是否可调度 // 检查是否可调度
// 检查token是否过期并自动刷新 // 检查token是否过期
const isExpired = openaiAccountService.isTokenExpired(account) const isExpired = openaiAccountService.isTokenExpired(account)
if (isExpired) { if (isExpired && !account.refreshToken) {
if (!account.refreshToken) { logger.warn(
logger.warn( `⚠️ OpenAI account ${account.name} token expired and no refresh token available`
`⚠️ OpenAI account ${account.name} token expired and no refresh token available` )
) continue
continue
}
// 自动刷新过期的 token
try {
logger.info(`🔄 Auto-refreshing expired token for OpenAI account ${account.name}`)
await openaiAccountService.refreshAccountToken(account.id)
// 重新获取更新后的账户信息
account = await openaiAccountService.getAccount(account.id)
logger.info(`✅ Token refreshed successfully for ${account.name}`)
} catch (refreshError) {
logger.error(`❌ Failed to refresh token for ${account.name}:`, refreshError.message)
continue // 刷新失败,跳过此账户
}
} }
// 检查模型支持仅在明确设置了supportedModels且不为空时才检查 // 检查模型支持仅在明确设置了supportedModels且不为空时才检查
@@ -253,7 +233,7 @@ class UnifiedOpenAIScheduler {
try { try {
if (accountType === 'openai') { if (accountType === 'openai') {
const account = await openaiAccountService.getAccount(accountId) const account = await openaiAccountService.getAccount(accountId)
if (!account || !account.isActive || account.status === 'error') { if (!account || account.isActive !== 'true' || account.status === 'error') {
return false return false
} }
// 检查是否可调度 // 检查是否可调度
@@ -303,10 +283,10 @@ class UnifiedOpenAIScheduler {
} }
// 🚫 标记账户为限流状态 // 🚫 标记账户为限流状态
async markAccountRateLimited(accountId, accountType, sessionHash = null, resetsInSeconds = null) { async markAccountRateLimited(accountId, accountType, sessionHash = null) {
try { try {
if (accountType === 'openai') { if (accountType === 'openai') {
await openaiAccountService.setAccountRateLimited(accountId, true, resetsInSeconds) await openaiAccountService.setAccountRateLimited(accountId, true)
} }
// 删除会话映射 // 删除会话映射
@@ -349,30 +329,12 @@ class UnifiedOpenAIScheduler {
return false return false
} }
if (account.rateLimitStatus === 'limited') { if (account.rateLimitStatus === 'limited' && account.rateLimitedAt) {
// 如果有具体的重置时间,使用它 const limitedAt = new Date(account.rateLimitedAt).getTime()
if (account.rateLimitResetAt) { const now = Date.now()
const resetTime = new Date(account.rateLimitResetAt).getTime() const limitDuration = 60 * 60 * 1000 // 1小时
const now = Date.now()
const isStillLimited = now < resetTime
// 如果已经过了重置时间,自动清除限流状态 return now < limitedAt + limitDuration
if (!isStillLimited) {
logger.info(`✅ Auto-clearing rate limit for account ${accountId} (reset time reached)`)
await openaiAccountService.setAccountRateLimited(accountId, false)
return false
}
return isStillLimited
}
// 如果没有具体的重置时间使用默认的1小时
if (account.rateLimitedAt) {
const limitedAt = new Date(account.rateLimitedAt).getTime()
const now = Date.now()
const limitDuration = 60 * 60 * 1000 // 1小时
return now < limitedAt + limitDuration
}
} }
return false return false
} catch (error) { } catch (error) {
@@ -408,8 +370,6 @@ class UnifiedOpenAIScheduler {
mappedAccount.accountType mappedAccount.accountType
) )
if (isAvailable) { if (isAvailable) {
// 🚀 智能会话续期剩余时间少于14天时自动续期到15天
await redis.extendSessionAccountMappingTTL(sessionHash)
logger.info( logger.info(
`🎯 Using sticky session account from group: ${mappedAccount.accountId} (${mappedAccount.accountType})` `🎯 Using sticky session account from group: ${mappedAccount.accountId} (${mappedAccount.accountType})`
) )
@@ -435,7 +395,7 @@ class UnifiedOpenAIScheduler {
const account = await openaiAccountService.getAccount(memberId) const account = await openaiAccountService.getAccount(memberId)
if ( if (
account && account &&
account.isActive && account.isActive === 'true' &&
account.status !== 'error' && account.status !== 'error' &&
this._isSchedulable(account.schedulable) this._isSchedulable(account.schedulable)
) { ) {

View File

@@ -1,593 +0,0 @@
const redis = require('../models/redis')
const crypto = require('crypto')
const logger = require('../utils/logger')
const config = require('../../config/config')
class UserService {
constructor() {
this.userPrefix = 'user:'
this.usernamePrefix = 'username:'
this.userSessionPrefix = 'user_session:'
}
// 🔑 生成用户ID
generateUserId() {
return crypto.randomBytes(16).toString('hex')
}
// 🔑 生成会话Token
generateSessionToken() {
return crypto.randomBytes(32).toString('hex')
}
// 👤 创建或更新用户
async createOrUpdateUser(userData) {
try {
const {
username,
email,
displayName,
firstName,
lastName,
role = config.userManagement.defaultUserRole,
isActive = true
} = userData
// 检查用户是否已存在
let user = await this.getUserByUsername(username)
const isNewUser = !user
if (isNewUser) {
const userId = this.generateUserId()
user = {
id: userId,
username,
email,
displayName,
firstName,
lastName,
role,
isActive,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
lastLoginAt: null,
apiKeyCount: 0,
totalUsage: {
requests: 0,
inputTokens: 0,
outputTokens: 0,
totalCost: 0
}
}
} else {
// 更新现有用户信息
user = {
...user,
email,
displayName,
firstName,
lastName,
updatedAt: new Date().toISOString()
}
}
// 保存用户信息
await redis.set(`${this.userPrefix}${user.id}`, JSON.stringify(user))
await redis.set(`${this.usernamePrefix}${username}`, user.id)
// 如果是新用户尝试转移匹配的API Keys
if (isNewUser) {
await this.transferMatchingApiKeys(user)
}
logger.info(`📝 ${isNewUser ? 'Created' : 'Updated'} user: ${username} (${user.id})`)
return user
} catch (error) {
logger.error('❌ Error creating/updating user:', error)
throw error
}
}
// 👤 通过用户名获取用户
async getUserByUsername(username) {
try {
const userId = await redis.get(`${this.usernamePrefix}${username}`)
if (!userId) {
return null
}
const userData = await redis.get(`${this.userPrefix}${userId}`)
return userData ? JSON.parse(userData) : null
} catch (error) {
logger.error('❌ Error getting user by username:', error)
throw error
}
}
// 👤 通过ID获取用户
async getUserById(userId, calculateUsage = true) {
try {
const userData = await redis.get(`${this.userPrefix}${userId}`)
if (!userData) {
return null
}
const user = JSON.parse(userData)
// Calculate totalUsage by aggregating user's API keys usage (if requested)
if (calculateUsage) {
try {
const usageStats = await this.calculateUserUsageStats(userId)
user.totalUsage = usageStats.totalUsage
user.apiKeyCount = usageStats.apiKeyCount
} catch (error) {
logger.error('❌ Error calculating user usage stats:', error)
// Fallback to stored values if calculation fails
user.totalUsage = user.totalUsage || {
requests: 0,
inputTokens: 0,
outputTokens: 0,
totalCost: 0
}
user.apiKeyCount = user.apiKeyCount || 0
}
}
return user
} catch (error) {
logger.error('❌ Error getting user by ID:', error)
throw error
}
}
// 📊 计算用户使用统计通过聚合API Keys
async calculateUserUsageStats(userId) {
try {
// Use the existing apiKeyService method which already includes usage stats
const apiKeyService = require('./apiKeyService')
const userApiKeys = await apiKeyService.getUserApiKeys(userId, true) // Include deleted keys for stats
const totalUsage = {
requests: 0,
inputTokens: 0,
outputTokens: 0,
totalCost: 0
}
for (const apiKey of userApiKeys) {
if (apiKey.usage && apiKey.usage.total) {
totalUsage.requests += apiKey.usage.total.requests || 0
totalUsage.inputTokens += apiKey.usage.total.inputTokens || 0
totalUsage.outputTokens += apiKey.usage.total.outputTokens || 0
totalUsage.totalCost += apiKey.totalCost || 0
}
}
logger.debug(
`📊 Calculated user ${userId} usage: ${totalUsage.requests} requests, ${totalUsage.inputTokens} input tokens, $${totalUsage.totalCost.toFixed(4)} total cost from ${userApiKeys.length} API keys`
)
// Count only non-deleted API keys for the user's active count
const activeApiKeyCount = userApiKeys.filter((key) => key.isDeleted !== 'true').length
return {
totalUsage,
apiKeyCount: activeApiKeyCount
}
} catch (error) {
logger.error('❌ Error calculating user usage stats:', error)
return {
totalUsage: {
requests: 0,
inputTokens: 0,
outputTokens: 0,
totalCost: 0
},
apiKeyCount: 0
}
}
}
// 📋 获取所有用户列表(管理员功能)
async getAllUsers(options = {}) {
try {
const client = redis.getClientSafe()
const { page = 1, limit = 20, role, isActive } = options
const pattern = `${this.userPrefix}*`
const keys = await client.keys(pattern)
const users = []
for (const key of keys) {
const userData = await client.get(key)
if (userData) {
const user = JSON.parse(userData)
// 应用过滤条件
if (role && user.role !== role) {
continue
}
if (typeof isActive === 'boolean' && user.isActive !== isActive) {
continue
}
// Calculate dynamic usage stats for each user
try {
const usageStats = await this.calculateUserUsageStats(user.id)
user.totalUsage = usageStats.totalUsage
user.apiKeyCount = usageStats.apiKeyCount
} catch (error) {
logger.error(`❌ Error calculating usage for user ${user.id}:`, error)
// Fallback to stored values
user.totalUsage = user.totalUsage || {
requests: 0,
inputTokens: 0,
outputTokens: 0,
totalCost: 0
}
user.apiKeyCount = user.apiKeyCount || 0
}
users.push(user)
}
}
// 排序和分页
users.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
const startIndex = (page - 1) * limit
const endIndex = startIndex + limit
const paginatedUsers = users.slice(startIndex, endIndex)
return {
users: paginatedUsers,
total: users.length,
page,
limit,
totalPages: Math.ceil(users.length / limit)
}
} catch (error) {
logger.error('❌ Error getting all users:', error)
throw error
}
}
// 🔄 更新用户状态
async updateUserStatus(userId, isActive) {
try {
const user = await this.getUserById(userId, false) // Skip usage calculation
if (!user) {
throw new Error('User not found')
}
user.isActive = isActive
user.updatedAt = new Date().toISOString()
await redis.set(`${this.userPrefix}${userId}`, JSON.stringify(user))
logger.info(`🔄 Updated user status: ${user.username} -> ${isActive ? 'active' : 'disabled'}`)
// 如果禁用用户删除所有会话并禁用其所有API Keys
if (!isActive) {
await this.invalidateUserSessions(userId)
// Disable all user's API keys when user is disabled
try {
const apiKeyService = require('./apiKeyService')
const result = await apiKeyService.disableUserApiKeys(userId)
logger.info(`🔑 Disabled ${result.count} API keys for disabled user: ${user.username}`)
} catch (error) {
logger.error('❌ Error disabling user API keys during user disable:', error)
}
}
return user
} catch (error) {
logger.error('❌ Error updating user status:', error)
throw error
}
}
// 🔄 更新用户角色
async updateUserRole(userId, role) {
try {
const user = await this.getUserById(userId, false) // Skip usage calculation
if (!user) {
throw new Error('User not found')
}
user.role = role
user.updatedAt = new Date().toISOString()
await redis.set(`${this.userPrefix}${userId}`, JSON.stringify(user))
logger.info(`🔄 Updated user role: ${user.username} -> ${role}`)
return user
} catch (error) {
logger.error('❌ Error updating user role:', error)
throw error
}
}
// 📊 更新用户API Key数量 (已废弃,现在通过聚合计算)
async updateUserApiKeyCount(userId, _count) {
// This method is deprecated since apiKeyCount is now calculated dynamically
// in getUserById by aggregating the user's API keys
logger.debug(
`📊 updateUserApiKeyCount called for ${userId} but is now deprecated (count auto-calculated)`
)
}
// 📝 记录用户登录
async recordUserLogin(userId) {
try {
const user = await this.getUserById(userId, false) // Skip usage calculation
if (!user) {
return
}
user.lastLoginAt = new Date().toISOString()
await redis.set(`${this.userPrefix}${userId}`, JSON.stringify(user))
} catch (error) {
logger.error('❌ Error recording user login:', error)
}
}
// 🎫 创建用户会话
async createUserSession(userId, sessionData = {}) {
try {
const sessionToken = this.generateSessionToken()
const session = {
token: sessionToken,
userId,
createdAt: new Date().toISOString(),
expiresAt: new Date(Date.now() + config.userManagement.userSessionTimeout).toISOString(),
...sessionData
}
const ttl = Math.floor(config.userManagement.userSessionTimeout / 1000)
await redis.setex(`${this.userSessionPrefix}${sessionToken}`, ttl, JSON.stringify(session))
logger.info(`🎫 Created session for user: ${userId}`)
return sessionToken
} catch (error) {
logger.error('❌ Error creating user session:', error)
throw error
}
}
// 🎫 验证用户会话
async validateUserSession(sessionToken) {
try {
const sessionData = await redis.get(`${this.userSessionPrefix}${sessionToken}`)
if (!sessionData) {
return null
}
const session = JSON.parse(sessionData)
// 检查会话是否过期
if (new Date() > new Date(session.expiresAt)) {
await this.invalidateUserSession(sessionToken)
return null
}
// 获取用户信息
const user = await this.getUserById(session.userId, false) // Skip usage calculation for validation
if (!user || !user.isActive) {
await this.invalidateUserSession(sessionToken)
return null
}
return { session, user }
} catch (error) {
logger.error('❌ Error validating user session:', error)
return null
}
}
// 🚫 使用户会话失效
async invalidateUserSession(sessionToken) {
try {
await redis.del(`${this.userSessionPrefix}${sessionToken}`)
logger.info(`🚫 Invalidated session: ${sessionToken}`)
} catch (error) {
logger.error('❌ Error invalidating user session:', error)
}
}
// 🚫 使用户所有会话失效
async invalidateUserSessions(userId) {
try {
const client = redis.getClientSafe()
const pattern = `${this.userSessionPrefix}*`
const keys = await client.keys(pattern)
for (const key of keys) {
const sessionData = await client.get(key)
if (sessionData) {
const session = JSON.parse(sessionData)
if (session.userId === userId) {
await client.del(key)
}
}
}
logger.info(`🚫 Invalidated all sessions for user: ${userId}`)
} catch (error) {
logger.error('❌ Error invalidating user sessions:', error)
}
}
// 🗑️ 删除用户(软删除,标记为不活跃)
async deleteUser(userId) {
try {
const user = await this.getUserById(userId, false) // Skip usage calculation
if (!user) {
throw new Error('User not found')
}
// 软删除:标记为不活跃并添加删除时间戳
user.isActive = false
user.deletedAt = new Date().toISOString()
user.updatedAt = new Date().toISOString()
await redis.set(`${this.userPrefix}${userId}`, JSON.stringify(user))
// 删除所有会话
await this.invalidateUserSessions(userId)
// Disable all user's API keys when user is deleted
try {
const apiKeyService = require('./apiKeyService')
const result = await apiKeyService.disableUserApiKeys(userId)
logger.info(`🔑 Disabled ${result.count} API keys for deleted user: ${user.username}`)
} catch (error) {
logger.error('❌ Error disabling user API keys during user deletion:', error)
}
logger.info(`🗑️ Soft deleted user: ${user.username} (${userId})`)
return user
} catch (error) {
logger.error('❌ Error deleting user:', error)
throw error
}
}
// 📊 获取用户统计信息
async getUserStats() {
try {
const client = redis.getClientSafe()
const pattern = `${this.userPrefix}*`
const keys = await client.keys(pattern)
const stats = {
totalUsers: 0,
activeUsers: 0,
adminUsers: 0,
regularUsers: 0,
totalApiKeys: 0,
totalUsage: {
requests: 0,
inputTokens: 0,
outputTokens: 0,
totalCost: 0
}
}
for (const key of keys) {
const userData = await client.get(key)
if (userData) {
const user = JSON.parse(userData)
stats.totalUsers++
if (user.isActive) {
stats.activeUsers++
}
if (user.role === 'admin') {
stats.adminUsers++
} else {
stats.regularUsers++
}
// Calculate dynamic usage stats for each user
try {
const usageStats = await this.calculateUserUsageStats(user.id)
stats.totalApiKeys += usageStats.apiKeyCount
stats.totalUsage.requests += usageStats.totalUsage.requests
stats.totalUsage.inputTokens += usageStats.totalUsage.inputTokens
stats.totalUsage.outputTokens += usageStats.totalUsage.outputTokens
stats.totalUsage.totalCost += usageStats.totalUsage.totalCost
} catch (error) {
logger.error(`❌ Error calculating usage for user ${user.id} in stats:`, error)
// Fallback to stored values if calculation fails
stats.totalApiKeys += user.apiKeyCount || 0
stats.totalUsage.requests += user.totalUsage?.requests || 0
stats.totalUsage.inputTokens += user.totalUsage?.inputTokens || 0
stats.totalUsage.outputTokens += user.totalUsage?.outputTokens || 0
stats.totalUsage.totalCost += user.totalUsage?.totalCost || 0
}
}
}
return stats
} catch (error) {
logger.error('❌ Error getting user stats:', error)
throw error
}
}
// 🔄 转移匹配的API Keys给新用户
async transferMatchingApiKeys(user) {
try {
const apiKeyService = require('./apiKeyService')
const { displayName, username, email } = user
// 获取所有API Keys
const allApiKeys = await apiKeyService.getAllApiKeys()
// 找到没有用户ID的API Keys即由Admin创建的
const unownedApiKeys = allApiKeys.filter((key) => !key.userId || key.userId === '')
if (unownedApiKeys.length === 0) {
logger.debug(`📝 No unowned API keys found for potential transfer to user: ${username}`)
return
}
// 构建匹配字符串数组只考虑displayName、username、email去除空值和重复值
const matchStrings = new Set()
if (displayName) {
matchStrings.add(displayName.toLowerCase().trim())
}
if (username) {
matchStrings.add(username.toLowerCase().trim())
}
if (email) {
matchStrings.add(email.toLowerCase().trim())
}
const matchingKeys = []
// 查找名称匹配的API Keys只进行完全匹配
for (const apiKey of unownedApiKeys) {
const keyName = apiKey.name ? apiKey.name.toLowerCase().trim() : ''
// 检查API Key名称是否与用户信息完全匹配
for (const matchString of matchStrings) {
if (keyName === matchString) {
matchingKeys.push(apiKey)
break // 找到匹配后跳出内层循环
}
}
}
// 转移匹配的API Keys
let transferredCount = 0
for (const apiKey of matchingKeys) {
try {
await apiKeyService.updateApiKey(apiKey.id, {
userId: user.id,
userUsername: user.username,
createdBy: user.username
})
transferredCount++
logger.info(`🔄 Transferred API key "${apiKey.name}" (${apiKey.id}) to user: ${username}`)
} catch (error) {
logger.error(`❌ Failed to transfer API key ${apiKey.id} to user ${username}:`, error)
}
}
if (transferredCount > 0) {
logger.success(
`🎉 Successfully transferred ${transferredCount} API key(s) to new user: ${username} (${displayName})`
)
} else if (matchingKeys.length === 0) {
logger.debug(`📝 No matching API keys found for user: ${username} (${displayName})`)
}
} catch (error) {
logger.error('❌ Error transferring matching API keys:', error)
// Don't throw error to prevent blocking user creation
}
}
}
module.exports = new UserService()

View File

@@ -56,27 +56,15 @@ class WebhookConfigService {
// 验证平台配置 // 验证平台配置
if (config.platforms) { if (config.platforms) {
const validPlatforms = [ const validPlatforms = ['wechat_work', 'dingtalk', 'feishu', 'slack', 'discord', 'custom']
'wechat_work',
'dingtalk',
'feishu',
'slack',
'discord',
'custom',
'bark',
'smtp'
]
for (const platform of config.platforms) { for (const platform of config.platforms) {
if (!validPlatforms.includes(platform.type)) { if (!validPlatforms.includes(platform.type)) {
throw new Error(`不支持的平台类型: ${platform.type}`) throw new Error(`不支持的平台类型: ${platform.type}`)
} }
// Bark和SMTP平台不使用标准URL if (!platform.url || !this.isValidUrl(platform.url)) {
if (platform.type !== 'bark' && platform.type !== 'smtp') { throw new Error(`无效的webhook URL: ${platform.url}`)
if (!platform.url || !this.isValidUrl(platform.url)) {
throw new Error(`无效的webhook URL: ${platform.url}`)
}
} }
// 验证平台特定的配置 // 验证平台特定的配置
@@ -120,133 +108,6 @@ class WebhookConfigService {
case 'custom': case 'custom':
// 自定义webhook用户自行负责格式 // 自定义webhook用户自行负责格式
break break
case 'bark':
// 验证设备密钥
if (!platform.deviceKey) {
throw new Error('Bark平台必须提供设备密钥')
}
// 验证设备密钥格式通常是22-24位字符
if (platform.deviceKey.length < 20 || platform.deviceKey.length > 30) {
logger.warn('⚠️ Bark设备密钥长度可能不正确请检查是否完整复制')
}
// 验证服务器URL如果提供
if (platform.serverUrl) {
if (!this.isValidUrl(platform.serverUrl)) {
throw new Error('Bark服务器URL格式无效')
}
if (!platform.serverUrl.includes('/push')) {
logger.warn('⚠️ Bark服务器URL应该以/push结尾')
}
}
// 验证声音参数(如果提供)
if (platform.sound) {
const validSounds = [
'default',
'alarm',
'anticipate',
'bell',
'birdsong',
'bloom',
'calypso',
'chime',
'choo',
'descent',
'electronic',
'fanfare',
'glass',
'gotosleep',
'healthnotification',
'horn',
'ladder',
'mailsent',
'minuet',
'multiwayinvitation',
'newmail',
'newsflash',
'noir',
'paymentsuccess',
'shake',
'sherwoodforest',
'silence',
'spell',
'suspense',
'telegraph',
'tiptoes',
'typewriters',
'update',
'alert'
]
if (!validSounds.includes(platform.sound)) {
logger.warn(`⚠️ 未知的Bark声音: ${platform.sound}`)
}
}
// 验证级别参数
if (platform.level) {
const validLevels = ['active', 'timeSensitive', 'passive', 'critical']
if (!validLevels.includes(platform.level)) {
throw new Error(`无效的Bark通知级别: ${platform.level}`)
}
}
// 验证图标URL如果提供
if (platform.icon && !this.isValidUrl(platform.icon)) {
logger.warn('⚠️ Bark图标URL格式可能不正确')
}
// 验证点击跳转URL如果提供
if (platform.clickUrl && !this.isValidUrl(platform.clickUrl)) {
logger.warn('⚠️ Bark点击跳转URL格式可能不正确')
}
break
case 'smtp': {
// 验证SMTP必需配置
if (!platform.host) {
throw new Error('SMTP平台必须提供主机地址')
}
if (!platform.user) {
throw new Error('SMTP平台必须提供用户名')
}
if (!platform.pass) {
throw new Error('SMTP平台必须提供密码')
}
if (!platform.to) {
throw new Error('SMTP平台必须提供接收邮箱')
}
// 验证端口
if (platform.port && (platform.port < 1 || platform.port > 65535)) {
throw new Error('SMTP端口必须在1-65535之间')
}
// 验证邮箱格式
// 支持两种格式1. 纯邮箱 user@domain.com 2. 带名称 Name <user@domain.com>
const simpleEmailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
// 验证接收邮箱
const toEmails = Array.isArray(platform.to) ? platform.to : [platform.to]
for (const email of toEmails) {
// 提取实际邮箱地址(如果是 Name <email> 格式)
const actualEmail = email.includes('<') ? email.match(/<([^>]+)>/)?.[1] : email
if (!actualEmail || !simpleEmailRegex.test(actualEmail)) {
throw new Error(`无效的接收邮箱格式: ${email}`)
}
}
// 验证发送邮箱(支持 Name <email> 格式)
if (platform.from) {
const actualFromEmail = platform.from.includes('<')
? platform.from.match(/<([^>]+)>/)?.[1]
: platform.from
if (!actualFromEmail || !simpleEmailRegex.test(actualFromEmail)) {
throw new Error(`无效的发送邮箱格式: ${platform.from}`)
}
}
break
}
} }
} }

295
src/services/webhookService.js Executable file → Normal file
View File

@@ -1,10 +1,7 @@
const axios = require('axios') const axios = require('axios')
const crypto = require('crypto') const crypto = require('crypto')
const nodemailer = require('nodemailer')
const logger = require('../utils/logger') const logger = require('../utils/logger')
const webhookConfigService = require('./webhookConfigService') const webhookConfigService = require('./webhookConfigService')
const { getISOStringWithTimezone } = require('../utils/dateHelper')
const appConfig = require('../../config/config')
class WebhookService { class WebhookService {
constructor() { constructor() {
@@ -14,11 +11,8 @@ class WebhookService {
feishu: this.sendToFeishu.bind(this), feishu: this.sendToFeishu.bind(this),
slack: this.sendToSlack.bind(this), slack: this.sendToSlack.bind(this),
discord: this.sendToDiscord.bind(this), discord: this.sendToDiscord.bind(this),
custom: this.sendToCustom.bind(this), custom: this.sendToCustom.bind(this)
bark: this.sendToBark.bind(this),
smtp: this.sendToSMTP.bind(this)
} }
this.timezone = appConfig.system.timezone || 'Asia/Shanghai'
} }
/** /**
@@ -211,85 +205,13 @@ class WebhookService {
const payload = { const payload = {
type, type,
service: 'claude-relay-service', service: 'claude-relay-service',
timestamp: getISOStringWithTimezone(new Date()), timestamp: new Date().toISOString(),
data data
} }
await this.sendHttpRequest(platform.url, payload, platform.timeout || 10000) await this.sendHttpRequest(platform.url, payload, platform.timeout || 10000)
} }
/**
* Bark webhook
*/
async sendToBark(platform, type, data) {
const payload = {
device_key: platform.deviceKey,
title: this.getNotificationTitle(type),
body: this.formatMessageForBark(type, data),
level: platform.level || this.getBarkLevel(type),
sound: platform.sound || this.getBarkSound(type),
group: platform.group || 'claude-relay',
badge: 1
}
// 添加可选参数
if (platform.icon) {
payload.icon = platform.icon
}
if (platform.clickUrl) {
payload.url = platform.clickUrl
}
const url = platform.serverUrl || 'https://api.day.app/push'
await this.sendHttpRequest(url, payload, platform.timeout || 10000)
}
/**
* SMTP邮件通知
*/
async sendToSMTP(platform, type, data) {
try {
// 创建SMTP传输器
const transporter = nodemailer.createTransport({
host: platform.host,
port: platform.port || 587,
secure: platform.secure || false, // true for 465, false for other ports
auth: {
user: platform.user,
pass: platform.pass
},
// 可选的TLS配置
tls: platform.ignoreTLS ? { rejectUnauthorized: false } : undefined,
// 连接超时
connectionTimeout: platform.timeout || 10000
})
// 构造邮件内容
const subject = this.getNotificationTitle(type)
const htmlContent = this.formatMessageForEmail(type, data)
const textContent = this.formatMessageForEmailText(type, data)
// 邮件选项
const mailOptions = {
from: platform.from || platform.user, // 发送者
to: platform.to, // 接收者(必填)
subject: `[Claude Relay Service] ${subject}`,
text: textContent,
html: htmlContent
}
// 发送邮件
const info = await transporter.sendMail(mailOptions)
logger.info(`✅ 邮件发送成功: ${info.messageId}`)
return info
} catch (error) {
logger.error('SMTP邮件发送失败:', error)
throw error
}
}
/** /**
* 发送HTTP请求 * 发送HTTP请求
*/ */
@@ -358,10 +280,11 @@ class WebhookService {
formatMessageForWechatWork(type, data) { formatMessageForWechatWork(type, data) {
const title = this.getNotificationTitle(type) const title = this.getNotificationTitle(type)
const details = this.formatNotificationDetails(data) const details = this.formatNotificationDetails(data)
return ( return (
`## ${title}\n\n` + `## ${title}\n\n` +
`> **服务**: Claude Relay Service\n` + `> **服务**: Claude Relay Service\n` +
`> **时间**: ${new Date().toLocaleString('zh-CN', { timeZone: this.timezone })}\n\n${details}` `> **时间**: ${new Date().toLocaleString('zh-CN')}\n\n${details}`
) )
} }
@@ -373,7 +296,7 @@ class WebhookService {
return ( return (
`#### 服务: Claude Relay Service\n` + `#### 服务: Claude Relay Service\n` +
`#### 时间: ${new Date().toLocaleString('zh-CN', { timeZone: this.timezone })}\n\n${details}` `#### 时间: ${new Date().toLocaleString('zh-CN')}\n\n${details}`
) )
} }
@@ -406,7 +329,7 @@ class WebhookService {
title, title,
color, color,
fields, fields,
timestamp: getISOStringWithTimezone(new Date()), timestamp: new Date().toISOString(),
footer: { footer: {
text: 'Claude Relay Service' text: 'Claude Relay Service'
} }
@@ -422,205 +345,12 @@ class WebhookService {
quotaWarning: '📊 配额警告', quotaWarning: '📊 配额警告',
systemError: '❌ 系统错误', systemError: '❌ 系统错误',
securityAlert: '🔒 安全警报', securityAlert: '🔒 安全警报',
rateLimitRecovery: '🎉 限流恢复通知',
test: '🧪 测试通知' test: '🧪 测试通知'
} }
return titles[type] || '📢 系统通知' return titles[type] || '📢 系统通知'
} }
/**
* 获取Bark通知级别
*/
getBarkLevel(type) {
const levels = {
accountAnomaly: 'timeSensitive',
quotaWarning: 'active',
systemError: 'critical',
securityAlert: 'critical',
rateLimitRecovery: 'active',
test: 'passive'
}
return levels[type] || 'active'
}
/**
* 获取Bark声音
*/
getBarkSound(type) {
const sounds = {
accountAnomaly: 'alarm',
quotaWarning: 'bell',
systemError: 'alert',
securityAlert: 'alarm',
rateLimitRecovery: 'success',
test: 'default'
}
return sounds[type] || 'default'
}
/**
* 格式化Bark消息
*/
formatMessageForBark(type, data) {
const lines = []
if (data.accountName) {
lines.push(`账号: ${data.accountName}`)
}
if (data.platform) {
lines.push(`平台: ${data.platform}`)
}
if (data.status) {
lines.push(`状态: ${data.status}`)
}
if (data.errorCode) {
lines.push(`错误: ${data.errorCode}`)
}
if (data.reason) {
lines.push(`原因: ${data.reason}`)
}
if (data.message) {
lines.push(`消息: ${data.message}`)
}
if (data.quota) {
lines.push(`剩余配额: ${data.quota.remaining}/${data.quota.total}`)
}
if (data.usage) {
lines.push(`使用率: ${data.usage}%`)
}
// 添加服务标识和时间戳
lines.push(`\n服务: Claude Relay Service`)
lines.push(`时间: ${new Date().toLocaleString('zh-CN', { timeZone: this.timezone })}`)
return lines.join('\n')
}
/**
* 构建通知详情数据
*/
buildNotificationDetails(data) {
const details = []
if (data.accountName) {
details.push({ label: '账号', value: data.accountName })
}
if (data.platform) {
details.push({ label: '平台', value: data.platform })
}
if (data.status) {
details.push({ label: '状态', value: data.status, color: this.getStatusColor(data.status) })
}
if (data.errorCode) {
details.push({ label: '错误代码', value: data.errorCode, isCode: true })
}
if (data.reason) {
details.push({ label: '原因', value: data.reason })
}
if (data.message) {
details.push({ label: '消息', value: data.message })
}
if (data.quota) {
details.push({ label: '配额', value: `${data.quota.remaining}/${data.quota.total}` })
}
if (data.usage) {
details.push({ label: '使用率', value: `${data.usage}%` })
}
return details
}
/**
* 格式化邮件HTML内容
*/
formatMessageForEmail(type, data) {
const title = this.getNotificationTitle(type)
const timestamp = new Date().toLocaleString('zh-CN', { timeZone: this.timezone })
const details = this.buildNotificationDetails(data)
let content = `
<div style="max-width: 600px; margin: 0 auto; font-family: Arial, sans-serif;">
<div style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 20px; border-radius: 8px 8px 0 0;">
<h1 style="margin: 0; font-size: 24px;">${title}</h1>
<p style="margin: 10px 0 0 0; opacity: 0.9;">Claude Relay Service</p>
</div>
<div style="background: #f8f9fa; padding: 20px; border: 1px solid #e9ecef; border-top: none; border-radius: 0 0 8px 8px;">
<div style="background: white; padding: 16px; border-radius: 6px; box-shadow: 0 2px 4px rgba(0,0,0,0.1);">
`
// 使用统一的详情数据渲染
details.forEach((detail) => {
if (detail.isCode) {
content += `<p><strong>${detail.label}:</strong> <code style="background: #f1f3f4; padding: 2px 6px; border-radius: 4px;">${detail.value}</code></p>`
} else if (detail.color) {
content += `<p><strong>${detail.label}:</strong> <span style="color: ${detail.color};">${detail.value}</span></p>`
} else {
content += `<p><strong>${detail.label}:</strong> ${detail.value}</p>`
}
})
content += `
</div>
<div style="margin-top: 20px; padding-top: 16px; border-top: 1px solid #e9ecef; font-size: 14px; color: #6c757d; text-align: center;">
<p>发送时间: ${timestamp}</p>
<p style="margin: 0;">此邮件由 Claude Relay Service 自动发送</p>
</div>
</div>
</div>
`
return content
}
/**
* 格式化邮件纯文本内容
*/
formatMessageForEmailText(type, data) {
const title = this.getNotificationTitle(type)
const timestamp = new Date().toLocaleString('zh-CN', { timeZone: this.timezone })
const details = this.buildNotificationDetails(data)
let content = `${title}\n`
content += `=====================================\n\n`
// 使用统一的详情数据渲染
details.forEach((detail) => {
content += `${detail.label}: ${detail.value}\n`
})
content += `\n发送时间: ${timestamp}\n`
content += `服务: Claude Relay Service\n`
content += `=====================================\n`
content += `此邮件由系统自动发送,请勿回复。`
return content
}
/**
* 获取状态颜色
*/
getStatusColor(status) {
const colors = {
error: '#dc3545',
unauthorized: '#fd7e14',
blocked: '#6f42c1',
disabled: '#6c757d',
active: '#28a745',
warning: '#ffc107'
}
return colors[status] || '#007bff'
}
/** /**
* 格式化通知详情 * 格式化通知详情
*/ */
@@ -635,14 +365,6 @@ class WebhookService {
lines.push(`**平台**: ${data.platform}`) lines.push(`**平台**: ${data.platform}`)
} }
if (data.platforms) {
lines.push(`**涉及平台**: ${data.platforms.join(', ')}`)
}
if (data.totalAccounts) {
lines.push(`**恢复账户数**: ${data.totalAccounts}`)
}
if (data.status) { if (data.status) {
lines.push(`**状态**: ${data.status}`) lines.push(`**状态**: ${data.status}`)
} }
@@ -712,7 +434,6 @@ class WebhookService {
quotaWarning: 'yellow', quotaWarning: 'yellow',
systemError: 'red', systemError: 'red',
securityAlert: 'red', securityAlert: 'red',
rateLimitRecovery: 'green',
test: 'blue' test: 'blue'
} }
@@ -728,7 +449,6 @@ class WebhookService {
quotaWarning: ':chart_with_downwards_trend:', quotaWarning: ':chart_with_downwards_trend:',
systemError: ':x:', systemError: ':x:',
securityAlert: ':lock:', securityAlert: ':lock:',
rateLimitRecovery: ':tada:',
test: ':test_tube:' test: ':test_tube:'
} }
@@ -744,7 +464,6 @@ class WebhookService {
quotaWarning: 0xffeb3b, // 黄色 quotaWarning: 0xffeb3b, // 黄色
systemError: 0xf44336, // 红色 systemError: 0xf44336, // 红色
securityAlert: 0xf44336, // 红色 securityAlert: 0xf44336, // 红色
rateLimitRecovery: 0x4caf50, // 绿色
test: 0x2196f3 // 蓝色 test: 0x2196f3 // 蓝色
} }
@@ -758,7 +477,7 @@ class WebhookService {
try { try {
const testData = { const testData = {
message: 'Claude Relay Service webhook测试', message: 'Claude Relay Service webhook测试',
timestamp: getISOStringWithTimezone(new Date()) timestamp: new Date().toISOString()
} }
await this.sendToPlatform(platform, 'test', testData, { maxRetries: 1, retryDelay: 1000 }) await this.sendToPlatform(platform, 'test', testData, { maxRetries: 1, retryDelay: 1000 })

View File

@@ -32,14 +32,6 @@ const MODEL_PRICING = {
cacheRead: 1.5 cacheRead: 1.5
}, },
// Claude Opus 4.1 (新模型)
'claude-opus-4-1-20250805': {
input: 15.0,
output: 75.0,
cacheWrite: 18.75,
cacheRead: 1.5
},
// Claude 3 Sonnet // Claude 3 Sonnet
'claude-3-sonnet-20240229': { 'claude-3-sonnet-20240229': {
input: 3.0, input: 3.0,
@@ -77,57 +69,9 @@ class CostCalculator {
* @returns {Object} 费用详情 * @returns {Object} 费用详情
*/ */
static calculateCost(usage, model = 'unknown') { static calculateCost(usage, model = 'unknown') {
// 如果 usage 包含详细的 cache_creation 对象或是 1M 模型,使用 pricingService 来处理 // 如果 usage 包含详细的 cache_creation 对象,使用 pricingService 来处理
if ( if (usage.cache_creation && typeof usage.cache_creation === 'object') {
(usage.cache_creation && typeof usage.cache_creation === 'object') || return pricingService.calculateCost(usage, model)
(model && model.includes('[1m]'))
) {
const result = pricingService.calculateCost(usage, model)
// 转换 pricingService 返回的格式到 costCalculator 的格式
return {
model,
pricing: {
input: result.pricing.input * 1000000, // 转换为 per 1M tokens
output: result.pricing.output * 1000000,
cacheWrite: result.pricing.cacheCreate * 1000000,
cacheRead: result.pricing.cacheRead * 1000000
},
usingDynamicPricing: true,
isLongContextRequest: result.isLongContextRequest || false,
usage: {
inputTokens: usage.input_tokens || 0,
outputTokens: usage.output_tokens || 0,
cacheCreateTokens: usage.cache_creation_input_tokens || 0,
cacheReadTokens: usage.cache_read_input_tokens || 0,
totalTokens:
(usage.input_tokens || 0) +
(usage.output_tokens || 0) +
(usage.cache_creation_input_tokens || 0) +
(usage.cache_read_input_tokens || 0)
},
costs: {
input: result.inputCost,
output: result.outputCost,
cacheWrite: result.cacheCreateCost,
cacheRead: result.cacheReadCost,
total: result.totalCost
},
formatted: {
input: this.formatCost(result.inputCost),
output: this.formatCost(result.outputCost),
cacheWrite: this.formatCost(result.cacheCreateCost),
cacheRead: this.formatCost(result.cacheReadCost),
total: this.formatCost(result.totalCost)
},
debug: {
isOpenAIModel: model.includes('gpt') || model.includes('o1'),
hasCacheCreatePrice: !!result.pricing.cacheCreate,
cacheCreateTokens: usage.cache_creation_input_tokens || 0,
cacheWritePriceUsed: result.pricing.cacheCreate * 1000000,
isLongContextModel: model && model.includes('[1m]'),
isLongContextRequest: result.isLongContextRequest || false
}
}
} }
// 否则使用旧的逻辑(向后兼容) // 否则使用旧的逻辑(向后兼容)

View File

@@ -1,100 +0,0 @@
const config = require('../../config/config')
/**
* 格式化日期时间为指定时区的本地时间字符串
* @param {Date|number} date - Date对象或时间戳秒或毫秒
* @param {boolean} includeTimezone - 是否在输出中包含时区信息
* @returns {string} 格式化后的时间字符串
*/
function formatDateWithTimezone(date, includeTimezone = true) {
// 处理不同类型的输入
let dateObj
if (typeof date === 'number') {
// 判断是秒还是毫秒时间戳
// Unix时间戳通常小于 10^10毫秒时间戳通常大于 10^12
if (date < 10000000000) {
dateObj = new Date(date * 1000) // 秒转毫秒
} else {
dateObj = new Date(date) // 已经是毫秒
}
} else if (date instanceof Date) {
dateObj = date
} else {
dateObj = new Date(date)
}
// 获取配置的时区偏移(小时)
const timezoneOffset = config.system.timezoneOffset || 8 // 默认 UTC+8
// 计算本地时间
const offsetMs = timezoneOffset * 3600000 // 转换为毫秒
const localTime = new Date(dateObj.getTime() + offsetMs)
// 格式化为 YYYY-MM-DD HH:mm:ss
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')
let formattedDate = `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
// 添加时区信息
if (includeTimezone) {
const sign = timezoneOffset >= 0 ? '+' : ''
formattedDate += ` (UTC${sign}${timezoneOffset})`
}
return formattedDate
}
/**
* 获取指定时区的ISO格式时间字符串
* @param {Date|number} date - Date对象或时间戳
* @returns {string} ISO格式的时间字符串
*/
function getISOStringWithTimezone(date) {
// 先获取本地格式的时间(不含时区后缀)
const localTimeStr = formatDateWithTimezone(date, false)
// 获取时区偏移
const timezoneOffset = config.system.timezoneOffset || 8
// 构建ISO格式添加时区偏移
const sign = timezoneOffset >= 0 ? '+' : '-'
const absOffset = Math.abs(timezoneOffset)
const offsetHours = String(Math.floor(absOffset)).padStart(2, '0')
const offsetMinutes = String(Math.round((absOffset % 1) * 60)).padStart(2, '0')
// 将空格替换为T并添加时区
return `${localTimeStr.replace(' ', 'T')}${sign}${offsetHours}:${offsetMinutes}`
}
/**
* 计算时间差并格式化为人类可读的字符串
* @param {number} seconds - 秒数
* @returns {string} 格式化的时间差字符串
*/
function formatDuration(seconds) {
if (seconds < 60) {
return `${seconds}`
} else if (seconds < 3600) {
const minutes = Math.floor(seconds / 60)
return `${minutes}分钟`
} else if (seconds < 86400) {
const hours = Math.floor(seconds / 3600)
const minutes = Math.floor((seconds % 3600) / 60)
return minutes > 0 ? `${hours}小时${minutes}分钟` : `${hours}小时`
} else {
const days = Math.floor(seconds / 86400)
const hours = Math.floor((seconds % 86400) / 3600)
return hours > 0 ? `${days}${hours}小时` : `${days}`
}
}
module.exports = {
formatDateWithTimezone,
getISOStringWithTimezone,
formatDuration
}

View File

@@ -1,291 +0,0 @@
/**
* 输入验证工具类
* 提供各种输入验证和清理功能,防止注入攻击
*/
class InputValidator {
/**
* 验证用户名
* @param {string} username - 用户名
* @returns {string} 验证后的用户名
* @throws {Error} 如果用户名无效
*/
validateUsername(username) {
if (!username || typeof username !== 'string') {
throw new Error('用户名必须是非空字符串')
}
const trimmed = username.trim()
// 长度检查
if (trimmed.length < 3 || trimmed.length > 64) {
throw new Error('用户名长度必须在3-64个字符之间')
}
// 格式检查:只允许字母、数字、下划线、连字符
const usernameRegex = /^[a-zA-Z0-9_-]+$/
if (!usernameRegex.test(trimmed)) {
throw new Error('用户名只能包含字母、数字、下划线和连字符')
}
// 不能以连字符开头或结尾
if (trimmed.startsWith('-') || trimmed.endsWith('-')) {
throw new Error('用户名不能以连字符开头或结尾')
}
return trimmed
}
/**
* 验证电子邮件
* @param {string} email - 电子邮件地址
* @returns {string} 验证后的电子邮件
* @throws {Error} 如果电子邮件无效
*/
validateEmail(email) {
if (!email || typeof email !== 'string') {
throw new Error('电子邮件必须是非空字符串')
}
const trimmed = email.trim().toLowerCase()
// 基本格式验证
const emailRegex =
/^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/
if (!emailRegex.test(trimmed)) {
throw new Error('电子邮件格式无效')
}
// 长度限制
if (trimmed.length > 254) {
throw new Error('电子邮件地址过长')
}
return trimmed
}
/**
* 验证密码强度
* @param {string} password - 密码
* @returns {boolean} 验证结果
*/
validatePassword(password) {
if (!password || typeof password !== 'string') {
throw new Error('密码必须是非空字符串')
}
// 最小长度
if (password.length < 8) {
throw new Error('密码至少需要8个字符')
}
// 最大长度防止DoS攻击
if (password.length > 128) {
throw new Error('密码不能超过128个字符')
}
return true
}
/**
* 验证角色
* @param {string} role - 用户角色
* @returns {string} 验证后的角色
* @throws {Error} 如果角色无效
*/
validateRole(role) {
const validRoles = ['admin', 'user', 'viewer']
if (!role || typeof role !== 'string') {
throw new Error('角色必须是非空字符串')
}
const trimmed = role.trim().toLowerCase()
if (!validRoles.includes(trimmed)) {
throw new Error(`角色必须是以下之一: ${validRoles.join(', ')}`)
}
return trimmed
}
/**
* 验证Webhook URL
* @param {string} url - Webhook URL
* @returns {string} 验证后的URL
* @throws {Error} 如果URL无效
*/
validateWebhookUrl(url) {
if (!url || typeof url !== 'string') {
throw new Error('Webhook URL必须是非空字符串')
}
const trimmed = url.trim()
// URL格式验证
try {
const urlObj = new URL(trimmed)
// 只允许HTTP和HTTPS协议
if (!['http:', 'https:'].includes(urlObj.protocol)) {
throw new Error('Webhook URL必须使用HTTP或HTTPS协议')
}
// 防止SSRF攻击禁止访问内网地址
const hostname = urlObj.hostname.toLowerCase()
const dangerousHosts = [
'localhost',
'127.0.0.1',
'0.0.0.0',
'::1',
'169.254.169.254', // AWS元数据服务
'metadata.google.internal' // GCP元数据服务
]
if (dangerousHosts.includes(hostname)) {
throw new Error('Webhook URL不能指向内部服务')
}
// 检查是否是内网IP
const ipRegex = /^(\d{1,3}\.){3}\d{1,3}$/
if (ipRegex.test(hostname)) {
const parts = hostname.split('.').map(Number)
// 检查私有IP范围
if (
parts[0] === 10 || // 10.0.0.0/8
(parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) || // 172.16.0.0/12
(parts[0] === 192 && parts[1] === 168) // 192.168.0.0/16
) {
throw new Error('Webhook URL不能指向私有IP地址')
}
}
return trimmed
} catch (error) {
if (error.message.includes('Webhook URL')) {
throw error
}
throw new Error('Webhook URL格式无效')
}
}
/**
* 验证显示名称
* @param {string} displayName - 显示名称
* @returns {string} 验证后的显示名称
* @throws {Error} 如果显示名称无效
*/
validateDisplayName(displayName) {
if (!displayName || typeof displayName !== 'string') {
throw new Error('显示名称必须是非空字符串')
}
const trimmed = displayName.trim()
// 长度检查
if (trimmed.length < 1 || trimmed.length > 100) {
throw new Error('显示名称长度必须在1-100个字符之间')
}
// 禁止特殊控制字符(排除常见的换行和制表符)
// eslint-disable-next-line no-control-regex
const controlCharRegex = /[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/
if (controlCharRegex.test(trimmed)) {
throw new Error('显示名称不能包含控制字符')
}
return trimmed
}
/**
* 清理HTML标签防止XSS
* @param {string} input - 输入字符串
* @returns {string} 清理后的字符串
*/
sanitizeHtml(input) {
if (!input || typeof input !== 'string') {
return ''
}
return input
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#x27;')
.replace(/\//g, '&#x2F;')
}
/**
* 验证API Key名称
* @param {string} name - API Key名称
* @returns {string} 验证后的名称
* @throws {Error} 如果名称无效
*/
validateApiKeyName(name) {
if (!name || typeof name !== 'string') {
throw new Error('API Key名称必须是非空字符串')
}
const trimmed = name.trim()
// 长度检查
if (trimmed.length < 1 || trimmed.length > 100) {
throw new Error('API Key名称长度必须在1-100个字符之间')
}
// 禁止特殊控制字符(排除常见的换行和制表符)
// eslint-disable-next-line no-control-regex
const controlCharRegex = /[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/
if (controlCharRegex.test(trimmed)) {
throw new Error('API Key名称不能包含控制字符')
}
return trimmed
}
/**
* 验证分页参数
* @param {number} page - 页码
* @param {number} limit - 每页数量
* @returns {{page: number, limit: number}} 验证后的分页参数
*/
validatePagination(page, limit) {
const pageNum = parseInt(page, 10) || 1
const limitNum = parseInt(limit, 10) || 20
if (pageNum < 1) {
throw new Error('页码必须大于0')
}
if (limitNum < 1 || limitNum > 100) {
throw new Error('每页数量必须在1-100之间')
}
return {
page: pageNum,
limit: limitNum
}
}
/**
* 验证UUID格式
* @param {string} uuid - UUID字符串
* @returns {string} 验证后的UUID
* @throws {Error} 如果UUID无效
*/
validateUuid(uuid) {
if (!uuid || typeof uuid !== 'string') {
throw new Error('UUID必须是非空字符串')
}
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
if (!uuidRegex.test(uuid)) {
throw new Error('UUID格式无效')
}
return uuid.toLowerCase()
}
}
module.exports = new InputValidator()

View File

@@ -1,7 +1,6 @@
const winston = require('winston') const winston = require('winston')
const DailyRotateFile = require('winston-daily-rotate-file') const DailyRotateFile = require('winston-daily-rotate-file')
const config = require('../../config/config') const config = require('../../config/config')
const { formatDateWithTimezone } = require('../utils/dateHelper')
const path = require('path') const path = require('path')
const fs = require('fs') const fs = require('fs')
const os = require('os') const os = require('os')
@@ -96,7 +95,7 @@ const safeStringify = (obj, maxDepth = 3, fullDepth = false) => {
// 📝 增强的日志格式 // 📝 增强的日志格式
const createLogFormat = (colorize = false) => { const createLogFormat = (colorize = false) => {
const formats = [ const formats = [
winston.format.timestamp({ format: () => formatDateWithTimezone(new Date(), false) }), winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
winston.format.errors({ stack: true }) winston.format.errors({ stack: true })
// 移除 winston.format.metadata() 来避免自动包装 // 移除 winston.format.metadata() 来避免自动包装
] ]
@@ -190,7 +189,7 @@ const securityLogger = winston.createLogger({
const authDetailLogger = winston.createLogger({ const authDetailLogger = winston.createLogger({
level: 'info', level: 'info',
format: winston.format.combine( format: winston.format.combine(
winston.format.timestamp({ format: () => formatDateWithTimezone(new Date(), false) }), winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
winston.format.printf(({ level, message, timestamp, data }) => { winston.format.printf(({ level, message, timestamp, data }) => {
// 使用更深的深度和格式化的JSON输出 // 使用更深的深度和格式化的JSON输出
const jsonData = data ? JSON.stringify(data, null, 2) : '{}' const jsonData = data ? JSON.stringify(data, null, 2) : '{}'

View File

@@ -4,7 +4,7 @@ const logger = require('./logger')
class SessionHelper { class SessionHelper {
/** /**
* 生成会话哈希用于sticky会话保持 * 生成会话哈希用于sticky会话保持
* 基于Anthropic的prompt caching机制优先使用metadata中的session ID * 基于Anthropic的prompt caching机制优先使用cacheable内容
* @param {Object} requestBody - 请求体 * @param {Object} requestBody - 请求体
* @returns {string|null} - 32字符的会话哈希如果无法生成则返回null * @returns {string|null} - 32字符的会话哈希如果无法生成则返回null
*/ */
@@ -13,24 +13,11 @@ class SessionHelper {
return null return null
} }
// 1. 最高优先级使用metadata中的session ID直接使用无需hash
if (requestBody.metadata && requestBody.metadata.user_id) {
// 提取 session_xxx 部分
const userIdString = requestBody.metadata.user_id
const sessionMatch = userIdString.match(/session_([a-f0-9-]{36})/)
if (sessionMatch && sessionMatch[1]) {
const sessionId = sessionMatch[1]
// 直接返回session ID
logger.debug(`📋 Session ID extracted from metadata.user_id: ${sessionId}`)
return sessionId
}
}
let cacheableContent = '' let cacheableContent = ''
const system = requestBody.system || '' const system = requestBody.system || ''
const messages = requestBody.messages || [] const messages = requestBody.messages || []
// 2. 提取带有cache_control: {"type": "ephemeral"}的内容 // 1. 优先提取带有cache_control: {"type": "ephemeral"}的内容
// 检查system中的cacheable内容 // 检查system中的cacheable内容
if (Array.isArray(system)) { if (Array.isArray(system)) {
for (const part of system) { for (const part of system) {
@@ -43,13 +30,13 @@ class SessionHelper {
// 检查messages中的cacheable内容 // 检查messages中的cacheable内容
for (const msg of messages) { for (const msg of messages) {
const content = msg.content || '' const content = msg.content || ''
let hasCacheControl = false
if (Array.isArray(content)) { if (Array.isArray(content)) {
for (const part of content) { for (const part of content) {
if (part && part.cache_control && part.cache_control.type === 'ephemeral') { if (part && part.cache_control && part.cache_control.type === 'ephemeral') {
hasCacheControl = true if (part.type === 'text') {
break cacheableContent += part.text || ''
}
// 其他类型如image不参与hash计算
} }
} }
} else if ( } else if (
@@ -57,31 +44,12 @@ class SessionHelper {
msg.cache_control && msg.cache_control &&
msg.cache_control.type === 'ephemeral' msg.cache_control.type === 'ephemeral'
) { ) {
hasCacheControl = true // 罕见情况,但需要检查
} cacheableContent += content
if (hasCacheControl) {
for (const message of messages) {
let messageText = ''
if (typeof message.content === 'string') {
messageText = message.content
} else if (Array.isArray(message.content)) {
messageText = message.content
.filter((part) => part.type === 'text')
.map((part) => part.text || '')
.join('')
}
if (messageText) {
cacheableContent += messageText
break
}
}
break
} }
} }
// 3. 如果有cacheable内容直接使用 // 2. 如果有cacheable内容直接使用
if (cacheableContent) { if (cacheableContent) {
const hash = crypto const hash = crypto
.createHash('sha256') .createHash('sha256')
@@ -92,7 +60,7 @@ class SessionHelper {
return hash return hash
} }
// 4. Fallback: 使用system内容 // 3. Fallback: 使用system内容
if (system) { if (system) {
let systemText = '' let systemText = ''
if (typeof system === 'string') { if (typeof system === 'string') {
@@ -108,7 +76,7 @@ class SessionHelper {
} }
} }
// 5. 最后fallback: 使用第一条消息内容 // 4. 最后fallback: 使用第一条消息内容
if (messages.length > 0) { if (messages.length > 0) {
const firstMessage = messages[0] const firstMessage = messages[0]
let firstMessageText = '' let firstMessageText = ''

View File

@@ -1,6 +1,5 @@
const logger = require('./logger') const logger = require('./logger')
const webhookService = require('../services/webhookService') const webhookService = require('../services/webhookService')
const { getISOStringWithTimezone } = require('./dateHelper')
class WebhookNotifier { class WebhookNotifier {
constructor() { constructor() {
@@ -29,7 +28,7 @@ class WebhookNotifier {
errorCode: errorCode:
notification.errorCode || this._getErrorCode(notification.platform, notification.status), notification.errorCode || this._getErrorCode(notification.platform, notification.status),
reason: notification.reason, reason: notification.reason,
timestamp: notification.timestamp || getISOStringWithTimezone(new Date()) timestamp: notification.timestamp || new Date().toISOString()
}) })
} catch (error) { } catch (error) {
logger.error('Failed to send account anomaly notification:', error) logger.error('Failed to send account anomaly notification:', error)
@@ -68,7 +67,6 @@ class WebhookNotifier {
const errorCodes = { const errorCodes = {
'claude-oauth': { 'claude-oauth': {
unauthorized: 'CLAUDE_OAUTH_UNAUTHORIZED', unauthorized: 'CLAUDE_OAUTH_UNAUTHORIZED',
blocked: 'CLAUDE_OAUTH_BLOCKED',
error: 'CLAUDE_OAUTH_ERROR', error: 'CLAUDE_OAUTH_ERROR',
disabled: 'CLAUDE_OAUTH_MANUALLY_DISABLED' disabled: 'CLAUDE_OAUTH_MANUALLY_DISABLED'
}, },
@@ -81,12 +79,6 @@ class WebhookNotifier {
error: 'GEMINI_ERROR', error: 'GEMINI_ERROR',
unauthorized: 'GEMINI_UNAUTHORIZED', unauthorized: 'GEMINI_UNAUTHORIZED',
disabled: 'GEMINI_MANUALLY_DISABLED' disabled: 'GEMINI_MANUALLY_DISABLED'
},
openai: {
error: 'OPENAI_ERROR',
unauthorized: 'OPENAI_UNAUTHORIZED',
blocked: 'OPENAI_RATE_LIMITED',
disabled: 'OPENAI_MANUALLY_DISABLED'
} }
} }

View File

@@ -23,14 +23,6 @@ VITE_APP_TITLE=Claude Relay Service - 管理后台
# 格式http://proxy-host:port # 格式http://proxy-host:port
#VITE_HTTP_PROXY=http://127.0.0.1:7890 #VITE_HTTP_PROXY=http://127.0.0.1:7890
# ========== 教程页面配置 ==========
# API 基础前缀(可选)
# 用于教程页面显示的自定义 API 前缀
# 如果不配置,则使用当前浏览器访问地址
# 示例https://api.example.com 或 https://relay.mysite.com
# VITE_API_BASE_PREFIX=https://api.example.com
# ========== 使用说明 ========== # ========== 使用说明 ==========
# 1. 复制此文件为 .env.local 进行本地配置 # 1. 复制此文件为 .env.local 进行本地配置
# 2. .env.local 文件不会被提交到版本控制 # 2. .env.local 文件不会被提交到版本控制

View File

@@ -15,9 +15,7 @@
"element-plus": "^2.4.4", "element-plus": "^2.4.4",
"pinia": "^2.1.7", "pinia": "^2.1.7",
"vue": "^3.3.4", "vue": "^3.3.4",
"vue-router": "^4.2.5", "vue-router": "^4.2.5"
"xlsx": "^0.18.5",
"xlsx-js-style": "^1.2.0"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.55.0", "@playwright/test": "^1.55.0",
@@ -1368,15 +1366,6 @@
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
} }
}, },
"node_modules/adler-32": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz",
"integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/ajv": { "node_modules/ajv": {
"version": "6.12.6", "version": "6.12.6",
"resolved": "https://registry.npmmirror.com/ajv/-/ajv-6.12.6.tgz", "resolved": "https://registry.npmmirror.com/ajv/-/ajv-6.12.6.tgz",
@@ -1654,19 +1643,6 @@
], ],
"license": "CC-BY-4.0" "license": "CC-BY-4.0"
}, },
"node_modules/cfb": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz",
"integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==",
"license": "Apache-2.0",
"dependencies": {
"adler-32": "~1.3.0",
"crc-32": "~1.2.0"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/chalk": { "node_modules/chalk": {
"version": "4.1.2", "version": "4.1.2",
"resolved": "https://registry.npmmirror.com/chalk/-/chalk-4.1.2.tgz", "resolved": "https://registry.npmmirror.com/chalk/-/chalk-4.1.2.tgz",
@@ -1734,15 +1710,6 @@
"node": ">= 6" "node": ">= 6"
} }
}, },
"node_modules/codepage": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz",
"integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/color-convert": { "node_modules/color-convert": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmmirror.com/color-convert/-/color-convert-2.0.1.tgz", "resolved": "https://registry.npmmirror.com/color-convert/-/color-convert-2.0.1.tgz",
@@ -1799,18 +1766,6 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/crc-32": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
"integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
"license": "Apache-2.0",
"bin": {
"crc32": "bin/crc32.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/cross-spawn": { "node_modules/cross-spawn": {
"version": "7.0.6", "version": "7.0.6",
"resolved": "https://registry.npmmirror.com/cross-spawn/-/cross-spawn-7.0.6.tgz", "resolved": "https://registry.npmmirror.com/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -2349,15 +2304,6 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/exit-on-epipe": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/exit-on-epipe/-/exit-on-epipe-1.0.1.tgz",
"integrity": "sha512-h2z5mrROTxce56S+pnvAV890uu7ls7f1kEvVGJbw1OlFH3/mlJ5bkXu0KRyW94v37zzHPiUd55iLn3DA7TjWpw==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/exsolve": { "node_modules/exsolve": {
"version": "1.0.7", "version": "1.0.7",
"resolved": "https://registry.npmmirror.com/exsolve/-/exsolve-1.0.7.tgz", "resolved": "https://registry.npmmirror.com/exsolve/-/exsolve-1.0.7.tgz",
@@ -2433,12 +2379,6 @@
"reusify": "^1.0.4" "reusify": "^1.0.4"
} }
}, },
"node_modules/fflate": {
"version": "0.3.11",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.3.11.tgz",
"integrity": "sha512-Rr5QlUeGN1mbOHlaqcSYMKVpPbgLy0AWT/W0EHxA6NGI12yO1jpoui2zBBvU2G824ltM6Ut8BFgfHSBGfkmS0A==",
"license": "MIT"
},
"node_modules/file-entry-cache": { "node_modules/file-entry-cache": {
"version": "6.0.1", "version": "6.0.1",
"resolved": "https://registry.npmmirror.com/file-entry-cache/-/file-entry-cache-6.0.1.tgz", "resolved": "https://registry.npmmirror.com/file-entry-cache/-/file-entry-cache-6.0.1.tgz",
@@ -2557,15 +2497,6 @@
"node": ">= 6" "node": ">= 6"
} }
}, },
"node_modules/frac": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz",
"integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/fraction.js": { "node_modules/fraction.js": {
"version": "4.3.7", "version": "4.3.7",
"resolved": "https://registry.npmmirror.com/fraction.js/-/fraction.js-4.3.7.tgz", "resolved": "https://registry.npmmirror.com/fraction.js/-/fraction.js-4.3.7.tgz",
@@ -3873,18 +3804,6 @@
} }
} }
}, },
"node_modules/printj": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/printj/-/printj-1.1.2.tgz",
"integrity": "sha512-zA2SmoLaxZyArQTOPj5LXecR+RagfPSU5Kw1qP+jkWeNlrq+eJZyY2oS68SU1Z/7/myXM4lo9716laOFAVStCQ==",
"license": "Apache-2.0",
"bin": {
"printj": "bin/printj.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/proxy-from-env": { "node_modules/proxy-from-env": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmmirror.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz", "resolved": "https://registry.npmmirror.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
@@ -4164,18 +4083,6 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/ssf": {
"version": "0.11.2",
"resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz",
"integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==",
"license": "Apache-2.0",
"dependencies": {
"frac": "~1.1.2"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/string-width": { "node_modules/string-width": {
"version": "5.1.2", "version": "5.1.2",
"resolved": "https://registry.npmmirror.com/string-width/-/string-width-5.1.2.tgz", "resolved": "https://registry.npmmirror.com/string-width/-/string-width-5.1.2.tgz",
@@ -5219,24 +5126,6 @@
"node": ">= 8" "node": ">= 8"
} }
}, },
"node_modules/wmf": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz",
"integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/word": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz",
"integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/word-wrap": { "node_modules/word-wrap": {
"version": "1.2.5", "version": "1.2.5",
"resolved": "https://registry.npmmirror.com/word-wrap/-/word-wrap-1.2.5.tgz", "resolved": "https://registry.npmmirror.com/word-wrap/-/word-wrap-1.2.5.tgz",
@@ -5355,95 +5244,6 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/xlsx": {
"version": "0.18.5",
"resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz",
"integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==",
"license": "Apache-2.0",
"dependencies": {
"adler-32": "~1.3.0",
"cfb": "~1.2.1",
"codepage": "~1.15.0",
"crc-32": "~1.2.1",
"ssf": "~0.11.2",
"wmf": "~1.0.1",
"word": "~0.3.0"
},
"bin": {
"xlsx": "bin/xlsx.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/xlsx-js-style": {
"version": "1.2.0",
"resolved": "https://registry.npmmirror.com/xlsx-js-style/-/xlsx-js-style-1.2.0.tgz",
"integrity": "sha512-DDT4FXFSWfT4DXMSok/m3TvmP1gvO3dn0Eu/c+eXHW5Kzmp7IczNkxg/iEPnImbG9X0Vb8QhROda5eatSR/97Q==",
"license": "Apache-2.0",
"dependencies": {
"adler-32": "~1.2.0",
"cfb": "^1.1.4",
"codepage": "~1.14.0",
"commander": "~2.17.1",
"crc-32": "~1.2.0",
"exit-on-epipe": "~1.0.1",
"fflate": "^0.3.8",
"ssf": "~0.11.2",
"wmf": "~1.0.1",
"word": "~0.3.0"
},
"bin": {
"xlsx": "bin/xlsx.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/xlsx-js-style/node_modules/adler-32": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.2.0.tgz",
"integrity": "sha512-/vUqU/UY4MVeFsg+SsK6c+/05RZXIHZMGJA+PX5JyWI0ZRcBpupnRuPLU/NXXoFwMYCPCoxIfElM2eS+DUXCqQ==",
"license": "Apache-2.0",
"dependencies": {
"exit-on-epipe": "~1.0.1",
"printj": "~1.1.0"
},
"bin": {
"adler32": "bin/adler32.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/xlsx-js-style/node_modules/codepage": {
"version": "1.14.0",
"resolved": "https://registry.npmjs.org/codepage/-/codepage-1.14.0.tgz",
"integrity": "sha512-iz3zJLhlrg37/gYRWgEPkaFTtzmnEv1h+r7NgZum2lFElYQPi0/5bnmuDfODHxfp0INEfnRqyfyeIJDbb7ahRw==",
"license": "Apache-2.0",
"dependencies": {
"commander": "~2.14.1",
"exit-on-epipe": "~1.0.1"
},
"bin": {
"codepage": "bin/codepage.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/xlsx-js-style/node_modules/codepage/node_modules/commander": {
"version": "2.14.1",
"resolved": "https://registry.npmjs.org/commander/-/commander-2.14.1.tgz",
"integrity": "sha512-+YR16o3rK53SmWHU3rEM3tPAh2rwb1yPcQX5irVn7mb0gXbwuCCrnkbV5+PBfETdfg1vui07nM6PCG1zndcjQw==",
"license": "MIT"
},
"node_modules/xlsx-js-style/node_modules/commander": {
"version": "2.17.1",
"resolved": "https://registry.npmjs.org/commander/-/commander-2.17.1.tgz",
"integrity": "sha512-wPMUt6FnH2yzG95SA6mzjQOEKUU3aLaDEmzs1ti+1E9h+CsrZghRlqEM/EJ4KscsQVG8uNN4uVreUeT8+drlgg==",
"license": "MIT"
},
"node_modules/xml-name-validator": { "node_modules/xml-name-validator": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmmirror.com/xml-name-validator/-/xml-name-validator-4.0.0.tgz", "resolved": "https://registry.npmmirror.com/xml-name-validator/-/xml-name-validator-4.0.0.tgz",

View File

@@ -18,9 +18,7 @@
"element-plus": "^2.4.4", "element-plus": "^2.4.4",
"pinia": "^2.1.7", "pinia": "^2.1.7",
"vue": "^3.3.4", "vue": "^3.3.4",
"vue-router": "^4.2.5", "vue-router": "^4.2.5"
"xlsx": "^0.18.5",
"xlsx-js-style": "^1.2.0"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.55.0", "@playwright/test": "^1.55.0",

View File

@@ -17,7 +17,7 @@
--bg-gradient-mid: #764ba2; --bg-gradient-mid: #764ba2;
--bg-gradient-end: #f093fb; --bg-gradient-end: #f093fb;
--input-bg: rgba(255, 255, 255, 0.9); --input-bg: rgba(255, 255, 255, 0.9);
--input-border: rgba(209, 213, 219, 0.8); --input-border: rgba(255, 255, 255, 0.3);
--modal-bg: rgba(0, 0, 0, 0.4); --modal-bg: rgba(0, 0, 0, 0.4);
--table-bg: rgba(255, 255, 255, 0.95); --table-bg: rgba(255, 255, 255, 0.95);
--table-hover: rgba(102, 126, 234, 0.05); --table-hover: rgba(102, 126, 234, 0.05);

File diff suppressed because it is too large Load Diff

View File

@@ -36,7 +36,7 @@
> >
<select <select
v-model="proxy.type" v-model="proxy.type"
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200" class="form-input w-full dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200"
> >
<option value="socks5">SOCKS5</option> <option value="socks5">SOCKS5</option>
<option value="http">HTTP</option> <option value="http">HTTP</option>
@@ -51,7 +51,7 @@
> >
<input <input
v-model="proxy.host" v-model="proxy.host"
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400" class="form-input w-full dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
placeholder="例如: 192.168.1.100" placeholder="例如: 192.168.1.100"
type="text" type="text"
/> />
@@ -62,7 +62,7 @@
> >
<input <input
v-model="proxy.port" v-model="proxy.port"
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400" class="form-input w-full dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
placeholder="例如: 1080" placeholder="例如: 1080"
type="number" type="number"
/> />
@@ -92,7 +92,7 @@
> >
<input <input
v-model="proxy.username" v-model="proxy.username"
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400" class="form-input w-full dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
placeholder="代理用户名" placeholder="代理用户名"
type="text" type="text"
/> />
@@ -104,7 +104,7 @@
<div class="relative"> <div class="relative">
<input <input
v-model="proxy.password" v-model="proxy.password"
class="form-input w-full border-gray-300 pr-10 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400" class="form-input w-full pr-10 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
placeholder="代理密码" placeholder="代理密码"
:type="showPassword ? 'text' : 'password'" :type="showPassword ? 'text' : 'password'"
/> />

View File

@@ -1,254 +0,0 @@
<template>
<div
v-if="show"
class="fixed inset-0 z-50 h-full w-full overflow-y-auto bg-gray-600 bg-opacity-50"
>
<div class="relative top-20 mx-auto w-96 rounded-md border bg-white p-5 shadow-lg">
<div class="mt-3">
<div class="mb-4 flex items-center justify-between">
<h3 class="text-lg font-medium text-gray-900">Change User Role</h3>
<button class="text-gray-400 hover:text-gray-600" @click="$emit('close')">
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
d="M6 18L18 6M6 6l12 12"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
/>
</svg>
</button>
</div>
<div v-if="user" class="space-y-4">
<!-- User Info -->
<div class="rounded-md bg-gray-50 p-4">
<div class="flex items-center">
<div class="flex-shrink-0">
<div class="flex h-10 w-10 items-center justify-center rounded-full bg-gray-300">
<svg
class="h-6 w-6 text-gray-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
/>
</svg>
</div>
</div>
<div class="ml-4">
<p class="text-sm font-medium text-gray-900">
{{ user.displayName || user.username }}
</p>
<p class="text-sm text-gray-500">@{{ user.username }}</p>
<div class="mt-1">
<span
:class="[
'inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium',
user.role === 'admin'
? 'bg-purple-100 text-purple-800'
: 'bg-blue-100 text-blue-800'
]"
>
Current: {{ user.role }}
</span>
</div>
</div>
</div>
</div>
<!-- Role Selection -->
<form class="space-y-4" @submit.prevent="handleSubmit">
<div>
<label class="mb-2 block text-sm font-medium text-gray-700"> New Role </label>
<div class="space-y-2">
<label class="flex items-center">
<input
v-model="selectedRole"
class="h-4 w-4 border-gray-300 text-blue-600 focus:ring-blue-500"
:disabled="loading"
type="radio"
value="user"
/>
<div class="ml-3">
<div class="text-sm font-medium text-gray-900">User</div>
<div class="text-xs text-gray-500">Regular user with basic permissions</div>
</div>
</label>
<label class="flex items-center">
<input
v-model="selectedRole"
class="h-4 w-4 border-gray-300 text-blue-600 focus:ring-blue-500"
:disabled="loading"
type="radio"
value="admin"
/>
<div class="ml-3">
<div class="text-sm font-medium text-gray-900">Administrator</div>
<div class="text-xs text-gray-500">Full access to manage users and system</div>
</div>
</label>
</div>
</div>
<!-- Warning for role changes -->
<div
v-if="selectedRole !== user.role"
class="rounded-md border border-yellow-200 bg-yellow-50 p-4"
>
<div class="flex">
<div class="flex-shrink-0">
<svg class="h-5 w-5 text-yellow-400" fill="currentColor" viewBox="0 0 20 20">
<path
clip-rule="evenodd"
d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z"
fill-rule="evenodd"
/>
</svg>
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-yellow-800">Role Change Warning</h3>
<div class="mt-2 text-sm text-yellow-700">
<p v-if="selectedRole === 'admin'">
Granting admin privileges will give this user full access to the system,
including the ability to manage other users and their API keys.
</p>
<p v-else>
Removing admin privileges will restrict this user to only managing their own
API keys and viewing their own usage statistics.
</p>
</div>
</div>
</div>
</div>
<div v-if="error" class="rounded-md border border-red-200 bg-red-50 p-4">
<div class="flex">
<div class="flex-shrink-0">
<svg class="h-5 w-5 text-red-400" fill="currentColor" viewBox="0 0 20 20">
<path
clip-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
fill-rule="evenodd"
/>
</svg>
</div>
<div class="ml-3">
<p class="text-sm text-red-700">{{ error }}</p>
</div>
</div>
</div>
<div class="flex justify-end space-x-3 pt-4">
<button
class="rounded-md border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:opacity-50"
:disabled="loading"
type="button"
@click="$emit('close')"
>
Cancel
</button>
<button
class="rounded-md border border-transparent bg-blue-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
:disabled="loading || selectedRole === user.role"
type="submit"
>
<span v-if="loading" class="flex items-center">
<svg
class="-ml-1 mr-2 h-4 w-4 animate-spin text-white"
fill="none"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
fill="currentColor"
></path>
</svg>
Updating...
</span>
<span v-else>Update Role</span>
</button>
</div>
</form>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, watch } from 'vue'
import { apiClient } from '@/config/api'
import { showToast } from '@/utils/toast'
const props = defineProps({
show: {
type: Boolean,
default: false
},
user: {
type: Object,
default: null
}
})
const emit = defineEmits(['close', 'updated'])
const loading = ref(false)
const error = ref('')
const selectedRole = ref('')
const handleSubmit = async () => {
if (!props.user || selectedRole.value === props.user.role) {
return
}
loading.value = true
error.value = ''
try {
const response = await apiClient.patch(`/users/${props.user.id}/role`, {
role: selectedRole.value
})
if (response.success) {
showToast(`User role updated to ${selectedRole.value}`, 'success')
emit('updated')
} else {
error.value = response.message || 'Failed to update user role'
}
} catch (err) {
console.error('Update user role error:', err)
error.value = err.response?.data?.message || err.message || 'Failed to update user role'
} finally {
loading.value = false
}
}
// Reset form when modal is shown
watch([() => props.show, () => props.user], ([show, user]) => {
if (show && user) {
selectedRole.value = user.role
error.value = ''
loading.value = false
}
})
</script>
<style scoped>
/* 组件特定样式 */
</style>

View File

@@ -1,428 +0,0 @@
<template>
<div
v-if="show"
class="fixed inset-0 z-50 h-full w-full overflow-y-auto bg-gray-600 bg-opacity-50"
>
<div class="relative top-10 mx-auto w-4/5 max-w-4xl rounded-md border bg-white p-5 shadow-lg">
<div class="mt-3">
<div class="mb-6 flex items-center justify-between">
<div>
<h3 class="text-lg font-medium text-gray-900">
Usage Statistics - {{ user?.displayName || user?.username }}
</h3>
<p class="text-sm text-gray-500">@{{ user?.username }} {{ user?.role }}</p>
</div>
<button class="text-gray-400 hover:text-gray-600" @click="emit('close')">
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
d="M6 18L18 6M6 6l12 12"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
/>
</svg>
</button>
</div>
<!-- Period Selector -->
<div class="mb-6">
<select
v-model="selectedPeriod"
class="block w-32 rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
@change="loadUsageStats"
>
<option value="day">Last 24 Hours</option>
<option value="week">Last 7 Days</option>
<option value="month">Last 30 Days</option>
<option value="quarter">Last 90 Days</option>
</select>
</div>
<!-- Loading State -->
<div v-if="loading" class="py-12 text-center">
<svg
class="mx-auto h-8 w-8 animate-spin text-blue-600"
fill="none"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
fill="currentColor"
></path>
</svg>
<p class="mt-2 text-sm text-gray-500">Loading usage statistics...</p>
</div>
<!-- Stats Content -->
<div v-else class="space-y-6">
<!-- Summary Cards -->
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">
<div class="overflow-hidden rounded-lg bg-blue-50 shadow">
<div class="p-5">
<div class="flex items-center">
<div class="flex-shrink-0">
<svg
class="h-6 w-6 text-blue-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
d="M13 10V3L4 14h7v7l9-11h-7z"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
/>
</svg>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="truncate text-sm font-medium text-blue-600">Requests</dt>
<dd class="text-lg font-medium text-blue-900">
{{ formatNumber(usageStats?.totalRequests || 0) }}
</dd>
</dl>
</div>
</div>
</div>
</div>
<div class="overflow-hidden rounded-lg bg-green-50 shadow">
<div class="p-5">
<div class="flex items-center">
<div class="flex-shrink-0">
<svg
class="h-6 w-6 text-green-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
/>
</svg>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="truncate text-sm font-medium text-green-600">Input Tokens</dt>
<dd class="text-lg font-medium text-green-900">
{{ formatNumber(usageStats?.totalInputTokens || 0) }}
</dd>
</dl>
</div>
</div>
</div>
</div>
<div class="overflow-hidden rounded-lg bg-purple-50 shadow">
<div class="p-5">
<div class="flex items-center">
<div class="flex-shrink-0">
<svg
class="h-6 w-6 text-purple-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
/>
</svg>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="truncate text-sm font-medium text-purple-600">Output Tokens</dt>
<dd class="text-lg font-medium text-purple-900">
{{ formatNumber(usageStats?.totalOutputTokens || 0) }}
</dd>
</dl>
</div>
</div>
</div>
</div>
<div class="overflow-hidden rounded-lg bg-yellow-50 shadow">
<div class="p-5">
<div class="flex items-center">
<div class="flex-shrink-0">
<svg
class="h-6 w-6 text-yellow-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
/>
</svg>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="truncate text-sm font-medium text-yellow-600">Total Cost</dt>
<dd class="text-lg font-medium text-yellow-900">
${{ (usageStats?.totalCost || 0).toFixed(4) }}
</dd>
</dl>
</div>
</div>
</div>
</div>
</div>
<!-- User API Keys Table -->
<div
v-if="userDetails?.apiKeys?.length > 0"
class="rounded-lg border border-gray-200 bg-white"
>
<div class="border-b border-gray-200 px-4 py-5 sm:px-6">
<h4 class="text-lg font-medium leading-6 text-gray-900">API Keys Usage</h4>
</div>
<div class="overflow-hidden">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500"
scope="col"
>
API Key
</th>
<th
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500"
scope="col"
>
Status
</th>
<th
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500"
scope="col"
>
Requests
</th>
<th
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500"
scope="col"
>
Tokens
</th>
<th
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500"
scope="col"
>
Cost
</th>
<th
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500"
scope="col"
>
Last Used
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 bg-white">
<tr v-for="apiKey in userDetails.apiKeys" :key="apiKey.id">
<td class="whitespace-nowrap px-6 py-4">
<div class="text-sm font-medium text-gray-900">{{ apiKey.name }}</div>
<div class="text-sm text-gray-500">{{ apiKey.keyPreview }}</div>
</td>
<td class="whitespace-nowrap px-6 py-4">
<span
:class="[
'inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium',
apiKey.isActive
? 'bg-green-100 text-green-800'
: 'bg-red-100 text-red-800'
]"
>
{{ apiKey.isActive ? 'Active' : 'Disabled' }}
</span>
</td>
<td class="whitespace-nowrap px-6 py-4 text-sm text-gray-900">
{{ formatNumber(apiKey.usage?.requests || 0) }}
</td>
<td class="whitespace-nowrap px-6 py-4 text-sm text-gray-900">
<div>In: {{ formatNumber(apiKey.usage?.inputTokens || 0) }}</div>
<div>Out: {{ formatNumber(apiKey.usage?.outputTokens || 0) }}</div>
</td>
<td class="whitespace-nowrap px-6 py-4 text-sm text-gray-900">
${{ (apiKey.usage?.totalCost || 0).toFixed(4) }}
</td>
<td class="whitespace-nowrap px-6 py-4 text-sm text-gray-500">
{{ apiKey.lastUsedAt ? formatDate(apiKey.lastUsedAt) : 'Never' }}
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Chart Placeholder -->
<div class="rounded-lg border border-gray-200 bg-white">
<div class="border-b border-gray-200 px-4 py-5 sm:px-6">
<h4 class="text-lg font-medium leading-6 text-gray-900">Usage Trend</h4>
</div>
<div class="p-6">
<div
class="flex h-64 items-center justify-center rounded-lg border-2 border-dashed border-gray-300"
>
<div class="text-center">
<svg
class="mx-auto h-12 w-12 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
/>
</svg>
<h3 class="mt-2 text-sm font-medium text-gray-900">Usage Chart</h3>
<p class="mt-1 text-sm text-gray-500">
Daily usage trends for {{ selectedPeriod }} period
</p>
<p class="mt-2 text-xs text-gray-400">
(Chart integration can be added with Chart.js, D3.js, or similar library)
</p>
</div>
</div>
</div>
</div>
<!-- No Data State -->
<div v-if="usageStats && usageStats.totalRequests === 0" class="py-12 text-center">
<svg
class="mx-auto h-12 w-12 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
/>
</svg>
<h3 class="mt-2 text-sm font-medium text-gray-900">No usage data</h3>
<p class="mt-1 text-sm text-gray-500">
This user hasn't made any API requests in the selected period.
</p>
</div>
</div>
<div class="mt-6 flex justify-end">
<button
class="rounded-md border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
@click="$emit('close')"
>
Close
</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, watch } from 'vue'
import { apiClient } from '@/config/api'
import { showToast } from '@/utils/toast'
const props = defineProps({
show: {
type: Boolean,
default: false
},
user: {
type: Object,
default: null
}
})
const emit = defineEmits(['close'])
const loading = ref(false)
const selectedPeriod = ref('week')
const usageStats = ref(null)
const userDetails = ref(null)
const formatNumber = (num) => {
if (num >= 1000000) {
return (num / 1000000).toFixed(1) + 'M'
} else if (num >= 1000) {
return (num / 1000).toFixed(1) + 'K'
}
return num.toString()
}
const formatDate = (dateString) => {
if (!dateString) return null
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
}
const loadUsageStats = async () => {
if (!props.user) return
loading.value = true
try {
const [statsResponse, userResponse] = await Promise.all([
apiClient.get(`/users/${props.user.id}/usage-stats`, {
params: { period: selectedPeriod.value }
}),
apiClient.get(`/users/${props.user.id}`)
])
if (statsResponse.success) {
usageStats.value = statsResponse.stats
}
if (userResponse.success) {
userDetails.value = userResponse.user
}
} catch (error) {
console.error('Failed to load user usage stats:', error)
showToast('Failed to load usage statistics', 'error')
} finally {
loading.value = false
}
}
// Watch for when modal is shown and user changes
watch([() => props.show, () => props.user], ([show, user]) => {
if (show && user) {
loadUsageStats()
}
})
</script>
<style scoped>
/* 组件特定样式 */
</style>

View File

@@ -127,7 +127,7 @@
<div class="flex gap-2"> <div class="flex gap-2">
<input <input
v-model="newTag" v-model="newTag"
class="form-input flex-1 border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200" class="form-input flex-1 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200"
placeholder="输入新标签名称" placeholder="输入新标签名称"
type="text" type="text"
@keypress.enter.prevent="addTag" @keypress.enter.prevent="addTag"
@@ -166,7 +166,7 @@
</label> </label>
<input <input
v-model="form.rateLimitWindow" v-model="form.rateLimitWindow"
class="form-input w-full border-gray-300 text-sm dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200" class="form-input w-full text-sm dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200"
min="1" min="1"
placeholder="不修改" placeholder="不修改"
type="number" type="number"
@@ -179,7 +179,7 @@
> >
<input <input
v-model="form.rateLimitRequests" v-model="form.rateLimitRequests"
class="form-input w-full border-gray-300 text-sm dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200" class="form-input w-full text-sm dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200"
min="1" min="1"
placeholder="不修改" placeholder="不修改"
type="number" type="number"
@@ -188,14 +188,12 @@
<div> <div>
<label class="mb-1 block text-xs font-medium text-gray-700 dark:text-gray-300" <label class="mb-1 block text-xs font-medium text-gray-700 dark:text-gray-300"
>费用限制 (美元)</label >Token 限制</label
> >
<input <input
v-model="form.rateLimitCost" v-model="form.tokenLimit"
class="form-input w-full border-gray-300 text-sm dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200" class="form-input w-full text-sm dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200"
min="0"
placeholder="不修改" placeholder="不修改"
step="0.01"
type="number" type="number"
/> />
</div> </div>
@@ -210,7 +208,7 @@
</label> </label>
<input <input
v-model="form.dailyCostLimit" v-model="form.dailyCostLimit"
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200" class="form-input w-full dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200"
min="0" min="0"
placeholder="不修改 (0 表示无限制)" placeholder="不修改 (0 表示无限制)"
step="0.01" step="0.01"
@@ -218,24 +216,6 @@
/> />
</div> </div>
<!-- Opus 模型周费用限制 -->
<div>
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300">
Opus 模型周费用限制 (美元)
</label>
<input
v-model="form.weeklyOpusCostLimit"
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200"
min="0"
placeholder="不修改 (0 表示无限制)"
step="0.01"
type="number"
/>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
设置 Opus 模型的周费用限制周一到周日仅限 Claude 官方账户
</p>
</div>
<!-- 并发限制 --> <!-- 并发限制 -->
<div> <div>
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300" <label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
@@ -243,7 +223,7 @@
> >
<input <input
v-model="form.concurrencyLimit" v-model="form.concurrencyLimit"
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200" class="form-input w-full dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200"
min="0" min="0"
placeholder="不修改 (0 表示无限制)" placeholder="不修改 (0 表示无限制)"
type="number" type="number"
@@ -330,7 +310,7 @@
> >
<select <select
v-model="form.claudeAccountId" v-model="form.claudeAccountId"
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200" class="form-input w-full dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200"
:disabled="form.permissions === 'gemini' || form.permissions === 'openai'" :disabled="form.permissions === 'gemini' || form.permissions === 'openai'"
> >
<option value="">不修改</option> <option value="">不修改</option>
@@ -365,7 +345,7 @@
> >
<select <select
v-model="form.geminiAccountId" v-model="form.geminiAccountId"
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200" class="form-input w-full dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200"
:disabled="form.permissions === 'claude' || form.permissions === 'openai'" :disabled="form.permissions === 'claude' || form.permissions === 'openai'"
> >
<option value="">不修改</option> <option value="">不修改</option>
@@ -396,7 +376,7 @@
> >
<select <select
v-model="form.openaiAccountId" v-model="form.openaiAccountId"
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200" class="form-input w-full dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200"
:disabled="form.permissions === 'claude' || form.permissions === 'gemini'" :disabled="form.permissions === 'claude' || form.permissions === 'gemini'"
> >
<option value="">不修改</option> <option value="">不修改</option>
@@ -427,7 +407,7 @@
> >
<select <select
v-model="form.bedrockAccountId" v-model="form.bedrockAccountId"
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200" class="form-input w-full dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200"
:disabled="form.permissions === 'gemini' || form.permissions === 'openai'" :disabled="form.permissions === 'gemini' || form.permissions === 'openai'"
> >
<option value="">不修改</option> <option value="">不修改</option>
@@ -516,12 +496,11 @@ const unselectedTags = computed(() => {
// 表单数据 // 表单数据
const form = reactive({ const form = reactive({
rateLimitCost: '', // 费用限制替代token限制 tokenLimit: '',
rateLimitWindow: '', rateLimitWindow: '',
rateLimitRequests: '', rateLimitRequests: '',
concurrencyLimit: '', concurrencyLimit: '',
dailyCostLimit: '', dailyCostLimit: '',
weeklyOpusCostLimit: '', // 新增Opus周费用限制
permissions: '', // 空字符串表示不修改 permissions: '', // 空字符串表示不修改
claudeAccountId: '', claudeAccountId: '',
geminiAccountId: '', geminiAccountId: '',
@@ -637,8 +616,8 @@ const batchUpdateApiKeys = async () => {
const updates = {} const updates = {}
// 只有非空值才添加到更新对象中 // 只有非空值才添加到更新对象中
if (form.rateLimitCost !== '' && form.rateLimitCost !== null) { if (form.tokenLimit !== '' && form.tokenLimit !== null) {
updates.rateLimitCost = parseFloat(form.rateLimitCost) updates.tokenLimit = parseInt(form.tokenLimit)
} }
if (form.rateLimitWindow !== '' && form.rateLimitWindow !== null) { if (form.rateLimitWindow !== '' && form.rateLimitWindow !== null) {
updates.rateLimitWindow = parseInt(form.rateLimitWindow) updates.rateLimitWindow = parseInt(form.rateLimitWindow)
@@ -652,9 +631,6 @@ const batchUpdateApiKeys = async () => {
if (form.dailyCostLimit !== '' && form.dailyCostLimit !== null) { if (form.dailyCostLimit !== '' && form.dailyCostLimit !== null) {
updates.dailyCostLimit = parseFloat(form.dailyCostLimit) updates.dailyCostLimit = parseFloat(form.dailyCostLimit)
} }
if (form.weeklyOpusCostLimit !== '' && form.weeklyOpusCostLimit !== null) {
updates.weeklyOpusCostLimit = parseFloat(form.weeklyOpusCostLimit)
}
// 权限设置 // 权限设置
if (form.permissions !== '') { if (form.permissions !== '') {

View File

@@ -81,7 +81,7 @@
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<input <input
v-model.number="form.batchCount" v-model.number="form.batchCount"
class="form-input w-full border-gray-300 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400" class="form-input w-full text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
max="500" max="500"
min="2" min="2"
placeholder="输入数量 (2-500)" placeholder="输入数量 (2-500)"
@@ -110,21 +110,19 @@
class="mb-1.5 block text-xs font-semibold text-gray-700 dark:text-gray-300 sm:mb-2 sm:text-sm" class="mb-1.5 block text-xs font-semibold text-gray-700 dark:text-gray-300 sm:mb-2 sm:text-sm"
>名称 <span class="text-red-500">*</span></label >名称 <span class="text-red-500">*</span></label
> >
<div> <input
<input v-model="form.name"
v-model="form.name" class="form-input w-full text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
class="form-input flex-1 border-gray-300 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400" :class="{ 'border-red-500': errors.name }"
:class="{ 'border-red-500': errors.name }" :placeholder="
:placeholder=" form.createType === 'batch'
form.createType === 'batch' ? '输入基础名称(将自动添加序号)'
? '输入基础名称(将自动添加序号)' : '为您的 API Key 取一个名称'
: '为您的 API Key 取一个名称' "
" required
required type="text"
type="text" @input="errors.name = ''"
@input="errors.name = ''" />
/>
</div>
<p v-if="errors.name" class="mt-1 text-xs text-red-500 dark:text-red-400"> <p v-if="errors.name" class="mt-1 text-xs text-red-500 dark:text-red-400">
{{ errors.name }} {{ errors.name }}
</p> </p>
@@ -186,7 +184,7 @@
<div class="flex gap-2"> <div class="flex gap-2">
<input <input
v-model="newTag" v-model="newTag"
class="form-input flex-1 border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400" class="form-input flex-1 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
placeholder="输入新标签名称" placeholder="输入新标签名称"
type="text" type="text"
@keypress.enter.prevent="addTag" @keypress.enter.prevent="addTag"
@@ -230,7 +228,7 @@
> >
<input <input
v-model="form.rateLimitWindow" v-model="form.rateLimitWindow"
class="form-input w-full border-gray-300 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400" class="form-input w-full text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
min="1" min="1"
placeholder="无限制" placeholder="无限制"
type="number" type="number"
@@ -244,7 +242,7 @@
> >
<input <input
v-model="form.rateLimitRequests" v-model="form.rateLimitRequests"
class="form-input w-full border-gray-300 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400" class="form-input w-full text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
min="1" min="1"
placeholder="无限制" placeholder="无限制"
type="number" type="number"
@@ -254,17 +252,17 @@
<div> <div>
<label class="mb-1 block text-xs font-medium text-gray-700 dark:text-gray-300" <label class="mb-1 block text-xs font-medium text-gray-700 dark:text-gray-300"
>费用限制 (美元)</label >Token 限制</label
> >
<input <input
v-model="form.rateLimitCost" v-model="form.tokenLimit"
class="form-input w-full border-gray-300 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400" class="form-input w-full text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
min="0"
placeholder="无限制" placeholder="无限制"
step="0.01"
type="number" type="number"
/> />
<p class="ml-2 mt-0.5 text-xs text-gray-500 dark:text-gray-400">窗口内最大费用</p> <p class="ml-2 mt-0.5 text-xs text-gray-500 dark:text-gray-400">
窗口内最大Token
</p>
</div> </div>
</div> </div>
@@ -277,9 +275,12 @@
<div> <div>
<strong>示例1:</strong> 时间窗口=60请求次数=1000 每60分钟最多1000次请求 <strong>示例1:</strong> 时间窗口=60请求次数=1000 每60分钟最多1000次请求
</div> </div>
<div><strong>示例2:</strong> 时间窗口=1费用=0.1 每分钟最多$0.1费用</div>
<div> <div>
<strong>示例3:</strong> 窗口=30请求=50费用=5 每30分钟50次请求且不超$5费用 <strong>示例2:</strong> 时间窗口=1Token=10000 每分钟最多10,000个Token
</div>
<div>
<strong>示例3:</strong> 窗口=30请求=50Token=100000
每30分钟50次请求且不超10万Token
</div> </div>
</div> </div>
</div> </div>
@@ -323,7 +324,7 @@
</div> </div>
<input <input
v-model="form.dailyCostLimit" v-model="form.dailyCostLimit"
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400" class="form-input w-full dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
min="0" min="0"
placeholder="0 表示无限制" placeholder="0 表示无限制"
step="0.01" step="0.01"
@@ -335,62 +336,13 @@
</div> </div>
</div> </div>
<div>
<label class="mb-2 block text-sm font-semibold text-gray-700 dark:text-gray-300"
>Opus 模型周费用限制 (美元)</label
>
<div class="space-y-2">
<div class="flex gap-2">
<button
class="rounded bg-gray-100 px-2 py-1 text-xs font-medium hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
type="button"
@click="form.weeklyOpusCostLimit = '100'"
>
$100
</button>
<button
class="rounded bg-gray-100 px-2 py-1 text-xs font-medium hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
type="button"
@click="form.weeklyOpusCostLimit = '500'"
>
$500
</button>
<button
class="rounded bg-gray-100 px-2 py-1 text-xs font-medium hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
type="button"
@click="form.weeklyOpusCostLimit = '1000'"
>
$1000
</button>
<button
class="rounded bg-gray-100 px-2 py-1 text-xs font-medium hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
type="button"
@click="form.weeklyOpusCostLimit = ''"
>
自定义
</button>
</div>
<input
v-model="form.weeklyOpusCostLimit"
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
min="0"
placeholder="0 表示无限制"
step="0.01"
type="number"
/>
<p class="text-xs text-gray-500 dark:text-gray-400">
设置 Opus 模型的周费用限制周一到周日仅限 Claude 官方账户0 或留空表示无限制
</p>
</div>
</div>
<div> <div>
<label class="mb-2 block text-sm font-semibold text-gray-700 dark:text-gray-300" <label class="mb-2 block text-sm font-semibold text-gray-700 dark:text-gray-300"
>并发限制 (可选)</label >并发限制 (可选)</label
> >
<input <input
v-model="form.concurrencyLimit" v-model="form.concurrencyLimit"
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400" class="form-input w-full dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
min="0" min="0"
placeholder="0 表示无限制" placeholder="0 表示无限制"
type="number" type="number"
@@ -406,7 +358,7 @@
> >
<textarea <textarea
v-model="form.description" v-model="form.description"
class="form-input w-full resize-none border-gray-300 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400" class="form-input w-full resize-none text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
placeholder="描述此 API Key 的用途..." placeholder="描述此 API Key 的用途..."
rows="2" rows="2"
/> />
@@ -414,103 +366,34 @@
<div> <div>
<label class="mb-2 block text-sm font-semibold text-gray-700 dark:text-gray-300" <label class="mb-2 block text-sm font-semibold text-gray-700 dark:text-gray-300"
>过期设置</label >有效期限</label
> >
<!-- 过期模式选择 --> <select
<div v-model="form.expireDuration"
class="mb-3 rounded-lg border border-gray-200 bg-gray-50 p-3 dark:border-gray-700 dark:bg-gray-800" class="form-input w-full dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200"
@change="updateExpireAt"
> >
<div class="flex items-center gap-4"> <option value="">永不过期</option>
<label class="flex cursor-pointer items-center"> <option value="1d">1 </option>
<input <option value="7d">7 </option>
v-model="form.expirationMode" <option value="30d">30 </option>
class="mr-2 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700" <option value="90d">90 </option>
type="radio" <option value="180d">180 </option>
value="fixed" <option value="365d">365 </option>
/> <option value="custom">自定义日期</option>
<span class="text-sm text-gray-700 dark:text-gray-300">固定时间过期</span> </select>
</label> <div v-if="form.expireDuration === 'custom'" class="mt-3">
<label class="flex cursor-pointer items-center"> <input
<input v-model="form.customExpireDate"
v-model="form.expirationMode" class="form-input w-full dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200"
class="mr-2 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700" :min="minDateTime"
type="radio" type="datetime-local"
value="activation" @change="updateCustomExpireAt"
/> />
<span class="text-sm text-gray-700 dark:text-gray-300">首次使用后激活</span>
</label>
</div>
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
<span v-if="form.expirationMode === 'fixed'">
<i class="fas fa-info-circle mr-1" />
固定时间模式Key 创建后立即生效按设定时间过期
</span>
<span v-else>
<i class="fas fa-info-circle mr-1" />
激活模式Key 首次使用时激活激活后按设定天数过期适合批量销售
</span>
</p>
</div>
<!-- 固定时间模式 -->
<div v-if="form.expirationMode === 'fixed'">
<select
v-model="form.expireDuration"
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200"
@change="updateExpireAt"
>
<option value="">永不过期</option>
<option value="1d">1 </option>
<option value="7d">7 </option>
<option value="30d">30 </option>
<option value="90d">90 </option>
<option value="180d">180 </option>
<option value="365d">365 </option>
<option value="custom">自定义日期</option>
</select>
<div v-if="form.expireDuration === 'custom'" class="mt-3">
<input
v-model="form.customExpireDate"
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200"
:min="minDateTime"
type="datetime-local"
@change="updateCustomExpireAt"
/>
</div>
<p v-if="form.expiresAt" class="mt-2 text-xs text-gray-500 dark:text-gray-400">
将于 {{ formatExpireDate(form.expiresAt) }} 过期
</p>
</div>
<!-- 激活模式 -->
<div v-else>
<div class="flex items-center gap-2">
<input
v-model.number="form.activationDays"
class="form-input flex-1 border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200"
max="3650"
min="1"
placeholder="输入天数"
type="number"
/>
<span class="text-sm text-gray-600 dark:text-gray-400"></span>
</div>
<div class="mt-2 flex flex-wrap gap-2">
<button
v-for="days in [30, 90, 180, 365]"
:key="days"
class="rounded-md border border-gray-300 px-3 py-1 text-xs hover:bg-gray-100 dark:border-gray-600 dark:hover:bg-gray-700"
type="button"
@click="form.activationDays = days"
>
{{ days }}
</button>
</div>
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
<i class="fas fa-clock mr-1" />
Key 将在首次使用后激活激活后 {{ form.activationDays || 30 }} 天过期
</p>
</div> </div>
<p v-if="form.expiresAt" class="mt-2 text-xs text-gray-500 dark:text-gray-400">
将于 {{ formatExpireDate(form.expiresAt) }} 过期
</p>
</div> </div>
<div> <div>
@@ -856,17 +739,14 @@ const form = reactive({
batchCount: 10, batchCount: 10,
name: '', name: '',
description: '', description: '',
tokenLimit: '',
rateLimitWindow: '', rateLimitWindow: '',
rateLimitRequests: '', rateLimitRequests: '',
rateLimitCost: '', // 新增:费用限制
concurrencyLimit: '', concurrencyLimit: '',
dailyCostLimit: '', dailyCostLimit: '',
weeklyOpusCostLimit: '',
expireDuration: '', expireDuration: '',
customExpireDate: '', customExpireDate: '',
expiresAt: null, expiresAt: null,
expirationMode: 'fixed', // 过期模式fixed(固定) 或 activation(激活)
activationDays: 30, // 激活后有效天数
permissions: 'all', permissions: 'all',
claudeAccountId: '', claudeAccountId: '',
geminiAccountId: '', geminiAccountId: '',
@@ -1105,32 +985,14 @@ const createApiKey = async () => {
} }
} }
// 检查是否设置了时间窗口但费用限制为0
if (form.rateLimitWindow && (!form.rateLimitCost || parseFloat(form.rateLimitCost) === 0)) {
let confirmed = false
if (window.showConfirm) {
confirmed = await window.showConfirm(
'费用限制提醒',
'您设置了时间窗口但费用限制为0这意味着不会有费用限制。\n\n是否继续',
'继续创建',
'返回修改'
)
} else {
// 降级方案
confirmed = confirm('您设置了时间窗口但费用限制为0这意味着不会有费用限制。\n是否继续')
}
if (!confirmed) {
return
}
}
loading.value = true loading.value = true
try { try {
// 准备提交的数据 // 准备提交的数据
const baseData = { const baseData = {
description: form.description || undefined, description: form.description || undefined,
tokenLimit: 0, // 设置为0清除历史token限制 tokenLimit:
form.tokenLimit !== '' && form.tokenLimit !== null ? parseInt(form.tokenLimit) : null,
rateLimitWindow: rateLimitWindow:
form.rateLimitWindow !== '' && form.rateLimitWindow !== null form.rateLimitWindow !== '' && form.rateLimitWindow !== null
? parseInt(form.rateLimitWindow) ? parseInt(form.rateLimitWindow)
@@ -1139,10 +1001,6 @@ const createApiKey = async () => {
form.rateLimitRequests !== '' && form.rateLimitRequests !== null form.rateLimitRequests !== '' && form.rateLimitRequests !== null
? parseInt(form.rateLimitRequests) ? parseInt(form.rateLimitRequests)
: null, : null,
rateLimitCost:
form.rateLimitCost !== '' && form.rateLimitCost !== null
? parseFloat(form.rateLimitCost)
: null,
concurrencyLimit: concurrencyLimit:
form.concurrencyLimit !== '' && form.concurrencyLimit !== null form.concurrencyLimit !== '' && form.concurrencyLimit !== null
? parseInt(form.concurrencyLimit) ? parseInt(form.concurrencyLimit)
@@ -1151,13 +1009,7 @@ const createApiKey = async () => {
form.dailyCostLimit !== '' && form.dailyCostLimit !== null form.dailyCostLimit !== '' && form.dailyCostLimit !== null
? parseFloat(form.dailyCostLimit) ? parseFloat(form.dailyCostLimit)
: 0, : 0,
weeklyOpusCostLimit: expiresAt: form.expiresAt || undefined,
form.weeklyOpusCostLimit !== '' && form.weeklyOpusCostLimit !== null
? parseFloat(form.weeklyOpusCostLimit)
: 0,
expiresAt: form.expirationMode === 'fixed' ? form.expiresAt || undefined : undefined,
expirationMode: form.expirationMode,
activationDays: form.expirationMode === 'activation' ? form.activationDays : undefined,
permissions: form.permissions, permissions: form.permissions,
tags: form.tags.length > 0 ? form.tags : undefined, tags: form.tags.length > 0 ? form.tags : undefined,
enableModelRestriction: form.enableModelRestriction, enableModelRestriction: form.enableModelRestriction,

View File

@@ -32,39 +32,13 @@
class="mb-1.5 block text-xs font-semibold text-gray-700 dark:text-gray-300 sm:mb-3 sm:text-sm" class="mb-1.5 block text-xs font-semibold text-gray-700 dark:text-gray-300 sm:mb-3 sm:text-sm"
>名称</label >名称</label
> >
<div> <input
<input class="form-input w-full cursor-not-allowed bg-gray-100 text-sm dark:border-gray-600 dark:bg-gray-800 dark:text-gray-400"
v-model="form.name" disabled
class="form-input flex-1 border-gray-300 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400" type="text"
maxlength="100" :value="form.name"
placeholder="请输入API Key名称" />
required <p class="mt-1 text-xs text-gray-500 dark:text-gray-400 sm:mt-2">名称不可修改</p>
type="text"
/>
</div>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400 sm:mt-2">
用于识别此 API Key 的用途
</p>
</div>
<!-- 所有者选择 -->
<div>
<label
class="mb-1.5 block text-xs font-semibold text-gray-700 dark:text-gray-300 sm:mb-3 sm:text-sm"
>所有者</label
>
<select
v-model="form.ownerId"
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200"
>
<option v-for="user in availableUsers" :key="user.id" :value="user.id">
{{ user.displayName }} ({{ user.username }})
<span v-if="user.role === 'admin'" class="text-gray-500">- 管理员</span>
</option>
</select>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400 sm:mt-2">
分配此 API Key 给指定用户或管理员管理员分配时不受用户 API Key 数量限制
</p>
</div> </div>
<!-- 标签 --> <!-- 标签 -->
@@ -124,7 +98,7 @@
<div class="flex gap-2"> <div class="flex gap-2">
<input <input
v-model="newTag" v-model="newTag"
class="form-input flex-1 border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400" class="form-input flex-1 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
placeholder="输入新标签名称" placeholder="输入新标签名称"
type="text" type="text"
@keypress.enter.prevent="addTag" @keypress.enter.prevent="addTag"
@@ -168,7 +142,7 @@
> >
<input <input
v-model="form.rateLimitWindow" v-model="form.rateLimitWindow"
class="form-input w-full border-gray-300 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400" class="form-input w-full text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
min="1" min="1"
placeholder="无限制" placeholder="无限制"
type="number" type="number"
@@ -182,7 +156,7 @@
> >
<input <input
v-model="form.rateLimitRequests" v-model="form.rateLimitRequests"
class="form-input w-full border-gray-300 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400" class="form-input w-full text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
min="1" min="1"
placeholder="无限制" placeholder="无限制"
type="number" type="number"
@@ -192,17 +166,17 @@
<div> <div>
<label class="mb-1 block text-xs font-medium text-gray-700 dark:text-gray-300" <label class="mb-1 block text-xs font-medium text-gray-700 dark:text-gray-300"
>费用限制 (美元)</label >Token 限制</label
> >
<input <input
v-model="form.rateLimitCost" v-model="form.tokenLimit"
class="form-input w-full border-gray-300 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400" class="form-input w-full text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
min="0"
placeholder="无限制" placeholder="无限制"
step="0.01"
type="number" type="number"
/> />
<p class="ml-2 mt-0.5 text-xs text-gray-500 dark:text-gray-400">窗口内最大费用</p> <p class="ml-2 mt-0.5 text-xs text-gray-500 dark:text-gray-400">
窗口内最大Token
</p>
</div> </div>
</div> </div>
@@ -215,9 +189,12 @@
<div> <div>
<strong>示例1:</strong> 时间窗口=60请求次数=1000 每60分钟最多1000次请求 <strong>示例1:</strong> 时间窗口=60请求次数=1000 每60分钟最多1000次请求
</div> </div>
<div><strong>示例2:</strong> 时间窗口=1费用=0.1 每分钟最多$0.1费用</div>
<div> <div>
<strong>示例3:</strong> 窗口=30请求=50费用=5 每30分钟50次请求且不超$5费用 <strong>示例2:</strong> 时间窗口=1Token=10000 每分钟最多10,000个Token
</div>
<div>
<strong>示例3:</strong> 窗口=30请求=50Token=100000
每30分钟50次请求且不超10万Token
</div> </div>
</div> </div>
</div> </div>
@@ -261,7 +238,7 @@
</div> </div>
<input <input
v-model="form.dailyCostLimit" v-model="form.dailyCostLimit"
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400" class="form-input w-full dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
min="0" min="0"
placeholder="0 表示无限制" placeholder="0 表示无限制"
step="0.01" step="0.01"
@@ -273,62 +250,13 @@
</div> </div>
</div> </div>
<div>
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
>Opus 模型周费用限制 (美元)</label
>
<div class="space-y-3">
<div class="flex gap-2">
<button
class="rounded-lg bg-gray-100 px-3 py-1 text-sm font-medium hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
type="button"
@click="form.weeklyOpusCostLimit = '100'"
>
$100
</button>
<button
class="rounded-lg bg-gray-100 px-3 py-1 text-sm font-medium hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
type="button"
@click="form.weeklyOpusCostLimit = '500'"
>
$500
</button>
<button
class="rounded-lg bg-gray-100 px-3 py-1 text-sm font-medium hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
type="button"
@click="form.weeklyOpusCostLimit = '1000'"
>
$1000
</button>
<button
class="rounded-lg bg-gray-100 px-3 py-1 text-sm font-medium hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
type="button"
@click="form.weeklyOpusCostLimit = ''"
>
自定义
</button>
</div>
<input
v-model="form.weeklyOpusCostLimit"
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
min="0"
placeholder="0 表示无限制"
step="0.01"
type="number"
/>
<p class="text-xs text-gray-500 dark:text-gray-400">
设置 Opus 模型的周费用限制周一到周日仅限 Claude 官方账户0 或留空表示无限制
</p>
</div>
</div>
<div> <div>
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300" <label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
>并发限制</label >并发限制</label
> >
<input <input
v-model="form.concurrencyLimit" v-model="form.concurrencyLimit"
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400" class="form-input w-full dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
min="0" min="0"
placeholder="0 表示无限制" placeholder="0 表示无限制"
type="number" type="number"
@@ -560,7 +488,7 @@
<div class="flex gap-2"> <div class="flex gap-2">
<input <input
v-model="form.modelInput" v-model="form.modelInput"
class="form-input flex-1 border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400" class="form-input flex-1 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
placeholder="输入模型名称,按回车添加" placeholder="输入模型名称,按回车添加"
type="text" type="text"
@keydown.enter.prevent="addRestrictedModel" @keydown.enter.prevent="addRestrictedModel"
@@ -692,9 +620,6 @@ const localAccounts = ref({
// 支持的客户端列表 // 支持的客户端列表
const supportedClients = ref([]) const supportedClients = ref([])
// 可用用户列表
const availableUsers = ref([])
// 标签相关 // 标签相关
const newTag = ref('') const newTag = ref('')
const availableTags = ref([]) const availableTags = ref([])
@@ -707,13 +632,11 @@ const unselectedTags = computed(() => {
// 表单数据 // 表单数据
const form = reactive({ const form = reactive({
name: '', name: '',
tokenLimit: '', // 保留用于检测历史数据 tokenLimit: '',
rateLimitWindow: '', rateLimitWindow: '',
rateLimitRequests: '', rateLimitRequests: '',
rateLimitCost: '', // 新增:费用限制
concurrencyLimit: '', concurrencyLimit: '',
dailyCostLimit: '', dailyCostLimit: '',
weeklyOpusCostLimit: '',
permissions: 'all', permissions: 'all',
claudeAccountId: '', claudeAccountId: '',
geminiAccountId: '', geminiAccountId: '',
@@ -725,8 +648,7 @@ const form = reactive({
enableClientRestriction: false, enableClientRestriction: false,
allowedClients: [], allowedClients: [],
tags: [], tags: [],
isActive: true, isActive: true
ownerId: '' // 新增所有者ID
}) })
// 添加限制的模型 // 添加限制的模型
@@ -780,32 +702,13 @@ const removeTag = (index) => {
// 更新 API Key // 更新 API Key
const updateApiKey = async () => { const updateApiKey = async () => {
// 检查是否设置了时间窗口但费用限制为0
if (form.rateLimitWindow && (!form.rateLimitCost || parseFloat(form.rateLimitCost) === 0)) {
let confirmed = false
if (window.showConfirm) {
confirmed = await window.showConfirm(
'费用限制提醒',
'您设置了时间窗口但费用限制为0这意味着不会有费用限制。\n\n是否继续',
'继续保存',
'返回修改'
)
} else {
// 降级方案
confirmed = confirm('您设置了时间窗口但费用限制为0这意味着不会有费用限制。\n是否继续')
}
if (!confirmed) {
return
}
}
loading.value = true loading.value = true
try { try {
// 准备提交的数据 // 准备提交的数据
const data = { const data = {
name: form.name, // 添加名称字段 tokenLimit:
tokenLimit: 0, // 清除历史token限制 form.tokenLimit !== '' && form.tokenLimit !== null ? parseInt(form.tokenLimit) : 0,
rateLimitWindow: rateLimitWindow:
form.rateLimitWindow !== '' && form.rateLimitWindow !== null form.rateLimitWindow !== '' && form.rateLimitWindow !== null
? parseInt(form.rateLimitWindow) ? parseInt(form.rateLimitWindow)
@@ -814,10 +717,6 @@ const updateApiKey = async () => {
form.rateLimitRequests !== '' && form.rateLimitRequests !== null form.rateLimitRequests !== '' && form.rateLimitRequests !== null
? parseInt(form.rateLimitRequests) ? parseInt(form.rateLimitRequests)
: 0, : 0,
rateLimitCost:
form.rateLimitCost !== '' && form.rateLimitCost !== null
? parseFloat(form.rateLimitCost)
: 0,
concurrencyLimit: concurrencyLimit:
form.concurrencyLimit !== '' && form.concurrencyLimit !== null form.concurrencyLimit !== '' && form.concurrencyLimit !== null
? parseInt(form.concurrencyLimit) ? parseInt(form.concurrencyLimit)
@@ -826,10 +725,6 @@ const updateApiKey = async () => {
form.dailyCostLimit !== '' && form.dailyCostLimit !== null form.dailyCostLimit !== '' && form.dailyCostLimit !== null
? parseFloat(form.dailyCostLimit) ? parseFloat(form.dailyCostLimit)
: 0, : 0,
weeklyOpusCostLimit:
form.weeklyOpusCostLimit !== '' && form.weeklyOpusCostLimit !== null
? parseFloat(form.weeklyOpusCostLimit)
: 0,
permissions: form.permissions, permissions: form.permissions,
tags: form.tags tags: form.tags
} }
@@ -887,11 +782,6 @@ const updateApiKey = async () => {
// 活跃状态 // 活跃状态
data.isActive = form.isActive data.isActive = form.isActive
// 所有者
if (form.ownerId !== undefined) {
data.ownerId = form.ownerId
}
const result = await apiClient.put(`/admin/api-keys/${props.apiKey.id}`, data) const result = await apiClient.put(`/admin/api-keys/${props.apiKey.id}`, data)
if (result.success) { if (result.success) {
@@ -983,45 +873,11 @@ const refreshAccounts = async () => {
} }
} }
// 加载用户列表
const loadUsers = async () => {
try {
const response = await apiClient.get('/admin/users')
if (response.success) {
availableUsers.value = response.data || []
}
} catch (error) {
console.error('Failed to load users:', error)
availableUsers.value = [
{
id: 'admin',
username: 'admin',
displayName: 'Admin',
email: '',
role: 'admin'
}
]
}
}
// 初始化表单数据 // 初始化表单数据
onMounted(async () => { onMounted(async () => {
try { // 加载支持的客户端和已存在的标签
// 并行加载所有需要的数据 supportedClients.value = await clientsStore.loadSupportedClients()
const [clients, tags] = await Promise.all([ availableTags.value = await apiKeysStore.fetchTags()
clientsStore.loadSupportedClients(),
apiKeysStore.fetchTags(),
loadUsers()
])
supportedClients.value = clients || []
availableTags.value = tags || []
} catch (error) {
console.error('Error loading initial data:', error)
// Fallback to empty arrays if loading fails
supportedClients.value = []
availableTags.value = []
}
// 初始化账号数据 // 初始化账号数据
if (props.accounts) { if (props.accounts) {
@@ -1037,22 +893,11 @@ onMounted(async () => {
} }
form.name = props.apiKey.name form.name = props.apiKey.name
// 处理速率限制迁移如果有tokenLimit且没有rateLimitCost提示用户
form.tokenLimit = props.apiKey.tokenLimit || '' form.tokenLimit = props.apiKey.tokenLimit || ''
form.rateLimitCost = props.apiKey.rateLimitCost || ''
// 如果有历史tokenLimit但没有rateLimitCost提示用户需要重新设置
if (props.apiKey.tokenLimit > 0 && !props.apiKey.rateLimitCost) {
// 可以根据需要添加提示,或者自动迁移(这里选择让用户手动设置)
console.log('检测到历史Token限制请考虑设置费用限制')
}
form.rateLimitWindow = props.apiKey.rateLimitWindow || '' form.rateLimitWindow = props.apiKey.rateLimitWindow || ''
form.rateLimitRequests = props.apiKey.rateLimitRequests || '' form.rateLimitRequests = props.apiKey.rateLimitRequests || ''
form.concurrencyLimit = props.apiKey.concurrencyLimit || '' form.concurrencyLimit = props.apiKey.concurrencyLimit || ''
form.dailyCostLimit = props.apiKey.dailyCostLimit || '' form.dailyCostLimit = props.apiKey.dailyCostLimit || ''
form.weeklyOpusCostLimit = props.apiKey.weeklyOpusCostLimit || ''
form.permissions = props.apiKey.permissions || 'all' form.permissions = props.apiKey.permissions || 'all'
// 处理 Claude 账号(区分 OAuth 和 Console // 处理 Claude 账号(区分 OAuth 和 Console
if (props.apiKey.claudeConsoleAccountId) { if (props.apiKey.claudeConsoleAccountId) {
@@ -1071,9 +916,6 @@ onMounted(async () => {
form.enableClientRestriction = props.apiKey.enableClientRestriction || false form.enableClientRestriction = props.apiKey.enableClientRestriction || false
// 初始化活跃状态,默认为 true // 初始化活跃状态,默认为 true
form.isActive = props.apiKey.isActive !== undefined ? props.apiKey.isActive : true form.isActive = props.apiKey.isActive !== undefined ? props.apiKey.isActive : true
// 初始化所有者
form.ownerId = props.apiKey.userId || 'admin'
}) })
</script> </script>

View File

@@ -39,18 +39,11 @@
> >
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
<p class="mb-1 text-xs font-medium text-gray-600 dark:text-gray-400">当前状态</p> <p class="mb-1 text-xs font-medium text-gray-600 dark:text-gray-400">
当前过期时间
</p>
<p class="text-sm font-semibold text-gray-800 dark:text-gray-200"> <p class="text-sm font-semibold text-gray-800 dark:text-gray-200">
<!-- 未激活状态 --> <template v-if="apiKey.expiresAt">
<template v-if="apiKey.expirationMode === 'activation' && !apiKey.isActivated">
<i class="fas fa-pause-circle mr-1 text-blue-500" />
未激活
<span class="ml-2 text-xs font-normal text-gray-600">
(激活后 {{ apiKey.activationDays || 30 }} 天过期)
</span>
</template>
<!-- 已设置过期时间 -->
<template v-else-if="apiKey.expiresAt">
{{ formatExpireDate(apiKey.expiresAt) }} {{ formatExpireDate(apiKey.expiresAt) }}
<span <span
v-if="getExpiryStatus(apiKey.expiresAt)" v-if="getExpiryStatus(apiKey.expiresAt)"
@@ -60,7 +53,6 @@
({{ getExpiryStatus(apiKey.expiresAt).text }}) ({{ getExpiryStatus(apiKey.expiresAt).text }})
</span> </span>
</template> </template>
<!-- 永不过期 -->
<template v-else> <template v-else>
<i class="fas fa-infinity mr-1 text-gray-500" /> <i class="fas fa-infinity mr-1 text-gray-500" />
永不过期 永不过期
@@ -82,21 +74,6 @@
</div> </div>
</div> </div>
<!-- 激活按钮仅在未激活状态显示 -->
<div v-if="apiKey.expirationMode === 'activation' && !apiKey.isActivated" class="mb-4">
<button
class="w-full rounded-lg bg-gradient-to-r from-blue-500 to-blue-600 px-4 py-3 font-semibold text-white transition-all hover:from-blue-600 hover:to-blue-700 hover:shadow-lg"
@click="handleActivateNow"
>
<i class="fas fa-rocket mr-2" />
立即激活 (激活后 {{ apiKey.activationDays || 30 }} 天过期)
</button>
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
<i class="fas fa-info-circle mr-1" />
点击立即激活此 API Key激活后将在 {{ apiKey.activationDays || 30 }} 天后过期
</p>
</div>
<!-- 快捷选项 --> <!-- 快捷选项 -->
<div> <div>
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300" <label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
@@ -138,7 +115,7 @@
> >
<input <input
v-model="localForm.customExpireDate" v-model="localForm.customExpireDate"
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200" class="form-input w-full dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200"
:min="minDateTime" :min="minDateTime"
type="datetime-local" type="datetime-local"
@change="updateCustomExpiryPreview" @change="updateCustomExpiryPreview"
@@ -393,35 +370,6 @@ const handleSave = () => {
}) })
} }
// 立即激活
const handleActivateNow = async () => {
// 使用确认弹窗
let confirmed = true
if (window.showConfirm) {
confirmed = await window.showConfirm(
'激活 API Key',
`确定要立即激活此 API Key 吗?激活后将在 ${props.apiKey.activationDays || 30} 天后自动过期。`,
'确定激活',
'取消'
)
} else {
// 降级方案
confirmed = confirm(
`确定要立即激活此 API Key 吗?激活后将在 ${props.apiKey.activationDays || 30} 天后自动过期。`
)
}
if (!confirmed) {
return
}
saving.value = true
emit('save', {
keyId: props.apiKey.id,
activateNow: true
})
}
// 重置保存状态 // 重置保存状态
const resetSaving = () => { const resetSaving = () => {
saving.value = false saving.value = false

View File

@@ -1,94 +0,0 @@
<template>
<div class="inline-flex items-center gap-1.5 rounded-md px-2 py-1" :class="badgeClass">
<div class="flex items-center gap-1">
<i :class="['text-xs', iconClass]" />
<span class="text-xs font-medium">{{ label }}</span>
</div>
<div class="flex items-center gap-1">
<span class="text-xs font-semibold">${{ current.toFixed(2) }}</span>
<span class="text-xs text-gray-500 dark:text-gray-400">/</span>
<span class="text-xs">${{ limit.toFixed(2) }}</span>
</div>
<!-- 小型进度条 -->
<div class="h-1 w-12 rounded-full bg-gray-200 dark:bg-gray-600">
<div
class="h-1 rounded-full transition-all duration-300"
:class="progressClass"
:style="{ width: progress + '%' }"
/>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
type: {
type: String,
required: true,
validator: (value) => ['daily', 'opus', 'window'].includes(value)
},
label: {
type: String,
required: true
},
current: {
type: Number,
default: 0
},
limit: {
type: Number,
required: true
}
})
const progress = computed(() => {
if (!props.limit || props.limit === 0) return 0
const percentage = (props.current / props.limit) * 100
return Math.min(percentage, 100)
})
const badgeClass = computed(() => {
switch (props.type) {
case 'daily':
return 'bg-gray-50 dark:bg-gray-700/50'
case 'opus':
return 'bg-indigo-50 dark:bg-indigo-900/20'
case 'window':
return 'bg-blue-50 dark:bg-blue-900/20'
default:
return 'bg-gray-50 dark:bg-gray-700/50'
}
})
const iconClass = computed(() => {
switch (props.type) {
case 'daily':
return 'fas fa-calendar-day text-gray-500'
case 'opus':
return 'fas fa-gem text-indigo-500'
case 'window':
return 'fas fa-clock text-blue-500'
default:
return 'fas fa-info-circle text-gray-500'
}
})
const progressClass = computed(() => {
const p = progress.value
if (p >= 100) return 'bg-red-500'
if (p >= 80) return 'bg-yellow-500'
switch (props.type) {
case 'daily':
return 'bg-green-500'
case 'opus':
return 'bg-indigo-500'
case 'window':
return 'bg-blue-500'
default:
return 'bg-gray-500'
}
})
</script>

View File

@@ -1,258 +0,0 @@
<template>
<div class="w-full">
<div class="relative h-8 w-full overflow-hidden rounded-lg shadow-sm" :class="containerClass">
<!-- 背景层 -->
<div class="absolute inset-0" :class="backgroundClass"></div>
<!-- 进度条层 -->
<div
class="absolute inset-0 h-full transition-all duration-500 ease-out"
:class="progressBarClass"
:style="{ width: progress + '%' }"
></div>
<!-- 文字层 - 使用双层文字技术确保可读性 -->
<div class="relative z-10 flex h-full items-center justify-between px-3">
<div class="flex items-center gap-1.5">
<i :class="['text-xs', iconClass]" />
<span class="text-xs font-semibold" :class="labelTextClass">{{ label }}</span>
</div>
<div class="flex items-center gap-1.5">
<span class="text-xs font-bold tabular-nums" :class="currentValueClass">
${{ current.toFixed(2) }}
</span>
<span class="text-xs font-medium" :class="limitTextClass">
/ ${{ limit.toFixed(2) }}
</span>
</div>
</div>
<!-- 闪光效果可选 -->
<div
v-if="showShine && progress > 0"
class="absolute inset-0 opacity-20"
:style="{
background:
'linear-gradient(105deg, transparent 40%, rgba(255,255,255,0.5) 50%, transparent 60%)',
animation: 'shine 3s infinite'
}"
></div>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
type: {
type: String,
required: true,
validator: (value) => ['daily', 'opus', 'window'].includes(value)
},
label: {
type: String,
required: true
},
current: {
type: Number,
default: 0
},
limit: {
type: Number,
required: true
},
showShine: {
type: Boolean,
default: false
}
})
const progress = computed(() => {
if (!props.limit || props.limit === 0) return 0
const percentage = (props.current / props.limit) * 100
return Math.min(percentage, 100)
})
// 容器样式 - 使用更柔和的边框和阴影
const containerClass = computed(() => {
return 'border border-gray-200/80 dark:border-gray-600/50 shadow-sm'
})
// 背景样式 - 使用更浅的背景色提升对比度
const backgroundClass = computed(() => {
switch (props.type) {
case 'daily':
return 'bg-gray-100/50 dark:bg-gray-800/30'
case 'opus':
return 'bg-violet-50/50 dark:bg-violet-950/20'
case 'window':
return 'bg-sky-50/50 dark:bg-sky-950/20'
default:
return 'bg-gray-100/50 dark:bg-gray-800/30'
}
})
// 进度条样式 - 使用更柔和的颜色配置
const progressBarClass = computed(() => {
const p = progress.value
if (props.type === 'daily') {
if (p >= 90) {
return 'bg-red-400 dark:bg-red-500'
} else if (p >= 70) {
return 'bg-amber-400 dark:bg-amber-500'
} else {
return 'bg-emerald-400 dark:bg-emerald-500'
}
}
if (props.type === 'opus') {
if (p >= 90) {
return 'bg-red-400 dark:bg-red-500'
} else if (p >= 70) {
return 'bg-amber-400 dark:bg-amber-500'
} else {
return 'bg-violet-400 dark:bg-violet-500'
}
}
if (props.type === 'window') {
if (p >= 90) {
return 'bg-red-400 dark:bg-red-500'
} else if (p >= 70) {
return 'bg-amber-400 dark:bg-amber-500'
} else {
return 'bg-sky-400 dark:bg-sky-500'
}
}
return 'bg-gray-300 dark:bg-gray-400'
})
// 图标类
const iconClass = computed(() => {
const p = progress.value
// 根据进度选择图标颜色
let colorClass = ''
if (p >= 90) {
colorClass = 'text-red-700 dark:text-red-400'
} else if (p >= 70) {
colorClass = 'text-orange-700 dark:text-orange-400'
} else {
switch (props.type) {
case 'daily':
colorClass = 'text-green-700 dark:text-green-400'
break
case 'opus':
colorClass = 'text-purple-700 dark:text-purple-400'
break
case 'window':
colorClass = 'text-blue-700 dark:text-blue-400'
break
default:
colorClass = 'text-gray-600 dark:text-gray-400'
}
}
let iconName = ''
switch (props.type) {
case 'daily':
iconName = 'fas fa-calendar-day'
break
case 'opus':
iconName = 'fas fa-gem'
break
case 'window':
iconName = 'fas fa-clock'
break
default:
iconName = 'fas fa-infinity'
}
return `${iconName} ${colorClass}`
})
// 标签文字颜色 - 始终保持高对比度
const labelTextClass = computed(() => {
const p = progress.value
// 根据进度条背景色智能选择文字颜色
if (p > 40) {
// 当进度条覆盖超过40%时,使用白色文字
return 'text-white drop-shadow-[0_1px_2px_rgba(0,0,0,0.8)]'
} else {
// 在浅色背景上使用深色文字
switch (props.type) {
case 'daily':
return 'text-gray-900 dark:text-gray-100'
case 'opus':
return 'text-purple-900 dark:text-purple-100'
case 'window':
return 'text-blue-900 dark:text-blue-100'
default:
return 'text-gray-900 dark:text-gray-100'
}
}
})
// 当前值文字颜色 - 最重要的数字,需要最高对比度
const currentValueClass = computed(() => {
const p = progress.value
// 判断数值是否在进度条上
if (p > 70) {
// 在彩色进度条上,使用白色+强阴影
return 'text-white drop-shadow-[0_2px_4px_rgba(0,0,0,0.9)]'
} else {
// 在浅色背景上,根据进度状态选择颜色
if (p >= 90) {
return 'text-red-700 dark:text-red-300'
} else if (p >= 70) {
return 'text-orange-700 dark:text-orange-300'
} else {
switch (props.type) {
case 'daily':
return 'text-green-800 dark:text-green-200'
case 'opus':
return 'text-purple-800 dark:text-purple-200'
case 'window':
return 'text-blue-800 dark:text-blue-200'
default:
return 'text-gray-900 dark:text-gray-100'
}
}
}
})
// 限制值文字颜色
const limitTextClass = computed(() => {
const p = progress.value
// 判断限制值是否在进度条上
if (p > 85) {
// 在进度条上
return 'text-white/90 drop-shadow-[0_1px_2px_rgba(0,0,0,0.8)]'
} else {
// 在背景上
return 'text-gray-600 dark:text-gray-400'
}
})
</script>
<style scoped>
@keyframes shine {
0% {
transform: translateX(-100%);
}
100% {
transform: translateX(200%);
}
}
/* 确保文字清晰 */
.tabular-nums {
font-variant-numeric: tabular-nums;
}
</style>

View File

@@ -6,7 +6,7 @@
<!-- 模态框 --> <!-- 模态框 -->
<div <div
class="modal-content relative mx-auto flex max-h-[90vh] w-[95%] max-w-5xl flex-col p-4 sm:w-full sm:p-6 md:p-8" class="modal-content relative mx-auto flex max-h-[90vh] w-[95%] max-w-2xl flex-col p-4 sm:w-full sm:max-w-3xl sm:p-6 md:p-8"
> >
<!-- 标题栏 --> <!-- 标题栏 -->
<div class="mb-4 flex items-center justify-between sm:mb-6"> <div class="mb-4 flex items-center justify-between sm:mb-6">
@@ -196,8 +196,6 @@
时间窗口限制 时间窗口限制
</h5> </h5>
<WindowCountdown <WindowCountdown
:cost-limit="apiKey.rateLimitCost"
:current-cost="apiKey.currentWindowCost"
:current-requests="apiKey.currentWindowRequests" :current-requests="apiKey.currentWindowRequests"
:current-tokens="apiKey.currentWindowTokens" :current-tokens="apiKey.currentWindowTokens"
label="窗口状态" label="窗口状态"

View File

@@ -33,7 +33,6 @@
</div> </div>
</div> </div>
<!-- Token限制向后兼容 -->
<div v-if="hasTokenLimit" class="space-y-0.5"> <div v-if="hasTokenLimit" class="space-y-0.5">
<div class="flex items-center justify-between text-xs"> <div class="flex items-center justify-between text-xs">
<span class="text-gray-400">Token</span> <span class="text-gray-400">Token</span>
@@ -49,23 +48,6 @@
/> />
</div> </div>
</div> </div>
<!-- 费用限制新功能 -->
<div v-if="hasCostLimit" class="space-y-0.5">
<div class="flex items-center justify-between text-xs">
<span class="text-gray-400">费用</span>
<span class="text-gray-600">
${{ (currentCost || 0).toFixed(2) }}/${{ costLimit.toFixed(2) }}
</span>
</div>
<div class="h-1 w-full rounded-full bg-gray-200">
<div
class="h-1 rounded-full transition-all duration-300"
:class="getCostProgressColor()"
:style="{ width: getCostProgress() + '%' }"
/>
</div>
</div>
</div> </div>
<!-- 额外提示信息 --> <!-- 额外提示信息 -->
@@ -120,14 +102,6 @@ const props = defineProps({
type: Number, type: Number,
default: 0 default: 0
}, },
currentCost: {
type: Number,
default: 0
},
costLimit: {
type: Number,
default: 0
},
showProgress: { showProgress: {
type: Boolean, type: Boolean,
default: true default: true
@@ -158,7 +132,6 @@ const windowState = computed(() => {
const hasRequestLimit = computed(() => props.requestLimit > 0) const hasRequestLimit = computed(() => props.requestLimit > 0)
const hasTokenLimit = computed(() => props.tokenLimit > 0) const hasTokenLimit = computed(() => props.tokenLimit > 0)
const hasCostLimit = computed(() => props.costLimit > 0)
// 方法 // 方法
const formatTime = (seconds) => { const formatTime = (seconds) => {
@@ -223,19 +196,6 @@ const getTokenProgressColor = () => {
return 'bg-purple-500' return 'bg-purple-500'
} }
const getCostProgress = () => {
if (!props.costLimit || props.costLimit === 0) return 0
const percentage = ((props.currentCost || 0) / props.costLimit) * 100
return Math.min(percentage, 100)
}
const getCostProgressColor = () => {
const progress = getCostProgress()
if (progress >= 100) return 'bg-red-500'
if (progress >= 80) return 'bg-yellow-500'
return 'bg-green-500'
}
// 更新倒计时 // 更新倒计时
const updateCountdown = () => { const updateCountdown = () => {
if (props.windowEndTime && remainingSeconds.value > 0) { if (props.windowEndTime && remainingSeconds.value > 0) {

View File

@@ -1,241 +0,0 @@
<template>
<div class="w-full space-y-1">
<!-- 时间窗口进度条 -->
<div
class="relative h-7 w-full overflow-hidden rounded-md border border-opacity-20 bg-gradient-to-r from-blue-50 to-cyan-100 dark:from-blue-950/30 dark:to-cyan-900/30"
>
<!-- 时间进度条背景 -->
<div
class="absolute inset-0 h-full bg-gradient-to-r from-blue-500 to-cyan-500 opacity-20 transition-all duration-1000"
:style="{ width: timeProgress + '%' }"
></div>
<!-- 文字层 -->
<div class="relative z-10 flex h-full items-center justify-between px-2">
<div class="flex items-center gap-1.5">
<i class="fas fa-clock text-xs text-blue-600 dark:text-blue-400" />
<span class="text-xs font-medium text-gray-700 dark:text-gray-200">
{{ rateLimitWindow }}分钟窗口
</span>
</div>
<span
class="text-xs font-bold"
:class="
remainingSeconds > 0
? 'text-blue-700 dark:text-blue-300'
: 'text-gray-400 dark:text-gray-500'
"
>
{{ remainingSeconds > 0 ? formatTime(remainingSeconds) : '未激活' }}
</span>
</div>
</div>
<!-- 费用和请求限制如果有的话 -->
<div v-if="costLimit > 0 || requestLimit > 0" class="flex gap-1">
<!-- 费用限制进度条 -->
<div
v-if="costLimit > 0"
class="relative h-6 overflow-hidden rounded-md border border-opacity-20 bg-gradient-to-r from-green-50 to-emerald-100 dark:from-green-950/30 dark:to-emerald-900/30"
:class="requestLimit > 0 ? 'w-1/2' : 'w-full'"
>
<!-- 进度条 -->
<div
class="absolute inset-0 h-full transition-all duration-500 ease-out"
:class="getCostProgressBarClass()"
:style="{ width: costProgress + '%' }"
></div>
<!-- 文字 -->
<div class="relative z-10 flex h-full items-center justify-between px-2">
<span class="text-[10px] font-medium" :class="getCostTextClass()">费用</span>
<span class="text-[10px] font-bold" :class="getCostValueTextClass()">
${{ currentCost.toFixed(1) }}/${{ costLimit.toFixed(0) }}
</span>
</div>
</div>
<!-- 请求限制进度条 -->
<div
v-if="requestLimit > 0"
class="relative h-6 overflow-hidden rounded-md border border-opacity-20 bg-gradient-to-r from-purple-50 to-indigo-100 dark:from-purple-950/30 dark:to-indigo-900/30"
:class="costLimit > 0 ? 'w-1/2' : 'w-full'"
>
<!-- 进度条 -->
<div
class="absolute inset-0 h-full transition-all duration-500 ease-out"
:class="getRequestProgressBarClass()"
:style="{ width: requestProgress + '%' }"
></div>
<!-- 文字 -->
<div class="relative z-10 flex h-full items-center justify-between px-2">
<span class="text-[10px] font-medium" :class="getRequestTextClass()">请求</span>
<span class="text-[10px] font-bold" :class="getRequestValueTextClass()">
{{ currentRequests }}/{{ requestLimit }}
</span>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
rateLimitWindow: {
type: Number,
required: true
},
remainingSeconds: {
type: Number,
default: 0
},
currentRequests: {
type: Number,
default: 0
},
requestLimit: {
type: Number,
default: 0
},
currentCost: {
type: Number,
default: 0
},
costLimit: {
type: Number,
default: 0
},
currentTokens: {
type: Number,
default: 0
},
tokenLimit: {
type: Number,
default: 0
}
})
// 费用进度
const costProgress = computed(() => {
if (!props.costLimit || props.costLimit === 0) return 0
const percentage = (props.currentCost / props.costLimit) * 100
return Math.min(percentage, 100)
})
// 请求进度
const requestProgress = computed(() => {
if (!props.requestLimit || props.requestLimit === 0) return 0
const percentage = (props.currentRequests / props.requestLimit) * 100
return Math.min(percentage, 100)
})
// 时间进度(倒计时)
const timeProgress = computed(() => {
if (!props.rateLimitWindow || props.rateLimitWindow === 0) return 0
const totalSeconds = props.rateLimitWindow * 60
const elapsed = totalSeconds - props.remainingSeconds
return Math.max(0, (elapsed / totalSeconds) * 100)
})
// 费用进度条颜色
const getCostProgressBarClass = () => {
const p = costProgress.value
if (p >= 90) {
return 'bg-gradient-to-r from-red-500 to-rose-600'
} else if (p >= 70) {
return 'bg-gradient-to-r from-orange-500 to-amber-500'
} else {
return 'bg-gradient-to-r from-green-500 to-emerald-500'
}
}
// 请求进度条颜色
const getRequestProgressBarClass = () => {
const p = requestProgress.value
if (p >= 90) {
return 'bg-gradient-to-r from-red-500 to-pink-600'
} else if (p >= 70) {
return 'bg-gradient-to-r from-orange-500 to-yellow-500'
} else {
return 'bg-gradient-to-r from-purple-500 to-indigo-600'
}
}
// 费用文字颜色
const getCostTextClass = () => {
const p = costProgress.value
if (p > 50) {
return 'text-white drop-shadow-sm'
} else {
return 'text-gray-600 dark:text-gray-300'
}
}
const getCostValueTextClass = () => {
const p = costProgress.value
if (p > 50) {
return 'text-white drop-shadow-md'
} else {
return 'text-gray-800 dark:text-gray-200'
}
}
// 请求文字颜色
const getRequestTextClass = () => {
const p = requestProgress.value
if (p > 50) {
return 'text-white drop-shadow-sm'
} else {
return 'text-gray-600 dark:text-gray-300'
}
}
const getRequestValueTextClass = () => {
const p = requestProgress.value
if (p > 50) {
return 'text-white drop-shadow-md'
} else {
return 'text-gray-800 dark:text-gray-200'
}
}
// 格式化时间
const formatTime = (seconds) => {
if (seconds === null || seconds === undefined) return '--:--'
const hours = Math.floor(seconds / 3600)
const minutes = Math.floor((seconds % 3600) / 60)
const secs = seconds % 60
if (hours > 0) {
return `${hours}h${minutes}m`
} else if (minutes > 0) {
return `${minutes}m${secs}s`
} else {
return `${secs}s`
}
}
// 格式化Token数 - 暂时未使用
// const formatTokens = (count) => {
// if (count >= 1000000) {
// return (count / 1000000).toFixed(1) + 'M'
// } else if (count >= 1000) {
// return (count / 1000).toFixed(1) + 'K'
// }
// return count.toString()
// }
</script>
<style scoped>
.border-opacity-20 {
border-color: rgba(0, 0, 0, 0.05);
}
.dark .border-opacity-20 {
border-color: rgba(255, 255, 255, 0.1);
}
</style>

View File

@@ -1,202 +0,0 @@
<template>
<div class="card h-full p-4 md:p-6">
<h3
class="mb-3 flex flex-col text-lg font-bold text-gray-900 dark:text-gray-100 sm:flex-row sm:items-center md:mb-4 md:text-xl"
>
<span class="flex items-center">
<i class="fas fa-chart-pie mr-2 text-sm text-orange-500 md:mr-3 md:text-base" />
使用占比
</span>
<span class="text-xs font-normal text-gray-600 dark:text-gray-400 sm:ml-2 md:text-sm"
>({{ statsPeriod === 'daily' ? '今日' : '本月' }})</span
>
</h3>
<div v-if="aggregatedStats && individualStats.length > 0" class="space-y-2 md:space-y-3">
<!-- 各Key使用占比列表 -->
<div v-for="(stat, index) in topKeys" :key="stat.apiId" class="relative">
<div class="mb-1 flex items-center justify-between text-sm">
<span class="truncate font-medium text-gray-700 dark:text-gray-300">
{{ stat.name || `Key ${index + 1}` }}
</span>
<span class="text-xs text-gray-600 dark:text-gray-400">
{{ calculatePercentage(stat) }}%
</span>
</div>
<div class="h-2 w-full rounded-full bg-gray-200 dark:bg-gray-700">
<div
class="h-2 rounded-full transition-all duration-300"
:class="getProgressColor(index)"
:style="{ width: calculatePercentage(stat) + '%' }"
/>
</div>
<div
class="mt-1 flex items-center justify-between text-xs text-gray-500 dark:text-gray-400"
>
<span>{{ formatNumber(getStatUsage(stat)?.requests || 0) }}</span>
<span>{{ getStatUsage(stat)?.formattedCost || '$0.00' }}</span>
</div>
</div>
<!-- 其他Keys汇总 -->
<div v-if="otherKeysCount > 0" class="border-t border-gray-200 pt-2 dark:border-gray-700">
<div class="flex items-center justify-between text-sm text-gray-600 dark:text-gray-400">
<span>其他 {{ otherKeysCount }} 个Keys</span>
<span>{{ otherPercentage }}%</span>
</div>
</div>
</div>
<!-- 单个Key模式提示 -->
<div
v-else-if="!multiKeyMode"
class="flex h-32 items-center justify-center text-sm text-gray-500 dark:text-gray-400"
>
<div class="text-center">
<i class="fas fa-chart-pie mb-2 text-2xl" />
<p>使用占比仅在多Key查询时显示</p>
</div>
</div>
<div
v-else
class="flex h-32 items-center justify-center text-sm text-gray-500 dark:text-gray-400"
>
<i class="fas fa-chart-pie mr-2" />
暂无数据
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { storeToRefs } from 'pinia'
import { useApiStatsStore } from '@/stores/apistats'
const apiStatsStore = useApiStatsStore()
const { aggregatedStats, individualStats, statsPeriod, multiKeyMode } = storeToRefs(apiStatsStore)
// 获取当前时间段的使用数据
const getStatUsage = (stat) => {
if (!stat) return null
if (statsPeriod.value === 'daily') {
return stat.dailyUsage || stat.usage
} else {
return stat.monthlyUsage || stat.usage
}
}
// 获取TOP Keys最多显示5个
const topKeys = computed(() => {
if (!individualStats.value || individualStats.value.length === 0) return []
return [...individualStats.value]
.sort((a, b) => {
const aUsage = getStatUsage(a)
const bUsage = getStatUsage(b)
return (bUsage?.cost || 0) - (aUsage?.cost || 0)
})
.slice(0, 5)
})
// 计算其他Keys数量
const otherKeysCount = computed(() => {
if (!individualStats.value) return 0
return Math.max(0, individualStats.value.length - 5)
})
// 计算其他Keys的占比
const otherPercentage = computed(() => {
if (!individualStats.value || !aggregatedStats.value) return 0
const topKeysCost = topKeys.value.reduce((sum, stat) => {
const usage = getStatUsage(stat)
return sum + (usage?.cost || 0)
}, 0)
const totalCost =
statsPeriod.value === 'daily'
? aggregatedStats.value.dailyUsage?.cost || 0
: aggregatedStats.value.monthlyUsage?.cost || 0
if (totalCost === 0) return 0
const otherCost = totalCost - topKeysCost
return Math.max(0, Math.round((otherCost / totalCost) * 100))
})
// 计算单个Key的百分比
const calculatePercentage = (stat) => {
if (!aggregatedStats.value) return 0
const totalCost =
statsPeriod.value === 'daily'
? aggregatedStats.value.dailyUsage?.cost || 0
: aggregatedStats.value.monthlyUsage?.cost || 0
if (totalCost === 0) return 0
const usage = getStatUsage(stat)
const percentage = ((usage?.cost || 0) / totalCost) * 100
return Math.round(percentage)
}
// 获取进度条颜色
const getProgressColor = (index) => {
const colors = ['bg-blue-500', 'bg-green-500', 'bg-purple-500', 'bg-yellow-500', 'bg-pink-500']
return colors[index] || 'bg-gray-400'
}
// 格式化数字
const formatNumber = (num) => {
if (typeof num !== 'number') {
num = parseInt(num) || 0
}
if (num === 0) return '0'
if (num >= 1000000) {
return (num / 1000000).toFixed(1) + 'M'
} else if (num >= 1000) {
return (num / 1000).toFixed(1) + 'K'
} else {
return num.toLocaleString()
}
}
</script>
<style scoped>
/* 卡片样式 - 使用CSS变量 */
.card {
background: var(--surface-color);
border-radius: 16px;
border: 1px solid var(--border-color);
box-shadow:
0 10px 15px -3px rgba(0, 0, 0, 0.1),
0 4px 6px -2px rgba(0, 0, 0, 0.05);
overflow: hidden;
position: relative;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.card::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 1px;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.5), transparent);
}
.card:hover {
transform: translateY(-2px);
box-shadow:
0 20px 25px -5px rgba(0, 0, 0, 0.15),
0 10px 10px -5px rgba(0, 0, 0, 0.08);
}
:global(.dark) .card:hover {
box-shadow:
0 20px 25px -5px rgba(0, 0, 0, 0.5),
0 10px 10px -5px rgba(0, 0, 0, 0.35);
}
</style>

View File

@@ -1,64 +1,24 @@
<template> <template>
<div class="api-input-wide-card mb-8 rounded-3xl p-6 shadow-xl"> <div class="api-input-wide-card mb-8 rounded-3xl p-6 shadow-xl">
<!-- 标题区域 --> <!-- 标题区域 -->
<div class="wide-card-title mb-6"> <div class="wide-card-title mb-6 text-center">
<h2 class="mb-2 text-2xl font-bold text-gray-900 dark:text-gray-200"> <h2 class="mb-2 text-2xl font-bold text-gray-900 dark:text-white">
<i class="fas fa-chart-line mr-3" /> <i class="fas fa-chart-line mr-3" />
使用统计查询 使用统计查询
</h2> </h2>
<p class="text-base text-gray-600 dark:text-gray-400">查询您的 API Key 使用情况和统计数据</p> <p class="text-base text-gray-600 dark:text-gray-300">查询您的 API Key 使用情况和统计数据</p>
</div> </div>
<!-- 输入区域 --> <!-- 输入区域 -->
<div class="mx-auto max-w-4xl"> <div class="mx-auto max-w-4xl">
<!-- 控制栏 --> <div class="api-input-grid grid grid-cols-1 lg:grid-cols-4">
<div class="control-bar mb-4 flex flex-wrap items-center justify-between gap-3">
<!-- API Key 标签 -->
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
<i class="fas fa-key mr-2" />
{{ multiKeyMode ? '输入您的 API Keys每行一个或用逗号分隔' : '输入您的 API Key' }}
</label>
<!-- 模式切换和查询按钮组 -->
<div class="button-group flex items-center gap-2">
<!-- 模式切换 -->
<div
class="mode-switch-group flex items-center rounded-lg bg-gray-100 p-1 dark:bg-gray-800"
>
<button
class="mode-switch-btn"
:class="{ active: !multiKeyMode }"
title="单一模式"
@click="multiKeyMode = false"
>
<i class="fas fa-key" />
<span class="ml-2 hidden sm:inline">单一</span>
</button>
<button
class="mode-switch-btn"
:class="{ active: multiKeyMode }"
title="聚合模式"
@click="multiKeyMode = true"
>
<i class="fas fa-layer-group" />
<span class="ml-2 hidden sm:inline">聚合</span>
<span
v-if="multiKeyMode && parsedApiKeys.length > 0"
class="ml-1 rounded-full bg-white/20 px-1.5 py-0.5 text-xs font-semibold"
>
{{ parsedApiKeys.length }}
</span>
</button>
</div>
</div>
</div>
<div class="api-input-grid grid grid-cols-1 gap-4 lg:grid-cols-4">
<!-- API Key 输入 --> <!-- API Key 输入 -->
<div class="lg:col-span-3"> <div class="lg:col-span-3">
<!-- Key 模式输入框 --> <label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-200">
<i class="fas fa-key mr-2" />
输入您的 API Key
</label>
<input <input
v-if="!multiKeyMode"
v-model="apiKey" v-model="apiKey"
class="wide-card-input w-full" class="wide-card-input w-full"
:disabled="loading" :disabled="loading"
@@ -66,33 +26,16 @@
type="password" type="password"
@keyup.enter="queryStats" @keyup.enter="queryStats"
/> />
<!-- Key 模式输入框 -->
<div v-else class="relative">
<textarea
v-model="apiKey"
class="wide-card-input w-full resize-y"
:disabled="loading"
placeholder="请输入您的 API Keys支持以下格式&#10;cr_xxx&#10;cr_yyy&#10;或&#10;cr_xxx, cr_yyy"
rows="4"
@keyup.ctrl.enter="queryStats"
/>
<button
v-if="apiKey && !loading"
class="absolute right-2 top-2 text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300"
title="清空输入"
@click="clearInput"
>
<i class="fas fa-times-circle" />
</button>
</div>
</div> </div>
<!-- 查询按钮 --> <!-- 查询按钮 -->
<div class="lg:col-span-1"> <div class="lg:col-span-1">
<label class="mb-2 hidden text-sm font-medium text-gray-700 dark:text-gray-200 lg:block">
&nbsp;
</label>
<button <button
class="btn btn-primary btn-query flex h-full w-full items-center justify-center gap-2" class="btn btn-primary btn-query flex h-full w-full items-center justify-center gap-2"
:disabled="loading || !hasValidInput" :disabled="loading || !apiKey.trim()"
@click="queryStats" @click="queryStats"
> >
<i v-if="loading" class="fas fa-spinner loading-spinner" /> <i v-if="loading" class="fas fa-spinner loading-spinner" />
@@ -105,56 +48,19 @@
<!-- 安全提示 --> <!-- 安全提示 -->
<div class="security-notice mt-4"> <div class="security-notice mt-4">
<i class="fas fa-shield-alt mr-2" /> <i class="fas fa-shield-alt mr-2" />
{{ 您的 API Key 仅用于查询自己的统计数据不会被存储或用于其他用途
multiKeyMode
? '您的 API Keys 仅用于查询统计数据,不会被存储。聚合模式下部分个体化信息将不显示。'
: '您的 API Key 仅用于查询自己的统计数据,不会被存储或用于其他用途'
}}
</div>
<!-- Key 模式额外提示 -->
<div
v-if="multiKeyMode"
class="mt-2 rounded-lg bg-blue-50 p-3 text-sm text-blue-700 dark:bg-blue-900/20 dark:text-blue-400"
>
<i class="fas fa-lightbulb mr-2" />
<span>提示最多支持同时查询 30 API Keys使用 Ctrl+Enter 快速查询</span>
</div> </div>
</div> </div>
</div> </div>
</template> </template>
<script setup> <script setup>
import { computed } from 'vue'
import { storeToRefs } from 'pinia' import { storeToRefs } from 'pinia'
import { useApiStatsStore } from '@/stores/apistats' import { useApiStatsStore } from '@/stores/apistats'
const apiStatsStore = useApiStatsStore() const apiStatsStore = useApiStatsStore()
const { apiKey, loading, multiKeyMode } = storeToRefs(apiStatsStore) const { apiKey, loading } = storeToRefs(apiStatsStore)
const { queryStats, clearInput } = apiStatsStore const { queryStats } = apiStatsStore
// 解析输入的 API Keys
const parsedApiKeys = computed(() => {
if (!multiKeyMode.value || !apiKey.value) return []
// 支持逗号和换行符分隔
const keys = apiKey.value
.split(/[,\n]+/)
.map((key) => key.trim())
.filter((key) => key.length > 0)
// 去重并限制最多30个
const uniqueKeys = [...new Set(keys)]
return uniqueKeys.slice(0, 30)
})
// 判断是否有有效输入
const hasValidInput = computed(() => {
if (multiKeyMode.value) {
return parsedApiKeys.value.length > 0
}
return apiKey.value && apiKey.value.trim().length > 0
})
</script> </script>
<style scoped> <style scoped>
@@ -195,6 +101,7 @@ const hasValidInput = computed(() => {
/* 标题样式 */ /* 标题样式 */
.wide-card-title h2 { .wide-card-title h2 {
color: #1f2937;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.1); text-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
font-weight: 700; font-weight: 700;
} }
@@ -205,12 +112,12 @@ const hasValidInput = computed(() => {
} }
.wide-card-title p { .wide-card-title p {
color: #6b7280; color: #4b5563;
text-shadow: 0 1px 1px rgba(0, 0, 0, 0.05); text-shadow: 0 1px 1px rgba(0, 0, 0, 0.05);
} }
:global(.dark) .wide-card-title p { :global(.dark) .wide-card-title p {
color: #9ca3af; color: #d1d5db;
text-shadow: 0 1px 1px rgba(0, 0, 0, 0.2); text-shadow: 0 1px 1px rgba(0, 0, 0, 0.2);
} }
@@ -344,93 +251,6 @@ const hasValidInput = computed(() => {
text-shadow: 0 1px 2px rgba(16, 185, 129, 0.2); text-shadow: 0 1px 2px rgba(16, 185, 129, 0.2);
} }
/* 控制栏 */
.control-bar {
padding-bottom: 0.5rem;
border-bottom: 1px solid rgba(229, 231, 235, 0.3);
}
:global(.dark) .control-bar {
border-bottom-color: rgba(75, 85, 99, 0.3);
}
/* 按钮组 */
.button-group {
display: flex;
align-items: center;
gap: 0.5rem;
}
/* 模式切换组 */
.mode-switch-group {
display: inline-flex;
padding: 4px;
background: #f3f4f6;
border-radius: 0.5rem;
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.05);
}
:global(.dark) .mode-switch-group {
background: #1f2937;
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.3);
}
/* 模式切换按钮 */
.mode-switch-btn {
display: inline-flex;
align-items: center;
padding: 6px 12px;
font-size: 0.875rem;
font-weight: 500;
color: #6b7280;
background: transparent;
border: none;
border-radius: 0.375rem;
cursor: pointer;
transition: all 0.2s ease;
white-space: nowrap;
}
:global(.dark) .mode-switch-btn {
color: #9ca3af;
}
.mode-switch-btn:hover:not(.active) {
color: #374151;
background: rgba(0, 0, 0, 0.05);
}
:global(.dark) .mode-switch-btn:hover:not(.active) {
color: #d1d5db;
background: rgba(255, 255, 255, 0.05);
}
.mode-switch-btn.active {
color: white;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
box-shadow: 0 2px 4px rgba(102, 126, 234, 0.2);
}
.mode-switch-btn.active:hover {
box-shadow: 0 4px 6px rgba(102, 126, 234, 0.3);
}
.mode-switch-btn i {
font-size: 0.875rem;
}
/* 淡入淡出动画 */
.fade-enter-active,
.fade-leave-active {
transition: all 0.3s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
transform: translateX(-10px);
}
/* 加载动画 */ /* 加载动画 */
.loading-spinner { .loading-spinner {
animation: spin 1s linear infinite; animation: spin 1s linear infinite;
@@ -447,18 +267,6 @@ const hasValidInput = computed(() => {
} }
/* 响应式优化 */ /* 响应式优化 */
@media (max-width: 768px) {
.control-bar {
flex-direction: column;
align-items: stretch;
gap: 1rem;
}
.button-group {
justify-content: center;
}
}
@media (max-width: 768px) { @media (max-width: 768px) {
.api-input-wide-card { .api-input-wide-card {
padding: 1.25rem; padding: 1.25rem;
@@ -496,22 +304,6 @@ const hasValidInput = computed(() => {
} }
} }
@media (max-width: 480px) {
.mode-toggle-btn {
padding: 5px 8px;
}
.toggle-icon {
width: 18px;
height: 18px;
}
.hint-text {
font-size: 0.7rem;
padding: 4px 8px;
}
}
@media (max-width: 480px) { @media (max-width: 480px) {
.api-input-wide-card { .api-input-wide-card {
padding: 1rem; padding: 1rem;

View File

@@ -1,108 +1,14 @@
<template> <template>
<div> <div>
<!-- 限制配置 / 聚合模式提示 --> <!-- 限制配置 -->
<div class="card p-4 md:p-6"> <div class="card p-4 md:p-6">
<h3 <h3
class="mb-3 flex items-center text-lg font-bold text-gray-900 dark:text-gray-100 md:mb-4 md:text-xl" class="mb-3 flex items-center text-lg font-bold text-gray-900 dark:text-gray-100 md:mb-4 md:text-xl"
> >
<i class="fas fa-shield-alt mr-2 text-sm text-red-500 md:mr-3 md:text-base" /> <i class="fas fa-shield-alt mr-2 text-sm text-red-500 md:mr-3 md:text-base" />
{{ multiKeyMode ? '限制配置(聚合查询模式)' : '限制配置' }} 限制配置
</h3> </h3>
<div class="space-y-4 md:space-y-5">
<!-- Key 模式下的聚合统计信息 -->
<div v-if="multiKeyMode && aggregatedStats" class="space-y-4">
<!-- API Keys 概况 -->
<div
class="rounded-lg bg-gradient-to-r from-blue-50 to-indigo-50 p-4 dark:from-blue-900/20 dark:to-indigo-900/20"
>
<div class="mb-3 flex items-center justify-between">
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">
<i class="fas fa-layer-group mr-2 text-blue-500" />
API Keys 概况
</span>
<span
class="rounded-full bg-blue-100 px-2 py-1 text-xs font-semibold text-blue-700 dark:bg-blue-800 dark:text-blue-200"
>
{{ aggregatedStats.activeKeys }}/{{ aggregatedStats.totalKeys }}
</span>
</div>
<div class="grid grid-cols-2 gap-3">
<div class="text-center">
<div class="text-lg font-bold text-gray-900 dark:text-gray-100">
{{ aggregatedStats.totalKeys }}
</div>
<div class="text-xs text-gray-600 dark:text-gray-400">总计 Keys</div>
</div>
<div class="text-center">
<div class="text-lg font-bold text-green-600">
{{ aggregatedStats.activeKeys }}
</div>
<div class="text-xs text-gray-600 dark:text-gray-400">激活 Keys</div>
</div>
</div>
</div>
<!-- 聚合统计数据 -->
<div
class="rounded-lg bg-gradient-to-r from-purple-50 to-pink-50 p-4 dark:from-purple-900/20 dark:to-pink-900/20"
>
<div class="mb-3 flex items-center">
<i class="fas fa-chart-pie mr-2 text-purple-500" />
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">聚合统计摘要</span>
</div>
<div class="space-y-2">
<div class="flex items-center justify-between">
<span class="text-xs text-gray-600 dark:text-gray-400">
<i class="fas fa-database mr-1 text-gray-400" />
总请求数
</span>
<span class="text-sm font-medium text-gray-900 dark:text-gray-100">
{{ formatNumber(aggregatedStats.usage.requests) }}
</span>
</div>
<div class="flex items-center justify-between">
<span class="text-xs text-gray-600 dark:text-gray-400">
<i class="fas fa-coins mr-1 text-yellow-500" />
Tokens
</span>
<span class="text-sm font-medium text-gray-900 dark:text-gray-100">
{{ formatNumber(aggregatedStats.usage.allTokens) }}
</span>
</div>
<div class="flex items-center justify-between">
<span class="text-xs text-gray-600 dark:text-gray-400">
<i class="fas fa-dollar-sign mr-1 text-green-500" />
总费用
</span>
<span class="text-sm font-medium text-gray-900 dark:text-gray-100">
{{ aggregatedStats.usage.formattedCost }}
</span>
</div>
</div>
</div>
<!-- 无效 Keys 提示 -->
<div
v-if="invalidKeys && invalidKeys.length > 0"
class="rounded-lg bg-red-50 p-3 text-sm dark:bg-red-900/20"
>
<i class="fas fa-exclamation-triangle mr-2 text-red-600 dark:text-red-400" />
<span class="text-red-700 dark:text-red-300">
{{ invalidKeys.length }} 个无效的 API Key
</span>
</div>
<!-- 提示信息 -->
<div
class="rounded-lg bg-gray-50 p-3 text-xs text-gray-600 dark:bg-gray-800 dark:text-gray-400"
>
<i class="fas fa-info-circle mr-1" />
每个 API Key 有独立的限制设置聚合模式下不显示单个限制配置
</div>
</div>
<!-- 仅在单 Key 模式下显示限制配置 -->
<div v-if="!multiKeyMode" class="space-y-4 md:space-y-5">
<!-- 每日费用限制 --> <!-- 每日费用限制 -->
<div> <div>
<div class="mb-2 flex items-center justify-between"> <div class="mb-2 flex items-center justify-between">
@@ -139,14 +45,10 @@
<div <div
v-if=" v-if="
statsData.limits.rateLimitWindow > 0 && statsData.limits.rateLimitWindow > 0 &&
(statsData.limits.rateLimitRequests > 0 || (statsData.limits.rateLimitRequests > 0 || statsData.limits.tokenLimit > 0)
statsData.limits.tokenLimit > 0 ||
statsData.limits.rateLimitCost > 0)
" "
> >
<WindowCountdown <WindowCountdown
:cost-limit="statsData.limits.rateLimitCost"
:current-cost="statsData.limits.currentWindowCost"
:current-requests="statsData.limits.currentWindowRequests" :current-requests="statsData.limits.currentWindowRequests"
:current-tokens="statsData.limits.currentWindowTokens" :current-tokens="statsData.limits.currentWindowTokens"
label="时间窗口限制" label="时间窗口限制"
@@ -162,13 +64,7 @@
<div class="mt-2 text-xs text-gray-500 dark:text-gray-400"> <div class="mt-2 text-xs text-gray-500 dark:text-gray-400">
<i class="fas fa-info-circle mr-1" /> <i class="fas fa-info-circle mr-1" />
<span v-if="statsData.limits.rateLimitCost > 0"> 请求次数和Token使用量为"或"的关系,任一达到限制即触发限流
请求次数和费用限制为"或"的关系,任一达到限制即触发限流
</span>
<span v-else-if="statsData.limits.tokenLimit > 0">
请求次数和Token使用量为"或"的关系,任一达到限制即触发限流
</span>
<span v-else> 仅限制请求次数 </span>
</div> </div>
</div> </div>
@@ -315,7 +211,7 @@ import { useApiStatsStore } from '@/stores/apistats'
import WindowCountdown from '@/components/apikeys/WindowCountdown.vue' import WindowCountdown from '@/components/apikeys/WindowCountdown.vue'
const apiStatsStore = useApiStatsStore() const apiStatsStore = useApiStatsStore()
const { statsData, multiKeyMode, aggregatedStats, invalidKeys } = storeToRefs(apiStatsStore) const { statsData } = storeToRefs(apiStatsStore)
// 获取每日费用进度 // 获取每日费用进度
const getDailyCostProgress = () => { const getDailyCostProgress = () => {
@@ -333,24 +229,6 @@ const getDailyCostProgressColor = () => {
if (progress >= 80) return 'bg-yellow-500' if (progress >= 80) return 'bg-yellow-500'
return 'bg-green-500' return 'bg-green-500'
} }
// 格式化数字
const formatNumber = (num) => {
if (typeof num !== 'number') {
num = parseInt(num) || 0
}
if (num === 0) return '0'
// 大数字使用简化格式
if (num >= 1000000) {
return (num / 1000000).toFixed(1) + 'M'
} else if (num >= 1000) {
return (num / 1000).toFixed(1) + 'K'
} else {
return num.toLocaleString()
}
}
</script> </script>
<style scoped> <style scoped>

View File

@@ -1,83 +1,14 @@
<template> <template>
<div class="mb-6 grid grid-cols-1 gap-4 md:mb-8 md:gap-6 lg:grid-cols-2"> <div class="mb-6 grid grid-cols-1 gap-4 md:mb-8 md:gap-6 lg:grid-cols-2">
<!-- API Key 基本信息 / 批量查询概要 --> <!-- API Key 基本信息 -->
<div class="card p-4 md:p-6"> <div class="card p-4 md:p-6">
<h3 <h3
class="mb-3 flex items-center text-lg font-bold text-gray-900 dark:text-gray-100 md:mb-4 md:text-xl" class="mb-3 flex items-center text-lg font-bold text-gray-900 dark:text-gray-100 md:mb-4 md:text-xl"
> >
<i <i class="fas fa-info-circle mr-2 text-sm text-blue-500 md:mr-3 md:text-base" />
class="mr-2 text-sm md:mr-3 md:text-base" API Key 信息
:class="
multiKeyMode ? 'fas fa-layer-group text-purple-500' : 'fas fa-info-circle text-blue-500'
"
/>
{{ multiKeyMode ? '批量查询概要' : 'API Key 信息' }}
</h3> </h3>
<div class="space-y-2 md:space-y-3">
<!-- Key 模式下的概要信息 -->
<div v-if="multiKeyMode && aggregatedStats" class="space-y-2 md:space-y-3">
<div class="flex items-center justify-between">
<span class="text-sm text-gray-600 dark:text-gray-400 md:text-base">查询 Keys </span>
<span class="text-sm font-medium text-gray-900 dark:text-gray-100 md:text-base">
{{ aggregatedStats.totalKeys }}
</span>
</div>
<div class="flex items-center justify-between">
<span class="text-sm text-gray-600 dark:text-gray-400 md:text-base">有效 Keys </span>
<span class="text-sm font-medium text-green-600 md:text-base">
<i class="fas fa-check-circle mr-1 text-xs md:text-sm" />
{{ aggregatedStats.activeKeys }}
</span>
</div>
<div v-if="invalidKeys.length > 0" class="flex items-center justify-between">
<span class="text-sm text-gray-600 dark:text-gray-400 md:text-base">无效 Keys </span>
<span class="text-sm font-medium text-red-600 md:text-base">
<i class="fas fa-times-circle mr-1 text-xs md:text-sm" />
{{ invalidKeys.length }}
</span>
</div>
<div class="flex items-center justify-between">
<span class="text-sm text-gray-600 dark:text-gray-400 md:text-base">总请求数</span>
<span class="text-sm font-medium text-gray-900 dark:text-gray-100 md:text-base">
{{ formatNumber(aggregatedStats.usage.requests) }}
</span>
</div>
<div class="flex items-center justify-between">
<span class="text-sm text-gray-600 dark:text-gray-400 md:text-base"> Token </span>
<span class="text-sm font-medium text-gray-900 dark:text-gray-100 md:text-base">
{{ formatNumber(aggregatedStats.usage.allTokens) }}
</span>
</div>
<div class="flex items-center justify-between">
<span class="text-sm text-gray-600 dark:text-gray-400 md:text-base">总费用</span>
<span class="text-sm font-medium text-indigo-600 md:text-base">
{{ aggregatedStats.usage.formattedCost }}
</span>
</div>
<!-- Key 贡献占比可选 -->
<div
v-if="individualStats.length > 1"
class="border-t border-gray-200 pt-2 dark:border-gray-700"
>
<div class="mb-2 text-xs text-gray-500 dark:text-gray-400"> Key 贡献占比</div>
<div class="space-y-1">
<div
v-for="stat in topContributors"
:key="stat.apiId"
class="flex items-center justify-between text-xs"
>
<span class="truncate text-gray-600 dark:text-gray-400">{{ stat.name }}</span>
<span class="text-gray-900 dark:text-gray-100"
>{{ calculateContribution(stat) }}%</span
>
</div>
</div>
</div>
</div>
<!-- Key 模式下的详细信息 -->
<div v-else class="space-y-2 md:space-y-3">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<span class="text-sm text-gray-600 dark:text-gray-400 md:text-base">名称</span> <span class="text-sm text-gray-600 dark:text-gray-400 md:text-base">名称</span>
<span <span
@@ -115,19 +46,7 @@
<span class="mt-1 flex-shrink-0 text-sm text-gray-600 dark:text-gray-400 md:text-base" <span class="mt-1 flex-shrink-0 text-sm text-gray-600 dark:text-gray-400 md:text-base"
>过期时间</span >过期时间</span
> >
<!-- 未激活状态 --> <div v-if="statsData.expiresAt" class="text-right">
<div
v-if="statsData.expirationMode === 'activation' && !statsData.isActivated"
class="text-sm font-medium text-amber-600 dark:text-amber-500 md:text-base"
>
<i class="fas fa-pause-circle mr-1 text-xs md:text-sm" />
未激活
<span class="ml-1 text-xs text-gray-500 dark:text-gray-400"
>(首次使用后{{ statsData.activationDays || 30 }}天过期)</span
>
</div>
<!-- 已设置过期时间 -->
<div v-else-if="statsData.expiresAt" class="text-right">
<div <div
v-if="isApiKeyExpired(statsData.expiresAt)" v-if="isApiKeyExpired(statsData.expiresAt)"
class="text-sm font-medium text-red-600 md:text-base" class="text-sm font-medium text-red-600 md:text-base"
@@ -149,7 +68,6 @@
{{ formatExpireDate(statsData.expiresAt) }} {{ formatExpireDate(statsData.expiresAt) }}
</div> </div>
</div> </div>
<!-- 永不过期 -->
<div v-else class="text-sm font-medium text-gray-400 dark:text-gray-500 md:text-base"> <div v-else class="text-sm font-medium text-gray-400 dark:text-gray-500 md:text-base">
<i class="fas fa-infinity mr-1 text-xs md:text-sm" /> <i class="fas fa-infinity mr-1 text-xs md:text-sm" />
永不过期 永不过期
@@ -210,38 +128,12 @@
</template> </template>
<script setup> <script setup>
/* eslint-disable no-unused-vars */
import { computed } from 'vue'
import { storeToRefs } from 'pinia' import { storeToRefs } from 'pinia'
import { useApiStatsStore } from '@/stores/apistats' import { useApiStatsStore } from '@/stores/apistats'
import dayjs from 'dayjs' import dayjs from 'dayjs'
const apiStatsStore = useApiStatsStore() const apiStatsStore = useApiStatsStore()
const { const { statsData, statsPeriod, currentPeriodData } = storeToRefs(apiStatsStore)
statsData,
statsPeriod,
currentPeriodData,
multiKeyMode,
aggregatedStats,
individualStats,
invalidKeys
} = storeToRefs(apiStatsStore)
// 计算前3个贡献最大的 Key
const topContributors = computed(() => {
if (!individualStats.value || individualStats.value.length === 0) return []
return [...individualStats.value]
.sort((a, b) => (b.usage?.allTokens || 0) - (a.usage?.allTokens || 0))
.slice(0, 3)
})
// 计算单个 Key 的贡献占比
const calculateContribution = (stat) => {
if (!aggregatedStats.value || !aggregatedStats.value.usage.allTokens) return 0
const percentage = ((stat.usage?.allTokens || 0) / aggregatedStats.value.usage.allTokens) * 100
return percentage.toFixed(1)
}
// 格式化日期 // 格式化日期
const formatDate = (dateString) => { const formatDate = (dateString) => {

View File

@@ -2,7 +2,7 @@
<div ref="triggerRef" class="relative"> <div ref="triggerRef" class="relative">
<!-- 选择器主体 --> <!-- 选择器主体 -->
<div <div
class="form-input flex w-full cursor-pointer items-center justify-between border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200" class="form-input flex w-full cursor-pointer items-center justify-between dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200"
:class="{ 'opacity-50': disabled }" :class="{ 'opacity-50': disabled }"
@click="!disabled && toggleDropdown()" @click="!disabled && toggleDropdown()"
> >
@@ -40,7 +40,7 @@
<input <input
ref="searchInput" ref="searchInput"
v-model="searchQuery" v-model="searchQuery"
class="form-input w-full border-gray-300 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400" class="form-input w-full text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
placeholder="搜索账号名称..." placeholder="搜索账号名称..."
style="padding-left: 40px; padding-right: 36px" style="padding-left: 40px; padding-right: 36px"
type="text" type="text"
@@ -298,8 +298,12 @@ const filteredGroups = computed(() => {
// 过滤的 OAuth 账号 // 过滤的 OAuth 账号
const filteredOAuthAccounts = computed(() => { const filteredOAuthAccounts = computed(() => {
let accounts = sortedAccounts.value.filter((a) => let accounts = sortedAccounts.value.filter(
props.platform === 'claude' ? a.platform === 'claude-oauth' : a.platform !== 'claude-console' (a) =>
a.accountType === 'dedicated' &&
(props.platform === 'claude'
? a.platform === 'claude-oauth'
: a.platform !== 'claude-console')
) )
if (searchQuery.value) { if (searchQuery.value) {
@@ -314,7 +318,9 @@ const filteredOAuthAccounts = computed(() => {
const filteredConsoleAccounts = computed(() => { const filteredConsoleAccounts = computed(() => {
if (props.platform !== 'claude') return [] if (props.platform !== 'claude') return []
let accounts = sortedAccounts.value.filter((a) => a.platform === 'claude-console') let accounts = sortedAccounts.value.filter(
(a) => a.accountType === 'dedicated' && a.platform === 'claude-console'
)
if (searchQuery.value) { if (searchQuery.value) {
const query = searchQuery.value.toLowerCase() const query = searchQuery.value.toLowerCase()

View File

@@ -20,43 +20,29 @@
</template> </template>
<script setup> <script setup>
import { ref, watch, nextTick, computed } from 'vue' import { ref, watch, nextTick } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import AppHeader from './AppHeader.vue' import AppHeader from './AppHeader.vue'
import TabBar from './TabBar.vue' import TabBar from './TabBar.vue'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const authStore = useAuthStore()
// 根据路由设置当前激活的标签 // 根据路由设置当前激活的标签
const activeTab = ref('dashboard') const activeTab = ref('dashboard')
// 根据 LDAP 配置动态生成路由映射 const tabRouteMap = {
const tabRouteMap = computed(() => { dashboard: '/dashboard',
const baseMap = { apiKeys: '/api-keys',
dashboard: '/dashboard', accounts: '/accounts',
apiKeys: '/api-keys', tutorial: '/tutorial',
accounts: '/accounts', settings: '/settings'
tutorial: '/tutorial', }
settings: '/settings'
}
// 只有在 LDAP 启用时才包含用户管理路由
if (authStore.oemSettings?.ldapEnabled) {
baseMap.userManagement = '/user-management'
}
return baseMap
})
// 初始化当前激活的标签 // 初始化当前激活的标签
const initActiveTab = () => { const initActiveTab = () => {
const currentPath = route.path const currentPath = route.path
const tabKey = Object.keys(tabRouteMap.value).find( const tabKey = Object.keys(tabRouteMap).find((key) => tabRouteMap[key] === currentPath)
(key) => tabRouteMap.value[key] === currentPath
)
if (tabKey) { if (tabKey) {
activeTab.value = tabKey activeTab.value = tabKey
@@ -86,7 +72,7 @@ initActiveTab()
watch( watch(
() => route.path, () => route.path,
(newPath) => { (newPath) => {
const tabKey = Object.keys(tabRouteMap.value).find((key) => tabRouteMap.value[key] === newPath) const tabKey = Object.keys(tabRouteMap).find((key) => tabRouteMap[key] === newPath)
if (tabKey) { if (tabKey) {
activeTab.value = tabKey activeTab.value = tabKey
} else { } else {
@@ -109,7 +95,7 @@ watch(
// 处理标签切换 // 处理标签切换
const handleTabChange = async (tabKey) => { const handleTabChange = async (tabKey) => {
// 如果已经在目标路由,不需要做任何事 // 如果已经在目标路由,不需要做任何事
if (tabRouteMap.value[tabKey] === route.path) { if (tabRouteMap[tabKey] === route.path) {
return return
} }
@@ -118,7 +104,7 @@ const handleTabChange = async (tabKey) => {
// 使用 await 确保路由切换完成 // 使用 await 确保路由切换完成
try { try {
await router.push(tabRouteMap.value[tabKey]) await router.push(tabRouteMap[tabKey])
// 等待下一个DOM更新周期确保组件正确渲染 // 等待下一个DOM更新周期确保组件正确渲染
await nextTick() await nextTick()
} catch (err) { } catch (err) {

View File

@@ -37,9 +37,6 @@
</template> </template>
<script setup> <script setup>
import { computed } from 'vue'
import { useAuthStore } from '@/stores/auth'
defineProps({ defineProps({
activeTab: { activeTab: {
type: String, type: String,
@@ -49,33 +46,13 @@ defineProps({
defineEmits(['tab-change']) defineEmits(['tab-change'])
const authStore = useAuthStore() const tabs = [
{ key: 'dashboard', name: '仪表板', shortName: '仪表板', icon: 'fas fa-tachometer-alt' },
// 根据 LDAP 配置动态生成 tabs { key: 'apiKeys', name: 'API Keys', shortName: 'API', icon: 'fas fa-key' },
const tabs = computed(() => { { key: 'accounts', name: '账户管理', shortName: '账户', icon: 'fas fa-user-circle' },
const baseTabs = [ { key: 'tutorial', name: '使用教程', shortName: '教程', icon: 'fas fa-graduation-cap' },
{ key: 'dashboard', name: '仪表板', shortName: '仪表板', icon: 'fas fa-tachometer-alt' }, { key: 'settings', name: '系统设置', shortName: '设置', icon: 'fas fa-cogs' }
{ key: 'apiKeys', name: 'API Keys', shortName: 'API', icon: 'fas fa-key' }, ]
{ key: 'accounts', name: '账户管理', shortName: '账户', icon: 'fas fa-user-circle' }
]
// 只有在 LDAP 启用时才显示用户管理
if (authStore.oemSettings?.ldapEnabled) {
baseTabs.push({
key: 'userManagement',
name: '用户管理',
shortName: '用户',
icon: 'fas fa-users'
})
}
baseTabs.push(
{ key: 'tutorial', name: '使用教程', shortName: '教程', icon: 'fas fa-graduation-cap' },
{ key: 'settings', name: '系统设置', shortName: '设置', icon: 'fas fa-cogs' }
)
return baseTabs
})
</script> </script>
<style scoped> <style scoped>

View File

@@ -1,265 +0,0 @@
<template>
<div
v-if="show"
class="fixed inset-0 z-50 h-full w-full overflow-y-auto bg-gray-600 bg-opacity-50"
>
<div
class="relative top-20 mx-auto w-[768px] max-w-4xl rounded-md border bg-white p-5 shadow-lg"
>
<div class="mt-3">
<div class="mb-4 flex items-center justify-between">
<h3 class="text-lg font-medium text-gray-900">Create New API Key</h3>
<button class="text-gray-400 hover:text-gray-600" @click="$emit('close')">
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
d="M6 18L18 6M6 6l12 12"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
/>
</svg>
</button>
</div>
<form class="space-y-4" @submit.prevent="handleSubmit">
<div>
<label class="block text-sm font-medium text-gray-700" for="name"> Name * </label>
<input
id="name"
v-model="form.name"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
:disabled="loading"
placeholder="Enter API key name"
required
type="text"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700" for="description">
Description
</label>
<textarea
id="description"
v-model="form.description"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
:disabled="loading"
placeholder="Optional description"
rows="3"
></textarea>
</div>
<div v-if="error" class="rounded-md border border-red-200 bg-red-50 p-3">
<div class="flex">
<div class="flex-shrink-0">
<svg class="h-5 w-5 text-red-400" fill="currentColor" viewBox="0 0 20 20">
<path
clip-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
fill-rule="evenodd"
/>
</svg>
</div>
<div class="ml-3">
<p class="text-sm text-red-700">{{ error }}</p>
</div>
</div>
</div>
<div class="flex justify-end space-x-3 pt-4">
<button
class="rounded-md border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:opacity-50"
:disabled="loading"
type="button"
@click="$emit('close')"
>
Cancel
</button>
<button
class="rounded-md border border-transparent bg-blue-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
:disabled="loading || !form.name.trim()"
type="submit"
>
<span v-if="loading" class="flex items-center">
<svg
class="-ml-1 mr-2 h-4 w-4 animate-spin text-white"
fill="none"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
fill="currentColor"
></path>
</svg>
Creating...
</span>
<span v-else>Create API Key</span>
</button>
</div>
</form>
<!-- Success Modal for showing the new API key -->
<div v-if="newApiKey" class="mt-6 rounded-md border border-green-200 bg-green-50 p-4">
<div class="flex items-start">
<div class="flex-shrink-0">
<svg class="h-5 w-5 text-green-400" fill="currentColor" viewBox="0 0 20 20">
<path
clip-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
fill-rule="evenodd"
/>
</svg>
</div>
<div class="ml-3 flex-1">
<h4 class="text-sm font-medium text-green-800">API Key Created Successfully!</h4>
<div class="mt-3">
<p class="mb-2 text-sm text-green-700">
<strong>Important:</strong> Copy your API key now. You won't be able to see it
again!
</p>
<div class="rounded-md border border-green-300 bg-white p-3">
<div class="flex items-center justify-between">
<code class="break-all font-mono text-sm text-gray-900">{{
newApiKey.key
}}</code>
<button
class="ml-3 inline-flex flex-shrink-0 items-center rounded border border-transparent bg-green-100 px-2 py-1 text-xs font-medium text-green-700 hover:bg-green-200 focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2"
@click="copyToClipboard(newApiKey.key)"
>
<svg
class="mr-1 h-3 w-3"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
/>
</svg>
Copy
</button>
</div>
</div>
</div>
<div class="mt-4 flex justify-end">
<button
class="rounded-md border border-transparent bg-green-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2"
@click="handleClose"
>
Done
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, reactive, watch } from 'vue'
import { useUserStore } from '@/stores/user'
import { showToast } from '@/utils/toast'
const props = defineProps({
show: {
type: Boolean,
default: false
}
})
const emit = defineEmits(['close', 'created'])
const userStore = useUserStore()
const loading = ref(false)
const error = ref('')
const newApiKey = ref(null)
const form = reactive({
name: '',
description: ''
})
const resetForm = () => {
form.name = ''
form.description = ''
error.value = ''
newApiKey.value = null
}
const handleSubmit = async () => {
if (!form.name.trim()) {
error.value = 'API key name is required'
return
}
loading.value = true
error.value = ''
try {
const apiKeyData = {
name: form.name.trim(),
description: form.description.trim() || undefined
}
const result = await userStore.createApiKey(apiKeyData)
if (result.success) {
newApiKey.value = result.apiKey
showToast('API key created successfully!', 'success')
} else {
error.value = result.message || 'Failed to create API key'
}
} catch (err) {
console.error('Create API key error:', err)
error.value = err.response?.data?.message || err.message || 'Failed to create API key'
} finally {
loading.value = false
}
}
const copyToClipboard = async (text) => {
try {
await navigator.clipboard.writeText(text)
showToast('API key copied to clipboard!', 'success')
} catch (err) {
console.error('Failed to copy:', err)
showToast('Failed to copy to clipboard', 'error')
}
}
const handleClose = () => {
resetForm()
emit('created')
emit('close')
}
// Reset form when modal is shown
watch(
() => props.show,
(newValue) => {
if (newValue) {
resetForm()
}
}
)
</script>
<style scoped>
/* 组件特定样式 */
</style>

View File

@@ -1,354 +0,0 @@
<template>
<div class="space-y-6">
<div class="sm:flex sm:items-center">
<div class="sm:flex-auto">
<h1 class="text-2xl font-semibold text-gray-900">My API Keys</h1>
<p class="mt-2 text-sm text-gray-700">
Manage your API keys to access Claude Relay services
</p>
</div>
<div class="mt-4 sm:ml-16 sm:mt-0 sm:flex-none">
<button
class="inline-flex items-center justify-center rounded-md border border-transparent bg-blue-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 sm:w-auto"
:disabled="activeApiKeysCount >= maxApiKeys"
@click="showCreateModal = true"
>
<svg class="-ml-1 mr-2 h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
d="M12 6v6m0 0v6m0-6h6m-6 0H6"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
/>
</svg>
Create API Key
</button>
</div>
</div>
<!-- API Keys 数量限制提示 -->
<div
v-if="activeApiKeysCount >= maxApiKeys"
class="rounded-md border border-yellow-200 bg-yellow-50 p-4"
>
<div class="flex">
<div class="flex-shrink-0">
<svg class="h-5 w-5 text-yellow-400" fill="currentColor" viewBox="0 0 20 20">
<path
clip-rule="evenodd"
d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z"
fill-rule="evenodd"
/>
</svg>
</div>
<div class="ml-3">
<p class="text-sm text-yellow-700">
You have reached the maximum number of API keys ({{ maxApiKeys }}). Please delete an
existing key to create a new one.
</p>
</div>
</div>
</div>
<!-- Loading State -->
<div v-if="loading" class="py-12 text-center">
<svg
class="mx-auto h-8 w-8 animate-spin text-blue-600"
fill="none"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
fill="currentColor"
></path>
</svg>
<p class="mt-2 text-sm text-gray-500">Loading API keys...</p>
</div>
<!-- API Keys List -->
<div v-else-if="sortedApiKeys.length > 0" class="overflow-hidden bg-white shadow sm:rounded-md">
<ul class="divide-y divide-gray-200" role="list">
<li v-for="apiKey in sortedApiKeys" :key="apiKey.id" class="px-6 py-4">
<div class="flex items-center justify-between">
<div class="flex items-center">
<div class="flex-shrink-0">
<div
:class="[
'h-2 w-2 rounded-full',
apiKey.isDeleted === 'true' || apiKey.deletedAt
? 'bg-gray-400'
: apiKey.isActive
? 'bg-green-400'
: 'bg-red-400'
]"
></div>
</div>
<div class="ml-4">
<div class="flex items-center">
<p class="text-sm font-medium text-gray-900">{{ apiKey.name }}</p>
<span
v-if="apiKey.isDeleted === 'true' || apiKey.deletedAt"
class="ml-2 inline-flex items-center rounded-full bg-gray-100 px-2.5 py-0.5 text-xs font-medium text-gray-800"
>
Deleted
</span>
<span
v-else-if="!apiKey.isActive"
class="ml-2 inline-flex items-center rounded-full bg-red-100 px-2.5 py-0.5 text-xs font-medium text-red-800"
>
Deleted
</span>
</div>
<div class="mt-1">
<p class="text-sm text-gray-500">{{ apiKey.description || 'No description' }}</p>
<div class="mt-1 flex items-center space-x-4 text-xs text-gray-400">
<span>Created: {{ formatDate(apiKey.createdAt) }}</span>
<span v-if="apiKey.isDeleted === 'true' || apiKey.deletedAt"
>Deleted: {{ formatDate(apiKey.deletedAt) }}</span
>
<span v-else-if="apiKey.lastUsedAt"
>Last used: {{ formatDate(apiKey.lastUsedAt) }}</span
>
<span v-else>Never used</span>
<span
v-if="apiKey.expiresAt && !(apiKey.isDeleted === 'true' || apiKey.deletedAt)"
>Expires: {{ formatDate(apiKey.expiresAt) }}</span
>
</div>
</div>
</div>
</div>
<div class="flex items-center space-x-2">
<!-- Usage Stats -->
<div class="text-right text-xs text-gray-500">
<div>{{ formatNumber(apiKey.usage?.requests || 0) }} requests</div>
<div v-if="apiKey.usage?.totalCost">${{ apiKey.usage.totalCost.toFixed(4) }}</div>
</div>
<!-- Actions -->
<div class="flex items-center space-x-1">
<button
class="inline-flex items-center rounded border border-transparent p-1 text-gray-400 hover:text-gray-600"
title="View API Key"
@click="showApiKey(apiKey)"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
/>
<path
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
/>
</svg>
</button>
<button
v-if="
!(apiKey.isDeleted === 'true' || apiKey.deletedAt) &&
apiKey.isActive &&
allowUserDeleteApiKeys
"
class="inline-flex items-center rounded border border-transparent p-1 text-red-400 hover:text-red-600"
title="Delete API Key"
@click="deleteApiKey(apiKey)"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
/>
</svg>
</button>
</div>
</div>
</div>
</li>
</ul>
</div>
<!-- Empty State -->
<div v-else class="py-12 text-center">
<svg
class="mx-auto h-12 w-12 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
d="M15 7a2 2 0 012 2m0 0a2 2 0 012 2m-2-2h-6m6 0v6a2 2 0 01-2 2H9a2 2 0 01-2-2V9a2 2 0 012-2h6z"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
/>
</svg>
<h3 class="mt-2 text-sm font-medium text-gray-900">No API keys</h3>
<p class="mt-1 text-sm text-gray-500">Get started by creating your first API key.</p>
<div class="mt-6">
<button
class="inline-flex items-center rounded-md border border-transparent bg-blue-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
@click="showCreateModal = true"
>
<svg class="-ml-1 mr-2 h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
d="M12 6v6m0 0v6m0-6h6m-6 0H6"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
/>
</svg>
Create API Key
</button>
</div>
</div>
<!-- Create API Key Modal -->
<CreateApiKeyModal
:show="showCreateModal"
@close="showCreateModal = false"
@created="handleApiKeyCreated"
/>
<!-- View API Key Modal -->
<ViewApiKeyModal
:api-key="selectedApiKey"
:show="showViewModal"
@close="showViewModal = false"
/>
<!-- Confirm Delete Modal -->
<ConfirmModal
confirm-class="bg-red-600 hover:bg-red-700"
confirm-text="Delete"
:message="`Are you sure you want to delete '${selectedApiKey?.name}'? This action cannot be undone.`"
:show="showDeleteModal"
title="Delete API Key"
@cancel="showDeleteModal = false"
@confirm="handleDeleteConfirm"
/>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useUserStore } from '@/stores/user'
import { showToast } from '@/utils/toast'
import CreateApiKeyModal from './CreateApiKeyModal.vue'
import ViewApiKeyModal from './ViewApiKeyModal.vue'
import ConfirmModal from '@/components/common/ConfirmModal.vue'
const userStore = useUserStore()
const loading = ref(true)
const apiKeys = ref([])
const maxApiKeys = computed(() => userStore.config?.maxApiKeysPerUser || 5)
const allowUserDeleteApiKeys = computed(() => userStore.config?.allowUserDeleteApiKeys === true)
const showCreateModal = ref(false)
const showViewModal = ref(false)
const showDeleteModal = ref(false)
const selectedApiKey = ref(null)
// Computed property to sort API keys by creation time (descending - newest first)
const sortedApiKeys = computed(() => {
return [...apiKeys.value].sort((a, b) => {
const dateA = new Date(a.createdAt)
const dateB = new Date(b.createdAt)
return dateB - dateA // Descending order
})
})
// Computed property to count only active (non-deleted) API keys
const activeApiKeysCount = computed(() => {
return apiKeys.value.filter((key) => !(key.isDeleted === 'true' || key.deletedAt)).length
})
const formatNumber = (num) => {
if (num >= 1000000) {
return (num / 1000000).toFixed(1) + 'M'
} else if (num >= 1000) {
return (num / 1000).toFixed(1) + 'K'
}
return num.toString()
}
const formatDate = (dateString) => {
if (!dateString) return null
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
}
const loadApiKeys = async () => {
loading.value = true
try {
apiKeys.value = await userStore.getUserApiKeys(true) // Include deleted keys
} catch (error) {
console.error('Failed to load API keys:', error)
showToast('Failed to load API keys', 'error')
} finally {
loading.value = false
}
}
const showApiKey = (apiKey) => {
selectedApiKey.value = apiKey
showViewModal.value = true
}
const deleteApiKey = (apiKey) => {
selectedApiKey.value = apiKey
showDeleteModal.value = true
}
const handleDeleteConfirm = async () => {
try {
const result = await userStore.deleteApiKey(selectedApiKey.value.id)
if (result.success) {
showToast('API key deleted successfully', 'success')
await loadApiKeys()
}
} catch (error) {
console.error('Failed to delete API key:', error)
showToast('Failed to delete API key', 'error')
} finally {
showDeleteModal.value = false
selectedApiKey.value = null
}
}
const handleApiKeyCreated = async () => {
showCreateModal.value = false
await loadApiKeys()
}
onMounted(() => {
loadApiKeys()
})
</script>
<style scoped>
/* 组件特定样式 */
</style>

View File

@@ -1,397 +0,0 @@
<template>
<div class="space-y-6">
<div class="sm:flex sm:items-center">
<div class="sm:flex-auto">
<h1 class="text-2xl font-semibold text-gray-900">Usage Statistics</h1>
<p class="mt-2 text-sm text-gray-700">View your API usage statistics and costs</p>
</div>
<div class="mt-4 sm:ml-16 sm:mt-0 sm:flex-none">
<select
v-model="selectedPeriod"
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
@change="loadUsageStats"
>
<option value="day">Last 24 Hours</option>
<option value="week">Last 7 Days</option>
<option value="month">Last 30 Days</option>
<option value="quarter">Last 90 Days</option>
</select>
</div>
</div>
<!-- Loading State -->
<div v-if="loading" class="py-12 text-center">
<svg
class="mx-auto h-8 w-8 animate-spin text-blue-600"
fill="none"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
fill="currentColor"
></path>
</svg>
<p class="mt-2 text-sm text-gray-500">Loading usage statistics...</p>
</div>
<!-- Stats Cards -->
<div v-else class="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-4">
<div class="overflow-hidden rounded-lg bg-white shadow">
<div class="p-5">
<div class="flex items-center">
<div class="flex-shrink-0">
<svg
class="h-6 w-6 text-blue-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
d="M13 10V3L4 14h7v7l9-11h-7z"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
/>
</svg>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="truncate text-sm font-medium text-gray-500">Total Requests</dt>
<dd class="text-lg font-medium text-gray-900">
{{ formatNumber(usageStats?.totalRequests || 0) }}
</dd>
</dl>
</div>
</div>
</div>
</div>
<div class="overflow-hidden rounded-lg bg-white shadow">
<div class="p-5">
<div class="flex items-center">
<div class="flex-shrink-0">
<svg
class="h-6 w-6 text-green-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
/>
</svg>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="truncate text-sm font-medium text-gray-500">Input Tokens</dt>
<dd class="text-lg font-medium text-gray-900">
{{ formatNumber(usageStats?.totalInputTokens || 0) }}
</dd>
</dl>
</div>
</div>
</div>
</div>
<div class="overflow-hidden rounded-lg bg-white shadow">
<div class="p-5">
<div class="flex items-center">
<div class="flex-shrink-0">
<svg
class="h-6 w-6 text-purple-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
/>
</svg>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="truncate text-sm font-medium text-gray-500">Output Tokens</dt>
<dd class="text-lg font-medium text-gray-900">
{{ formatNumber(usageStats?.totalOutputTokens || 0) }}
</dd>
</dl>
</div>
</div>
</div>
</div>
<div class="overflow-hidden rounded-lg bg-white shadow">
<div class="p-5">
<div class="flex items-center">
<div class="flex-shrink-0">
<svg
class="h-6 w-6 text-yellow-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
/>
</svg>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="truncate text-sm font-medium text-gray-500">Total Cost</dt>
<dd class="text-lg font-medium text-gray-900">
${{ (usageStats?.totalCost || 0).toFixed(4) }}
</dd>
</dl>
</div>
</div>
</div>
</div>
</div>
<!-- Daily Usage Chart -->
<div v-if="!loading && usageStats" class="rounded-lg bg-white shadow">
<div class="px-4 py-5 sm:p-6">
<h3 class="mb-4 text-lg font-medium leading-6 text-gray-900">Daily Usage Trend</h3>
<!-- Placeholder for chart - you can integrate Chart.js or similar -->
<div
class="flex h-64 items-center justify-center rounded-lg border-2 border-dashed border-gray-300"
>
<div class="text-center">
<svg
class="mx-auto h-12 w-12 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
/>
</svg>
<h3 class="mt-2 text-sm font-medium text-gray-900">Usage Chart</h3>
<p class="mt-1 text-sm text-gray-500">Daily usage trends would be displayed here</p>
<p class="mt-2 text-xs text-gray-400">
(Chart integration can be added with Chart.js, D3.js, or similar library)
</p>
</div>
</div>
</div>
</div>
<!-- Model Usage Breakdown -->
<div
v-if="!loading && usageStats && usageStats.modelStats?.length > 0"
class="rounded-lg bg-white shadow"
>
<div class="px-4 py-5 sm:p-6">
<h3 class="mb-4 text-lg font-medium leading-6 text-gray-900">Usage by Model</h3>
<div class="space-y-3">
<div
v-for="model in usageStats.modelStats"
:key="model.name"
class="flex items-center justify-between"
>
<div class="flex items-center">
<div class="flex-shrink-0">
<div class="h-2 w-2 rounded-full bg-blue-500"></div>
</div>
<div class="ml-3">
<p class="text-sm font-medium text-gray-900">{{ model.name }}</p>
</div>
</div>
<div class="text-right">
<p class="text-sm text-gray-900">{{ formatNumber(model.requests) }} requests</p>
<p class="text-xs text-gray-500">${{ model.cost.toFixed(4) }}</p>
</div>
</div>
</div>
</div>
</div>
<!-- Detailed Usage Table -->
<div v-if="!loading && userApiKeys.length > 0" class="rounded-lg bg-white shadow">
<div class="px-4 py-5 sm:p-6">
<h3 class="mb-4 text-lg font-medium leading-6 text-gray-900">Usage by API Key</h3>
<div class="overflow-hidden">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500"
scope="col"
>
API Key
</th>
<th
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500"
scope="col"
>
Requests
</th>
<th
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500"
scope="col"
>
Input Tokens
</th>
<th
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500"
scope="col"
>
Output Tokens
</th>
<th
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500"
scope="col"
>
Cost
</th>
<th
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500"
scope="col"
>
Status
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 bg-white">
<tr v-for="apiKey in userApiKeys" :key="apiKey.id">
<td class="whitespace-nowrap px-6 py-4">
<div class="text-sm font-medium text-gray-900">{{ apiKey.name }}</div>
<div class="text-sm text-gray-500">{{ apiKey.keyPreview }}</div>
</td>
<td class="whitespace-nowrap px-6 py-4 text-sm text-gray-900">
{{ formatNumber(apiKey.usage?.requests || 0) }}
</td>
<td class="whitespace-nowrap px-6 py-4 text-sm text-gray-900">
{{ formatNumber(apiKey.usage?.inputTokens || 0) }}
</td>
<td class="whitespace-nowrap px-6 py-4 text-sm text-gray-900">
{{ formatNumber(apiKey.usage?.outputTokens || 0) }}
</td>
<td class="whitespace-nowrap px-6 py-4 text-sm text-gray-900">
${{ (apiKey.usage?.totalCost || 0).toFixed(4) }}
</td>
<td class="whitespace-nowrap px-6 py-4">
<span
:class="[
'inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium',
apiKey.isDeleted === 'true' || apiKey.deletedAt
? 'bg-gray-100 text-gray-800'
: apiKey.isActive
? 'bg-green-100 text-green-800'
: 'bg-red-100 text-red-800'
]"
>
{{
apiKey.isDeleted === 'true' || apiKey.deletedAt
? 'Deleted'
: apiKey.isActive
? 'Active'
: 'Disabled'
}}
</span>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- No Data State -->
<div
v-if="!loading && (!usageStats || usageStats.totalRequests === 0)"
class="py-12 text-center"
>
<svg
class="mx-auto h-12 w-12 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
/>
</svg>
<h3 class="mt-2 text-sm font-medium text-gray-900">No usage data</h3>
<p class="mt-1 text-sm text-gray-500">
You haven't made any API requests yet. Create an API key and start using the service to see
usage statistics.
</p>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useUserStore } from '@/stores/user'
import { showToast } from '@/utils/toast'
const userStore = useUserStore()
const loading = ref(true)
const selectedPeriod = ref('week')
const usageStats = ref(null)
const userApiKeys = ref([])
const formatNumber = (num) => {
if (num >= 1000000) {
return (num / 1000000).toFixed(1) + 'M'
} else if (num >= 1000) {
return (num / 1000).toFixed(1) + 'K'
}
return num.toString()
}
const loadUsageStats = async () => {
loading.value = true
try {
const [stats, apiKeys] = await Promise.all([
userStore.getUserUsageStats({ period: selectedPeriod.value }),
userStore.getUserApiKeys(true) // Include deleted keys
])
usageStats.value = stats
userApiKeys.value = apiKeys
} catch (error) {
console.error('Failed to load usage stats:', error)
showToast('Failed to load usage statistics', 'error')
} finally {
loading.value = false
}
}
onMounted(() => {
loadUsageStats()
})
</script>
<style scoped>
/* 组件特定样式 */
</style>

View File

@@ -1,250 +0,0 @@
<template>
<div
v-if="show"
class="fixed inset-0 z-50 h-full w-full overflow-y-auto bg-gray-600 bg-opacity-50"
>
<div
class="relative top-20 mx-auto w-[768px] max-w-4xl rounded-md border bg-white p-5 shadow-lg"
>
<div class="mt-3">
<div class="mb-4 flex items-center justify-between">
<h3 class="text-lg font-medium text-gray-900">API Key Details</h3>
<button class="text-gray-400 hover:text-gray-600" @click="emit('close')">
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
d="M6 18L18 6M6 6l12 12"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
/>
</svg>
</button>
</div>
<div v-if="apiKey" class="space-y-4">
<!-- API Key Name -->
<div>
<label class="block text-sm font-medium text-gray-700">Name</label>
<p class="mt-1 text-sm text-gray-900">{{ apiKey.name }}</p>
</div>
<!-- Description -->
<div v-if="apiKey.description">
<label class="block text-sm font-medium text-gray-700">Description</label>
<p class="mt-1 text-sm text-gray-900">{{ apiKey.description }}</p>
</div>
<!-- API Key -->
<div>
<label class="block text-sm font-medium text-gray-700">API Key</label>
<div class="mt-1 flex items-center space-x-2">
<div class="flex-1">
<div v-if="showFullKey" class="rounded-md border border-gray-300 bg-gray-50 p-3">
<code class="break-all font-mono text-sm text-gray-900">{{
apiKey.key || 'Not available'
}}</code>
</div>
<div v-else class="rounded-md border border-gray-300 bg-gray-50 p-3">
<code class="font-mono text-sm text-gray-900">{{
apiKey.keyPreview || 'cr_****'
}}</code>
</div>
</div>
<div class="flex flex-col space-y-1">
<button
v-if="apiKey.key"
class="inline-flex items-center rounded border border-gray-300 bg-white px-2 py-1 text-xs font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
@click="showFullKey = !showFullKey"
>
<svg
v-if="showFullKey"
class="mr-1 h-3 w-3"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.878 9.878L3 3m6.878 6.878L12 12m-1.122-2.122L12 12m-1.122-2.122l-4.243-4.242m6.879 6.878L15 15"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
/>
</svg>
<svg
v-else
class="mr-1 h-3 w-3"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
/>
<path
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
/>
</svg>
{{ showFullKey ? 'Hide' : 'Show' }}
</button>
<button
v-if="showFullKey && apiKey.key"
class="inline-flex items-center rounded border border-gray-300 bg-white px-2 py-1 text-xs font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
@click="copyToClipboard(apiKey.key)"
>
<svg class="mr-1 h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
/>
</svg>
Copy
</button>
</div>
</div>
<p v-if="!apiKey.key" class="mt-1 text-xs text-gray-500">
Full API key is only shown when first created or regenerated
</p>
</div>
<!-- Status -->
<div>
<label class="block text-sm font-medium text-gray-700">Status</label>
<div class="mt-1">
<span
:class="[
'inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium',
apiKey.isActive ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'
]"
>
{{ apiKey.isActive ? 'Active' : 'Disabled' }}
</span>
</div>
</div>
<!-- Usage Stats -->
<div v-if="apiKey.usage" class="border-t border-gray-200 pt-4">
<label class="mb-2 block text-sm font-medium text-gray-700">Usage Statistics</label>
<div class="grid grid-cols-2 gap-4 text-sm">
<div>
<span class="text-gray-500">Requests:</span>
<span class="ml-2 font-medium">{{ formatNumber(apiKey.usage.requests || 0) }}</span>
</div>
<div>
<span class="text-gray-500">Input Tokens:</span>
<span class="ml-2 font-medium">{{
formatNumber(apiKey.usage.inputTokens || 0)
}}</span>
</div>
<div>
<span class="text-gray-500">Output Tokens:</span>
<span class="ml-2 font-medium">{{
formatNumber(apiKey.usage.outputTokens || 0)
}}</span>
</div>
<div>
<span class="text-gray-500">Total Cost:</span>
<span class="ml-2 font-medium"
>${{ (apiKey.usage.totalCost || 0).toFixed(4) }}</span
>
</div>
</div>
</div>
<!-- Timestamps -->
<div class="space-y-2 border-t border-gray-200 pt-4 text-sm">
<div class="flex justify-between">
<span class="text-gray-500">Created:</span>
<span class="text-gray-900">{{ formatDate(apiKey.createdAt) }}</span>
</div>
<div v-if="apiKey.lastUsedAt" class="flex justify-between">
<span class="text-gray-500">Last Used:</span>
<span class="text-gray-900">{{ formatDate(apiKey.lastUsedAt) }}</span>
</div>
<div v-if="apiKey.expiresAt" class="flex justify-between">
<span class="text-gray-500">Expires:</span>
<span
:class="[
'font-medium',
new Date(apiKey.expiresAt) < new Date() ? 'text-red-600' : 'text-gray-900'
]"
>
{{ formatDate(apiKey.expiresAt) }}
</span>
</div>
</div>
<div class="flex justify-end pt-4">
<button
class="rounded-md border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
@click="emit('close')"
>
Close
</button>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { showToast } from '@/utils/toast'
defineProps({
show: {
type: Boolean,
default: false
},
apiKey: {
type: Object,
default: null
}
})
const emit = defineEmits(['close'])
const showFullKey = ref(false)
const formatNumber = (num) => {
if (num >= 1000000) {
return (num / 1000000).toFixed(1) + 'M'
} else if (num >= 1000) {
return (num / 1000).toFixed(1) + 'K'
}
return num.toString()
}
const formatDate = (dateString) => {
if (!dateString) return null
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
}
const copyToClipboard = async (text) => {
try {
await navigator.clipboard.writeText(text)
showToast('Copied to clipboard!', 'success')
} catch (err) {
console.error('Failed to copy:', err)
showToast('Failed to copy to clipboard', 'error')
}
}
</script>
<style scoped>
/* 组件特定样式 */
</style>

View File

@@ -82,16 +82,7 @@ class ApiClient {
// 如果响应不成功,抛出错误 // 如果响应不成功,抛出错误
if (!response.ok) { if (!response.ok) {
// 创建一个包含完整错误信息的错误对象 throw new Error(data.message || `HTTP ${response.status}`)
const error = new Error(data.message || `HTTP ${response.status}`)
// 保留完整的响应数据,以便错误处理时可以访问详细信息
error.response = {
status: response.status,
data: data
}
// 为了向后兼容,也保留原始的 message
error.message = data.message || error.message
throw error
} }
return data return data
@@ -107,18 +98,9 @@ class ApiClient {
// GET 请求 // GET 请求
async get(url, options = {}) { async get(url, options = {}) {
// 处理查询参数 const fullUrl = createApiUrl(url)
let fullUrl = createApiUrl(url)
if (options.params) {
const params = new URLSearchParams(options.params)
fullUrl += '?' + params.toString()
}
// 移除 params 避免传递给 fetch
// eslint-disable-next-line no-unused-vars
const { params, ...configOptions } = options
const config = this.buildConfig({ const config = this.buildConfig({
...configOptions, ...options,
method: 'GET' method: 'GET'
}) })
@@ -167,24 +149,6 @@ class ApiClient {
} }
} }
// PATCH 请求
async patch(url, data = null, options = {}) {
const fullUrl = createApiUrl(url)
const config = this.buildConfig({
...options,
method: 'PATCH',
body: data ? JSON.stringify(data) : undefined
})
try {
const response = await fetch(fullUrl, config)
return await this.handleResponse(response)
} catch (error) {
console.error('API PATCH Error:', error)
throw error
}
}
// DELETE 请求 // DELETE 请求
async delete(url, options = {}) { async delete(url, options = {}) {
const fullUrl = createApiUrl(url) const fullUrl = createApiUrl(url)

View File

@@ -76,22 +76,6 @@ class ApiStatsClient {
} }
} }
} }
// 批量查询统计数据
async getBatchStats(apiIds) {
return this.request('/apiStats/api/batch-stats', {
method: 'POST',
body: JSON.stringify({ apiIds })
})
}
// 批量查询模型统计
async getBatchModelStats(apiIds, period = 'daily') {
return this.request('/apiStats/api/batch-model-stats', {
method: 'POST',
body: JSON.stringify({ apiIds, period })
})
}
} }
export const apiStatsClient = new ApiStatsClient() export const apiStatsClient = new ApiStatsClient()

View File

@@ -6,7 +6,6 @@ import 'element-plus/dist/index.css'
import 'element-plus/theme-chalk/dark/css-vars.css' import 'element-plus/theme-chalk/dark/css-vars.css'
import App from './App.vue' import App from './App.vue'
import router from './router' import router from './router'
import { useUserStore } from './stores/user'
import './assets/styles/main.css' import './assets/styles/main.css'
import './assets/styles/global.css' import './assets/styles/global.css'
@@ -25,9 +24,5 @@ app.use(ElementPlus, {
locale: zhCn locale: zhCn
}) })
// 设置axios拦截器
const userStore = useUserStore()
userStore.setupAxiosInterceptors()
// 挂载应用 // 挂载应用
app.mount('#app') app.mount('#app')

View File

@@ -1,13 +1,9 @@
import { createRouter, createWebHistory } from 'vue-router' import { createRouter, createWebHistory } from 'vue-router'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { useUserStore } from '@/stores/user'
import { APP_CONFIG } from '@/config/app' import { APP_CONFIG } from '@/config/app'
// 路由懒加载 // 路由懒加载
const LoginView = () => import('@/views/LoginView.vue') const LoginView = () => import('@/views/LoginView.vue')
const UserLoginView = () => import('@/views/UserLoginView.vue')
const UserDashboardView = () => import('@/views/UserDashboardView.vue')
const UserManagementView = () => import('@/views/UserManagementView.vue')
const MainLayout = () => import('@/components/layout/MainLayout.vue') const MainLayout = () => import('@/components/layout/MainLayout.vue')
const DashboardView = () => import('@/views/DashboardView.vue') const DashboardView = () => import('@/views/DashboardView.vue')
const ApiKeysView = () => import('@/views/ApiKeysView.vue') const ApiKeysView = () => import('@/views/ApiKeysView.vue')
@@ -39,22 +35,6 @@ const routes = [
component: LoginView, component: LoginView,
meta: { requiresAuth: false } meta: { requiresAuth: false }
}, },
{
path: '/admin-login',
redirect: '/login'
},
{
path: '/user-login',
name: 'UserLogin',
component: UserLoginView,
meta: { requiresAuth: false, userAuth: true }
},
{
path: '/user-dashboard',
name: 'UserDashboard',
component: UserDashboardView,
meta: { requiresUserAuth: true }
},
{ {
path: '/api-stats', path: '/api-stats',
name: 'ApiStats', name: 'ApiStats',
@@ -121,18 +101,6 @@ const routes = [
} }
] ]
}, },
{
path: '/user-management',
component: MainLayout,
meta: { requiresAuth: true },
children: [
{
path: '',
name: 'UserManagement',
component: UserManagementView
}
]
},
// 捕获所有未匹配的路由 // 捕获所有未匹配的路由
{ {
path: '/:pathMatch(.*)*', path: '/:pathMatch(.*)*',
@@ -146,18 +114,15 @@ const router = createRouter({
}) })
// 路由守卫 // 路由守卫
router.beforeEach(async (to, from, next) => { router.beforeEach((to, from, next) => {
const authStore = useAuthStore() const authStore = useAuthStore()
const userStore = useUserStore()
console.log('路由导航:', { console.log('路由导航:', {
to: to.path, to: to.path,
from: from.path, from: from.path,
fullPath: to.fullPath, fullPath: to.fullPath,
requiresAuth: to.meta.requiresAuth, requiresAuth: to.meta.requiresAuth,
requiresUserAuth: to.meta.requiresUserAuth, isAuthenticated: authStore.isAuthenticated
isAuthenticated: authStore.isAuthenticated,
isUserAuthenticated: userStore.isAuthenticated
}) })
// 防止重定向循环:如果已经在目标路径,直接放行 // 防止重定向循环:如果已经在目标路径,直接放行
@@ -165,38 +130,9 @@ router.beforeEach(async (to, from, next) => {
return next() return next()
} }
// 检查用户认证状态
if (to.meta.requiresUserAuth) {
if (!userStore.isAuthenticated) {
// 尝试检查本地存储的认证信息
try {
const isUserLoggedIn = await userStore.checkAuth()
if (!isUserLoggedIn) {
return next('/user-login')
}
} catch (error) {
// If the error is about disabled account, redirect to login with error
if (error.message && error.message.includes('disabled')) {
// Import showToast to display the error
const { showToast } = await import('@/utils/toast')
showToast(error.message, 'error')
}
return next('/user-login')
}
}
return next()
}
// API Stats 页面不需要认证,直接放行 // API Stats 页面不需要认证,直接放行
if (to.path === '/api-stats' || to.path.startsWith('/api-stats')) { if (to.path === '/api-stats' || to.path.startsWith('/api-stats')) {
next() next()
} else if (to.path === '/user-login') {
// 如果已经是用户登录状态,重定向到用户仪表板
if (userStore.isAuthenticated) {
next('/user-dashboard')
} else {
next()
}
} else if (to.meta.requiresAuth && !authStore.isAuthenticated) { } else if (to.meta.requiresAuth && !authStore.isAuthenticated) {
next('/login') next('/login')
} else if (to.path === '/login' && authStore.isAuthenticated) { } else if (to.path === '/login' && authStore.isAuthenticated) {

View File

@@ -21,14 +21,6 @@ export const useApiStatsStore = defineStore('apistats', () => {
siteIconData: '' siteIconData: ''
}) })
// 多 Key 模式相关状态
const multiKeyMode = ref(false)
const apiKeys = ref([]) // 多个 API Key 数组
const apiIds = ref([]) // 对应的 ID 数组
const aggregatedStats = ref(null) // 聚合后的统计数据
const individualStats = ref([]) // 各个 Key 的独立数据
const invalidKeys = ref([]) // 无效的 Keys 列表
// 计算属性 // 计算属性
const currentPeriodData = computed(() => { const currentPeriodData = computed(() => {
const defaultData = { const defaultData = {
@@ -42,16 +34,6 @@ export const useApiStatsStore = defineStore('apistats', () => {
formattedCost: '$0.000000' formattedCost: '$0.000000'
} }
// 聚合模式下使用聚合数据
if (multiKeyMode.value && aggregatedStats.value) {
if (statsPeriod.value === 'daily') {
return aggregatedStats.value.dailyUsage || defaultData
} else {
return aggregatedStats.value.monthlyUsage || defaultData
}
}
// 单个 Key 模式下使用原有逻辑
if (statsPeriod.value === 'daily') { if (statsPeriod.value === 'daily') {
return dailyStats.value || defaultData return dailyStats.value || defaultData
} else { } else {
@@ -87,11 +69,6 @@ export const useApiStatsStore = defineStore('apistats', () => {
// 查询统计数据 // 查询统计数据
async function queryStats() { async function queryStats() {
// 多 Key 模式处理
if (multiKeyMode.value) {
return queryBatchStats()
}
if (!apiKey.value.trim()) { if (!apiKey.value.trim()) {
error.value = '请输入 API Key' error.value = '请输入 API Key'
return return
@@ -227,12 +204,6 @@ export const useApiStatsStore = defineStore('apistats', () => {
statsPeriod.value = period statsPeriod.value = period
// 多 Key 模式下加载批量模型统计
if (multiKeyMode.value && apiIds.value.length > 0) {
await loadBatchModelStats(period)
return
}
// 如果对应时间段的数据还没有加载,则加载它 // 如果对应时间段的数据还没有加载,则加载它
if ( if (
(period === 'daily' && !dailyStats.value) || (period === 'daily' && !dailyStats.value) ||
@@ -326,127 +297,6 @@ export const useApiStatsStore = defineStore('apistats', () => {
} }
} }
// 批量查询统计数据
async function queryBatchStats() {
const keys = parseApiKeys()
if (keys.length === 0) {
error.value = '请输入至少一个有效的 API Key'
return
}
loading.value = true
error.value = ''
aggregatedStats.value = null
individualStats.value = []
invalidKeys.value = []
modelStats.value = []
apiKeys.value = keys
apiIds.value = []
try {
// 批量获取 API Key IDs
const idResults = await Promise.allSettled(keys.map((key) => apiStatsClient.getKeyId(key)))
const validIds = []
const validKeys = []
idResults.forEach((result, index) => {
if (result.status === 'fulfilled' && result.value.success) {
validIds.push(result.value.data.id)
validKeys.push(keys[index])
} else {
invalidKeys.value.push(keys[index])
}
})
if (validIds.length === 0) {
throw new Error('所有 API Key 都无效')
}
apiIds.value = validIds
apiKeys.value = validKeys
// 批量查询统计数据
const batchResult = await apiStatsClient.getBatchStats(validIds)
if (batchResult.success) {
aggregatedStats.value = batchResult.data.aggregated
individualStats.value = batchResult.data.individual
statsData.value = batchResult.data.aggregated // 兼容现有组件
// 设置聚合模式下的日期统计数据,以保证现有组件的兼容性
dailyStats.value = batchResult.data.aggregated.dailyUsage || null
monthlyStats.value = batchResult.data.aggregated.monthlyUsage || null
// 加载聚合的模型统计
await loadBatchModelStats(statsPeriod.value)
// 更新 URL
updateBatchURL()
} else {
throw new Error(batchResult.message || '批量查询失败')
}
} catch (err) {
console.error('Batch query error:', err)
error.value = err.message || '批量查询统计数据失败'
aggregatedStats.value = null
individualStats.value = []
} finally {
loading.value = false
}
}
// 加载批量模型统计
async function loadBatchModelStats(period = 'daily') {
if (apiIds.value.length === 0) return
modelStatsLoading.value = true
try {
const result = await apiStatsClient.getBatchModelStats(apiIds.value, period)
if (result.success) {
modelStats.value = result.data || []
} else {
throw new Error(result.message || '加载批量模型统计失败')
}
} catch (err) {
console.error('Load batch model stats error:', err)
modelStats.value = []
} finally {
modelStatsLoading.value = false
}
}
// 解析 API Keys
function parseApiKeys() {
if (!apiKey.value) return []
const keys = apiKey.value
.split(/[,\n]+/)
.map((key) => key.trim())
.filter((key) => key.length > 0)
// 去重并限制最多30个
const uniqueKeys = [...new Set(keys)]
return uniqueKeys.slice(0, 30)
}
// 更新批量查询 URL
function updateBatchURL() {
if (apiIds.value.length > 0) {
const url = new URL(window.location)
url.searchParams.set('apiIds', apiIds.value.join(','))
url.searchParams.set('batch', 'true')
window.history.pushState({}, '', url)
}
}
// 清空输入
function clearInput() {
apiKey.value = ''
}
// 清除数据 // 清除数据
function clearData() { function clearData() {
statsData.value = null statsData.value = null
@@ -456,18 +306,11 @@ export const useApiStatsStore = defineStore('apistats', () => {
error.value = '' error.value = ''
statsPeriod.value = 'daily' statsPeriod.value = 'daily'
apiId.value = null apiId.value = null
// 清除多 Key 模式数据
apiKeys.value = []
apiIds.value = []
aggregatedStats.value = null
individualStats.value = []
invalidKeys.value = []
} }
// 重置 // 重置
function reset() { function reset() {
apiKey.value = '' apiKey.value = ''
multiKeyMode.value = false
clearData() clearData()
} }
@@ -485,13 +328,6 @@ export const useApiStatsStore = defineStore('apistats', () => {
dailyStats, dailyStats,
monthlyStats, monthlyStats,
oemSettings, oemSettings,
// 多 Key 模式状态
multiKeyMode,
apiKeys,
apiIds,
aggregatedStats,
individualStats,
invalidKeys,
// Computed // Computed
currentPeriodData, currentPeriodData,
@@ -499,16 +335,13 @@ export const useApiStatsStore = defineStore('apistats', () => {
// Actions // Actions
queryStats, queryStats,
queryBatchStats,
loadAllPeriodStats, loadAllPeriodStats,
loadPeriodStats, loadPeriodStats,
loadModelStats, loadModelStats,
loadBatchModelStats,
switchPeriod, switchPeriod,
loadStatsWithApiId, loadStatsWithApiId,
loadOemSettings, loadOemSettings,
clearData, clearData,
clearInput,
reset reset
} }
}) })

View File

@@ -8,7 +8,6 @@ export const useSettingsStore = defineStore('settings', () => {
siteName: 'Claude Relay Service', siteName: 'Claude Relay Service',
siteIcon: '', siteIcon: '',
siteIconData: '', siteIconData: '',
showAdminButton: true, // 控制管理后台按钮的显示
updatedAt: null updatedAt: null
}) })
@@ -65,7 +64,6 @@ export const useSettingsStore = defineStore('settings', () => {
siteName: 'Claude Relay Service', siteName: 'Claude Relay Service',
siteIcon: '', siteIcon: '',
siteIconData: '', siteIconData: '',
showAdminButton: true,
updatedAt: null updatedAt: null
} }

View File

@@ -1,217 +0,0 @@
import { defineStore } from 'pinia'
import axios from 'axios'
import { showToast } from '@/utils/toast'
const API_BASE = '/users'
export const useUserStore = defineStore('user', {
state: () => ({
user: null,
isAuthenticated: false,
sessionToken: null,
loading: false,
config: null
}),
getters: {
isLoggedIn: (state) => state.isAuthenticated && state.user,
userName: (state) => state.user?.displayName || state.user?.username,
userRole: (state) => state.user?.role
},
actions: {
// 🔐 用户登录
async login(credentials) {
this.loading = true
try {
const response = await axios.post(`${API_BASE}/login`, credentials)
if (response.data.success) {
this.user = response.data.user
this.sessionToken = response.data.sessionToken
this.isAuthenticated = true
// 保存到 localStorage
localStorage.setItem('userToken', this.sessionToken)
localStorage.setItem('userData', JSON.stringify(this.user))
// 设置 axios 默认头部
this.setAuthHeader()
return response.data
} else {
throw new Error(response.data.message || 'Login failed')
}
} catch (error) {
this.clearAuth()
throw error
} finally {
this.loading = false
}
},
// 🚪 用户登出
async logout() {
try {
if (this.sessionToken) {
await axios.post(
`${API_BASE}/logout`,
{},
{
headers: { 'x-user-token': this.sessionToken }
}
)
}
} catch (error) {
console.error('Logout request failed:', error)
} finally {
this.clearAuth()
}
},
// 🔄 检查认证状态
async checkAuth() {
const token = localStorage.getItem('userToken')
const userData = localStorage.getItem('userData')
const userConfig = localStorage.getItem('userConfig')
if (!token || !userData) {
this.clearAuth()
return false
}
try {
this.sessionToken = token
this.user = JSON.parse(userData)
this.config = userConfig ? JSON.parse(userConfig) : null
this.isAuthenticated = true
this.setAuthHeader()
// 验证 token 是否仍然有效
await this.getUserProfile()
return true
} catch (error) {
console.error('Auth check failed:', error)
this.clearAuth()
return false
}
},
// 👤 获取用户资料
async getUserProfile() {
try {
const response = await axios.get(`${API_BASE}/profile`)
if (response.data.success) {
this.user = response.data.user
this.config = response.data.config
localStorage.setItem('userData', JSON.stringify(this.user))
localStorage.setItem('userConfig', JSON.stringify(this.config))
return response.data.user
}
} catch (error) {
if (error.response?.status === 401 || error.response?.status === 403) {
// 401: Invalid/expired session, 403: Account disabled
this.clearAuth()
// If it's a disabled account error, throw a specific error
if (error.response?.status === 403) {
throw new Error(error.response.data?.message || 'Your account has been disabled')
}
}
throw error
}
},
// 🔑 获取用户API Keys
async getUserApiKeys(includeDeleted = false) {
try {
const params = {}
if (includeDeleted) {
params.includeDeleted = 'true'
}
const response = await axios.get(`${API_BASE}/api-keys`, { params })
return response.data.success ? response.data.apiKeys : []
} catch (error) {
console.error('Failed to fetch API keys:', error)
throw error
}
},
// 🔑 创建API Key
async createApiKey(keyData) {
try {
const response = await axios.post(`${API_BASE}/api-keys`, keyData)
return response.data
} catch (error) {
console.error('Failed to create API key:', error)
throw error
}
},
// 🗑️ 删除API Key
async deleteApiKey(keyId) {
try {
const response = await axios.delete(`${API_BASE}/api-keys/${keyId}`)
return response.data
} catch (error) {
console.error('Failed to delete API key:', error)
throw error
}
},
// 📊 获取使用统计
async getUserUsageStats(params = {}) {
try {
const response = await axios.get(`${API_BASE}/usage-stats`, { params })
return response.data.success ? response.data.stats : null
} catch (error) {
console.error('Failed to fetch usage stats:', error)
throw error
}
},
// 🧹 清除认证信息
clearAuth() {
this.user = null
this.sessionToken = null
this.isAuthenticated = false
this.config = null
localStorage.removeItem('userToken')
localStorage.removeItem('userData')
localStorage.removeItem('userConfig')
// 清除 axios 默认头部
delete axios.defaults.headers.common['x-user-token']
},
// 🔧 设置认证头部
setAuthHeader() {
if (this.sessionToken) {
axios.defaults.headers.common['x-user-token'] = this.sessionToken
}
},
// 🔧 设置axios拦截器
setupAxiosInterceptors() {
// Response interceptor to handle disabled user responses globally
axios.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 403) {
const message = error.response.data?.message
if (message && (message.includes('disabled') || message.includes('Account disabled'))) {
this.clearAuth()
showToast(message, 'error')
// Redirect to login page
if (window.location.pathname !== '/user-login') {
window.location.href = '/user-login'
}
}
}
return Promise.reject(error)
}
)
}
}
})

View File

@@ -31,9 +31,6 @@ export function showToast(message, type = 'info', title = '', duration = 3000) {
info: 'fas fa-info-circle' info: 'fas fa-info-circle'
} }
// 处理消息中的换行符,转换为 HTML 换行
const formattedMessage = message.replace(/\n/g, '<br>')
toast.innerHTML = ` toast.innerHTML = `
<div class="flex items-start gap-3"> <div class="flex items-start gap-3">
<div class="flex-shrink-0 mt-0.5"> <div class="flex-shrink-0 mt-0.5">
@@ -41,7 +38,7 @@ export function showToast(message, type = 'info', title = '', duration = 3000) {
</div> </div>
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
${title ? `<h4 class="font-semibold text-sm mb-1">${title}</h4>` : ''} ${title ? `<h4 class="font-semibold text-sm mb-1">${title}</h4>` : ''}
<p class="text-sm opacity-90 leading-relaxed">${formattedMessage}</p> <p class="text-sm opacity-90 leading-relaxed">${message}</p>
</div> </div>
<button onclick="this.parentElement.parentElement.remove()" <button onclick="this.parentElement.parentElement.remove()"
class="flex-shrink-0 text-white/70 hover:text-white transition-colors ml-2"> class="flex-shrink-0 text-white/70 hover:text-white transition-colors ml-2">

View File

@@ -191,39 +191,7 @@
<th <th
class="w-[10%] min-w-[100px] px-3 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-700 dark:text-gray-300" class="w-[10%] min-w-[100px] px-3 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-700 dark:text-gray-300"
> >
<div class="flex items-center gap-2"> 会话窗口
<span>会话窗口</span>
<el-tooltip placement="top">
<template #content>
<div class="space-y-2">
<div>会话窗口进度表示5小时窗口的时间进度</div>
<div class="space-y-1 text-xs">
<div class="flex items-center gap-2">
<div
class="h-2 w-16 rounded bg-gradient-to-r from-blue-500 to-indigo-600"
></div>
<span>正常请求正常处理</span>
</div>
<div class="flex items-center gap-2">
<div
class="h-2 w-16 rounded bg-gradient-to-r from-yellow-500 to-orange-500"
></div>
<span>警告接近限制</span>
</div>
<div class="flex items-center gap-2">
<div
class="h-2 w-16 rounded bg-gradient-to-r from-red-500 to-red-600"
></div>
<span>拒绝达到速率限制</span>
</div>
</div>
</div>
</template>
<i
class="fas fa-question-circle cursor-help text-xs text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-400"
/>
</el-tooltip>
</div>
</th> </th>
<th <th
class="w-[8%] min-w-[80px] px-3 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-700 dark:text-gray-300" class="w-[8%] min-w-[80px] px-3 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-700 dark:text-gray-300"
@@ -272,16 +240,11 @@
> >
<i class="fas fa-share-alt mr-1" />共享 <i class="fas fa-share-alt mr-1" />共享
</span> </span>
</div> <!-- 显示所有分组 -->
<!-- 显示所有分组 - 换行显示 -->
<div
v-if="account.groupInfos && account.groupInfos.length > 0"
class="my-2 flex flex-wrap items-center gap-2"
>
<span <span
v-for="group in account.groupInfos" v-for="group in account.groupInfos"
:key="group.id" :key="group.id"
class="inline-flex items-center rounded-full bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-600 dark:bg-gray-700 dark:text-gray-400" class="ml-1 inline-flex items-center rounded-full bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-600 dark:bg-gray-700 dark:text-gray-400"
:title="`所属分组: ${group.name}`" :title="`所属分组: ${group.name}`"
> >
<i class="fas fa-folder mr-1" />{{ group.name }} <i class="fas fa-folder mr-1" />{{ group.name }}
@@ -381,11 +344,9 @@
? 'bg-orange-100 text-orange-800' ? 'bg-orange-100 text-orange-800'
: account.status === 'unauthorized' : account.status === 'unauthorized'
? 'bg-red-100 text-red-800' ? 'bg-red-100 text-red-800'
: account.status === 'temp_error' : account.isActive
? 'bg-orange-100 text-orange-800' ? 'bg-green-100 text-green-800'
: account.isActive : 'bg-red-100 text-red-800'
? 'bg-green-100 text-green-800'
: 'bg-red-100 text-red-800'
]" ]"
> >
<div <div
@@ -395,11 +356,9 @@
? 'bg-orange-500' ? 'bg-orange-500'
: account.status === 'unauthorized' : account.status === 'unauthorized'
? 'bg-red-500' ? 'bg-red-500'
: account.status === 'temp_error' : account.isActive
? 'bg-orange-500' ? 'bg-green-500'
: account.isActive : 'bg-red-500'
? 'bg-green-500'
: 'bg-red-500'
]" ]"
/> />
{{ {{
@@ -407,11 +366,9 @@
? '已封锁' ? '已封锁'
: account.status === 'unauthorized' : account.status === 'unauthorized'
? '异常' ? '异常'
: account.status === 'temp_error' : account.isActive
? '临时异常' ? '常'
: account.isActive : '异常'
? '正常'
: '异常'
}} }}
</span> </span>
<span <span
@@ -429,7 +386,7 @@
typeof account.rateLimitStatus === 'object' && typeof account.rateLimitStatus === 'object' &&
account.rateLimitStatus.minutesRemaining > 0 account.rateLimitStatus.minutesRemaining > 0
" "
>({{ formatRateLimitTime(account.rateLimitStatus.minutesRemaining) }})</span >({{ account.rateLimitStatus.minutesRemaining }}分钟)</span
> >
</span> </span>
<span <span
@@ -438,14 +395,6 @@
> >
<i class="fas fa-pause-circle mr-1" /> <i class="fas fa-pause-circle mr-1" />
不可调度 不可调度
<el-tooltip
v-if="getSchedulableReason(account)"
:content="getSchedulableReason(account)"
effect="dark"
placement="top"
>
<i class="fas fa-question-circle ml-1 cursor-help text-gray-500" />
</el-tooltip>
</span> </span>
<span <span
v-if="account.status === 'blocked' && account.errorMessage" v-if="account.status === 'blocked' && account.errorMessage"
@@ -469,8 +418,7 @@
account.platform === 'claude-console' || account.platform === 'claude-console' ||
account.platform === 'bedrock' || account.platform === 'bedrock' ||
account.platform === 'gemini' || account.platform === 'gemini' ||
account.platform === 'openai' || account.platform === 'openai'
account.platform === 'azure_openai'
" "
class="flex items-center gap-2" class="flex items-center gap-2"
> >
@@ -501,21 +449,15 @@
<td class="whitespace-nowrap px-3 py-4 text-sm"> <td class="whitespace-nowrap px-3 py-4 text-sm">
<div v-if="account.usage && account.usage.daily" class="space-y-1"> <div v-if="account.usage && account.usage.daily" class="space-y-1">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<div class="h-2 w-2 rounded-full bg-blue-500" /> <div class="h-2 w-2 rounded-full bg-green-500" />
<span class="text-sm font-medium text-gray-900 dark:text-gray-100" <span class="text-sm font-medium text-gray-900 dark:text-gray-100"
>{{ account.usage.daily.requests || 0 }} 次</span >{{ account.usage.daily.requests || 0 }} 次</span
> >
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<div class="h-2 w-2 rounded-full bg-purple-500" /> <div class="h-2 w-2 rounded-full bg-blue-500" />
<span class="text-xs text-gray-600 dark:text-gray-300" <span class="text-xs text-gray-600 dark:text-gray-300"
>{{ formatNumber(account.usage.daily.allTokens || 0) }}M</span >{{ formatNumber(account.usage.daily.allTokens || 0) }} tokens</span
>
</div>
<div class="flex items-center gap-2">
<div class="h-2 w-2 rounded-full bg-green-500" />
<span class="text-xs text-gray-600 dark:text-gray-300"
>${{ calculateDailyCost(account) }}</span
> >
</div> </div>
<div <div
@@ -536,33 +478,10 @@
" "
class="space-y-2" class="space-y-2"
> >
<!-- 使用统计在顶部 -->
<div
v-if="account.usage && account.usage.sessionWindow"
class="flex items-center gap-3 text-xs"
>
<div class="flex items-center gap-1">
<div class="h-1.5 w-1.5 rounded-full bg-purple-500" />
<span class="font-medium text-gray-900 dark:text-gray-100">
{{ formatNumber(account.usage.sessionWindow.totalTokens) }}M
</span>
</div>
<div class="flex items-center gap-1">
<div class="h-1.5 w-1.5 rounded-full bg-green-500" />
<span class="font-medium text-gray-900 dark:text-gray-100">
${{ formatCost(account.usage.sessionWindow.totalCost) }}
</span>
</div>
</div>
<!-- 进度条 -->
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<div class="h-2 w-24 rounded-full bg-gray-200 dark:bg-gray-700"> <div class="h-2 w-24 rounded-full bg-gray-200">
<div <div
:class="[ class="h-2 rounded-full bg-gradient-to-r from-blue-500 to-indigo-600 transition-all duration-300"
'h-2 rounded-full transition-all duration-300',
getSessionProgressBarClass(account.sessionWindow.sessionWindowStatus)
]"
:style="{ width: account.sessionWindow.progress + '%' }" :style="{ width: account.sessionWindow.progress + '%' }"
/> />
</div> </div>
@@ -570,9 +489,7 @@
{{ account.sessionWindow.progress }}% {{ account.sessionWindow.progress }}%
</span> </span>
</div> </div>
<div class="text-xs text-gray-600 dark:text-gray-300">
<!-- 时间信息 -->
<div class="text-xs text-gray-600 dark:text-gray-400">
<div> <div>
{{ {{
formatSessionWindow( formatSessionWindow(
@@ -583,50 +500,12 @@
</div> </div>
<div <div
v-if="account.sessionWindow.remainingTime > 0" v-if="account.sessionWindow.remainingTime > 0"
class="font-medium text-indigo-600 dark:text-indigo-400" class="font-medium text-indigo-600"
> >
剩余 {{ formatRemainingTime(account.sessionWindow.remainingTime) }} 剩余 {{ formatRemainingTime(account.sessionWindow.remainingTime) }}
</div> </div>
</div> </div>
</div> </div>
<!-- Claude Console: 显示每日额度使用进度 -->
<div v-else-if="account.platform === 'claude-console'" class="space-y-2">
<div v-if="Number(account.dailyQuota) > 0">
<div class="flex items-center justify-between text-xs">
<span class="text-gray-600 dark:text-gray-300">额度进度</span>
<span class="font-medium text-gray-700 dark:text-gray-200">
{{ getQuotaUsagePercent(account).toFixed(1) }}%
</span>
</div>
<div class="flex items-center gap-2">
<div class="h-2 w-24 rounded-full bg-gray-200 dark:bg-gray-700">
<div
:class="[
'h-2 rounded-full transition-all duration-300',
getQuotaBarClass(getQuotaUsagePercent(account))
]"
:style="{ width: Math.min(100, getQuotaUsagePercent(account)) + '%' }"
/>
</div>
<span
class="min-w-[32px] text-xs font-medium text-gray-700 dark:text-gray-200"
>
${{ formatCost(account.usage?.daily?.cost || 0) }} / ${{
Number(account.dailyQuota).toFixed(2)
}}
</span>
</div>
<div class="text-xs text-gray-600 dark:text-gray-400">
剩余 ${{ formatRemainingQuota(account) }}
<span class="ml-2 text-gray-400"
>重置 {{ account.quotaResetTime || '00:00' }}</span
>
</div>
</div>
<div v-else class="text-sm text-gray-400">
<i class="fas fa-minus" />
</div>
</div>
<div v-else-if="account.platform === 'claude'" class="text-sm text-gray-400"> <div v-else-if="account.platform === 'claude'" class="text-sm text-gray-400">
<i class="fas fa-minus" /> <i class="fas fa-minus" />
</div> </div>
@@ -641,9 +520,7 @@
<div class="flex flex-wrap items-center gap-1"> <div class="flex flex-wrap items-center gap-1">
<button <button
v-if=" v-if="
(account.platform === 'claude' || account.platform === 'claude' &&
account.platform === 'claude-console' ||
account.platform === 'openai') &&
(account.status === 'unauthorized' || (account.status === 'unauthorized' ||
account.status !== 'active' || account.status !== 'active' ||
account.rateLimitStatus?.isRateLimited || account.rateLimitStatus?.isRateLimited ||
@@ -771,44 +648,21 @@
<div class="mb-3 grid grid-cols-2 gap-3"> <div class="mb-3 grid grid-cols-2 gap-3">
<div> <div>
<p class="text-xs text-gray-500 dark:text-gray-400">今日使用</p> <p class="text-xs text-gray-500 dark:text-gray-400">今日使用</p>
<div class="space-y-1"> <p class="text-sm font-semibold text-gray-900 dark:text-gray-100">
<div class="flex items-center gap-1.5"> {{ formatNumber(account.usage?.daily?.requests || 0) }} 次
<div class="h-1.5 w-1.5 rounded-full bg-blue-500" /> </p>
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100"> <p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
{{ account.usage?.daily?.requests || 0 }} {{ formatNumber(account.usage?.daily?.allTokens || 0) }} tokens
</p> </p>
</div>
<div class="flex items-center gap-1.5">
<div class="h-1.5 w-1.5 rounded-full bg-purple-500" />
<p class="text-xs text-gray-600 dark:text-gray-400">
{{ formatNumber(account.usage?.daily?.allTokens || 0) }}M
</p>
</div>
<div class="flex items-center gap-1.5">
<div class="h-1.5 w-1.5 rounded-full bg-green-500" />
<p class="text-xs text-gray-600 dark:text-gray-400">
${{ calculateDailyCost(account) }}
</p>
</div>
</div>
</div> </div>
<div> <div>
<p class="text-xs text-gray-500 dark:text-gray-400">会话窗口</p> <p class="text-xs text-gray-500 dark:text-gray-400">总使用量</p>
<div v-if="account.usage && account.usage.sessionWindow" class="space-y-1"> <p class="text-sm font-semibold text-gray-900 dark:text-gray-100">
<div class="flex items-center gap-1.5"> {{ formatNumber(account.usage?.total?.requests || 0) }} 次
<div class="h-1.5 w-1.5 rounded-full bg-purple-500" /> </p>
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100"> <p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
{{ formatNumber(account.usage.sessionWindow.totalTokens) }}M {{ formatNumber(account.usage?.total?.allTokens || 0) }} tokens
</p> </p>
</div>
<div class="flex items-center gap-1.5">
<div class="h-1.5 w-1.5 rounded-full bg-green-500" />
<p class="text-xs text-gray-600 dark:text-gray-400">
${{ formatCost(account.usage.sessionWindow.totalCost) }}
</p>
</div>
</div>
<div v-else class="text-sm font-semibold text-gray-400">-</div>
</div> </div>
</div> </div>
@@ -824,27 +678,14 @@
class="space-y-1.5 rounded-lg bg-gray-50 p-2 dark:bg-gray-700" class="space-y-1.5 rounded-lg bg-gray-50 p-2 dark:bg-gray-700"
> >
<div class="flex items-center justify-between text-xs"> <div class="flex items-center justify-between text-xs">
<div class="flex items-center gap-1"> <span class="font-medium text-gray-600 dark:text-gray-300">会话窗口</span>
<span class="font-medium text-gray-600 dark:text-gray-300">会话窗口</span>
<el-tooltip
content="会话窗口进度不代表使用量仅表示距离下一个5小时窗口的剩余时间"
placement="top"
>
<i
class="fas fa-question-circle cursor-help text-xs text-gray-400 hover:text-gray-600"
/>
</el-tooltip>
</div>
<span class="font-medium text-gray-700 dark:text-gray-200"> <span class="font-medium text-gray-700 dark:text-gray-200">
{{ account.sessionWindow.progress }}% {{ account.sessionWindow.progress }}%
</span> </span>
</div> </div>
<div class="h-2 w-full overflow-hidden rounded-full bg-gray-200 dark:bg-gray-600"> <div class="h-2 w-full overflow-hidden rounded-full bg-gray-200 dark:bg-gray-600">
<div <div
:class="[ class="h-full bg-gradient-to-r from-blue-500 to-indigo-600 transition-all duration-300"
'h-full transition-all duration-300',
getSessionProgressBarClass(account.sessionWindow.sessionWindowStatus)
]"
:style="{ width: account.sessionWindow.progress + '%' }" :style="{ width: account.sessionWindow.progress + '%' }"
/> />
</div> </div>
@@ -1076,24 +917,12 @@ const sortedAccounts = computed(() => {
const loadAccounts = async (forceReload = false) => { const loadAccounts = async (forceReload = false) => {
accountsLoading.value = true accountsLoading.value = true
try { try {
// 检查是否选择了特定分组 // 构建查询参数
if (groupFilter.value && groupFilter.value !== 'all' && groupFilter.value !== 'ungrouped') {
// 直接调用分组成员接口
const response = await apiClient.get(`/admin/account-groups/${groupFilter.value}/members`)
if (response.success) {
// 分组成员接口已经包含了完整的账户信息,直接使用
accounts.value = response.data
accountsLoading.value = false
return
}
}
// 构建查询参数(用于其他筛选情况)
const params = {} const params = {}
if (platformFilter.value !== 'all') { if (platformFilter.value !== 'all') {
params.platform = platformFilter.value params.platform = platformFilter.value
} }
if (groupFilter.value === 'ungrouped') { if (groupFilter.value !== 'all') {
params.groupId = groupFilter.value params.groupId = groupFilter.value
} }
@@ -1118,9 +947,7 @@ const loadAccounts = async (forceReload = false) => {
apiClient.get('/admin/claude-accounts', { params }), apiClient.get('/admin/claude-accounts', { params }),
Promise.resolve({ success: true, data: [] }), // claude-console 占位 Promise.resolve({ success: true, data: [] }), // claude-console 占位
Promise.resolve({ success: true, data: [] }), // bedrock 占位 Promise.resolve({ success: true, data: [] }), // bedrock 占位
Promise.resolve({ success: true, data: [] }), // gemini 占位 Promise.resolve({ success: true, data: [] }) // gemini 占位
Promise.resolve({ success: true, data: [] }), // openai 占位
Promise.resolve({ success: true, data: [] }) // azure-openai 占位
) )
break break
case 'claude-console': case 'claude-console':
@@ -1128,9 +955,7 @@ const loadAccounts = async (forceReload = false) => {
Promise.resolve({ success: true, data: [] }), // claude 占位 Promise.resolve({ success: true, data: [] }), // claude 占位
apiClient.get('/admin/claude-console-accounts', { params }), apiClient.get('/admin/claude-console-accounts', { params }),
Promise.resolve({ success: true, data: [] }), // bedrock 占位 Promise.resolve({ success: true, data: [] }), // bedrock 占位
Promise.resolve({ success: true, data: [] }), // gemini 占位 Promise.resolve({ success: true, data: [] }) // gemini 占位
Promise.resolve({ success: true, data: [] }), // openai 占位
Promise.resolve({ success: true, data: [] }) // azure-openai 占位
) )
break break
case 'bedrock': case 'bedrock':
@@ -1138,9 +963,7 @@ const loadAccounts = async (forceReload = false) => {
Promise.resolve({ success: true, data: [] }), // claude 占位 Promise.resolve({ success: true, data: [] }), // claude 占位
Promise.resolve({ success: true, data: [] }), // claude-console 占位 Promise.resolve({ success: true, data: [] }), // claude-console 占位
apiClient.get('/admin/bedrock-accounts', { params }), apiClient.get('/admin/bedrock-accounts', { params }),
Promise.resolve({ success: true, data: [] }), // gemini 占位 Promise.resolve({ success: true, data: [] }) // gemini 占位
Promise.resolve({ success: true, data: [] }), // openai 占位
Promise.resolve({ success: true, data: [] }) // azure-openai 占位
) )
break break
case 'gemini': case 'gemini':
@@ -1148,40 +971,7 @@ const loadAccounts = async (forceReload = false) => {
Promise.resolve({ success: true, data: [] }), // claude 占位 Promise.resolve({ success: true, data: [] }), // claude 占位
Promise.resolve({ success: true, data: [] }), // claude-console 占位 Promise.resolve({ success: true, data: [] }), // claude-console 占位
Promise.resolve({ success: true, data: [] }), // bedrock 占位 Promise.resolve({ success: true, data: [] }), // bedrock 占位
apiClient.get('/admin/gemini-accounts', { params }), apiClient.get('/admin/gemini-accounts', { params })
Promise.resolve({ success: true, data: [] }), // openai 占位
Promise.resolve({ success: true, data: [] }) // azure-openai 占位
)
break
case 'openai':
requests.push(
Promise.resolve({ success: true, data: [] }), // claude 占位
Promise.resolve({ success: true, data: [] }), // claude-console 占位
Promise.resolve({ success: true, data: [] }), // bedrock 占位
Promise.resolve({ success: true, data: [] }), // gemini 占位
apiClient.get('/admin/openai-accounts', { params }),
Promise.resolve({ success: true, data: [] }) // azure-openai 占位
)
break
case 'azure_openai':
requests.push(
Promise.resolve({ success: true, data: [] }), // claude 占位
Promise.resolve({ success: true, data: [] }), // claude-console 占位
Promise.resolve({ success: true, data: [] }), // bedrock 占位
Promise.resolve({ success: true, data: [] }), // gemini 占位
Promise.resolve({ success: true, data: [] }), // openai 占位
apiClient.get('/admin/azure-openai-accounts', { params })
)
break
default:
// 默认情况下返回空数组
requests.push(
Promise.resolve({ success: true, data: [] }),
Promise.resolve({ success: true, data: [] }),
Promise.resolve({ success: true, data: [] }),
Promise.resolve({ success: true, data: [] }),
Promise.resolve({ success: true, data: [] }),
Promise.resolve({ success: true, data: [] })
) )
break break
} }
@@ -1256,33 +1046,13 @@ const loadAccounts = async (forceReload = false) => {
const boundApiKeysCount = apiKeys.value.filter( const boundApiKeysCount = apiKeys.value.filter(
(key) => key.azureOpenaiAccountId === acc.id (key) => key.azureOpenaiAccountId === acc.id
).length ).length
// 后端已经包含了groupInfos直接使用 const groupInfo = accountGroupMap.value.get(acc.id) || null
return { ...acc, platform: 'azure_openai', boundApiKeysCount } return { ...acc, platform: 'azure_openai', boundApiKeysCount, groupInfo }
}) })
allAccounts.push(...azureOpenaiAccounts) allAccounts.push(...azureOpenaiAccounts)
} }
// 根据分组筛选器过滤账户 accounts.value = allAccounts
let filteredAccounts = allAccounts
if (groupFilter.value !== 'all') {
if (groupFilter.value === 'ungrouped') {
// 筛选未分组的账户(没有 groupInfos 或 groupInfos 为空数组)
filteredAccounts = allAccounts.filter((account) => {
return !account.groupInfos || account.groupInfos.length === 0
})
} else {
// 筛选属于特定分组的账户
filteredAccounts = allAccounts.filter((account) => {
if (!account.groupInfos || account.groupInfos.length === 0) {
return false
}
// 检查账户是否属于选中的分组
return account.groupInfos.some((group) => group.id === groupFilter.value)
})
}
}
accounts.value = filteredAccounts
} catch (error) { } catch (error) {
showToast('加载账户失败', 'error') showToast('加载账户失败', 'error')
} finally { } finally {
@@ -1307,11 +1077,9 @@ const formatNumber = (num) => {
if (num === null || num === undefined) return '0' if (num === null || num === undefined) return '0'
const number = Number(num) const number = Number(num)
if (number >= 1000000) { if (number >= 1000000) {
return (number / 1000000).toFixed(2) return Math.floor(number / 1000000).toLocaleString() + 'M'
} else if (number >= 1000) {
return (number / 1000000).toFixed(4)
} }
return (number / 1000000).toFixed(6) return number.toLocaleString()
} }
// 格式化最后使用时间 // 格式化最后使用时间
@@ -1343,7 +1111,7 @@ const loadApiKeys = async (forceReload = false) => {
apiKeysLoaded.value = true apiKeysLoaded.value = true
} }
} catch (error) { } catch (error) {
// 静默处理错误 console.error('Failed to load API keys:', error)
} }
} }
@@ -1360,7 +1128,7 @@ const loadAccountGroups = async (forceReload = false) => {
groupsLoaded.value = true groupsLoaded.value = true
} }
} catch (error) { } catch (error) {
// 静默处理错误 console.error('Failed to load account groups:', error)
} }
} }
@@ -1433,38 +1201,6 @@ const formatRemainingTime = (minutes) => {
return `${mins}分钟` return `${mins}分钟`
} }
// 格式化限流时间(支持显示天数)
const formatRateLimitTime = (minutes) => {
if (!minutes || minutes <= 0) return ''
// 转换为整数,避免小数
minutes = Math.floor(minutes)
// 计算天数、小时和分钟
const days = Math.floor(minutes / 1440) // 1天 = 1440分钟
const remainingAfterDays = minutes % 1440
const hours = Math.floor(remainingAfterDays / 60)
const mins = remainingAfterDays % 60
// 根据时间长度返回不同格式
if (days > 0) {
// 超过1天显示天数和小时
if (hours > 0) {
return `${days}天${hours}小时`
}
return `${days}天`
} else if (hours > 0) {
// 超过1小时但不到1天显示小时和分钟
if (mins > 0) {
return `${hours}小时${mins}分钟`
}
return `${hours}小时`
} else {
// 不到1小时只显示分钟
return `${mins}分钟`
}
}
// 打开创建账户模态框 // 打开创建账户模态框
const openCreateAccountModal = () => { const openCreateAccountModal = () => {
showCreateAccountModal.value = true showCreateAccountModal.value = true
@@ -1554,27 +1290,11 @@ const resetAccountStatus = async (account) => {
try { try {
account.isResetting = true account.isResetting = true
const data = await apiClient.post(`/admin/claude-accounts/${account.id}/reset-status`)
// 根据账户平台选择不同的 API 端点
let endpoint = ''
if (account.platform === 'openai') {
endpoint = `/admin/openai-accounts/${account.id}/reset-status`
} else if (account.platform === 'claude') {
endpoint = `/admin/claude-accounts/${account.id}/reset-status`
} else if (account.platform === 'claude-console') {
endpoint = `/admin/claude-console-accounts/${account.id}/reset-status`
} else {
showToast('不支持的账户类型', 'error')
account.isResetting = false
return
}
const data = await apiClient.post(endpoint)
if (data.success) { if (data.success) {
showToast('账户状态已重置', 'success') showToast('账户状态已重置', 'success')
// 强制刷新,绕过前端缓存,确保最终一致性 loadAccounts()
loadAccounts(true)
} else { } else {
showToast(data.message || '状态重置失败', 'error') showToast(data.message || '状态重置失败', 'error')
} }
@@ -1675,7 +1395,13 @@ const getClaudeAccountType = (account) => {
? JSON.parse(account.subscriptionInfo) ? JSON.parse(account.subscriptionInfo)
: account.subscriptionInfo : account.subscriptionInfo
// 订阅信息已解析 // 添加调试日志
console.log('Account subscription info:', {
accountName: account.name,
subscriptionInfo: info,
hasClaudeMax: info.hasClaudeMax,
hasClaudePro: info.hasClaudePro
})
// 根据 has_claude_max 和 has_claude_pro 判断 // 根据 has_claude_max 和 has_claude_pro 判断
if (info.hasClaudeMax === true) { if (info.hasClaudeMax === true) {
@@ -1687,83 +1413,16 @@ const getClaudeAccountType = (account) => {
} }
} catch (e) { } catch (e) {
// 解析失败,返回默认值 // 解析失败,返回默认值
console.error('Failed to parse subscription info:', e)
return 'Claude' return 'Claude'
} }
} }
// 没有订阅信息,保持原有显示 // 没有订阅信息,保持原有显示
console.log('No subscription info for account:', account.name)
return 'Claude' return 'Claude'
} }
// 获取停止调度的原因
const getSchedulableReason = (account) => {
if (account.schedulable !== false) return null
// Claude Console 账户的错误状态
if (account.platform === 'claude-console') {
if (account.status === 'unauthorized') {
return 'API Key无效或已过期401错误'
}
if (account.overloadStatus === 'overloaded') {
return '服务过载529错误'
}
if (account.rateLimitStatus === 'limited') {
return '触发限流429错误'
}
if (account.status === 'blocked' && account.errorMessage) {
return account.errorMessage
}
}
// Claude 官方账户的错误状态
if (account.platform === 'claude') {
if (account.status === 'unauthorized') {
return '认证失败401错误'
}
if (account.status === 'temp_error' && account.errorMessage) {
return account.errorMessage
}
if (account.status === 'error' && account.errorMessage) {
return account.errorMessage
}
if (account.isRateLimited) {
return '触发限流429错误'
}
// 自动停止调度的原因
if (account.stoppedReason) {
return account.stoppedReason
}
}
// OpenAI 账户的错误状态
if (account.platform === 'openai') {
if (account.status === 'unauthorized') {
return '认证失败401错误'
}
// 检查限流状态 - 兼容嵌套的 rateLimitStatus 对象
if (
(account.rateLimitStatus && account.rateLimitStatus.isRateLimited) ||
account.isRateLimited
) {
return '触发限流429错误'
}
if (account.status === 'error' && account.errorMessage) {
return account.errorMessage
}
}
// 通用原因
if (account.stoppedReason) {
return account.stoppedReason
}
if (account.errorMessage) {
return account.errorMessage
}
// 默认为手动停止
return '手动停止调度'
}
// 获取账户状态文本 // 获取账户状态文本
const getAccountStatusText = (account) => { const getAccountStatusText = (account) => {
// 检查是否被封锁 // 检查是否被封锁
@@ -1778,8 +1437,6 @@ const getAccountStatusText = (account) => {
account.rateLimitStatus === 'limited' account.rateLimitStatus === 'limited'
) )
return '限流中' return '限流中'
// 检查是否临时错误
if (account.status === 'temp_error') return '临时异常'
// 检查是否错误 // 检查是否错误
if (account.status === 'error' || !account.isActive) return '错误' if (account.status === 'error' || !account.isActive) return '错误'
// 检查是否可调度 // 检查是否可调度
@@ -1804,9 +1461,6 @@ const getAccountStatusClass = (account) => {
) { ) {
return 'bg-orange-100 text-orange-800' return 'bg-orange-100 text-orange-800'
} }
if (account.status === 'temp_error') {
return 'bg-orange-100 text-orange-800'
}
if (account.status === 'error' || !account.isActive) { if (account.status === 'error' || !account.isActive) {
return 'bg-red-100 text-red-800' return 'bg-red-100 text-red-800'
} }
@@ -1832,9 +1486,6 @@ const getAccountStatusDotClass = (account) => {
) { ) {
return 'bg-orange-500' return 'bg-orange-500'
} }
if (account.status === 'temp_error') {
return 'bg-orange-500'
}
if (account.status === 'error' || !account.isActive) { if (account.status === 'error' || !account.isActive) {
return 'bg-red-500' return 'bg-red-500'
} }
@@ -1857,74 +1508,6 @@ const formatRelativeTime = (dateString) => {
return formatLastUsed(dateString) return formatLastUsed(dateString)
} }
// 获取会话窗口进度条的样式类
const getSessionProgressBarClass = (status) => {
// 根据状态返回不同的颜色类,包含防御性检查
if (!status) {
// 无状态信息时默认为蓝色
return 'bg-gradient-to-r from-blue-500 to-indigo-600'
}
// 转换为小写进行比较,避免大小写问题
const normalizedStatus = String(status).toLowerCase()
if (normalizedStatus === 'rejected') {
// 被拒绝 - 红色
return 'bg-gradient-to-r from-red-500 to-red-600'
} else if (normalizedStatus === 'allowed_warning') {
// 警告状态 - 橙色/黄色
return 'bg-gradient-to-r from-yellow-500 to-orange-500'
} else {
// 正常状态allowed 或其他) - 蓝色
return 'bg-gradient-to-r from-blue-500 to-indigo-600'
}
}
// 格式化费用显示
const formatCost = (cost) => {
if (!cost || cost === 0) return '0.0000'
if (cost < 0.0001) return cost.toExponential(2)
if (cost < 0.01) return cost.toFixed(6)
if (cost < 1) return cost.toFixed(4)
return cost.toFixed(2)
}
// 额度使用百分比Claude Console
const getQuotaUsagePercent = (account) => {
const used = Number(account?.usage?.daily?.cost || 0)
const quota = Number(account?.dailyQuota || 0)
if (!quota || quota <= 0) return 0
return (used / quota) * 100
}
// 额度进度条颜色Claude Console
const getQuotaBarClass = (percent) => {
if (percent >= 90) return 'bg-red-500'
if (percent >= 70) return 'bg-yellow-500'
return 'bg-green-500'
}
// 剩余额度Claude Console
const formatRemainingQuota = (account) => {
const used = Number(account?.usage?.daily?.cost || 0)
const quota = Number(account?.dailyQuota || 0)
if (!quota || quota <= 0) return '0.00'
return Math.max(0, quota - used).toFixed(2)
}
// 计算每日费用(使用后端返回的精确费用数据)
const calculateDailyCost = (account) => {
if (!account.usage || !account.usage.daily) return '0.0000'
// 如果后端已经返回了计算好的费用,直接使用
if (account.usage.daily.cost !== undefined) {
return formatCost(account.usage.daily.cost)
}
// 如果后端没有返回费用旧版本返回0
return '0.0000'
}
// 切换调度状态 // 切换调度状态
// const toggleDispatch = async (account) => { // const toggleDispatch = async (account) => {
// await toggleSchedulable(account) // await toggleSchedulable(account)

File diff suppressed because it is too large Load Diff

View File

@@ -17,22 +17,11 @@
<!-- 分隔线 --> <!-- 分隔线 -->
<div <div
v-if="oemSettings.ldapEnabled || oemSettings.showAdminButton !== false"
class="h-8 w-px bg-gradient-to-b from-transparent via-gray-300 to-transparent opacity-50 dark:via-gray-600" class="h-8 w-px bg-gradient-to-b from-transparent via-gray-300 to-transparent opacity-50 dark:via-gray-600"
/> />
<!-- 用户登录按钮 (仅在 LDAP 启用时显示) -->
<router-link
v-if="oemSettings.ldapEnabled"
class="user-login-button flex items-center gap-2 rounded-2xl px-4 py-2 text-white transition-all duration-300 md:px-5 md:py-2.5"
to="/user-login"
>
<i class="fas fa-user text-sm md:text-base" />
<span class="text-xs font-semibold tracking-wide md:text-sm">用户登录</span>
</router-link>
<!-- 管理后台按钮 --> <!-- 管理后台按钮 -->
<router-link <router-link
v-if="oemSettings.showAdminButton !== false"
class="admin-button-refined flex items-center gap-2 rounded-2xl px-4 py-2 transition-all duration-300 md:px-5 md:py-2.5" class="admin-button-refined flex items-center gap-2 rounded-2xl px-4 py-2 transition-all duration-300 md:px-5 md:py-2.5"
to="/dashboard" to="/dashboard"
> >
@@ -125,10 +114,7 @@
<!-- Token 分布和限制配置 --> <!-- Token 分布和限制配置 -->
<div class="mb-6 grid grid-cols-1 gap-4 md:mb-8 md:gap-6 lg:grid-cols-2"> <div class="mb-6 grid grid-cols-1 gap-4 md:mb-8 md:gap-6 lg:grid-cols-2">
<TokenDistribution /> <TokenDistribution />
<!-- 单key模式下显示限制配置 --> <LimitConfig />
<LimitConfig v-if="!multiKeyMode" />
<!-- 多key模式下显示聚合统计卡片填充右侧空白 -->
<AggregatedStatsCard v-if="multiKeyMode" />
</div> </div>
<!-- 模型使用统计 --> <!-- 模型使用统计 -->
@@ -158,7 +144,6 @@ import ApiKeyInput from '@/components/apistats/ApiKeyInput.vue'
import StatsOverview from '@/components/apistats/StatsOverview.vue' import StatsOverview from '@/components/apistats/StatsOverview.vue'
import TokenDistribution from '@/components/apistats/TokenDistribution.vue' import TokenDistribution from '@/components/apistats/TokenDistribution.vue'
import LimitConfig from '@/components/apistats/LimitConfig.vue' import LimitConfig from '@/components/apistats/LimitConfig.vue'
import AggregatedStatsCard from '@/components/apistats/AggregatedStatsCard.vue'
import ModelUsageStats from '@/components/apistats/ModelUsageStats.vue' import ModelUsageStats from '@/components/apistats/ModelUsageStats.vue'
import TutorialView from './TutorialView.vue' import TutorialView from './TutorialView.vue'
@@ -181,8 +166,7 @@ const {
error, error,
statsPeriod, statsPeriod,
statsData, statsData,
oemSettings, oemSettings
multiKeyMode
} = storeToRefs(apiStatsStore) } = storeToRefs(apiStatsStore)
const { queryStats, switchPeriod, loadStatsWithApiId, loadOemSettings, reset } = apiStatsStore const { queryStats, switchPeriod, loadStatsWithApiId, loadOemSettings, reset } = apiStatsStore
@@ -325,73 +309,6 @@ watch(apiKey, (newValue) => {
letter-spacing: -0.025em; letter-spacing: -0.025em;
} }
/* 用户登录按钮 */
.user-login-button {
background: linear-gradient(135deg, #34d399 0%, #10b981 100%);
backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.3);
text-decoration: none;
box-shadow:
0 4px 12px rgba(52, 211, 153, 0.25),
inset 0 1px 1px rgba(255, 255, 255, 0.2);
position: relative;
overflow: hidden;
font-weight: 600;
}
/* 暗色模式下的用户登录按钮 */
:global(.dark) .user-login-button {
background: linear-gradient(135deg, #34d399 0%, #10b981 100%);
border: 1px solid rgba(52, 211, 153, 0.4);
color: white;
box-shadow:
0 4px 12px rgba(52, 211, 153, 0.3),
inset 0 1px 1px rgba(255, 255, 255, 0.1);
}
.user-login-button::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(135deg, #10b981 0%, #34d399 100%);
opacity: 0;
transition: opacity 0.3s ease;
}
.user-login-button:hover {
transform: translateY(-2px) scale(1.02);
box-shadow:
0 8px 20px rgba(52, 211, 153, 0.35),
inset 0 1px 1px rgba(255, 255, 255, 0.3);
border-color: rgba(255, 255, 255, 0.4);
}
.user-login-button:hover::before {
opacity: 1;
}
/* 暗色模式下的悬停效果 */
:global(.dark) .user-login-button:hover {
box-shadow:
0 8px 20px rgba(52, 211, 153, 0.4),
inset 0 1px 1px rgba(255, 255, 255, 0.2);
border-color: rgba(52, 211, 153, 0.5);
}
.user-login-button:active {
transform: translateY(-1px) scale(1);
}
/* 确保图标和文字在所有模式下都清晰可见 */
.user-login-button i,
.user-login-button span {
position: relative;
z-index: 1;
}
/* 管理后台按钮 - 精致版本 */ /* 管理后台按钮 - 精致版本 */
.admin-button-refined { .admin-button-refined {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);

View File

@@ -41,8 +41,9 @@
<!-- 加载状态 --> <!-- 加载状态 -->
<div v-if="loading" class="py-12 text-center"> <div v-if="loading" class="py-12 text-center">
<div class="loading-spinner mx-auto mb-4"></div> <div class="loading-spinner mx-auto mb-4">
<p class="text-gray-500 dark:text-gray-400">正在加载设置...</p> <p class="text-gray-500 dark:text-gray-400">正在加载设置...</p>
</div>
</div> </div>
<!-- 内容区域 --> <!-- 内容区域 -->
@@ -147,41 +148,6 @@
</td> </td>
</tr> </tr>
<!-- 管理后台按钮显示控制 -->
<tr class="table-row">
<td class="w-48 whitespace-nowrap px-6 py-4">
<div class="flex items-center">
<div
class="mr-3 flex h-8 w-8 items-center justify-center rounded-lg bg-gradient-to-br from-indigo-500 to-purple-600"
>
<i class="fas fa-eye-slash text-xs text-white" />
</div>
<div>
<div class="text-sm font-semibold text-gray-900 dark:text-gray-100">
管理入口
</div>
<div class="text-xs text-gray-500 dark:text-gray-400">登录按钮显示</div>
</div>
</div>
</td>
<td class="px-6 py-4">
<div class="flex items-center">
<label class="inline-flex cursor-pointer items-center">
<input v-model="hideAdminButton" class="peer sr-only" type="checkbox" />
<div
class="peer relative h-6 w-11 rounded-full bg-gray-200 after:absolute after:left-[2px] after:top-[2px] after:h-5 after:w-5 after:rounded-full after:border after:border-gray-300 after:bg-white after:transition-all after:content-[''] peer-checked:bg-blue-600 peer-checked:after:translate-x-full peer-checked:after:border-white peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:border-gray-600 dark:bg-gray-700 dark:peer-focus:ring-blue-800"
></div>
<span class="ml-3 text-sm font-medium text-gray-900 dark:text-gray-300">{{
hideAdminButton ? '隐藏登录按钮' : '显示登录按钮'
}}</span>
</label>
</div>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
隐藏后用户需要直接访问 /admin/login 页面登录
</p>
</td>
</tr>
<!-- 操作按钮 --> <!-- 操作按钮 -->
<tr> <tr>
<td class="px-6 py-6" colspan="2"> <td class="px-6 py-6" colspan="2">
@@ -224,148 +190,7 @@
<!-- 移动端卡片视图 --> <!-- 移动端卡片视图 -->
<div class="space-y-4 sm:hidden"> <div class="space-y-4 sm:hidden">
<!-- 站点名称卡片 --> <!-- 省略移动端视图代码... -->
<div class="glass-card p-4">
<div class="mb-3 flex items-center gap-3">
<div
class="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-blue-500 to-cyan-600 text-white shadow-md"
>
<i class="fas fa-tag"></i>
</div>
<div>
<h3 class="text-base font-semibold text-gray-900 dark:text-gray-100">站点名称</h3>
<p class="text-sm text-gray-500 dark:text-gray-400">自定义您的站点品牌名称</p>
</div>
</div>
<input
v-model="oemSettings.siteName"
class="form-input w-full dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200"
maxlength="100"
placeholder="Claude Relay Service"
type="text"
/>
</div>
<!-- 站点图标卡片 -->
<div class="glass-card p-4">
<div class="mb-3 flex items-center gap-3">
<div
class="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-purple-500 to-pink-600 text-white shadow-md"
>
<i class="fas fa-image"></i>
</div>
<div>
<h3 class="text-base font-semibold text-gray-900 dark:text-gray-100">站点图标</h3>
<p class="text-sm text-gray-500 dark:text-gray-400">
上传自定义图标或输入图标URL
</p>
</div>
</div>
<div class="space-y-3">
<!-- 图标预览 -->
<div
v-if="oemSettings.siteIconData || oemSettings.siteIcon"
class="inline-flex items-center gap-3 rounded-lg bg-gray-50 p-3 dark:bg-gray-700"
>
<img
alt="图标预览"
class="h-8 w-8"
:src="oemSettings.siteIconData || oemSettings.siteIcon"
@error="handleIconError"
/>
<span class="text-sm text-gray-600 dark:text-gray-400">当前图标</span>
<button
class="rounded-lg px-3 py-1 font-medium text-red-600 transition-colors hover:bg-red-50 hover:text-red-900"
@click="removeIcon"
>
删除
</button>
</div>
<!-- 上传按钮 -->
<div>
<input
ref="iconFileInputMobile"
accept=".ico,.png,.jpg,.jpeg,.svg"
class="hidden"
type="file"
@change="handleIconUpload"
/>
<button
class="btn btn-success px-4 py-2"
@click="$refs.iconFileInputMobile.click()"
>
<i class="fas fa-upload mr-2" />
上传图标
</button>
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
支持 .ico, .png, .jpg, .svg 格式最大 350KB
</p>
</div>
</div>
</div>
<!-- 管理后台按钮显示控制卡片 -->
<div class="glass-card p-4">
<div class="mb-3 flex items-center gap-3">
<div
class="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-indigo-500 to-purple-600 text-white shadow-md"
>
<i class="fas fa-eye-slash"></i>
</div>
<div>
<h3 class="text-base font-semibold text-gray-900 dark:text-gray-100">管理入口</h3>
<p class="text-sm text-gray-500 dark:text-gray-400">控制登录按钮在首页的显示</p>
</div>
</div>
<div class="space-y-2">
<label class="inline-flex cursor-pointer items-center">
<input v-model="hideAdminButton" class="peer sr-only" type="checkbox" />
<div
class="peer relative h-6 w-11 rounded-full bg-gray-200 after:absolute after:left-[2px] after:top-[2px] after:h-5 after:w-5 after:rounded-full after:border after:border-gray-300 after:bg-white after:transition-all after:content-[''] peer-checked:bg-blue-600 peer-checked:after:translate-x-full peer-checked:after:border-white peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:border-gray-600 dark:bg-gray-700 dark:peer-focus:ring-blue-800"
></div>
<span class="ml-3 text-sm font-medium text-gray-900 dark:text-gray-300">{{
hideAdminButton ? '隐藏登录按钮' : '显示登录按钮'
}}</span>
</label>
<p class="text-xs text-gray-500 dark:text-gray-400">
隐藏后用户需要直接访问 /admin/login 页面登录
</p>
</div>
</div>
<!-- 操作按钮卡片 -->
<div class="glass-card p-4">
<div class="flex flex-col gap-3">
<button
class="btn btn-primary w-full px-6 py-3"
:class="{ 'cursor-not-allowed opacity-50': saving }"
:disabled="saving"
@click="saveOemSettings"
>
<div v-if="saving" class="loading-spinner mr-2"></div>
<i v-else class="fas fa-save mr-2" />
{{ saving ? '保存中...' : '保存设置' }}
</button>
<button
class="btn w-full bg-gray-100 px-6 py-3 text-gray-700 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
:disabled="saving"
@click="resetOemSettings"
>
<i class="fas fa-undo mr-2" />
重置为默认
</button>
<div
v-if="oemSettings.updatedAt"
class="text-center text-sm text-gray-500 dark:text-gray-400"
>
<i class="fas fa-clock mr-1" />
上次更新: {{ formatDateTime(oemSettings.updatedAt) }}
</div>
</div>
</div>
</div> </div>
</div> </div>
@@ -377,7 +202,9 @@
> >
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
<h2 class="text-lg font-semibold text-gray-800 dark:text-gray-200">启用通知</h2> <h2 class="text-lg font-semibold text-gray-800 dark:text-gray-200">
启用 Webhook 通知
</h2>
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400"> <p class="mt-1 text-sm text-gray-600 dark:text-gray-400">
开启后系统将按配置发送通知到指定平台 开启后系统将按配置发送通知到指定平台
</p> </p>
@@ -469,22 +296,10 @@
</div> </div>
</div> </div>
<div class="mt-3 space-y-1 text-sm"> <div class="mt-3 space-y-1 text-sm">
<div <div class="flex items-center text-gray-600 dark:text-gray-400">
v-if="platform.type !== 'smtp'"
class="flex items-center text-gray-600 dark:text-gray-400"
>
<i class="fas fa-link mr-2"></i> <i class="fas fa-link mr-2"></i>
<span class="truncate">{{ platform.url }}</span> <span class="truncate">{{ platform.url }}</span>
</div> </div>
<div
v-if="platform.type === 'smtp' && platform.to"
class="flex items-center text-gray-600 dark:text-gray-400"
>
<i class="fas fa-envelope mr-2"></i>
<span class="truncate">{{
Array.isArray(platform.to) ? platform.to.join(', ') : platform.to
}}</span>
</div>
<div <div
v-if="platform.enableSign" v-if="platform.enableSign"
class="flex items-center text-gray-600 dark:text-gray-400" class="flex items-center text-gray-600 dark:text-gray-400"
@@ -664,8 +479,6 @@
<option value="feishu">🟦 飞书</option> <option value="feishu">🟦 飞书</option>
<option value="slack">🟣 Slack</option> <option value="slack">🟣 Slack</option>
<option value="discord">🟪 Discord</option> <option value="discord">🟪 Discord</option>
<option value="bark">🔔 Bark</option>
<option value="smtp">📧 邮件通知</option>
<option value="custom"> 自定义</option> <option value="custom"> 自定义</option>
</select> </select>
<div class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3"> <div class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3">
@@ -695,8 +508,8 @@
/> />
</div> </div>
<!-- Webhook URL (非Bark和SMTP平台) --> <!-- Webhook URL -->
<div v-if="platformForm.type !== 'bark' && platformForm.type !== 'smtp'"> <div>
<label <label
class="mb-2 flex items-center text-sm font-medium text-gray-700 dark:text-gray-300" class="mb-2 flex items-center text-sm font-medium text-gray-700 dark:text-gray-300"
> >
@@ -735,253 +548,6 @@
</div> </div>
</div> </div>
<!-- Bark 平台特有字段 -->
<div v-if="platformForm.type === 'bark'" class="space-y-5">
<!-- 设备密钥 -->
<div>
<label
class="mb-2 flex items-center text-sm font-medium text-gray-700 dark:text-gray-300"
>
<i class="fas fa-key mr-2 text-gray-400"></i>
设备密钥 (Device Key)
<span class="ml-1 text-xs text-red-500">*</span>
</label>
<input
v-model="platformForm.deviceKey"
class="w-full rounded-xl border border-gray-300 bg-white px-4 py-3 font-mono text-sm text-gray-900 shadow-sm transition-all placeholder:text-gray-400 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder:text-gray-500"
placeholder="例如aBcDeFgHiJkLmNoPqRsTuVwX"
required
type="text"
/>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
在Bark App中查看您的推送密钥
</p>
</div>
<!-- 服务器URL可选 -->
<div>
<label
class="mb-2 flex items-center text-sm font-medium text-gray-700 dark:text-gray-300"
>
<i class="fas fa-server mr-2 text-gray-400"></i>
服务器地址
<span class="ml-2 text-xs text-gray-500">(可选)</span>
</label>
<input
v-model="platformForm.serverUrl"
class="w-full rounded-xl border border-gray-300 bg-white px-4 py-3 font-mono text-sm text-gray-900 shadow-sm transition-all placeholder:text-gray-400 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder:text-gray-500"
placeholder="默认: https://api.day.app/push"
type="url"
/>
</div>
<!-- 通知级别 -->
<div>
<label
class="mb-2 flex items-center text-sm font-medium text-gray-700 dark:text-gray-300"
>
<i class="fas fa-flag mr-2 text-gray-400"></i>
通知级别
</label>
<select
v-model="platformForm.level"
class="w-full appearance-none rounded-xl border border-gray-300 bg-white px-4 py-3 pr-10 text-gray-900 shadow-sm transition-all focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
>
<option value="">自动根据通知类型</option>
<option value="passive">被动</option>
<option value="active">默认</option>
<option value="timeSensitive">时效性</option>
<option value="critical">紧急</option>
</select>
</div>
<!-- 通知声音 -->
<div>
<label
class="mb-2 flex items-center text-sm font-medium text-gray-700 dark:text-gray-300"
>
<i class="fas fa-volume-up mr-2 text-gray-400"></i>
通知声音
</label>
<select
v-model="platformForm.sound"
class="w-full appearance-none rounded-xl border border-gray-300 bg-white px-4 py-3 pr-10 text-gray-900 shadow-sm transition-all focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
>
<option value="">自动根据通知类型</option>
<option value="default">默认</option>
<option value="alarm">警报</option>
<option value="bell">铃声</option>
<option value="birdsong">鸟鸣</option>
<option value="electronic">电子音</option>
<option value="glass">玻璃</option>
<option value="horn">喇叭</option>
<option value="silence">静音</option>
</select>
</div>
<!-- 分组 -->
<div>
<label
class="mb-2 flex items-center text-sm font-medium text-gray-700 dark:text-gray-300"
>
<i class="fas fa-folder mr-2 text-gray-400"></i>
通知分组
<span class="ml-2 text-xs text-gray-500">(可选)</span>
</label>
<input
v-model="platformForm.group"
class="w-full rounded-xl border border-gray-300 bg-white px-4 py-3 text-gray-900 shadow-sm transition-all placeholder:text-gray-400 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder:text-gray-500"
placeholder="默认: claude-relay"
type="text"
/>
</div>
<!-- 提示信息 -->
<div class="mt-2 flex items-start rounded-lg bg-blue-50 p-3 dark:bg-blue-900/20">
<i class="fas fa-info-circle mr-2 mt-0.5 text-blue-600 dark:text-blue-400"></i>
<div class="text-sm text-blue-700 dark:text-blue-300">
<p>1. 在iPhone上安装Bark App</p>
<p>2. 打开App获取您的设备密钥</p>
<p>3. 将密钥粘贴到上方输入框</p>
</div>
</div>
</div>
<!-- SMTP 平台特有字段 -->
<div v-if="platformForm.type === 'smtp'" class="space-y-5">
<!-- SMTP 主机 -->
<div>
<label
class="mb-2 flex items-center text-sm font-medium text-gray-700 dark:text-gray-300"
>
<i class="fas fa-server mr-2 text-gray-400"></i>
SMTP 服务器
<span class="ml-1 text-xs text-red-500">*</span>
</label>
<input
v-model="platformForm.host"
class="w-full rounded-xl border border-gray-300 bg-white px-4 py-3 text-gray-900 shadow-sm transition-all placeholder:text-gray-400 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder:text-gray-500"
placeholder="例如: smtp.gmail.com"
required
type="text"
/>
</div>
<!-- SMTP 端口和安全设置 -->
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label
class="mb-2 flex items-center text-sm font-medium text-gray-700 dark:text-gray-300"
>
<i class="fas fa-plug mr-2 text-gray-400"></i>
端口
</label>
<input
v-model.number="platformForm.port"
class="w-full rounded-xl border border-gray-300 bg-white px-4 py-3 text-gray-900 shadow-sm transition-all focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
max="65535"
min="1"
placeholder="587"
type="number"
/>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
默认: 587 (TLS) 465 (SSL)
</p>
</div>
<div>
<label
class="mb-2 flex items-center text-sm font-medium text-gray-700 dark:text-gray-300"
>
<i class="fas fa-shield-alt mr-2 text-gray-400"></i>
加密方式
</label>
<select
v-model="platformForm.secure"
class="w-full appearance-none rounded-xl border border-gray-300 bg-white px-4 py-3 pr-10 text-gray-900 shadow-sm transition-all focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
>
<option :value="false">STARTTLS (端口587)</option>
<option :value="true">SSL/TLS (端口465)</option>
</select>
</div>
</div>
<!-- 用户名 -->
<div>
<label
class="mb-2 flex items-center text-sm font-medium text-gray-700 dark:text-gray-300"
>
<i class="fas fa-user mr-2 text-gray-400"></i>
用户名
<span class="ml-1 text-xs text-red-500">*</span>
</label>
<input
v-model="platformForm.user"
class="w-full rounded-xl border border-gray-300 bg-white px-4 py-3 text-gray-900 shadow-sm transition-all placeholder:text-gray-400 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder:text-gray-500"
placeholder="user@example.com"
required
type="email"
/>
</div>
<!-- 密码 -->
<div>
<label
class="mb-2 flex items-center text-sm font-medium text-gray-700 dark:text-gray-300"
>
<i class="fas fa-lock mr-2 text-gray-400"></i>
密码 / 应用密码
<span class="ml-1 text-xs text-red-500">*</span>
</label>
<input
v-model="platformForm.pass"
class="w-full rounded-xl border border-gray-300 bg-white px-4 py-3 text-gray-900 shadow-sm transition-all placeholder:text-gray-400 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder:text-gray-500"
placeholder="邮箱密码或应用专用密码"
required
type="password"
/>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
建议使用应用专用密码而非邮箱登录密码
</p>
</div>
<!-- 发件人邮箱 -->
<div>
<label
class="mb-2 flex items-center text-sm font-medium text-gray-700 dark:text-gray-300"
>
<i class="fas fa-paper-plane mr-2 text-gray-400"></i>
发件人邮箱
<span class="ml-2 text-xs text-gray-500">(可选)</span>
</label>
<input
v-model="platformForm.from"
class="w-full rounded-xl border border-gray-300 bg-white px-4 py-3 text-gray-900 shadow-sm transition-all placeholder:text-gray-400 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder:text-gray-500"
placeholder="默认使用用户名邮箱"
type="email"
/>
</div>
<!-- 收件人邮箱 -->
<div>
<label
class="mb-2 flex items-center text-sm font-medium text-gray-700 dark:text-gray-300"
>
<i class="fas fa-envelope mr-2 text-gray-400"></i>
收件人邮箱
<span class="ml-1 text-xs text-red-500">*</span>
</label>
<input
v-model="platformForm.to"
class="w-full rounded-xl border border-gray-300 bg-white px-4 py-3 text-gray-900 shadow-sm transition-all placeholder:text-gray-400 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder:text-gray-500"
placeholder="admin@example.com"
required
type="email"
/>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">接收通知的邮箱地址</p>
</div>
</div>
<!-- 签名设置钉钉/飞书 --> <!-- 签名设置钉钉/飞书 -->
<div <div
v-if="platformForm.type === 'dingtalk' || platformForm.type === 'feishu'" v-if="platformForm.type === 'dingtalk' || platformForm.type === 'feishu'"
@@ -1067,7 +633,7 @@
</button> </button>
<button <button
class="group flex items-center rounded-xl bg-gradient-to-r from-blue-600 to-indigo-600 px-5 py-2.5 text-sm font-medium text-white shadow-md transition-all hover:from-blue-700 hover:to-indigo-700 hover:shadow-lg disabled:cursor-not-allowed disabled:from-gray-400 disabled:to-gray-500" class="group flex items-center rounded-xl bg-gradient-to-r from-blue-600 to-indigo-600 px-5 py-2.5 text-sm font-medium text-white shadow-md transition-all hover:from-blue-700 hover:to-indigo-700 hover:shadow-lg disabled:cursor-not-allowed disabled:from-gray-400 disabled:to-gray-500"
:disabled="!isPlatformFormValid || savingPlatform" :disabled="!platformForm.url || savingPlatform"
@click="savePlatform" @click="savePlatform"
> >
<i <i
@@ -1086,7 +652,7 @@
</template> </template>
<script setup> <script setup>
import { ref, onMounted, onBeforeUnmount, watch, computed } from 'vue' import { ref, onMounted, onBeforeUnmount, watch } from 'vue'
import { storeToRefs } from 'pinia' import { storeToRefs } from 'pinia'
import { showToast } from '@/utils/toast' import { showToast } from '@/utils/toast'
import { useSettingsStore } from '@/stores/settings' import { useSettingsStore } from '@/stores/settings'
@@ -1113,16 +679,6 @@ const isMounted = ref(true)
// API请求取消控制器 // API请求取消控制器
const abortController = ref(new AbortController()) const abortController = ref(new AbortController())
// 计算属性:隐藏管理后台按钮(反转 showAdminButton 的值)
const hideAdminButton = computed({
get() {
return !oemSettings.value.showAdminButton
},
set(value) {
oemSettings.value.showAdminButton = !value
}
})
// URL 验证状态 // URL 验证状态
const urlError = ref(false) const urlError = ref(false)
const urlValid = ref(false) const urlValid = ref(false)
@@ -1154,23 +710,7 @@ const platformForm = ref({
name: '', name: '',
url: '', url: '',
enableSign: false, enableSign: false,
secret: '', secret: ''
// Bark特有字段
deviceKey: '',
serverUrl: '',
level: '',
sound: '',
group: '',
// SMTP特有字段
host: '',
port: null,
secure: false,
user: '',
pass: '',
from: '',
to: '',
timeout: null,
ignoreTLS: false
}) })
// 监听activeSection变化加载对应配置 // 监听activeSection变化加载对应配置
@@ -1181,83 +721,6 @@ const sectionWatcher = watch(activeSection, async (newSection) => {
} }
}) })
// 监听平台类型变化,重置验证状态
const platformTypeWatcher = watch(
() => platformForm.value.type,
(newType) => {
// 切换平台类型时重置验证状态
urlError.value = false
urlValid.value = false
// 如果不是编辑模式,清空相关字段
if (!editingPlatform.value) {
if (newType === 'bark') {
// 切换到Bark时清空URL和SMTP相关字段
platformForm.value.url = ''
platformForm.value.enableSign = false
platformForm.value.secret = ''
// 清空SMTP字段
platformForm.value.host = ''
platformForm.value.port = null
platformForm.value.secure = false
platformForm.value.user = ''
platformForm.value.pass = ''
platformForm.value.from = ''
platformForm.value.to = ''
platformForm.value.timeout = null
platformForm.value.ignoreTLS = false
} else if (newType === 'smtp') {
// 切换到SMTP时清空URL和Bark相关字段
platformForm.value.url = ''
platformForm.value.enableSign = false
platformForm.value.secret = ''
// 清空Bark字段
platformForm.value.deviceKey = ''
platformForm.value.serverUrl = ''
platformForm.value.level = ''
platformForm.value.sound = ''
platformForm.value.group = ''
} else {
// 切换到其他平台时清空Bark和SMTP相关字段
platformForm.value.deviceKey = ''
platformForm.value.serverUrl = ''
platformForm.value.level = ''
platformForm.value.sound = ''
platformForm.value.group = ''
// SMTP 字段
platformForm.value.host = ''
platformForm.value.port = null
platformForm.value.secure = false
platformForm.value.user = ''
platformForm.value.pass = ''
platformForm.value.from = ''
platformForm.value.to = ''
platformForm.value.timeout = null
platformForm.value.ignoreTLS = false
}
}
}
)
// 计算属性:判断平台表单是否有效
const isPlatformFormValid = computed(() => {
if (platformForm.value.type === 'bark') {
// Bark平台需要deviceKey
return !!platformForm.value.deviceKey
} else if (platformForm.value.type === 'smtp') {
// SMTP平台需要必要的配置
return !!(
platformForm.value.host &&
platformForm.value.user &&
platformForm.value.pass &&
platformForm.value.to
)
} else {
// 其他平台需要URL且URL格式正确
return !!platformForm.value.url && !urlError.value
}
})
// 页面加载时获取设置 // 页面加载时获取设置
onMounted(async () => { onMounted(async () => {
try { try {
@@ -1284,9 +747,6 @@ onBeforeUnmount(() => {
if (sectionWatcher) { if (sectionWatcher) {
sectionWatcher() sectionWatcher()
} }
if (platformTypeWatcher) {
platformTypeWatcher()
}
// 安全关闭模态框 // 安全关闭模态框
if (showAddPlatformModal.value) { if (showAddPlatformModal.value) {
@@ -1335,13 +795,6 @@ const saveWebhookConfig = async () => {
// 验证 URL // 验证 URL
const validateUrl = () => { const validateUrl = () => {
// Bark和SMTP平台不需要验证URL
if (platformForm.value.type === 'bark' || platformForm.value.type === 'smtp') {
urlError.value = false
urlValid.value = false
return
}
const url = platformForm.value.url const url = platformForm.value.url
if (!url) { if (!url) {
urlError.value = false urlError.value = false
@@ -1364,46 +817,19 @@ const validateUrl = () => {
} }
} }
// 验证平台配置
const validatePlatformForm = () => {
if (platformForm.value.type === 'bark') {
if (!platformForm.value.deviceKey) {
showToast('请输入Bark设备密钥', 'error')
return false
}
} else if (platformForm.value.type === 'smtp') {
const requiredFields = [
{ field: 'host', message: 'SMTP服务器' },
{ field: 'user', message: '用户名' },
{ field: 'pass', message: '密码' },
{ field: 'to', message: '收件人邮箱' }
]
for (const { field, message } of requiredFields) {
if (!platformForm.value[field]) {
showToast(`请输入${message}`, 'error')
return false
}
}
} else {
if (!platformForm.value.url) {
showToast('请输入Webhook URL', 'error')
return false
}
if (urlError.value) {
showToast('请输入有效的Webhook URL', 'error')
return false
}
}
return true
}
// 添加/更新平台 // 添加/更新平台
const savePlatform = async () => { const savePlatform = async () => {
if (!isMounted.value) return if (!isMounted.value) return
// 验证表单 if (!platformForm.value.url) {
if (!validatePlatformForm()) return showToast('请输入Webhook URL', 'error')
return
}
if (urlError.value) {
showToast('请输入有效的Webhook URL', 'error')
return
}
savingPlatform.value = true savingPlatform.value = true
try { try {
@@ -1499,37 +925,20 @@ const testPlatform = async (platform) => {
if (!isMounted.value) return if (!isMounted.value) return
try { try {
const testData = { const response = await apiClient.post(
type: platform.type, '/admin/webhook/test',
secret: platform.secret, {
enableSign: platform.enableSign url: platform.url,
} type: platform.type,
secret: platform.secret,
// 根据平台类型添加不同字段 enableSign: platform.enableSign
if (platform.type === 'bark') { },
testData.deviceKey = platform.deviceKey {
testData.serverUrl = platform.serverUrl signal: abortController.value.signal
testData.level = platform.level }
testData.sound = platform.sound )
testData.group = platform.group
} else if (platform.type === 'smtp') {
testData.host = platform.host
testData.port = platform.port
testData.secure = platform.secure
testData.user = platform.user
testData.pass = platform.pass
testData.from = platform.from
testData.to = platform.to
testData.ignoreTLS = platform.ignoreTLS
} else {
testData.url = platform.url
}
const response = await apiClient.post('/admin/webhook/test', testData, {
signal: abortController.value.signal
})
if (response.success && isMounted.value) { if (response.success && isMounted.value) {
showToast('测试成功', 'success') showToast('测试成功webhook连接正常', 'success')
} }
} catch (error) { } catch (error) {
if (error.name === 'AbortError') return if (error.name === 'AbortError') return
@@ -1543,8 +952,15 @@ const testPlatform = async (platform) => {
const testPlatformForm = async () => { const testPlatformForm = async () => {
if (!isMounted.value) return if (!isMounted.value) return
// 验证表单 if (!platformForm.value.url) {
if (!validatePlatformForm()) return showToast('请先输入Webhook URL', 'error')
return
}
if (urlError.value) {
showToast('请输入有效的Webhook URL', 'error')
return
}
testingConnection.value = true testingConnection.value = true
try { try {
@@ -1552,7 +968,7 @@ const testPlatformForm = async () => {
signal: abortController.value.signal signal: abortController.value.signal
}) })
if (response.success && isMounted.value) { if (response.success && isMounted.value) {
showToast('测试成功', 'success') showToast('测试成功webhook连接正常', 'success')
} }
} catch (error) { } catch (error) {
if (error.name === 'AbortError') return if (error.name === 'AbortError') return
@@ -1604,23 +1020,7 @@ const closePlatformModal = () => {
name: '', name: '',
url: '', url: '',
enableSign: false, enableSign: false,
secret: '', secret: ''
// Bark特有字段
deviceKey: '',
serverUrl: '',
level: '',
sound: '',
group: '',
// SMTP特有字段
host: '',
port: null,
secure: false,
user: '',
pass: '',
from: '',
to: '',
timeout: null,
ignoreTLS: false
} }
urlError.value = false urlError.value = false
urlValid.value = false urlValid.value = false
@@ -1637,8 +1037,6 @@ const getPlatformName = (type) => {
feishu: '飞书', feishu: '飞书',
slack: 'Slack', slack: 'Slack',
discord: 'Discord', discord: 'Discord',
bark: 'Bark',
smtp: '邮件通知',
custom: '自定义' custom: '自定义'
} }
return names[type] || type return names[type] || type
@@ -1651,8 +1049,6 @@ const getPlatformIcon = (type) => {
feishu: 'fas fa-dove text-blue-600', feishu: 'fas fa-dove text-blue-600',
slack: 'fab fa-slack text-purple-600', slack: 'fab fa-slack text-purple-600',
discord: 'fab fa-discord text-indigo-600', discord: 'fab fa-discord text-indigo-600',
bark: 'fas fa-bell text-orange-500',
smtp: 'fas fa-envelope text-blue-600',
custom: 'fas fa-webhook text-gray-600' custom: 'fas fa-webhook text-gray-600'
} }
return icons[type] || 'fas fa-bell' return icons[type] || 'fas fa-bell'
@@ -1665,8 +1061,6 @@ const getWebhookHint = (type) => {
feishu: '请在飞书群机器人设置中获取Webhook地址', feishu: '请在飞书群机器人设置中获取Webhook地址',
slack: '请在Slack应用的Incoming Webhooks中获取地址', slack: '请在Slack应用的Incoming Webhooks中获取地址',
discord: '请在Discord服务器的集成设置中创建Webhook', discord: '请在Discord服务器的集成设置中创建Webhook',
bark: '请在Bark App中查看您的设备密钥',
smtp: '请配置SMTP服务器信息支持Gmail、QQ邮箱等',
custom: '请输入完整的Webhook接收地址' custom: '请输入完整的Webhook接收地址'
} }
return hints[type] || '' return hints[type] || ''
@@ -1677,8 +1071,7 @@ const getNotificationTypeName = (type) => {
accountAnomaly: '账号异常', accountAnomaly: '账号异常',
quotaWarning: '配额警告', quotaWarning: '配额警告',
systemError: '系统错误', systemError: '系统错误',
securityAlert: '安全警报', securityAlert: '安全警报'
test: '测试通知'
} }
return names[type] || type return names[type] || type
} }
@@ -1688,8 +1081,7 @@ const getNotificationTypeDescription = (type) => {
accountAnomaly: '账号状态异常、认证失败等', accountAnomaly: '账号状态异常、认证失败等',
quotaWarning: 'API调用配额不足警告', quotaWarning: 'API调用配额不足警告',
systemError: '系统运行错误和故障', systemError: '系统运行错误和故障',
securityAlert: '安全相关的警报通知', securityAlert: '安全相关的警报通知'
test: '用于测试Webhook连接是否正常'
} }
return descriptions[type] || '' return descriptions[type] || ''
} }
@@ -1700,8 +1092,7 @@ const saveOemSettings = async () => {
const settings = { const settings = {
siteName: oemSettings.value.siteName, siteName: oemSettings.value.siteName,
siteIcon: oemSettings.value.siteIcon, siteIcon: oemSettings.value.siteIcon,
siteIconData: oemSettings.value.siteIconData, siteIconData: oemSettings.value.siteIconData
showAdminButton: oemSettings.value.showAdminButton
} }
const result = await settingsStore.saveOemSettings(settings) const result = await settingsStore.saveOemSettings(settings)
if (result && result.success) { if (result && result.success) {

View File

@@ -420,43 +420,60 @@
</p> </p>
<div class="space-y-4"> <div class="space-y-4">
<div class="rounded-lg border border-yellow-200 bg-yellow-50 p-3 sm:p-4"> <div class="rounded-lg border border-indigo-200 bg-white p-3 sm:p-4">
<h6 class="mb-2 font-medium text-yellow-800">Codex 配置文件</h6> <h6 class="mb-2 text-sm font-medium text-gray-800 dark:text-gray-600 sm:text-base">
<p class="mb-3 text-sm text-yellow-700"> PowerShell 设置方法
</h6>
<code class="rounded bg-yellow-100 px-1">~/.codex/config.toml</code> <p class="mb-3 text-sm text-gray-600"> PowerShell 中运行以下命令</p>
文件中添加以下配置
</p>
<div <div
class="overflow-x-auto rounded bg-gray-900 p-2 font-mono text-xs text-green-400 sm:p-3 sm:text-sm" class="overflow-x-auto rounded bg-gray-900 p-2 font-mono text-xs text-green-400 sm:p-3 sm:text-sm"
> >
<div class="whitespace-nowrap text-gray-300">model_provider = "crs"</div> <div class="whitespace-nowrap text-gray-300">
<div class="whitespace-nowrap text-gray-300">model = "gpt-5"</div> $env:OPENAI_BASE_URL = "{{ openaiBaseUrl }}"
<div class="whitespace-nowrap text-gray-300">model_reasoning_effort = "high"</div> </div>
<div class="whitespace-nowrap text-gray-300">disable_response_storage = true</div> <div class="whitespace-nowrap text-gray-300">
<div class="whitespace-nowrap text-gray-300">preferred_auth_method = "apikey"</div> $env:OPENAI_API_KEY = "你的API密钥"
<div class="mt-2"></div> </div>
<div class="whitespace-nowrap text-gray-300">[model_providers.crs]</div>
<div class="whitespace-nowrap text-gray-300">name = "crs"</div>
<div class="whitespace-nowrap text-gray-300">base_url = "{{ openaiBaseUrl }}"</div>
<div class="whitespace-nowrap text-gray-300">wire_api = "responses"</div>
</div>
<p class="mt-3 text-sm text-yellow-700">
<code class="rounded bg-yellow-100 px-1">~/.codex/auth.json</code>
文件中配置API密钥
</p>
<div
class="mt-2 overflow-x-auto rounded bg-gray-900 p-2 font-mono text-xs text-green-400 sm:p-3 sm:text-sm"
>
<div class="whitespace-nowrap text-gray-300">{</div>
<div class="whitespace-nowrap text-gray-300">"OPENAI_API_KEY": "你的API密钥"</div>
<div class="whitespace-nowrap text-gray-300">}</div>
</div> </div>
<p class="mt-2 text-xs text-yellow-700"> <p class="mt-2 text-xs text-yellow-700">
💡 使用与 Claude Code 相同的 API 密钥即可格式如 cr_xxxxxxxxxx 💡 使用与 Claude Code 相同的 API 密钥即可格式如 cr_xxxxxxxxxx
</p> </p>
</div> </div>
<div class="rounded-lg border border-indigo-200 bg-white p-3 sm:p-4">
<h6 class="mb-2 text-sm font-medium text-gray-800 dark:text-gray-600 sm:text-base">
PowerShell 永久设置用户级
</h6>
<p class="mb-3 text-sm text-gray-600"> PowerShell 中运行以下命令</p>
<div
class="mb-3 overflow-x-auto rounded bg-gray-900 p-2 font-mono text-xs text-green-400 sm:p-3 sm:text-sm"
>
<div class="mb-2"># 设置用户级环境变量永久生效</div>
<div class="whitespace-nowrap text-gray-300">
[System.Environment]::SetEnvironmentVariable("OPENAI_BASE_URL", "{{
openaiBaseUrl
}}", [System.EnvironmentVariableTarget]::User)
</div>
<div class="whitespace-nowrap text-gray-300">
[System.Environment]::SetEnvironmentVariable("OPENAI_API_KEY", "你的API密钥",
[System.EnvironmentVariableTarget]::User)
</div>
</div>
<p class="mt-2 text-xs text-blue-700">
💡 设置后需要重新打开 PowerShell 窗口才能生效
</p>
</div>
<div class="rounded-lg border border-indigo-200 bg-indigo-50 p-3 sm:p-4">
<h6 class="mb-2 font-medium text-indigo-800">验证 Codex 环境变量</h6>
<p class="mb-3 text-sm text-indigo-700"> PowerShell 中验证</p>
<div
class="space-y-1 overflow-x-auto rounded bg-gray-900 p-2 font-mono text-xs text-green-400 sm:p-3 sm:text-sm"
>
<div class="whitespace-nowrap text-gray-300">echo $env:OPENAI_BASE_URL</div>
<div class="whitespace-nowrap text-gray-300">echo $env:OPENAI_API_KEY</div>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -907,43 +924,67 @@
</p> </p>
<div class="space-y-4"> <div class="space-y-4">
<div class="rounded-lg border border-yellow-200 bg-yellow-50 p-3 sm:p-4"> <div class="rounded-lg border border-indigo-200 bg-white p-3 sm:p-4">
<h6 class="mb-2 font-medium text-yellow-800">Codex 配置文件</h6> <h6 class="mb-2 text-sm font-medium text-gray-800 dark:text-gray-600 sm:text-base">
<p class="mb-3 text-sm text-yellow-700"> Terminal 设置方法
</h6>
<code class="rounded bg-yellow-100 px-1">~/.codex/config.toml</code> <p class="mb-3 text-sm text-gray-600"> Terminal 中运行以下命令</p>
文件中添加以下配置
</p>
<div <div
class="overflow-x-auto rounded bg-gray-900 p-2 font-mono text-xs text-green-400 sm:p-3 sm:text-sm" class="overflow-x-auto rounded bg-gray-900 p-2 font-mono text-xs text-green-400 sm:p-3 sm:text-sm"
> >
<div class="whitespace-nowrap text-gray-300">model_provider = "crs"</div> <div class="whitespace-nowrap text-gray-300">
<div class="whitespace-nowrap text-gray-300">model = "gpt-5"</div> export OPENAI_BASE_URL="{{ openaiBaseUrl }}"
<div class="whitespace-nowrap text-gray-300">model_reasoning_effort = "high"</div> </div>
<div class="whitespace-nowrap text-gray-300">disable_response_storage = true</div> <div class="whitespace-nowrap text-gray-300">
<div class="whitespace-nowrap text-gray-300">preferred_auth_method = "apikey"</div> export OPENAI_API_KEY="你的API密钥"
<div class="mt-2"></div> </div>
<div class="whitespace-nowrap text-gray-300">[model_providers.crs]</div>
<div class="whitespace-nowrap text-gray-300">name = "crs"</div>
<div class="whitespace-nowrap text-gray-300">base_url = "{{ openaiBaseUrl }}"</div>
<div class="whitespace-nowrap text-gray-300">wire_api = "responses"</div>
</div>
<p class="mt-3 text-sm text-yellow-700">
<code class="rounded bg-yellow-100 px-1">~/.codex/auth.json</code>
文件中配置API密钥
</p>
<div
class="mt-2 overflow-x-auto rounded bg-gray-900 p-2 font-mono text-xs text-green-400 sm:p-3 sm:text-sm"
>
<div class="whitespace-nowrap text-gray-300">{</div>
<div class="whitespace-nowrap text-gray-300">"OPENAI_API_KEY": "你的API密钥"</div>
<div class="whitespace-nowrap text-gray-300">}</div>
</div> </div>
<p class="mt-2 text-xs text-yellow-700"> <p class="mt-2 text-xs text-yellow-700">
💡 使用与 Claude Code 相同的 API 密钥即可格式如 cr_xxxxxxxxxx 💡 使用与 Claude Code 相同的 API 密钥即可格式如 cr_xxxxxxxxxx
</p> </p>
</div> </div>
<div class="rounded-lg border border-indigo-200 bg-white p-3 sm:p-4">
<h6 class="mb-2 text-sm font-medium text-gray-800 dark:text-gray-600 sm:text-base">
永久设置方法
</h6>
<p class="mb-3 text-sm text-gray-600">添加到你的 shell 配置文件</p>
<div
class="mb-3 overflow-x-auto rounded bg-gray-900 p-2 font-mono text-xs text-green-400 sm:p-3 sm:text-sm"
>
<div class="mb-2"># 对于 zsh (默认)</div>
<div class="whitespace-nowrap text-gray-300">
echo 'export OPENAI_BASE_URL="{{ openaiBaseUrl }}"' >> ~/.zshrc
</div>
<div class="whitespace-nowrap text-gray-300">
echo 'export OPENAI_API_KEY="你的API密钥"' >> ~/.zshrc
</div>
<div class="whitespace-nowrap text-gray-300">source ~/.zshrc</div>
</div>
<div
class="overflow-x-auto rounded bg-gray-900 p-2 font-mono text-xs text-green-400 sm:p-3 sm:text-sm"
>
<div class="mb-2"># 对于 bash</div>
<div class="whitespace-nowrap text-gray-300">
echo 'export OPENAI_BASE_URL="{{ openaiBaseUrl }}"' >> ~/.bash_profile
</div>
<div class="whitespace-nowrap text-gray-300">
echo 'export OPENAI_API_KEY="你的API密钥"' >> ~/.bash_profile
</div>
<div class="whitespace-nowrap text-gray-300">source ~/.bash_profile</div>
</div>
</div>
<div class="rounded-lg border border-indigo-200 bg-indigo-50 p-3 sm:p-4">
<h6 class="mb-2 font-medium text-indigo-800">验证 Codex 环境变量</h6>
<p class="mb-3 text-sm text-indigo-700"> Terminal 中验证</p>
<div
class="space-y-1 overflow-x-auto rounded bg-gray-900 p-2 font-mono text-xs text-green-400 sm:p-3 sm:text-sm"
>
<div class="whitespace-nowrap text-gray-300">echo $OPENAI_BASE_URL</div>
<div class="whitespace-nowrap text-gray-300">echo $OPENAI_API_KEY</div>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -1385,43 +1426,67 @@
</p> </p>
<div class="space-y-4"> <div class="space-y-4">
<div class="rounded-lg border border-yellow-200 bg-yellow-50 p-3 sm:p-4"> <div class="rounded-lg border border-indigo-200 bg-white p-3 sm:p-4">
<h6 class="mb-2 font-medium text-yellow-800">Codex 配置文件</h6> <h6 class="mb-2 text-sm font-medium text-gray-800 dark:text-gray-600 sm:text-base">
<p class="mb-3 text-sm text-yellow-700"> 终端设置方法
</h6>
<code class="rounded bg-yellow-100 px-1">~/.codex/config.toml</code> <p class="mb-3 text-sm text-gray-600">在终端中运行以下命令</p>
文件中添加以下配置
</p>
<div <div
class="overflow-x-auto rounded bg-gray-900 p-2 font-mono text-xs text-green-400 sm:p-3 sm:text-sm" class="overflow-x-auto rounded bg-gray-900 p-2 font-mono text-xs text-green-400 sm:p-3 sm:text-sm"
> >
<div class="whitespace-nowrap text-gray-300">model_provider = "crs"</div> <div class="whitespace-nowrap text-gray-300">
<div class="whitespace-nowrap text-gray-300">model = "gpt-5"</div> export OPENAI_BASE_URL="{{ openaiBaseUrl }}"
<div class="whitespace-nowrap text-gray-300">model_reasoning_effort = "high"</div> </div>
<div class="whitespace-nowrap text-gray-300">disable_response_storage = true</div> <div class="whitespace-nowrap text-gray-300">
<div class="whitespace-nowrap text-gray-300">preferred_auth_method = "apikey"</div> export OPENAI_API_KEY="你的API密钥"
<div class="mt-2"></div> </div>
<div class="whitespace-nowrap text-gray-300">[model_providers.crs]</div>
<div class="whitespace-nowrap text-gray-300">name = "crs"</div>
<div class="whitespace-nowrap text-gray-300">base_url = "{{ openaiBaseUrl }}"</div>
<div class="whitespace-nowrap text-gray-300">wire_api = "responses"</div>
</div>
<p class="mt-3 text-sm text-yellow-700">
<code class="rounded bg-yellow-100 px-1">~/.codex/auth.json</code>
文件中配置API密钥
</p>
<div
class="mt-2 overflow-x-auto rounded bg-gray-900 p-2 font-mono text-xs text-green-400 sm:p-3 sm:text-sm"
>
<div class="whitespace-nowrap text-gray-300">{</div>
<div class="whitespace-nowrap text-gray-300">"OPENAI_API_KEY": "你的API密钥"</div>
<div class="whitespace-nowrap text-gray-300">}</div>
</div> </div>
<p class="mt-2 text-xs text-yellow-700"> <p class="mt-2 text-xs text-yellow-700">
💡 使用与 Claude Code 相同的 API 密钥即可格式如 cr_xxxxxxxxxx 💡 使用与 Claude Code 相同的 API 密钥即可格式如 cr_xxxxxxxxxx
</p> </p>
</div> </div>
<div class="rounded-lg border border-indigo-200 bg-white p-3 sm:p-4">
<h6 class="mb-2 text-sm font-medium text-gray-800 dark:text-gray-600 sm:text-base">
永久设置方法
</h6>
<p class="mb-3 text-sm text-gray-600">添加到你的 shell 配置文件</p>
<div
class="mb-3 overflow-x-auto rounded bg-gray-900 p-2 font-mono text-xs text-green-400 sm:p-3 sm:text-sm"
>
<div class="mb-2"># 对于 bash (默认)</div>
<div class="whitespace-nowrap text-gray-300">
echo 'export OPENAI_BASE_URL="{{ openaiBaseUrl }}"' >> ~/.bashrc
</div>
<div class="whitespace-nowrap text-gray-300">
echo 'export OPENAI_API_KEY="你的API密钥"' >> ~/.bashrc
</div>
<div class="whitespace-nowrap text-gray-300">source ~/.bashrc</div>
</div>
<div
class="overflow-x-auto rounded bg-gray-900 p-2 font-mono text-xs text-green-400 sm:p-3 sm:text-sm"
>
<div class="mb-2"># 对于 zsh</div>
<div class="whitespace-nowrap text-gray-300">
echo 'export OPENAI_BASE_URL="{{ openaiBaseUrl }}"' >> ~/.zshrc
</div>
<div class="whitespace-nowrap text-gray-300">
echo 'export OPENAI_API_KEY="你的API密钥"' >> ~/.zshrc
</div>
<div class="whitespace-nowrap text-gray-300">source ~/.zshrc</div>
</div>
</div>
<div class="rounded-lg border border-indigo-200 bg-indigo-50 p-3 sm:p-4">
<h6 class="mb-2 font-medium text-indigo-800">验证 Codex 环境变量</h6>
<p class="mb-3 text-sm text-indigo-700">在终端中验证</p>
<div
class="space-y-1 overflow-x-auto rounded bg-gray-900 p-2 font-mono text-xs text-green-400 sm:p-3 sm:text-sm"
>
<div class="whitespace-nowrap text-gray-300">echo $OPENAI_BASE_URL</div>
<div class="whitespace-nowrap text-gray-300">echo $OPENAI_API_KEY</div>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -1574,7 +1639,7 @@
</template> </template>
<script setup> <script setup>
import { computed, ref } from 'vue' import { ref, computed } from 'vue'
// 当前系统选择 // 当前系统选择
const activeTutorialSystem = ref('windows') const activeTutorialSystem = ref('windows')
@@ -1588,14 +1653,6 @@ const tutorialSystems = [
// 获取基础URL前缀 // 获取基础URL前缀
const getBaseUrlPrefix = () => { const getBaseUrlPrefix = () => {
// 优先使用环境变量配置的自定义前缀
const customPrefix = import.meta.env.VITE_API_BASE_PREFIX
if (customPrefix) {
// 去除末尾的斜杠
return customPrefix.replace(/\/$/, '')
}
// 否则使用当前浏览器访问地址
// 更健壮的获取 origin 的方法,兼容旧版浏览器和特殊环境 // 更健壮的获取 origin 的方法,兼容旧版浏览器和特殊环境
let origin = '' let origin = ''

View File

@@ -1,437 +0,0 @@
<template>
<div class="min-h-screen bg-gray-50 dark:bg-gray-900">
<!-- 导航栏 -->
<nav class="bg-white shadow dark:bg-gray-800">
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div class="flex h-16 justify-between">
<div class="flex items-center">
<div class="flex flex-shrink-0 items-center">
<svg
class="h-8 w-8 text-blue-600 dark:text-blue-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
/>
</svg>
<span class="ml-2 text-xl font-bold text-gray-900 dark:text-white">Claude Relay</span>
</div>
<div class="ml-10">
<div class="flex items-baseline space-x-4">
<button
:class="[
'rounded-md px-3 py-2 text-sm font-medium',
activeTab === 'overview'
? 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300'
: 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300'
]"
@click="handleTabChange('overview')"
>
Overview
</button>
<button
:class="[
'rounded-md px-3 py-2 text-sm font-medium',
activeTab === 'api-keys'
? 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300'
: 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300'
]"
@click="handleTabChange('api-keys')"
>
API Keys
</button>
<button
:class="[
'rounded-md px-3 py-2 text-sm font-medium',
activeTab === 'usage'
? 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300'
: 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300'
]"
@click="handleTabChange('usage')"
>
Usage Stats
</button>
<button
:class="[
'rounded-md px-3 py-2 text-sm font-medium',
activeTab === 'tutorial'
? 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300'
: 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300'
]"
@click="handleTabChange('tutorial')"
>
Tutorial
</button>
</div>
</div>
</div>
<div class="flex items-center space-x-4">
<div class="text-sm text-gray-700 dark:text-gray-300">
Welcome, <span class="font-medium">{{ userStore.userName }}</span>
</div>
<!-- 主题切换按钮 -->
<ThemeToggle mode="icon" />
<button
class="rounded-md px-3 py-2 text-sm font-medium text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300"
@click="handleLogout"
>
Logout
</button>
</div>
</div>
</div>
</nav>
<!-- 主内容 -->
<main class="mx-auto max-w-7xl px-4 py-6 sm:px-6 lg:px-8">
<!-- Overview Tab -->
<div v-if="activeTab === 'overview'" class="space-y-6">
<div>
<h1 class="text-2xl font-semibold text-gray-900 dark:text-white">Dashboard Overview</h1>
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
Welcome to your Claude Relay dashboard
</p>
</div>
<!-- Stats Cards -->
<div class="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-5">
<div class="overflow-hidden rounded-lg bg-white shadow dark:bg-gray-800">
<div class="p-5">
<div class="flex items-center">
<div class="flex-shrink-0">
<svg
class="h-6 w-6 text-green-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
d="M15 7a2 2 0 012 2m0 0a2 2 0 012 2m-2-2h-6m6 0v6a2 2 0 01-2 2H9a2 2 0 01-2-2V9a2 2 0 012-2h6z"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
/>
</svg>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="truncate text-sm font-medium text-gray-500 dark:text-gray-400">
Active API Keys
</dt>
<dd class="text-lg font-medium text-gray-900 dark:text-white">
{{ apiKeysStats.active }}
</dd>
</dl>
</div>
</div>
</div>
</div>
<div class="overflow-hidden rounded-lg bg-white shadow dark:bg-gray-800">
<div class="p-5">
<div class="flex items-center">
<div class="flex-shrink-0">
<svg
class="h-6 w-6 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
/>
</svg>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="truncate text-sm font-medium text-gray-500 dark:text-gray-400">
Deleted API Keys
</dt>
<dd class="text-lg font-medium text-gray-900 dark:text-white">
{{ apiKeysStats.deleted }}
</dd>
</dl>
</div>
</div>
</div>
</div>
<div class="overflow-hidden rounded-lg bg-white shadow dark:bg-gray-800">
<div class="p-5">
<div class="flex items-center">
<div class="flex-shrink-0">
<svg
class="h-6 w-6 text-blue-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
d="M13 10V3L4 14h7v7l9-11h-7z"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
/>
</svg>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="truncate text-sm font-medium text-gray-500 dark:text-gray-400">
Total Requests
</dt>
<dd class="text-lg font-medium text-gray-900 dark:text-white">
{{ formatNumber(userProfile?.totalUsage?.requests || 0) }}
</dd>
</dl>
</div>
</div>
</div>
</div>
<div class="overflow-hidden rounded-lg bg-white shadow dark:bg-gray-800">
<div class="p-5">
<div class="flex items-center">
<div class="flex-shrink-0">
<svg
class="h-6 w-6 text-purple-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
/>
</svg>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="truncate text-sm font-medium text-gray-500 dark:text-gray-400">
Input Tokens
</dt>
<dd class="text-lg font-medium text-gray-900 dark:text-white">
{{ formatNumber(userProfile?.totalUsage?.inputTokens || 0) }}
</dd>
</dl>
</div>
</div>
</div>
</div>
<div class="overflow-hidden rounded-lg bg-white shadow dark:bg-gray-800">
<div class="p-5">
<div class="flex items-center">
<div class="flex-shrink-0">
<svg
class="h-6 w-6 text-yellow-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
/>
</svg>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="truncate text-sm font-medium text-gray-500 dark:text-gray-400">
Total Cost
</dt>
<dd class="text-lg font-medium text-gray-900 dark:text-white">
${{ (userProfile?.totalUsage?.totalCost || 0).toFixed(4) }}
</dd>
</dl>
</div>
</div>
</div>
</div>
</div>
<!-- User Info -->
<div class="rounded-lg bg-white shadow dark:bg-gray-800">
<div class="px-4 py-5 sm:p-6">
<h3 class="text-lg font-medium leading-6 text-gray-900 dark:text-white">
Account Information
</h3>
<div class="mt-5 border-t border-gray-200 dark:border-gray-700">
<dl class="divide-y divide-gray-200 dark:divide-gray-700">
<div class="py-4 sm:grid sm:grid-cols-3 sm:gap-4 sm:py-5">
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Username</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-white sm:col-span-2 sm:mt-0">
{{ userProfile?.username }}
</dd>
</div>
<div class="py-4 sm:grid sm:grid-cols-3 sm:gap-4 sm:py-5">
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Display Name</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-white sm:col-span-2 sm:mt-0">
{{ userProfile?.displayName || 'N/A' }}
</dd>
</div>
<div class="py-4 sm:grid sm:grid-cols-3 sm:gap-4 sm:py-5">
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Email</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-white sm:col-span-2 sm:mt-0">
{{ userProfile?.email || 'N/A' }}
</dd>
</div>
<div class="py-4 sm:grid sm:grid-cols-3 sm:gap-4 sm:py-5">
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Role</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-white sm:col-span-2 sm:mt-0">
<span
class="inline-flex items-center rounded-full bg-blue-100 px-2.5 py-0.5 text-xs font-medium text-blue-800 dark:bg-blue-900 dark:text-blue-200"
>
{{ userProfile?.role || 'user' }}
</span>
</dd>
</div>
<div class="py-4 sm:grid sm:grid-cols-3 sm:gap-4 sm:py-5">
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Member Since</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-white sm:col-span-2 sm:mt-0">
{{ formatDate(userProfile?.createdAt) }}
</dd>
</div>
<div class="py-4 sm:grid sm:grid-cols-3 sm:gap-4 sm:py-5">
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Last Login</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-white sm:col-span-2 sm:mt-0">
{{ formatDate(userProfile?.lastLoginAt) || 'N/A' }}
</dd>
</div>
</dl>
</div>
</div>
</div>
</div>
<!-- API Keys Tab -->
<div v-else-if="activeTab === 'api-keys'">
<UserApiKeysManager />
</div>
<!-- Usage Stats Tab -->
<div v-else-if="activeTab === 'usage'">
<UserUsageStats />
</div>
<!-- Tutorial Tab -->
<div v-else-if="activeTab === 'tutorial'" class="space-y-6">
<TutorialView />
</div>
</main>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useUserStore } from '@/stores/user'
import { useThemeStore } from '@/stores/theme'
import { showToast } from '@/utils/toast'
import ThemeToggle from '@/components/common/ThemeToggle.vue'
import UserApiKeysManager from '@/components/user/UserApiKeysManager.vue'
import UserUsageStats from '@/components/user/UserUsageStats.vue'
import TutorialView from '@/views/TutorialView.vue'
const router = useRouter()
const userStore = useUserStore()
const themeStore = useThemeStore()
const activeTab = ref('overview')
const userProfile = ref(null)
const apiKeysStats = ref({ active: 0, deleted: 0 })
const formatNumber = (num) => {
if (num >= 1000000) {
return (num / 1000000).toFixed(1) + 'M'
} else if (num >= 1000) {
return (num / 1000).toFixed(1) + 'K'
}
return num.toString()
}
const formatDate = (dateString) => {
if (!dateString) return null
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
}
const handleTabChange = (tab) => {
activeTab.value = tab
// Refresh API keys stats when switching to overview tab
if (tab === 'overview') {
loadApiKeysStats()
}
}
const handleLogout = async () => {
try {
await userStore.logout()
showToast('Logged out successfully', 'success')
router.push('/user-login')
} catch (error) {
console.error('Logout error:', error)
showToast('Logout failed', 'error')
}
}
const loadUserProfile = async () => {
try {
userProfile.value = await userStore.getUserProfile()
} catch (error) {
console.error('Failed to load user profile:', error)
showToast('Failed to load user profile', 'error')
}
}
const loadApiKeysStats = async () => {
try {
const allApiKeys = await userStore.getUserApiKeys(true) // Include deleted keys
console.log('All API Keys received:', allApiKeys)
const activeKeys = allApiKeys.filter(
(key) => !(key.isDeleted === 'true' || key.deletedAt) && key.isActive
)
const deletedKeys = allApiKeys.filter((key) => key.isDeleted === 'true' || key.deletedAt)
console.log('Active keys:', activeKeys)
console.log('Deleted keys:', deletedKeys)
console.log('Active count:', activeKeys.length)
console.log('Deleted count:', deletedKeys.length)
apiKeysStats.value = { active: activeKeys.length, deleted: deletedKeys.length }
} catch (error) {
console.error('Failed to load API keys stats:', error)
apiKeysStats.value = { active: 0, deleted: 0 }
}
}
onMounted(() => {
// 初始化主题
themeStore.initTheme()
loadUserProfile()
loadApiKeysStats()
})
</script>
<style scoped>
/* 组件特定样式 */
</style>

View File

@@ -1,199 +0,0 @@
<template>
<div
class="relative flex min-h-screen items-center justify-center bg-gray-50 px-4 py-12 dark:bg-gray-900 sm:px-6 lg:px-8"
>
<!-- 主题切换按钮 -->
<div class="fixed right-4 top-4 z-10">
<ThemeToggle mode="dropdown" />
</div>
<div class="w-full max-w-md space-y-8">
<div>
<div class="mx-auto flex h-12 w-auto items-center justify-center">
<svg
class="h-8 w-8 text-blue-600 dark:text-blue-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
/>
</svg>
<span class="ml-2 text-xl font-bold text-gray-900 dark:text-white">Claude Relay</span>
</div>
<h2 class="mt-6 text-center text-3xl font-extrabold text-gray-900 dark:text-white">
User Sign In
</h2>
<p class="mt-2 text-center text-sm text-gray-600 dark:text-gray-400">
Sign in to your account to manage your API keys
</p>
</div>
<div class="rounded-lg bg-white px-6 py-8 shadow dark:bg-gray-800 dark:shadow-xl">
<form class="space-y-6" @submit.prevent="handleLogin">
<div>
<label
class="block text-sm font-medium text-gray-700 dark:text-gray-300"
for="username"
>
Username
</label>
<div class="mt-1">
<input
id="username"
v-model="form.username"
class="relative block w-full appearance-none rounded-md border border-gray-300 px-3 py-2 text-gray-900 placeholder-gray-500 focus:z-10 focus:border-blue-500 focus:outline-none focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder-gray-400 dark:focus:border-blue-400 dark:focus:ring-blue-400 sm:text-sm"
:disabled="loading"
name="username"
placeholder="Enter your username"
required
type="text"
/>
</div>
</div>
<div>
<label
class="block text-sm font-medium text-gray-700 dark:text-gray-300"
for="password"
>
Password
</label>
<div class="mt-1">
<input
id="password"
v-model="form.password"
class="relative block w-full appearance-none rounded-md border border-gray-300 px-3 py-2 text-gray-900 placeholder-gray-500 focus:z-10 focus:border-blue-500 focus:outline-none focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder-gray-400 dark:focus:border-blue-400 dark:focus:ring-blue-400 sm:text-sm"
:disabled="loading"
name="password"
placeholder="Enter your password"
required
type="password"
/>
</div>
</div>
<div
v-if="error"
class="rounded-md border border-red-200 bg-red-50 p-4 dark:border-red-800 dark:bg-red-900/20"
>
<div class="flex">
<div class="flex-shrink-0">
<svg class="h-5 w-5 text-red-400" fill="currentColor" viewBox="0 0 20 20">
<path
clip-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
fill-rule="evenodd"
/>
</svg>
</div>
<div class="ml-3">
<p class="text-sm text-red-700 dark:text-red-400">{{ error }}</p>
</div>
</div>
</div>
<div>
<button
class="group relative flex w-full justify-center rounded-md border border-transparent bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-blue-500 dark:hover:bg-blue-600 dark:focus:ring-blue-400 dark:focus:ring-offset-gray-800"
:disabled="loading || !form.username || !form.password"
type="submit"
>
<span v-if="loading" class="absolute inset-y-0 left-0 flex items-center pl-3">
<svg
class="h-5 w-5 animate-spin text-white"
fill="none"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
fill="currentColor"
></path>
</svg>
</span>
{{ loading ? 'Signing In...' : 'Sign In' }}
</button>
</div>
<div class="text-center">
<router-link
class="text-sm text-blue-600 hover:text-blue-500 dark:text-blue-400 dark:hover:text-blue-300"
to="/admin-login"
>
Admin Login
</router-link>
</div>
</form>
</div>
</div>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useUserStore } from '@/stores/user'
import { useThemeStore } from '@/stores/theme'
import { showToast } from '@/utils/toast'
import ThemeToggle from '@/components/common/ThemeToggle.vue'
const router = useRouter()
const userStore = useUserStore()
const themeStore = useThemeStore()
const loading = ref(false)
const error = ref('')
const form = reactive({
username: '',
password: ''
})
const handleLogin = async () => {
if (!form.username || !form.password) {
error.value = 'Please enter both username and password'
return
}
loading.value = true
error.value = ''
try {
await userStore.login({
username: form.username,
password: form.password
})
showToast('Login successful!', 'success')
router.push('/user-dashboard')
} catch (err) {
console.error('Login error:', err)
error.value = err.response?.data?.message || err.message || 'Login failed'
} finally {
loading.value = false
}
}
onMounted(() => {
// 初始化主题(因为该页面不在 MainLayout 内)
themeStore.initTheme()
})
</script>
<style scoped>
/* 组件特定样式 */
</style>

View File

@@ -1,675 +0,0 @@
<template>
<div class="space-y-6">
<!-- Header -->
<div class="sm:flex sm:items-center">
<div class="sm:flex-auto">
<h1 class="text-2xl font-semibold text-gray-900 dark:text-white">User Management</h1>
<p class="mt-2 text-sm text-gray-700 dark:text-gray-300">
Manage users, their API keys, and view usage statistics
</p>
</div>
<div class="mt-4 sm:ml-16 sm:mt-0 sm:flex-none">
<button
class="inline-flex items-center justify-center rounded-md border border-transparent bg-blue-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:opacity-50 sm:w-auto"
:disabled="loading"
@click="loadUsers"
>
<svg class="-ml-1 mr-2 h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
/>
</svg>
Refresh
</button>
</div>
</div>
<!-- Stats Cards -->
<div class="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-4">
<div class="overflow-hidden rounded-lg bg-white shadow dark:bg-gray-800">
<div class="p-5">
<div class="flex items-center">
<div class="flex-shrink-0">
<svg
class="h-6 w-6 text-blue-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197m13.5-9a2.5 2.5 0 11-5 0 2.5 2.5 0 015 0z"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
/>
</svg>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="truncate text-sm font-medium text-gray-500 dark:text-gray-400">
Total Users
</dt>
<dd class="text-lg font-medium text-gray-900 dark:text-white">
{{ userStats?.totalUsers || 0 }}
</dd>
</dl>
</div>
</div>
</div>
</div>
<div class="overflow-hidden rounded-lg bg-white shadow dark:bg-gray-800">
<div class="p-5">
<div class="flex items-center">
<div class="flex-shrink-0">
<svg
class="h-6 w-6 text-green-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
/>
</svg>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="truncate text-sm font-medium text-gray-500 dark:text-gray-400">
Active Users
</dt>
<dd class="text-lg font-medium text-gray-900 dark:text-white">
{{ userStats?.activeUsers || 0 }}
</dd>
</dl>
</div>
</div>
</div>
</div>
<div class="overflow-hidden rounded-lg bg-white shadow dark:bg-gray-800">
<div class="p-5">
<div class="flex items-center">
<div class="flex-shrink-0">
<svg
class="h-6 w-6 text-purple-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
d="M15 7a2 2 0 012 2m0 0a2 2 0 012 2m-2-2h-6m6 0v6a2 2 0 01-2 2H9a2 2 0 01-2-2V9a2 2 0 012-2h6z"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
/>
</svg>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="truncate text-sm font-medium text-gray-500 dark:text-gray-400">
Total API Keys
</dt>
<dd class="text-lg font-medium text-gray-900 dark:text-white">
{{ userStats?.totalApiKeys || 0 }}
</dd>
</dl>
</div>
</div>
</div>
</div>
<div class="overflow-hidden rounded-lg bg-white shadow dark:bg-gray-800">
<div class="p-5">
<div class="flex items-center">
<div class="flex-shrink-0">
<svg
class="h-6 w-6 text-yellow-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
/>
</svg>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="truncate text-sm font-medium text-gray-500 dark:text-gray-400">
Total Cost
</dt>
<dd class="text-lg font-medium text-gray-900 dark:text-white">
${{ (userStats?.totalUsage?.totalCost || 0).toFixed(4) }}
</dd>
</dl>
</div>
</div>
</div>
</div>
</div>
<!-- Search and Filters -->
<div class="rounded-lg bg-white shadow dark:bg-gray-800">
<div class="px-4 py-5 sm:p-6">
<div class="sm:flex sm:items-center sm:justify-between">
<div class="space-y-4 sm:flex sm:items-center sm:space-x-4 sm:space-y-0">
<!-- Search -->
<div class="min-w-0 flex-1">
<div class="relative rounded-md shadow-sm">
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
<svg
class="h-5 w-5 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
/>
</svg>
</div>
<input
v-model="searchQuery"
class="block w-full rounded-md border-gray-300 pl-10 focus:border-blue-500 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white sm:text-sm"
placeholder="Search users..."
type="search"
@input="debouncedSearch"
/>
</div>
</div>
<!-- Role Filter -->
<div>
<select
v-model="selectedRole"
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white sm:text-sm"
@change="loadUsers"
>
<option value="">All Roles</option>
<option value="user">User</option>
<option value="admin">Admin</option>
</select>
</div>
<!-- Status Filter -->
<div>
<select
v-model="selectedStatus"
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white sm:text-sm"
@change="loadUsers"
>
<option value="">All Status</option>
<option value="true">Active</option>
<option value="false">Disabled</option>
</select>
</div>
</div>
</div>
</div>
</div>
<!-- Users Table -->
<div class="overflow-hidden bg-white shadow dark:bg-gray-800 sm:rounded-md">
<div class="border-b border-gray-200 px-4 py-5 dark:border-gray-700 sm:px-6">
<h3 class="text-lg font-medium leading-6 text-gray-900 dark:text-white">
Users
<span v-if="!loading" class="text-sm text-gray-500 dark:text-gray-400"
>({{ filteredUsers.length }} of {{ users.length }})</span
>
</h3>
</div>
<!-- Loading State -->
<div v-if="loading" class="py-12 text-center">
<svg
class="mx-auto h-8 w-8 animate-spin text-blue-600"
fill="none"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
fill="currentColor"
></path>
</svg>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">Loading users...</p>
</div>
<!-- Users List -->
<ul
v-else-if="filteredUsers.length > 0"
class="divide-y divide-gray-200 dark:divide-gray-700"
role="list"
>
<li v-for="user in filteredUsers" :key="user.id" class="px-6 py-4">
<div class="flex items-center justify-between">
<div class="flex min-w-0 flex-1 items-center">
<div class="flex-shrink-0">
<div
class="flex h-10 w-10 items-center justify-center rounded-full bg-gray-300 dark:bg-gray-600"
>
<svg
class="h-6 w-6 text-gray-600 dark:text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
/>
</svg>
</div>
</div>
<div class="ml-4 min-w-0 flex-1">
<div class="flex items-center">
<p class="truncate text-sm font-medium text-gray-900 dark:text-white">
{{ user.displayName || user.username }}
</p>
<div class="ml-2 flex items-center space-x-2">
<span
:class="[
'inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium',
user.isActive
? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200'
: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200'
]"
>
{{ user.isActive ? 'Active' : 'Disabled' }}
</span>
<span
:class="[
'inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium',
user.role === 'admin'
? 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200'
: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200'
]"
>
{{ user.role }}
</span>
</div>
</div>
<div
class="mt-1 flex items-center space-x-4 text-sm text-gray-500 dark:text-gray-400"
>
<span>@{{ user.username }}</span>
<span v-if="user.email">{{ user.email }}</span>
<span>{{ user.apiKeyCount || 0 }} API keys</span>
<span v-if="user.lastLoginAt"
>Last login: {{ formatDate(user.lastLoginAt) }}</span
>
<span v-else>Never logged in</span>
</div>
<div
v-if="user.totalUsage"
class="mt-1 flex items-center space-x-4 text-xs text-gray-400 dark:text-gray-500"
>
<span>{{ formatNumber(user.totalUsage.requests || 0) }} requests</span>
<span>${{ (user.totalUsage.totalCost || 0).toFixed(4) }} total cost</span>
</div>
</div>
</div>
<div class="flex items-center space-x-2">
<!-- View Usage Stats -->
<button
class="inline-flex items-center rounded border border-transparent p-1 text-gray-400 hover:text-blue-600"
title="View Usage Stats"
@click="viewUserStats(user)"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
/>
</svg>
</button>
<!-- Disable User API Keys -->
<button
class="inline-flex items-center rounded border border-transparent p-1 text-gray-400 hover:text-red-600 disabled:cursor-not-allowed disabled:opacity-50"
:disabled="user.apiKeyCount === 0"
title="Disable All API Keys"
@click="disableUserApiKeys(user)"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728L5.636 5.636m12.728 12.728L18 12M6 6l12 12"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
/>
</svg>
</button>
<!-- Toggle User Status -->
<button
:class="[
'inline-flex items-center rounded border border-transparent p-1',
user.isActive
? 'text-gray-400 hover:text-red-600'
: 'text-gray-400 hover:text-green-600'
]"
:title="user.isActive ? 'Disable User' : 'Enable User'"
@click="toggleUserStatus(user)"
>
<svg
v-if="user.isActive"
class="h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728L5.636 5.636m12.728 12.728L18 12M6 6l12 12"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
/>
</svg>
<svg v-else class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
/>
</svg>
</button>
<!-- Change Role -->
<button
class="inline-flex items-center rounded border border-transparent p-1 text-gray-400 hover:text-purple-600"
title="Change Role"
@click="changeUserRole(user)"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 100 4m0-4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 100 4m0-4v2m0-6V4"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
/>
</svg>
</button>
</div>
</div>
</li>
</ul>
<!-- Empty State -->
<div v-else class="py-12 text-center">
<svg
class="mx-auto h-12 w-12 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197m13.5-9a2.5 2.5 0 11-5 0 2.5 2.5 0 015 0z"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
/>
</svg>
<h3 class="mt-2 text-sm font-medium text-gray-900 dark:text-white">No users found</h3>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{{
searchQuery ? 'No users match your search criteria.' : 'No users have been created yet.'
}}
</p>
</div>
</div>
<!-- User Usage Stats Modal -->
<UserUsageStatsModal
:show="showStatsModal"
:user="selectedUser"
@close="showStatsModal = false"
/>
<!-- Confirm Modals -->
<ConfirmModal
:confirm-class="confirmAction.confirmClass"
:confirm-text="confirmAction.confirmText"
:message="confirmAction.message"
:show="showConfirmModal"
:title="confirmAction.title"
@cancel="showConfirmModal = false"
@confirm="handleConfirmAction"
/>
<!-- Change Role Modal -->
<ChangeRoleModal
:show="showRoleModal"
:user="selectedUser"
@close="showRoleModal = false"
@updated="handleUserUpdated"
/>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { apiClient } from '@/config/api'
import { showToast } from '@/utils/toast'
import { debounce } from 'lodash-es'
import UserUsageStatsModal from '@/components/admin/UserUsageStatsModal.vue'
import ChangeRoleModal from '@/components/admin/ChangeRoleModal.vue'
import ConfirmModal from '@/components/common/ConfirmModal.vue'
const loading = ref(true)
const users = ref([])
const userStats = ref(null)
const searchQuery = ref('')
const selectedRole = ref('')
const selectedStatus = ref('')
const showStatsModal = ref(false)
const showConfirmModal = ref(false)
const showRoleModal = ref(false)
const selectedUser = ref(null)
const confirmAction = ref({
title: '',
message: '',
confirmText: '',
confirmClass: '',
action: null
})
const filteredUsers = computed(() => {
let filtered = users.value
// Apply search filter
if (searchQuery.value) {
const query = searchQuery.value.toLowerCase()
filtered = filtered.filter(
(user) =>
user.username.toLowerCase().includes(query) ||
user.displayName?.toLowerCase().includes(query) ||
user.email?.toLowerCase().includes(query)
)
}
// Apply role filter
if (selectedRole.value) {
filtered = filtered.filter((user) => user.role === selectedRole.value)
}
// Apply status filter
if (selectedStatus.value !== '') {
const isActive = selectedStatus.value === 'true'
filtered = filtered.filter((user) => user.isActive === isActive)
}
return filtered
})
const formatNumber = (num) => {
if (num >= 1000000) {
return (num / 1000000).toFixed(1) + 'M'
} else if (num >= 1000) {
return (num / 1000).toFixed(1) + 'K'
}
return num.toString()
}
const formatDate = (dateString) => {
if (!dateString) return null
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
}
const loadUsers = async () => {
loading.value = true
try {
// Build params object, only including parameters with actual values
const params = {}
if (selectedRole.value && selectedRole.value.trim() !== '') {
params.role = selectedRole.value
}
if (selectedStatus.value !== '') {
params.isActive = selectedStatus.value
}
const [usersResponse, statsResponse] = await Promise.all([
apiClient.get('/users', { params }),
apiClient.get('/users/stats/overview')
])
if (usersResponse.success) {
users.value = usersResponse.users
}
if (statsResponse.success) {
userStats.value = statsResponse.stats
}
} catch (error) {
console.error('Failed to load users:', error)
showToast('Failed to load users', 'error')
} finally {
loading.value = false
}
}
const debouncedSearch = debounce(() => {
// Search is handled by computed property
}, 300)
const viewUserStats = (user) => {
selectedUser.value = user
showStatsModal.value = true
}
const toggleUserStatus = (user) => {
selectedUser.value = user
confirmAction.value = {
title: user.isActive ? 'Disable User' : 'Enable User',
message: user.isActive
? `Are you sure you want to disable user "${user.username}"? This will prevent them from logging in.`
: `Are you sure you want to enable user "${user.username}"?`,
confirmText: user.isActive ? 'Disable' : 'Enable',
confirmClass: user.isActive ? 'bg-red-600 hover:bg-red-700' : 'bg-green-600 hover:bg-green-700',
action: 'toggleStatus'
}
showConfirmModal.value = true
}
const disableUserApiKeys = (user) => {
if (user.apiKeyCount === 0) return
selectedUser.value = user
confirmAction.value = {
title: 'Disable All API Keys',
message: `Are you sure you want to disable all ${user.apiKeyCount} API keys for user "${user.username}"? This will prevent them from using the service.`,
confirmText: 'Disable Keys',
confirmClass: 'bg-red-600 hover:bg-red-700',
action: 'disableKeys'
}
showConfirmModal.value = true
}
const changeUserRole = (user) => {
selectedUser.value = user
showRoleModal.value = true
}
const handleConfirmAction = async () => {
const user = selectedUser.value
const action = confirmAction.value.action
try {
if (action === 'toggleStatus') {
const response = await apiClient.patch(`/users/${user.id}/status`, {
isActive: !user.isActive
})
if (response.success) {
const userIndex = users.value.findIndex((u) => u.id === user.id)
if (userIndex !== -1) {
users.value[userIndex].isActive = !user.isActive
}
showToast(`User ${user.isActive ? 'disabled' : 'enabled'} successfully`, 'success')
}
} else if (action === 'disableKeys') {
const response = await apiClient.post(`/users/${user.id}/disable-keys`)
if (response.success) {
showToast(`Disabled ${response.disabledCount} API keys`, 'success')
await loadUsers() // Refresh to get updated counts
}
}
} catch (error) {
console.error(`Failed to ${action}:`, error)
showToast(`Failed to ${action}`, 'error')
} finally {
showConfirmModal.value = false
selectedUser.value = null
}
}
const handleUserUpdated = () => {
showRoleModal.value = false
selectedUser.value = null
loadUsers()
}
onMounted(() => {
loadUsers()
})
</script>
<style scoped>
/* 组件特定样式 */
</style>