From 1ee71ffbc9df782aa2d17f8018853bd1c45ec2b4 Mon Sep 17 00:00:00 2001 From: iRubbish Date: Tue, 19 Aug 2025 09:46:33 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=8C=E5=96=84=20API=20Keys=20?= =?UTF-8?q?=E6=89=B9=E9=87=8F=E5=88=A0=E9=99=A4=E5=8A=9F=E8=83=BD=E5=B9=B6?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=90=9C=E7=B4=A2=E8=B7=A8=E9=80=89=E6=8B=A9?= =?UTF-8?q?=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 主要改进 ### 🔧 核心修复 - 修复搜索时勾选状态无法保存的问题 - 优化全选/取消全选逻辑,支持跨搜索结果保持选择状态 - 改进批量删除的用户体验 - 添加 Unicode 字符处理中间件,提升请求体解析稳定性 ### 🎯 具体变更 - **路由修复**: 解决批量删除路由匹配问题,调整路由顺序 - **API客户端**: 修复 DELETE 方法支持请求体数据传输 - **前端逻辑**: 分离筛选和搜索的监听器,搜索时保持已选中状态 - **全选优化**: 取消全选时只移除当前页选中项,保留其他页面选择 - **Unicode处理**: 添加无效 UTF-16 代理对清理和错误处理机制 - **配置管理**: 将 .mcp.json 添加到 .gitignore,避免本地配置被提交 ### 🚀 用户体验提升 - 支持跨搜索结果批量选择和删除 - 批量删除按钮显示选中数量 - 智能的全选状态管理 - 更好的 Unicode 字符处理容错性 ### 🧪 测试验证 - 验证搜索切换时选择状态保持 - 确认批量删除功能正常工作 - 检查 Redis 数据清理完整性 - 测试 Unicode 字符处理稳定性 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- .gitignore | 3 + .mcp.json | 14 -- src/app.js | 52 +++++++ src/routes/admin.js | 47 ++++--- src/routes/api.js | 41 ++++++ web/admin-spa/src/config/api.js | 7 +- web/admin-spa/src/views/ApiKeysView.vue | 178 +++++++++++++++++++++++- 7 files changed, 304 insertions(+), 38 deletions(-) delete mode 100644 .mcp.json diff --git a/.gitignore b/.gitignore index c8b75b27..b6179027 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,9 @@ pnpm-debug.log* # Claude specific directories .claude/ +# MCP configuration (local only) +.mcp.json + # Data directory (contains sensitive information) data/ !data/.gitkeep diff --git a/.mcp.json b/.mcp.json deleted file mode 100644 index c64c0718..00000000 --- a/.mcp.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "mcpServers": { - "spec-workflow": { - "type": "stdio", - "command": "npx", - "args": [ - "-y", - "@pimzino/spec-workflow-mcp@latest", - "/Users/weidian/project/claude-relay-service" - ], - "env": {} - } - } -} \ No newline at end of file diff --git a/src/app.js b/src/app.js index 2f6d09cb..1a6a7d56 100644 --- a/src/app.js +++ b/src/app.js @@ -141,10 +141,62 @@ class Application { if (buf && buf.length && !buf.toString(encoding || 'utf8').trim()) { throw new Error('Invalid JSON: empty body') } + + // Unicode 字符清理 - 清理无效的 UTF-16 代理对 + if (buf && buf.length) { + try { + const str = buf.toString(encoding || 'utf8') + // 移除无效的 UTF-16 代理对字符 + const cleanedStr = str.replace( + /[\uDC00-\uDFFF](?![\uD800-\uDBFF])|[\uD800-\uDBFF](?![\uDC00-\uDFFF])/g, + '\uFFFD' + ) + + // 如果字符串被清理过,重新写入buffer + if (cleanedStr !== str) { + logger.warn('🧹 Cleaned invalid Unicode characters from request body') + const cleanedBuf = Buffer.from(cleanedStr, encoding || 'utf8') + // 将清理后的内容复制回原buffer + cleanedBuf.copy(buf, 0) + // 调整buffer长度 + buf._charsWritten = cleanedBuf.length + } + } catch (error) { + logger.warn( + '⚠️ Unicode cleaning failed, proceeding with original buffer:', + error.message + ) + } + } } }) ) this.app.use(express.urlencoded({ extended: true, limit: '10mb' })) + + // 🧹 Unicode 错误处理中间件 + this.app.use((err, req, res, next) => { + if (err instanceof SyntaxError && err.status === 400 && 'body' in err) { + // 检查是否是Unicode相关的JSON解析错误 + if ( + err.message.includes('surrogate') || + err.message.includes('UTF-16') || + err.message.includes('invalid character') + ) { + logger.warn('🧹 Detected Unicode JSON parsing error, attempting recovery:', err.message) + + return res.status(400).json({ + type: 'error', + error: { + type: 'invalid_request_error', + message: + 'The request body contains invalid Unicode characters. Please ensure your text uses valid UTF-8 encoding and does not contain malformed surrogate pairs.' + } + }) + } + } + next(err) + }) + this.app.use(securityMiddleware) // 🎯 信任代理 diff --git a/src/routes/admin.js b/src/routes/admin.js index 719db889..7b674e6e 100644 --- a/src/routes/admin.js +++ b/src/routes/admin.js @@ -799,28 +799,20 @@ router.put('/api-keys/:keyId', authenticateAdmin, async (req, res) => { } }) -// 删除API Key -router.delete('/api-keys/:keyId', authenticateAdmin, async (req, res) => { - try { - const { keyId } = req.params - - await apiKeyService.deleteApiKey(keyId) - - logger.success(`🗑️ Admin deleted API key: ${keyId}`) - return res.json({ success: true, message: 'API key deleted successfully' }) - } catch (error) { - logger.error('❌ Failed to delete API key:', error) - return res.status(500).json({ error: 'Failed to delete API key', message: error.message }) - } -}) - -// 批量删除API Keys +// 批量删除API Keys(必须在 :keyId 路由之前定义) router.delete('/api-keys/batch', authenticateAdmin, async (req, res) => { try { const { keyIds } = req.body + // 调试信息 + logger.info(`🐛 Batch delete request body: ${JSON.stringify(req.body)}`) + logger.info(`🐛 keyIds type: ${typeof keyIds}, value: ${JSON.stringify(keyIds)}`) + // 参数验证 if (!keyIds || !Array.isArray(keyIds) || keyIds.length === 0) { + logger.warn( + `🚨 Invalid keyIds: ${JSON.stringify({ keyIds, type: typeof keyIds, isArray: Array.isArray(keyIds) })}` + ) return res.status(400).json({ error: 'Invalid request', message: 'keyIds 必须是一个非空数组' @@ -843,7 +835,9 @@ router.delete('/api-keys/batch', authenticateAdmin, async (req, res) => { }) } - logger.info(`🗑️ Admin attempting batch delete of ${keyIds.length} API keys`) + logger.info( + `🗑️ Admin attempting batch delete of ${keyIds.length} API keys: ${JSON.stringify(keyIds)}` + ) const results = { successCount: 0, @@ -855,8 +849,8 @@ router.delete('/api-keys/batch', authenticateAdmin, async (req, res) => { for (const keyId of keyIds) { try { // 检查API Key是否存在 - const apiKey = await apiKeyService.getApiKey(keyId) - if (!apiKey) { + const apiKey = await redis.getApiKey(keyId) + if (!apiKey || Object.keys(apiKey).length === 0) { results.failedCount++ results.errors.push({ keyId, error: 'API Key 不存在' }) continue @@ -903,6 +897,21 @@ router.delete('/api-keys/batch', authenticateAdmin, async (req, res) => { } }) +// 删除单个API Key(必须在批量删除路由之后定义) +router.delete('/api-keys/:keyId', authenticateAdmin, async (req, res) => { + try { + const { keyId } = req.params + + await apiKeyService.deleteApiKey(keyId) + + logger.success(`🗑️ Admin deleted API key: ${keyId}`) + return res.json({ success: true, message: 'API key deleted successfully' }) + } catch (error) { + logger.error('❌ Failed to delete API key:', error) + return res.status(500).json({ error: 'Failed to delete API key', message: error.message }) + } +}) + // 👥 账户分组管理 // 创建账户分组 diff --git a/src/routes/api.js b/src/routes/api.js index 3b1c4160..9f66bc6d 100644 --- a/src/routes/api.js +++ b/src/routes/api.js @@ -12,11 +12,52 @@ const sessionHelper = require('../utils/sessionHelper') const router = express.Router() +// 🧹 Unicode 字符清理函数 +function cleanUnicodeString(str) { + if (typeof str !== 'string') { + return str + } + + // 移除无效的 UTF-16 代理对字符 + // 匹配无效的低代理字符 (0xDC00-0xDFFF) 没有对应的高代理字符 + // 匹配无效的高代理字符 (0xD800-0xDBFF) 没有对应的低代理字符 + return str.replace( + /[\uDC00-\uDFFF](?![\uD800-\uDBFF])|[\uD800-\uDBFF](?![\uDC00-\uDFFF])/g, + '\uFFFD' + ) +} + +// 🧹 递归清理对象中的 Unicode 字符 +function cleanUnicodeInObject(obj) { + if (typeof obj === 'string') { + return cleanUnicodeString(obj) + } + + if (Array.isArray(obj)) { + return obj.map((item) => cleanUnicodeInObject(item)) + } + + if (obj && typeof obj === 'object') { + const cleaned = {} + for (const [key, value] of Object.entries(obj)) { + cleaned[cleanUnicodeString(key)] = cleanUnicodeInObject(value) + } + return cleaned + } + + return obj +} + // 🔧 共享的消息处理函数 async function handleMessagesRequest(req, res) { try { const startTime = Date.now() + // Unicode 字符清理 - 在输入验证之前清理请求体 + if (req.body) { + req.body = cleanUnicodeInObject(req.body) + } + // 严格的输入验证 if (!req.body || typeof req.body !== 'object') { return res.status(400).json({ diff --git a/web/admin-spa/src/config/api.js b/web/admin-spa/src/config/api.js index dbe02a5a..155d8bcf 100644 --- a/web/admin-spa/src/config/api.js +++ b/web/admin-spa/src/config/api.js @@ -152,9 +152,12 @@ class ApiClient { // DELETE 请求 async delete(url, options = {}) { const fullUrl = createApiUrl(url) + const { data, ...restOptions } = options + const config = this.buildConfig({ - ...options, - method: 'DELETE' + ...restOptions, + method: 'DELETE', + body: data ? JSON.stringify(data) : undefined }) try { diff --git a/web/admin-spa/src/views/ApiKeysView.vue b/web/admin-spa/src/views/ApiKeysView.vue index e8285841..fbf4dfcc 100644 --- a/web/admin-spa/src/views/ApiKeysView.vue +++ b/web/admin-spa/src/views/ApiKeysView.vue @@ -88,6 +88,19 @@ /> 刷新 + + +