From 31bdb4aa8c4259fbe19dc008d3ce3e5f85377149 Mon Sep 17 00:00:00 2001 From: shaw Date: Fri, 8 Aug 2025 00:35:26 +0800 Subject: [PATCH] feat: add comprehensive 401 error handling and account status management MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add 401 error detection and automatic account suspension after 3 consecutive failures - Implement account status reset functionality for clearing all error states - Enhance admin interface with status reset controls and improved status display - Upgrade service management script with backup protection and retry mechanisms - Add mandatory code formatting requirements using Prettier - Improve account selector with detailed status information and color coding 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- CLAUDE.md | 7 + scripts/manage.sh | 136 ++++++++++++++++-- src/routes/admin.js | 15 ++ src/services/claudeAccountService.js | 93 ++++++++++++ src/services/claudeRelayService.js | 73 +++++++++- src/services/unifiedClaudeScheduler.js | 29 ++++ web/admin-spa/package-lock.json | 2 +- .../src/components/common/AccountSelector.vue | 44 ++++-- web/admin-spa/src/views/AccountsView.vue | 88 +++++++++++- 9 files changed, 456 insertions(+), 31 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 568ba60b..fc293375 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -144,6 +144,13 @@ npm run setup # 自动生成密钥并创建管理员账户 ## 开发最佳实践 +### 代码格式化要求 +- **必须使用 Prettier 格式化所有代码** +- 后端代码(src/):运行 `npx prettier --write ` 格式化 +- 前端代码(web/admin-spa/):已安装 `prettier-plugin-tailwindcss`,运行 `npx prettier --write ` 格式化 +- 提交前检查格式:`npx prettier --check ` +- 格式化所有文件:`npm run format`(如果配置了此脚本) + ### 代码修改原则 - 对现有文件进行修改时,首先检查代码库的现有模式和风格 - 尽可能重用现有的服务和工具函数,避免重复代码 diff --git a/scripts/manage.sh b/scripts/manage.sh index ebd41b3a..fba168ad 100644 --- a/scripts/manage.sh +++ b/scripts/manage.sh @@ -577,13 +577,67 @@ update_service() { cp config/config.js config/config.js.backup.$(date +%Y%m%d%H%M%S) fi - # 拉取最新代码 - print_info "拉取最新代码..." - if ! git pull origin main; then - print_error "拉取代码失败,请检查网络连接" + # 检查本地修改 + print_info "检查本地文件修改..." + local has_changes=false + if git status --porcelain | grep -v "^??" | grep -q .; then + has_changes=true + print_warning "检测到本地文件已修改:" + git status --short | grep -v "^??" + echo "" + echo -e "${YELLOW}警告:更新将使用远程版本覆盖本地修改!${NC}" + + # 创建本地修改的备份 + local backup_branch="backup-$(date +%Y%m%d-%H%M%S)" + print_info "创建本地修改备份分支: $backup_branch" + git stash push -m "Backup before update $(date +%Y-%m-%d)" >/dev/null 2>&1 + git branch "$backup_branch" 2>/dev/null || true + + echo -e "${GREEN}已创建备份分支: $backup_branch${NC}" + echo "如需恢复,可执行: git checkout $backup_branch" + echo "" + + echo -n "是否继续更新?(y/N): " + read -n 1 confirm_update + echo + + if [[ ! "$confirm_update" =~ ^[Yy]$ ]]; then + print_info "已取消更新" + # 恢复 stash 的修改 + git stash pop >/dev/null 2>&1 || true + # 如果之前在运行,重新启动服务 + if [ "$was_running" = true ]; then + print_info "重新启动服务..." + start_service + fi + return 0 + fi + fi + + # 获取最新代码(强制使用远程版本) + print_info "获取最新代码..." + + # 先获取远程更新 + if ! git fetch origin main; then + print_error "获取远程代码失败,请检查网络连接" return 1 fi + # 强制重置到远程版本 + print_info "应用远程更新..." + if ! git reset --hard origin/main; then + print_error "重置到远程版本失败" + # 尝试恢复 + print_info "尝试恢复..." + git reset --hard HEAD + return 1 + fi + + # 清理未跟踪的文件(可选,保留用户新建的文件) + # git clean -fd # 注释掉,避免删除用户的新文件 + + print_success "代码已更新到最新版本" + # 更新依赖 print_info "更新依赖..." npm install @@ -599,8 +653,14 @@ update_service() { # 创建目标目录 mkdir -p web/admin-spa/dist - # 清理旧的前端文件 - rm -rf web/admin-spa/dist/* + # 清理旧的前端文件(保留用户自定义文件) + if [ -d "web/admin-spa/dist" ]; then + print_info "清理旧的前端文件..." + # 只删除已知的前端文件,保留用户可能添加的自定义文件 + rm -rf web/admin-spa/dist/assets 2>/dev/null + rm -f web/admin-spa/dist/index.html 2>/dev/null + rm -f web/admin-spa/dist/favicon.ico 2>/dev/null + fi # 从 web-dist 分支获取构建好的文件 if git ls-remote --heads origin web-dist | grep -q web-dist; then @@ -609,14 +669,42 @@ update_service() { # 创建临时目录用于 clone TEMP_CLONE_DIR=$(mktemp -d) - # 使用 sparse-checkout 来只获取需要的文件 - git clone --depth 1 --branch web-dist --single-branch \ - https://github.com/Wei-Shaw/claude-relay-service.git \ - "$TEMP_CLONE_DIR" 2>/dev/null || { + # 添加错误处理 + if [ ! -d "$TEMP_CLONE_DIR" ]; then + print_error "无法创建临时目录" + return 1 + fi + + # 使用 sparse-checkout 来只获取需要的文件,添加重试机制 + local clone_success=false + for attempt in 1 2 3; do + print_info "尝试下载前端文件 (第 $attempt 次)..." + + if git clone --depth 1 --branch web-dist --single-branch \ + https://github.com/Wei-Shaw/claude-relay-service.git \ + "$TEMP_CLONE_DIR" 2>/dev/null; then + clone_success=true + break + fi + # 如果 HTTPS 失败,尝试使用当前仓库的 remote URL REPO_URL=$(git config --get remote.origin.url) - git clone --depth 1 --branch web-dist --single-branch "$REPO_URL" "$TEMP_CLONE_DIR" - } + if git clone --depth 1 --branch web-dist --single-branch "$REPO_URL" "$TEMP_CLONE_DIR" 2>/dev/null; then + clone_success=true + break + fi + + if [ $attempt -lt 3 ]; then + print_warning "下载失败,等待 2 秒后重试..." + sleep 2 + fi + done + + if [ "$clone_success" = false ]; then + print_error "无法下载前端文件" + rm -rf "$TEMP_CLONE_DIR" + return 1 + fi # 复制文件到目标目录(排除 .git 和 README.md) rsync -av --exclude='.git' --exclude='README.md' "$TEMP_CLONE_DIR/" web/admin-spa/dist/ 2>/dev/null || { @@ -663,10 +751,32 @@ update_service() { print_success "更新完成!" + # 显示更新摘要 + echo "" + echo -e "${BLUE}=== 更新摘要 ===${NC}" + # 显示版本信息 if [ -f "$APP_DIR/VERSION" ]; then - echo -e "\n当前版本: ${GREEN}$(cat "$APP_DIR/VERSION")${NC}" + echo -e "当前版本: ${GREEN}$(cat "$APP_DIR/VERSION")${NC}" fi + + # 显示最新的提交信息 + local latest_commit=$(git log -1 --oneline 2>/dev/null) + if [ -n "$latest_commit" ]; then + echo -e "最新提交: ${GREEN}$latest_commit${NC}" + fi + + # 显示备份信息 + echo -e "\n${YELLOW}配置文件备份:${NC}" + ls -la .env.backup.* 2>/dev/null | tail -3 || echo " 无备份文件" + + # 提醒用户检查配置 + echo -e "\n${YELLOW}提示:${NC}" + echo " - 配置文件已自动备份" + echo " - 如有本地修改已保存到备份分支" + echo " - 建议检查 .env 和 config/config.js 配置" + + echo -e "\n${BLUE}==================${NC}" } # 卸载服务 diff --git a/src/routes/admin.js b/src/routes/admin.js index 7d964d98..f330f633 100644 --- a/src/routes/admin.js +++ b/src/routes/admin.js @@ -1341,6 +1341,21 @@ router.post('/claude-accounts/:accountId/refresh', authenticateAdmin, async (req } }) +// 重置Claude账户状态(清除所有异常状态) +router.post('/claude-accounts/:accountId/reset-status', authenticateAdmin, async (req, res) => { + try { + const { accountId } = req.params + + const result = await claudeAccountService.resetAccountStatus(accountId) + + logger.success(`✅ Admin reset status for Claude account: ${accountId}`) + return res.json({ success: true, data: result }) + } catch (error) { + logger.error('❌ Failed to reset Claude account status:', error) + return res.status(500).json({ error: 'Failed to reset status', message: error.message }) + } +}) + // 切换Claude账户调度状态 router.put( '/claude-accounts/:accountId/toggle-schedulable', diff --git a/src/services/claudeAccountService.js b/src/services/claudeAccountService.js index 9ffa6e42..2029957b 100644 --- a/src/services/claudeAccountService.js +++ b/src/services/claudeAccountService.js @@ -1194,6 +1194,99 @@ class ClaudeAccountService { } } } + + // 🚫 标记账户为未授权状态(401错误) + async markAccountUnauthorized(accountId, sessionHash = null) { + try { + const accountData = await redis.getClaudeAccount(accountId) + if (!accountData || Object.keys(accountData).length === 0) { + throw new Error('Account not found') + } + + // 更新账户状态 + const updatedAccountData = { ...accountData } + updatedAccountData.status = 'unauthorized' + updatedAccountData.schedulable = 'false' // 设置为不可调度 + updatedAccountData.errorMessage = 'Account unauthorized (401 errors detected)' + updatedAccountData.unauthorizedAt = new Date().toISOString() + + // 保存更新后的账户数据 + await redis.setClaudeAccount(accountId, updatedAccountData) + + // 如果有sessionHash,删除粘性会话映射 + if (sessionHash) { + await redis.client.del(`sticky_session:${sessionHash}`) + logger.info(`🗑️ Deleted sticky session mapping for hash: ${sessionHash}`) + } + + logger.warn( + `⚠️ Account ${accountData.name} (${accountId}) marked as unauthorized and disabled for scheduling` + ) + + return { success: true } + } catch (error) { + logger.error(`❌ Failed to mark account ${accountId} as unauthorized:`, error) + throw error + } + } + + // 🔄 重置账户所有异常状态 + async resetAccountStatus(accountId) { + try { + const accountData = await redis.getClaudeAccount(accountId) + if (!accountData || Object.keys(accountData).length === 0) { + throw new Error('Account not found') + } + + // 重置账户状态 + const updatedAccountData = { ...accountData } + + // 根据是否有有效的accessToken来设置status + if (updatedAccountData.accessToken) { + updatedAccountData.status = 'active' + } else { + updatedAccountData.status = 'created' + } + + // 恢复可调度状态 + updatedAccountData.schedulable = 'true' + + // 清除错误相关字段 + delete updatedAccountData.errorMessage + delete updatedAccountData.unauthorizedAt + delete updatedAccountData.rateLimitedAt + delete updatedAccountData.rateLimitStatus + delete updatedAccountData.rateLimitEndAt + + // 保存更新后的账户数据 + await redis.setClaudeAccount(accountId, updatedAccountData) + + // 清除401错误计数 + const errorKey = `claude_account:${accountId}:401_errors` + await redis.client.del(errorKey) + + // 清除限流状态(如果存在) + const rateLimitKey = `ratelimit:${accountId}` + await redis.client.del(rateLimitKey) + + logger.info( + `✅ Successfully reset all error states for account ${accountData.name} (${accountId})` + ) + + return { + success: true, + account: { + id: accountId, + name: accountData.name, + status: updatedAccountData.status, + schedulable: updatedAccountData.schedulable === 'true' + } + } + } catch (error) { + logger.error(`❌ Failed to reset account status for ${accountId}:`, error) + throw error + } + } } module.exports = new ClaudeAccountService() diff --git a/src/services/claudeRelayService.js b/src/services/claudeRelayService.js index 6e8f1552..b2ac5fec 100644 --- a/src/services/claudeRelayService.js +++ b/src/services/claudeRelayService.js @@ -169,13 +169,37 @@ class ClaudeRelayService { clientResponse.removeListener('close', handleClientDisconnect) } - // 检查响应是否为限流错误 + // 检查响应是否为限流错误或认证错误 if (response.statusCode !== 200 && response.statusCode !== 201) { let isRateLimited = false let rateLimitResetTimestamp = null + // 检查是否为401状态码(未授权) + if (response.statusCode === 401) { + logger.warn(`🔐 Unauthorized error (401) detected for account ${accountId}`) + + // 记录401错误 + await this.recordUnauthorizedError(accountId) + + // 检查是否需要标记为异常(连续3次401) + const errorCount = await this.getUnauthorizedErrorCount(accountId) + logger.info( + `🔐 Account ${accountId} has ${errorCount} consecutive 401 errors in the last 5 minutes` + ) + + if (errorCount >= 3) { + logger.error( + `❌ Account ${accountId} exceeded 401 error threshold (${errorCount} errors), marking as unauthorized` + ) + await unifiedClaudeScheduler.markAccountUnauthorized( + accountId, + accountType, + sessionHash + ) + } + } // 检查是否为429状态码 - if (response.statusCode === 429) { + else if (response.statusCode === 429) { isRateLimited = true // 提取限流重置时间戳 @@ -224,6 +248,8 @@ class ClaudeRelayService { ) } } else if (response.statusCode === 200 || response.statusCode === 201) { + // 请求成功,清除401错误计数 + await this.clearUnauthorizedErrors(accountId) // 如果请求成功,检查并移除限流状态 const isRateLimited = await unifiedClaudeScheduler.isAccountRateLimited( accountId, @@ -1295,6 +1321,49 @@ class ClaudeRelayService { throw lastError } + // 🔐 记录401未授权错误 + async recordUnauthorizedError(accountId) { + try { + const key = `claude_account:${accountId}:401_errors` + const redis = require('../models/redis') + + // 增加错误计数,设置5分钟过期时间 + await redis.client.incr(key) + await redis.client.expire(key, 300) // 5分钟 + + logger.info(`📝 Recorded 401 error for account ${accountId}`) + } catch (error) { + logger.error(`❌ Failed to record 401 error for account ${accountId}:`, error) + } + } + + // 🔍 获取401错误计数 + async getUnauthorizedErrorCount(accountId) { + try { + const key = `claude_account:${accountId}:401_errors` + const redis = require('../models/redis') + + const count = await redis.client.get(key) + return parseInt(count) || 0 + } catch (error) { + logger.error(`❌ Failed to get 401 error count for account ${accountId}:`, error) + return 0 + } + } + + // 🧹 清除401错误计数 + async clearUnauthorizedErrors(accountId) { + try { + const key = `claude_account:${accountId}:401_errors` + const redis = require('../models/redis') + + await redis.client.del(key) + logger.info(`✅ Cleared 401 error count for account ${accountId}`) + } catch (error) { + logger.error(`❌ Failed to clear 401 errors for account ${accountId}:`, error) + } + } + // 🎯 健康检查 async healthCheck() { try { diff --git a/src/services/unifiedClaudeScheduler.js b/src/services/unifiedClaudeScheduler.js index e76bba6e..47ee1499 100644 --- a/src/services/unifiedClaudeScheduler.js +++ b/src/services/unifiedClaudeScheduler.js @@ -551,6 +551,35 @@ class UnifiedClaudeScheduler { } } + // 🚫 标记账户为未授权状态(401错误) + async markAccountUnauthorized(accountId, accountType, sessionHash = null) { + try { + // 只处理claude-official类型的账户,不处理claude-console和gemini + if (accountType === 'claude-official') { + await claudeAccountService.markAccountUnauthorized(accountId, sessionHash) + + // 删除会话映射 + if (sessionHash) { + await this._deleteSessionMapping(sessionHash) + } + + logger.warn(`🚫 Account ${accountId} marked as unauthorized due to consecutive 401 errors`) + } else { + logger.info( + `ℹ️ Skipping unauthorized marking for non-Claude OAuth account: ${accountId} (${accountType})` + ) + } + + return { success: true } + } catch (error) { + logger.error( + `❌ Failed to mark account as unauthorized: ${accountId} (${accountType})`, + error + ) + throw error + } + } + // 🚫 标记Claude Console账户为封锁状态(模型不支持) async blockConsoleAccount(accountId, reason) { try { diff --git a/web/admin-spa/package-lock.json b/web/admin-spa/package-lock.json index 6ac43b20..21b505e0 100644 --- a/web/admin-spa/package-lock.json +++ b/web/admin-spa/package-lock.json @@ -3655,7 +3655,7 @@ }, "node_modules/prettier-plugin-tailwindcss": { "version": "0.6.14", - "resolved": "https://registry.npmjs.org/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.6.14.tgz", + "resolved": "https://registry.npmmirror.com/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.6.14.tgz", "integrity": "sha512-pi2e/+ZygeIqntN+vC573BcW5Cve8zUB0SSAGxqpB4f96boZF4M3phPVoOFCeypwkpRYdi7+jQ5YJJUwrkGUAg==", "dev": true, "license": "MIT", diff --git a/web/admin-spa/src/components/common/AccountSelector.vue b/web/admin-spa/src/components/common/AccountSelector.vue index bc9231cd..74100f5d 100644 --- a/web/admin-spa/src/components/common/AccountSelector.vue +++ b/web/admin-spa/src/components/common/AccountSelector.vue @@ -101,12 +101,14 @@ - {{ account.status === 'active' ? '正常' : '异常' }} + {{ getAccountStatusText(account) }} @@ -134,12 +136,14 @@ - {{ account.status === 'active' ? '正常' : '异常' }} + {{ getAccountStatusText(account) }} @@ -224,14 +228,38 @@ const selectedLabel = computed(() => { const account = props.accounts.find( (a) => a.id === accountId && a.platform === 'claude-console' ) - return account ? `${account.name} (${account.status === 'active' ? '正常' : '异常'})` : '' + return account ? `${account.name} (${getAccountStatusText(account)})` : '' } // OAuth 账号 const account = props.accounts.find((a) => a.id === props.modelValue) - return account ? `${account.name} (${account.status === 'active' ? '正常' : '异常'})` : '' + return account ? `${account.name} (${getAccountStatusText(account)})` : '' }) +// 获取账户状态文本 +const getAccountStatusText = (account) => { + if (!account) return '未知' + + // 优先使用 isActive 判断 + if (account.isActive === false) { + // 根据 status 提供更详细的状态信息 + switch (account.status) { + case 'unauthorized': + return '未授权' + case 'error': + return 'Token错误' + case 'created': + return '待验证' + case 'rate_limited': + return '限流中' + default: + return '异常' + } + } + + return '正常' +} + // 按创建时间倒序排序账号 const sortedAccounts = computed(() => { return [...props.accounts].sort((a, b) => { diff --git a/web/admin-spa/src/views/AccountsView.vue b/web/admin-spa/src/views/AccountsView.vue index 67739a66..fb53a73a 100644 --- a/web/admin-spa/src/views/AccountsView.vue +++ b/web/admin-spa/src/views/AccountsView.vue @@ -248,9 +248,11 @@ 'inline-flex items-center rounded-full px-3 py-1 text-xs font-semibold', account.status === 'blocked' ? 'bg-orange-100 text-orange-800' - : account.isActive - ? 'bg-green-100 text-green-800' - : 'bg-red-100 text-red-800' + : account.status === 'unauthorized' + ? 'bg-red-100 text-red-800' + : account.isActive + ? 'bg-green-100 text-green-800' + : 'bg-red-100 text-red-800' ]" >
{{ - account.status === 'blocked' ? '已封锁' : account.isActive ? '正常' : '异常' + account.status === 'blocked' + ? '已封锁' + : account.status === 'unauthorized' + ? '异常' + : account.isActive + ? '正常' + : '异常' }} 刷新 +