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 @@
+
+
+
+
+
+
+
+
+
API Keys 管理
+
每个用户只能创建一个 API Key
+
+
+
{{ apiKeys.length }}/1 个 Key
+
+
+
+
+
+
+
+ 创建您的第一个 API Key
+
+
+ API Key 将用于访问 Claude Relay Service
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ apiKey.name || '未命名 API Key' }}
+
+
+ 创建时间:{{ formatDate(apiKey.createdAt) }}
+
+
+
+
+ {{ apiKey.status === 'active' ? '活跃' : '已禁用' }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
已使用
+
+ {{ apiKey.used?.toLocaleString() || 0 }}
+
+
+
+
+
+
+
+
+
总额度
+
+ {{ apiKey.limit?.toLocaleString() || 0 }}
+
+
+
+
+
+
+
+
+
使用率
+
+ {{ calculateUsagePercentage(apiKey.used, apiKey.limit) }}%
+
+
+
+
+
+
+
+
+
+ 使用进度
+ {{ calculateUsagePercentage(apiKey.used, apiKey.limit) }}%
+
+
+
+
+
+
+
+
+
+
+
+ {{ error }}
+
+
+
+
+ {{ successMessage }}
+
+
+
+
+
+
+
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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
API Keys
+
+ {{ stats.keyCount }}
+
+
+
+
+
+
+
+
+
+
+
+
+
总使用量
+
+ {{ stats.totalUsage.toLocaleString() }}
+
+
+
+
+
+
+
+
+
+
+
+
+
总额度
+
+ {{ stats.totalLimit.toLocaleString() }}
+
+
+
+
+
+
+
+
+
+
+
+
+
使用率
+
+ {{ stats.percentage }}%
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
API Key 详细统计
+
+
+
+
+
+
+
+
+
+ {{ keyStats.name || '未命名 API Key' }}
+
+
ID: {{ keyStats.id }}
+
+
+
+
使用率
+
+ {{ keyStats.percentage }}%
+
+
+
+
+
+
+
+ 已使用:
+ {{
+ keyStats.used.toLocaleString()
+ }}
+
+
+ 总额度:
+ {{
+ keyStats.limit.toLocaleString()
+ }}
+
+
+
+
+
+
+
+
+
+ 额度即将用尽,请注意使用
+
+
+
+ 额度使用较多,建议关注使用情况
+
+
+
+
+
+
+
+
暂无使用数据
+
+ 创建 API Key 后开始使用即可查看详细统计
+
+
+
+
+
+ {{ error }}
+
+
+
+
+
+
+
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"
/>
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ 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 @@
+
+
+
+
+
+
+
+
+ {{
+ userInfo.groups && userInfo.groups.length > 0
+ ? extractGroupName(userInfo.groups[0])
+ : '未知部门'
+ }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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