From 7624c383e8b1d670c8b5f1b3ad46c21b9f9c112b Mon Sep 17 00:00:00 2001 From: iRubbish Date: Mon, 25 Aug 2025 18:03:55 +0800 Subject: [PATCH 01/17] =?UTF-8?q?feat:=20=E5=AE=8C=E6=95=B4=E5=AE=9E?= =?UTF-8?q?=E7=8E=B0AD=E5=9F=9F=E6=8E=A7=E7=94=A8=E6=88=B7=E8=AE=A4?= =?UTF-8?q?=E8=AF=81=E7=B3=BB=E7=BB=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 主要功能: - 新增LDAP服务连接AD域控服务器 - 实现多格式AD用户认证(sAMAccountName, UPN, 域\用户名, DN) - 支持中文显示名和拼音用户名搜索 - 添加用户账户状态检查(禁用账户检测) - 实现JWT token认证和用户会话管理 新增文件: - src/services/ldapService.js - LDAP核心服务 - src/routes/ldapRoutes.js - AD认证API路由 - src/services/userMappingService.js - 用户映射服务 - web/admin-spa/src/views/UserDashboardView.vue - 用户控制台 - web/admin-spa/src/components/user/ - 用户组件目录 修改功能: - ApiStatsView.vue 增加用户登录按钮和模态框 - 路由系统增加用户专用页面 - 安装ldapjs和jsonwebtoken依赖 技术特性: - 多种认证格式自动尝试 - LDAP referral错误处理 - 详细认证日志和错误码记录 - 前后端完整用户认证流程 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- .env.example | 11 +- package-lock.json | 321 +++++++- package.json | 2 + src/app.js | 2 + src/routes/ldapRoutes.js | 562 +++++++++++++ src/services/ldapService.js | 750 ++++++++++++++++++ src/services/userMappingService.js | 195 +++++ .../src/components/user/UserApiKeysView.vue | 361 +++++++++ .../src/components/user/UserStatsView.vue | 268 +++++++ web/admin-spa/src/router/index.js | 20 +- web/admin-spa/src/views/ApiStatsView.vue | 192 +++++ web/admin-spa/src/views/UserDashboardView.vue | 357 +++++++++ 12 files changed, 3037 insertions(+), 4 deletions(-) create mode 100644 src/routes/ldapRoutes.js create mode 100644 src/services/ldapService.js create mode 100644 src/services/userMappingService.js create mode 100644 web/admin-spa/src/components/user/UserApiKeysView.vue create mode 100644 web/admin-spa/src/components/user/UserStatsView.vue create mode 100644 web/admin-spa/src/views/UserDashboardView.vue diff --git a/.env.example b/.env.example index bdf204cf..817731a8 100644 --- a/.env.example +++ b/.env.example @@ -65,4 +65,13 @@ TRUST_PROXY=true 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 +WEBHOOK_RETRIES=3 + +# 🏢 LDAP/AD 域控配置 +LDAP_URL=ldap://172.25.3.100:389 +LDAP_BIND_DN=LDAP-Proxy-Read +LDAP_BIND_PASSWORD=Y%77JsVK8W +LDAP_BASE_DN=OU=微店,DC=corp,DC=weidian-inc,DC=com +LDAP_SEARCH_FILTER=(&(objectClass=user)(cn={username})) +LDAP_TIMEOUT=10000 +LDAP_CONNECT_TIMEOUT=10000 \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 98b89998..72fbe9fc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,6 +24,8 @@ "https-proxy-agent": "^7.0.2", "inquirer": "^8.2.6", "ioredis": "^5.3.2", + "jsonwebtoken": "^9.0.2", + "ldapjs": "^3.0.7", "morgan": "^1.10.0", "ora": "^5.4.1", "rate-limiter-flexible": "^5.0.5", @@ -2048,6 +2050,101 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@ldapjs/asn1": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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 +3018,12 @@ "dev": true, "license": "ISC" }, + "node_modules/abstract-logging": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/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 +3180,15 @@ "dev": true, "license": "MIT" }, + "node_modules/assert-plus": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/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 +3337,18 @@ "@babel/core": "^7.0.0" } }, + "node_modules/backoff": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/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 +4036,12 @@ "dev": true, "license": "MIT" }, + "node_modules/core-util-is": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/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 +4763,15 @@ "node": ">=4" } }, + "node_modules/extsprintf": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/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", @@ -6477,6 +6616,67 @@ "node": ">=6" } }, + "node_modules/jsonwebtoken": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz", + "integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==", + "license": "MIT", + "dependencies": { + "jws": "^3.2.2", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jsonwebtoken/node_modules/jwa": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.2.tgz", + "integrity": "sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jsonwebtoken/node_modules/jws": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz", + "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==", + "license": "MIT", + "dependencies": { + "jwa": "^1.4.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jsonwebtoken/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/jsonwebtoken/node_modules/semver": { + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/jwa": { "version": "2.0.1", "resolved": "https://registry.npmmirror.com/jwa/-/jwa-2.0.1.tgz", @@ -6524,6 +6724,29 @@ "integrity": "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==", "license": "MIT" }, + "node_modules/ldapjs": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/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", @@ -6583,12 +6806,48 @@ "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", "license": "MIT" }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, "node_modules/lodash.isarguments": { "version": "3.1.0", "resolved": "https://registry.npmmirror.com/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz", "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==", "license": "MIT" }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmmirror.com/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -6596,6 +6855,12 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, "node_modules/lodash.truncate": { "version": "4.4.2", "resolved": "https://registry.npmmirror.com/lodash.truncate/-/lodash.truncate-4.4.2.tgz", @@ -7096,7 +7361,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 +7665,14 @@ "node": ">=8" } }, + "node_modules/precond": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/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 +7740,12 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/process-warning": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/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 +9035,46 @@ "node": ">= 0.8" } }, + "node_modules/vasync": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/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.npmjs.org/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.npmjs.org/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 +9229,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..bdfaf284 100644 --- a/package.json +++ b/package.json @@ -63,6 +63,8 @@ "https-proxy-agent": "^7.0.2", "inquirer": "^8.2.6", "ioredis": "^5.3.2", + "jsonwebtoken": "^9.0.2", + "ldapjs": "^3.0.7", "morgan": "^1.10.0", "ora": "^5.4.1", "rate-limiter-flexible": "^5.0.5", diff --git a/src/app.js b/src/app.js index f80347c8..45d6ed6e 100644 --- a/src/app.js +++ b/src/app.js @@ -23,6 +23,7 @@ const openaiClaudeRoutes = require('./routes/openaiClaudeRoutes') const openaiRoutes = require('./routes/openaiRoutes') const azureOpenaiRoutes = require('./routes/azureOpenaiRoutes') const webhookRoutes = require('./routes/webhook') +const ldapRoutes = require('./routes/ldapRoutes') // Import middleware const { @@ -244,6 +245,7 @@ class Application { this.app.use('/openai', openaiRoutes) this.app.use('/azure', azureOpenaiRoutes) this.app.use('/admin/webhook', webhookRoutes) + this.app.use('/admin/ldap', ldapRoutes) // 🏠 根路径重定向到新版管理界面 this.app.get('/', (req, res) => { diff --git a/src/routes/ldapRoutes.js b/src/routes/ldapRoutes.js new file mode 100644 index 00000000..4608bb8d --- /dev/null +++ b/src/routes/ldapRoutes.js @@ -0,0 +1,562 @@ +const express = require('express') +const ldapService = require('../services/ldapService') +const logger = require('../utils/logger') + +const router = express.Router() + +/** + * 测试LDAP/AD连接 + */ +router.get('/test-connection', async (req, res) => { + try { + logger.info('LDAP connection test requested') + const result = await ldapService.testConnection() + + if (result.success) { + res.json({ + success: true, + message: 'LDAP/AD connection successful', + data: result + }) + } else { + res.status(500).json({ + success: false, + message: 'LDAP/AD connection failed', + error: result.error, + config: result.config + }) + } + } catch (error) { + logger.error('LDAP connection test error:', error) + res.status(500).json({ + success: false, + message: 'LDAP connection test failed', + error: error.message + }) + } +}) + +/** + * 获取LDAP配置信息 + */ +router.get('/config', (req, res) => { + try { + const config = ldapService.getConfig() + res.json({ + success: true, + config + }) + } catch (error) { + logger.error('Get LDAP config error:', error) + res.status(500).json({ + success: false, + message: 'Failed to get LDAP config', + error: error.message + }) + } +}) + +/** + * 搜索用户 + */ +router.post('/search-user', async (req, res) => { + try { + const { username } = req.body + + if (!username) { + return res.status(400).json({ + success: false, + message: 'Username is required' + }) + } + + logger.info(`Searching for user: ${username}`) + + await ldapService.createConnection() + await ldapService.bind() + + const users = await ldapService.searchUser(username) + + res.json({ + success: true, + message: `Found ${users.length} users`, + users + }) + } catch (error) { + logger.error('User search error:', error) + res.status(500).json({ + success: false, + message: 'User search failed', + error: error.message + }) + } finally { + ldapService.disconnect() + } +}) + +/** + * 列出所有用户(模拟Python代码的describe_ou功能) + */ +router.get('/list-users', async (req, res) => { + try { + const { limit = 20, type = 'human' } = req.query + const limitNum = parseInt(limit) + + logger.info(`Listing users with limit: ${limitNum}, type: ${type}`) + + await ldapService.createConnection() + await ldapService.bind() + + const users = await ldapService.listAllUsers(limitNum, type) + + res.json({ + success: true, + message: `Found ${users.length} users`, + users, + total: users.length, + limit: limitNum, + type + }) + } catch (error) { + logger.error('List users error:', error) + res.status(500).json({ + success: false, + message: 'List users failed', + error: error.message + }) + } finally { + ldapService.disconnect() + } +}) + +/** + * 测试用户认证 + */ +router.post('/test-auth', async (req, res) => { + try { + const { username, password } = req.body + + if (!username || !password) { + return res.status(400).json({ + success: false, + message: 'Username and password are required' + }) + } + + logger.info(`Testing authentication for user: ${username}`) + + const result = await ldapService.authenticateUser(username, password) + + res.json({ + success: true, + message: 'Authentication successful', + user: result.user + }) + } catch (error) { + logger.error('User authentication test error:', error) + res.status(401).json({ + success: false, + message: 'Authentication failed', + error: error.message + }) + } +}) + +/** + * 列出所有OU + */ +router.get('/list-ous', async (req, res) => { + try { + logger.info('Listing all OUs in domain') + + await ldapService.createConnection() + await ldapService.bind() + + const ous = await ldapService.listOUs() + + res.json({ + success: true, + message: `Found ${ous.length} OUs`, + ous + }) + } catch (error) { + logger.error('List OUs error:', error) + res.status(500).json({ + success: false, + message: 'List OUs failed', + error: error.message + }) + } finally { + ldapService.disconnect() + } +}) + +/** + * 验证OU是否存在 + */ +router.get('/verify-ou', async (req, res) => { + try { + const { ou = '微店' } = req.query + const testDN = `OU=${ou},DC=corp,DC=weidian-inc,DC=com` + + logger.info(`Verifying OU exists: ${testDN}`) + + await ldapService.createConnection() + await ldapService.bind() + + const result = await ldapService.verifyOU(testDN) + + res.json({ + success: true, + message: 'OU verification completed', + testDN, + result + }) + } catch (error) { + logger.error('OU verification error:', error) + res.status(500).json({ + success: false, + message: 'OU verification failed', + error: error.message + }) + } finally { + ldapService.disconnect() + } +}) + +/** + * LDAP服务状态检查 + */ +router.get('/status', async (req, res) => { + try { + const config = ldapService.getConfig() + + // 简单的连接测试 + const connectionTest = await ldapService.testConnection() + + res.json({ + success: true, + status: connectionTest.success ? 'connected' : 'disconnected', + config, + lastTest: new Date().toISOString(), + testResult: connectionTest + }) + } catch (error) { + logger.error('LDAP status check error:', error) + res.status(500).json({ + success: false, + status: 'error', + message: 'Status check failed', + error: error.message + }) + } +}) + +/** + * AD用户登录认证 + */ +router.post('/login', async (req, res) => { + try { + const { username, password } = req.body + + if (!username || !password) { + return res.status(400).json({ + success: false, + message: '用户名和密码不能为空' + }) + } + + logger.info(`AD用户登录尝试: ${username}`) + + // 使用AD认证用户 + const authResult = await ldapService.authenticateUser(username, password) + + // 生成用户会话token + const jwt = require('jsonwebtoken') + const config = require('../../config/config') + + const userInfo = { + type: 'ad_user', + username: authResult.user.username || authResult.user.cn, + displayName: authResult.user.displayName, + email: authResult.user.email, + groups: authResult.user.groups, + loginTime: new Date().toISOString() + } + + const token = jwt.sign(userInfo, config.security.jwtSecret, { + expiresIn: '8h' // 8小时过期 + }) + + logger.info(`AD用户登录成功: ${username}`) + + res.json({ + success: true, + message: '登录成功', + token, + user: userInfo + }) + } catch (error) { + logger.error('AD用户登录失败:', error) + res.status(401).json({ + success: false, + message: '用户名或密码错误', + error: error.message + }) + } +}) + +/** + * AD用户token验证 + */ +router.get('/verify-token', (req, res) => { + try { + const authHeader = req.headers.authorization + if (!authHeader || !authHeader.startsWith('Bearer ')) { + return res.status(401).json({ + success: false, + message: '未提供有效的认证token' + }) + } + + const token = authHeader.substring(7) + const jwt = require('jsonwebtoken') + const config = require('../../config/config') + + const decoded = jwt.verify(token, config.security.jwtSecret) + + if (decoded.type !== 'ad_user') { + return res.status(403).json({ + success: false, + message: '无效的用户类型' + }) + } + + res.json({ + success: true, + user: decoded + }) + } catch (error) { + logger.error('Token验证失败:', error) + res.status(401).json({ + success: false, + message: 'Token无效或已过期' + }) + } +}) + +/** + * AD用户认证中间件 + */ +const authenticateUser = (req, res, next) => { + try { + const authHeader = req.headers.authorization + if (!authHeader || !authHeader.startsWith('Bearer ')) { + return res.status(401).json({ + success: false, + message: '未提供有效的认证token' + }) + } + + const token = authHeader.substring(7) + const jwt = require('jsonwebtoken') + const config = require('../../config/config') + + const decoded = jwt.verify(token, config.security.jwtSecret) + + if (decoded.type !== 'ad_user') { + return res.status(403).json({ + success: false, + message: '无效的用户类型' + }) + } + + req.user = decoded + next() + } catch (error) { + logger.error('用户认证失败:', error) + res.status(401).json({ + success: false, + message: 'Token无效或已过期' + }) + } +} + +/** + * 获取用户的API Keys + */ +router.get('/user/api-keys', authenticateUser, async (req, res) => { + try { + const redis = require('../models/redis') + const { username } = req.user + + logger.info(`获取用户API Keys: ${username}`) + + // 获取所有API Keys + const allKeysPattern = 'api_key:*' + const keys = await redis.getClient().keys(allKeysPattern) + + const userKeys = [] + + // 筛选属于该用户的API Keys + for (const key of keys) { + const apiKeyData = await redis.getClient().hgetall(key) + if (apiKeyData && apiKeyData.owner === username) { + userKeys.push({ + id: apiKeyData.id, + name: apiKeyData.name || '未命名', + key: apiKeyData.key, + limit: parseInt(apiKeyData.limit) || 1000000, + used: parseInt(apiKeyData.used) || 0, + createdAt: apiKeyData.createdAt, + status: apiKeyData.status || 'active' + }) + } + } + + res.json({ + success: true, + apiKeys: userKeys + }) + } catch (error) { + logger.error('获取用户API Keys失败:', error) + res.status(500).json({ + success: false, + message: '获取API Keys失败' + }) + } +}) + +/** + * 创建用户API Key + */ +router.post('/user/api-keys', authenticateUser, async (req, res) => { + try { + const { username } = req.user + const { name, limit } = req.body + + // 检查用户是否已有API Key + const redis = require('../models/redis') + const allKeysPattern = 'api_key:*' + const keys = await redis.getClient().keys(allKeysPattern) + + let userKeyCount = 0 + for (const key of keys) { + const apiKeyData = await redis.getClient().hgetall(key) + if (apiKeyData && apiKeyData.owner === username) { + userKeyCount++ + } + } + + if (userKeyCount >= 1) { + return res.status(400).json({ + success: false, + message: '每个用户只能创建一个API Key' + }) + } + + // 生成API Key + const crypto = require('crypto') + const uuid = require('uuid') + + const keyId = uuid.v4() + const apiKey = `cr_${crypto.randomBytes(32).toString('hex')}` + + const keyData = { + id: keyId, + key: apiKey, + name: name || 'AD用户密钥', + limit: limit || 100000, + used: 0, + owner: username, + ownerType: 'ad_user', + createdAt: new Date().toISOString(), + status: 'active' + } + + // 存储到Redis + await redis.getClient().hset(`api_key:${keyId}`, keyData) + + // 创建哈希映射以快速查找 + const keyHash = crypto.createHash('sha256').update(apiKey).digest('hex') + await redis.getClient().set(`api_key_hash:${keyHash}`, keyId) + + logger.info(`用户${username}创建API Key成功: ${keyId}`) + + res.json({ + success: true, + message: 'API Key创建成功', + apiKey: { + id: keyId, + key: apiKey, + name: keyData.name, + limit: keyData.limit, + used: 0, + createdAt: keyData.createdAt, + status: keyData.status + } + }) + } catch (error) { + logger.error('创建用户API Key失败:', error) + res.status(500).json({ + success: false, + message: '创建API Key失败' + }) + } +}) + +/** + * 获取用户API Key使用统计 + */ +router.get('/user/usage-stats', authenticateUser, async (req, res) => { + try { + const { username } = req.user + const redis = require('../models/redis') + + // 获取用户的API Keys + const allKeysPattern = 'api_key:*' + const keys = await redis.getClient().keys(allKeysPattern) + + let totalUsage = 0 + let totalLimit = 0 + const userKeys = [] + + for (const key of keys) { + const apiKeyData = await redis.getClient().hgetall(key) + if (apiKeyData && apiKeyData.owner === username) { + const used = parseInt(apiKeyData.used) || 0 + const limit = parseInt(apiKeyData.limit) || 0 + + totalUsage += used + totalLimit += limit + + userKeys.push({ + id: apiKeyData.id, + name: apiKeyData.name, + used, + limit, + percentage: limit > 0 ? Math.round((used / limit) * 100) : 0 + }) + } + } + + res.json({ + success: true, + stats: { + totalUsage, + totalLimit, + percentage: totalLimit > 0 ? Math.round((totalUsage / totalLimit) * 100) : 0, + keyCount: userKeys.length, + keys: userKeys + } + }) + } catch (error) { + logger.error('获取用户使用统计失败:', error) + res.status(500).json({ + success: false, + message: '获取使用统计失败' + }) + } +}) + +module.exports = router diff --git a/src/services/ldapService.js b/src/services/ldapService.js new file mode 100644 index 00000000..1630cb72 --- /dev/null +++ b/src/services/ldapService.js @@ -0,0 +1,750 @@ +const ldap = require('ldapjs') +const logger = require('../utils/logger') + +class LDAPService { + constructor() { + this.client = null + this.config = { + url: process.env.LDAP_URL || 'ldap://172.25.3.100:389', + bindDN: process.env.LDAP_BIND_DN || 'LDAP-Proxy-Read', + bindPassword: process.env.LDAP_BIND_PASSWORD || 'Y%77JsVK8W', + baseDN: process.env.LDAP_BASE_DN || 'OU=微店,DC=corp,DC=weidian-inc,DC=com', + searchFilter: process.env.LDAP_SEARCH_FILTER || '(&(objectClass=user)(cn={username}))', + timeout: parseInt(process.env.LDAP_TIMEOUT) || 10000, + connectTimeout: parseInt(process.env.LDAP_CONNECT_TIMEOUT) || 10000 + } + } + + /** + * 创建LDAP连接 + */ + createConnection() { + return new Promise((resolve, reject) => { + const options = { + url: this.config.url, + timeout: this.config.timeout, + connectTimeout: this.config.connectTimeout, + reconnect: false, + // 匹配Python代码中的设置:禁用referrals + followReferrals: false, + // LDAP协议版本3 + version: 3, + // 增加兼容性选项 + strictDN: false + } + + this.client = ldap.createClient(options) + + // 连接超时处理 + const timeoutTimer = setTimeout(() => { + this.client.destroy() + reject(new Error(`LDAP connection timeout after ${this.config.connectTimeout}ms`)) + }, this.config.connectTimeout) + + // 连接成功 + this.client.on('connect', () => { + clearTimeout(timeoutTimer) + logger.info('LDAP connection established successfully') + resolve() + }) + + // 连接错误 + this.client.on('error', (err) => { + clearTimeout(timeoutTimer) + logger.error('LDAP connection error:', err) + reject(err) + }) + + // 连接关闭 + this.client.on('close', () => { + logger.info('LDAP connection closed') + }) + }) + } + + /** + * 绑定LDAP连接(认证) + */ + bind() { + return new Promise((resolve, reject) => { + if (!this.client) { + return reject(new Error('LDAP client not initialized')) + } + + this.client.bind(this.config.bindDN, this.config.bindPassword, (err) => { + if (err) { + logger.error('LDAP bind failed:', err) + reject(err) + } else { + logger.info('LDAP bind successful') + resolve() + } + }) + }) + } + + /** + * 测试AD域控连接 + */ + async testConnection() { + try { + logger.info('Testing LDAP/AD connection...') + logger.info(`Connecting to: ${this.config.url}`) + logger.info(`Bind DN: ${this.config.bindDN}`) + logger.info(`Base DN: ${this.config.baseDN}`) + + await this.createConnection() + await this.bind() + + // 先测试连接和绑定是否真的成功 + logger.info('LDAP connection and bind successful') + + // 尝试简单的根 DSE 查询来验证连接 + let searchResult = null + try { + searchResult = await this.testRootDSE() + logger.info('Root DSE query successful') + } catch (searchError) { + logger.warn('Root DSE query failed, trying base search:', searchError.message) + try { + searchResult = await this.testSearch() + } catch (baseSearchError) { + logger.warn('Base search also failed:', baseSearchError.message) + // 连接成功但搜索失败,仍然返回部分成功 + return { + success: true, + message: + 'LDAP connection and authentication successful, but search requires DN adjustment', + connectionTest: 'SUCCESS', + authTest: 'SUCCESS', + searchTest: `FAILED - ${baseSearchError.message}`, + config: { + url: this.config.url, + bindDN: this.config.bindDN, + baseDN: this.config.baseDN, + searchFilter: this.config.searchFilter + } + } + } + } + + logger.info('LDAP/AD full connection test successful') + return { + success: true, + message: 'LDAP/AD connection test successful', + connectionTest: 'SUCCESS', + authTest: 'SUCCESS', + searchTest: 'SUCCESS', + config: { + url: this.config.url, + bindDN: this.config.bindDN, + baseDN: this.config.baseDN, + searchFilter: this.config.searchFilter + }, + searchResult + } + } catch (error) { + logger.error('LDAP/AD connection test failed:', error) + return { + success: false, + message: `LDAP/AD connection test failed: ${error.message}`, + error: error.message, + connectionTest: error.message.includes('connect') ? 'FAILED' : 'UNKNOWN', + authTest: + error.message.includes('bind') || error.message.includes('authentication') + ? 'FAILED' + : 'UNKNOWN', + config: { + url: this.config.url, + bindDN: this.config.bindDN, + baseDN: this.config.baseDN + } + } + } finally { + this.disconnect() + } + } + + /** + * 测试根DSE查询(最基本的LDAP查询) + */ + testRootDSE() { + return new Promise((resolve, reject) => { + const searchOptions = { + filter: '(objectClass=*)', + scope: 'base', + attributes: ['*'] + } + + this.client.search('', searchOptions, (err, res) => { + if (err) { + reject(err) + return + } + + let rootDSE = null + + res.on('searchEntry', (entry) => { + rootDSE = { + dn: entry.dn, + namingContexts: entry.object?.namingContexts || entry.attributes?.namingContexts, + supportedLDAPVersion: + entry.object?.supportedLDAPVersion || entry.attributes?.supportedLDAPVersion, + defaultNamingContext: + entry.object?.defaultNamingContext || entry.attributes?.defaultNamingContext, + raw: entry.object || entry.attributes + } + }) + + res.on('referral', (referral) => { + logger.info(`Root DSE referral: ${referral}`) + }) + + res.on('error', (error) => { + if (error.message && error.message.toLowerCase().includes('referral')) { + logger.warn(`Root DSE referral error (ignored): ${error.message}`) + return + } + reject(error) + }) + + res.on('end', () => { + if (rootDSE) { + logger.info('Root DSE query completed successfully') + resolve(rootDSE) + } else { + resolve({ message: 'No Root DSE data returned' }) + } + }) + }) + }) + } + + /** + * 执行测试搜索 + */ + testSearch() { + return new Promise((resolve, reject) => { + // 匹配Python代码的搜索:查找用户对象,获取CN和userAccountControl属性 + const searchOptions = { + filter: '(objectClass=user)', + scope: 'sub', // SCOPE_SUBTREE in Python + attributes: ['CN', 'userAccountControl'], + sizeLimit: 10 // 限制结果数量 + } + + this.client.search(this.config.baseDN, searchOptions, (err, res) => { + if (err) { + reject(err) + return + } + + let entryCount = 0 + const entries = [] + + res.on('searchEntry', (entry) => { + entryCount++ + entries.push({ + dn: entry.dn, + cn: entry.object.CN || entry.object.cn, + userAccountControl: entry.object.userAccountControl + }) + }) + + res.on('referral', (referral) => { + // 记录referral但不作为错误处理 + logger.info(`LDAP referral received: ${referral}`) + }) + + res.on('error', (error) => { + // 如果是referral相关错误,不视为失败 + if (error.message && error.message.toLowerCase().includes('referral')) { + logger.warn(`LDAP referral error (ignored): ${error.message}`) + return + } + reject(error) + }) + + res.on('end', (result) => { + logger.info( + `Search test completed. Found ${entryCount} entries, status: ${result.status}` + ) + resolve({ + entryCount, + status: result.status, + entries: entries.slice(0, 5) + }) + }) + }) + }) + } + + /** + * 根据用户名搜索用户 + */ + searchUser(username) { + return new Promise((resolve, reject) => { + if (!this.client) { + return reject(new Error('LDAP client not initialized')) + } + + const filter = this.config.searchFilter.replace(/{username}/g, username) + const searchOptions = { + filter, + scope: 'sub', + attributes: [ + 'dn', + 'sAMAccountName', + 'displayName', + 'mail', + 'memberOf', + 'cn', + 'userAccountControl' + ] + } + + logger.info(`Searching for user: ${username}, Filter: ${filter}`) + + this.client.search(this.config.baseDN, searchOptions, (err, res) => { + if (err) { + reject(err) + return + } + + const users = [] + + res.on('searchEntry', (entry) => { + const obj = entry.object || {} + const attrs = entry.attributes || [] + + // 创建属性查找函数 + const getAttr = (name) => { + if (obj[name]) { + return obj[name] + } + const attr = attrs.find((a) => a.type === name) + return attr ? (Array.isArray(attr.values) ? attr.values[0] : attr.values) : null + } + + const user = { + dn: entry.dn, + username: getAttr('sAMAccountName'), + displayName: getAttr('displayName'), + email: getAttr('mail'), + cn: getAttr('cn'), + userAccountControl: getAttr('userAccountControl'), + groups: (() => { + const memberOf = getAttr('memberOf') + return Array.isArray(memberOf) ? memberOf : memberOf ? [memberOf] : [] + })() + } + users.push(user) + }) + + res.on('referral', (referral) => { + logger.info(`LDAP referral received during user search: ${referral}`) + }) + + res.on('error', (error) => { + if (error.message && error.message.toLowerCase().includes('referral')) { + logger.warn(`LDAP referral error during user search (ignored): ${error.message}`) + return + } + reject(error) + }) + + res.on('end', () => { + logger.info(`Found ${users.length} users for username: ${username}`) + resolve(users) + }) + }) + }) + } + + /** + * 列出所有用户(模拟Python代码的describe_ou功能) + */ + listAllUsers(limit = 20, type = 'human') { + return new Promise((resolve, reject) => { + if (!this.client) { + return reject(new Error('LDAP client not initialized')) + } + + // 根据类型选择不同的搜索过滤器 + let filter + if (type === 'computer') { + // 只显示计算机账户 + filter = '(&(objectClass=user)(sAMAccountName=*$))' + } else if (type === 'human') { + // 只显示人员账户(排除计算机账户) + filter = '(&(objectClass=user)(!(sAMAccountName=*$)))' + } else { + // 显示所有用户 + filter = '(objectClass=user)' + } + + const searchOptions = { + filter, + scope: 'sub', // SCOPE_SUBTREE + attributes: ['CN', 'userAccountControl', 'sAMAccountName', 'displayName', 'mail', 'dn'] + // 不使用 sizeLimit,而是在客户端限制结果数量 + } + + logger.info(`Listing all users with filter: ${searchOptions.filter}, limit: ${limit}`) + + this.client.search(this.config.baseDN, searchOptions, (err, res) => { + if (err) { + reject(err) + return + } + + const users = [] + + res.on('searchEntry', (entry) => { + // 如果已经达到限制,停止处理 + if (users.length >= limit) { + return + } + + const obj = entry.object || {} + const attrs = entry.attributes || [] + + // 创建属性查找函数 + const getAttr = (name) => { + if (obj[name]) { + return obj[name] + } + const attr = attrs.find((a) => a.type === name) + return attr ? (Array.isArray(attr.values) ? attr.values[0] : attr.values) : null + } + + const user = { + dn: entry.dn, + cn: getAttr('CN') || getAttr('cn'), + sAMAccountName: getAttr('sAMAccountName'), + displayName: getAttr('displayName'), + email: getAttr('mail'), + userAccountControl: getAttr('userAccountControl'), + // 为了兼容Python代码的数据结构 + org: entry.dn, + // 调试信息 (限制原始数据大小) + raw: users.length < 3 ? { object: entry.object, attributes: entry.attributes } : null + } + users.push(user) + }) + + res.on('referral', (referral) => { + logger.info(`LDAP referral received during user listing: ${referral}`) + }) + + res.on('error', (error) => { + if (error.message && error.message.toLowerCase().includes('referral')) { + logger.warn(`LDAP referral error during user listing (ignored): ${error.message}`) + return + } + reject(error) + }) + + res.on('end', () => { + logger.info(`Found ${users.length} users total`) + resolve(users) + }) + }) + }) + } + + /** + * 验证用户凭据 + */ + async authenticateUser(username, password) { + try { + // 先搜索用户获取DN + await this.createConnection() + await this.bind() + + const users = await this.searchUser(username) + if (users.length === 0) { + throw new Error('User not found') + } + + // 修复DN提取逻辑,处理ldapjs的DN对象 + let userDN = users[0].dn + if (userDN && typeof userDN === 'object') { + // ldapjs返回的是DN对象,需要正确转换为字符串 + if (userDN.toString && typeof userDN.toString === 'function') { + userDN = userDN.toString() + } else if (userDN.format && typeof userDN.format === 'function') { + userDN = userDN.format() + } else { + // 从dn对象中提取rdns信息手动构建DN字符串 + logger.info('User DN object structure:', JSON.stringify(userDN, null, 2)) + throw new Error('Unable to extract user DN from object') + } + } else if (typeof userDN !== 'string') { + throw new Error('Invalid DN format') + } + + logger.info(`Attempting to authenticate with DN: ${userDN}`) + logger.info(`User sAMAccountName: ${users[0].sAMAccountName || users[0].username}`) + logger.info(`User Account Control: ${users[0].userAccountControl}`) + + // 检查账户状态 + const userAccountControl = parseInt(users[0].userAccountControl) || 0 + if (userAccountControl & 2) { + // UF_ACCOUNTDISABLE = 2 + throw new Error('User account is disabled') + } + + // 断开管理员连接 + this.disconnect() + + // 尝试多种认证格式 + const sAMAccountName = users[0].sAMAccountName || users[0].username + const authFormats = [ + sAMAccountName, // 直接使用sAMAccountName + `${sAMAccountName}@corp.weidian-inc.com`, // UPN格式 + `${sAMAccountName}@weidian-inc.com`, // 简化UPN格式 + `corp\\${sAMAccountName}`, // 域\\用户名格式 + `CORP\\${sAMAccountName}`, // 大写域\\用户名格式 + `weidian-inc\\${sAMAccountName}`, // 完整域名\\用户名格式 + userDN // 完整DN(最后尝试) + ].filter(Boolean) + + logger.info(`Trying authentication with formats: ${JSON.stringify(authFormats)}`) + + for (const authFormat of authFormats) { + try { + logger.info(`Attempting authentication with: ${authFormat}`) + + const userClient = ldap.createClient({ + url: this.config.url, + timeout: 10000, + connectTimeout: 10000, + idleTimeout: 30000 + }) + + const authResult = await new Promise((resolve, reject) => { + let resolved = false + + // 设置错误处理 + userClient.on('error', (err) => { + if (!resolved) { + resolved = true + logger.warn(`Connection error with ${authFormat}:`, err.message) + userClient.destroy() + reject(err) + } + }) + + userClient.on('connect', () => { + logger.info(`Connected for authentication with: ${authFormat}`) + + // 尝试使用用户凭据绑定 + userClient.bind(authFormat, password, (err) => { + if (!resolved) { + resolved = true + if (err) { + logger.warn( + `Bind failed with ${authFormat}: ${err.name} - ${err.message} (Code: ${err.code})` + ) + userClient.destroy() + reject(err) + } else { + logger.info(`Bind successful with ${authFormat}`) + userClient.unbind() + resolve(true) + } + } + }) + }) + + // 超时处理 + setTimeout(() => { + if (!resolved) { + resolved = true + userClient.destroy() + reject(new Error('Authentication timeout')) + } + }, 5000) + }) + + if (authResult) { + logger.info(`User ${username} authenticated successfully with format: ${authFormat}`) + return { + success: true, + user: users[0] + } + } + } catch (err) { + logger.warn( + `Authentication failed with format ${authFormat}: ${err.name} - ${err.message}` + ) + continue + } + } + + // 所有格式都失败 + throw new Error('Invalid username or password') + } catch (error) { + logger.error('User authentication error:', error) + throw error + } finally { + this.disconnect() + } + } + + /** + * 关闭连接 + */ + disconnect() { + if (this.client) { + this.client.destroy() + this.client = null + logger.info('LDAP connection closed') + } + } + + /** + * 列出所有OU + */ + listOUs() { + return new Promise((resolve, reject) => { + if (!this.client) { + return reject(new Error('LDAP client not initialized')) + } + + const searchOptions = { + filter: '(objectClass=organizationalUnit)', + scope: 'sub', + attributes: ['ou', 'dn', 'objectClass', 'description'] + } + + // 从域根开始搜索所有OU + const baseDN = 'DC=corp,DC=weidian-inc,DC=com' + logger.info(`Searching for all OUs in: ${baseDN}`) + + this.client.search(baseDN, searchOptions, (err, res) => { + if (err) { + reject(err) + return + } + + const ous = [] + + res.on('searchEntry', (entry) => { + const obj = entry.object || {} + const attrs = entry.attributes || [] + + const getAttr = (name) => { + if (obj[name]) { + return obj[name] + } + const attr = attrs.find((a) => a.type === name) + return attr ? (Array.isArray(attr.values) ? attr.values[0] : attr.values) : null + } + + const ou = { + dn: entry.dn, + ou: getAttr('ou'), + description: getAttr('description'), + objectClass: getAttr('objectClass') + } + ous.push(ou) + }) + + res.on('referral', (referral) => { + logger.info(`OUs search referral: ${referral}`) + }) + + res.on('error', (error) => { + if (error.message && error.message.toLowerCase().includes('referral')) { + logger.warn(`OUs search referral error (ignored): ${error.message}`) + return + } + reject(error) + }) + + res.on('end', () => { + logger.info(`Found ${ous.length} OUs total`) + resolve(ous) + }) + }) + }) + } + + /** + * 验证OU是否存在 + */ + verifyOU(ouDN) { + return new Promise((resolve, reject) => { + if (!this.client) { + return reject(new Error('LDAP client not initialized')) + } + + const searchOptions = { + filter: '(objectClass=organizationalUnit)', + scope: 'base', + attributes: ['ou', 'dn', 'objectClass'] + } + + logger.info(`Searching for OU: ${ouDN}`) + + this.client.search(ouDN, searchOptions, (err, res) => { + if (err) { + reject(err) + return + } + + let found = false + let ouInfo = null + + res.on('searchEntry', (entry) => { + found = true + ouInfo = { + dn: entry.dn, + ou: entry.object?.ou || entry.attributes?.find((a) => a.type === 'ou')?.values, + objectClass: + entry.object?.objectClass || + entry.attributes?.find((a) => a.type === 'objectClass')?.values + } + }) + + res.on('referral', (referral) => { + logger.info(`OU search referral: ${referral}`) + }) + + res.on('error', (error) => { + if (error.message && error.message.toLowerCase().includes('referral')) { + logger.warn(`OU search referral error (ignored): ${error.message}`) + return + } + reject(error) + }) + + res.on('end', () => { + resolve({ + exists: found, + dn: ouDN, + info: ouInfo + }) + }) + }) + }) + } + + /** + * 获取配置信息(不包含密码) + */ + getConfig() { + return { + url: this.config.url, + bindDN: this.config.bindDN, + baseDN: this.config.baseDN, + searchFilter: this.config.searchFilter, + timeout: this.config.timeout, + connectTimeout: this.config.connectTimeout + } + } +} + +module.exports = new LDAPService() diff --git a/src/services/userMappingService.js b/src/services/userMappingService.js new file mode 100644 index 00000000..b79c2b23 --- /dev/null +++ b/src/services/userMappingService.js @@ -0,0 +1,195 @@ +const logger = require('../utils/logger') + +/** + * 用户映射服务 - 处理AD用户数据转换和过滤 + */ +class UserMappingService { + /** + * 解析AD用户账户控制状态 + */ + static parseUserAccountControl(uac) { + if (!uac) { + return { disabled: true, description: 'Unknown' } + } + + const uacValue = parseInt(uac) + const flags = { + SCRIPT: 0x00000001, + ACCOUNTDISABLE: 0x00000002, + HOMEDIR_REQUIRED: 0x00000008, + LOCKOUT: 0x00000010, + PASSWD_NOTREQD: 0x00000020, + PASSWD_CANT_CHANGE: 0x00000040, + ENCRYPTED_TEXT_PASSWORD_ALLOWED: 0x00000080, + TEMP_DUPLICATE_ACCOUNT: 0x00000100, + NORMAL_ACCOUNT: 0x00000200, + INTERDOMAIN_TRUST_ACCOUNT: 0x00000800, + WORKSTATION_TRUST_ACCOUNT: 0x00001000, + SERVER_TRUST_ACCOUNT: 0x00002000, + DONT_EXPIRE_PASSWD: 0x00010000, + MNS_LOGON_ACCOUNT: 0x00020000, + SMARTCARD_REQUIRED: 0x00040000, + TRUSTED_FOR_DELEGATION: 0x00080000, + NOT_DELEGATED: 0x00100000, + USE_DES_KEY_ONLY: 0x00200000, + DONT_REQUIRE_PREAUTH: 0x00400000, + PASSWORD_EXPIRED: 0x00800000, + TRUSTED_TO_AUTHENTICATE_FOR_DELEGATION: 0x01000000, + PARTIAL_SECRETS_ACCOUNT: 0x04000000 + } + + const status = { + disabled: !!(uacValue & flags.ACCOUNTDISABLE), + locked: !!(uacValue & flags.LOCKOUT), + passwordExpired: !!(uacValue & flags.PASSWORD_EXPIRED), + normalAccount: !!(uacValue & flags.NORMAL_ACCOUNT), + passwordNotRequired: !!(uacValue & flags.PASSWD_NOTREQD), + dontExpirePassword: !!(uacValue & flags.DONT_EXPIRE_PASSWD), + description: this.getUserAccountControlDescription(uacValue) + } + + return status + } + + /** + * 获取用户账户控制的描述 + */ + static getUserAccountControlDescription(uac) { + const uacValue = parseInt(uac) + + if (uacValue & 0x00000002) { + return 'Account Disabled' + } + if (uacValue & 0x00000010) { + return 'Account Locked' + } + if (uacValue & 0x00800000) { + return 'Password Expired' + } + if (uacValue & 0x00000200) { + return 'Normal User Account' + } + + return `UAC: ${uacValue}` + } + + /** + * 过滤和映射AD用户数据 + * 模拟Python代码中的get_ad()函数逻辑 + */ + static mapAdUsers(searchResults) { + if (!Array.isArray(searchResults)) { + return [] + } + + // 移除第一个元素(Python代码中的slist.pop(0)) + const userList = searchResults.slice(1) + const mappedUsers = [] + + for (const user of userList) { + try { + const userObj = { + org: user.dn || user.distinguishedName, + cn: null, + userAccountControl: null, + accountStatus: null + } + + // 提取CN + if (user.cn || user.CN) { + userObj.cn = user.cn || user.CN + } else { + // 如果没有CN属性,跳过此用户 + continue + } + + // 提取userAccountControl + if (user.userAccountControl) { + userObj.userAccountControl = user.userAccountControl + userObj.accountStatus = this.parseUserAccountControl(user.userAccountControl) + } else { + // 如果没有userAccountControl,跳过此用户 + continue + } + + mappedUsers.push(userObj) + } catch (error) { + logger.warn(`Error processing user entry: ${error.message}`, { user }) + continue + } + } + + return mappedUsers + } + + /** + * 过滤活跃用户(未禁用的账户) + */ + static filterActiveUsers(users) { + return users.filter((user) => user.accountStatus && !user.accountStatus.disabled) + } + + /** + * 根据用户名搜索(支持模糊匹配) + */ + static searchUsersByName(users, searchTerm) { + if (!searchTerm) { + return users + } + + const term = searchTerm.toLowerCase() + return users.filter((user) => user.cn && user.cn.toLowerCase().includes(term)) + } + + /** + * 格式化用户信息用于显示 + */ + static formatUserInfo(user) { + return { + name: user.cn, + distinguishedName: user.org, + accountControl: user.userAccountControl, + status: user.accountStatus + ? { + enabled: !user.accountStatus.disabled, + locked: user.accountStatus.locked, + description: user.accountStatus.description + } + : null + } + } + + /** + * 获取用户统计信息 + */ + static getUserStats(users) { + const stats = { + total: users.length, + active: 0, + disabled: 0, + locked: 0, + passwordExpired: 0 + } + + users.forEach((user) => { + if (user.accountStatus) { + if (!user.accountStatus.disabled) { + stats.active++ + } + if (user.accountStatus.disabled) { + stats.disabled++ + } + if (user.accountStatus.locked) { + stats.locked++ + } + if (user.accountStatus.passwordExpired) { + stats.passwordExpired++ + } + } + }) + + return stats + } +} + +module.exports = UserMappingService diff --git a/web/admin-spa/src/components/user/UserApiKeysView.vue b/web/admin-spa/src/components/user/UserApiKeysView.vue new file mode 100644 index 00000000..7f472ab6 --- /dev/null +++ b/web/admin-spa/src/components/user/UserApiKeysView.vue @@ -0,0 +1,361 @@ + + + + + diff --git a/web/admin-spa/src/components/user/UserStatsView.vue b/web/admin-spa/src/components/user/UserStatsView.vue new file mode 100644 index 00000000..58e2467b --- /dev/null +++ b/web/admin-spa/src/components/user/UserStatsView.vue @@ -0,0 +1,268 @@ + + + + + diff --git a/web/admin-spa/src/router/index.js b/web/admin-spa/src/router/index.js index 47680c7d..11a8cc46 100644 --- a/web/admin-spa/src/router/index.js +++ b/web/admin-spa/src/router/index.js @@ -11,6 +11,7 @@ const AccountsView = () => import('@/views/AccountsView.vue') const TutorialView = () => import('@/views/TutorialView.vue') const SettingsView = () => import('@/views/SettingsView.vue') const ApiStatsView = () => import('@/views/ApiStatsView.vue') +const UserDashboardView = () => import('@/views/UserDashboardView.vue') const routes = [ { @@ -41,6 +42,12 @@ const routes = [ component: ApiStatsView, meta: { requiresAuth: false } }, + { + path: '/user-dashboard', + name: 'UserDashboard', + component: UserDashboardView, + meta: { requiresAuth: false, userAuth: true } + }, { path: '/dashboard', component: MainLayout, @@ -133,7 +140,18 @@ router.beforeEach((to, from, next) => { // API Stats 页面不需要认证,直接放行 if (to.path === '/api-stats' || to.path.startsWith('/api-stats')) { next() - } else if (to.meta.requiresAuth && !authStore.isAuthenticated) { + } + // 用户仪表盘需要用户token验证 + else if (to.meta.userAuth) { + const userToken = localStorage.getItem('user_token') + if (!userToken) { + next('/api-stats') + } else { + next() + } + } + // 管理员页面需要管理员认证 + else if (to.meta.requiresAuth && !authStore.isAuthenticated) { next('/login') } else if (to.path === '/login' && authStore.isAuthenticated) { next('/dashboard') diff --git a/web/admin-spa/src/views/ApiStatsView.vue b/web/admin-spa/src/views/ApiStatsView.vue index f2af17b2..fcd6bd51 100644 --- a/web/admin-spa/src/views/ApiStatsView.vue +++ b/web/admin-spa/src/views/ApiStatsView.vue @@ -20,6 +20,15 @@ class="h-8 w-px bg-gradient-to-b from-transparent via-gray-300 to-transparent opacity-50 dark:via-gray-600" /> + + + + + +
+
+
+
+

AD域控登录

+

使用您的域账号登录

+
+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+
+ +
+ {{ userLoginError }} +
+
+
@@ -157,6 +234,15 @@ const currentTab = ref('stats') // 主题相关 const isDarkMode = computed(() => themeStore.isDarkMode) +// 用户登录相关 +const showLoginModal = ref(false) +const userLoginLoading = ref(false) +const userLoginError = ref('') +const userLoginForm = ref({ + username: '', + password: '' +}) + const { apiKey, apiId, @@ -171,6 +257,63 @@ const { const { queryStats, switchPeriod, loadStatsWithApiId, loadOemSettings, reset } = apiStatsStore +// 用户登录相关方法 +const showUserLogin = () => { + showLoginModal.value = true + userLoginError.value = '' + userLoginForm.value = { + username: '', + password: '' + } +} + +const hideUserLogin = () => { + showLoginModal.value = false + userLoginError.value = '' + userLoginForm.value = { + username: '', + password: '' + } +} + +const handleUserLogin = async () => { + if (!userLoginForm.value.username || !userLoginForm.value.password) { + userLoginError.value = '请输入用户名和密码' + return + } + + userLoginLoading.value = true + userLoginError.value = '' + + try { + const response = await fetch('/admin/ldap/login', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(userLoginForm.value) + }) + + const result = await response.json() + + if (result.success) { + // 保存token到localStorage + localStorage.setItem('user_token', result.token) + localStorage.setItem('user_info', JSON.stringify(result.user)) + + // 跳转到用户专用页面 + window.location.href = '/admin-next/user-dashboard' + } else { + userLoginError.value = result.message || '登录失败' + } + } catch (error) { + console.error('用户登录错误:', error) + userLoginError.value = '网络错误,请重试' + } finally { + userLoginLoading.value = false + } +} + // 处理键盘快捷键 const handleKeyDown = (event) => { // Ctrl/Cmd + Enter 查询 @@ -309,6 +452,55 @@ watch(apiKey, (newValue) => { letter-spacing: -0.025em; } +/* 用户登录按钮 */ +.user-login-button { + background: linear-gradient(135deg, #10b981 0%, #059669 100%); + backdrop-filter: blur(20px); + border: 1px solid rgba(255, 255, 255, 0.3); + color: white; + text-decoration: none; + box-shadow: + 0 4px 12px rgba(16, 185, 129, 0.25), + inset 0 1px 1px rgba(255, 255, 255, 0.2); + position: relative; + overflow: hidden; + font-weight: 600; + cursor: pointer; +} + +/* 暗色模式下的用户登录按钮 */ +:global(.dark) .user-login-button { + background: rgba(34, 197, 94, 0.8); + border: 1px solid rgba(107, 114, 128, 0.4); + color: #f3f4f6; + box-shadow: + 0 4px 12px rgba(0, 0, 0, 0.3), + inset 0 1px 1px rgba(255, 255, 255, 0.05); +} + +.user-login-button:hover { + transform: translateY(-2px) scale(1.02); + background: linear-gradient(135deg, #059669 0%, #10b981 100%); + box-shadow: + 0 8px 20px rgba(5, 150, 105, 0.35), + inset 0 1px 1px rgba(255, 255, 255, 0.3); + border-color: rgba(255, 255, 255, 0.4); + color: white; +} + +:global(.dark) .user-login-button:hover { + background: linear-gradient(135deg, #10b981 0%, #059669 100%); + border-color: rgba(34, 197, 94, 0.4); + box-shadow: + 0 8px 20px rgba(16, 185, 129, 0.3), + inset 0 1px 1px rgba(255, 255, 255, 0.1); + color: white; +} + +.user-login-button:active { + transform: translateY(-1px) scale(1); +} + /* 管理后台按钮 - 精致版本 */ .admin-button-refined { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); diff --git a/web/admin-spa/src/views/UserDashboardView.vue b/web/admin-spa/src/views/UserDashboardView.vue new file mode 100644 index 00000000..74802f77 --- /dev/null +++ b/web/admin-spa/src/views/UserDashboardView.vue @@ -0,0 +1,357 @@ + + + + + From f31f7c9385e7c9c5c0c001daa703be17e14230fb Mon Sep 17 00:00:00 2001 From: iRubbish Date: Mon, 25 Aug 2025 18:19:33 +0800 Subject: [PATCH 02/17] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=E5=8E=86?= =?UTF-8?q?=E5=8F=B2API=20Key=E8=87=AA=E5=8A=A8=E5=85=B3=E8=81=94=E5=8A=9F?= =?UTF-8?q?=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 核心功能: - AD用户登录时自动关联已存在的历史API Key - 关联规则: API Key name字段与用户displayName完全匹配 - 自动设置owner字段完成关联,避免用户重新创建Key 实现逻辑: 1. 优先匹配owner字段(已关联的Key) 2. 如无owner匹配,尝试匹配name与displayName 3. 找到匹配历史Key后,自动设置owner完成关联 技术特性: - 详细日志记录关联过程 - 支持JWT token中完整用户信息传递 - Redis数据自动更新owner字段 - 系统迁移兼容性处理 测试验证: - 创建测试历史Key验证自动关联 - JWT token正确解析displayName字段 - Redis数据正确更新owner关联关系 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- package-lock.json | 73 +++++++++++++++++++++++++++++++--------- package.json | 1 + src/routes/ldapRoutes.js | 47 ++++++++++++++++++++++++-- 3 files changed, 103 insertions(+), 18 deletions(-) diff --git a/package-lock.json b/package-lock.json index 72fbe9fc..c428539e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,6 +27,7 @@ "jsonwebtoken": "^9.0.2", "ldapjs": "^3.0.7", "morgan": "^1.10.0", + "node-fetch": "^2.7.0", "ora": "^5.4.1", "rate-limiter-flexible": "^5.0.5", "socks-proxy-agent": "^8.0.2", @@ -4094,7 +4095,7 @@ }, "node_modules/data-uri-to-buffer": { "version": "4.0.1", - "resolved": "https://registry.npmmirror.com/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", "license": "MIT", "engines": { @@ -4868,7 +4869,7 @@ }, "node_modules/fetch-blob": { "version": "3.2.0", - "resolved": "https://registry.npmmirror.com/fetch-blob/-/fetch-blob-3.2.0.tgz", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", "funding": [ { @@ -5049,7 +5050,7 @@ }, "node_modules/formdata-polyfill": { "version": "4.0.10", - "resolved": "https://registry.npmmirror.com/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", "license": "MIT", "dependencies": { @@ -5138,6 +5139,24 @@ "node": ">=18" } }, + "node_modules/gaxios/node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, "node_modules/gcp-metadata": { "version": "7.0.1", "resolved": "https://registry.npmmirror.com/gcp-metadata/-/gcp-metadata-7.0.1.tgz", @@ -7143,7 +7162,7 @@ }, "node_modules/node-domexception": { "version": "1.0.0", - "resolved": "https://registry.npmmirror.com/node-domexception/-/node-domexception-1.0.0.tgz", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", "deprecated": "Use your platform's native DOMException instead", "funding": [ @@ -7162,21 +7181,23 @@ } }, "node_modules/node-fetch": { - "version": "3.3.2", - "resolved": "https://registry.npmmirror.com/node-fetch/-/node-fetch-3.3.2.tgz", - "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", "license": "MIT", "dependencies": { - "data-uri-to-buffer": "^4.0.0", - "fetch-blob": "^3.1.4", - "formdata-polyfill": "^4.0.10" + "whatwg-url": "^5.0.0" }, "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + "node": "4.x || >=6.0.0" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/node-fetch" + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } } }, "node_modules/node-int64": { @@ -8855,6 +8876,12 @@ "nodetouch": "bin/nodetouch.js" } }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, "node_modules/triple-beam": { "version": "1.4.1", "resolved": "https://registry.npmmirror.com/triple-beam/-/triple-beam-1.4.1.tgz", @@ -9096,13 +9123,29 @@ }, "node_modules/web-streams-polyfill": { "version": "3.3.3", - "resolved": "https://registry.npmmirror.com/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", "license": "MIT", "engines": { "node": ">= 8" } }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmmirror.com/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index bdfaf284..3c08d7da 100644 --- a/package.json +++ b/package.json @@ -66,6 +66,7 @@ "jsonwebtoken": "^9.0.2", "ldapjs": "^3.0.7", "morgan": "^1.10.0", + "node-fetch": "^2.7.0", "ora": "^5.4.1", "rate-limiter-flexible": "^5.0.5", "socks-proxy-agent": "^8.0.2", diff --git a/src/routes/ldapRoutes.js b/src/routes/ldapRoutes.js index 4608bb8d..133a558b 100644 --- a/src/routes/ldapRoutes.js +++ b/src/routes/ldapRoutes.js @@ -384,24 +384,41 @@ const authenticateUser = (req, res, next) => { /** * 获取用户的API Keys + * + * 自动关联逻辑说明: + * 系统迁移过程中存在历史API Key,这些Key是在AD集成前手动创建的 + * 创建时使用的name字段恰好与AD用户的displayName一致 + * 例如: AD用户displayName为"测试用户",对应的API Key name也是"测试用户" + * 为了避免用户重复创建Key,系统会自动关联这些历史Key + * 关联规则: + * 1. 优先匹配owner字段(新建的Key) + * 2. 如果没有owner匹配,则尝试匹配name字段与displayName + * 3. 找到匹配的历史Key后,自动将owner设置为当前用户,完成关联 */ router.get('/user/api-keys', authenticateUser, async (req, res) => { try { const redis = require('../models/redis') - const { username } = req.user + const { username, displayName } = req.user - logger.info(`获取用户API Keys: ${username}`) + logger.info(`获取用户API Keys: ${username}, displayName: ${displayName}`) + logger.info(`用户完整信息: ${JSON.stringify(req.user)}`) // 获取所有API Keys const allKeysPattern = 'api_key:*' const keys = await redis.getClient().keys(allKeysPattern) const userKeys = [] + let foundHistoricalKey = false // 筛选属于该用户的API Keys for (const key of keys) { const apiKeyData = await redis.getClient().hgetall(key) - if (apiKeyData && apiKeyData.owner === username) { + if (!apiKeyData) { + continue + } + + // 规则1: 直接owner匹配(已关联的Key) + if (apiKeyData.owner === username) { userKeys.push({ id: apiKeyData.id, name: apiKeyData.name || '未命名', @@ -412,6 +429,30 @@ router.get('/user/api-keys', authenticateUser, async (req, res) => { status: apiKeyData.status || 'active' }) } + // 规则2: 历史Key自动关联(name字段匹配displayName且无owner) + else if (displayName && apiKeyData.name === displayName && !apiKeyData.owner) { + logger.info(`发现历史API Key需要关联: name=${apiKeyData.name}, displayName=${displayName}`) + + // 自动关联: 设置owner为当前用户 + await redis.getClient().hset(key, 'owner', username) + foundHistoricalKey = true + + userKeys.push({ + id: apiKeyData.id, + name: apiKeyData.name || '未命名', + key: apiKeyData.key, + limit: parseInt(apiKeyData.limit) || 1000000, + used: parseInt(apiKeyData.used) || 0, + createdAt: apiKeyData.createdAt, + status: apiKeyData.status || 'active' + }) + + logger.info(`历史API Key关联成功: ${apiKeyData.id} -> ${username}`) + } + } + + if (foundHistoricalKey) { + logger.info(`用户 ${username} 自动关联了历史API Key`) } res.json({ From f4f88091c16ce31fc1f8c2e7634b946ae6bbc78f Mon Sep 17 00:00:00 2001 From: sczheng189 <724100151@qq.com> Date: Mon, 25 Aug 2025 15:17:31 +0800 Subject: [PATCH 03/17] =?UTF-8?q?feat:=20=E6=89=A9=E5=B1=95=E7=86=94?= =?UTF-8?q?=E6=96=AD=E6=9C=BA=E5=88=B6=E6=94=AF=E6=8C=81=E6=89=80=E6=9C=89?= =?UTF-8?q?5xx=E9=94=99=E8=AF=AF=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 扩展错误检测从单一500错误到所有5xx错误码(500-599) - 新增temp_error状态,连续3次5xx错误触发临时熔断 - 支持流式和非流式请求的统一5xx错误处理 - 添加定时清理机制,60分钟后自动恢复temp_error状态 - 完善错误计数和清理逻辑,提高系统可靠性 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- src/app.js | 3 +- src/services/claudeAccountService.js | 139 +++++++++++++++++++++++++ src/services/claudeRelayService.js | 51 ++++++++- src/services/unifiedClaudeScheduler.js | 11 +- 4 files changed, 200 insertions(+), 4 deletions(-) diff --git a/src/app.js b/src/app.js index f80347c8..a1f8020b 100644 --- a/src/app.js +++ b/src/app.js @@ -507,7 +507,8 @@ class Application { const [expiredKeys, errorAccounts] = await Promise.all([ apiKeyService.cleanupExpiredKeys(), - claudeAccountService.cleanupErrorAccounts() + claudeAccountService.cleanupErrorAccounts(), + claudeAccountService.cleanupTempErrorAccounts() // 新增:清理临时错误账户 ]) await redis.cleanup() diff --git a/src/services/claudeAccountService.js b/src/services/claudeAccountService.js index ffd390bd..97748393 100644 --- a/src/services/claudeAccountService.js +++ b/src/services/claudeAccountService.js @@ -1734,6 +1734,145 @@ class ClaudeAccountService { throw error } } + + // 🧹 清理临时错误账户 + async cleanupTempErrorAccounts() { + try { + const accounts = await redis.getAllClaudeAccounts() + let cleanedCount = 0 + const TEMP_ERROR_RECOVERY_MINUTES = 60 // 临时错误状态恢复时间(分钟) + + 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 + } + } } module.exports = new ClaudeAccountService() diff --git a/src/services/claudeRelayService.js b/src/services/claudeRelayService.js index 49a9192a..0ca60f1b 100644 --- a/src/services/claudeRelayService.js +++ b/src/services/claudeRelayService.js @@ -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 >= 3) { + 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,9 @@ class ClaudeRelayService { ) } } else if (response.statusCode === 200 || response.statusCode === 201) { - // 请求成功,清除401错误计数 + // 请求成功,清除401和500错误计数 await this.clearUnauthorizedErrors(accountId) + await claudeAccountService.clearInternalErrors(accountId) // 如果请求成功,检查并移除限流状态 const isRateLimited = await unifiedClaudeScheduler.isAccountRateLimited( accountId, @@ -883,6 +901,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 >= 3) { + 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 = '' @@ -1162,6 +1208,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/unifiedClaudeScheduler.js b/src/services/unifiedClaudeScheduler.js index 287bb465..4e6535bd 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 } // 检查是否可调度 From 5366dc70e143fe618d383a5a08d452d2e39e5049 Mon Sep 17 00:00:00 2001 From: sczheng189 <724100151@qq.com> Date: Mon, 25 Aug 2025 11:44:26 +0800 Subject: [PATCH 04/17] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E9=87=8D?= =?UTF-8?q?=E7=BD=AE=E8=B4=A6=E5=8F=B7=E7=8A=B6=E6=80=81=E5=90=8E=E4=BB=8D?= =?UTF-8?q?=E8=A2=AB=E8=AE=A4=E4=B8=BA=E4=B8=8D=E5=8F=AF=E7=94=A8=E7=9A=84?= =?UTF-8?q?bug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 问题描述: - 重置账号状态时虽然正确设置了 schedulable: 'true' - 但在账号选择逻辑中缺少对 schedulable !== 'false' 的检查 - 导致重置后的账号仍被认为不可用 修复内容: - selectAvailableAccount: 在 activeAccounts 过滤中添加 schedulable 检查 - selectAccountForApiKey: 在绑定账户和 sharedAccounts 过滤中添加 schedulable 检查 - 确保重置状态后的账号能正确被识别为可用 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- src/services/claudeAccountService.js | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/services/claudeAccountService.js b/src/services/claudeAccountService.js index ffd390bd..6a9baacc 100644 --- a/src/services/claudeAccountService.js +++ b/src/services/claudeAccountService.js @@ -630,7 +630,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 账号 @@ -717,7 +720,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}` ) @@ -736,6 +744,7 @@ class ClaudeAccountService { (account) => account.isActive === 'true' && account.status !== 'error' && + account.schedulable !== 'false' && (account.accountType === 'shared' || !account.accountType) // 兼容旧数据 ) From e69ab2161d01f0a5df5869c29d543cd7bc54896d Mon Sep 17 00:00:00 2001 From: sczheng189 <724100151@qq.com> Date: Mon, 25 Aug 2025 18:58:08 +0800 Subject: [PATCH 05/17] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=E8=B4=A6?= =?UTF-8?q?=E6=88=B7=E5=A4=9A=E5=88=86=E7=BB=84=E8=B0=83=E5=BA=A6=E5=8A=9F?= =?UTF-8?q?=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加账户分组管理功能,支持创建、编辑、删除分组 - 实现基于分组的账户调度逻辑 - 添加分组权重和优先级支持 - 提供测试脚本验证多分组调度功能 - 修复代码格式化问题(统一使用LF换行符) 🤖 Generated with Claude Code Co-Authored-By: Claude --- scripts/test-group-scheduling.js | 5 +- scripts/test-multi-group.js | 379 +++++++++++++++++++++++ src/routes/admin.js | 197 +++++++++--- src/services/accountGroupService.js | 17 +- src/services/claudeAccountService.js | 7 + web/admin-spa/package-lock.json | 1 - web/admin-spa/src/views/AccountsView.vue | 65 ++-- 7 files changed, 567 insertions(+), 104 deletions(-) create mode 100644 scripts/test-multi-group.js diff --git a/scripts/test-group-scheduling.js b/scripts/test-group-scheduling.js index 4312ec65..e22a20e1 100644 --- a/scripts/test-group-scheduling.js +++ b/scripts/test-group-scheduling.js @@ -436,8 +436,9 @@ async function test8_groupMemberManagement() { const account = testData.accounts.find((a) => a.type === 'claude') // 获取账户所属分组 - const accountGroup = await accountGroupService.getAccountGroup(account.id) - if (accountGroup && accountGroup.id === claudeGroup.id) { + const accountGroups = await accountGroupService.getAccountGroup(account.id) + const hasTargetGroup = accountGroups.some((group) => group.id === claudeGroup.id) + if (hasTargetGroup) { log('✅ 账户分组查询验证通过', 'success') } else { throw new Error('账户分组查询结果不正确') diff --git a/scripts/test-multi-group.js b/scripts/test-multi-group.js new file mode 100644 index 00000000..484bc714 --- /dev/null +++ b/scripts/test-multi-group.js @@ -0,0 +1,379 @@ +/** + * 多分组功能测试脚本 + * 测试一个账户可以属于多个分组的功能 + */ + +require('dotenv').config() +const redis = require('../src/models/redis') +const accountGroupService = require('../src/services/accountGroupService') +const claudeAccountService = require('../src/services/claudeAccountService') + +// 测试配置 +const TEST_PREFIX = 'multi_group_test_' +const CLEANUP_ON_FINISH = true + +// 测试数据存储 +const testData = { + groups: [], + accounts: [] +} + +// 颜色输出 +const colors = { + green: '\x1b[32m', + red: '\x1b[31m', + yellow: '\x1b[33m', + blue: '\x1b[34m', + reset: '\x1b[0m' +} + +function log(message, type = 'info') { + const color = + { + success: colors.green, + error: colors.red, + warning: colors.yellow, + info: colors.blue + }[type] || colors.reset + + console.log(`${color}${message}${colors.reset}`) +} + +async function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +// 清理测试数据 +async function cleanup() { + log('\n🧹 清理测试数据...', 'info') + + // 删除测试账户 + for (const account of testData.accounts) { + try { + await claudeAccountService.deleteAccount(account.id) + log(`✅ 删除测试账户: ${account.name}`, 'success') + } catch (error) { + log(`❌ 删除账户失败: ${error.message}`, 'error') + } + } + + // 删除测试分组 + for (const group of testData.groups) { + try { + // 先移除所有成员 + const members = await accountGroupService.getGroupMembers(group.id) + for (const memberId of members) { + await accountGroupService.removeAccountFromGroup(memberId, group.id) + } + + await accountGroupService.deleteGroup(group.id) + log(`✅ 删除测试分组: ${group.name}`, 'success') + } catch (error) { + log(`❌ 删除分组失败: ${error.message}`, 'error') + } + } +} + +// 测试1: 创建测试数据 +async function test1_createTestData() { + log('\n📝 测试1: 创建测试数据', 'info') + + try { + // 创建3个测试分组 + const group1 = await accountGroupService.createGroup({ + name: `${TEST_PREFIX}高优先级组`, + platform: 'claude', + description: '高优先级账户分组' + }) + testData.groups.push(group1) + log(`✅ 创建分组1: ${group1.name}`, 'success') + + const group2 = await accountGroupService.createGroup({ + name: `${TEST_PREFIX}备用组`, + platform: 'claude', + description: '备用账户分组' + }) + testData.groups.push(group2) + log(`✅ 创建分组2: ${group2.name}`, 'success') + + const group3 = await accountGroupService.createGroup({ + name: `${TEST_PREFIX}专用组`, + platform: 'claude', + description: '专用账户分组' + }) + testData.groups.push(group3) + log(`✅ 创建分组3: ${group3.name}`, 'success') + + // 创建测试账户 + const account1 = await claudeAccountService.createAccount({ + name: `${TEST_PREFIX}测试账户1`, + email: 'test1@example.com', + refreshToken: 'test_refresh_token_1', + accountType: 'group' + }) + testData.accounts.push(account1) + log(`✅ 创建测试账户1: ${account1.name}`, 'success') + + const account2 = await claudeAccountService.createAccount({ + name: `${TEST_PREFIX}测试账户2`, + email: 'test2@example.com', + refreshToken: 'test_refresh_token_2', + accountType: 'group' + }) + testData.accounts.push(account2) + log(`✅ 创建测试账户2: ${account2.name}`, 'success') + + log(`✅ 测试数据创建完成: 3个分组, 2个账户`, 'success') + } catch (error) { + log(`❌ 测试1失败: ${error.message}`, 'error') + throw error + } +} + +// 测试2: 账户加入多个分组 +async function test2_addAccountToMultipleGroups() { + log('\n📝 测试2: 账户加入多个分组', 'info') + + try { + const [group1, group2, group3] = testData.groups + const [account1, account2] = testData.accounts + + // 账户1加入分组1和分组2 + await accountGroupService.addAccountToGroup(account1.id, group1.id, 'claude') + log(`✅ 账户1加入分组1: ${group1.name}`, 'success') + + await accountGroupService.addAccountToGroup(account1.id, group2.id, 'claude') + log(`✅ 账户1加入分组2: ${group2.name}`, 'success') + + // 账户2加入分组2和分组3 + await accountGroupService.addAccountToGroup(account2.id, group2.id, 'claude') + log(`✅ 账户2加入分组2: ${group2.name}`, 'success') + + await accountGroupService.addAccountToGroup(account2.id, group3.id, 'claude') + log(`✅ 账户2加入分组3: ${group3.name}`, 'success') + + log(`✅ 多分组关系建立完成`, 'success') + } catch (error) { + log(`❌ 测试2失败: ${error.message}`, 'error') + throw error + } +} + +// 测试3: 验证多分组关系 +async function test3_verifyMultiGroupRelationships() { + log('\n📝 测试3: 验证多分组关系', 'info') + + try { + const [group1, group2, group3] = testData.groups + const [account1, account2] = testData.accounts + + // 验证账户1的分组关系 + const account1Groups = await accountGroupService.getAccountGroup(account1.id) + log(`📊 账户1所属分组数量: ${account1Groups.length}`, 'info') + + const account1GroupNames = account1Groups.map((g) => g.name).sort() + const expectedAccount1Groups = [group1.name, group2.name].sort() + + if (JSON.stringify(account1GroupNames) === JSON.stringify(expectedAccount1Groups)) { + log(`✅ 账户1分组关系正确: [${account1GroupNames.join(', ')}]`, 'success') + } else { + throw new Error( + `账户1分组关系错误,期望: [${expectedAccount1Groups.join(', ')}], 实际: [${account1GroupNames.join(', ')}]` + ) + } + + // 验证账户2的分组关系 + const account2Groups = await accountGroupService.getAccountGroup(account2.id) + log(`📊 账户2所属分组数量: ${account2Groups.length}`, 'info') + + const account2GroupNames = account2Groups.map((g) => g.name).sort() + const expectedAccount2Groups = [group2.name, group3.name].sort() + + if (JSON.stringify(account2GroupNames) === JSON.stringify(expectedAccount2Groups)) { + log(`✅ 账户2分组关系正确: [${account2GroupNames.join(', ')}]`, 'success') + } else { + throw new Error( + `账户2分组关系错误,期望: [${expectedAccount2Groups.join(', ')}], 实际: [${account2GroupNames.join(', ')}]` + ) + } + + log(`✅ 多分组关系验证通过`, 'success') + } catch (error) { + log(`❌ 测试3失败: ${error.message}`, 'error') + throw error + } +} + +// 测试4: 验证分组成员关系 +async function test4_verifyGroupMemberships() { + log('\n📝 测试4: 验证分组成员关系', 'info') + + try { + const [group1, group2, group3] = testData.groups + const [account1, account2] = testData.accounts + + // 验证分组1的成员 + const group1Members = await accountGroupService.getGroupMembers(group1.id) + if (group1Members.includes(account1.id) && group1Members.length === 1) { + log(`✅ 分组1成员正确: [${account1.name}]`, 'success') + } else { + throw new Error(`分组1成员错误,期望: [${account1.id}], 实际: [${group1Members.join(', ')}]`) + } + + // 验证分组2的成员(应该包含两个账户) + const group2Members = await accountGroupService.getGroupMembers(group2.id) + const expectedGroup2Members = [account1.id, account2.id].sort() + const actualGroup2Members = group2Members.sort() + + if (JSON.stringify(actualGroup2Members) === JSON.stringify(expectedGroup2Members)) { + log(`✅ 分组2成员正确: [${account1.name}, ${account2.name}]`, 'success') + } else { + throw new Error( + `分组2成员错误,期望: [${expectedGroup2Members.join(', ')}], 实际: [${actualGroup2Members.join(', ')}]` + ) + } + + // 验证分组3的成员 + const group3Members = await accountGroupService.getGroupMembers(group3.id) + if (group3Members.includes(account2.id) && group3Members.length === 1) { + log(`✅ 分组3成员正确: [${account2.name}]`, 'success') + } else { + throw new Error(`分组3成员错误,期望: [${account2.id}], 实际: [${group3Members.join(', ')}]`) + } + + log(`✅ 分组成员关系验证通过`, 'success') + } catch (error) { + log(`❌ 测试4失败: ${error.message}`, 'error') + throw error + } +} + +// 测试5: 从部分分组中移除账户 +async function test5_removeFromPartialGroups() { + log('\n📝 测试5: 从部分分组中移除账户', 'info') + + try { + const [group1, group2] = testData.groups + const [account1] = testData.accounts + + // 将账户1从分组1中移除(但仍在分组2中) + await accountGroupService.removeAccountFromGroup(account1.id, group1.id) + log(`✅ 从分组1中移除账户1`, 'success') + + // 验证账户1现在只属于分组2 + const account1Groups = await accountGroupService.getAccountGroup(account1.id) + if (account1Groups.length === 1 && account1Groups[0].id === group2.id) { + log(`✅ 账户1现在只属于分组2: ${account1Groups[0].name}`, 'success') + } else { + const groupNames = account1Groups.map((g) => g.name) + throw new Error(`账户1分组状态错误,期望只在分组2中,实际: [${groupNames.join(', ')}]`) + } + + // 验证分组1现在为空 + const group1Members = await accountGroupService.getGroupMembers(group1.id) + if (group1Members.length === 0) { + log(`✅ 分组1现在为空`, 'success') + } else { + throw new Error(`分组1应该为空,但还有成员: [${group1Members.join(', ')}]`) + } + + // 验证分组2仍有两个成员 + const group2Members = await accountGroupService.getGroupMembers(group2.id) + if (group2Members.length === 2) { + log(`✅ 分组2仍有两个成员`, 'success') + } else { + throw new Error(`分组2应该有2个成员,实际: ${group2Members.length}个`) + } + + log(`✅ 部分移除测试通过`, 'success') + } catch (error) { + log(`❌ 测试5失败: ${error.message}`, 'error') + throw error + } +} + +// 测试6: 账户完全移除时的分组清理 +async function test6_accountDeletionGroupCleanup() { + log('\n📝 测试6: 账户删除时的分组清理', 'info') + + try { + const [, group2, group3] = testData.groups // 跳过第一个元素 + const [account1, account2] = testData.accounts + + // 记录删除前的状态 + const beforeGroup2Members = await accountGroupService.getGroupMembers(group2.id) + const beforeGroup3Members = await accountGroupService.getGroupMembers(group3.id) + + log(`📊 删除前分组2成员数: ${beforeGroup2Members.length}`, 'info') + log(`📊 删除前分组3成员数: ${beforeGroup3Members.length}`, 'info') + + // 删除账户2(这应该会触发从所有分组中移除的逻辑) + await claudeAccountService.deleteAccount(account2.id) + log(`✅ 删除账户2: ${account2.name}`, 'success') + + // 从测试数据中移除,避免cleanup时重复删除 + testData.accounts = testData.accounts.filter((acc) => acc.id !== account2.id) + + // 等待一下确保删除操作完成 + await sleep(500) + + // 验证分组2现在只有账户1 + const afterGroup2Members = await accountGroupService.getGroupMembers(group2.id) + if (afterGroup2Members.length === 1 && afterGroup2Members[0] === account1.id) { + log(`✅ 分组2现在只有账户1`, 'success') + } else { + throw new Error(`分组2成员状态错误,期望只有账户1,实际: [${afterGroup2Members.join(', ')}]`) + } + + // 验证分组3现在为空 + const afterGroup3Members = await accountGroupService.getGroupMembers(group3.id) + if (afterGroup3Members.length === 0) { + log(`✅ 分组3现在为空`, 'success') + } else { + throw new Error(`分组3应该为空,但还有成员: [${afterGroup3Members.join(', ')}]`) + } + + log(`✅ 账户删除的分组清理测试通过`, 'success') + } catch (error) { + log(`❌ 测试6失败: ${error.message}`, 'error') + throw error + } +} + +// 主测试函数 +async function runTests() { + log('\n🚀 开始多分组功能测试\n', 'info') + + try { + // 连接Redis + await redis.connect() + log('✅ Redis连接成功', 'success') + + // 执行测试 + await test1_createTestData() + await test2_addAccountToMultipleGroups() + await test3_verifyMultiGroupRelationships() + await test4_verifyGroupMemberships() + await test5_removeFromPartialGroups() + await test6_accountDeletionGroupCleanup() + + log('\n🎉 所有测试通过!多分组功能工作正常', 'success') + } catch (error) { + log(`\n❌ 测试失败: ${error.message}`, 'error') + console.error(error) + } finally { + // 清理测试数据 + if (CLEANUP_ON_FINISH) { + await cleanup() + } else { + log('\n⚠️ 测试数据未清理,请手动清理', 'warning') + } + + // 关闭Redis连接 + await redis.disconnect() + process.exit(0) + } +} + +// 运行测试 +runTests() diff --git a/src/routes/admin.js b/src/routes/admin.js index 86556904..368d0a33 100644 --- a/src/routes/admin.js +++ b/src/routes/admin.js @@ -1449,11 +1449,14 @@ router.get('/claude-accounts', authenticateAdmin, async (req, res) => { if (groupId && groupId !== 'all') { if (groupId === 'ungrouped') { // 筛选未分组账户 - accounts = accounts.filter((account) => !account.groupInfo) + accounts = accounts.filter( + (account) => !account.groupInfos || account.groupInfos.length === 0 + ) } else { // 筛选特定分组的账户 accounts = accounts.filter( - (account) => account.groupInfo && account.groupInfo.id === groupId + (account) => + account.groupInfos && account.groupInfos.some((group) => group.id === groupId) ) } } @@ -1463,8 +1466,11 @@ router.get('/claude-accounts', authenticateAdmin, async (req, res) => { accounts.map(async (account) => { try { const usageStats = await redis.getAccountUsageStats(account.id) + const groupInfos = await accountGroupService.getAccountGroup(account.id) + return { ...account, + groupInfos, usage: { daily: usageStats.daily, total: usageStats.total, @@ -1474,12 +1480,30 @@ router.get('/claude-accounts', authenticateAdmin, async (req, res) => { } catch (statsError) { logger.warn(`⚠️ Failed to get usage stats for account ${account.id}:`, statsError.message) // 如果获取统计失败,返回空统计 - return { - ...account, - usage: { - daily: { tokens: 0, requests: 0, allTokens: 0 }, - total: { tokens: 0, requests: 0, allTokens: 0 }, - averages: { rpm: 0, tpm: 0 } + try { + const groupInfos = await accountGroupService.getAccountGroup(account.id) + return { + ...account, + groupInfos, + usage: { + daily: { tokens: 0, requests: 0, allTokens: 0 }, + total: { tokens: 0, requests: 0, allTokens: 0 }, + averages: { rpm: 0, tpm: 0 } + } + } + } catch (groupError) { + logger.warn( + `⚠️ Failed to get group info for account ${account.id}:`, + groupError.message + ) + return { + ...account, + groupInfos: [], + usage: { + daily: { tokens: 0, requests: 0, allTokens: 0 }, + total: { tokens: 0, requests: 0, allTokens: 0 }, + averages: { rpm: 0, tpm: 0 } + } } } } @@ -1596,10 +1620,10 @@ router.put('/claude-accounts/:accountId', authenticateAdmin, async (req, res) => // 处理分组的变更 if (updates.accountType !== undefined) { - // 如果之前是分组类型,需要从原分组中移除 + // 如果之前是分组类型,需要从所有分组中移除 if (currentAccount.accountType === 'group') { - const oldGroup = await accountGroupService.getAccountGroup(accountId) - if (oldGroup) { + const oldGroups = await accountGroupService.getAccountGroup(accountId) + for (const oldGroup of oldGroups) { await accountGroupService.removeAccountFromGroup(accountId, oldGroup.id) } } @@ -1631,8 +1655,8 @@ router.delete('/claude-accounts/:accountId', authenticateAdmin, async (req, res) // 获取账户信息以检查是否在分组中 const account = await claudeAccountService.getAccount(accountId) if (account && account.accountType === 'group') { - const group = await accountGroupService.getAccountGroup(accountId) - if (group) { + const groups = await accountGroupService.getAccountGroup(accountId) + for (const group of groups) { await accountGroupService.removeAccountFromGroup(accountId, group.id) } } @@ -1781,11 +1805,14 @@ router.get('/claude-console-accounts', authenticateAdmin, async (req, res) => { if (groupId && groupId !== 'all') { if (groupId === 'ungrouped') { // 筛选未分组账户 - accounts = accounts.filter((account) => !account.groupInfo) + accounts = accounts.filter( + (account) => !account.groupInfos || account.groupInfos.length === 0 + ) } else { // 筛选特定分组的账户 accounts = accounts.filter( - (account) => account.groupInfo && account.groupInfo.id === groupId + (account) => + account.groupInfos && account.groupInfos.some((group) => group.id === groupId) ) } } @@ -1795,8 +1822,11 @@ router.get('/claude-console-accounts', authenticateAdmin, async (req, res) => { accounts.map(async (account) => { try { const usageStats = await redis.getAccountUsageStats(account.id) + const groupInfos = await accountGroupService.getAccountGroup(account.id) + return { ...account, + groupInfos, usage: { daily: usageStats.daily, total: usageStats.total, @@ -1808,12 +1838,30 @@ router.get('/claude-console-accounts', authenticateAdmin, async (req, res) => { `⚠️ Failed to get usage stats for Claude Console account ${account.id}:`, statsError.message ) - return { - ...account, - usage: { - daily: { tokens: 0, requests: 0, allTokens: 0 }, - total: { tokens: 0, requests: 0, allTokens: 0 }, - averages: { rpm: 0, tpm: 0 } + try { + const groupInfos = await accountGroupService.getAccountGroup(account.id) + return { + ...account, + groupInfos, + usage: { + daily: { tokens: 0, requests: 0, allTokens: 0 }, + total: { tokens: 0, requests: 0, allTokens: 0 }, + averages: { rpm: 0, tpm: 0 } + } + } + } catch (groupError) { + logger.warn( + `⚠️ Failed to get group info for Claude Console account ${account.id}:`, + groupError.message + ) + return { + ...account, + groupInfos: [], + usage: { + daily: { tokens: 0, requests: 0, allTokens: 0 }, + total: { tokens: 0, requests: 0, allTokens: 0 }, + averages: { rpm: 0, tpm: 0 } + } } } } @@ -1927,10 +1975,10 @@ router.put('/claude-console-accounts/:accountId', authenticateAdmin, async (req, // 处理分组的变更 if (updates.accountType !== undefined) { - // 如果之前是分组类型,需要从原分组中移除 + // 如果之前是分组类型,需要从所有分组中移除 if (currentAccount.accountType === 'group') { - const oldGroup = await accountGroupService.getAccountGroup(accountId) - if (oldGroup) { + const oldGroups = await accountGroupService.getAccountGroup(accountId) + for (const oldGroup of oldGroups) { await accountGroupService.removeAccountFromGroup(accountId, oldGroup.id) } } @@ -1961,8 +2009,8 @@ router.delete('/claude-console-accounts/:accountId', authenticateAdmin, async (r // 获取账户信息以检查是否在分组中 const account = await claudeConsoleAccountService.getAccount(accountId) if (account && account.accountType === 'group') { - const group = await accountGroupService.getAccountGroup(accountId) - if (group) { + const groups = await accountGroupService.getAccountGroup(accountId) + for (const group of groups) { await accountGroupService.removeAccountFromGroup(accountId, group.id) } } @@ -2071,11 +2119,14 @@ router.get('/bedrock-accounts', authenticateAdmin, async (req, res) => { if (groupId && groupId !== 'all') { if (groupId === 'ungrouped') { // 筛选未分组账户 - accounts = accounts.filter((account) => !account.groupInfo) + accounts = accounts.filter( + (account) => !account.groupInfos || account.groupInfos.length === 0 + ) } else { // 筛选特定分组的账户 accounts = accounts.filter( - (account) => account.groupInfo && account.groupInfo.id === groupId + (account) => + account.groupInfos && account.groupInfos.some((group) => group.id === groupId) ) } } @@ -2085,8 +2136,11 @@ router.get('/bedrock-accounts', authenticateAdmin, async (req, res) => { accounts.map(async (account) => { try { const usageStats = await redis.getAccountUsageStats(account.id) + const groupInfos = await accountGroupService.getAccountGroup(account.id) + return { ...account, + groupInfos, usage: { daily: usageStats.daily, total: usageStats.total, @@ -2098,12 +2152,30 @@ router.get('/bedrock-accounts', authenticateAdmin, async (req, res) => { `⚠️ Failed to get usage stats for Bedrock account ${account.id}:`, statsError.message ) - return { - ...account, - usage: { - daily: { tokens: 0, requests: 0, allTokens: 0 }, - total: { tokens: 0, requests: 0, allTokens: 0 }, - averages: { rpm: 0, tpm: 0 } + try { + const groupInfos = await accountGroupService.getAccountGroup(account.id) + return { + ...account, + groupInfos, + usage: { + daily: { tokens: 0, requests: 0, allTokens: 0 }, + total: { tokens: 0, requests: 0, allTokens: 0 }, + averages: { rpm: 0, tpm: 0 } + } + } + } catch (groupError) { + logger.warn( + `⚠️ Failed to get group info for account ${account.id}:`, + groupError.message + ) + return { + ...account, + groupInfos: [], + usage: { + daily: { tokens: 0, requests: 0, allTokens: 0 }, + total: { tokens: 0, requests: 0, allTokens: 0 }, + averages: { rpm: 0, tpm: 0 } + } } } } @@ -2494,11 +2566,14 @@ router.get('/gemini-accounts', authenticateAdmin, async (req, res) => { if (groupId && groupId !== 'all') { if (groupId === 'ungrouped') { // 筛选未分组账户 - accounts = accounts.filter((account) => !account.groupInfo) + accounts = accounts.filter( + (account) => !account.groupInfos || account.groupInfos.length === 0 + ) } else { // 筛选特定分组的账户 accounts = accounts.filter( - (account) => account.groupInfo && account.groupInfo.id === groupId + (account) => + account.groupInfos && account.groupInfos.some((group) => group.id === groupId) ) } } @@ -2508,8 +2583,11 @@ router.get('/gemini-accounts', authenticateAdmin, async (req, res) => { accounts.map(async (account) => { try { const usageStats = await redis.getAccountUsageStats(account.id) + const groupInfos = await accountGroupService.getAccountGroup(account.id) + return { ...account, + groupInfos, usage: { daily: usageStats.daily, total: usageStats.total, @@ -2522,12 +2600,30 @@ router.get('/gemini-accounts', authenticateAdmin, async (req, res) => { statsError.message ) // 如果获取统计失败,返回空统计 - return { - ...account, - usage: { - daily: { tokens: 0, requests: 0, allTokens: 0 }, - total: { tokens: 0, requests: 0, allTokens: 0 }, - averages: { rpm: 0, tpm: 0 } + try { + const groupInfos = await accountGroupService.getAccountGroup(account.id) + return { + ...account, + groupInfos, + usage: { + daily: { tokens: 0, requests: 0, allTokens: 0 }, + total: { tokens: 0, requests: 0, allTokens: 0 }, + averages: { rpm: 0, tpm: 0 } + } + } + } catch (groupError) { + logger.warn( + `⚠️ Failed to get group info for account ${account.id}:`, + groupError.message + ) + return { + ...account, + groupInfos: [], + usage: { + daily: { tokens: 0, requests: 0, allTokens: 0 }, + total: { tokens: 0, requests: 0, allTokens: 0 }, + averages: { rpm: 0, tpm: 0 } + } } } } @@ -2607,10 +2703,10 @@ router.put('/gemini-accounts/:accountId', authenticateAdmin, async (req, res) => // 处理分组的变更 if (updates.accountType !== undefined) { - // 如果之前是分组类型,需要从原分组中移除 + // 如果之前是分组类型,需要从所有分组中移除 if (currentAccount.accountType === 'group') { - const oldGroup = await accountGroupService.getAccountGroup(accountId) - if (oldGroup) { + const oldGroups = await accountGroupService.getAccountGroup(accountId) + for (const oldGroup of oldGroups) { await accountGroupService.removeAccountFromGroup(accountId, oldGroup.id) } } @@ -2638,8 +2734,8 @@ router.delete('/gemini-accounts/:accountId', authenticateAdmin, async (req, res) // 获取账户信息以检查是否在分组中 const account = await geminiAccountService.getAccount(accountId) if (account && account.accountType === 'group') { - const group = await accountGroupService.getAccountGroup(accountId) - if (group) { + const groups = await accountGroupService.getAccountGroup(accountId) + for (const group of groups) { await accountGroupService.removeAccountFromGroup(accountId, group.id) } } @@ -4977,11 +5073,14 @@ router.get('/openai-accounts', authenticateAdmin, async (req, res) => { if (groupId && groupId !== 'all') { if (groupId === 'ungrouped') { // 筛选未分组账户 - accounts = accounts.filter((account) => !account.groupInfo) + accounts = accounts.filter( + (account) => !account.groupInfos || account.groupInfos.length === 0 + ) } else { // 筛选特定分组的账户 accounts = accounts.filter( - (account) => account.groupInfo && account.groupInfo.id === groupId + (account) => + account.groupInfos && account.groupInfos.some((group) => group.id === groupId) ) } } diff --git a/src/services/accountGroupService.js b/src/services/accountGroupService.js index 078ba5b6..d2061266 100644 --- a/src/services/accountGroupService.js +++ b/src/services/accountGroupService.js @@ -328,25 +328,32 @@ class AccountGroupService { } /** - * 根据账户ID获取其所属的分组 + * 根据账户ID获取其所属的所有分组 * @param {string} accountId - 账户ID - * @returns {Object|null} 分组信息 + * @returns {Array} 分组信息数组 */ async getAccountGroup(accountId) { try { const client = redis.getClientSafe() const allGroupIds = await client.smembers(this.GROUPS_KEY) + const memberGroups = [] for (const groupId of allGroupIds) { const isMember = await client.sismember(`${this.GROUP_MEMBERS_PREFIX}${groupId}`, accountId) if (isMember) { - return await this.getGroup(groupId) + const group = await this.getGroup(groupId) + if (group) { + memberGroups.push(group) + } } } - return null + // 按创建时间倒序排序 + memberGroups.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)) + + return memberGroups } catch (error) { - logger.error('❌ 获取账户所属分组失败:', error) + logger.error('❌ 获取账户所属分组列表失败:', error) throw error } } diff --git a/src/services/claudeAccountService.js b/src/services/claudeAccountService.js index ffd390bd..e0a0bd0f 100644 --- a/src/services/claudeAccountService.js +++ b/src/services/claudeAccountService.js @@ -609,6 +609,13 @@ class ClaudeAccountService { // 🗑️ 删除Claude账户 async deleteAccount(accountId) { try { + // 首先从所有分组中移除此账户 + const accountGroupService = require('./accountGroupService') + const groups = await accountGroupService.getAccountGroup(accountId) + for (const group of groups) { + await accountGroupService.removeAccountFromGroup(accountId, group.id) + } + const result = await redis.deleteClaudeAccount(accountId) if (result === 0) { diff --git a/web/admin-spa/package-lock.json b/web/admin-spa/package-lock.json index 6d04a5f8..30b46f1e 100644 --- a/web/admin-spa/package-lock.json +++ b/web/admin-spa/package-lock.json @@ -3723,7 +3723,6 @@ "resolved": "https://registry.npmmirror.com/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.6.14.tgz", "integrity": "sha512-pi2e/+ZygeIqntN+vC573BcW5Cve8zUB0SSAGxqpB4f96boZF4M3phPVoOFCeypwkpRYdi7+jQ5YJJUwrkGUAg==", "dev": true, - "license": "MIT", "engines": { "node": ">=14.21.3" }, diff --git a/web/admin-spa/src/views/AccountsView.vue b/web/admin-spa/src/views/AccountsView.vue index f0555b6e..1e6e4793 100644 --- a/web/admin-spa/src/views/AccountsView.vue +++ b/web/admin-spa/src/views/AccountsView.vue @@ -240,12 +240,14 @@ > 共享 + - {{ account.groupInfo.name }} + {{ group.name }}
> // 下拉选项数据 const sortOptions = ref([ @@ -978,8 +980,8 @@ const loadAccounts = async (forceReload = false) => { // 使用缓存机制加载 API Keys 和分组数据 await Promise.all([loadApiKeys(forceReload), loadAccountGroups(forceReload)]) - // 加载分组成员关系(需要在分组数据加载完成后) - await loadGroupMembers(forceReload) + // 后端账户API已经包含分组信息,不需要单独加载分组成员关系 + // await loadGroupMembers(forceReload) const [claudeData, claudeConsoleData, bedrockData, geminiData, openaiData, azureOpenaiData] = await Promise.all(requests) @@ -992,9 +994,8 @@ const loadAccounts = async (forceReload = false) => { const boundApiKeysCount = apiKeys.value.filter( (key) => key.claudeAccountId === acc.id ).length - // 检查是否属于某个分组 - const groupInfo = accountGroupMap.value.get(acc.id) || null - return { ...acc, platform: 'claude', boundApiKeysCount, groupInfo } + // 后端已经包含了groupInfos,直接使用 + return { ...acc, platform: 'claude', boundApiKeysCount } }) allAccounts.push(...claudeAccounts) } @@ -1002,8 +1003,8 @@ const loadAccounts = async (forceReload = false) => { if (claudeConsoleData.success) { const claudeConsoleAccounts = (claudeConsoleData.data || []).map((acc) => { // Claude Console账户暂时不支持直接绑定 - const groupInfo = accountGroupMap.value.get(acc.id) || null - return { ...acc, platform: 'claude-console', boundApiKeysCount: 0, groupInfo } + // 后端已经包含了groupInfos,直接使用 + return { ...acc, platform: 'claude-console', boundApiKeysCount: 0 } }) allAccounts.push(...claudeConsoleAccounts) } @@ -1011,8 +1012,8 @@ const loadAccounts = async (forceReload = false) => { if (bedrockData.success) { const bedrockAccounts = (bedrockData.data || []).map((acc) => { // Bedrock账户暂时不支持直接绑定 - const groupInfo = accountGroupMap.value.get(acc.id) || null - return { ...acc, platform: 'bedrock', boundApiKeysCount: 0, groupInfo } + // 后端已经包含了groupInfos,直接使用 + return { ...acc, platform: 'bedrock', boundApiKeysCount: 0 } }) allAccounts.push(...bedrockAccounts) } @@ -1023,8 +1024,8 @@ const loadAccounts = async (forceReload = false) => { const boundApiKeysCount = apiKeys.value.filter( (key) => key.geminiAccountId === acc.id ).length - const groupInfo = accountGroupMap.value.get(acc.id) || null - return { ...acc, platform: 'gemini', boundApiKeysCount, groupInfo } + // 后端已经包含了groupInfos,直接使用 + return { ...acc, platform: 'gemini', boundApiKeysCount } }) allAccounts.push(...geminiAccounts) } @@ -1034,8 +1035,8 @@ const loadAccounts = async (forceReload = false) => { const boundApiKeysCount = apiKeys.value.filter( (key) => key.openaiAccountId === acc.id ).length - const groupInfo = accountGroupMap.value.get(acc.id) || null - return { ...acc, platform: 'openai', boundApiKeysCount, groupInfo } + // 后端已经包含了groupInfos,直接使用 + return { ...acc, platform: 'openai', boundApiKeysCount } }) allAccounts.push(...openaiAccounts) } @@ -1131,36 +1132,6 @@ const loadAccountGroups = async (forceReload = false) => { } } -// 加载分组成员关系(缓存版本) -const loadGroupMembers = async (forceReload = false) => { - if (!forceReload && groupMembersLoaded.value) { - return // 使用缓存数据 - } - - try { - // 重置映射 - accountGroupMap.value.clear() - - // 获取所有分组的成员信息 - for (const group of accountGroups.value) { - try { - const membersResponse = await apiClient.get(`/admin/account-groups/${group.id}/members`) - if (membersResponse.success) { - const members = membersResponse.data || [] - members.forEach((member) => { - accountGroupMap.value.set(member.id, group) - }) - } - } catch (error) { - console.error(`Failed to load members for group ${group.id}:`, error) - } - } - groupMembersLoaded.value = true - } catch (error) { - console.error('Failed to load group members:', error) - } -} - // 清空缓存的函数 const clearCache = () => { apiKeysLoaded.value = false From 82f545c3b0ab59be700de583b165eff4b78a06c8 Mon Sep 17 00:00:00 2001 From: iRubbish Date: Tue, 26 Aug 2025 13:42:02 +0800 Subject: [PATCH 06/17] =?UTF-8?q?=E4=BF=9D=E5=AD=98=E5=BD=93=E5=89=8DAPI?= =?UTF-8?q?=20Key=E7=AE=A1=E7=90=86=E5=8A=9F=E8=83=BD=E7=9A=84=E4=BF=AE?= =?UTF-8?q?=E6=94=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 统一用户创建和admin创建API Key的逻辑 - 修复admin更新用户创建的API Key功能 - 用户创建API Key名称改为displayName - 默认无限制配置 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- src/routes/admin.js | 36 +- src/routes/ldapRoutes.js | 203 ++++++++---- src/services/apiKeyService.js | 27 +- test-fixed-auto-link.js | 54 +++ .../components/apikeys/EditApiKeyModal.vue | 31 +- .../src/components/user/UserApiKeysView.vue | 312 +++++++++++++----- web/admin-spa/src/views/UserDashboardView.vue | 15 +- 7 files changed, 507 insertions(+), 171 deletions(-) create mode 100644 test-fixed-auto-link.js diff --git a/src/routes/admin.js b/src/routes/admin.js index 86556904..645bcebc 100644 --- a/src/routes/admin.js +++ b/src/routes/admin.js @@ -791,6 +791,8 @@ router.put('/api-keys/:keyId', authenticateAdmin, async (req, res) => { try { const { keyId } = req.params const { + name, + description, tokenLimit, concurrencyLimit, rateLimitWindow, @@ -814,6 +816,30 @@ router.put('/api-keys/:keyId', authenticateAdmin, async (req, res) => { // 只允许更新指定字段 const updates = {} + // 处理name字段 + if (name !== undefined) { + if (name === null || name === '') { + return res.status(400).json({ error: 'Name cannot be empty' }) + } + if (typeof name !== 'string' || name.trim().length === 0) { + return res.status(400).json({ error: 'Name must be a non-empty string' }) + } + if (name.length > 100) { + return res.status(400).json({ error: 'Name must be less than 100 characters' }) + } + updates.name = name.trim() + } + + // 处理description字段 + if (description !== undefined) { + if (description && (typeof description !== 'string' || description.length > 500)) { + return res + .status(400) + .json({ error: 'Description must be a string with less than 500 characters' }) + } + updates.description = description || '' + } + if (tokenLimit !== undefined && tokenLimit !== null && tokenLimit !== '') { if (!Number.isInteger(Number(tokenLimit)) || Number(tokenLimit) < 0) { return res.status(400).json({ error: 'Token limit must be a non-negative integer' }) @@ -954,12 +980,20 @@ router.put('/api-keys/:keyId', authenticateAdmin, async (req, res) => { updates.isActive = isActive } + logger.info(`🔧 Admin updating API key: ${keyId}`, { + updates: Object.keys(updates), + updatesData: updates + }) + await apiKeyService.updateApiKey(keyId, updates) logger.success(`📝 Admin updated API key: ${keyId}`) return res.json({ success: true, message: 'API key updated successfully' }) } catch (error) { - logger.error('❌ Failed to update API key:', error) + logger.error(`❌ Failed to update API key ${req.params.keyId}:`, { + error: error.message, + stack: error.stack + }) return res.status(500).json({ error: 'Failed to update API key', message: error.message }) } }) diff --git a/src/routes/ldapRoutes.js b/src/routes/ldapRoutes.js index 133a558b..5b207fff 100644 --- a/src/routes/ldapRoutes.js +++ b/src/routes/ldapRoutes.js @@ -397,57 +397,44 @@ const authenticateUser = (req, res, next) => { */ router.get('/user/api-keys', authenticateUser, async (req, res) => { try { + const apiKeyService = require('../services/apiKeyService') const redis = require('../models/redis') const { username, displayName } = req.user logger.info(`获取用户API Keys: ${username}, displayName: ${displayName}`) - logger.info(`用户完整信息: ${JSON.stringify(req.user)}`) - // 获取所有API Keys - const allKeysPattern = 'api_key:*' - const keys = await redis.getClient().keys(allKeysPattern) + // 使用与admin相同的API Key服务,获取所有API Keys的完整信息 + const allApiKeys = await apiKeyService.getAllApiKeys() const userKeys = [] let foundHistoricalKey = false - // 筛选属于该用户的API Keys - for (const key of keys) { - const apiKeyData = await redis.getClient().hgetall(key) - if (!apiKeyData) { - continue - } + // 筛选属于该用户的API Keys,并处理自动关联 + for (const apiKey of allApiKeys) { + logger.debug( + `检查API Key: ${apiKey.id}, name: "${apiKey.name}", owner: "${apiKey.owner || '无'}", displayName: "${displayName}"` + ) // 规则1: 直接owner匹配(已关联的Key) - if (apiKeyData.owner === username) { - userKeys.push({ - id: apiKeyData.id, - name: apiKeyData.name || '未命名', - key: apiKeyData.key, - limit: parseInt(apiKeyData.limit) || 1000000, - used: parseInt(apiKeyData.used) || 0, - createdAt: apiKeyData.createdAt, - status: apiKeyData.status || 'active' - }) + if (apiKey.owner === username) { + logger.info(`找到已关联的API Key: ${apiKey.id}`) + userKeys.push(apiKey) } // 规则2: 历史Key自动关联(name字段匹配displayName且无owner) - else if (displayName && apiKeyData.name === displayName && !apiKeyData.owner) { - logger.info(`发现历史API Key需要关联: name=${apiKeyData.name}, displayName=${displayName}`) + else if (displayName && apiKey.name === displayName && !apiKey.owner) { + logger.info( + `🔗 发现历史API Key需要关联: id=${apiKey.id}, name="${apiKey.name}", displayName="${displayName}"` + ) // 自动关联: 设置owner为当前用户 - await redis.getClient().hset(key, 'owner', username) + await redis.getClient().hset(`apikey:${apiKey.id}`, 'owner', username) foundHistoricalKey = true - userKeys.push({ - id: apiKeyData.id, - name: apiKeyData.name || '未命名', - key: apiKeyData.key, - limit: parseInt(apiKeyData.limit) || 1000000, - used: parseInt(apiKeyData.used) || 0, - createdAt: apiKeyData.createdAt, - status: apiKeyData.status || 'active' - }) + // 更新本地数据并添加到用户Key列表 + apiKey.owner = username + userKeys.push(apiKey) - logger.info(`历史API Key关联成功: ${apiKeyData.id} -> ${username}`) + logger.info(`✅ 历史API Key关联成功: ${apiKey.id} -> ${username}`) } } @@ -474,11 +461,11 @@ router.get('/user/api-keys', authenticateUser, async (req, res) => { router.post('/user/api-keys', authenticateUser, async (req, res) => { try { const { username } = req.user - const { name, limit } = req.body + const { limit } = req.body // 检查用户是否已有API Key const redis = require('../models/redis') - const allKeysPattern = 'api_key:*' + const allKeysPattern = 'apikey:*' const keys = await redis.getClient().keys(allKeysPattern) let userKeyCount = 0 @@ -496,45 +483,53 @@ router.post('/user/api-keys', authenticateUser, async (req, res) => { }) } - // 生成API Key - const crypto = require('crypto') - const uuid = require('uuid') + // 使用与admin相同的API Key生成服务,确保数据结构一致性 + const apiKeyService = require('../services/apiKeyService') - const keyId = uuid.v4() - const apiKey = `cr_${crypto.randomBytes(32).toString('hex')}` + // 获取用户的显示名称 + const { displayName } = req.user + // 用户创建的API Key名称固定为displayName,不允许自定义 + const defaultName = displayName || username - const keyData = { - id: keyId, - key: apiKey, - name: name || 'AD用户密钥', - limit: limit || 100000, - used: 0, + const keyParams = { + name: defaultName, // 忽略用户输入的name,强制使用displayName + tokenLimit: limit || 0, + description: `AD用户${username}创建的API Key`, + // AD用户创建的Key添加owner信息以区分用户归属 owner: username, ownerType: 'ad_user', - createdAt: new Date().toISOString(), - status: 'active' + // 确保用户创建的Key默认激活 + isActive: true, + // 设置基本权限(与admin创建保持一致) + permissions: 'all', + // 设置合理的并发和速率限制(与admin创建保持一致) + concurrencyLimit: 0, + rateLimitWindow: 0, + rateLimitRequests: 0, + // 添加标签标识AD用户创建 + tags: ['ad-user', 'user-created'] } - // 存储到Redis - await redis.getClient().hset(`api_key:${keyId}`, keyData) + const newKey = await apiKeyService.generateApiKey(keyParams) - // 创建哈希映射以快速查找 - const keyHash = crypto.createHash('sha256').update(apiKey).digest('hex') - await redis.getClient().set(`api_key_hash:${keyHash}`, keyId) - - logger.info(`用户${username}创建API Key成功: ${keyId}`) + logger.info(`用户${username}创建API Key成功: ${newKey.id}`) res.json({ success: true, message: 'API Key创建成功', apiKey: { - id: keyId, - key: apiKey, - name: keyData.name, - limit: keyData.limit, + id: newKey.id, + key: newKey.apiKey, // 返回完整的API Key + name: newKey.name, + tokenLimit: newKey.tokenLimit || limit || 0, used: 0, - createdAt: keyData.createdAt, - status: keyData.status + createdAt: newKey.createdAt, + isActive: true, + usage: { + daily: { requests: 0, tokens: 0 }, + total: { requests: 0, tokens: 0 } + }, + dailyCost: 0 } }) } catch (error) { @@ -555,7 +550,7 @@ router.get('/user/usage-stats', authenticateUser, async (req, res) => { const redis = require('../models/redis') // 获取用户的API Keys - const allKeysPattern = 'api_key:*' + const allKeysPattern = 'apikey:*' const keys = await redis.getClient().keys(allKeysPattern) let totalUsage = 0 @@ -600,4 +595,88 @@ router.get('/user/usage-stats', authenticateUser, async (req, res) => { } }) +/** + * 更新用户API Key + */ +router.put('/user/api-keys/:keyId', authenticateUser, async (req, res) => { + try { + const { username } = req.user + const { keyId } = req.params + const updates = req.body + + // 验证用户只能编辑自己的API Key + const apiKeyService = require('../services/apiKeyService') + const allApiKeys = await apiKeyService.getAllApiKeys() + const apiKey = allApiKeys.find((key) => key.id === keyId && key.owner === username) + + if (!apiKey) { + return res.status(404).json({ + success: false, + message: 'API Key 不存在或无权限' + }) + } + + // 限制用户只能修改特定字段 + const allowedFields = ['name', 'description', 'isActive'] + const filteredUpdates = {} + for (const [key, value] of Object.entries(updates)) { + if (allowedFields.includes(key)) { + filteredUpdates[key] = value + } + } + + await apiKeyService.updateApiKey(keyId, filteredUpdates) + + logger.info(`用户 ${username} 更新了 API Key: ${keyId}`) + + res.json({ + success: true, + message: 'API Key 更新成功' + }) + } catch (error) { + logger.error('更新用户API Key失败:', error) + res.status(500).json({ + success: false, + message: '更新 API Key 失败' + }) + } +}) + +/** + * 删除用户API Key + */ +router.delete('/user/api-keys/:keyId', authenticateUser, async (req, res) => { + try { + const { username } = req.user + const { keyId } = req.params + + // 验证用户只能删除自己的API Key + const apiKeyService = require('../services/apiKeyService') + const allApiKeys = await apiKeyService.getAllApiKeys() + const apiKey = allApiKeys.find((key) => key.id === keyId && key.owner === username) + + if (!apiKey) { + return res.status(404).json({ + success: false, + message: 'API Key 不存在或无权限' + }) + } + + await apiKeyService.deleteApiKey(keyId) + + logger.info(`用户 ${username} 删除了 API Key: ${keyId}`) + + res.json({ + success: true, + message: 'API Key 删除成功' + }) + } catch (error) { + logger.error('删除用户API Key失败:', error) + res.status(500).json({ + success: false, + message: '删除 API Key 失败' + }) + } +}) + module.exports = router diff --git a/src/services/apiKeyService.js b/src/services/apiKeyService.js index 46be6352..9986736b 100644 --- a/src/services/apiKeyService.js +++ b/src/services/apiKeyService.js @@ -32,7 +32,9 @@ class ApiKeyService { enableClientRestriction = false, allowedClients = [], dailyCostLimit = 0, - tags = [] + tags = [], + owner = null, + ownerType = null } = options // 生成简单的API Key (64字符十六进制) @@ -66,7 +68,9 @@ class ApiKeyService { createdAt: new Date().toISOString(), lastUsedAt: '', expiresAt: expiresAt || '', - createdBy: 'admin' // 可以根据需要扩展用户系统 + createdBy: 'admin', // 可以根据需要扩展用户系统 + owner: owner || '', + ownerType: ownerType || '' } // 保存API Key数据并建立哈希映射 @@ -99,7 +103,9 @@ class ApiKeyService { tags: JSON.parse(keyData.tags || '[]'), createdAt: keyData.createdAt, expiresAt: keyData.expiresAt, - createdBy: keyData.createdBy + createdBy: keyData.createdBy, + owner: keyData.owner, + ownerType: keyData.ownerType } } @@ -294,11 +300,21 @@ class ApiKeyService { // 📝 更新API Key async updateApiKey(keyId, updates) { try { + logger.debug(`🔧 Updating API key ${keyId} with:`, updates) + const keyData = await redis.getApiKey(keyId) if (!keyData || Object.keys(keyData).length === 0) { + logger.error(`❌ API key not found: ${keyId}`) throw new Error('API key not found') } + logger.debug(`📋 Current API key data:`, { + id: keyData.id, + name: keyData.name, + owner: keyData.owner, + ownerType: keyData.ownerType + }) + // 允许更新的字段 const allowedUpdates = [ 'name', @@ -344,7 +360,10 @@ class ApiKeyService { // 更新时不需要重新建立哈希映射,因为API Key本身没有变化 await redis.setApiKey(keyId, updatedData) - logger.success(`📝 Updated API key: ${keyId}`) + logger.success(`📝 Updated API key: ${keyId}`, { + updatedFields: Object.keys(updates), + newName: updatedData.name + }) return { success: true } } catch (error) { diff --git a/test-fixed-auto-link.js b/test-fixed-auto-link.js new file mode 100644 index 00000000..e13b8ac1 --- /dev/null +++ b/test-fixed-auto-link.js @@ -0,0 +1,54 @@ +const jwt = require('jsonwebtoken'); +const config = require('./config/config'); +const fetch = require('node-fetch'); + +// 模拟创建一个包含displayName的JWT token +const userInfo = { + type: 'ad_user', + username: 'zhangji', + displayName: '张佶', + email: 'zhangji@weidian.com', + groups: ['CN=Weidian-IT组,OU=Weidian Groups,OU=微店,DC=corp,DC=weidian-inc,DC=com'], + loginTime: new Date().toISOString() +}; + +const token = jwt.sign(userInfo, config.security.jwtSecret, { + expiresIn: '8h' +}); + +console.log('测试修正后的自动关联功能'); +console.log('用户displayName: 张佶'); + +async function testFixedAutoLink() { + try { + console.log('\n=== 测试获取用户API Keys ==='); + + const response = await fetch('http://localhost:3000/admin/ldap/user/api-keys', { + headers: { + 'Authorization': `Bearer ${token}` + } + }); + + const result = await response.json(); + + console.log('\n结果:', JSON.stringify(result, null, 2)); + + if (result.success && result.apiKeys && result.apiKeys.length > 0) { + console.log('\n✅ 成功!找到了关联的API Key:'); + result.apiKeys.forEach(key => { + console.log(`- ID: ${key.id}`); + console.log(`- Name: ${key.name}`); + console.log(`- Key: ${key.key.substring(0, 10)}...${key.key.substring(key.key.length-10)}`); + console.log(`- Limit: ${key.limit}`); + console.log(`- Status: ${key.status}`); + }); + } else { + console.log('\n❌ 没有找到关联的API Key'); + } + + } catch (error) { + console.error('测试错误:', error.message); + } +} + +testFixedAutoLink(); \ No newline at end of file diff --git a/web/admin-spa/src/components/apikeys/EditApiKeyModal.vue b/web/admin-spa/src/components/apikeys/EditApiKeyModal.vue index 7d8069cf..f72b32be 100644 --- a/web/admin-spa/src/components/apikeys/EditApiKeyModal.vue +++ b/web/admin-spa/src/components/apikeys/EditApiKeyModal.vue @@ -33,12 +33,31 @@ >名称 -

名称不可修改

+

最多100个字符

+
+ +
+ + +

+ 最多500个字符(可选) +

@@ -632,6 +651,7 @@ const unselectedTags = computed(() => { // 表单数据 const form = reactive({ name: '', + description: '', tokenLimit: '', rateLimitWindow: '', rateLimitRequests: '', @@ -707,6 +727,8 @@ const updateApiKey = async () => { try { // 准备提交的数据 const data = { + name: form.name, + description: form.description, tokenLimit: form.tokenLimit !== '' && form.tokenLimit !== null ? parseInt(form.tokenLimit) : 0, rateLimitWindow: @@ -893,6 +915,7 @@ onMounted(async () => { } form.name = props.apiKey.name + form.description = props.apiKey.description || '' form.tokenLimit = props.apiKey.tokenLimit || '' form.rateLimitWindow = props.apiKey.rateLimitWindow || '' form.rateLimitRequests = props.apiKey.rateLimitRequests || '' diff --git a/web/admin-spa/src/components/user/UserApiKeysView.vue b/web/admin-spa/src/components/user/UserApiKeysView.vue index 7f472ab6..4e029731 100644 --- a/web/admin-spa/src/components/user/UserApiKeysView.vue +++ b/web/admin-spa/src/components/user/UserApiKeysView.vue @@ -26,18 +26,15 @@ API Key 将用于访问 Claude Relay Service

- +

+ API Key 名称将自动设置为您的用户名 +

+ + + - +
-
- - - +
+
+ + + 已关联的历史API + Key无法显示原始内容,仅在创建时可见。如需查看完整Key,请联系管理员或创建新Key。 + +
+
+ Key ID: {{ apiKey.id }} +
@@ -136,52 +151,86 @@
-

已使用

+

今日请求

- {{ apiKey.used?.toLocaleString() || 0 }} + {{ apiKey.usage?.daily?.requests?.toLocaleString() || 0 }}

- +
-

总额度

+

今日Token

- {{ apiKey.limit?.toLocaleString() || 0 }} + {{ apiKey.usage?.daily?.tokens?.toLocaleString() || 0 }}

- +
-

使用率

+

今日费用

- {{ calculateUsagePercentage(apiKey.used, apiKey.limit) }}% + ${{ (apiKey.dailyCost || 0).toFixed(4) }}

- -
+ +
- 使用进度 - {{ calculateUsagePercentage(apiKey.used, apiKey.limit) }}% + Token 使用进度 + + {{ apiKey.usage?.total?.tokens?.toLocaleString() || 0 }} / + {{ apiKey.tokenLimit?.toLocaleString() || 0 }} +
+ + +
+
+ 每日费用限制 + + ${{ (apiKey.dailyCost || 0).toFixed(4) }} / ${{ + (apiKey.dailyCostLimit || 0).toFixed(2) + }} + +
+
+
+
+
+ + +
+ +
@@ -210,11 +259,27 @@ > {{ successMessage }} + + + + + + - - diff --git a/web/admin-spa/src/components/user/UserStatsView.vue b/web/admin-spa/src/components/user/UserStatsView.vue deleted file mode 100644 index 58e2467b..00000000 --- a/web/admin-spa/src/components/user/UserStatsView.vue +++ /dev/null @@ -1,268 +0,0 @@ - - - - - diff --git a/web/admin-spa/src/router/index.js b/web/admin-spa/src/router/index.js index 11a8cc46..47680c7d 100644 --- a/web/admin-spa/src/router/index.js +++ b/web/admin-spa/src/router/index.js @@ -11,7 +11,6 @@ const AccountsView = () => import('@/views/AccountsView.vue') const TutorialView = () => import('@/views/TutorialView.vue') const SettingsView = () => import('@/views/SettingsView.vue') const ApiStatsView = () => import('@/views/ApiStatsView.vue') -const UserDashboardView = () => import('@/views/UserDashboardView.vue') const routes = [ { @@ -42,12 +41,6 @@ const routes = [ component: ApiStatsView, meta: { requiresAuth: false } }, - { - path: '/user-dashboard', - name: 'UserDashboard', - component: UserDashboardView, - meta: { requiresAuth: false, userAuth: true } - }, { path: '/dashboard', component: MainLayout, @@ -140,18 +133,7 @@ router.beforeEach((to, from, next) => { // API Stats 页面不需要认证,直接放行 if (to.path === '/api-stats' || to.path.startsWith('/api-stats')) { next() - } - // 用户仪表盘需要用户token验证 - else if (to.meta.userAuth) { - const userToken = localStorage.getItem('user_token') - if (!userToken) { - next('/api-stats') - } else { - next() - } - } - // 管理员页面需要管理员认证 - else if (to.meta.requiresAuth && !authStore.isAuthenticated) { + } else if (to.meta.requiresAuth && !authStore.isAuthenticated) { next('/login') } else if (to.path === '/login' && authStore.isAuthenticated) { next('/dashboard') diff --git a/web/admin-spa/src/views/ApiStatsView.vue b/web/admin-spa/src/views/ApiStatsView.vue index fcd6bd51..f2af17b2 100644 --- a/web/admin-spa/src/views/ApiStatsView.vue +++ b/web/admin-spa/src/views/ApiStatsView.vue @@ -20,15 +20,6 @@ class="h-8 w-px bg-gradient-to-b from-transparent via-gray-300 to-transparent opacity-50 dark:via-gray-600" /> - - - - - -
-
-
-
-

AD域控登录

-

使用您的域账号登录

-
- - -
- - -
- -
- - -
- -
- - -
- - -
- {{ userLoginError }} -
-
-
@@ -234,15 +157,6 @@ const currentTab = ref('stats') // 主题相关 const isDarkMode = computed(() => themeStore.isDarkMode) -// 用户登录相关 -const showLoginModal = ref(false) -const userLoginLoading = ref(false) -const userLoginError = ref('') -const userLoginForm = ref({ - username: '', - password: '' -}) - const { apiKey, apiId, @@ -257,63 +171,6 @@ const { const { queryStats, switchPeriod, loadStatsWithApiId, loadOemSettings, reset } = apiStatsStore -// 用户登录相关方法 -const showUserLogin = () => { - showLoginModal.value = true - userLoginError.value = '' - userLoginForm.value = { - username: '', - password: '' - } -} - -const hideUserLogin = () => { - showLoginModal.value = false - userLoginError.value = '' - userLoginForm.value = { - username: '', - password: '' - } -} - -const handleUserLogin = async () => { - if (!userLoginForm.value.username || !userLoginForm.value.password) { - userLoginError.value = '请输入用户名和密码' - return - } - - userLoginLoading.value = true - userLoginError.value = '' - - try { - const response = await fetch('/admin/ldap/login', { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify(userLoginForm.value) - }) - - const result = await response.json() - - if (result.success) { - // 保存token到localStorage - localStorage.setItem('user_token', result.token) - localStorage.setItem('user_info', JSON.stringify(result.user)) - - // 跳转到用户专用页面 - window.location.href = '/admin-next/user-dashboard' - } else { - userLoginError.value = result.message || '登录失败' - } - } catch (error) { - console.error('用户登录错误:', error) - userLoginError.value = '网络错误,请重试' - } finally { - userLoginLoading.value = false - } -} - // 处理键盘快捷键 const handleKeyDown = (event) => { // Ctrl/Cmd + Enter 查询 @@ -452,55 +309,6 @@ watch(apiKey, (newValue) => { letter-spacing: -0.025em; } -/* 用户登录按钮 */ -.user-login-button { - background: linear-gradient(135deg, #10b981 0%, #059669 100%); - backdrop-filter: blur(20px); - border: 1px solid rgba(255, 255, 255, 0.3); - color: white; - text-decoration: none; - box-shadow: - 0 4px 12px rgba(16, 185, 129, 0.25), - inset 0 1px 1px rgba(255, 255, 255, 0.2); - position: relative; - overflow: hidden; - font-weight: 600; - cursor: pointer; -} - -/* 暗色模式下的用户登录按钮 */ -:global(.dark) .user-login-button { - background: rgba(34, 197, 94, 0.8); - border: 1px solid rgba(107, 114, 128, 0.4); - color: #f3f4f6; - box-shadow: - 0 4px 12px rgba(0, 0, 0, 0.3), - inset 0 1px 1px rgba(255, 255, 255, 0.05); -} - -.user-login-button:hover { - transform: translateY(-2px) scale(1.02); - background: linear-gradient(135deg, #059669 0%, #10b981 100%); - box-shadow: - 0 8px 20px rgba(5, 150, 105, 0.35), - inset 0 1px 1px rgba(255, 255, 255, 0.3); - border-color: rgba(255, 255, 255, 0.4); - color: white; -} - -:global(.dark) .user-login-button:hover { - background: linear-gradient(135deg, #10b981 0%, #059669 100%); - border-color: rgba(34, 197, 94, 0.4); - box-shadow: - 0 8px 20px rgba(16, 185, 129, 0.3), - inset 0 1px 1px rgba(255, 255, 255, 0.1); - color: white; -} - -.user-login-button:active { - transform: translateY(-1px) scale(1); -} - /* 管理后台按钮 - 精致版本 */ .admin-button-refined { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); diff --git a/web/admin-spa/src/views/UserDashboardView.vue b/web/admin-spa/src/views/UserDashboardView.vue deleted file mode 100644 index 09937bd8..00000000 --- a/web/admin-spa/src/views/UserDashboardView.vue +++ /dev/null @@ -1,344 +0,0 @@ - - - - - From e84c6a5555797a2e7ecb260543918a76047d727d Mon Sep 17 00:00:00 2001 From: shaw Date: Sun, 31 Aug 2025 17:27:37 +0800 Subject: [PATCH 13/17] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=E5=9F=BA?= =?UTF-8?q?=E4=BA=8E=E8=B4=B9=E7=94=A8=E7=9A=84=E9=80=9F=E7=8E=87=E9=99=90?= =?UTF-8?q?=E5=88=B6=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 rateLimitCost 字段,支持按费用进行速率限制 - 新增 weeklyOpusCostLimit 字段,支持 Opus 模型周费用限制 - 优化速率限制逻辑,支持费用、请求数、token多维度控制 - 更新前端界面,添加费用限制配置选项 - 增强账户管理功能,支持费用统计和限制 - 改进 Redis 数据模型,支持费用计数器 - 优化价格计算服务,支持更精确的成本核算 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- src/middleware/auth.js | 172 ++++++++--- src/models/redis.js | 213 ++++++++++++- src/routes/admin.js | 93 +++++- src/routes/api.js | 84 ++++- src/routes/apiStats.js | 8 +- src/services/apiKeyService.js | 78 ++++- src/services/claudeAccountService.js | 123 +++++++- src/services/claudeConsoleAccountService.js | 138 +++++++++ src/services/claudeConsoleRelayService.js | 41 ++- src/services/claudeRelayService.js | 53 +++- src/services/pricingService.js | 96 +++++- src/services/unifiedClaudeScheduler.js | 24 +- src/utils/costCalculator.js | 54 +++- .../src/components/accounts/AccountForm.vue | 43 +++ .../components/apikeys/CreateApiKeyModal.vue | 99 +++++- .../components/apikeys/EditApiKeyModal.vue | 111 ++++++- .../components/apikeys/UsageDetailModal.vue | 2 + .../components/apikeys/WindowCountdown.vue | 40 +++ .../src/components/apistats/LimitConfig.vue | 14 +- web/admin-spa/src/views/AccountsView.vue | 292 ++++++++++++++++-- web/admin-spa/src/views/ApiKeysView.vue | 45 ++- 21 files changed, 1662 insertions(+), 161 deletions(-) diff --git a/src/middleware/auth.js b/src/middleware/auth.js index 38c43485..89369c41 100644 --- a/src/middleware/auth.js +++ b/src/middleware/auth.js @@ -1,7 +1,7 @@ const apiKeyService = require('../services/apiKeyService') 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 +182,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 +206,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 +217,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 +225,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 +246,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 +297,13 @@ const authenticateApiKey = async (req, res, next) => { windowDuration, requestCountKey, tokenCountKey, + costCountKey, // 新增:费用计数器 currentRequests: currentRequests + 1, currentTokens, + currentCost, // 新增:当前费用 rateLimitRequests, - tokenLimit + tokenLimit, + rateLimitCost // 新增:费用限制 } } @@ -297,6 +332,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 +386,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, @@ -713,35 +789,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 +859,7 @@ const globalRateLimit = async (req, res, next) => { retryAfter: Math.round(msBeforeNext / 1000) }) } -} + */ // 📊 请求大小限制中间件 const requestSizeLimit = (req, res, next) => { diff --git a/src/models/redis.js b/src/models/redis.js index 4d62bda7..db8fbf6d 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), @@ -475,7 +509,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 +680,39 @@ 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 getAccountUsageStats(accountId) { const accountKey = `account_usage:${accountId}` @@ -1311,6 +1392,129 @@ class RedisClient { return 0 } } + + // 📊 获取账户会话窗口内的使用统计(包含模型细分) + 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) + + // 获取窗口内所有可能的小时键 + const hourlyKeys = [] + const currentHour = new Date(startDate) + currentHour.setMinutes(0) + currentHour.setSeconds(0) + currentHour.setMilliseconds(0) + + while (currentHour <= endDate) { + const dateStr = `${currentHour.getUTCFullYear()}-${String(currentHour.getUTCMonth() + 1).padStart(2, '0')}-${String(currentHour.getUTCDate()).padStart(2, '0')}` + const hourStr = String(currentHour.getUTCHours()).padStart(2, '0') + const key = `account_usage:hourly:${accountId}:${dateStr}:${hourStr}` + 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 = {} + + for (const [error, data] of results) { + if (error || !data || Object.keys(data).length === 0) { + continue + } + + // 处理总计数据 + totalInputTokens += parseInt(data.totalInputTokens || 0) + totalOutputTokens += parseInt(data.totalOutputTokens || 0) + totalCacheCreateTokens += parseInt(data.totalCacheCreateTokens || 0) + totalCacheReadTokens += parseInt(data.totalCacheReadTokens || 0) + totalAllTokens += parseInt(data.totalAllTokens || 0) + totalRequests += parseInt(data.totalRequests || 0) + + // 处理每个模型的数据 + 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) + } + } + } + } + } + + 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 +1523,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 368d0a33..1436a6c0 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)) { @@ -1468,13 +1503,53 @@ router.get('/claude-accounts', authenticateAdmin, async (req, res) => { const usageStats = await redis.getAccountUsageStats(account.id) const groupInfos = await accountGroupService.getAccountGroup(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 + } + + const costResult = CostCalculator.calculateCost(usageData, modelName) + 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) { @@ -1488,7 +1563,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) { @@ -1502,7 +1578,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 } } } @@ -1531,7 +1608,8 @@ router.post('/claude-accounts', authenticateAdmin, async (req, res) => { accountType, platform = 'claude', priority, - groupId + groupId, + autoStopOnWarning } = req.body if (!name) { @@ -1568,7 +1646,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 }) // 如果是分组类型,将账户添加到分组 @@ -1826,6 +1905,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, @@ -1842,6 +1923,8 @@ router.get('/claude-console-accounts', authenticateAdmin, async (req, res) => { const groupInfos = await accountGroupService.getAccountGroup(account.id) return { ...account, + // 转换schedulable为布尔值 + schedulable: account.schedulable === 'true' || account.schedulable === true, groupInfos, usage: { daily: { tokens: 0, requests: 0, allTokens: 0 }, 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/services/apiKeyService.js b/src/services/apiKeyService.js index 46be6352..94e7ae77 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,6 +65,7 @@ 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: '', @@ -83,6 +87,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 +101,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, @@ -184,12 +190,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 } @@ -213,22 +222,27 @@ class ApiKeyService { 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 +265,7 @@ class ApiKeyService { // 重置计数为0,因为窗口已过期 key.currentWindowRequests = 0 key.currentWindowTokens = 0 + key.currentWindowCost = 0 // 新增:重置费用 } } else { // 窗口还未开始(没有任何请求) @@ -261,6 +276,7 @@ class ApiKeyService { } else { key.currentWindowRequests = 0 key.currentWindowTokens = 0 + key.currentWindowCost = 0 // 新增:重置费用 key.windowStartTime = null key.windowEndTime = null key.windowRemainingSeconds = null @@ -307,6 +323,7 @@ class ApiKeyService { 'concurrencyLimit', 'rateLimitWindow', 'rateLimitRequests', + 'rateLimitCost', // 新增:速率限制费用字段 'isActive', 'claudeAccountId', 'claudeConsoleAccountId', @@ -321,6 +338,7 @@ class ApiKeyService { 'enableClientRestriction', 'allowedClients', 'dailyCostLimit', + 'weeklyOpusCostLimit', 'tags' ] const updatedData = { ...keyData } @@ -396,6 +414,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 +429,10 @@ class ApiKeyService { outputTokens, cacheCreateTokens, cacheReadTokens, - model + model, + 0, // ephemeral5mTokens - 暂时为0,后续处理 + 0, // ephemeral1hTokens - 暂时为0,后续处理 + isLongContextRequest ) // 记录费用统计 @@ -433,7 +461,8 @@ class ApiKeyService { outputTokens, cacheCreateTokens, cacheReadTokens, - model + model, + isLongContextRequest ) logger.database( `📊 Recorded account usage: ${accountId} - ${totalTokens} tokens (API Key: ${keyId})` @@ -460,8 +489,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 +564,8 @@ class ApiKeyService { cacheReadTokens, model, ephemeral5mTokens, // 传递5分钟缓存 tokens - ephemeral1hTokens // 传递1小时缓存 tokens + ephemeral1hTokens, // 传递1小时缓存 tokens + costInfo.isLongContextRequest || false // 传递 1M 上下文请求标记 ) // 记录费用统计 @@ -515,6 +575,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 +604,8 @@ class ApiKeyService { outputTokens, cacheCreateTokens, cacheReadTokens, - model + model, + costInfo.isLongContextRequest || false ) logger.database( `📊 Recorded account usage: ${accountId} - ${totalTokens} tokens (API Key: ${keyId})` diff --git a/src/services/claudeAccountService.js b/src/services/claudeAccountService.js index a4ef8411..17bb3465 100644 --- a/src/services/claudeAccountService.js +++ b/src/services/claudeAccountService.js @@ -57,7 +57,8 @@ class ClaudeAccountService { platform = 'claude', priority = 50, // 调度优先级 (1-100,数字越小优先级越高) schedulable = true, // 是否可被调度 - subscriptionInfo = null // 手动设置的订阅信息 + subscriptionInfo = null, // 手动设置的订阅信息 + autoStopOnWarning = false // 5小时使用量接近限制时自动停止调度 } = options const accountId = uuidv4() @@ -88,6 +89,7 @@ class ClaudeAccountService { status: 'active', // 有OAuth数据的账户直接设为active errorMessage: '', schedulable: schedulable.toString(), // 是否可被调度 + autoStopOnWarning: autoStopOnWarning.toString(), // 5小时使用量接近限制时自动停止调度 // 优先使用手动设置的订阅信息,否则使用OAuth数据中的,否则默认为空 subscriptionInfo: subscriptionInfo ? JSON.stringify(subscriptionInfo) @@ -118,6 +120,7 @@ class ClaudeAccountService { status: 'created', // created, active, expired, error errorMessage: '', schedulable: schedulable.toString(), // 是否可被调度 + autoStopOnWarning: autoStopOnWarning.toString(), // 5小时使用量接近限制时自动停止调度 // 手动设置的订阅信息 subscriptionInfo: subscriptionInfo ? JSON.stringify(subscriptionInfo) : '' } @@ -158,7 +161,8 @@ class ClaudeAccountService { status: accountData.status, createdAt: accountData.createdAt, expiresAt: accountData.expiresAt, - scopes: claudeAiOauth ? claudeAiOauth.scopes : [] + scopes: claudeAiOauth ? claudeAiOauth.scopes : [], + autoStopOnWarning } } @@ -479,7 +483,11 @@ class ClaudeAccountService { lastRequestTime: null }, // 添加调度状态 - schedulable: account.schedulable !== 'false' // 默认为true,兼容历史数据 + schedulable: account.schedulable !== 'false', // 默认为true,兼容历史数据 + // 添加自动停止调度设置 + autoStopOnWarning: account.autoStopOnWarning === 'true', // 默认为false + // 添加停止原因 + stoppedReason: account.stoppedReason || null } }) ) @@ -1284,6 +1292,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: new Date().toISOString() + }) + } 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)` ) @@ -1329,7 +1373,8 @@ class ClaudeAccountService { windowEnd: null, progress: 0, remainingTime: null, - lastRequestTime: accountData.lastRequestTime || null + lastRequestTime: accountData.lastRequestTime || null, + sessionWindowStatus: accountData.sessionWindowStatus || null } } @@ -1346,7 +1391,8 @@ class ClaudeAccountService { windowEnd: accountData.sessionWindowEnd, progress: 100, remainingTime: 0, - lastRequestTime: accountData.lastRequestTime || null + lastRequestTime: accountData.lastRequestTime || null, + sessionWindowStatus: accountData.sessionWindowStatus || null } } @@ -1364,7 +1410,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) @@ -1889,6 +1936,70 @@ class ClaudeAccountService { 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: new Date().toISOString() + }) + } 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..7bde2c29 100644 --- a/src/services/claudeConsoleAccountService.js +++ b/src/services/claudeConsoleAccountService.js @@ -453,6 +453,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 0ca60f1b..57e42438 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, @@ -264,6 +264,27 @@ class ClaudeRelayService { ) } } else if (response.statusCode === 200 || response.statusCode === 201) { + // 提取5小时会话窗口状态 + // 使用大小写不敏感的方式获取响应头 + const get5hStatus = (headers) => { + if (!headers) { + return null + } + // HTTP头部名称不区分大小写,需要处理不同情况 + return ( + headers['anthropic-ratelimit-unified-5h-status'] || + headers['Anthropic-Ratelimit-Unified-5h-Status'] || + headers['ANTHROPIC-RATELIMIT-UNIFIED-5H-STATUS'] + ) + } + + const sessionWindowStatus = get5hStatus(response.headers) + if (sessionWindowStatus) { + logger.info(`📊 Session window status for account ${accountId}: ${sessionWindowStatus}`) + // 保存会话窗口状态到账户数据 + await claudeAccountService.updateSessionWindowStatus(accountId, sessionWindowStatus) + } + // 请求成功,清除401和500错误计数 await this.clearUnauthorizedErrors(accountId) await claudeAccountService.clearInternalErrors(accountId) @@ -454,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 } @@ -1189,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) { // 提取限流重置时间戳 diff --git a/src/services/pricingService.js b/src/services/pricingService.js index 5ded4c0a..0084a2ab 100644 --- a/src/services/pricingService.js +++ b/src/services/pricingService.js @@ -55,6 +55,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 模型的价格 + } } // 初始化价格服务 @@ -329,9 +340,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 +382,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 +425,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 +436,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 +449,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 4e6535bd..c83676a2 100644 --- a/src/services/unifiedClaudeScheduler.js +++ b/src/services/unifiedClaudeScheduler.js @@ -459,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 } // 检查是否可调度 @@ -467,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/utils/costCalculator.js b/src/utils/costCalculator.js index a0fe6700..3c3b7c41 100644 --- a/src/utils/costCalculator.js +++ b/src/utils/costCalculator.js @@ -69,9 +69,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/web/admin-spa/src/components/accounts/AccountForm.vue b/web/admin-spa/src/components/accounts/AccountForm.vue index 2943deca..465952d5 100644 --- a/web/admin-spa/src/components/accounts/AccountForm.vue +++ b/web/admin-spa/src/components/accounts/AccountForm.vue @@ -797,6 +797,25 @@

+ +
+ +
+
+ +
+ +
+
@@ -275,12 +275,9 @@
示例1: 时间窗口=60,请求次数=1000 → 每60分钟最多1000次请求
+
示例2: 时间窗口=1,费用=0.1 → 每分钟最多$0.1费用
- 示例2: 时间窗口=1,Token=10000 → 每分钟最多10,000个Token -
-
- 示例3: 窗口=30,请求=50,Token=100000 → - 每30分钟50次请求且不超10万Token + 示例3: 窗口=30,请求=50,费用=5 → 每30分钟50次请求且不超$5费用
@@ -336,6 +333,55 @@ +
+ +
+
+ + + + +
+ +

+ 设置 Opus 模型的周费用限制(周一到周日),仅限 Claude 官方账户,0 或留空表示无限制 +

+
+
+
{ } } + // 检查是否设置了时间窗口但费用限制为0 + if (form.rateLimitWindow && (!form.rateLimitCost || parseFloat(form.rateLimitCost) === 0)) { + let confirmed = false + if (window.showConfirm) { + confirmed = await window.showConfirm( + '费用限制提醒', + '您设置了时间窗口但费用限制为0,这意味着不会有费用限制。\n\n是否继续?', + '继续创建', + '返回修改' + ) + } else { + // 降级方案 + confirmed = confirm('您设置了时间窗口但费用限制为0,这意味着不会有费用限制。\n是否继续?') + } + if (!confirmed) { + return + } + } + loading.value = true try { // 准备提交的数据 const baseData = { description: form.description || undefined, - tokenLimit: - form.tokenLimit !== '' && form.tokenLimit !== null ? parseInt(form.tokenLimit) : null, + tokenLimit: 0, // 设置为0,清除历史token限制 rateLimitWindow: form.rateLimitWindow !== '' && form.rateLimitWindow !== null ? parseInt(form.rateLimitWindow) @@ -1001,6 +1066,10 @@ const createApiKey = async () => { form.rateLimitRequests !== '' && form.rateLimitRequests !== null ? parseInt(form.rateLimitRequests) : null, + rateLimitCost: + form.rateLimitCost !== '' && form.rateLimitCost !== null + ? parseFloat(form.rateLimitCost) + : null, concurrencyLimit: form.concurrencyLimit !== '' && form.concurrencyLimit !== null ? parseInt(form.concurrencyLimit) @@ -1009,6 +1078,10 @@ const createApiKey = async () => { form.dailyCostLimit !== '' && form.dailyCostLimit !== null ? parseFloat(form.dailyCostLimit) : 0, + weeklyOpusCostLimit: + form.weeklyOpusCostLimit !== '' && form.weeklyOpusCostLimit !== null + ? parseFloat(form.weeklyOpusCostLimit) + : 0, expiresAt: form.expiresAt || undefined, permissions: form.permissions, tags: form.tags.length > 0 ? form.tags : undefined, diff --git a/web/admin-spa/src/components/apikeys/EditApiKeyModal.vue b/web/admin-spa/src/components/apikeys/EditApiKeyModal.vue index 7d8069cf..f74b25f8 100644 --- a/web/admin-spa/src/components/apikeys/EditApiKeyModal.vue +++ b/web/admin-spa/src/components/apikeys/EditApiKeyModal.vue @@ -166,17 +166,17 @@
费用限制 (美元) -

- 窗口内最大Token -

+

窗口内最大费用

@@ -189,12 +189,9 @@
示例1: 时间窗口=60,请求次数=1000 → 每60分钟最多1000次请求
+
示例2: 时间窗口=1,费用=0.1 → 每分钟最多$0.1费用
- 示例2: 时间窗口=1,Token=10000 → 每分钟最多10,000个Token -
-
- 示例3: 窗口=30,请求=50,Token=100000 → - 每30分钟50次请求且不超10万Token + 示例3: 窗口=30,请求=50,费用=5 → 每30分钟50次请求且不超$5费用
@@ -250,6 +247,55 @@ +
+ +
+
+ + + + +
+ +

+ 设置 Opus 模型的周费用限制(周一到周日),仅限 Claude 官方账户,0 或留空表示无限制 +

+
+
+
{ // 表单数据 const form = reactive({ name: '', - tokenLimit: '', + tokenLimit: '', // 保留用于检测历史数据 rateLimitWindow: '', rateLimitRequests: '', + rateLimitCost: '', // 新增:费用限制 concurrencyLimit: '', dailyCostLimit: '', + weeklyOpusCostLimit: '', permissions: 'all', claudeAccountId: '', geminiAccountId: '', @@ -702,13 +750,31 @@ const removeTag = (index) => { // 更新 API Key const updateApiKey = async () => { + // 检查是否设置了时间窗口但费用限制为0 + if (form.rateLimitWindow && (!form.rateLimitCost || parseFloat(form.rateLimitCost) === 0)) { + let confirmed = false + if (window.showConfirm) { + confirmed = await window.showConfirm( + '费用限制提醒', + '您设置了时间窗口但费用限制为0,这意味着不会有费用限制。\n\n是否继续?', + '继续保存', + '返回修改' + ) + } else { + // 降级方案 + confirmed = confirm('您设置了时间窗口但费用限制为0,这意味着不会有费用限制。\n是否继续?') + } + if (!confirmed) { + return + } + } + loading.value = true try { // 准备提交的数据 const data = { - tokenLimit: - form.tokenLimit !== '' && form.tokenLimit !== null ? parseInt(form.tokenLimit) : 0, + tokenLimit: 0, // 清除历史token限制 rateLimitWindow: form.rateLimitWindow !== '' && form.rateLimitWindow !== null ? parseInt(form.rateLimitWindow) @@ -717,6 +783,10 @@ const updateApiKey = async () => { form.rateLimitRequests !== '' && form.rateLimitRequests !== null ? parseInt(form.rateLimitRequests) : 0, + rateLimitCost: + form.rateLimitCost !== '' && form.rateLimitCost !== null + ? parseFloat(form.rateLimitCost) + : 0, concurrencyLimit: form.concurrencyLimit !== '' && form.concurrencyLimit !== null ? parseInt(form.concurrencyLimit) @@ -725,6 +795,10 @@ const updateApiKey = async () => { form.dailyCostLimit !== '' && form.dailyCostLimit !== null ? parseFloat(form.dailyCostLimit) : 0, + weeklyOpusCostLimit: + form.weeklyOpusCostLimit !== '' && form.weeklyOpusCostLimit !== null + ? parseFloat(form.weeklyOpusCostLimit) + : 0, permissions: form.permissions, tags: form.tags } @@ -893,11 +967,22 @@ onMounted(async () => { } form.name = props.apiKey.name + + // 处理速率限制迁移:如果有tokenLimit且没有rateLimitCost,提示用户 form.tokenLimit = props.apiKey.tokenLimit || '' + form.rateLimitCost = props.apiKey.rateLimitCost || '' + + // 如果有历史tokenLimit但没有rateLimitCost,提示用户需要重新设置 + if (props.apiKey.tokenLimit > 0 && !props.apiKey.rateLimitCost) { + // 可以根据需要添加提示,或者自动迁移(这里选择让用户手动设置) + console.log('检测到历史Token限制,请考虑设置费用限制') + } + form.rateLimitWindow = props.apiKey.rateLimitWindow || '' form.rateLimitRequests = props.apiKey.rateLimitRequests || '' form.concurrencyLimit = props.apiKey.concurrencyLimit || '' form.dailyCostLimit = props.apiKey.dailyCostLimit || '' + form.weeklyOpusCostLimit = props.apiKey.weeklyOpusCostLimit || '' form.permissions = props.apiKey.permissions || 'all' // 处理 Claude 账号(区分 OAuth 和 Console) if (props.apiKey.claudeConsoleAccountId) { diff --git a/web/admin-spa/src/components/apikeys/UsageDetailModal.vue b/web/admin-spa/src/components/apikeys/UsageDetailModal.vue index c084e602..54593e11 100644 --- a/web/admin-spa/src/components/apikeys/UsageDetailModal.vue +++ b/web/admin-spa/src/components/apikeys/UsageDetailModal.vue @@ -196,6 +196,8 @@ 时间窗口限制
+
Token @@ -48,6 +49,23 @@ />
+ + +
+
+ 费用 + + ${{ (currentCost || 0).toFixed(2) }}/${{ costLimit.toFixed(2) }} + +
+
+
+
+
@@ -102,6 +120,14 @@ const props = defineProps({ type: Number, default: 0 }, + currentCost: { + type: Number, + default: 0 + }, + costLimit: { + type: Number, + default: 0 + }, showProgress: { type: Boolean, default: true @@ -132,6 +158,7 @@ const windowState = computed(() => { const hasRequestLimit = computed(() => props.requestLimit > 0) const hasTokenLimit = computed(() => props.tokenLimit > 0) +const hasCostLimit = computed(() => props.costLimit > 0) // 方法 const formatTime = (seconds) => { @@ -196,6 +223,19 @@ const getTokenProgressColor = () => { return 'bg-purple-500' } +const getCostProgress = () => { + if (!props.costLimit || props.costLimit === 0) return 0 + const percentage = ((props.currentCost || 0) / props.costLimit) * 100 + return Math.min(percentage, 100) +} + +const getCostProgressColor = () => { + const progress = getCostProgress() + if (progress >= 100) return 'bg-red-500' + if (progress >= 80) return 'bg-yellow-500' + return 'bg-green-500' +} + // 更新倒计时 const updateCountdown = () => { if (props.windowEndTime && remainingSeconds.value > 0) { diff --git a/web/admin-spa/src/components/apistats/LimitConfig.vue b/web/admin-spa/src/components/apistats/LimitConfig.vue index a666e2d5..c5338184 100644 --- a/web/admin-spa/src/components/apistats/LimitConfig.vue +++ b/web/admin-spa/src/components/apistats/LimitConfig.vue @@ -45,10 +45,14 @@
- 请求次数和Token使用量为"或"的关系,任一达到限制即触发限流 + + 请求次数和费用限制为"或"的关系,任一达到限制即触发限流 + + + 请求次数和Token使用量为"或"的关系,任一达到限制即触发限流 + + 仅限制请求次数
diff --git a/web/admin-spa/src/views/AccountsView.vue b/web/admin-spa/src/views/AccountsView.vue index 1e6e4793..683ab91f 100644 --- a/web/admin-spa/src/views/AccountsView.vue +++ b/web/admin-spa/src/views/AccountsView.vue @@ -191,7 +191,39 @@ - 会话窗口 +
+ 会话窗口 + + + + +
不可调度 + + +
-
+
{{ account.usage.daily.requests || 0 }} 次
-
+
{{ formatNumber(account.usage.daily.allTokens || 0) }} tokens{{ formatNumber(account.usage.daily.allTokens || 0) }}M +
+
+
+ ${{ calculateDailyCost(account) }}
+ +
+
+
+ + {{ formatNumber(account.usage.sessionWindow.totalTokens) }}M + +
+
+
+ + ${{ formatCost(account.usage.sessionWindow.totalCost) }} + +
+
+ +
-
+
@@ -489,7 +558,9 @@ {{ account.sessionWindow.progress }}%
-
+ + +
{{ formatSessionWindow( @@ -500,7 +571,7 @@
剩余 {{ formatRemainingTime(account.sessionWindow.remainingTime) }}
@@ -648,21 +719,44 @@

今日使用

-

- {{ 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) }} +

+
+
+
-
@@ -678,14 +772,27 @@ class="space-y-1.5 rounded-lg bg-gray-50 p-2 dark:bg-gray-700" >
- 会话窗口 +
+ 会话窗口 + + + +
{{ account.sessionWindow.progress }}%
@@ -947,7 +1054,9 @@ const loadAccounts = async (forceReload = false) => { apiClient.get('/admin/claude-accounts', { params }), Promise.resolve({ success: true, data: [] }), // claude-console 占位 Promise.resolve({ success: true, data: [] }), // bedrock 占位 - Promise.resolve({ success: true, data: [] }) // gemini 占位 + Promise.resolve({ success: true, data: [] }), // gemini 占位 + Promise.resolve({ success: true, data: [] }), // openai 占位 + Promise.resolve({ success: true, data: [] }) // azure-openai 占位 ) break case 'claude-console': @@ -955,7 +1064,9 @@ const loadAccounts = async (forceReload = false) => { Promise.resolve({ success: true, data: [] }), // claude 占位 apiClient.get('/admin/claude-console-accounts', { params }), Promise.resolve({ success: true, data: [] }), // bedrock 占位 - Promise.resolve({ success: true, data: [] }) // gemini 占位 + Promise.resolve({ success: true, data: [] }), // gemini 占位 + Promise.resolve({ success: true, data: [] }), // openai 占位 + Promise.resolve({ success: true, data: [] }) // azure-openai 占位 ) break case 'bedrock': @@ -963,7 +1074,9 @@ const loadAccounts = async (forceReload = false) => { Promise.resolve({ success: true, data: [] }), // claude 占位 Promise.resolve({ success: true, data: [] }), // claude-console 占位 apiClient.get('/admin/bedrock-accounts', { params }), - Promise.resolve({ success: true, data: [] }) // gemini 占位 + Promise.resolve({ success: true, data: [] }), // gemini 占位 + Promise.resolve({ success: true, data: [] }), // openai 占位 + Promise.resolve({ success: true, data: [] }) // azure-openai 占位 ) break case 'gemini': @@ -971,7 +1084,29 @@ const loadAccounts = async (forceReload = false) => { Promise.resolve({ success: true, data: [] }), // claude 占位 Promise.resolve({ success: true, data: [] }), // claude-console 占位 Promise.resolve({ success: true, data: [] }), // bedrock 占位 - apiClient.get('/admin/gemini-accounts', { params }) + apiClient.get('/admin/gemini-accounts', { params }), + Promise.resolve({ success: true, data: [] }), // openai 占位 + Promise.resolve({ success: true, data: [] }) // azure-openai 占位 + ) + break + case 'openai': + requests.push( + Promise.resolve({ success: true, data: [] }), // claude 占位 + Promise.resolve({ success: true, data: [] }), // claude-console 占位 + Promise.resolve({ success: true, data: [] }), // bedrock 占位 + Promise.resolve({ success: true, data: [] }), // gemini 占位 + apiClient.get('/admin/openai-accounts', { params }), + Promise.resolve({ success: true, data: [] }) // azure-openai 占位 + ) + break + case 'azure_openai': + requests.push( + Promise.resolve({ success: true, data: [] }), // claude 占位 + Promise.resolve({ success: true, data: [] }), // claude-console 占位 + Promise.resolve({ success: true, data: [] }), // bedrock 占位 + Promise.resolve({ success: true, data: [] }), // gemini 占位 + Promise.resolve({ success: true, data: [] }), // openai 占位 + apiClient.get('/admin/azure-openai-accounts', { params }) ) break } @@ -1077,9 +1212,11 @@ const formatNumber = (num) => { if (num === null || num === undefined) return '0' const number = Number(num) if (number >= 1000000) { - return Math.floor(number / 1000000).toLocaleString() + 'M' + return (number / 1000000).toFixed(2) + } else if (number >= 1000) { + return (number / 1000000).toFixed(4) } - return number.toLocaleString() + return (number / 1000000).toFixed(6) } // 格式化最后使用时间 @@ -1423,6 +1560,55 @@ const getClaudeAccountType = (account) => { return 'Claude' } +// 获取停止调度的原因 +const getSchedulableReason = (account) => { + if (account.schedulable !== false) return null + + // Claude Console 账户的错误状态 + if (account.platform === 'claude-console') { + if (account.status === 'unauthorized') { + return 'API Key无效或已过期(401错误)' + } + if (account.overloadStatus === 'overloaded') { + return '服务过载(529错误)' + } + if (account.rateLimitStatus === 'limited') { + return '触发限流(429错误)' + } + if (account.status === 'blocked' && account.errorMessage) { + return account.errorMessage + } + } + + // Claude 官方账户的错误状态 + if (account.platform === 'claude') { + if (account.status === 'unauthorized') { + return '认证失败(401错误)' + } + if (account.status === 'error' && account.errorMessage) { + return account.errorMessage + } + if (account.isRateLimited) { + return '触发限流(429错误)' + } + // 自动停止调度的原因 + if (account.stoppedReason) { + return account.stoppedReason + } + } + + // 通用原因 + if (account.stoppedReason) { + return account.stoppedReason + } + if (account.errorMessage) { + return account.errorMessage + } + + // 默认为手动停止 + return '手动停止调度' +} + // 获取账户状态文本 const getAccountStatusText = (account) => { // 检查是否被封锁 @@ -1508,6 +1694,54 @@ const formatRelativeTime = (dateString) => { return formatLastUsed(dateString) } +// 获取会话窗口进度条的样式类 +const getSessionProgressBarClass = (status) => { + // 根据状态返回不同的颜色类,包含防御性检查 + if (!status) { + // 无状态信息时默认为蓝色 + return 'bg-gradient-to-r from-blue-500 to-indigo-600' + } + + // 转换为小写进行比较,避免大小写问题 + const normalizedStatus = String(status).toLowerCase() + + if (normalizedStatus === 'rejected') { + // 被拒绝 - 红色 + return 'bg-gradient-to-r from-red-500 to-red-600' + } else if (normalizedStatus === 'allowed_warning') { + // 警告状态 - 橙色/黄色 + return 'bg-gradient-to-r from-yellow-500 to-orange-500' + } else { + // 正常状态(allowed 或其他) - 蓝色 + return 'bg-gradient-to-r from-blue-500 to-indigo-600' + } +} + +// 格式化费用显示 +const formatCost = (cost) => { + if (!cost || cost === 0) return '0.0000' + if (cost < 0.0001) return cost.toExponential(2) + if (cost < 0.01) return cost.toFixed(6) + if (cost < 1) return cost.toFixed(4) + return cost.toFixed(2) +} + +// 计算每日费用(估算,基于平均模型价格) +const calculateDailyCost = (account) => { + if (!account.usage || !account.usage.daily) return '0.0000' + + const dailyTokens = account.usage.daily.allTokens || 0 + if (dailyTokens === 0) return '0.0000' + + // 使用平均价格估算(基于Claude 3.5 Sonnet的价格) + // 输入: $3/1M tokens, 输出: $15/1M tokens + // 假设平均比例为 输入:输出 = 3:1 + const avgPricePerMillion = 3 * 0.75 + 15 * 0.25 // 加权平均价格 + const cost = (dailyTokens / 1000000) * avgPricePerMillion + + return formatCost(cost) +} + // 切换调度状态 // const toggleDispatch = async (account) => { // await toggleSchedulable(account) diff --git a/web/admin-spa/src/views/ApiKeysView.vue b/web/admin-spa/src/views/ApiKeysView.vue index 6b01e835..9f5a183c 100644 --- a/web/admin-spa/src/views/ApiKeysView.vue +++ b/web/admin-spa/src/views/ApiKeysView.vue @@ -416,7 +416,7 @@
- 费用限额 + 每日费用 ${{ (key.dailyCost || 0).toFixed(2) }} / ${{ key.dailyCostLimit.toFixed(2) @@ -432,9 +432,30 @@
+ +
+
+ Opus周费用 + + ${{ (key.weeklyOpusCost || 0).toFixed(2) }} / ${{ + key.weeklyOpusCostLimit.toFixed(2) + }} + +
+
+
+
+
+ { return 'bg-green-500' } +// 获取 Opus 周费用进度 +const getWeeklyOpusCostProgress = (key) => { + if (!key.weeklyOpusCostLimit || key.weeklyOpusCostLimit === 0) return 0 + const percentage = ((key.weeklyOpusCost || 0) / key.weeklyOpusCostLimit) * 100 + return Math.min(percentage, 100) +} + +// 获取 Opus 周费用进度条颜色 +const getWeeklyOpusCostProgressColor = (key) => { + const progress = getWeeklyOpusCostProgress(key) + if (progress >= 100) return 'bg-red-500' + if (progress >= 80) return 'bg-yellow-500' + return 'bg-green-500' +} + // 显示使用详情 const showUsageDetails = (apiKey) => { selectedApiKeyForDetail.value = apiKey From ef21c118e99ca070c81cf758e3bcaf4e07d9bc0d Mon Sep 17 00:00:00 2001 From: shaw Date: Sun, 31 Aug 2025 18:20:35 +0800 Subject: [PATCH 14/17] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B=E7=BA=A7=E5=88=AB=E7=9A=84=E5=B0=8F=E6=97=B6=E7=BB=9F?= =?UTF-8?q?=E8=AE=A1=E6=95=B0=E6=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在 recordApiKeyUsage 方法中添加了模型级别的小时统计记录, 用于支持基于会话窗口的详细使用统计功能。 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- src/models/redis.js | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/models/redis.js b/src/models/redis.js index db8fbf6d..2c15d4d4 100644 --- a/src/models/redis.js +++ b/src/models/redis.js @@ -478,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), From 07e9bc113788c955a1b973dbf4a7e0b7943337ce Mon Sep 17 00:00:00 2001 From: shaw Date: Sun, 31 Aug 2025 19:04:12 +0800 Subject: [PATCH 15/17] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E4=BC=9A?= =?UTF-8?q?=E8=AF=9D=E7=AA=97=E5=8F=A3=E4=BD=BF=E7=94=A8=E7=BB=9F=E8=AE=A1?= =?UTF-8?q?=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/models/redis.js | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/models/redis.js b/src/models/redis.js index 2c15d4d4..7db5b280 100644 --- a/src/models/redis.js +++ b/src/models/redis.js @@ -1468,12 +1468,12 @@ class RedisClient { } // 处理总计数据 - totalInputTokens += parseInt(data.totalInputTokens || 0) - totalOutputTokens += parseInt(data.totalOutputTokens || 0) - totalCacheCreateTokens += parseInt(data.totalCacheCreateTokens || 0) - totalCacheReadTokens += parseInt(data.totalCacheReadTokens || 0) - totalAllTokens += parseInt(data.totalAllTokens || 0) - totalRequests += parseInt(data.totalRequests || 0) + totalInputTokens += parseInt(data.inputTokens || 0) + totalOutputTokens += parseInt(data.outputTokens || 0) + totalCacheCreateTokens += parseInt(data.cacheCreateTokens || 0) + totalCacheReadTokens += parseInt(data.cacheReadTokens || 0) + totalAllTokens += parseInt(data.allTokens || 0) + totalRequests += parseInt(data.requests || 0) // 处理每个模型的数据 for (const [key, value] of Object.entries(data)) { From 9a46310238e4b67a78aabf24eb0913d578f80489 Mon Sep 17 00:00:00 2001 From: shaw Date: Sun, 31 Aug 2025 20:14:12 +0800 Subject: [PATCH 16/17] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E4=BC=9A?= =?UTF-8?q?=E8=AF=9D=E7=AA=97=E5=8F=A3=E4=BD=BF=E7=94=A8=E7=BB=9F=E8=AE=A1?= =?UTF-8?q?=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/models/redis.js | 48 +++++++++++++++++++++++++++------- src/routes/admin.js | 3 +++ src/services/pricingService.js | 16 ++++++++++++ src/utils/costCalculator.js | 8 ++++++ 4 files changed, 66 insertions(+), 9 deletions(-) diff --git a/src/models/redis.js b/src/models/redis.js index 7db5b280..b65cc8d1 100644 --- a/src/models/redis.js +++ b/src/models/redis.js @@ -1431,7 +1431,13 @@ class RedisClient { 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) @@ -1439,9 +1445,12 @@ class RedisClient { currentHour.setMilliseconds(0) while (currentHour <= endDate) { - const dateStr = `${currentHour.getUTCFullYear()}-${String(currentHour.getUTCMonth() + 1).padStart(2, '0')}-${String(currentHour.getUTCDate()).padStart(2, '0')}` - const hourStr = String(currentHour.getUTCHours()).padStart(2, '0') - const key = `account_usage:hourly:${accountId}:${dateStr}:${hourStr}` + // 使用时区转换函数来获取正确的日期和小时 + 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) } @@ -1462,18 +1471,31 @@ class RedisClient { 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 } // 处理总计数据 - totalInputTokens += parseInt(data.inputTokens || 0) - totalOutputTokens += parseInt(data.outputTokens || 0) - totalCacheCreateTokens += parseInt(data.cacheCreateTokens || 0) - totalCacheReadTokens += parseInt(data.cacheReadTokens || 0) - totalAllTokens += parseInt(data.allTokens || 0) - totalRequests += parseInt(data.requests || 0) + 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)) { @@ -1513,6 +1535,14 @@ class RedisClient { } } + 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, diff --git a/src/routes/admin.js b/src/routes/admin.js index 1436a6c0..478669d1 100644 --- a/src/routes/admin.js +++ b/src/routes/admin.js @@ -1524,7 +1524,10 @@ router.get('/claude-accounts', authenticateAdmin, async (req, res) => { 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 diff --git a/src/services/pricingService.js b/src/services/pricingService.js index 0084a2ab..606465d5 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, @@ -260,6 +261,7 @@ class PricingService { // 尝试直接匹配 if (this.pricingData[modelName]) { + logger.debug(`💰 Found exact pricing match for ${modelName}`) return this.pricingData[modelName] } @@ -304,6 +306,20 @@ 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) { diff --git a/src/utils/costCalculator.js b/src/utils/costCalculator.js index 3c3b7c41..e623abaa 100644 --- a/src/utils/costCalculator.js +++ b/src/utils/costCalculator.js @@ -31,6 +31,14 @@ const MODEL_PRICING = { cacheWrite: 18.75, 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': { From 86f5a3e6708d52ed2f561042ca9641526e37222f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 31 Aug 2025 12:19:06 +0000 Subject: [PATCH 17/17] chore: sync VERSION file with release v1.1.122 [skip ci] --- VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VERSION b/VERSION index cc868806..b176d896 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.1.121 +1.1.122