diff --git a/.env.example b/.env.example index 8fb9648e..eae3d0ed 100644 --- a/.env.example +++ b/.env.example @@ -61,3 +61,45 @@ TRUST_PROXY=true # 🔒 客户端限制(可选) # ALLOW_CUSTOM_CLIENTS=false + +# 🔐 LDAP 认证配置 +LDAP_ENABLED=false +LDAP_URL=ldaps://ldap-1.test1.bj.yxops.net:636 +LDAP_BIND_DN=cn=admin,dc=example,dc=com +LDAP_BIND_PASSWORD=admin_password +LDAP_SEARCH_BASE=dc=example,dc=com +LDAP_SEARCH_FILTER=(uid={{username}}) +LDAP_SEARCH_ATTRIBUTES=dn,uid,cn,mail,givenName,sn +LDAP_TIMEOUT=5000 +LDAP_CONNECT_TIMEOUT=10000 + +# 🔒 LDAP TLS/SSL 配置 (用于 ldaps:// URL) +# 是否忽略证书验证错误 (设置为false可忽略自签名证书错误) +LDAP_TLS_REJECT_UNAUTHORIZED=true +# CA 证书文件路径 (可选,用于自定义CA证书) +# LDAP_TLS_CA_FILE=/path/to/ca-cert.pem +# 客户端证书文件路径 (可选,用于双向认证) +# LDAP_TLS_CERT_FILE=/path/to/client-cert.pem +# 客户端私钥文件路径 (可选,用于双向认证) +# LDAP_TLS_KEY_FILE=/path/to/client-key.pem +# 服务器名称 (可选,用于 SNI) +# LDAP_TLS_SERVERNAME=ldap.example.com + +# 🗺️ LDAP 用户属性映射 +LDAP_USER_ATTR_USERNAME=uid +LDAP_USER_ATTR_DISPLAY_NAME=cn +LDAP_USER_ATTR_EMAIL=mail +LDAP_USER_ATTR_FIRST_NAME=givenName +LDAP_USER_ATTR_LAST_NAME=sn + +# 👥 用户管理配置 +USER_MANAGEMENT_ENABLED=false +DEFAULT_USER_ROLE=user +USER_SESSION_TIMEOUT=86400000 +MAX_API_KEYS_PER_USER=5 + +# 📢 Webhook 通知配置 +WEBHOOK_ENABLED=true +WEBHOOK_URLS=https://your-webhook-url.com/notify,https://backup-webhook.com/notify +WEBHOOK_TIMEOUT=10000 +WEBHOOK_RETRIES=3 \ No newline at end of file diff --git a/config/config.example.js b/config/config.example.js index 9bab4b9f..5b8786b6 100644 --- a/config/config.example.js +++ b/config/config.example.js @@ -127,6 +127,67 @@ const config = { allowCustomClients: process.env.ALLOW_CUSTOM_CLIENTS === 'true' }, + // 🔐 LDAP 认证配置 + ldap: { + enabled: process.env.LDAP_ENABLED === 'true', + server: { + url: process.env.LDAP_URL || 'ldap://localhost:389', + bindDN: process.env.LDAP_BIND_DN || 'cn=admin,dc=example,dc=com', + bindCredentials: process.env.LDAP_BIND_PASSWORD || 'admin', + searchBase: process.env.LDAP_SEARCH_BASE || 'dc=example,dc=com', + searchFilter: process.env.LDAP_SEARCH_FILTER || '(uid={{username}})', + searchAttributes: process.env.LDAP_SEARCH_ATTRIBUTES + ? process.env.LDAP_SEARCH_ATTRIBUTES.split(',') + : ['dn', 'uid', 'cn', 'mail', 'givenName', 'sn'], + timeout: parseInt(process.env.LDAP_TIMEOUT) || 5000, + connectTimeout: parseInt(process.env.LDAP_CONNECT_TIMEOUT) || 10000, + // TLS/SSL 配置 + tls: { + // 是否忽略证书错误 (用于自签名证书) + rejectUnauthorized: process.env.LDAP_TLS_REJECT_UNAUTHORIZED !== 'false', // 默认验证证书,设置为false则忽略 + // CA证书文件路径 (可选,用于自定义CA证书) + ca: process.env.LDAP_TLS_CA_FILE + ? require('fs').readFileSync(process.env.LDAP_TLS_CA_FILE) + : undefined, + // 客户端证书文件路径 (可选,用于双向认证) + cert: process.env.LDAP_TLS_CERT_FILE + ? require('fs').readFileSync(process.env.LDAP_TLS_CERT_FILE) + : undefined, + // 客户端私钥文件路径 (可选,用于双向认证) + key: process.env.LDAP_TLS_KEY_FILE + ? require('fs').readFileSync(process.env.LDAP_TLS_KEY_FILE) + : undefined, + // 服务器名称 (用于SNI,可选) + servername: process.env.LDAP_TLS_SERVERNAME || undefined + } + }, + userMapping: { + username: process.env.LDAP_USER_ATTR_USERNAME || 'uid', + displayName: process.env.LDAP_USER_ATTR_DISPLAY_NAME || 'cn', + email: process.env.LDAP_USER_ATTR_EMAIL || 'mail', + firstName: process.env.LDAP_USER_ATTR_FIRST_NAME || 'givenName', + lastName: process.env.LDAP_USER_ATTR_LAST_NAME || 'sn' + } + }, + + // 👥 用户管理配置 + userManagement: { + enabled: process.env.USER_MANAGEMENT_ENABLED === 'true', + defaultUserRole: process.env.DEFAULT_USER_ROLE || 'user', + userSessionTimeout: parseInt(process.env.USER_SESSION_TIMEOUT) || 86400000, // 24小时 + maxApiKeysPerUser: parseInt(process.env.MAX_API_KEYS_PER_USER) || 5 + }, + + // 📢 Webhook通知配置 + webhook: { + enabled: process.env.WEBHOOK_ENABLED !== 'false', // 默认启用 + urls: process.env.WEBHOOK_URLS + ? process.env.WEBHOOK_URLS.split(',').map((url) => url.trim()) + : [], + timeout: parseInt(process.env.WEBHOOK_TIMEOUT) || 10000, // 10秒超时 + retries: parseInt(process.env.WEBHOOK_RETRIES) || 3 // 重试3次 + }, + // 🛠️ 开发配置 development: { debug: process.env.DEBUG === 'true', diff --git a/package-lock.json b/package-lock.json index 98b89998..9831ecd8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,6 +24,7 @@ "https-proxy-agent": "^7.0.2", "inquirer": "^8.2.6", "ioredis": "^5.3.2", + "ldapjs": "^3.0.7", "morgan": "^1.10.0", "ora": "^5.4.1", "rate-limiter-flexible": "^5.0.5", @@ -2048,6 +2049,101 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@ldapjs/asn1": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/@ldapjs/asn1/-/asn1-2.0.0.tgz", + "integrity": "sha512-G9+DkEOirNgdPmD0I8nu57ygQJKOOgFEMKknEuQvIHbGLwP3ny1mY+OTUYLCbCaGJP4sox5eYgBJRuSUpnAddA==", + "deprecated": "This package has been decomissioned. See https://github.com/ldapjs/node-ldapjs/blob/8ffd0bc9c149088a10ec4c1ec6a18450f76ad05d/README.md", + "license": "MIT" + }, + "node_modules/@ldapjs/attribute": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/@ldapjs/attribute/-/attribute-1.0.0.tgz", + "integrity": "sha512-ptMl2d/5xJ0q+RgmnqOi3Zgwk/TMJYG7dYMC0Keko+yZU6n+oFM59MjQOUht5pxJeS4FWrImhu/LebX24vJNRQ==", + "deprecated": "This package has been decomissioned. See https://github.com/ldapjs/node-ldapjs/blob/8ffd0bc9c149088a10ec4c1ec6a18450f76ad05d/README.md", + "license": "MIT", + "dependencies": { + "@ldapjs/asn1": "2.0.0", + "@ldapjs/protocol": "^1.2.1", + "process-warning": "^2.1.0" + } + }, + "node_modules/@ldapjs/change": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/@ldapjs/change/-/change-1.0.0.tgz", + "integrity": "sha512-EOQNFH1RIku3M1s0OAJOzGfAohuFYXFY4s73wOhRm4KFGhmQQ7MChOh2YtYu9Kwgvuq1B0xKciXVzHCGkB5V+Q==", + "deprecated": "This package has been decomissioned. See https://github.com/ldapjs/node-ldapjs/blob/8ffd0bc9c149088a10ec4c1ec6a18450f76ad05d/README.md", + "license": "MIT", + "dependencies": { + "@ldapjs/asn1": "2.0.0", + "@ldapjs/attribute": "1.0.0" + } + }, + "node_modules/@ldapjs/controls": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/@ldapjs/controls/-/controls-2.1.0.tgz", + "integrity": "sha512-2pFdD1yRC9V9hXfAWvCCO2RRWK9OdIEcJIos/9cCVP9O4k72BY1bLDQQ4KpUoJnl4y/JoD4iFgM+YWT3IfITWw==", + "deprecated": "This package has been decomissioned. See https://github.com/ldapjs/node-ldapjs/blob/8ffd0bc9c149088a10ec4c1ec6a18450f76ad05d/README.md", + "license": "MIT", + "dependencies": { + "@ldapjs/asn1": "^1.2.0", + "@ldapjs/protocol": "^1.2.1" + } + }, + "node_modules/@ldapjs/controls/node_modules/@ldapjs/asn1": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/@ldapjs/asn1/-/asn1-1.2.0.tgz", + "integrity": "sha512-KX/qQJ2xxzvO2/WOvr1UdQ+8P5dVvuOLk/C9b1bIkXxZss8BaR28njXdPgFCpj5aHaf1t8PmuVnea+N9YG9YMw==", + "deprecated": "This package has been decomissioned. See https://github.com/ldapjs/node-ldapjs/blob/8ffd0bc9c149088a10ec4c1ec6a18450f76ad05d/README.md", + "license": "MIT" + }, + "node_modules/@ldapjs/dn": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/@ldapjs/dn/-/dn-1.1.0.tgz", + "integrity": "sha512-R72zH5ZeBj/Fujf/yBu78YzpJjJXG46YHFo5E4W1EqfNpo1UsVPqdLrRMXeKIsJT3x9dJVIfR6OpzgINlKpi0A==", + "deprecated": "This package has been decomissioned. See https://github.com/ldapjs/node-ldapjs/blob/8ffd0bc9c149088a10ec4c1ec6a18450f76ad05d/README.md", + "license": "MIT", + "dependencies": { + "@ldapjs/asn1": "2.0.0", + "process-warning": "^2.1.0" + } + }, + "node_modules/@ldapjs/filter": { + "version": "2.1.1", + "resolved": "https://registry.npmmirror.com/@ldapjs/filter/-/filter-2.1.1.tgz", + "integrity": "sha512-TwPK5eEgNdUO1ABPBUQabcZ+h9heDORE4V9WNZqCtYLKc06+6+UAJ3IAbr0L0bYTnkkWC/JEQD2F+zAFsuikNw==", + "deprecated": "This package has been decomissioned. See https://github.com/ldapjs/node-ldapjs/blob/8ffd0bc9c149088a10ec4c1ec6a18450f76ad05d/README.md", + "license": "MIT", + "dependencies": { + "@ldapjs/asn1": "2.0.0", + "@ldapjs/protocol": "^1.2.1", + "process-warning": "^2.1.0" + } + }, + "node_modules/@ldapjs/messages": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/@ldapjs/messages/-/messages-1.3.0.tgz", + "integrity": "sha512-K7xZpXJ21bj92jS35wtRbdcNrwmxAtPwy4myeh9duy/eR3xQKvikVycbdWVzkYEAVE5Ce520VXNOwCHjomjCZw==", + "deprecated": "This package has been decomissioned. See https://github.com/ldapjs/node-ldapjs/blob/8ffd0bc9c149088a10ec4c1ec6a18450f76ad05d/README.md", + "license": "MIT", + "dependencies": { + "@ldapjs/asn1": "^2.0.0", + "@ldapjs/attribute": "^1.0.0", + "@ldapjs/change": "^1.0.0", + "@ldapjs/controls": "^2.1.0", + "@ldapjs/dn": "^1.1.0", + "@ldapjs/filter": "^2.1.1", + "@ldapjs/protocol": "^1.2.1", + "process-warning": "^2.2.0" + } + }, + "node_modules/@ldapjs/protocol": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/@ldapjs/protocol/-/protocol-1.2.1.tgz", + "integrity": "sha512-O89xFDLW2gBoZWNXuXpBSM32/KealKCTb3JGtJdtUQc7RjAk8XzrRgyz02cPAwGKwKPxy0ivuC7UP9bmN87egQ==", + "deprecated": "This package has been decomissioned. See https://github.com/ldapjs/node-ldapjs/blob/8ffd0bc9c149088a10ec4c1ec6a18450f76ad05d/README.md", + "license": "MIT" + }, "node_modules/@noble/hashes": { "version": "1.8.0", "resolved": "https://registry.npmmirror.com/@noble/hashes/-/hashes-1.8.0.tgz", @@ -2921,6 +3017,12 @@ "dev": true, "license": "ISC" }, + "node_modules/abstract-logging": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/abstract-logging/-/abstract-logging-2.0.1.tgz", + "integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==", + "license": "MIT" + }, "node_modules/accepts": { "version": "1.3.8", "resolved": "https://registry.npmmirror.com/accepts/-/accepts-1.3.8.tgz", @@ -3077,6 +3179,15 @@ "dev": true, "license": "MIT" }, + "node_modules/assert-plus": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/assert-plus/-/assert-plus-1.0.0.tgz", + "integrity": "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==", + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, "node_modules/astral-regex": { "version": "2.0.0", "resolved": "https://registry.npmmirror.com/astral-regex/-/astral-regex-2.0.0.tgz", @@ -3225,6 +3336,18 @@ "@babel/core": "^7.0.0" } }, + "node_modules/backoff": { + "version": "2.5.0", + "resolved": "https://registry.npmmirror.com/backoff/-/backoff-2.5.0.tgz", + "integrity": "sha512-wC5ihrnUXmR2douXmXLCe5O3zg3GKIyvRi/hi58a/XyRxVI+3/yM0PYueQOZXPXQ9pxBislYkw+sF9b7C/RuMA==", + "license": "MIT", + "dependencies": { + "precond": "0.2" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz", @@ -3912,6 +4035,12 @@ "dev": true, "license": "MIT" }, + "node_modules/core-util-is": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/core-util-is/-/core-util-is-1.0.2.tgz", + "integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==", + "license": "MIT" + }, "node_modules/cors": { "version": "2.8.5", "resolved": "https://registry.npmmirror.com/cors/-/cors-2.8.5.tgz", @@ -4633,6 +4762,15 @@ "node": ">=4" } }, + "node_modules/extsprintf": { + "version": "1.4.1", + "resolved": "https://registry.npmmirror.com/extsprintf/-/extsprintf-1.4.1.tgz", + "integrity": "sha512-Wrk35e8ydCKDj/ArClo1VrPVmN8zph5V4AtHwIuHhvMXsKf73UT3BOD+azBIW+3wOJ4FhEH7zyaJCFvChjYvMA==", + "engines": [ + "node >=0.6.0" + ], + "license": "MIT" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmmirror.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -6524,6 +6662,29 @@ "integrity": "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==", "license": "MIT" }, + "node_modules/ldapjs": { + "version": "3.0.7", + "resolved": "https://registry.npmmirror.com/ldapjs/-/ldapjs-3.0.7.tgz", + "integrity": "sha512-1ky+WrN+4CFMuoekUOv7Y1037XWdjKpu0xAPwSP+9KdvmV9PG+qOKlssDV6a+U32apwxdD3is/BZcWOYzN30cg==", + "deprecated": "This package has been decomissioned. See https://github.com/ldapjs/node-ldapjs/blob/8ffd0bc9c149088a10ec4c1ec6a18450f76ad05d/README.md", + "license": "MIT", + "dependencies": { + "@ldapjs/asn1": "^2.0.0", + "@ldapjs/attribute": "^1.0.0", + "@ldapjs/change": "^1.0.0", + "@ldapjs/controls": "^2.1.0", + "@ldapjs/dn": "^1.1.0", + "@ldapjs/filter": "^2.1.1", + "@ldapjs/messages": "^1.3.0", + "@ldapjs/protocol": "^1.2.1", + "abstract-logging": "^2.0.1", + "assert-plus": "^1.0.0", + "backoff": "^2.5.0", + "once": "^1.4.0", + "vasync": "^2.2.1", + "verror": "^1.10.1" + } + }, "node_modules/leven": { "version": "3.1.0", "resolved": "https://registry.npmmirror.com/leven/-/leven-3.1.0.tgz", @@ -7096,7 +7257,6 @@ "version": "1.4.0", "resolved": "https://registry.npmmirror.com/once/-/once-1.4.0.tgz", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dev": true, "license": "ISC", "dependencies": { "wrappy": "1" @@ -7401,6 +7561,14 @@ "node": ">=8" } }, + "node_modules/precond": { + "version": "0.2.3", + "resolved": "https://registry.npmmirror.com/precond/-/precond-0.2.3.tgz", + "integrity": "sha512-QCYG84SgGyGzqJ/vlMsxeXd/pgL/I94ixdNFyh1PusWmTCyVfPJjZ1K1jvHtsbfnXQs2TSkEP2fR7QiMZAnKFQ==", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmmirror.com/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -7468,6 +7636,12 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/process-warning": { + "version": "2.3.2", + "resolved": "https://registry.npmmirror.com/process-warning/-/process-warning-2.3.2.tgz", + "integrity": "sha512-n9wh8tvBe5sFmsqlg+XQhaQLumwpqoAUruLwjCopgTmUBjJ/fjtBsJzKleCaIGBOMXYEhp1YfKl4d7rJ5ZKJGA==", + "license": "MIT" + }, "node_modules/prompts": { "version": "2.4.2", "resolved": "https://registry.npmmirror.com/prompts/-/prompts-2.4.2.tgz", @@ -8757,6 +8931,46 @@ "node": ">= 0.8" } }, + "node_modules/vasync": { + "version": "2.2.1", + "resolved": "https://registry.npmmirror.com/vasync/-/vasync-2.2.1.tgz", + "integrity": "sha512-Hq72JaTpcTFdWiNA4Y22Amej2GH3BFmBaKPPlDZ4/oC8HNn2ISHLkFrJU4Ds8R3jcUi7oo5Y9jcMHKjES+N9wQ==", + "engines": [ + "node >=0.6.0" + ], + "license": "MIT", + "dependencies": { + "verror": "1.10.0" + } + }, + "node_modules/vasync/node_modules/verror": { + "version": "1.10.0", + "resolved": "https://registry.npmmirror.com/verror/-/verror-1.10.0.tgz", + "integrity": "sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw==", + "engines": [ + "node >=0.6.0" + ], + "license": "MIT", + "dependencies": { + "assert-plus": "^1.0.0", + "core-util-is": "1.0.2", + "extsprintf": "^1.2.0" + } + }, + "node_modules/verror": { + "version": "1.10.1", + "resolved": "https://registry.npmmirror.com/verror/-/verror-1.10.1.tgz", + "integrity": "sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg==", + "license": "MIT", + "dependencies": { + "assert-plus": "^1.0.0", + "core-util-is": "1.0.2", + "extsprintf": "^1.2.0" + }, + "engines": { + "node": ">=0.6.0" + } + }, "node_modules/walker": { "version": "1.0.8", "resolved": "https://registry.npmmirror.com/walker/-/walker-1.0.8.tgz", @@ -8911,7 +9125,6 @@ "version": "1.0.2", "resolved": "https://registry.npmmirror.com/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "dev": true, "license": "ISC" }, "node_modules/write-file-atomic": { diff --git a/package.json b/package.json index 48d7c604..86424cea 100644 --- a/package.json +++ b/package.json @@ -63,6 +63,7 @@ "https-proxy-agent": "^7.0.2", "inquirer": "^8.2.6", "ioredis": "^5.3.2", + "ldapjs": "^3.0.7", "morgan": "^1.10.0", "ora": "^5.4.1", "rate-limiter-flexible": "^5.0.5", diff --git a/src/app.js b/src/app.js index 25550664..31d7b7f3 100644 --- a/src/app.js +++ b/src/app.js @@ -21,6 +21,7 @@ const geminiRoutes = require('./routes/geminiRoutes') const openaiGeminiRoutes = require('./routes/openaiGeminiRoutes') const openaiClaudeRoutes = require('./routes/openaiClaudeRoutes') const openaiRoutes = require('./routes/openaiRoutes') +const userRoutes = require('./routes/userRoutes') const azureOpenaiRoutes = require('./routes/azureOpenaiRoutes') const webhookRoutes = require('./routes/webhook') @@ -246,6 +247,7 @@ class Application { this.app.use('/api', apiRoutes) this.app.use('/claude', apiRoutes) // /claude 路由别名,与 /api 功能相同 this.app.use('/admin', adminRoutes) + this.app.use('/users', userRoutes) // 使用 web 路由(包含 auth 和页面重定向) this.app.use('/web', webRoutes) this.app.use('/apiStats', apiStatsRoutes) diff --git a/src/middleware/auth.js b/src/middleware/auth.js index 89369c41..aadcf0d9 100644 --- a/src/middleware/auth.js +++ b/src/middleware/auth.js @@ -1,4 +1,5 @@ const apiKeyService = require('../services/apiKeyService') +const userService = require('../services/userService') const logger = require('../utils/logger') const redis = require('../models/redis') // const { RateLimiterRedis } = require('rate-limiter-flexible') // 暂时未使用 @@ -525,6 +526,234 @@ const authenticateAdmin = async (req, res, next) => { } } +// 👤 用户验证中间件 +const authenticateUser = async (req, res, next) => { + const startTime = Date.now() + + try { + // 安全提取用户session token,支持多种方式 + const sessionToken = + req.headers['authorization']?.replace(/^Bearer\s+/i, '') || + req.cookies?.userToken || + req.headers['x-user-token'] + + if (!sessionToken) { + logger.security(`🔒 Missing user session token attempt from ${req.ip || 'unknown'}`) + return res.status(401).json({ + error: 'Missing user session token', + message: 'Please login to access this resource' + }) + } + + // 基本token格式验证 + if (typeof sessionToken !== 'string' || sessionToken.length < 32 || sessionToken.length > 128) { + logger.security(`🔒 Invalid user session token format from ${req.ip || 'unknown'}`) + return res.status(401).json({ + error: 'Invalid session token format', + message: 'Session token format is invalid' + }) + } + + // 验证用户会话 + const sessionValidation = await userService.validateUserSession(sessionToken) + + if (!sessionValidation) { + logger.security(`🔒 Invalid user session token attempt from ${req.ip || 'unknown'}`) + return res.status(401).json({ + error: 'Invalid session token', + message: 'Invalid or expired user session' + }) + } + + const { session, user } = sessionValidation + + // 检查用户是否被禁用 + if (!user.isActive) { + logger.security( + `🔒 Disabled user login attempt: ${user.username} from ${req.ip || 'unknown'}` + ) + return res.status(403).json({ + error: 'Account disabled', + message: 'Your account has been disabled. Please contact administrator.' + }) + } + + // 设置用户信息(只包含必要信息) + req.user = { + id: user.id, + username: user.username, + email: user.email, + displayName: user.displayName, + firstName: user.firstName, + lastName: user.lastName, + role: user.role, + sessionToken, + sessionCreatedAt: session.createdAt + } + + const authDuration = Date.now() - startTime + logger.info(`👤 User authenticated: ${user.username} (${user.id}) in ${authDuration}ms`) + + return next() + } catch (error) { + const authDuration = Date.now() - startTime + logger.error(`❌ User authentication error (${authDuration}ms):`, { + error: error.message, + ip: req.ip, + userAgent: req.get('User-Agent'), + url: req.originalUrl + }) + + return res.status(500).json({ + error: 'Authentication error', + message: 'Internal server error during user authentication' + }) + } +} + +// 👤 用户或管理员验证中间件(支持两种身份) +const authenticateUserOrAdmin = async (req, res, next) => { + const startTime = Date.now() + + try { + // 检查是否有管理员token + const adminToken = + req.headers['authorization']?.replace(/^Bearer\s+/i, '') || + req.cookies?.adminToken || + req.headers['x-admin-token'] + + // 检查是否有用户session token + const userToken = + req.headers['x-user-token'] || + req.cookies?.userToken || + (!adminToken ? req.headers['authorization']?.replace(/^Bearer\s+/i, '') : null) + + // 优先尝试管理员认证 + if (adminToken) { + try { + const adminSession = await redis.getSession(adminToken) + if (adminSession && Object.keys(adminSession).length > 0) { + req.admin = { + id: adminSession.adminId || 'admin', + username: adminSession.username, + sessionId: adminToken, + loginTime: adminSession.loginTime + } + req.userType = 'admin' + + const authDuration = Date.now() - startTime + logger.security(`🔐 Admin authenticated: ${adminSession.username} in ${authDuration}ms`) + return next() + } + } catch (error) { + logger.debug('Admin authentication failed, trying user authentication:', error.message) + } + } + + // 尝试用户认证 + if (userToken) { + try { + const sessionValidation = await userService.validateUserSession(userToken) + if (sessionValidation) { + const { session, user } = sessionValidation + + if (user.isActive) { + req.user = { + id: user.id, + username: user.username, + email: user.email, + displayName: user.displayName, + firstName: user.firstName, + lastName: user.lastName, + role: user.role, + sessionToken: userToken, + sessionCreatedAt: session.createdAt + } + req.userType = 'user' + + const authDuration = Date.now() - startTime + logger.info(`👤 User authenticated: ${user.username} (${user.id}) in ${authDuration}ms`) + return next() + } + } + } catch (error) { + logger.debug('User authentication failed:', error.message) + } + } + + // 如果都失败了,返回未授权 + logger.security(`🔒 Authentication failed from ${req.ip || 'unknown'}`) + return res.status(401).json({ + error: 'Authentication required', + message: 'Please login as user or admin to access this resource' + }) + } catch (error) { + const authDuration = Date.now() - startTime + logger.error(`❌ User/Admin authentication error (${authDuration}ms):`, { + error: error.message, + ip: req.ip, + userAgent: req.get('User-Agent'), + url: req.originalUrl + }) + + return res.status(500).json({ + error: 'Authentication error', + message: 'Internal server error during authentication' + }) + } +} + +// 🛡️ 权限检查中间件 +const requireRole = (allowedRoles) => (req, res, next) => { + // 管理员始终有权限 + if (req.admin) { + return next() + } + + // 检查用户角色 + if (req.user) { + const userRole = req.user.role + const allowed = Array.isArray(allowedRoles) ? allowedRoles : [allowedRoles] + + if (allowed.includes(userRole)) { + return next() + } else { + logger.security( + `🚫 Access denied for user ${req.user.username} (role: ${userRole}) to ${req.originalUrl}` + ) + return res.status(403).json({ + error: 'Insufficient permissions', + message: `This resource requires one of the following roles: ${allowed.join(', ')}` + }) + } + } + + return res.status(401).json({ + error: 'Authentication required', + message: 'Please login to access this resource' + }) +} + +// 🔒 管理员权限检查中间件 +const requireAdmin = (req, res, next) => { + if (req.admin) { + return next() + } + + // 检查是否是admin角色的用户 + if (req.user && req.user.role === 'admin') { + return next() + } + + logger.security( + `🚫 Admin access denied for ${req.user?.username || 'unknown'} from ${req.ip || 'unknown'}` + ) + return res.status(403).json({ + error: 'Admin access required', + message: 'This resource requires administrator privileges' + }) +} + // 注意:使用统计现在直接在/api/v1/messages路由中处理, // 以便从Claude API响应中提取真实的usage数据 @@ -881,6 +1110,10 @@ const requestSizeLimit = (req, res, next) => { module.exports = { authenticateApiKey, authenticateAdmin, + authenticateUser, + authenticateUserOrAdmin, + requireRole, + requireAdmin, corsMiddleware, requestLogger, securityMiddleware, diff --git a/src/models/redis.js b/src/models/redis.js index 1515ce3c..145f94cd 100644 --- a/src/models/redis.js +++ b/src/models/redis.js @@ -1429,7 +1429,7 @@ class RedisClient { const luaScript = ` local key = KEYS[1] local current = tonumber(redis.call('get', key) or "0") - + if current <= 0 then redis.call('del', key) return 0 @@ -1465,6 +1465,32 @@ class RedisClient { } } + // 🔧 Basic Redis operations wrapper methods for convenience + async get(key) { + const client = this.getClientSafe() + return await client.get(key) + } + + async set(key, value, ...args) { + const client = this.getClientSafe() + return await client.set(key, value, ...args) + } + + async setex(key, ttl, value) { + const client = this.getClientSafe() + return await client.setex(key, ttl, value) + } + + async del(...keys) { + const client = this.getClientSafe() + return await client.del(...keys) + } + + async keys(pattern) { + const client = this.getClientSafe() + return await client.keys(pattern) + } + // 📊 获取账户会话窗口内的使用统计(包含模型细分) async getAccountSessionWindowUsage(accountId, windowStart, windowEnd) { try { diff --git a/src/routes/admin.js b/src/routes/admin.js index 478669d1..28469d7b 100644 --- a/src/routes/admin.js +++ b/src/routes/admin.js @@ -1102,7 +1102,7 @@ router.delete('/api-keys/:keyId', authenticateAdmin, async (req, res) => { try { const { keyId } = req.params - await apiKeyService.deleteApiKey(keyId) + await apiKeyService.deleteApiKey(keyId, req.admin.username, 'admin') logger.success(`🗑️ Admin deleted API key: ${keyId}`) return res.json({ success: true, message: 'API key deleted successfully' }) @@ -1112,6 +1112,32 @@ router.delete('/api-keys/:keyId', authenticateAdmin, async (req, res) => { } }) +// 📋 获取已删除的API Keys +router.get('/api-keys/deleted', authenticateAdmin, async (req, res) => { + try { + const deletedApiKeys = await apiKeyService.getAllApiKeys(true) // Include deleted + const onlyDeleted = deletedApiKeys.filter((key) => key.isDeleted === 'true') + + // Add additional metadata for deleted keys + const enrichedKeys = onlyDeleted.map((key) => ({ + ...key, + isDeleted: key.isDeleted === 'true', + deletedAt: key.deletedAt, + deletedBy: key.deletedBy, + deletedByType: key.deletedByType, + canRestore: false // Deleted keys cannot be restored per requirement + })) + + logger.success(`📋 Admin retrieved ${enrichedKeys.length} deleted API keys`) + return res.json({ success: true, apiKeys: enrichedKeys, total: enrichedKeys.length }) + } catch (error) { + logger.error('❌ Failed to get deleted API keys:', error) + return res + .status(500) + .json({ error: 'Failed to retrieve deleted API keys', message: error.message }) + } +}) + // 👥 账户分组管理 // 创建账户分组 @@ -2527,7 +2553,7 @@ router.post('/gemini-accounts/generate-auth-url', authenticateAdmin, async (req, state: authState, codeVerifier, redirectUri: finalRedirectUri - } = await geminiAccountService.generateAuthUrl(state, redirectUri) + } = await geminiAccountService.generateAuthUrl(state, redirectUri, proxy) // 创建 OAuth 会话,包含 codeVerifier 和代理配置 const sessionId = authState diff --git a/src/routes/azureOpenaiRoutes.js b/src/routes/azureOpenaiRoutes.js index 50041980..ca0aa8fe 100644 --- a/src/routes/azureOpenaiRoutes.js +++ b/src/routes/azureOpenaiRoutes.js @@ -14,8 +14,11 @@ const ALLOWED_MODELS = { 'gpt-4-turbo', 'gpt-4o', 'gpt-4o-mini', + 'gpt-5', + 'gpt-5-mini', 'gpt-35-turbo', - 'gpt-35-turbo-16k' + 'gpt-35-turbo-16k', + 'codex-mini' ], EMBEDDING_MODELS: ['text-embedding-ada-002', 'text-embedding-3-small', 'text-embedding-3-large'] } @@ -234,6 +237,99 @@ router.post('/chat/completions', authenticateApiKey, async (req, res) => { } }) +// 处理响应请求 (gpt-5, gpt-5-mini, codex-mini models) +router.post('/responses', authenticateApiKey, async (req, res) => { + const requestId = `azure_resp_${Date.now()}_${crypto.randomBytes(8).toString('hex')}` + const sessionId = req.sessionId || req.headers['x-session-id'] || null + + logger.info(`🚀 Azure OpenAI Responses Request ${requestId}`, { + apiKeyId: req.apiKey?.id, + sessionId, + model: req.body.model, + stream: req.body.stream || false, + messages: req.body.messages?.length || 0 + }) + + try { + // 获取绑定的 Azure OpenAI 账户 + let account = null + if (req.apiKey?.azureOpenaiAccountId) { + account = await azureOpenaiAccountService.getAccount(req.apiKey.azureOpenaiAccountId) + if (!account) { + logger.warn(`Bound Azure OpenAI account not found: ${req.apiKey.azureOpenaiAccountId}`) + } + } + + // 如果没有绑定账户或账户不可用,选择一个可用账户 + if (!account || account.isActive !== 'true') { + account = await azureOpenaiAccountService.selectAvailableAccount(sessionId) + } + + // 发送请求到 Azure OpenAI + const response = await azureOpenaiRelayService.handleAzureOpenAIRequest({ + account, + requestBody: req.body, + headers: req.headers, + isStream: req.body.stream || false, + endpoint: 'responses' + }) + + // 处理流式响应 + if (req.body.stream) { + await azureOpenaiRelayService.handleStreamResponse(response, res, { + onEnd: async ({ usageData, actualModel }) => { + if (usageData) { + const modelToRecord = actualModel || req.body.model || 'unknown' + await usageReporter.reportOnce( + requestId, + usageData, + req.apiKey.id, + modelToRecord, + account.id + ) + } + }, + onError: (error) => { + logger.error(`Stream error for request ${requestId}:`, error) + } + }) + } else { + // 处理非流式响应 + const { usageData, actualModel } = azureOpenaiRelayService.handleNonStreamResponse( + response, + res + ) + + if (usageData) { + const modelToRecord = actualModel || req.body.model || 'unknown' + await usageReporter.reportOnce( + requestId, + usageData, + req.apiKey.id, + modelToRecord, + account.id + ) + } + } + } catch (error) { + logger.error(`Azure OpenAI responses request failed ${requestId}:`, error) + + if (!res.headersSent) { + const statusCode = error.response?.status || 500 + const errorMessage = + error.response?.data?.error?.message || error.message || 'Internal server error' + + res.status(statusCode).json({ + error: { + message: errorMessage, + type: 'azure_openai_error', + code: error.code || 'unknown' + } + }) + } + } +}) + // 处理嵌入请求 router.post('/embeddings', authenticateApiKey, async (req, res) => { const requestId = `azure_embed_${Date.now()}_${crypto.randomBytes(8).toString('hex')}` diff --git a/src/routes/geminiRoutes.js b/src/routes/geminiRoutes.js index 2a1525f2..75f633d0 100644 --- a/src/routes/geminiRoutes.js +++ b/src/routes/geminiRoutes.js @@ -331,7 +331,17 @@ async function handleLoadCodeAssist(req, res) { apiKeyId: req.apiKey?.id || 'unknown' }) - const client = await geminiAccountService.getOauthClient(accessToken, refreshToken) + // 解析账户的代理配置 + let proxyConfig = null + if (account.proxy) { + try { + proxyConfig = typeof account.proxy === 'string' ? JSON.parse(account.proxy) : account.proxy + } catch (e) { + logger.warn('Failed to parse proxy configuration:', e) + } + } + + const client = await geminiAccountService.getOauthClient(accessToken, refreshToken, proxyConfig) // 根据账户配置决定项目ID: // 1. 如果账户有项目ID -> 使用账户的项目ID(强制覆盖) @@ -348,7 +358,11 @@ async function handleLoadCodeAssist(req, res) { logger.info('No project ID in account for loadCodeAssist, removing project parameter') } - const response = await geminiAccountService.loadCodeAssist(client, effectiveProjectId) + const response = await geminiAccountService.loadCodeAssist( + client, + effectiveProjectId, + proxyConfig + ) res.json(response) } catch (error) { @@ -387,7 +401,17 @@ async function handleOnboardUser(req, res) { apiKeyId: req.apiKey?.id || 'unknown' }) - const client = await geminiAccountService.getOauthClient(accessToken, refreshToken) + // 解析账户的代理配置 + let proxyConfig = null + if (account.proxy) { + try { + proxyConfig = typeof account.proxy === 'string' ? JSON.parse(account.proxy) : account.proxy + } catch (e) { + logger.warn('Failed to parse proxy configuration:', e) + } + } + + const client = await geminiAccountService.getOauthClient(accessToken, refreshToken, proxyConfig) // 根据账户配置决定项目ID: // 1. 如果账户有项目ID -> 使用账户的项目ID(强制覆盖) @@ -410,7 +434,8 @@ async function handleOnboardUser(req, res) { client, tierId, effectiveProjectId, // 使用处理后的项目ID - metadata + metadata, + proxyConfig ) res.json(response) @@ -419,7 +444,8 @@ async function handleOnboardUser(req, res) { const response = await geminiAccountService.setupUser( client, effectiveProjectId, // 使用处理后的项目ID - metadata + metadata, + proxyConfig ) res.json(response) @@ -460,7 +486,8 @@ async function handleCountTokens(req, res) { sessionHash, model ) - const { accessToken, refreshToken } = await geminiAccountService.getAccount(accountId) + const account = await geminiAccountService.getAccount(accountId) + const { accessToken, refreshToken } = account const version = req.path.includes('v1beta') ? 'v1beta' : 'v1internal' logger.info(`CountTokens request (${version})`, { @@ -469,8 +496,18 @@ async function handleCountTokens(req, res) { apiKeyId: req.apiKey?.id || 'unknown' }) - const client = await geminiAccountService.getOauthClient(accessToken, refreshToken) - const response = await geminiAccountService.countTokens(client, contents, model) + // 解析账户的代理配置 + let proxyConfig = null + if (account.proxy) { + try { + proxyConfig = typeof account.proxy === 'string' ? JSON.parse(account.proxy) : account.proxy + } catch (e) { + logger.warn('Failed to parse proxy configuration:', e) + } + } + + const client = await geminiAccountService.getOauthClient(accessToken, refreshToken, proxyConfig) + const response = await geminiAccountService.countTokens(client, contents, model, proxyConfig) res.json(response) } catch (error) { @@ -544,8 +581,6 @@ async function handleGenerateContent(req, res) { apiKeyId: req.apiKey?.id || 'unknown' }) - const client = await geminiAccountService.getOauthClient(accessToken, refreshToken) - // 解析账户的代理配置 let proxyConfig = null if (account.proxy) { @@ -556,6 +591,8 @@ async function handleGenerateContent(req, res) { } } + const client = await geminiAccountService.getOauthClient(accessToken, refreshToken, proxyConfig) + const response = await geminiAccountService.generateContent( client, { model, request: actualRequestData }, @@ -680,8 +717,6 @@ async function handleStreamGenerateContent(req, res) { } }) - const client = await geminiAccountService.getOauthClient(accessToken, refreshToken) - // 解析账户的代理配置 let proxyConfig = null if (account.proxy) { @@ -692,6 +727,8 @@ async function handleStreamGenerateContent(req, res) { } } + const client = await geminiAccountService.getOauthClient(accessToken, refreshToken, proxyConfig) + const streamResponse = await geminiAccountService.generateContentStream( client, { model, request: actualRequestData }, diff --git a/src/routes/openaiGeminiRoutes.js b/src/routes/openaiGeminiRoutes.js index 5e304f06..54305401 100644 --- a/src/routes/openaiGeminiRoutes.js +++ b/src/routes/openaiGeminiRoutes.js @@ -311,6 +311,16 @@ router.post('/v1/chat/completions', authenticateApiKey, async (req, res) => { // 标记账户被使用 await geminiAccountService.markAccountUsed(account.id) + // 解析账户的代理配置 + let proxyConfig = null + if (account.proxy) { + try { + proxyConfig = typeof account.proxy === 'string' ? JSON.parse(account.proxy) : account.proxy + } catch (e) { + logger.warn('Failed to parse proxy configuration:', e) + } + } + // 创建中止控制器 abortController = new AbortController() @@ -325,7 +335,8 @@ router.post('/v1/chat/completions', authenticateApiKey, async (req, res) => { // 获取OAuth客户端 const client = await geminiAccountService.getOauthClient( account.accessToken, - account.refreshToken + account.refreshToken, + proxyConfig ) if (actualStream) { // 流式响应 @@ -341,7 +352,8 @@ router.post('/v1/chat/completions', authenticateApiKey, async (req, res) => { null, // user_prompt_id account.projectId, // 使用有权限的项目ID apiKeyData.id, // 使用 API Key ID 作为 session ID - abortController.signal // 传递中止信号 + abortController.signal, // 传递中止信号 + proxyConfig // 传递代理配置 ) // 设置流式响应头 @@ -541,7 +553,8 @@ router.post('/v1/chat/completions', authenticateApiKey, async (req, res) => { { model, request: geminiRequestBody }, null, // user_prompt_id account.projectId, // 使用有权限的项目ID - apiKeyData.id // 使用 API Key ID 作为 session ID + apiKeyData.id, // 使用 API Key ID 作为 session ID + proxyConfig // 传递代理配置 ) // 转换为 OpenAI 格式并返回 diff --git a/src/routes/userRoutes.js b/src/routes/userRoutes.js new file mode 100644 index 00000000..18074863 --- /dev/null +++ b/src/routes/userRoutes.js @@ -0,0 +1,647 @@ +const express = require('express') +const router = express.Router() +const ldapService = require('../services/ldapService') +const userService = require('../services/userService') +const apiKeyService = require('../services/apiKeyService') +const logger = require('../utils/logger') +const config = require('../../config/config') +const { authenticateUser, authenticateUserOrAdmin, requireAdmin } = require('../middleware/auth') + +// 🔐 用户登录端点 +router.post('/login', async (req, res) => { + try { + const { username, password } = req.body + + if (!username || !password) { + return res.status(400).json({ + error: 'Missing credentials', + message: 'Username and password are required' + }) + } + + // 检查用户管理是否启用 + if (!config.userManagement.enabled) { + return res.status(503).json({ + error: 'Service unavailable', + message: 'User management is not enabled' + }) + } + + // 检查LDAP是否启用 + if (!config.ldap.enabled) { + return res.status(503).json({ + error: 'Service unavailable', + message: 'LDAP authentication is not enabled' + }) + } + + // 尝试LDAP认证 + const authResult = await ldapService.authenticateUserCredentials(username, password) + + if (!authResult.success) { + return res.status(401).json({ + error: 'Authentication failed', + message: authResult.message + }) + } + + logger.info(`✅ User login successful: ${username}`) + + res.json({ + success: true, + message: 'Login successful', + user: { + id: authResult.user.id, + username: authResult.user.username, + email: authResult.user.email, + displayName: authResult.user.displayName, + firstName: authResult.user.firstName, + lastName: authResult.user.lastName, + role: authResult.user.role + }, + sessionToken: authResult.sessionToken + }) + } catch (error) { + logger.error('❌ User login error:', error) + res.status(500).json({ + error: 'Login error', + message: 'Internal server error during login' + }) + } +}) + +// 🚪 用户登出端点 +router.post('/logout', authenticateUser, async (req, res) => { + try { + await userService.invalidateUserSession(req.user.sessionToken) + + logger.info(`👋 User logout: ${req.user.username}`) + + res.json({ + success: true, + message: 'Logout successful' + }) + } catch (error) { + logger.error('❌ User logout error:', error) + res.status(500).json({ + error: 'Logout error', + message: 'Internal server error during logout' + }) + } +}) + +// 👤 获取当前用户信息 +router.get('/profile', authenticateUser, async (req, res) => { + try { + const user = await userService.getUserById(req.user.id) + if (!user) { + return res.status(404).json({ + error: 'User not found', + message: 'User profile not found' + }) + } + + res.json({ + success: true, + user: { + id: user.id, + username: user.username, + email: user.email, + displayName: user.displayName, + firstName: user.firstName, + lastName: user.lastName, + role: user.role, + isActive: user.isActive, + createdAt: user.createdAt, + lastLoginAt: user.lastLoginAt, + apiKeyCount: user.apiKeyCount, + totalUsage: user.totalUsage + }, + config: { + maxApiKeysPerUser: config.userManagement.maxApiKeysPerUser + } + }) + } catch (error) { + logger.error('❌ Get user profile error:', error) + res.status(500).json({ + error: 'Profile error', + message: 'Failed to retrieve user profile' + }) + } +}) + +// 🔑 获取用户的API Keys +router.get('/api-keys', authenticateUser, async (req, res) => { + try { + const { includeDeleted = 'false' } = req.query + const apiKeys = await apiKeyService.getUserApiKeys(req.user.id, includeDeleted === 'true') + + // 移除敏感信息并格式化usage数据 + const safeApiKeys = apiKeys.map((key) => { + // Flatten usage structure for frontend compatibility + let flatUsage = { + requests: 0, + inputTokens: 0, + outputTokens: 0, + totalCost: 0 + } + + if (key.usage && key.usage.total) { + flatUsage = { + requests: key.usage.total.requests || 0, + inputTokens: key.usage.total.inputTokens || 0, + outputTokens: key.usage.total.outputTokens || 0, + totalCost: key.totalCost || 0 + } + } + + return { + id: key.id, + name: key.name, + description: key.description, + tokenLimit: key.tokenLimit, + isActive: key.isActive, + createdAt: key.createdAt, + lastUsedAt: key.lastUsedAt, + expiresAt: key.expiresAt, + usage: flatUsage, + dailyCost: key.dailyCost, + dailyCostLimit: key.dailyCostLimit, + // 不返回实际的key值,只返回前缀和后几位 + keyPreview: key.key + ? `${key.key.substring(0, 8)}...${key.key.substring(key.key.length - 4)}` + : null, + // Include deletion fields for deleted keys + isDeleted: key.isDeleted, + deletedAt: key.deletedAt, + deletedBy: key.deletedBy, + deletedByType: key.deletedByType + } + }) + + res.json({ + success: true, + apiKeys: safeApiKeys, + total: safeApiKeys.length + }) + } catch (error) { + logger.error('❌ Get user API keys error:', error) + res.status(500).json({ + error: 'API Keys error', + message: 'Failed to retrieve API keys' + }) + } +}) + +// 🔑 创建新的API Key +router.post('/api-keys', authenticateUser, async (req, res) => { + try { + const { name, description, tokenLimit, expiresAt, dailyCostLimit } = req.body + + if (!name || !name.trim()) { + return res.status(400).json({ + error: 'Missing name', + message: 'API key name is required' + }) + } + + // 检查用户API Key数量限制 + const userApiKeys = await apiKeyService.getUserApiKeys(req.user.id) + if (userApiKeys.length >= config.userManagement.maxApiKeysPerUser) { + return res.status(400).json({ + error: 'API key limit exceeded', + message: `You can only have up to ${config.userManagement.maxApiKeysPerUser} API keys` + }) + } + + // 创建API Key数据 + const apiKeyData = { + name: name.trim(), + description: description?.trim() || '', + userId: req.user.id, + userUsername: req.user.username, + tokenLimit: tokenLimit || null, + expiresAt: expiresAt || null, + dailyCostLimit: dailyCostLimit || null, + createdBy: 'user', + permissions: ['messages'] // 用户创建的API Key默认只有messages权限 + } + + const newApiKey = await apiKeyService.createApiKey(apiKeyData) + + // 更新用户API Key数量 + await userService.updateUserApiKeyCount(req.user.id, userApiKeys.length + 1) + + logger.info(`🔑 User ${req.user.username} created API key: ${name}`) + + res.status(201).json({ + success: true, + message: 'API key created successfully', + apiKey: { + id: newApiKey.id, + name: newApiKey.name, + description: newApiKey.description, + key: newApiKey.apiKey, // 只在创建时返回完整key + tokenLimit: newApiKey.tokenLimit, + expiresAt: newApiKey.expiresAt, + dailyCostLimit: newApiKey.dailyCostLimit, + createdAt: newApiKey.createdAt + } + }) + } catch (error) { + logger.error('❌ Create user API key error:', error) + res.status(500).json({ + error: 'API Key creation error', + message: 'Failed to create API key' + }) + } +}) + +// 🗑️ 删除API Key +router.delete('/api-keys/:keyId', authenticateUser, async (req, res) => { + try { + const { keyId } = req.params + + // 检查API Key是否属于当前用户 + const existingKey = await apiKeyService.getApiKeyById(keyId) + if (!existingKey || existingKey.userId !== req.user.id) { + return res.status(404).json({ + error: 'API key not found', + message: 'API key not found or you do not have permission to access it' + }) + } + + await apiKeyService.deleteApiKey(keyId, req.user.username, 'user') + + // 更新用户API Key数量 + const userApiKeys = await apiKeyService.getUserApiKeys(req.user.id) + await userService.updateUserApiKeyCount(req.user.id, userApiKeys.length) + + logger.info(`🗑️ User ${req.user.username} deleted API key: ${existingKey.name}`) + + res.json({ + success: true, + message: 'API key deleted successfully' + }) + } catch (error) { + logger.error('❌ Delete user API key error:', error) + res.status(500).json({ + error: 'API Key deletion error', + message: 'Failed to delete API key' + }) + } +}) + +// 📊 获取用户使用统计 +router.get('/usage-stats', authenticateUser, async (req, res) => { + try { + const { period = 'week', model } = req.query + + // 获取用户的API Keys (including deleted ones for complete usage stats) + const userApiKeys = await apiKeyService.getUserApiKeys(req.user.id, true) + const apiKeyIds = userApiKeys.map((key) => key.id) + + if (apiKeyIds.length === 0) { + return res.json({ + success: true, + stats: { + totalRequests: 0, + totalInputTokens: 0, + totalOutputTokens: 0, + totalCost: 0, + dailyStats: [], + modelStats: [] + } + }) + } + + // 获取使用统计 + const stats = await apiKeyService.getAggregatedUsageStats(apiKeyIds, { period, model }) + + res.json({ + success: true, + stats + }) + } catch (error) { + logger.error('❌ Get user usage stats error:', error) + res.status(500).json({ + error: 'Usage stats error', + message: 'Failed to retrieve usage statistics' + }) + } +}) + +// === 管理员用户管理端点 === + +// 📋 获取用户列表(管理员) +router.get('/', authenticateUserOrAdmin, requireAdmin, async (req, res) => { + try { + const { page = 1, limit = 20, role, isActive, search } = req.query + + const options = { + page: parseInt(page), + limit: parseInt(limit), + role, + isActive: isActive === 'true' ? true : isActive === 'false' ? false : undefined + } + + const result = await userService.getAllUsers(options) + + // 如果有搜索条件,进行过滤 + let filteredUsers = result.users + if (search) { + const searchLower = search.toLowerCase() + filteredUsers = result.users.filter( + (user) => + user.username.toLowerCase().includes(searchLower) || + user.displayName.toLowerCase().includes(searchLower) || + user.email.toLowerCase().includes(searchLower) + ) + } + + res.json({ + success: true, + users: filteredUsers, + pagination: { + total: result.total, + page: result.page, + limit: result.limit, + totalPages: result.totalPages + } + }) + } catch (error) { + logger.error('❌ Get users list error:', error) + res.status(500).json({ + error: 'Users list error', + message: 'Failed to retrieve users list' + }) + } +}) + +// 👤 获取特定用户信息(管理员) +router.get('/:userId', authenticateUserOrAdmin, requireAdmin, async (req, res) => { + try { + const { userId } = req.params + + const user = await userService.getUserById(userId) + if (!user) { + return res.status(404).json({ + error: 'User not found', + message: 'User not found' + }) + } + + // 获取用户的API Keys(包括已删除的以保留统计数据) + const apiKeys = await apiKeyService.getUserApiKeys(userId, true) + + res.json({ + success: true, + user: { + ...user, + apiKeys: apiKeys.map((key) => { + // Flatten usage structure for frontend compatibility + let flatUsage = { + requests: 0, + inputTokens: 0, + outputTokens: 0, + totalCost: 0 + } + + if (key.usage && key.usage.total) { + flatUsage = { + requests: key.usage.total.requests || 0, + inputTokens: key.usage.total.inputTokens || 0, + outputTokens: key.usage.total.outputTokens || 0, + totalCost: key.totalCost || 0 + } + } + + return { + id: key.id, + name: key.name, + description: key.description, + isActive: key.isActive, + createdAt: key.createdAt, + lastUsedAt: key.lastUsedAt, + usage: flatUsage, + keyPreview: key.key + ? `${key.key.substring(0, 8)}...${key.key.substring(key.key.length - 4)}` + : null + } + }) + } + }) + } catch (error) { + logger.error('❌ Get user details error:', error) + res.status(500).json({ + error: 'User details error', + message: 'Failed to retrieve user details' + }) + } +}) + +// 🔄 更新用户状态(管理员) +router.patch('/:userId/status', authenticateUserOrAdmin, requireAdmin, async (req, res) => { + try { + const { userId } = req.params + const { isActive } = req.body + + if (typeof isActive !== 'boolean') { + return res.status(400).json({ + error: 'Invalid status', + message: 'isActive must be a boolean value' + }) + } + + const updatedUser = await userService.updateUserStatus(userId, isActive) + + const adminUser = req.admin?.username || req.user?.username + logger.info( + `🔄 Admin ${adminUser} ${isActive ? 'enabled' : 'disabled'} user: ${updatedUser.username}` + ) + + res.json({ + success: true, + message: `User ${isActive ? 'enabled' : 'disabled'} successfully`, + user: { + id: updatedUser.id, + username: updatedUser.username, + isActive: updatedUser.isActive, + updatedAt: updatedUser.updatedAt + } + }) + } catch (error) { + logger.error('❌ Update user status error:', error) + res.status(500).json({ + error: 'Update status error', + message: error.message || 'Failed to update user status' + }) + } +}) + +// 🔄 更新用户角色(管理员) +router.patch('/:userId/role', authenticateUserOrAdmin, requireAdmin, async (req, res) => { + try { + const { userId } = req.params + const { role } = req.body + + const validRoles = ['user', 'admin'] + if (!role || !validRoles.includes(role)) { + return res.status(400).json({ + error: 'Invalid role', + message: `Role must be one of: ${validRoles.join(', ')}` + }) + } + + const updatedUser = await userService.updateUserRole(userId, role) + + const adminUser = req.admin?.username || req.user?.username + logger.info(`🔄 Admin ${adminUser} changed user ${updatedUser.username} role to: ${role}`) + + res.json({ + success: true, + message: `User role updated to ${role} successfully`, + user: { + id: updatedUser.id, + username: updatedUser.username, + role: updatedUser.role, + updatedAt: updatedUser.updatedAt + } + }) + } catch (error) { + logger.error('❌ Update user role error:', error) + res.status(500).json({ + error: 'Update role error', + message: error.message || 'Failed to update user role' + }) + } +}) + +// 🔑 禁用用户的所有API Keys(管理员) +router.post('/:userId/disable-keys', authenticateUserOrAdmin, requireAdmin, async (req, res) => { + try { + const { userId } = req.params + + const user = await userService.getUserById(userId) + if (!user) { + return res.status(404).json({ + error: 'User not found', + message: 'User not found' + }) + } + + const result = await apiKeyService.disableUserApiKeys(userId) + + const adminUser = req.admin?.username || req.user?.username + logger.info(`🔑 Admin ${adminUser} disabled all API keys for user: ${user.username}`) + + res.json({ + success: true, + message: `Disabled ${result.count} API keys for user ${user.username}`, + disabledCount: result.count + }) + } catch (error) { + logger.error('❌ Disable user API keys error:', error) + res.status(500).json({ + error: 'Disable keys error', + message: 'Failed to disable user API keys' + }) + } +}) + +// 📊 获取用户使用统计(管理员) +router.get('/:userId/usage-stats', authenticateUserOrAdmin, requireAdmin, async (req, res) => { + try { + const { userId } = req.params + const { period = 'week', model } = req.query + + const user = await userService.getUserById(userId) + if (!user) { + return res.status(404).json({ + error: 'User not found', + message: 'User not found' + }) + } + + // 获取用户的API Keys(包括已删除的以保留统计数据) + const userApiKeys = await apiKeyService.getUserApiKeys(userId, true) + const apiKeyIds = userApiKeys.map((key) => key.id) + + if (apiKeyIds.length === 0) { + return res.json({ + success: true, + user: { + id: user.id, + username: user.username, + displayName: user.displayName + }, + stats: { + totalRequests: 0, + totalInputTokens: 0, + totalOutputTokens: 0, + totalCost: 0, + dailyStats: [], + modelStats: [] + } + }) + } + + // 获取使用统计 + const stats = await apiKeyService.getAggregatedUsageStats(apiKeyIds, { period, model }) + + res.json({ + success: true, + user: { + id: user.id, + username: user.username, + displayName: user.displayName + }, + stats + }) + } catch (error) { + logger.error('❌ Get user usage stats (admin) error:', error) + res.status(500).json({ + error: 'Usage stats error', + message: 'Failed to retrieve user usage statistics' + }) + } +}) + +// 📊 获取用户管理统计(管理员) +router.get('/stats/overview', authenticateUserOrAdmin, requireAdmin, async (req, res) => { + try { + const stats = await userService.getUserStats() + + res.json({ + success: true, + stats + }) + } catch (error) { + logger.error('❌ Get user stats overview error:', error) + res.status(500).json({ + error: 'Stats error', + message: 'Failed to retrieve user statistics' + }) + } +}) + +// 🔧 测试LDAP连接(管理员) +router.get('/admin/ldap-test', authenticateUserOrAdmin, requireAdmin, async (req, res) => { + try { + const testResult = await ldapService.testConnection() + + res.json({ + success: true, + ldapTest: testResult, + config: ldapService.getConfigInfo() + }) + } catch (error) { + logger.error('❌ LDAP test error:', error) + res.status(500).json({ + error: 'LDAP test error', + message: 'Failed to test LDAP connection' + }) + } +}) + +module.exports = router diff --git a/src/services/apiKeyService.js b/src/services/apiKeyService.js index 94e7ae77..197f8e78 100644 --- a/src/services/apiKeyService.js +++ b/src/services/apiKeyService.js @@ -70,7 +70,9 @@ class ApiKeyService { createdAt: new Date().toISOString(), lastUsedAt: '', expiresAt: expiresAt || '', - createdBy: 'admin' // 可以根据需要扩展用户系统 + createdBy: options.createdBy || 'admin', + userId: options.userId || '', + userUsername: options.userUsername || '' } // 保存API Key数据并建立哈希映射 @@ -136,6 +138,20 @@ class ApiKeyService { return { valid: false, error: 'API key has expired' } } + // 如果API Key属于某个用户,检查用户是否被禁用 + if (keyData.userId) { + try { + const userService = require('./userService') + const user = await userService.getUserById(keyData.userId, false) + if (!user || !user.isActive) { + return { valid: false, error: 'User account is disabled' } + } + } catch (error) { + logger.error('❌ Error checking user status during API key validation:', error) + return { valid: false, error: 'Unable to validate user status' } + } + } + // 获取使用统计(供返回数据使用) const usage = await redis.getUsageStats(keyData.id) @@ -210,14 +226,27 @@ class ApiKeyService { } // 📋 获取所有API Keys - async getAllApiKeys() { + async getAllApiKeys(includeDeleted = false) { try { - const apiKeys = await redis.getAllApiKeys() + let apiKeys = await redis.getAllApiKeys() const client = redis.getClientSafe() + // 默认过滤掉已删除的API Keys + if (!includeDeleted) { + apiKeys = apiKeys.filter((key) => key.isDeleted !== 'true') + } + // 为每个key添加使用统计和当前并发数 for (const key of apiKeys) { key.usage = await redis.getUsageStats(key.id) + const costStats = await redis.getCostStats(key.id) + // Add cost information to usage object for frontend compatibility + if (key.usage && costStats) { + key.usage.total = key.usage.total || {} + key.usage.total.cost = costStats.total + key.usage.totalCost = costStats.total + } + key.totalCost = costStats ? costStats.total : 0 key.tokenLimit = parseInt(key.tokenLimit) key.concurrencyLimit = parseInt(key.concurrencyLimit || 0) key.rateLimitWindow = parseInt(key.rateLimitWindow || 0) @@ -371,16 +400,32 @@ class ApiKeyService { } } - // 🗑️ 删除API Key - async deleteApiKey(keyId) { + // 🗑️ 软删除API Key (保留使用统计) + async deleteApiKey(keyId, deletedBy = 'system', deletedByType = 'system') { try { - const result = await redis.deleteApiKey(keyId) - - if (result === 0) { + const keyData = await redis.getApiKey(keyId) + if (!keyData || Object.keys(keyData).length === 0) { throw new Error('API key not found') } - logger.success(`🗑️ Deleted API key: ${keyId}`) + // 标记为已删除,保留所有数据和统计信息 + const updatedData = { + ...keyData, + isDeleted: 'true', + deletedAt: new Date().toISOString(), + deletedBy, + deletedByType, // 'user', 'admin', 'system' + isActive: 'false' // 同时禁用 + } + + await redis.setApiKey(keyId, updatedData) + + // 从哈希映射中移除(这样就不能再使用这个key进行API调用) + if (keyData.apiKey) { + await redis.deleteApiKeyHash(keyData.apiKey) + } + + logger.success(`🗑️ Soft deleted API key: ${keyId} by ${deletedBy} (${deletedByType})`) return { success: true } } catch (error) { @@ -672,6 +717,225 @@ class ApiKeyService { return await redis.getAllAccountsUsageStats() } + // === 用户相关方法 === + + // 🔑 创建API Key(支持用户) + async createApiKey(options = {}) { + return await this.generateApiKey(options) + } + + // 👤 获取用户的API Keys + async getUserApiKeys(userId, includeDeleted = false) { + try { + const allKeys = await redis.getAllApiKeys() + let userKeys = allKeys.filter((key) => key.userId === userId) + + // 默认过滤掉已删除的API Keys + if (!includeDeleted) { + userKeys = userKeys.filter((key) => key.isDeleted !== 'true') + } + + // Populate usage stats for each user's API key (same as getAllApiKeys does) + const userKeysWithUsage = [] + for (const key of userKeys) { + const usage = await redis.getUsageStats(key.id) + const dailyCost = (await redis.getDailyCost(key.id)) || 0 + const costStats = await redis.getCostStats(key.id) + + userKeysWithUsage.push({ + id: key.id, + name: key.name, + description: key.description, + key: key.apiKey ? `${this.prefix}****${key.apiKey.slice(-4)}` : null, // 只显示前缀和后4位 + tokenLimit: parseInt(key.tokenLimit || 0), + isActive: key.isActive === 'true', + createdAt: key.createdAt, + lastUsedAt: key.lastUsedAt, + expiresAt: key.expiresAt, + usage, + dailyCost, + totalCost: costStats.total, + dailyCostLimit: parseFloat(key.dailyCostLimit || 0), + userId: key.userId, + userUsername: key.userUsername, + createdBy: key.createdBy, + // Include deletion fields for deleted keys + isDeleted: key.isDeleted, + deletedAt: key.deletedAt, + deletedBy: key.deletedBy, + deletedByType: key.deletedByType + }) + } + + return userKeysWithUsage + } catch (error) { + logger.error('❌ Failed to get user API keys:', error) + return [] + } + } + + // 🔍 通过ID获取API Key(检查权限) + async getApiKeyById(keyId, userId = null) { + try { + const keyData = await redis.getApiKey(keyId) + if (!keyData) { + return null + } + + // 如果指定了用户ID,检查权限 + if (userId && keyData.userId !== userId) { + return null + } + + return { + id: keyData.id, + name: keyData.name, + description: keyData.description, + key: keyData.apiKey, + tokenLimit: parseInt(keyData.tokenLimit || 0), + isActive: keyData.isActive === 'true', + createdAt: keyData.createdAt, + lastUsedAt: keyData.lastUsedAt, + expiresAt: keyData.expiresAt, + userId: keyData.userId, + userUsername: keyData.userUsername, + createdBy: keyData.createdBy, + permissions: keyData.permissions, + dailyCostLimit: parseFloat(keyData.dailyCostLimit || 0) + } + } catch (error) { + logger.error('❌ Failed to get API key by ID:', error) + return null + } + } + + // 🔄 重新生成API Key + async regenerateApiKey(keyId) { + try { + const existingKey = await redis.getApiKey(keyId) + if (!existingKey) { + throw new Error('API key not found') + } + + // 生成新的key + const newApiKey = `${this.prefix}${this._generateSecretKey()}` + const newHashedKey = this._hashApiKey(newApiKey) + + // 删除旧的哈希映射 + const oldHashedKey = existingKey.apiKey + await redis.deleteApiKeyHash(oldHashedKey) + + // 更新key数据 + const updatedKeyData = { + ...existingKey, + apiKey: newHashedKey, + updatedAt: new Date().toISOString() + } + + // 保存新数据并建立新的哈希映射 + await redis.setApiKey(keyId, updatedKeyData, newHashedKey) + + logger.info(`🔄 Regenerated API key: ${existingKey.name} (${keyId})`) + + return { + id: keyId, + name: existingKey.name, + key: newApiKey, // 返回完整的新key + updatedAt: updatedKeyData.updatedAt + } + } catch (error) { + logger.error('❌ Failed to regenerate API key:', error) + throw error + } + } + + // 🗑️ 硬删除API Key (完全移除) + async hardDeleteApiKey(keyId) { + try { + const keyData = await redis.getApiKey(keyId) + if (!keyData) { + throw new Error('API key not found') + } + + // 删除key数据和哈希映射 + await redis.deleteApiKey(keyId) + await redis.deleteApiKeyHash(keyData.apiKey) + + logger.info(`🗑️ Deleted API key: ${keyData.name} (${keyId})`) + return true + } catch (error) { + logger.error('❌ Failed to delete API key:', error) + throw error + } + } + + // 🚫 禁用用户的所有API Keys + async disableUserApiKeys(userId) { + try { + const userKeys = await this.getUserApiKeys(userId) + let disabledCount = 0 + + for (const key of userKeys) { + if (key.isActive) { + await this.updateApiKey(key.id, { isActive: false }) + disabledCount++ + } + } + + logger.info(`🚫 Disabled ${disabledCount} API keys for user: ${userId}`) + return { count: disabledCount } + } catch (error) { + logger.error('❌ Failed to disable user API keys:', error) + throw error + } + } + + // 📊 获取聚合使用统计(支持多个API Key) + async getAggregatedUsageStats(keyIds, options = {}) { + try { + if (!Array.isArray(keyIds)) { + keyIds = [keyIds] + } + + const { period: _period = 'week', model: _model } = options + const stats = { + totalRequests: 0, + totalInputTokens: 0, + totalOutputTokens: 0, + totalCost: 0, + dailyStats: [], + modelStats: [] + } + + // 汇总所有API Key的统计数据 + for (const keyId of keyIds) { + const keyStats = await redis.getUsageStats(keyId) + const costStats = await redis.getCostStats(keyId) + if (keyStats && keyStats.total) { + stats.totalRequests += keyStats.total.requests || 0 + stats.totalInputTokens += keyStats.total.inputTokens || 0 + stats.totalOutputTokens += keyStats.total.outputTokens || 0 + stats.totalCost += costStats?.total || 0 + } + } + + // TODO: 实现日期范围和模型统计 + // 这里可以根据需要添加更详细的统计逻辑 + + return stats + } catch (error) { + logger.error('❌ Failed to get usage stats:', error) + return { + totalRequests: 0, + totalInputTokens: 0, + totalOutputTokens: 0, + totalCost: 0, + dailyStats: [], + modelStats: [] + } + } + } + // 🧹 清理过期的API Keys async cleanupExpiredKeys() { try { diff --git a/src/services/azureOpenaiAccountService.js b/src/services/azureOpenaiAccountService.js index 49cb78cc..8ff86a99 100644 --- a/src/services/azureOpenaiAccountService.js +++ b/src/services/azureOpenaiAccountService.js @@ -296,7 +296,11 @@ async function getAllAccounts() { } } - accounts.push(accountData) + accounts.push({ + ...accountData, + isActive: accountData.isActive === 'true', + schedulable: accountData.schedulable !== 'false' + }) } } diff --git a/src/services/azureOpenaiRelayService.js b/src/services/azureOpenaiRelayService.js index 9590884b..7517c4ad 100644 --- a/src/services/azureOpenaiRelayService.js +++ b/src/services/azureOpenaiRelayService.js @@ -273,6 +273,11 @@ function handleStreamResponse(upstreamResponse, clientResponse, options = {}) { let eventCount = 0 const maxEvents = 10000 // 最大事件数量限制 + // 专门用于保存最后几个chunks以提取usage数据 + let finalChunksBuffer = '' + const FINAL_CHUNKS_SIZE = 32 * 1024 // 32KB保留最终chunks + const allParsedEvents = [] // 存储所有解析的事件用于最终usage提取 + // 设置响应头 clientResponse.setHeader('Content-Type', 'text/event-stream') clientResponse.setHeader('Cache-Control', 'no-cache') @@ -297,8 +302,8 @@ function handleStreamResponse(upstreamResponse, clientResponse, options = {}) { clientResponse.flushHeaders() } - // 解析 SSE 事件以捕获 usage 数据 - const parseSSEForUsage = (data) => { + // 强化的SSE事件解析,保存所有事件用于最终处理 + const parseSSEForUsage = (data, isFromFinalBuffer = false) => { const lines = data.split('\n') for (const line of lines) { @@ -310,34 +315,54 @@ function handleStreamResponse(upstreamResponse, clientResponse, options = {}) { } const eventData = JSON.parse(jsonStr) + // 保存所有成功解析的事件 + allParsedEvents.push(eventData) + // 获取模型信息 if (eventData.model) { actualModel = eventData.model } - // 获取使用统计(Responses API: response.completed -> response.usage) - if (eventData.type === 'response.completed' && eventData.response) { - if (eventData.response.model) { - actualModel = eventData.response.model - } - if (eventData.response.usage) { - usageData = eventData.response.usage - logger.debug('Captured Azure OpenAI nested usage (response.usage):', usageData) + // 使用强化的usage提取函数 + const { usageData: extractedUsage, actualModel: extractedModel } = + extractUsageDataRobust( + eventData, + `stream-event-${isFromFinalBuffer ? 'final' : 'normal'}` + ) + + if (extractedUsage && !usageData) { + usageData = extractedUsage + if (extractedModel) { + actualModel = extractedModel } + logger.debug(`🎯 Stream usage captured via robust extraction`, { + isFromFinalBuffer, + usageData, + actualModel + }) } - // 兼容 Chat Completions 风格(顶层 usage) - if (!usageData && eventData.usage) { - usageData = eventData.usage - logger.debug('Captured Azure OpenAI usage (top-level):', usageData) - } + // 原有的简单提取作为备用 + if (!usageData) { + // 获取使用统计(Responses API: response.completed -> response.usage) + if (eventData.type === 'response.completed' && eventData.response) { + if (eventData.response.model) { + actualModel = eventData.response.model + } + if (eventData.response.usage) { + usageData = eventData.response.usage + logger.debug('🎯 Stream usage (backup method - response.usage):', usageData) + } + } - // 检查是否是完成事件 - if (eventData.choices && eventData.choices[0] && eventData.choices[0].finish_reason) { - // 这是最后一个 chunk + // 兼容 Chat Completions 风格(顶层 usage) + if (!usageData && eventData.usage) { + usageData = eventData.usage + logger.debug('🎯 Stream usage (backup method - top-level):', usageData) + } } } catch (e) { - // 忽略解析错误 + logger.debug('SSE parsing error (expected for incomplete chunks):', e.message) } } } @@ -387,10 +412,19 @@ function handleStreamResponse(upstreamResponse, clientResponse, options = {}) { // 同时解析数据以捕获 usage 信息,带缓冲区大小限制 buffer += chunkStr - // 防止缓冲区过大 + // 保留最后的chunks用于最终usage提取(不被truncate影响) + finalChunksBuffer += chunkStr + if (finalChunksBuffer.length > FINAL_CHUNKS_SIZE) { + finalChunksBuffer = finalChunksBuffer.slice(-FINAL_CHUNKS_SIZE) + } + + // 防止主缓冲区过大 - 但保持最后部分用于usage解析 if (buffer.length > MAX_BUFFER_SIZE) { - logger.warn(`Stream ${streamId} buffer exceeded limit, truncating`) - buffer = buffer.slice(-MAX_BUFFER_SIZE / 2) // 保留后一半 + logger.warn( + `Stream ${streamId} buffer exceeded limit, truncating main buffer but preserving final chunks` + ) + // 保留最后1/4而不是1/2,为usage数据留更多空间 + buffer = buffer.slice(-MAX_BUFFER_SIZE / 4) } // 处理完整的 SSE 事件 @@ -426,9 +460,91 @@ function handleStreamResponse(upstreamResponse, clientResponse, options = {}) { hasEnded = true try { - // 处理剩余的 buffer - if (buffer.trim() && buffer.length <= MAX_EVENT_SIZE) { - parseSSEForUsage(buffer) + logger.debug(`🔚 Stream ended, performing comprehensive usage extraction for ${streamId}`, { + mainBufferSize: buffer.length, + finalChunksBufferSize: finalChunksBuffer.length, + parsedEventsCount: allParsedEvents.length, + hasUsageData: !!usageData + }) + + // 多层次的最终usage提取策略 + if (!usageData) { + logger.debug('🔍 No usage found during stream, trying final extraction methods...') + + // 方法1: 解析剩余的主buffer + if (buffer.trim() && buffer.length <= MAX_EVENT_SIZE) { + parseSSEForUsage(buffer, false) + } + + // 方法2: 解析保留的final chunks buffer + if (!usageData && finalChunksBuffer.trim()) { + logger.debug('🔍 Trying final chunks buffer for usage extraction...') + parseSSEForUsage(finalChunksBuffer, true) + } + + // 方法3: 从所有解析的事件中重新搜索usage + if (!usageData && allParsedEvents.length > 0) { + logger.debug('🔍 Searching through all parsed events for usage...') + + // 倒序查找,因为usage通常在最后 + for (let i = allParsedEvents.length - 1; i >= 0; i--) { + const { usageData: foundUsage, actualModel: foundModel } = extractUsageDataRobust( + allParsedEvents[i], + `final-event-scan-${i}` + ) + if (foundUsage) { + usageData = foundUsage + if (foundModel) { + actualModel = foundModel + } + logger.debug(`🎯 Usage found in event ${i} during final scan!`) + break + } + } + } + + // 方法4: 尝试合并所有事件并搜索 + if (!usageData && allParsedEvents.length > 0) { + logger.debug('🔍 Trying combined events analysis...') + const combinedData = { + events: allParsedEvents, + lastEvent: allParsedEvents[allParsedEvents.length - 1], + eventCount: allParsedEvents.length + } + + const { usageData: combinedUsage } = extractUsageDataRobust( + combinedData, + 'combined-events' + ) + if (combinedUsage) { + usageData = combinedUsage + logger.debug('🎯 Usage found via combined events analysis!') + } + } + } + + // 最终usage状态报告 + if (usageData) { + logger.debug('✅ Final stream usage extraction SUCCESS', { + streamId, + usageData, + actualModel, + totalEvents: allParsedEvents.length, + finalBufferSize: finalChunksBuffer.length + }) + } else { + logger.warn('❌ Final stream usage extraction FAILED', { + streamId, + totalEvents: allParsedEvents.length, + finalBufferSize: finalChunksBuffer.length, + mainBufferSize: buffer.length, + lastFewEvents: allParsedEvents.slice(-3).map((e) => ({ + type: e.type, + hasUsage: !!e.usage, + hasResponse: !!e.response, + keys: Object.keys(e) + })) + }) } if (onEnd) { @@ -484,6 +600,120 @@ function handleStreamResponse(upstreamResponse, clientResponse, options = {}) { }) } +// 强化的用量数据提取函数 +function extractUsageDataRobust(responseData, context = 'unknown') { + logger.debug(`🔍 Attempting usage extraction for ${context}`, { + responseDataKeys: Object.keys(responseData || {}), + responseDataType: typeof responseData, + hasUsage: !!responseData?.usage, + hasResponse: !!responseData?.response + }) + + let usageData = null + let actualModel = null + + try { + // 策略 1: 顶层 usage (标准 Chat Completions) + if (responseData?.usage) { + usageData = responseData.usage + actualModel = responseData.model + logger.debug('✅ Usage extracted via Strategy 1 (top-level)', { usageData, actualModel }) + } + + // 策略 2: response.usage (Responses API) + else if (responseData?.response?.usage) { + usageData = responseData.response.usage + actualModel = responseData.response.model || responseData.model + logger.debug('✅ Usage extracted via Strategy 2 (response.usage)', { usageData, actualModel }) + } + + // 策略 3: 嵌套搜索 - 深度查找 usage 字段 + else { + const findUsageRecursive = (obj, path = '') => { + if (!obj || typeof obj !== 'object') { + return null + } + + for (const [key, value] of Object.entries(obj)) { + const currentPath = path ? `${path}.${key}` : key + + if (key === 'usage' && value && typeof value === 'object') { + logger.debug(`✅ Usage found at path: ${currentPath}`, value) + return { usage: value, path: currentPath } + } + + if (typeof value === 'object' && value !== null) { + const nested = findUsageRecursive(value, currentPath) + if (nested) { + return nested + } + } + } + return null + } + + const found = findUsageRecursive(responseData) + if (found) { + usageData = found.usage + // Try to find model in the same parent object + const pathParts = found.path.split('.') + pathParts.pop() // remove 'usage' + let modelParent = responseData + for (const part of pathParts) { + modelParent = modelParent?.[part] + } + actualModel = modelParent?.model || responseData?.model + logger.debug('✅ Usage extracted via Strategy 3 (recursive)', { + usageData, + actualModel, + foundPath: found.path + }) + } + } + + // 策略 4: 特殊响应格式处理 + if (!usageData) { + // 检查是否有 choices 数组,usage 可能在最后一个 choice 中 + if (responseData?.choices?.length > 0) { + const lastChoice = responseData.choices[responseData.choices.length - 1] + if (lastChoice?.usage) { + usageData = lastChoice.usage + actualModel = responseData.model || lastChoice.model + logger.debug('✅ Usage extracted via Strategy 4 (choices)', { usageData, actualModel }) + } + } + } + + // 最终验证和记录 + if (usageData) { + logger.debug('🎯 Final usage extraction result', { + context, + usageData, + actualModel, + inputTokens: usageData.prompt_tokens || usageData.input_tokens || 0, + outputTokens: usageData.completion_tokens || usageData.output_tokens || 0, + totalTokens: usageData.total_tokens || 0 + }) + } else { + logger.warn('❌ Failed to extract usage data', { + context, + responseDataStructure: `${JSON.stringify(responseData, null, 2).substring(0, 1000)}...`, + availableKeys: Object.keys(responseData || {}), + responseSize: JSON.stringify(responseData || {}).length + }) + } + } catch (extractionError) { + logger.error('🚨 Error during usage extraction', { + context, + error: extractionError.message, + stack: extractionError.stack, + responseDataType: typeof responseData + }) + } + + return { usageData, actualModel } +} + // 处理非流式响应 function handleNonStreamResponse(upstreamResponse, clientResponse) { try { @@ -510,9 +740,8 @@ function handleNonStreamResponse(upstreamResponse, clientResponse) { const responseData = upstreamResponse.data clientResponse.json(responseData) - // 提取 usage 数据 - const usageData = responseData.usage - const actualModel = responseData.model + // 使用强化的用量提取 + const { usageData, actualModel } = extractUsageDataRobust(responseData, 'non-stream') return { usageData, actualModel, responseData } } catch (error) { diff --git a/src/services/claudeAccountService.js b/src/services/claudeAccountService.js index bb579bf5..e236d626 100644 --- a/src/services/claudeAccountService.js +++ b/src/services/claudeAccountService.js @@ -1769,6 +1769,9 @@ class ClaudeAccountService { delete updatedAccountData.rateLimitedAt delete updatedAccountData.rateLimitStatus delete updatedAccountData.rateLimitEndAt + delete updatedAccountData.tempErrorAt + delete updatedAccountData.sessionWindowStart + delete updatedAccountData.sessionWindowEnd // 保存更新后的账户数据 await redis.setClaudeAccount(accountId, updatedAccountData) @@ -1781,6 +1784,10 @@ class ClaudeAccountService { const rateLimitKey = `ratelimit:${accountId}` await redis.client.del(rateLimitKey) + // 清除5xx错误计数 + const serverErrorKey = `claude_account:${accountId}:5xx_errors` + await redis.client.del(serverErrorKey) + logger.info( `✅ Successfully reset all error states for account ${accountData.name} (${accountId})` ) @@ -1805,7 +1812,7 @@ class ClaudeAccountService { try { const accounts = await redis.getAllClaudeAccounts() let cleanedCount = 0 - const TEMP_ERROR_RECOVERY_MINUTES = 60 // 临时错误状态恢复时间(分钟) + const TEMP_ERROR_RECOVERY_MINUTES = 5 // 临时错误状态恢复时间(分钟) for (const account of accounts) { if (account.status === 'temp_error' && account.tempErrorAt) { diff --git a/src/services/claudeRelayService.js b/src/services/claudeRelayService.js index 57e42438..e285dea8 100644 --- a/src/services/claudeRelayService.js +++ b/src/services/claudeRelayService.js @@ -207,7 +207,7 @@ class ClaudeRelayService { logger.info( `🔥 Account ${accountId} has ${errorCount} consecutive 5xx errors in the last 5 minutes` ) - if (errorCount >= 3) { + if (errorCount > 10) { logger.error( `❌ Account ${accountId} exceeded 5xx error threshold (${errorCount} errors), marking as temp_error` ) @@ -939,7 +939,7 @@ class ClaudeRelayService { logger.info( `🔥 [Stream] Account ${accountId} has ${errorCount} consecutive 5xx errors in the last 5 minutes` ) - if (errorCount >= 3) { + if (errorCount > 10) { logger.error( `❌ [Stream] Account ${accountId} exceeded 5xx error threshold (${errorCount} errors), marking as temp_error` ) diff --git a/src/services/geminiAccountService.js b/src/services/geminiAccountService.js index 78e1d5a1..bd10f455 100644 --- a/src/services/geminiAccountService.js +++ b/src/services/geminiAccountService.js @@ -138,11 +138,19 @@ function createOAuth2Client(redirectUri = null, proxyConfig = null) { return new OAuth2Client(clientOptions) } -// 生成授权 URL (支持 PKCE) -async function generateAuthUrl(state = null, redirectUri = null) { +// 生成授权 URL (支持 PKCE 和代理) +async function generateAuthUrl(state = null, redirectUri = null, proxyConfig = null) { // 使用新的 redirect URI const finalRedirectUri = redirectUri || 'https://codeassist.google.com/authcode' - const oAuth2Client = createOAuth2Client(finalRedirectUri) + const oAuth2Client = createOAuth2Client(finalRedirectUri, proxyConfig) + + if (proxyConfig) { + logger.info( + `🌐 Using proxy for Gemini auth URL generation: ${ProxyHelper.getProxyDescription(proxyConfig)}` + ) + } else { + logger.debug('🌐 No proxy configured for Gemini auth URL generation') + } // 生成 PKCE code verifier const codeVerifier = await oAuth2Client.generateCodeVerifierAsync() @@ -965,12 +973,10 @@ async function getAccountRateLimitInfo(accountId) { } } -// 获取配置的OAuth客户端 - 参考GeminiCliSimulator的getOauthClient方法 -async function getOauthClient(accessToken, refreshToken) { - const client = new OAuth2Client({ - clientId: OAUTH_CLIENT_ID, - clientSecret: OAUTH_CLIENT_SECRET - }) +// 获取配置的OAuth客户端 - 参考GeminiCliSimulator的getOauthClient方法(支持代理) +async function getOauthClient(accessToken, refreshToken, proxyConfig = null) { + const client = createOAuth2Client(null, proxyConfig) + const creds = { access_token: accessToken, refresh_token: refreshToken, @@ -980,6 +986,14 @@ async function getOauthClient(accessToken, refreshToken) { expiry_date: 1754269905646 } + if (proxyConfig) { + logger.info( + `🌐 Using proxy for Gemini OAuth client: ${ProxyHelper.getProxyDescription(proxyConfig)}` + ) + } else { + logger.debug('🌐 No proxy configured for Gemini OAuth client') + } + // 设置凭据 client.setCredentials(creds) @@ -996,8 +1010,8 @@ async function getOauthClient(accessToken, refreshToken) { return client } -// 调用 Google Code Assist API 的 loadCodeAssist 方法 -async function loadCodeAssist(client, projectId = null) { +// 调用 Google Code Assist API 的 loadCodeAssist 方法(支持代理) +async function loadCodeAssist(client, projectId = null, proxyConfig = null) { const axios = require('axios') const CODE_ASSIST_ENDPOINT = 'https://cloudcode-pa.googleapis.com' const CODE_ASSIST_API_VERSION = 'v1internal' @@ -1017,7 +1031,7 @@ async function loadCodeAssist(client, projectId = null) { metadata: clientMetadata } - const response = await axios({ + const axiosConfig = { url: `${CODE_ASSIST_ENDPOINT}/${CODE_ASSIST_API_VERSION}:loadCodeAssist`, method: 'POST', headers: { @@ -1026,7 +1040,20 @@ async function loadCodeAssist(client, projectId = null) { }, data: request, timeout: 30000 - }) + } + + // 添加代理配置 + const proxyAgent = ProxyHelper.createProxyAgent(proxyConfig) + if (proxyAgent) { + axiosConfig.httpsAgent = proxyAgent + logger.info( + `🌐 Using proxy for Gemini loadCodeAssist: ${ProxyHelper.getProxyDescription(proxyConfig)}` + ) + } else { + logger.debug('🌐 No proxy configured for Gemini loadCodeAssist') + } + + const response = await axios(axiosConfig) logger.info('📋 loadCodeAssist API调用成功') return response.data @@ -1059,8 +1086,8 @@ function getOnboardTier(loadRes) { } } -// 调用 Google Code Assist API 的 onboardUser 方法(包含轮询逻辑) -async function onboardUser(client, tierId, projectId, clientMetadata) { +// 调用 Google Code Assist API 的 onboardUser 方法(包含轮询逻辑,支持代理) +async function onboardUser(client, tierId, projectId, clientMetadata, proxyConfig = null) { const axios = require('axios') const CODE_ASSIST_ENDPOINT = 'https://cloudcode-pa.googleapis.com' const CODE_ASSIST_API_VERSION = 'v1internal' @@ -1073,15 +1100,8 @@ async function onboardUser(client, tierId, projectId, clientMetadata) { metadata: clientMetadata } - logger.info('📋 开始onboardUser API调用', { - tierId, - projectId, - hasProjectId: !!projectId, - isFreeTier: tierId === 'free-tier' || tierId === 'FREE' - }) - - // 轮询onboardUser直到长运行操作完成 - let lroRes = await axios({ + // 创建基础axios配置 + const baseAxiosConfig = { url: `${CODE_ASSIST_ENDPOINT}/${CODE_ASSIST_API_VERSION}:onboardUser`, method: 'POST', headers: { @@ -1090,8 +1110,29 @@ async function onboardUser(client, tierId, projectId, clientMetadata) { }, data: onboardReq, timeout: 30000 + } + + // 添加代理配置 + const proxyAgent = ProxyHelper.createProxyAgent(proxyConfig) + if (proxyAgent) { + baseAxiosConfig.httpsAgent = proxyAgent + logger.info( + `🌐 Using proxy for Gemini onboardUser: ${ProxyHelper.getProxyDescription(proxyConfig)}` + ) + } else { + logger.debug('🌐 No proxy configured for Gemini onboardUser') + } + + logger.info('📋 开始onboardUser API调用', { + tierId, + projectId, + hasProjectId: !!projectId, + isFreeTier: tierId === 'free-tier' || tierId === 'FREE' }) + // 轮询onboardUser直到长运行操作完成 + let lroRes = await axios(baseAxiosConfig) + let attempts = 0 const maxAttempts = 12 // 最多等待1分钟(5秒 * 12次) @@ -1099,17 +1140,7 @@ async function onboardUser(client, tierId, projectId, clientMetadata) { logger.info(`⏳ 等待onboardUser完成... (${attempts + 1}/${maxAttempts})`) await new Promise((resolve) => setTimeout(resolve, 5000)) - lroRes = await axios({ - url: `${CODE_ASSIST_ENDPOINT}/${CODE_ASSIST_API_VERSION}:onboardUser`, - method: 'POST', - headers: { - Authorization: `Bearer ${token}`, - 'Content-Type': 'application/json' - }, - data: onboardReq, - timeout: 30000 - }) - + lroRes = await axios(baseAxiosConfig) attempts++ } @@ -1121,8 +1152,13 @@ async function onboardUser(client, tierId, projectId, clientMetadata) { return lroRes.data } -// 完整的用户设置流程 - 参考setup.ts的逻辑 -async function setupUser(client, initialProjectId = null, clientMetadata = null) { +// 完整的用户设置流程 - 参考setup.ts的逻辑(支持代理) +async function setupUser( + client, + initialProjectId = null, + clientMetadata = null, + proxyConfig = null +) { logger.info('🚀 setupUser 开始', { initialProjectId, hasClientMetadata: !!clientMetadata }) let projectId = initialProjectId || process.env.GOOGLE_CLOUD_PROJECT || null @@ -1141,7 +1177,7 @@ async function setupUser(client, initialProjectId = null, clientMetadata = null) // 调用loadCodeAssist logger.info('📞 调用 loadCodeAssist...') - const loadRes = await loadCodeAssist(client, projectId) + const loadRes = await loadCodeAssist(client, projectId, proxyConfig) logger.info('✅ loadCodeAssist 完成', { hasCloudaicompanionProject: !!loadRes.cloudaicompanionProject }) @@ -1164,7 +1200,7 @@ async function setupUser(client, initialProjectId = null, clientMetadata = null) // 调用onboardUser logger.info('📞 调用 onboardUser...', { tierId: tier.id, projectId }) - const lroRes = await onboardUser(client, tier.id, projectId, clientMetadata) + const lroRes = await onboardUser(client, tier.id, projectId, clientMetadata, proxyConfig) logger.info('✅ onboardUser 完成', { hasDone: !!lroRes.done, hasResponse: !!lroRes.response }) const result = { @@ -1178,8 +1214,8 @@ async function setupUser(client, initialProjectId = null, clientMetadata = null) return result } -// 调用 Code Assist API 计算 token 数量 -async function countTokens(client, contents, model = 'gemini-2.0-flash-exp') { +// 调用 Code Assist API 计算 token 数量(支持代理) +async function countTokens(client, contents, model = 'gemini-2.0-flash-exp', proxyConfig = null) { const axios = require('axios') const CODE_ASSIST_ENDPOINT = 'https://cloudcode-pa.googleapis.com' const CODE_ASSIST_API_VERSION = 'v1internal' @@ -1196,7 +1232,7 @@ async function countTokens(client, contents, model = 'gemini-2.0-flash-exp') { logger.info('📊 countTokens API调用开始', { model, contentsLength: contents.length }) - const response = await axios({ + const axiosConfig = { url: `${CODE_ASSIST_ENDPOINT}/${CODE_ASSIST_API_VERSION}:countTokens`, method: 'POST', headers: { @@ -1205,7 +1241,20 @@ async function countTokens(client, contents, model = 'gemini-2.0-flash-exp') { }, data: request, timeout: 30000 - }) + } + + // 添加代理配置 + const proxyAgent = ProxyHelper.createProxyAgent(proxyConfig) + if (proxyAgent) { + axiosConfig.httpsAgent = proxyAgent + logger.info( + `🌐 Using proxy for Gemini countTokens: ${ProxyHelper.getProxyDescription(proxyConfig)}` + ) + } else { + logger.debug('🌐 No proxy configured for Gemini countTokens') + } + + const response = await axios(axiosConfig) logger.info('✅ countTokens API调用成功', { totalTokens: response.data.totalTokens }) return response.data diff --git a/src/services/ldapService.js b/src/services/ldapService.js new file mode 100644 index 00000000..1586fd0a --- /dev/null +++ b/src/services/ldapService.js @@ -0,0 +1,591 @@ +const ldap = require('ldapjs') +const logger = require('../utils/logger') +const config = require('../../config/config') +const userService = require('./userService') + +class LdapService { + constructor() { + this.config = config.ldap + this.client = null + + // 验证配置 + if (this.config.enabled) { + this.validateConfiguration() + } + } + + // 🔍 验证LDAP配置 + validateConfiguration() { + const errors = [] + + if (!this.config.server) { + errors.push('LDAP server configuration is missing') + } else { + if (!this.config.server.url || typeof this.config.server.url !== 'string') { + errors.push('LDAP server URL is not configured or invalid') + } + + if (!this.config.server.bindDN || typeof this.config.server.bindDN !== 'string') { + errors.push('LDAP bind DN is not configured or invalid') + } + + if ( + !this.config.server.bindCredentials || + typeof this.config.server.bindCredentials !== 'string' + ) { + errors.push('LDAP bind credentials are not configured or invalid') + } + + if (!this.config.server.searchBase || typeof this.config.server.searchBase !== 'string') { + errors.push('LDAP search base is not configured or invalid') + } + + if (!this.config.server.searchFilter || typeof this.config.server.searchFilter !== 'string') { + errors.push('LDAP search filter is not configured or invalid') + } + } + + if (errors.length > 0) { + logger.error('❌ LDAP configuration validation failed:', errors) + // Don't throw error during initialization, just log warnings + logger.warn('⚠️ LDAP authentication may not work properly due to configuration errors') + } else { + logger.info('✅ LDAP configuration validation passed') + } + } + + // 🔍 提取LDAP条目的DN + extractDN(ldapEntry) { + if (!ldapEntry) { + return null + } + + // Try different ways to get the DN + let dn = null + + // Method 1: Direct dn property + if (ldapEntry.dn) { + ;({ dn } = ldapEntry) + } + // Method 2: objectName property (common in some LDAP implementations) + else if (ldapEntry.objectName) { + dn = ldapEntry.objectName + } + // Method 3: distinguishedName property + else if (ldapEntry.distinguishedName) { + dn = ldapEntry.distinguishedName + } + // Method 4: Check if the entry itself is a DN string + else if (typeof ldapEntry === 'string' && ldapEntry.includes('=')) { + dn = ldapEntry + } + + // Convert DN to string if it's an object + if (dn && typeof dn === 'object') { + if (dn.toString && typeof dn.toString === 'function') { + dn = dn.toString() + } else if (dn.dn && typeof dn.dn === 'string') { + ;({ dn } = dn) + } + } + + // Validate the DN format + if (typeof dn === 'string' && dn.trim() !== '' && dn.includes('=')) { + return dn.trim() + } + + return null + } + + // 🔗 创建LDAP客户端连接 + createClient() { + try { + const clientOptions = { + url: this.config.server.url, + timeout: this.config.server.timeout, + connectTimeout: this.config.server.connectTimeout, + reconnect: true + } + + // 如果使用 LDAPS (SSL/TLS),添加 TLS 选项 + if (this.config.server.url.toLowerCase().startsWith('ldaps://')) { + const tlsOptions = {} + + // 证书验证设置 + if (this.config.server.tls) { + if (typeof this.config.server.tls.rejectUnauthorized === 'boolean') { + tlsOptions.rejectUnauthorized = this.config.server.tls.rejectUnauthorized + } + + // CA 证书 + if (this.config.server.tls.ca) { + tlsOptions.ca = this.config.server.tls.ca + } + + // 客户端证书和私钥 (双向认证) + if (this.config.server.tls.cert) { + tlsOptions.cert = this.config.server.tls.cert + } + + if (this.config.server.tls.key) { + tlsOptions.key = this.config.server.tls.key + } + + // 服务器名称 (SNI) + if (this.config.server.tls.servername) { + tlsOptions.servername = this.config.server.tls.servername + } + } + + clientOptions.tlsOptions = tlsOptions + + logger.debug('🔒 Creating LDAPS client with TLS options:', { + url: this.config.server.url, + rejectUnauthorized: tlsOptions.rejectUnauthorized, + hasCA: !!tlsOptions.ca, + hasCert: !!tlsOptions.cert, + hasKey: !!tlsOptions.key, + servername: tlsOptions.servername + }) + } + + const client = ldap.createClient(clientOptions) + + // 设置错误处理 + client.on('error', (err) => { + if (err.code === 'CERT_HAS_EXPIRED' || err.code === 'UNABLE_TO_VERIFY_LEAF_SIGNATURE') { + logger.error('🔒 LDAP TLS certificate error:', { + code: err.code, + message: err.message, + hint: 'Consider setting LDAP_TLS_REJECT_UNAUTHORIZED=false for self-signed certificates' + }) + } else { + logger.error('🔌 LDAP client error:', err) + } + }) + + client.on('connect', () => { + if (this.config.server.url.toLowerCase().startsWith('ldaps://')) { + logger.info('🔒 LDAPS client connected successfully') + } else { + logger.info('🔗 LDAP client connected successfully') + } + }) + + client.on('connectTimeout', () => { + logger.warn('⏱️ LDAP connection timeout') + }) + + return client + } catch (error) { + logger.error('❌ Failed to create LDAP client:', error) + throw error + } + } + + // 🔒 绑定LDAP连接(管理员认证) + async bindClient(client) { + return new Promise((resolve, reject) => { + // 验证绑定凭据 + const { bindDN } = this.config.server + const { bindCredentials } = this.config.server + + if (!bindDN || typeof bindDN !== 'string') { + const error = new Error('LDAP bind DN is not configured or invalid') + logger.error('❌ LDAP configuration error:', error.message) + reject(error) + return + } + + if (!bindCredentials || typeof bindCredentials !== 'string') { + const error = new Error('LDAP bind credentials are not configured or invalid') + logger.error('❌ LDAP configuration error:', error.message) + reject(error) + return + } + + client.bind(bindDN, bindCredentials, (err) => { + if (err) { + logger.error('❌ LDAP bind failed:', err) + reject(err) + } else { + logger.debug('🔑 LDAP bind successful') + resolve() + } + }) + }) + } + + // 🔍 搜索用户 + async searchUser(client, username) { + return new Promise((resolve, reject) => { + const searchFilter = this.config.server.searchFilter.replace('{{username}}', username) + const searchOptions = { + scope: 'sub', + filter: searchFilter, + attributes: this.config.server.searchAttributes + } + + logger.debug(`🔍 Searching for user: ${username} with filter: ${searchFilter}`) + + const entries = [] + + client.search(this.config.server.searchBase, searchOptions, (err, res) => { + if (err) { + logger.error('❌ LDAP search error:', err) + reject(err) + return + } + + res.on('searchEntry', (entry) => { + logger.debug('🔍 LDAP search entry received:', { + dn: entry.dn, + objectName: entry.objectName, + type: typeof entry.dn, + entryType: typeof entry, + hasAttributes: !!entry.attributes, + attributeCount: entry.attributes ? entry.attributes.length : 0 + }) + entries.push(entry) + }) + + res.on('searchReference', (referral) => { + logger.debug('🔗 LDAP search referral:', referral.uris) + }) + + res.on('error', (error) => { + logger.error('❌ LDAP search result error:', error) + reject(error) + }) + + res.on('end', (result) => { + logger.debug( + `✅ LDAP search completed. Status: ${result.status}, Found ${entries.length} entries` + ) + + if (entries.length === 0) { + resolve(null) + } else { + // Log the structure of the first entry for debugging + if (entries[0]) { + logger.debug('🔍 Full LDAP entry structure:', { + entryType: typeof entries[0], + entryConstructor: entries[0].constructor?.name, + entryKeys: Object.keys(entries[0]), + entryStringified: JSON.stringify(entries[0], null, 2).substring(0, 500) + }) + } + + if (entries.length === 1) { + resolve(entries[0]) + } else { + logger.warn(`⚠️ Multiple LDAP entries found for username: ${username}`) + resolve(entries[0]) // 使用第一个结果 + } + } + }) + }) + }) + } + + // 🔐 验证用户密码 + async authenticateUser(userDN, password) { + return new Promise((resolve, reject) => { + // 验证输入参数 + if (!userDN || typeof userDN !== 'string') { + const error = new Error('User DN is not provided or invalid') + logger.error('❌ LDAP authentication error:', error.message) + reject(error) + return + } + + if (!password || typeof password !== 'string') { + logger.debug(`🚫 Invalid or empty password for DN: ${userDN}`) + resolve(false) + return + } + + const authClient = this.createClient() + + authClient.bind(userDN, password, (err) => { + authClient.unbind() // 立即关闭认证客户端 + + if (err) { + if (err.name === 'InvalidCredentialsError') { + logger.debug(`🚫 Invalid credentials for DN: ${userDN}`) + resolve(false) + } else { + logger.error('❌ LDAP authentication error:', err) + reject(err) + } + } else { + logger.debug(`✅ Authentication successful for DN: ${userDN}`) + resolve(true) + } + }) + }) + } + + // 📝 提取用户信息 + extractUserInfo(ldapEntry, username) { + try { + const attributes = ldapEntry.attributes || [] + const userInfo = { username } + + // 创建属性映射 + const attrMap = {} + attributes.forEach((attr) => { + const name = attr.type || attr.name + const values = Array.isArray(attr.values) ? attr.values : [attr.values] + attrMap[name] = values.length === 1 ? values[0] : values + }) + + // 根据配置映射用户属性 + const mapping = this.config.userMapping + + userInfo.displayName = attrMap[mapping.displayName] || username + userInfo.email = attrMap[mapping.email] || '' + userInfo.firstName = attrMap[mapping.firstName] || '' + userInfo.lastName = attrMap[mapping.lastName] || '' + + // 如果没有displayName,尝试组合firstName和lastName + if (!userInfo.displayName || userInfo.displayName === username) { + if (userInfo.firstName || userInfo.lastName) { + userInfo.displayName = `${userInfo.firstName || ''} ${userInfo.lastName || ''}`.trim() + } + } + + logger.debug('📋 Extracted user info:', { + username: userInfo.username, + displayName: userInfo.displayName, + email: userInfo.email + }) + + return userInfo + } catch (error) { + logger.error('❌ Error extracting user info:', error) + return { username } + } + } + + // 🔍 验证和清理用户名 + validateAndSanitizeUsername(username) { + if (!username || typeof username !== 'string' || username.trim() === '') { + throw new Error('Username is required and must be a non-empty string') + } + + const trimmedUsername = username.trim() + + // 用户名只能包含字母、数字、下划线和连字符 + const usernameRegex = /^[a-zA-Z0-9_-]+$/ + if (!usernameRegex.test(trimmedUsername)) { + throw new Error('Username can only contain letters, numbers, underscores, and hyphens') + } + + // 长度限制 (防止过长的输入) + if (trimmedUsername.length > 64) { + throw new Error('Username cannot exceed 64 characters') + } + + // 不能以连字符开头或结尾 + if (trimmedUsername.startsWith('-') || trimmedUsername.endsWith('-')) { + throw new Error('Username cannot start or end with a hyphen') + } + + return trimmedUsername + } + + // 🔐 主要的登录验证方法 + async authenticateUserCredentials(username, password) { + if (!this.config.enabled) { + throw new Error('LDAP authentication is not enabled') + } + + // 验证和清理用户名 (防止LDAP注入) + const sanitizedUsername = this.validateAndSanitizeUsername(username) + + if (!password || typeof password !== 'string' || password.trim() === '') { + throw new Error('Password is required and must be a non-empty string') + } + + // 验证LDAP服务器配置 + if (!this.config.server || !this.config.server.url) { + throw new Error('LDAP server URL is not configured') + } + + if (!this.config.server.bindDN || typeof this.config.server.bindDN !== 'string') { + throw new Error('LDAP bind DN is not configured') + } + + if ( + !this.config.server.bindCredentials || + typeof this.config.server.bindCredentials !== 'string' + ) { + throw new Error('LDAP bind credentials are not configured') + } + + if (!this.config.server.searchBase || typeof this.config.server.searchBase !== 'string') { + throw new Error('LDAP search base is not configured') + } + + const client = this.createClient() + + try { + // 1. 使用管理员凭据绑定 + await this.bindClient(client) + + // 2. 搜索用户 (使用已验证的用户名) + const ldapEntry = await this.searchUser(client, sanitizedUsername) + if (!ldapEntry) { + logger.info(`🚫 User not found in LDAP: ${sanitizedUsername}`) + return { success: false, message: 'Invalid username or password' } + } + + // 3. 获取用户DN + logger.debug('🔍 LDAP entry details for DN extraction:', { + hasEntry: !!ldapEntry, + entryType: typeof ldapEntry, + entryKeys: Object.keys(ldapEntry || {}), + dn: ldapEntry.dn, + objectName: ldapEntry.objectName, + dnType: typeof ldapEntry.dn, + objectNameType: typeof ldapEntry.objectName + }) + + // Use the helper method to extract DN + const userDN = this.extractDN(ldapEntry) + + logger.debug(`👤 Extracted user DN: ${userDN} (type: ${typeof userDN})`) + + // 验证用户DN + if (!userDN) { + logger.error(`❌ Invalid or missing DN for user: ${sanitizedUsername}`, { + ldapEntryDn: ldapEntry.dn, + ldapEntryObjectName: ldapEntry.objectName, + ldapEntryType: typeof ldapEntry, + extractedDN: userDN + }) + return { success: false, message: 'Authentication service error' } + } + + // 4. 验证用户密码 + const isPasswordValid = await this.authenticateUser(userDN, password) + if (!isPasswordValid) { + logger.info(`🚫 Invalid password for user: ${sanitizedUsername}`) + return { success: false, message: 'Invalid username or password' } + } + + // 5. 提取用户信息 + const userInfo = this.extractUserInfo(ldapEntry, sanitizedUsername) + + // 6. 创建或更新本地用户 + const user = await userService.createOrUpdateUser(userInfo) + + // 7. 检查用户是否被禁用 + if (!user.isActive) { + logger.security( + `🔒 Disabled user LDAP login attempt: ${sanitizedUsername} from LDAP authentication` + ) + return { + success: false, + message: 'Your account has been disabled. Please contact administrator.' + } + } + + // 8. 记录登录 + await userService.recordUserLogin(user.id) + + // 9. 创建用户会话 + const sessionToken = await userService.createUserSession(user.id) + + logger.info(`✅ LDAP authentication successful for user: ${sanitizedUsername}`) + + return { + success: true, + user, + sessionToken, + message: 'Authentication successful' + } + } catch (error) { + logger.error('❌ LDAP authentication error:', error) + return { + success: false, + message: 'Authentication service unavailable' + } + } finally { + // 确保客户端连接被关闭 + if (client) { + client.unbind((err) => { + if (err) { + logger.debug('Error unbinding LDAP client:', err) + } + }) + } + } + } + + // 🔍 测试LDAP连接 + async testConnection() { + if (!this.config.enabled) { + return { success: false, message: 'LDAP is not enabled' } + } + + const client = this.createClient() + + try { + await this.bindClient(client) + + return { + success: true, + message: 'LDAP connection successful', + server: this.config.server.url, + searchBase: this.config.server.searchBase + } + } catch (error) { + logger.error('❌ LDAP connection test failed:', error) + return { + success: false, + message: `LDAP connection failed: ${error.message}`, + server: this.config.server.url + } + } finally { + if (client) { + client.unbind((err) => { + if (err) { + logger.debug('Error unbinding test LDAP client:', err) + } + }) + } + } + } + + // 📊 获取LDAP配置信息(不包含敏感信息) + getConfigInfo() { + const configInfo = { + enabled: this.config.enabled, + server: { + url: this.config.server.url, + searchBase: this.config.server.searchBase, + searchFilter: this.config.server.searchFilter, + timeout: this.config.server.timeout, + connectTimeout: this.config.server.connectTimeout + }, + userMapping: this.config.userMapping + } + + // 添加 TLS 配置信息(不包含敏感数据) + if (this.config.server.url.toLowerCase().startsWith('ldaps://') && this.config.server.tls) { + configInfo.server.tls = { + rejectUnauthorized: this.config.server.tls.rejectUnauthorized, + hasCA: !!this.config.server.tls.ca, + hasCert: !!this.config.server.tls.cert, + hasKey: !!this.config.server.tls.key, + servername: this.config.server.tls.servername + } + } + + return configInfo + } +} + +module.exports = new LdapService() diff --git a/src/services/openaiAccountService.js b/src/services/openaiAccountService.js index 1e88cdec..e60a8b3a 100644 --- a/src/services/openaiAccountService.js +++ b/src/services/openaiAccountService.js @@ -502,6 +502,8 @@ async function getAllAccounts() { // 不解密敏感字段,只返回基本信息 accounts.push({ ...accountData, + isActive: accountData.isActive === 'true', + schedulable: accountData.schedulable !== 'false', openaiOauth: accountData.openaiOauth ? '[ENCRYPTED]' : '', accessToken: accountData.accessToken ? '[ENCRYPTED]' : '', refreshToken: accountData.refreshToken ? '[ENCRYPTED]' : '', diff --git a/src/services/unifiedOpenAIScheduler.js b/src/services/unifiedOpenAIScheduler.js index f800621c..153f75b0 100644 --- a/src/services/unifiedOpenAIScheduler.js +++ b/src/services/unifiedOpenAIScheduler.js @@ -34,7 +34,11 @@ class UnifiedOpenAIScheduler { // 普通专属账户 const boundAccount = await openaiAccountService.getAccount(apiKeyData.openaiAccountId) - if (boundAccount && boundAccount.isActive === 'true' && boundAccount.status !== 'error') { + if ( + boundAccount && + (boundAccount.isActive === true || boundAccount.isActive === 'true') && + boundAccount.status !== 'error' + ) { // 检查是否被限流 const isRateLimited = await this.isAccountRateLimited(boundAccount.id) if (isRateLimited) { @@ -165,7 +169,7 @@ class UnifiedOpenAIScheduler { const openaiAccounts = await openaiAccountService.getAllAccounts() for (const account of openaiAccounts) { if ( - account.isActive === 'true' && + account.isActive && account.status !== 'error' && (account.accountType === 'shared' || !account.accountType) && // 兼容旧数据 this._isSchedulable(account.schedulable) @@ -233,7 +237,7 @@ class UnifiedOpenAIScheduler { try { if (accountType === 'openai') { const account = await openaiAccountService.getAccount(accountId) - if (!account || account.isActive !== 'true' || account.status === 'error') { + if (!account || !account.isActive || account.status === 'error') { return false } // 检查是否可调度 @@ -395,7 +399,7 @@ class UnifiedOpenAIScheduler { const account = await openaiAccountService.getAccount(memberId) if ( account && - account.isActive === 'true' && + account.isActive && account.status !== 'error' && this._isSchedulable(account.schedulable) ) { diff --git a/src/services/userService.js b/src/services/userService.js new file mode 100644 index 00000000..601d6419 --- /dev/null +++ b/src/services/userService.js @@ -0,0 +1,514 @@ +const redis = require('../models/redis') +const crypto = require('crypto') +const logger = require('../utils/logger') +const config = require('../../config/config') + +class UserService { + constructor() { + this.userPrefix = 'user:' + this.usernamePrefix = 'username:' + this.userSessionPrefix = 'user_session:' + } + + // 🔑 生成用户ID + generateUserId() { + return crypto.randomBytes(16).toString('hex') + } + + // 🔑 生成会话Token + generateSessionToken() { + return crypto.randomBytes(32).toString('hex') + } + + // 👤 创建或更新用户 + async createOrUpdateUser(userData) { + try { + const { + username, + email, + displayName, + firstName, + lastName, + role = config.userManagement.defaultUserRole, + isActive = true + } = userData + + // 检查用户是否已存在 + let user = await this.getUserByUsername(username) + const isNewUser = !user + + if (isNewUser) { + const userId = this.generateUserId() + user = { + id: userId, + username, + email, + displayName, + firstName, + lastName, + role, + isActive, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + lastLoginAt: null, + apiKeyCount: 0, + totalUsage: { + requests: 0, + inputTokens: 0, + outputTokens: 0, + totalCost: 0 + } + } + } else { + // 更新现有用户信息 + user = { + ...user, + email, + displayName, + firstName, + lastName, + updatedAt: new Date().toISOString() + } + } + + // 保存用户信息 + await redis.set(`${this.userPrefix}${user.id}`, JSON.stringify(user)) + await redis.set(`${this.usernamePrefix}${username}`, user.id) + + logger.info(`📝 ${isNewUser ? 'Created' : 'Updated'} user: ${username} (${user.id})`) + return user + } catch (error) { + logger.error('❌ Error creating/updating user:', error) + throw error + } + } + + // 👤 通过用户名获取用户 + async getUserByUsername(username) { + try { + const userId = await redis.get(`${this.usernamePrefix}${username}`) + if (!userId) { + return null + } + + const userData = await redis.get(`${this.userPrefix}${userId}`) + return userData ? JSON.parse(userData) : null + } catch (error) { + logger.error('❌ Error getting user by username:', error) + throw error + } + } + + // 👤 通过ID获取用户 + async getUserById(userId, calculateUsage = true) { + try { + const userData = await redis.get(`${this.userPrefix}${userId}`) + if (!userData) { + return null + } + + const user = JSON.parse(userData) + + // Calculate totalUsage by aggregating user's API keys usage (if requested) + if (calculateUsage) { + try { + const usageStats = await this.calculateUserUsageStats(userId) + user.totalUsage = usageStats.totalUsage + user.apiKeyCount = usageStats.apiKeyCount + } catch (error) { + logger.error('❌ Error calculating user usage stats:', error) + // Fallback to stored values if calculation fails + user.totalUsage = user.totalUsage || { + requests: 0, + inputTokens: 0, + outputTokens: 0, + totalCost: 0 + } + user.apiKeyCount = user.apiKeyCount || 0 + } + } + + return user + } catch (error) { + logger.error('❌ Error getting user by ID:', error) + throw error + } + } + + // 📊 计算用户使用统计(通过聚合API Keys) + async calculateUserUsageStats(userId) { + try { + // Use the existing apiKeyService method which already includes usage stats + const apiKeyService = require('./apiKeyService') + const userApiKeys = await apiKeyService.getUserApiKeys(userId, true) // Include deleted keys for stats + + const totalUsage = { + requests: 0, + inputTokens: 0, + outputTokens: 0, + totalCost: 0 + } + + for (const apiKey of userApiKeys) { + if (apiKey.usage && apiKey.usage.total) { + totalUsage.requests += apiKey.usage.total.requests || 0 + totalUsage.inputTokens += apiKey.usage.total.inputTokens || 0 + totalUsage.outputTokens += apiKey.usage.total.outputTokens || 0 + totalUsage.totalCost += apiKey.totalCost || 0 + } + } + + logger.debug( + `📊 Calculated user ${userId} usage: ${totalUsage.requests} requests, ${totalUsage.inputTokens} input tokens, $${totalUsage.totalCost.toFixed(4)} total cost from ${userApiKeys.length} API keys` + ) + + // Count only non-deleted API keys for the user's active count + const activeApiKeyCount = userApiKeys.filter((key) => key.isDeleted !== 'true').length + + return { + totalUsage, + apiKeyCount: activeApiKeyCount + } + } catch (error) { + logger.error('❌ Error calculating user usage stats:', error) + return { + totalUsage: { + requests: 0, + inputTokens: 0, + outputTokens: 0, + totalCost: 0 + }, + apiKeyCount: 0 + } + } + } + + // 📋 获取所有用户列表(管理员功能) + async getAllUsers(options = {}) { + try { + const client = redis.getClientSafe() + const { page = 1, limit = 20, role, isActive } = options + const pattern = `${this.userPrefix}*` + const keys = await client.keys(pattern) + + const users = [] + for (const key of keys) { + const userData = await client.get(key) + if (userData) { + const user = JSON.parse(userData) + + // 应用过滤条件 + if (role && user.role !== role) { + continue + } + if (typeof isActive === 'boolean' && user.isActive !== isActive) { + continue + } + + // Calculate dynamic usage stats for each user + try { + const usageStats = await this.calculateUserUsageStats(user.id) + user.totalUsage = usageStats.totalUsage + user.apiKeyCount = usageStats.apiKeyCount + } catch (error) { + logger.error(`❌ Error calculating usage for user ${user.id}:`, error) + // Fallback to stored values + user.totalUsage = user.totalUsage || { + requests: 0, + inputTokens: 0, + outputTokens: 0, + totalCost: 0 + } + user.apiKeyCount = user.apiKeyCount || 0 + } + + users.push(user) + } + } + + // 排序和分页 + users.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)) + const startIndex = (page - 1) * limit + const endIndex = startIndex + limit + const paginatedUsers = users.slice(startIndex, endIndex) + + return { + users: paginatedUsers, + total: users.length, + page, + limit, + totalPages: Math.ceil(users.length / limit) + } + } catch (error) { + logger.error('❌ Error getting all users:', error) + throw error + } + } + + // 🔄 更新用户状态 + async updateUserStatus(userId, isActive) { + try { + const user = await this.getUserById(userId, false) // Skip usage calculation + if (!user) { + throw new Error('User not found') + } + + user.isActive = isActive + user.updatedAt = new Date().toISOString() + + await redis.set(`${this.userPrefix}${userId}`, JSON.stringify(user)) + logger.info(`🔄 Updated user status: ${user.username} -> ${isActive ? 'active' : 'disabled'}`) + + // 如果禁用用户,删除所有会话并禁用其所有API Keys + if (!isActive) { + await this.invalidateUserSessions(userId) + + // Disable all user's API keys when user is disabled + try { + const apiKeyService = require('./apiKeyService') + const result = await apiKeyService.disableUserApiKeys(userId) + logger.info(`🔑 Disabled ${result.count} API keys for disabled user: ${user.username}`) + } catch (error) { + logger.error('❌ Error disabling user API keys during user disable:', error) + } + } + + return user + } catch (error) { + logger.error('❌ Error updating user status:', error) + throw error + } + } + + // 🔄 更新用户角色 + async updateUserRole(userId, role) { + try { + const user = await this.getUserById(userId, false) // Skip usage calculation + if (!user) { + throw new Error('User not found') + } + + user.role = role + user.updatedAt = new Date().toISOString() + + await redis.set(`${this.userPrefix}${userId}`, JSON.stringify(user)) + logger.info(`🔄 Updated user role: ${user.username} -> ${role}`) + + return user + } catch (error) { + logger.error('❌ Error updating user role:', error) + throw error + } + } + + // 📊 更新用户API Key数量 (已废弃,现在通过聚合计算) + async updateUserApiKeyCount(userId, _count) { + // This method is deprecated since apiKeyCount is now calculated dynamically + // in getUserById by aggregating the user's API keys + logger.debug( + `📊 updateUserApiKeyCount called for ${userId} but is now deprecated (count auto-calculated)` + ) + } + + // 📝 记录用户登录 + async recordUserLogin(userId) { + try { + const user = await this.getUserById(userId, false) // Skip usage calculation + if (!user) { + return + } + + user.lastLoginAt = new Date().toISOString() + await redis.set(`${this.userPrefix}${userId}`, JSON.stringify(user)) + } catch (error) { + logger.error('❌ Error recording user login:', error) + } + } + + // 🎫 创建用户会话 + async createUserSession(userId, sessionData = {}) { + try { + const sessionToken = this.generateSessionToken() + const session = { + token: sessionToken, + userId, + createdAt: new Date().toISOString(), + expiresAt: new Date(Date.now() + config.userManagement.userSessionTimeout).toISOString(), + ...sessionData + } + + const ttl = Math.floor(config.userManagement.userSessionTimeout / 1000) + await redis.setex(`${this.userSessionPrefix}${sessionToken}`, ttl, JSON.stringify(session)) + + logger.info(`🎫 Created session for user: ${userId}`) + return sessionToken + } catch (error) { + logger.error('❌ Error creating user session:', error) + throw error + } + } + + // 🎫 验证用户会话 + async validateUserSession(sessionToken) { + try { + const sessionData = await redis.get(`${this.userSessionPrefix}${sessionToken}`) + if (!sessionData) { + return null + } + + const session = JSON.parse(sessionData) + + // 检查会话是否过期 + if (new Date() > new Date(session.expiresAt)) { + await this.invalidateUserSession(sessionToken) + return null + } + + // 获取用户信息 + const user = await this.getUserById(session.userId, false) // Skip usage calculation for validation + if (!user || !user.isActive) { + await this.invalidateUserSession(sessionToken) + return null + } + + return { session, user } + } catch (error) { + logger.error('❌ Error validating user session:', error) + return null + } + } + + // 🚫 使用户会话失效 + async invalidateUserSession(sessionToken) { + try { + await redis.del(`${this.userSessionPrefix}${sessionToken}`) + logger.info(`🚫 Invalidated session: ${sessionToken}`) + } catch (error) { + logger.error('❌ Error invalidating user session:', error) + } + } + + // 🚫 使用户所有会话失效 + async invalidateUserSessions(userId) { + try { + const client = redis.getClientSafe() + const pattern = `${this.userSessionPrefix}*` + const keys = await client.keys(pattern) + + for (const key of keys) { + const sessionData = await client.get(key) + if (sessionData) { + const session = JSON.parse(sessionData) + if (session.userId === userId) { + await client.del(key) + } + } + } + + logger.info(`🚫 Invalidated all sessions for user: ${userId}`) + } catch (error) { + logger.error('❌ Error invalidating user sessions:', error) + } + } + + // 🗑️ 删除用户(软删除,标记为不活跃) + async deleteUser(userId) { + try { + const user = await this.getUserById(userId, false) // Skip usage calculation + if (!user) { + throw new Error('User not found') + } + + // 软删除:标记为不活跃并添加删除时间戳 + user.isActive = false + user.deletedAt = new Date().toISOString() + user.updatedAt = new Date().toISOString() + + await redis.set(`${this.userPrefix}${userId}`, JSON.stringify(user)) + + // 删除所有会话 + await this.invalidateUserSessions(userId) + + // Disable all user's API keys when user is deleted + try { + const apiKeyService = require('./apiKeyService') + const result = await apiKeyService.disableUserApiKeys(userId) + logger.info(`🔑 Disabled ${result.count} API keys for deleted user: ${user.username}`) + } catch (error) { + logger.error('❌ Error disabling user API keys during user deletion:', error) + } + + logger.info(`🗑️ Soft deleted user: ${user.username} (${userId})`) + return user + } catch (error) { + logger.error('❌ Error deleting user:', error) + throw error + } + } + + // 📊 获取用户统计信息 + async getUserStats() { + try { + const client = redis.getClientSafe() + const pattern = `${this.userPrefix}*` + const keys = await client.keys(pattern) + + const stats = { + totalUsers: 0, + activeUsers: 0, + adminUsers: 0, + regularUsers: 0, + totalApiKeys: 0, + totalUsage: { + requests: 0, + inputTokens: 0, + outputTokens: 0, + totalCost: 0 + } + } + + for (const key of keys) { + const userData = await client.get(key) + if (userData) { + const user = JSON.parse(userData) + stats.totalUsers++ + + if (user.isActive) { + stats.activeUsers++ + } + + if (user.role === 'admin') { + stats.adminUsers++ + } else { + stats.regularUsers++ + } + + // Calculate dynamic usage stats for each user + try { + const usageStats = await this.calculateUserUsageStats(user.id) + stats.totalApiKeys += usageStats.apiKeyCount + stats.totalUsage.requests += usageStats.totalUsage.requests + stats.totalUsage.inputTokens += usageStats.totalUsage.inputTokens + stats.totalUsage.outputTokens += usageStats.totalUsage.outputTokens + stats.totalUsage.totalCost += usageStats.totalUsage.totalCost + } catch (error) { + logger.error(`❌ Error calculating usage for user ${user.id} in stats:`, error) + // Fallback to stored values if calculation fails + stats.totalApiKeys += user.apiKeyCount || 0 + stats.totalUsage.requests += user.totalUsage?.requests || 0 + stats.totalUsage.inputTokens += user.totalUsage?.inputTokens || 0 + stats.totalUsage.outputTokens += user.totalUsage?.outputTokens || 0 + stats.totalUsage.totalCost += user.totalUsage?.totalCost || 0 + } + } + } + + return stats + } catch (error) { + logger.error('❌ Error getting user stats:', error) + throw error + } + } +} + +module.exports = new UserService() diff --git a/web/admin-spa/src/components/accounts/AccountForm.vue b/web/admin-spa/src/components/accounts/AccountForm.vue index 465952d5..35e3f776 100644 --- a/web/admin-spa/src/components/accounts/AccountForm.vue +++ b/web/admin-spa/src/components/accounts/AccountForm.vue @@ -528,7 +528,17 @@ >
+ +
+
+ + +

+ {{ errors.azureEndpoint }} +

+
+ +
+ + +

+ Azure OpenAI API 版本,默认使用最新稳定版本 2024-02-01 +

+
+ +
+ + +

+ {{ errors.deploymentName }} +

+
+ +
+ + +

+ {{ errors.apiKey }} +

+

留空表示不更新 API Key

+
+ +
+ +
+ +
+

选择此部署支持的模型类型

+
+
+
@@ -1821,16 +1934,16 @@ const form = ref({ priority: props.account?.priority || 50, supportedModels: (() => { const models = props.account?.supportedModels - if (!models) return '' + if (!models) return [] // 处理对象格式(Claude Console 的新格式) if (typeof models === 'object' && !Array.isArray(models)) { - return Object.keys(models).join('\n') + return Object.keys(models) } // 处理数组格式(向后兼容) if (Array.isArray(models)) { - return models.join('\n') + return models } - return '' + return [] })(), userAgent: props.account?.userAgent || '', enableRateLimit: props.account ? props.account.rateLimitDuration > 0 : true, @@ -1841,7 +1954,11 @@ const form = ref({ region: props.account?.region || '', sessionToken: props.account?.sessionToken || '', defaultModel: props.account?.defaultModel || '', - smallFastModel: props.account?.smallFastModel || '' + smallFastModel: props.account?.smallFastModel || '', + // Azure OpenAI 特定字段 + azureEndpoint: props.account?.azureEndpoint || '', + apiVersion: props.account?.apiVersion || '', + deploymentName: props.account?.deploymentName || '' }) // 模型映射表数据 @@ -1878,7 +1995,9 @@ const errors = ref({ apiKey: '', accessKeyId: '', secretAccessKey: '', - region: '' + region: '', + azureEndpoint: '', + deploymentName: '' }) // 计算是否可以进入下一步 @@ -2146,6 +2265,20 @@ const createAccount = async () => { errors.value.region = '请选择 AWS 区域' hasError = true } + } else if (form.value.platform === 'azure_openai') { + // Azure OpenAI 验证 + if (!form.value.azureEndpoint || form.value.azureEndpoint.trim() === '') { + errors.value.azureEndpoint = '请填写 Azure Endpoint' + hasError = true + } + if (!form.value.deploymentName || form.value.deploymentName.trim() === '') { + errors.value.deploymentName = '请填写部署名称' + hasError = true + } + if (!form.value.apiKey || form.value.apiKey.trim() === '') { + errors.value.apiKey = '请填写 API Key' + hasError = true + } } else if (form.value.addType === 'manual') { // 手动模式验证 if (!form.value.accessToken || form.value.accessToken.trim() === '') { @@ -2307,6 +2440,16 @@ const createAccount = async () => { data.priority = form.value.priority || 50 // 如果不启用限流,传递 0 表示不限流 data.rateLimitDuration = form.value.enableRateLimit ? form.value.rateLimitDuration || 60 : 0 + } else if (form.value.platform === 'azure_openai') { + // Azure OpenAI 账户特定数据 + data.azureEndpoint = form.value.azureEndpoint + data.apiKey = form.value.apiKey + data.apiVersion = form.value.apiVersion || '2024-02-01' + data.deploymentName = form.value.deploymentName + data.supportedModels = Array.isArray(form.value.supportedModels) + ? form.value.supportedModels + : [] + data.priority = form.value.priority || 50 } let result @@ -2318,6 +2461,8 @@ const createAccount = async () => { result = await accountsStore.createBedrockAccount(data) } else if (form.value.platform === 'openai') { result = await accountsStore.createOpenAIAccount(data) + } else if (form.value.platform === 'azure_openai') { + result = await accountsStore.createAzureOpenAIAccount(data) } else { result = await accountsStore.createGeminiAccount(data) } @@ -2492,6 +2637,21 @@ const updateAccount = async () => { data.rateLimitDuration = form.value.enableRateLimit ? form.value.rateLimitDuration || 60 : 0 } + // Azure OpenAI 特定更新 + if (props.account.platform === 'azure_openai') { + data.azureEndpoint = form.value.azureEndpoint + data.apiVersion = form.value.apiVersion || '2024-02-01' + data.deploymentName = form.value.deploymentName + data.supportedModels = Array.isArray(form.value.supportedModels) + ? form.value.supportedModels + : [] + data.priority = form.value.priority || 50 + // 只有当有新的 API Key 时才更新 + if (form.value.apiKey && form.value.apiKey.trim()) { + data.apiKey = form.value.apiKey + } + } + if (props.account.platform === 'claude') { await accountsStore.updateClaudeAccount(props.account.id, data) } else if (props.account.platform === 'claude-console') { @@ -2500,6 +2660,8 @@ const updateAccount = async () => { await accountsStore.updateBedrockAccount(props.account.id, data) } else if (props.account.platform === 'openai') { await accountsStore.updateOpenAIAccount(props.account.id, data) + } else if (props.account.platform === 'azure_openai') { + await accountsStore.updateAzureOpenAIAccount(props.account.id, data) } else { await accountsStore.updateGeminiAccount(props.account.id, data) } @@ -2552,6 +2714,26 @@ watch( } ) +// 监听Azure Endpoint变化,清除错误 +watch( + () => form.value.azureEndpoint, + () => { + if (errors.value.azureEndpoint && form.value.azureEndpoint?.trim()) { + errors.value.azureEndpoint = '' + } + } +) + +// 监听Deployment Name变化,清除错误 +watch( + () => form.value.deploymentName, + () => { + if (errors.value.deploymentName && form.value.deploymentName?.trim()) { + errors.value.deploymentName = '' + } + } +) + // 分组相关数据 const groups = ref([]) const loadingGroups = ref(false) @@ -2784,16 +2966,16 @@ watch( priority: newAccount.priority || 50, supportedModels: (() => { const models = newAccount.supportedModels - if (!models) return '' + if (!models) return [] // 处理对象格式(Claude Console 的新格式) if (typeof models === 'object' && !Array.isArray(models)) { - return Object.keys(models).join('\n') + return Object.keys(models) } // 处理数组格式(向后兼容) if (Array.isArray(models)) { - return models.join('\n') + return models } - return '' + return [] })(), userAgent: newAccount.userAgent || '', enableRateLimit: @@ -2805,7 +2987,11 @@ watch( region: newAccount.region || '', sessionToken: '', // 编辑模式不显示现有的会话令牌 defaultModel: newAccount.defaultModel || '', - smallFastModel: newAccount.smallFastModel || '' + smallFastModel: newAccount.smallFastModel || '', + // Azure OpenAI 特定字段 + azureEndpoint: newAccount.azureEndpoint || '', + apiVersion: newAccount.apiVersion || '', + deploymentName: newAccount.deploymentName || '' } // 如果是分组类型,加载分组ID diff --git a/web/admin-spa/src/components/admin/ChangeRoleModal.vue b/web/admin-spa/src/components/admin/ChangeRoleModal.vue new file mode 100644 index 00000000..10b0757e --- /dev/null +++ b/web/admin-spa/src/components/admin/ChangeRoleModal.vue @@ -0,0 +1,254 @@ + + + + + diff --git a/web/admin-spa/src/components/admin/UserUsageStatsModal.vue b/web/admin-spa/src/components/admin/UserUsageStatsModal.vue new file mode 100644 index 00000000..97f933cf --- /dev/null +++ b/web/admin-spa/src/components/admin/UserUsageStatsModal.vue @@ -0,0 +1,428 @@ + + + + + diff --git a/web/admin-spa/src/components/layout/MainLayout.vue b/web/admin-spa/src/components/layout/MainLayout.vue index df4d4d20..61e7a353 100644 --- a/web/admin-spa/src/components/layout/MainLayout.vue +++ b/web/admin-spa/src/components/layout/MainLayout.vue @@ -35,6 +35,7 @@ const tabRouteMap = { dashboard: '/dashboard', apiKeys: '/api-keys', accounts: '/accounts', + userManagement: '/user-management', tutorial: '/tutorial', settings: '/settings' } diff --git a/web/admin-spa/src/components/layout/TabBar.vue b/web/admin-spa/src/components/layout/TabBar.vue index 55196957..87c6dcc8 100644 --- a/web/admin-spa/src/components/layout/TabBar.vue +++ b/web/admin-spa/src/components/layout/TabBar.vue @@ -50,6 +50,7 @@ const tabs = [ { key: 'dashboard', name: '仪表板', shortName: '仪表板', icon: 'fas fa-tachometer-alt' }, { key: 'apiKeys', name: 'API Keys', shortName: 'API', icon: 'fas fa-key' }, { key: 'accounts', name: '账户管理', shortName: '账户', icon: 'fas fa-user-circle' }, + { key: 'userManagement', name: '用户管理', shortName: '用户', icon: 'fas fa-users' }, { key: 'tutorial', name: '使用教程', shortName: '教程', icon: 'fas fa-graduation-cap' }, { key: 'settings', name: '系统设置', shortName: '设置', icon: 'fas fa-cogs' } ] diff --git a/web/admin-spa/src/components/user/CreateApiKeyModal.vue b/web/admin-spa/src/components/user/CreateApiKeyModal.vue new file mode 100644 index 00000000..a752d7c6 --- /dev/null +++ b/web/admin-spa/src/components/user/CreateApiKeyModal.vue @@ -0,0 +1,265 @@ + + + + + diff --git a/web/admin-spa/src/components/user/UserApiKeysManager.vue b/web/admin-spa/src/components/user/UserApiKeysManager.vue new file mode 100644 index 00000000..8100cd88 --- /dev/null +++ b/web/admin-spa/src/components/user/UserApiKeysManager.vue @@ -0,0 +1,349 @@ + + + + + diff --git a/web/admin-spa/src/components/user/UserUsageStats.vue b/web/admin-spa/src/components/user/UserUsageStats.vue new file mode 100644 index 00000000..0cf885d4 --- /dev/null +++ b/web/admin-spa/src/components/user/UserUsageStats.vue @@ -0,0 +1,397 @@ + + + + + diff --git a/web/admin-spa/src/components/user/ViewApiKeyModal.vue b/web/admin-spa/src/components/user/ViewApiKeyModal.vue new file mode 100644 index 00000000..99ea1738 --- /dev/null +++ b/web/admin-spa/src/components/user/ViewApiKeyModal.vue @@ -0,0 +1,250 @@ + + + + + diff --git a/web/admin-spa/src/config/api.js b/web/admin-spa/src/config/api.js index 155d8bcf..27aaee55 100644 --- a/web/admin-spa/src/config/api.js +++ b/web/admin-spa/src/config/api.js @@ -149,6 +149,24 @@ class ApiClient { } } + // PATCH 请求 + async patch(url, data = null, options = {}) { + const fullUrl = createApiUrl(url) + const config = this.buildConfig({ + ...options, + method: 'PATCH', + body: data ? JSON.stringify(data) : undefined + }) + + try { + const response = await fetch(fullUrl, config) + return await this.handleResponse(response) + } catch (error) { + console.error('API PATCH Error:', error) + throw error + } + } + // DELETE 请求 async delete(url, options = {}) { const fullUrl = createApiUrl(url) diff --git a/web/admin-spa/src/main.js b/web/admin-spa/src/main.js index 6213f3eb..79181b6b 100644 --- a/web/admin-spa/src/main.js +++ b/web/admin-spa/src/main.js @@ -6,6 +6,7 @@ import 'element-plus/dist/index.css' import 'element-plus/theme-chalk/dark/css-vars.css' import App from './App.vue' import router from './router' +import { useUserStore } from './stores/user' import './assets/styles/main.css' import './assets/styles/global.css' @@ -24,5 +25,9 @@ app.use(ElementPlus, { locale: zhCn }) +// 设置axios拦截器 +const userStore = useUserStore() +userStore.setupAxiosInterceptors() + // 挂载应用 app.mount('#app') diff --git a/web/admin-spa/src/router/index.js b/web/admin-spa/src/router/index.js index 47680c7d..16a2b50a 100644 --- a/web/admin-spa/src/router/index.js +++ b/web/admin-spa/src/router/index.js @@ -1,9 +1,13 @@ import { createRouter, createWebHistory } from 'vue-router' import { useAuthStore } from '@/stores/auth' +import { useUserStore } from '@/stores/user' import { APP_CONFIG } from '@/config/app' // 路由懒加载 const LoginView = () => import('@/views/LoginView.vue') +const UserLoginView = () => import('@/views/UserLoginView.vue') +const UserDashboardView = () => import('@/views/UserDashboardView.vue') +const UserManagementView = () => import('@/views/UserManagementView.vue') const MainLayout = () => import('@/components/layout/MainLayout.vue') const DashboardView = () => import('@/views/DashboardView.vue') const ApiKeysView = () => import('@/views/ApiKeysView.vue') @@ -35,6 +39,22 @@ const routes = [ component: LoginView, meta: { requiresAuth: false } }, + { + path: '/admin-login', + redirect: '/login' + }, + { + path: '/user-login', + name: 'UserLogin', + component: UserLoginView, + meta: { requiresAuth: false, userAuth: true } + }, + { + path: '/user-dashboard', + name: 'UserDashboard', + component: UserDashboardView, + meta: { requiresUserAuth: true } + }, { path: '/api-stats', name: 'ApiStats', @@ -101,6 +121,18 @@ const routes = [ } ] }, + { + path: '/user-management', + component: MainLayout, + meta: { requiresAuth: true }, + children: [ + { + path: '', + name: 'UserManagement', + component: UserManagementView + } + ] + }, // 捕获所有未匹配的路由 { path: '/:pathMatch(.*)*', @@ -114,15 +146,18 @@ const router = createRouter({ }) // 路由守卫 -router.beforeEach((to, from, next) => { +router.beforeEach(async (to, from, next) => { const authStore = useAuthStore() + const userStore = useUserStore() console.log('路由导航:', { to: to.path, from: from.path, fullPath: to.fullPath, requiresAuth: to.meta.requiresAuth, - isAuthenticated: authStore.isAuthenticated + requiresUserAuth: to.meta.requiresUserAuth, + isAuthenticated: authStore.isAuthenticated, + isUserAuthenticated: userStore.isAuthenticated }) // 防止重定向循环:如果已经在目标路径,直接放行 @@ -130,9 +165,38 @@ router.beforeEach((to, from, next) => { return next() } + // 检查用户认证状态 + if (to.meta.requiresUserAuth) { + if (!userStore.isAuthenticated) { + // 尝试检查本地存储的认证信息 + try { + const isUserLoggedIn = await userStore.checkAuth() + if (!isUserLoggedIn) { + return next('/user-login') + } + } catch (error) { + // If the error is about disabled account, redirect to login with error + if (error.message && error.message.includes('disabled')) { + // Import showToast to display the error + const { showToast } = await import('@/utils/toast') + showToast(error.message, 'error') + } + return next('/user-login') + } + } + return next() + } + // API Stats 页面不需要认证,直接放行 if (to.path === '/api-stats' || to.path.startsWith('/api-stats')) { next() + } else if (to.path === '/user-login') { + // 如果已经是用户登录状态,重定向到用户仪表板 + if (userStore.isAuthenticated) { + next('/user-dashboard') + } else { + next() + } } else if (to.meta.requiresAuth && !authStore.isAuthenticated) { next('/login') } else if (to.path === '/login' && authStore.isAuthenticated) { diff --git a/web/admin-spa/src/stores/user.js b/web/admin-spa/src/stores/user.js new file mode 100644 index 00000000..db6a4dfc --- /dev/null +++ b/web/admin-spa/src/stores/user.js @@ -0,0 +1,217 @@ +import { defineStore } from 'pinia' +import axios from 'axios' +import { showToast } from '@/utils/toast' + +const API_BASE = '/users' + +export const useUserStore = defineStore('user', { + state: () => ({ + user: null, + isAuthenticated: false, + sessionToken: null, + loading: false, + config: null + }), + + getters: { + isLoggedIn: (state) => state.isAuthenticated && state.user, + userName: (state) => state.user?.displayName || state.user?.username, + userRole: (state) => state.user?.role + }, + + actions: { + // 🔐 用户登录 + async login(credentials) { + this.loading = true + try { + const response = await axios.post(`${API_BASE}/login`, credentials) + + if (response.data.success) { + this.user = response.data.user + this.sessionToken = response.data.sessionToken + this.isAuthenticated = true + + // 保存到 localStorage + localStorage.setItem('userToken', this.sessionToken) + localStorage.setItem('userData', JSON.stringify(this.user)) + + // 设置 axios 默认头部 + this.setAuthHeader() + + return response.data + } else { + throw new Error(response.data.message || 'Login failed') + } + } catch (error) { + this.clearAuth() + throw error + } finally { + this.loading = false + } + }, + + // 🚪 用户登出 + async logout() { + try { + if (this.sessionToken) { + await axios.post( + `${API_BASE}/logout`, + {}, + { + headers: { 'x-user-token': this.sessionToken } + } + ) + } + } catch (error) { + console.error('Logout request failed:', error) + } finally { + this.clearAuth() + } + }, + + // 🔄 检查认证状态 + async checkAuth() { + const token = localStorage.getItem('userToken') + const userData = localStorage.getItem('userData') + const userConfig = localStorage.getItem('userConfig') + + if (!token || !userData) { + this.clearAuth() + return false + } + + try { + this.sessionToken = token + this.user = JSON.parse(userData) + this.config = userConfig ? JSON.parse(userConfig) : null + this.isAuthenticated = true + this.setAuthHeader() + + // 验证 token 是否仍然有效 + await this.getUserProfile() + return true + } catch (error) { + console.error('Auth check failed:', error) + this.clearAuth() + return false + } + }, + + // 👤 获取用户资料 + async getUserProfile() { + try { + const response = await axios.get(`${API_BASE}/profile`) + + if (response.data.success) { + this.user = response.data.user + this.config = response.data.config + localStorage.setItem('userData', JSON.stringify(this.user)) + localStorage.setItem('userConfig', JSON.stringify(this.config)) + return response.data.user + } + } catch (error) { + if (error.response?.status === 401 || error.response?.status === 403) { + // 401: Invalid/expired session, 403: Account disabled + this.clearAuth() + // If it's a disabled account error, throw a specific error + if (error.response?.status === 403) { + throw new Error(error.response.data?.message || 'Your account has been disabled') + } + } + throw error + } + }, + + // 🔑 获取用户API Keys + async getUserApiKeys(includeDeleted = false) { + try { + const params = {} + if (includeDeleted) { + params.includeDeleted = 'true' + } + const response = await axios.get(`${API_BASE}/api-keys`, { params }) + return response.data.success ? response.data.apiKeys : [] + } catch (error) { + console.error('Failed to fetch API keys:', error) + throw error + } + }, + + // 🔑 创建API Key + async createApiKey(keyData) { + try { + const response = await axios.post(`${API_BASE}/api-keys`, keyData) + return response.data + } catch (error) { + console.error('Failed to create API key:', error) + throw error + } + }, + + // 🗑️ 删除API Key + async deleteApiKey(keyId) { + try { + const response = await axios.delete(`${API_BASE}/api-keys/${keyId}`) + return response.data + } catch (error) { + console.error('Failed to delete API key:', error) + throw error + } + }, + + // 📊 获取使用统计 + async getUserUsageStats(params = {}) { + try { + const response = await axios.get(`${API_BASE}/usage-stats`, { params }) + return response.data.success ? response.data.stats : null + } catch (error) { + console.error('Failed to fetch usage stats:', error) + throw error + } + }, + + // 🧹 清除认证信息 + clearAuth() { + this.user = null + this.sessionToken = null + this.isAuthenticated = false + this.config = null + + localStorage.removeItem('userToken') + localStorage.removeItem('userData') + localStorage.removeItem('userConfig') + + // 清除 axios 默认头部 + delete axios.defaults.headers.common['x-user-token'] + }, + + // 🔧 设置认证头部 + setAuthHeader() { + if (this.sessionToken) { + axios.defaults.headers.common['x-user-token'] = this.sessionToken + } + }, + + // 🔧 设置axios拦截器 + setupAxiosInterceptors() { + // Response interceptor to handle disabled user responses globally + axios.interceptors.response.use( + (response) => response, + (error) => { + if (error.response?.status === 403) { + const message = error.response.data?.message + if (message && (message.includes('disabled') || message.includes('Account disabled'))) { + this.clearAuth() + showToast(message, 'error') + // Redirect to login page + if (window.location.pathname !== '/user-login') { + window.location.href = '/user-login' + } + } + } + return Promise.reject(error) + } + ) + } + } +}) diff --git a/web/admin-spa/src/views/AccountsView.vue b/web/admin-spa/src/views/AccountsView.vue index a9e49d05..947a36b9 100644 --- a/web/admin-spa/src/views/AccountsView.vue +++ b/web/admin-spa/src/views/AccountsView.vue @@ -1431,7 +1431,8 @@ const resetAccountStatus = async (account) => { if (data.success) { showToast('账户状态已重置', 'success') - loadAccounts() + // 强制刷新,绕过前端缓存,确保最终一致性 + loadAccounts(true) } else { showToast(data.message || '状态重置失败', 'error') } diff --git a/web/admin-spa/src/views/ApiKeysView.vue b/web/admin-spa/src/views/ApiKeysView.vue index 9f5a183c..54a5b350 100644 --- a/web/admin-spa/src/views/ApiKeysView.vue +++ b/web/admin-spa/src/views/ApiKeysView.vue @@ -10,506 +10,1112 @@ 管理和监控您的 API 密钥

-
- -
- -
-
- -
- -
-
-
- - - {{ selectedTagCount }} - -
-
- - -
-
-
- - - -
-
- - + +
+ +
+ + + +
+
+ +
+ +
+
+ +
+ + +
+
+
+ + + {{ selectedTagCount }} + +
+
+ + +
+
+
+ + + +
+
+ + + +
+ +
- - -
-
-
-
-

正在加载 API Keys...

-
+
+
+

正在加载 API Keys...

+
-
-
- -
-

暂无 API Keys

-

点击上方按钮创建您的第一个 API Key

-
+
+
+ +
+

暂无 API Keys

+

点击上方按钮创建您的第一个 API Key

+
- - - - -
-
- -
-
- -
- +
-
-

- {{ key.name }} -

-

- {{ key.id }} -

+ + +
+ + {{ tag }} +
-
- -
- {{ key.isActive ? '活跃' : '已停用' }} - -
- -
- -
- - - Claude - - - {{ getClaudeBindingInfo(key) }} - -
- -
- - - Gemini - - - {{ getGeminiBindingInfo(key) }} - -
- -
- - - OpenAI - - - {{ getOpenAIBindingInfo(key) }} - -
- -
- - - Bedrock - - - {{ getBedrockBindingInfo(key) }} - -
- -
- - 使用共享池 -
-
- - -
- -
-
- 今日使用 + +
-
-
-
-

- {{ formatNumber(key.usage?.daily?.requests || 0) }} 次 -

-

请求

-
-
-

- ${{ (key.dailyCost || 0).toFixed(4) }} -

-

费用

-
-
-
- 最后使用 - {{ - formatLastUsed(key.lastUsedAt) - }} -
-
- - -
-
- 每日费用限额 - - ${{ (key.dailyCost || 0).toFixed(2) }} / ${{ key.dailyCostLimit.toFixed(2) }} - -
-
-
-
-
- - - -
- - -
-
- 创建时间 - {{ formatDate(key.createdAt) }} -
-
- 过期时间 -
- - {{ key.expiresAt ? formatDate(key.expiresAt) : '永不过期' }} - + + +
- -
- - {{ tag }} - -
+ +
+
+ + 共 {{ sortedApiKeys.length }} 条记录 + +
+ 每页显示 + + +
+
- -
- - - - - -
-
-
+
+ + - -
-
- - 共 {{ sortedApiKeys.length }} 条记录 - -
- 每页显示 - - + +
+ + + + + + + + + + +
+ + + +
-
- - - - -
- - - - - - - - - - + +
+
+
+

正在加载已删除的 API Keys...

- - +
+
+ +
+

暂无已删除的 API Keys

+

已删除的 API Keys 会出现在这里

+
+ + +
+ + + + + + + + + + + + + + + + + + + + + +
+ 名称 + + 创建者 + + 创建时间 + + 删除者 + + 删除时间 + + 使用统计 +
+
+
+ +
+
+
+ {{ key.name }} +
+
+ {{ key.id }} +
+
+
+
+
+ + + 管理员 + + + + {{ key.userUsername }} + + + + 未知 + +
+
+ {{ formatDate(key.createdAt) }} + +
+ + + {{ key.deletedBy }} + + + + {{ key.deletedBy }} + + + + {{ key.deletedBy }} + +
+
+ {{ formatDate(key.deletedAt) }} + +
+
+ 请求 + + {{ formatNumber(key.usage?.total?.requests || 0) }}次 + +
+
+ 费用 + + ${{ (key.usage?.total?.cost || 0).toFixed(4) }} + +
+
+ 最后使用 + + {{ formatLastUsed(key.lastUsedAt) }} + +
+
从未使用
+
+
+
@@ -1301,6 +1520,11 @@ const selectAllChecked = ref(false) const isIndeterminate = ref(false) const apiKeysLoading = ref(false) const apiKeyStatsTimeRange = ref('today') + +// Tab management +const activeTab = ref('active') +const deletedApiKeys = ref([]) +const deletedApiKeysLoading = ref(false) const apiKeysSortBy = ref('') const apiKeysSortOrder = ref('asc') const expandedApiKeys = ref({}) @@ -1552,6 +1776,22 @@ const loadApiKeys = async () => { } } +// 加载已删除的API Keys +const loadDeletedApiKeys = async () => { + activeTab.value = 'deleted' + deletedApiKeysLoading.value = true + try { + const data = await apiClient.get('/admin/api-keys/deleted') + if (data.success) { + deletedApiKeys.value = data.apiKeys || [] + } + } catch (error) { + showToast('加载已删除的 API Keys 失败', 'error') + } finally { + deletedApiKeysLoading.value = false + } +} + // 排序API Keys const sortApiKeys = (field) => { if (apiKeysSortBy.value === field) { diff --git a/web/admin-spa/src/views/ApiStatsView.vue b/web/admin-spa/src/views/ApiStatsView.vue index f2af17b2..73c6e3fb 100644 --- a/web/admin-spa/src/views/ApiStatsView.vue +++ b/web/admin-spa/src/views/ApiStatsView.vue @@ -21,6 +21,13 @@ /> + { letter-spacing: -0.025em; } +/* 用户登录按钮 */ +.user-login-button { + background: linear-gradient(135deg, #34d399 0%, #10b981 100%); + border: 1px solid rgba(255, 255, 255, 0.2); + text-decoration: none; + box-shadow: + 0 4px 6px -1px rgba(52, 211, 153, 0.3), + 0 2px 4px -1px rgba(52, 211, 153, 0.1); + position: relative; + overflow: hidden; +} + +.user-login-button::before { + content: ''; + position: absolute; + top: 0; + left: -100%; + width: 100%; + height: 100%; + background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent); + transition: left 0.5s; +} + +.user-login-button:hover { + transform: translateY(-2px); + box-shadow: + 0 10px 15px -3px rgba(52, 211, 153, 0.4), + 0 4px 6px -2px rgba(52, 211, 153, 0.15); +} + +.user-login-button:hover::before { + left: 100%; +} + /* 管理后台按钮 - 精致版本 */ .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..b80a8345 --- /dev/null +++ b/web/admin-spa/src/views/UserDashboardView.vue @@ -0,0 +1,397 @@ + + + + + diff --git a/web/admin-spa/src/views/UserLoginView.vue b/web/admin-spa/src/views/UserLoginView.vue new file mode 100644 index 00000000..77ce6d10 --- /dev/null +++ b/web/admin-spa/src/views/UserLoginView.vue @@ -0,0 +1,161 @@ + + + + + diff --git a/web/admin-spa/src/views/UserManagementView.vue b/web/admin-spa/src/views/UserManagementView.vue new file mode 100644 index 00000000..95687fd9 --- /dev/null +++ b/web/admin-spa/src/views/UserManagementView.vue @@ -0,0 +1,649 @@ + + + + +