diff --git a/.env.example b/.env.example index bdf204cf..b69ee64e 100644 --- a/.env.example +++ b/.env.example @@ -55,14 +55,45 @@ WEB_LOGO_URL=/assets/logo.png # 🛠️ 开发配置 DEBUG=false +DEBUG_HTTP_TRAFFIC=false # 启用HTTP请求/响应调试日志(仅开发环境) ENABLE_CORS=true TRUST_PROXY=true # 🔒 客户端限制(可选) # ALLOW_CUSTOM_CLIENTS=false -# 📢 Webhook 通知配置 -WEBHOOK_ENABLED=true -WEBHOOK_URLS=https://your-webhook-url.com/notify,https://backup-webhook.com/notify -WEBHOOK_TIMEOUT=10000 -WEBHOOK_RETRIES=3 \ No newline at end of file +# 🔐 LDAP 认证配置 +LDAP_ENABLED=false +LDAP_URL=ldaps://ldap-1.test1.bj.yxops.net:636 +LDAP_BIND_DN=cn=admin,dc=example,dc=com +LDAP_BIND_PASSWORD=admin_password +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=5 diff --git a/.gitignore b/.gitignore index fbe70338..10594f73 100644 --- a/.gitignore +++ b/.gitignore @@ -216,6 +216,10 @@ local/ debug.log error.log access.log +http-debug*.log +logs/http-debug-*.log + +src/middleware/debugInterceptor.js # Session files sessions/ diff --git a/README.md b/README.md index 8e6cfd5a..53f58c95 100644 --- a/README.md +++ b/README.md @@ -250,11 +250,6 @@ REDIS_HOST=localhost REDIS_PORT=6379 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` 文件:** @@ -518,67 +513,6 @@ http://你的服务器:3000/openai/claude/v1/ --- -## 📢 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. 发送测试通知确认配置正确 - ---- - ## 🔧 日常维护 ### 服务管理 diff --git a/VERSION b/VERSION index 872610a2..59aa7594 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.1.120 +1.1.124 diff --git a/config/config.example.js b/config/config.example.js index ec3ff3d2..5b8786b6 100644 --- a/config/config.example.js +++ b/config/config.example.js @@ -127,6 +127,57 @@ const config = { 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) || 5 + }, + // 📢 Webhook通知配置 webhook: { enabled: process.env.WEBHOOK_ENABLED !== 'false', // 默认启用 diff --git a/package-lock.json b/package-lock.json index 98b89998..9831ecd8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,6 +24,7 @@ "https-proxy-agent": "^7.0.2", "inquirer": "^8.2.6", "ioredis": "^5.3.2", + "ldapjs": "^3.0.7", "morgan": "^1.10.0", "ora": "^5.4.1", "rate-limiter-flexible": "^5.0.5", @@ -2048,6 +2049,101 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@ldapjs/asn1": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/@ldapjs/asn1/-/asn1-2.0.0.tgz", + "integrity": "sha512-G9+DkEOirNgdPmD0I8nu57ygQJKOOgFEMKknEuQvIHbGLwP3ny1mY+OTUYLCbCaGJP4sox5eYgBJRuSUpnAddA==", + "deprecated": "This package has been decomissioned. See https://github.com/ldapjs/node-ldapjs/blob/8ffd0bc9c149088a10ec4c1ec6a18450f76ad05d/README.md", + "license": "MIT" + }, + "node_modules/@ldapjs/attribute": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/@ldapjs/attribute/-/attribute-1.0.0.tgz", + "integrity": "sha512-ptMl2d/5xJ0q+RgmnqOi3Zgwk/TMJYG7dYMC0Keko+yZU6n+oFM59MjQOUht5pxJeS4FWrImhu/LebX24vJNRQ==", + "deprecated": "This package has been decomissioned. See https://github.com/ldapjs/node-ldapjs/blob/8ffd0bc9c149088a10ec4c1ec6a18450f76ad05d/README.md", + "license": "MIT", + "dependencies": { + "@ldapjs/asn1": "2.0.0", + "@ldapjs/protocol": "^1.2.1", + "process-warning": "^2.1.0" + } + }, + "node_modules/@ldapjs/change": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/@ldapjs/change/-/change-1.0.0.tgz", + "integrity": "sha512-EOQNFH1RIku3M1s0OAJOzGfAohuFYXFY4s73wOhRm4KFGhmQQ7MChOh2YtYu9Kwgvuq1B0xKciXVzHCGkB5V+Q==", + "deprecated": "This package has been decomissioned. See https://github.com/ldapjs/node-ldapjs/blob/8ffd0bc9c149088a10ec4c1ec6a18450f76ad05d/README.md", + "license": "MIT", + "dependencies": { + "@ldapjs/asn1": "2.0.0", + "@ldapjs/attribute": "1.0.0" + } + }, + "node_modules/@ldapjs/controls": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/@ldapjs/controls/-/controls-2.1.0.tgz", + "integrity": "sha512-2pFdD1yRC9V9hXfAWvCCO2RRWK9OdIEcJIos/9cCVP9O4k72BY1bLDQQ4KpUoJnl4y/JoD4iFgM+YWT3IfITWw==", + "deprecated": "This package has been decomissioned. See https://github.com/ldapjs/node-ldapjs/blob/8ffd0bc9c149088a10ec4c1ec6a18450f76ad05d/README.md", + "license": "MIT", + "dependencies": { + "@ldapjs/asn1": "^1.2.0", + "@ldapjs/protocol": "^1.2.1" + } + }, + "node_modules/@ldapjs/controls/node_modules/@ldapjs/asn1": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/@ldapjs/asn1/-/asn1-1.2.0.tgz", + "integrity": "sha512-KX/qQJ2xxzvO2/WOvr1UdQ+8P5dVvuOLk/C9b1bIkXxZss8BaR28njXdPgFCpj5aHaf1t8PmuVnea+N9YG9YMw==", + "deprecated": "This package has been decomissioned. See https://github.com/ldapjs/node-ldapjs/blob/8ffd0bc9c149088a10ec4c1ec6a18450f76ad05d/README.md", + "license": "MIT" + }, + "node_modules/@ldapjs/dn": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/@ldapjs/dn/-/dn-1.1.0.tgz", + "integrity": "sha512-R72zH5ZeBj/Fujf/yBu78YzpJjJXG46YHFo5E4W1EqfNpo1UsVPqdLrRMXeKIsJT3x9dJVIfR6OpzgINlKpi0A==", + "deprecated": "This package has been decomissioned. See https://github.com/ldapjs/node-ldapjs/blob/8ffd0bc9c149088a10ec4c1ec6a18450f76ad05d/README.md", + "license": "MIT", + "dependencies": { + "@ldapjs/asn1": "2.0.0", + "process-warning": "^2.1.0" + } + }, + "node_modules/@ldapjs/filter": { + "version": "2.1.1", + "resolved": "https://registry.npmmirror.com/@ldapjs/filter/-/filter-2.1.1.tgz", + "integrity": "sha512-TwPK5eEgNdUO1ABPBUQabcZ+h9heDORE4V9WNZqCtYLKc06+6+UAJ3IAbr0L0bYTnkkWC/JEQD2F+zAFsuikNw==", + "deprecated": "This package has been decomissioned. See https://github.com/ldapjs/node-ldapjs/blob/8ffd0bc9c149088a10ec4c1ec6a18450f76ad05d/README.md", + "license": "MIT", + "dependencies": { + "@ldapjs/asn1": "2.0.0", + "@ldapjs/protocol": "^1.2.1", + "process-warning": "^2.1.0" + } + }, + "node_modules/@ldapjs/messages": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/@ldapjs/messages/-/messages-1.3.0.tgz", + "integrity": "sha512-K7xZpXJ21bj92jS35wtRbdcNrwmxAtPwy4myeh9duy/eR3xQKvikVycbdWVzkYEAVE5Ce520VXNOwCHjomjCZw==", + "deprecated": "This package has been decomissioned. See https://github.com/ldapjs/node-ldapjs/blob/8ffd0bc9c149088a10ec4c1ec6a18450f76ad05d/README.md", + "license": "MIT", + "dependencies": { + "@ldapjs/asn1": "^2.0.0", + "@ldapjs/attribute": "^1.0.0", + "@ldapjs/change": "^1.0.0", + "@ldapjs/controls": "^2.1.0", + "@ldapjs/dn": "^1.1.0", + "@ldapjs/filter": "^2.1.1", + "@ldapjs/protocol": "^1.2.1", + "process-warning": "^2.2.0" + } + }, + "node_modules/@ldapjs/protocol": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/@ldapjs/protocol/-/protocol-1.2.1.tgz", + "integrity": "sha512-O89xFDLW2gBoZWNXuXpBSM32/KealKCTb3JGtJdtUQc7RjAk8XzrRgyz02cPAwGKwKPxy0ivuC7UP9bmN87egQ==", + "deprecated": "This package has been decomissioned. See https://github.com/ldapjs/node-ldapjs/blob/8ffd0bc9c149088a10ec4c1ec6a18450f76ad05d/README.md", + "license": "MIT" + }, "node_modules/@noble/hashes": { "version": "1.8.0", "resolved": "https://registry.npmmirror.com/@noble/hashes/-/hashes-1.8.0.tgz", @@ -2921,6 +3017,12 @@ "dev": true, "license": "ISC" }, + "node_modules/abstract-logging": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/abstract-logging/-/abstract-logging-2.0.1.tgz", + "integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==", + "license": "MIT" + }, "node_modules/accepts": { "version": "1.3.8", "resolved": "https://registry.npmmirror.com/accepts/-/accepts-1.3.8.tgz", @@ -3077,6 +3179,15 @@ "dev": true, "license": "MIT" }, + "node_modules/assert-plus": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/assert-plus/-/assert-plus-1.0.0.tgz", + "integrity": "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==", + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, "node_modules/astral-regex": { "version": "2.0.0", "resolved": "https://registry.npmmirror.com/astral-regex/-/astral-regex-2.0.0.tgz", @@ -3225,6 +3336,18 @@ "@babel/core": "^7.0.0" } }, + "node_modules/backoff": { + "version": "2.5.0", + "resolved": "https://registry.npmmirror.com/backoff/-/backoff-2.5.0.tgz", + "integrity": "sha512-wC5ihrnUXmR2douXmXLCe5O3zg3GKIyvRi/hi58a/XyRxVI+3/yM0PYueQOZXPXQ9pxBislYkw+sF9b7C/RuMA==", + "license": "MIT", + "dependencies": { + "precond": "0.2" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz", @@ -3912,6 +4035,12 @@ "dev": true, "license": "MIT" }, + "node_modules/core-util-is": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/core-util-is/-/core-util-is-1.0.2.tgz", + "integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==", + "license": "MIT" + }, "node_modules/cors": { "version": "2.8.5", "resolved": "https://registry.npmmirror.com/cors/-/cors-2.8.5.tgz", @@ -4633,6 +4762,15 @@ "node": ">=4" } }, + "node_modules/extsprintf": { + "version": "1.4.1", + "resolved": "https://registry.npmmirror.com/extsprintf/-/extsprintf-1.4.1.tgz", + "integrity": "sha512-Wrk35e8ydCKDj/ArClo1VrPVmN8zph5V4AtHwIuHhvMXsKf73UT3BOD+azBIW+3wOJ4FhEH7zyaJCFvChjYvMA==", + "engines": [ + "node >=0.6.0" + ], + "license": "MIT" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmmirror.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -6524,6 +6662,29 @@ "integrity": "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==", "license": "MIT" }, + "node_modules/ldapjs": { + "version": "3.0.7", + "resolved": "https://registry.npmmirror.com/ldapjs/-/ldapjs-3.0.7.tgz", + "integrity": "sha512-1ky+WrN+4CFMuoekUOv7Y1037XWdjKpu0xAPwSP+9KdvmV9PG+qOKlssDV6a+U32apwxdD3is/BZcWOYzN30cg==", + "deprecated": "This package has been decomissioned. See https://github.com/ldapjs/node-ldapjs/blob/8ffd0bc9c149088a10ec4c1ec6a18450f76ad05d/README.md", + "license": "MIT", + "dependencies": { + "@ldapjs/asn1": "^2.0.0", + "@ldapjs/attribute": "^1.0.0", + "@ldapjs/change": "^1.0.0", + "@ldapjs/controls": "^2.1.0", + "@ldapjs/dn": "^1.1.0", + "@ldapjs/filter": "^2.1.1", + "@ldapjs/messages": "^1.3.0", + "@ldapjs/protocol": "^1.2.1", + "abstract-logging": "^2.0.1", + "assert-plus": "^1.0.0", + "backoff": "^2.5.0", + "once": "^1.4.0", + "vasync": "^2.2.1", + "verror": "^1.10.1" + } + }, "node_modules/leven": { "version": "3.1.0", "resolved": "https://registry.npmmirror.com/leven/-/leven-3.1.0.tgz", @@ -7096,7 +7257,6 @@ "version": "1.4.0", "resolved": "https://registry.npmmirror.com/once/-/once-1.4.0.tgz", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dev": true, "license": "ISC", "dependencies": { "wrappy": "1" @@ -7401,6 +7561,14 @@ "node": ">=8" } }, + "node_modules/precond": { + "version": "0.2.3", + "resolved": "https://registry.npmmirror.com/precond/-/precond-0.2.3.tgz", + "integrity": "sha512-QCYG84SgGyGzqJ/vlMsxeXd/pgL/I94ixdNFyh1PusWmTCyVfPJjZ1K1jvHtsbfnXQs2TSkEP2fR7QiMZAnKFQ==", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmmirror.com/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -7468,6 +7636,12 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/process-warning": { + "version": "2.3.2", + "resolved": "https://registry.npmmirror.com/process-warning/-/process-warning-2.3.2.tgz", + "integrity": "sha512-n9wh8tvBe5sFmsqlg+XQhaQLumwpqoAUruLwjCopgTmUBjJ/fjtBsJzKleCaIGBOMXYEhp1YfKl4d7rJ5ZKJGA==", + "license": "MIT" + }, "node_modules/prompts": { "version": "2.4.2", "resolved": "https://registry.npmmirror.com/prompts/-/prompts-2.4.2.tgz", @@ -8757,6 +8931,46 @@ "node": ">= 0.8" } }, + "node_modules/vasync": { + "version": "2.2.1", + "resolved": "https://registry.npmmirror.com/vasync/-/vasync-2.2.1.tgz", + "integrity": "sha512-Hq72JaTpcTFdWiNA4Y22Amej2GH3BFmBaKPPlDZ4/oC8HNn2ISHLkFrJU4Ds8R3jcUi7oo5Y9jcMHKjES+N9wQ==", + "engines": [ + "node >=0.6.0" + ], + "license": "MIT", + "dependencies": { + "verror": "1.10.0" + } + }, + "node_modules/vasync/node_modules/verror": { + "version": "1.10.0", + "resolved": "https://registry.npmmirror.com/verror/-/verror-1.10.0.tgz", + "integrity": "sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw==", + "engines": [ + "node >=0.6.0" + ], + "license": "MIT", + "dependencies": { + "assert-plus": "^1.0.0", + "core-util-is": "1.0.2", + "extsprintf": "^1.2.0" + } + }, + "node_modules/verror": { + "version": "1.10.1", + "resolved": "https://registry.npmmirror.com/verror/-/verror-1.10.1.tgz", + "integrity": "sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg==", + "license": "MIT", + "dependencies": { + "assert-plus": "^1.0.0", + "core-util-is": "1.0.2", + "extsprintf": "^1.2.0" + }, + "engines": { + "node": ">=0.6.0" + } + }, "node_modules/walker": { "version": "1.0.8", "resolved": "https://registry.npmmirror.com/walker/-/walker-1.0.8.tgz", @@ -8911,7 +9125,6 @@ "version": "1.0.2", "resolved": "https://registry.npmmirror.com/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "dev": true, "license": "ISC" }, "node_modules/write-file-atomic": { diff --git a/package.json b/package.json index 48d7c604..86424cea 100644 --- a/package.json +++ b/package.json @@ -63,6 +63,7 @@ "https-proxy-agent": "^7.0.2", "inquirer": "^8.2.6", "ioredis": "^5.3.2", + "ldapjs": "^3.0.7", "morgan": "^1.10.0", "ora": "^5.4.1", "rate-limiter-flexible": "^5.0.5", diff --git a/scripts/manage.sh b/scripts/manage.sh index 95834e94..f8530b83 100755 --- a/scripts/manage.sh +++ b/scripts/manage.sh @@ -937,15 +937,61 @@ stop_service() { # 强制停止所有相关进程 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 "服务已停止" } # 重启服务 restart_service() { print_info "重启服务..." - stop_service - sleep 2 - start_service + + # 停止服务并检查结果 + if ! stop_service; then + 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 } # 更新模型价格 diff --git a/src/app.js b/src/app.js index f80347c8..31d7b7f3 100644 --- a/src/app.js +++ b/src/app.js @@ -21,6 +21,7 @@ const geminiRoutes = require('./routes/geminiRoutes') const openaiGeminiRoutes = require('./routes/openaiGeminiRoutes') const openaiClaudeRoutes = require('./routes/openaiClaudeRoutes') const openaiRoutes = require('./routes/openaiRoutes') +const userRoutes = require('./routes/userRoutes') const azureOpenaiRoutes = require('./routes/azureOpenaiRoutes') const webhookRoutes = require('./routes/webhook') @@ -133,6 +134,17 @@ class Application { // 📝 请求日志(使用自定义logger而不是morgan) 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( express.json({ @@ -235,6 +247,7 @@ class Application { this.app.use('/api', apiRoutes) this.app.use('/claude', apiRoutes) // /claude 路由别名,与 /api 功能相同 this.app.use('/admin', adminRoutes) + this.app.use('/users', userRoutes) // 使用 web 路由(包含 auth 和页面重定向) this.app.use('/web', webRoutes) this.app.use('/apiStats', apiStatsRoutes) @@ -507,7 +520,8 @@ class Application { const [expiredKeys, errorAccounts] = await Promise.all([ apiKeyService.cleanupExpiredKeys(), - claudeAccountService.cleanupErrorAccounts() + claudeAccountService.cleanupErrorAccounts(), + claudeAccountService.cleanupTempErrorAccounts() // 新增:清理临时错误账户 ]) await redis.cleanup() diff --git a/src/middleware/auth.js b/src/middleware/auth.js index 38c43485..aadcf0d9 100644 --- a/src/middleware/auth.js +++ b/src/middleware/auth.js @@ -1,7 +1,8 @@ const apiKeyService = require('../services/apiKeyService') +const userService = require('../services/userService') const logger = require('../utils/logger') const redis = require('../models/redis') -const { RateLimiterRedis } = require('rate-limiter-flexible') +// const { RateLimiterRedis } = require('rate-limiter-flexible') // 暂时未使用 const config = require('../../config/config') // 🔑 API Key验证中间件(优化版) @@ -182,11 +183,18 @@ const authenticateApiKey = async (req, res, next) => { // 检查时间窗口限流 const rateLimitWindow = validation.keyData.rateLimitWindow || 0 const rateLimitRequests = validation.keyData.rateLimitRequests || 0 + const rateLimitCost = validation.keyData.rateLimitCost || 0 // 新增:费用限制 - if (rateLimitWindow > 0 && (rateLimitRequests > 0 || validation.keyData.tokenLimit > 0)) { + // 兼容性检查:如果tokenLimit仍有值,使用tokenLimit;否则使用rateLimitCost + const hasRateLimits = + rateLimitWindow > 0 && + (rateLimitRequests > 0 || validation.keyData.tokenLimit > 0 || rateLimitCost > 0) + + if (hasRateLimits) { const windowStartKey = `rate_limit:window_start:${validation.keyData.id}` const requestCountKey = `rate_limit:requests:${validation.keyData.id}` const tokenCountKey = `rate_limit:tokens:${validation.keyData.id}` + const costCountKey = `rate_limit:cost:${validation.keyData.id}` // 新增:费用计数器 const now = Date.now() const windowDuration = rateLimitWindow * 60 * 1000 // 转换为毫秒 @@ -199,6 +207,7 @@ const authenticateApiKey = async (req, res, next) => { await redis.getClient().set(windowStartKey, now, 'PX', windowDuration) await redis.getClient().set(requestCountKey, 0, 'PX', windowDuration) await redis.getClient().set(tokenCountKey, 0, 'PX', windowDuration) + await redis.getClient().set(costCountKey, 0, 'PX', windowDuration) // 新增:重置费用 windowStart = now } else { windowStart = parseInt(windowStart) @@ -209,6 +218,7 @@ const authenticateApiKey = async (req, res, next) => { await redis.getClient().set(windowStartKey, now, 'PX', windowDuration) await redis.getClient().set(requestCountKey, 0, 'PX', windowDuration) await redis.getClient().set(tokenCountKey, 0, 'PX', windowDuration) + await redis.getClient().set(costCountKey, 0, 'PX', windowDuration) // 新增:重置费用 windowStart = now } } @@ -216,6 +226,7 @@ const authenticateApiKey = async (req, res, next) => { // 获取当前计数 const currentRequests = parseInt((await redis.getClient().get(requestCountKey)) || '0') const currentTokens = parseInt((await redis.getClient().get(tokenCountKey)) || '0') + const currentCost = parseFloat((await redis.getClient().get(costCountKey)) || '0') // 新增:当前费用 // 检查请求次数限制 if (rateLimitRequests > 0 && currentRequests >= rateLimitRequests) { @@ -236,24 +247,46 @@ const authenticateApiKey = async (req, res, next) => { }) } - // 检查Token使用量限制 + // 兼容性检查:优先使用Token限制(历史数据),否则使用费用限制 const tokenLimit = parseInt(validation.keyData.tokenLimit) - if (tokenLimit > 0 && currentTokens >= tokenLimit) { - const resetTime = new Date(windowStart + windowDuration) - const remainingMinutes = Math.ceil((resetTime - now) / 60000) + if (tokenLimit > 0) { + // 使用Token限制(向后兼容) + if (currentTokens >= tokenLimit) { + const resetTime = new Date(windowStart + windowDuration) + const remainingMinutes = Math.ceil((resetTime - now) / 60000) - logger.security( - `🚦 Rate limit exceeded (tokens) for key: ${validation.keyData.id} (${validation.keyData.name}), tokens: ${currentTokens}/${tokenLimit}` - ) + logger.security( + `🚦 Rate limit exceeded (tokens) for key: ${validation.keyData.id} (${validation.keyData.name}), tokens: ${currentTokens}/${tokenLimit}` + ) - return res.status(429).json({ - error: 'Rate limit exceeded', - message: `已达到 Token 使用限制 (${tokenLimit} tokens),将在 ${remainingMinutes} 分钟后重置`, - currentTokens, - tokenLimit, - resetAt: resetTime.toISOString(), - remainingMinutes - }) + return res.status(429).json({ + error: 'Rate limit exceeded', + message: `已达到 Token 使用限制 (${tokenLimit} tokens),将在 ${remainingMinutes} 分钟后重置`, + currentTokens, + tokenLimit, + resetAt: resetTime.toISOString(), + 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 + }) + } } // 增加请求计数 @@ -265,10 +298,13 @@ const authenticateApiKey = async (req, res, next) => { windowDuration, requestCountKey, tokenCountKey, + costCountKey, // 新增:费用计数器 currentRequests: currentRequests + 1, currentTokens, + currentCost, // 新增:当前费用 rateLimitRequests, - tokenLimit + tokenLimit, + rateLimitCost // 新增:费用限制 } } @@ -297,6 +333,46 @@ 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 = { id: validation.keyData.id, @@ -311,6 +387,7 @@ const authenticateApiKey = async (req, res, next) => { concurrencyLimit: validation.keyData.concurrencyLimit, rateLimitWindow: validation.keyData.rateLimitWindow, rateLimitRequests: validation.keyData.rateLimitRequests, + rateLimitCost: validation.keyData.rateLimitCost, // 新增:费用限制 enableModelRestriction: validation.keyData.enableModelRestriction, restrictedModels: validation.keyData.restrictedModels, enableClientRestriction: validation.keyData.enableClientRestriction, @@ -449,6 +526,234 @@ 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路由中处理, // 以便从Claude API响应中提取真实的usage数据 @@ -713,35 +1018,41 @@ const errorHandler = (error, req, res, _next) => { } // 🌐 全局速率限制中间件(延迟初始化) -let rateLimiter = null +// const rateLimiter = null // 暂时未使用 -const getRateLimiter = () => { - if (!rateLimiter) { - try { - const client = redis.getClient() - if (!client) { - logger.warn('⚠️ Redis client not available for rate limiter') - return null - } +// 暂时注释掉未使用的函数 +// const getRateLimiter = () => { +// if (!rateLimiter) { +// try { +// const client = redis.getClient() +// if (!client) { +// logger.warn('⚠️ Redis client not available for rate limiter') +// 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 +// } - rateLimiter = new RateLimiterRedis({ - storeClient: client, - keyPrefix: 'global_rate_limit', - points: 1000, // 请求数量 - duration: 900, // 15分钟 (900秒) - blockDuration: 900 // 阻塞时间15分钟 - }) +const globalRateLimit = async (req, res, next) => + // 已禁用全局IP限流 - 直接跳过所有请求 + next() - 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') { return next() @@ -777,7 +1088,7 @@ const globalRateLimit = async (req, res, next) => { retryAfter: Math.round(msBeforeNext / 1000) }) } -} + */ // 📊 请求大小限制中间件 const requestSizeLimit = (req, res, next) => { @@ -799,6 +1110,10 @@ const requestSizeLimit = (req, res, next) => { module.exports = { authenticateApiKey, authenticateAdmin, + authenticateUser, + authenticateUserOrAdmin, + requireRole, + requireAdmin, corsMiddleware, requestLogger, securityMiddleware, diff --git a/src/models/redis.js b/src/models/redis.js index 4d62bda7..145f94cd 100644 --- a/src/models/redis.js +++ b/src/models/redis.js @@ -29,6 +29,25 @@ function getHourInTimezone(date = new Date()) { 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 { constructor() { this.client = null @@ -193,7 +212,8 @@ class RedisClient { cacheReadTokens = 0, model = 'unknown', ephemeral5mTokens = 0, // 新增:5分钟缓存 tokens - ephemeral1hTokens = 0 // 新增:1小时缓存 tokens + ephemeral1hTokens = 0, // 新增:1小时缓存 tokens + isLongContextRequest = false // 新增:是否为 1M 上下文请求(超过200k) ) { const key = `usage:${keyId}` const now = new Date() @@ -250,6 +270,12 @@ class RedisClient { // 详细缓存类型统计(新增) pipeline.hincrby(key, 'totalEphemeral5mTokens', ephemeral5mTokens) 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) @@ -264,6 +290,12 @@ class RedisClient { // 详细缓存类型统计 pipeline.hincrby(daily, 'ephemeral5mTokens', ephemeral5mTokens) 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) @@ -376,7 +408,8 @@ class RedisClient { outputTokens = 0, cacheCreateTokens = 0, cacheReadTokens = 0, - model = 'unknown' + model = 'unknown', + isLongContextRequest = false ) { const now = new Date() const today = getDateStringInTimezone(now) @@ -407,7 +440,8 @@ class RedisClient { finalInputTokens + finalOutputTokens + finalCacheCreateTokens + finalCacheReadTokens const coreTokens = finalInputTokens + finalOutputTokens - await Promise.all([ + // 构建统计操作数组 + const operations = [ // 账户总体统计 this.client.hincrby(accountKey, 'totalTokens', coreTokens), this.client.hincrby(accountKey, 'totalInputTokens', finalInputTokens), @@ -444,6 +478,26 @@ class RedisClient { this.client.hincrby(accountHourly, 'allTokens', actualTotalTokens), 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, 'outputTokens', finalOutputTokens), @@ -475,7 +529,21 @@ class RedisClient { this.client.expire(accountModelDaily, 86400 * 32), // 32天过期 this.client.expire(accountModelMonthly, 86400 * 365), // 1年过期 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) { @@ -632,6 +700,85 @@ 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) { const accountKey = `account_usage:${accountId}` @@ -691,10 +838,16 @@ class RedisClient { const dailyData = handleAccountData(daily) const monthlyData = handleAccountData(monthly) + // 获取每日费用(基于模型使用) + const dailyCost = await this.getAccountDailyCost(accountId) + return { accountId, total: totalData, - daily: dailyData, + daily: { + ...dailyData, + cost: dailyCost + }, monthly: monthlyData, averages: { rpm: Math.round(avgRPM * 100) / 100, @@ -1276,7 +1429,7 @@ class RedisClient { const luaScript = ` local key = KEYS[1] local current = tonumber(redis.call('get', key) or "0") - + if current <= 0 then redis.call('del', key) return 0 @@ -1311,6 +1464,185 @@ class RedisClient { 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() @@ -1319,5 +1651,6 @@ const redisClient = new RedisClient() redisClient.getDateInTimezone = getDateInTimezone redisClient.getDateStringInTimezone = getDateStringInTimezone redisClient.getHourInTimezone = getHourInTimezone +redisClient.getWeekStringInTimezone = getWeekStringInTimezone module.exports = redisClient diff --git a/src/routes/admin.js b/src/routes/admin.js index eb8c9be6..49d43fb2 100644 --- a/src/routes/admin.js +++ b/src/routes/admin.js @@ -397,11 +397,13 @@ router.post('/api-keys', authenticateAdmin, async (req, res) => { concurrencyLimit, rateLimitWindow, rateLimitRequests, + rateLimitCost, enableModelRestriction, restrictedModels, enableClientRestriction, allowedClients, dailyCostLimit, + weeklyOpusCostLimit, tags } = req.body @@ -494,11 +496,13 @@ router.post('/api-keys', authenticateAdmin, async (req, res) => { concurrencyLimit, rateLimitWindow, rateLimitRequests, + rateLimitCost, enableModelRestriction, restrictedModels, enableClientRestriction, allowedClients, dailyCostLimit, + weeklyOpusCostLimit, tags }) @@ -532,6 +536,7 @@ router.post('/api-keys/batch', authenticateAdmin, async (req, res) => { enableClientRestriction, allowedClients, dailyCostLimit, + weeklyOpusCostLimit, tags } = req.body @@ -575,6 +580,7 @@ router.post('/api-keys/batch', authenticateAdmin, async (req, res) => { enableClientRestriction, allowedClients, dailyCostLimit, + weeklyOpusCostLimit, tags }) @@ -685,6 +691,9 @@ router.put('/api-keys/batch', authenticateAdmin, async (req, res) => { if (updates.dailyCostLimit !== undefined) { finalUpdates.dailyCostLimit = updates.dailyCostLimit } + if (updates.weeklyOpusCostLimit !== undefined) { + finalUpdates.weeklyOpusCostLimit = updates.weeklyOpusCostLimit + } if (updates.permissions !== undefined) { finalUpdates.permissions = updates.permissions } @@ -795,6 +804,7 @@ router.put('/api-keys/:keyId', authenticateAdmin, async (req, res) => { concurrencyLimit, rateLimitWindow, rateLimitRequests, + rateLimitCost, isActive, claudeAccountId, claudeConsoleAccountId, @@ -808,6 +818,7 @@ router.put('/api-keys/:keyId', authenticateAdmin, async (req, res) => { allowedClients, expiresAt, dailyCostLimit, + weeklyOpusCostLimit, tags } = req.body @@ -844,6 +855,14 @@ router.put('/api-keys/:keyId', authenticateAdmin, async (req, res) => { updates.rateLimitRequests = Number(rateLimitRequests) } + if (rateLimitCost !== undefined && rateLimitCost !== null && rateLimitCost !== '') { + const cost = Number(rateLimitCost) + if (isNaN(cost) || cost < 0) { + return res.status(400).json({ error: 'Rate limit cost must be a non-negative number' }) + } + updates.rateLimitCost = cost + } + if (claudeAccountId !== undefined) { // 空字符串表示解绑,null或空字符串都设置为空字符串 updates.claudeAccountId = claudeAccountId || '' @@ -935,6 +954,22 @@ router.put('/api-keys/:keyId', authenticateAdmin, async (req, res) => { updates.dailyCostLimit = costLimit } + // 处理 Opus 周费用限制 + if ( + weeklyOpusCostLimit !== undefined && + weeklyOpusCostLimit !== null && + weeklyOpusCostLimit !== '' + ) { + const costLimit = Number(weeklyOpusCostLimit) + // 明确验证非负数(0 表示禁用,负数无意义) + if (isNaN(costLimit) || costLimit < 0) { + return res + .status(400) + .json({ error: 'Weekly Opus cost limit must be a non-negative number' }) + } + updates.weeklyOpusCostLimit = costLimit + } + // 处理标签 if (tags !== undefined) { if (!Array.isArray(tags)) { @@ -1067,7 +1102,7 @@ router.delete('/api-keys/:keyId', authenticateAdmin, async (req, res) => { try { const { keyId } = req.params - await apiKeyService.deleteApiKey(keyId) + await apiKeyService.deleteApiKey(keyId, req.admin.username, 'admin') logger.success(`🗑️ Admin deleted API key: ${keyId}`) return res.json({ success: true, message: 'API key deleted successfully' }) @@ -1077,6 +1112,32 @@ router.delete('/api-keys/:keyId', authenticateAdmin, async (req, res) => { } }) +// 📋 获取已删除的API Keys +router.get('/api-keys/deleted', authenticateAdmin, async (req, res) => { + try { + const deletedApiKeys = await apiKeyService.getAllApiKeys(true) // Include deleted + const onlyDeleted = deletedApiKeys.filter((key) => key.isDeleted === 'true') + + // Add additional metadata for deleted keys + const enrichedKeys = onlyDeleted.map((key) => ({ + ...key, + isDeleted: key.isDeleted === 'true', + deletedAt: key.deletedAt, + deletedBy: key.deletedBy, + deletedByType: key.deletedByType, + canRestore: false // Deleted keys cannot be restored per requirement + })) + + logger.success(`📋 Admin retrieved ${enrichedKeys.length} deleted API keys`) + return res.json({ success: true, apiKeys: enrichedKeys, total: enrichedKeys.length }) + } catch (error) { + logger.error('❌ Failed to get deleted API keys:', error) + return res + .status(500) + .json({ error: 'Failed to retrieve deleted API keys', message: error.message }) + } +}) + // 👥 账户分组管理 // 创建账户分组 @@ -1471,13 +1532,56 @@ router.get('/claude-accounts', authenticateAdmin, async (req, res) => { const usageStats = await redis.getAccountUsageStats(account.id) const groupInfos = await accountGroupService.getAccountGroups(account.id) + // 获取会话窗口使用统计(仅对有活跃窗口的账户) + let sessionWindowUsage = null + if (account.sessionWindow && account.sessionWindow.hasActiveWindow) { + const windowUsage = await redis.getAccountSessionWindowUsage( + account.id, + account.sessionWindow.windowStart, + account.sessionWindow.windowEnd + ) + + // 计算会话窗口的总费用 + let totalCost = 0 + const modelCosts = {} + + for (const [modelName, usage] of Object.entries(windowUsage.modelUsage)) { + const usageData = { + input_tokens: usage.inputTokens, + output_tokens: usage.outputTokens, + cache_creation_input_tokens: usage.cacheCreateTokens, + cache_read_input_tokens: usage.cacheReadTokens + } + + logger.debug(`💰 Calculating cost for model ${modelName}:`, JSON.stringify(usageData)) + const costResult = CostCalculator.calculateCost(usageData, modelName) + logger.debug(`💰 Cost result for ${modelName}: total=${costResult.costs.total}`) + + modelCosts[modelName] = { + ...usage, + cost: costResult.costs.total + } + totalCost += costResult.costs.total + } + + sessionWindowUsage = { + totalTokens: windowUsage.totalAllTokens, + totalRequests: windowUsage.totalRequests, + totalCost, + modelUsage: modelCosts + } + } + return { ...account, + // 转换schedulable为布尔值 + schedulable: account.schedulable === 'true' || account.schedulable === true, groupInfos, usage: { daily: usageStats.daily, total: usageStats.total, - averages: usageStats.averages + averages: usageStats.averages, + sessionWindow: sessionWindowUsage } } } catch (statsError) { @@ -1491,7 +1595,8 @@ router.get('/claude-accounts', authenticateAdmin, async (req, res) => { usage: { daily: { tokens: 0, requests: 0, allTokens: 0 }, total: { tokens: 0, requests: 0, allTokens: 0 }, - averages: { rpm: 0, tpm: 0 } + averages: { rpm: 0, tpm: 0 }, + sessionWindow: null } } } catch (groupError) { @@ -1505,7 +1610,8 @@ router.get('/claude-accounts', authenticateAdmin, async (req, res) => { usage: { daily: { tokens: 0, requests: 0, allTokens: 0 }, total: { tokens: 0, requests: 0, allTokens: 0 }, - averages: { rpm: 0, tpm: 0 } + averages: { rpm: 0, tpm: 0 }, + sessionWindow: null } } } @@ -1535,7 +1641,8 @@ router.post('/claude-accounts', authenticateAdmin, async (req, res) => { platform = 'claude', priority, groupId, - groupIds + groupIds, + autoStopOnWarning } = req.body if (!name) { @@ -1574,7 +1681,8 @@ router.post('/claude-accounts', authenticateAdmin, async (req, res) => { proxy, accountType: accountType || 'shared', // 默认为共享类型 platform, - priority: priority || 50 // 默认优先级为50 + priority: priority || 50, // 默认优先级为50 + autoStopOnWarning: autoStopOnWarning === true // 默认为false }) // 如果是分组类型,将账户添加到分组 @@ -1855,6 +1963,8 @@ router.get('/claude-console-accounts', authenticateAdmin, async (req, res) => { return { ...account, + // 转换schedulable为布尔值 + schedulable: account.schedulable === 'true' || account.schedulable === true, groupInfos, usage: { daily: usageStats.daily, @@ -1871,6 +1981,8 @@ router.get('/claude-console-accounts', authenticateAdmin, async (req, res) => { const groupInfos = await accountGroupService.getAccountGroups(account.id) return { ...account, + // 转换schedulable为布尔值 + schedulable: account.schedulable === 'true' || account.schedulable === true, groupInfos, usage: { daily: { tokens: 0, requests: 0, allTokens: 0 }, @@ -2484,7 +2596,7 @@ router.post('/gemini-accounts/generate-auth-url', authenticateAdmin, async (req, state: authState, codeVerifier, redirectUri: finalRedirectUri - } = await geminiAccountService.generateAuthUrl(state, redirectUri) + } = await geminiAccountService.generateAuthUrl(state, redirectUri, proxy) // 创建 OAuth 会话,包含 codeVerifier 和代理配置 const sessionId = authState @@ -4847,9 +4959,13 @@ router.get('/oem-settings', async (req, res) => { } } + // 添加 LDAP 启用状态到响应中 return res.json({ success: true, - data: settings + data: { + ...settings, + ldapEnabled: config.ldap && config.ldap.enabled === true + } }) } catch (error) { logger.error('❌ Failed to get OEM settings:', error) diff --git a/src/routes/api.js b/src/routes/api.js index bad90a41..73b771b6 100644 --- a/src/routes/api.js +++ b/src/routes/api.js @@ -5,6 +5,7 @@ const bedrockRelayService = require('../services/bedrockRelayService') const bedrockAccountService = require('../services/bedrockAccountService') const unifiedClaudeScheduler = require('../services/unifiedClaudeScheduler') const apiKeyService = require('../services/apiKeyService') +const pricingService = require('../services/pricingService') const { authenticateApiKey } = require('../middleware/auth') const logger = require('../utils/logger') const redis = require('../models/redis') @@ -131,14 +132,16 @@ async function handleMessagesRequest(req, res) { } apiKeyService - .recordUsageWithDetails(req.apiKey.id, usageObject, model, usageAccountId) + .recordUsageWithDetails(req.apiKey.id, usageObject, model, usageAccountId, 'claude') .catch((error) => { logger.error('❌ Failed to record stream usage:', error) }) - // 更新时间窗口内的token计数 + // 更新时间窗口内的token计数和费用 if (req.rateLimitInfo) { const totalTokens = inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens + + // 更新Token计数(向后兼容) redis .getClient() .incrby(req.rateLimitInfo.tokenCountKey, totalTokens) @@ -146,6 +149,22 @@ async function handleMessagesRequest(req, res) { logger.error('❌ Failed to update rate limit token count:', error) }) 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 @@ -216,14 +235,22 @@ async function handleMessagesRequest(req, res) { } apiKeyService - .recordUsageWithDetails(req.apiKey.id, usageObject, model, usageAccountId) + .recordUsageWithDetails( + req.apiKey.id, + usageObject, + model, + usageAccountId, + 'claude-console' + ) .catch((error) => { logger.error('❌ Failed to record stream usage:', error) }) - // 更新时间窗口内的token计数 + // 更新时间窗口内的token计数和费用 if (req.rateLimitInfo) { const totalTokens = inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens + + // 更新Token计数(向后兼容) redis .getClient() .incrby(req.rateLimitInfo.tokenCountKey, totalTokens) @@ -231,6 +258,22 @@ async function handleMessagesRequest(req, res) { logger.error('❌ Failed to update rate limit token count:', error) }) 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 @@ -271,9 +314,11 @@ async function handleMessagesRequest(req, res) { logger.error('❌ Failed to record Bedrock stream usage:', error) }) - // 更新时间窗口内的token计数 + // 更新时间窗口内的token计数和费用 if (req.rateLimitInfo) { const totalTokens = inputTokens + outputTokens + + // 更新Token计数(向后兼容) redis .getClient() .incrby(req.rateLimitInfo.tokenCountKey, totalTokens) @@ -281,6 +326,20 @@ async function handleMessagesRequest(req, res) { logger.error('❌ Failed to update rate limit token count:', error) }) 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 @@ -438,11 +497,24 @@ async function handleMessagesRequest(req, res) { responseAccountId ) - // 更新时间窗口内的token计数 + // 更新时间窗口内的token计数和费用 if (req.rateLimitInfo) { const totalTokens = inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens + + // 更新Token计数(向后兼容) await redis.getClient().incrby(req.rateLimitInfo.tokenCountKey, totalTokens) 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 diff --git a/src/routes/apiStats.js b/src/routes/apiStats.js index 2b8eca6f..3233b1f4 100644 --- a/src/routes/apiStats.js +++ b/src/routes/apiStats.js @@ -278,21 +278,24 @@ router.post('/api/user-stats', async (req, res) => { // 获取当前使用量 let currentWindowRequests = 0 let currentWindowTokens = 0 + let currentWindowCost = 0 // 新增:当前窗口费用 let currentDailyCost = 0 let windowStartTime = null let windowEndTime = null let windowRemainingSeconds = null try { - // 获取当前时间窗口的请求次数和Token使用量 + // 获取当前时间窗口的请求次数、Token使用量和费用 if (fullKeyData.rateLimitWindow > 0) { const client = redis.getClientSafe() const requestCountKey = `rate_limit:requests:${keyId}` const tokenCountKey = `rate_limit:tokens:${keyId}` + const costCountKey = `rate_limit:cost:${keyId}` // 新增:费用计数key const windowStartKey = `rate_limit:window_start:${keyId}` currentWindowRequests = parseInt((await client.get(requestCountKey)) || '0') currentWindowTokens = parseInt((await client.get(tokenCountKey)) || '0') + currentWindowCost = parseFloat((await client.get(costCountKey)) || '0') // 新增:获取当前窗口费用 // 获取窗口开始时间和计算剩余时间 const windowStart = await client.get(windowStartKey) @@ -313,6 +316,7 @@ router.post('/api/user-stats', async (req, res) => { // 重置计数为0,因为窗口已过期 currentWindowRequests = 0 currentWindowTokens = 0 + currentWindowCost = 0 // 新增:重置窗口费用 } } } @@ -356,10 +360,12 @@ router.post('/api/user-stats', async (req, res) => { concurrencyLimit: fullKeyData.concurrencyLimit || 0, rateLimitWindow: fullKeyData.rateLimitWindow || 0, rateLimitRequests: fullKeyData.rateLimitRequests || 0, + rateLimitCost: parseFloat(fullKeyData.rateLimitCost) || 0, // 新增:费用限制 dailyCostLimit: fullKeyData.dailyCostLimit || 0, // 当前使用量 currentWindowRequests, currentWindowTokens, + currentWindowCost, // 新增:当前窗口费用 currentDailyCost, // 时间窗口信息 windowStartTime, diff --git a/src/routes/azureOpenaiRoutes.js b/src/routes/azureOpenaiRoutes.js index 50041980..ca0aa8fe 100644 --- a/src/routes/azureOpenaiRoutes.js +++ b/src/routes/azureOpenaiRoutes.js @@ -14,8 +14,11 @@ const ALLOWED_MODELS = { 'gpt-4-turbo', 'gpt-4o', 'gpt-4o-mini', + 'gpt-5', + 'gpt-5-mini', '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'] } @@ -234,6 +237,99 @@ 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) => { const requestId = `azure_embed_${Date.now()}_${crypto.randomBytes(8).toString('hex')}` diff --git a/src/routes/geminiRoutes.js b/src/routes/geminiRoutes.js index c5d706a3..75f633d0 100644 --- a/src/routes/geminiRoutes.js +++ b/src/routes/geminiRoutes.js @@ -50,7 +50,7 @@ router.post('/messages', authenticateApiKey, async (req, res) => { // 提取请求参数 const { messages, - model = 'gemini-2.0-flash-exp', + model = 'gemini-2.5-flash', temperature = 0.7, max_tokens = 4096, stream = false @@ -217,7 +217,7 @@ router.get('/models', authenticateApiKey, async (req, res) => { object: 'list', data: [ { - id: 'gemini-2.0-flash-exp', + id: 'gemini-2.5-flash', object: 'model', created: Date.now() / 1000, owned_by: 'google' @@ -311,8 +311,8 @@ async function handleLoadCodeAssist(req, res) { try { const sessionHash = sessionHelper.generateSessionHash(req.body) - // 使用统一调度选择账号(传递请求的模型) - const requestedModel = req.body.model + // 从路径参数或请求体中获取模型名 + const requestedModel = req.body.model || req.params.modelName || 'gemini-2.5-flash' const { accountId } = await unifiedGeminiScheduler.selectAccountForApiKey( req.apiKey, sessionHash, @@ -331,7 +331,17 @@ async function handleLoadCodeAssist(req, res) { apiKeyId: req.apiKey?.id || 'unknown' }) - const client = await geminiAccountService.getOauthClient(accessToken, refreshToken) + // 解析账户的代理配置 + 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) + } + } + + const client = await geminiAccountService.getOauthClient(accessToken, refreshToken, proxyConfig) // 根据账户配置决定项目ID: // 1. 如果账户有项目ID -> 使用账户的项目ID(强制覆盖) @@ -348,7 +358,11 @@ async function handleLoadCodeAssist(req, res) { logger.info('No project ID in account for loadCodeAssist, removing project parameter') } - const response = await geminiAccountService.loadCodeAssist(client, effectiveProjectId) + const response = await geminiAccountService.loadCodeAssist( + client, + effectiveProjectId, + proxyConfig + ) res.json(response) } catch (error) { @@ -368,8 +382,8 @@ async function handleOnboardUser(req, res) { const { tierId, cloudaicompanionProject, metadata } = req.body const sessionHash = sessionHelper.generateSessionHash(req.body) - // 使用统一调度选择账号(传递请求的模型) - const requestedModel = req.body.model + // 从路径参数或请求体中获取模型名 + const requestedModel = req.body.model || req.params.modelName || 'gemini-2.5-flash' const { accountId } = await unifiedGeminiScheduler.selectAccountForApiKey( req.apiKey, sessionHash, @@ -387,7 +401,17 @@ async function handleOnboardUser(req, res) { apiKeyId: req.apiKey?.id || 'unknown' }) - const client = await geminiAccountService.getOauthClient(accessToken, refreshToken) + // 解析账户的代理配置 + 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) + } + } + + const client = await geminiAccountService.getOauthClient(accessToken, refreshToken, proxyConfig) // 根据账户配置决定项目ID: // 1. 如果账户有项目ID -> 使用账户的项目ID(强制覆盖) @@ -410,7 +434,8 @@ async function handleOnboardUser(req, res) { client, tierId, effectiveProjectId, // 使用处理后的项目ID - metadata + metadata, + proxyConfig ) res.json(response) @@ -419,7 +444,8 @@ async function handleOnboardUser(req, res) { const response = await geminiAccountService.setupUser( client, effectiveProjectId, // 使用处理后的项目ID - metadata + metadata, + proxyConfig ) res.json(response) @@ -439,7 +465,9 @@ async function handleCountTokens(req, res) { try { // 处理请求体结构,支持直接 contents 或 request.contents const requestData = req.body.request || req.body - const { contents, model = 'gemini-2.0-flash-exp' } = requestData + const { contents } = requestData + // 从路径参数或请求体中获取模型名 + const model = requestData.model || req.params.modelName || 'gemini-2.5-flash' const sessionHash = sessionHelper.generateSessionHash(req.body) // 验证必需参数 @@ -458,7 +486,8 @@ async function handleCountTokens(req, res) { sessionHash, model ) - const { accessToken, refreshToken } = await geminiAccountService.getAccount(accountId) + const account = await geminiAccountService.getAccount(accountId) + const { accessToken, refreshToken } = account const version = req.path.includes('v1beta') ? 'v1beta' : 'v1internal' logger.info(`CountTokens request (${version})`, { @@ -467,8 +496,18 @@ async function handleCountTokens(req, res) { apiKeyId: req.apiKey?.id || 'unknown' }) - const client = await geminiAccountService.getOauthClient(accessToken, refreshToken) - const response = await geminiAccountService.countTokens(client, contents, model) + // 解析账户的代理配置 + 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) + } + } + + const client = await geminiAccountService.getOauthClient(accessToken, refreshToken, proxyConfig) + const response = await geminiAccountService.countTokens(client, contents, model, proxyConfig) res.json(response) } catch (error) { @@ -487,7 +526,9 @@ async function handleCountTokens(req, res) { // 共用的 generateContent 处理函数 async function handleGenerateContent(req, res) { try { - const { model, project, user_prompt_id, request: requestData } = req.body + const { 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) // 处理不同格式的请求 @@ -540,8 +581,6 @@ async function handleGenerateContent(req, res) { apiKeyId: req.apiKey?.id || 'unknown' }) - const client = await geminiAccountService.getOauthClient(accessToken, refreshToken) - // 解析账户的代理配置 let proxyConfig = null if (account.proxy) { @@ -552,6 +591,8 @@ async function handleGenerateContent(req, res) { } } + const client = await geminiAccountService.getOauthClient(accessToken, refreshToken, proxyConfig) + const response = await geminiAccountService.generateContent( client, { model, request: actualRequestData }, @@ -582,7 +623,7 @@ async function handleGenerateContent(req, res) { } } - res.json(response) + res.json(version === 'v1beta' ? response.response : response) } catch (error) { const version = req.path.includes('v1beta') ? 'v1beta' : 'v1internal' // 打印详细的错误信息 @@ -610,7 +651,9 @@ async function handleStreamGenerateContent(req, res) { let abortController = null try { - const { model, project, user_prompt_id, request: requestData } = req.body + const { 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) // 处理不同格式的请求 @@ -674,8 +717,6 @@ async function handleStreamGenerateContent(req, res) { } }) - const client = await geminiAccountService.getOauthClient(accessToken, refreshToken) - // 解析账户的代理配置 let proxyConfig = null if (account.proxy) { @@ -686,6 +727,8 @@ async function handleStreamGenerateContent(req, res) { } } + const client = await geminiAccountService.getOauthClient(accessToken, refreshToken, proxyConfig) + const streamResponse = await geminiAccountService.generateContentStream( client, { model, request: actualRequestData }, @@ -702,8 +745,28 @@ async function handleStreamGenerateContent(req, res) { res.setHeader('Connection', 'keep-alive') 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数据 - let buffer = '' + let streamBuffer = '' // 统一的流处理缓冲区 let totalUsage = { promptTokenCount: 0, candidatesTokenCount: 0, @@ -715,32 +778,60 @@ async function handleStreamGenerateContent(req, res) { try { const chunkStr = chunk.toString() - // 直接转发数据到客户端 - if (!res.destroyed) { - res.write(chunkStr) + if (!chunkStr.trim()) { + return } - // 同时解析数据以捕获usage信息 - buffer += chunkStr - const lines = buffer.split('\n') - buffer = lines.pop() || '' + // 使用统一缓冲区处理不完整的行 + streamBuffer += chunkStr + const lines = streamBuffer.split('\n') + streamBuffer = lines.pop() || '' // 保留最后一个不完整的行 + + const processedLines = [] for (const line of lines) { - if (line.startsWith('data: ') && line.length > 6) { - try { - const jsonStr = line.slice(6) - if (jsonStr && jsonStr !== '[DONE]') { - const data = JSON.parse(jsonStr) + if (!line.trim()) { + continue // 跳过空行,不添加到处理队列 + } - // 从响应中提取usage数据 - if (data.response?.usageMetadata) { - totalUsage = data.response.usageMetadata - logger.debug('📊 Captured Gemini usage data:', totalUsage) - } + // 解析 SSE 行 + const parsed = parseSSELine(line) + + // 提取 usage 数据(适用于所有版本) + 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)}`) } - } catch (e) { - // 忽略解析错误 + } else if (parsed.type === 'control') { + // 控制消息(如 [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) { diff --git a/src/routes/openaiGeminiRoutes.js b/src/routes/openaiGeminiRoutes.js index 5e304f06..54305401 100644 --- a/src/routes/openaiGeminiRoutes.js +++ b/src/routes/openaiGeminiRoutes.js @@ -311,6 +311,16 @@ router.post('/v1/chat/completions', authenticateApiKey, async (req, res) => { // 标记账户被使用 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() @@ -325,7 +335,8 @@ router.post('/v1/chat/completions', authenticateApiKey, async (req, res) => { // 获取OAuth客户端 const client = await geminiAccountService.getOauthClient( account.accessToken, - account.refreshToken + account.refreshToken, + proxyConfig ) if (actualStream) { // 流式响应 @@ -341,7 +352,8 @@ router.post('/v1/chat/completions', authenticateApiKey, async (req, res) => { null, // user_prompt_id account.projectId, // 使用有权限的项目ID apiKeyData.id, // 使用 API Key ID 作为 session ID - abortController.signal // 传递中止信号 + abortController.signal, // 传递中止信号 + proxyConfig // 传递代理配置 ) // 设置流式响应头 @@ -541,7 +553,8 @@ router.post('/v1/chat/completions', authenticateApiKey, async (req, res) => { { model, request: geminiRequestBody }, null, // user_prompt_id account.projectId, // 使用有权限的项目ID - apiKeyData.id // 使用 API Key ID 作为 session ID + apiKeyData.id, // 使用 API Key ID 作为 session ID + proxyConfig // 传递代理配置 ) // 转换为 OpenAI 格式并返回 diff --git a/src/routes/userRoutes.js b/src/routes/userRoutes.js new file mode 100644 index 00000000..653e3c9e --- /dev/null +++ b/src/routes/userRoutes.js @@ -0,0 +1,737 @@ +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 + } + }) + } 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: ['messages'] // 用户创建的API Key默认只有messages权限 + } + + 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 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 diff --git a/src/routes/webhook.js b/src/routes/webhook.js index 3f31802a..f4c70b41 100644 --- a/src/routes/webhook.js +++ b/src/routes/webhook.js @@ -4,6 +4,7 @@ const logger = require('../utils/logger') const webhookService = require('../services/webhookService') const webhookConfigService = require('../services/webhookConfigService') const { authenticateAdmin } = require('../middleware/auth') +const { getISOStringWithTimezone } = require('../utils/dateHelper') // 获取webhook配置 router.get('/config', authenticateAdmin, async (req, res) => { @@ -114,27 +115,62 @@ router.post('/platforms/:id/toggle', authenticateAdmin, async (req, res) => { // 测试Webhook连通性 router.post('/test', authenticateAdmin, async (req, res) => { try { - const { url, type = 'custom', secret, enableSign } = req.body + const { + url, + type = 'custom', + secret, + enableSign, + deviceKey, + serverUrl, + level, + sound, + group + } = req.body - if (!url) { - return res.status(400).json({ - error: 'Missing webhook URL', - message: '请提供webhook URL' - }) + // Bark平台特殊处理 + if (type === 'bark') { + if (!deviceKey) { + return res.status(400).json({ + 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 { + // 其他平台验证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 = { type, @@ -145,21 +181,34 @@ router.post('/test', authenticateAdmin, async (req, res) => { timeout: 10000 } + // 添加Bark特有字段 + if (type === 'bark') { + platform.deviceKey = deviceKey + platform.serverUrl = serverUrl + platform.level = level + platform.sound = sound + platform.group = group + } + const result = await webhookService.testWebhook(platform) if (result.success) { - logger.info(`✅ Webhook测试成功: ${url}`) + const identifier = type === 'bark' ? `Device: ${deviceKey.substring(0, 8)}...` : url + logger.info(`✅ Webhook测试成功: ${identifier}`) res.json({ success: true, message: 'Webhook测试成功', - url + url: type === 'bark' ? undefined : url, + deviceKey: type === 'bark' ? `${deviceKey.substring(0, 8)}...` : undefined }) } else { - logger.warn(`❌ Webhook测试失败: ${url} - ${result.error}`) + const identifier = type === 'bark' ? `Device: ${deviceKey.substring(0, 8)}...` : url + logger.warn(`❌ Webhook测试失败: ${identifier} - ${result.error}`) res.status(400).json({ success: false, message: 'Webhook测试失败', - url, + url: type === 'bark' ? undefined : url, + deviceKey: type === 'bark' ? `${deviceKey.substring(0, 8)}...` : undefined, error: result.error }) } @@ -218,7 +267,7 @@ router.post('/test-notification', authenticateAdmin, async (req, res) => { errorCode, reason, message, - timestamp: new Date().toISOString() + timestamp: getISOStringWithTimezone(new Date()) } const result = await webhookService.sendNotification(type, testData) diff --git a/src/services/apiKeyService.js b/src/services/apiKeyService.js index 46be6352..197f8e78 100644 --- a/src/services/apiKeyService.js +++ b/src/services/apiKeyService.js @@ -14,7 +14,7 @@ class ApiKeyService { const { name = 'Unnamed Key', description = '', - tokenLimit = config.limits.defaultTokenLimit, + tokenLimit = 0, // 默认为0,不再使用token限制 expiresAt = null, claudeAccountId = null, claudeConsoleAccountId = null, @@ -27,11 +27,13 @@ class ApiKeyService { concurrencyLimit = 0, rateLimitWindow = null, rateLimitRequests = null, + rateLimitCost = null, // 新增:速率限制费用字段 enableModelRestriction = false, restrictedModels = [], enableClientRestriction = false, allowedClients = [], dailyCostLimit = 0, + weeklyOpusCostLimit = 0, tags = [] } = options @@ -49,6 +51,7 @@ class ApiKeyService { concurrencyLimit: String(concurrencyLimit ?? 0), rateLimitWindow: String(rateLimitWindow ?? 0), rateLimitRequests: String(rateLimitRequests ?? 0), + rateLimitCost: String(rateLimitCost ?? 0), // 新增:速率限制费用字段 isActive: String(isActive), claudeAccountId: claudeAccountId || '', claudeConsoleAccountId: claudeConsoleAccountId || '', @@ -62,11 +65,14 @@ class ApiKeyService { enableClientRestriction: String(enableClientRestriction || false), allowedClients: JSON.stringify(allowedClients || []), dailyCostLimit: String(dailyCostLimit || 0), + weeklyOpusCostLimit: String(weeklyOpusCostLimit || 0), tags: JSON.stringify(tags || []), createdAt: new Date().toISOString(), lastUsedAt: '', expiresAt: expiresAt || '', - createdBy: 'admin' // 可以根据需要扩展用户系统 + createdBy: options.createdBy || 'admin', + userId: options.userId || '', + userUsername: options.userUsername || '' } // 保存API Key数据并建立哈希映射 @@ -83,6 +89,7 @@ class ApiKeyService { concurrencyLimit: parseInt(keyData.concurrencyLimit), rateLimitWindow: parseInt(keyData.rateLimitWindow || 0), rateLimitRequests: parseInt(keyData.rateLimitRequests || 0), + rateLimitCost: parseFloat(keyData.rateLimitCost || 0), // 新增:速率限制费用字段 isActive: keyData.isActive === 'true', claudeAccountId: keyData.claudeAccountId, claudeConsoleAccountId: keyData.claudeConsoleAccountId, @@ -96,6 +103,7 @@ class ApiKeyService { enableClientRestriction: keyData.enableClientRestriction === 'true', allowedClients: JSON.parse(keyData.allowedClients || '[]'), dailyCostLimit: parseFloat(keyData.dailyCostLimit || 0), + weeklyOpusCostLimit: parseFloat(keyData.weeklyOpusCostLimit || 0), tags: JSON.parse(keyData.tags || '[]'), createdAt: keyData.createdAt, expiresAt: keyData.expiresAt, @@ -130,6 +138,20 @@ class ApiKeyService { 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) @@ -184,12 +206,15 @@ class ApiKeyService { 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 } @@ -201,34 +226,52 @@ class ApiKeyService { } // 📋 获取所有API Keys - async getAllApiKeys() { + async getAllApiKeys(includeDeleted = false) { try { - const apiKeys = await redis.getAllApiKeys() + let apiKeys = await redis.getAllApiKeys() const client = redis.getClientSafe() + // 默认过滤掉已删除的API Keys + if (!includeDeleted) { + apiKeys = apiKeys.filter((key) => key.isDeleted !== 'true') + } + // 为每个key添加使用统计和当前并发数 for (const key of apiKeys) { 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.concurrencyLimit = parseInt(key.concurrencyLimit || 0) key.rateLimitWindow = parseInt(key.rateLimitWindow || 0) key.rateLimitRequests = parseInt(key.rateLimitRequests || 0) + key.rateLimitCost = parseFloat(key.rateLimitCost || 0) // 新增:速率限制费用字段 key.currentConcurrency = await redis.getConcurrency(key.id) key.isActive = key.isActive === 'true' key.enableModelRestriction = key.enableModelRestriction === 'true' key.enableClientRestriction = key.enableClientRestriction === 'true' key.permissions = key.permissions || 'all' // 兼容旧数据 key.dailyCostLimit = parseFloat(key.dailyCostLimit || 0) + key.weeklyOpusCostLimit = parseFloat(key.weeklyOpusCostLimit || 0) key.dailyCost = (await redis.getDailyCost(key.id)) || 0 + key.weeklyOpusCost = (await redis.getWeeklyOpusCost(key.id)) || 0 - // 获取当前时间窗口的请求次数和Token使用量 + // 获取当前时间窗口的请求次数、Token使用量和费用 if (key.rateLimitWindow > 0) { const requestCountKey = `rate_limit:requests:${key.id}` const tokenCountKey = `rate_limit:tokens:${key.id}` + const costCountKey = `rate_limit:cost:${key.id}` // 新增:费用计数器 const windowStartKey = `rate_limit:window_start:${key.id}` key.currentWindowRequests = parseInt((await client.get(requestCountKey)) || '0') key.currentWindowTokens = parseInt((await client.get(tokenCountKey)) || '0') + key.currentWindowCost = parseFloat((await client.get(costCountKey)) || '0') // 新增:当前窗口费用 // 获取窗口开始时间和计算剩余时间 const windowStart = await client.get(windowStartKey) @@ -251,6 +294,7 @@ class ApiKeyService { // 重置计数为0,因为窗口已过期 key.currentWindowRequests = 0 key.currentWindowTokens = 0 + key.currentWindowCost = 0 // 新增:重置费用 } } else { // 窗口还未开始(没有任何请求) @@ -261,6 +305,7 @@ class ApiKeyService { } else { key.currentWindowRequests = 0 key.currentWindowTokens = 0 + key.currentWindowCost = 0 // 新增:重置费用 key.windowStartTime = null key.windowEndTime = null key.windowRemainingSeconds = null @@ -307,6 +352,7 @@ class ApiKeyService { 'concurrencyLimit', 'rateLimitWindow', 'rateLimitRequests', + 'rateLimitCost', // 新增:速率限制费用字段 'isActive', 'claudeAccountId', 'claudeConsoleAccountId', @@ -321,6 +367,7 @@ class ApiKeyService { 'enableClientRestriction', 'allowedClients', 'dailyCostLimit', + 'weeklyOpusCostLimit', 'tags' ] const updatedData = { ...keyData } @@ -353,16 +400,32 @@ class ApiKeyService { } } - // 🗑️ 删除API Key - async deleteApiKey(keyId) { + // 🗑️ 软删除API Key (保留使用统计) + async deleteApiKey(keyId, deletedBy = 'system', deletedByType = 'system') { try { - const result = await redis.deleteApiKey(keyId) - - if (result === 0) { + const keyData = await redis.getApiKey(keyId) + if (!keyData || Object.keys(keyData).length === 0) { 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 } } catch (error) { @@ -396,6 +459,13 @@ class ApiKeyService { model ) + // 检查是否为 1M 上下文请求 + let isLongContextRequest = false + if (model && model.includes('[1m]')) { + const totalInputTokens = inputTokens + cacheCreateTokens + cacheReadTokens + isLongContextRequest = totalInputTokens > 200000 + } + // 记录API Key级别的使用统计 await redis.incrementTokenUsage( keyId, @@ -404,7 +474,10 @@ class ApiKeyService { outputTokens, cacheCreateTokens, cacheReadTokens, - model + model, + 0, // ephemeral5mTokens - 暂时为0,后续处理 + 0, // ephemeral1hTokens - 暂时为0,后续处理 + isLongContextRequest ) // 记录费用统计 @@ -433,7 +506,8 @@ class ApiKeyService { outputTokens, cacheCreateTokens, cacheReadTokens, - model + model, + isLongContextRequest ) logger.database( `📊 Recorded account usage: ${accountId} - ${totalTokens} tokens (API Key: ${keyId})` @@ -460,8 +534,38 @@ 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(keyId, usageObject, model = 'unknown', accountId = null) { + async recordUsageWithDetails( + keyId, + usageObject, + model = 'unknown', + accountId = null, + accountType = null + ) { try { // 提取 token 数量 const inputTokens = usageObject.input_tokens || 0 @@ -505,7 +609,8 @@ class ApiKeyService { cacheReadTokens, model, ephemeral5mTokens, // 传递5分钟缓存 tokens - ephemeral1hTokens // 传递1小时缓存 tokens + ephemeral1hTokens, // 传递1小时缓存 tokens + costInfo.isLongContextRequest || false // 传递 1M 上下文请求标记 ) // 记录费用统计 @@ -515,6 +620,9 @@ class ApiKeyService { `💰 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) { logger.database( @@ -541,7 +649,8 @@ class ApiKeyService { outputTokens, cacheCreateTokens, cacheReadTokens, - model + model, + costInfo.isLongContextRequest || false ) logger.database( `📊 Recorded account usage: ${accountId} - ${totalTokens} tokens (API Key: ${keyId})` @@ -608,6 +717,225 @@ class ApiKeyService { 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 async cleanupExpiredKeys() { try { diff --git a/src/services/azureOpenaiAccountService.js b/src/services/azureOpenaiAccountService.js index 3a9bd7ec..e7d5754d 100644 --- a/src/services/azureOpenaiAccountService.js +++ b/src/services/azureOpenaiAccountService.js @@ -300,7 +300,11 @@ async function getAllAccounts() { } } - accounts.push(accountData) + accounts.push({ + ...accountData, + isActive: accountData.isActive === 'true', + schedulable: accountData.schedulable !== 'false' + }) } } diff --git a/src/services/azureOpenaiRelayService.js b/src/services/azureOpenaiRelayService.js index 9590884b..7517c4ad 100644 --- a/src/services/azureOpenaiRelayService.js +++ b/src/services/azureOpenaiRelayService.js @@ -273,6 +273,11 @@ function handleStreamResponse(upstreamResponse, clientResponse, options = {}) { let eventCount = 0 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('Cache-Control', 'no-cache') @@ -297,8 +302,8 @@ function handleStreamResponse(upstreamResponse, clientResponse, options = {}) { clientResponse.flushHeaders() } - // 解析 SSE 事件以捕获 usage 数据 - const parseSSEForUsage = (data) => { + // 强化的SSE事件解析,保存所有事件用于最终处理 + const parseSSEForUsage = (data, isFromFinalBuffer = false) => { const lines = data.split('\n') for (const line of lines) { @@ -310,34 +315,54 @@ function handleStreamResponse(upstreamResponse, clientResponse, options = {}) { } const eventData = JSON.parse(jsonStr) + // 保存所有成功解析的事件 + allParsedEvents.push(eventData) + // 获取模型信息 if (eventData.model) { actualModel = eventData.model } - // 获取使用统计(Responses API: response.completed -> response.usage) - if (eventData.type === 'response.completed' && eventData.response) { - if (eventData.response.model) { - actualModel = eventData.response.model - } - if (eventData.response.usage) { - usageData = eventData.response.usage - logger.debug('Captured Azure OpenAI nested usage (response.usage):', usageData) + // 使用强化的usage提取函数 + const { usageData: extractedUsage, actualModel: extractedModel } = + extractUsageDataRobust( + eventData, + `stream-event-${isFromFinalBuffer ? 'final' : 'normal'}` + ) + + if (extractedUsage && !usageData) { + usageData = extractedUsage + if (extractedModel) { + actualModel = extractedModel } + logger.debug(`🎯 Stream usage captured via robust extraction`, { + isFromFinalBuffer, + usageData, + actualModel + }) } - // 兼容 Chat Completions 风格(顶层 usage) - if (!usageData && eventData.usage) { - usageData = eventData.usage - logger.debug('Captured Azure OpenAI usage (top-level):', usageData) - } + // 原有的简单提取作为备用 + if (!usageData) { + // 获取使用统计(Responses API: response.completed -> response.usage) + if (eventData.type === 'response.completed' && eventData.response) { + 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) + } + } - // 检查是否是完成事件 - if (eventData.choices && eventData.choices[0] && eventData.choices[0].finish_reason) { - // 这是最后一个 chunk + // 兼容 Chat Completions 风格(顶层 usage) + if (!usageData && eventData.usage) { + usageData = eventData.usage + logger.debug('🎯 Stream usage (backup method - top-level):', usageData) + } } } catch (e) { - // 忽略解析错误 + logger.debug('SSE parsing error (expected for incomplete chunks):', e.message) } } } @@ -387,10 +412,19 @@ function handleStreamResponse(upstreamResponse, clientResponse, options = {}) { // 同时解析数据以捕获 usage 信息,带缓冲区大小限制 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) { - logger.warn(`Stream ${streamId} buffer exceeded limit, truncating`) - buffer = buffer.slice(-MAX_BUFFER_SIZE / 2) // 保留后一半 + logger.warn( + `Stream ${streamId} buffer exceeded limit, truncating main buffer but preserving final chunks` + ) + // 保留最后1/4而不是1/2,为usage数据留更多空间 + buffer = buffer.slice(-MAX_BUFFER_SIZE / 4) } // 处理完整的 SSE 事件 @@ -426,9 +460,91 @@ function handleStreamResponse(upstreamResponse, clientResponse, options = {}) { hasEnded = true try { - // 处理剩余的 buffer - if (buffer.trim() && buffer.length <= MAX_EVENT_SIZE) { - parseSSEForUsage(buffer) + logger.debug(`🔚 Stream ended, performing comprehensive usage extraction for ${streamId}`, { + mainBufferSize: buffer.length, + finalChunksBufferSize: finalChunksBuffer.length, + 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) { @@ -484,6 +600,120 @@ 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) { try { @@ -510,9 +740,8 @@ function handleNonStreamResponse(upstreamResponse, clientResponse) { const responseData = upstreamResponse.data clientResponse.json(responseData) - // 提取 usage 数据 - const usageData = responseData.usage - const actualModel = responseData.model + // 使用强化的用量提取 + const { usageData, actualModel } = extractUsageDataRobust(responseData, 'non-stream') return { usageData, actualModel, responseData } } catch (error) { diff --git a/src/services/claudeAccountService.js b/src/services/claudeAccountService.js index 00a4eb7d..1540b74b 100644 --- a/src/services/claudeAccountService.js +++ b/src/services/claudeAccountService.js @@ -15,6 +15,7 @@ const { } = require('../utils/tokenRefreshLogger') const tokenRefreshService = require('./tokenRefreshService') const LRUCache = require('../utils/lruCache') +const { formatDateWithTimezone, getISOStringWithTimezone } = require('../utils/dateHelper') class ClaudeAccountService { constructor() { @@ -57,7 +58,8 @@ class ClaudeAccountService { platform = 'claude', priority = 50, // 调度优先级 (1-100,数字越小优先级越高) schedulable = true, // 是否可被调度 - subscriptionInfo = null // 手动设置的订阅信息 + subscriptionInfo = null, // 手动设置的订阅信息 + autoStopOnWarning = false // 5小时使用量接近限制时自动停止调度 } = options const accountId = uuidv4() @@ -88,6 +90,7 @@ class ClaudeAccountService { status: 'active', // 有OAuth数据的账户直接设为active errorMessage: '', schedulable: schedulable.toString(), // 是否可被调度 + autoStopOnWarning: autoStopOnWarning.toString(), // 5小时使用量接近限制时自动停止调度 // 优先使用手动设置的订阅信息,否则使用OAuth数据中的,否则默认为空 subscriptionInfo: subscriptionInfo ? JSON.stringify(subscriptionInfo) @@ -118,6 +121,7 @@ class ClaudeAccountService { status: 'created', // created, active, expired, error errorMessage: '', schedulable: schedulable.toString(), // 是否可被调度 + autoStopOnWarning: autoStopOnWarning.toString(), // 5小时使用量接近限制时自动停止调度 // 手动设置的订阅信息 subscriptionInfo: subscriptionInfo ? JSON.stringify(subscriptionInfo) : '' } @@ -158,7 +162,8 @@ class ClaudeAccountService { status: accountData.status, createdAt: accountData.createdAt, expiresAt: accountData.expiresAt, - scopes: claudeAiOauth ? claudeAiOauth.scopes : [] + scopes: claudeAiOauth ? claudeAiOauth.scopes : [], + autoStopOnWarning } } @@ -479,7 +484,11 @@ class ClaudeAccountService { lastRequestTime: null }, // 添加调度状态 - schedulable: account.schedulable !== 'false' // 默认为true,兼容历史数据 + schedulable: account.schedulable !== 'false', // 默认为true,兼容历史数据 + // 添加自动停止调度设置 + autoStopOnWarning: account.autoStopOnWarning === 'true', // 默认为false + // 添加停止原因 + stoppedReason: account.stoppedReason || null } }) ) @@ -512,7 +521,8 @@ class ClaudeAccountService { 'accountType', 'priority', 'schedulable', - 'subscriptionInfo' + 'subscriptionInfo', + 'autoStopOnWarning' ] const updatedData = { ...accountData } @@ -634,7 +644,10 @@ class ClaudeAccountService { const accounts = await redis.getAllClaudeAccounts() let activeAccounts = accounts.filter( - (account) => account.isActive === 'true' && account.status !== 'error' + (account) => + account.isActive === 'true' && + account.status !== 'error' && + account.schedulable !== 'false' ) // 如果请求的是 Opus 模型,过滤掉 Pro 和 Free 账号 @@ -721,7 +734,12 @@ class ClaudeAccountService { // 如果API Key绑定了专属账户,优先使用 if (apiKeyData.claudeAccountId) { const boundAccount = await redis.getClaudeAccount(apiKeyData.claudeAccountId) - if (boundAccount && boundAccount.isActive === 'true' && boundAccount.status !== 'error') { + if ( + boundAccount && + boundAccount.isActive === 'true' && + boundAccount.status !== 'error' && + boundAccount.schedulable !== 'false' + ) { logger.info( `🎯 Using bound dedicated account: ${boundAccount.name} (${apiKeyData.claudeAccountId}) for API key ${apiKeyData.name}` ) @@ -740,6 +758,7 @@ class ClaudeAccountService { (account) => account.isActive === 'true' && account.status !== 'error' && + account.schedulable !== 'false' && (account.accountType === 'shared' || !account.accountType) // 兼容旧数据 ) @@ -1100,8 +1119,8 @@ class ClaudeAccountService { platform: 'claude-oauth', status: 'error', errorCode: 'CLAUDE_OAUTH_RATE_LIMITED', - reason: `Account rate limited (429 error). ${rateLimitResetTimestamp ? `Reset at: ${new Date(rateLimitResetTimestamp * 1000).toISOString()}` : 'Estimated reset in 1-5 hours'}`, - timestamp: new Date().toISOString() + reason: `Account rate limited (429 error). ${rateLimitResetTimestamp ? `Reset at: ${formatDateWithTimezone(rateLimitResetTimestamp)}` : 'Estimated reset in 1-5 hours'}`, + timestamp: getISOStringWithTimezone(new Date()) }) } catch (webhookError) { logger.error('Failed to send rate limit webhook notification:', webhookError) @@ -1272,6 +1291,42 @@ class ClaudeAccountService { accountData.sessionWindowEnd = windowEnd.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( `🕐 Created new session window for account ${accountData.name} (${accountId}): ${windowStart.toISOString()} - ${windowEnd.toISOString()} (from current time)` ) @@ -1317,7 +1372,8 @@ class ClaudeAccountService { windowEnd: null, progress: 0, remainingTime: null, - lastRequestTime: accountData.lastRequestTime || null + lastRequestTime: accountData.lastRequestTime || null, + sessionWindowStatus: accountData.sessionWindowStatus || null } } @@ -1334,7 +1390,8 @@ class ClaudeAccountService { windowEnd: accountData.sessionWindowEnd, progress: 100, remainingTime: 0, - lastRequestTime: accountData.lastRequestTime || null + lastRequestTime: accountData.lastRequestTime || null, + sessionWindowStatus: accountData.sessionWindowStatus || null } } @@ -1352,7 +1409,8 @@ class ClaudeAccountService { windowEnd: accountData.sessionWindowEnd, progress, remainingTime, - lastRequestTime: accountData.lastRequestTime || null + lastRequestTime: accountData.lastRequestTime || null, + sessionWindowStatus: accountData.sessionWindowStatus || null } } catch (error) { logger.error(`❌ Failed to get session window info for account ${accountId}:`, error) @@ -1708,6 +1766,9 @@ class ClaudeAccountService { delete updatedAccountData.rateLimitedAt delete updatedAccountData.rateLimitStatus delete updatedAccountData.rateLimitEndAt + delete updatedAccountData.tempErrorAt + delete updatedAccountData.sessionWindowStart + delete updatedAccountData.sessionWindowEnd // 保存更新后的账户数据 await redis.setClaudeAccount(accountId, updatedAccountData) @@ -1720,6 +1781,10 @@ class ClaudeAccountService { const rateLimitKey = `ratelimit:${accountId}` await redis.client.del(rateLimitKey) + // 清除5xx错误计数 + const serverErrorKey = `claude_account:${accountId}:5xx_errors` + await redis.client.del(serverErrorKey) + logger.info( `✅ Successfully reset all error states for account ${accountData.name} (${accountId})` ) @@ -1738,6 +1803,209 @@ class ClaudeAccountService { throw error } } + + // 🧹 清理临时错误账户 + async cleanupTempErrorAccounts() { + try { + const accounts = await redis.getAllClaudeAccounts() + let cleanedCount = 0 + const TEMP_ERROR_RECOVERY_MINUTES = 5 // 临时错误状态恢复时间(分钟) + + for (const account of accounts) { + if (account.status === 'temp_error' && account.tempErrorAt) { + const tempErrorAt = new Date(account.tempErrorAt) + const now = new Date() + const minutesSinceTempError = (now - tempErrorAt) / (1000 * 60) + + // 如果临时错误状态超过指定时间,尝试重新激活 + if (minutesSinceTempError > TEMP_ERROR_RECOVERY_MINUTES) { + account.status = 'active' // 恢复为 active 状态 + account.schedulable = 'true' // 恢复为可调度 + delete account.errorMessage + delete account.tempErrorAt + await redis.setClaudeAccount(account.id, account) + // 同时清除500错误计数 + await this.clearInternalErrors(account.id) + cleanedCount++ + logger.success(`🧹 Reset temp_error status for account ${account.name} (${account.id})`) + } + } + } + + if (cleanedCount > 0) { + logger.success(`🧹 Reset ${cleanedCount} temp_error accounts`) + } + + return cleanedCount + } catch (error) { + logger.error('❌ Failed to cleanup temp_error accounts:', error) + return 0 + } + } + + // 记录5xx服务器错误 + async recordServerError(accountId, statusCode) { + try { + const key = `claude_account:${accountId}:5xx_errors` + + // 增加错误计数,设置5分钟过期时间 + await redis.client.incr(key) + await redis.client.expire(key, 300) // 5分钟 + + logger.info(`📝 Recorded ${statusCode} error for account ${accountId}`) + } catch (error) { + logger.error(`❌ Failed to record ${statusCode} error for account ${accountId}:`, error) + } + } + + // 记录500内部错误(保留以便向后兼容) + async recordInternalError(accountId) { + return this.recordServerError(accountId, 500) + } + + // 获取5xx错误计数 + async getServerErrorCount(accountId) { + try { + const key = `claude_account:${accountId}:5xx_errors` + + const count = await redis.client.get(key) + return parseInt(count) || 0 + } catch (error) { + logger.error(`❌ Failed to get 5xx error count for account ${accountId}:`, error) + return 0 + } + } + + // 获取500错误计数(保留以便向后兼容) + async getInternalErrorCount(accountId) { + return this.getServerErrorCount(accountId) + } + + // 清除500错误计数 + async clearInternalErrors(accountId) { + try { + const key = `claude_account:${accountId}:5xx_errors` + + await redis.client.del(key) + logger.info(`✅ Cleared 5xx error count for account ${accountId}`) + } catch (error) { + logger.error(`❌ Failed to clear 5xx errors for account ${accountId}:`, error) + } + } + + // 标记账号为临时错误状态 + async markAccountTempError(accountId, sessionHash = null) { + try { + const accountData = await redis.getClaudeAccount(accountId) + if (!accountData || Object.keys(accountData).length === 0) { + throw new Error('Account not found') + } + + // 更新账户状态 + const updatedAccountData = { ...accountData } + updatedAccountData.status = 'temp_error' // 新增的临时错误状态 + updatedAccountData.schedulable = 'false' // 设置为不可调度 + updatedAccountData.errorMessage = 'Account temporarily disabled due to consecutive 500 errors' + updatedAccountData.tempErrorAt = new Date().toISOString() + + // 保存更新后的账户数据 + await redis.setClaudeAccount(accountId, updatedAccountData) + + // 如果有sessionHash,删除粘性会话映射 + if (sessionHash) { + await redis.client.del(`sticky_session:${sessionHash}`) + logger.info(`🗑️ Deleted sticky session mapping for hash: ${sessionHash}`) + } + + logger.warn( + `⚠️ Account ${accountData.name} (${accountId}) marked as temp_error and disabled for scheduling` + ) + + // 发送Webhook通知 + try { + const webhookNotifier = require('../utils/webhookNotifier') + await webhookNotifier.sendAccountAnomalyNotification({ + accountId, + accountName: accountData.name, + platform: 'claude-oauth', + status: 'temp_error', + errorCode: 'CLAUDE_OAUTH_TEMP_ERROR', + reason: 'Account temporarily disabled due to consecutive 500 errors' + }) + } catch (webhookError) { + logger.error('Failed to send webhook notification:', webhookError) + } + + return { success: true } + } catch (error) { + logger.error(`❌ Failed to mark account ${accountId} as temp_error:`, 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) + } + } } module.exports = new ClaudeAccountService() diff --git a/src/services/claudeConsoleAccountService.js b/src/services/claudeConsoleAccountService.js index c2044895..28be976d 100644 --- a/src/services/claudeConsoleAccountService.js +++ b/src/services/claudeConsoleAccountService.js @@ -369,6 +369,7 @@ class ClaudeConsoleAccountService { // 发送Webhook通知 try { const webhookNotifier = require('../utils/webhookNotifier') + const { getISOStringWithTimezone } = require('../utils/dateHelper') await webhookNotifier.sendAccountAnomalyNotification({ accountId, accountName: account.name || 'Claude Console Account', @@ -376,7 +377,7 @@ class ClaudeConsoleAccountService { status: 'error', errorCode: 'CLAUDE_CONSOLE_RATE_LIMITED', reason: `Account rate limited (429 error). ${account.rateLimitDuration ? `Will be blocked for ${account.rateLimitDuration} hours` : 'Temporary rate limit'}`, - timestamp: new Date().toISOString() + timestamp: getISOStringWithTimezone(new Date()) }) } catch (webhookError) { logger.error('Failed to send rate limit webhook notification:', webhookError) @@ -453,6 +454,144 @@ class ClaudeConsoleAccountService { } } + // 🚫 标记账号为未授权状态(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) { try { diff --git a/src/services/claudeConsoleRelayService.js b/src/services/claudeConsoleRelayService.js index dafb7f98..27920a47 100644 --- a/src/services/claudeConsoleRelayService.js +++ b/src/services/claudeConsoleRelayService.js @@ -175,16 +175,26 @@ class ClaudeConsoleRelayService { `[DEBUG] Response data preview: ${typeof response.data === 'string' ? response.data.substring(0, 200) : JSON.stringify(response.data).substring(0, 200)}` ) - // 检查是否为限流错误 - if (response.status === 429) { + // 检查错误状态并相应处理 + if (response.status === 401) { + 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}`) 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) { - // 如果请求成功,检查并移除限流状态 + // 如果请求成功,检查并移除错误状态 const isRateLimited = await claudeConsoleAccountService.isAccountRateLimited(accountId) if (isRateLimited) { await claudeConsoleAccountService.removeAccountRateLimit(accountId) } + const isOverloaded = await claudeConsoleAccountService.isAccountOverloaded(accountId) + if (isOverloaded) { + await claudeConsoleAccountService.removeAccountOverload(accountId) + } } // 更新最后使用时间 @@ -363,8 +373,12 @@ class ClaudeConsoleRelayService { if (response.status !== 200) { logger.error(`❌ Claude Console API returned error status: ${response.status}`) - if (response.status === 429) { + if (response.status === 401) { + claudeConsoleAccountService.markAccountUnauthorized(accountId) + } else if (response.status === 429) { claudeConsoleAccountService.markAccountRateLimited(accountId) + } else if (response.status === 529) { + claudeConsoleAccountService.markAccountOverloaded(accountId) } // 设置错误响应的状态码和响应头 @@ -396,12 +410,17 @@ class ClaudeConsoleRelayService { return } - // 成功响应,检查并移除限流状态 + // 成功响应,检查并移除错误状态 claudeConsoleAccountService.isAccountRateLimited(accountId).then((isRateLimited) => { if (isRateLimited) { claudeConsoleAccountService.removeAccountRateLimit(accountId) } }) + claudeConsoleAccountService.isAccountOverloaded(accountId).then((isOverloaded) => { + if (isOverloaded) { + claudeConsoleAccountService.removeAccountOverload(accountId) + } + }) // 设置响应头 if (!responseStream.headersSent) { @@ -564,9 +583,15 @@ class ClaudeConsoleRelayService { logger.error('❌ Claude Console Claude stream request error:', error.message) - // 检查是否是429错误 - if (error.response && error.response.status === 429) { - claudeConsoleAccountService.markAccountRateLimited(accountId) + // 检查错误状态 + if (error.response) { + if (error.response.status === 401) { + claudeConsoleAccountService.markAccountUnauthorized(accountId) + } else if (error.response.status === 429) { + claudeConsoleAccountService.markAccountRateLimited(accountId) + } else if (error.response.status === 529) { + claudeConsoleAccountService.markAccountOverloaded(accountId) + } } // 发送错误响应 diff --git a/src/services/claudeRelayService.js b/src/services/claudeRelayService.js index 49a9192a..e285dea8 100644 --- a/src/services/claudeRelayService.js +++ b/src/services/claudeRelayService.js @@ -180,15 +180,15 @@ class ClaudeRelayService { // 记录401错误 await this.recordUnauthorizedError(accountId) - // 检查是否需要标记为异常(连续3次401) + // 检查是否需要标记为异常(遇到1次401就停止调度) const errorCount = await this.getUnauthorizedErrorCount(accountId) logger.info( `🔐 Account ${accountId} has ${errorCount} consecutive 401 errors in the last 5 minutes` ) - if (errorCount >= 3) { + if (errorCount >= 1) { logger.error( - `❌ Account ${accountId} exceeded 401 error threshold (${errorCount} errors), marking as unauthorized` + `❌ Account ${accountId} encountered 401 error (${errorCount} errors), marking as unauthorized` ) await unifiedClaudeScheduler.markAccountUnauthorized( accountId, @@ -197,6 +197,23 @@ class ClaudeRelayService { ) } } + // 检查是否为5xx状态码 + else if (response.statusCode >= 500 && response.statusCode < 600) { + logger.warn(`🔥 Server error (${response.statusCode}) detected for account ${accountId}`) + // 记录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 > 10) { + logger.error( + `❌ Account ${accountId} exceeded 5xx error threshold (${errorCount} errors), marking as temp_error` + ) + await claudeAccountService.markAccountTempError(accountId, sessionHash) + } + } // 检查是否为429状态码 else if (response.statusCode === 429) { isRateLimited = true @@ -247,8 +264,30 @@ class ClaudeRelayService { ) } } else if (response.statusCode === 200 || response.statusCode === 201) { - // 请求成功,清除401错误计数 + // 提取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错误计数 await this.clearUnauthorizedErrors(accountId) + await claudeAccountService.clearInternalErrors(accountId) // 如果请求成功,检查并移除限流状态 const isRateLimited = await unifiedClaudeScheduler.isAccountRateLimited( accountId, @@ -436,7 +475,10 @@ class ClaudeRelayService { const modelConfig = pricingData[model] 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 } @@ -883,6 +925,34 @@ class ClaudeRelayService { // 错误响应处理 if (res.statusCode !== 200) { + // 将错误处理逻辑封装在一个异步函数中 + const handleErrorResponse = async () => { + // 增加对5xx错误的处理 + if (res.statusCode >= 500 && res.statusCode < 600) { + logger.warn( + `🔥 [Stream] Server error (${res.statusCode}) detected for account ${accountId}` + ) + // 记录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 > 10) { + logger.error( + `❌ [Stream] Account ${accountId} exceeded 5xx error threshold (${errorCount} errors), marking as temp_error` + ) + await claudeAccountService.markAccountTempError(accountId, sessionHash) + } + } + } + + // 调用异步错误处理函数 + handleErrorResponse().catch((err) => { + logger.error('❌ Error in stream error handler:', err) + }) + logger.error(`❌ Claude API returned error status: ${res.statusCode}`) let errorData = '' @@ -1143,6 +1213,27 @@ class ClaudeRelayService { 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) { // 提取限流重置时间戳 @@ -1162,6 +1253,9 @@ class ClaudeRelayService { rateLimitResetTimestamp ) } else if (res.statusCode === 200) { + // 请求成功,清除401和500错误计数 + await this.clearUnauthorizedErrors(accountId) + await claudeAccountService.clearInternalErrors(accountId) // 如果请求成功,检查并移除限流状态 const isRateLimited = await unifiedClaudeScheduler.isAccountRateLimited( accountId, diff --git a/src/services/geminiAccountService.js b/src/services/geminiAccountService.js index 78e1d5a1..bd10f455 100644 --- a/src/services/geminiAccountService.js +++ b/src/services/geminiAccountService.js @@ -138,11 +138,19 @@ function createOAuth2Client(redirectUri = null, proxyConfig = null) { return new OAuth2Client(clientOptions) } -// 生成授权 URL (支持 PKCE) -async function generateAuthUrl(state = null, redirectUri = null) { +// 生成授权 URL (支持 PKCE 和代理) +async function generateAuthUrl(state = null, redirectUri = null, proxyConfig = null) { // 使用新的 redirect URI const finalRedirectUri = redirectUri || 'https://codeassist.google.com/authcode' - const oAuth2Client = createOAuth2Client(finalRedirectUri) + const oAuth2Client = createOAuth2Client(finalRedirectUri, proxyConfig) + + 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 const codeVerifier = await oAuth2Client.generateCodeVerifierAsync() @@ -965,12 +973,10 @@ async function getAccountRateLimitInfo(accountId) { } } -// 获取配置的OAuth客户端 - 参考GeminiCliSimulator的getOauthClient方法 -async function getOauthClient(accessToken, refreshToken) { - const client = new OAuth2Client({ - clientId: OAUTH_CLIENT_ID, - clientSecret: OAUTH_CLIENT_SECRET - }) +// 获取配置的OAuth客户端 - 参考GeminiCliSimulator的getOauthClient方法(支持代理) +async function getOauthClient(accessToken, refreshToken, proxyConfig = null) { + const client = createOAuth2Client(null, proxyConfig) + const creds = { access_token: accessToken, refresh_token: refreshToken, @@ -980,6 +986,14 @@ async function getOauthClient(accessToken, refreshToken) { 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) @@ -996,8 +1010,8 @@ async function getOauthClient(accessToken, refreshToken) { return client } -// 调用 Google Code Assist API 的 loadCodeAssist 方法 -async function loadCodeAssist(client, projectId = null) { +// 调用 Google Code Assist API 的 loadCodeAssist 方法(支持代理) +async function loadCodeAssist(client, projectId = null, proxyConfig = null) { const axios = require('axios') const CODE_ASSIST_ENDPOINT = 'https://cloudcode-pa.googleapis.com' const CODE_ASSIST_API_VERSION = 'v1internal' @@ -1017,7 +1031,7 @@ async function loadCodeAssist(client, projectId = null) { metadata: clientMetadata } - const response = await axios({ + const axiosConfig = { url: `${CODE_ASSIST_ENDPOINT}/${CODE_ASSIST_API_VERSION}:loadCodeAssist`, method: 'POST', headers: { @@ -1026,7 +1040,20 @@ async function loadCodeAssist(client, projectId = null) { }, data: request, 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调用成功') return response.data @@ -1059,8 +1086,8 @@ function getOnboardTier(loadRes) { } } -// 调用 Google Code Assist API 的 onboardUser 方法(包含轮询逻辑) -async function onboardUser(client, tierId, projectId, clientMetadata) { +// 调用 Google Code Assist API 的 onboardUser 方法(包含轮询逻辑,支持代理) +async function onboardUser(client, tierId, projectId, clientMetadata, proxyConfig = null) { const axios = require('axios') const CODE_ASSIST_ENDPOINT = 'https://cloudcode-pa.googleapis.com' const CODE_ASSIST_API_VERSION = 'v1internal' @@ -1073,15 +1100,8 @@ async function onboardUser(client, tierId, projectId, clientMetadata) { metadata: clientMetadata } - logger.info('📋 开始onboardUser API调用', { - tierId, - projectId, - hasProjectId: !!projectId, - isFreeTier: tierId === 'free-tier' || tierId === 'FREE' - }) - - // 轮询onboardUser直到长运行操作完成 - let lroRes = await axios({ + // 创建基础axios配置 + const baseAxiosConfig = { url: `${CODE_ASSIST_ENDPOINT}/${CODE_ASSIST_API_VERSION}:onboardUser`, method: 'POST', headers: { @@ -1090,8 +1110,29 @@ async function onboardUser(client, tierId, projectId, clientMetadata) { }, 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调用', { + tierId, + projectId, + hasProjectId: !!projectId, + isFreeTier: tierId === 'free-tier' || tierId === 'FREE' }) + // 轮询onboardUser直到长运行操作完成 + let lroRes = await axios(baseAxiosConfig) + let attempts = 0 const maxAttempts = 12 // 最多等待1分钟(5秒 * 12次) @@ -1099,17 +1140,7 @@ async function onboardUser(client, tierId, projectId, clientMetadata) { logger.info(`⏳ 等待onboardUser完成... (${attempts + 1}/${maxAttempts})`) await new Promise((resolve) => setTimeout(resolve, 5000)) - 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 - }) - + lroRes = await axios(baseAxiosConfig) attempts++ } @@ -1121,8 +1152,13 @@ async function onboardUser(client, tierId, projectId, clientMetadata) { return lroRes.data } -// 完整的用户设置流程 - 参考setup.ts的逻辑 -async function setupUser(client, initialProjectId = null, clientMetadata = null) { +// 完整的用户设置流程 - 参考setup.ts的逻辑(支持代理) +async function setupUser( + client, + initialProjectId = null, + clientMetadata = null, + proxyConfig = null +) { logger.info('🚀 setupUser 开始', { initialProjectId, hasClientMetadata: !!clientMetadata }) let projectId = initialProjectId || process.env.GOOGLE_CLOUD_PROJECT || null @@ -1141,7 +1177,7 @@ async function setupUser(client, initialProjectId = null, clientMetadata = null) // 调用loadCodeAssist logger.info('📞 调用 loadCodeAssist...') - const loadRes = await loadCodeAssist(client, projectId) + const loadRes = await loadCodeAssist(client, projectId, proxyConfig) logger.info('✅ loadCodeAssist 完成', { hasCloudaicompanionProject: !!loadRes.cloudaicompanionProject }) @@ -1164,7 +1200,7 @@ async function setupUser(client, initialProjectId = null, clientMetadata = null) // 调用onboardUser logger.info('📞 调用 onboardUser...', { tierId: tier.id, projectId }) - const lroRes = await onboardUser(client, tier.id, projectId, clientMetadata) + const lroRes = await onboardUser(client, tier.id, projectId, clientMetadata, proxyConfig) logger.info('✅ onboardUser 完成', { hasDone: !!lroRes.done, hasResponse: !!lroRes.response }) const result = { @@ -1178,8 +1214,8 @@ async function setupUser(client, initialProjectId = null, clientMetadata = null) return result } -// 调用 Code Assist API 计算 token 数量 -async function countTokens(client, contents, model = 'gemini-2.0-flash-exp') { +// 调用 Code Assist API 计算 token 数量(支持代理) +async function countTokens(client, contents, model = 'gemini-2.0-flash-exp', proxyConfig = null) { const axios = require('axios') const CODE_ASSIST_ENDPOINT = 'https://cloudcode-pa.googleapis.com' const CODE_ASSIST_API_VERSION = 'v1internal' @@ -1196,7 +1232,7 @@ async function countTokens(client, contents, model = 'gemini-2.0-flash-exp') { logger.info('📊 countTokens API调用开始', { model, contentsLength: contents.length }) - const response = await axios({ + const axiosConfig = { url: `${CODE_ASSIST_ENDPOINT}/${CODE_ASSIST_API_VERSION}:countTokens`, method: 'POST', headers: { @@ -1205,7 +1241,20 @@ async function countTokens(client, contents, model = 'gemini-2.0-flash-exp') { }, data: request, 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 }) return response.data diff --git a/src/services/ldapService.js b/src/services/ldapService.js new file mode 100644 index 00000000..75b4e704 --- /dev/null +++ b/src/services/ldapService.js @@ -0,0 +1,626 @@ +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 + } + + // 🔗 创建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) + } + }) + }) + } + + // 📝 提取用户信息 + 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. 验证用户密码 + const isPasswordValid = await this.authenticateUser(userDN, password) + if (!isPasswordValid) { + logger.info(`🚫 Invalid password 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() diff --git a/src/services/openaiAccountService.js b/src/services/openaiAccountService.js index 1e88cdec..e60a8b3a 100644 --- a/src/services/openaiAccountService.js +++ b/src/services/openaiAccountService.js @@ -502,6 +502,8 @@ async function getAllAccounts() { // 不解密敏感字段,只返回基本信息 accounts.push({ ...accountData, + isActive: accountData.isActive === 'true', + schedulable: accountData.schedulable !== 'false', openaiOauth: accountData.openaiOauth ? '[ENCRYPTED]' : '', accessToken: accountData.accessToken ? '[ENCRYPTED]' : '', refreshToken: accountData.refreshToken ? '[ENCRYPTED]' : '', diff --git a/src/services/pricingService.js b/src/services/pricingService.js index 5ded4c0a..43ce2d1a 100644 --- a/src/services/pricingService.js +++ b/src/services/pricingService.js @@ -45,6 +45,7 @@ class PricingService { 'claude-sonnet-3-5': 0.000006, 'claude-sonnet-3-7': 0.000006, 'claude-sonnet-4': 0.000006, + 'claude-sonnet-4-20250514': 0.000006, // Haiku 系列: $1.6/MTok 'claude-3-5-haiku': 0.0000016, @@ -55,6 +56,17 @@ class PricingService { 'claude-haiku-3': 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 模型的价格 + } } // 初始化价格服务 @@ -249,6 +261,7 @@ class PricingService { // 尝试直接匹配 if (this.pricingData[modelName]) { + logger.debug(`💰 Found exact pricing match for ${modelName}`) return this.pricingData[modelName] } @@ -293,6 +306,22 @@ class PricingService { 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 小时缓存价格 getEphemeral1hPricing(modelName) { if (!modelName) { @@ -329,9 +358,40 @@ class PricingService { // 计算使用费用 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) - if (!pricing) { + if (!pricing && !useLongContextPricing) { return { inputCost: 0, outputCost: 0, @@ -340,14 +400,35 @@ class PricingService { ephemeral5mCost: 0, ephemeral1hCost: 0, totalCost: 0, - hasPricing: false + hasPricing: false, + isLongContextRequest: false } } - const inputCost = (usage.input_tokens || 0) * (pricing.input_cost_per_token || 0) - const outputCost = (usage.output_tokens || 0) * (pricing.output_cost_per_token || 0) + let inputCost = 0 + let outputCost = 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 = - (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 对象,使用它 @@ -362,7 +443,7 @@ class PricingService { const ephemeral1hTokens = usage.cache_creation.ephemeral_1h_input_tokens || 0 // 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小时缓存使用硬编码的价格 const ephemeral1hPrice = this.getEphemeral1hPricing(modelName) @@ -373,7 +454,7 @@ class PricingService { } else if (usage.cache_creation_input_tokens) { // 旧格式,所有缓存创建 tokens 都按 5 分钟价格计算(向后兼容) 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 } @@ -386,11 +467,22 @@ class PricingService { ephemeral1hCost, totalCost: inputCost + outputCost + cacheCreateCost + cacheReadCost, hasPricing: true, + isLongContextRequest, pricing: { - input: pricing.input_cost_per_token || 0, - output: pricing.output_cost_per_token || 0, - cacheCreate: pricing.cache_creation_input_token_cost || 0, - cacheRead: pricing.cache_read_input_token_cost || 0, + input: useLongContextPricing + ? ( + this.longContextPricing[modelName] || + this.longContextPricing[Object.keys(this.longContextPricing)[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) } } diff --git a/src/services/unifiedClaudeScheduler.js b/src/services/unifiedClaudeScheduler.js index 287bb465..c83676a2 100644 --- a/src/services/unifiedClaudeScheduler.js +++ b/src/services/unifiedClaudeScheduler.js @@ -176,7 +176,8 @@ class UnifiedClaudeScheduler { boundAccount && boundAccount.isActive === 'true' && boundAccount.status !== 'error' && - boundAccount.status !== 'blocked' + boundAccount.status !== 'blocked' && + boundAccount.status !== 'temp_error' ) { const isRateLimited = await claudeAccountService.isAccountRateLimited(boundAccount.id) if (!isRateLimited) { @@ -262,6 +263,7 @@ class UnifiedClaudeScheduler { account.isActive === 'true' && account.status !== 'error' && account.status !== 'blocked' && + account.status !== 'temp_error' && (account.accountType === 'shared' || !account.accountType) && // 兼容旧数据 this._isSchedulable(account.schedulable) ) { @@ -441,7 +443,12 @@ class UnifiedClaudeScheduler { try { if (accountType === 'claude-official') { const account = await redis.getClaudeAccount(accountId) - if (!account || account.isActive !== 'true' || account.status === 'error') { + if ( + !account || + account.isActive !== 'true' || + account.status === 'error' || + account.status === 'temp_error' + ) { return false } // 检查是否可调度 @@ -452,7 +459,15 @@ class UnifiedClaudeScheduler { return !(await claudeAccountService.isAccountRateLimited(accountId)) } else if (accountType === 'claude-console') { const account = await claudeConsoleAccountService.getAccount(accountId) - if (!account || !account.isActive || account.status !== 'active') { + if (!account || !account.isActive) { + return false + } + // 检查账户状态 + if ( + account.status !== 'active' && + account.status !== 'unauthorized' && + account.status !== 'overloaded' + ) { return false } // 检查是否可调度 @@ -460,7 +475,19 @@ class UnifiedClaudeScheduler { logger.info(`🚫 Claude Console account ${accountId} is not schedulable`) return false } - return !(await claudeConsoleAccountService.isAccountRateLimited(accountId)) + // 检查是否被限流 + if (await claudeConsoleAccountService.isAccountRateLimited(accountId)) { + return false + } + // 检查是否未授权(401错误) + if (account.status === 'unauthorized') { + return false + } + // 检查是否过载(529错误) + if (await claudeConsoleAccountService.isAccountOverloaded(accountId)) { + return false + } + return true } else if (accountType === 'bedrock') { const accountResult = await bedrockAccountService.getAccount(accountId) if (!accountResult.success || !accountResult.data.isActive) { diff --git a/src/services/unifiedOpenAIScheduler.js b/src/services/unifiedOpenAIScheduler.js index f800621c..153f75b0 100644 --- a/src/services/unifiedOpenAIScheduler.js +++ b/src/services/unifiedOpenAIScheduler.js @@ -34,7 +34,11 @@ class UnifiedOpenAIScheduler { // 普通专属账户 const boundAccount = await openaiAccountService.getAccount(apiKeyData.openaiAccountId) - if (boundAccount && boundAccount.isActive === 'true' && boundAccount.status !== 'error') { + if ( + boundAccount && + (boundAccount.isActive === true || boundAccount.isActive === 'true') && + boundAccount.status !== 'error' + ) { // 检查是否被限流 const isRateLimited = await this.isAccountRateLimited(boundAccount.id) if (isRateLimited) { @@ -165,7 +169,7 @@ class UnifiedOpenAIScheduler { const openaiAccounts = await openaiAccountService.getAllAccounts() for (const account of openaiAccounts) { if ( - account.isActive === 'true' && + account.isActive && account.status !== 'error' && (account.accountType === 'shared' || !account.accountType) && // 兼容旧数据 this._isSchedulable(account.schedulable) @@ -233,7 +237,7 @@ class UnifiedOpenAIScheduler { try { if (accountType === 'openai') { const account = await openaiAccountService.getAccount(accountId) - if (!account || account.isActive !== 'true' || account.status === 'error') { + if (!account || !account.isActive || account.status === 'error') { return false } // 检查是否可调度 @@ -395,7 +399,7 @@ class UnifiedOpenAIScheduler { const account = await openaiAccountService.getAccount(memberId) if ( account && - account.isActive === 'true' && + account.isActive && account.status !== 'error' && this._isSchedulable(account.schedulable) ) { diff --git a/src/services/userService.js b/src/services/userService.js new file mode 100644 index 00000000..601d6419 --- /dev/null +++ b/src/services/userService.js @@ -0,0 +1,514 @@ +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) + + 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 + } + } +} + +module.exports = new UserService() diff --git a/src/services/webhookConfigService.js b/src/services/webhookConfigService.js index 18a460f6..59955cd1 100644 --- a/src/services/webhookConfigService.js +++ b/src/services/webhookConfigService.js @@ -56,15 +56,26 @@ class WebhookConfigService { // 验证平台配置 if (config.platforms) { - const validPlatforms = ['wechat_work', 'dingtalk', 'feishu', 'slack', 'discord', 'custom'] + const validPlatforms = [ + 'wechat_work', + 'dingtalk', + 'feishu', + 'slack', + 'discord', + 'custom', + 'bark' + ] for (const platform of config.platforms) { if (!validPlatforms.includes(platform.type)) { throw new Error(`不支持的平台类型: ${platform.type}`) } - if (!platform.url || !this.isValidUrl(platform.url)) { - throw new Error(`无效的webhook URL: ${platform.url}`) + // Bark平台使用deviceKey而不是url + if (platform.type !== 'bark') { + if (!platform.url || !this.isValidUrl(platform.url)) { + throw new Error(`无效的webhook URL: ${platform.url}`) + } } // 验证平台特定的配置 @@ -108,6 +119,88 @@ class WebhookConfigService { case 'custom': // 自定义webhook,用户自行负责格式 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 } } diff --git a/src/services/webhookService.js b/src/services/webhookService.js index ad2778ff..c026ead6 100644 --- a/src/services/webhookService.js +++ b/src/services/webhookService.js @@ -2,6 +2,7 @@ const axios = require('axios') const crypto = require('crypto') const logger = require('../utils/logger') const webhookConfigService = require('./webhookConfigService') +const { getISOStringWithTimezone } = require('../utils/dateHelper') class WebhookService { constructor() { @@ -11,7 +12,8 @@ class WebhookService { feishu: this.sendToFeishu.bind(this), slack: this.sendToSlack.bind(this), discord: this.sendToDiscord.bind(this), - custom: this.sendToCustom.bind(this) + custom: this.sendToCustom.bind(this), + bark: this.sendToBark.bind(this) } } @@ -205,13 +207,40 @@ class WebhookService { const payload = { type, service: 'claude-relay-service', - timestamp: new Date().toISOString(), + timestamp: getISOStringWithTimezone(new Date()), data } 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) + } + /** * 发送HTTP请求 */ @@ -329,7 +358,7 @@ class WebhookService { title, color, fields, - timestamp: new Date().toISOString(), + timestamp: getISOStringWithTimezone(new Date()), footer: { text: 'Claude Relay Service' } @@ -351,6 +380,81 @@ class WebhookService { return titles[type] || '📢 系统通知' } + /** + * 获取Bark通知级别 + */ + getBarkLevel(type) { + const levels = { + accountAnomaly: 'timeSensitive', + quotaWarning: 'active', + systemError: 'critical', + securityAlert: 'critical', + test: 'passive' + } + + return levels[type] || 'active' + } + + /** + * 获取Bark声音 + */ + getBarkSound(type) { + const sounds = { + accountAnomaly: 'alarm', + quotaWarning: 'bell', + systemError: 'alert', + securityAlert: 'alarm', + 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')}`) + + return lines.join('\n') + } + /** * 格式化通知详情 */ @@ -477,7 +581,7 @@ class WebhookService { try { const testData = { message: 'Claude Relay Service webhook测试', - timestamp: new Date().toISOString() + timestamp: getISOStringWithTimezone(new Date()) } await this.sendToPlatform(platform, 'test', testData, { maxRetries: 1, retryDelay: 1000 }) diff --git a/src/utils/costCalculator.js b/src/utils/costCalculator.js index a0fe6700..9eef07d2 100644 --- a/src/utils/costCalculator.js +++ b/src/utils/costCalculator.js @@ -32,6 +32,14 @@ const MODEL_PRICING = { 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-20240229': { input: 3.0, @@ -69,9 +77,57 @@ class CostCalculator { * @returns {Object} 费用详情 */ static calculateCost(usage, model = 'unknown') { - // 如果 usage 包含详细的 cache_creation 对象,使用 pricingService 来处理 - if (usage.cache_creation && typeof usage.cache_creation === 'object') { - return pricingService.calculateCost(usage, model) + // 如果 usage 包含详细的 cache_creation 对象或是 1M 模型,使用 pricingService 来处理 + if ( + (usage.cache_creation && typeof usage.cache_creation === 'object') || + (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 + } + } } // 否则使用旧的逻辑(向后兼容) diff --git a/src/utils/dateHelper.js b/src/utils/dateHelper.js new file mode 100644 index 00000000..7a8a333c --- /dev/null +++ b/src/utils/dateHelper.js @@ -0,0 +1,100 @@ +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 +} diff --git a/src/utils/inputValidator.js b/src/utils/inputValidator.js new file mode 100644 index 00000000..ca9232a9 --- /dev/null +++ b/src/utils/inputValidator.js @@ -0,0 +1,291 @@ +/** + * 输入验证工具类 + * 提供各种输入验证和清理功能,防止注入攻击 + */ +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, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') + .replace(/\//g, '/') + } + + /** + * 验证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() diff --git a/src/utils/webhookNotifier.js b/src/utils/webhookNotifier.js index 59c15147..e5002c29 100644 --- a/src/utils/webhookNotifier.js +++ b/src/utils/webhookNotifier.js @@ -1,5 +1,6 @@ const logger = require('./logger') const webhookService = require('../services/webhookService') +const { getISOStringWithTimezone } = require('./dateHelper') class WebhookNotifier { constructor() { @@ -28,7 +29,7 @@ class WebhookNotifier { errorCode: notification.errorCode || this._getErrorCode(notification.platform, notification.status), reason: notification.reason, - timestamp: notification.timestamp || new Date().toISOString() + timestamp: notification.timestamp || getISOStringWithTimezone(new Date()) }) } catch (error) { logger.error('Failed to send account anomaly notification:', error) diff --git a/web/admin-spa/.env.example b/web/admin-spa/.env.example index e0f33e8b..d0bd7a97 100644 --- a/web/admin-spa/.env.example +++ b/web/admin-spa/.env.example @@ -23,6 +23,14 @@ VITE_APP_TITLE=Claude Relay Service - 管理后台 # 格式:http://proxy-host:port #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 进行本地配置 # 2. .env.local 文件不会被提交到版本控制 diff --git a/web/admin-spa/src/components/accounts/AccountForm.vue b/web/admin-spa/src/components/accounts/AccountForm.vue index ad933766..b7055c1e 100644 --- a/web/admin-spa/src/components/accounts/AccountForm.vue +++ b/web/admin-spa/src/components/accounts/AccountForm.vue @@ -556,7 +556,17 @@ >
+ {{ errors.azureEndpoint }} +
++ Azure OpenAI API 版本,默认使用最新稳定版本 2024-02-01 +
++ {{ errors.deploymentName }} +
++ {{ errors.apiKey }} +
+留空表示不更新 API Key
+选择此部署支持的模型类型
++ {{ user.displayName || user.username }} +
+@{{ user.username }}
+@{{ user?.username }} • {{ user?.role }}
+Loading usage statistics...
+| + API Key + | ++ Status + | ++ Requests + | ++ Tokens + | ++ Cost + | ++ Last Used + | +
|---|---|---|---|---|---|
|
+ {{ apiKey.name }}
+ {{ apiKey.keyPreview }}
+ |
+ + + {{ apiKey.isActive ? 'Active' : 'Disabled' }} + + | ++ {{ formatNumber(apiKey.usage?.requests || 0) }} + | +
+ In: {{ formatNumber(apiKey.usage?.inputTokens || 0) }}
+ Out: {{ formatNumber(apiKey.usage?.outputTokens || 0) }}
+ |
+ + ${{ (apiKey.usage?.totalCost || 0).toFixed(4) }} + | ++ {{ apiKey.lastUsedAt ? formatDate(apiKey.lastUsedAt) : 'Never' }} + | +
+ Daily usage trends for {{ selectedPeriod }} period +
++ (Chart integration can be added with Chart.js, D3.js, or similar library) +
++ This user hasn't made any API requests in the selected period. +
+- 窗口内最大Token -
+窗口内最大费用
+ 设置 Opus 模型的周费用限制(周一到周日),仅限 Claude 官方账户,0 或留空表示无限制 +
+- 窗口内最大Token -
+窗口内最大费用
+ 设置 Opus 模型的周费用限制(周一到周日),仅限 Claude 官方账户,0 或留空表示无限制 +
++ Manage your API keys to access Claude Relay services +
++ You have reached the maximum number of API keys ({{ maxApiKeys }}). Please delete an + existing key to create a new one. +
+Loading API keys...
+{{ apiKey.name }}
+ + Deleted + + + Deleted + +{{ apiKey.description || 'No description' }}
+Get started by creating your first API key.
+View your API usage statistics and costs
+Loading usage statistics...
+Daily usage trends would be displayed here
++ (Chart integration can be added with Chart.js, D3.js, or similar library) +
+{{ model.name }}
+{{ formatNumber(model.requests) }} requests
+${{ model.cost.toFixed(4) }}
+| + API Key + | ++ Requests + | ++ Input Tokens + | ++ Output Tokens + | ++ Cost + | ++ Status + | +
|---|---|---|---|---|---|
|
+ {{ apiKey.name }}
+ {{ apiKey.keyPreview }}
+ |
+ + {{ formatNumber(apiKey.usage?.requests || 0) }} + | ++ {{ formatNumber(apiKey.usage?.inputTokens || 0) }} + | ++ {{ formatNumber(apiKey.usage?.outputTokens || 0) }} + | ++ ${{ (apiKey.usage?.totalCost || 0).toFixed(4) }} + | ++ + {{ + apiKey.isDeleted === 'true' || apiKey.deletedAt + ? 'Deleted' + : apiKey.isActive + ? 'Active' + : 'Disabled' + }} + + | +
+ You haven't made any API requests yet. Create an API key and start using the service to see + usage statistics. +
+{{ apiKey.name }}
+{{ apiKey.description }}
+{{
+ apiKey.key || 'Not available'
+ }}
+ {{
+ apiKey.keyPreview || 'cr_****'
+ }}
+ + Full API key is only shown when first created or regenerated +
+今日使用
-- {{ formatNumber(account.usage?.daily?.requests || 0) }} 次 -
-- {{ formatNumber(account.usage?.daily?.allTokens || 0) }} tokens -
++ {{ account.usage?.daily?.requests || 0 }} 次 +
++ {{ formatNumber(account.usage?.daily?.allTokens || 0) }}M +
++ ${{ calculateDailyCost(account) }} +
+总使用量
-- {{ formatNumber(account.usage?.total?.requests || 0) }} 次 -
-- {{ formatNumber(account.usage?.total?.allTokens || 0) }} tokens -
+会话窗口
++ {{ formatNumber(account.usage.sessionWindow.totalTokens) }}M +
++ ${{ formatCost(account.usage.sessionWindow.totalCost) }} +
+正在加载 API Keys...
-正在加载 API Keys...
+暂无 API Keys
-点击上方按钮创建您的第一个 API Key
-暂无 API Keys
+点击上方按钮创建您的第一个 API Key
+- {{ key.id }} -
+ + +- {{ formatNumber(key.usage?.daily?.requests || 0) }} 次 -
-请求
-- ${{ (key.dailyCost || 0).toFixed(4) }} -
-费用
-正在加载已删除的 API Keys...
暂无已删除的 API Keys
+已删除的 API Keys 会出现在这里
+| + 名称 + | ++ 创建者 + | ++ 创建时间 + | ++ 删除者 + | ++ 删除时间 + | ++ 使用统计 + | +
|---|---|---|---|---|---|
|
+
+
+
+
+
+
+
+
+ {{ key.name }}
+
+
+ {{ key.id }}
+
+ |
+
+
+
+
+ 管理员
+
+
+
+ {{ key.userUsername }}
+
+
+
+ 未知
+
+
+ |
+ + {{ formatDate(key.createdAt) }} + | +
+
+
+
+ {{ key.deletedBy }}
+
+
+
+ {{ key.deletedBy }}
+
+
+
+ {{ key.deletedBy }}
+
+
+ |
+ + {{ formatDate(key.deletedAt) }} + | +
+
+
+
+ 请求
+
+ {{ formatNumber(key.usage?.total?.requests || 0) }}次
+
+
+
+ 费用
+
+ ${{ (key.usage?.total?.cost || 0).toFixed(4) }}
+
+
+
+ 最后使用
+
+ {{ formatLastUsed(key.lastUsedAt) }}
+
+
+ 从未使用
+ |
+
正在加载设置...
-正在加载设置...
+ 在Bark App中查看您的推送密钥 +
+1. 在iPhone上安装Bark App
+2. 打开App获取您的设备密钥
+3. 将密钥粘贴到上方输入框
++ Sign in to your account to manage your API keys +
++ Manage users, their API keys, and view usage statistics +
+Loading users...
++ {{ user.displayName || user.username }} +
++ {{ + searchQuery ? 'No users match your search criteria.' : 'No users have been created yet.' + }} +
+