feat: 实现历史API Key自动关联功能

核心功能:
- AD用户登录时自动关联已存在的历史API Key
- 关联规则: API Key name字段与用户displayName完全匹配
- 自动设置owner字段完成关联,避免用户重新创建Key

实现逻辑:
1. 优先匹配owner字段(已关联的Key)
2. 如无owner匹配,尝试匹配name与displayName
3. 找到匹配历史Key后,自动设置owner完成关联

技术特性:
- 详细日志记录关联过程
- 支持JWT token中完整用户信息传递
- Redis数据自动更新owner字段
- 系统迁移兼容性处理

测试验证:
- 创建测试历史Key验证自动关联
- JWT token正确解析displayName字段
- Redis数据正确更新owner关联关系

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
iRubbish
2025-08-25 18:19:33 +08:00
parent 7624c383e8
commit f31f7c9385
3 changed files with 103 additions and 18 deletions

73
package-lock.json generated
View File

@@ -27,6 +27,7 @@
"jsonwebtoken": "^9.0.2",
"ldapjs": "^3.0.7",
"morgan": "^1.10.0",
"node-fetch": "^2.7.0",
"ora": "^5.4.1",
"rate-limiter-flexible": "^5.0.5",
"socks-proxy-agent": "^8.0.2",
@@ -4094,7 +4095,7 @@
},
"node_modules/data-uri-to-buffer": {
"version": "4.0.1",
"resolved": "https://registry.npmmirror.com/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz",
"resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz",
"integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==",
"license": "MIT",
"engines": {
@@ -4868,7 +4869,7 @@
},
"node_modules/fetch-blob": {
"version": "3.2.0",
"resolved": "https://registry.npmmirror.com/fetch-blob/-/fetch-blob-3.2.0.tgz",
"resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz",
"integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==",
"funding": [
{
@@ -5049,7 +5050,7 @@
},
"node_modules/formdata-polyfill": {
"version": "4.0.10",
"resolved": "https://registry.npmmirror.com/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz",
"resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz",
"integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==",
"license": "MIT",
"dependencies": {
@@ -5138,6 +5139,24 @@
"node": ">=18"
}
},
"node_modules/gaxios/node_modules/node-fetch": {
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz",
"integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==",
"license": "MIT",
"dependencies": {
"data-uri-to-buffer": "^4.0.0",
"fetch-blob": "^3.1.4",
"formdata-polyfill": "^4.0.10"
},
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/node-fetch"
}
},
"node_modules/gcp-metadata": {
"version": "7.0.1",
"resolved": "https://registry.npmmirror.com/gcp-metadata/-/gcp-metadata-7.0.1.tgz",
@@ -7143,7 +7162,7 @@
},
"node_modules/node-domexception": {
"version": "1.0.0",
"resolved": "https://registry.npmmirror.com/node-domexception/-/node-domexception-1.0.0.tgz",
"resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz",
"integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==",
"deprecated": "Use your platform's native DOMException instead",
"funding": [
@@ -7162,21 +7181,23 @@
}
},
"node_modules/node-fetch": {
"version": "3.3.2",
"resolved": "https://registry.npmmirror.com/node-fetch/-/node-fetch-3.3.2.tgz",
"integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==",
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
"license": "MIT",
"dependencies": {
"data-uri-to-buffer": "^4.0.0",
"fetch-blob": "^3.1.4",
"formdata-polyfill": "^4.0.10"
"whatwg-url": "^5.0.0"
},
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
"node": "4.x || >=6.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/node-fetch"
"peerDependencies": {
"encoding": "^0.1.0"
},
"peerDependenciesMeta": {
"encoding": {
"optional": true
}
}
},
"node_modules/node-int64": {
@@ -8855,6 +8876,12 @@
"nodetouch": "bin/nodetouch.js"
}
},
"node_modules/tr46": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
"license": "MIT"
},
"node_modules/triple-beam": {
"version": "1.4.1",
"resolved": "https://registry.npmmirror.com/triple-beam/-/triple-beam-1.4.1.tgz",
@@ -9096,13 +9123,29 @@
},
"node_modules/web-streams-polyfill": {
"version": "3.3.3",
"resolved": "https://registry.npmmirror.com/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz",
"resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz",
"integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==",
"license": "MIT",
"engines": {
"node": ">= 8"
}
},
"node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
"license": "BSD-2-Clause"
},
"node_modules/whatwg-url": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
"license": "MIT",
"dependencies": {
"tr46": "~0.0.3",
"webidl-conversions": "^3.0.0"
}
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmmirror.com/which/-/which-2.0.2.tgz",

View File

@@ -66,6 +66,7 @@
"jsonwebtoken": "^9.0.2",
"ldapjs": "^3.0.7",
"morgan": "^1.10.0",
"node-fetch": "^2.7.0",
"ora": "^5.4.1",
"rate-limiter-flexible": "^5.0.5",
"socks-proxy-agent": "^8.0.2",

View File

@@ -384,24 +384,41 @@ const authenticateUser = (req, res, next) => {
/**
* 获取用户的API Keys
*
* 自动关联逻辑说明:
* 系统迁移过程中存在历史API Key这些Key是在AD集成前手动创建的
* 创建时使用的name字段恰好与AD用户的displayName一致
* 例如: AD用户displayName为"测试用户"对应的API Key name也是"测试用户"
* 为了避免用户重复创建Key系统会自动关联这些历史Key
* 关联规则:
* 1. 优先匹配owner字段(新建的Key)
* 2. 如果没有owner匹配则尝试匹配name字段与displayName
* 3. 找到匹配的历史Key后自动将owner设置为当前用户完成关联
*/
router.get('/user/api-keys', authenticateUser, async (req, res) => {
try {
const redis = require('../models/redis')
const { username } = req.user
const { username, displayName } = req.user
logger.info(`获取用户API Keys: ${username}`)
logger.info(`获取用户API Keys: ${username}, displayName: ${displayName}`)
logger.info(`用户完整信息: ${JSON.stringify(req.user)}`)
// 获取所有API Keys
const allKeysPattern = 'api_key:*'
const keys = await redis.getClient().keys(allKeysPattern)
const userKeys = []
let foundHistoricalKey = false
// 筛选属于该用户的API Keys
for (const key of keys) {
const apiKeyData = await redis.getClient().hgetall(key)
if (apiKeyData && apiKeyData.owner === username) {
if (!apiKeyData) {
continue
}
// 规则1: 直接owner匹配(已关联的Key)
if (apiKeyData.owner === username) {
userKeys.push({
id: apiKeyData.id,
name: apiKeyData.name || '未命名',
@@ -412,6 +429,30 @@ router.get('/user/api-keys', authenticateUser, async (req, res) => {
status: apiKeyData.status || 'active'
})
}
// 规则2: 历史Key自动关联(name字段匹配displayName且无owner)
else if (displayName && apiKeyData.name === displayName && !apiKeyData.owner) {
logger.info(`发现历史API Key需要关联: name=${apiKeyData.name}, displayName=${displayName}`)
// 自动关联: 设置owner为当前用户
await redis.getClient().hset(key, 'owner', username)
foundHistoricalKey = true
userKeys.push({
id: apiKeyData.id,
name: apiKeyData.name || '未命名',
key: apiKeyData.key,
limit: parseInt(apiKeyData.limit) || 1000000,
used: parseInt(apiKeyData.used) || 0,
createdAt: apiKeyData.createdAt,
status: apiKeyData.status || 'active'
})
logger.info(`历史API Key关联成功: ${apiKeyData.id} -> ${username}`)
}
}
if (foundHistoricalKey) {
logger.info(`用户 ${username} 自动关联了历史API Key`)
}
res.json({