Compare commits

..

328 Commits

Author SHA1 Message Date
Wesley Liddick
96f213e3e2 Revert "feat: 完整国际化支持 - Web 管理界面多语言实现" 2025-09-12 11:09:52 +08:00
Wesley Liddick
1d915d8327 Merge pull request #424 from Wangnov/feat/i18n
feat: 完整国际化支持 - Web 管理界面多语言实现
2025-09-12 09:00:30 +08:00
Wangnov
af8350b850 fix: 修复自动生成文件的 Prettier 格式问题
修复 auto-imports.d.ts 和 components.d.ts 的代码格式问题,
确保通过 CI 的 Prettier 格式检查。
2025-09-12 00:32:20 +08:00
Wangnov
a039d817db chore: 优化国际化文件格式,移除多余空行
- 统一三个语言文件的代码格式
- 移除多余的空行以保持代码整洁
2025-09-12 00:04:11 +08:00
Wangnov
ebafbdcc55 feat: 移除未使用的组件声明以优化类型定义
- 从 components.d.ts 文件中移除了多个未使用的组件声明,提升了类型定义的清晰度和可维护性。
- 此变更有助于减少代码冗余,确保组件声明与实际使用保持一致。
2025-09-12 00:04:11 +08:00
Wangnov
67e72f1aaf feat: 更新 Element Plus 语言配置处理方式
- 移除了 main.js 中对 zhCn 语言包的直接引用,改为在 App.vue 中通过 ElConfigProvider 处理语言配置。
- 这一变更提升了国际化的灵活性和可维护性,确保语言设置集中管理。
2025-09-12 00:04:11 +08:00
Wangnov
99d72516ae feat: 完成 AccountForm.vue 组件的国际化文本替换
- 将多个文本替换为 i18n 语言包中的键,以提升多语言支持和一致性。
- 更新了模型支持描述、用户代理描述、凭证文件描述等文本内容。
- 通过引入 i18n 键,增强了用户界面的可读性和可维护性。
2025-09-12 00:04:11 +08:00
Wangnov
5ea3623736 feat: 更新 DashboardView.vue 中的系统状态文本为 i18n 语言包中的键
- 将系统状态文本替换为动态获取的 i18n 键,以提升多语言支持和一致性。
2025-09-12 00:04:11 +08:00
Wangnov
e36bacfd6b feat: 完成多个组件的国际化支持与文本替换
- 更新 AccountForm.vue 中的占位符文本为 i18n 语言包中的键
- 修改 ConfirmModal.vue 中的确认和取消按钮文本为 i18n 语言包中的键
- 更新 CustomDropdown.vue 中的占位符文本为 i18n 语言包中的键
- 修改 app.js 中的应用标题为英文版本
- 更新 router/index.js 中的日志输出为英文
- 在 accounts.js 和 apiKeys.js 中的错误处理信息中引入 i18n 键以提升多语言一致性
- 更新 dashboard.js 中的系统状态和错误日志为 i18n 键
- 在 DashboardView.vue 中的多个文本替换为 i18n 语言包中的键
2025-09-12 00:04:11 +08:00
Wangnov
22e27738aa feat: 更新 ESLint 和 Vite 配置以优化开发体验
- 在 .eslintrc.cjs 中允许在所有环境中使用 console 语句,避免构建警告
- 在 vite.config.js 中提升 chunk 大小限制以消除 UI 库的警告,并明确本地组件的导入设置
2025-09-12 00:04:11 +08:00
Wangnov
8522d20cad fix: 修复重复键 2025-09-12 00:04:11 +08:00
Wangnov
d5b9f809b0 feat: 完成布局/仪表板/用户相关组件国际化与语言切换优化(TabBar/MainLayout/AppHeader、UsageTrend/ModelDistribution、User*、Common 组件、i18n/locale 增强) 2025-09-12 00:04:10 +08:00
Wangnov
022724336b feat: Element Plus 语言随 i18n 切换;用户侧登录/禁用提示接入 i18n 2025-09-12 00:03:05 +08:00
Wangnov
482eb7c8f7 feat: 统一 stores(apiKeys/accounts) 错误回退为 i18n 键,提升多语言一致性 2025-09-12 00:03:05 +08:00
Wangnov
01eadea10b feat: stores 部分接入 i18n(auth/settings/apistats/dashboard/clients:标题、错误与日期提示本地化) 2025-09-12 00:03:05 +08:00
Wangnov
5f5826ce56 feat: 基础本地化支持与通用键补充(useConfirm/useChartConfig/format/apiStats 回退 + common.time/errors 等 i18n 键) 2025-09-12 00:03:05 +08:00
Wangnov
97b94eeff9 feat: 完成web/admin-spa/src/components/apikeys的国际化并修复语法错误和警告 2025-09-12 00:03:05 +08:00
Wangnov
9836f88068 feat: 添加Element Plus组件的类型定义
- 在components.d.ts中添加ElDatePicker和ElTooltip的类型定义
- 确保与Element Plus库的兼容性
- 提升代码的类型安全性和可维护性
2025-09-12 00:03:03 +08:00
Wangnov
26d8c98c9d feat: 完成UserUsageStatsModal和ChangeRoleModal组件国际化
- 添加用户使用统计模态框的完整国际化支持
  * 时间选择器选项(最近24小时/7天/30天/90天)
  * 统计卡片(请求数/输入Token/输出Token/总费用)
  * API Keys表格表头和状态显示
  * 使用趋势图表占位符和无数据状态

- 添加角色变更模态框的完整国际化支持
  * 角色选择表单和描述文本
  * 动态警告消息(授予/移除管理员权限)
  * 按钮状态和成功提示消息

- 更新三种语言文件(zh-cn/en/zh-tw)添加新的翻译键值
- 集成Vue I18n组合式API支持动态参数替换
- 保持响应式翻译和用户体验的一致性
2025-09-12 00:03:03 +08:00
Wangnov
5706c32933 feat: 添加.prettierignore解决ESLint插件冲突
- 修复Cursor中ESLint无法找到prettier-plugin-tailwindcss的错误
- 让根目录prettier配置忽略web/admin-spa目录
- 保持前端和后端prettier配置的独立性
- 避免在根目录安装不必要的tailwindcss插件依赖
2025-09-12 00:03:03 +08:00
Wangnov
2de5191c05 feat: 完成三个核心组件的国际化实现
- 完成 GroupManagementModal.vue 组件国际化
  * 添加分组管理相关的所有翻译键
  * 实现创建、编辑、删除分组功能的多语言支持

- 完成 OAuthFlow.vue 组件国际化
  * 支持 Claude、Gemini、OpenAI 三个平台的授权流程
  * 修复模板中的语法错误(缺少引号)
  * 保留技术性地址不进行翻译

- 完成 ProxyConfig.vue 组件国际化
  * 添加代理配置相关的翻译键
  * 支持 SOCKS5 和 HTTP 代理类型的多语言显示

- 更新语言文件
  * 在 zh-cn.js、en.js、zh-tw.js 中添加所有新的翻译键
  * 保持三种语言文件的同步

变更统计:6 文件修改,526 行新增,116 行删除
2025-09-12 00:03:03 +08:00
Wangnov
2b40552eab feat: 完成AccountForm组件国际化的最终验证和修复
- 修复遗漏的API URL和API Key标签国际化
- 修复title属性的国际化(复制链接提示)
- 修复Claude Max/Pro订阅类型显示的国际化
- 修复剩余placeholder属性的国际化
- 完成系统性的多维度验证检查:
  *  模板中的硬编码文本
  *  JavaScript中的字符串常量
  *  特殊属性(title, placeholder等)
  *  翻译键在三语言文件中的存在性
  *  动态内容和条件渲染

现在AccountForm组件已真正实现完整的三语言国际化支持
2025-09-12 00:03:03 +08:00
Wangnov
30acf4a374 feat: 修复AccountForm组件中所有遗漏的国际化内容
- 添加60+个新的翻译键到三语言文件
- 国际化所有placeholder属性
- 国际化按钮文本和标签
- 国际化错误消息和验证提示
- 国际化OAuth步骤描述文本
- 国际化Claude功能描述和配置说明
- 确保三种语言完整覆盖所有UI文本
2025-09-12 00:03:03 +08:00
Wangnov
be7416386f feat: 完成AccountForm组件剩余模块国际化 2025-09-12 00:03:02 +08:00
Wangnov
1beed324d9 feat: 完成AccountForm组件剩余模块国际化
- 国际化剩余JavaScript错误消息和验证文本
- 完成AWS Bedrock配置字段和帮助文本国际化
- 完成Azure OpenAI特定字段和描述国际化
- 国际化了150+个翻译键,覆盖三种语言(zh-cn, zh-tw, en)
- 将所有硬编码中文字符串替换为响应式翻译
- 国际化了Toast消息、确认对话框、表单验证等用户交互元素
- 确保了编辑模式和创建模式的完整国际化支持

AccountForm组件国际化工作已基本完成,支持完整的三语言切换体验。
2025-09-12 00:03:02 +08:00
Wangnov
2e09896d0b feat: 继续完成AccountForm组件国际化的核心模块
- 完成手动Token输入部分国际化,支持Claude/Gemini/OpenAI三个平台
- 完成编辑模式所有特定功能的国际化:账户信息、类型、分组管理
- 完成Claude高级功能国际化:订阅类型、自动停止调度、统一User-Agent、客户端标识
- 完成Gemini Project ID配置的国际化支持
- 新增150+翻译键,涵盖三种语言(简中/繁中/英文)
- 保持响应式特性和暗黑模式兼容性

技术改进:
- 采用结构化翻译键命名策略 (accountForm.module.item)
- 解决重复字符串精确匹配问题
- 使用上下文信息区分相似文本的不同用法
- 优化用户交互文本:占位符、提示、按钮等

进度:AccountForm组件(3730行)已完成约70%的国际化工作
2025-09-12 00:03:02 +08:00
Wangnov
e80c49c1ce feat: 开始 AccountForm 组件国际化
- 扩展语言文件,添加200+条 AccountForm 相关翻译键
- 支持简体中文、繁体中文、英文三种语言
- 添加 useI18n 组合式 API 支持
- 国际化模态框标题、步骤指示器、平台选择等关键UI元素
- 国际化账户类型、分组管理等核心功能
- 国际化 JavaScript 中的Toast消息和确认对话框
- 为多平台(Claude、Gemini、OpenAI、Azure OpenAI、Bedrock、Claude Console)提供完整翻译支持

这是一个大型组件(3730行)的渐进式国际化工作,后续将继续完善其余部分。
2025-09-12 00:03:02 +08:00
Wangnov
27034997a6 feat: 完成用户相关组件的完整国际化支持
* 扩展语言文件新增用户功能翻译键
  - 新增 user.dashboard、user.login、user.management 翻译组
  - 涵盖三语言支持(zh-cn/zh-tw/en)
  - 包含120+翻译键covering用户仪表板、登录、管理功能

* UserDashboardView.vue 完整国际化
  - 集成useI18n composable
  - 国际化导航标签、统计卡片、账户信息
  - 响应式翻译Toast消息和错误处理

* UserLoginView.vue 完整国际化
  - 国际化登录表单标签、占位符、按钮文本
  - 响应式验证消息和状态提示
  - 支持动态语言切换

* UserManagementView.vue 完整国际化
  - 国际化用户列表、搜索过滤器、操作按钮
  - 响应式确认对话框和Toast通知
  - 支持参数化翻译消息(用户名、数量等)

Technical implementation:
- 遵循Vue 3 Composition API最佳实践
- 保持响应式设计和暗黑模式兼容性
- 统一错误处理和用户体验
2025-09-12 00:03:02 +08:00
Wangnov
24ad052d02 feat: 完成SettingsView页面完整国际化支持
- 扩展三个语言文件,添加198个settings翻译键,支持中英繁三语言
- 完成SettingsView.vue所有1604行的系统化国际化处理:
  * 完整国际化HTML模板:页面标题、导航标签、品牌设置、Webhook设置等
  * 完整国际化JavaScript功能:Toast消息、确认对话框、表单验证、错误处理
  * 集成Vue i18n:添加useI18n composable,实现响应式翻译支持
  * 转换静态函数为响应式翻译,支持语言切换时实时更新

- 主要功能模块全面国际化:
  * 品牌设置:网站名称、图标管理、管理入口配置完全国际化
  * Webhook通知:7种平台类型、通知类型、高级设置完全国际化
  * 模态框:复杂的平台添加/编辑表单完全国际化
  * 响应式布局:桌面端表格和移动端卡片视图完全适配
  * 错误处理:37个Toast消息、确认对话框、表单验证完全国际化

现在SettingsView完全支持多语言切换,与其他页面保持一致的国际化标准
2025-09-12 00:03:02 +08:00
Wangnov
19ca374527 feat: 完成ApiKeysView页面完整国际化支持
- 扩展三个语言文件,添加167个apiKeys翻译键,支持中英繁三语言
- 完成ApiKeysView.vue所有2869行的系统化国际化处理:
  * 完整国际化HTML模板:页面标题、Tab导航、表格标题、筛选器、状态指示等
  * 完整国际化JavaScript功能:Toast消息、确认对话框、错误处理、时间格式化
  * 集成Vue i18n:添加useI18n composable,实现响应式翻译支持
  * 转换静态选项为计算属性,支持语言切换时实时更新

- 主要功能模块全面国际化:
  * 主界面:标题描述、Tab导航、工具栏按钮完全国际化
  * 数据表格:表头、状态标签、统计信息、操作按钮全面适配
  * 移动端视图:卡片布局、统计展示、操作按钮完全国际化
  * 已删除管理:已删除API Keys的表格和操作完全国际化
  * 确认对话框:所有删除、恢复、清空操作的确认信息国际化
  * 错误处理:统一的错误消息和成功提示国际化

现在ApiKeysView完全支持多语言切换,与AccountsView保持一致的国际化标准
2025-09-12 00:03:02 +08:00
Wangnov
27c0804219 feat: 完成AccountsView页面完整国际化
- 添加useI18n导入并替换100+硬编码中文文本
- 扩展三种语言文件的accounts翻译键(150+条)
- 更新下拉选项为响应式计算属性支持动态翻译
- 国际化页面标题、表格列头、筛选器和操作按钮
- 处理状态文本、错误消息和工具提示
- 更新JavaScript函数返回值使用翻译键
- 完整支持桌面端和移动端视图的国际化
- 修正货币符号和时间格式化的参数化翻译

涵盖组件:
- 账户管理主界面(标题、描述、筛选器)
- 桌面端表格视图(列头、状态、操作按钮)
- 移动端卡片视图(标签、按钮、状态)
- 错误处理和确认对话框
- 时间和数值格式化函数
2025-09-12 00:03:02 +08:00
Wangnov
cd7959f3bf feat: 实现DashboardView.vue完整国际化支持
- 完成DashboardView.vue全面国际化
  * 主要统计卡片:总API Keys、服务账户、今日请求、系统状态全部多语言化
  * Token统计模块:今日Token、总消耗量、实时RPM/TPM指标完整国际化
  * 实时性能监控:请求数/Token数每分钟指标、历史数据标识多语言化
  * 图表组件完整国际化:饼图、趋势图、API Keys使用趋势图
  * 交互控件全面支持:日期选择器、粒度切换、自动刷新等

- 图表系统深度国际化
  * Chart.js图表标签完全多语言化:Token使用趋势、模型分布等
  * 工具提示和坐标轴标签支持动态语言切换
  * 表格头部和数据展示完整国际化支持

- 扩展三语言dashboard翻译组
  * zh-cn.js: 简体中文专业术语翻译
  * zh-tw.js: 繁体中文技术翻译(快取、即時等台湾用词)
  * en.js: 英文专业技术术语标准翻译
  * 总计90+个翻译键值,涵盖所有用户可见文本

- 平台账户工具提示国际化
  * Claude/Console/Gemini/Bedrock/OpenAI/Azure OpenAI账户状态
  * 支持参数化翻译,动态显示账户数量和状态

- 提升复杂业务场景多语言体验
  * 管理后台核心数据可视化页面完全国际化
  * 60+个硬编码中文字符串全部替换
  * 确保不同语言环境下数据展示的专业性
2025-09-12 00:03:02 +08:00
Wangnov
e88e97b485 feat: 实现AppHeader和LoginView完整国际化支持
- 完成AppHeader.vue全面国际化
  * 版本检查和更新通知系统多语言支持
  * 用户菜单和账户信息修改模态框国际化
  * 退出登录确认流程多语言化
  * 总计替换30+个硬编码中文字符串

- 实现LoginView.vue完整国际化
  * 登录表单所有文本支持多语言
  * 添加语言切换组件到登录页面
  * 确保用户可在登录前选择语言

- 扩展三语言翻译文件
  * zh-cn.js: 简体中文标准翻译
  * zh-tw.js: 繁体中文专业化翻译
  * en.js: 英文技术术语标准翻译
  * 新增header和login完整翻译组

- 提升用户体验
  * 登录页面右上角工具栏(语言+主题切换)
  * 响应式布局适配多设备
  * 完整的首次访问多语言体验
2025-09-12 00:03:02 +08:00
Wangnov
4aae4aaec0 feat: 完成API统计组件完整国际化支持
- 完成6个apistats组件的全面国际化改造
  * ModelUsageStats.vue - 模型使用统计
  * AggregatedStatsCard.vue - 聚合统计卡片
  * StatsOverview.vue - 统计概览
  * LimitConfig.vue - 限制配置
  * TokenDistribution.vue - Token使用分布
  * ApiKeyInput.vue - API Key输入组件

- 扩展三语言翻译支持(zh-cn/zh-tw/en)
  * 新增100+专业翻译键涵盖所有UI文字
  * 台湾本地化的繁体中文翻译
  * 技术专业的英文术语翻译
  * 支持参数化翻译处理动态内容

- 技术优化
  * 统一使用Vue 3 Composition API的useI18n()模式
  * 智能日期格式国际化处理
  * 完全消除硬编码中文文字
  * 支持条件性翻译和动态时间段显示

现在整个API统计功能模块支持完整的多语言切换体验
2025-09-12 00:03:02 +08:00
Wangnov
c7e1a3429d feat: 完善教程系统国际化架构并完成英文教程翻译
- 创建分离式多语言教程组件架构
  - TutorialView-zh-cn.vue (简体中文教程)
  - TutorialView-zh-tw.vue (繁体中文教程,统一台湾语言习惯)
  - TutorialView-en.vue (英文教程,全面翻译用户界面和技术文档)

- 重构教程路由系统
  - 新的TutorialView.vue作为国际化代理组件
  - 根据用户语言设置动态选择合适的教程组件
  - 保持/tutorial路径可访问性,提升用户体验

- 完成英文教程完整翻译
  - Windows/macOS/Linux安装教程全英文化
  - 环境变量配置说明英文化
  - 故障排除章节英文化
  - JavaScript注释和用户界面文本英文化

- 优化国际化架构
  - ApiStatsView使用新的分离式教程组件
  - 统一的语言选择逻辑和组件复用
  - 更清晰的代码组织和维护性
2025-09-12 00:03:02 +08:00
Wangnov
74d37486b8 chore: 添加Vue组件和composables自动导入类型定义
- 添加unplugin-vue-components自动生成的组件类型定义
- 添加unplugin-auto-import自动生成的composables类型定义
- 为TypeScript提供更好的类型支持和IDE智能提示
2025-09-12 00:03:02 +08:00
Wangnov
1eadc94592 feat: 实现ApiStatsView页面完整国际化
- 集成vue-i18n到ApiStatsView,支持动态语言切换
- 国际化所有用户界面文本:页面标题、按钮、Tab标签、时间选择器
- 实现LogoTitle动态subtitle,根据当前tab显示对应语言的标题
- 添加语言切换组件到页面header,与主题切换并列显示
- 实现教程内容的整体替换机制,支持基于语言的动态组件选择
- 确保用户登录、管理后台、统计查询等核心功能完全本地化
2025-09-12 00:03:02 +08:00
Wangnov
87591365bc feat: 在AppHeader中集成语言切换组件
- 在AppHeader主导航栏中添加LanguageSwitch组件
- 使用dropdown模式和medium尺寸提供最佳用户体验
- 与现有ThemeToggle组件并列放置,保持界面一致性
- 为管理后台提供全局语言切换功能
2025-09-12 00:03:02 +08:00
Wangnov
f4b873315a feat: 创建语言切换组件
- 创建LanguageSwitch.vue组件,支持dropdown/button/icon三种显示模式
- 实现点击外部自动关闭下拉菜单功能
- 支持size属性控制组件大小(small/medium/large)
- 集成locale store实现语言切换和状态同步
- 使用纯文字标识符显示:简/繁/EN,提供清晰的语言选择界面
- 下拉菜单显示完整语言名称:简体中文/繁體中文/English
2025-09-12 00:03:02 +08:00
Wangnov
cb1b7bc0e3 feat: 实现i18n核心配置和语言状态管理
- 创建i18n配置系统,支持简体中文/繁体中文/英文三种语言
- 实现浏览器语言自动检测和localStorage持久化
- 添加基础翻译文件,包含common、language、header、apiStats模块
- 创建locale store使用Pinia管理语言状态
- 配置语言标识符为纯文字:简/繁/EN,去除国旗emoji
2025-09-12 00:03:01 +08:00
Wangnov
504b9e3ea7 feat: 添加vue-i18n依赖和基础配置
- 安装vue-i18n@9.x作为项目国际化解决方案
- 在main.js中集成i18n插件到Vue应用
- 配置支持简体中文、繁体中文、英文三种语言
2025-09-12 00:03:01 +08:00
github-actions[bot]
19ad0cd5f8 chore: sync VERSION file with release v1.1.138 [skip ci] 2025-09-11 14:26:42 +00:00
shaw
009f7c84f6 fix: 简化css动画以降低gpu占用高问题 2025-09-11 22:22:05 +08:00
shaw
7c4feec5aa feat: 添加账户状态监控和自动恢复机制
- 实现账户健康度监控系统,支持30分钟内错误率检测
- 添加自动恢复机制,失败账户在30分钟后自动尝试恢复
- 优化账户选择策略,优先选择健康账户
- 增强Redis键管理,添加账户状态和错误追踪功能
- 改进Gemini服务错误处理和重试逻辑
- 新增standardGeminiRoutes标准化路由支持

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-11 22:02:53 +08:00
github-actions[bot]
b6b16d05f0 chore: sync VERSION file with release v1.1.137 [skip ci] 2025-09-11 03:55:21 +00:00
shaw
02989a7588 fix: 修复CCR账户表单验证和平台分组逻辑
- 修复CCR账户创建时的表单字段验证
- 统一CCR与Claude Console的处理逻辑
- 修复账户删除前的API Key绑定检查
- 修复Claude Console账户绑定的API Key计数
- 优化平台分组判断逻辑

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-11 11:54:27 +08:00
shaw
fef2c8c3c2 feat: 优化ProxyConfig组件添加代理URL智能识别功能
- 新增快速配置输入框,支持粘贴完整代理URL自动填充表单
- 支持多种格式自动识别:socks5://、http://、https://、host:port
- 自动忽略#后的别名部分
- 粘贴即解析,输入即智能识别
- 添加实时解析成功/失败提示
- 优化用户体验,无需失去焦点即可触发解析

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-11 11:52:41 +08:00
github-actions[bot]
c0735b1bc5 chore: sync VERSION file with release v1.1.136 [skip ci] 2025-09-11 01:48:57 +00:00
shaw
0eb95b3b06 refactor: 清理模型限制检查的冗余代码
优化内容:
- 删除 claudeRelayService.js 中的重复模型限制检查(82行代码)
- 保留 api.js 中的统一检查,覆盖所有服务类型(claude/console/ccr)
- 移除 /v1/messages/count_tokens 端点的模型限制(计数接口不需要限制)

架构改进:
- 模型限制逻辑现在集中在 api.js 的 handleMessagesRequest 函数中
- 避免了每个服务各自实现一遍的重复代码
- 提高了代码的可维护性和一致性

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-11 09:43:15 +08:00
shaw
f667a95d88 fix: 修复模型限制功能逻辑错误(从白名单改回黑名单)
问题原因:
- 在提交 7f9869ae 添加CCR支持时,错误地将模型限制从黑名单改成了白名单
- 前端UI显示"设置此API Key无法访问的模型",明确表示这是黑名单
- 后端却将其当作白名单处理,导致逻辑完全相反

修复内容:
- 将判断逻辑从 !includes 改回 includes(黑名单逻辑)
- 更新注释和日志消息,明确这是"限制列表"而非"允许列表"
- 同时修复了 api.js 和 claudeRelayService.js 中的所有相关判断

影响范围:
- src/routes/api.js: 修复 /v1/messages 和 /v1/messages/count_tokens 端点的模型限制判断
- src/services/claudeRelayService.js: 修复流式和非流式请求的模型限制判断

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-11 09:35:04 +08:00
github-actions[bot]
03db930354 chore: sync VERSION file with release v1.1.135 [skip ci] 2025-09-11 01:17:31 +00:00
shaw
2fcfccb2fc Merge branch 'dev' of github.com:Wei-Shaw/claude-relay-service into dev 2025-09-10 22:46:18 +08:00
shaw
78adf82f0d fix: 优化账户表单组件的UI和功能
- 将 CCR 描述从 "Claude Connector" 更正为 "Claude Code Router"
- 隐藏限流时长输入字段,统一使用默认值 60 分钟
- 为 OpenAI-Responses 平台添加分组支持
- 清理 CcrAccountForm 中的冗余标签文字

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-10 22:44:05 +08:00
Wesley Liddick
fe1f05fadd Merge pull request #411 from bottotl/main
兼容 sider 自定义 API
2025-09-10 22:36:54 +08:00
root
cd5573ecde Fix Prettier formatting issues
- Remove trailing whitespace and fix indentation in src/app.js
- Format whitespace in src/middleware/auth.js
- Fix formatting and add missing newline in src/middleware/browserFallback.js

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-10 09:13:51 +00:00
Wesley Liddick
fcc2f51f81 Merge pull request #407 from sususu98/dev
fix: 更新会话续期逻辑,调整续期阈值和TTL设置,确保统一调度会话映射按配置正确续期
2025-09-10 16:36:55 +08:00
root
4fd4dbfa51 fix: 回退401错误处理逻辑到原始版本
- 恢复"遇到1次401就停止调度"的原始逻辑
- 移除"记录401错误但不停用账号"的临时修改
- 修复非流式和流式请求中的401处理逻辑
- 确保401错误会立即标记账号为异常状态

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-10 08:20:17 +00:00
sususu98
ce8706d1b6 Merge branch 'Wei-Shaw:dev' into dev 2025-09-10 15:56:03 +08:00
sususu98
d3fcd95b94 refactor: improve readability of conditional statements 2025-09-10 15:55:34 +08:00
sususu98
433f0c5f23 fix: 更新会话续期逻辑,调整续期阈值和TTL设置,确保统一调度会话映射按配置正确续期 2025-09-10 15:53:23 +08:00
shaw
7712d5516c merge: 合并远程 dev 分支,整合 CCR 和 OpenAI-Responses 功能
## 合并内容
- 成功合并远程 dev 分支的 CCR (Claude Connector) 功能
- 保留本地的 OpenAI-Responses 账户管理功能
- 解决所有合并冲突,保留双方功能

## UI 调整
- 将 CCR 平台归类到 Claude 分组中
- 保留新的平台分组选择器设计
- 支持所有平台类型:Claude、CCR、OpenAI、OpenAI-Responses、Gemini、Azure OpenAI、Bedrock

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-10 15:49:52 +08:00
root
bdae9d6ceb feat: 添加Chrome插件兜底支持,解决第三方插件401错误问题
• 新增browserFallback中间件,自动识别并处理Chrome插件请求
• 增强CORS支持,明确允许chrome-extension://来源
• 优化请求头过滤,移除可能触发Claude CORS检查的浏览器头信息
• 完善401错误处理逻辑,避免因临时token问题导致账号被错误停用

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-10 07:48:41 +00:00
shaw
08946c67ea feat: 新增 OpenAI-Responses 账户管理功能和独立自动停止标记机制
## 功能新增
- 实现 OpenAI-Responses 账户服务(openaiResponsesAccountService.js)
  - 支持使用账户内置 API Key 进行请求转发
  - 实现每日额度管理和重置机制
  - 支持代理配置和优先级设置
- 实现 OpenAI-Responses 中继服务(openaiResponsesRelayService.js)
  - 处理请求转发和响应流处理
  - 自动记录使用统计信息
  - 支持流式和非流式响应
- 新增管理界面的 OpenAI-Responses 账户管理功能
  - 完整的 CRUD 操作支持
  - 实时额度监控和状态管理
  - 支持手动重置限流和每日额度

## 架构改进
- 引入独立的自动停止标记机制,区分不同原因的自动停止
  - rateLimitAutoStopped: 限流自动停止
  - fiveHourAutoStopped: 5小时限制自动停止
  - tempErrorAutoStopped: 临时错误自动停止
  - quotaAutoStopped: 额度耗尽自动停止
- 修复手动修改调度状态时自动恢复的问题
- 统一清理逻辑,防止状态冲突

## 其他优化
- getAccountUsageStats 支持不同账户类型参数
- 统一调度器支持 OpenAI-Responses 账户类型
- WebHook 通知增强,支持新账户类型的事件

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-10 15:41:52 +08:00
Wesley Liddick
8b0e9b8d8e Merge pull request #404 from sususu98/dev
feat: 添加 CCR (Claude Code Router) 账户类型支持
2025-09-10 15:22:29 +08:00
sususu98
1dd00e1463 format 2025-09-10 14:46:06 +08:00
sususu98
5938180583 Merge branch 'Wei-Shaw:dev' into dev 2025-09-10 14:40:46 +08:00
Wesley Liddick
fb6d0e7f55 Merge pull request #403 from Wei-Shaw/revert-401-main
Revert "合并所有新功能到Wei-Shaw仓库(排除ApiStatsView.vue)"
2025-09-10 14:40:00 +08:00
Wesley Liddick
3c5068866c Revert "合并所有新功能到Wei-Shaw仓库(排除ApiStatsView.vue)" 2025-09-10 14:37:52 +08:00
sususu98
c0059c68eb Merge branch 'Wei-Shaw:dev' into dev 2025-09-10 14:22:45 +08:00
sususu98
7f9869ae20 feat: 添加 CCR (Claude Code Router) 账户类型支持
实现通过供应商前缀语法进行 CCR 后端路由的完整支持。
用户现在可以在 Claude Code 中使用 `/model ccr,model_name` 将请求路由到 CCR 后端。
暂时没有实现`/v1/messages/count_tokens`,因为这需要在CCR后端支持。
CCR类型的账户也暂时没有考虑模型的支持情况

## 核心实现

### 供应商前缀路由

- 添加 modelHelper 工具用于解析模型名称中的 `ccr,` 供应商前缀
- 检测到前缀时自动路由到 CCR 账户池
- 转发到 CCR 后端前移除供应商前缀

### 账户管理

- 创建 ccrAccountService 实现 CCR 账户的完整 CRUD 操作
- 支持账户属性:名称、API URL、API Key、代理、优先级、配额
- 实现账户状态:active、rate_limited、unauthorized、overloaded
- 支持模型映射和支持模型配置

### 请求转发

- 实现 ccrRelayService 处理 CCR 后端通信
- 支持流式和非流式请求
- 从 SSE 流中解析和捕获使用数据
- 支持 Bearer 和 x-api-key 两种认证格式

### 统一调度

- 将 CCR 账户集成到 unifiedClaudeScheduler
- 添加 \_selectCcrAccount 方法用于 CCR 特定账户选择
- 支持 CCR 账户的会话粘性
- 防止跨类型会话映射(CCR 会话仅用于 CCR 请求)

### 错误处理

- 实现全面的错误状态管理
- 处理 401(未授权)、429(速率限制)、529(过载)错误
- 成功请求后自动从错误状态恢复
- 支持可配置的速率限制持续时间

### Web 管理界面

- 添加 CcrAccountForm 组件用于创建/编辑 CCR 账户
- 将 CCR 账户集成到 AccountsView 中,提供完整管理功能
- 支持账户切换、重置和使用统计
- 在界面中显示账户状态和错误信息

### API 端点

- POST /admin/ccr-accounts - 创建 CCR 账户
- GET /admin/ccr-accounts - 列出所有 CCR 账户
- PUT /admin/ccr-accounts/:id - 更新 CCR 账户
- DELETE /admin/ccr-accounts/:id - 删除 CCR 账户
- PUT /admin/ccr-accounts/:id/toggle - 切换账户启用状态
- PUT /admin/ccr-accounts/:id/toggle-schedulable - 切换可调度状态
- POST /admin/ccr-accounts/:id/reset-usage - 重置每日使用量
- POST /admin/ccr-accounts/:id/reset-status - 重置错误状态

## 技术细节

- CCR 账户使用 'ccr' 作为 accountType 标识符
- 带有 `ccr,` 前缀的请求绕过普通账户池
- 转发到 CCR 后端前清理模型名称内的`ccr,`
- 从流式和非流式响应中捕获使用数据
- 支持缓存令牌跟踪(创建和读取)
2025-09-10 14:21:48 +08:00
Wesley Liddick
61cf1166ff Merge pull request #401 from DuanNaiSheQu/main
合并所有新功能到Wei-Shaw仓库(排除ApiStatsView.vue)
2025-09-10 14:08:10 +08:00
Wesley Liddick
27fe3b6853 Merge branch 'dev' into main 2025-09-10 14:04:27 +08:00
DuanNaiSheQu
af3d688e98 合并所有新功能到Wei-Shaw仓库(排除ApiStatsView.vue)
 新增功能:
- GPT-5 High推理级别费用追踪和限制
- API Key图标上传功能
- 优化的进度条显示组件
- 暗黑模式UI兼容
- 完整的前后端集成

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-10 13:38:27 +08:00
Wesley Liddick
1c3b74f45b Merge pull request #389 from iaineng/dev
fix(ui): correct table row striping in API Keys view
2025-09-09 14:18:06 +08:00
iaineng
929852a881 fix(ui): correct table row striping in API Keys view
Fixed second row appearing white due to invalid Tailwind CSS class bg-gray-850.
Replaced with valid bg-gray-700/30 for odd rows and kept bg-gray-800/40 for even rows
to enhance dark mode contrast.
2025-09-09 13:52:38 +08:00
Wesley Liddick
c58d8d2040 Merge pull request #387 from f3n9/main-um-8
修复用户自行创建的API Key缺少服务权限信息的问题
2025-09-09 13:37:28 +08:00
Feng Yue
4ee9e0b546 API Keys created by users have all permissions by default 2025-09-09 12:52:34 +08:00
Wesley Liddick
8064bc24b9 Merge pull request #382 from Edric-Li/feat/add-account-info-to-error-logs
feat: 在错误日志中添加账号信息
2025-09-09 12:26:45 +08:00
Wesley Liddick
4592773ea2 Merge pull request #381 from Edric-Li/fix/dedicated-account-schedulable-check
fix: 修复专属账号停止调度后仍能使用的问题
2025-09-09 12:19:41 +08:00
Wesley Liddick
da744528bc Merge pull request #383 from Edric-Li/feature/smtp-notification
feat: 添加SMTP邮件通知功能
2025-09-09 12:19:26 +08:00
Wesley Liddick
d3577fae03 Merge pull request #380 from Edric-Li/feature/529-error-handling
feat: 为普通Claude账户添加529错误处理功能
2025-09-09 12:19:07 +08:00
Wesley Liddick
b581a618b9 Merge pull request #379 from geminiwen/dev
ui: improve group tags layout in AccountsView
2025-09-09 12:13:18 +08:00
Edric Li
f375f9f841 fix: 修复 ESLint 错误 - 解决未定义变量问题
- claudeConsoleRelayService.js: 将 account 变量声明提到更高作用域
- claudeRelayService.js: 移除 _makeClaudeStreamRequest 函数中的未定义变量引用
2025-09-09 11:10:27 +08:00
Edric Li
52820a7e49 style: 修复 Prettier 格式问题
- 格式化 src/app.js
- 格式化 src/services/claudeConsoleRelayService.js
- 格式化 src/services/claudeRelayService.js
2025-09-09 04:14:27 +08:00
Edric Li
5d677b4f17 fix: 优化UI文案和修复SMTP测试问题
- 修复SMTP平台测试按钮传递必要字段
- 添加"测试通知"类型的友好显示文本和描述
- 简化标题:"启用 Webhook 通知" → "启用通知"
2025-09-09 04:09:51 +08:00
Edric Li
268a262281 fix: 修复SMTP平台测试按钮缺少必要字段的问题
- 在testPlatform函数中添加SMTP平台的完整字段传递
- 确保测试时包含host, port, user, pass, to等必填字段
2025-09-09 04:02:13 +08:00
Edric Li
283362acd0 feat: 添加SMTP邮件通知功能
新增功能:
- 支持SMTP邮件通知平台,可通过邮件接收系统通知
- 支持配置SMTP服务器、端口、用户名、密码、发件人和收件人
- 支持TLS/SSL加密连接
- 提供美观的HTML邮件模板和纯文本备用格式

代码优化:
- 重构邮件格式化逻辑,提取buildNotificationDetails减少重复代码
- 优化前端表单验证逻辑,提取validatePlatformForm统一验证
- 清理UI中的冗余提示信息和配置项

UI改进:
- 移除SMTP配置说明文字
- 移除超时设置和忽略TLS证书验证选项
- 简化测试成功提示消息
- SMTP平台显示收件人邮箱而非URL

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-09 04:00:35 +08:00
Edric Li
756918b0ce feat: 在错误日志中添加账号信息
- 在 claudeRelayService.js 的所有错误日志中添加账号名称或 ID
- 在 claudeConsoleRelayService.js 的错误日志中添加账号信息
- 便于排查 529 (过载) 和 504 (超时) 错误对应的具体账号

问题背景:
用户反馈错误日志中没有账号信息,无法定位是哪个账号出现问题,
特别是 529 和 504 错误频繁出现时难以排查。

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-09 02:45:47 +08:00
Edric Li
4c2660a2d3 fix: 修复专属账号停止调度后仍能使用的问题
- 在 selectAccountForApiKey 方法中为所有专属账号类型添加 schedulable 检查
- 在 _getAllAvailableAccounts 方法中为所有专属账号类型添加 schedulable 检查
- 改进日志输出,显示账号不可用的具体原因(isActive、status、schedulable 状态)

问题描述:
当 Claude Console/OAuth/Bedrock 账号设置为专属账号并停止调度(schedulable=false)后,
系统仍然会使用该账号,没有正确回退到账号池。

修复内容:
1. Claude OAuth 账号:添加 this._isSchedulable(boundAccount.schedulable) 检查
2. Claude Console 账号:添加 this._isSchedulable(boundConsoleAccount.schedulable) 检查
3. Bedrock 账号:添加 this._isSchedulable(boundBedrockAccountResult.data.schedulable) 检查

兼容性:
_isSchedulable 方法已处理向后兼容,当 schedulable 字段为 undefined/null 时默认返回 true

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-09 02:33:50 +08:00
Edric Li
908e323db0 feat: 为普通Claude账户添加529错误处理功能
- 添加可配置的529错误处理机制,通过CLAUDE_OVERLOAD_HANDLING_MINUTES环境变量控制
- 支持流式和非流式请求的529错误检测
- 自动标记过载账户并在指定时间后恢复
- 成功请求后自动清除过载状态
- 默认禁用,需手动配置启用(0表示禁用,>0表示过载持续分钟数)

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-09 00:46:40 +08:00
Gemini Wen
97da7d44ba ui: improve group tags layout in AccountsView
- Add vertical margin and flex-wrap for better multi-line display
- Remove left margin to align properly with container

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-08 22:55:56 +08:00
github-actions[bot]
ca79e08c81 chore: sync VERSION file with release v1.1.134 [skip ci] 2025-09-08 14:17:38 +00:00
Wesley Liddick
86ed5c6344 Merge pull request #377 from f3n9/dev-um-8
用户自助管理页面顶部增加链接,用于展示配置教程
2025-09-08 22:14:34 +08:00
shaw
6e16df0b45 fix: 进度条配色优化 2025-09-08 22:13:46 +08:00
shaw
73d3df56e5 fix: 进度条颜色显示优化 2025-09-08 21:11:16 +08:00
shaw
c4f1e7a411 fix: api-keys页面布局优化 2025-09-08 20:45:19 +08:00
Feng Yue
3f5004626a Merge branch 'main-um-8' into dev-um-8 2025-09-08 18:53:32 +08:00
shaw
7f8fae70e6 fix: azure转发问题修复 2025-09-08 17:26:14 +08:00
Feng Yue
3239965cbe add tutorial page for user 2025-09-08 17:03:45 +08:00
shaw
fec80a16fa fix: 优化请求超时配置 2025-09-08 16:34:27 +08:00
shaw
399e6b9d8c fix: 优化codex 429限流显示为恩替 2025-09-08 16:34:26 +08:00
Wesley Liddick
d77605a8ad Merge pull request #375 from Edric-Li/main
增强API KEYS 页面、增强粘性回话的续期、增强错误处理
2025-09-08 16:19:42 +08:00
github-actions[bot]
4dcd251662 chore: sync VERSION file with release v1.1.133 [skip ci] 2025-09-08 08:15:05 +00:00
Wesley Liddick
5c8136ddd4 Merge branch 'dev' into main 2025-09-08 16:14:54 +08:00
github-actions[bot]
8b13403304 chore: sync VERSION file with release v1.1.132 [skip ci] 2025-09-08 08:06:36 +00:00
Edric Li
8cb9f52c1a feat: 优化粘性会话TTL管理策略
- 将TTL默认值从15天改为1小时,更适合短期会话场景
- 将续期阈值默认设为0,默认不自动续期,提高控制精度
- 时间单位从天调整为小时/分钟,提供更细粒度的控制
- 添加环境变量配置支持:STICKY_SESSION_TTL_HOURS 和 STICKY_SESSION_RENEWAL_THRESHOLD_MINUTES
- 保持向后兼容性,所有现有部署将自动使用新的默认值

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-08 16:06:12 +08:00
Edric Li
3aa7c89e25 feat: 完全移除 API Key 图标功能
彻底删除 API Key 图标功能的所有相关代码:

前端改动:
- 删除 IconPicker.vue 组件文件
- 移除 ApiKeysView.vue 中的图标显示和 updateApiKeyIcon 方法
- 清理 CreateApiKeyModal.vue 中的图标选择器
- 清理 EditApiKeyModal.vue 中的图标选择器
- 移除所有 IconPicker 组件的引用

后端改动:
- 从 apiKeyService.js 中移除 icon 字段更新支持
- 从 admin.js 路由中移除 icon 参数处理和验证逻辑
- 清理创建和更新 API Key 时的 icon 参数

此改动简化了 API Key 管理界面,移除了不必要的图标功能。

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-08 16:06:12 +08:00
Edric Li
b46ccb10d0 Revert "feat: 实现基于日费用的智能负载均衡策略"
This reverts commit 8976c470e584f1179bcfb30c4856aa6b76633484.
2025-09-08 16:06:12 +08:00
Edric Li
f51d345ad9 Revert "feat: 将费用优先调度逻辑集成到 UnifiedClaudeScheduler"
This reverts commit 1a5b3b614961e889a0700809e3e86b08eccb5e19.
2025-09-08 16:06:12 +08:00
Edric Li
bed7b7f000 refactor: 优化 API Keys 管理界面布局和用户体验
主要改进:
- 移除 API Key 图标功能,简化界面设计
- 新增独立的"所属账号"列,提高信息层次清晰度
- 统一所有数据列字体大小为 13px,改善可读性
- 优化列宽度分配:名称(14%)、状态(6%)、操作(27%)等
- 调整列显示顺序:费用 → Token → 请求数,更符合逻辑
- 费用显示精度从4位调整为2位小数
- 同步优化已删除 API Keys 表格布局
- 简化 Token 列标题(去掉"数"字)

技术细节:
- 使用内联样式统一字体大小
- 保持活跃和已删除表格的一致性
- 清理冗余代码,减少约 30 行

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-08 16:06:12 +08:00
Edric Li
bd2f25dc19 feat: 将费用优先调度逻辑集成到 UnifiedClaudeScheduler
- 替换 _sortAccountsByPriority 为 _sortAccountsByCost 方法
- 支持多种账户类型的费用获取(claude-official、claude-console、bedrock)
- 实现智能降级机制:费用获取失败时自动回退到优先级排序
- 排序优先级:日费用 → 账户优先级 → 最后使用时间
- 添加详细的费用排名调试日志
- 确保所有 API 调用都使用费用优化的账户选择策略

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-08 16:06:12 +08:00
Edric Li
cc27b377d8 feat: 实现基于日费用的智能负载均衡策略
- 新增 sortAccountsByCost() 方法,支持按日费用排序账号
- 修改账号选择逻辑从时间排序改为费用排序
- 添加多层容错机制:单账号失败、全局失败、方法异常
- 费用获取失败的账号设为最低优先级,避免故障传播
- 费用相同时仍按时间排序,保持负载均衡
- 增强日志输出,显示账号费用排名和选中账号费用

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-08 16:06:12 +08:00
Edric Li
9cbf3195e0 feat: 优化粘性会话TTL管理策略
- 将默认TTL从1小时延长至15天,更适合长期项目开发
- 实现智能续期机制:剩余时间<14天时自动续期到15天
- 添加配置化支持:通过环境变量STICKY_SESSION_TTL_DAYS和STICKY_SESSION_RENEWAL_THRESHOLD_DAYS调整TTL策略
- 集成到所有调度器:Claude、OpenAI、Gemini的普通会话和分组会话
- 提升用户体验:活跃项目会话持续有效,停用项目自动清理
- 性能优化:智能判断减少不必要的Redis EXPIRE操作

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-08 16:06:12 +08:00
Edric Li
9fa7602947 feat: 优化错误处理机制和代码重构
- 将5xx错误阈值从10次降低到3次,符合行业标准(AWS ELB: 2次, K8s: 3次)
- 新增网络超时(ETIMEDOUT)错误处理,触发账户降级机制
- 重构错误处理逻辑,提取统一方法_handleServerError,消除75%重复代码
- 支持不同上下文的错误日志(Network, Request, Stream等)
- 修复流式请求中的参数作用域问题,确保错误处理一致性

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-08 16:06:12 +08:00
Wesley Liddick
9bd7f7ae7b Merge pull request #376 from geminiwen/dev
optimize: 优化分组显示
2025-09-08 14:06:50 +08:00
Gemini Wen
0a43bb2645 format: optimized front-end code format 2025-09-08 12:39:42 +08:00
github-actions[bot]
94c5c2e364 chore: sync VERSION file with release v1.1.132 [skip ci] 2025-09-08 04:34:24 +00:00
shaw
f284d5666f feat: 支持隐藏后台登录入口按钮 2025-09-08 12:19:14 +08:00
shaw
e824858d60 feat: claude账户支持使用统一的客户端标识 2025-09-08 11:35:44 +08:00
github-actions[bot]
9d05c03a3a chore: sync VERSION file with release v1.1.131 [skip ci] 2025-09-07 14:18:58 +00:00
Edric Li
0fc5309ff9 feat: 优化API Keys页面布局和导出功能
- 重新组织工具栏布局:左侧为查询筛选器,右侧为操作按钮
- Excel导出功能增加API Keys的标签信息
- 设置标签列宽度和样式,无标签时显示"无"并用斜体
- 布局优化:查询功能和操作功能分离,提升用户体验

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-07 22:18:34 +08:00
Edric Li
92ec3ffc72 feat: API Keys页面恢复今日时间选项并设为默认
- 添加"今日"时间筛选选项,使用fa-calendar-day图标
- 将默认时间范围从"最近7天"改为"今日"
- 优化日期处理逻辑,确保今日选项从0点开始
- 调整UsageDetailModal宽度以适应内容显示
- 同步更新所有相关的初始化和重置逻辑

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-07 22:18:34 +08:00
Edric Li
8c9d6381f3 feat: API Keys图标系统和UI优化
主要功能增强:
- 实现API Key自定义图标功能,支持图片上传、裁剪和智能压缩
- 新增IconPicker组件,提供内置图标选择和图片上传功能
- 支持固定尺寸裁剪区域,可拖拽定位选择头像区域
- 智能图片压缩:PNG保留透明度,JPEG用于不透明图片

UI/UX改进:
- 优化表格布局:移除账号列,在名称下方显示账号绑定信息
- 调整行高和字体大小,提升信息密度
- 最后使用时间改为相对时间显示,悬浮显示具体时间
- 过期时间编辑改为点击文本触发,带悬浮下划线效果
- 更新默认API Key图标为蓝色渐变设计
- 修复表格悬浮偏移和横向滚动条问题
- 将"TOKEN 数量"改为"Token数"

后端支持:
- apiKeyService增加icon字段持久化
- admin路由增加图标数据处理和验证

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-07 22:18:34 +08:00
Edric Li
fc5c60a9b4 feat: 增强API Keys Excel导出功能和样式美化
- 添加输入/输出Token列到Excel导出
- 使用xlsx-js-style库实现专业的Excel样式
  - 彩色表头(蓝色/绿色区分)
  - 交替行背景色
  - 正确的列对齐(日期右对齐,名称左对齐)
  - 费用列特殊样式(蓝色加粗)
- 简化导出内容,仅包含用量数据
- Token数量使用K/M单位格式化
- 模型统计也包含输入/输出Token

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-07 22:18:34 +08:00
Edric Li
8f43b9367b feat: 优化Excel导出功能,专注用量数据统计
- 简化导出内容,仅包含用量相关数据
- 保留API Key名称和所有者信息
- 导出详细的分模型用量统计:
  * 今日各模型请求数、费用、输入/输出/总Token
  * 累计各模型请求数、费用、输入/输出/总Token
  * 根据时间筛选条件导出对应周期的模型统计
- 文件名包含时间筛选条件,便于识别数据范围
- 动态设置列宽,优化Excel显示效果
- 移除冗余的配置信息,专注核心用量数据

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-07 22:18:34 +08:00
Edric Li
8dd58900d6 feat: 优化API Keys表格显示和交互体验
- 添加可切换的复选框列,默认隐藏减少视觉干扰
- 复选框列隐藏时不占用宽度,优化表格布局
- 移除名称列的钥匙图标,使界面更简洁
- 数字列(请求数、费用、Token数量)右对齐,符合阅读习惯
- 新增Token数量列,支持K/M单位格式化显示
- 将使用统计拆分为独立的请求数和费用列
- 降低表格行高,提高信息密度
- 调整列顺序,将账号列移至名称列后
- 修复表格悬浮时出现额外横向滚动条的问题

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-07 22:18:34 +08:00
Edric Li
4e67e597b0 feat: API Keys页面添加全部时间选项和UI改进
- 添加"全部时间"选项到时间范围下拉菜单,可查看所有历史使用数据
- 统一费用显示列,根据选择的时间范围动态显示对应标签
- 支持自定义日期范围查询(最多31天)
- 优化日期选择器高度与其他控件对齐(38px)
- 使用更通用的标签名称(累计费用、总费用等)
- 移除调试console.log语句

后端改进:
- 添加自定义日期范围查询支持
- 日期范围验证和31天限制
- 支持all时间范围查询

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-07 22:18:34 +08:00
shaw
a9a560da67 Merge branch 'main' into dev 2025-09-07 20:41:41 +08:00
github-actions[bot]
b11305f4e9 chore: sync VERSION file with release v1.1.131 [skip ci] 2025-09-07 13:40:26 +08:00
Edric Li
ed02c8abec feat: 移除API Key账号绑定的专属类型限制
- 允许所有账号类型被API Key绑定,不再限制必须是dedicated类型
- 移除AccountSelector组件中的accountType === 'dedicated'过滤条件
- 保持原有专属账号的显示文本不变,确保界面一致性
- 维持原有调度策略:绑定账号后只使用该账号,不回退到共享池

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-07 13:40:26 +08:00
github-actions[bot]
9e01095249 chore: sync VERSION file with release v1.1.130 [skip ci] 2025-09-07 13:40:26 +08:00
shaw
e28080bb51 docs: codex cli配置优先使用apikey 2025-09-07 13:40:26 +08:00
github-actions[bot]
4104858bc0 chore: sync VERSION file with release v1.1.129 [skip ci] 2025-09-07 13:40:25 +08:00
github-actions[bot]
b4e7c760b2 chore: sync VERSION file with release v1.1.131 [skip ci] 2025-09-07 05:36:24 +00:00
Wesley Liddick
6efd95ed9d Merge pull request #360 from Edric-Li/feature/remove-account-type-restriction
feat: 移除API Key账号绑定的专属类型限制
2025-09-07 13:36:09 +08:00
Wesley Liddick
abe18211c0 Merge pull request #368 from YNZH/dev
authLogger timezone 适配
2025-09-07 13:34:57 +08:00
Wesley Liddick
33bb5d7895 Merge pull request #370 from sczheng189/dev
去除掉统一user-agent的冗余逻辑,增加流式处理日志打印
2025-09-07 13:34:34 +08:00
sczheng189
0cb58c099d 去除掉统一user-agent的冗余逻辑,增加流式处理日志打印 2025-09-07 08:41:11 +08:00
maplegao
6479db0b16 authLogger timezone 适配 2025-09-07 00:17:06 +08:00
sczheng189
9d1906c0b1 Merge branch 'dev' of https://github.com/Wei-Shaw/claude-relay-service into dev 2025-09-06 23:40:10 +08:00
github-actions[bot]
2c2f772071 chore: sync VERSION file with release v1.1.130 [skip ci] 2025-09-06 12:56:23 +00:00
shaw
c14abe5132 docs: codex cli配置优先使用apikey 2025-09-06 20:55:43 +08:00
github-actions[bot]
cd2ccef5a1 chore: sync VERSION file with release v1.1.130 [skip ci] 2025-09-06 11:10:46 +00:00
Edric Li
3e6edae198 Merge remote-tracking branch 'upstream/main' 2025-09-06 19:09:59 +08:00
github-actions[bot]
8b51c2ef64 chore: sync VERSION file with release v1.1.129 [skip ci] 2025-09-06 10:10:01 +00:00
shaw
d2f3f6866c feat: Codex账号管理优化与API Key激活机制
 新功能
- 支持通过refreshToken新增Codex账号,创建时立即验证token有效性
- API Key新增首次使用自动激活机制,支持activation模式设置有效期
- 前端账号表单增加token验证功能,确保账号创建成功

🐛 修复
- 修复Codex token刷新失败问题,增加分布式锁防止并发刷新
- 优化token刷新错误处理,提供更详细的错误信息和建议
- 修复OpenAI账号token过期检测和自动刷新逻辑

📝 文档更新
- 更新README中Codex使用说明,改为config.toml配置方式
- 优化Cherry Studio等第三方工具接入文档
- 添加详细的配置示例和账号类型说明

🎨 界面优化
- 改进账号创建表单UI,支持手动和OAuth两种模式
- 优化API Key过期时间编辑弹窗,支持激活操作
- 调整教程页面布局,提升移动端响应式体验

💡 代码改进
- 重构token刷新服务,增强错误处理和重试机制
- 优化代理配置处理,确保OAuth请求正确使用代理
- 改进webhook通知,增加token刷新失败告警
2025-09-06 18:04:06 +08:00
Wesley Liddick
0e746b1056 Merge pull request #359 from YNZH/dev
日志时间格式适配时区 ,README版本升级脚本 npm run service:restart:daemon 不生效fix
2025-09-06 17:46:33 +08:00
github-actions[bot]
723f13eb2b chore: sync VERSION file with release v1.1.129 [skip ci] 2025-09-06 08:10:25 +00:00
Edric Li
d7c80b69e8 feat: 移除API Key账号绑定的专属类型限制
- 允许所有账号类型被API Key绑定,不再限制必须是dedicated类型
- 移除AccountSelector组件中的accountType === 'dedicated'过滤条件
- 保持原有专属账号的显示文本不变,确保界面一致性
- 维持原有调度策略:绑定账号后只使用该账号,不回退到共享池

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-06 15:55:13 +08:00
maplegao
a71f0e58a2 修复README.md中 版本更新不work问题, 重启脚本中没有stop 2025-09-06 14:27:18 +08:00
maplegao
56c48a4304 日志格式适配市区 2025-09-06 14:22:33 +08:00
Wesley Liddick
2f6e5ab289 Merge pull request #357 from YNZH/dev
webHook通知时间适配时区
2025-09-06 08:28:50 +08:00
maplegao
96e505d662 eslint fix 2025-09-05 21:42:49 +08:00
maplegao
d4989f5401 format 2025-09-05 20:51:07 +08:00
Wesley Liddick
ed10fb06b2 Merge pull request #353 from sususu98/dev
feat(Claude Console): 添加Claude Console账号每日配额
2025-09-05 20:39:12 +08:00
maplegao
503f20b06b webhook时间可以指定时区 2025-09-05 17:54:06 +08:00
sususu
19cf38d92d fix(unifiedClaudeScheduler): Add error logging for quota check failures. 2025-09-05 17:01:40 +08:00
sususu
c16cfe60ab Merge branch 'dev' of https://github.com/sususu98/claude-relay-service into dev 2025-09-05 16:41:25 +08:00
sususu
4cc937a144 feat(Claude Console): 添加Claude Console账号每日配额
1. 额度检查优先级更高:即使不启用限流机制,超额仍会禁用账户
2. 状态会被覆盖:quota_exceeded 会覆盖 rate_limited
3. 两种恢复时间:
  - 限流恢复:分钟级(如60分钟)
  - 额度恢复:天级(第二天重置)
4. 独立控制:
  - rateLimitDuration = 0:只管理额度,忽略429
  - rateLimitDuration > 0:同时管理限流和额度
2025-09-05 14:58:59 +08:00
Wesley Liddick
7d20810179 Merge pull request #347 from iaineng/dev
fix: 修复会话粘性机制下Pro账户被错误调度用于Opus请求的问题
2025-09-05 13:54:17 +08:00
Wesley Liddick
4e8e630904 Merge pull request #349 from iaineng/fix/use-unified-user-agent-missing
fix: 添加创建Claude账户时缺失的useUnifiedUserAgent字段处理
2025-09-05 13:54:06 +08:00
iaineng
8c158d82fa fix: 添加创建Claude账户时缺失的useUnifiedUserAgent字段处理
- 在 /admin/claude-accounts POST 路由中添加 useUnifiedUserAgent 参数解构
- 将 useUnifiedUserAgent 参数传递给 claudeAccountService.createAccount() 方法
- 保持与前端 AccountForm.vue 和服务层 claudeAccountService.js 的一致性
2025-09-05 12:18:33 +08:00
iaineng
d8e833ef1a fix: 修复会话粘性机制下Pro账户被错误调度用于Opus请求的问题
- 在 _isAccountAvailable 方法中添加了模型兼容性检查,避免Pro账户被用于Opus请求
- 创建 _isModelSupportedByAccount 统一方法来处理模型兼容性验证
- 支持Claude OAuth账户的订阅类型检查(Pro/Free/Max)
- 支持Claude Console账户的supportedModels配置检查
2025-09-04 23:26:18 +08:00
Wesley Liddick
bdd17a85e9 Merge pull request #342 from sususu98/dev
feat(admin-spa): 在API视图中添加每日费用和总费用的排序,并默认按照每日费用排序
2025-09-04 14:17:32 +08:00
Wesley Liddick
192fd19632 Merge pull request #341 from sczheng189/fix-schedulable-check
Fix:修复重置状态错误以及5xx熔断状态清除
2025-09-04 14:17:22 +08:00
sususu
9d94475d3f feat(admin-spa): 在API视图中添加每日费用和总费用的排序,并默认按照每日费用排序 2025-09-04 14:15:21 +08:00
sczheng189
b2e7d686fe Fix:前端显示临时异常状态 2025-09-04 14:08:55 +08:00
sczheng189
ae727d381c fix:确保清楚了5xx错误导致的临时熔断状态,修复之前没有添加的5分钟定时器 2025-09-04 13:49:55 +08:00
sczheng189
4b0861eb7f fix:修复了重置状态只删除js对象而没有删除redis的问题 2025-09-04 13:09:55 +08:00
github-actions[bot]
861af192bf chore: sync VERSION file with release v1.1.128 [skip ci] 2025-09-04 03:06:11 +00:00
shaw
566f15768f Merge branch 'dev' 2025-09-04 11:05:20 +08:00
shaw
0cc8714c3c docs: 增加codex额外参数配置说明 2025-09-04 11:03:20 +08:00
Wesley Liddick
d6745dbe4a Merge pull request #335 from iaineng/dev
feat: 添加Claude账户403错误处理和封禁状态支持
2025-09-04 10:46:44 +08:00
Wesley Liddick
75ac51bb57 Merge pull request #337 from sczheng189/dev
优化Claude Code User-Agent识别逻辑,更适配多段版本号比较的代码
2025-09-04 10:46:09 +08:00
Wesley Liddick
6e353893d1 Merge pull request #338 from sususu98/dev
Extract session ID directly from metadata.user_id->session
2025-09-04 10:45:57 +08:00
shaw
5a29502fcd fix: 修复gemini转发 2025-09-04 10:45:07 +08:00
sususu
aa869521c0 refactor(sessionHelper): Extract session ID directly from metadata.user_id 2025-09-04 10:26:40 +08:00
sczheng189
8f08d7843f fix: 优化Claude Code User-Agent识别逻辑
- 将字符串匹配改为正则表达式匹配,提高准确性
  - 统一版本号提取正则,支持多段版本号格式
  - 修复isRealClaudeCodeRequest中的User-Agent验证逻辑"
2025-09-04 09:19:39 +08:00
iaineng
1ff14e38cb feat: 添加Claude账户403错误处理和封禁状态支持
- 新增Claude账户403错误自动检测和处理机制
- 区分Claude账户401未授权和403封禁两种错误状态
- 支持非流式和流式请求中的401/403错误处理
- 优化Claude账户错误处理代码,减少重复逻辑
- 支持前端显示不同的Claude账户错误状态和颜色
- 完善Claude账户异常Webhook通知错误码区分
2025-09-04 00:50:28 +08:00
sususu
86f92a774e feat: 增强API Key 导入处理,支持明文与哈希值自动识别以实现脚本批量导入apiKey 2025-09-03 21:40:45 +08:00
Wesley Liddick
5ed07f4407 Merge pull request #330 from sususu98/main [skip ci]
feat: 增强API Key 导入处理,支持明文与哈希值自动识别以实现脚本批量导入apiKey
2025-09-03 21:39:41 +08:00
Wesley Liddick
ac9107aa5f Merge pull request #334 from iaineng/dev
fix: 改进会话粘性机制,支持metadata.user_id并修复cache_control导致的会话切换问题
2025-09-03 21:36:03 +08:00
Wesley Liddick
5bed7c932b Merge pull request #333 from sczheng189/dev
feat: 添加统一Claude Code User-Agent支持及缓存管理功能(仅支持Claude账户,不支持api)
2025-09-03 21:35:51 +08:00
Wesley Liddick
9dd1b07e45 Merge pull request #329 from f3n9/dev-um-8
修复用户管理页中"All Roles"不显示用户的问题
2025-09-03 21:33:41 +08:00
iaineng
69795f2ed0 fix: 改进会话粘性机制,支持metadata.user_id并修复cache_control导致的会话切换问题
- 添加metadata.user_id作为最高优先级会话标识
- 修改messages中cache_control检测逻辑,使用第一条消息而非缓存断点内容
- 避免动态内容破坏会话粘性,提高会话保持的稳定性
2025-09-03 21:07:45 +08:00
sczheng189
39c49fe2bb feat: 添加统一Claude Code User-Agent支持及缓存管理功能
### **核心功能**
*   **自动更新**:自动获取并使用最新的 “Claude Code” 客户端版本号。
*   **智能缓存**:获取到的版本会缓存25小时,然后自动刷新。
*   **独立开关**:每个账户都可以单独设置是否启用此功能。

### **前端界面**
*   **新增开关**:账户设置里增加了“使用统一版本”的选项。
*   **信息显示**:能直接看到当前正在使用的版本号。
*   **手动刷新**:提供“清除缓存”按钮,可手动强制更新。

### **后端技术**
*   **核心方法**:开发了新的后台功能,用于捕获、比较和管理版本号。
*   **管理接口**:为管理员提供了新的API (`/admin/claude-code-version`),方便查询和刷新。
2025-09-03 20:14:58 +08:00
sususu98
7fa3ed239f Merge branch 'Wei-Shaw:main' into main 2025-09-03 17:55:24 +08:00
sususu
3fd9110ba7 feat: 增强API Key 导入处理,支持明文与哈希值自动识别以实现脚本批量导入apiKey 2025-09-03 17:53:45 +08:00
Feng Yue
26c57148f7 Merge remote-tracking branch 'f3n9/main' into main-um-8 2025-09-03 17:43:36 +08:00
github-actions[bot]
631931990b chore: sync VERSION file with release v1.1.127 [skip ci] 2025-09-03 09:30:14 +00:00
shaw
79097d5b40 Merge branch 'dev' 2025-09-03 17:29:41 +08:00
shaw
16d397125a feat: 支持apikey名称修改 2025-09-03 17:28:13 +08:00
Feng Yue
3e6b7c729f fix: prettier issue 2025-09-03 16:11:57 +08:00
Feng Yue
eba52a6e88 fix: improve frontend parameter handling for user role filtering
- Only send parameters with actual values to avoid undefined values in API calls
- Ensures 'All Roles' filter works correctly by not sending role parameter
- Works together with backend filtering fix from previous commit

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-03 16:10:26 +08:00
Feng Yue
8ab8cf4a7a fix: user role filtering issue 2025-09-03 16:03:43 +08:00
Feng Yue
6aeb05f685 Merge remote-tracking branch 'f3n9/main' into main-um-8 2025-09-03 15:40:28 +08:00
github-actions[bot]
ff99f5d123 chore: sync VERSION file with release v1.1.126 [skip ci] 2025-09-03 15:29:39 +08:00
Wesley Liddick
2c0ffd07d0 Merge pull request #322 from f3n9/dev-um-8
用户API Key管理相关优化
2025-09-03 15:24:00 +08:00
Feng Yue
54d1bc076c Merge branch 'main-um-8' into dev-um-8 2025-09-03 15:21:58 +08:00
Feng Yue
bec9cf565b feat: add Windows Active Directory LDAP authentication support 2025-09-03 15:15:13 +08:00
Feng Yue
f69333f312 Revert "add support of Windows AD Server"
This reverts commit a1005e91c8.
2025-09-03 15:03:14 +08:00
Feng Yue
0039569471 Revert "fix prettier issue"
This reverts commit 088cf8401f.
2025-09-03 15:02:38 +08:00
github-actions[bot]
a5361c15a1 chore: sync VERSION file with release v1.1.126 [skip ci] 2025-09-03 06:38:38 +00:00
shaw
d93a157380 Merge branch 'dev' 2025-09-03 14:38:03 +08:00
shaw
aeace0c5f0 fix: codex转发store默认false 2025-09-03 14:32:11 +08:00
Feng Yue
088cf8401f fix prettier issue 2025-09-03 13:35:47 +08:00
Feng Yue
a1005e91c8 add support of Windows AD Server 2025-09-03 13:30:13 +08:00
shaw
b158a90b72 fix: 修复API统计和OpenAI路由问题 2025-09-03 10:54:11 +08:00
shaw
941cfacea9 fix: 优化多key查询费用错误问题 2025-09-03 10:29:12 +08:00
Feng Yue
1fc35197e1 Merge remote-tracking branch 'f3n9/dev' into dev-um-8 2025-09-03 09:45:52 +08:00
shaw
2e6feeb1c1 fix: 优化多key查询卡片 2025-09-03 09:45:13 +08:00
shaw
da0ffa07ec fix: 修复apikeys聚合查询统计问题 2025-09-02 23:54:37 +08:00
shaw
886ec35edc feat: api-stats页面支持多key查询 2025-09-02 23:18:31 +08:00
Feng Yue
58fcf6962c Merge pull request #5 from f3n9/codex/fix-issue-in-userapikeysmanager.vue
align delete API key UI with server config
2025-09-02 22:31:19 +08:00
Feng Yue
b0990e7169 fix: respect user delete api key config 2025-09-02 22:26:32 +08:00
Feng Yue
3860f7d9b3 update default limit of apikey number per user to one and disallow key deletion by default 2025-09-02 21:42:48 +08:00
shaw
81ad098678 fix: 修复apikeys页面的一些bug 2025-09-02 21:38:54 +08:00
github-actions[bot]
59d7705697 chore: sync VERSION file with release v1.1.125 [skip ci] 2025-09-02 21:38:53 +08:00
Wesley Liddick
8b0d8088d1 Merge pull request #321 from sczheng189/feat/multi-group-scheduling
feat: 实现账户多分组调度功能
2025-09-02 21:36:15 +08:00
sczheng189
9c7ec8758d resolve: 解决与upstream/dev的合并冲突
- 合并admin.js中的groupIds和autoStopOnWarning参数
- 统一AccountForm.vue中的错误提示文案和平台判断逻辑
- 保留AccountsView.vue中的分组过滤和ungrouped功能
- 确保Azure OpenAI账户创建和更新逻辑完整性

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-02 20:32:42 +08:00
Feng Yue
d56da4d799 transfer existing api keys to users on the first login 2025-09-02 20:32:28 +08:00
sczheng189
945e0ac198 refactor: 精简Azure OpenAI多分组功能实现
- 移除不必要的分组清理逻辑
- 简化组成员端点实现,使用简单的members.push()
- 移除OpenAI账户路由中的groupInfos添加
- 保持最小化修改原则,只保留必要的功能实现

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-02 20:21:24 +08:00
sczheng189
37e6c14eac feat: 完善账户多分组功能和Azure OpenAI支持
主要功能:
- 实现账户多分组调度功能完整支持
- 修复Azure OpenAI账户优先级显示问题(前端条件判断缺失)
- 修复未分组筛选功能失效(API参数处理)
- 修复Azure OpenAI账户创建错误调用Gemini API的问题
- 完善各平台分组信息支持和使用统计显示
- 统一删除账户时的分组清理逻辑
- 添加前端请求参数处理支持

技术改进:
- 前端支持多平台账户请求构造
- 后端统一groupInfos返回格式
- API客户端完善查询参数处理

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-02 20:16:20 +08:00
Feng Yue
4627475b7c filter apikeys by either key name or username 2025-09-02 19:40:45 +08:00
Feng Yue
58958cf246 fix ownership update issue 2025-09-02 18:53:42 +08:00
Feng Yue
5ee98597e7 fix get userlist issue 2025-09-02 18:40:16 +08:00
Wesley Liddick
1165427df0 Merge pull request #315 from zephyrcicd/dev
feat: 支持自定义API前缀配置
2025-09-02 18:19:31 +08:00
Feng Yue
7a9e4abdd5 admin now is able to reassign apikey to admin/user 2025-09-02 17:17:06 +08:00
Feng Yue
e973158472 show owner's name in apikey management page 2025-09-02 16:16:43 +08:00
github-actions[bot]
9c3fec7568 chore: sync VERSION file with release v1.1.125 [skip ci] 2025-09-02 06:49:02 +00:00
shaw
558aa173fe Merge branch 'dev' 2025-09-02 14:48:27 +08:00
shaw
1a9746c84d feat: LDAP适配深色模式 2025-09-02 14:43:30 +08:00
Zephyr
aa04487c79 Merge branch 'Wei-Shaw:dev' into dev 2025-09-02 14:00:07 +08:00
Zephyr
3f570d5fc2 fix: 修复TutorialView.vue的代码格式问题
- 应用Prettier格式化规范
- 确保代码符合项目的格式要求
2025-09-02 13:59:15 +08:00
shaw
86c243e1a4 fix: 修复loading动画错误 2025-09-02 11:51:38 +08:00
github-actions[bot]
4e094c21b7 chore: sync VERSION file with release v1.1.124 [skip ci] 2025-09-02 11:51:38 +08:00
Wesley Liddick
b1ca898dff Merge pull request #317 from f3n9/um-5
修复账户管理页中Azure/OpenAI类型账户调度状态不准确的问题
2025-09-02 11:29:27 +08:00
Feng Yue
85196911ce Merge remote-tracking branch 'f3n9/codex/fix-comments-in-unifiedopenaischeduler.js' into um-5 2025-09-02 10:55:57 +08:00
Feng Yue
e00872f9db Merge pull request #1 from f3n9/codex/fix-account-management-scheduling-status
Fix schedulable flag for OpenAI and Azure accounts
2025-09-02 10:18:45 +08:00
Feng Yue
9f3fff1f27 fix: treat OpenAI account isActive as string 2025-09-02 10:13:27 +08:00
Feng Yue
23cb44f60f fix: handle boolean account flags in OpenAI scheduler 2025-09-02 10:06:59 +08:00
Feng Yue
60428921a1 Fix schedulable flag for OpenAI and Azure accounts 2025-09-02 09:58:05 +08:00
Wesley Liddick
5406b5790c Merge pull request #308 from f3n9/um-5
增加用户管理及Azure/Gemini相关改进
2025-09-02 09:33:38 +08:00
Wesley Liddick
d0eef7e98e Merge pull request #314 from sczheng189/feat/5xx-error-circuit-breaker
feat: 改进5xx错误熔断机制和重置状态功能
2025-09-02 09:32:08 +08:00
Zephyr
96cf49d3b7 feat: 支持自定义API前缀配置
- 添加 VITE_API_BASE_PREFIX 环境变量支持
- 教程页面优先使用自定义前缀,未配置时使用浏览器访问地址
- 更新 .env.example 添加配置说明
2025-09-01 22:50:17 +08:00
sczheng189
f2c2bdf6d6 feat: 改进5xx错误熔断机制和重置状态功能
## 熔断机制优化
- 将5xx错误阈值从3次提升到10次,减少误触发
- 缩短临时错误恢复时间从60分钟到5分钟
- 支持所有5xx状态码(500-599)的统一处理

## 重置状态功能完善
后端 resetAccountStatus 新增清除:
- tempErrorAt 字段 (temp_error状态)
- sessionWindowStart/sessionWindowEnd 字段
- 5xx_errors Redis计数键

前端优化:
- 重置成功后强制刷新 loadAccounts(true)
- 避免缓存导致的状态显示不一致

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-01 21:39:39 +08:00
Feng Yue
68603bc046 Merge branch 'dev' into um-5 2025-09-01 12:19:53 +08:00
github-actions[bot]
e2e621341c chore: sync VERSION file with release v1.1.124 [skip ci] 2025-09-01 03:45:04 +00:00
shaw
19eaed2f32 fix: 修复crs脚本偶尔重启失败的问题 2025-09-01 11:35:49 +08:00
shaw
5cfa3cc72f feat: 添加精确的账户费用计算和时区支持
- 实现基于模型使用量的精确每日费用计算
- 添加 dateHelper 工具支持时区转换
- 移除未使用的 webhook 配置代码
- 清理环境变量和配置文件中的 webhook 相关设置
- 优化前端费用显示,使用后端精确计算的数据
- 添加 DEBUG_HTTP_TRAFFIC 调试选项支持

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-01 11:29:16 +08:00
Feng Yue
c979be5aab fix lint/format issues 2025-08-31 23:31:38 +08:00
Feng Yue
e0c926c53d Merge remote-tracking branch 'f3n9/main' into um-5 2025-08-31 23:21:12 +08:00
Feng Yue
50b372473c Merge remote-tracking branch 'f3n9/main' into um-5 2025-08-31 23:12:46 +08:00
github-actions[bot]
246bdc928a chore: sync VERSION file with release v1.1.123 [skip ci] 2025-08-31 14:58:24 +00:00
Wesley Liddick
f77ab03d18 Merge pull request #306 from iaineng/main
fix: 修复Claude账户autoStopOnWarning字段无法更新的问题
2025-08-31 22:58:10 +08:00
iaineng
26438e0c9b fix: 修复Claude账户autoStopOnWarning字段无法更新的问题
在updateAccount方法的allowedUpdates数组中添加autoStopOnWarning字段,
解决通过管理后台API更新Claude账户时该字段被过滤掉的问题
2025-08-31 21:49:40 +08:00
github-actions[bot]
86f5a3e670 chore: sync VERSION file with release v1.1.122 [skip ci] 2025-08-31 12:19:06 +00:00
shaw
9a46310238 fix: 修复会话窗口使用统计问题 2025-08-31 20:14:12 +08:00
shaw
07e9bc1137 fix: 修复会话窗口使用统计问题 2025-08-31 19:04:12 +08:00
shaw
ef21c118e9 feat: 添加模型级别的小时统计数据
在 recordApiKeyUsage 方法中添加了模型级别的小时统计记录,
用于支持基于会话窗口的详细使用统计功能。

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-31 18:20:35 +08:00
shaw
e84c6a5555 feat: 实现基于费用的速率限制功能
- 新增 rateLimitCost 字段,支持按费用进行速率限制
- 新增 weeklyOpusCostLimit 字段,支持 Opus 模型周费用限制
- 优化速率限制逻辑,支持费用、请求数、token多维度控制
- 更新前端界面,添加费用限制配置选项
- 增强账户管理功能,支持费用统计和限制
- 改进 Redis 数据模型,支持费用计数器
- 优化价格计算服务,支持更精确的成本核算

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-31 17:27:37 +08:00
Feng Yue
0240a17c1e fix lint/format issues 2025-08-31 14:21:56 +08:00
Feng Yue
e4078e36ad fix npm building and code quality issue 2025-08-31 14:08:17 +08:00
Feng Yue
01274a6a96 Revert "add logs to fix azure request issue"
This reverts commit 2cf2574ebe.
2025-08-31 01:37:48 +08:00
Feng Yue
87c2f1dfe2 Revert "fix azure endpoint and api version issue"
This reverts commit 92f4fbcef3.
2025-08-31 01:37:18 +08:00
Feng Yue
7c4cbe6ed7 Revert "add debug log for headers and body of Azure OpenAI requests"
This reverts commit 70c8cb5aff.
2025-08-31 01:36:13 +08:00
Feng Yue
bf732b9525 Partially Revert "fix azure openai usage count issue"
This reverts commit dc3d311def.
2025-08-31 01:34:40 +08:00
Feng Yue
b00d0eb9e1 use proxy if configured in all Gemini API requests 2025-08-31 01:21:25 +08:00
Feng Yue
1762669de4 use proxy if configured in Gemini OAuth requests 2025-08-31 01:00:49 +08:00
Feng Yue
dc3d311def fix azure openai usage count issue 2025-08-30 20:45:01 +08:00
shaw
a54622e3d7 Revert "Merge pull request #292 from iRubbish/dev"
This reverts commit 9e8e74ce6b, reversing
changes made to 222f4e44fe.
2025-08-30 20:09:41 +08:00
shaw
3bc239e85c Merge branch 'main' into dev 2025-08-30 19:55:33 +08:00
Feng Yue
70c8cb5aff add debug log for headers and body of Azure OpenAI requests 2025-08-30 19:25:06 +08:00
Feng Yue
92f4fbcef3 fix azure endpoint and api version issue 2025-08-30 18:55:24 +08:00
Feng Yue
2cf2574ebe add logs to fix azure request issue 2025-08-30 18:46:46 +08:00
Feng Yue
06f7e3c28f fix azure account editing issue 2025-08-30 18:20:31 +08:00
Feng Yue
90574bc4e6 fix azure account editing issue 2025-08-30 18:16:11 +08:00
Feng Yue
d01bcdbaca fix azure account issue 2025-08-30 17:58:50 +08:00
Feng Yue
76ec2e6afb add new models to supported model list 2025-08-30 17:40:50 +08:00
Feng Yue
34629a9bb2 add support of gpt-5, gpt-5-mini and codex-mini models in Azure 2025-08-30 17:17:05 +08:00
Feng Yue
c638c8b82c Merge remote-tracking branch 'f3n9/main' into um-5 2025-08-30 15:39:33 +08:00
Wesley Liddick
3f1117e8f6 Merge pull request #293 from hging/main [skip ci]
feat: 增加Bark作为webhook渠道
2025-08-28 13:39:13 +08:00
github-actions[bot]
a608b267ae chore: sync VERSION file with release v1.1.121 [skip ci] 2025-08-28 08:43:49 +08:00
zjpyb
43cf7d3c28 fix: 修复Gemini v1beta非流式响应数据结构问题
🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-28 08:43:48 +08:00
zjpyb
7c3257764c fix: 修复Gemini v1beta流式响应中断问题
- 优化SSE流式响应处理逻辑,修复客户端接收第一条消息后断开连接的问题
- 统一流处理缓冲区,正确处理不完整的SSE行
- v1beta版本返回response字段内容,v1internal保持原始转发
- 移除调试日志输出,提升生产环境稳定性

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-28 08:43:48 +08:00
zjpyb
7ce55c006e fix: Gemini原生接口没获取到modelName #295 2025-08-28 08:43:48 +08:00
github-actions[bot]
71b3374761 chore: sync VERSION file with release v1.1.121 [skip ci] 2025-08-28 00:41:58 +00:00
Wesley Liddick
1726e6d3f3 Merge pull request #296 from zjpyb/main
fix: Gemini原生接口没获取到modelName #295
2025-08-28 08:41:48 +08:00
zjpyb
79c7d1d116 fix: 修复Gemini v1beta非流式响应数据结构问题
🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-28 03:04:52 +08:00
zjpyb
fb57cfd293 fix: 修复Gemini v1beta流式响应中断问题
- 优化SSE流式响应处理逻辑,修复客户端接收第一条消息后断开连接的问题
- 统一流处理缓冲区,正确处理不完整的SSE行
- v1beta版本返回response字段内容,v1internal保持原始转发
- 移除调试日志输出,提升生产环境稳定性

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-28 02:38:01 +08:00
zjpyb
a7009e6864 fix: Gemini原生接口没获取到modelName #295 2025-08-28 00:03:34 +08:00
Hg
fcc8387c24 feat: 增加Bark作为webhook渠道 2025-08-26 17:40:02 +08:00
Feng Yue
d5f5e0f4dd Merge branch 'main' into um-5 2025-08-25 17:19:24 +08:00
Feng Yue
b0ad541f5d Merge remote-tracking branch 'f3n9/main' into azure-openai 2025-08-20 18:59:15 +08:00
Feng Yue
77338276db Merge remote-tracking branch 'f3n9/main' into user-management-new 2025-08-18 15:32:17 +08:00
Feng Yue
7a0acbdfdc security: fix LDAP injection vulnerability in username parameter
- Add strict username validation to prevent LDAP injection attacks
- Only allow alphanumeric characters, underscores, and hyphens in usernames
- Implement length limits and format validation for usernames
- Replace direct string interpolation with validated input in LDAP filters
- Update all logging to use sanitized username consistently
- Fix ESLint warnings for code style compliance

This prevents injection attacks like: *)(|(uid=admin that could bypass
authentication or allow user enumeration through malicious LDAP filters.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-15 18:57:42 +08:00
Feng Yue
71ce1e33b7 fix: API key limit now only counts active keys and uses config value
- Modified API key limit to count only active (non-deleted) keys instead of all keys
- Fixed frontend to use MAX_API_KEYS_PER_USER environment variable instead of hardcoded value
- Added activeApiKeysCount computed property to filter deleted keys
- Updated user profile endpoint to include maxApiKeysPerUser config
- Enhanced user store to persist and retrieve config values

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-15 13:36:05 +08:00
Feng Yue
94eed70cf2 fix: disable user API keys when user account is disabled
Security enhancement to prevent disabled users from using API keys:

- Auto-disable all API keys when user is disabled/deleted
- Add user status validation during API key authentication
- Prevent API usage even if key is active but user is disabled
- Add comprehensive logging for security audit trail

This ensures disabled users cannot bypass restrictions through
existing API keys and maintains system security integrity.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-14 16:25:42 +08:00
Feng Yue
6b4ce99237 fix: usage stats issue 2025-08-14 16:16:27 +08:00
Feng Yue
283583d289 fix: prettier errors 2025-08-14 16:04:00 +08:00
Feng Yue
c80446ae98 fix: include deletion metadata in user API keys response
- Add isDeleted, deletedAt, deletedBy, deletedByType fields to getUserApiKeys service method
- Include deletion fields in user routes API keys response
- Add debug logging to dashboard component to troubleshoot deleted keys count
- Ensure frontend can properly identify and count deleted API keys

This fixes the issue where deleted API keys count was always showing 0
instead of the actual number of deleted keys.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-14 16:02:11 +08:00
Feng Yue
65620a4cde feat: separate active and deleted API keys display in user dashboard
- Replace single "API Keys" counter with separate "Active API Keys" and "Deleted API Keys"
- Add loadApiKeysStats function to count active vs deleted keys separately
- Update grid layout from lg:grid-cols-4 to lg:grid-cols-5 to accommodate new card
- Add green icon for active keys and trash icon for deleted keys
- Refresh API keys stats when switching to overview tab
- Change default tab to 'overview' for better UX

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-14 15:46:54 +08:00
Feng Yue
a7c6445f36 fix: improve user API keys display and interaction
- Hide delete button for deleted/disabled keys to prevent invalid actions
- Sort API keys by creation time descending (newest first)
- Change "Disabled" label to "Deleted" for consistency
- Add sortedApiKeys computed property for better organization

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-14 15:35:07 +08:00
Feng Yue
4509f303e6 feat: enhance user API keys view and fix admin cost display
- Add deleted API keys display to user's My API Keys view
- Show deleted status with gray indicator and "Deleted" badge
- Display deletion date and hide delete button for deleted keys
- Fix cost calculation in admin deleted API keys tab
- Add getCostStats call to properly populate cost data
- Support includeDeleted parameter in user API keys endpoint

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-14 15:25:22 +08:00
Feng Yue
aff9966ed1 feat: management of deleted keys 2025-08-14 12:42:39 +08:00
Feng Yue
5d850a7c1c chore: remove regenerate api key functionality 2025-08-14 11:59:42 +08:00
Feng Yue
70e87de639 fix: user stats in admin panel again 2025-08-14 11:38:57 +08:00
Feng Yue
9efe429912 fix: user stats in admin panel 2025-08-14 11:38:51 +08:00
Feng Yue
8ea150a975 feat: enhance user API key management and implement soft delete
- Redirect users to API Keys tab after login instead of overview
- Remove Token Limit and Daily Cost Limit from user API key details modal
- Implement soft delete for API keys to preserve usage statistics
- Add admin endpoint to view deleted API keys with metadata
- Track deletion metadata (deletedBy, deletedAt, deletedByType)
- Ensure deleted API keys cannot be restored
- Include deleted key stats in user totals while excluding from active count

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-14 11:38:46 +08:00
Feng Yue
c413fddec0 fix: user stats again x4 2025-08-14 11:38:41 +08:00
Feng Yue
1ba55401f9 fix: user stats again again again 2025-08-14 11:38:36 +08:00
Feng Yue
983cc520ae fix: user stats again again 2025-08-14 11:38:32 +08:00
Feng Yue
02a801c290 fix: user stats again 2025-08-14 11:38:27 +08:00
Feng Yue
2756671117 fix: user stats 2025-08-14 11:38:23 +08:00
Feng Yue
a3c9e39401 chore: remove specific mirror/proxy settings from Dockerfile 2025-08-14 11:38:17 +08:00
Feng Yue
9a46ac3928 chore: improve apikey interface 2025-08-14 11:38:13 +08:00
Feng Yue
bb60df8b41 chore: redirect back to login page for deactivated users 2025-08-14 11:38:08 +08:00
Feng Yue
aa86e062f1 fix: user apiKey creation issue 2025-08-14 11:38:04 +08:00
Feng Yue
4a1423615f chore: add debug log for LDAP auth 2025-08-14 11:37:59 +08:00
Feng Yue
d8af7959e2 fix: LDAP authentication string validation error
Add comprehensive input validation for LDAP authentication:
- Validate bindDN, bindCredentials, userDN, and password parameters
- Add configuration validation during service initialization
- Enhanced error messages for missing/invalid LDAP settings
- Prevent "stringToWrite must be a string" errors from ldapjs client
- Added null/undefined checks for all LDAP credential parameters

This fixes authentication errors when LDAP configuration values
are missing, empty, or of incorrect type.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-14 11:37:45 +08:00
Feng Yue
1f3fd9c285 chore: support LDAPS 2025-08-14 11:37:38 +08:00
Feng Yue
39c6e3146c fix: redis issue in user management 2025-08-14 11:37:32 +08:00
Feng Yue
1ad720304c fix: 401 errors on user management page 2025-08-14 11:37:21 +08:00
Feng Yue
2a0be1b187 chore: add user login button 2025-08-14 11:37:13 +08:00
Feng Yue
8ab4ad32fe chore: use mirror/proxy to speed up docker image building 2025-08-14 11:37:07 +08:00
Feng Yue
56e4630827 fix: lint errors again 2025-08-14 11:37:01 +08:00
Feng Yue
f193db926d fix: lint errors 2025-08-14 11:36:54 +08:00
Feng Yue
eb150b4937 feat: 实现完整用户管理系统和LDAP认证集成
- 新增LDAP认证服务支持用户登录验证
- 实现用户服务包含会话管理和权限控制
- 添加用户专用路由和API端点
- 扩展认证中间件支持用户和管理员双重身份
- 新增用户仪表板、API密钥管理和使用统计界面
- 完善前端用户管理组件和路由配置
- 支持用户自助API密钥创建和管理
- 添加管理员用户管理功能包含角色权限控制

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-14 11:36:45 +08:00
106 changed files with 29189 additions and 4315 deletions

View File

@@ -22,17 +22,30 @@ REDIS_PASSWORD=
REDIS_DB=0
REDIS_ENABLE_TLS=
# 🔗 会话管理配置
# 粘性会话TTL配置小时默认1小时
STICKY_SESSION_TTL_HOURS=1
# 续期阈值分钟默认0分钟不续期
STICKY_SESSION_RENEWAL_THRESHOLD_MINUTES=15
# 🎯 Claude API 配置
CLAUDE_API_URL=https://api.anthropic.com/v1/messages
CLAUDE_API_VERSION=2023-06-01
CLAUDE_BETA_HEADER=claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14
# 🚫 529错误处理配置
# 启用529错误处理0表示禁用>0表示过载状态持续时间分钟
CLAUDE_OVERLOAD_HANDLING_MINUTES=0
# 🌐 代理配置
DEFAULT_PROXY_TIMEOUT=60000
DEFAULT_PROXY_TIMEOUT=600000
MAX_PROXY_RETRIES=3
# IP协议族配置true=IPv4, false=IPv6, 默认IPv4兼容性更好
PROXY_USE_IPV4=true
# ⏱️ 请求超时配置
REQUEST_TIMEOUT=600000 # 请求超时设置毫秒默认10分钟
# 📈 使用限制
DEFAULT_TOKEN_LIMIT=1000000
@@ -55,14 +68,46 @@ WEB_LOGO_URL=/assets/logo.png
# 🛠️ 开发配置
DEBUG=false
DEBUG_HTTP_TRAFFIC=false # 启用HTTP请求/响应调试日志(仅开发环境)
ENABLE_CORS=true
TRUST_PROXY=true
# 🔒 客户端限制(可选)
# ALLOW_CUSTOM_CLIENTS=false
# 📢 Webhook 通知配置
WEBHOOK_ENABLED=true
WEBHOOK_URLS=https://your-webhook-url.com/notify,https://backup-webhook.com/notify
WEBHOOK_TIMEOUT=10000
WEBHOOK_RETRIES=3
# 🔐 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=1
ALLOW_USER_DELETE_API_KEYS=false

4
.gitignore vendored
View File

@@ -216,6 +216,10 @@ local/
debug.log
error.log
access.log
http-debug*.log
logs/http-debug-*.log
src/middleware/debugInterceptor.js
# Session files
sessions/

160
README.md
View File

@@ -250,11 +250,6 @@ REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_PASSWORD=
# Webhook通知配置可选
WEBHOOK_ENABLED=true
WEBHOOK_URLS=https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=your-key
WEBHOOK_TIMEOUT=10000
WEBHOOK_RETRIES=3
```
**编辑 `config/config.js` 文件:**
@@ -479,103 +474,102 @@ claude
gemini # 或其他 Gemini CLI 命令
```
**Codex 设置环境变量**
**Codex 配置**
```bash
export OPENAI_BASE_URL="http://127.0.0.1:3000/openai" # 根据实际填写你服务器的ip地址或者域名
export OPENAI_API_KEY="后台创建的API密钥" # 使用后台创建的API密钥
在 `~/.codex/config.toml` 文件中添加以下配置:
```toml
model_provider = "crs"
model = "gpt-5"
model_reasoning_effort = "high"
disable_response_storage = true
preferred_auth_method = "apikey"
[model_providers.crs]
name = "crs"
base_url = "http://127.0.0.1:3000/openai" # 根据实际填写你服务器的ip地址或者域名
wire_api = "responses"
```
在 `~/.codex/auth.json` 文件中配置API密钥
```json
{
"OPENAI_API_KEY": "你的后台创建的API密钥"
}
```
### 5. 第三方工具API接入
本服务支持多种API端点格式方便接入不同的第三方工具如Cherry Studio等
本服务支持多种API端点格式方便接入不同的第三方工具如Cherry Studio等
**Claude标准格式**
#### Cherry Studio 接入示例
Cherry Studio支持多种AI服务的接入下面是不同账号类型的详细配置
**1. Claude账号接入**
```
# 如果工具支持Claude标准格式请使用该接口
# API地址
http://你的服务器:3000/claude/
# 模型ID示例
claude-sonnet-4-20250514 # Claude Sonnet 4
claude-opus-4-20250514 # Claude Opus 4
```
**OpenAI兼容格式**
配置步骤:
- 供应商类型选择"Anthropic"
- API地址填入`http://你的服务器:3000/claude/`
- API Key填入后台创建的API密钥cr_开头
**2. Gemini账号接入**
```
# 适用于需要OpenAI格式的第三方工具
http://你的服务器:3000/openai/claude/v1/
# API地址
http://你的服务器:3000/gemini/
# 模型ID示例
gemini-2.5-pro # Gemini 2.5 Pro
```
**接入示例:**
配置步骤:
- 供应商类型选择"Gemini"
- API地址填入`http://你的服务器:3000/gemini/`
- API Key填入后台创建的API密钥cr_开头
- **Cherry Studio**: 使用OpenAI格式 `http://你的服务器:3000/openai/claude/v1/` 使用Codex cli API `http://你的服务器:3000/openai/responses`
- **其他支持自定义API的工具**: 根据工具要求选择合适的格式
**3. Codex接入**
```
# API地址
http://你的服务器:3000/openai/
# 模型ID固定
gpt-5 # Codex使用固定模型ID
```
配置步骤:
- 供应商类型选择"Openai-Response"
- API地址填入`http://你的服务器:3000/openai/`
- API Key填入后台创建的API密钥cr_开头
- **重要**Codex只支持Openai-Response标准
#### 其他第三方工具接入
**接入要点:**
- 所有账号类型都使用相同的API密钥在后台统一创建
- 根据不同的路由前缀自动识别账号类型
- `/claude/` - 使用Claude账号池
- `/gemini/` - 使用Gemini账号池
- `/openai/` - 使用Codex账号只支持Openai-Response格式
- 支持所有标准API端点messages、models等
**重要说明:**
- 所有格式都支持相同的功能,仅是路径不同
- `/api/v1/messages` = `/claude/v1/messages` = `/openai/claude/v1/messages`
- 选择适合你使用工具的格式即可
- 支持所有Claude API端点messages、models等
---
## 📢 Webhook 通知功能
### 功能说明
当系统检测到账号异常时,会自动发送 webhook 通知支持企业微信、钉钉、Slack 等平台。
### 通知触发场景
- **Claude OAuth 账户**: token 过期或未授权时
- **Claude Console 账户**: 系统检测到账户被封锁时
- **Gemini 账户**: token 刷新失败时
- **手动禁用账户**: 管理员手动禁用账户时
### 配置方法
**1. 环境变量配置**
```bash
# 启用 webhook 通知
WEBHOOK_ENABLED=true
# 企业微信 webhook 地址(替换为你的实际地址)
WEBHOOK_URLS=https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=your-key
# 多个地址用逗号分隔
WEBHOOK_URLS=https://webhook1.com,https://webhook2.com
# 请求超时时间毫秒默认10秒
WEBHOOK_TIMEOUT=10000
# 重试次数默认3次
WEBHOOK_RETRIES=3
```
**2. 企业微信设置**
1. 在企业微信群中添加「群机器人」
2. 获取 webhook 地址:`https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx`
3. 将地址配置到 `WEBHOOK_URLS` 环境变量
### 通知内容格式
系统会发送结构化的通知消息:
```
账户名称 账号异常,异常代码 ERROR_CODE
平台claude-oauth
时间2025-08-14 17:30:00
原因Token expired
```
### 测试 Webhook
可以通过管理后台测试 webhook 连通性:
1. 登录管理后台:`http://你的服务器:3000/web`
2. 访问:`/admin/webhook/test`
3. 发送测试通知确认配置正确
- 确保在后台已添加对应类型的账号Claude/Gemini/Codex
- API密钥可以通用系统会根据路由自动选择账号类型
- 建议为不同用户创建不同的API密钥便于使用统计
---

View File

@@ -1 +1 @@
1.1.120
1.1.137

View File

@@ -32,13 +32,28 @@ const config = {
enableTLS: process.env.REDIS_ENABLE_TLS === 'true'
},
// 🔗 会话管理配置
session: {
// 粘性会话TTL配置小时默认1小时
stickyTtlHours: parseFloat(process.env.STICKY_SESSION_TTL_HOURS) || 1,
// 续期阈值分钟默认0分钟不续期
renewalThresholdMinutes: parseInt(process.env.STICKY_SESSION_RENEWAL_THRESHOLD_MINUTES) || 0
},
// 🎯 Claude API配置
claude: {
apiUrl: process.env.CLAUDE_API_URL || 'https://api.anthropic.com/v1/messages',
apiVersion: process.env.CLAUDE_API_VERSION || '2023-06-01',
betaHeader:
process.env.CLAUDE_BETA_HEADER ||
'claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14'
'claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14',
overloadHandling: {
enabled: (() => {
const minutes = parseInt(process.env.CLAUDE_OVERLOAD_HANDLING_MINUTES) || 0
// 验证配置值限制在0-1440分钟(24小时)内
return Math.max(0, Math.min(minutes, 1440))
})()
}
},
// ☁️ Bedrock API配置
@@ -56,12 +71,15 @@ const config = {
// 🌐 代理配置
proxy: {
timeout: parseInt(process.env.DEFAULT_PROXY_TIMEOUT) || 30000,
timeout: parseInt(process.env.DEFAULT_PROXY_TIMEOUT) || 600000, // 10分钟
maxRetries: parseInt(process.env.MAX_PROXY_RETRIES) || 3,
// IP协议族配置true=IPv4, false=IPv6, 默认IPv4兼容性更好
useIPv4: process.env.PROXY_USE_IPV4 !== 'false' // 默认 true只有明确设置为 'false' 才使用 IPv6
},
// ⏱️ 请求超时配置
requestTimeout: parseInt(process.env.REQUEST_TIMEOUT) || 600000, // 默认 10 分钟
// 📈 使用限制
limits: {
defaultTokenLimit: parseInt(process.env.DEFAULT_TOKEN_LIMIT) || 1000000
@@ -127,6 +145,58 @@ 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) || 1,
allowUserDeleteApiKeys: process.env.ALLOW_USER_DELETE_API_KEYS === 'true' // 默认不允许用户删除自己的API Keys
},
// 📢 Webhook通知配置
webhook: {
enabled: process.env.WEBHOOK_ENABLED !== 'false', // 默认启用

1923
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -63,7 +63,9 @@
"https-proxy-agent": "^7.0.2",
"inquirer": "^8.2.6",
"ioredis": "^5.3.2",
"ldapjs": "^3.0.7",
"morgan": "^1.10.0",
"nodemailer": "^7.0.6",
"ora": "^5.4.1",
"rate-limiter-flexible": "^5.0.5",
"socks-proxy-agent": "^8.0.2",

View File

@@ -86,6 +86,33 @@ function decryptGeminiData(encryptedData) {
}
}
// API Key 哈希函数与apiKeyService保持一致
function hashApiKey(apiKey) {
if (!apiKey || !config.security.encryptionKey) {
return apiKey
}
return crypto
.createHash('sha256')
.update(apiKey + config.security.encryptionKey)
.digest('hex')
}
// 检查是否为明文API Key通过格式判断不依赖前缀
function isPlaintextApiKey(apiKey) {
if (!apiKey || typeof apiKey !== 'string') {
return false
}
// SHA256哈希值固定为64个十六进制字符如果是哈希值则返回false
if (apiKey.length === 64 && /^[a-f0-9]+$/i.test(apiKey)) {
return false // 已经是哈希值
}
// 其他情况都认为是明文API Key包括sk-ant-、cr_、自定义前缀等
return true
}
// 数据加密函数(用于导入)
function encryptClaudeData(data) {
if (!data || !config.security.encryptionKey) {
@@ -651,6 +678,13 @@ Important Notes:
- If importing decrypted data, it will be re-encrypted automatically
- If importing encrypted data, it will be stored as-is
- Sanitized exports cannot be properly imported (missing sensitive data)
- Automatic handling of plaintext API Keys
* Uses your configured API_KEY_PREFIX from config (sk-, cr_, etc.)
* Automatically detects plaintext vs hashed API Keys by format
* Plaintext API Keys are automatically hashed during import
* Hash mappings are created correctly for plaintext keys
* Supports custom prefixes and legacy format detection
* No manual conversion needed - just import your backup file
Examples:
# Export all data with decryption (for migration)
@@ -659,7 +693,7 @@ Examples:
# Export without decrypting (for backup)
node scripts/data-transfer-enhanced.js export --decrypt=false
# Import data (auto-handles encryption)
# Import data (auto-handles encryption and plaintext API keys)
node scripts/data-transfer-enhanced.js import --input=backup.json
# Import with force overwrite
@@ -773,6 +807,26 @@ async function importData() {
const apiKeyData = { ...apiKey }
delete apiKeyData.usageStats
// 检查并处理API Key哈希
let plainTextApiKey = null
let hashedApiKey = null
if (apiKeyData.apiKey && isPlaintextApiKey(apiKeyData.apiKey)) {
// 如果是明文API Key保存明文并计算哈希
plainTextApiKey = apiKeyData.apiKey
hashedApiKey = hashApiKey(plainTextApiKey)
logger.info(`🔐 Detected plaintext API Key for: ${apiKey.name} (${apiKey.id})`)
} else if (apiKeyData.apiKey) {
// 如果已经是哈希值,直接使用
hashedApiKey = apiKeyData.apiKey
logger.info(`🔍 Using existing hashed API Key for: ${apiKey.name} (${apiKey.id})`)
}
// API Key字段始终存储哈希值
if (hashedApiKey) {
apiKeyData.apiKey = hashedApiKey
}
// 使用 hset 存储到哈希表
const pipeline = redis.client.pipeline()
for (const [field, value] of Object.entries(apiKeyData)) {
@@ -780,9 +834,12 @@ async function importData() {
}
await pipeline.exec()
// 更新哈希映射
if (apiKey.apiKey && !importDataObj.metadata.sanitized) {
await redis.client.hset('apikey:hash_map', apiKey.apiKey, apiKey.id)
// 更新哈希映射hash_map的key必须是哈希值
if (!importDataObj.metadata.sanitized && hashedApiKey) {
await redis.client.hset('apikey:hash_map', hashedApiKey, apiKey.id)
logger.info(
`📝 Updated hash mapping: ${hashedApiKey.substring(0, 8)}... -> ${apiKey.id}`
)
}
// 导入使用统计数据

View File

@@ -185,7 +185,7 @@ class ServiceManager {
restart(daemon = false) {
console.log('🔄 重启服务...')
this.stop()
// 等待停止完成
setTimeout(() => {
this.start(daemon)

View File

@@ -937,15 +937,61 @@ stop_service() {
# 强制停止所有相关进程
pkill -f "node.*src/app.js" 2>/dev/null || true
# 等待进程完全退出最多等待10秒
local wait_count=0
while pgrep -f "node.*src/app.js" > /dev/null; do
if [ $wait_count -ge 10 ]; then
print_warning "进程停止超时,尝试强制终止..."
pkill -9 -f "node.*src/app.js" 2>/dev/null || true
sleep 1
break
fi
sleep 1
wait_count=$((wait_count + 1))
done
# 最终确认进程已停止
if pgrep -f "node.*src/app.js" > /dev/null; then
print_error "无法完全停止服务进程"
return 1
fi
print_success "服务已停止"
}
# 重启服务
restart_service() {
print_info "重启服务..."
stop_service
sleep 2
start_service
# 停止服务并检查结果
if ! stop_service; then
print_error "停止服务失败"
return 1
fi
# 短暂等待,确保端口释放
sleep 1
# 启动服务,如果失败则重试
local retry_count=0
while [ $retry_count -lt 3 ]; do
# 清除可能的僵尸进程检测
if ! pgrep -f "node.*src/app.js" > /dev/null; then
# 进程确实已停止,可以启动
if start_service; then
return 0
fi
fi
retry_count=$((retry_count + 1))
if [ $retry_count -lt 3 ]; then
print_warning "启动失败等待2秒后重试$retry_count 次)..."
sleep 2
fi
done
print_error "重启服务失败"
return 1
}
# 更新模型价格

View File

@@ -1,379 +0,0 @@
/**
* 多分组功能测试脚本
* 测试一个账户可以属于多个分组的功能
*/
require('dotenv').config()
const redis = require('../src/models/redis')
const accountGroupService = require('../src/services/accountGroupService')
const claudeAccountService = require('../src/services/claudeAccountService')
// 测试配置
const TEST_PREFIX = 'multi_group_test_'
const CLEANUP_ON_FINISH = true
// 测试数据存储
const testData = {
groups: [],
accounts: []
}
// 颜色输出
const colors = {
green: '\x1b[32m',
red: '\x1b[31m',
yellow: '\x1b[33m',
blue: '\x1b[34m',
reset: '\x1b[0m'
}
function log(message, type = 'info') {
const color =
{
success: colors.green,
error: colors.red,
warning: colors.yellow,
info: colors.blue
}[type] || colors.reset
console.log(`${color}${message}${colors.reset}`)
}
async function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms))
}
// 清理测试数据
async function cleanup() {
log('\n🧹 清理测试数据...', 'info')
// 删除测试账户
for (const account of testData.accounts) {
try {
await claudeAccountService.deleteAccount(account.id)
log(`✅ 删除测试账户: ${account.name}`, 'success')
} catch (error) {
log(`❌ 删除账户失败: ${error.message}`, 'error')
}
}
// 删除测试分组
for (const group of testData.groups) {
try {
// 先移除所有成员
const members = await accountGroupService.getGroupMembers(group.id)
for (const memberId of members) {
await accountGroupService.removeAccountFromGroup(memberId, group.id)
}
await accountGroupService.deleteGroup(group.id)
log(`✅ 删除测试分组: ${group.name}`, 'success')
} catch (error) {
log(`❌ 删除分组失败: ${error.message}`, 'error')
}
}
}
// 测试1: 创建测试数据
async function test1_createTestData() {
log('\n📝 测试1: 创建测试数据', 'info')
try {
// 创建3个测试分组
const group1 = await accountGroupService.createGroup({
name: `${TEST_PREFIX}高优先级组`,
platform: 'claude',
description: '高优先级账户分组'
})
testData.groups.push(group1)
log(`✅ 创建分组1: ${group1.name}`, 'success')
const group2 = await accountGroupService.createGroup({
name: `${TEST_PREFIX}备用组`,
platform: 'claude',
description: '备用账户分组'
})
testData.groups.push(group2)
log(`✅ 创建分组2: ${group2.name}`, 'success')
const group3 = await accountGroupService.createGroup({
name: `${TEST_PREFIX}专用组`,
platform: 'claude',
description: '专用账户分组'
})
testData.groups.push(group3)
log(`✅ 创建分组3: ${group3.name}`, 'success')
// 创建测试账户
const account1 = await claudeAccountService.createAccount({
name: `${TEST_PREFIX}测试账户1`,
email: 'test1@example.com',
refreshToken: 'test_refresh_token_1',
accountType: 'group'
})
testData.accounts.push(account1)
log(`✅ 创建测试账户1: ${account1.name}`, 'success')
const account2 = await claudeAccountService.createAccount({
name: `${TEST_PREFIX}测试账户2`,
email: 'test2@example.com',
refreshToken: 'test_refresh_token_2',
accountType: 'group'
})
testData.accounts.push(account2)
log(`✅ 创建测试账户2: ${account2.name}`, 'success')
log(`✅ 测试数据创建完成: 3个分组, 2个账户`, 'success')
} catch (error) {
log(`❌ 测试1失败: ${error.message}`, 'error')
throw error
}
}
// 测试2: 账户加入多个分组
async function test2_addAccountToMultipleGroups() {
log('\n📝 测试2: 账户加入多个分组', 'info')
try {
const [group1, group2, group3] = testData.groups
const [account1, account2] = testData.accounts
// 账户1加入分组1和分组2
await accountGroupService.addAccountToGroup(account1.id, group1.id, 'claude')
log(`✅ 账户1加入分组1: ${group1.name}`, 'success')
await accountGroupService.addAccountToGroup(account1.id, group2.id, 'claude')
log(`✅ 账户1加入分组2: ${group2.name}`, 'success')
// 账户2加入分组2和分组3
await accountGroupService.addAccountToGroup(account2.id, group2.id, 'claude')
log(`✅ 账户2加入分组2: ${group2.name}`, 'success')
await accountGroupService.addAccountToGroup(account2.id, group3.id, 'claude')
log(`✅ 账户2加入分组3: ${group3.name}`, 'success')
log(`✅ 多分组关系建立完成`, 'success')
} catch (error) {
log(`❌ 测试2失败: ${error.message}`, 'error')
throw error
}
}
// 测试3: 验证多分组关系
async function test3_verifyMultiGroupRelationships() {
log('\n📝 测试3: 验证多分组关系', 'info')
try {
const [group1, group2, group3] = testData.groups
const [account1, account2] = testData.accounts
// 验证账户1的分组关系
const account1Groups = await accountGroupService.getAccountGroup(account1.id)
log(`📊 账户1所属分组数量: ${account1Groups.length}`, 'info')
const account1GroupNames = account1Groups.map((g) => g.name).sort()
const expectedAccount1Groups = [group1.name, group2.name].sort()
if (JSON.stringify(account1GroupNames) === JSON.stringify(expectedAccount1Groups)) {
log(`✅ 账户1分组关系正确: [${account1GroupNames.join(', ')}]`, 'success')
} else {
throw new Error(
`账户1分组关系错误期望: [${expectedAccount1Groups.join(', ')}], 实际: [${account1GroupNames.join(', ')}]`
)
}
// 验证账户2的分组关系
const account2Groups = await accountGroupService.getAccountGroup(account2.id)
log(`📊 账户2所属分组数量: ${account2Groups.length}`, 'info')
const account2GroupNames = account2Groups.map((g) => g.name).sort()
const expectedAccount2Groups = [group2.name, group3.name].sort()
if (JSON.stringify(account2GroupNames) === JSON.stringify(expectedAccount2Groups)) {
log(`✅ 账户2分组关系正确: [${account2GroupNames.join(', ')}]`, 'success')
} else {
throw new Error(
`账户2分组关系错误期望: [${expectedAccount2Groups.join(', ')}], 实际: [${account2GroupNames.join(', ')}]`
)
}
log(`✅ 多分组关系验证通过`, 'success')
} catch (error) {
log(`❌ 测试3失败: ${error.message}`, 'error')
throw error
}
}
// 测试4: 验证分组成员关系
async function test4_verifyGroupMemberships() {
log('\n📝 测试4: 验证分组成员关系', 'info')
try {
const [group1, group2, group3] = testData.groups
const [account1, account2] = testData.accounts
// 验证分组1的成员
const group1Members = await accountGroupService.getGroupMembers(group1.id)
if (group1Members.includes(account1.id) && group1Members.length === 1) {
log(`✅ 分组1成员正确: [${account1.name}]`, 'success')
} else {
throw new Error(`分组1成员错误期望: [${account1.id}], 实际: [${group1Members.join(', ')}]`)
}
// 验证分组2的成员应该包含两个账户
const group2Members = await accountGroupService.getGroupMembers(group2.id)
const expectedGroup2Members = [account1.id, account2.id].sort()
const actualGroup2Members = group2Members.sort()
if (JSON.stringify(actualGroup2Members) === JSON.stringify(expectedGroup2Members)) {
log(`✅ 分组2成员正确: [${account1.name}, ${account2.name}]`, 'success')
} else {
throw new Error(
`分组2成员错误期望: [${expectedGroup2Members.join(', ')}], 实际: [${actualGroup2Members.join(', ')}]`
)
}
// 验证分组3的成员
const group3Members = await accountGroupService.getGroupMembers(group3.id)
if (group3Members.includes(account2.id) && group3Members.length === 1) {
log(`✅ 分组3成员正确: [${account2.name}]`, 'success')
} else {
throw new Error(`分组3成员错误期望: [${account2.id}], 实际: [${group3Members.join(', ')}]`)
}
log(`✅ 分组成员关系验证通过`, 'success')
} catch (error) {
log(`❌ 测试4失败: ${error.message}`, 'error')
throw error
}
}
// 测试5: 从部分分组中移除账户
async function test5_removeFromPartialGroups() {
log('\n📝 测试5: 从部分分组中移除账户', 'info')
try {
const [group1, group2] = testData.groups
const [account1] = testData.accounts
// 将账户1从分组1中移除但仍在分组2中
await accountGroupService.removeAccountFromGroup(account1.id, group1.id)
log(`✅ 从分组1中移除账户1`, 'success')
// 验证账户1现在只属于分组2
const account1Groups = await accountGroupService.getAccountGroup(account1.id)
if (account1Groups.length === 1 && account1Groups[0].id === group2.id) {
log(`✅ 账户1现在只属于分组2: ${account1Groups[0].name}`, 'success')
} else {
const groupNames = account1Groups.map((g) => g.name)
throw new Error(`账户1分组状态错误期望只在分组2中实际: [${groupNames.join(', ')}]`)
}
// 验证分组1现在为空
const group1Members = await accountGroupService.getGroupMembers(group1.id)
if (group1Members.length === 0) {
log(`✅ 分组1现在为空`, 'success')
} else {
throw new Error(`分组1应该为空但还有成员: [${group1Members.join(', ')}]`)
}
// 验证分组2仍有两个成员
const group2Members = await accountGroupService.getGroupMembers(group2.id)
if (group2Members.length === 2) {
log(`✅ 分组2仍有两个成员`, 'success')
} else {
throw new Error(`分组2应该有2个成员实际: ${group2Members.length}`)
}
log(`✅ 部分移除测试通过`, 'success')
} catch (error) {
log(`❌ 测试5失败: ${error.message}`, 'error')
throw error
}
}
// 测试6: 账户完全移除时的分组清理
async function test6_accountDeletionGroupCleanup() {
log('\n📝 测试6: 账户删除时的分组清理', 'info')
try {
const [, group2, group3] = testData.groups // 跳过第一个元素
const [account1, account2] = testData.accounts
// 记录删除前的状态
const beforeGroup2Members = await accountGroupService.getGroupMembers(group2.id)
const beforeGroup3Members = await accountGroupService.getGroupMembers(group3.id)
log(`📊 删除前分组2成员数: ${beforeGroup2Members.length}`, 'info')
log(`📊 删除前分组3成员数: ${beforeGroup3Members.length}`, 'info')
// 删除账户2这应该会触发从所有分组中移除的逻辑
await claudeAccountService.deleteAccount(account2.id)
log(`✅ 删除账户2: ${account2.name}`, 'success')
// 从测试数据中移除避免cleanup时重复删除
testData.accounts = testData.accounts.filter((acc) => acc.id !== account2.id)
// 等待一下确保删除操作完成
await sleep(500)
// 验证分组2现在只有账户1
const afterGroup2Members = await accountGroupService.getGroupMembers(group2.id)
if (afterGroup2Members.length === 1 && afterGroup2Members[0] === account1.id) {
log(`✅ 分组2现在只有账户1`, 'success')
} else {
throw new Error(`分组2成员状态错误期望只有账户1实际: [${afterGroup2Members.join(', ')}]`)
}
// 验证分组3现在为空
const afterGroup3Members = await accountGroupService.getGroupMembers(group3.id)
if (afterGroup3Members.length === 0) {
log(`✅ 分组3现在为空`, 'success')
} else {
throw new Error(`分组3应该为空但还有成员: [${afterGroup3Members.join(', ')}]`)
}
log(`✅ 账户删除的分组清理测试通过`, 'success')
} catch (error) {
log(`❌ 测试6失败: ${error.message}`, 'error')
throw error
}
}
// 主测试函数
async function runTests() {
log('\n🚀 开始多分组功能测试\n', 'info')
try {
// 连接Redis
await redis.connect()
log('✅ Redis连接成功', 'success')
// 执行测试
await test1_createTestData()
await test2_addAccountToMultipleGroups()
await test3_verifyMultiGroupRelationships()
await test4_verifyGroupMemberships()
await test5_removeFromPartialGroups()
await test6_accountDeletionGroupCleanup()
log('\n🎉 所有测试通过!多分组功能工作正常', 'success')
} catch (error) {
log(`\n❌ 测试失败: ${error.message}`, 'error')
console.error(error)
} finally {
// 清理测试数据
if (CLEANUP_ON_FINISH) {
await cleanup()
} else {
log('\n⚠ 测试数据未清理,请手动清理', 'warning')
}
// 关闭Redis连接
await redis.disconnect()
process.exit(0)
}
}
// 运行测试
runTests()

View File

@@ -19,8 +19,10 @@ const webRoutes = require('./routes/web')
const apiStatsRoutes = require('./routes/apiStats')
const geminiRoutes = require('./routes/geminiRoutes')
const openaiGeminiRoutes = require('./routes/openaiGeminiRoutes')
const standardGeminiRoutes = require('./routes/standardGeminiRoutes')
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')
@@ -33,6 +35,7 @@ const {
globalRateLimit,
requestSizeLimit
} = require('./middleware/auth')
const { browserFallbackMiddleware } = require('./middleware/browserFallback')
class Application {
constructor() {
@@ -108,6 +111,9 @@ class Application {
this.app.use(corsMiddleware)
}
// 🆕 兜底中间件处理Chrome插件兼容性必须在认证之前
this.app.use(browserFallbackMiddleware)
// 📦 压缩 - 排除流式响应SSE
this.app.use(
compression({
@@ -133,6 +139,17 @@ class Application {
// 📝 请求日志使用自定义logger而不是morgan
this.app.use(requestLogger)
// 🐛 HTTP调试拦截器仅在启用调试时生效
if (process.env.DEBUG_HTTP_TRAFFIC === 'true') {
try {
const { debugInterceptor } = require('./middleware/debugInterceptor')
this.app.use(debugInterceptor)
logger.info('🐛 HTTP调试拦截器已启用 - 日志输出到 logs/http-debug-*.log')
} catch (error) {
logger.warn('⚠️ 无法加载HTTP调试拦截器:', error.message)
}
}
// 🔧 基础中间件
this.app.use(
express.json({
@@ -235,10 +252,13 @@ 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)
this.app.use('/gemini', geminiRoutes)
// Gemini 路由:同时支持标准格式和原有格式
this.app.use('/gemini', standardGeminiRoutes) // 标准 Gemini API 格式路由
this.app.use('/gemini', geminiRoutes) // 保留原有路径以保持向后兼容
this.app.use('/openai/gemini', openaiGeminiRoutes)
this.app.use('/openai/claude', openaiClaudeRoutes)
this.app.use('/openai', openaiRoutes)
@@ -524,6 +544,15 @@ class Application {
logger.info(
`🔄 Cleanup tasks scheduled every ${config.system.cleanupInterval / 1000 / 60} minutes`
)
// 🚨 启动限流状态自动清理服务
// 每5分钟检查一次过期的限流状态确保账号能及时恢复调度
const rateLimitCleanupService = require('./services/rateLimitCleanupService')
const cleanupIntervalMinutes = config.system.rateLimitCleanupInterval || 5 // 默认5分钟
rateLimitCleanupService.start(cleanupIntervalMinutes)
logger.info(
`🚨 Rate limit cleanup service started (checking every ${cleanupIntervalMinutes} minutes)`
)
}
setupGracefulShutdown() {
@@ -542,6 +571,15 @@ class Application {
logger.error('❌ Error cleaning up pricing service:', error)
}
// 停止限流清理服务
try {
const rateLimitCleanupService = require('./services/rateLimitCleanupService')
rateLimitCleanupService.stop()
logger.info('🚨 Rate limit cleanup service stopped')
} catch (error) {
logger.error('❌ Error stopping rate limit cleanup service:', error)
}
try {
await redis.disconnect()
logger.info('👋 Redis disconnected')

View File

@@ -1,7 +1,8 @@
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')
// const { RateLimiterRedis } = require('rate-limiter-flexible') // 暂时未使用
const config = require('../../config/config')
// 🔑 API Key验证中间件优化版
@@ -182,11 +183,18 @@ const authenticateApiKey = async (req, res, next) => {
// 检查时间窗口限流
const rateLimitWindow = validation.keyData.rateLimitWindow || 0
const rateLimitRequests = validation.keyData.rateLimitRequests || 0
const rateLimitCost = validation.keyData.rateLimitCost || 0 // 新增:费用限制
if (rateLimitWindow > 0 && (rateLimitRequests > 0 || validation.keyData.tokenLimit > 0)) {
// 兼容性检查如果tokenLimit仍有值使用tokenLimit否则使用rateLimitCost
const hasRateLimits =
rateLimitWindow > 0 &&
(rateLimitRequests > 0 || validation.keyData.tokenLimit > 0 || rateLimitCost > 0)
if (hasRateLimits) {
const windowStartKey = `rate_limit:window_start:${validation.keyData.id}`
const requestCountKey = `rate_limit:requests:${validation.keyData.id}`
const tokenCountKey = `rate_limit:tokens:${validation.keyData.id}`
const costCountKey = `rate_limit:cost:${validation.keyData.id}` // 新增:费用计数器
const now = Date.now()
const windowDuration = rateLimitWindow * 60 * 1000 // 转换为毫秒
@@ -199,6 +207,7 @@ const authenticateApiKey = async (req, res, next) => {
await redis.getClient().set(windowStartKey, now, 'PX', windowDuration)
await redis.getClient().set(requestCountKey, 0, 'PX', windowDuration)
await redis.getClient().set(tokenCountKey, 0, 'PX', windowDuration)
await redis.getClient().set(costCountKey, 0, 'PX', windowDuration) // 新增:重置费用
windowStart = now
} else {
windowStart = parseInt(windowStart)
@@ -209,6 +218,7 @@ const authenticateApiKey = async (req, res, next) => {
await redis.getClient().set(windowStartKey, now, 'PX', windowDuration)
await redis.getClient().set(requestCountKey, 0, 'PX', windowDuration)
await redis.getClient().set(tokenCountKey, 0, 'PX', windowDuration)
await redis.getClient().set(costCountKey, 0, 'PX', windowDuration) // 新增:重置费用
windowStart = now
}
}
@@ -216,6 +226,7 @@ const authenticateApiKey = async (req, res, next) => {
// 获取当前计数
const currentRequests = parseInt((await redis.getClient().get(requestCountKey)) || '0')
const currentTokens = parseInt((await redis.getClient().get(tokenCountKey)) || '0')
const currentCost = parseFloat((await redis.getClient().get(costCountKey)) || '0') // 新增:当前费用
// 检查请求次数限制
if (rateLimitRequests > 0 && currentRequests >= rateLimitRequests) {
@@ -236,24 +247,46 @@ const authenticateApiKey = async (req, res, next) => {
})
}
// 检查Token使用量限制
// 兼容性检查优先使用Token限制历史数据否则使用费用限制
const tokenLimit = parseInt(validation.keyData.tokenLimit)
if (tokenLimit > 0 && currentTokens >= tokenLimit) {
const resetTime = new Date(windowStart + windowDuration)
const remainingMinutes = Math.ceil((resetTime - now) / 60000)
if (tokenLimit > 0) {
// 使用Token限制向后兼容
if (currentTokens >= tokenLimit) {
const resetTime = new Date(windowStart + windowDuration)
const remainingMinutes = Math.ceil((resetTime - now) / 60000)
logger.security(
`🚦 Rate limit exceeded (tokens) for key: ${validation.keyData.id} (${validation.keyData.name}), tokens: ${currentTokens}/${tokenLimit}`
)
logger.security(
`🚦 Rate limit exceeded (tokens) for key: ${validation.keyData.id} (${validation.keyData.name}), tokens: ${currentTokens}/${tokenLimit}`
)
return res.status(429).json({
error: 'Rate limit exceeded',
message: `已达到 Token 使用限制 (${tokenLimit} tokens),将在 ${remainingMinutes} 分钟后重置`,
currentTokens,
tokenLimit,
resetAt: resetTime.toISOString(),
remainingMinutes
})
return res.status(429).json({
error: 'Rate limit exceeded',
message: `已达到 Token 使用限制 (${tokenLimit} tokens),将在 ${remainingMinutes} 分钟后重置`,
currentTokens,
tokenLimit,
resetAt: resetTime.toISOString(),
remainingMinutes
})
}
} else if (rateLimitCost > 0) {
// 使用费用限制(新功能)
if (currentCost >= rateLimitCost) {
const resetTime = new Date(windowStart + windowDuration)
const remainingMinutes = Math.ceil((resetTime - now) / 60000)
logger.security(
`💰 Rate limit exceeded (cost) for key: ${validation.keyData.id} (${validation.keyData.name}), cost: $${currentCost.toFixed(2)}/$${rateLimitCost}`
)
return res.status(429).json({
error: 'Rate limit exceeded',
message: `已达到费用限制 ($${rateLimitCost}),将在 ${remainingMinutes} 分钟后重置`,
currentCost,
costLimit: rateLimitCost,
resetAt: resetTime.toISOString(),
remainingMinutes
})
}
}
// 增加请求计数
@@ -265,10 +298,13 @@ const authenticateApiKey = async (req, res, next) => {
windowDuration,
requestCountKey,
tokenCountKey,
costCountKey, // 新增:费用计数器
currentRequests: currentRequests + 1,
currentTokens,
currentCost, // 新增:当前费用
rateLimitRequests,
tokenLimit
tokenLimit,
rateLimitCost // 新增:费用限制
}
}
@@ -297,6 +333,46 @@ const authenticateApiKey = async (req, res, next) => {
)
}
// 检查 Opus 周费用限制(仅对 Opus 模型生效)
const weeklyOpusCostLimit = validation.keyData.weeklyOpusCostLimit || 0
if (weeklyOpusCostLimit > 0) {
// 从请求中获取模型信息
const requestBody = req.body || {}
const model = requestBody.model || ''
// 判断是否为 Opus 模型
if (model && model.toLowerCase().includes('claude-opus')) {
const weeklyOpusCost = validation.keyData.weeklyOpusCost || 0
if (weeklyOpusCost >= weeklyOpusCostLimit) {
logger.security(
`💰 Weekly Opus cost limit exceeded for key: ${validation.keyData.id} (${validation.keyData.name}), cost: $${weeklyOpusCost.toFixed(2)}/$${weeklyOpusCostLimit}`
)
// 计算下周一的重置时间
const now = new Date()
const dayOfWeek = now.getDay()
const daysUntilMonday = dayOfWeek === 0 ? 1 : (8 - dayOfWeek) % 7 || 7
const resetDate = new Date(now)
resetDate.setDate(now.getDate() + daysUntilMonday)
resetDate.setHours(0, 0, 0, 0)
return res.status(429).json({
error: 'Weekly Opus cost limit exceeded',
message: `已达到 Opus 模型周费用限制 ($${weeklyOpusCostLimit})`,
currentCost: weeklyOpusCost,
costLimit: weeklyOpusCostLimit,
resetAt: resetDate.toISOString() // 下周一重置
})
}
// 记录当前 Opus 费用使用情况
logger.api(
`💰 Opus weekly cost usage for key: ${validation.keyData.id} (${validation.keyData.name}), current: $${weeklyOpusCost.toFixed(2)}/$${weeklyOpusCostLimit}`
)
}
}
// 将验证信息添加到请求对象(只包含必要信息)
req.apiKey = {
id: validation.keyData.id,
@@ -311,6 +387,7 @@ const authenticateApiKey = async (req, res, next) => {
concurrencyLimit: validation.keyData.concurrencyLimit,
rateLimitWindow: validation.keyData.rateLimitWindow,
rateLimitRequests: validation.keyData.rateLimitRequests,
rateLimitCost: validation.keyData.rateLimitCost, // 新增:费用限制
enableModelRestriction: validation.keyData.enableModelRestriction,
restrictedModels: validation.keyData.restrictedModels,
enableClientRestriction: validation.keyData.enableClientRestriction,
@@ -449,10 +526,238 @@ 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数据
// 🚦 CORS中间件优化版
// 🚦 CORS中间件优化版支持Chrome插件
const corsMiddleware = (req, res, next) => {
const { origin } = req.headers
@@ -464,8 +769,11 @@ const corsMiddleware = (req, res, next) => {
'https://127.0.0.1:3000'
]
// 🆕 检查是否为Chrome插件请求
const isChromeExtension = origin && origin.startsWith('chrome-extension://')
// 设置CORS头
if (allowedOrigins.includes(origin) || !origin) {
if (allowedOrigins.includes(origin) || !origin || isChromeExtension) {
res.header('Access-Control-Allow-Origin', origin || '*')
}
@@ -480,7 +788,9 @@ const corsMiddleware = (req, res, next) => {
'Authorization',
'x-api-key',
'api-key',
'x-admin-token'
'x-admin-token',
'anthropic-version',
'anthropic-dangerous-direct-browser-access'
].join(', ')
)
@@ -713,35 +1023,41 @@ const errorHandler = (error, req, res, _next) => {
}
// 🌐 全局速率限制中间件(延迟初始化)
let rateLimiter = null
// const rateLimiter = null // 暂时未使用
const getRateLimiter = () => {
if (!rateLimiter) {
try {
const client = redis.getClient()
if (!client) {
logger.warn('⚠️ Redis client not available for rate limiter')
return null
}
// 暂时注释掉未使用的函数
// const getRateLimiter = () => {
// if (!rateLimiter) {
// try {
// const client = redis.getClient()
// if (!client) {
// logger.warn('⚠️ Redis client not available for rate limiter')
// return null
// }
//
// rateLimiter = new RateLimiterRedis({
// storeClient: client,
// keyPrefix: 'global_rate_limit',
// points: 1000, // 请求数量
// duration: 900, // 15分钟 (900秒)
// blockDuration: 900 // 阻塞时间15分钟
// })
//
// logger.info('✅ Rate limiter initialized successfully')
// } catch (error) {
// logger.warn('⚠️ Rate limiter initialization failed, using fallback', { error: error.message })
// return null
// }
// }
// return rateLimiter
// }
rateLimiter = new RateLimiterRedis({
storeClient: client,
keyPrefix: 'global_rate_limit',
points: 1000, // 请求数量
duration: 900, // 15分钟 (900秒)
blockDuration: 900 // 阻塞时间15分钟
})
const globalRateLimit = async (req, res, next) =>
// 已禁用全局IP限流 - 直接跳过所有请求
next()
logger.info('✅ Rate limiter initialized successfully')
} catch (error) {
logger.warn('⚠️ Rate limiter initialization failed, using fallback', { error: error.message })
return null
}
}
return rateLimiter
}
const globalRateLimit = async (req, res, next) => {
// 以下代码已被禁用
/*
// 跳过健康检查和内部请求
if (req.path === '/health' || req.path === '/api/health') {
return next()
@@ -777,11 +1093,11 @@ const globalRateLimit = async (req, res, next) => {
retryAfter: Math.round(msBeforeNext / 1000)
})
}
}
*/
// 📊 请求大小限制中间件
const requestSizeLimit = (req, res, next) => {
const maxSize = 10 * 1024 * 1024 // 10MB
const maxSize = 60 * 1024 * 1024 // 60MB
const contentLength = parseInt(req.headers['content-length'] || '0')
if (contentLength > maxSize) {
@@ -799,6 +1115,10 @@ const requestSizeLimit = (req, res, next) => {
module.exports = {
authenticateApiKey,
authenticateAdmin,
authenticateUser,
authenticateUserOrAdmin,
requireRole,
requireAdmin,
corsMiddleware,
requestLogger,
securityMiddleware,

View File

@@ -0,0 +1,52 @@
const logger = require('../utils/logger')
/**
* 浏览器/Chrome插件兜底中间件
* 专门处理第三方插件的兼容性问题
*/
const browserFallbackMiddleware = (req, res, next) => {
const userAgent = req.headers['user-agent'] || ''
const origin = req.headers['origin'] || ''
const authHeader = req.headers['authorization'] || req.headers['x-api-key'] || ''
// 检查是否为Chrome插件或浏览器请求
const isChromeExtension = origin.startsWith('chrome-extension://')
const isBrowserRequest = userAgent.includes('Mozilla/') && userAgent.includes('Chrome/')
const hasApiKey = authHeader.startsWith('cr_') // 我们的API Key格式
if ((isChromeExtension || isBrowserRequest) && hasApiKey) {
// 为Chrome插件请求添加特殊标记
req.isBrowserFallback = true
req.originalUserAgent = userAgent
// 🆕 关键修改伪装成claude-cli请求以绕过客户端限制
req.headers['user-agent'] = 'claude-cli/1.0.110 (external, cli, browser-fallback)'
// 确保设置正确的认证头
if (!req.headers['authorization'] && req.headers['x-api-key']) {
req.headers['authorization'] = `Bearer ${req.headers['x-api-key']}`
}
// 添加必要的Anthropic头
if (!req.headers['anthropic-version']) {
req.headers['anthropic-version'] = '2023-06-01'
}
if (!req.headers['anthropic-dangerous-direct-browser-access']) {
req.headers['anthropic-dangerous-direct-browser-access'] = 'true'
}
logger.api(
`🔧 Browser fallback activated for ${isChromeExtension ? 'Chrome extension' : 'browser'} request`
)
logger.api(` Original User-Agent: "${req.originalUserAgent}"`)
logger.api(` Origin: "${origin}"`)
logger.api(` Modified User-Agent: "${req.headers['user-agent']}"`)
}
next()
}
module.exports = {
browserFallbackMiddleware
}

View File

@@ -29,6 +29,25 @@ function getHourInTimezone(date = new Date()) {
return tzDate.getUTCHours()
}
// 获取配置时区的 ISO 周YYYY-Wxx 格式,周一到周日)
function getWeekStringInTimezone(date = new Date()) {
const tzDate = getDateInTimezone(date)
// 获取年份
const year = tzDate.getUTCFullYear()
// 计算 ISO 周数(周一为第一天)
const dateObj = new Date(tzDate)
const dayOfWeek = dateObj.getUTCDay() || 7 // 将周日(0)转换为7
const firstThursday = new Date(dateObj)
firstThursday.setUTCDate(dateObj.getUTCDate() + 4 - dayOfWeek) // 找到这周的周四
const yearStart = new Date(firstThursday.getUTCFullYear(), 0, 1)
const weekNumber = Math.ceil(((firstThursday - yearStart) / 86400000 + 1) / 7)
return `${year}-W${String(weekNumber).padStart(2, '0')}`
}
class RedisClient {
constructor() {
this.client = null
@@ -193,7 +212,8 @@ class RedisClient {
cacheReadTokens = 0,
model = 'unknown',
ephemeral5mTokens = 0, // 新增5分钟缓存 tokens
ephemeral1hTokens = 0 // 新增1小时缓存 tokens
ephemeral1hTokens = 0, // 新增1小时缓存 tokens
isLongContextRequest = false // 新增:是否为 1M 上下文请求超过200k
) {
const key = `usage:${keyId}`
const now = new Date()
@@ -250,6 +270,12 @@ class RedisClient {
// 详细缓存类型统计(新增)
pipeline.hincrby(key, 'totalEphemeral5mTokens', ephemeral5mTokens)
pipeline.hincrby(key, 'totalEphemeral1hTokens', ephemeral1hTokens)
// 1M 上下文请求统计(新增)
if (isLongContextRequest) {
pipeline.hincrby(key, 'totalLongContextInputTokens', finalInputTokens)
pipeline.hincrby(key, 'totalLongContextOutputTokens', finalOutputTokens)
pipeline.hincrby(key, 'totalLongContextRequests', 1)
}
// 请求计数
pipeline.hincrby(key, 'totalRequests', 1)
@@ -264,6 +290,12 @@ class RedisClient {
// 详细缓存类型统计
pipeline.hincrby(daily, 'ephemeral5mTokens', ephemeral5mTokens)
pipeline.hincrby(daily, 'ephemeral1hTokens', ephemeral1hTokens)
// 1M 上下文请求统计
if (isLongContextRequest) {
pipeline.hincrby(daily, 'longContextInputTokens', finalInputTokens)
pipeline.hincrby(daily, 'longContextOutputTokens', finalOutputTokens)
pipeline.hincrby(daily, 'longContextRequests', 1)
}
// 每月统计
pipeline.hincrby(monthly, 'tokens', coreTokens)
@@ -376,7 +408,8 @@ class RedisClient {
outputTokens = 0,
cacheCreateTokens = 0,
cacheReadTokens = 0,
model = 'unknown'
model = 'unknown',
isLongContextRequest = false
) {
const now = new Date()
const today = getDateStringInTimezone(now)
@@ -407,7 +440,8 @@ class RedisClient {
finalInputTokens + finalOutputTokens + finalCacheCreateTokens + finalCacheReadTokens
const coreTokens = finalInputTokens + finalOutputTokens
await Promise.all([
// 构建统计操作数组
const operations = [
// 账户总体统计
this.client.hincrby(accountKey, 'totalTokens', coreTokens),
this.client.hincrby(accountKey, 'totalInputTokens', finalInputTokens),
@@ -444,6 +478,26 @@ class RedisClient {
this.client.hincrby(accountHourly, 'allTokens', actualTotalTokens),
this.client.hincrby(accountHourly, 'requests', 1),
// 添加模型级别的数据到hourly键中以支持会话窗口的统计
this.client.hincrby(accountHourly, `model:${normalizedModel}:inputTokens`, finalInputTokens),
this.client.hincrby(
accountHourly,
`model:${normalizedModel}:outputTokens`,
finalOutputTokens
),
this.client.hincrby(
accountHourly,
`model:${normalizedModel}:cacheCreateTokens`,
finalCacheCreateTokens
),
this.client.hincrby(
accountHourly,
`model:${normalizedModel}:cacheReadTokens`,
finalCacheReadTokens
),
this.client.hincrby(accountHourly, `model:${normalizedModel}:allTokens`, actualTotalTokens),
this.client.hincrby(accountHourly, `model:${normalizedModel}:requests`, 1),
// 账户按模型统计 - 每日
this.client.hincrby(accountModelDaily, 'inputTokens', finalInputTokens),
this.client.hincrby(accountModelDaily, 'outputTokens', finalOutputTokens),
@@ -475,7 +529,21 @@ class RedisClient {
this.client.expire(accountModelDaily, 86400 * 32), // 32天过期
this.client.expire(accountModelMonthly, 86400 * 365), // 1年过期
this.client.expire(accountModelHourly, 86400 * 7) // 7天过期
])
]
// 如果是 1M 上下文请求,添加额外的统计
if (isLongContextRequest) {
operations.push(
this.client.hincrby(accountKey, 'totalLongContextInputTokens', finalInputTokens),
this.client.hincrby(accountKey, 'totalLongContextOutputTokens', finalOutputTokens),
this.client.hincrby(accountKey, 'totalLongContextRequests', 1),
this.client.hincrby(accountDaily, 'longContextInputTokens', finalInputTokens),
this.client.hincrby(accountDaily, 'longContextOutputTokens', finalOutputTokens),
this.client.hincrby(accountDaily, 'longContextRequests', 1)
)
}
await Promise.all(operations)
}
async getUsageStats(keyId) {
@@ -632,8 +700,87 @@ class RedisClient {
}
}
// 💰 获取本周 Opus 费用
async getWeeklyOpusCost(keyId) {
const currentWeek = getWeekStringInTimezone()
const costKey = `usage:opus:weekly:${keyId}:${currentWeek}`
const cost = await this.client.get(costKey)
const result = parseFloat(cost || 0)
logger.debug(
`💰 Getting weekly Opus cost for ${keyId}, week: ${currentWeek}, key: ${costKey}, value: ${cost}, result: ${result}`
)
return result
}
// 💰 增加本周 Opus 费用
async incrementWeeklyOpusCost(keyId, amount) {
const currentWeek = getWeekStringInTimezone()
const weeklyKey = `usage:opus:weekly:${keyId}:${currentWeek}`
const totalKey = `usage:opus:total:${keyId}`
logger.debug(
`💰 Incrementing weekly Opus cost for ${keyId}, week: ${currentWeek}, amount: $${amount}`
)
// 使用 pipeline 批量执行,提高性能
const pipeline = this.client.pipeline()
pipeline.incrbyfloat(weeklyKey, amount)
pipeline.incrbyfloat(totalKey, amount)
// 设置周费用键的过期时间为 2 周
pipeline.expire(weeklyKey, 14 * 24 * 3600)
const results = await pipeline.exec()
logger.debug(`💰 Opus cost incremented successfully, new weekly total: $${results[0][1]}`)
}
// 💰 计算账户的每日费用(基于模型使用)
async getAccountDailyCost(accountId) {
const CostCalculator = require('../utils/costCalculator')
const today = getDateStringInTimezone()
// 获取账户今日所有模型的使用数据
const pattern = `account_usage:model:daily:${accountId}:*:${today}`
const modelKeys = await this.client.keys(pattern)
if (!modelKeys || modelKeys.length === 0) {
return 0
}
let totalCost = 0
for (const key of modelKeys) {
// 从key中解析模型名称
// 格式account_usage:model:daily:{accountId}:{model}:{date}
const parts = key.split(':')
const model = parts[4] // 模型名在第5个位置索引4
// 获取该模型的使用数据
const modelUsage = await this.client.hgetall(key)
if (modelUsage && (modelUsage.inputTokens || modelUsage.outputTokens)) {
const usage = {
input_tokens: parseInt(modelUsage.inputTokens || 0),
output_tokens: parseInt(modelUsage.outputTokens || 0),
cache_creation_input_tokens: parseInt(modelUsage.cacheCreateTokens || 0),
cache_read_input_tokens: parseInt(modelUsage.cacheReadTokens || 0)
}
// 使用CostCalculator计算费用
const costResult = CostCalculator.calculateCost(usage, model)
totalCost += costResult.costs.total
logger.debug(
`💰 Account ${accountId} daily cost for model ${model}: $${costResult.costs.total}`
)
}
}
logger.debug(`💰 Account ${accountId} total daily cost: $${totalCost}`)
return totalCost
}
// 📊 获取账户使用统计
async getAccountUsageStats(accountId) {
async getAccountUsageStats(accountId, accountType = null) {
const accountKey = `account_usage:${accountId}`
const today = getDateStringInTimezone()
const accountDailyKey = `account_usage:daily:${accountId}:${today}`
@@ -647,8 +794,25 @@ class RedisClient {
this.client.hgetall(accountMonthlyKey)
])
// 获取账户创建时间来计算平均值
const accountData = await this.client.hgetall(`claude_account:${accountId}`)
// 获取账户创建时间来计算平均值 - 支持不同类型的账号
let accountData = {}
if (accountType === 'openai') {
accountData = await this.client.hgetall(`openai:account:${accountId}`)
} else if (accountType === 'openai-responses') {
accountData = await this.client.hgetall(`openai_responses_account:${accountId}`)
} else {
// 尝试多个前缀
accountData = await this.client.hgetall(`claude_account:${accountId}`)
if (!accountData.createdAt) {
accountData = await this.client.hgetall(`openai:account:${accountId}`)
}
if (!accountData.createdAt) {
accountData = await this.client.hgetall(`openai_responses_account:${accountId}`)
}
if (!accountData.createdAt) {
accountData = await this.client.hgetall(`openai_account:${accountId}`)
}
}
const createdAt = accountData.createdAt ? new Date(accountData.createdAt) : new Date()
const now = new Date()
const daysSinceCreated = Math.max(1, Math.ceil((now - createdAt) / (1000 * 60 * 60 * 24)))
@@ -691,10 +855,16 @@ class RedisClient {
const dailyData = handleAccountData(daily)
const monthlyData = handleAccountData(monthly)
// 获取每日费用(基于模型使用)
const dailyCost = await this.getAccountDailyCost(accountId)
return {
accountId,
total: totalData,
daily: dailyData,
daily: {
...dailyData,
cost: dailyCost
},
monthly: monthlyData,
averages: {
rpm: Math.round(avgRPM * 100) / 100,
@@ -1203,9 +1373,12 @@ class RedisClient {
}
// 🔗 会话sticky映射管理
async setSessionAccountMapping(sessionHash, accountId, ttl = 3600) {
async setSessionAccountMapping(sessionHash, accountId, ttl = null) {
const appConfig = require('../../config/config')
// 从配置读取TTL小时转换为秒默认1小时
const defaultTTL = ttl !== null ? ttl : (appConfig.session?.stickyTtlHours || 1) * 60 * 60
const key = `sticky_session:${sessionHash}`
await this.client.set(key, accountId, 'EX', ttl)
await this.client.set(key, accountId, 'EX', defaultTTL)
}
async getSessionAccountMapping(sessionHash) {
@@ -1213,6 +1386,57 @@ class RedisClient {
return await this.client.get(key)
}
// 🚀 智能会话TTL续期剩余时间少于阈值时自动续期
async extendSessionAccountMappingTTL(sessionHash) {
const appConfig = require('../../config/config')
const key = `sticky_session:${sessionHash}`
// 📊 从配置获取参数
const ttlHours = appConfig.session?.stickyTtlHours || 1 // 小时默认1小时
const thresholdMinutes = appConfig.session?.renewalThresholdMinutes || 0 // 分钟默认0不续期
// 如果阈值为0不执行续期
if (thresholdMinutes === 0) {
return true
}
const fullTTL = ttlHours * 60 * 60 // 转换为秒
const renewalThreshold = thresholdMinutes * 60 // 转换为秒
try {
// 获取当前剩余TTL
const remainingTTL = await this.client.ttl(key)
// 键不存在或已过期
if (remainingTTL === -2) {
return false
}
// 键存在但没有TTL永不过期不需要处理
if (remainingTTL === -1) {
return true
}
// 🎯 智能续期策略:仅在剩余时间少于阈值时才续期
if (remainingTTL < renewalThreshold) {
await this.client.expire(key, fullTTL)
logger.debug(
`🔄 Renewed sticky session TTL: ${sessionHash} (was ${Math.round(remainingTTL / 60)}min, renewed to ${ttlHours}h)`
)
return true
}
// 剩余时间充足,无需续期
logger.debug(
`✅ Sticky session TTL sufficient: ${sessionHash} (remaining ${Math.round(remainingTTL / 60)}min)`
)
return true
} catch (error) {
logger.error('❌ Failed to extend session TTL:', error)
return false
}
}
async deleteSessionAccountMapping(sessionHash) {
const key = `sticky_session:${sessionHash}`
return await this.client.del(key)
@@ -1311,13 +1535,229 @@ class RedisClient {
return 0
}
}
// 🔧 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 {
if (!windowStart || !windowEnd) {
return {
totalInputTokens: 0,
totalOutputTokens: 0,
totalCacheCreateTokens: 0,
totalCacheReadTokens: 0,
totalAllTokens: 0,
totalRequests: 0,
modelUsage: {}
}
}
const startDate = new Date(windowStart)
const endDate = new Date(windowEnd)
// 添加日志以调试时间窗口
logger.debug(`📊 Getting session window usage for account ${accountId}`)
logger.debug(` Window: ${windowStart} to ${windowEnd}`)
logger.debug(` Start UTC: ${startDate.toISOString()}, End UTC: ${endDate.toISOString()}`)
// 获取窗口内所有可能的小时键
// 重要:需要使用配置的时区来构建键名,因为数据存储时使用的是配置时区
const hourlyKeys = []
const currentHour = new Date(startDate)
currentHour.setMinutes(0)
currentHour.setSeconds(0)
currentHour.setMilliseconds(0)
while (currentHour <= endDate) {
// 使用时区转换函数来获取正确的日期和小时
const tzDateStr = getDateStringInTimezone(currentHour)
const tzHour = String(getHourInTimezone(currentHour)).padStart(2, '0')
const key = `account_usage:hourly:${accountId}:${tzDateStr}:${tzHour}`
logger.debug(` Adding hourly key: ${key}`)
hourlyKeys.push(key)
currentHour.setHours(currentHour.getHours() + 1)
}
// 批量获取所有小时的数据
const pipeline = this.client.pipeline()
for (const key of hourlyKeys) {
pipeline.hgetall(key)
}
const results = await pipeline.exec()
// 聚合所有数据
let totalInputTokens = 0
let totalOutputTokens = 0
let totalCacheCreateTokens = 0
let totalCacheReadTokens = 0
let totalAllTokens = 0
let totalRequests = 0
const modelUsage = {}
logger.debug(` Processing ${results.length} hourly results`)
for (const [error, data] of results) {
if (error || !data || Object.keys(data).length === 0) {
continue
}
// 处理总计数据
const hourInputTokens = parseInt(data.inputTokens || 0)
const hourOutputTokens = parseInt(data.outputTokens || 0)
const hourCacheCreateTokens = parseInt(data.cacheCreateTokens || 0)
const hourCacheReadTokens = parseInt(data.cacheReadTokens || 0)
const hourAllTokens = parseInt(data.allTokens || 0)
const hourRequests = parseInt(data.requests || 0)
totalInputTokens += hourInputTokens
totalOutputTokens += hourOutputTokens
totalCacheCreateTokens += hourCacheCreateTokens
totalCacheReadTokens += hourCacheReadTokens
totalAllTokens += hourAllTokens
totalRequests += hourRequests
if (hourAllTokens > 0) {
logger.debug(` Hour data: allTokens=${hourAllTokens}, requests=${hourRequests}`)
}
// 处理每个模型的数据
for (const [key, value] of Object.entries(data)) {
// 查找模型相关的键(格式: model:{modelName}:{metric}
if (key.startsWith('model:')) {
const parts = key.split(':')
if (parts.length >= 3) {
const modelName = parts[1]
const metric = parts.slice(2).join(':')
if (!modelUsage[modelName]) {
modelUsage[modelName] = {
inputTokens: 0,
outputTokens: 0,
cacheCreateTokens: 0,
cacheReadTokens: 0,
allTokens: 0,
requests: 0
}
}
if (metric === 'inputTokens') {
modelUsage[modelName].inputTokens += parseInt(value || 0)
} else if (metric === 'outputTokens') {
modelUsage[modelName].outputTokens += parseInt(value || 0)
} else if (metric === 'cacheCreateTokens') {
modelUsage[modelName].cacheCreateTokens += parseInt(value || 0)
} else if (metric === 'cacheReadTokens') {
modelUsage[modelName].cacheReadTokens += parseInt(value || 0)
} else if (metric === 'allTokens') {
modelUsage[modelName].allTokens += parseInt(value || 0)
} else if (metric === 'requests') {
modelUsage[modelName].requests += parseInt(value || 0)
}
}
}
}
}
logger.debug(`📊 Session window usage summary:`)
logger.debug(` Total allTokens: ${totalAllTokens}`)
logger.debug(` Total requests: ${totalRequests}`)
logger.debug(` Input: ${totalInputTokens}, Output: ${totalOutputTokens}`)
logger.debug(
` Cache Create: ${totalCacheCreateTokens}, Cache Read: ${totalCacheReadTokens}`
)
return {
totalInputTokens,
totalOutputTokens,
totalCacheCreateTokens,
totalCacheReadTokens,
totalAllTokens,
totalRequests,
modelUsage
}
} catch (error) {
logger.error(`❌ Failed to get session window usage for account ${accountId}:`, error)
return {
totalInputTokens: 0,
totalOutputTokens: 0,
totalCacheCreateTokens: 0,
totalCacheReadTokens: 0,
totalAllTokens: 0,
totalRequests: 0,
modelUsage: {}
}
}
}
}
const redisClient = new RedisClient()
// 分布式锁相关方法
redisClient.setAccountLock = async function (lockKey, lockValue, ttlMs) {
try {
// 使用SET NX EX实现原子性的锁获取
const result = await this.client.set(lockKey, lockValue, {
NX: true, // 只在键不存在时设置
PX: ttlMs // 毫秒级过期时间
})
return result === 'OK'
} catch (error) {
logger.error(`Failed to acquire lock ${lockKey}:`, error)
return false
}
}
redisClient.releaseAccountLock = async function (lockKey, lockValue) {
try {
// 使用Lua脚本确保只有持有锁的进程才能释放锁
const script = `
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end
`
const result = await this.client.eval(script, {
keys: [lockKey],
arguments: [lockValue]
})
return result === 1
} catch (error) {
logger.error(`Failed to release lock ${lockKey}:`, error)
return false
}
}
// 导出时区辅助函数
redisClient.getDateInTimezone = getDateInTimezone
redisClient.getDateStringInTimezone = getDateStringInTimezone
redisClient.getHourInTimezone = getHourInTimezone
redisClient.getWeekStringInTimezone = getWeekStringInTimezone
module.exports = redisClient

File diff suppressed because it is too large Load Diff

View File

@@ -2,12 +2,15 @@ const express = require('express')
const claudeRelayService = require('../services/claudeRelayService')
const claudeConsoleRelayService = require('../services/claudeConsoleRelayService')
const bedrockRelayService = require('../services/bedrockRelayService')
const ccrRelayService = require('../services/ccrRelayService')
const bedrockAccountService = require('../services/bedrockAccountService')
const unifiedClaudeScheduler = require('../services/unifiedClaudeScheduler')
const apiKeyService = require('../services/apiKeyService')
const pricingService = require('../services/pricingService')
const { authenticateApiKey } = require('../middleware/auth')
const logger = require('../utils/logger')
const redis = require('../models/redis')
const { getEffectiveModel, parseVendorPrefixedModel } = require('../utils/modelHelper')
const sessionHelper = require('../utils/sessionHelper')
const router = express.Router()
@@ -39,6 +42,23 @@ async function handleMessagesRequest(req, res) {
})
}
// 模型限制(黑名单)校验:统一在此处处理(去除供应商前缀)
if (
req.apiKey.enableModelRestriction &&
Array.isArray(req.apiKey.restrictedModels) &&
req.apiKey.restrictedModels.length > 0
) {
const effectiveModel = getEffectiveModel(req.body.model || '')
if (req.apiKey.restrictedModels.includes(effectiveModel)) {
return res.status(403).json({
error: {
type: 'forbidden',
message: '暂无该模型访问权限'
}
})
}
}
// 检查是否为流式请求
const isStream = req.body.stream === true
@@ -131,14 +151,16 @@ async function handleMessagesRequest(req, res) {
}
apiKeyService
.recordUsageWithDetails(req.apiKey.id, usageObject, model, usageAccountId)
.recordUsageWithDetails(req.apiKey.id, usageObject, model, usageAccountId, 'claude')
.catch((error) => {
logger.error('❌ Failed to record stream usage:', error)
})
// 更新时间窗口内的token计数
// 更新时间窗口内的token计数和费用
if (req.rateLimitInfo) {
const totalTokens = inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens
// 更新Token计数向后兼容
redis
.getClient()
.incrby(req.rateLimitInfo.tokenCountKey, totalTokens)
@@ -146,6 +168,22 @@ async function handleMessagesRequest(req, res) {
logger.error('❌ Failed to update rate limit token count:', error)
})
logger.api(`📊 Updated rate limit token count: +${totalTokens} tokens`)
// 计算并更新费用计数(新功能)
if (req.rateLimitInfo.costCountKey) {
const costInfo = pricingService.calculateCost(usageData, model)
if (costInfo.totalCost > 0) {
redis
.getClient()
.incrbyfloat(req.rateLimitInfo.costCountKey, costInfo.totalCost)
.catch((error) => {
logger.error('❌ Failed to update rate limit cost count:', error)
})
logger.api(
`💰 Updated rate limit cost count: +$${costInfo.totalCost.toFixed(6)}`
)
}
}
}
usageDataCaptured = true
@@ -216,14 +254,22 @@ async function handleMessagesRequest(req, res) {
}
apiKeyService
.recordUsageWithDetails(req.apiKey.id, usageObject, model, usageAccountId)
.recordUsageWithDetails(
req.apiKey.id,
usageObject,
model,
usageAccountId,
'claude-console'
)
.catch((error) => {
logger.error('❌ Failed to record stream usage:', error)
})
// 更新时间窗口内的token计数
// 更新时间窗口内的token计数和费用
if (req.rateLimitInfo) {
const totalTokens = inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens
// 更新Token计数向后兼容
redis
.getClient()
.incrby(req.rateLimitInfo.tokenCountKey, totalTokens)
@@ -231,6 +277,22 @@ async function handleMessagesRequest(req, res) {
logger.error('❌ Failed to update rate limit token count:', error)
})
logger.api(`📊 Updated rate limit token count: +${totalTokens} tokens`)
// 计算并更新费用计数(新功能)
if (req.rateLimitInfo.costCountKey) {
const costInfo = pricingService.calculateCost(usageData, model)
if (costInfo.totalCost > 0) {
redis
.getClient()
.incrbyfloat(req.rateLimitInfo.costCountKey, costInfo.totalCost)
.catch((error) => {
logger.error('❌ Failed to update rate limit cost count:', error)
})
logger.api(
`💰 Updated rate limit cost count: +$${costInfo.totalCost.toFixed(6)}`
)
}
}
}
usageDataCaptured = true
@@ -271,9 +333,11 @@ async function handleMessagesRequest(req, res) {
logger.error('❌ Failed to record Bedrock stream usage:', error)
})
// 更新时间窗口内的token计数
// 更新时间窗口内的token计数和费用
if (req.rateLimitInfo) {
const totalTokens = inputTokens + outputTokens
// 更新Token计数向后兼容
redis
.getClient()
.incrby(req.rateLimitInfo.tokenCountKey, totalTokens)
@@ -281,6 +345,20 @@ async function handleMessagesRequest(req, res) {
logger.error('❌ Failed to update rate limit token count:', error)
})
logger.api(`📊 Updated rate limit token count: +${totalTokens} tokens`)
// 计算并更新费用计数(新功能)
if (req.rateLimitInfo.costCountKey) {
const costInfo = pricingService.calculateCost(result.usage, result.model)
if (costInfo.totalCost > 0) {
redis
.getClient()
.incrbyfloat(req.rateLimitInfo.costCountKey, costInfo.totalCost)
.catch((error) => {
logger.error('❌ Failed to update rate limit cost count:', error)
})
logger.api(`💰 Updated rate limit cost count: +$${costInfo.totalCost.toFixed(6)}`)
}
}
}
usageDataCaptured = true
@@ -295,6 +373,110 @@ async function handleMessagesRequest(req, res) {
}
return undefined
}
} else if (accountType === 'ccr') {
// CCR账号使用CCR转发服务需要传递accountId
await ccrRelayService.relayStreamRequestWithUsageCapture(
req.body,
req.apiKey,
res,
req.headers,
(usageData) => {
// 回调函数当检测到完整usage数据时记录真实token使用量
logger.info(
'🎯 CCR usage callback triggered with complete data:',
JSON.stringify(usageData, null, 2)
)
if (
usageData &&
usageData.input_tokens !== undefined &&
usageData.output_tokens !== undefined
) {
const inputTokens = usageData.input_tokens || 0
const outputTokens = usageData.output_tokens || 0
// 兼容处理:如果有详细的 cache_creation 对象,使用它;否则使用总的 cache_creation_input_tokens
let cacheCreateTokens = usageData.cache_creation_input_tokens || 0
let ephemeral5mTokens = 0
let ephemeral1hTokens = 0
if (usageData.cache_creation && typeof usageData.cache_creation === 'object') {
ephemeral5mTokens = usageData.cache_creation.ephemeral_5m_input_tokens || 0
ephemeral1hTokens = usageData.cache_creation.ephemeral_1h_input_tokens || 0
// 总的缓存创建 tokens 是两者之和
cacheCreateTokens = ephemeral5mTokens + ephemeral1hTokens
}
const cacheReadTokens = usageData.cache_read_input_tokens || 0
const model = usageData.model || 'unknown'
// 记录真实的token使用量包含模型信息和所有4种token以及账户ID
const usageAccountId = usageData.accountId
// 构建 usage 对象以传递给 recordUsage
const usageObject = {
input_tokens: inputTokens,
output_tokens: outputTokens,
cache_creation_input_tokens: cacheCreateTokens,
cache_read_input_tokens: cacheReadTokens
}
// 如果有详细的缓存创建数据,添加到 usage 对象中
if (ephemeral5mTokens > 0 || ephemeral1hTokens > 0) {
usageObject.cache_creation = {
ephemeral_5m_input_tokens: ephemeral5mTokens,
ephemeral_1h_input_tokens: ephemeral1hTokens
}
}
apiKeyService
.recordUsageWithDetails(req.apiKey.id, usageObject, model, usageAccountId, 'ccr')
.catch((error) => {
logger.error('❌ Failed to record CCR stream usage:', error)
})
// 更新时间窗口内的token计数和费用
if (req.rateLimitInfo) {
const totalTokens = inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens
// 更新Token计数向后兼容
redis
.getClient()
.incrby(req.rateLimitInfo.tokenCountKey, totalTokens)
.catch((error) => {
logger.error('❌ Failed to update rate limit token count:', error)
})
logger.api(`📊 Updated rate limit token count: +${totalTokens} tokens`)
// 计算并更新费用计数(新功能)
if (req.rateLimitInfo.costCountKey) {
const costInfo = pricingService.calculateCost(usageData, model)
if (costInfo.totalCost > 0) {
redis
.getClient()
.incrbyfloat(req.rateLimitInfo.costCountKey, costInfo.totalCost)
.catch((error) => {
logger.error('❌ Failed to update rate limit cost count:', error)
})
logger.api(
`💰 Updated rate limit cost count: +$${costInfo.totalCost.toFixed(6)}`
)
}
}
}
usageDataCaptured = true
logger.api(
`📊 CCR stream usage recorded (real) - Model: ${model}, Input: ${inputTokens}, Output: ${outputTokens}, Cache Create: ${cacheCreateTokens}, Cache Read: ${cacheReadTokens}, Total: ${inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens} tokens`
)
} else {
logger.warn(
'⚠️ CCR usage callback triggered but data is incomplete:',
JSON.stringify(usageData)
)
}
},
accountId
)
}
// 流式请求完成后 - 如果没有捕获到usage数据记录警告但不进行估算
@@ -388,6 +570,17 @@ async function handleMessagesRequest(req, res) {
accountId
}
}
} else if (accountType === 'ccr') {
// CCR账号使用CCR转发服务
logger.debug(`[DEBUG] Calling ccrRelayService.relayRequest with accountId: ${accountId}`)
response = await ccrRelayService.relayRequest(
req.body,
req.apiKey,
req,
res,
req.headers,
accountId
)
}
logger.info('📡 Claude API response received', {
@@ -424,7 +617,10 @@ async function handleMessagesRequest(req, res) {
const outputTokens = jsonData.usage.output_tokens || 0
const cacheCreateTokens = jsonData.usage.cache_creation_input_tokens || 0
const cacheReadTokens = jsonData.usage.cache_read_input_tokens || 0
const model = jsonData.model || req.body.model || 'unknown'
// Parse the model to remove vendor prefix if present (e.g., "ccr,gemini-2.5-pro" -> "gemini-2.5-pro")
const rawModel = jsonData.model || req.body.model || 'unknown'
const { baseModel } = parseVendorPrefixedModel(rawModel)
const model = baseModel || rawModel
// 记录真实的token使用量包含模型信息和所有4种token以及账户ID
const { accountId: responseAccountId } = response
@@ -438,11 +634,24 @@ async function handleMessagesRequest(req, res) {
responseAccountId
)
// 更新时间窗口内的token计数
// 更新时间窗口内的token计数和费用
if (req.rateLimitInfo) {
const totalTokens = inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens
// 更新Token计数向后兼容
await redis.getClient().incrby(req.rateLimitInfo.tokenCountKey, totalTokens)
logger.api(`📊 Updated rate limit token count: +${totalTokens} tokens`)
// 计算并更新费用计数(新功能)
if (req.rateLimitInfo.costCountKey) {
const costInfo = pricingService.calculateCost(jsonData.usage, model)
if (costInfo.totalCost > 0) {
await redis
.getClient()
.incrbyfloat(req.rateLimitInfo.costCountKey, costInfo.totalCost)
logger.api(`💰 Updated rate limit cost count: +$${costInfo.totalCost.toFixed(6)}`)
}
}
}
usageRecorded = true
@@ -729,6 +938,14 @@ router.post('/v1/messages/count_tokens', authenticateApiKey, async (req, res) =>
customPath: '/v1/messages/count_tokens' // 指定count_tokens路径
}
)
} else if (accountType === 'ccr') {
// CCR不支持count_tokens
return res.status(501).json({
error: {
type: 'not_supported',
message: 'Token counting is not supported for CCR accounts'
}
})
} else {
// Bedrock不支持count_tokens
return res.status(501).json({

View File

@@ -31,8 +31,8 @@ router.post('/api/get-key-id', async (req, res) => {
})
}
// 验证API Key
const validation = await apiKeyService.validateApiKey(apiKey)
// 验证API Key(使用不触发激活的验证方法)
const validation = await apiKeyService.validateApiKeyForStats(apiKey)
if (!validation.valid) {
const clientIP = req.ip || req.connection?.remoteAddress || 'unknown'
@@ -146,6 +146,11 @@ router.post('/api/user-stats', async (req, res) => {
enableClientRestriction: keyData.enableClientRestriction === 'true',
allowedClients,
permissions: keyData.permissions || 'all',
// 添加激活相关字段
expirationMode: keyData.expirationMode || 'fixed',
isActivated: keyData.isActivated === 'true',
activationDays: parseInt(keyData.activationDays || 0),
activatedAt: keyData.activatedAt || null,
usage // 使用完整的 usage 数据,而不是只有 total
}
} else if (apiKey) {
@@ -158,8 +163,8 @@ router.post('/api/user-stats', async (req, res) => {
})
}
// 验证API Key重用现有的验证逻辑
const validation = await apiKeyService.validateApiKey(apiKey)
// 验证API Key使用不触发激活的验证方法
const validation = await apiKeyService.validateApiKeyForStats(apiKey)
if (!validation.valid) {
const clientIP = req.ip || req.connection?.remoteAddress || 'unknown'
@@ -278,21 +283,24 @@ router.post('/api/user-stats', async (req, res) => {
// 获取当前使用量
let currentWindowRequests = 0
let currentWindowTokens = 0
let currentWindowCost = 0 // 新增:当前窗口费用
let currentDailyCost = 0
let windowStartTime = null
let windowEndTime = null
let windowRemainingSeconds = null
try {
// 获取当前时间窗口的请求次数Token使用量
// 获取当前时间窗口的请求次数Token使用量和费用
if (fullKeyData.rateLimitWindow > 0) {
const client = redis.getClientSafe()
const requestCountKey = `rate_limit:requests:${keyId}`
const tokenCountKey = `rate_limit:tokens:${keyId}`
const costCountKey = `rate_limit:cost:${keyId}` // 新增费用计数key
const windowStartKey = `rate_limit:window_start:${keyId}`
currentWindowRequests = parseInt((await client.get(requestCountKey)) || '0')
currentWindowTokens = parseInt((await client.get(tokenCountKey)) || '0')
currentWindowCost = parseFloat((await client.get(costCountKey)) || '0') // 新增:获取当前窗口费用
// 获取窗口开始时间和计算剩余时间
const windowStart = await client.get(windowStartKey)
@@ -313,6 +321,7 @@ router.post('/api/user-stats', async (req, res) => {
// 重置计数为0因为窗口已过期
currentWindowRequests = 0
currentWindowTokens = 0
currentWindowCost = 0 // 新增:重置窗口费用
}
}
}
@@ -327,10 +336,15 @@ router.post('/api/user-stats', async (req, res) => {
const responseData = {
id: keyId,
name: fullKeyData.name,
description: keyData.description || '',
description: fullKeyData.description || keyData.description || '',
isActive: true, // 如果能通过validateApiKey验证说明一定是激活的
createdAt: keyData.createdAt,
expiresAt: keyData.expiresAt,
createdAt: fullKeyData.createdAt || keyData.createdAt,
expiresAt: fullKeyData.expiresAt || keyData.expiresAt,
// 添加激活相关字段
expirationMode: fullKeyData.expirationMode || 'fixed',
isActivated: fullKeyData.isActivated === true || fullKeyData.isActivated === 'true',
activationDays: parseInt(fullKeyData.activationDays || 0),
activatedAt: fullKeyData.activatedAt || null,
permissions: fullKeyData.permissions,
// 使用统计(使用验证结果中的完整数据)
@@ -356,10 +370,12 @@ router.post('/api/user-stats', async (req, res) => {
concurrencyLimit: fullKeyData.concurrencyLimit || 0,
rateLimitWindow: fullKeyData.rateLimitWindow || 0,
rateLimitRequests: fullKeyData.rateLimitRequests || 0,
rateLimitCost: parseFloat(fullKeyData.rateLimitCost) || 0, // 新增:费用限制
dailyCostLimit: fullKeyData.dailyCostLimit || 0,
// 当前使用量
currentWindowRequests,
currentWindowTokens,
currentWindowCost, // 新增:当前窗口费用
currentDailyCost,
// 时间窗口信息
windowStartTime,
@@ -401,6 +417,317 @@ router.post('/api/user-stats', async (req, res) => {
}
})
// 📊 批量查询统计数据接口
router.post('/api/batch-stats', async (req, res) => {
try {
const { apiIds } = req.body
// 验证输入
if (!apiIds || !Array.isArray(apiIds) || apiIds.length === 0) {
return res.status(400).json({
error: 'Invalid input',
message: 'API IDs array is required'
})
}
// 限制最多查询 30 个
if (apiIds.length > 30) {
return res.status(400).json({
error: 'Too many keys',
message: 'Maximum 30 API keys can be queried at once'
})
}
// 验证所有 ID 格式
const uuidRegex = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i
const invalidIds = apiIds.filter((id) => !uuidRegex.test(id))
if (invalidIds.length > 0) {
return res.status(400).json({
error: 'Invalid API ID format',
message: `Invalid API IDs: ${invalidIds.join(', ')}`
})
}
const individualStats = []
const aggregated = {
totalKeys: apiIds.length,
activeKeys: 0,
usage: {
requests: 0,
inputTokens: 0,
outputTokens: 0,
cacheCreateTokens: 0,
cacheReadTokens: 0,
allTokens: 0,
cost: 0,
formattedCost: '$0.000000'
},
dailyUsage: {
requests: 0,
inputTokens: 0,
outputTokens: 0,
cacheCreateTokens: 0,
cacheReadTokens: 0,
allTokens: 0,
cost: 0,
formattedCost: '$0.000000'
},
monthlyUsage: {
requests: 0,
inputTokens: 0,
outputTokens: 0,
cacheCreateTokens: 0,
cacheReadTokens: 0,
allTokens: 0,
cost: 0,
formattedCost: '$0.000000'
}
}
// 并行查询所有 API Key 数据复用单key查询逻辑
const results = await Promise.allSettled(
apiIds.map(async (apiId) => {
const keyData = await redis.getApiKey(apiId)
if (!keyData || Object.keys(keyData).length === 0) {
return { error: 'Not found', apiId }
}
// 检查是否激活
if (keyData.isActive !== 'true') {
return { error: 'Disabled', apiId }
}
// 检查是否过期
if (keyData.expiresAt && new Date() > new Date(keyData.expiresAt)) {
return { error: 'Expired', apiId }
}
// 复用单key查询的逻辑获取使用统计
const usage = await redis.getUsageStats(apiId)
// 获取费用统计与单key查询一致
const costStats = await redis.getCostStats(apiId)
return {
apiId,
name: keyData.name,
description: keyData.description || '',
isActive: true,
createdAt: keyData.createdAt,
usage: usage.total || {},
dailyStats: {
...usage.daily,
cost: costStats.daily
},
monthlyStats: {
...usage.monthly,
cost: costStats.monthly
},
totalCost: costStats.total
}
})
)
// 处理结果并聚合
results.forEach((result) => {
if (result.status === 'fulfilled' && result.value && !result.value.error) {
const stats = result.value
aggregated.activeKeys++
// 聚合总使用量
if (stats.usage) {
aggregated.usage.requests += stats.usage.requests || 0
aggregated.usage.inputTokens += stats.usage.inputTokens || 0
aggregated.usage.outputTokens += stats.usage.outputTokens || 0
aggregated.usage.cacheCreateTokens += stats.usage.cacheCreateTokens || 0
aggregated.usage.cacheReadTokens += stats.usage.cacheReadTokens || 0
aggregated.usage.allTokens += stats.usage.allTokens || 0
}
// 聚合总费用
aggregated.usage.cost += stats.totalCost || 0
// 聚合今日使用量
aggregated.dailyUsage.requests += stats.dailyStats.requests || 0
aggregated.dailyUsage.inputTokens += stats.dailyStats.inputTokens || 0
aggregated.dailyUsage.outputTokens += stats.dailyStats.outputTokens || 0
aggregated.dailyUsage.cacheCreateTokens += stats.dailyStats.cacheCreateTokens || 0
aggregated.dailyUsage.cacheReadTokens += stats.dailyStats.cacheReadTokens || 0
aggregated.dailyUsage.allTokens += stats.dailyStats.allTokens || 0
aggregated.dailyUsage.cost += stats.dailyStats.cost || 0
// 聚合本月使用量
aggregated.monthlyUsage.requests += stats.monthlyStats.requests || 0
aggregated.monthlyUsage.inputTokens += stats.monthlyStats.inputTokens || 0
aggregated.monthlyUsage.outputTokens += stats.monthlyStats.outputTokens || 0
aggregated.monthlyUsage.cacheCreateTokens += stats.monthlyStats.cacheCreateTokens || 0
aggregated.monthlyUsage.cacheReadTokens += stats.monthlyStats.cacheReadTokens || 0
aggregated.monthlyUsage.allTokens += stats.monthlyStats.allTokens || 0
aggregated.monthlyUsage.cost += stats.monthlyStats.cost || 0
// 添加到个体统计
individualStats.push({
apiId: stats.apiId,
name: stats.name,
isActive: true,
usage: stats.usage,
dailyUsage: {
...stats.dailyStats,
formattedCost: CostCalculator.formatCost(stats.dailyStats.cost || 0)
},
monthlyUsage: {
...stats.monthlyStats,
formattedCost: CostCalculator.formatCost(stats.monthlyStats.cost || 0)
}
})
}
})
// 格式化费用显示
aggregated.usage.formattedCost = CostCalculator.formatCost(aggregated.usage.cost)
aggregated.dailyUsage.formattedCost = CostCalculator.formatCost(aggregated.dailyUsage.cost)
aggregated.monthlyUsage.formattedCost = CostCalculator.formatCost(aggregated.monthlyUsage.cost)
logger.api(`📊 Batch stats query for ${apiIds.length} keys from ${req.ip || 'unknown'}`)
return res.json({
success: true,
data: {
aggregated,
individual: individualStats
}
})
} catch (error) {
logger.error('❌ Failed to process batch stats query:', error)
return res.status(500).json({
error: 'Internal server error',
message: 'Failed to retrieve batch statistics'
})
}
})
// 📊 批量模型统计查询接口
router.post('/api/batch-model-stats', async (req, res) => {
try {
const { apiIds, period = 'daily' } = req.body
// 验证输入
if (!apiIds || !Array.isArray(apiIds) || apiIds.length === 0) {
return res.status(400).json({
error: 'Invalid input',
message: 'API IDs array is required'
})
}
// 限制最多查询 30 个
if (apiIds.length > 30) {
return res.status(400).json({
error: 'Too many keys',
message: 'Maximum 30 API keys can be queried at once'
})
}
const client = redis.getClientSafe()
const tzDate = redis.getDateInTimezone()
const today = redis.getDateStringInTimezone()
const currentMonth = `${tzDate.getFullYear()}-${String(tzDate.getMonth() + 1).padStart(2, '0')}`
const modelUsageMap = new Map()
// 并行查询所有 API Key 的模型统计
await Promise.all(
apiIds.map(async (apiId) => {
const pattern =
period === 'daily'
? `usage:${apiId}:model:daily:*:${today}`
: `usage:${apiId}:model:monthly:*:${currentMonth}`
const keys = await client.keys(pattern)
for (const key of keys) {
const match = key.match(
period === 'daily'
? /usage:.+:model:daily:(.+):\d{4}-\d{2}-\d{2}$/
: /usage:.+:model:monthly:(.+):\d{4}-\d{2}$/
)
if (!match) {
continue
}
const model = match[1]
const data = await client.hgetall(key)
if (data && Object.keys(data).length > 0) {
if (!modelUsageMap.has(model)) {
modelUsageMap.set(model, {
requests: 0,
inputTokens: 0,
outputTokens: 0,
cacheCreateTokens: 0,
cacheReadTokens: 0,
allTokens: 0
})
}
const modelUsage = modelUsageMap.get(model)
modelUsage.requests += parseInt(data.requests) || 0
modelUsage.inputTokens += parseInt(data.inputTokens) || 0
modelUsage.outputTokens += parseInt(data.outputTokens) || 0
modelUsage.cacheCreateTokens += parseInt(data.cacheCreateTokens) || 0
modelUsage.cacheReadTokens += parseInt(data.cacheReadTokens) || 0
modelUsage.allTokens += parseInt(data.allTokens) || 0
}
}
})
)
// 转换为数组并计算费用
const modelStats = []
for (const [model, usage] of modelUsageMap) {
const usageData = {
input_tokens: usage.inputTokens,
output_tokens: usage.outputTokens,
cache_creation_input_tokens: usage.cacheCreateTokens,
cache_read_input_tokens: usage.cacheReadTokens
}
const costData = CostCalculator.calculateCost(usageData, model)
modelStats.push({
model,
requests: usage.requests,
inputTokens: usage.inputTokens,
outputTokens: usage.outputTokens,
cacheCreateTokens: usage.cacheCreateTokens,
cacheReadTokens: usage.cacheReadTokens,
allTokens: usage.allTokens,
costs: costData.costs,
formatted: costData.formatted,
pricing: costData.pricing
})
}
// 按总 token 数降序排列
modelStats.sort((a, b) => b.allTokens - a.allTokens)
logger.api(`📊 Batch model stats query for ${apiIds.length} keys, period: ${period}`)
return res.json({
success: true,
data: modelStats,
period
})
} catch (error) {
logger.error('❌ Failed to process batch model stats query:', error)
return res.status(500).json({
error: 'Internal server error',
message: 'Failed to retrieve batch model statistics'
})
}
})
// 📊 用户模型统计查询接口 - 安全的自查询接口
router.post('/api/user-model-stats', async (req, res) => {
try {

View File

@@ -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')}`

View File

@@ -50,7 +50,7 @@ router.post('/messages', authenticateApiKey, async (req, res) => {
// 提取请求参数
const {
messages,
model = 'gemini-2.0-flash-exp',
model = 'gemini-2.5-flash',
temperature = 0.7,
max_tokens = 4096,
stream = false
@@ -217,7 +217,7 @@ router.get('/models', authenticateApiKey, async (req, res) => {
object: 'list',
data: [
{
id: 'gemini-2.0-flash-exp',
id: 'gemini-2.5-flash',
object: 'model',
created: Date.now() / 1000,
owned_by: 'google'
@@ -311,8 +311,8 @@ async function handleLoadCodeAssist(req, res) {
try {
const sessionHash = sessionHelper.generateSessionHash(req.body)
// 使用统一调度选择账号(传递请求的模型
const requestedModel = req.body.model
// 从路径参数或请求体中获取模型
const requestedModel = req.body.model || req.params.modelName || 'gemini-2.5-flash'
const { accountId } = await unifiedGeminiScheduler.selectAccountForApiKey(
req.apiKey,
sessionHash,
@@ -331,24 +331,40 @@ async function handleLoadCodeAssist(req, res) {
apiKeyId: req.apiKey?.id || 'unknown'
})
const client = await geminiAccountService.getOauthClient(accessToken, refreshToken)
// 根据账户配置决定项目ID
// 1. 如果账户有项目ID -> 使用账户的项目ID强制覆盖
// 2. 如果账户没有项目ID -> 传递 null移除项目ID
let effectiveProjectId = null
if (projectId) {
// 账户配置了项目ID强制使用它
effectiveProjectId = projectId
logger.info('Using account project ID for loadCodeAssist:', effectiveProjectId)
} else {
// 账户没有配置项目ID确保不传递项目ID
effectiveProjectId = null
logger.info('No project ID in account for loadCodeAssist, removing project parameter')
// 解析账户的代理配置
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 response = await geminiAccountService.loadCodeAssist(client, effectiveProjectId)
const client = await geminiAccountService.getOauthClient(accessToken, refreshToken, proxyConfig)
// 智能处理项目ID
// 1. 如果账户配置了项目ID -> 使用账户的项目ID覆盖请求中的
// 2. 如果账户没有项目ID -> 使用请求中的cloudaicompanionProject
// 3. 都没有 -> 传null
const effectiveProjectId = projectId || cloudaicompanionProject || null
logger.info('📋 loadCodeAssist项目ID处理逻辑', {
accountProjectId: projectId,
requestProjectId: cloudaicompanionProject,
effectiveProjectId,
decision: projectId
? '使用账户配置'
: cloudaicompanionProject
? '使用请求参数'
: '不使用项目ID'
})
const response = await geminiAccountService.loadCodeAssist(
client,
effectiveProjectId,
proxyConfig
)
res.json(response)
} catch (error) {
@@ -368,8 +384,8 @@ async function handleOnboardUser(req, res) {
const { tierId, cloudaicompanionProject, metadata } = req.body
const sessionHash = sessionHelper.generateSessionHash(req.body)
// 使用统一调度选择账号(传递请求的模型
const requestedModel = req.body.model
// 从路径参数或请求体中获取模型
const requestedModel = req.body.model || req.params.modelName || 'gemini-2.5-flash'
const { accountId } = await unifiedGeminiScheduler.selectAccountForApiKey(
req.apiKey,
sessionHash,
@@ -387,30 +403,43 @@ async function handleOnboardUser(req, res) {
apiKeyId: req.apiKey?.id || 'unknown'
})
const client = await geminiAccountService.getOauthClient(accessToken, refreshToken)
// 根据账户配置决定项目ID
// 1. 如果账户有项目ID -> 使用账户的项目ID强制覆盖
// 2. 如果账户没有项目ID -> 传递 null移除项目ID
let effectiveProjectId = null
if (projectId) {
// 账户配置了项目ID强制使用它
effectiveProjectId = projectId
logger.info('Using account project ID:', effectiveProjectId)
} else {
// 账户没有配置项目ID确保不传递项目ID即使客户端传了也要移除
effectiveProjectId = null
logger.info('No project ID in account, removing project parameter')
// 解析账户的代理配置
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覆盖请求中的
// 2. 如果账户没有项目ID -> 使用请求中的cloudaicompanionProject
// 3. 都没有 -> 传null
const effectiveProjectId = projectId || cloudaicompanionProject || null
logger.info('📋 onboardUser项目ID处理逻辑', {
accountProjectId: projectId,
requestProjectId: cloudaicompanionProject,
effectiveProjectId,
decision: projectId
? '使用账户配置'
: cloudaicompanionProject
? '使用请求参数'
: '不使用项目ID'
})
// 如果提供了 tierId直接调用 onboardUser
if (tierId) {
const response = await geminiAccountService.onboardUser(
client,
tierId,
effectiveProjectId, // 使用处理后的项目ID
metadata
metadata,
proxyConfig
)
res.json(response)
@@ -419,7 +448,8 @@ async function handleOnboardUser(req, res) {
const response = await geminiAccountService.setupUser(
client,
effectiveProjectId, // 使用处理后的项目ID
metadata
metadata,
proxyConfig
)
res.json(response)
@@ -439,7 +469,9 @@ async function handleCountTokens(req, res) {
try {
// 处理请求体结构,支持直接 contents 或 request.contents
const requestData = req.body.request || req.body
const { contents, model = 'gemini-2.0-flash-exp' } = requestData
const { contents } = requestData
// 从路径参数或请求体中获取模型名
const model = requestData.model || req.params.modelName || 'gemini-2.5-flash'
const sessionHash = sessionHelper.generateSessionHash(req.body)
// 验证必需参数
@@ -458,7 +490,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})`, {
@@ -467,8 +500,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) {
@@ -487,7 +530,9 @@ async function handleCountTokens(req, res) {
// 共用的 generateContent 处理函数
async function handleGenerateContent(req, res) {
try {
const { model, project, user_prompt_id, request: requestData } = req.body
const { project, user_prompt_id, request: requestData } = req.body
// 从路径参数或请求体中获取模型名
const model = req.body.model || req.params.modelName || 'gemini-2.5-flash'
const sessionHash = sessionHelper.generateSessionHash(req.body)
// 处理不同格式的请求
@@ -540,8 +585,6 @@ async function handleGenerateContent(req, res) {
apiKeyId: req.apiKey?.id || 'unknown'
})
const client = await geminiAccountService.getOauthClient(accessToken, refreshToken)
// 解析账户的代理配置
let proxyConfig = null
if (account.proxy) {
@@ -552,11 +595,26 @@ async function handleGenerateContent(req, res) {
}
}
const client = await geminiAccountService.getOauthClient(accessToken, refreshToken, proxyConfig)
// 智能处理项目ID
// 1. 如果账户配置了项目ID -> 使用账户的项目ID覆盖请求中的
// 2. 如果账户没有项目ID -> 使用请求中的项目ID如果有的话
// 3. 都没有 -> 传null
const effectiveProjectId = account.projectId || project || null
logger.info('📋 项目ID处理逻辑', {
accountProjectId: account.projectId,
requestProjectId: project,
effectiveProjectId,
decision: account.projectId ? '使用账户配置' : project ? '使用请求参数' : '不使用项目ID'
})
const response = await geminiAccountService.generateContent(
client,
{ model, request: actualRequestData },
user_prompt_id,
account.projectId, // 始终使用账户配置的项目ID忽略请求中的project
effectiveProjectId, // 使用智能决策的项目ID
req.apiKey?.id, // 使用 API Key ID 作为 session ID
proxyConfig // 传递代理配置
)
@@ -582,7 +640,7 @@ async function handleGenerateContent(req, res) {
}
}
res.json(response)
res.json(version === 'v1beta' ? response.response : response)
} catch (error) {
const version = req.path.includes('v1beta') ? 'v1beta' : 'v1internal'
// 打印详细的错误信息
@@ -610,7 +668,9 @@ async function handleStreamGenerateContent(req, res) {
let abortController = null
try {
const { model, project, user_prompt_id, request: requestData } = req.body
const { project, user_prompt_id, request: requestData } = req.body
// 从路径参数或请求体中获取模型名
const model = req.body.model || req.params.modelName || 'gemini-2.5-flash'
const sessionHash = sessionHelper.generateSessionHash(req.body)
// 处理不同格式的请求
@@ -674,8 +734,6 @@ async function handleStreamGenerateContent(req, res) {
}
})
const client = await geminiAccountService.getOauthClient(accessToken, refreshToken)
// 解析账户的代理配置
let proxyConfig = null
if (account.proxy) {
@@ -686,11 +744,26 @@ async function handleStreamGenerateContent(req, res) {
}
}
const client = await geminiAccountService.getOauthClient(accessToken, refreshToken, proxyConfig)
// 智能处理项目ID
// 1. 如果账户配置了项目ID -> 使用账户的项目ID覆盖请求中的
// 2. 如果账户没有项目ID -> 使用请求中的项目ID如果有的话
// 3. 都没有 -> 传null
const effectiveProjectId = account.projectId || project || null
logger.info('📋 流式请求项目ID处理逻辑', {
accountProjectId: account.projectId,
requestProjectId: project,
effectiveProjectId,
decision: account.projectId ? '使用账户配置' : project ? '使用请求参数' : '不使用项目ID'
})
const streamResponse = await geminiAccountService.generateContentStream(
client,
{ model, request: actualRequestData },
user_prompt_id,
account.projectId, // 始终使用账户配置的项目ID忽略请求中的project
effectiveProjectId, // 使用智能决策的项目ID
req.apiKey?.id, // 使用 API Key ID 作为 session ID
abortController.signal, // 传递中止信号
proxyConfig // 传递代理配置
@@ -702,8 +775,28 @@ async function handleStreamGenerateContent(req, res) {
res.setHeader('Connection', 'keep-alive')
res.setHeader('X-Accel-Buffering', 'no')
// SSE 解析函数
const parseSSELine = (line) => {
if (!line.startsWith('data: ')) {
return { type: 'other', line, data: null }
}
const jsonStr = line.substring(6).trim()
if (!jsonStr || jsonStr === '[DONE]') {
return { type: 'control', line, data: null, jsonStr }
}
try {
const data = JSON.parse(jsonStr)
return { type: 'data', line, data, jsonStr }
} catch (e) {
return { type: 'invalid', line, data: null, jsonStr, error: e }
}
}
// 处理流式响应并捕获usage数据
let buffer = ''
let streamBuffer = '' // 统一的流处理缓冲区
let totalUsage = {
promptTokenCount: 0,
candidatesTokenCount: 0,
@@ -715,32 +808,60 @@ async function handleStreamGenerateContent(req, res) {
try {
const chunkStr = chunk.toString()
// 直接转发数据到客户端
if (!res.destroyed) {
res.write(chunkStr)
if (!chunkStr.trim()) {
return
}
// 同时解析数据以捕获usage信息
buffer += chunkStr
const lines = buffer.split('\n')
buffer = lines.pop() || ''
// 使用统一缓冲区处理不完整的行
streamBuffer += chunkStr
const lines = streamBuffer.split('\n')
streamBuffer = lines.pop() || '' // 保留最后一个不完整的行
const processedLines = []
for (const line of lines) {
if (line.startsWith('data: ') && line.length > 6) {
try {
const jsonStr = line.slice(6)
if (jsonStr && jsonStr !== '[DONE]') {
const data = JSON.parse(jsonStr)
if (!line.trim()) {
continue // 跳过空行,不添加到处理队列
}
// 从响应中提取usage数据
if (data.response?.usageMetadata) {
totalUsage = data.response.usageMetadata
logger.debug('📊 Captured Gemini usage data:', totalUsage)
}
// 解析 SSE 行
const parsed = parseSSELine(line)
// 提取 usage 数据(适用于所有版本)
if (parsed.type === 'data' && parsed.data.response?.usageMetadata) {
totalUsage = parsed.data.response.usageMetadata
logger.debug('📊 Captured Gemini usage data:', totalUsage)
}
// 根据版本处理输出
if (version === 'v1beta') {
if (parsed.type === 'data') {
if (parsed.data.response) {
// 有 response 字段,只返回 response 的内容
processedLines.push(`data: ${JSON.stringify(parsed.data.response)}`)
} else {
// 没有 response 字段,返回整个数据对象
processedLines.push(`data: ${JSON.stringify(parsed.data)}`)
}
} catch (e) {
// 忽略解析错误
} else if (parsed.type === 'control') {
// 控制消息(如 [DONE])保持原样
processedLines.push(line)
}
// 跳过其他类型的行('other', 'invalid'
}
}
// 发送数据到客户端
if (version === 'v1beta') {
for (const line of processedLines) {
if (!res.destroyed) {
res.write(`${line}\n\n`)
}
}
} else {
// v1internal 直接转发原始数据
if (!res.destroyed) {
res.write(chunkStr)
}
}
} catch (error) {
@@ -840,4 +961,10 @@ router.post(
handleStreamGenerateContent
)
// 导出处理函数供标准路由使用
module.exports = router
module.exports.handleLoadCodeAssist = handleLoadCodeAssist
module.exports.handleOnboardUser = handleOnboardUser
module.exports.handleCountTokens = handleCountTokens
module.exports.handleGenerateContent = handleGenerateContent
module.exports.handleStreamGenerateContent = handleStreamGenerateContent

View File

@@ -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 格式并返回

View File

@@ -2,10 +2,12 @@ const express = require('express')
const axios = require('axios')
const router = express.Router()
const logger = require('../utils/logger')
const config = require('../../config/config')
const { authenticateApiKey } = require('../middleware/auth')
const claudeAccountService = require('../services/claudeAccountService')
const unifiedOpenAIScheduler = require('../services/unifiedOpenAIScheduler')
const openaiAccountService = require('../services/openaiAccountService')
const openaiResponsesAccountService = require('../services/openaiResponsesAccountService')
const openaiResponsesRelayService = require('../services/openaiResponsesRelayService')
const apiKeyService = require('../services/apiKeyService')
const crypto = require('crypto')
const ProxyHelper = require('../utils/proxyHelper')
@@ -34,33 +36,81 @@ async function getOpenAIAuthToken(apiKeyData, sessionId = null, requestedModel =
throw new Error('No available OpenAI account found')
}
// 获取账户详情
const account = await openaiAccountService.getAccount(result.accountId)
if (!account || !account.accessToken) {
throw new Error(`OpenAI account ${result.accountId} has no valid accessToken`)
}
// 根据账户类型获取账户详情
let account,
accessToken,
proxy = null
// 解密 accessToken
const accessToken = claudeAccountService._decryptSensitiveData(account.accessToken)
if (!accessToken) {
throw new Error('Failed to decrypt OpenAI accessToken')
}
// 解析代理配置
let proxy = null
if (account.proxy) {
try {
proxy = typeof account.proxy === 'string' ? JSON.parse(account.proxy) : account.proxy
} catch (e) {
logger.warn('Failed to parse proxy configuration:', e)
if (result.accountType === 'openai-responses') {
// 处理 OpenAI-Responses 账户
account = await openaiResponsesAccountService.getAccount(result.accountId)
if (!account || !account.apiKey) {
throw new Error(`OpenAI-Responses account ${result.accountId} has no valid apiKey`)
}
// OpenAI-Responses 账户不需要 accessToken直接返回账户信息
accessToken = null // OpenAI-Responses 使用账户内的 apiKey
// 解析代理配置
if (account.proxy) {
try {
proxy = typeof account.proxy === 'string' ? JSON.parse(account.proxy) : account.proxy
} catch (e) {
logger.warn('Failed to parse proxy configuration:', e)
}
}
logger.info(`Selected OpenAI-Responses account: ${account.name} (${result.accountId})`)
} else {
// 处理普通 OpenAI 账户
account = await openaiAccountService.getAccount(result.accountId)
if (!account || !account.accessToken) {
throw new Error(`OpenAI account ${result.accountId} has no valid accessToken`)
}
// 检查 token 是否过期并自动刷新(双重保护)
if (openaiAccountService.isTokenExpired(account)) {
if (account.refreshToken) {
logger.info(`🔄 Token expired, auto-refreshing for account ${account.name} (fallback)`)
try {
await openaiAccountService.refreshAccountToken(result.accountId)
// 重新获取更新后的账户
account = await openaiAccountService.getAccount(result.accountId)
logger.info(`✅ Token refreshed successfully in route handler`)
} catch (refreshError) {
logger.error(`Failed to refresh token for ${account.name}:`, refreshError)
throw new Error(`Token expired and refresh failed: ${refreshError.message}`)
}
} else {
throw new Error(
`Token expired and no refresh token available for account ${account.name}`
)
}
}
// 解密 accessTokenaccount.accessToken 是加密的)
accessToken = openaiAccountService.decrypt(account.accessToken)
if (!accessToken) {
throw new Error('Failed to decrypt OpenAI accessToken')
}
// 解析代理配置
if (account.proxy) {
try {
proxy = typeof account.proxy === 'string' ? JSON.parse(account.proxy) : account.proxy
} catch (e) {
logger.warn('Failed to parse proxy configuration:', e)
}
}
logger.info(`Selected OpenAI account: ${account.name} (${result.accountId})`)
}
logger.info(`Selected OpenAI account: ${account.name} (${result.accountId})`)
return {
accessToken,
accountId: result.accountId,
accountName: account.name,
accountType: result.accountType,
proxy,
account
}
@@ -70,7 +120,8 @@ async function getOpenAIAuthToken(apiKeyData, sessionId = null, requestedModel =
}
}
router.post('/responses', authenticateApiKey, async (req, res) => {
// 主处理函数,供两个路由共享
const handleResponses = async (req, res) => {
let upstream = null
try {
// 从中间件获取 API Key 数据
@@ -132,9 +183,16 @@ router.post('/responses', authenticateApiKey, async (req, res) => {
accessToken,
accountId,
accountName: _accountName,
accountType,
proxy,
account
} = await getOpenAIAuthToken(apiKeyData, sessionId, requestedModel)
// 如果是 OpenAI-Responses 账户,使用专门的中继服务处理
if (accountType === 'openai-responses') {
logger.info(`🔀 Using OpenAI-Responses relay service for account: ${account.name}`)
return await openaiResponsesRelayService.handleRequest(req, res, account, apiKeyData)
}
// 基于白名单构造上游所需的请求头,确保键为小写且值受控
const incoming = req.headers || {}
@@ -161,7 +219,7 @@ router.post('/responses', authenticateApiKey, async (req, res) => {
// 配置请求选项
const axiosConfig = {
headers,
timeout: 60000,
timeout: config.requestTimeout || 600000,
validateStatus: () => true
}
@@ -188,6 +246,96 @@ router.post('/responses', authenticateApiKey, async (req, res) => {
axiosConfig
)
}
// 处理 429 限流错误
if (upstream.status === 429) {
logger.warn(`🚫 Rate limit detected for OpenAI account ${accountId} (Codex API)`)
// 解析响应体中的限流信息
let resetsInSeconds = null
let errorData = null
try {
// 对于429错误无论是否是流式请求响应都会是完整的JSON错误对象
if (isStream && upstream.data) {
// 流式响应需要先收集数据
const chunks = []
await new Promise((resolve, reject) => {
upstream.data.on('data', (chunk) => chunks.push(chunk))
upstream.data.on('end', resolve)
upstream.data.on('error', reject)
// 设置超时防止无限等待
setTimeout(resolve, 5000)
})
const fullResponse = Buffer.concat(chunks).toString()
try {
errorData = JSON.parse(fullResponse)
} catch (e) {
logger.error('Failed to parse 429 error response:', e)
logger.debug('Raw response:', fullResponse)
}
} else {
// 非流式响应直接使用data
errorData = upstream.data
}
// 提取重置时间
if (errorData && errorData.error && errorData.error.resets_in_seconds) {
resetsInSeconds = errorData.error.resets_in_seconds
logger.info(
`🕐 Codex rate limit will reset in ${resetsInSeconds} seconds (${Math.ceil(resetsInSeconds / 60)} minutes / ${Math.ceil(resetsInSeconds / 3600)} hours)`
)
} else {
logger.warn(
'⚠️ Could not extract resets_in_seconds from 429 response, using default 60 minutes'
)
}
} catch (e) {
logger.error('⚠️ Failed to parse rate limit error:', e)
}
// 标记账户为限流状态
await unifiedOpenAIScheduler.markAccountRateLimited(
accountId,
'openai',
sessionId ? crypto.createHash('sha256').update(sessionId).digest('hex') : null,
resetsInSeconds
)
// 返回错误响应给客户端
const errorResponse = errorData || {
error: {
type: 'usage_limit_reached',
message: 'The usage limit has been reached',
resets_in_seconds: resetsInSeconds
}
}
if (isStream) {
// 流式响应也需要设置正确的状态码
res.status(429)
res.setHeader('Content-Type', 'text/event-stream')
res.setHeader('Cache-Control', 'no-cache')
res.setHeader('Connection', 'keep-alive')
res.write(`data: ${JSON.stringify(errorResponse)}\n\n`)
res.end()
} else {
res.status(429).json(errorResponse)
}
return
} else if (upstream.status === 200 || upstream.status === 201) {
// 请求成功,检查并移除限流状态
const isRateLimited = await unifiedOpenAIScheduler.isAccountRateLimited(accountId)
if (isRateLimited) {
logger.info(
`✅ Removing rate limit for OpenAI account ${accountId} after successful request`
)
await unifiedOpenAIScheduler.removeAccountRateLimit(accountId, 'openai')
}
}
res.status(upstream.status)
if (isStream) {
@@ -222,6 +370,8 @@ router.post('/responses', authenticateApiKey, async (req, res) => {
let usageData = null
let actualModel = null
let usageReported = false
let rateLimitDetected = false
let rateLimitResetsInSeconds = null
if (!isStream) {
// 非流式响应处理
@@ -300,6 +450,17 @@ router.post('/responses', authenticateApiKey, async (req, res) => {
logger.debug('📊 Captured OpenAI usage data:', usageData)
}
}
// 检查是否有限流错误
if (eventData.error && eventData.error.type === 'usage_limit_reached') {
rateLimitDetected = true
if (eventData.error.resets_in_seconds) {
rateLimitResetsInSeconds = eventData.error.resets_in_seconds
logger.warn(
`🚫 Rate limit detected in stream, resets in ${rateLimitResetsInSeconds} seconds`
)
}
}
} catch (e) {
// 忽略解析错误
}
@@ -371,6 +532,26 @@ router.post('/responses', authenticateApiKey, async (req, res) => {
}
}
// 如果在流式响应中检测到限流
if (rateLimitDetected) {
logger.warn(`🚫 Processing rate limit for OpenAI account ${accountId} from stream`)
await unifiedOpenAIScheduler.markAccountRateLimited(
accountId,
'openai',
sessionId ? crypto.createHash('sha256').update(sessionId).digest('hex') : null,
rateLimitResetsInSeconds
)
} else if (upstream.status === 200) {
// 流式请求成功,检查并移除限流状态
const isRateLimited = await unifiedOpenAIScheduler.isAccountRateLimited(accountId)
if (isRateLimited) {
logger.info(
`✅ Removing rate limit for OpenAI account ${accountId} after successful stream`
)
await unifiedOpenAIScheduler.removeAccountRateLimit(accountId, 'openai')
}
}
res.end()
})
@@ -402,7 +583,11 @@ router.post('/responses', authenticateApiKey, async (req, res) => {
res.status(status).json({ error: { message } })
}
}
})
}
// 注册两个路由路径,都使用相同的处理函数
router.post('/responses', authenticateApiKey, handleResponses)
router.post('/v1/responses', authenticateApiKey, handleResponses)
// 使用情况统计端点
router.get('/usage', authenticateApiKey, async (req, res) => {

View File

@@ -0,0 +1,638 @@
const express = require('express')
const router = express.Router()
const { authenticateApiKey } = require('../middleware/auth')
const logger = require('../utils/logger')
const geminiAccountService = require('../services/geminiAccountService')
const unifiedGeminiScheduler = require('../services/unifiedGeminiScheduler')
const apiKeyService = require('../services/apiKeyService')
const sessionHelper = require('../utils/sessionHelper')
// 导入 geminiRoutes 中导出的处理函数
const { handleLoadCodeAssist, handleOnboardUser, handleCountTokens } = require('./geminiRoutes')
// 标准 Gemini API 路由处理器
// 这些路由将挂载在 /gemini 路径下,处理标准 Gemini API 格式的请求
// 标准格式: /gemini/v1beta/models/{model}:generateContent
// 专门处理标准 Gemini API 格式的 generateContent
async function handleStandardGenerateContent(req, res) {
try {
// 从路径参数中获取模型名
const model = req.params.modelName || 'gemini-2.0-flash-exp'
const sessionHash = sessionHelper.generateSessionHash(req.body)
// 标准 Gemini API 请求体直接包含 contents 等字段
const { contents, generationConfig, safetySettings, systemInstruction } = req.body
// 验证必需参数
if (!contents || !Array.isArray(contents) || contents.length === 0) {
return res.status(400).json({
error: {
message: 'Contents array is required',
type: 'invalid_request_error'
}
})
}
// 构建内部 API 需要的请求格式
const actualRequestData = {
contents,
generationConfig: generationConfig || {
temperature: 0.7,
maxOutputTokens: 4096,
topP: 0.95,
topK: 40
}
}
// 只有在 safetySettings 存在且非空时才添加
if (safetySettings && safetySettings.length > 0) {
actualRequestData.safetySettings = safetySettings
}
// 如果有 system instruction修正格式并添加到请求体
// Gemini CLI 的内部 API 需要 role: "user" 字段
if (systemInstruction) {
// 确保 systemInstruction 格式正确
if (typeof systemInstruction === 'string' && systemInstruction.trim()) {
actualRequestData.systemInstruction = {
role: 'user', // Gemini CLI 内部 API 需要这个字段
parts: [{ text: systemInstruction }]
}
} else if (systemInstruction.parts && systemInstruction.parts.length > 0) {
// 检查是否有实际内容
const hasContent = systemInstruction.parts.some(
(part) => part.text && part.text.trim() !== ''
)
if (hasContent) {
// 添加 role 字段Gemini CLI 格式)
actualRequestData.systemInstruction = {
role: 'user', // Gemini CLI 内部 API 需要这个字段
parts: systemInstruction.parts
}
}
}
}
// 使用统一调度选择账号
const { accountId } = await unifiedGeminiScheduler.selectAccountForApiKey(
req.apiKey,
sessionHash,
model
)
const account = await geminiAccountService.getAccount(accountId)
const { accessToken, refreshToken } = account
const version = req.path.includes('v1beta') ? 'v1beta' : 'v1'
logger.info(`Standard Gemini API generateContent request (${version})`, {
model,
projectId: account.projectId,
apiKeyId: req.apiKey?.id || 'unknown'
})
// 解析账户的代理配置
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如果有的话
const effectiveProjectId = account.projectId || null
logger.info('📋 Standard API 项目ID处理逻辑', {
accountProjectId: account.projectId,
effectiveProjectId,
decision: account.projectId ? '使用账户配置' : '不使用项目ID'
})
// 生成一个符合 Gemini CLI 格式的 user_prompt_id
const userPromptId = `${require('crypto').randomUUID()}########0`
// 调用内部 APIcloudcode-pa
const response = await geminiAccountService.generateContent(
client,
{ model, request: actualRequestData },
userPromptId, // 使用生成的 user_prompt_id
effectiveProjectId || 'oceanic-graph-cgcz4', // 如果没有项目ID使用默认值
req.apiKey?.id, // 使用 API Key ID 作为 session ID
proxyConfig
)
// 记录使用统计
if (response?.response?.usageMetadata) {
try {
const usage = response.response.usageMetadata
await apiKeyService.recordUsage(
req.apiKey.id,
usage.promptTokenCount || 0,
usage.candidatesTokenCount || 0,
0, // cacheCreateTokens
0, // cacheReadTokens
model,
account.id
)
logger.info(
`📊 Recorded Gemini usage - Input: ${usage.promptTokenCount}, Output: ${usage.candidatesTokenCount}, Total: ${usage.totalTokenCount}`
)
} catch (error) {
logger.error('Failed to record Gemini usage:', error)
}
}
// 返回标准 Gemini API 格式的响应
// 内部 API 返回的是 { response: {...} } 格式,需要提取并过滤
if (response.response) {
// 过滤掉 thought 部分(这是内部 API 特有的)
const standardResponse = { ...response.response }
if (standardResponse.candidates) {
standardResponse.candidates = standardResponse.candidates.map((candidate) => {
if (candidate.content && candidate.content.parts) {
// 过滤掉 thought: true 的 parts
const filteredParts = candidate.content.parts.filter((part) => !part.thought)
return {
...candidate,
content: {
...candidate.content,
parts: filteredParts
}
}
}
return candidate
})
}
res.json(standardResponse)
} else {
res.json(response)
}
} catch (error) {
logger.error(`Error in standard generateContent endpoint`, {
message: error.message,
status: error.response?.status,
statusText: error.response?.statusText,
responseData: error.response?.data,
stack: error.stack
})
res.status(500).json({
error: {
message: error.message || 'Internal server error',
type: 'api_error'
}
})
}
}
// 专门处理标准 Gemini API 格式的 streamGenerateContent
async function handleStandardStreamGenerateContent(req, res) {
let abortController = null
try {
// 从路径参数中获取模型名
const model = req.params.modelName || 'gemini-2.0-flash-exp'
const sessionHash = sessionHelper.generateSessionHash(req.body)
// 标准 Gemini API 请求体直接包含 contents 等字段
const { contents, generationConfig, safetySettings, systemInstruction } = req.body
// 验证必需参数
if (!contents || !Array.isArray(contents) || contents.length === 0) {
return res.status(400).json({
error: {
message: 'Contents array is required',
type: 'invalid_request_error'
}
})
}
// 构建内部 API 需要的请求格式
const actualRequestData = {
contents,
generationConfig: generationConfig || {
temperature: 0.7,
maxOutputTokens: 4096,
topP: 0.95,
topK: 40
}
}
// 只有在 safetySettings 存在且非空时才添加
if (safetySettings && safetySettings.length > 0) {
actualRequestData.safetySettings = safetySettings
}
// 如果有 system instruction修正格式并添加到请求体
// Gemini CLI 的内部 API 需要 role: "user" 字段
if (systemInstruction) {
// 确保 systemInstruction 格式正确
if (typeof systemInstruction === 'string' && systemInstruction.trim()) {
actualRequestData.systemInstruction = {
role: 'user', // Gemini CLI 内部 API 需要这个字段
parts: [{ text: systemInstruction }]
}
} else if (systemInstruction.parts && systemInstruction.parts.length > 0) {
// 检查是否有实际内容
const hasContent = systemInstruction.parts.some(
(part) => part.text && part.text.trim() !== ''
)
if (hasContent) {
// 添加 role 字段Gemini CLI 格式)
actualRequestData.systemInstruction = {
role: 'user', // Gemini CLI 内部 API 需要这个字段
parts: systemInstruction.parts
}
}
}
}
// 使用统一调度选择账号
const { accountId } = await unifiedGeminiScheduler.selectAccountForApiKey(
req.apiKey,
sessionHash,
model
)
const account = await geminiAccountService.getAccount(accountId)
const { accessToken, refreshToken } = account
const version = req.path.includes('v1beta') ? 'v1beta' : 'v1'
logger.info(`Standard Gemini API streamGenerateContent request (${version})`, {
model,
projectId: account.projectId,
apiKeyId: req.apiKey?.id || 'unknown'
})
// 创建中止控制器
abortController = new AbortController()
// 处理客户端断开连接
req.on('close', () => {
if (abortController && !abortController.signal.aborted) {
logger.info('Client disconnected, aborting stream request')
abortController.abort()
}
})
// 解析账户的代理配置
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如果有的话
const effectiveProjectId = account.projectId || null
logger.info('📋 Standard API 流式项目ID处理逻辑', {
accountProjectId: account.projectId,
effectiveProjectId,
decision: account.projectId ? '使用账户配置' : '不使用项目ID'
})
// 生成一个符合 Gemini CLI 格式的 user_prompt_id
const userPromptId = `${require('crypto').randomUUID()}########0`
// 调用内部 APIcloudcode-pa的流式接口
const streamResponse = await geminiAccountService.generateContentStream(
client,
{ model, request: actualRequestData },
userPromptId, // 使用生成的 user_prompt_id
effectiveProjectId || 'oceanic-graph-cgcz4', // 如果没有项目ID使用默认值
req.apiKey?.id, // 使用 API Key ID 作为 session ID
abortController.signal,
proxyConfig
)
// 设置 SSE 响应头
res.setHeader('Content-Type', 'text/event-stream')
res.setHeader('Cache-Control', 'no-cache')
res.setHeader('Connection', 'keep-alive')
res.setHeader('X-Accel-Buffering', 'no')
// 处理流式响应并捕获usage数据
let totalUsage = {
promptTokenCount: 0,
candidatesTokenCount: 0,
totalTokenCount: 0
}
streamResponse.on('data', (chunk) => {
try {
if (!res.destroyed) {
const chunkStr = chunk.toString()
// 处理 SSE 格式的数据
const lines = chunkStr.split('\n')
for (const line of lines) {
if (line.startsWith('data: ')) {
const jsonStr = line.substring(6).trim()
if (jsonStr && jsonStr !== '[DONE]') {
try {
const data = JSON.parse(jsonStr)
// 捕获 usage 数据
if (data.response?.usageMetadata) {
totalUsage = data.response.usageMetadata
}
// 转换格式:移除 response 包装,直接返回标准 Gemini API 格式
if (data.response) {
// 过滤掉 thought 部分(这是内部 API 特有的)
if (data.response.candidates) {
const filteredCandidates = data.response.candidates
.map((candidate) => {
if (candidate.content && candidate.content.parts) {
// 过滤掉 thought: true 的 parts
const filteredParts = candidate.content.parts.filter(
(part) => !part.thought
)
if (filteredParts.length > 0) {
return {
...candidate,
content: {
...candidate.content,
parts: filteredParts
}
}
}
return null
}
return candidate
})
.filter(Boolean)
// 只有当有有效内容时才发送
if (filteredCandidates.length > 0 || data.response.usageMetadata) {
const standardResponse = {
candidates: filteredCandidates,
...(data.response.usageMetadata && {
usageMetadata: data.response.usageMetadata
}),
...(data.response.modelVersion && {
modelVersion: data.response.modelVersion
}),
...(data.response.createTime && { createTime: data.response.createTime }),
...(data.response.responseId && { responseId: data.response.responseId })
}
res.write(`data: ${JSON.stringify(standardResponse)}\n\n`)
}
}
} else {
// 如果没有 response 包装,直接发送
res.write(`data: ${JSON.stringify(data)}\n\n`)
}
} catch (e) {
// 忽略解析错误
}
} else if (jsonStr === '[DONE]') {
// 保持 [DONE] 标记
res.write(`${line}\n\n`)
}
}
}
}
} catch (error) {
logger.error('Error processing stream chunk:', error)
}
})
streamResponse.on('end', async () => {
logger.info('Stream completed successfully')
// 记录使用统计
if (totalUsage.totalTokenCount > 0) {
try {
await apiKeyService.recordUsage(
req.apiKey.id,
totalUsage.promptTokenCount || 0,
totalUsage.candidatesTokenCount || 0,
0, // cacheCreateTokens
0, // cacheReadTokens
model,
account.id
)
logger.info(
`📊 Recorded Gemini stream usage - Input: ${totalUsage.promptTokenCount}, Output: ${totalUsage.candidatesTokenCount}, Total: ${totalUsage.totalTokenCount}`
)
} catch (error) {
logger.error('Failed to record Gemini usage:', error)
}
}
res.end()
})
streamResponse.on('error', (error) => {
logger.error('Stream error:', error)
if (!res.headersSent) {
res.status(500).json({
error: {
message: error.message || 'Stream error',
type: 'api_error'
}
})
} else {
res.end()
}
})
} catch (error) {
logger.error(`Error in standard streamGenerateContent endpoint`, {
message: error.message,
status: error.response?.status,
statusText: error.response?.statusText,
responseData: error.response?.data,
stack: error.stack
})
if (!res.headersSent) {
res.status(500).json({
error: {
message: error.message || 'Internal server error',
type: 'api_error'
}
})
}
} finally {
// 清理资源
if (abortController) {
abortController = null
}
}
}
// v1beta 版本的标准路由 - 支持动态模型名称
router.post('/v1beta/models/:modelName\\:loadCodeAssist', authenticateApiKey, (req, res, next) => {
logger.info(`Standard Gemini API request: ${req.method} ${req.originalUrl}`)
handleLoadCodeAssist(req, res, next)
})
router.post('/v1beta/models/:modelName\\:onboardUser', authenticateApiKey, (req, res, next) => {
logger.info(`Standard Gemini API request: ${req.method} ${req.originalUrl}`)
handleOnboardUser(req, res, next)
})
router.post('/v1beta/models/:modelName\\:countTokens', authenticateApiKey, (req, res, next) => {
logger.info(`Standard Gemini API request: ${req.method} ${req.originalUrl}`)
handleCountTokens(req, res, next)
})
// 使用专门的处理函数处理标准 Gemini API 格式
router.post(
'/v1beta/models/:modelName\\:generateContent',
authenticateApiKey,
handleStandardGenerateContent
)
router.post(
'/v1beta/models/:modelName\\:streamGenerateContent',
authenticateApiKey,
handleStandardStreamGenerateContent
)
// v1 版本的标准路由(为了完整性,虽然 Gemini 主要使用 v1beta
router.post(
'/v1/models/:modelName\\:generateContent',
authenticateApiKey,
handleStandardGenerateContent
)
router.post(
'/v1/models/:modelName\\:streamGenerateContent',
authenticateApiKey,
handleStandardStreamGenerateContent
)
router.post('/v1/models/:modelName\\:countTokens', authenticateApiKey, (req, res, next) => {
logger.info(`Standard Gemini API request (v1): ${req.method} ${req.originalUrl}`)
handleCountTokens(req, res, next)
})
// v1internal 版本的标准路由(这些使用原有的处理函数,因为格式不同)
router.post('/v1internal\\:loadCodeAssist', authenticateApiKey, (req, res, next) => {
logger.info(`Standard Gemini API request (v1internal): ${req.method} ${req.originalUrl}`)
handleLoadCodeAssist(req, res, next)
})
router.post('/v1internal\\:onboardUser', authenticateApiKey, (req, res, next) => {
logger.info(`Standard Gemini API request (v1internal): ${req.method} ${req.originalUrl}`)
handleOnboardUser(req, res, next)
})
router.post('/v1internal\\:countTokens', authenticateApiKey, (req, res, next) => {
logger.info(`Standard Gemini API request (v1internal): ${req.method} ${req.originalUrl}`)
handleCountTokens(req, res, next)
})
// v1internal 使用不同的处理逻辑,因为它们不包含模型在 URL 中
router.post('/v1internal\\:generateContent', authenticateApiKey, (req, res, next) => {
logger.info(`Standard Gemini API request (v1internal): ${req.method} ${req.originalUrl}`)
// v1internal 格式不同,使用原有的处理函数
const { handleGenerateContent } = require('./geminiRoutes')
handleGenerateContent(req, res, next)
})
router.post('/v1internal\\:streamGenerateContent', authenticateApiKey, (req, res, next) => {
logger.info(`Standard Gemini API request (v1internal): ${req.method} ${req.originalUrl}`)
// v1internal 格式不同,使用原有的处理函数
const { handleStreamGenerateContent } = require('./geminiRoutes')
handleStreamGenerateContent(req, res, next)
})
// 添加标准 Gemini API 的模型列表端点
router.get('/v1beta/models', authenticateApiKey, async (req, res) => {
try {
logger.info('Standard Gemini API models request')
// 直接调用 geminiRoutes 中的模型处理逻辑
const geminiRoutes = require('./geminiRoutes')
const modelHandler = geminiRoutes.stack.find(
(layer) => layer.route && layer.route.path === '/models' && layer.route.methods.get
)
if (modelHandler && modelHandler.route.stack[1]) {
// 调用处理函数(跳过第一个 authenticateApiKey 中间件)
modelHandler.route.stack[1].handle(req, res)
} else {
res.status(500).json({ error: 'Models handler not found' })
}
} catch (error) {
logger.error('Error in standard models endpoint:', error)
res.status(500).json({
error: {
message: 'Failed to retrieve models',
type: 'api_error'
}
})
}
})
router.get('/v1/models', authenticateApiKey, async (req, res) => {
try {
logger.info('Standard Gemini API models request (v1)')
// 直接调用 geminiRoutes 中的模型处理逻辑
const geminiRoutes = require('./geminiRoutes')
const modelHandler = geminiRoutes.stack.find(
(layer) => layer.route && layer.route.path === '/models' && layer.route.methods.get
)
if (modelHandler && modelHandler.route.stack[1]) {
modelHandler.route.stack[1].handle(req, res)
} else {
res.status(500).json({ error: 'Models handler not found' })
}
} catch (error) {
logger.error('Error in standard models endpoint (v1):', error)
res.status(500).json({
error: {
message: 'Failed to retrieve models',
type: 'api_error'
}
})
}
})
// 添加模型详情端点
router.get('/v1beta/models/:modelName', authenticateApiKey, (req, res) => {
const { modelName } = req.params
logger.info(`Standard Gemini API model details request: ${modelName}`)
res.json({
name: `models/${modelName}`,
version: '001',
displayName: modelName,
description: `Gemini model: ${modelName}`,
inputTokenLimit: 1048576,
outputTokenLimit: 8192,
supportedGenerationMethods: ['generateContent', 'streamGenerateContent', 'countTokens'],
temperature: 1.0,
topP: 0.95,
topK: 40
})
})
router.get('/v1/models/:modelName', authenticateApiKey, (req, res) => {
const { modelName } = req.params
logger.info(`Standard Gemini API model details request (v1): ${modelName}`)
res.json({
name: `models/${modelName}`,
version: '001',
displayName: modelName,
description: `Gemini model: ${modelName}`,
inputTokenLimit: 1048576,
outputTokenLimit: 8192,
supportedGenerationMethods: ['generateContent', 'streamGenerateContent', 'countTokens'],
temperature: 1.0,
topP: 0.95,
topK: 40
})
})
logger.info('Standard Gemini API routes initialized')
module.exports = router

748
src/routes/userRoutes.js Normal file
View File

@@ -0,0 +1,748 @@
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 inputValidator = require('../utils/inputValidator')
const { RateLimiterRedis } = require('rate-limiter-flexible')
const redis = require('../models/redis')
const { authenticateUser, authenticateUserOrAdmin, requireAdmin } = require('../middleware/auth')
// 🚦 配置登录速率限制
// 只基于IP地址限制避免攻击者恶意锁定特定账户
// 延迟初始化速率限制器,确保 Redis 已连接
let ipRateLimiter = null
let strictIpRateLimiter = null
// 初始化速率限制器函数
function initRateLimiters() {
if (!ipRateLimiter) {
try {
const redisClient = redis.getClientSafe()
// IP地址速率限制 - 正常限制
ipRateLimiter = new RateLimiterRedis({
storeClient: redisClient,
keyPrefix: 'login_ip_limiter',
points: 30, // 每个IP允许30次尝试
duration: 900, // 15分钟窗口期
blockDuration: 900 // 超限后封禁15分钟
})
// IP地址速率限制 - 严格限制(用于检测暴力破解)
strictIpRateLimiter = new RateLimiterRedis({
storeClient: redisClient,
keyPrefix: 'login_ip_strict',
points: 100, // 每个IP允许100次尝试
duration: 3600, // 1小时窗口期
blockDuration: 3600 // 超限后封禁1小时
})
} catch (error) {
logger.error('❌ 初始化速率限制器失败:', error)
// 速率限制器初始化失败时继续运行,但记录错误
}
}
return { ipRateLimiter, strictIpRateLimiter }
}
// 🔐 用户登录端点
router.post('/login', async (req, res) => {
try {
const { username, password } = req.body
const clientIp = req.ip || req.connection.remoteAddress || 'unknown'
// 初始化速率限制器(如果尚未初始化)
const limiters = initRateLimiters()
// 检查IP速率限制 - 基础限制
if (limiters.ipRateLimiter) {
try {
await limiters.ipRateLimiter.consume(clientIp)
} catch (rateLimiterRes) {
const retryAfter = Math.round(rateLimiterRes.msBeforeNext / 1000) || 900
logger.security(`🚫 Login rate limit exceeded for IP: ${clientIp}`)
res.set('Retry-After', String(retryAfter))
return res.status(429).json({
error: 'Too many requests',
message: `Too many login attempts from this IP. Please try again later.`
})
}
}
// 检查IP速率限制 - 严格限制(防止暴力破解)
if (limiters.strictIpRateLimiter) {
try {
await limiters.strictIpRateLimiter.consume(clientIp)
} catch (rateLimiterRes) {
const retryAfter = Math.round(rateLimiterRes.msBeforeNext / 1000) || 3600
logger.security(`🚫 Strict rate limit exceeded for IP: ${clientIp} - possible brute force`)
res.set('Retry-After', String(retryAfter))
return res.status(429).json({
error: 'Too many requests',
message: 'Too many login attempts detected. Access temporarily blocked.'
})
}
}
if (!username || !password) {
return res.status(400).json({
error: 'Missing credentials',
message: 'Username and password are required'
})
}
// 验证输入格式
let validatedUsername
try {
validatedUsername = inputValidator.validateUsername(username)
inputValidator.validatePassword(password)
} catch (validationError) {
return res.status(400).json({
error: 'Invalid input',
message: validationError.message
})
}
// 检查用户管理是否启用
if (!config.userManagement.enabled) {
return res.status(503).json({
error: 'Service unavailable',
message: 'User management is not enabled'
})
}
// 检查LDAP是否启用
if (!config.ldap || !config.ldap.enabled) {
return res.status(503).json({
error: 'Service unavailable',
message: 'LDAP authentication is not enabled'
})
}
// 尝试LDAP认证
const authResult = await ldapService.authenticateUserCredentials(validatedUsername, password)
if (!authResult.success) {
// 登录失败
logger.info(`🚫 Failed login attempt for user: ${validatedUsername} from IP: ${clientIp}`)
return res.status(401).json({
error: 'Authentication failed',
message: authResult.message
})
}
// 登录成功
logger.info(`✅ User login successful: ${validatedUsername} from IP: ${clientIp}`)
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,
allowUserDeleteApiKeys: config.userManagement.allowUserDeleteApiKeys
}
})
} 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: 'all'
}
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 Keys
if (!config.userManagement.allowUserDeleteApiKeys) {
return res.status(403).json({
error: 'Operation not allowed',
message:
'Users are not allowed to delete their own API keys. Please contact an administrator.'
})
}
// 检查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

View File

@@ -4,6 +4,7 @@ const logger = require('../utils/logger')
const webhookService = require('../services/webhookService')
const webhookConfigService = require('../services/webhookConfigService')
const { authenticateAdmin } = require('../middleware/auth')
const { getISOStringWithTimezone } = require('../utils/dateHelper')
// 获取webhook配置
router.get('/config', authenticateAdmin, async (req, res) => {
@@ -114,27 +115,99 @@ router.post('/platforms/:id/toggle', authenticateAdmin, async (req, res) => {
// 测试Webhook连通性
router.post('/test', authenticateAdmin, async (req, res) => {
try {
const { url, type = 'custom', secret, enableSign } = req.body
const {
url,
type = 'custom',
secret,
enableSign,
deviceKey,
serverUrl,
level,
sound,
group,
// SMTP 相关字段
host,
port,
secure,
user,
pass,
from,
to,
ignoreTLS
} = req.body
if (!url) {
return res.status(400).json({
error: 'Missing webhook URL',
message: '请提供webhook URL'
})
// Bark平台特殊处理
if (type === 'bark') {
if (!deviceKey) {
return res.status(400).json({
error: 'Missing device key',
message: '请提供Bark设备密钥'
})
}
// 验证服务器URL如果提供
if (serverUrl) {
try {
new URL(serverUrl)
} catch (urlError) {
return res.status(400).json({
error: 'Invalid server URL format',
message: '请提供有效的Bark服务器URL'
})
}
}
logger.info(`🧪 测试webhook: ${type} - Device Key: ${deviceKey.substring(0, 8)}...`)
} else if (type === 'smtp') {
// SMTP平台验证
if (!host) {
return res.status(400).json({
error: 'Missing SMTP host',
message: '请提供SMTP服务器地址'
})
}
if (!user) {
return res.status(400).json({
error: 'Missing SMTP user',
message: '请提供SMTP用户名'
})
}
if (!pass) {
return res.status(400).json({
error: 'Missing SMTP password',
message: '请提供SMTP密码'
})
}
if (!to) {
return res.status(400).json({
error: 'Missing recipient email',
message: '请提供收件人邮箱'
})
}
logger.info(`🧪 测试webhook: ${type} - ${host}:${port || 587} -> ${to}`)
} else {
// 其他平台验证URL
if (!url) {
return res.status(400).json({
error: 'Missing webhook URL',
message: '请提供webhook URL'
})
}
// 验证URL格式
try {
new URL(url)
} catch (urlError) {
return res.status(400).json({
error: 'Invalid URL format',
message: '请提供有效的webhook URL'
})
}
logger.info(`🧪 测试webhook: ${type} - ${url}`)
}
// 验证URL格式
try {
new URL(url)
} catch (urlError) {
return res.status(400).json({
error: 'Invalid URL format',
message: '请提供有效的webhook URL'
})
}
logger.info(`🧪 测试webhook: ${type} - ${url}`)
// 创建临时平台配置
const platform = {
type,
@@ -145,21 +218,44 @@ router.post('/test', authenticateAdmin, async (req, res) => {
timeout: 10000
}
// 添加Bark特有字段
if (type === 'bark') {
platform.deviceKey = deviceKey
platform.serverUrl = serverUrl
platform.level = level
platform.sound = sound
platform.group = group
} else if (type === 'smtp') {
// 添加SMTP特有字段
platform.host = host
platform.port = port || 587
platform.secure = secure || false
platform.user = user
platform.pass = pass
platform.from = from
platform.to = to
platform.ignoreTLS = ignoreTLS || false
}
const result = await webhookService.testWebhook(platform)
if (result.success) {
logger.info(`✅ Webhook测试成功: ${url}`)
const identifier = type === 'bark' ? `Device: ${deviceKey.substring(0, 8)}...` : url
logger.info(`✅ Webhook测试成功: ${identifier}`)
res.json({
success: true,
message: 'Webhook测试成功',
url
url: type === 'bark' ? undefined : url,
deviceKey: type === 'bark' ? `${deviceKey.substring(0, 8)}...` : undefined
})
} else {
logger.warn(`❌ Webhook测试失败: ${url} - ${result.error}`)
const identifier = type === 'bark' ? `Device: ${deviceKey.substring(0, 8)}...` : url
logger.warn(`❌ Webhook测试失败: ${identifier} - ${result.error}`)
res.status(400).json({
success: false,
message: 'Webhook测试失败',
url,
url: type === 'bark' ? undefined : url,
deviceKey: type === 'bark' ? `${deviceKey.substring(0, 8)}...` : undefined,
error: result.error
})
}
@@ -218,7 +314,7 @@ router.post('/test-notification', authenticateAdmin, async (req, res) => {
errorCode,
reason,
message,
timestamp: new Date().toISOString()
timestamp: getISOStringWithTimezone(new Date())
}
const result = await webhookService.sendNotification(type, testData)

View File

@@ -13,7 +13,7 @@ class AccountGroupService {
* 创建账户分组
* @param {Object} groupData - 分组数据
* @param {string} groupData.name - 分组名称
* @param {string} groupData.platform - 平台类型 (claude/gemini)
* @param {string} groupData.platform - 平台类型 (claude/gemini/openai)
* @param {string} groupData.description - 分组描述
* @returns {Object} 创建的分组
*/
@@ -327,12 +327,36 @@ class AccountGroupService {
}
}
/**
* 根据账户ID获取其所属的分组兼容性方法返回单个分组
* @param {string} accountId - 账户ID
* @returns {Object|null} 分组信息
*/
async getAccountGroup(accountId) {
try {
const client = redis.getClientSafe()
const allGroupIds = await client.smembers(this.GROUPS_KEY)
for (const groupId of allGroupIds) {
const isMember = await client.sismember(`${this.GROUP_MEMBERS_PREFIX}${groupId}`, accountId)
if (isMember) {
return await this.getGroup(groupId)
}
}
return null
} catch (error) {
logger.error('❌ 获取账户所属分组失败:', error)
throw error
}
}
/**
* 根据账户ID获取其所属的所有分组
* @param {string} accountId - 账户ID
* @returns {Array} 分组信息数组
*/
async getAccountGroup(accountId) {
async getAccountGroups(accountId) {
try {
const client = redis.getClientSafe()
const allGroupIds = await client.smembers(this.GROUPS_KEY)
@@ -357,6 +381,49 @@ class AccountGroupService {
throw error
}
}
/**
* 批量设置账户的分组
* @param {string} accountId - 账户ID
* @param {Array} groupIds - 分组ID数组
* @param {string} accountPlatform - 账户平台
*/
async setAccountGroups(accountId, groupIds, accountPlatform) {
try {
// 首先移除账户的所有现有分组
await this.removeAccountFromAllGroups(accountId)
// 然后添加到新的分组中
for (const groupId of groupIds) {
await this.addAccountToGroup(accountId, groupId, accountPlatform)
}
logger.success(`✅ 批量设置账户分组成功: ${accountId} -> [${groupIds.join(', ')}]`)
} catch (error) {
logger.error('❌ 批量设置账户分组失败:', error)
throw error
}
}
/**
* 从所有分组中移除账户
* @param {string} accountId - 账户ID
*/
async removeAccountFromAllGroups(accountId) {
try {
const client = redis.getClientSafe()
const allGroupIds = await client.smembers(this.GROUPS_KEY)
for (const groupId of allGroupIds) {
await client.srem(`${this.GROUP_MEMBERS_PREFIX}${groupId}`, accountId)
}
logger.success(`✅ 从所有分组移除账户成功: ${accountId}`)
} catch (error) {
logger.error('❌ 从所有分组移除账户失败:', error)
throw error
}
}
}
module.exports = new AccountGroupService()

View File

@@ -14,7 +14,7 @@ class ApiKeyService {
const {
name = 'Unnamed Key',
description = '',
tokenLimit = config.limits.defaultTokenLimit,
tokenLimit = 0, // 默认为0不再使用token限制
expiresAt = null,
claudeAccountId = null,
claudeConsoleAccountId = null,
@@ -27,12 +27,17 @@ class ApiKeyService {
concurrencyLimit = 0,
rateLimitWindow = null,
rateLimitRequests = null,
rateLimitCost = null, // 新增:速率限制费用字段
enableModelRestriction = false,
restrictedModels = [],
enableClientRestriction = false,
allowedClients = [],
dailyCostLimit = 0,
tags = []
weeklyOpusCostLimit = 0,
tags = [],
activationDays = 0, // 新增激活后有效天数0表示不使用此功能
expirationMode = 'fixed', // 新增:过期模式 'fixed'(固定时间) 或 'activation'(首次使用后激活)
icon = '' // 新增图标base64编码
} = options
// 生成简单的API Key (64字符十六进制)
@@ -49,6 +54,7 @@ class ApiKeyService {
concurrencyLimit: String(concurrencyLimit ?? 0),
rateLimitWindow: String(rateLimitWindow ?? 0),
rateLimitRequests: String(rateLimitRequests ?? 0),
rateLimitCost: String(rateLimitCost ?? 0), // 新增:速率限制费用字段
isActive: String(isActive),
claudeAccountId: claudeAccountId || '',
claudeConsoleAccountId: claudeConsoleAccountId || '',
@@ -62,11 +68,19 @@ class ApiKeyService {
enableClientRestriction: String(enableClientRestriction || false),
allowedClients: JSON.stringify(allowedClients || []),
dailyCostLimit: String(dailyCostLimit || 0),
weeklyOpusCostLimit: String(weeklyOpusCostLimit || 0),
tags: JSON.stringify(tags || []),
activationDays: String(activationDays || 0), // 新增:激活后有效天数
expirationMode: expirationMode || 'fixed', // 新增:过期模式
isActivated: expirationMode === 'fixed' ? 'true' : 'false', // 根据模式决定激活状态
activatedAt: expirationMode === 'fixed' ? new Date().toISOString() : '', // 激活时间
createdAt: new Date().toISOString(),
lastUsedAt: '',
expiresAt: expiresAt || '',
createdBy: 'admin' // 可以根据需要扩展用户系统
expiresAt: expirationMode === 'fixed' ? expiresAt || '' : '', // 固定模式才设置过期时间
createdBy: options.createdBy || 'admin',
userId: options.userId || '',
userUsername: options.userUsername || '',
icon: icon || '' // 新增图标base64编码
}
// 保存API Key数据并建立哈希映射
@@ -83,6 +97,7 @@ class ApiKeyService {
concurrencyLimit: parseInt(keyData.concurrencyLimit),
rateLimitWindow: parseInt(keyData.rateLimitWindow || 0),
rateLimitRequests: parseInt(keyData.rateLimitRequests || 0),
rateLimitCost: parseFloat(keyData.rateLimitCost || 0), // 新增:速率限制费用字段
isActive: keyData.isActive === 'true',
claudeAccountId: keyData.claudeAccountId,
claudeConsoleAccountId: keyData.claudeConsoleAccountId,
@@ -96,7 +111,12 @@ class ApiKeyService {
enableClientRestriction: keyData.enableClientRestriction === 'true',
allowedClients: JSON.parse(keyData.allowedClients || '[]'),
dailyCostLimit: parseFloat(keyData.dailyCostLimit || 0),
weeklyOpusCostLimit: parseFloat(keyData.weeklyOpusCostLimit || 0),
tags: JSON.parse(keyData.tags || '[]'),
activationDays: parseInt(keyData.activationDays || 0),
expirationMode: keyData.expirationMode || 'fixed',
isActivated: keyData.isActivated === 'true',
activatedAt: keyData.activatedAt,
createdAt: keyData.createdAt,
expiresAt: keyData.expiresAt,
createdBy: keyData.createdBy
@@ -125,11 +145,46 @@ class ApiKeyService {
return { valid: false, error: 'API key is disabled' }
}
// 处理激活逻辑(仅在 activation 模式下)
if (keyData.expirationMode === 'activation' && keyData.isActivated !== 'true') {
// 首次使用,需要激活
const now = new Date()
const activationDays = parseInt(keyData.activationDays || 30) // 默认30天
const expiresAt = new Date(now.getTime() + activationDays * 24 * 60 * 60 * 1000)
// 更新激活状态和过期时间
keyData.isActivated = 'true'
keyData.activatedAt = now.toISOString()
keyData.expiresAt = expiresAt.toISOString()
keyData.lastUsedAt = now.toISOString()
// 保存到Redis
await redis.setApiKey(keyData.id, keyData)
logger.success(
`🔓 API key activated: ${keyData.id} (${keyData.name}), will expire in ${activationDays} days at ${expiresAt.toISOString()}`
)
}
// 检查是否过期
if (keyData.expiresAt && new Date() > new Date(keyData.expiresAt)) {
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)
@@ -184,12 +239,15 @@ class ApiKeyService {
concurrencyLimit: parseInt(keyData.concurrencyLimit || 0),
rateLimitWindow: parseInt(keyData.rateLimitWindow || 0),
rateLimitRequests: parseInt(keyData.rateLimitRequests || 0),
rateLimitCost: parseFloat(keyData.rateLimitCost || 0), // 新增:速率限制费用字段
enableModelRestriction: keyData.enableModelRestriction === 'true',
restrictedModels,
enableClientRestriction: keyData.enableClientRestriction === 'true',
allowedClients,
dailyCostLimit: parseFloat(keyData.dailyCostLimit || 0),
weeklyOpusCostLimit: parseFloat(keyData.weeklyOpusCostLimit || 0),
dailyCost: dailyCost || 0,
weeklyOpusCost: (await redis.getWeeklyOpusCost(keyData.id)) || 0,
tags,
usage
}
@@ -200,35 +258,177 @@ class ApiKeyService {
}
}
// 📋 获取所有API Keys
async getAllApiKeys() {
// 🔍 验证API Key(仅用于统计查询,不触发激活)
async validateApiKeyForStats(apiKey) {
try {
const apiKeys = await redis.getAllApiKeys()
if (!apiKey || !apiKey.startsWith(this.prefix)) {
return { valid: false, error: 'Invalid API key format' }
}
// 计算API Key的哈希值
const hashedKey = this._hashApiKey(apiKey)
// 通过哈希值直接查找API Key性能优化
const keyData = await redis.findApiKeyByHash(hashedKey)
if (!keyData) {
return { valid: false, error: 'API key not found' }
}
// 检查是否激活
if (keyData.isActive !== 'true') {
return { valid: false, error: 'API key is disabled' }
}
// 注意:这里不处理激活逻辑,保持 API Key 的未激活状态
// 检查是否过期(仅对已激活的 Key 检查)
if (
keyData.isActivated === 'true' &&
keyData.expiresAt &&
new Date() > new Date(keyData.expiresAt)
) {
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 (userError) {
// 如果用户服务出错记录但不影响API Key验证
logger.warn(`Failed to check user status for API key ${keyData.id}:`, userError)
}
}
// 获取当日费用
const dailyCost = (await redis.getDailyCost(keyData.id)) || 0
// 获取使用统计
const usage = await redis.getUsageStats(keyData.id)
// 解析限制模型数据
let restrictedModels = []
try {
restrictedModels = keyData.restrictedModels ? JSON.parse(keyData.restrictedModels) : []
} catch (e) {
restrictedModels = []
}
// 解析允许的客户端
let allowedClients = []
try {
allowedClients = keyData.allowedClients ? JSON.parse(keyData.allowedClients) : []
} catch (e) {
allowedClients = []
}
// 解析标签
let tags = []
try {
tags = keyData.tags ? JSON.parse(keyData.tags) : []
} catch (e) {
tags = []
}
return {
valid: true,
keyData: {
id: keyData.id,
name: keyData.name,
description: keyData.description,
createdAt: keyData.createdAt,
expiresAt: keyData.expiresAt,
// 添加激活相关字段
expirationMode: keyData.expirationMode || 'fixed',
isActivated: keyData.isActivated === 'true',
activationDays: parseInt(keyData.activationDays || 0),
activatedAt: keyData.activatedAt || null,
claudeAccountId: keyData.claudeAccountId,
claudeConsoleAccountId: keyData.claudeConsoleAccountId,
geminiAccountId: keyData.geminiAccountId,
openaiAccountId: keyData.openaiAccountId,
azureOpenaiAccountId: keyData.azureOpenaiAccountId,
bedrockAccountId: keyData.bedrockAccountId,
permissions: keyData.permissions || 'all',
tokenLimit: parseInt(keyData.tokenLimit),
concurrencyLimit: parseInt(keyData.concurrencyLimit || 0),
rateLimitWindow: parseInt(keyData.rateLimitWindow || 0),
rateLimitRequests: parseInt(keyData.rateLimitRequests || 0),
rateLimitCost: parseFloat(keyData.rateLimitCost || 0),
enableModelRestriction: keyData.enableModelRestriction === 'true',
restrictedModels,
enableClientRestriction: keyData.enableClientRestriction === 'true',
allowedClients,
dailyCostLimit: parseFloat(keyData.dailyCostLimit || 0),
weeklyOpusCostLimit: parseFloat(keyData.weeklyOpusCostLimit || 0),
dailyCost: dailyCost || 0,
weeklyOpusCost: (await redis.getWeeklyOpusCost(keyData.id)) || 0,
tags,
usage
}
}
} catch (error) {
logger.error('❌ API key validation error (stats):', error)
return { valid: false, error: 'Internal validation error' }
}
}
// 📋 获取所有API Keys
async getAllApiKeys(includeDeleted = false) {
try {
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)
key.rateLimitRequests = parseInt(key.rateLimitRequests || 0)
key.rateLimitCost = parseFloat(key.rateLimitCost || 0) // 新增:速率限制费用字段
key.currentConcurrency = await redis.getConcurrency(key.id)
key.isActive = key.isActive === 'true'
key.enableModelRestriction = key.enableModelRestriction === 'true'
key.enableClientRestriction = key.enableClientRestriction === 'true'
key.permissions = key.permissions || 'all' // 兼容旧数据
key.dailyCostLimit = parseFloat(key.dailyCostLimit || 0)
key.weeklyOpusCostLimit = parseFloat(key.weeklyOpusCostLimit || 0)
key.dailyCost = (await redis.getDailyCost(key.id)) || 0
key.weeklyOpusCost = (await redis.getWeeklyOpusCost(key.id)) || 0
key.activationDays = parseInt(key.activationDays || 0)
key.expirationMode = key.expirationMode || 'fixed'
key.isActivated = key.isActivated === 'true'
key.activatedAt = key.activatedAt || null
// 获取当前时间窗口的请求次数Token使用量
// 获取当前时间窗口的请求次数Token使用量和费用
if (key.rateLimitWindow > 0) {
const requestCountKey = `rate_limit:requests:${key.id}`
const tokenCountKey = `rate_limit:tokens:${key.id}`
const costCountKey = `rate_limit:cost:${key.id}` // 新增:费用计数器
const windowStartKey = `rate_limit:window_start:${key.id}`
key.currentWindowRequests = parseInt((await client.get(requestCountKey)) || '0')
key.currentWindowTokens = parseInt((await client.get(tokenCountKey)) || '0')
key.currentWindowCost = parseFloat((await client.get(costCountKey)) || '0') // 新增:当前窗口费用
// 获取窗口开始时间和计算剩余时间
const windowStart = await client.get(windowStartKey)
@@ -251,6 +451,7 @@ class ApiKeyService {
// 重置计数为0因为窗口已过期
key.currentWindowRequests = 0
key.currentWindowTokens = 0
key.currentWindowCost = 0 // 新增:重置费用
}
} else {
// 窗口还未开始(没有任何请求)
@@ -261,6 +462,7 @@ class ApiKeyService {
} else {
key.currentWindowRequests = 0
key.currentWindowTokens = 0
key.currentWindowCost = 0 // 新增:重置费用
key.windowStartTime = null
key.windowEndTime = null
key.windowRemainingSeconds = null
@@ -281,6 +483,10 @@ class ApiKeyService {
} catch (e) {
key.tags = []
}
// 不暴露已弃用字段
if (Object.prototype.hasOwnProperty.call(key, 'ccrAccountId')) {
delete key.ccrAccountId
}
delete key.apiKey // 不返回哈希后的key
}
@@ -307,6 +513,7 @@ class ApiKeyService {
'concurrencyLimit',
'rateLimitWindow',
'rateLimitRequests',
'rateLimitCost', // 新增:速率限制费用字段
'isActive',
'claudeAccountId',
'claudeConsoleAccountId',
@@ -316,12 +523,20 @@ class ApiKeyService {
'bedrockAccountId', // 添加 Bedrock 账号ID
'permissions',
'expiresAt',
'activationDays', // 新增:激活后有效天数
'expirationMode', // 新增:过期模式
'isActivated', // 新增:是否已激活
'activatedAt', // 新增:激活时间
'enableModelRestriction',
'restrictedModels',
'enableClientRestriction',
'allowedClients',
'dailyCostLimit',
'tags'
'weeklyOpusCostLimit',
'tags',
'userId', // 新增用户ID所有者变更
'userUsername', // 新增:用户名(所有者变更)
'createdBy' // 新增:创建者(所有者变更)
]
const updatedData = { ...keyData }
@@ -330,9 +545,16 @@ class ApiKeyService {
if (field === 'restrictedModels' || field === 'allowedClients' || field === 'tags') {
// 特殊处理数组字段
updatedData[field] = JSON.stringify(value || [])
} else if (field === 'enableModelRestriction' || field === 'enableClientRestriction') {
} else if (
field === 'enableModelRestriction' ||
field === 'enableClientRestriction' ||
field === 'isActivated'
) {
// 布尔值转字符串
updatedData[field] = String(value)
} else if (field === 'expiresAt' || field === 'activatedAt') {
// 日期字段保持原样不要toString()
updatedData[field] = value || ''
} else {
updatedData[field] = (value !== null && value !== undefined ? value : '').toString()
}
@@ -353,16 +575,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) {
@@ -371,6 +609,139 @@ class ApiKeyService {
}
}
// 🔄 恢复已删除的API Key
async restoreApiKey(keyId, restoredBy = 'system', restoredByType = 'system') {
try {
const keyData = await redis.getApiKey(keyId)
if (!keyData || Object.keys(keyData).length === 0) {
throw new Error('API key not found')
}
// 检查是否确实是已删除的key
if (keyData.isDeleted !== 'true') {
throw new Error('API key is not deleted')
}
// 准备更新的数据
const updatedData = { ...keyData }
updatedData.isActive = 'true'
updatedData.restoredAt = new Date().toISOString()
updatedData.restoredBy = restoredBy
updatedData.restoredByType = restoredByType
// 从更新的数据中移除删除相关的字段
delete updatedData.isDeleted
delete updatedData.deletedAt
delete updatedData.deletedBy
delete updatedData.deletedByType
// 保存更新后的数据
await redis.setApiKey(keyId, updatedData)
// 使用Redis的hdel命令删除不需要的字段
const keyName = `apikey:${keyId}`
await redis.client.hdel(keyName, 'isDeleted', 'deletedAt', 'deletedBy', 'deletedByType')
// 重新建立哈希映射恢复API Key的使用能力
if (keyData.apiKey) {
await redis.setApiKeyHash(keyData.apiKey, {
id: keyId,
name: keyData.name,
isActive: 'true'
})
}
logger.success(`✅ Restored API key: ${keyId} by ${restoredBy} (${restoredByType})`)
return { success: true, apiKey: updatedData }
} catch (error) {
logger.error('❌ Failed to restore API key:', error)
throw error
}
}
// 🗑️ 彻底删除API Key物理删除
async permanentDeleteApiKey(keyId) {
try {
const keyData = await redis.getApiKey(keyId)
if (!keyData || Object.keys(keyData).length === 0) {
throw new Error('API key not found')
}
// 确保只能彻底删除已经软删除的key
if (keyData.isDeleted !== 'true') {
throw new Error('只能彻底删除已经删除的API Key')
}
// 删除所有相关的使用统计数据
const today = new Date().toISOString().split('T')[0]
const yesterday = new Date(Date.now() - 86400000).toISOString().split('T')[0]
// 删除每日统计
await redis.client.del(`usage:daily:${today}:${keyId}`)
await redis.client.del(`usage:daily:${yesterday}:${keyId}`)
// 删除月度统计
const currentMonth = today.substring(0, 7)
await redis.client.del(`usage:monthly:${currentMonth}:${keyId}`)
// 删除所有相关的统计键(通过模式匹配)
const usageKeys = await redis.client.keys(`usage:*:${keyId}*`)
if (usageKeys.length > 0) {
await redis.client.del(...usageKeys)
}
// 删除API Key本身
await redis.deleteApiKey(keyId)
logger.success(`🗑️ Permanently deleted API key: ${keyId}`)
return { success: true }
} catch (error) {
logger.error('❌ Failed to permanently delete API key:', error)
throw error
}
}
// 🧹 清空所有已删除的API Keys
async clearAllDeletedApiKeys() {
try {
const allKeys = await this.getAllApiKeys(true)
const deletedKeys = allKeys.filter((key) => key.isDeleted === 'true')
let successCount = 0
let failedCount = 0
const errors = []
for (const key of deletedKeys) {
try {
await this.permanentDeleteApiKey(key.id)
successCount++
} catch (error) {
failedCount++
errors.push({
keyId: key.id,
keyName: key.name,
error: error.message
})
}
}
logger.success(`🧹 Cleared deleted API keys: ${successCount} success, ${failedCount} failed`)
return {
success: true,
total: deletedKeys.length,
successCount,
failedCount,
errors
}
} catch (error) {
logger.error('❌ Failed to clear all deleted API keys:', error)
throw error
}
}
// 📊 记录使用情况支持缓存token和账户级别统计
async recordUsage(
keyId,
@@ -396,6 +767,13 @@ class ApiKeyService {
model
)
// 检查是否为 1M 上下文请求
let isLongContextRequest = false
if (model && model.includes('[1m]')) {
const totalInputTokens = inputTokens + cacheCreateTokens + cacheReadTokens
isLongContextRequest = totalInputTokens > 200000
}
// 记录API Key级别的使用统计
await redis.incrementTokenUsage(
keyId,
@@ -404,7 +782,10 @@ class ApiKeyService {
outputTokens,
cacheCreateTokens,
cacheReadTokens,
model
model,
0, // ephemeral5mTokens - 暂时为0后续处理
0, // ephemeral1hTokens - 暂时为0后续处理
isLongContextRequest
)
// 记录费用统计
@@ -433,7 +814,8 @@ class ApiKeyService {
outputTokens,
cacheCreateTokens,
cacheReadTokens,
model
model,
isLongContextRequest
)
logger.database(
`📊 Recorded account usage: ${accountId} - ${totalTokens} tokens (API Key: ${keyId})`
@@ -460,8 +842,41 @@ class ApiKeyService {
}
}
// 📊 记录 Opus 模型费用(仅限 claude 和 claude-console 账户)
async recordOpusCost(keyId, cost, model, accountType) {
try {
// 判断是否为 Opus 模型
if (!model || !model.toLowerCase().includes('claude-opus')) {
return // 不是 Opus 模型,直接返回
}
// 判断是否为 claude、claude-console 或 ccr 账户
if (
!accountType ||
(accountType !== 'claude' && accountType !== 'claude-console' && accountType !== 'ccr')
) {
logger.debug(`⚠️ Skipping Opus cost recording for non-Claude account type: ${accountType}`)
return // 不是 claude 账户,直接返回
}
// 记录 Opus 周费用
await redis.incrementWeeklyOpusCost(keyId, cost)
logger.database(
`💰 Recorded Opus weekly cost for ${keyId}: $${cost.toFixed(6)}, model: ${model}, account type: ${accountType}`
)
} catch (error) {
logger.error('❌ Failed to record Opus cost:', error)
}
}
// 📊 记录使用情况(新版本,支持详细的缓存类型)
async recordUsageWithDetails(keyId, usageObject, model = 'unknown', accountId = null) {
async recordUsageWithDetails(
keyId,
usageObject,
model = 'unknown',
accountId = null,
accountType = null
) {
try {
// 提取 token 数量
const inputTokens = usageObject.input_tokens || 0
@@ -505,7 +920,8 @@ class ApiKeyService {
cacheReadTokens,
model,
ephemeral5mTokens, // 传递5分钟缓存 tokens
ephemeral1hTokens // 传递1小时缓存 tokens
ephemeral1hTokens, // 传递1小时缓存 tokens
costInfo.isLongContextRequest || false // 传递 1M 上下文请求标记
)
// 记录费用统计
@@ -515,6 +931,9 @@ class ApiKeyService {
`💰 Recorded cost for ${keyId}: $${costInfo.totalCost.toFixed(6)}, model: ${model}`
)
// 记录 Opus 周费用(如果适用)
await this.recordOpusCost(keyId, costInfo.totalCost, model, accountType)
// 记录详细的缓存费用(如果有)
if (costInfo.ephemeral5mCost > 0 || costInfo.ephemeral1hCost > 0) {
logger.database(
@@ -541,7 +960,8 @@ class ApiKeyService {
outputTokens,
cacheCreateTokens,
cacheReadTokens,
model
model,
costInfo.isLongContextRequest || false
)
logger.database(
`📊 Recorded account usage: ${accountId} - ${totalTokens} tokens (API Key: ${keyId})`
@@ -608,6 +1028,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 {

View File

@@ -249,6 +249,10 @@ async function updateAccount(accountId, updates) {
// 删除账户
async function deleteAccount(accountId) {
// 首先从所有分组中移除此账户
const accountGroupService = require('./accountGroupService')
await accountGroupService.removeAccountFromAllGroups(accountId)
const client = redisClient.getClientSafe()
const accountKey = `${AZURE_OPENAI_ACCOUNT_KEY_PREFIX}${accountId}`
@@ -296,7 +300,11 @@ async function getAllAccounts() {
}
}
accounts.push(accountData)
accounts.push({
...accountData,
isActive: accountData.isActive === 'true',
schedulable: accountData.schedulable !== 'false'
})
}
}

View File

@@ -1,6 +1,7 @@
const axios = require('axios')
const ProxyHelper = require('../utils/proxyHelper')
const logger = require('../utils/logger')
const config = require('../../config/config')
// 转换模型名称(去掉 azure/ 前缀)
function normalizeModelName(model) {
@@ -29,7 +30,7 @@ async function handleAzureOpenAIRequest({
deploymentName = account.deploymentName || 'default'
// Azure Responses API requires preview versions; fall back appropriately
const apiVersion =
account.apiVersion || (endpoint === 'responses' ? '2024-10-01-preview' : '2024-02-01')
account.apiVersion || (endpoint === 'responses' ? '2025-04-01-preview' : '2024-02-01')
if (endpoint === 'chat/completions') {
requestUrl = `${baseUrl}/openai/deployments/${deploymentName}/chat/completions?api-version=${apiVersion}`
} else if (endpoint === 'responses') {
@@ -53,7 +54,9 @@ async function handleAzureOpenAIRequest({
const processedBody = { ...requestBody }
// 标准化模型名称
if (processedBody.model) {
if (endpoint === 'responses') {
processedBody.model = deploymentName
} else if (processedBody.model) {
processedBody.model = normalizeModelName(processedBody.model)
} else {
processedBody.model = 'gpt-4'
@@ -68,7 +71,7 @@ async function handleAzureOpenAIRequest({
url: requestUrl,
headers: requestHeaders,
data: processedBody,
timeout: 600000, // 10 minutes for Azure OpenAI
timeout: config.requestTimeout || 600000,
validateStatus: () => true,
// 添加连接保活选项
keepAlive: true,
@@ -273,6 +276,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 +305,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 +318,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 +415,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 +463,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 +603,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 +743,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) {

View File

@@ -0,0 +1,903 @@
const { v4: uuidv4 } = require('uuid')
const crypto = require('crypto')
const ProxyHelper = require('../utils/proxyHelper')
const redis = require('../models/redis')
const logger = require('../utils/logger')
const config = require('../../config/config')
const LRUCache = require('../utils/lruCache')
class CcrAccountService {
constructor() {
// 加密相关常量
this.ENCRYPTION_ALGORITHM = 'aes-256-cbc'
this.ENCRYPTION_SALT = 'ccr-account-salt'
// Redis键前缀
this.ACCOUNT_KEY_PREFIX = 'ccr_account:'
this.SHARED_ACCOUNTS_KEY = 'shared_ccr_accounts'
// 🚀 性能优化:缓存派生的加密密钥,避免每次重复计算
// scryptSync 是 CPU 密集型操作,缓存可以减少 95%+ 的 CPU 密集型操作
this._encryptionKeyCache = null
// 🔄 解密结果缓存,提高解密性能
this._decryptCache = new LRUCache(500)
// 🧹 定期清理缓存每10分钟
setInterval(
() => {
this._decryptCache.cleanup()
logger.info('🧹 CCR account decrypt cache cleanup completed', this._decryptCache.getStats())
},
10 * 60 * 1000
)
}
// 🏢 创建CCR账户
async createAccount(options = {}) {
const {
name = 'CCR Account',
description = '',
apiUrl = '',
apiKey = '',
priority = 50, // 默认优先级501-100
supportedModels = [], // 支持的模型列表或映射表,空数组/对象表示支持所有
userAgent = 'claude-relay-service/1.0.0',
rateLimitDuration = 60, // 限流时间(分钟)
proxy = null,
isActive = true,
accountType = 'shared', // 'dedicated' or 'shared'
schedulable = true, // 是否可被调度
dailyQuota = 0, // 每日额度限制美元0表示不限制
quotaResetTime = '00:00' // 额度重置时间HH:mm格式
} = options
// 验证必填字段
if (!apiUrl || !apiKey) {
throw new Error('API URL and API Key are required for CCR account')
}
const accountId = uuidv4()
// 处理 supportedModels确保向后兼容
const processedModels = this._processModelMapping(supportedModels)
const accountData = {
id: accountId,
platform: 'ccr',
name,
description,
apiUrl,
apiKey: this._encryptSensitiveData(apiKey),
priority: priority.toString(),
supportedModels: JSON.stringify(processedModels),
userAgent,
rateLimitDuration: rateLimitDuration.toString(),
proxy: proxy ? JSON.stringify(proxy) : '',
isActive: isActive.toString(),
accountType,
createdAt: new Date().toISOString(),
lastUsedAt: '',
status: 'active',
errorMessage: '',
// 限流相关
rateLimitedAt: '',
rateLimitStatus: '',
// 调度控制
schedulable: schedulable.toString(),
// 额度管理相关
dailyQuota: dailyQuota.toString(), // 每日额度限制(美元)
dailyUsage: '0', // 当日使用金额(美元)
// 使用与统计一致的时区日期,避免边界问题
lastResetDate: redis.getDateStringInTimezone(), // 最后重置日期(按配置时区)
quotaResetTime, // 额度重置时间
quotaStoppedAt: '' // 因额度停用的时间
}
const client = redis.getClientSafe()
logger.debug(
`[DEBUG] Saving CCR account data to Redis with key: ${this.ACCOUNT_KEY_PREFIX}${accountId}`
)
logger.debug(`[DEBUG] CCR Account data to save: ${JSON.stringify(accountData, null, 2)}`)
await client.hset(`${this.ACCOUNT_KEY_PREFIX}${accountId}`, accountData)
// 如果是共享账户,添加到共享账户集合
if (accountType === 'shared') {
await client.sadd(this.SHARED_ACCOUNTS_KEY, accountId)
}
logger.success(`🏢 Created CCR account: ${name} (${accountId})`)
return {
id: accountId,
name,
description,
apiUrl,
priority,
supportedModels,
userAgent,
rateLimitDuration,
isActive,
proxy,
accountType,
status: 'active',
createdAt: accountData.createdAt,
dailyQuota,
dailyUsage: 0,
lastResetDate: accountData.lastResetDate,
quotaResetTime,
quotaStoppedAt: null
}
}
// 📋 获取所有CCR账户
async getAllAccounts() {
try {
const client = redis.getClientSafe()
const keys = await client.keys(`${this.ACCOUNT_KEY_PREFIX}*`)
const accounts = []
for (const key of keys) {
const accountData = await client.hgetall(key)
if (accountData && Object.keys(accountData).length > 0) {
// 获取限流状态信息
const rateLimitInfo = this._getRateLimitInfo(accountData)
accounts.push({
id: accountData.id,
platform: accountData.platform,
name: accountData.name,
description: accountData.description,
apiUrl: accountData.apiUrl,
priority: parseInt(accountData.priority) || 50,
supportedModels: JSON.parse(accountData.supportedModels || '[]'),
userAgent: accountData.userAgent,
rateLimitDuration: Number.isNaN(parseInt(accountData.rateLimitDuration))
? 60
: parseInt(accountData.rateLimitDuration),
isActive: accountData.isActive === 'true',
proxy: accountData.proxy ? JSON.parse(accountData.proxy) : null,
accountType: accountData.accountType || 'shared',
createdAt: accountData.createdAt,
lastUsedAt: accountData.lastUsedAt,
status: accountData.status || 'active',
errorMessage: accountData.errorMessage,
rateLimitInfo,
schedulable: accountData.schedulable !== 'false', // 默认为true只有明确设置为false才不可调度
// 额度管理相关
dailyQuota: parseFloat(accountData.dailyQuota || '0'),
dailyUsage: parseFloat(accountData.dailyUsage || '0'),
lastResetDate: accountData.lastResetDate || '',
quotaResetTime: accountData.quotaResetTime || '00:00',
quotaStoppedAt: accountData.quotaStoppedAt || null
})
}
}
return accounts
} catch (error) {
logger.error('❌ Failed to get CCR accounts:', error)
throw error
}
}
// 🔍 获取单个账户(内部使用,包含敏感信息)
async getAccount(accountId) {
const client = redis.getClientSafe()
logger.debug(`[DEBUG] Getting CCR account data for ID: ${accountId}`)
const accountData = await client.hgetall(`${this.ACCOUNT_KEY_PREFIX}${accountId}`)
if (!accountData || Object.keys(accountData).length === 0) {
logger.debug(`[DEBUG] No CCR account data found for ID: ${accountId}`)
return null
}
logger.debug(`[DEBUG] Raw CCR account data keys: ${Object.keys(accountData).join(', ')}`)
logger.debug(`[DEBUG] Raw supportedModels value: ${accountData.supportedModels}`)
// 解密敏感字段只解密apiKeyapiUrl不加密
const decryptedKey = this._decryptSensitiveData(accountData.apiKey)
logger.debug(
`[DEBUG] URL exists: ${!!accountData.apiUrl}, Decrypted key exists: ${!!decryptedKey}`
)
accountData.apiKey = decryptedKey
// 解析JSON字段
const parsedModels = JSON.parse(accountData.supportedModels || '[]')
logger.debug(`[DEBUG] Parsed supportedModels: ${JSON.stringify(parsedModels)}`)
accountData.supportedModels = parsedModels
accountData.priority = parseInt(accountData.priority) || 50
{
const _parsedDuration = parseInt(accountData.rateLimitDuration)
accountData.rateLimitDuration = Number.isNaN(_parsedDuration) ? 60 : _parsedDuration
}
accountData.isActive = accountData.isActive === 'true'
accountData.schedulable = accountData.schedulable !== 'false' // 默认为true
if (accountData.proxy) {
accountData.proxy = JSON.parse(accountData.proxy)
}
logger.debug(
`[DEBUG] Final CCR account data - name: ${accountData.name}, hasApiUrl: ${!!accountData.apiUrl}, hasApiKey: ${!!accountData.apiKey}, supportedModels: ${JSON.stringify(accountData.supportedModels)}`
)
return accountData
}
// 📝 更新账户
async updateAccount(accountId, updates) {
try {
const existingAccount = await this.getAccount(accountId)
if (!existingAccount) {
throw new Error('CCR Account not found')
}
const client = redis.getClientSafe()
const updatedData = {}
// 处理各个字段的更新
logger.debug(
`[DEBUG] CCR update request received with fields: ${Object.keys(updates).join(', ')}`
)
logger.debug(`[DEBUG] CCR Updates content: ${JSON.stringify(updates, null, 2)}`)
if (updates.name !== undefined) {
updatedData.name = updates.name
}
if (updates.description !== undefined) {
updatedData.description = updates.description
}
if (updates.apiUrl !== undefined) {
updatedData.apiUrl = updates.apiUrl
}
if (updates.apiKey !== undefined) {
updatedData.apiKey = this._encryptSensitiveData(updates.apiKey)
}
if (updates.priority !== undefined) {
updatedData.priority = updates.priority.toString()
}
if (updates.supportedModels !== undefined) {
logger.debug(`[DEBUG] Updating supportedModels: ${JSON.stringify(updates.supportedModels)}`)
// 处理 supportedModels确保向后兼容
const processedModels = this._processModelMapping(updates.supportedModels)
updatedData.supportedModels = JSON.stringify(processedModels)
}
if (updates.userAgent !== undefined) {
updatedData.userAgent = updates.userAgent
}
if (updates.rateLimitDuration !== undefined) {
updatedData.rateLimitDuration = updates.rateLimitDuration.toString()
}
if (updates.proxy !== undefined) {
updatedData.proxy = updates.proxy ? JSON.stringify(updates.proxy) : ''
}
if (updates.isActive !== undefined) {
updatedData.isActive = updates.isActive.toString()
}
if (updates.schedulable !== undefined) {
updatedData.schedulable = updates.schedulable.toString()
}
if (updates.dailyQuota !== undefined) {
updatedData.dailyQuota = updates.dailyQuota.toString()
}
if (updates.quotaResetTime !== undefined) {
updatedData.quotaResetTime = updates.quotaResetTime
}
await client.hset(`${this.ACCOUNT_KEY_PREFIX}${accountId}`, updatedData)
// 处理共享账户集合变更
if (updates.accountType !== undefined) {
updatedData.accountType = updates.accountType
if (updates.accountType === 'shared') {
await client.sadd(this.SHARED_ACCOUNTS_KEY, accountId)
} else {
await client.srem(this.SHARED_ACCOUNTS_KEY, accountId)
}
}
logger.success(`📝 Updated CCR account: ${accountId}`)
return await this.getAccount(accountId)
} catch (error) {
logger.error(`❌ Failed to update CCR account ${accountId}:`, error)
throw error
}
}
// 🗑️ 删除账户
async deleteAccount(accountId) {
try {
const client = redis.getClientSafe()
// 从共享账户集合中移除
await client.srem(this.SHARED_ACCOUNTS_KEY, accountId)
// 删除账户数据
const result = await client.del(`${this.ACCOUNT_KEY_PREFIX}${accountId}`)
if (result === 0) {
throw new Error('CCR Account not found or already deleted')
}
logger.success(`🗑️ Deleted CCR account: ${accountId}`)
return { success: true }
} catch (error) {
logger.error(`❌ Failed to delete CCR account ${accountId}:`, error)
throw error
}
}
// 🚫 标记账户为限流状态
async markAccountRateLimited(accountId) {
try {
const client = redis.getClientSafe()
const account = await this.getAccount(accountId)
if (!account) {
throw new Error('CCR Account not found')
}
// 如果限流时间设置为 0表示不启用限流机制直接返回
if (account.rateLimitDuration === 0) {
logger.info(
` CCR account ${account.name} (${accountId}) has rate limiting disabled, skipping rate limit`
)
return { success: true, skipped: true }
}
const now = new Date().toISOString()
await client.hmset(`${this.ACCOUNT_KEY_PREFIX}${accountId}`, {
status: 'rate_limited',
rateLimitedAt: now,
rateLimitStatus: 'active',
errorMessage: 'Rate limited by upstream service'
})
logger.warn(`⏱️ Marked CCR account as rate limited: ${account.name} (${accountId})`)
return { success: true, rateLimitedAt: now }
} catch (error) {
logger.error(`❌ Failed to mark CCR account as rate limited: ${accountId}`, error)
throw error
}
}
// ✅ 移除账户限流状态
async removeAccountRateLimit(accountId) {
try {
const client = redis.getClientSafe()
const accountKey = `${this.ACCOUNT_KEY_PREFIX}${accountId}`
// 获取账户当前状态和额度信息
const [, quotaStoppedAt] = await client.hmget(accountKey, 'status', 'quotaStoppedAt')
// 删除限流相关字段
await client.hdel(accountKey, 'rateLimitedAt', 'rateLimitStatus')
// 根据不同情况决定是否恢复账户
let newStatus = 'active'
let errorMessage = ''
// 如果因额度问题停用,不要自动激活
if (quotaStoppedAt) {
newStatus = 'quota_exceeded'
errorMessage = 'Account stopped due to quota exceeded'
logger.info(
` CCR account ${accountId} rate limit removed but remains stopped due to quota exceeded`
)
} else {
logger.success(`✅ Removed rate limit for CCR account: ${accountId}`)
}
await client.hmset(accountKey, {
status: newStatus,
errorMessage
})
return { success: true, newStatus }
} catch (error) {
logger.error(`❌ Failed to remove rate limit for CCR account: ${accountId}`, error)
throw error
}
}
// 🔍 检查账户是否被限流
async isAccountRateLimited(accountId) {
try {
const client = redis.getClientSafe()
const accountKey = `${this.ACCOUNT_KEY_PREFIX}${accountId}`
const [rateLimitedAt, rateLimitDuration] = await client.hmget(
accountKey,
'rateLimitedAt',
'rateLimitDuration'
)
if (rateLimitedAt) {
const limitTime = new Date(rateLimitedAt)
const duration = parseInt(rateLimitDuration) || 60
const now = new Date()
const expireTime = new Date(limitTime.getTime() + duration * 60 * 1000)
if (now < expireTime) {
return true
} else {
// 限流时间已过,自动移除限流状态
await this.removeAccountRateLimit(accountId)
return false
}
}
return false
} catch (error) {
logger.error(`❌ Failed to check rate limit status for CCR account: ${accountId}`, error)
return false
}
}
// 🔥 标记账户为过载状态
async markAccountOverloaded(accountId) {
try {
const client = redis.getClientSafe()
const account = await this.getAccount(accountId)
if (!account) {
throw new Error('CCR Account not found')
}
const now = new Date().toISOString()
await client.hmset(`${this.ACCOUNT_KEY_PREFIX}${accountId}`, {
status: 'overloaded',
overloadedAt: now,
errorMessage: 'Account overloaded'
})
logger.warn(`🔥 Marked CCR account as overloaded: ${account.name} (${accountId})`)
return { success: true, overloadedAt: now }
} catch (error) {
logger.error(`❌ Failed to mark CCR account as overloaded: ${accountId}`, error)
throw error
}
}
// ✅ 移除账户过载状态
async removeAccountOverload(accountId) {
try {
const client = redis.getClientSafe()
const accountKey = `${this.ACCOUNT_KEY_PREFIX}${accountId}`
// 删除过载相关字段
await client.hdel(accountKey, 'overloadedAt')
await client.hmset(accountKey, {
status: 'active',
errorMessage: ''
})
logger.success(`✅ Removed overload status for CCR account: ${accountId}`)
return { success: true }
} catch (error) {
logger.error(`❌ Failed to remove overload status for CCR account: ${accountId}`, error)
throw error
}
}
// 🔍 检查账户是否过载
async isAccountOverloaded(accountId) {
try {
const client = redis.getClientSafe()
const accountKey = `${this.ACCOUNT_KEY_PREFIX}${accountId}`
const status = await client.hget(accountKey, 'status')
return status === 'overloaded'
} catch (error) {
logger.error(`❌ Failed to check overload status for CCR account: ${accountId}`, error)
return false
}
}
// 🚫 标记账户为未授权状态
async markAccountUnauthorized(accountId) {
try {
const client = redis.getClientSafe()
const account = await this.getAccount(accountId)
if (!account) {
throw new Error('CCR Account not found')
}
await client.hmset(`${this.ACCOUNT_KEY_PREFIX}${accountId}`, {
status: 'unauthorized',
errorMessage: 'API key invalid or unauthorized'
})
logger.warn(`🚫 Marked CCR account as unauthorized: ${account.name} (${accountId})`)
return { success: true }
} catch (error) {
logger.error(`❌ Failed to mark CCR account as unauthorized: ${accountId}`, error)
throw error
}
}
// 🔄 处理模型映射
_processModelMapping(supportedModels) {
// 如果是空值,返回空对象(支持所有模型)
if (!supportedModels || (Array.isArray(supportedModels) && supportedModels.length === 0)) {
return {}
}
// 如果已经是对象格式(新的映射表格式),直接返回
if (typeof supportedModels === 'object' && !Array.isArray(supportedModels)) {
return supportedModels
}
// 如果是数组格式(旧格式),转换为映射表
if (Array.isArray(supportedModels)) {
const mapping = {}
supportedModels.forEach((model) => {
if (model && typeof model === 'string') {
mapping[model] = model // 默认映射:原模型名 -> 原模型名
}
})
return mapping
}
return {}
}
// 🔍 检查模型是否被支持
isModelSupported(modelMapping, requestedModel) {
// 如果映射表为空,支持所有模型
if (!modelMapping || Object.keys(modelMapping).length === 0) {
return true
}
// 检查请求的模型是否在映射表的键中
return Object.prototype.hasOwnProperty.call(modelMapping, requestedModel)
}
// 🔄 获取映射后的模型名称
getMappedModel(modelMapping, requestedModel) {
// 如果映射表为空,返回原模型
if (!modelMapping || Object.keys(modelMapping).length === 0) {
return requestedModel
}
// 返回映射后的模型名,如果不存在映射则返回原模型名
return modelMapping[requestedModel] || requestedModel
}
// 🔐 加密敏感数据
_encryptSensitiveData(data) {
if (!data) {
return ''
}
try {
const key = this._generateEncryptionKey()
const iv = crypto.randomBytes(16)
const cipher = crypto.createCipheriv(this.ENCRYPTION_ALGORITHM, key, iv)
let encrypted = cipher.update(data, 'utf8', 'hex')
encrypted += cipher.final('hex')
return `${iv.toString('hex')}:${encrypted}`
} catch (error) {
logger.error('❌ CCR encryption error:', error)
return data
}
}
// 🔓 解密敏感数据
_decryptSensitiveData(encryptedData) {
if (!encryptedData) {
return ''
}
// 🎯 检查缓存
const cacheKey = crypto.createHash('sha256').update(encryptedData).digest('hex')
const cached = this._decryptCache.get(cacheKey)
if (cached !== undefined) {
return cached
}
try {
const parts = encryptedData.split(':')
if (parts.length === 2) {
const key = this._generateEncryptionKey()
const iv = Buffer.from(parts[0], 'hex')
const encrypted = parts[1]
const decipher = crypto.createDecipheriv(this.ENCRYPTION_ALGORITHM, key, iv)
let decrypted = decipher.update(encrypted, 'hex', 'utf8')
decrypted += decipher.final('utf8')
// 💾 存入缓存5分钟过期
this._decryptCache.set(cacheKey, decrypted, 5 * 60 * 1000)
return decrypted
} else {
logger.error('❌ Invalid CCR encrypted data format')
return encryptedData
}
} catch (error) {
logger.error('❌ CCR decryption error:', error)
return encryptedData
}
}
// 🔑 生成加密密钥
_generateEncryptionKey() {
// 性能优化:缓存密钥派生结果,避免重复的 CPU 密集计算
if (!this._encryptionKeyCache) {
this._encryptionKeyCache = crypto.scryptSync(
config.security.encryptionKey,
this.ENCRYPTION_SALT,
32
)
}
return this._encryptionKeyCache
}
// 🔍 获取限流状态信息
_getRateLimitInfo(accountData) {
const { rateLimitedAt } = accountData
const rateLimitDuration = parseInt(accountData.rateLimitDuration) || 60
if (rateLimitedAt) {
const limitTime = new Date(rateLimitedAt)
const now = new Date()
const expireTime = new Date(limitTime.getTime() + rateLimitDuration * 60 * 1000)
const remainingMs = expireTime.getTime() - now.getTime()
return {
isRateLimited: remainingMs > 0,
rateLimitedAt,
rateLimitExpireAt: expireTime.toISOString(),
remainingTimeMs: Math.max(0, remainingMs),
remainingTimeMinutes: Math.max(0, Math.ceil(remainingMs / (60 * 1000)))
}
}
return {
isRateLimited: false,
rateLimitedAt: null,
rateLimitExpireAt: null,
remainingTimeMs: 0,
remainingTimeMinutes: 0
}
}
// 🔧 创建代理客户端
_createProxyAgent(proxy) {
return ProxyHelper.createProxyAgent(proxy)
}
// 💰 检查配额使用情况(可选实现)
async checkQuotaUsage(accountId) {
try {
const account = await this.getAccount(accountId)
if (!account) {
return false
}
const dailyQuota = parseFloat(account.dailyQuota || '0')
// 如果未设置额度限制,则不限制
if (dailyQuota <= 0) {
return false
}
// 检查是否需要重置每日使用量
const today = redis.getDateStringInTimezone()
if (account.lastResetDate !== today) {
await this.resetDailyUsage(accountId)
return false // 刚重置,不会超额
}
// 获取当日使用统计
const usageStats = await this.getAccountUsageStats(accountId)
if (!usageStats) {
return false
}
const dailyUsage = usageStats.dailyUsage || 0
const isExceeded = dailyUsage >= dailyQuota
if (isExceeded) {
// 标记账户因额度停用
const client = redis.getClientSafe()
await client.hmset(`${this.ACCOUNT_KEY_PREFIX}${accountId}`, {
status: 'quota_exceeded',
errorMessage: `Daily quota exceeded: $${dailyUsage.toFixed(2)} / $${dailyQuota.toFixed(2)}`,
quotaStoppedAt: new Date().toISOString()
})
logger.warn(
`💰 CCR account ${account.name} (${accountId}) quota exceeded: $${dailyUsage.toFixed(2)} / $${dailyQuota.toFixed(2)}`
)
// 发送 Webhook 通知
try {
const webhookNotifier = require('../utils/webhookNotifier')
await webhookNotifier.sendAccountAnomalyNotification({
accountId,
accountName: account.name || accountId,
platform: 'ccr',
status: 'quota_exceeded',
errorCode: 'QUOTA_EXCEEDED',
reason: `Daily quota exceeded: $${dailyUsage.toFixed(2)} / $${dailyQuota.toFixed(2)}`,
timestamp: new Date().toISOString()
})
} catch (webhookError) {
logger.warn('Failed to send webhook notification for CCR quota exceeded:', webhookError)
}
}
return isExceeded
} catch (error) {
logger.error(`❌ Failed to check quota usage for CCR account ${accountId}:`, error)
return false
}
}
// 🔄 重置每日使用量(可选实现)
async resetDailyUsage(accountId) {
try {
const client = redis.getClientSafe()
await client.hmset(`${this.ACCOUNT_KEY_PREFIX}${accountId}`, {
dailyUsage: '0',
lastResetDate: redis.getDateStringInTimezone(),
quotaStoppedAt: ''
})
return { success: true }
} catch (error) {
logger.error(`❌ Failed to reset daily usage for CCR account: ${accountId}`, error)
throw error
}
}
// 🚫 检查账户是否超额
async isAccountQuotaExceeded(accountId) {
try {
const account = await this.getAccount(accountId)
if (!account) {
return false
}
const dailyQuota = parseFloat(account.dailyQuota || '0')
// 如果未设置额度限制,则不限制
if (dailyQuota <= 0) {
return false
}
// 获取当日使用统计
const usageStats = await this.getAccountUsageStats(accountId)
if (!usageStats) {
return false
}
const dailyUsage = usageStats.dailyUsage || 0
const isExceeded = dailyUsage >= dailyQuota
if (isExceeded && !account.quotaStoppedAt) {
// 标记账户因额度停用
const client = redis.getClientSafe()
await client.hmset(`${this.ACCOUNT_KEY_PREFIX}${accountId}`, {
status: 'quota_exceeded',
errorMessage: `Daily quota exceeded: $${dailyUsage.toFixed(2)} / $${dailyQuota.toFixed(2)}`,
quotaStoppedAt: new Date().toISOString()
})
logger.warn(`💰 CCR account ${account.name} (${accountId}) quota exceeded`)
}
return isExceeded
} catch (error) {
logger.error(`❌ Failed to check quota for CCR account ${accountId}:`, error)
return false
}
}
// 🔄 重置所有CCR账户的每日使用量
async resetAllDailyUsage() {
try {
const accounts = await this.getAllAccounts()
const today = redis.getDateStringInTimezone()
let resetCount = 0
for (const account of accounts) {
if (account.lastResetDate !== today) {
await this.resetDailyUsage(account.id)
resetCount += 1
}
}
logger.success(`✅ Reset daily usage for ${resetCount} CCR accounts`)
return { success: true, resetCount }
} catch (error) {
logger.error('❌ Failed to reset all CCR daily usage:', error)
throw error
}
}
// 📊 获取CCR账户使用统计含每日费用
async getAccountUsageStats(accountId) {
try {
// 使用统一的 Redis 统计
const usageStats = await redis.getAccountUsageStats(accountId)
// 叠加账户自身的额度配置
const accountData = await this.getAccount(accountId)
if (!accountData) {
return null
}
const dailyQuota = parseFloat(accountData.dailyQuota || '0')
const currentDailyCost = usageStats?.daily?.cost || 0
return {
dailyQuota,
dailyUsage: currentDailyCost,
remainingQuota: dailyQuota > 0 ? Math.max(0, dailyQuota - currentDailyCost) : null,
usagePercentage: dailyQuota > 0 ? (currentDailyCost / dailyQuota) * 100 : 0,
lastResetDate: accountData.lastResetDate,
quotaResetTime: accountData.quotaResetTime,
quotaStoppedAt: accountData.quotaStoppedAt,
isQuotaExceeded: dailyQuota > 0 && currentDailyCost >= dailyQuota,
fullUsageStats: usageStats
}
} catch (error) {
logger.error('❌ Failed to get CCR account usage stats:', error)
return null
}
}
// 🔄 重置CCR账户所有异常状态
async resetAccountStatus(accountId) {
try {
const accountData = await this.getAccount(accountId)
if (!accountData) {
throw new Error('Account not found')
}
const client = redis.getClientSafe()
const accountKey = `${this.ACCOUNT_KEY_PREFIX}${accountId}`
const updates = {
status: 'active',
errorMessage: '',
schedulable: 'true',
isActive: 'true'
}
const fieldsToDelete = [
'rateLimitedAt',
'rateLimitStatus',
'unauthorizedAt',
'unauthorizedCount',
'overloadedAt',
'overloadStatus',
'blockedAt',
'quotaStoppedAt'
]
await client.hset(accountKey, updates)
await client.hdel(accountKey, ...fieldsToDelete)
logger.success(`✅ Reset all error status for CCR account ${accountId}`)
// 异步发送 Webhook 通知(忽略错误)
try {
const webhookNotifier = require('../utils/webhookNotifier')
await webhookNotifier.sendAccountAnomalyNotification({
accountId,
accountName: accountData.name || accountId,
platform: 'ccr',
status: 'recovered',
errorCode: 'STATUS_RESET',
reason: 'Account status manually reset',
timestamp: new Date().toISOString()
})
} catch (webhookError) {
logger.warn('Failed to send webhook notification for CCR status reset:', webhookError)
}
return { success: true, accountId }
} catch (error) {
logger.error(`❌ Failed to reset CCR account status: ${accountId}`, error)
throw error
}
}
}
module.exports = new CcrAccountService()

View File

@@ -0,0 +1,641 @@
const axios = require('axios')
const ccrAccountService = require('./ccrAccountService')
const logger = require('../utils/logger')
const config = require('../../config/config')
const { parseVendorPrefixedModel } = require('../utils/modelHelper')
class CcrRelayService {
constructor() {
this.defaultUserAgent = 'claude-relay-service/1.0.0'
}
// 🚀 转发请求到CCR API
async relayRequest(
requestBody,
apiKeyData,
clientRequest,
clientResponse,
clientHeaders,
accountId,
options = {}
) {
let abortController = null
let account = null
try {
// 获取账户信息
account = await ccrAccountService.getAccount(accountId)
if (!account) {
throw new Error('CCR account not found')
}
logger.info(
`📤 Processing CCR API request for key: ${apiKeyData.name || apiKeyData.id}, account: ${account.name} (${accountId})`
)
logger.debug(`🌐 Account API URL: ${account.apiUrl}`)
logger.debug(`🔍 Account supportedModels: ${JSON.stringify(account.supportedModels)}`)
logger.debug(`🔑 Account has apiKey: ${!!account.apiKey}`)
logger.debug(`📝 Request model: ${requestBody.model}`)
// 处理模型前缀解析和映射
const { baseModel } = parseVendorPrefixedModel(requestBody.model)
logger.debug(`🔄 Parsed base model: ${baseModel} from original: ${requestBody.model}`)
let mappedModel = baseModel
if (
account.supportedModels &&
typeof account.supportedModels === 'object' &&
!Array.isArray(account.supportedModels)
) {
const newModel = ccrAccountService.getMappedModel(account.supportedModels, baseModel)
if (newModel !== baseModel) {
logger.info(`🔄 Mapping model from ${baseModel} to ${newModel}`)
mappedModel = newModel
}
}
// 创建修改后的请求体,使用去前缀后的模型名
const modifiedRequestBody = {
...requestBody,
model: mappedModel
}
// 创建代理agent
const proxyAgent = ccrAccountService._createProxyAgent(account.proxy)
// 创建AbortController用于取消请求
abortController = new AbortController()
// 设置客户端断开监听器
const handleClientDisconnect = () => {
logger.info('🔌 Client disconnected, aborting CCR request')
if (abortController && !abortController.signal.aborted) {
abortController.abort()
}
}
// 监听客户端断开事件
if (clientRequest) {
clientRequest.once('close', handleClientDisconnect)
}
if (clientResponse) {
clientResponse.once('close', handleClientDisconnect)
}
// 构建完整的API URL
const cleanUrl = account.apiUrl.replace(/\/$/, '') // 移除末尾斜杠
let apiEndpoint
if (options.customPath) {
// 如果指定了自定义路径(如 count_tokens使用它
const baseUrl = cleanUrl.replace(/\/v1\/messages$/, '') // 移除已有的 /v1/messages
apiEndpoint = `${baseUrl}${options.customPath}`
} else {
// 默认使用 messages 端点
apiEndpoint = cleanUrl.endsWith('/v1/messages') ? cleanUrl : `${cleanUrl}/v1/messages`
}
logger.debug(`🎯 Final API endpoint: ${apiEndpoint}`)
logger.debug(`[DEBUG] Options passed to relayRequest: ${JSON.stringify(options)}`)
logger.debug(`[DEBUG] Client headers received: ${JSON.stringify(clientHeaders)}`)
// 过滤客户端请求头
const filteredHeaders = this._filterClientHeaders(clientHeaders)
logger.debug(`[DEBUG] Filtered client headers: ${JSON.stringify(filteredHeaders)}`)
// 决定使用的 User-Agent优先使用账户自定义的否则透传客户端的最后才使用默认值
const userAgent =
account.userAgent ||
clientHeaders?.['user-agent'] ||
clientHeaders?.['User-Agent'] ||
this.defaultUserAgent
// 准备请求配置
const requestConfig = {
method: 'POST',
url: apiEndpoint,
data: modifiedRequestBody,
headers: {
'Content-Type': 'application/json',
'anthropic-version': '2023-06-01',
'User-Agent': userAgent,
...filteredHeaders
},
httpsAgent: proxyAgent,
timeout: config.requestTimeout || 600000,
signal: abortController.signal,
validateStatus: () => true // 接受所有状态码
}
// 根据 API Key 格式选择认证方式
if (account.apiKey && account.apiKey.startsWith('sk-ant-')) {
// Anthropic 官方 API Key 使用 x-api-key
requestConfig.headers['x-api-key'] = account.apiKey
logger.debug('[DEBUG] Using x-api-key authentication for sk-ant-* API key')
} else {
// 其他 API Key (包括CCR API Key) 使用 Authorization Bearer
requestConfig.headers['Authorization'] = `Bearer ${account.apiKey}`
logger.debug('[DEBUG] Using Authorization Bearer authentication')
}
logger.debug(
`[DEBUG] Initial headers before beta: ${JSON.stringify(requestConfig.headers, null, 2)}`
)
// 添加beta header如果需要
if (options.betaHeader) {
logger.debug(`[DEBUG] Adding beta header: ${options.betaHeader}`)
requestConfig.headers['anthropic-beta'] = options.betaHeader
} else {
logger.debug('[DEBUG] No beta header to add')
}
// 发送请求
logger.debug(
'📤 Sending request to CCR API with headers:',
JSON.stringify(requestConfig.headers, null, 2)
)
const response = await axios(requestConfig)
// 移除监听器(请求成功完成)
if (clientRequest) {
clientRequest.removeListener('close', handleClientDisconnect)
}
if (clientResponse) {
clientResponse.removeListener('close', handleClientDisconnect)
}
logger.debug(`🔗 CCR API response: ${response.status}`)
logger.debug(`[DEBUG] Response headers: ${JSON.stringify(response.headers)}`)
logger.debug(`[DEBUG] Response data type: ${typeof response.data}`)
logger.debug(
`[DEBUG] Response data length: ${response.data ? (typeof response.data === 'string' ? response.data.length : JSON.stringify(response.data).length) : 0}`
)
logger.debug(
`[DEBUG] Response data preview: ${typeof response.data === 'string' ? response.data.substring(0, 200) : JSON.stringify(response.data).substring(0, 200)}`
)
// 检查错误状态并相应处理
if (response.status === 401) {
logger.warn(`🚫 Unauthorized error detected for CCR account ${accountId}`)
await ccrAccountService.markAccountUnauthorized(accountId)
} else if (response.status === 429) {
logger.warn(`🚫 Rate limit detected for CCR account ${accountId}`)
// 收到429先检查是否因为超过了手动配置的每日额度
await ccrAccountService.checkQuotaUsage(accountId).catch((err) => {
logger.error('❌ Failed to check quota after 429 error:', err)
})
await ccrAccountService.markAccountRateLimited(accountId)
} else if (response.status === 529) {
logger.warn(`🚫 Overload error detected for CCR account ${accountId}`)
await ccrAccountService.markAccountOverloaded(accountId)
} else if (response.status === 200 || response.status === 201) {
// 如果请求成功,检查并移除错误状态
const isRateLimited = await ccrAccountService.isAccountRateLimited(accountId)
if (isRateLimited) {
await ccrAccountService.removeAccountRateLimit(accountId)
}
const isOverloaded = await ccrAccountService.isAccountOverloaded(accountId)
if (isOverloaded) {
await ccrAccountService.removeAccountOverload(accountId)
}
}
// 更新最后使用时间
await this._updateLastUsedTime(accountId)
const responseBody =
typeof response.data === 'string' ? response.data : JSON.stringify(response.data)
logger.debug(`[DEBUG] Final response body to return: ${responseBody}`)
return {
statusCode: response.status,
headers: response.headers,
body: responseBody,
accountId
}
} catch (error) {
// 处理特定错误
if (error.name === 'AbortError' || error.code === 'ECONNABORTED') {
logger.info('Request aborted due to client disconnect')
throw new Error('Client disconnected')
}
logger.error(
`❌ CCR relay request failed (Account: ${account?.name || accountId}):`,
error.message
)
throw error
}
}
// 🌊 处理流式响应
async relayStreamRequestWithUsageCapture(
requestBody,
apiKeyData,
responseStream,
clientHeaders,
usageCallback,
accountId,
streamTransformer = null,
options = {}
) {
let account = null
try {
// 获取账户信息
account = await ccrAccountService.getAccount(accountId)
if (!account) {
throw new Error('CCR account not found')
}
logger.info(
`📡 Processing streaming CCR API request for key: ${apiKeyData.name || apiKeyData.id}, account: ${account.name} (${accountId})`
)
logger.debug(`🌐 Account API URL: ${account.apiUrl}`)
// 处理模型前缀解析和映射
const { baseModel } = parseVendorPrefixedModel(requestBody.model)
logger.debug(`🔄 Parsed base model: ${baseModel} from original: ${requestBody.model}`)
let mappedModel = baseModel
if (
account.supportedModels &&
typeof account.supportedModels === 'object' &&
!Array.isArray(account.supportedModels)
) {
const newModel = ccrAccountService.getMappedModel(account.supportedModels, baseModel)
if (newModel !== baseModel) {
logger.info(`🔄 [Stream] Mapping model from ${baseModel} to ${newModel}`)
mappedModel = newModel
}
}
// 创建修改后的请求体,使用去前缀后的模型名
const modifiedRequestBody = {
...requestBody,
model: mappedModel
}
// 创建代理agent
const proxyAgent = ccrAccountService._createProxyAgent(account.proxy)
// 发送流式请求
await this._makeCcrStreamRequest(
modifiedRequestBody,
account,
proxyAgent,
clientHeaders,
responseStream,
accountId,
usageCallback,
streamTransformer,
options
)
// 更新最后使用时间
await this._updateLastUsedTime(accountId)
} catch (error) {
logger.error(`❌ CCR stream relay failed (Account: ${account?.name || accountId}):`, error)
throw error
}
}
// 🌊 发送流式请求到CCR API
async _makeCcrStreamRequest(
body,
account,
proxyAgent,
clientHeaders,
responseStream,
accountId,
usageCallback,
streamTransformer = null,
requestOptions = {}
) {
return new Promise((resolve, reject) => {
let aborted = false
// 构建完整的API URL
const cleanUrl = account.apiUrl.replace(/\/$/, '') // 移除末尾斜杠
const apiEndpoint = cleanUrl.endsWith('/v1/messages') ? cleanUrl : `${cleanUrl}/v1/messages`
logger.debug(`🎯 Final API endpoint for stream: ${apiEndpoint}`)
// 过滤客户端请求头
const filteredHeaders = this._filterClientHeaders(clientHeaders)
logger.debug(`[DEBUG] Filtered client headers: ${JSON.stringify(filteredHeaders)}`)
// 决定使用的 User-Agent优先使用账户自定义的否则透传客户端的最后才使用默认值
const userAgent =
account.userAgent ||
clientHeaders?.['user-agent'] ||
clientHeaders?.['User-Agent'] ||
this.defaultUserAgent
// 准备请求配置
const requestConfig = {
method: 'POST',
url: apiEndpoint,
data: body,
headers: {
'Content-Type': 'application/json',
'anthropic-version': '2023-06-01',
'User-Agent': userAgent,
...filteredHeaders
},
httpsAgent: proxyAgent,
timeout: config.requestTimeout || 600000,
responseType: 'stream',
validateStatus: () => true // 接受所有状态码
}
// 根据 API Key 格式选择认证方式
if (account.apiKey && account.apiKey.startsWith('sk-ant-')) {
// Anthropic 官方 API Key 使用 x-api-key
requestConfig.headers['x-api-key'] = account.apiKey
logger.debug('[DEBUG] Using x-api-key authentication for sk-ant-* API key')
} else {
// 其他 API Key (包括CCR API Key) 使用 Authorization Bearer
requestConfig.headers['Authorization'] = `Bearer ${account.apiKey}`
logger.debug('[DEBUG] Using Authorization Bearer authentication')
}
// 添加beta header如果需要
if (requestOptions.betaHeader) {
requestConfig.headers['anthropic-beta'] = requestOptions.betaHeader
}
// 发送请求
const request = axios(requestConfig)
request
.then((response) => {
logger.debug(`🌊 CCR stream response status: ${response.status}`)
// 错误响应处理
if (response.status !== 200) {
logger.error(
`❌ CCR API returned error status: ${response.status} | Account: ${account?.name || accountId}`
)
if (response.status === 401) {
ccrAccountService.markAccountUnauthorized(accountId)
} else if (response.status === 429) {
ccrAccountService.markAccountRateLimited(accountId)
// 检查是否因为超过每日额度
ccrAccountService.checkQuotaUsage(accountId).catch((err) => {
logger.error('❌ Failed to check quota after 429 error:', err)
})
} else if (response.status === 529) {
ccrAccountService.markAccountOverloaded(accountId)
}
// 设置错误响应的状态码和响应头
if (!responseStream.headersSent) {
const errorHeaders = {
'Content-Type': response.headers['content-type'] || 'application/json',
'Cache-Control': 'no-cache',
Connection: 'keep-alive'
}
// 避免 Transfer-Encoding 冲突,让 Express 自动处理
delete errorHeaders['Transfer-Encoding']
delete errorHeaders['Content-Length']
responseStream.writeHead(response.status, errorHeaders)
}
// 直接透传错误数据,不进行包装
response.data.on('data', (chunk) => {
if (!responseStream.destroyed) {
responseStream.write(chunk)
}
})
response.data.on('end', () => {
if (!responseStream.destroyed) {
responseStream.end()
}
resolve() // 不抛出异常,正常完成流处理
})
return
}
// 成功响应,检查并移除错误状态
ccrAccountService.isAccountRateLimited(accountId).then((isRateLimited) => {
if (isRateLimited) {
ccrAccountService.removeAccountRateLimit(accountId)
}
})
ccrAccountService.isAccountOverloaded(accountId).then((isOverloaded) => {
if (isOverloaded) {
ccrAccountService.removeAccountOverload(accountId)
}
})
// 设置响应头
if (!responseStream.headersSent) {
const headers = {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': 'Cache-Control'
}
responseStream.writeHead(200, headers)
}
// 处理流数据和使用统计收集
let rawBuffer = ''
const collectedUsage = {}
response.data.on('data', (chunk) => {
if (aborted || responseStream.destroyed) {
return
}
try {
const chunkStr = chunk.toString('utf8')
rawBuffer += chunkStr
// 按行分割处理 SSE 数据
const lines = rawBuffer.split('\n')
rawBuffer = lines.pop() // 保留最后一个可能不完整的行
for (const line of lines) {
if (line.trim()) {
// 解析 SSE 数据并收集使用统计
const usageData = this._parseSSELineForUsage(line)
if (usageData) {
Object.assign(collectedUsage, usageData)
}
// 应用流转换器(如果提供)
let outputLine = line
if (streamTransformer && typeof streamTransformer === 'function') {
outputLine = streamTransformer(line)
}
// 写入到响应流
if (outputLine && !responseStream.destroyed) {
responseStream.write(`${outputLine}\n`)
}
} else {
// 空行也需要传递
if (!responseStream.destroyed) {
responseStream.write('\n')
}
}
}
} catch (err) {
logger.error('❌ Error processing SSE chunk:', err)
}
})
response.data.on('end', () => {
if (!responseStream.destroyed) {
responseStream.end()
}
// 如果收集到使用统计数据,调用回调
if (usageCallback && Object.keys(collectedUsage).length > 0) {
try {
logger.debug(`📊 Collected usage data: ${JSON.stringify(collectedUsage)}`)
// 在 usage 回调中包含模型信息
usageCallback({ ...collectedUsage, accountId, model: body.model })
} catch (err) {
logger.error('❌ Error in usage callback:', err)
}
}
resolve()
})
response.data.on('error', (err) => {
logger.error('❌ Stream data error:', err)
if (!responseStream.destroyed) {
responseStream.end()
}
reject(err)
})
// 客户端断开处理
responseStream.on('close', () => {
logger.info('🔌 Client disconnected from CCR stream')
aborted = true
if (response.data && typeof response.data.destroy === 'function') {
response.data.destroy()
}
})
responseStream.on('error', (err) => {
logger.error('❌ Response stream error:', err)
aborted = true
})
})
.catch((error) => {
if (!responseStream.headersSent) {
responseStream.writeHead(500, { 'Content-Type': 'application/json' })
}
const errorResponse = {
error: {
type: 'internal_error',
message: 'CCR API request failed'
}
}
if (!responseStream.destroyed) {
responseStream.write(`data: ${JSON.stringify(errorResponse)}\n\n`)
responseStream.end()
}
reject(error)
})
})
}
// 📊 解析SSE行以提取使用统计信息
_parseSSELineForUsage(line) {
try {
if (line.startsWith('data: ')) {
const data = line.substring(6).trim()
if (data === '[DONE]') {
return null
}
const jsonData = JSON.parse(data)
// 检查是否包含使用统计信息
if (jsonData.usage) {
return {
input_tokens: jsonData.usage.input_tokens || 0,
output_tokens: jsonData.usage.output_tokens || 0,
cache_creation_input_tokens: jsonData.usage.cache_creation_input_tokens || 0,
cache_read_input_tokens: jsonData.usage.cache_read_input_tokens || 0,
// 支持 ephemeral cache 字段
cache_creation_input_tokens_ephemeral_5m:
jsonData.usage.cache_creation_input_tokens_ephemeral_5m || 0,
cache_creation_input_tokens_ephemeral_1h:
jsonData.usage.cache_creation_input_tokens_ephemeral_1h || 0
}
}
// 检查 message_delta 事件中的使用统计
if (jsonData.type === 'message_delta' && jsonData.delta && jsonData.delta.usage) {
return {
input_tokens: jsonData.delta.usage.input_tokens || 0,
output_tokens: jsonData.delta.usage.output_tokens || 0,
cache_creation_input_tokens: jsonData.delta.usage.cache_creation_input_tokens || 0,
cache_read_input_tokens: jsonData.delta.usage.cache_read_input_tokens || 0,
cache_creation_input_tokens_ephemeral_5m:
jsonData.delta.usage.cache_creation_input_tokens_ephemeral_5m || 0,
cache_creation_input_tokens_ephemeral_1h:
jsonData.delta.usage.cache_creation_input_tokens_ephemeral_1h || 0
}
}
}
} catch (err) {
// 忽略解析错误,不是所有行都包含 JSON
}
return null
}
// 🔍 过滤客户端请求头
_filterClientHeaders(clientHeaders) {
if (!clientHeaders) {
return {}
}
const filteredHeaders = {}
const allowedHeaders = [
'accept-language',
'anthropic-beta',
'anthropic-dangerous-direct-browser-access'
]
// 只保留允许的头部信息
for (const [key, value] of Object.entries(clientHeaders)) {
const lowerKey = key.toLowerCase()
if (allowedHeaders.includes(lowerKey)) {
filteredHeaders[key] = value
}
}
return filteredHeaders
}
// ⏰ 更新账户最后使用时间
async _updateLastUsedTime(accountId) {
try {
const redis = require('../models/redis')
const client = redis.getClientSafe()
await client.hset(`ccr_account:${accountId}`, 'lastUsedAt', new Date().toISOString())
} catch (error) {
logger.error(`❌ Failed to update last used time for CCR account ${accountId}:`, error)
}
}
}
module.exports = new CcrRelayService()

View File

@@ -3,8 +3,8 @@ const crypto = require('crypto')
const ProxyHelper = require('../utils/proxyHelper')
const axios = require('axios')
const redis = require('../models/redis')
const logger = require('../utils/logger')
const config = require('../../config/config')
const logger = require('../utils/logger')
const { maskToken } = require('../utils/tokenMask')
const {
logRefreshStart,
@@ -15,6 +15,7 @@ const {
} = require('../utils/tokenRefreshLogger')
const tokenRefreshService = require('./tokenRefreshService')
const LRUCache = require('../utils/lruCache')
const { formatDateWithTimezone, getISOStringWithTimezone } = require('../utils/dateHelper')
class ClaudeAccountService {
constructor() {
@@ -57,7 +58,11 @@ class ClaudeAccountService {
platform = 'claude',
priority = 50, // 调度优先级 (1-100数字越小优先级越高)
schedulable = true, // 是否可被调度
subscriptionInfo = null // 手动设置的订阅信息
subscriptionInfo = null, // 手动设置的订阅信息
autoStopOnWarning = false, // 5小时使用量接近限制时自动停止调度
useUnifiedUserAgent = false, // 是否使用统一Claude Code版本的User-Agent
useUnifiedClientId = false, // 是否使用统一的客户端标识
unifiedClientId = '' // 统一的客户端标识
} = options
const accountId = uuidv4()
@@ -88,6 +93,10 @@ class ClaudeAccountService {
status: 'active', // 有OAuth数据的账户直接设为active
errorMessage: '',
schedulable: schedulable.toString(), // 是否可被调度
autoStopOnWarning: autoStopOnWarning.toString(), // 5小时使用量接近限制时自动停止调度
useUnifiedUserAgent: useUnifiedUserAgent.toString(), // 是否使用统一Claude Code版本的User-Agent
useUnifiedClientId: useUnifiedClientId.toString(), // 是否使用统一的客户端标识
unifiedClientId: unifiedClientId || '', // 统一的客户端标识
// 优先使用手动设置的订阅信息否则使用OAuth数据中的否则默认为空
subscriptionInfo: subscriptionInfo
? JSON.stringify(subscriptionInfo)
@@ -118,6 +127,8 @@ class ClaudeAccountService {
status: 'created', // created, active, expired, error
errorMessage: '',
schedulable: schedulable.toString(), // 是否可被调度
autoStopOnWarning: autoStopOnWarning.toString(), // 5小时使用量接近限制时自动停止调度
useUnifiedUserAgent: useUnifiedUserAgent.toString(), // 是否使用统一Claude Code版本的User-Agent
// 手动设置的订阅信息
subscriptionInfo: subscriptionInfo ? JSON.stringify(subscriptionInfo) : ''
}
@@ -158,7 +169,11 @@ class ClaudeAccountService {
status: accountData.status,
createdAt: accountData.createdAt,
expiresAt: accountData.expiresAt,
scopes: claudeAiOauth ? claudeAiOauth.scopes : []
scopes: claudeAiOauth ? claudeAiOauth.scopes : [],
autoStopOnWarning,
useUnifiedUserAgent,
useUnifiedClientId,
unifiedClientId
}
}
@@ -479,7 +494,16 @@ class ClaudeAccountService {
lastRequestTime: null
},
// 添加调度状态
schedulable: account.schedulable !== 'false' // 默认为true兼容历史数据
schedulable: account.schedulable !== 'false', // 默认为true兼容历史数据
// 添加自动停止调度设置
autoStopOnWarning: account.autoStopOnWarning === 'true', // 默认为false
// 添加统一User-Agent设置
useUnifiedUserAgent: account.useUnifiedUserAgent === 'true', // 默认为false
// 添加统一客户端标识设置
useUnifiedClientId: account.useUnifiedClientId === 'true', // 默认为false
unifiedClientId: account.unifiedClientId || '', // 统一的客户端标识
// 添加停止原因
stoppedReason: account.stoppedReason || null
}
})
)
@@ -512,7 +536,11 @@ class ClaudeAccountService {
'accountType',
'priority',
'schedulable',
'subscriptionInfo'
'subscriptionInfo',
'autoStopOnWarning',
'useUnifiedUserAgent',
'useUnifiedClientId',
'unifiedClientId'
]
const updatedData = { ...accountData }
@@ -575,6 +603,25 @@ class ClaudeAccountService {
updatedData.updatedAt = new Date().toISOString()
// 如果是手动修改调度状态,清除所有自动停止相关的字段
if (Object.prototype.hasOwnProperty.call(updates, 'schedulable')) {
// 清除所有自动停止的标记,防止自动恢复
delete updatedData.rateLimitAutoStopped
delete updatedData.fiveHourAutoStopped
delete updatedData.fiveHourStoppedAt
delete updatedData.tempErrorAutoStopped
// 兼容旧的标记(逐步迁移)
delete updatedData.autoStoppedAt
delete updatedData.stoppedReason
// 如果是手动启用调度,记录日志
if (updates.schedulable === true || updates.schedulable === 'true') {
logger.info(`✅ Manually enabled scheduling for account ${accountId}`)
} else {
logger.info(`⛔ Manually disabled scheduling for account ${accountId}`)
}
}
// 检查是否手动禁用了账号如果是则发送webhook通知
if (updates.isActive === 'false' && accountData.isActive === 'true') {
try {
@@ -611,10 +658,7 @@ class ClaudeAccountService {
try {
// 首先从所有分组中移除此账户
const accountGroupService = require('./accountGroupService')
const groups = await accountGroupService.getAccountGroup(accountId)
for (const group of groups) {
await accountGroupService.removeAccountFromGroup(accountId, group.id)
}
await accountGroupService.removeAccountFromAllGroups(accountId)
const result = await redis.deleteClaudeAccount(accountId)
@@ -682,6 +726,8 @@ class ClaudeAccountService {
// 验证映射的账户是否仍然可用
const mappedAccount = activeAccounts.find((acc) => acc.id === mappedAccountId)
if (mappedAccount) {
// 🚀 智能会话续期剩余时间少于14天时自动续期到15天
await redis.extendSessionAccountMappingTTL(sessionHash)
logger.info(
`🎯 Using sticky session account: ${mappedAccount.name} (${mappedAccountId}) for session ${sessionHash}`
)
@@ -708,7 +754,9 @@ class ClaudeAccountService {
// 如果有会话哈希,建立新的映射
if (sessionHash) {
await redis.setSessionAccountMapping(sessionHash, selectedAccountId, 3600) // 1小时过期
// 从配置获取TTL小时转换为秒
const ttlSeconds = (config.session?.stickyTtlHours || 1) * 60 * 60
await redis.setSessionAccountMapping(sessionHash, selectedAccountId, ttlSeconds)
logger.info(
`🎯 Created new sticky session mapping: ${sortedAccounts[0].name} (${selectedAccountId}) for session ${sessionHash}`
)
@@ -802,6 +850,8 @@ class ClaudeAccountService {
)
await redis.deleteSessionAccountMapping(sessionHash)
} else {
// 🚀 智能会话续期剩余时间少于14天时自动续期到15天
await redis.extendSessionAccountMappingTTL(sessionHash)
logger.info(
`🎯 Using sticky session shared account: ${mappedAccount.name} (${mappedAccountId}) for session ${sessionHash}`
)
@@ -860,7 +910,9 @@ class ClaudeAccountService {
// 如果有会话哈希,建立新的映射
if (sessionHash) {
await redis.setSessionAccountMapping(sessionHash, selectedAccountId, 3600) // 1小时过期
// 从配置获取TTL小时转换为秒
const ttlSeconds = (config.session?.stickyTtlHours || 1) * 60 * 60
await redis.setSessionAccountMapping(sessionHash, selectedAccountId, ttlSeconds)
logger.info(
`🎯 Created new sticky session mapping for shared account: ${candidateAccounts[0].name} (${selectedAccountId}) for session ${sessionHash}`
)
@@ -1054,6 +1106,10 @@ class ClaudeAccountService {
const updatedAccountData = { ...accountData }
updatedAccountData.rateLimitedAt = new Date().toISOString()
updatedAccountData.rateLimitStatus = 'limited'
// 限流时停止调度,与 OpenAI 账号保持一致
updatedAccountData.schedulable = 'false'
// 使用独立的限流自动停止标记,避免与其他自动停止冲突
updatedAccountData.rateLimitAutoStopped = 'true'
// 如果提供了准确的限流重置时间戳来自API响应头
if (rateLimitResetTimestamp) {
@@ -1112,8 +1168,8 @@ class ClaudeAccountService {
platform: 'claude-oauth',
status: 'error',
errorCode: 'CLAUDE_OAUTH_RATE_LIMITED',
reason: `Account rate limited (429 error). ${rateLimitResetTimestamp ? `Reset at: ${new Date(rateLimitResetTimestamp * 1000).toISOString()}` : 'Estimated reset in 1-5 hours'}`,
timestamp: new Date().toISOString()
reason: `Account rate limited (429 error). ${rateLimitResetTimestamp ? `Reset at: ${formatDateWithTimezone(rateLimitResetTimestamp)}` : 'Estimated reset in 1-5 hours'}`,
timestamp: getISOStringWithTimezone(new Date())
})
} catch (webhookError) {
logger.error('Failed to send rate limit webhook notification:', webhookError)
@@ -1138,9 +1194,17 @@ class ClaudeAccountService {
delete accountData.rateLimitedAt
delete accountData.rateLimitStatus
delete accountData.rateLimitEndAt // 清除限流结束时间
// 只恢复因限流而自动停止的账户
if (accountData.rateLimitAutoStopped === 'true' && accountData.schedulable === 'false') {
accountData.schedulable = 'true'
delete accountData.rateLimitAutoStopped
logger.info(`✅ Auto-resuming scheduling for account ${accountId} after rate limit cleared`)
}
await redis.setClaudeAccount(accountId, accountData)
logger.success(`✅ Rate limit removed for account: ${accountData.name} (${accountId})`)
return { success: true }
} catch (error) {
logger.error(`❌ Failed to remove rate limit for account: ${accountId}`, error)
@@ -1284,6 +1348,38 @@ class ClaudeAccountService {
accountData.sessionWindowEnd = windowEnd.toISOString()
accountData.lastRequestTime = now.toISOString()
// 清除会话窗口状态,因为进入了新窗口
if (accountData.sessionWindowStatus) {
delete accountData.sessionWindowStatus
delete accountData.sessionWindowStatusUpdatedAt
}
// 如果账户因为5小时限制被自动停止现在恢复调度
if (accountData.fiveHourAutoStopped === 'true' && accountData.schedulable === 'false') {
logger.info(
`✅ Auto-resuming scheduling for account ${accountData.name} (${accountId}) - new session window started`
)
accountData.schedulable = 'true'
delete accountData.fiveHourAutoStopped
delete accountData.fiveHourStoppedAt
// 发送Webhook通知
try {
const webhookNotifier = require('../utils/webhookNotifier')
await webhookNotifier.sendAccountAnomalyNotification({
accountId,
accountName: accountData.name || 'Claude Account',
platform: 'claude',
status: 'resumed',
errorCode: 'CLAUDE_5H_LIMIT_RESUMED',
reason: '进入新的5小时窗口已自动恢复调度',
timestamp: getISOStringWithTimezone(new Date())
})
} catch (webhookError) {
logger.error('Failed to send webhook notification:', webhookError)
}
}
logger.info(
`🕐 Created new session window for account ${accountData.name} (${accountId}): ${windowStart.toISOString()} - ${windowEnd.toISOString()} (from current time)`
)
@@ -1329,7 +1425,8 @@ class ClaudeAccountService {
windowEnd: null,
progress: 0,
remainingTime: null,
lastRequestTime: accountData.lastRequestTime || null
lastRequestTime: accountData.lastRequestTime || null,
sessionWindowStatus: accountData.sessionWindowStatus || null
}
}
@@ -1346,7 +1443,8 @@ class ClaudeAccountService {
windowEnd: accountData.sessionWindowEnd,
progress: 100,
remainingTime: 0,
lastRequestTime: accountData.lastRequestTime || null
lastRequestTime: accountData.lastRequestTime || null,
sessionWindowStatus: accountData.sessionWindowStatus || null
}
}
@@ -1364,7 +1462,8 @@ class ClaudeAccountService {
windowEnd: accountData.sessionWindowEnd,
progress,
remainingTime,
lastRequestTime: accountData.lastRequestTime || null
lastRequestTime: accountData.lastRequestTime || null,
sessionWindowStatus: accountData.sessionWindowStatus || null
}
} catch (error) {
logger.error(`❌ Failed to get session window info for account ${accountId}:`, error)
@@ -1643,9 +1742,31 @@ class ClaudeAccountService {
}
}
// 🚫 标记账户为未授权状态401错误
async markAccountUnauthorized(accountId, sessionHash = null) {
// 🚫 通用的账户错误标记方法
async markAccountError(accountId, errorType, sessionHash = null) {
const ERROR_CONFIG = {
unauthorized: {
status: 'unauthorized',
errorMessage: 'Account unauthorized (401 errors detected)',
timestampField: 'unauthorizedAt',
errorCode: 'CLAUDE_OAUTH_UNAUTHORIZED',
logMessage: 'unauthorized'
},
blocked: {
status: 'blocked',
errorMessage: 'Account blocked (403 error detected - account may be suspended by Claude)',
timestampField: 'blockedAt',
errorCode: 'CLAUDE_OAUTH_BLOCKED',
logMessage: 'blocked'
}
}
try {
const errorConfig = ERROR_CONFIG[errorType]
if (!errorConfig) {
throw new Error(`Unsupported error type: ${errorType}`)
}
const accountData = await redis.getClaudeAccount(accountId)
if (!accountData || Object.keys(accountData).length === 0) {
throw new Error('Account not found')
@@ -1653,10 +1774,10 @@ class ClaudeAccountService {
// 更新账户状态
const updatedAccountData = { ...accountData }
updatedAccountData.status = 'unauthorized'
updatedAccountData.status = errorConfig.status
updatedAccountData.schedulable = 'false' // 设置为不可调度
updatedAccountData.errorMessage = 'Account unauthorized (401 errors detected)'
updatedAccountData.unauthorizedAt = new Date().toISOString()
updatedAccountData.errorMessage = errorConfig.errorMessage
updatedAccountData[errorConfig.timestampField] = new Date().toISOString()
// 保存更新后的账户数据
await redis.setClaudeAccount(accountId, updatedAccountData)
@@ -1668,7 +1789,7 @@ class ClaudeAccountService {
}
logger.warn(
`⚠️ Account ${accountData.name} (${accountId}) marked as unauthorized and disabled for scheduling`
`⚠️ Account ${accountData.name} (${accountId}) marked as ${errorConfig.logMessage} and disabled for scheduling`
)
// 发送Webhook通知
@@ -1678,9 +1799,10 @@ class ClaudeAccountService {
accountId,
accountName: accountData.name,
platform: 'claude-oauth',
status: 'unauthorized',
errorCode: 'CLAUDE_OAUTH_UNAUTHORIZED',
reason: 'Account unauthorized (401 errors detected)'
status: errorConfig.status,
errorCode: errorConfig.errorCode,
reason: errorConfig.errorMessage,
timestamp: getISOStringWithTimezone(new Date())
})
} catch (webhookError) {
logger.error('Failed to send webhook notification:', webhookError)
@@ -1688,11 +1810,21 @@ class ClaudeAccountService {
return { success: true }
} catch (error) {
logger.error(`❌ Failed to mark account ${accountId} as unauthorized:`, error)
logger.error(`❌ Failed to mark account ${accountId} as ${errorType}:`, error)
throw error
}
}
// 🚫 标记账户为未授权状态401错误
async markAccountUnauthorized(accountId, sessionHash = null) {
return this.markAccountError(accountId, 'unauthorized', sessionHash)
}
// 🚫 标记账户为被封锁状态403错误
async markAccountBlocked(accountId, sessionHash = null) {
return this.markAccountError(accountId, 'blocked', sessionHash)
}
// 🔄 重置账户所有异常状态
async resetAccountStatus(accountId) {
try {
@@ -1711,19 +1843,53 @@ class ClaudeAccountService {
updatedAccountData.status = 'created'
}
// 恢复可调度状态
// 恢复可调度状态(管理员手动重置时恢复调度是合理的)
updatedAccountData.schedulable = 'true'
// 清除所有自动停止相关的标记
delete updatedAccountData.rateLimitAutoStopped
delete updatedAccountData.fiveHourAutoStopped
delete updatedAccountData.fiveHourStoppedAt
delete updatedAccountData.tempErrorAutoStopped
// 兼容旧的标记
delete updatedAccountData.autoStoppedAt
delete updatedAccountData.stoppedReason
// 清除错误相关字段
delete updatedAccountData.errorMessage
delete updatedAccountData.unauthorizedAt
delete updatedAccountData.blockedAt
delete updatedAccountData.rateLimitedAt
delete updatedAccountData.rateLimitStatus
delete updatedAccountData.rateLimitEndAt
delete updatedAccountData.tempErrorAt
delete updatedAccountData.sessionWindowStart
delete updatedAccountData.sessionWindowEnd
// 保存更新后的账户数据
await redis.setClaudeAccount(accountId, updatedAccountData)
// 显式从 Redis 中删除这些字段(因为 HSET 不会删除现有字段)
const fieldsToDelete = [
'errorMessage',
'unauthorizedAt',
'blockedAt',
'rateLimitedAt',
'rateLimitStatus',
'rateLimitEndAt',
'tempErrorAt',
'sessionWindowStart',
'sessionWindowEnd',
// 新的独立标记
'rateLimitAutoStopped',
'fiveHourAutoStopped',
'fiveHourStoppedAt',
'tempErrorAutoStopped',
// 兼容旧的标记
'autoStoppedAt',
'stoppedReason'
]
await redis.client.hdel(`claude:account:${accountId}`, ...fieldsToDelete)
// 清除401错误计数
const errorKey = `claude_account:${accountId}:401_errors`
await redis.client.del(errorKey)
@@ -1732,6 +1898,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})`
)
@@ -1756,7 +1926,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) {
@@ -1767,10 +1937,23 @@ class ClaudeAccountService {
// 如果临时错误状态超过指定时间,尝试重新激活
if (minutesSinceTempError > TEMP_ERROR_RECOVERY_MINUTES) {
account.status = 'active' // 恢复为 active 状态
account.schedulable = 'true' // 恢复为可调度
// 恢复因临时错误而自动停止的账户
if (account.tempErrorAutoStopped === 'true') {
account.schedulable = 'true' // 恢复为可调度
delete account.tempErrorAutoStopped
}
delete account.errorMessage
delete account.tempErrorAt
await redis.setClaudeAccount(account.id, account)
// 显式从 Redis 中删除这些字段(因为 HSET 不会删除现有字段)
await redis.client.hdel(
`claude:account:${account.id}`,
'errorMessage',
'tempErrorAt',
'tempErrorAutoStopped'
)
// 同时清除500错误计数
await this.clearInternalErrors(account.id)
cleanedCount++
@@ -1854,10 +2037,63 @@ class ClaudeAccountService {
updatedAccountData.schedulable = 'false' // 设置为不可调度
updatedAccountData.errorMessage = 'Account temporarily disabled due to consecutive 500 errors'
updatedAccountData.tempErrorAt = new Date().toISOString()
// 使用独立的临时错误自动停止标记
updatedAccountData.tempErrorAutoStopped = 'true'
// 保存更新后的账户数据
await redis.setClaudeAccount(accountId, updatedAccountData)
// 设置 5 分钟后自动恢复(一次性定时器)
setTimeout(
async () => {
try {
const account = await redis.getClaudeAccount(accountId)
if (account && account.status === 'temp_error' && account.tempErrorAt) {
// 验证是否确实过了 5 分钟(防止重复定时器)
const tempErrorAt = new Date(account.tempErrorAt)
const now = new Date()
const minutesSince = (now - tempErrorAt) / (1000 * 60)
if (minutesSince >= 5) {
// 恢复账户
account.status = 'active'
// 只恢复因临时错误而自动停止的账户
if (account.tempErrorAutoStopped === 'true') {
account.schedulable = 'true'
delete account.tempErrorAutoStopped
}
delete account.errorMessage
delete account.tempErrorAt
await redis.setClaudeAccount(accountId, account)
// 显式删除 Redis 字段
await redis.client.hdel(
`claude:account:${accountId}`,
'errorMessage',
'tempErrorAt',
'tempErrorAutoStopped'
)
// 清除 500 错误计数
await this.clearInternalErrors(accountId)
logger.success(
`✅ Auto-recovered temp_error after 5 minutes: ${account.name} (${accountId})`
)
} else {
logger.debug(
`⏰ Temp error timer triggered but only ${minutesSince.toFixed(1)} minutes passed for ${account.name} (${accountId})`
)
}
}
} catch (error) {
logger.error(`❌ Failed to auto-recover temp_error account ${accountId}:`, error)
}
},
6 * 60 * 1000
) // 6 分钟后执行,确保已过 5 分钟
// 如果有sessionHash删除粘性会话映射
if (sessionHash) {
await redis.client.del(`sticky_session:${sessionHash}`)
@@ -1889,6 +2125,345 @@ class ClaudeAccountService {
throw error
}
}
// 更新会话窗口状态allowed, allowed_warning, rejected
async updateSessionWindowStatus(accountId, status) {
try {
// 参数验证
if (!accountId || !status) {
logger.warn(
`Invalid parameters for updateSessionWindowStatus: accountId=${accountId}, status=${status}`
)
return
}
const accountData = await redis.getClaudeAccount(accountId)
if (!accountData || Object.keys(accountData).length === 0) {
logger.warn(`Account not found: ${accountId}`)
return
}
// 验证状态值是否有效
const validStatuses = ['allowed', 'allowed_warning', 'rejected']
if (!validStatuses.includes(status)) {
logger.warn(`Invalid session window status: ${status} for account ${accountId}`)
return
}
// 更新会话窗口状态
accountData.sessionWindowStatus = status
accountData.sessionWindowStatusUpdatedAt = new Date().toISOString()
// 如果状态是 allowed_warning 且账户设置了自动停止调度
if (status === 'allowed_warning' && accountData.autoStopOnWarning === 'true') {
logger.warn(
`⚠️ Account ${accountData.name} (${accountId}) approaching 5h limit, auto-stopping scheduling`
)
accountData.schedulable = 'false'
// 使用独立的5小时限制自动停止标记
accountData.fiveHourAutoStopped = 'true'
accountData.fiveHourStoppedAt = new Date().toISOString()
// 发送Webhook通知
try {
const webhookNotifier = require('../utils/webhookNotifier')
await webhookNotifier.sendAccountAnomalyNotification({
accountId,
accountName: accountData.name || 'Claude Account',
platform: 'claude',
status: 'warning',
errorCode: 'CLAUDE_5H_LIMIT_WARNING',
reason: '5小时使用量接近限制已自动停止调度',
timestamp: getISOStringWithTimezone(new Date())
})
} catch (webhookError) {
logger.error('Failed to send webhook notification:', webhookError)
}
}
await redis.setClaudeAccount(accountId, accountData)
logger.info(
`📊 Updated session window status for account ${accountData.name} (${accountId}): ${status}`
)
} catch (error) {
logger.error(`❌ Failed to update session window status for account ${accountId}:`, error)
}
}
// 🚫 标记账号为过载状态529错误
async markAccountOverloaded(accountId) {
try {
const accountData = await redis.getClaudeAccount(accountId)
if (!accountData) {
throw new Error('Account not found')
}
// 获取配置的过载处理时间(分钟)
const overloadMinutes = config.overloadHandling?.enabled || 0
if (overloadMinutes === 0) {
logger.info('⏭️ 529 error handling is disabled')
return { success: false, error: '529 error handling is disabled' }
}
const overloadKey = `account:overload:${accountId}`
const ttl = overloadMinutes * 60 // 转换为秒
await redis.setex(
overloadKey,
ttl,
JSON.stringify({
accountId,
accountName: accountData.name,
markedAt: new Date().toISOString(),
expiresAt: new Date(Date.now() + ttl * 1000).toISOString()
})
)
logger.warn(
`🚫 Account ${accountData.name} (${accountId}) marked as overloaded for ${overloadMinutes} minutes`
)
// 在账号上记录最后一次529错误
const updates = {
lastOverloadAt: new Date().toISOString(),
errorMessage: `529错误 - 过载${overloadMinutes}分钟`
}
const updatedAccountData = { ...accountData, ...updates }
await redis.setClaudeAccount(accountId, updatedAccountData)
return { success: true, accountName: accountData.name, duration: overloadMinutes }
} catch (error) {
logger.error(`❌ Failed to mark account as overloaded: ${accountId}`, error)
// 不抛出错误,避免影响主请求流程
return { success: false, error: error.message }
}
}
// ✅ 检查账号是否过载
async isAccountOverloaded(accountId) {
try {
// 如果529处理未启用直接返回false
const overloadMinutes = config.overloadHandling?.enabled || 0
if (overloadMinutes === 0) {
return false
}
const overloadKey = `account:overload:${accountId}`
const overloadData = await redis.get(overloadKey)
if (overloadData) {
// 账号处于过载状态
return true
}
// 账号未过载
return false
} catch (error) {
logger.error(`❌ Failed to check if account is overloaded: ${accountId}`, error)
return false
}
}
// 🔄 移除账号的过载状态
async removeAccountOverload(accountId) {
try {
const accountData = await redis.getClaudeAccount(accountId)
if (!accountData) {
throw new Error('Account not found')
}
const overloadKey = `account:overload:${accountId}`
await redis.del(overloadKey)
logger.info(`✅ Account ${accountData.name} (${accountId}) overload status removed`)
// 清理账号上的错误信息
if (accountData.errorMessage && accountData.errorMessage.includes('529错误')) {
const updatedAccountData = { ...accountData }
delete updatedAccountData.errorMessage
delete updatedAccountData.lastOverloadAt
await redis.setClaudeAccount(accountId, updatedAccountData)
}
} catch (error) {
logger.error(`❌ Failed to remove overload status for account: ${accountId}`, error)
// 不抛出错误,移除过载状态失败不应该影响主流程
}
}
/**
* 检查并恢复因5小时限制被自动停止的账号
* 用于定时任务自动恢复
* @returns {Promise<{checked: number, recovered: number, accounts: Array}>}
*/
async checkAndRecoverFiveHourStoppedAccounts() {
const result = {
checked: 0,
recovered: 0,
accounts: []
}
try {
const accounts = await this.getAllAccounts()
const now = new Date()
for (const account of accounts) {
// 只检查因5小时限制被自动停止的账号
// 重要不恢复手动停止的账号没有fiveHourAutoStopped标记的
if (account.fiveHourAutoStopped === 'true' && account.schedulable === 'false') {
result.checked++
// 使用分布式锁防止并发修改
const lockKey = `lock:account:${account.id}:recovery`
const lockValue = `${Date.now()}_${Math.random()}`
const lockTTL = 5000 // 5秒锁超时
try {
// 尝试获取锁
const lockAcquired = await redis.setAccountLock(lockKey, lockValue, lockTTL)
if (!lockAcquired) {
logger.debug(
`⏭️ Account ${account.name} (${account.id}) is being processed by another instance`
)
continue
}
// 重新获取账号数据,确保是最新的
const latestAccount = await redis.getClaudeAccount(account.id)
if (
!latestAccount ||
latestAccount.fiveHourAutoStopped !== 'true' ||
latestAccount.schedulable !== 'false'
) {
// 账号状态已变化,跳过
await redis.releaseAccountLock(lockKey, lockValue)
continue
}
// 检查当前时间是否已经进入新的5小时窗口
let shouldRecover = false
let newWindowStart = null
let newWindowEnd = null
if (latestAccount.sessionWindowEnd) {
const windowEnd = new Date(latestAccount.sessionWindowEnd)
// 使用严格的时间比较添加1分钟缓冲避免边界问题
if (now.getTime() > windowEnd.getTime() + 60000) {
shouldRecover = true
// 计算新的窗口时间(基于窗口结束时间,而不是当前时间)
// 这样可以保证窗口时间的连续性
newWindowStart = new Date(windowEnd)
newWindowStart.setMilliseconds(newWindowStart.getMilliseconds() + 1)
newWindowEnd = new Date(newWindowStart)
newWindowEnd.setHours(newWindowEnd.getHours() + 5)
logger.info(
`🔄 Account ${latestAccount.name} (${latestAccount.id}) has entered new session window. ` +
`Old window: ${latestAccount.sessionWindowStart} - ${latestAccount.sessionWindowEnd}, ` +
`New window: ${newWindowStart.toISOString()} - ${newWindowEnd.toISOString()}`
)
}
} else {
// 如果没有窗口结束时间但有停止时间检查是否已经过了5小时
if (latestAccount.fiveHourStoppedAt) {
const stoppedAt = new Date(latestAccount.fiveHourStoppedAt)
const hoursSinceStopped = (now.getTime() - stoppedAt.getTime()) / (1000 * 60 * 60)
// 使用严格的5小时判断加上1分钟缓冲
if (hoursSinceStopped > 5.017) {
// 5小时1分钟
shouldRecover = true
newWindowStart = this._calculateSessionWindowStart(now)
newWindowEnd = this._calculateSessionWindowEnd(newWindowStart)
logger.info(
`🔄 Account ${latestAccount.name} (${latestAccount.id}) stopped ${hoursSinceStopped.toFixed(2)} hours ago, recovering`
)
}
}
}
if (shouldRecover) {
// 恢复账号调度
const updatedAccountData = { ...latestAccount }
// 恢复调度状态
updatedAccountData.schedulable = 'true'
delete updatedAccountData.fiveHourAutoStopped
delete updatedAccountData.fiveHourStoppedAt
// 更新会话窗口(如果有新窗口)
if (newWindowStart && newWindowEnd) {
updatedAccountData.sessionWindowStart = newWindowStart.toISOString()
updatedAccountData.sessionWindowEnd = newWindowEnd.toISOString()
// 清除会话窗口状态
delete updatedAccountData.sessionWindowStatus
delete updatedAccountData.sessionWindowStatusUpdatedAt
}
// 保存更新
await redis.setClaudeAccount(account.id, updatedAccountData)
result.recovered++
result.accounts.push({
id: latestAccount.id,
name: latestAccount.name,
oldWindow: latestAccount.sessionWindowEnd
? {
start: latestAccount.sessionWindowStart,
end: latestAccount.sessionWindowEnd
}
: null,
newWindow:
newWindowStart && newWindowEnd
? {
start: newWindowStart.toISOString(),
end: newWindowEnd.toISOString()
}
: null
})
logger.info(
`✅ Auto-resumed scheduling for account ${latestAccount.name} (${latestAccount.id}) - 5-hour limit expired`
)
}
// 释放锁
await redis.releaseAccountLock(lockKey, lockValue)
} catch (error) {
// 确保释放锁
if (lockKey && lockValue) {
try {
await redis.releaseAccountLock(lockKey, lockValue)
} catch (unlockError) {
logger.error(`Failed to release lock for account ${account.id}:`, unlockError)
}
}
logger.error(
`❌ Failed to check/recover 5-hour stopped account ${account.name} (${account.id}):`,
error
)
}
}
}
if (result.recovered > 0) {
logger.info(
`🔄 5-hour limit recovery completed: ${result.recovered}/${result.checked} accounts recovered`
)
}
return result
} catch (error) {
logger.error('❌ Failed to check and recover 5-hour stopped accounts:', error)
throw error
}
}
}
module.exports = new ClaudeAccountService()

View File

@@ -50,7 +50,7 @@ class ClaudeCodeHeadersService {
if (!userAgent) {
return null
}
const match = userAgent.match(/claude-cli\/(\d+\.\d+\.\d+)/)
const match = userAgent.match(/claude-cli\/([\d.]+(?:[a-zA-Z0-9-]*)?)/i)
return match ? match[1] : null
}
@@ -113,7 +113,7 @@ class ClaudeCodeHeadersService {
// 检查是否有 user-agent
const userAgent = extractedHeaders['user-agent']
if (!userAgent || !userAgent.includes('claude-cli')) {
if (!userAgent || !/^claude-cli\/[\d.]+\s+\(/i.test(userAgent)) {
// 不是 Claude Code 的请求,不存储
return
}

View File

@@ -50,7 +50,9 @@ class ClaudeConsoleAccountService {
proxy = null,
isActive = true,
accountType = 'shared', // 'dedicated' or 'shared'
schedulable = true // 是否可被调度
schedulable = true, // 是否可被调度
dailyQuota = 0, // 每日额度限制美元0表示不限制
quotaResetTime = '00:00' // 额度重置时间HH:mm格式
} = options
// 验证必填字段
@@ -85,7 +87,14 @@ class ClaudeConsoleAccountService {
rateLimitedAt: '',
rateLimitStatus: '',
// 调度控制
schedulable: schedulable.toString()
schedulable: schedulable.toString(),
// 额度管理相关
dailyQuota: dailyQuota.toString(), // 每日额度限制(美元)
dailyUsage: '0', // 当日使用金额(美元)
// 使用与统计一致的时区日期,避免边界问题
lastResetDate: redis.getDateStringInTimezone(), // 最后重置日期(按配置时区)
quotaResetTime, // 额度重置时间
quotaStoppedAt: '' // 因额度停用的时间
}
const client = redis.getClientSafe()
@@ -116,7 +125,12 @@ class ClaudeConsoleAccountService {
proxy,
accountType,
status: 'active',
createdAt: accountData.createdAt
createdAt: accountData.createdAt,
dailyQuota,
dailyUsage: 0,
lastResetDate: accountData.lastResetDate,
quotaResetTime,
quotaStoppedAt: null
}
}
@@ -148,12 +162,18 @@ class ClaudeConsoleAccountService {
isActive: accountData.isActive === 'true',
proxy: accountData.proxy ? JSON.parse(accountData.proxy) : null,
accountType: accountData.accountType || 'shared',
status: accountData.status,
errorMessage: accountData.errorMessage,
createdAt: accountData.createdAt,
lastUsedAt: accountData.lastUsedAt,
rateLimitStatus: rateLimitInfo,
schedulable: accountData.schedulable !== 'false' // 默认为true只有明确设置为false才不可调度
status: accountData.status || 'active',
errorMessage: accountData.errorMessage,
rateLimitInfo,
schedulable: accountData.schedulable !== 'false', // 默认为true只有明确设置为false才不可调度
// 额度管理相关
dailyQuota: parseFloat(accountData.dailyQuota || '0'),
dailyUsage: parseFloat(accountData.dailyUsage || '0'),
lastResetDate: accountData.lastResetDate || '',
quotaResetTime: accountData.quotaResetTime || '00:00',
quotaStoppedAt: accountData.quotaStoppedAt || null
})
}
}
@@ -265,6 +285,37 @@ class ClaudeConsoleAccountService {
}
if (updates.schedulable !== undefined) {
updatedData.schedulable = updates.schedulable.toString()
// 如果是手动修改调度状态,清除所有自动停止相关的字段
// 防止自动恢复
updatedData.rateLimitAutoStopped = ''
updatedData.quotaAutoStopped = ''
// 兼容旧的标记
updatedData.autoStoppedAt = ''
updatedData.stoppedReason = ''
// 记录日志
if (updates.schedulable === true || updates.schedulable === 'true') {
logger.info(`✅ Manually enabled scheduling for Claude Console account ${accountId}`)
} else {
logger.info(`⛔ Manually disabled scheduling for Claude Console account ${accountId}`)
}
}
// 额度管理相关字段
if (updates.dailyQuota !== undefined) {
updatedData.dailyQuota = updates.dailyQuota.toString()
}
if (updates.quotaResetTime !== undefined) {
updatedData.quotaResetTime = updates.quotaResetTime
}
if (updates.dailyUsage !== undefined) {
updatedData.dailyUsage = updates.dailyUsage.toString()
}
if (updates.lastResetDate !== undefined) {
updatedData.lastResetDate = updates.lastResetDate
}
if (updates.quotaStoppedAt !== undefined) {
updatedData.quotaStoppedAt = updates.quotaStoppedAt
}
// 处理账户类型变更
@@ -361,7 +412,19 @@ class ClaudeConsoleAccountService {
const updates = {
rateLimitedAt: new Date().toISOString(),
rateLimitStatus: 'limited'
rateLimitStatus: 'limited',
isActive: 'false', // 禁用账户
schedulable: 'false', // 停止调度,与其他平台保持一致
errorMessage: `Rate limited at ${new Date().toISOString()}`,
// 使用独立的限流自动停止标记
rateLimitAutoStopped: 'true'
}
// 只有当前状态不是quota_exceeded时才设置为rate_limited
// 避免覆盖更重要的配额超限状态
const currentStatus = await client.hget(`${this.ACCOUNT_KEY_PREFIX}${accountId}`, 'status')
if (currentStatus !== 'quota_exceeded') {
updates.status = 'rate_limited'
}
await client.hset(`${this.ACCOUNT_KEY_PREFIX}${accountId}`, updates)
@@ -369,14 +432,15 @@ class ClaudeConsoleAccountService {
// 发送Webhook通知
try {
const webhookNotifier = require('../utils/webhookNotifier')
const { getISOStringWithTimezone } = require('../utils/dateHelper')
await webhookNotifier.sendAccountAnomalyNotification({
accountId,
accountName: account.name || 'Claude Console Account',
platform: 'claude-console',
status: 'error',
errorCode: 'CLAUDE_CONSOLE_RATE_LIMITED',
reason: `Account rate limited (429 error). ${account.rateLimitDuration ? `Will be blocked for ${account.rateLimitDuration} hours` : 'Temporary rate limit'}`,
timestamp: new Date().toISOString()
reason: `Account rate limited (429 error) and has been disabled. ${account.rateLimitDuration ? `Will be automatically re-enabled after ${account.rateLimitDuration} minutes` : 'Manual intervention required to re-enable'}`,
timestamp: getISOStringWithTimezone(new Date())
})
} catch (webhookError) {
logger.error('Failed to send rate limit webhook notification:', webhookError)
@@ -396,14 +460,53 @@ class ClaudeConsoleAccountService {
async removeAccountRateLimit(accountId) {
try {
const client = redis.getClientSafe()
const accountKey = `${this.ACCOUNT_KEY_PREFIX}${accountId}`
await client.hdel(
`${this.ACCOUNT_KEY_PREFIX}${accountId}`,
'rateLimitedAt',
'rateLimitStatus'
// 获取账户当前状态和额度信息
const [currentStatus, quotaStoppedAt] = await client.hmget(
accountKey,
'status',
'quotaStoppedAt'
)
logger.success(`✅ Rate limit removed for Claude Console account: ${accountId}`)
// 删除限流相关字段
await client.hdel(accountKey, 'rateLimitedAt', 'rateLimitStatus')
// 根据不同情况决定是否恢复账户
if (currentStatus === 'rate_limited') {
if (quotaStoppedAt) {
// 还有额度限制改为quota_exceeded状态
await client.hset(accountKey, {
status: 'quota_exceeded'
// isActive保持false
})
logger.info(`⚠️ Rate limit removed but quota exceeded remains for account: ${accountId}`)
} else {
// 没有额度限制,完全恢复
const accountData = await client.hgetall(accountKey)
const updateData = {
isActive: 'true',
status: 'active',
errorMessage: ''
}
// 只恢复因限流而自动停止的账户
if (accountData.rateLimitAutoStopped === 'true' && accountData.schedulable === 'false') {
updateData.schedulable = 'true' // 恢复调度
// 删除限流自动停止标记
await client.hdel(accountKey, 'rateLimitAutoStopped')
logger.info(
`✅ Auto-resuming scheduling for Claude Console account ${accountId} after rate limit cleared`
)
}
await client.hset(accountKey, updateData)
logger.success(`✅ Rate limit removed and account re-enabled: ${accountId}`)
}
} else {
logger.success(`✅ Rate limit removed for Claude Console account: ${accountId}`)
}
return { success: true }
} catch (error) {
logger.error(`❌ Failed to remove rate limit for Claude Console account: ${accountId}`, error)
@@ -453,6 +556,202 @@ class ClaudeConsoleAccountService {
}
}
// 🔍 检查账号是否因额度超限而被停用(懒惰检查)
async isAccountQuotaExceeded(accountId) {
try {
const account = await this.getAccount(accountId)
if (!account) {
return false
}
// 如果没有设置额度限制,不会超额
const dailyQuota = parseFloat(account.dailyQuota || '0')
if (isNaN(dailyQuota) || dailyQuota <= 0) {
return false
}
// 如果账户没有被额度停用,检查当前使用情况
if (!account.quotaStoppedAt) {
return false
}
// 检查是否应该重置额度(到了新的重置时间点)
if (this._shouldResetQuota(account)) {
await this.resetDailyUsage(accountId)
return false
}
// 仍在额度超限状态
return true
} catch (error) {
logger.error(
`❌ Failed to check quota exceeded status for Claude Console account: ${accountId}`,
error
)
return false
}
}
// 🔍 判断是否应该重置账户额度
_shouldResetQuota(account) {
// 与 Redis 统计一致:按配置时区判断“今天”与时间点
const tzNow = redis.getDateInTimezone(new Date())
const today = redis.getDateStringInTimezone(tzNow)
// 如果已经是今天重置过的,不需要重置
if (account.lastResetDate === today) {
return false
}
// 检查是否到了重置时间点(按配置时区的小时/分钟)
const resetTime = account.quotaResetTime || '00:00'
const [resetHour, resetMinute] = resetTime.split(':').map((n) => parseInt(n))
const currentHour = tzNow.getUTCHours()
const currentMinute = tzNow.getUTCMinutes()
// 如果当前时间已过重置时间且不是同一天重置的,应该重置
return currentHour > resetHour || (currentHour === resetHour && currentMinute >= resetMinute)
}
// 🚫 标记账号为未授权状态401错误
async markAccountUnauthorized(accountId) {
try {
const client = redis.getClientSafe()
const account = await this.getAccount(accountId)
if (!account) {
throw new Error('Account not found')
}
const updates = {
schedulable: 'false',
status: 'unauthorized',
errorMessage: 'API Key无效或已过期401错误',
unauthorizedAt: new Date().toISOString(),
unauthorizedCount: String((parseInt(account.unauthorizedCount || '0') || 0) + 1)
}
await client.hset(`${this.ACCOUNT_KEY_PREFIX}${accountId}`, updates)
// 发送Webhook通知
try {
const webhookNotifier = require('../utils/webhookNotifier')
await webhookNotifier.sendAccountAnomalyNotification({
accountId,
accountName: account.name || 'Claude Console Account',
platform: 'claude-console',
status: 'error',
errorCode: 'CLAUDE_CONSOLE_UNAUTHORIZED',
reason: 'API Key无效或已过期401错误账户已停止调度',
timestamp: new Date().toISOString()
})
} catch (webhookError) {
logger.error('Failed to send unauthorized webhook notification:', webhookError)
}
logger.warn(
`🚫 Claude Console account marked as unauthorized: ${account.name} (${accountId})`
)
return { success: true }
} catch (error) {
logger.error(`❌ Failed to mark Claude Console account as unauthorized: ${accountId}`, error)
throw error
}
}
// 🚫 标记账号为过载状态529错误
async markAccountOverloaded(accountId) {
try {
const client = redis.getClientSafe()
const account = await this.getAccount(accountId)
if (!account) {
throw new Error('Account not found')
}
const updates = {
overloadedAt: new Date().toISOString(),
overloadStatus: 'overloaded',
errorMessage: '服务过载529错误'
}
await client.hset(`${this.ACCOUNT_KEY_PREFIX}${accountId}`, updates)
// 发送Webhook通知
try {
const webhookNotifier = require('../utils/webhookNotifier')
await webhookNotifier.sendAccountAnomalyNotification({
accountId,
accountName: account.name || 'Claude Console Account',
platform: 'claude-console',
status: 'error',
errorCode: 'CLAUDE_CONSOLE_OVERLOADED',
reason: '服务过载529错误。账户将暂时停止调度',
timestamp: new Date().toISOString()
})
} catch (webhookError) {
logger.error('Failed to send overload webhook notification:', webhookError)
}
logger.warn(`🚫 Claude Console account marked as overloaded: ${account.name} (${accountId})`)
return { success: true }
} catch (error) {
logger.error(`❌ Failed to mark Claude Console account as overloaded: ${accountId}`, error)
throw error
}
}
// ✅ 移除账号的过载状态
async removeAccountOverload(accountId) {
try {
const client = redis.getClientSafe()
await client.hdel(`${this.ACCOUNT_KEY_PREFIX}${accountId}`, 'overloadedAt', 'overloadStatus')
logger.success(`✅ Overload status removed for Claude Console account: ${accountId}`)
return { success: true }
} catch (error) {
logger.error(
`❌ Failed to remove overload status for Claude Console account: ${accountId}`,
error
)
throw error
}
}
// 🔍 检查账号是否处于过载状态
async isAccountOverloaded(accountId) {
try {
const account = await this.getAccount(accountId)
if (!account) {
return false
}
if (account.overloadStatus === 'overloaded' && account.overloadedAt) {
const overloadedAt = new Date(account.overloadedAt)
const now = new Date()
const minutesSinceOverload = (now - overloadedAt) / (1000 * 60)
// 过载状态持续10分钟后自动恢复
if (minutesSinceOverload >= 10) {
await this.removeAccountOverload(accountId)
return false
}
return true
}
return false
} catch (error) {
logger.error(
`❌ Failed to check overload status for Claude Console account: ${accountId}`,
error
)
return false
}
}
// 🚫 标记账号为封锁状态(模型不支持等原因)
async blockAccount(accountId, reason) {
try {
@@ -681,6 +980,256 @@ class ClaudeConsoleAccountService {
// 返回映射后的模型,如果不存在则返回原模型
return modelMapping[requestedModel] || requestedModel
}
// 💰 检查账户使用额度(基于实时统计数据)
async checkQuotaUsage(accountId) {
try {
// 获取实时的使用统计(包含费用)
const usageStats = await redis.getAccountUsageStats(accountId)
const currentDailyCost = usageStats.daily.cost || 0
// 获取账户配置
const accountData = await this.getAccount(accountId)
if (!accountData) {
logger.warn(`Account not found: ${accountId}`)
return
}
// 解析额度配置,确保数值有效
const dailyQuota = parseFloat(accountData.dailyQuota || '0')
if (isNaN(dailyQuota) || dailyQuota <= 0) {
// 没有设置有效额度,无需检查
return
}
// 检查是否已经因额度停用(避免重复操作)
if (!accountData.isActive && accountData.quotaStoppedAt) {
return
}
// 检查是否超过额度限制
if (currentDailyCost >= dailyQuota) {
// 使用原子操作避免竞态条件 - 再次检查是否已设置quotaStoppedAt
const client = redis.getClientSafe()
const accountKey = `${this.ACCOUNT_KEY_PREFIX}${accountId}`
// double-check locking pattern - 检查quotaStoppedAt而不是status
const existingQuotaStop = await client.hget(accountKey, 'quotaStoppedAt')
if (existingQuotaStop) {
return // 已经被其他进程处理
}
// 超过额度,停用账户
const updates = {
isActive: false,
quotaStoppedAt: new Date().toISOString(),
errorMessage: `Daily quota exceeded: $${currentDailyCost.toFixed(2)} / $${dailyQuota.toFixed(2)}`,
schedulable: false, // 停止调度
// 使用独立的额度超限自动停止标记
quotaAutoStopped: 'true'
}
// 只有当前状态是active时才改为quota_exceeded
// 如果是rate_limited等其他状态保持原状态不变
const currentStatus = await client.hget(accountKey, 'status')
if (currentStatus === 'active') {
updates.status = 'quota_exceeded'
}
await this.updateAccount(accountId, updates)
logger.warn(
`💰 Account ${accountId} exceeded daily quota: $${currentDailyCost.toFixed(2)} / $${dailyQuota.toFixed(2)}`
)
// 发送webhook通知
try {
const webhookNotifier = require('../utils/webhookNotifier')
await webhookNotifier.sendAccountAnomalyNotification({
accountId,
accountName: accountData.name || 'Unknown Account',
platform: 'claude-console',
status: 'quota_exceeded',
errorCode: 'CLAUDE_CONSOLE_QUOTA_EXCEEDED',
reason: `Daily quota exceeded: $${currentDailyCost.toFixed(2)} / $${dailyQuota.toFixed(2)}`
})
} catch (webhookError) {
logger.error('Failed to send webhook notification for quota exceeded:', webhookError)
}
}
logger.debug(
`💰 Quota check for account ${accountId}: $${currentDailyCost.toFixed(4)} / $${dailyQuota.toFixed(2)}`
)
} catch (error) {
logger.error('Failed to check quota usage:', error)
}
}
// 🔄 重置账户每日使用量(恢复因额度停用的账户)
async resetDailyUsage(accountId) {
try {
const accountData = await this.getAccount(accountId)
if (!accountData) {
return
}
const today = redis.getDateStringInTimezone()
const updates = {
lastResetDate: today
}
// 如果账户是因为超额被停用的,恢复账户
// 注意:状态可能是 quota_exceeded 或 rate_limited如果429错误时也超额了
if (
accountData.quotaStoppedAt &&
accountData.isActive === false &&
(accountData.status === 'quota_exceeded' || accountData.status === 'rate_limited')
) {
updates.isActive = true
updates.status = 'active'
updates.errorMessage = ''
updates.quotaStoppedAt = ''
// 只恢复因额度超限而自动停止的账户
if (accountData.quotaAutoStopped === 'true') {
updates.schedulable = true
updates.quotaAutoStopped = ''
}
// 如果是rate_limited状态也清除限流相关字段
if (accountData.status === 'rate_limited') {
const client = redis.getClientSafe()
const accountKey = `${this.ACCOUNT_KEY_PREFIX}${accountId}`
await client.hdel(accountKey, 'rateLimitedAt', 'rateLimitStatus', 'rateLimitAutoStopped')
}
logger.info(
`✅ Restored account ${accountId} after daily reset (was ${accountData.status})`
)
}
await this.updateAccount(accountId, updates)
logger.debug(`🔄 Reset daily usage for account ${accountId}`)
} catch (error) {
logger.error('Failed to reset daily usage:', error)
}
}
// 🔄 重置所有账户的每日使用量
async resetAllDailyUsage() {
try {
const accounts = await this.getAllAccounts()
// 与统计一致使用配置时区日期
const today = redis.getDateStringInTimezone()
let resetCount = 0
for (const account of accounts) {
// 只重置需要重置的账户
if (account.lastResetDate !== today) {
await this.resetDailyUsage(account.id)
resetCount += 1
}
}
logger.success(`✅ Reset daily usage for ${resetCount} Claude Console accounts`)
} catch (error) {
logger.error('Failed to reset all daily usage:', error)
}
}
// 📊 获取账户使用统计(基于实时数据)
async getAccountUsageStats(accountId) {
try {
// 获取实时的使用统计(包含费用)
const usageStats = await redis.getAccountUsageStats(accountId)
const currentDailyCost = usageStats.daily.cost || 0
// 获取账户配置
const accountData = await this.getAccount(accountId)
if (!accountData) {
return null
}
const dailyQuota = parseFloat(accountData.dailyQuota || '0')
return {
dailyQuota,
dailyUsage: currentDailyCost, // 使用实时计算的费用
remainingQuota: dailyQuota > 0 ? Math.max(0, dailyQuota - currentDailyCost) : null,
usagePercentage: dailyQuota > 0 ? (currentDailyCost / dailyQuota) * 100 : 0,
lastResetDate: accountData.lastResetDate,
quotaStoppedAt: accountData.quotaStoppedAt,
isQuotaExceeded: dailyQuota > 0 && currentDailyCost >= dailyQuota,
// 额外返回完整的使用统计
fullUsageStats: usageStats
}
} catch (error) {
logger.error('Failed to get account usage stats:', error)
return null
}
}
// 🔄 重置账户所有异常状态
async resetAccountStatus(accountId) {
try {
const accountData = await this.getAccount(accountId)
if (!accountData) {
throw new Error('Account not found')
}
const client = redis.getClientSafe()
const accountKey = `${this.ACCOUNT_KEY_PREFIX}${accountId}`
// 准备要更新的字段
const updates = {
status: 'active',
errorMessage: '',
schedulable: 'true',
isActive: 'true' // 重要必须恢复isActive状态
}
// 删除所有异常状态相关的字段
const fieldsToDelete = [
'rateLimitedAt',
'rateLimitStatus',
'unauthorizedAt',
'unauthorizedCount',
'overloadedAt',
'overloadStatus',
'blockedAt',
'quotaStoppedAt'
]
// 执行更新
await client.hset(accountKey, updates)
await client.hdel(accountKey, ...fieldsToDelete)
logger.success(`✅ Reset all error status for Claude Console account ${accountId}`)
// 发送 Webhook 通知
try {
const webhookNotifier = require('../utils/webhookNotifier')
await webhookNotifier.sendAccountAnomalyNotification({
accountId,
accountName: accountData.name || accountId,
platform: 'claude-console',
status: 'recovered',
errorCode: 'STATUS_RESET',
reason: 'Account status manually reset',
timestamp: new Date().toISOString()
})
} catch (webhookError) {
logger.warn('Failed to send webhook notification:', webhookError)
}
return { success: true, accountId }
} catch (error) {
logger.error(`❌ Failed to reset Claude Console account status: ${accountId}`, error)
throw error
}
}
}
module.exports = new ClaudeConsoleAccountService()

View File

@@ -19,10 +19,11 @@ class ClaudeConsoleRelayService {
options = {}
) {
let abortController = null
let account = null
try {
// 获取账户信息
const account = await claudeConsoleAccountService.getAccount(accountId)
account = await claudeConsoleAccountService.getAccount(accountId)
if (!account) {
throw new Error('Claude Console Claude account not found')
}
@@ -122,7 +123,7 @@ class ClaudeConsoleRelayService {
...filteredHeaders
},
httpsAgent: proxyAgent,
timeout: config.proxy.timeout || 60000,
timeout: config.requestTimeout || 600000,
signal: abortController.signal,
validateStatus: () => true // 接受所有状态码
}
@@ -175,16 +176,31 @@ class ClaudeConsoleRelayService {
`[DEBUG] Response data preview: ${typeof response.data === 'string' ? response.data.substring(0, 200) : JSON.stringify(response.data).substring(0, 200)}`
)
// 检查是否为限流错误
if (response.status === 429) {
// 检查错误状态并相应处理
if (response.status === 401) {
logger.warn(`🚫 Unauthorized error detected for Claude Console account ${accountId}`)
await claudeConsoleAccountService.markAccountUnauthorized(accountId)
} else if (response.status === 429) {
logger.warn(`🚫 Rate limit detected for Claude Console account ${accountId}`)
// 收到429先检查是否因为超过了手动配置的每日额度
await claudeConsoleAccountService.checkQuotaUsage(accountId).catch((err) => {
logger.error('❌ Failed to check quota after 429 error:', err)
})
await claudeConsoleAccountService.markAccountRateLimited(accountId)
} else if (response.status === 529) {
logger.warn(`🚫 Overload error detected for Claude Console account ${accountId}`)
await claudeConsoleAccountService.markAccountOverloaded(accountId)
} else if (response.status === 200 || response.status === 201) {
// 如果请求成功,检查并移除限流状态
// 如果请求成功,检查并移除错误状态
const isRateLimited = await claudeConsoleAccountService.isAccountRateLimited(accountId)
if (isRateLimited) {
await claudeConsoleAccountService.removeAccountRateLimit(accountId)
}
const isOverloaded = await claudeConsoleAccountService.isAccountOverloaded(accountId)
if (isOverloaded) {
await claudeConsoleAccountService.removeAccountOverload(accountId)
}
}
// 更新最后使用时间
@@ -207,7 +223,10 @@ class ClaudeConsoleRelayService {
throw new Error('Client disconnected')
}
logger.error('❌ Claude Console Claude relay request failed:', error.message)
logger.error(
`❌ Claude Console relay request failed (Account: ${account?.name || accountId}):`,
error.message
)
// 不再因为模型不支持而block账号
@@ -226,9 +245,10 @@ class ClaudeConsoleRelayService {
streamTransformer = null,
options = {}
) {
let account = null
try {
// 获取账户信息
const account = await claudeConsoleAccountService.getAccount(accountId)
account = await claudeConsoleAccountService.getAccount(accountId)
if (!account) {
throw new Error('Claude Console Claude account not found')
}
@@ -282,7 +302,10 @@ class ClaudeConsoleRelayService {
// 更新最后使用时间
await this._updateLastUsedTime(accountId)
} catch (error) {
logger.error('❌ Claude Console Claude stream relay failed:', error)
logger.error(
`❌ Claude Console stream relay failed (Account: ${account?.name || accountId}):`,
error
)
throw error
}
}
@@ -331,7 +354,7 @@ class ClaudeConsoleRelayService {
...filteredHeaders
},
httpsAgent: proxyAgent,
timeout: config.proxy.timeout || 60000,
timeout: config.requestTimeout || 600000,
responseType: 'stream',
validateStatus: () => true // 接受所有状态码
}
@@ -361,10 +384,20 @@ class ClaudeConsoleRelayService {
// 错误响应处理
if (response.status !== 200) {
logger.error(`❌ Claude Console API returned error status: ${response.status}`)
logger.error(
`❌ Claude Console API returned error status: ${response.status} | Account: ${account?.name || accountId}`
)
if (response.status === 429) {
if (response.status === 401) {
claudeConsoleAccountService.markAccountUnauthorized(accountId)
} else if (response.status === 429) {
claudeConsoleAccountService.markAccountRateLimited(accountId)
// 检查是否因为超过每日额度
claudeConsoleAccountService.checkQuotaUsage(accountId).catch((err) => {
logger.error('❌ Failed to check quota after 429 error:', err)
})
} else if (response.status === 529) {
claudeConsoleAccountService.markAccountOverloaded(accountId)
}
// 设置错误响应的状态码和响应头
@@ -396,12 +429,17 @@ class ClaudeConsoleRelayService {
return
}
// 成功响应,检查并移除限流状态
// 成功响应,检查并移除错误状态
claudeConsoleAccountService.isAccountRateLimited(accountId).then((isRateLimited) => {
if (isRateLimited) {
claudeConsoleAccountService.removeAccountRateLimit(accountId)
}
})
claudeConsoleAccountService.isAccountOverloaded(accountId).then((isOverloaded) => {
if (isOverloaded) {
claudeConsoleAccountService.removeAccountOverload(accountId)
}
})
// 设置响应头
if (!responseStream.headersSent) {
@@ -500,7 +538,10 @@ class ClaudeConsoleRelayService {
}
}
} catch (error) {
logger.error('❌ Error processing Claude Console stream data:', error)
logger.error(
`❌ Error processing Claude Console stream data (Account: ${account?.name || accountId}):`,
error
)
if (!responseStream.destroyed) {
responseStream.write('event: error\n')
responseStream.write(
@@ -542,7 +583,10 @@ class ClaudeConsoleRelayService {
})
response.data.on('error', (error) => {
logger.error('❌ Claude Console stream error:', error)
logger.error(
`❌ Claude Console stream error (Account: ${account?.name || accountId}):`,
error
)
if (!responseStream.destroyed) {
responseStream.write('event: error\n')
responseStream.write(
@@ -562,11 +606,24 @@ class ClaudeConsoleRelayService {
return
}
logger.error('❌ Claude Console Claude stream request error:', error.message)
logger.error(
`❌ Claude Console stream request error (Account: ${account?.name || accountId}):`,
error.message
)
// 检查是否是429错误
if (error.response && error.response.status === 429) {
claudeConsoleAccountService.markAccountRateLimited(accountId)
// 检查错误状态
if (error.response) {
if (error.response.status === 401) {
claudeConsoleAccountService.markAccountUnauthorized(accountId)
} else if (error.response.status === 429) {
claudeConsoleAccountService.markAccountRateLimited(accountId)
// 检查是否因为超过每日额度
claudeConsoleAccountService.checkQuotaUsage(accountId).catch((err) => {
logger.error('❌ Failed to check quota after 429 error:', err)
})
} else if (error.response.status === 529) {
claudeConsoleAccountService.markAccountOverloaded(accountId)
}
}
// 发送错误响应

View File

@@ -9,6 +9,7 @@ const sessionHelper = require('../utils/sessionHelper')
const logger = require('../utils/logger')
const config = require('../../config/config')
const claudeCodeHeadersService = require('./claudeCodeHeadersService')
const redis = require('../models/redis')
class ClaudeRelayService {
constructor() {
@@ -23,7 +24,7 @@ class ClaudeRelayService {
isRealClaudeCodeRequest(requestBody, clientHeaders) {
// 检查 user-agent 是否匹配 Claude Code 格式
const userAgent = clientHeaders?.['user-agent'] || clientHeaders?.['User-Agent'] || ''
const isClaudeCodeUserAgent = /claude-cli\/\d+\.\d+\.\d+/.test(userAgent)
const isClaudeCodeUserAgent = /^claude-cli\/[\d.]+\s+\(/i.test(userAgent)
// 检查系统提示词是否包含 Claude Code 标识
const hasClaudeCodeSystemPrompt = this._hasClaudeCodeSystemPrompt(requestBody)
@@ -78,34 +79,6 @@ class ClaudeRelayService {
requestedModel: requestBody.model
})
// 检查模型限制
if (
apiKeyData.enableModelRestriction &&
apiKeyData.restrictedModels &&
apiKeyData.restrictedModels.length > 0
) {
const requestedModel = requestBody.model
logger.info(
`🔒 Model restriction check - Requested model: ${requestedModel}, Restricted models: ${JSON.stringify(apiKeyData.restrictedModels)}`
)
if (requestedModel && apiKeyData.restrictedModels.includes(requestedModel)) {
logger.warn(
`🚫 Model restriction violation for key ${apiKeyData.name}: Attempted to use restricted model ${requestedModel}`
)
return {
statusCode: 403,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
error: {
type: 'forbidden',
message: '暂无该模型访问权限'
}
})
}
}
}
// 生成会话哈希用于sticky会话
const sessionHash = sessionHelper.generateSessionHash(requestBody)
@@ -125,8 +98,11 @@ class ClaudeRelayService {
// 获取有效的访问token
const accessToken = await claudeAccountService.getValidAccessToken(accountId)
// 获取账户信息
const account = await claudeAccountService.getAccount(accountId)
// 处理请求体(传递 clientHeaders 以判断是否需要设置 Claude Code 系统提示词)
const processedBody = this._processRequestBody(requestBody, clientHeaders)
const processedBody = this._processRequestBody(requestBody, clientHeaders, account)
// 获取代理配置
const proxyAgent = await this._getProxyAgent(accountId)
@@ -180,15 +156,15 @@ class ClaudeRelayService {
// 记录401错误
await this.recordUnauthorizedError(accountId)
// 检查是否需要标记为异常(连续3次401
// 检查是否需要标记为异常(遇到1次401就停止调度
const errorCount = await this.getUnauthorizedErrorCount(accountId)
logger.info(
`🔐 Account ${accountId} has ${errorCount} consecutive 401 errors in the last 5 minutes`
)
if (errorCount >= 3) {
if (errorCount >= 1) {
logger.error(
`❌ Account ${accountId} exceeded 401 error threshold (${errorCount} errors), marking as unauthorized`
`❌ Account ${accountId} encountered 401 error (${errorCount} errors), marking as unauthorized`
)
await unifiedClaudeScheduler.markAccountUnauthorized(
accountId,
@@ -197,22 +173,35 @@ class ClaudeRelayService {
)
}
}
// 检查是否为403状态码禁止访问
else if (response.statusCode === 403) {
logger.error(
`🚫 Forbidden error (403) detected for account ${accountId}, marking as blocked`
)
await unifiedClaudeScheduler.markAccountBlocked(accountId, accountType, sessionHash)
}
// 检查是否为529状态码服务过载
else if (response.statusCode === 529) {
logger.warn(`🚫 Overload error (529) detected for account ${accountId}`)
// 检查是否启用了529错误处理
if (config.claude.overloadHandling.enabled > 0) {
try {
await claudeAccountService.markAccountOverloaded(accountId)
logger.info(
`🚫 Account ${accountId} marked as overloaded for ${config.claude.overloadHandling.enabled} minutes`
)
} catch (overloadError) {
logger.error(`❌ Failed to mark account as overloaded: ${accountId}`, overloadError)
}
} else {
logger.info(`🚫 529 error handling is disabled, skipping account overload marking`)
}
}
// 检查是否为5xx状态码
else if (response.statusCode >= 500 && response.statusCode < 600) {
logger.warn(`🔥 Server error (${response.statusCode}) detected for account ${accountId}`)
// 记录5xx错误
await claudeAccountService.recordServerError(accountId, response.statusCode)
// 检查是否需要标记为临时错误状态连续3次500
const errorCount = await claudeAccountService.getServerErrorCount(accountId)
logger.info(
`🔥 Account ${accountId} has ${errorCount} consecutive 5xx errors in the last 5 minutes`
)
if (errorCount >= 3) {
logger.error(
`❌ Account ${accountId} exceeded 5xx error threshold (${errorCount} errors), marking as temp_error`
)
await claudeAccountService.markAccountTempError(accountId, sessionHash)
}
await this._handleServerError(accountId, response.statusCode, sessionHash)
}
// 检查是否为429状态码
else if (response.statusCode === 429) {
@@ -264,6 +253,27 @@ class ClaudeRelayService {
)
}
} else if (response.statusCode === 200 || response.statusCode === 201) {
// 提取5小时会话窗口状态
// 使用大小写不敏感的方式获取响应头
const get5hStatus = (headers) => {
if (!headers) {
return null
}
// HTTP头部名称不区分大小写需要处理不同情况
return (
headers['anthropic-ratelimit-unified-5h-status'] ||
headers['Anthropic-Ratelimit-Unified-5h-Status'] ||
headers['ANTHROPIC-RATELIMIT-UNIFIED-5H-STATUS']
)
}
const sessionWindowStatus = get5hStatus(response.headers)
if (sessionWindowStatus) {
logger.info(`📊 Session window status for account ${accountId}: ${sessionWindowStatus}`)
// 保存会话窗口状态到账户数据
await claudeAccountService.updateSessionWindowStatus(accountId, sessionWindowStatus)
}
// 请求成功清除401和500错误计数
await this.clearUnauthorizedErrors(accountId)
await claudeAccountService.clearInternalErrors(accountId)
@@ -276,6 +286,19 @@ class ClaudeRelayService {
await unifiedClaudeScheduler.removeAccountRateLimit(accountId, accountType)
}
// 如果请求成功,检查并移除过载状态
try {
const isOverloaded = await claudeAccountService.isAccountOverloaded(accountId)
if (isOverloaded) {
await claudeAccountService.removeAccountOverload(accountId)
}
} catch (overloadError) {
logger.error(
`❌ Failed to check/remove overload status for account ${accountId}:`,
overloadError
)
}
// 只有真实的 Claude Code 请求才更新 headers
if (
clientHeaders &&
@@ -327,7 +350,7 @@ class ClaudeRelayService {
}
// 🔄 处理请求体
_processRequestBody(body, clientHeaders = {}) {
_processRequestBody(body, clientHeaders = {}, account = null) {
if (!body) {
return body
}
@@ -429,9 +452,31 @@ class ClaudeRelayService {
delete processedBody.top_p
}
// 处理统一的客户端标识
if (account && account.useUnifiedClientId && account.unifiedClientId) {
this._replaceClientId(processedBody, account.unifiedClientId)
}
return processedBody
}
// 🔄 替换请求中的客户端标识
_replaceClientId(body, unifiedClientId) {
if (!body || !body.metadata || !body.metadata.user_id || !unifiedClientId) {
return
}
const userId = body.metadata.user_id
// user_id格式user_{64位十六进制}_account__session_{uuid}
// 只替换第一个下划线后到_account之前的部分客户端标识
const match = userId.match(/^user_[a-f0-9]{64}(_account__session_[a-f0-9-]{36})$/)
if (match && match[1]) {
// 替换客户端标识部分
body.metadata.user_id = `user_${unifiedClientId}${match[1]}`
logger.info(`🔄 Replaced client ID with unified ID: ${body.metadata.user_id}`)
}
}
// 🔢 验证并限制max_tokens参数
_validateAndLimitMaxTokens(body) {
if (!body || !body.max_tokens) {
@@ -454,7 +499,10 @@ class ClaudeRelayService {
const modelConfig = pricingData[model]
if (!modelConfig) {
logger.debug(`🔍 Model ${model} not found in pricing file, skipping max_tokens validation`)
// 如果找不到模型配置,直接透传客户端参数,不进行任何干预
logger.info(
`📝 Model ${model} not found in pricing file, passing through client parameters without modification`
)
return
}
@@ -553,8 +601,30 @@ class ClaudeRelayService {
'transfer-encoding'
]
// 🆕 需要移除的浏览器相关 headers避免CORS问题
const browserHeaders = [
'origin',
'referer',
'sec-fetch-mode',
'sec-fetch-site',
'sec-fetch-dest',
'sec-ch-ua',
'sec-ch-ua-mobile',
'sec-ch-ua-platform',
'accept-language',
'accept-encoding',
'accept',
'cache-control',
'pragma',
'anthropic-dangerous-direct-browser-access' // 这个头可能触发CORS检查
]
// 应该保留的 headers用于会话一致性和追踪
const allowedHeaders = ['x-request-id']
const allowedHeaders = [
'x-request-id',
'anthropic-version', // 保留API版本
'anthropic-beta' // 保留beta功能
]
const filteredHeaders = {}
@@ -565,8 +635,8 @@ class ClaudeRelayService {
if (allowedHeaders.includes(lowerKey)) {
filteredHeaders[key] = clientHeaders[key]
}
// 如果不在敏感列表中,也保留
else if (!sensitiveHeaders.includes(lowerKey)) {
// 如果不在敏感列表和浏览器列表中,也保留
else if (!sensitiveHeaders.includes(lowerKey) && !browserHeaders.includes(lowerKey)) {
filteredHeaders[key] = clientHeaders[key]
}
})
@@ -586,6 +656,12 @@ class ClaudeRelayService {
) {
const url = new URL(this.claudeApiUrl)
// 获取账户信息用于统一 User-Agent
const account = await claudeAccountService.getAccount(accountId)
// 获取统一的 User-Agent
const unifiedUA = await this.captureAndGetUnifiedUserAgent(clientHeaders, account)
// 获取过滤后的客户端 headers
const filteredHeaders = this._filterClientHeaders(clientHeaders)
@@ -629,14 +705,19 @@ class ClaudeRelayService {
...finalHeaders
},
agent: proxyAgent,
timeout: config.proxy.timeout
timeout: config.requestTimeout || 600000
}
// 如果客户端没有提供 User-Agent使用默认值
// 使用统一 User-Agent 或客户端提供的,最后使用默认值
if (!options.headers['User-Agent'] && !options.headers['user-agent']) {
options.headers['User-Agent'] = 'claude-cli/1.0.57 (external, cli)'
const userAgent = unifiedUA || 'claude-cli/1.0.57 (external, cli)'
options.headers['User-Agent'] = userAgent
}
logger.info(
`🔗 指纹是这个: ${options.headers['User-Agent'] || options.headers['user-agent']}`
)
// 使用自定义的 betaHeader 或默认值
const betaHeader =
requestOptions?.betaHeader !== undefined ? requestOptions.betaHeader : this.betaHeader
@@ -685,7 +766,7 @@ class ClaudeRelayService {
resolve(response)
} catch (error) {
logger.error('❌ Failed to parse Claude API response:', error)
logger.error(`❌ Failed to parse Claude API response (Account: ${accountId}):`, error)
reject(error)
}
})
@@ -696,9 +777,9 @@ class ClaudeRelayService {
onRequest(req)
}
req.on('error', (error) => {
req.on('error', async (error) => {
console.error(': ❌ ', error)
logger.error('❌ Claude API request error:', error.message, {
logger.error(`❌ Claude API request error (Account: ${accountId}):`, error.message, {
code: error.code,
errno: error.errno,
syscall: error.syscall,
@@ -716,14 +797,19 @@ class ClaudeRelayService {
errorMessage = 'Connection refused by Claude API server'
} else if (error.code === 'ETIMEDOUT') {
errorMessage = 'Connection timed out to Claude API server'
await this._handleServerError(accountId, 504, null, 'Network')
}
reject(new Error(errorMessage))
})
req.on('timeout', () => {
req.on('timeout', async () => {
req.destroy()
logger.error('❌ Claude API request timeout')
logger.error(`❌ Claude API request timeout (Account: ${accountId})`)
await this._handleServerError(accountId, 504, null, 'Request')
reject(new Error('Request timeout'))
})
@@ -752,36 +838,6 @@ class ClaudeRelayService {
requestedModel: requestBody.model
})
// 检查模型限制
if (
apiKeyData.enableModelRestriction &&
apiKeyData.restrictedModels &&
apiKeyData.restrictedModels.length > 0
) {
const requestedModel = requestBody.model
logger.info(
`🔒 [Stream] Model restriction check - Requested model: ${requestedModel}, Restricted models: ${JSON.stringify(apiKeyData.restrictedModels)}`
)
if (requestedModel && apiKeyData.restrictedModels.includes(requestedModel)) {
logger.warn(
`🚫 Model restriction violation for key ${apiKeyData.name}: Attempted to use restricted model ${requestedModel}`
)
// 对于流式响应,需要写入错误并结束流
const errorResponse = JSON.stringify({
error: {
type: 'forbidden',
message: '暂无该模型访问权限'
}
})
responseStream.writeHead(403, { 'Content-Type': 'application/json' })
responseStream.end(errorResponse)
return
}
}
// 生成会话哈希用于sticky会话
const sessionHash = sessionHelper.generateSessionHash(requestBody)
@@ -801,8 +857,11 @@ class ClaudeRelayService {
// 获取有效的访问token
const accessToken = await claudeAccountService.getValidAccessToken(accountId)
// 获取账户信息
const account = await claudeAccountService.getAccount(accountId)
// 处理请求体(传递 clientHeaders 以判断是否需要设置 Claude Code 系统提示词)
const processedBody = this._processRequestBody(requestBody, clientHeaders)
const processedBody = this._processRequestBody(requestBody, clientHeaders, account)
// 获取代理配置
const proxyAgent = await this._getProxyAgent(accountId)
@@ -825,7 +884,7 @@ class ClaudeRelayService {
options
)
} catch (error) {
logger.error('❌ Claude stream relay with usage capture failed:', error)
logger.error(`❌ Claude stream relay with usage capture failed:`, error)
throw error
}
}
@@ -844,6 +903,12 @@ class ClaudeRelayService {
streamTransformer = null,
requestOptions = {}
) {
// 获取账户信息用于统一 User-Agent
const account = await claudeAccountService.getAccount(accountId)
// 获取统一的 User-Agent
const unifiedUA = await this.captureAndGetUnifiedUserAgent(clientHeaders, account)
// 获取过滤后的客户端 headers
const filteredHeaders = this._filterClientHeaders(clientHeaders)
@@ -881,14 +946,18 @@ class ClaudeRelayService {
...finalHeaders
},
agent: proxyAgent,
timeout: config.proxy.timeout
timeout: config.requestTimeout || 600000
}
// 如果客户端没有提供 User-Agent使用默认值
// 使用统一 User-Agent 或客户端提供的,最后使用默认值
if (!options.headers['User-Agent'] && !options.headers['user-agent']) {
options.headers['User-Agent'] = 'claude-cli/1.0.57 (external, cli)'
const userAgent = unifiedUA || 'claude-cli/1.0.57 (external, cli)'
options.headers['User-Agent'] = userAgent
}
logger.info(
`🔗 指纹是这个: ${options.headers['User-Agent'] || options.headers['user-agent']}`
)
// 使用自定义的 betaHeader 或默认值
const betaHeader =
requestOptions?.betaHeader !== undefined ? requestOptions.betaHeader : this.betaHeader
@@ -903,24 +972,57 @@ class ClaudeRelayService {
if (res.statusCode !== 200) {
// 将错误处理逻辑封装在一个异步函数中
const handleErrorResponse = async () => {
// 增加对5xx错误的处理
if (res.statusCode >= 500 && res.statusCode < 600) {
if (res.statusCode === 401) {
logger.warn(`🔐 [Stream] Unauthorized error (401) detected for account ${accountId}`)
await this.recordUnauthorizedError(accountId)
const errorCount = await this.getUnauthorizedErrorCount(accountId)
logger.info(
`🔐 [Stream] Account ${accountId} has ${errorCount} consecutive 401 errors in the last 5 minutes`
)
if (errorCount >= 1) {
logger.error(
`❌ [Stream] Account ${accountId} encountered 401 error (${errorCount} errors), marking as unauthorized`
)
await unifiedClaudeScheduler.markAccountUnauthorized(
accountId,
accountType,
sessionHash
)
}
} else if (res.statusCode === 403) {
logger.error(
`🚫 [Stream] Forbidden error (403) detected for account ${accountId}, marking as blocked`
)
await unifiedClaudeScheduler.markAccountBlocked(accountId, accountType, sessionHash)
} else if (res.statusCode === 529) {
logger.warn(`🚫 [Stream] Overload error (529) detected for account ${accountId}`)
// 检查是否启用了529错误处理
if (config.claude.overloadHandling.enabled > 0) {
try {
await claudeAccountService.markAccountOverloaded(accountId)
logger.info(
`🚫 [Stream] Account ${accountId} marked as overloaded for ${config.claude.overloadHandling.enabled} minutes`
)
} catch (overloadError) {
logger.error(
`❌ [Stream] Failed to mark account as overloaded: ${accountId}`,
overloadError
)
}
} else {
logger.info(
`🚫 [Stream] 529 error handling is disabled, skipping account overload marking`
)
}
} else if (res.statusCode >= 500 && res.statusCode < 600) {
logger.warn(
`🔥 [Stream] Server error (${res.statusCode}) detected for account ${accountId}`
)
// 记录5xx错误
await claudeAccountService.recordServerError(accountId, res.statusCode)
// 检查是否需要标记为临时错误状态连续3次500
const errorCount = await claudeAccountService.getServerErrorCount(accountId)
logger.info(
`🔥 [Stream] Account ${accountId} has ${errorCount} consecutive 5xx errors in the last 5 minutes`
)
if (errorCount >= 3) {
logger.error(
`❌ [Stream] Account ${accountId} exceeded 5xx error threshold (${errorCount} errors), marking as temp_error`
)
await claudeAccountService.markAccountTempError(accountId, sessionHash)
}
await this._handleServerError(accountId, res.statusCode, sessionHash, '[Stream]')
}
}
@@ -929,7 +1031,9 @@ class ClaudeRelayService {
logger.error('❌ Error in stream error handler:', err)
})
logger.error(`❌ Claude API returned error status: ${res.statusCode}`)
logger.error(
`❌ Claude API returned error status: ${res.statusCode} | Account: ${account?.name || accountId}`
)
let errorData = ''
res.on('data', (chunk) => {
@@ -938,7 +1042,10 @@ class ClaudeRelayService {
res.on('end', () => {
console.error(': ❌ ', errorData)
logger.error('❌ Claude API error response:', errorData)
logger.error(
`❌ Claude API error response (Account: ${account?.name || accountId}):`,
errorData
)
if (!responseStream.destroyed) {
// 发送错误事件
responseStream.write('event: error\n')
@@ -1189,6 +1296,27 @@ class ClaudeRelayService {
usageCallback(finalUsage)
}
// 提取5小时会话窗口状态
// 使用大小写不敏感的方式获取响应头
const get5hStatus = (headers) => {
if (!headers) {
return null
}
// HTTP头部名称不区分大小写需要处理不同情况
return (
headers['anthropic-ratelimit-unified-5h-status'] ||
headers['Anthropic-Ratelimit-Unified-5h-Status'] ||
headers['ANTHROPIC-RATELIMIT-UNIFIED-5H-STATUS']
)
}
const sessionWindowStatus = get5hStatus(res.headers)
if (sessionWindowStatus) {
logger.info(`📊 Session window status for account ${accountId}: ${sessionWindowStatus}`)
// 保存会话窗口状态到账户数据
await claudeAccountService.updateSessionWindowStatus(accountId, sessionWindowStatus)
}
// 处理限流状态
if (rateLimitDetected || res.statusCode === 429) {
// 提取限流重置时间戳
@@ -1220,6 +1348,19 @@ class ClaudeRelayService {
await unifiedClaudeScheduler.removeAccountRateLimit(accountId, accountType)
}
// 如果流式请求成功,检查并移除过载状态
try {
const isOverloaded = await claudeAccountService.isAccountOverloaded(accountId)
if (isOverloaded) {
await claudeAccountService.removeAccountOverload(accountId)
}
} catch (overloadError) {
logger.error(
`❌ [Stream] Failed to check/remove overload status for account ${accountId}:`,
overloadError
)
}
// 只有真实的 Claude Code 请求才更新 headers流式请求
if (
clientHeaders &&
@@ -1235,12 +1376,16 @@ class ClaudeRelayService {
})
})
req.on('error', (error) => {
logger.error('❌ Claude stream request error:', error.message, {
code: error.code,
errno: error.errno,
syscall: error.syscall
})
req.on('error', async (error) => {
logger.error(
`❌ Claude stream request error (Account: ${account?.name || accountId}):`,
error.message,
{
code: error.code,
errno: error.errno,
syscall: error.syscall
}
)
// 根据错误类型提供更具体的错误信息
let errorMessage = 'Upstream request failed'
@@ -1282,9 +1427,10 @@ class ClaudeRelayService {
reject(error)
})
req.on('timeout', () => {
req.on('timeout', async () => {
req.destroy()
logger.error('❌ Claude stream request timeout')
logger.error(`❌ Claude stream request timeout | Account: ${account?.name || accountId}`)
if (!responseStream.headersSent) {
responseStream.writeHead(504, {
'Content-Type': 'text/event-stream',
@@ -1348,12 +1494,17 @@ class ClaudeRelayService {
...filteredHeaders
},
agent: proxyAgent,
timeout: config.proxy.timeout
timeout: config.requestTimeout || 600000
}
// 如果客户端没有提供 User-Agent使用默认值
if (!filteredHeaders['User-Agent'] && !filteredHeaders['user-agent']) {
options.headers['User-Agent'] = 'claude-cli/1.0.53 (external, cli)'
// 第三个方法不支持统一 User-Agent使用简化逻辑
const userAgent =
clientHeaders?.['user-agent'] ||
clientHeaders?.['User-Agent'] ||
'claude-cli/1.0.102 (external, cli)'
options.headers['User-Agent'] = userAgent
}
// 使用自定义的 betaHeader 或默认值
@@ -1379,8 +1530,8 @@ class ClaudeRelayService {
})
})
req.on('error', (error) => {
logger.error('❌ Claude stream request error:', error.message, {
req.on('error', async (error) => {
logger.error(`❌ Claude stream request error:`, error.message, {
code: error.code,
errno: error.errno,
syscall: error.syscall
@@ -1426,9 +1577,10 @@ class ClaudeRelayService {
reject(error)
})
req.on('timeout', () => {
req.on('timeout', async () => {
req.destroy()
logger.error('❌ Claude stream request timeout')
logger.error(`❌ Claude stream request timeout`)
if (!responseStream.headersSent) {
responseStream.writeHead(504, {
'Content-Type': 'text/event-stream',
@@ -1465,6 +1617,33 @@ class ClaudeRelayService {
})
}
// 🛠️ 统一的错误处理方法
async _handleServerError(accountId, statusCode, sessionHash = null, context = '') {
try {
await claudeAccountService.recordServerError(accountId, statusCode)
const errorCount = await claudeAccountService.getServerErrorCount(accountId)
// 根据错误类型设置不同的阈值和日志前缀
const isTimeout = statusCode === 504
const threshold = 3 // 统一使用3次阈值
const prefix = context ? `${context} ` : ''
logger.warn(
`⏱️ ${prefix}${isTimeout ? 'Timeout' : 'Server'} error for account ${accountId}, error count: ${errorCount}/${threshold}`
)
if (errorCount > threshold) {
const errorTypeLabel = isTimeout ? 'timeout' : '5xx'
logger.error(
`${prefix}Account ${accountId} exceeded ${errorTypeLabel} error threshold (${errorCount} errors), marking as temp_error`
)
await claudeAccountService.markAccountTempError(accountId, sessionHash)
}
} catch (handlingError) {
logger.error(`❌ Failed to handle ${context} server error:`, handlingError)
}
}
// 🔄 重试逻辑
async _retryRequest(requestFunc, maxRetries = 3) {
let lastError
@@ -1490,7 +1669,6 @@ class ClaudeRelayService {
async recordUnauthorizedError(accountId) {
try {
const key = `claude_account:${accountId}:401_errors`
const redis = require('../models/redis')
// 增加错误计数设置5分钟过期时间
await redis.client.incr(key)
@@ -1506,7 +1684,6 @@ class ClaudeRelayService {
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
@@ -1520,7 +1697,6 @@ class ClaudeRelayService {
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}`)
@@ -1529,6 +1705,103 @@ class ClaudeRelayService {
}
}
// 🔧 动态捕获并获取统一的 User-Agent
async captureAndGetUnifiedUserAgent(clientHeaders, account) {
if (account.useUnifiedUserAgent !== 'true') {
return null
}
const CACHE_KEY = 'claude_code_user_agent:daily'
const TTL = 90000 // 25小时
// ⚠️ 重要:这里通过正则表达式判断是否为 Claude Code 客户端
// 如果未来 Claude Code 的 User-Agent 格式发生变化,需要更新这个正则表达式
// 当前已知格式claude-cli/1.0.102 (external, cli)
const CLAUDE_CODE_UA_PATTERN = /^claude-cli\/[\d.]+\s+\(/i
const clientUA = clientHeaders?.['user-agent'] || clientHeaders?.['User-Agent']
let cachedUA = await redis.client.get(CACHE_KEY)
if (clientUA && CLAUDE_CODE_UA_PATTERN.test(clientUA)) {
if (!cachedUA) {
// 没有缓存,直接存储
await redis.client.setex(CACHE_KEY, TTL, clientUA)
logger.info(`📱 Captured unified Claude Code User-Agent: ${clientUA}`)
cachedUA = clientUA
} else {
// 有缓存,比较版本号,保存更新的版本
const shouldUpdate = this.compareClaudeCodeVersions(clientUA, cachedUA)
if (shouldUpdate) {
await redis.client.setex(CACHE_KEY, TTL, clientUA)
logger.info(`🔄 Updated to newer Claude Code User-Agent: ${clientUA} (was: ${cachedUA})`)
cachedUA = clientUA
} else {
// 当前版本不比缓存版本新仅刷新TTL
await redis.client.expire(CACHE_KEY, TTL)
}
}
}
return cachedUA // 没有缓存返回 null
}
// 🔄 比较Claude Code版本号判断是否需要更新
// 返回 true 表示 newUA 版本更新,需要更新缓存
compareClaudeCodeVersions(newUA, cachedUA) {
try {
// 提取版本号claude-cli/1.0.102 (external, cli) -> 1.0.102
// 支持多段版本号格式,如 1.0.102、2.1.0.beta1 等
const newVersionMatch = newUA.match(/claude-cli\/([\d.]+(?:[a-zA-Z0-9-]*)?)/i)
const cachedVersionMatch = cachedUA.match(/claude-cli\/([\d.]+(?:[a-zA-Z0-9-]*)?)/i)
if (!newVersionMatch || !cachedVersionMatch) {
// 无法解析版本号,优先使用新的
logger.warn(`⚠️ Unable to parse Claude Code versions: new=${newUA}, cached=${cachedUA}`)
return true
}
const newVersion = newVersionMatch[1]
const cachedVersion = cachedVersionMatch[1]
// 比较版本号 (semantic version)
const compareResult = this.compareSemanticVersions(newVersion, cachedVersion)
logger.debug(`🔍 Version comparison: ${newVersion} vs ${cachedVersion} = ${compareResult}`)
return compareResult > 0 // 新版本更大则返回 true
} catch (error) {
logger.warn(`⚠️ Error comparing Claude Code versions, defaulting to update: ${error.message}`)
return true // 出错时优先使用新的
}
}
// 🔢 比较版本号
// 返回1 表示 v1 > v2-1 表示 v1 < v20 表示相等
compareSemanticVersions(version1, version2) {
// 将版本号字符串按"."分割成数字数组
const arr1 = version1.split('.')
const arr2 = version2.split('.')
// 获取两个版本号数组中的最大长度
const maxLength = Math.max(arr1.length, arr2.length)
// 循环遍历,逐段比较版本号
for (let i = 0; i < maxLength; i++) {
// 如果某个版本号的某一段不存在则视为0
const num1 = parseInt(arr1[i] || 0, 10)
const num2 = parseInt(arr2[i] || 0, 10)
if (num1 > num2) {
return 1 // version1 大于 version2
}
if (num1 < num2) {
return -1 // version1 小于 version2
}
}
return 0 // 两个版本号相等
}
// 🎯 健康检查
async healthCheck() {
try {

View File

@@ -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'
@@ -1008,16 +1022,24 @@ async function loadCodeAssist(client, projectId = null) {
const clientMetadata = {
ideType: 'IDE_UNSPECIFIED',
platform: 'PLATFORM_UNSPECIFIED',
pluginType: 'GEMINI',
duetProject: projectId
pluginType: 'GEMINI'
}
// 只有当projectId存在时才添加duetProject
if (projectId) {
clientMetadata.duetProject = projectId
}
const request = {
cloudaicompanionProject: projectId,
metadata: clientMetadata
}
const response = await axios({
// 只有当projectId存在时才添加cloudaicompanionProject
if (projectId) {
request.cloudaicompanionProject = projectId
}
const axiosConfig = {
url: `${CODE_ASSIST_ENDPOINT}/${CODE_ASSIST_API_VERSION}:loadCodeAssist`,
method: 'POST',
headers: {
@@ -1026,7 +1048,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 +1094,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'
@@ -1069,10 +1104,37 @@ async function onboardUser(client, tierId, projectId, clientMetadata) {
const onboardReq = {
tierId,
cloudaicompanionProject: projectId,
metadata: clientMetadata
}
// 只有当projectId存在时才添加cloudaicompanionProject
if (projectId) {
onboardReq.cloudaicompanionProject = projectId
}
// 创建基础axios配置
const baseAxiosConfig = {
url: `${CODE_ASSIST_ENDPOINT}/${CODE_ASSIST_API_VERSION}:onboardUser`,
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json'
},
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,
@@ -1081,16 +1143,7 @@ async function onboardUser(client, tierId, projectId, clientMetadata) {
})
// 轮询onboardUser直到长运行操作完成
let 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
})
let lroRes = await axios(baseAxiosConfig)
let attempts = 0
const maxAttempts = 12 // 最多等待1分钟5秒 * 12次
@@ -1099,17 +1152,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 +1164,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 +1189,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 +1212,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 +1226,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 +1244,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 +1253,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
@@ -1229,14 +1290,22 @@ async function generateContent(
// 按照 gemini-cli 的转换格式构造请求
const request = {
model: requestData.model,
project: projectId,
user_prompt_id: userPromptId,
request: {
...requestData.request,
session_id: sessionId
}
}
// 只有当 userPromptId 存在时才添加
if (userPromptId) {
request.user_prompt_id = userPromptId
}
// 只有当projectId存在时才添加project字段
if (projectId) {
request.project = projectId
}
logger.info('🤖 generateContent API调用开始', {
model: requestData.model,
userPromptId,
@@ -1244,6 +1313,12 @@ async function generateContent(
sessionId
})
// 添加详细的请求日志
logger.info('📦 generateContent 请求详情', {
url: `${CODE_ASSIST_ENDPOINT}/${CODE_ASSIST_API_VERSION}:generateContent`,
requestBody: JSON.stringify(request, null, 2)
})
const axiosConfig = {
url: `${CODE_ASSIST_ENDPOINT}/${CODE_ASSIST_API_VERSION}:generateContent`,
method: 'POST',
@@ -1291,14 +1366,22 @@ async function generateContentStream(
// 按照 gemini-cli 的转换格式构造请求
const request = {
model: requestData.model,
project: projectId,
user_prompt_id: userPromptId,
request: {
...requestData.request,
session_id: sessionId
}
}
// 只有当 userPromptId 存在时才添加
if (userPromptId) {
request.user_prompt_id = userPromptId
}
// 只有当projectId存在时才添加project字段
if (projectId) {
request.project = projectId
}
logger.info('🌊 streamGenerateContent API调用开始', {
model: requestData.model,
userPromptId,

View File

@@ -273,7 +273,7 @@ async function sendGeminiRequest({
'Content-Type': 'application/json'
},
data: requestBody,
timeout: config.requestTimeout || 120000
timeout: config.requestTimeout || 600000
}
// 添加代理配置
@@ -382,7 +382,7 @@ async function getAvailableModels(accessToken, proxy, projectId, location = 'us-
headers: {
Authorization: `Bearer ${accessToken}`
},
timeout: 30000
timeout: config.requestTimeout || 600000
}
const proxyAgent = createProxyAgent(proxy)
@@ -482,7 +482,7 @@ async function countTokens({
'X-Goog-User-Project': projectId || undefined
},
data: requestBody,
timeout: 30000
timeout: config.requestTimeout || 600000
}
// 添加代理配置

753
src/services/ldapService.js Normal file
View File

@@ -0,0 +1,753 @@
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
// 验证配置 - 只有在 LDAP 配置存在且启用时才验证
if (this.config && 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
}
// 🌐 从DN中提取域名用于Windows AD UPN格式认证
extractDomainFromDN(dnString) {
try {
if (!dnString || typeof dnString !== 'string') {
return null
}
// 提取所有DC组件DC=test,DC=demo,DC=com
const dcMatches = dnString.match(/DC=([^,]+)/gi)
if (!dcMatches || dcMatches.length === 0) {
return null
}
// 提取DC值并连接成域名
const domainParts = dcMatches.map((match) => {
const value = match.replace(/DC=/i, '').trim()
return value
})
if (domainParts.length > 0) {
const domain = domainParts.join('.')
logger.debug(`🌐 从DN提取域名: ${domain}`)
return domain
}
return null
} catch (error) {
logger.debug('⚠️ 域名提取失败:', error.message)
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) => {
// 防止LDAP注入转义特殊字符
// 根据RFC 4515需要转义的特殊字符* ( ) \ NUL
const escapedUsername = username
.replace(/\\/g, '\\5c') // 反斜杠必须先转义
.replace(/\*/g, '\\2a') // 星号
.replace(/\(/g, '\\28') // 左括号
.replace(/\)/g, '\\29') // 右括号
.replace(/\0/g, '\\00') // NUL字符
.replace(/\//g, '\\2f') // 斜杠
const searchFilter = this.config.server.searchFilter.replace('{{username}}', escapedUsername)
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)
}
})
})
}
// 🔐 Windows AD兼容认证 - 在DN认证失败时尝试多种格式
async tryWindowsADAuthentication(username, password) {
if (!username || !password) {
return false
}
// 从searchBase提取域名
const domain = this.extractDomainFromDN(this.config.server.searchBase)
const adFormats = []
if (domain) {
// UPN格式Windows AD标准
adFormats.push(`${username}@${domain}`)
// 如果域名有多个部分,也尝试简化版本
const domainParts = domain.split('.')
if (domainParts.length > 1) {
adFormats.push(`${username}@${domainParts.slice(-2).join('.')}`) // 只取后两部分
}
// 域\用户名格式
const firstDomainPart = domainParts[0]
if (firstDomainPart) {
adFormats.push(`${firstDomainPart}\\${username}`)
adFormats.push(`${firstDomainPart.toUpperCase()}\\${username}`)
}
}
// 纯用户名(最后尝试)
adFormats.push(username)
logger.info(`🔄 尝试 ${adFormats.length} 种Windows AD认证格式...`)
for (const format of adFormats) {
try {
logger.info(`🔍 尝试格式: ${format}`)
const result = await this.tryDirectBind(format, password)
if (result) {
logger.info(`✅ Windows AD认证成功: ${format}`)
return true
}
logger.debug(`❌ 认证失败: ${format}`)
} catch (error) {
logger.debug(`认证异常 ${format}:`, error.message)
}
}
logger.info(`🚫 所有Windows AD格式认证都失败了`)
return false
}
// 🔐 直接尝试绑定认证的辅助方法
async tryDirectBind(identifier, password) {
return new Promise((resolve, reject) => {
const authClient = this.createClient()
authClient.bind(identifier, password, (err) => {
authClient.unbind()
if (err) {
if (err.name === 'InvalidCredentialsError') {
resolve(false)
} else {
reject(err)
}
} else {
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. 验证用户密码 - 支持传统LDAP和Windows AD
let isPasswordValid = false
// 首先尝试传统的DN认证保持原有LDAP逻辑
try {
isPasswordValid = await this.authenticateUser(userDN, password)
if (isPasswordValid) {
logger.info(`✅ DN authentication successful for user: ${sanitizedUsername}`)
}
} catch (error) {
logger.debug(
`DN authentication failed for user: ${sanitizedUsername}, error: ${error.message}`
)
}
// 如果DN认证失败尝试Windows AD多格式认证
if (!isPasswordValid) {
logger.debug(`🔄 Trying Windows AD authentication formats for user: ${sanitizedUsername}`)
isPasswordValid = await this.tryWindowsADAuthentication(sanitizedUsername, password)
if (isPasswordValid) {
logger.info(`✅ Windows AD authentication successful for user: ${sanitizedUsername}`)
}
}
if (!isPasswordValid) {
logger.info(`🚫 All authentication methods failed 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:', {
username: sanitizedUsername,
error: error.message,
stack: process.env.NODE_ENV === 'development' ? error.stack : undefined
})
// 返回通用错误消息,避免信息泄露
// 不要尝试解析具体的错误信息因为不同LDAP服务器返回的格式不同
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: error.message,
server: this.config.server.url,
stack: process.env.NODE_ENV === 'development' ? error.stack : undefined
})
// 提供通用错误消息,避免泄露系统细节
let userMessage = 'LDAP connection failed'
// 对于某些已知错误类型,提供有用但不泄露细节的信息
if (error.code === 'ECONNREFUSED') {
userMessage = 'Unable to connect to LDAP server'
} else if (error.code === 'ETIMEDOUT') {
userMessage = 'LDAP server connection timeout'
} else if (error.name === 'InvalidCredentialsError') {
userMessage = 'LDAP bind credentials are invalid'
}
return {
success: false,
message: userMessage,
server: this.config.server.url.replace(/:[^:]*@/, ':***@') // 隐藏密码部分
}
} 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()

View File

@@ -14,7 +14,7 @@ const {
logRefreshSkipped
} = require('../utils/tokenRefreshLogger')
const LRUCache = require('../utils/lruCache')
// const tokenRefreshService = require('./tokenRefreshService')
const tokenRefreshService = require('./tokenRefreshService')
// 加密相关常量
const ALGORITHM = 'aes-256-cbc'
@@ -57,7 +57,17 @@ function encrypt(text) {
// 解密函数
function decrypt(text) {
if (!text) {
if (!text || text === '') {
return ''
}
// 检查是否是有效的加密格式(至少需要 32 个字符的 IV + 冒号 + 加密文本)
if (text.length < 33 || text.charAt(32) !== ':') {
logger.warn('Invalid encrypted text format, returning empty string', {
textLength: text ? text.length : 0,
char32: text && text.length > 32 ? text.charAt(32) : 'N/A',
first50: text ? text.substring(0, 50) : 'N/A'
})
return ''
}
@@ -128,13 +138,14 @@ async function refreshAccessToken(refreshToken, proxy = null) {
'Content-Length': requestData.length
},
data: requestData,
timeout: 30000 // 30秒超时
timeout: config.requestTimeout || 600000 // 使用统一的请求超时配置
}
// 配置代理(如果有)
const proxyAgent = ProxyHelper.createProxyAgent(proxy)
if (proxyAgent) {
requestOptions.httpsAgent = proxyAgent
requestOptions.proxy = false // 重要:禁用 axios 的默认代理,强制使用我们的 httpsAgent
logger.info(
`🌐 Using proxy for OpenAI token refresh: ${ProxyHelper.getProxyDescription(proxy)}`
)
@@ -143,6 +154,7 @@ async function refreshAccessToken(refreshToken, proxy = null) {
}
// 发送请求
logger.info('🔍 发送 token 刷新请求,使用代理:', !!requestOptions.httpsAgent)
const response = await axios(requestOptions)
if (response.status === 200 && response.data) {
@@ -164,22 +176,73 @@ async function refreshAccessToken(refreshToken, proxy = null) {
} catch (error) {
if (error.response) {
// 服务器响应了错误状态码
const errorData = error.response.data || {}
logger.error('OpenAI token refresh failed:', {
status: error.response.status,
data: error.response.data,
data: errorData,
headers: error.response.headers
})
throw new Error(
`Token refresh failed: ${error.response.status} - ${JSON.stringify(error.response.data)}`
)
// 构建详细的错误信息
let errorMessage = `OpenAI 服务器返回错误 (${error.response.status})`
if (error.response.status === 400) {
if (errorData.error === 'invalid_grant') {
errorMessage = 'Refresh Token 无效或已过期,请重新授权'
} else if (errorData.error === 'invalid_request') {
errorMessage = `请求参数错误:${errorData.error_description || errorData.error}`
} else {
errorMessage = `请求错误:${errorData.error_description || errorData.error || '未知错误'}`
}
} else if (error.response.status === 401) {
errorMessage = '认证失败Refresh Token 无效'
} else if (error.response.status === 403) {
errorMessage = '访问被拒绝:可能是 IP 被封或账户被禁用'
} else if (error.response.status === 429) {
errorMessage = '请求过于频繁,请稍后重试'
} else if (error.response.status >= 500) {
errorMessage = 'OpenAI 服务器内部错误,请稍后重试'
} else if (errorData.error_description) {
errorMessage = errorData.error_description
} else if (errorData.error) {
errorMessage = errorData.error
} else if (errorData.message) {
errorMessage = errorData.message
}
const fullError = new Error(errorMessage)
fullError.status = error.response.status
fullError.details = errorData
throw fullError
} else if (error.request) {
// 请求已发出但没有收到响应
logger.error('OpenAI token refresh no response:', error.message)
throw new Error(`Token refresh failed: No response from server - ${error.message}`)
let errorMessage = '无法连接到 OpenAI 服务器'
if (proxy) {
errorMessage += `(代理: ${ProxyHelper.getProxyDescription(proxy)}`
}
if (error.code === 'ECONNREFUSED') {
errorMessage += ' - 连接被拒绝'
} else if (error.code === 'ETIMEDOUT') {
errorMessage += ' - 连接超时'
} else if (error.code === 'ENOTFOUND') {
errorMessage += ' - 无法解析域名'
} else if (error.code === 'EPROTO') {
errorMessage += ' - 协议错误(可能是代理配置问题)'
} else if (error.message) {
errorMessage += ` - ${error.message}`
}
const fullError = new Error(errorMessage)
fullError.code = error.code
throw fullError
} else {
// 设置请求时发生错误
logger.error('OpenAI token refresh error:', error.message)
throw new Error(`Token refresh failed: ${error.message}`)
const fullError = new Error(`请求设置错误: ${error.message}`)
fullError.originalError = error
throw fullError
}
}
}
@@ -192,34 +255,71 @@ function isTokenExpired(account) {
return new Date(account.expiresAt) <= new Date()
}
// 刷新账户的 access token
// 刷新账户的 access token(带分布式锁)
async function refreshAccountToken(accountId) {
const account = await getAccount(accountId)
if (!account) {
throw new Error('Account not found')
}
const accountName = account.name || accountId
logRefreshStart(accountId, accountName, 'openai')
// 检查是否有 refresh token
const refreshToken = account.refreshToken ? decrypt(account.refreshToken) : null
if (!refreshToken) {
logRefreshSkipped(accountId, accountName, 'openai', 'No refresh token available')
throw new Error('No refresh token available')
}
// 获取代理配置
let proxy = null
if (account.proxy) {
try {
proxy = typeof account.proxy === 'string' ? JSON.parse(account.proxy) : account.proxy
} catch (e) {
logger.warn(`Failed to parse proxy config for account ${accountId}:`, e)
}
}
let lockAcquired = false
let account = null
let accountName = accountId
try {
account = await getAccount(accountId)
if (!account) {
throw new Error('Account not found')
}
accountName = account.name || accountId
// 检查是否有 refresh token
// account.refreshToken 在 getAccount 中已经被解密了,直接使用即可
const refreshToken = account.refreshToken || null
if (!refreshToken) {
logRefreshSkipped(accountId, accountName, 'openai', 'No refresh token available')
throw new Error('No refresh token available')
}
// 尝试获取分布式锁
lockAcquired = await tokenRefreshService.acquireRefreshLock(accountId, 'openai')
if (!lockAcquired) {
// 如果无法获取锁,说明另一个进程正在刷新
logger.info(
`🔒 Token refresh already in progress for OpenAI account: ${accountName} (${accountId})`
)
logRefreshSkipped(accountId, accountName, 'openai', 'already_locked')
// 等待一段时间后返回,期望其他进程已完成刷新
await new Promise((resolve) => setTimeout(resolve, 2000))
// 重新获取账户数据(可能已被其他进程刷新)
const updatedAccount = await getAccount(accountId)
if (updatedAccount && !isTokenExpired(updatedAccount)) {
return {
access_token: decrypt(updatedAccount.accessToken),
id_token: updatedAccount.idToken,
refresh_token: updatedAccount.refreshToken,
expires_in: 3600,
expiry_date: new Date(updatedAccount.expiresAt).getTime()
}
}
throw new Error('Token refresh in progress by another process')
}
// 获取锁成功,开始刷新
logRefreshStart(accountId, accountName, 'openai')
logger.info(`🔄 Starting token refresh for OpenAI account: ${accountName} (${accountId})`)
// 获取代理配置
let proxy = null
if (account.proxy) {
try {
proxy = typeof account.proxy === 'string' ? JSON.parse(account.proxy) : account.proxy
} catch (e) {
logger.warn(`Failed to parse proxy config for account ${accountId}:`, e)
}
}
const newTokens = await refreshAccessToken(refreshToken, proxy)
if (!newTokens) {
throw new Error('Failed to refresh token')
@@ -231,9 +331,51 @@ async function refreshAccountToken(accountId) {
expiresAt: new Date(newTokens.expiry_date).toISOString()
}
// 如果有新的 ID token也更新它
// 如果有新的 ID token也更新它(这对于首次未提供 ID Token 的账户特别重要)
if (newTokens.id_token) {
updates.idToken = encrypt(newTokens.id_token)
// 如果之前没有 ID Token尝试解析并更新用户信息
if (!account.idToken || account.idToken === '') {
try {
const idTokenParts = newTokens.id_token.split('.')
if (idTokenParts.length === 3) {
const payload = JSON.parse(Buffer.from(idTokenParts[1], 'base64').toString())
const authClaims = payload['https://api.openai.com/auth'] || {}
// 更新账户信息 - 使用正确的字段名
// OpenAI ID Token中用户ID在chatgpt_account_id、chatgpt_user_id和user_id字段
if (authClaims.chatgpt_account_id) {
updates.accountId = authClaims.chatgpt_account_id
}
if (authClaims.chatgpt_user_id) {
updates.chatgptUserId = authClaims.chatgpt_user_id
} else if (authClaims.user_id) {
// 有些情况下可能只有user_id字段
updates.chatgptUserId = authClaims.user_id
}
if (authClaims.organizations?.[0]?.id) {
updates.organizationId = authClaims.organizations[0].id
}
if (authClaims.organizations?.[0]?.role) {
updates.organizationRole = authClaims.organizations[0].role
}
if (authClaims.organizations?.[0]?.title) {
updates.organizationTitle = authClaims.organizations[0].title
}
if (payload.email) {
updates.email = encrypt(payload.email)
}
if (payload.email_verified !== undefined) {
updates.emailVerified = payload.email_verified
}
logger.info(`Updated user info from ID Token for account ${accountId}`)
}
} catch (e) {
logger.warn(`Failed to parse ID Token for account ${accountId}:`, e)
}
}
}
// 如果返回了新的 refresh token更新它
@@ -248,8 +390,34 @@ async function refreshAccountToken(accountId) {
logRefreshSuccess(accountId, accountName, 'openai', newTokens.expiry_date)
return newTokens
} catch (error) {
logRefreshError(accountId, accountName, 'openai', error.message)
logRefreshError(accountId, account?.name || accountName, 'openai', error.message)
// 发送 Webhook 通知(如果启用)
try {
const webhookNotifier = require('../utils/webhookNotifier')
await webhookNotifier.sendAccountAnomalyNotification({
accountId,
accountName: account?.name || accountName,
platform: 'openai',
status: 'error',
errorCode: 'OPENAI_TOKEN_REFRESH_FAILED',
reason: `Token refresh failed: ${error.message}`,
timestamp: new Date().toISOString()
})
logger.info(
`📢 Webhook notification sent for OpenAI account ${account?.name || accountName} refresh failure`
)
} catch (webhookError) {
logger.error('Failed to send webhook notification:', webhookError)
}
throw error
} finally {
// 确保释放锁
if (lockAcquired) {
await tokenRefreshService.releaseRefreshLock(accountId, 'openai')
logger.debug(`🔓 Released refresh lock for OpenAI account ${accountId}`)
}
}
}
@@ -270,6 +438,10 @@ async function createAccount(accountData) {
// 处理账户信息
const accountInfo = accountData.accountInfo || {}
// 检查邮箱是否已经是加密格式包含冒号分隔的32位十六进制字符
const isEmailEncrypted =
accountInfo.email && accountInfo.email.length >= 33 && accountInfo.email.charAt(32) === ':'
const account = {
id: accountId,
name: accountData.name,
@@ -282,19 +454,25 @@ async function createAccount(accountData) {
? accountData.rateLimitDuration
: 60,
// OAuth相关字段加密存储
idToken: encrypt(oauthData.idToken || ''),
accessToken: encrypt(oauthData.accessToken || ''),
refreshToken: encrypt(oauthData.refreshToken || ''),
// ID Token 现在是可选的,如果没有提供会在首次刷新时自动获取
idToken: oauthData.idToken && oauthData.idToken.trim() ? encrypt(oauthData.idToken) : '',
accessToken:
oauthData.accessToken && oauthData.accessToken.trim() ? encrypt(oauthData.accessToken) : '',
refreshToken:
oauthData.refreshToken && oauthData.refreshToken.trim()
? encrypt(oauthData.refreshToken)
: '',
openaiOauth: encrypt(JSON.stringify(oauthData)),
// 账户信息字段
// 账户信息字段 - 确保所有字段都被保存,即使是空字符串
accountId: accountInfo.accountId || '',
chatgptUserId: accountInfo.chatgptUserId || '',
organizationId: accountInfo.organizationId || '',
organizationRole: accountInfo.organizationRole || '',
organizationTitle: accountInfo.organizationTitle || '',
planType: accountInfo.planType || '',
email: encrypt(accountInfo.email || ''),
emailVerified: accountInfo.emailVerified || false,
// 邮箱字段:检查是否已经加密,避免双重加密
email: isEmailEncrypted ? accountInfo.email : encrypt(accountInfo.email || ''),
emailVerified: accountInfo.emailVerified === true ? 'true' : 'false',
// 过期时间
expiresAt: oauthData.expires_in
? new Date(Date.now() + oauthData.expires_in * 1000).toISOString()
@@ -339,9 +517,10 @@ async function getAccount(accountId) {
if (accountData.idToken) {
accountData.idToken = decrypt(accountData.idToken)
}
if (accountData.accessToken) {
accountData.accessToken = decrypt(accountData.accessToken)
}
// 注意accessToken 在 openaiRoutes.js 中会被单独解密,这里不解密
// if (accountData.accessToken) {
// accountData.accessToken = decrypt(accountData.accessToken)
// }
if (accountData.refreshToken) {
accountData.refreshToken = decrypt(accountData.refreshToken)
}
@@ -391,7 +570,7 @@ async function updateAccount(accountId, updates) {
if (updates.accessToken) {
updates.accessToken = encrypt(updates.accessToken)
}
if (updates.refreshToken) {
if (updates.refreshToken && updates.refreshToken.trim()) {
updates.refreshToken = encrypt(updates.refreshToken)
}
if (updates.email) {
@@ -476,6 +655,9 @@ async function getAllAccounts() {
accountData.email = decrypt(accountData.email)
}
// 先保存 refreshToken 是否存在的标记
const hasRefreshTokenFlag = !!accountData.refreshToken
// 屏蔽敏感信息token等不应该返回给前端
delete accountData.idToken
delete accountData.accessToken
@@ -502,6 +684,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]' : '',
@@ -510,7 +694,7 @@ async function getAllAccounts() {
scopes:
accountData.scopes && accountData.scopes.trim() ? accountData.scopes.split(' ') : [],
// 添加 hasRefreshToken 标记
hasRefreshToken: !!accountData.refreshToken,
hasRefreshToken: hasRefreshTokenFlag,
// 添加限流状态信息(统一格式)
rateLimitStatus: rateLimitInfo
? {
@@ -630,14 +814,101 @@ function isRateLimited(account) {
}
// 设置账户限流状态
async function setAccountRateLimited(accountId, isLimited) {
async function setAccountRateLimited(accountId, isLimited, resetsInSeconds = null) {
const updates = {
rateLimitStatus: isLimited ? 'limited' : 'normal',
rateLimitedAt: isLimited ? new Date().toISOString() : null
rateLimitedAt: isLimited ? new Date().toISOString() : null,
// 限流时停止调度,解除限流时恢复调度
schedulable: isLimited ? 'false' : 'true'
}
// 如果提供了重置时间(秒数),计算重置时间戳
if (isLimited && resetsInSeconds !== null && resetsInSeconds > 0) {
const resetTime = new Date(Date.now() + resetsInSeconds * 1000).toISOString()
updates.rateLimitResetAt = resetTime
logger.info(
`🕐 Account ${accountId} will be reset at ${resetTime} (in ${resetsInSeconds} seconds / ${Math.ceil(resetsInSeconds / 60)} minutes)`
)
} else if (isLimited) {
// 如果没有提供重置时间使用默认的60分钟
const defaultResetSeconds = 60 * 60 // 1小时
const resetTime = new Date(Date.now() + defaultResetSeconds * 1000).toISOString()
updates.rateLimitResetAt = resetTime
logger.warn(
`⚠️ No reset time provided for account ${accountId}, using default 60 minutes. Reset at ${resetTime}`
)
} else if (!isLimited) {
updates.rateLimitResetAt = null
}
await updateAccount(accountId, updates)
logger.info(`Set rate limit status for OpenAI account ${accountId}: ${updates.rateLimitStatus}`)
logger.info(
`Set rate limit status for OpenAI account ${accountId}: ${updates.rateLimitStatus}, schedulable: ${updates.schedulable}`
)
// 如果被限流,发送 Webhook 通知
if (isLimited) {
try {
const account = await getAccount(accountId)
const webhookNotifier = require('../utils/webhookNotifier')
await webhookNotifier.sendAccountAnomalyNotification({
accountId,
accountName: account.name || accountId,
platform: 'openai',
status: 'blocked',
errorCode: 'OPENAI_RATE_LIMITED',
reason: resetsInSeconds
? `Account rate limited (429 error). Reset in ${Math.ceil(resetsInSeconds / 60)} minutes`
: 'Account rate limited (429 error). Estimated reset in 1 hour',
timestamp: new Date().toISOString()
})
logger.info(`📢 Webhook notification sent for OpenAI account ${account.name} rate limit`)
} catch (webhookError) {
logger.error('Failed to send rate limit webhook notification:', webhookError)
}
}
}
// 🔄 重置账户所有异常状态
async function resetAccountStatus(accountId) {
const account = await getAccount(accountId)
if (!account) {
throw new Error('Account not found')
}
const updates = {
// 根据是否有有效的 accessToken 来设置 status
status: account.accessToken ? 'active' : 'created',
// 恢复可调度状态
schedulable: 'true',
// 清除错误相关字段
errorMessage: null,
rateLimitedAt: null,
rateLimitStatus: 'normal',
rateLimitResetAt: null
}
await updateAccount(accountId, updates)
logger.info(`✅ Reset all error status for OpenAI account ${accountId}`)
// 发送 Webhook 通知
try {
const webhookNotifier = require('../utils/webhookNotifier')
await webhookNotifier.sendAccountAnomalyNotification({
accountId,
accountName: account.name || accountId,
platform: 'openai',
status: 'recovered',
errorCode: 'STATUS_RESET',
reason: 'Account status manually reset',
timestamp: new Date().toISOString()
})
logger.info(`📢 Webhook notification sent for OpenAI account ${account.name} status reset`)
} catch (webhookError) {
logger.error('Failed to send status reset webhook notification:', webhookError)
}
return { success: true, message: 'Account status reset successfully' }
}
// 切换账户调度状态
@@ -669,15 +940,26 @@ async function getAccountRateLimitInfo(accountId) {
return null
}
if (account.rateLimitStatus === 'limited' && account.rateLimitedAt) {
const limitedAt = new Date(account.rateLimitedAt).getTime()
if (account.rateLimitStatus === 'limited') {
const now = Date.now()
const limitDuration = 60 * 60 * 1000 // 1小时
const remainingTime = Math.max(0, limitedAt + limitDuration - now)
let remainingTime = 0
// 优先使用 rateLimitResetAt 字段(精确的重置时间)
if (account.rateLimitResetAt) {
const resetAt = new Date(account.rateLimitResetAt).getTime()
remainingTime = Math.max(0, resetAt - now)
}
// 回退到使用 rateLimitedAt + 默认1小时
else if (account.rateLimitedAt) {
const limitedAt = new Date(account.rateLimitedAt).getTime()
const limitDuration = 60 * 60 * 1000 // 默认1小时
remainingTime = Math.max(0, limitedAt + limitDuration - now)
}
return {
isRateLimited: remainingTime > 0,
rateLimitedAt: account.rateLimitedAt,
rateLimitResetAt: account.rateLimitResetAt,
minutesRemaining: Math.ceil(remainingTime / (60 * 1000))
}
}
@@ -685,6 +967,7 @@ async function getAccountRateLimitInfo(accountId) {
return {
isRateLimited: false,
rateLimitedAt: null,
rateLimitResetAt: null,
minutesRemaining: 0
}
}
@@ -722,6 +1005,7 @@ module.exports = {
refreshAccountToken,
isTokenExpired,
setAccountRateLimited,
resetAccountStatus,
toggleSchedulable,
getAccountRateLimitInfo,
updateAccountUsage,

View File

@@ -0,0 +1,574 @@
const { v4: uuidv4 } = require('uuid')
const crypto = require('crypto')
const redis = require('../models/redis')
const logger = require('../utils/logger')
const config = require('../../config/config')
const LRUCache = require('../utils/lruCache')
class OpenAIResponsesAccountService {
constructor() {
// 加密相关常量
this.ENCRYPTION_ALGORITHM = 'aes-256-cbc'
this.ENCRYPTION_SALT = 'openai-responses-salt'
// Redis 键前缀
this.ACCOUNT_KEY_PREFIX = 'openai_responses_account:'
this.SHARED_ACCOUNTS_KEY = 'shared_openai_responses_accounts'
// 🚀 性能优化:缓存派生的加密密钥,避免每次重复计算
this._encryptionKeyCache = null
// 🔄 解密结果缓存,提高解密性能
this._decryptCache = new LRUCache(500)
// 🧹 定期清理缓存每10分钟
setInterval(
() => {
this._decryptCache.cleanup()
logger.info(
'🧹 OpenAI-Responses decrypt cache cleanup completed',
this._decryptCache.getStats()
)
},
10 * 60 * 1000
)
}
// 创建账户
async createAccount(options = {}) {
const {
name = 'OpenAI Responses Account',
description = '',
baseApi = '', // 必填API 基础地址
apiKey = '', // 必填API 密钥
userAgent = '', // 可选:自定义 User-Agent空则透传原始请求
priority = 50, // 调度优先级 (1-100)
proxy = null,
isActive = true,
accountType = 'shared', // 'dedicated' or 'shared'
schedulable = true, // 是否可被调度
dailyQuota = 0, // 每日额度限制美元0表示不限制
quotaResetTime = '00:00', // 额度重置时间HH:mm格式
rateLimitDuration = 60 // 限流时间(分钟)
} = options
// 验证必填字段
if (!baseApi || !apiKey) {
throw new Error('Base API URL and API Key are required for OpenAI-Responses account')
}
// 规范化 baseApi确保不以 / 结尾)
const normalizedBaseApi = baseApi.endsWith('/') ? baseApi.slice(0, -1) : baseApi
const accountId = uuidv4()
const accountData = {
id: accountId,
platform: 'openai-responses',
name,
description,
baseApi: normalizedBaseApi,
apiKey: this._encryptSensitiveData(apiKey),
userAgent,
priority: priority.toString(),
proxy: proxy ? JSON.stringify(proxy) : '',
isActive: isActive.toString(),
accountType,
schedulable: schedulable.toString(),
createdAt: new Date().toISOString(),
lastUsedAt: '',
status: 'active',
errorMessage: '',
// 限流相关
rateLimitedAt: '',
rateLimitStatus: '',
rateLimitDuration: rateLimitDuration.toString(),
// 额度管理
dailyQuota: dailyQuota.toString(),
dailyUsage: '0',
lastResetDate: redis.getDateStringInTimezone(),
quotaResetTime,
quotaStoppedAt: ''
}
// 保存到 Redis
await this._saveAccount(accountId, accountData)
logger.success(`🚀 Created OpenAI-Responses account: ${name} (${accountId})`)
return {
...accountData,
apiKey: '***' // 返回时隐藏敏感信息
}
}
// 获取账户
async getAccount(accountId) {
const client = redis.getClientSafe()
const key = `${this.ACCOUNT_KEY_PREFIX}${accountId}`
const accountData = await client.hgetall(key)
if (!accountData || !accountData.id) {
return null
}
// 解密敏感数据
accountData.apiKey = this._decryptSensitiveData(accountData.apiKey)
// 解析 JSON 字段
if (accountData.proxy) {
try {
accountData.proxy = JSON.parse(accountData.proxy)
} catch (e) {
accountData.proxy = null
}
}
return accountData
}
// 更新账户
async updateAccount(accountId, updates) {
const account = await this.getAccount(accountId)
if (!account) {
throw new Error('Account not found')
}
// 处理敏感字段加密
if (updates.apiKey) {
updates.apiKey = this._encryptSensitiveData(updates.apiKey)
}
// 处理 JSON 字段
if (updates.proxy !== undefined) {
updates.proxy = updates.proxy ? JSON.stringify(updates.proxy) : ''
}
// 规范化 baseApi
if (updates.baseApi) {
updates.baseApi = updates.baseApi.endsWith('/')
? updates.baseApi.slice(0, -1)
: updates.baseApi
}
// 更新 Redis
const client = redis.getClientSafe()
const key = `${this.ACCOUNT_KEY_PREFIX}${accountId}`
await client.hset(key, updates)
logger.info(`📝 Updated OpenAI-Responses account: ${account.name}`)
return { success: true }
}
// 删除账户
async deleteAccount(accountId) {
const client = redis.getClientSafe()
const key = `${this.ACCOUNT_KEY_PREFIX}${accountId}`
// 从共享账户列表中移除
await client.srem(this.SHARED_ACCOUNTS_KEY, accountId)
// 删除账户数据
await client.del(key)
logger.info(`🗑️ Deleted OpenAI-Responses account: ${accountId}`)
return { success: true }
}
// 获取所有账户
async getAllAccounts(includeInactive = false) {
const client = redis.getClientSafe()
const accountIds = await client.smembers(this.SHARED_ACCOUNTS_KEY)
const accounts = []
for (const accountId of accountIds) {
const account = await this.getAccount(accountId)
if (account) {
// 过滤非活跃账户
if (includeInactive || account.isActive === 'true') {
// 隐藏敏感信息
account.apiKey = '***'
// 获取限流状态信息与普通OpenAI账号保持一致的格式
const rateLimitInfo = this._getRateLimitInfo(account)
// 格式化 rateLimitStatus 为对象(与普通 OpenAI 账号一致)
account.rateLimitStatus = rateLimitInfo.isRateLimited
? {
isRateLimited: true,
rateLimitedAt: account.rateLimitedAt || null,
minutesRemaining: rateLimitInfo.remainingMinutes || 0
}
: {
isRateLimited: false,
rateLimitedAt: null,
minutesRemaining: 0
}
// 转换 schedulable 字段为布尔值(前端需要布尔值来判断)
account.schedulable = account.schedulable !== 'false'
// 转换 isActive 字段为布尔值
account.isActive = account.isActive === 'true'
accounts.push(account)
}
}
}
// 直接从 Redis 获取所有账户(包括非共享账户)
const keys = await client.keys(`${this.ACCOUNT_KEY_PREFIX}*`)
for (const key of keys) {
const accountId = key.replace(this.ACCOUNT_KEY_PREFIX, '')
if (!accountIds.includes(accountId)) {
const accountData = await client.hgetall(key)
if (accountData && accountData.id) {
// 过滤非活跃账户
if (includeInactive || accountData.isActive === 'true') {
// 隐藏敏感信息
accountData.apiKey = '***'
// 解析 JSON 字段
if (accountData.proxy) {
try {
accountData.proxy = JSON.parse(accountData.proxy)
} catch (e) {
accountData.proxy = null
}
}
// 获取限流状态信息与普通OpenAI账号保持一致的格式
const rateLimitInfo = this._getRateLimitInfo(accountData)
// 格式化 rateLimitStatus 为对象(与普通 OpenAI 账号一致)
accountData.rateLimitStatus = rateLimitInfo.isRateLimited
? {
isRateLimited: true,
rateLimitedAt: accountData.rateLimitedAt || null,
minutesRemaining: rateLimitInfo.remainingMinutes || 0
}
: {
isRateLimited: false,
rateLimitedAt: null,
minutesRemaining: 0
}
// 转换 schedulable 字段为布尔值(前端需要布尔值来判断)
accountData.schedulable = accountData.schedulable !== 'false'
// 转换 isActive 字段为布尔值
accountData.isActive = accountData.isActive === 'true'
accounts.push(accountData)
}
}
}
}
return accounts
}
// 标记账户限流
async markAccountRateLimited(accountId, duration = null) {
const account = await this.getAccount(accountId)
if (!account) {
return
}
const rateLimitDuration = duration || parseInt(account.rateLimitDuration) || 60
const now = new Date()
const resetAt = new Date(now.getTime() + rateLimitDuration * 60000)
await this.updateAccount(accountId, {
rateLimitedAt: now.toISOString(),
rateLimitStatus: 'limited',
rateLimitResetAt: resetAt.toISOString(),
rateLimitDuration: rateLimitDuration.toString(),
status: 'rateLimited',
schedulable: 'false', // 防止被调度
errorMessage: `Rate limited until ${resetAt.toISOString()}`
})
logger.warn(
`⏳ Account ${account.name} marked as rate limited for ${rateLimitDuration} minutes (until ${resetAt.toISOString()})`
)
}
// 检查并清除过期的限流状态
async checkAndClearRateLimit(accountId) {
const account = await this.getAccount(accountId)
if (!account || account.rateLimitStatus !== 'limited') {
return false
}
const now = new Date()
let shouldClear = false
// 优先使用 rateLimitResetAt 字段
if (account.rateLimitResetAt) {
const resetAt = new Date(account.rateLimitResetAt)
shouldClear = now >= resetAt
} else {
// 如果没有 rateLimitResetAt使用旧的逻辑
const rateLimitedAt = new Date(account.rateLimitedAt)
const rateLimitDuration = parseInt(account.rateLimitDuration) || 60
shouldClear = now - rateLimitedAt > rateLimitDuration * 60000
}
if (shouldClear) {
// 限流已过期,清除状态
await this.updateAccount(accountId, {
rateLimitedAt: '',
rateLimitStatus: '',
rateLimitResetAt: '',
status: 'active',
schedulable: 'true', // 恢复调度
errorMessage: ''
})
logger.info(`✅ Rate limit cleared for account ${account.name}`)
return true
}
return false
}
// 切换调度状态
async toggleSchedulable(accountId) {
const account = await this.getAccount(accountId)
if (!account) {
throw new Error('Account not found')
}
const newSchedulableStatus = account.schedulable === 'true' ? 'false' : 'true'
await this.updateAccount(accountId, {
schedulable: newSchedulableStatus
})
logger.info(
`🔄 Toggled schedulable status for account ${account.name}: ${newSchedulableStatus}`
)
return {
success: true,
schedulable: newSchedulableStatus === 'true'
}
}
// 更新使用额度
async updateUsageQuota(accountId, amount) {
const account = await this.getAccount(accountId)
if (!account) {
return
}
// 检查是否需要重置额度
const today = redis.getDateStringInTimezone()
if (account.lastResetDate !== today) {
// 重置额度
await this.updateAccount(accountId, {
dailyUsage: amount.toString(),
lastResetDate: today,
quotaStoppedAt: ''
})
} else {
// 累加使用额度
const currentUsage = parseFloat(account.dailyUsage) || 0
const newUsage = currentUsage + amount
const dailyQuota = parseFloat(account.dailyQuota) || 0
const updates = {
dailyUsage: newUsage.toString()
}
// 检查是否超出额度
if (dailyQuota > 0 && newUsage >= dailyQuota) {
updates.status = 'quotaExceeded'
updates.quotaStoppedAt = new Date().toISOString()
updates.errorMessage = `Daily quota exceeded: $${newUsage.toFixed(2)} / $${dailyQuota.toFixed(2)}`
logger.warn(`💸 Account ${account.name} exceeded daily quota`)
}
await this.updateAccount(accountId, updates)
}
}
// 更新账户使用统计(记录 token 使用量)
async updateAccountUsage(accountId, tokens = 0) {
const account = await this.getAccount(accountId)
if (!account) {
return
}
const updates = {
lastUsedAt: new Date().toISOString()
}
// 如果有 tokens 参数且大于0同时更新使用统计
if (tokens > 0) {
const currentTokens = parseInt(account.totalUsedTokens) || 0
updates.totalUsedTokens = (currentTokens + tokens).toString()
}
await this.updateAccount(accountId, updates)
}
// 记录使用量(为了兼容性的别名)
async recordUsage(accountId, tokens = 0) {
return this.updateAccountUsage(accountId, tokens)
}
// 重置账户状态(清除所有异常状态)
async resetAccountStatus(accountId) {
const account = await this.getAccount(accountId)
if (!account) {
throw new Error('Account not found')
}
const updates = {
// 根据是否有有效的 apiKey 来设置 status
status: account.apiKey ? 'active' : 'created',
// 恢复可调度状态
schedulable: 'true',
// 清除错误相关字段
errorMessage: '',
rateLimitedAt: '',
rateLimitStatus: '',
rateLimitResetAt: '',
rateLimitDuration: ''
}
await this.updateAccount(accountId, updates)
logger.info(`✅ Reset all error status for OpenAI-Responses account ${accountId}`)
// 发送 Webhook 通知
try {
const webhookNotifier = require('../utils/webhookNotifier')
await webhookNotifier.sendAccountAnomalyNotification({
accountId,
accountName: account.name || accountId,
platform: 'openai-responses',
status: 'recovered',
errorCode: 'STATUS_RESET',
reason: 'Account status manually reset',
timestamp: new Date().toISOString()
})
logger.info(
`📢 Webhook notification sent for OpenAI-Responses account ${account.name} status reset`
)
} catch (webhookError) {
logger.error('Failed to send status reset webhook notification:', webhookError)
}
return { success: true, message: 'Account status reset successfully' }
}
// 获取限流信息
_getRateLimitInfo(accountData) {
if (accountData.rateLimitStatus !== 'limited') {
return { isRateLimited: false }
}
const now = new Date()
let willBeAvailableAt
let remainingMinutes
// 优先使用 rateLimitResetAt 字段
if (accountData.rateLimitResetAt) {
willBeAvailableAt = new Date(accountData.rateLimitResetAt)
remainingMinutes = Math.max(0, Math.ceil((willBeAvailableAt - now) / 60000))
} else {
// 如果没有 rateLimitResetAt使用旧的逻辑
const rateLimitedAt = new Date(accountData.rateLimitedAt)
const rateLimitDuration = parseInt(accountData.rateLimitDuration) || 60
const elapsedMinutes = Math.floor((now - rateLimitedAt) / 60000)
remainingMinutes = Math.max(0, rateLimitDuration - elapsedMinutes)
willBeAvailableAt = new Date(rateLimitedAt.getTime() + rateLimitDuration * 60000)
}
return {
isRateLimited: remainingMinutes > 0,
remainingMinutes,
willBeAvailableAt
}
}
// 加密敏感数据
_encryptSensitiveData(text) {
if (!text) {
return ''
}
const key = this._getEncryptionKey()
const iv = crypto.randomBytes(16)
const cipher = crypto.createCipheriv(this.ENCRYPTION_ALGORITHM, key, iv)
let encrypted = cipher.update(text)
encrypted = Buffer.concat([encrypted, cipher.final()])
return `${iv.toString('hex')}:${encrypted.toString('hex')}`
}
// 解密敏感数据
_decryptSensitiveData(text) {
if (!text || text === '') {
return ''
}
// 检查缓存
const cacheKey = crypto.createHash('sha256').update(text).digest('hex')
const cached = this._decryptCache.get(cacheKey)
if (cached !== undefined) {
return cached
}
try {
const key = this._getEncryptionKey()
const [ivHex, encryptedHex] = text.split(':')
const iv = Buffer.from(ivHex, 'hex')
const encryptedText = Buffer.from(encryptedHex, 'hex')
const decipher = crypto.createDecipheriv(this.ENCRYPTION_ALGORITHM, key, iv)
let decrypted = decipher.update(encryptedText)
decrypted = Buffer.concat([decrypted, decipher.final()])
const result = decrypted.toString()
// 存入缓存5分钟过期
this._decryptCache.set(cacheKey, result, 5 * 60 * 1000)
return result
} catch (error) {
logger.error('Decryption error:', error)
return ''
}
}
// 获取加密密钥
_getEncryptionKey() {
if (!this._encryptionKeyCache) {
this._encryptionKeyCache = crypto.scryptSync(
config.security.encryptionKey,
this.ENCRYPTION_SALT,
32
)
}
return this._encryptionKeyCache
}
// 保存账户到 Redis
async _saveAccount(accountId, accountData) {
const client = redis.getClientSafe()
const key = `${this.ACCOUNT_KEY_PREFIX}${accountId}`
// 保存账户数据
await client.hset(key, accountData)
// 添加到共享账户列表
if (accountData.accountType === 'shared') {
await client.sadd(this.SHARED_ACCOUNTS_KEY, accountId)
}
}
}
module.exports = new OpenAIResponsesAccountService()

View File

@@ -0,0 +1,708 @@
const axios = require('axios')
const ProxyHelper = require('../utils/proxyHelper')
const logger = require('../utils/logger')
const openaiResponsesAccountService = require('./openaiResponsesAccountService')
const apiKeyService = require('./apiKeyService')
const unifiedOpenAIScheduler = require('./unifiedOpenAIScheduler')
const config = require('../../config/config')
const crypto = require('crypto')
class OpenAIResponsesRelayService {
constructor() {
this.defaultTimeout = config.requestTimeout || 600000
}
// 处理请求转发
async handleRequest(req, res, account, apiKeyData) {
let abortController = null
// 获取会话哈希(如果有的话)
const sessionId = req.headers['session_id'] || req.body?.session_id
const sessionHash = sessionId
? crypto.createHash('sha256').update(sessionId).digest('hex')
: null
try {
// 获取完整的账户信息(包含解密的 API Key
const fullAccount = await openaiResponsesAccountService.getAccount(account.id)
if (!fullAccount) {
throw new Error('Account not found')
}
// 创建 AbortController 用于取消请求
abortController = new AbortController()
// 设置客户端断开监听器
const handleClientDisconnect = () => {
logger.info('🔌 Client disconnected, aborting OpenAI-Responses request')
if (abortController && !abortController.signal.aborted) {
abortController.abort()
}
}
// 监听客户端断开事件
req.once('close', handleClientDisconnect)
res.once('close', handleClientDisconnect)
// 构建目标 URL
const targetUrl = `${fullAccount.baseApi}${req.path}`
logger.info(`🎯 Forwarding to: ${targetUrl}`)
// 构建请求头
const headers = {
...this._filterRequestHeaders(req.headers),
Authorization: `Bearer ${fullAccount.apiKey}`,
'Content-Type': 'application/json'
}
// 处理 User-Agent
if (fullAccount.userAgent) {
// 使用自定义 User-Agent
headers['User-Agent'] = fullAccount.userAgent
logger.debug(`📱 Using custom User-Agent: ${fullAccount.userAgent}`)
} else if (req.headers['user-agent']) {
// 透传原始 User-Agent
headers['User-Agent'] = req.headers['user-agent']
logger.debug(`📱 Forwarding original User-Agent: ${req.headers['user-agent']}`)
}
// 配置请求选项
const requestOptions = {
method: req.method,
url: targetUrl,
headers,
data: req.body,
timeout: this.defaultTimeout,
responseType: req.body?.stream ? 'stream' : 'json',
validateStatus: () => true, // 允许处理所有状态码
signal: abortController.signal
}
// 配置代理(如果有)
if (fullAccount.proxy) {
const proxyAgent = ProxyHelper.createProxyAgent(fullAccount.proxy)
if (proxyAgent) {
requestOptions.httpsAgent = proxyAgent
requestOptions.proxy = false
logger.info(
`🌐 Using proxy for OpenAI-Responses: ${ProxyHelper.getProxyDescription(fullAccount.proxy)}`
)
}
}
// 记录请求信息
logger.info('📤 OpenAI-Responses relay request', {
accountId: account.id,
accountName: account.name,
targetUrl,
method: req.method,
stream: req.body?.stream || false,
model: req.body?.model || 'unknown',
userAgent: headers['User-Agent'] || 'not set'
})
// 发送请求
const response = await axios(requestOptions)
// 处理 429 限流错误
if (response.status === 429) {
const { resetsInSeconds, errorData } = await this._handle429Error(
account,
response,
req.body?.stream,
sessionHash
)
// 返回错误响应(使用处理后的数据,避免循环引用)
const errorResponse = errorData || {
error: {
message: 'Rate limit exceeded',
type: 'rate_limit_error',
code: 'rate_limit_exceeded',
resets_in_seconds: resetsInSeconds
}
}
return res.status(429).json(errorResponse)
}
// 处理其他错误状态码
if (response.status >= 400) {
// 处理流式错误响应
let errorData = response.data
if (response.data && typeof response.data.pipe === 'function') {
// 流式响应需要先读取内容
const chunks = []
await new Promise((resolve) => {
response.data.on('data', (chunk) => chunks.push(chunk))
response.data.on('end', resolve)
response.data.on('error', resolve)
setTimeout(resolve, 5000) // 超时保护
})
const fullResponse = Buffer.concat(chunks).toString()
// 尝试解析错误响应
try {
if (fullResponse.includes('data: ')) {
// SSE格式
const lines = fullResponse.split('\n')
for (const line of lines) {
if (line.startsWith('data: ')) {
const jsonStr = line.slice(6).trim()
if (jsonStr && jsonStr !== '[DONE]') {
errorData = JSON.parse(jsonStr)
break
}
}
}
} else {
// 普通JSON
errorData = JSON.parse(fullResponse)
}
} catch (e) {
logger.error('Failed to parse error response:', e)
errorData = { error: { message: fullResponse || 'Unknown error' } }
}
}
logger.error('OpenAI-Responses API error', {
status: response.status,
statusText: response.statusText,
errorData
})
// 清理监听器
req.removeListener('close', handleClientDisconnect)
res.removeListener('close', handleClientDisconnect)
return res.status(response.status).json(errorData)
}
// 更新最后使用时间
await openaiResponsesAccountService.updateAccount(account.id, {
lastUsedAt: new Date().toISOString()
})
// 处理流式响应
if (req.body?.stream && response.data && typeof response.data.pipe === 'function') {
return this._handleStreamResponse(
response,
res,
account,
apiKeyData,
req.body?.model,
handleClientDisconnect,
req
)
}
// 处理非流式响应
return this._handleNormalResponse(response, res, account, apiKeyData, req.body?.model)
} catch (error) {
// 清理 AbortController
if (abortController && !abortController.signal.aborted) {
abortController.abort()
}
// 安全地记录错误,避免循环引用
const errorInfo = {
message: error.message,
code: error.code,
status: error.response?.status,
statusText: error.response?.statusText
}
logger.error('OpenAI-Responses relay error:', errorInfo)
// 检查是否是网络错误
if (error.code === 'ECONNREFUSED' || error.code === 'ETIMEDOUT') {
await openaiResponsesAccountService.updateAccount(account.id, {
status: 'error',
errorMessage: `Connection error: ${error.code}`
})
}
// 如果已经发送了响应头,直接结束
if (res.headersSent) {
return res.end()
}
// 检查是否是axios错误并包含响应
if (error.response) {
// 处理axios错误响应
const status = error.response.status || 500
let errorData = {
error: {
message: error.response.statusText || 'Request failed',
type: 'api_error',
code: error.code || 'unknown'
}
}
// 如果响应包含数据,尝试使用它
if (error.response.data) {
// 检查是否是流
if (typeof error.response.data === 'object' && !error.response.data.pipe) {
errorData = error.response.data
} else if (typeof error.response.data === 'string') {
try {
errorData = JSON.parse(error.response.data)
} catch (e) {
errorData.error.message = error.response.data
}
}
}
return res.status(status).json(errorData)
}
// 其他错误
return res.status(500).json({
error: {
message: 'Internal server error',
type: 'internal_error',
details: error.message
}
})
}
}
// 处理流式响应
async _handleStreamResponse(
response,
res,
account,
apiKeyData,
requestedModel,
handleClientDisconnect,
req
) {
// 设置 SSE 响应头
res.setHeader('Content-Type', 'text/event-stream')
res.setHeader('Cache-Control', 'no-cache')
res.setHeader('Connection', 'keep-alive')
res.setHeader('X-Accel-Buffering', 'no')
let usageData = null
let actualModel = null
let buffer = ''
let rateLimitDetected = false
let rateLimitResetsInSeconds = null
let streamEnded = false
// 解析 SSE 事件以捕获 usage 数据和 model
const parseSSEForUsage = (data) => {
const lines = data.split('\n')
for (const line of lines) {
if (line.startsWith('data: ')) {
try {
const jsonStr = line.slice(6)
if (jsonStr === '[DONE]') {
continue
}
const eventData = JSON.parse(jsonStr)
// 检查是否是 response.completed 事件OpenAI-Responses 格式)
if (eventData.type === 'response.completed' && eventData.response) {
// 从响应中获取真实的 model
if (eventData.response.model) {
actualModel = eventData.response.model
logger.debug(`📊 Captured actual model from response.completed: ${actualModel}`)
}
// 获取 usage 数据 - OpenAI-Responses 格式在 response.usage 下
if (eventData.response.usage) {
usageData = eventData.response.usage
logger.info('📊 Successfully captured usage data from OpenAI-Responses:', {
input_tokens: usageData.input_tokens,
output_tokens: usageData.output_tokens,
total_tokens: usageData.total_tokens
})
}
}
// 检查是否有限流错误
if (eventData.error) {
// 检查多种可能的限流错误类型
if (
eventData.error.type === 'rate_limit_error' ||
eventData.error.type === 'usage_limit_reached' ||
eventData.error.type === 'rate_limit_exceeded'
) {
rateLimitDetected = true
if (eventData.error.resets_in_seconds) {
rateLimitResetsInSeconds = eventData.error.resets_in_seconds
logger.warn(
`🚫 Rate limit detected in stream, resets in ${rateLimitResetsInSeconds} seconds (${Math.ceil(rateLimitResetsInSeconds / 60)} minutes)`
)
}
}
}
} catch (e) {
// 忽略解析错误
}
}
}
}
// 监听数据流
response.data.on('data', (chunk) => {
try {
const chunkStr = chunk.toString()
// 转发数据给客户端
if (!res.destroyed && !streamEnded) {
res.write(chunk)
}
// 同时解析数据以捕获 usage 信息
buffer += chunkStr
// 处理完整的 SSE 事件
if (buffer.includes('\n\n')) {
const events = buffer.split('\n\n')
buffer = events.pop() || ''
for (const event of events) {
if (event.trim()) {
parseSSEForUsage(event)
}
}
}
} catch (error) {
logger.error('Error processing stream chunk:', error)
}
})
response.data.on('end', async () => {
streamEnded = true
// 处理剩余的 buffer
if (buffer.trim()) {
parseSSEForUsage(buffer)
}
// 记录使用统计
if (usageData) {
try {
// OpenAI-Responses 使用 input_tokens/output_tokens标准 OpenAI 使用 prompt_tokens/completion_tokens
const inputTokens = usageData.input_tokens || usageData.prompt_tokens || 0
const outputTokens = usageData.output_tokens || usageData.completion_tokens || 0
// 提取缓存相关的 tokens如果存在
const cacheCreateTokens = usageData.input_tokens_details?.cache_creation_tokens || 0
const cacheReadTokens = usageData.input_tokens_details?.cached_tokens || 0
const totalTokens = usageData.total_tokens || inputTokens + outputTokens
const modelToRecord = actualModel || requestedModel || 'gpt-4'
await apiKeyService.recordUsage(
apiKeyData.id,
inputTokens,
outputTokens,
cacheCreateTokens,
cacheReadTokens,
modelToRecord,
account.id
)
logger.info(
`📊 Recorded usage - Input: ${inputTokens}, Output: ${outputTokens}, CacheRead: ${cacheReadTokens}, CacheCreate: ${cacheCreateTokens}, Total: ${totalTokens}, Model: ${modelToRecord}`
)
// 更新账户的 token 使用统计
await openaiResponsesAccountService.updateAccountUsage(account.id, totalTokens)
// 更新账户使用额度(如果设置了额度限制)
if (parseFloat(account.dailyQuota) > 0) {
// 估算费用根据模型和token数量
const estimatedCost = this._estimateCost(modelToRecord, inputTokens, outputTokens)
await openaiResponsesAccountService.updateUsageQuota(account.id, estimatedCost)
}
} catch (error) {
logger.error('Failed to record usage:', error)
}
}
// 如果在流式响应中检测到限流
if (rateLimitDetected) {
// 使用统一调度器处理限流(与非流式响应保持一致)
const sessionId = req.headers['session_id'] || req.body?.session_id
const sessionHash = sessionId
? crypto.createHash('sha256').update(sessionId).digest('hex')
: null
await unifiedOpenAIScheduler.markAccountRateLimited(
account.id,
'openai-responses',
sessionHash,
rateLimitResetsInSeconds
)
logger.warn(
`🚫 Processing rate limit for OpenAI-Responses account ${account.id} from stream`
)
}
// 清理监听器
req.removeListener('close', handleClientDisconnect)
res.removeListener('close', handleClientDisconnect)
if (!res.destroyed) {
res.end()
}
logger.info('Stream response completed', {
accountId: account.id,
hasUsage: !!usageData,
actualModel: actualModel || 'unknown'
})
})
response.data.on('error', (error) => {
streamEnded = true
logger.error('Stream error:', error)
// 清理监听器
req.removeListener('close', handleClientDisconnect)
res.removeListener('close', handleClientDisconnect)
if (!res.headersSent) {
res.status(502).json({ error: { message: 'Upstream stream error' } })
} else if (!res.destroyed) {
res.end()
}
})
// 处理客户端断开连接
const cleanup = () => {
streamEnded = true
try {
response.data?.unpipe?.(res)
response.data?.destroy?.()
} catch (_) {
// 忽略清理错误
}
}
req.on('close', cleanup)
req.on('aborted', cleanup)
}
// 处理非流式响应
async _handleNormalResponse(response, res, account, apiKeyData, requestedModel) {
const responseData = response.data
// 提取 usage 数据和实际 model
// 支持两种格式:直接的 usage 或嵌套在 response 中的 usage
const usageData = responseData?.usage || responseData?.response?.usage
const actualModel =
responseData?.model || responseData?.response?.model || requestedModel || 'gpt-4'
// 记录使用统计
if (usageData) {
try {
// OpenAI-Responses 使用 input_tokens/output_tokens标准 OpenAI 使用 prompt_tokens/completion_tokens
const inputTokens = usageData.input_tokens || usageData.prompt_tokens || 0
const outputTokens = usageData.output_tokens || usageData.completion_tokens || 0
// 提取缓存相关的 tokens如果存在
const cacheCreateTokens = usageData.input_tokens_details?.cache_creation_tokens || 0
const cacheReadTokens = usageData.input_tokens_details?.cached_tokens || 0
const totalTokens = usageData.total_tokens || inputTokens + outputTokens
await apiKeyService.recordUsage(
apiKeyData.id,
inputTokens,
outputTokens,
cacheCreateTokens,
cacheReadTokens,
actualModel,
account.id
)
logger.info(
`📊 Recorded non-stream usage - Input: ${inputTokens}, Output: ${outputTokens}, CacheRead: ${cacheReadTokens}, CacheCreate: ${cacheCreateTokens}, Total: ${totalTokens}, Model: ${actualModel}`
)
// 更新账户的 token 使用统计
await openaiResponsesAccountService.updateAccountUsage(account.id, totalTokens)
// 更新账户使用额度(如果设置了额度限制)
if (parseFloat(account.dailyQuota) > 0) {
// 估算费用根据模型和token数量
const estimatedCost = this._estimateCost(actualModel, inputTokens, outputTokens)
await openaiResponsesAccountService.updateUsageQuota(account.id, estimatedCost)
}
} catch (error) {
logger.error('Failed to record usage:', error)
}
}
// 返回响应
res.status(response.status).json(responseData)
logger.info('Normal response completed', {
accountId: account.id,
status: response.status,
hasUsage: !!usageData,
model: actualModel
})
}
// 处理 429 限流错误
async _handle429Error(account, response, isStream = false, sessionHash = null) {
let resetsInSeconds = null
let errorData = null
try {
// 对于429错误响应可能是JSON或SSE格式
if (isStream && response.data && typeof response.data.pipe === 'function') {
// 流式响应需要先收集数据
const chunks = []
await new Promise((resolve, reject) => {
response.data.on('data', (chunk) => chunks.push(chunk))
response.data.on('end', resolve)
response.data.on('error', reject)
// 设置超时防止无限等待
setTimeout(resolve, 5000)
})
const fullResponse = Buffer.concat(chunks).toString()
// 尝试解析SSE格式的错误响应
if (fullResponse.includes('data: ')) {
const lines = fullResponse.split('\n')
for (const line of lines) {
if (line.startsWith('data: ')) {
try {
const jsonStr = line.slice(6).trim()
if (jsonStr && jsonStr !== '[DONE]') {
errorData = JSON.parse(jsonStr)
break
}
} catch (e) {
// 继续尝试下一行
}
}
}
}
// 如果SSE解析失败尝试直接解析为JSON
if (!errorData) {
try {
errorData = JSON.parse(fullResponse)
} catch (e) {
logger.error('Failed to parse 429 error response:', e)
logger.debug('Raw response:', fullResponse)
}
}
} else if (response.data && typeof response.data !== 'object') {
// 如果response.data是字符串尝试解析为JSON
try {
errorData = JSON.parse(response.data)
} catch (e) {
logger.error('Failed to parse 429 error response as JSON:', e)
errorData = { error: { message: response.data } }
}
} else if (response.data && typeof response.data === 'object' && !response.data.pipe) {
// 非流式响应,且是对象,直接使用
errorData = response.data
}
// 从响应体中提取重置时间OpenAI 标准格式)
if (errorData && errorData.error) {
if (errorData.error.resets_in_seconds) {
resetsInSeconds = errorData.error.resets_in_seconds
logger.info(
`🕐 Rate limit will reset in ${resetsInSeconds} seconds (${Math.ceil(resetsInSeconds / 60)} minutes / ${Math.ceil(resetsInSeconds / 3600)} hours)`
)
} else if (errorData.error.resets_in) {
// 某些 API 可能使用不同的字段名
resetsInSeconds = parseInt(errorData.error.resets_in)
logger.info(
`🕐 Rate limit will reset in ${resetsInSeconds} seconds (${Math.ceil(resetsInSeconds / 60)} minutes / ${Math.ceil(resetsInSeconds / 3600)} hours)`
)
}
}
if (!resetsInSeconds) {
logger.warn('⚠️ Could not extract reset time from 429 response, using default 60 minutes')
}
} catch (e) {
logger.error('⚠️ Failed to parse rate limit error:', e)
}
// 使用统一调度器标记账户为限流状态与普通OpenAI账号保持一致
await unifiedOpenAIScheduler.markAccountRateLimited(
account.id,
'openai-responses',
sessionHash,
resetsInSeconds
)
logger.warn('OpenAI-Responses account rate limited', {
accountId: account.id,
accountName: account.name,
resetsInSeconds: resetsInSeconds || 'unknown',
resetInMinutes: resetsInSeconds ? Math.ceil(resetsInSeconds / 60) : 60,
resetInHours: resetsInSeconds ? Math.ceil(resetsInSeconds / 3600) : 1
})
// 返回处理后的数据,避免循环引用
return { resetsInSeconds, errorData }
}
// 过滤请求头
_filterRequestHeaders(headers) {
const filtered = {}
const skipHeaders = [
'host',
'content-length',
'authorization',
'x-api-key',
'x-cr-api-key',
'connection',
'upgrade',
'sec-websocket-key',
'sec-websocket-version',
'sec-websocket-extensions'
]
for (const [key, value] of Object.entries(headers)) {
if (!skipHeaders.includes(key.toLowerCase())) {
filtered[key] = value
}
}
return filtered
}
// 估算费用(简化版本,实际应该根据不同的定价模型)
_estimateCost(model, inputTokens, outputTokens) {
// 这是一个简化的费用估算,实际应该根据不同的 API 提供商和模型定价
const rates = {
'gpt-4': { input: 0.03, output: 0.06 }, // per 1K tokens
'gpt-4-turbo': { input: 0.01, output: 0.03 },
'gpt-3.5-turbo': { input: 0.0005, output: 0.0015 },
'claude-3-opus': { input: 0.015, output: 0.075 },
'claude-3-sonnet': { input: 0.003, output: 0.015 },
'claude-3-haiku': { input: 0.00025, output: 0.00125 }
}
// 查找匹配的模型定价
let rate = rates['gpt-3.5-turbo'] // 默认使用 GPT-3.5 的价格
for (const [modelKey, modelRate] of Object.entries(rates)) {
if (model.toLowerCase().includes(modelKey.toLowerCase())) {
rate = modelRate
break
}
}
const inputCost = (inputTokens / 1000) * rate.input
const outputCost = (outputTokens / 1000) * rate.output
return inputCost + outputCost
}
}
module.exports = new OpenAIResponsesRelayService()

View File

@@ -45,6 +45,7 @@ class PricingService {
'claude-sonnet-3-5': 0.000006,
'claude-sonnet-3-7': 0.000006,
'claude-sonnet-4': 0.000006,
'claude-sonnet-4-20250514': 0.000006,
// Haiku 系列: $1.6/MTok
'claude-3-5-haiku': 0.0000016,
@@ -55,6 +56,17 @@ class PricingService {
'claude-haiku-3': 0.0000016,
'claude-haiku-3-5': 0.0000016
}
// 硬编码的 1M 上下文模型价格(美元/token
// 当总输入 tokens 超过 200k 时使用这些价格
this.longContextPricing = {
// claude-sonnet-4-20250514[1m] 模型的 1M 上下文价格
'claude-sonnet-4-20250514[1m]': {
input: 0.000006, // $6/MTok
output: 0.0000225 // $22.50/MTok
}
// 未来可以添加更多 1M 模型的价格
}
}
// 初始化价格服务
@@ -249,6 +261,7 @@ class PricingService {
// 尝试直接匹配
if (this.pricingData[modelName]) {
logger.debug(`💰 Found exact pricing match for ${modelName}`)
return this.pricingData[modelName]
}
@@ -293,6 +306,22 @@ class PricingService {
return null
}
// 确保价格对象包含缓存价格
ensureCachePricing(pricing) {
if (!pricing) {
return pricing
}
// 如果缺少缓存价格根据输入价格计算缓存创建价格通常是输入价格的1.25倍缓存读取是0.1倍)
if (!pricing.cache_creation_input_token_cost && pricing.input_cost_per_token) {
pricing.cache_creation_input_token_cost = pricing.input_cost_per_token * 1.25
}
if (!pricing.cache_read_input_token_cost && pricing.input_cost_per_token) {
pricing.cache_read_input_token_cost = pricing.input_cost_per_token * 0.1
}
return pricing
}
// 获取 1 小时缓存价格
getEphemeral1hPricing(modelName) {
if (!modelName) {
@@ -329,9 +358,40 @@ class PricingService {
// 计算使用费用
calculateCost(usage, modelName) {
// 检查是否为 1M 上下文模型
const isLongContextModel = modelName && modelName.includes('[1m]')
let isLongContextRequest = false
let useLongContextPricing = false
if (isLongContextModel) {
// 计算总输入 tokens
const inputTokens = usage.input_tokens || 0
const cacheCreationTokens = usage.cache_creation_input_tokens || 0
const cacheReadTokens = usage.cache_read_input_tokens || 0
const totalInputTokens = inputTokens + cacheCreationTokens + cacheReadTokens
// 如果总输入超过 200k使用 1M 上下文价格
if (totalInputTokens > 200000) {
isLongContextRequest = true
// 检查是否有硬编码的 1M 价格
if (this.longContextPricing[modelName]) {
useLongContextPricing = true
} else {
// 如果没有找到硬编码价格,使用第一个 1M 模型的价格作为默认
const defaultLongContextModel = Object.keys(this.longContextPricing)[0]
if (defaultLongContextModel) {
useLongContextPricing = true
logger.warn(
`⚠️ No specific 1M pricing for ${modelName}, using default from ${defaultLongContextModel}`
)
}
}
}
}
const pricing = this.getModelPricing(modelName)
if (!pricing) {
if (!pricing && !useLongContextPricing) {
return {
inputCost: 0,
outputCost: 0,
@@ -340,14 +400,35 @@ class PricingService {
ephemeral5mCost: 0,
ephemeral1hCost: 0,
totalCost: 0,
hasPricing: false
hasPricing: false,
isLongContextRequest: false
}
}
const inputCost = (usage.input_tokens || 0) * (pricing.input_cost_per_token || 0)
const outputCost = (usage.output_tokens || 0) * (pricing.output_cost_per_token || 0)
let inputCost = 0
let outputCost = 0
if (useLongContextPricing) {
// 使用 1M 上下文特殊价格(仅输入和输出价格改变)
const longContextPrices =
this.longContextPricing[modelName] ||
this.longContextPricing[Object.keys(this.longContextPricing)[0]]
inputCost = (usage.input_tokens || 0) * longContextPrices.input
outputCost = (usage.output_tokens || 0) * longContextPrices.output
logger.info(
`💰 Using 1M context pricing for ${modelName}: input=$${longContextPrices.input}/token, output=$${longContextPrices.output}/token`
)
} else {
// 使用正常价格
inputCost = (usage.input_tokens || 0) * (pricing?.input_cost_per_token || 0)
outputCost = (usage.output_tokens || 0) * (pricing?.output_cost_per_token || 0)
}
// 缓存价格保持不变(即使对于 1M 模型)
const cacheReadCost =
(usage.cache_read_input_tokens || 0) * (pricing.cache_read_input_token_cost || 0)
(usage.cache_read_input_tokens || 0) * (pricing?.cache_read_input_token_cost || 0)
// 处理缓存创建费用:
// 1. 如果有详细的 cache_creation 对象,使用它
@@ -362,7 +443,7 @@ class PricingService {
const ephemeral1hTokens = usage.cache_creation.ephemeral_1h_input_tokens || 0
// 5分钟缓存使用标准的 cache_creation_input_token_cost
ephemeral5mCost = ephemeral5mTokens * (pricing.cache_creation_input_token_cost || 0)
ephemeral5mCost = ephemeral5mTokens * (pricing?.cache_creation_input_token_cost || 0)
// 1小时缓存使用硬编码的价格
const ephemeral1hPrice = this.getEphemeral1hPricing(modelName)
@@ -373,7 +454,7 @@ class PricingService {
} else if (usage.cache_creation_input_tokens) {
// 旧格式,所有缓存创建 tokens 都按 5 分钟价格计算(向后兼容)
cacheCreateCost =
(usage.cache_creation_input_tokens || 0) * (pricing.cache_creation_input_token_cost || 0)
(usage.cache_creation_input_tokens || 0) * (pricing?.cache_creation_input_token_cost || 0)
ephemeral5mCost = cacheCreateCost
}
@@ -386,11 +467,22 @@ class PricingService {
ephemeral1hCost,
totalCost: inputCost + outputCost + cacheCreateCost + cacheReadCost,
hasPricing: true,
isLongContextRequest,
pricing: {
input: pricing.input_cost_per_token || 0,
output: pricing.output_cost_per_token || 0,
cacheCreate: pricing.cache_creation_input_token_cost || 0,
cacheRead: pricing.cache_read_input_token_cost || 0,
input: useLongContextPricing
? (
this.longContextPricing[modelName] ||
this.longContextPricing[Object.keys(this.longContextPricing)[0]]
)?.input || 0
: pricing?.input_cost_per_token || 0,
output: useLongContextPricing
? (
this.longContextPricing[modelName] ||
this.longContextPricing[Object.keys(this.longContextPricing)[0]]
)?.output || 0
: pricing?.output_cost_per_token || 0,
cacheCreate: pricing?.cache_creation_input_token_cost || 0,
cacheRead: pricing?.cache_read_input_token_cost || 0,
ephemeral1h: this.getEphemeral1hPricing(modelName)
}
}

View File

@@ -0,0 +1,384 @@
/**
* 限流状态自动清理服务
* 定期检查并清理所有类型账号的过期限流状态
*/
const logger = require('../utils/logger')
const openaiAccountService = require('./openaiAccountService')
const claudeAccountService = require('./claudeAccountService')
const claudeConsoleAccountService = require('./claudeConsoleAccountService')
const unifiedOpenAIScheduler = require('./unifiedOpenAIScheduler')
const webhookService = require('./webhookService')
class RateLimitCleanupService {
constructor() {
this.cleanupInterval = null
this.isRunning = false
// 默认每5分钟检查一次
this.intervalMs = 5 * 60 * 1000
// 存储已清理的账户信息,用于发送恢复通知
this.clearedAccounts = []
}
/**
* 启动自动清理服务
* @param {number} intervalMinutes - 检查间隔分钟默认5分钟
*/
start(intervalMinutes = 5) {
if (this.cleanupInterval) {
logger.warn('⚠️ Rate limit cleanup service is already running')
return
}
this.intervalMs = intervalMinutes * 60 * 1000
logger.info(`🧹 Starting rate limit cleanup service (interval: ${intervalMinutes} minutes)`)
// 立即执行一次清理
this.performCleanup()
// 设置定期执行
this.cleanupInterval = setInterval(() => {
this.performCleanup()
}, this.intervalMs)
}
/**
* 停止自动清理服务
*/
stop() {
if (this.cleanupInterval) {
clearInterval(this.cleanupInterval)
this.cleanupInterval = null
logger.info('🛑 Rate limit cleanup service stopped')
}
}
/**
* 执行一次清理检查
*/
async performCleanup() {
if (this.isRunning) {
logger.debug('⏭️ Cleanup already in progress, skipping this cycle')
return
}
this.isRunning = true
const startTime = Date.now()
try {
logger.debug('🔍 Starting rate limit cleanup check...')
const results = {
openai: { checked: 0, cleared: 0, errors: [] },
claude: { checked: 0, cleared: 0, errors: [] },
claudeConsole: { checked: 0, cleared: 0, errors: [] }
}
// 清理 OpenAI 账号
await this.cleanupOpenAIAccounts(results.openai)
// 清理 Claude 账号
await this.cleanupClaudeAccounts(results.claude)
// 清理 Claude Console 账号
await this.cleanupClaudeConsoleAccounts(results.claudeConsole)
const totalChecked =
results.openai.checked + results.claude.checked + results.claudeConsole.checked
const totalCleared =
results.openai.cleared + results.claude.cleared + results.claudeConsole.cleared
const duration = Date.now() - startTime
if (totalCleared > 0) {
logger.info(
`✅ Rate limit cleanup completed: ${totalCleared} accounts cleared out of ${totalChecked} checked (${duration}ms)`
)
logger.info(` OpenAI: ${results.openai.cleared}/${results.openai.checked}`)
logger.info(` Claude: ${results.claude.cleared}/${results.claude.checked}`)
logger.info(
` Claude Console: ${results.claudeConsole.cleared}/${results.claudeConsole.checked}`
)
// 发送 webhook 恢复通知
if (this.clearedAccounts.length > 0) {
await this.sendRecoveryNotifications()
}
} else {
logger.debug(
`🔍 Rate limit cleanup check completed: no expired limits found (${duration}ms)`
)
}
// 清空已清理账户列表
this.clearedAccounts = []
// 记录错误
const allErrors = [
...results.openai.errors,
...results.claude.errors,
...results.claudeConsole.errors
]
if (allErrors.length > 0) {
logger.warn(`⚠️ Encountered ${allErrors.length} errors during cleanup:`, allErrors)
}
} catch (error) {
logger.error('❌ Rate limit cleanup failed:', error)
} finally {
this.isRunning = false
}
}
/**
* 清理 OpenAI 账号的过期限流
*/
async cleanupOpenAIAccounts(result) {
try {
const accounts = await openaiAccountService.getAllAccounts()
for (const account of accounts) {
// 只检查标记为限流的账号
if (account.rateLimitStatus === 'limited') {
result.checked++
try {
// 使用 unifiedOpenAIScheduler 的检查方法,它会自动清除过期的限流
const isStillLimited = await unifiedOpenAIScheduler.isAccountRateLimited(account.id)
if (!isStillLimited) {
result.cleared++
logger.info(
`🧹 Auto-cleared expired rate limit for OpenAI account: ${account.name} (${account.id})`
)
// 记录已清理的账户信息
this.clearedAccounts.push({
platform: 'OpenAI',
accountId: account.id,
accountName: account.name,
previousStatus: 'rate_limited',
currentStatus: 'active'
})
}
} catch (error) {
result.errors.push({
accountId: account.id,
accountName: account.name,
error: error.message
})
}
}
}
} catch (error) {
logger.error('Failed to cleanup OpenAI accounts:', error)
result.errors.push({ error: error.message })
}
}
/**
* 清理 Claude 账号的过期限流
*/
async cleanupClaudeAccounts(result) {
try {
const accounts = await claudeAccountService.getAllAccounts()
for (const account of accounts) {
// 只检查标记为限流的账号
if (account.rateLimitStatus === 'limited' || account.rateLimitedAt) {
result.checked++
try {
// 使用 claudeAccountService 的检查方法,它会自动清除过期的限流
const isStillLimited = await claudeAccountService.isAccountRateLimited(account.id)
if (!isStillLimited) {
result.cleared++
logger.info(
`🧹 Auto-cleared expired rate limit for Claude account: ${account.name} (${account.id})`
)
// 记录已清理的账户信息
this.clearedAccounts.push({
platform: 'Claude',
accountId: account.id,
accountName: account.name,
previousStatus: 'rate_limited',
currentStatus: 'active'
})
}
} catch (error) {
result.errors.push({
accountId: account.id,
accountName: account.name,
error: error.message
})
}
}
}
// 检查并恢复因5小时限制被自动停止的账号
try {
const fiveHourResult = await claudeAccountService.checkAndRecoverFiveHourStoppedAccounts()
if (fiveHourResult.recovered > 0) {
// 将5小时限制恢复的账号也加入到已清理账户列表中用于发送通知
for (const account of fiveHourResult.accounts) {
this.clearedAccounts.push({
platform: 'Claude',
accountId: account.id,
accountName: account.name,
previousStatus: '5hour_limited',
currentStatus: 'active',
windowInfo: account.newWindow
})
}
// 更新统计数据
result.checked += fiveHourResult.checked
result.cleared += fiveHourResult.recovered
logger.info(
`🕐 Claude 5-hour limit recovery: ${fiveHourResult.recovered}/${fiveHourResult.checked} accounts recovered`
)
}
} catch (error) {
logger.error('Failed to check and recover 5-hour stopped Claude accounts:', error)
result.errors.push({
type: '5hour_recovery',
error: error.message
})
}
} catch (error) {
logger.error('Failed to cleanup Claude accounts:', error)
result.errors.push({ error: error.message })
}
}
/**
* 清理 Claude Console 账号的过期限流
*/
async cleanupClaudeConsoleAccounts(result) {
try {
const accounts = await claudeConsoleAccountService.getAllAccounts()
for (const account of accounts) {
// 检查两种状态字段rateLimitStatus 和 status
const hasRateLimitStatus = account.rateLimitStatus === 'limited'
const hasStatusRateLimited = account.status === 'rate_limited'
if (hasRateLimitStatus || hasStatusRateLimited) {
result.checked++
try {
// 使用 claudeConsoleAccountService 的检查方法,它会自动清除过期的限流
const isStillLimited = await claudeConsoleAccountService.isAccountRateLimited(
account.id
)
if (!isStillLimited) {
result.cleared++
// 如果 status 字段是 rate_limited需要额外清理
if (hasStatusRateLimited && !hasRateLimitStatus) {
await claudeConsoleAccountService.updateAccount(account.id, {
status: 'active'
})
}
logger.info(
`🧹 Auto-cleared expired rate limit for Claude Console account: ${account.name} (${account.id})`
)
// 记录已清理的账户信息
this.clearedAccounts.push({
platform: 'Claude Console',
accountId: account.id,
accountName: account.name,
previousStatus: 'rate_limited',
currentStatus: 'active'
})
}
} catch (error) {
result.errors.push({
accountId: account.id,
accountName: account.name,
error: error.message
})
}
}
}
} catch (error) {
logger.error('Failed to cleanup Claude Console accounts:', error)
result.errors.push({ error: error.message })
}
}
/**
* 手动触发一次清理(供 API 或 CLI 调用)
*/
async manualCleanup() {
logger.info('🧹 Manual rate limit cleanup triggered')
await this.performCleanup()
}
/**
* 发送限流恢复通知
*/
async sendRecoveryNotifications() {
try {
// 按平台分组账户
const groupedAccounts = {}
for (const account of this.clearedAccounts) {
if (!groupedAccounts[account.platform]) {
groupedAccounts[account.platform] = []
}
groupedAccounts[account.platform].push(account)
}
// 构建通知消息
const platforms = Object.keys(groupedAccounts)
const totalAccounts = this.clearedAccounts.length
let message = `🎉 共有 ${totalAccounts} 个账户的限流状态已恢复\n\n`
for (const platform of platforms) {
const accounts = groupedAccounts[platform]
message += `**${platform}** (${accounts.length} 个):\n`
for (const account of accounts) {
message += `${account.accountName} (ID: ${account.accountId})\n`
}
message += '\n'
}
// 发送 webhook 通知
await webhookService.sendNotification('rateLimitRecovery', {
title: '限流恢复通知',
message,
totalAccounts,
platforms: Object.keys(groupedAccounts),
accounts: this.clearedAccounts,
timestamp: new Date().toISOString()
})
logger.info(`📢 已发送限流恢复通知,涉及 ${totalAccounts} 个账户`)
} catch (error) {
logger.error('❌ 发送限流恢复通知失败:', error)
}
}
/**
* 获取服务状态
*/
getStatus() {
return {
running: !!this.cleanupInterval,
intervalMinutes: this.intervalMs / (60 * 1000),
isProcessing: this.isRunning
}
}
}
// 创建单例实例
const rateLimitCleanupService = new RateLimitCleanupService()
module.exports = rateLimitCleanupService

View File

@@ -1,9 +1,11 @@
const claudeAccountService = require('./claudeAccountService')
const claudeConsoleAccountService = require('./claudeConsoleAccountService')
const bedrockAccountService = require('./bedrockAccountService')
const ccrAccountService = require('./ccrAccountService')
const accountGroupService = require('./accountGroupService')
const redis = require('../models/redis')
const logger = require('../utils/logger')
const { parseVendorPrefixedModel } = require('../utils/modelHelper')
class UnifiedClaudeScheduler {
constructor() {
@@ -20,9 +22,121 @@ class UnifiedClaudeScheduler {
return schedulable !== false && schedulable !== 'false'
}
// 🔍 检查账户是否支持请求的模型
_isModelSupportedByAccount(account, accountType, requestedModel, context = '') {
if (!requestedModel) {
return true // 没有指定模型时,默认支持
}
// Claude OAuth 账户的 Opus 模型检查
if (accountType === 'claude-official') {
if (requestedModel.toLowerCase().includes('opus')) {
if (account.subscriptionInfo) {
try {
const info =
typeof account.subscriptionInfo === 'string'
? JSON.parse(account.subscriptionInfo)
: account.subscriptionInfo
// Pro 和 Free 账号不支持 Opus
if (info.hasClaudePro === true && info.hasClaudeMax !== true) {
logger.info(
`🚫 Claude account ${account.name} (Pro) does not support Opus model${context ? ` ${context}` : ''}`
)
return false
}
if (info.accountType === 'claude_pro' || info.accountType === 'claude_free') {
logger.info(
`🚫 Claude account ${account.name} (${info.accountType}) does not support Opus model${context ? ` ${context}` : ''}`
)
return false
}
} catch (e) {
// 解析失败,假设为旧数据,默认支持(兼容旧数据为 Max
logger.debug(
`Account ${account.name} has invalid subscriptionInfo${context ? ` ${context}` : ''}, assuming Max`
)
}
}
// 没有订阅信息的账号,默认当作支持(兼容旧数据)
}
}
// Claude Console 账户的模型支持检查
if (accountType === 'claude-console' && account.supportedModels) {
// 兼容旧格式(数组)和新格式(对象)
if (Array.isArray(account.supportedModels)) {
// 旧格式:数组
if (
account.supportedModels.length > 0 &&
!account.supportedModels.includes(requestedModel)
) {
logger.info(
`🚫 Claude Console account ${account.name} does not support model ${requestedModel}${context ? ` ${context}` : ''}`
)
return false
}
} else if (typeof account.supportedModels === 'object') {
// 新格式:映射表
if (
Object.keys(account.supportedModels).length > 0 &&
!claudeConsoleAccountService.isModelSupported(account.supportedModels, requestedModel)
) {
logger.info(
`🚫 Claude Console account ${account.name} does not support model ${requestedModel}${context ? ` ${context}` : ''}`
)
return false
}
}
}
// CCR 账户的模型支持检查
if (accountType === 'ccr' && account.supportedModels) {
// 兼容旧格式(数组)和新格式(对象)
if (Array.isArray(account.supportedModels)) {
// 旧格式:数组
if (
account.supportedModels.length > 0 &&
!account.supportedModels.includes(requestedModel)
) {
logger.info(
`🚫 CCR account ${account.name} does not support model ${requestedModel}${context ? ` ${context}` : ''}`
)
return false
}
} else if (typeof account.supportedModels === 'object') {
// 新格式:映射表
if (
Object.keys(account.supportedModels).length > 0 &&
!ccrAccountService.isModelSupported(account.supportedModels, requestedModel)
) {
logger.info(
`🚫 CCR account ${account.name} does not support model ${requestedModel}${context ? ` ${context}` : ''}`
)
return false
}
}
}
return true
}
// 🎯 统一调度Claude账号官方和Console
async selectAccountForApiKey(apiKeyData, sessionHash = null, requestedModel = null) {
try {
// 解析供应商前缀
const { vendor, baseModel } = parseVendorPrefixedModel(requestedModel)
const effectiveModel = vendor === 'ccr' ? baseModel : requestedModel
logger.debug(
`🔍 Model parsing - Original: ${requestedModel}, Vendor: ${vendor}, Effective: ${effectiveModel}`
)
// 如果是 CCR 前缀,只在 CCR 账户池中选择
if (vendor === 'ccr') {
logger.info(`🎯 CCR vendor prefix detected, routing to CCR accounts only`)
return await this._selectCcrAccount(apiKeyData, sessionHash, effectiveModel)
}
// 如果API Key绑定了专属账户或分组优先使用
if (apiKeyData.claudeAccountId) {
// 检查是否是分组
@@ -31,12 +145,22 @@ class UnifiedClaudeScheduler {
logger.info(
`🎯 API key ${apiKeyData.name} is bound to group ${groupId}, selecting from group`
)
return await this.selectAccountFromGroup(groupId, sessionHash, requestedModel)
return await this.selectAccountFromGroup(
groupId,
sessionHash,
effectiveModel,
vendor === 'ccr'
)
}
// 普通专属账户
const boundAccount = await redis.getClaudeAccount(apiKeyData.claudeAccountId)
if (boundAccount && boundAccount.isActive === 'true' && boundAccount.status !== 'error') {
if (
boundAccount &&
boundAccount.isActive === 'true' &&
boundAccount.status !== 'error' &&
this._isSchedulable(boundAccount.schedulable)
) {
logger.info(
`🎯 Using bound dedicated Claude OAuth account: ${boundAccount.name} (${apiKeyData.claudeAccountId}) for API key ${apiKeyData.name}`
)
@@ -46,7 +170,7 @@ class UnifiedClaudeScheduler {
}
} else {
logger.warn(
`⚠️ Bound Claude OAuth account ${apiKeyData.claudeAccountId} is not available, falling back to pool`
`⚠️ Bound Claude OAuth account ${apiKeyData.claudeAccountId} is not available (isActive: ${boundAccount?.isActive}, status: ${boundAccount?.status}, schedulable: ${boundAccount?.schedulable}), falling back to pool`
)
}
}
@@ -59,7 +183,8 @@ class UnifiedClaudeScheduler {
if (
boundConsoleAccount &&
boundConsoleAccount.isActive === true &&
boundConsoleAccount.status === 'active'
boundConsoleAccount.status === 'active' &&
this._isSchedulable(boundConsoleAccount.schedulable)
) {
logger.info(
`🎯 Using bound dedicated Claude Console account: ${boundConsoleAccount.name} (${apiKeyData.claudeConsoleAccountId}) for API key ${apiKeyData.name}`
@@ -70,7 +195,7 @@ class UnifiedClaudeScheduler {
}
} else {
logger.warn(
`⚠️ Bound Claude Console account ${apiKeyData.claudeConsoleAccountId} is not available, falling back to pool`
`⚠️ Bound Claude Console account ${apiKeyData.claudeConsoleAccountId} is not available (isActive: ${boundConsoleAccount?.isActive}, status: ${boundConsoleAccount?.status}, schedulable: ${boundConsoleAccount?.schedulable}), falling back to pool`
)
}
}
@@ -80,7 +205,11 @@ class UnifiedClaudeScheduler {
const boundBedrockAccountResult = await bedrockAccountService.getAccount(
apiKeyData.bedrockAccountId
)
if (boundBedrockAccountResult.success && boundBedrockAccountResult.data.isActive === true) {
if (
boundBedrockAccountResult.success &&
boundBedrockAccountResult.data.isActive === true &&
this._isSchedulable(boundBedrockAccountResult.data.schedulable)
) {
logger.info(
`🎯 Using bound dedicated Bedrock account: ${boundBedrockAccountResult.data.name} (${apiKeyData.bedrockAccountId}) for API key ${apiKeyData.name}`
)
@@ -90,42 +219,59 @@ class UnifiedClaudeScheduler {
}
} else {
logger.warn(
`⚠️ Bound Bedrock account ${apiKeyData.bedrockAccountId} is not available, falling back to pool`
`⚠️ Bound Bedrock account ${apiKeyData.bedrockAccountId} is not available (isActive: ${boundBedrockAccountResult?.data?.isActive}, schedulable: ${boundBedrockAccountResult?.data?.schedulable}), falling back to pool`
)
}
}
// CCR 账户不支持绑定(仅通过 ccr, 前缀进行 CCR 路由)
// 如果有会话哈希,检查是否有已映射的账户
if (sessionHash) {
const mappedAccount = await this._getSessionMapping(sessionHash)
if (mappedAccount) {
// 验证映射的账户是否仍然可用
const isAvailable = await this._isAccountAvailable(
mappedAccount.accountId,
mappedAccount.accountType
)
if (isAvailable) {
// 当本次请求不是 CCR 前缀时,不允许使用指向 CCR 的粘性会话映射
if (vendor !== 'ccr' && mappedAccount.accountType === 'ccr') {
logger.info(
`🎯 Using sticky session account: ${mappedAccount.accountId} (${mappedAccount.accountType}) for session ${sessionHash}`
)
return mappedAccount
} else {
logger.warn(
`⚠️ Mapped account ${mappedAccount.accountId} is no longer available, selecting new account`
` Skipping CCR sticky session mapping for non-CCR request; removing mapping for session ${sessionHash}`
)
await this._deleteSessionMapping(sessionHash)
} else {
// 验证映射的账户是否仍然可用
const isAvailable = await this._isAccountAvailable(
mappedAccount.accountId,
mappedAccount.accountType,
effectiveModel
)
if (isAvailable) {
// 🚀 智能会话续期剩余时间少于14天时自动续期到15天续期正确的 unified 映射键)
await this._extendSessionMappingTTL(sessionHash)
logger.info(
`🎯 Using sticky session account: ${mappedAccount.accountId} (${mappedAccount.accountType}) for session ${sessionHash}`
)
return mappedAccount
} else {
logger.warn(
`⚠️ Mapped account ${mappedAccount.accountId} is no longer available, selecting new account`
)
await this._deleteSessionMapping(sessionHash)
}
}
}
}
// 获取所有可用账户(传递请求的模型进行过滤)
const availableAccounts = await this._getAllAvailableAccounts(apiKeyData, requestedModel)
const availableAccounts = await this._getAllAvailableAccounts(
apiKeyData,
effectiveModel,
false // 仅前缀才走 CCR默认池不包含 CCR 账户
)
if (availableAccounts.length === 0) {
// 提供更详细的错误信息
if (requestedModel) {
if (effectiveModel) {
throw new Error(
`No available Claude accounts support the requested model: ${requestedModel}`
`No available Claude accounts support the requested model: ${effectiveModel}`
)
} else {
throw new Error('No available Claude accounts (neither official nor console)')
@@ -165,7 +311,7 @@ class UnifiedClaudeScheduler {
}
// 📋 获取所有可用账户合并官方和Console
async _getAllAvailableAccounts(apiKeyData, requestedModel = null) {
async _getAllAvailableAccounts(apiKeyData, requestedModel = null, includeCcr = false) {
const availableAccounts = []
// 如果API Key绑定了专属账户优先返回
@@ -177,7 +323,8 @@ class UnifiedClaudeScheduler {
boundAccount.isActive === 'true' &&
boundAccount.status !== 'error' &&
boundAccount.status !== 'blocked' &&
boundAccount.status !== 'temp_error'
boundAccount.status !== 'temp_error' &&
this._isSchedulable(boundAccount.schedulable)
) {
const isRateLimited = await claudeAccountService.isAccountRateLimited(boundAccount.id)
if (!isRateLimited) {
@@ -195,7 +342,9 @@ class UnifiedClaudeScheduler {
]
}
} else {
logger.warn(`⚠️ Bound Claude OAuth account ${apiKeyData.claudeAccountId} is not available`)
logger.warn(
`⚠️ Bound Claude OAuth account ${apiKeyData.claudeAccountId} is not available (isActive: ${boundAccount?.isActive}, status: ${boundAccount?.status}, schedulable: ${boundAccount?.schedulable})`
)
}
}
@@ -207,12 +356,28 @@ class UnifiedClaudeScheduler {
if (
boundConsoleAccount &&
boundConsoleAccount.isActive === true &&
boundConsoleAccount.status === 'active'
boundConsoleAccount.status === 'active' &&
this._isSchedulable(boundConsoleAccount.schedulable)
) {
// 主动触发一次额度检查
try {
await claudeConsoleAccountService.checkQuotaUsage(boundConsoleAccount.id)
} catch (e) {
logger.warn(
`Failed to check quota for bound Claude Console account ${boundConsoleAccount.name}: ${e.message}`
)
// 继续使用该账号
}
// 检查限流状态和额度状态
const isRateLimited = await claudeConsoleAccountService.isAccountRateLimited(
boundConsoleAccount.id
)
if (!isRateLimited) {
const isQuotaExceeded = await claudeConsoleAccountService.isAccountQuotaExceeded(
boundConsoleAccount.id
)
if (!isRateLimited && !isQuotaExceeded) {
logger.info(
`🎯 Using bound dedicated Claude Console account: ${boundConsoleAccount.name} (${apiKeyData.claudeConsoleAccountId})`
)
@@ -228,7 +393,7 @@ class UnifiedClaudeScheduler {
}
} else {
logger.warn(
`⚠️ Bound Claude Console account ${apiKeyData.claudeConsoleAccountId} is not available`
`⚠️ Bound Claude Console account ${apiKeyData.claudeConsoleAccountId} is not available (isActive: ${boundConsoleAccount?.isActive}, status: ${boundConsoleAccount?.status}, schedulable: ${boundConsoleAccount?.schedulable})`
)
}
}
@@ -238,7 +403,11 @@ class UnifiedClaudeScheduler {
const boundBedrockAccountResult = await bedrockAccountService.getAccount(
apiKeyData.bedrockAccountId
)
if (boundBedrockAccountResult.success && boundBedrockAccountResult.data.isActive === true) {
if (
boundBedrockAccountResult.success &&
boundBedrockAccountResult.data.isActive === true &&
this._isSchedulable(boundBedrockAccountResult.data.schedulable)
) {
logger.info(
`🎯 Using bound dedicated Bedrock account: ${boundBedrockAccountResult.data.name} (${apiKeyData.bedrockAccountId})`
)
@@ -252,7 +421,9 @@ class UnifiedClaudeScheduler {
}
]
} else {
logger.warn(`⚠️ Bound Bedrock account ${apiKeyData.bedrockAccountId} is not available`)
logger.warn(
`⚠️ Bound Bedrock account ${apiKeyData.bedrockAccountId} is not available (isActive: ${boundBedrockAccountResult?.data?.isActive}, schedulable: ${boundBedrockAccountResult?.data?.schedulable})`
)
}
}
@@ -269,33 +440,9 @@ class UnifiedClaudeScheduler {
) {
// 检查是否可调度
// 检查模型支持(如果请求的是 Opus 模型)
if (requestedModel && requestedModel.toLowerCase().includes('opus')) {
// 检查账号的订阅信息
if (account.subscriptionInfo) {
try {
const info =
typeof account.subscriptionInfo === 'string'
? JSON.parse(account.subscriptionInfo)
: account.subscriptionInfo
// Pro 和 Free 账号不支持 Opus
if (info.hasClaudePro === true && info.hasClaudeMax !== true) {
logger.info(`🚫 Claude account ${account.name} (Pro) does not support Opus model`)
continue // Claude Pro 不支持 Opus
}
if (info.accountType === 'claude_pro' || info.accountType === 'claude_free') {
logger.info(
`🚫 Claude account ${account.name} (${info.accountType}) does not support Opus model`
)
continue // 明确标记为 Pro 或 Free 的账号不支持
}
} catch (e) {
// 解析失败,假设为旧数据,默认支持(兼容旧数据为 Max
logger.debug(`Account ${account.name} has invalid subscriptionInfo, assuming Max`)
}
}
// 没有订阅信息的账号,默认当作支持(兼容旧数据)
// 检查模型支持
if (!this._isModelSupportedByAccount(account, 'claude-official', requestedModel)) {
continue
}
// 检查是否被限流
@@ -330,37 +477,26 @@ class UnifiedClaudeScheduler {
) {
// 检查是否可调度
// 检查模型支持(如果有请求的模型)
if (requestedModel && account.supportedModels) {
// 兼容旧格式(数组)和新格式(对象)
if (Array.isArray(account.supportedModels)) {
// 旧格式:数组
if (
account.supportedModels.length > 0 &&
!account.supportedModels.includes(requestedModel)
) {
logger.info(
`🚫 Claude Console account ${account.name} does not support model ${requestedModel}`
)
continue
}
} else if (typeof account.supportedModels === 'object') {
// 新格式:映射表
if (
Object.keys(account.supportedModels).length > 0 &&
!claudeConsoleAccountService.isModelSupported(account.supportedModels, requestedModel)
) {
logger.info(
`🚫 Claude Console account ${account.name} does not support model ${requestedModel}`
)
continue
}
}
// 检查模型支持
if (!this._isModelSupportedByAccount(account, 'claude-console', requestedModel)) {
continue
}
// 主动触发一次额度检查,确保状态即时生效
try {
await claudeConsoleAccountService.checkQuotaUsage(account.id)
} catch (e) {
logger.warn(
`Failed to check quota for Claude Console account ${account.name}: ${e.message}`
)
// 继续处理该账号
}
// 检查是否被限流
const isRateLimited = await claudeConsoleAccountService.isAccountRateLimited(account.id)
if (!isRateLimited) {
const isQuotaExceeded = await claudeConsoleAccountService.isAccountQuotaExceeded(account.id)
if (!isRateLimited && !isQuotaExceeded) {
availableAccounts.push({
...account,
accountId: account.id,
@@ -372,7 +508,12 @@ class UnifiedClaudeScheduler {
`✅ Added Claude Console account to available pool: ${account.name} (priority: ${account.priority})`
)
} else {
logger.warn(`⚠️ Claude Console account ${account.name} is rate limited`)
if (isRateLimited) {
logger.warn(`⚠️ Claude Console account ${account.name} is rate limited`)
}
if (isQuotaExceeded) {
logger.warn(`💰 Claude Console account ${account.name} quota exceeded`)
}
}
} else {
logger.info(
@@ -417,8 +558,60 @@ class UnifiedClaudeScheduler {
}
}
// 获取CCR账户共享池- 仅当明确要求包含时
if (includeCcr) {
const ccrAccounts = await ccrAccountService.getAllAccounts()
logger.info(`📋 Found ${ccrAccounts.length} total CCR accounts`)
for (const account of ccrAccounts) {
logger.info(
`🔍 Checking CCR account: ${account.name} - isActive: ${account.isActive}, status: ${account.status}, accountType: ${account.accountType}, schedulable: ${account.schedulable}`
)
if (
account.isActive === true &&
account.status === 'active' &&
account.accountType === 'shared' &&
this._isSchedulable(account.schedulable)
) {
// 检查模型支持
if (!this._isModelSupportedByAccount(account, 'ccr', requestedModel)) {
continue
}
// 检查是否被限流
const isRateLimited = await ccrAccountService.isAccountRateLimited(account.id)
const isQuotaExceeded = await ccrAccountService.isAccountQuotaExceeded(account.id)
if (!isRateLimited && !isQuotaExceeded) {
availableAccounts.push({
...account,
accountId: account.id,
accountType: 'ccr',
priority: parseInt(account.priority) || 50,
lastUsedAt: account.lastUsedAt || '0'
})
logger.info(
`✅ Added CCR account to available pool: ${account.name} (priority: ${account.priority})`
)
} else {
if (isRateLimited) {
logger.warn(`⚠️ CCR account ${account.name} is rate limited`)
}
if (isQuotaExceeded) {
logger.warn(`💰 CCR account ${account.name} quota exceeded`)
}
}
} else {
logger.info(
`❌ CCR account ${account.name} not eligible - isActive: ${account.isActive}, status: ${account.status}, accountType: ${account.accountType}, schedulable: ${account.schedulable}`
)
}
}
}
logger.info(
`📊 Total available accounts: ${availableAccounts.length} (Claude: ${availableAccounts.filter((a) => a.accountType === 'claude-official').length}, Console: ${availableAccounts.filter((a) => a.accountType === 'claude-console').length}, Bedrock: ${availableAccounts.filter((a) => a.accountType === 'bedrock').length})`
`📊 Total available accounts: ${availableAccounts.length} (Claude: ${availableAccounts.filter((a) => a.accountType === 'claude-official').length}, Console: ${availableAccounts.filter((a) => a.accountType === 'claude-console').length}, Bedrock: ${availableAccounts.filter((a) => a.accountType === 'bedrock').length}, CCR: ${availableAccounts.filter((a) => a.accountType === 'ccr').length})`
)
return availableAccounts
}
@@ -439,7 +632,7 @@ class UnifiedClaudeScheduler {
}
// 🔍 检查账户是否可用
async _isAccountAvailable(accountId, accountType) {
async _isAccountAvailable(accountId, accountType, requestedModel = null) {
try {
if (accountType === 'claude-official') {
const account = await redis.getClaudeAccount(accountId)
@@ -456,10 +649,34 @@ class UnifiedClaudeScheduler {
logger.info(`🚫 Account ${accountId} is not schedulable`)
return false
}
return !(await claudeAccountService.isAccountRateLimited(accountId))
// 检查模型兼容性
if (
!this._isModelSupportedByAccount(
account,
'claude-official',
requestedModel,
'in session check'
)
) {
return false
}
// 检查是否限流或过载
const isRateLimited = await claudeAccountService.isAccountRateLimited(accountId)
const isOverloaded = await claudeAccountService.isAccountOverloaded(accountId)
return !isRateLimited && !isOverloaded
} else if (accountType === 'claude-console') {
const account = await claudeConsoleAccountService.getAccount(accountId)
if (!account || !account.isActive || account.status !== 'active') {
if (!account || !account.isActive) {
return false
}
// 检查账户状态
if (
account.status !== 'active' &&
account.status !== 'unauthorized' &&
account.status !== 'overloaded'
) {
return false
}
// 检查是否可调度
@@ -467,7 +684,41 @@ class UnifiedClaudeScheduler {
logger.info(`🚫 Claude Console account ${accountId} is not schedulable`)
return false
}
return !(await claudeConsoleAccountService.isAccountRateLimited(accountId))
// 检查模型支持
if (
!this._isModelSupportedByAccount(
account,
'claude-console',
requestedModel,
'in session check'
)
) {
return false
}
// 检查是否超额
try {
await claudeConsoleAccountService.checkQuotaUsage(accountId)
} catch (e) {
logger.warn(`Failed to check quota for Claude Console account ${accountId}: ${e.message}`)
// 继续处理
}
// 检查是否被限流
if (await claudeConsoleAccountService.isAccountRateLimited(accountId)) {
return false
}
if (await claudeConsoleAccountService.isAccountQuotaExceeded(accountId)) {
return false
}
// 检查是否未授权401错误
if (account.status === 'unauthorized') {
return false
}
// 检查是否过载529错误
if (await claudeConsoleAccountService.isAccountOverloaded(accountId)) {
return false
}
return true
} else if (accountType === 'bedrock') {
const accountResult = await bedrockAccountService.getAccount(accountId)
if (!accountResult.success || !accountResult.data.isActive) {
@@ -480,6 +731,52 @@ class UnifiedClaudeScheduler {
}
// Bedrock账户暂不需要限流检查因为AWS管理限流
return true
} else if (accountType === 'ccr') {
const account = await ccrAccountService.getAccount(accountId)
if (!account || !account.isActive) {
return false
}
// 检查账户状态
if (
account.status !== 'active' &&
account.status !== 'unauthorized' &&
account.status !== 'overloaded'
) {
return false
}
// 检查是否可调度
if (!this._isSchedulable(account.schedulable)) {
logger.info(`🚫 CCR account ${accountId} is not schedulable`)
return false
}
// 检查模型支持
if (!this._isModelSupportedByAccount(account, 'ccr', requestedModel, 'in session check')) {
return false
}
// 检查是否超额
try {
await ccrAccountService.checkQuotaUsage(accountId)
} catch (e) {
logger.warn(`Failed to check quota for CCR account ${accountId}: ${e.message}`)
// 继续处理
}
// 检查是否被限流
if (await ccrAccountService.isAccountRateLimited(accountId)) {
return false
}
if (await ccrAccountService.isAccountQuotaExceeded(accountId)) {
return false
}
// 检查是否未授权401错误
if (account.status === 'unauthorized') {
return false
}
// 检查是否过载529错误
if (await ccrAccountService.isAccountOverloaded(accountId)) {
return false
}
return true
}
return false
} catch (error) {
@@ -509,9 +806,11 @@ class UnifiedClaudeScheduler {
async _setSessionMapping(sessionHash, accountId, accountType) {
const client = redis.getClientSafe()
const mappingData = JSON.stringify({ accountId, accountType })
// 设置1小时过期
await client.setex(`${this.SESSION_MAPPING_PREFIX}${sessionHash}`, 3600, mappingData)
// 依据配置设置TTL小时
const appConfig = require('../../config/config')
const ttlHours = appConfig.session?.stickyTtlHours || 1
const ttlSeconds = Math.max(1, Math.floor(ttlHours * 60 * 60))
await client.setex(`${this.SESSION_MAPPING_PREFIX}${sessionHash}`, ttlSeconds, mappingData)
}
// 🗑️ 删除会话映射
@@ -520,6 +819,50 @@ class UnifiedClaudeScheduler {
await client.del(`${this.SESSION_MAPPING_PREFIX}${sessionHash}`)
}
// 🔁 续期统一调度会话映射TTL针对 unified_claude_session_mapping:* 键),遵循会话配置
async _extendSessionMappingTTL(sessionHash) {
try {
const client = redis.getClientSafe()
const key = `${this.SESSION_MAPPING_PREFIX}${sessionHash}`
const remainingTTL = await client.ttl(key)
// -2: key 不存在;-1: 无过期时间
if (remainingTTL === -2) {
return false
}
if (remainingTTL === -1) {
return true
}
const appConfig = require('../../config/config')
const ttlHours = appConfig.session?.stickyTtlHours || 1
const renewalThresholdMinutes = appConfig.session?.renewalThresholdMinutes || 0
// 阈值为0则不续期
if (!renewalThresholdMinutes) {
return true
}
const fullTTL = Math.max(1, Math.floor(ttlHours * 60 * 60))
const threshold = Math.max(0, Math.floor(renewalThresholdMinutes * 60))
if (remainingTTL < threshold) {
await client.expire(key, fullTTL)
logger.debug(
`🔄 Renewed unified session TTL: ${sessionHash} (was ${Math.round(remainingTTL / 60)}m, renewed to ${ttlHours}h)`
)
} else {
logger.debug(
`✅ Unified session TTL sufficient: ${sessionHash} (remaining ${Math.round(remainingTTL / 60)}m)`
)
}
return true
} catch (error) {
logger.error('❌ Failed to extend unified session TTL:', error)
return false
}
}
// 🚫 标记账户为限流状态
async markAccountRateLimited(
accountId,
@@ -536,6 +879,8 @@ class UnifiedClaudeScheduler {
)
} else if (accountType === 'claude-console') {
await claudeConsoleAccountService.markAccountRateLimited(accountId)
} else if (accountType === 'ccr') {
await ccrAccountService.markAccountRateLimited(accountId)
}
// 删除会话映射
@@ -560,6 +905,8 @@ class UnifiedClaudeScheduler {
await claudeAccountService.removeAccountRateLimit(accountId)
} else if (accountType === 'claude-console') {
await claudeConsoleAccountService.removeAccountRateLimit(accountId)
} else if (accountType === 'ccr') {
await ccrAccountService.removeAccountRateLimit(accountId)
}
return { success: true }
@@ -579,6 +926,8 @@ class UnifiedClaudeScheduler {
return await claudeAccountService.isAccountRateLimited(accountId)
} else if (accountType === 'claude-console') {
return await claudeConsoleAccountService.isAccountRateLimited(accountId)
} else if (accountType === 'ccr') {
return await ccrAccountService.isAccountRateLimited(accountId)
}
return false
} catch (error) {
@@ -616,6 +965,32 @@ class UnifiedClaudeScheduler {
}
}
// 🚫 标记账户为被封锁状态403错误
async markAccountBlocked(accountId, accountType, sessionHash = null) {
try {
// 只处理claude-official类型的账户不处理claude-console和gemini
if (accountType === 'claude-official') {
await claudeAccountService.markAccountBlocked(accountId, sessionHash)
// 删除会话映射
if (sessionHash) {
await this._deleteSessionMapping(sessionHash)
}
logger.warn(`🚫 Account ${accountId} marked as blocked due to 403 error`)
} else {
logger.info(
` Skipping blocked marking for non-Claude OAuth account: ${accountId} (${accountType})`
)
}
return { success: true }
} catch (error) {
logger.error(`❌ Failed to mark account as blocked: ${accountId} (${accountType})`, error)
throw error
}
}
// 🚫 标记Claude Console账户为封锁状态模型不支持
async blockConsoleAccount(accountId, reason) {
try {
@@ -628,7 +1003,12 @@ class UnifiedClaudeScheduler {
}
// 👥 从分组中选择账户
async selectAccountFromGroup(groupId, sessionHash = null, requestedModel = null) {
async selectAccountFromGroup(
groupId,
sessionHash = null,
requestedModel = null,
allowCcr = false
) {
try {
// 获取分组信息
const group = await accountGroupService.getGroup(groupId)
@@ -645,15 +1025,23 @@ class UnifiedClaudeScheduler {
// 验证映射的账户是否属于这个分组
const memberIds = await accountGroupService.getGroupMembers(groupId)
if (memberIds.includes(mappedAccount.accountId)) {
const isAvailable = await this._isAccountAvailable(
mappedAccount.accountId,
mappedAccount.accountType
)
if (isAvailable) {
logger.info(
`🎯 Using sticky session account from group: ${mappedAccount.accountId} (${mappedAccount.accountType}) for session ${sessionHash}`
// 非 CCR 请求时不允许 CCR 粘性映射
if (!allowCcr && mappedAccount.accountType === 'ccr') {
await this._deleteSessionMapping(sessionHash)
} else {
const isAvailable = await this._isAccountAvailable(
mappedAccount.accountId,
mappedAccount.accountType,
requestedModel
)
return mappedAccount
if (isAvailable) {
// 🚀 智能会话续期:续期 unified 映射键
await this._extendSessionMappingTTL(sessionHash)
logger.info(
`🎯 Using sticky session account from group: ${mappedAccount.accountId} (${mappedAccount.accountType}) for session ${sessionHash}`
)
return mappedAccount
}
}
}
// 如果映射的账户不可用或不在分组中,删除映射
@@ -685,6 +1073,14 @@ class UnifiedClaudeScheduler {
account = await claudeConsoleAccountService.getAccount(memberId)
if (account) {
accountType = 'claude-console'
} else {
// 尝试CCR账户仅允许在 allowCcr 为 true 时)
if (allowCcr) {
account = await ccrAccountService.getAccount(memberId)
if (account) {
accountType = 'ccr'
}
}
}
}
} else if (group.platform === 'gemini') {
@@ -707,22 +1103,14 @@ class UnifiedClaudeScheduler {
const status =
accountType === 'claude-official'
? account.status !== 'error' && account.status !== 'blocked'
: account.status === 'active'
: accountType === 'ccr'
? account.status === 'active'
: account.status === 'active'
if (isActive && status && this._isSchedulable(account.schedulable)) {
// 检查模型支持Console账户
if (
accountType === 'claude-console' &&
requestedModel &&
account.supportedModels &&
account.supportedModels.length > 0
) {
if (!account.supportedModels.includes(requestedModel)) {
logger.info(
`🚫 Account ${account.name} in group does not support model ${requestedModel}`
)
continue
}
// 检查模型支持
if (!this._isModelSupportedByAccount(account, accountType, requestedModel, 'in group')) {
continue
}
// 检查是否被限流
@@ -774,6 +1162,133 @@ class UnifiedClaudeScheduler {
throw error
}
}
// 🎯 专门选择CCR账户仅限CCR前缀路由使用
async _selectCcrAccount(apiKeyData, sessionHash = null, effectiveModel = null) {
try {
// 1. 检查会话粘性
if (sessionHash) {
const mappedAccount = await this._getSessionMapping(sessionHash)
if (mappedAccount && mappedAccount.accountType === 'ccr') {
// 验证映射的CCR账户是否仍然可用
const isAvailable = await this._isAccountAvailable(
mappedAccount.accountId,
mappedAccount.accountType,
effectiveModel
)
if (isAvailable) {
// 🚀 智能会话续期:续期 unified 映射键
await this._extendSessionMappingTTL(sessionHash)
logger.info(
`🎯 Using sticky CCR session account: ${mappedAccount.accountId} for session ${sessionHash}`
)
return mappedAccount
} else {
logger.warn(
`⚠️ Mapped CCR account ${mappedAccount.accountId} is no longer available, selecting new account`
)
await this._deleteSessionMapping(sessionHash)
}
}
}
// 2. 获取所有可用的CCR账户
const availableCcrAccounts = await this._getAvailableCcrAccounts(effectiveModel)
if (availableCcrAccounts.length === 0) {
throw new Error(
`No available CCR accounts support the requested model: ${effectiveModel || 'unspecified'}`
)
}
// 3. 按优先级和最后使用时间排序
const sortedAccounts = this._sortAccountsByPriority(availableCcrAccounts)
const selectedAccount = sortedAccounts[0]
// 4. 建立会话映射
if (sessionHash) {
await this._setSessionMapping(
sessionHash,
selectedAccount.accountId,
selectedAccount.accountType
)
logger.info(
`🎯 Created new sticky CCR session mapping: ${selectedAccount.name} (${selectedAccount.accountId}) for session ${sessionHash}`
)
}
logger.info(
`🎯 Selected CCR account: ${selectedAccount.name} (${selectedAccount.accountId}) with priority ${selectedAccount.priority} for API key ${apiKeyData.name}`
)
return {
accountId: selectedAccount.accountId,
accountType: selectedAccount.accountType
}
} catch (error) {
logger.error('❌ Failed to select CCR account:', error)
throw error
}
}
// 📋 获取所有可用的CCR账户
async _getAvailableCcrAccounts(requestedModel = null) {
const availableAccounts = []
try {
const ccrAccounts = await ccrAccountService.getAllAccounts()
logger.info(`📋 Found ${ccrAccounts.length} total CCR accounts for CCR-only selection`)
for (const account of ccrAccounts) {
logger.debug(
`🔍 Checking CCR account: ${account.name} - isActive: ${account.isActive}, status: ${account.status}, accountType: ${account.accountType}, schedulable: ${account.schedulable}`
)
if (
account.isActive === true &&
account.status === 'active' &&
account.accountType === 'shared' &&
this._isSchedulable(account.schedulable)
) {
// 检查模型支持
if (!this._isModelSupportedByAccount(account, 'ccr', requestedModel)) {
logger.debug(`CCR account ${account.name} does not support model ${requestedModel}`)
continue
}
// 检查是否被限流或超额
const isRateLimited = await ccrAccountService.isAccountRateLimited(account.id)
const isQuotaExceeded = await ccrAccountService.isAccountQuotaExceeded(account.id)
const isOverloaded = await ccrAccountService.isAccountOverloaded(account.id)
if (!isRateLimited && !isQuotaExceeded && !isOverloaded) {
availableAccounts.push({
...account,
accountId: account.id,
accountType: 'ccr',
priority: parseInt(account.priority) || 50,
lastUsedAt: account.lastUsedAt || '0'
})
logger.debug(`✅ Added CCR account to available pool: ${account.name}`)
} else {
logger.debug(
`❌ CCR account ${account.name} not available - rateLimited: ${isRateLimited}, quotaExceeded: ${isQuotaExceeded}, overloaded: ${isOverloaded}`
)
}
} else {
logger.debug(
`❌ CCR account ${account.name} not eligible - isActive: ${account.isActive}, status: ${account.status}, accountType: ${account.accountType}, schedulable: ${account.schedulable}`
)
}
}
logger.info(`📊 Total available CCR accounts: ${availableAccounts.length}`)
return availableAccounts
} catch (error) {
logger.error('❌ Failed to get available CCR accounts:', error)
return []
}
}
}
module.exports = new UnifiedClaudeScheduler()

View File

@@ -61,6 +61,8 @@ class UnifiedGeminiScheduler {
mappedAccount.accountType
)
if (isAvailable) {
// 🚀 智能会话续期(续期 unified 映射键,按配置)
await this._extendSessionMappingTTL(sessionHash)
logger.info(
`🎯 Using sticky session account: ${mappedAccount.accountId} (${mappedAccount.accountType}) for session ${sessionHash}`
)
@@ -283,9 +285,11 @@ class UnifiedGeminiScheduler {
async _setSessionMapping(sessionHash, accountId, accountType) {
const client = redis.getClientSafe()
const mappingData = JSON.stringify({ accountId, accountType })
// 设置1小时过期
await client.setex(`${this.SESSION_MAPPING_PREFIX}${sessionHash}`, 3600, mappingData)
// 依据配置设置TTL小时
const appConfig = require('../../config/config')
const ttlHours = appConfig.session?.stickyTtlHours || 1
const ttlSeconds = Math.max(1, Math.floor(ttlHours * 60 * 60))
await client.setex(`${this.SESSION_MAPPING_PREFIX}${sessionHash}`, ttlSeconds, mappingData)
}
// 🗑️ 删除会话映射
@@ -294,6 +298,47 @@ class UnifiedGeminiScheduler {
await client.del(`${this.SESSION_MAPPING_PREFIX}${sessionHash}`)
}
// 🔁 续期统一调度会话映射TTL针对 unified_gemini_session_mapping:* 键),遵循会话配置
async _extendSessionMappingTTL(sessionHash) {
try {
const client = redis.getClientSafe()
const key = `${this.SESSION_MAPPING_PREFIX}${sessionHash}`
const remainingTTL = await client.ttl(key)
if (remainingTTL === -2) {
return false
}
if (remainingTTL === -1) {
return true
}
const appConfig = require('../../config/config')
const ttlHours = appConfig.session?.stickyTtlHours || 1
const renewalThresholdMinutes = appConfig.session?.renewalThresholdMinutes || 0
if (!renewalThresholdMinutes) {
return true
}
const fullTTL = Math.max(1, Math.floor(ttlHours * 60 * 60))
const threshold = Math.max(0, Math.floor(renewalThresholdMinutes * 60))
if (remainingTTL < threshold) {
await client.expire(key, fullTTL)
logger.debug(
`🔄 Renewed unified Gemini session TTL: ${sessionHash} (was ${Math.round(remainingTTL / 60)}m, renewed to ${ttlHours}h)`
)
} else {
logger.debug(
`✅ Unified Gemini session TTL sufficient: ${sessionHash} (remaining ${Math.round(remainingTTL / 60)}m)`
)
}
return true
} catch (error) {
logger.error('❌ Failed to extend unified Gemini session TTL:', error)
return false
}
}
// 🚫 标记账户为限流状态
async markAccountRateLimited(accountId, accountType, sessionHash = null) {
try {
@@ -382,6 +427,8 @@ class UnifiedGeminiScheduler {
mappedAccount.accountType
)
if (isAvailable) {
// 🚀 智能会话续期(续期 unified 映射键,按配置)
await this._extendSessionMappingTTL(sessionHash)
logger.info(
`🎯 Using sticky session account from group: ${mappedAccount.accountId} (${mappedAccount.accountType}) for session ${sessionHash}`
)

View File

@@ -1,4 +1,5 @@
const openaiAccountService = require('./openaiAccountService')
const openaiResponsesAccountService = require('./openaiResponsesAccountService')
const accountGroupService = require('./accountGroupService')
const redis = require('../models/redis')
const logger = require('../utils/logger')
@@ -32,19 +33,53 @@ class UnifiedOpenAIScheduler {
return await this.selectAccountFromGroup(groupId, sessionHash, requestedModel, apiKeyData)
}
// 普通专属账户
const boundAccount = await openaiAccountService.getAccount(apiKeyData.openaiAccountId)
if (boundAccount && boundAccount.isActive === 'true' && boundAccount.status !== 'error') {
// 普通专属账户 - 根据前缀判断是 OpenAI 还是 OpenAI-Responses 类型
let boundAccount = null
let accountType = 'openai'
// 检查是否有 responses: 前缀(用于区分 OpenAI-Responses 账户)
if (apiKeyData.openaiAccountId.startsWith('responses:')) {
const accountId = apiKeyData.openaiAccountId.replace('responses:', '')
boundAccount = await openaiResponsesAccountService.getAccount(accountId)
accountType = 'openai-responses'
} else {
// 普通 OpenAI 账户
boundAccount = await openaiAccountService.getAccount(apiKeyData.openaiAccountId)
accountType = 'openai'
}
if (
boundAccount &&
(boundAccount.isActive === true || boundAccount.isActive === 'true') &&
boundAccount.status !== 'error'
) {
// 检查是否被限流
const isRateLimited = await this.isAccountRateLimited(boundAccount.id)
if (isRateLimited) {
const errorMsg = `Dedicated account ${boundAccount.name} is currently rate limited`
logger.warn(`⚠️ ${errorMsg}`)
throw new Error(errorMsg)
if (accountType === 'openai') {
const isRateLimited = await this.isAccountRateLimited(boundAccount.id)
if (isRateLimited) {
const errorMsg = `Dedicated account ${boundAccount.name} is currently rate limited`
logger.warn(`⚠️ ${errorMsg}`)
throw new Error(errorMsg)
}
} else if (
accountType === 'openai-responses' &&
boundAccount.rateLimitStatus === 'limited'
) {
// OpenAI-Responses 账户的限流检查
const isRateLimitCleared = await openaiResponsesAccountService.checkAndClearRateLimit(
boundAccount.id
)
if (!isRateLimitCleared) {
const errorMsg = `Dedicated account ${boundAccount.name} is currently rate limited`
logger.warn(`⚠️ ${errorMsg}`)
throw new Error(errorMsg)
}
}
// 专属账户可选的模型检查只有明确配置了supportedModels且不为空才检查
// OpenAI-Responses 账户默认支持所有模型
if (
accountType === 'openai' &&
requestedModel &&
boundAccount.supportedModels &&
boundAccount.supportedModels.length > 0
@@ -58,13 +93,19 @@ class UnifiedOpenAIScheduler {
}
logger.info(
`🎯 Using bound dedicated OpenAI account: ${boundAccount.name} (${apiKeyData.openaiAccountId}) for API key ${apiKeyData.name}`
`🎯 Using bound dedicated ${accountType} account: ${boundAccount.name} (${boundAccount.id}) for API key ${apiKeyData.name}`
)
// 更新账户的最后使用时间
await openaiAccountService.recordUsage(apiKeyData.openaiAccountId, 0)
if (accountType === 'openai') {
await openaiAccountService.recordUsage(boundAccount.id, 0)
} else {
await openaiResponsesAccountService.updateAccount(boundAccount.id, {
lastUsedAt: new Date().toISOString()
})
}
return {
accountId: apiKeyData.openaiAccountId,
accountType: 'openai'
accountId: boundAccount.id,
accountType
}
} else {
// 专属账户不可用时直接报错,不降级到共享池
@@ -86,6 +127,8 @@ class UnifiedOpenAIScheduler {
mappedAccount.accountType
)
if (isAvailable) {
// 🚀 智能会话续期(续期 unified 映射键,按配置)
await this._extendSessionMappingTTL(sessionHash)
logger.info(
`🎯 Using sticky session account: ${mappedAccount.accountId} (${mappedAccount.accountType}) for session ${sessionHash}`
)
@@ -163,22 +206,36 @@ class UnifiedOpenAIScheduler {
// 获取所有OpenAI账户共享池
const openaiAccounts = await openaiAccountService.getAllAccounts()
for (const account of openaiAccounts) {
for (let account of openaiAccounts) {
if (
account.isActive === 'true' &&
account.isActive &&
account.status !== 'error' &&
(account.accountType === 'shared' || !account.accountType) && // 兼容旧数据
this._isSchedulable(account.schedulable)
) {
// 检查是否可调度
// 检查token是否过期
// 检查token是否过期并自动刷新
const isExpired = openaiAccountService.isTokenExpired(account)
if (isExpired && !account.refreshToken) {
logger.warn(
`⚠️ OpenAI account ${account.name} token expired and no refresh token available`
)
continue
if (isExpired) {
if (!account.refreshToken) {
logger.warn(
`⚠️ OpenAI account ${account.name} token expired and no refresh token available`
)
continue
}
// 自动刷新过期的 token
try {
logger.info(`🔄 Auto-refreshing expired token for OpenAI account ${account.name}`)
await openaiAccountService.refreshAccountToken(account.id)
// 重新获取更新后的账户信息
account = await openaiAccountService.getAccount(account.id)
logger.info(`✅ Token refreshed successfully for ${account.name}`)
} catch (refreshError) {
logger.error(`❌ Failed to refresh token for ${account.name}:`, refreshError.message)
continue // 刷新失败,跳过此账户
}
}
// 检查模型支持仅在明确设置了supportedModels且不为空时才检查
@@ -210,6 +267,40 @@ class UnifiedOpenAIScheduler {
}
}
// 获取所有 OpenAI-Responses 账户(共享池)
const openaiResponsesAccounts = await openaiResponsesAccountService.getAllAccounts()
for (const account of openaiResponsesAccounts) {
if (
(account.isActive === true || account.isActive === 'true') &&
account.status !== 'error' &&
account.status !== 'rateLimited' &&
(account.accountType === 'shared' || !account.accountType) && // 兼容旧数据
this._isSchedulable(account.schedulable)
) {
// 检查并清除过期的限流状态
const isRateLimitCleared = await openaiResponsesAccountService.checkAndClearRateLimit(
account.id
)
// 如果仍然处于限流状态,跳过
if (account.rateLimitStatus === 'limited' && !isRateLimitCleared) {
logger.debug(`⏭️ Skipping OpenAI-Responses account ${account.name} - rate limited`)
continue
}
// OpenAI-Responses 账户默认支持所有模型
// 因为它们是第三方兼容 API模型支持由第三方决定
availableAccounts.push({
...account,
accountId: account.id,
accountType: 'openai-responses',
priority: parseInt(account.priority) || 50,
lastUsedAt: account.lastUsedAt || '0'
})
}
}
return availableAccounts
}
@@ -233,7 +324,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
}
// 检查是否可调度
@@ -242,6 +333,24 @@ class UnifiedOpenAIScheduler {
return false
}
return !(await this.isAccountRateLimited(accountId))
} else if (accountType === 'openai-responses') {
const account = await openaiResponsesAccountService.getAccount(accountId)
if (
!account ||
(account.isActive !== true && account.isActive !== 'true') ||
account.status === 'error'
) {
return false
}
// 检查是否可调度
if (!this._isSchedulable(account.schedulable)) {
logger.info(`🚫 OpenAI-Responses account ${accountId} is not schedulable`)
return false
}
// 检查并清除过期的限流状态
const isRateLimitCleared =
await openaiResponsesAccountService.checkAndClearRateLimit(accountId)
return account.rateLimitStatus !== 'limited' || isRateLimitCleared
}
return false
} catch (error) {
@@ -271,9 +380,11 @@ class UnifiedOpenAIScheduler {
async _setSessionMapping(sessionHash, accountId, accountType) {
const client = redis.getClientSafe()
const mappingData = JSON.stringify({ accountId, accountType })
// 设置1小时过期
await client.setex(`${this.SESSION_MAPPING_PREFIX}${sessionHash}`, 3600, mappingData)
// 依据配置设置TTL小时
const appConfig = require('../../config/config')
const ttlHours = appConfig.session?.stickyTtlHours || 1
const ttlSeconds = Math.max(1, Math.floor(ttlHours * 60 * 60))
await client.setex(`${this.SESSION_MAPPING_PREFIX}${sessionHash}`, ttlSeconds, mappingData)
}
// 🗑️ 删除会话映射
@@ -282,11 +393,64 @@ class UnifiedOpenAIScheduler {
await client.del(`${this.SESSION_MAPPING_PREFIX}${sessionHash}`)
}
// 🔁 续期统一调度会话映射TTL针对 unified_openai_session_mapping:* 键),遵循会话配置
async _extendSessionMappingTTL(sessionHash) {
try {
const client = redis.getClientSafe()
const key = `${this.SESSION_MAPPING_PREFIX}${sessionHash}`
const remainingTTL = await client.ttl(key)
if (remainingTTL === -2) {
return false
}
if (remainingTTL === -1) {
return true
}
const appConfig = require('../../config/config')
const ttlHours = appConfig.session?.stickyTtlHours || 1
const renewalThresholdMinutes = appConfig.session?.renewalThresholdMinutes || 0
if (!renewalThresholdMinutes) {
return true
}
const fullTTL = Math.max(1, Math.floor(ttlHours * 60 * 60))
const threshold = Math.max(0, Math.floor(renewalThresholdMinutes * 60))
if (remainingTTL < threshold) {
await client.expire(key, fullTTL)
logger.debug(
`🔄 Renewed unified OpenAI session TTL: ${sessionHash} (was ${Math.round(remainingTTL / 60)}m, renewed to ${ttlHours}h)`
)
} else {
logger.debug(
`✅ Unified OpenAI session TTL sufficient: ${sessionHash} (remaining ${Math.round(remainingTTL / 60)}m)`
)
}
return true
} catch (error) {
logger.error('❌ Failed to extend unified OpenAI session TTL:', error)
return false
}
}
// 🚫 标记账户为限流状态
async markAccountRateLimited(accountId, accountType, sessionHash = null) {
async markAccountRateLimited(accountId, accountType, sessionHash = null, resetsInSeconds = null) {
try {
if (accountType === 'openai') {
await openaiAccountService.setAccountRateLimited(accountId, true)
await openaiAccountService.setAccountRateLimited(accountId, true, resetsInSeconds)
} else if (accountType === 'openai-responses') {
// 对于 OpenAI-Responses 账户,使用与普通 OpenAI 账户类似的处理方式
const duration = resetsInSeconds ? Math.ceil(resetsInSeconds / 60) : null
await openaiResponsesAccountService.markAccountRateLimited(accountId, duration)
// 同时更新调度状态,避免继续被调度
await openaiResponsesAccountService.updateAccount(accountId, {
schedulable: 'false',
rateLimitResetAt: resetsInSeconds
? new Date(Date.now() + resetsInSeconds * 1000).toISOString()
: new Date(Date.now() + 3600000).toISOString() // 默认1小时
})
}
// 删除会话映射
@@ -309,6 +473,17 @@ class UnifiedOpenAIScheduler {
try {
if (accountType === 'openai') {
await openaiAccountService.setAccountRateLimited(accountId, false)
} else if (accountType === 'openai-responses') {
// 清除 OpenAI-Responses 账户的限流状态
await openaiResponsesAccountService.updateAccount(accountId, {
rateLimitedAt: '',
rateLimitStatus: '',
rateLimitResetAt: '',
status: 'active',
errorMessage: '',
schedulable: 'true'
})
logger.info(`✅ Rate limit cleared for OpenAI-Responses account ${accountId}`)
}
return { success: true }
@@ -329,12 +504,30 @@ class UnifiedOpenAIScheduler {
return false
}
if (account.rateLimitStatus === 'limited' && account.rateLimitedAt) {
const limitedAt = new Date(account.rateLimitedAt).getTime()
const now = Date.now()
const limitDuration = 60 * 60 * 1000 // 1小时
if (account.rateLimitStatus === 'limited') {
// 如果有具体的重置时间,使用它
if (account.rateLimitResetAt) {
const resetTime = new Date(account.rateLimitResetAt).getTime()
const now = Date.now()
const isStillLimited = now < resetTime
return now < limitedAt + limitDuration
// 如果已经过了重置时间,自动清除限流状态
if (!isStillLimited) {
logger.info(`✅ Auto-clearing rate limit for account ${accountId} (reset time reached)`)
await openaiAccountService.setAccountRateLimited(accountId, false)
return false
}
return isStillLimited
}
// 如果没有具体的重置时间使用默认的1小时
if (account.rateLimitedAt) {
const limitedAt = new Date(account.rateLimitedAt).getTime()
const now = Date.now()
const limitDuration = 60 * 60 * 1000 // 1小时
return now < limitedAt + limitDuration
}
}
return false
} catch (error) {
@@ -370,6 +563,8 @@ class UnifiedOpenAIScheduler {
mappedAccount.accountType
)
if (isAvailable) {
// 🚀 智能会话续期(续期 unified 映射键,按配置)
await this._extendSessionMappingTTL(sessionHash)
logger.info(
`🎯 Using sticky session account from group: ${mappedAccount.accountId} (${mappedAccount.accountType})`
)
@@ -395,7 +590,7 @@ class UnifiedOpenAIScheduler {
const account = await openaiAccountService.getAccount(memberId)
if (
account &&
account.isActive === 'true' &&
account.isActive &&
account.status !== 'error' &&
this._isSchedulable(account.schedulable)
) {

593
src/services/userService.js Normal file
View File

@@ -0,0 +1,593 @@
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)
// 如果是新用户尝试转移匹配的API Keys
if (isNewUser) {
await this.transferMatchingApiKeys(user)
}
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
}
}
// 🔄 转移匹配的API Keys给新用户
async transferMatchingApiKeys(user) {
try {
const apiKeyService = require('./apiKeyService')
const { displayName, username, email } = user
// 获取所有API Keys
const allApiKeys = await apiKeyService.getAllApiKeys()
// 找到没有用户ID的API Keys即由Admin创建的
const unownedApiKeys = allApiKeys.filter((key) => !key.userId || key.userId === '')
if (unownedApiKeys.length === 0) {
logger.debug(`📝 No unowned API keys found for potential transfer to user: ${username}`)
return
}
// 构建匹配字符串数组只考虑displayName、username、email去除空值和重复值
const matchStrings = new Set()
if (displayName) {
matchStrings.add(displayName.toLowerCase().trim())
}
if (username) {
matchStrings.add(username.toLowerCase().trim())
}
if (email) {
matchStrings.add(email.toLowerCase().trim())
}
const matchingKeys = []
// 查找名称匹配的API Keys只进行完全匹配
for (const apiKey of unownedApiKeys) {
const keyName = apiKey.name ? apiKey.name.toLowerCase().trim() : ''
// 检查API Key名称是否与用户信息完全匹配
for (const matchString of matchStrings) {
if (keyName === matchString) {
matchingKeys.push(apiKey)
break // 找到匹配后跳出内层循环
}
}
}
// 转移匹配的API Keys
let transferredCount = 0
for (const apiKey of matchingKeys) {
try {
await apiKeyService.updateApiKey(apiKey.id, {
userId: user.id,
userUsername: user.username,
createdBy: user.username
})
transferredCount++
logger.info(`🔄 Transferred API key "${apiKey.name}" (${apiKey.id}) to user: ${username}`)
} catch (error) {
logger.error(`❌ Failed to transfer API key ${apiKey.id} to user ${username}:`, error)
}
}
if (transferredCount > 0) {
logger.success(
`🎉 Successfully transferred ${transferredCount} API key(s) to new user: ${username} (${displayName})`
)
} else if (matchingKeys.length === 0) {
logger.debug(`📝 No matching API keys found for user: ${username} (${displayName})`)
}
} catch (error) {
logger.error('❌ Error transferring matching API keys:', error)
// Don't throw error to prevent blocking user creation
}
}
}
module.exports = new UserService()

View File

@@ -56,15 +56,27 @@ class WebhookConfigService {
// 验证平台配置
if (config.platforms) {
const validPlatforms = ['wechat_work', 'dingtalk', 'feishu', 'slack', 'discord', 'custom']
const validPlatforms = [
'wechat_work',
'dingtalk',
'feishu',
'slack',
'discord',
'custom',
'bark',
'smtp'
]
for (const platform of config.platforms) {
if (!validPlatforms.includes(platform.type)) {
throw new Error(`不支持的平台类型: ${platform.type}`)
}
if (!platform.url || !this.isValidUrl(platform.url)) {
throw new Error(`无效的webhook URL: ${platform.url}`)
// Bark和SMTP平台不使用标准URL
if (platform.type !== 'bark' && platform.type !== 'smtp') {
if (!platform.url || !this.isValidUrl(platform.url)) {
throw new Error(`无效的webhook URL: ${platform.url}`)
}
}
// 验证平台特定的配置
@@ -108,6 +120,133 @@ class WebhookConfigService {
case 'custom':
// 自定义webhook用户自行负责格式
break
case 'bark':
// 验证设备密钥
if (!platform.deviceKey) {
throw new Error('Bark平台必须提供设备密钥')
}
// 验证设备密钥格式通常是22-24位字符
if (platform.deviceKey.length < 20 || platform.deviceKey.length > 30) {
logger.warn('⚠️ Bark设备密钥长度可能不正确请检查是否完整复制')
}
// 验证服务器URL如果提供
if (platform.serverUrl) {
if (!this.isValidUrl(platform.serverUrl)) {
throw new Error('Bark服务器URL格式无效')
}
if (!platform.serverUrl.includes('/push')) {
logger.warn('⚠️ Bark服务器URL应该以/push结尾')
}
}
// 验证声音参数(如果提供)
if (platform.sound) {
const validSounds = [
'default',
'alarm',
'anticipate',
'bell',
'birdsong',
'bloom',
'calypso',
'chime',
'choo',
'descent',
'electronic',
'fanfare',
'glass',
'gotosleep',
'healthnotification',
'horn',
'ladder',
'mailsent',
'minuet',
'multiwayinvitation',
'newmail',
'newsflash',
'noir',
'paymentsuccess',
'shake',
'sherwoodforest',
'silence',
'spell',
'suspense',
'telegraph',
'tiptoes',
'typewriters',
'update',
'alert'
]
if (!validSounds.includes(platform.sound)) {
logger.warn(`⚠️ 未知的Bark声音: ${platform.sound}`)
}
}
// 验证级别参数
if (platform.level) {
const validLevels = ['active', 'timeSensitive', 'passive', 'critical']
if (!validLevels.includes(platform.level)) {
throw new Error(`无效的Bark通知级别: ${platform.level}`)
}
}
// 验证图标URL如果提供
if (platform.icon && !this.isValidUrl(platform.icon)) {
logger.warn('⚠️ Bark图标URL格式可能不正确')
}
// 验证点击跳转URL如果提供
if (platform.clickUrl && !this.isValidUrl(platform.clickUrl)) {
logger.warn('⚠️ Bark点击跳转URL格式可能不正确')
}
break
case 'smtp': {
// 验证SMTP必需配置
if (!platform.host) {
throw new Error('SMTP平台必须提供主机地址')
}
if (!platform.user) {
throw new Error('SMTP平台必须提供用户名')
}
if (!platform.pass) {
throw new Error('SMTP平台必须提供密码')
}
if (!platform.to) {
throw new Error('SMTP平台必须提供接收邮箱')
}
// 验证端口
if (platform.port && (platform.port < 1 || platform.port > 65535)) {
throw new Error('SMTP端口必须在1-65535之间')
}
// 验证邮箱格式
// 支持两种格式1. 纯邮箱 user@domain.com 2. 带名称 Name <user@domain.com>
const simpleEmailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
// 验证接收邮箱
const toEmails = Array.isArray(platform.to) ? platform.to : [platform.to]
for (const email of toEmails) {
// 提取实际邮箱地址(如果是 Name <email> 格式)
const actualEmail = email.includes('<') ? email.match(/<([^>]+)>/)?.[1] : email
if (!actualEmail || !simpleEmailRegex.test(actualEmail)) {
throw new Error(`无效的接收邮箱格式: ${email}`)
}
}
// 验证发送邮箱(支持 Name <email> 格式)
if (platform.from) {
const actualFromEmail = platform.from.includes('<')
? platform.from.match(/<([^>]+)>/)?.[1]
: platform.from
if (!actualFromEmail || !simpleEmailRegex.test(actualFromEmail)) {
throw new Error(`无效的发送邮箱格式: ${platform.from}`)
}
}
break
}
}
}

295
src/services/webhookService.js Normal file → Executable file
View File

@@ -1,7 +1,10 @@
const axios = require('axios')
const crypto = require('crypto')
const nodemailer = require('nodemailer')
const logger = require('../utils/logger')
const webhookConfigService = require('./webhookConfigService')
const { getISOStringWithTimezone } = require('../utils/dateHelper')
const appConfig = require('../../config/config')
class WebhookService {
constructor() {
@@ -11,8 +14,11 @@ class WebhookService {
feishu: this.sendToFeishu.bind(this),
slack: this.sendToSlack.bind(this),
discord: this.sendToDiscord.bind(this),
custom: this.sendToCustom.bind(this)
custom: this.sendToCustom.bind(this),
bark: this.sendToBark.bind(this),
smtp: this.sendToSMTP.bind(this)
}
this.timezone = appConfig.system.timezone || 'Asia/Shanghai'
}
/**
@@ -205,13 +211,85 @@ class WebhookService {
const payload = {
type,
service: 'claude-relay-service',
timestamp: new Date().toISOString(),
timestamp: getISOStringWithTimezone(new Date()),
data
}
await this.sendHttpRequest(platform.url, payload, platform.timeout || 10000)
}
/**
* Bark webhook
*/
async sendToBark(platform, type, data) {
const payload = {
device_key: platform.deviceKey,
title: this.getNotificationTitle(type),
body: this.formatMessageForBark(type, data),
level: platform.level || this.getBarkLevel(type),
sound: platform.sound || this.getBarkSound(type),
group: platform.group || 'claude-relay',
badge: 1
}
// 添加可选参数
if (platform.icon) {
payload.icon = platform.icon
}
if (platform.clickUrl) {
payload.url = platform.clickUrl
}
const url = platform.serverUrl || 'https://api.day.app/push'
await this.sendHttpRequest(url, payload, platform.timeout || 10000)
}
/**
* SMTP邮件通知
*/
async sendToSMTP(platform, type, data) {
try {
// 创建SMTP传输器
const transporter = nodemailer.createTransport({
host: platform.host,
port: platform.port || 587,
secure: platform.secure || false, // true for 465, false for other ports
auth: {
user: platform.user,
pass: platform.pass
},
// 可选的TLS配置
tls: platform.ignoreTLS ? { rejectUnauthorized: false } : undefined,
// 连接超时
connectionTimeout: platform.timeout || 10000
})
// 构造邮件内容
const subject = this.getNotificationTitle(type)
const htmlContent = this.formatMessageForEmail(type, data)
const textContent = this.formatMessageForEmailText(type, data)
// 邮件选项
const mailOptions = {
from: platform.from || platform.user, // 发送者
to: platform.to, // 接收者(必填)
subject: `[Claude Relay Service] ${subject}`,
text: textContent,
html: htmlContent
}
// 发送邮件
const info = await transporter.sendMail(mailOptions)
logger.info(`✅ 邮件发送成功: ${info.messageId}`)
return info
} catch (error) {
logger.error('SMTP邮件发送失败:', error)
throw error
}
}
/**
* 发送HTTP请求
*/
@@ -280,11 +358,10 @@ class WebhookService {
formatMessageForWechatWork(type, data) {
const title = this.getNotificationTitle(type)
const details = this.formatNotificationDetails(data)
return (
`## ${title}\n\n` +
`> **服务**: Claude Relay Service\n` +
`> **时间**: ${new Date().toLocaleString('zh-CN')}\n\n${details}`
`> **时间**: ${new Date().toLocaleString('zh-CN', { timeZone: this.timezone })}\n\n${details}`
)
}
@@ -296,7 +373,7 @@ class WebhookService {
return (
`#### 服务: Claude Relay Service\n` +
`#### 时间: ${new Date().toLocaleString('zh-CN')}\n\n${details}`
`#### 时间: ${new Date().toLocaleString('zh-CN', { timeZone: this.timezone })}\n\n${details}`
)
}
@@ -329,7 +406,7 @@ class WebhookService {
title,
color,
fields,
timestamp: new Date().toISOString(),
timestamp: getISOStringWithTimezone(new Date()),
footer: {
text: 'Claude Relay Service'
}
@@ -345,12 +422,205 @@ class WebhookService {
quotaWarning: '📊 配额警告',
systemError: '❌ 系统错误',
securityAlert: '🔒 安全警报',
rateLimitRecovery: '🎉 限流恢复通知',
test: '🧪 测试通知'
}
return titles[type] || '📢 系统通知'
}
/**
* 获取Bark通知级别
*/
getBarkLevel(type) {
const levels = {
accountAnomaly: 'timeSensitive',
quotaWarning: 'active',
systemError: 'critical',
securityAlert: 'critical',
rateLimitRecovery: 'active',
test: 'passive'
}
return levels[type] || 'active'
}
/**
* 获取Bark声音
*/
getBarkSound(type) {
const sounds = {
accountAnomaly: 'alarm',
quotaWarning: 'bell',
systemError: 'alert',
securityAlert: 'alarm',
rateLimitRecovery: 'success',
test: 'default'
}
return sounds[type] || 'default'
}
/**
* 格式化Bark消息
*/
formatMessageForBark(type, data) {
const lines = []
if (data.accountName) {
lines.push(`账号: ${data.accountName}`)
}
if (data.platform) {
lines.push(`平台: ${data.platform}`)
}
if (data.status) {
lines.push(`状态: ${data.status}`)
}
if (data.errorCode) {
lines.push(`错误: ${data.errorCode}`)
}
if (data.reason) {
lines.push(`原因: ${data.reason}`)
}
if (data.message) {
lines.push(`消息: ${data.message}`)
}
if (data.quota) {
lines.push(`剩余配额: ${data.quota.remaining}/${data.quota.total}`)
}
if (data.usage) {
lines.push(`使用率: ${data.usage}%`)
}
// 添加服务标识和时间戳
lines.push(`\n服务: Claude Relay Service`)
lines.push(`时间: ${new Date().toLocaleString('zh-CN', { timeZone: this.timezone })}`)
return lines.join('\n')
}
/**
* 构建通知详情数据
*/
buildNotificationDetails(data) {
const details = []
if (data.accountName) {
details.push({ label: '账号', value: data.accountName })
}
if (data.platform) {
details.push({ label: '平台', value: data.platform })
}
if (data.status) {
details.push({ label: '状态', value: data.status, color: this.getStatusColor(data.status) })
}
if (data.errorCode) {
details.push({ label: '错误代码', value: data.errorCode, isCode: true })
}
if (data.reason) {
details.push({ label: '原因', value: data.reason })
}
if (data.message) {
details.push({ label: '消息', value: data.message })
}
if (data.quota) {
details.push({ label: '配额', value: `${data.quota.remaining}/${data.quota.total}` })
}
if (data.usage) {
details.push({ label: '使用率', value: `${data.usage}%` })
}
return details
}
/**
* 格式化邮件HTML内容
*/
formatMessageForEmail(type, data) {
const title = this.getNotificationTitle(type)
const timestamp = new Date().toLocaleString('zh-CN', { timeZone: this.timezone })
const details = this.buildNotificationDetails(data)
let content = `
<div style="max-width: 600px; margin: 0 auto; font-family: Arial, sans-serif;">
<div style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 20px; border-radius: 8px 8px 0 0;">
<h1 style="margin: 0; font-size: 24px;">${title}</h1>
<p style="margin: 10px 0 0 0; opacity: 0.9;">Claude Relay Service</p>
</div>
<div style="background: #f8f9fa; padding: 20px; border: 1px solid #e9ecef; border-top: none; border-radius: 0 0 8px 8px;">
<div style="background: white; padding: 16px; border-radius: 6px; box-shadow: 0 2px 4px rgba(0,0,0,0.1);">
`
// 使用统一的详情数据渲染
details.forEach((detail) => {
if (detail.isCode) {
content += `<p><strong>${detail.label}:</strong> <code style="background: #f1f3f4; padding: 2px 6px; border-radius: 4px;">${detail.value}</code></p>`
} else if (detail.color) {
content += `<p><strong>${detail.label}:</strong> <span style="color: ${detail.color};">${detail.value}</span></p>`
} else {
content += `<p><strong>${detail.label}:</strong> ${detail.value}</p>`
}
})
content += `
</div>
<div style="margin-top: 20px; padding-top: 16px; border-top: 1px solid #e9ecef; font-size: 14px; color: #6c757d; text-align: center;">
<p>发送时间: ${timestamp}</p>
<p style="margin: 0;">此邮件由 Claude Relay Service 自动发送</p>
</div>
</div>
</div>
`
return content
}
/**
* 格式化邮件纯文本内容
*/
formatMessageForEmailText(type, data) {
const title = this.getNotificationTitle(type)
const timestamp = new Date().toLocaleString('zh-CN', { timeZone: this.timezone })
const details = this.buildNotificationDetails(data)
let content = `${title}\n`
content += `=====================================\n\n`
// 使用统一的详情数据渲染
details.forEach((detail) => {
content += `${detail.label}: ${detail.value}\n`
})
content += `\n发送时间: ${timestamp}\n`
content += `服务: Claude Relay Service\n`
content += `=====================================\n`
content += `此邮件由系统自动发送,请勿回复。`
return content
}
/**
* 获取状态颜色
*/
getStatusColor(status) {
const colors = {
error: '#dc3545',
unauthorized: '#fd7e14',
blocked: '#6f42c1',
disabled: '#6c757d',
active: '#28a745',
warning: '#ffc107'
}
return colors[status] || '#007bff'
}
/**
* 格式化通知详情
*/
@@ -365,6 +635,14 @@ class WebhookService {
lines.push(`**平台**: ${data.platform}`)
}
if (data.platforms) {
lines.push(`**涉及平台**: ${data.platforms.join(', ')}`)
}
if (data.totalAccounts) {
lines.push(`**恢复账户数**: ${data.totalAccounts}`)
}
if (data.status) {
lines.push(`**状态**: ${data.status}`)
}
@@ -434,6 +712,7 @@ class WebhookService {
quotaWarning: 'yellow',
systemError: 'red',
securityAlert: 'red',
rateLimitRecovery: 'green',
test: 'blue'
}
@@ -449,6 +728,7 @@ class WebhookService {
quotaWarning: ':chart_with_downwards_trend:',
systemError: ':x:',
securityAlert: ':lock:',
rateLimitRecovery: ':tada:',
test: ':test_tube:'
}
@@ -464,6 +744,7 @@ class WebhookService {
quotaWarning: 0xffeb3b, // 黄色
systemError: 0xf44336, // 红色
securityAlert: 0xf44336, // 红色
rateLimitRecovery: 0x4caf50, // 绿色
test: 0x2196f3 // 蓝色
}
@@ -477,7 +758,7 @@ class WebhookService {
try {
const testData = {
message: 'Claude Relay Service webhook测试',
timestamp: new Date().toISOString()
timestamp: getISOStringWithTimezone(new Date())
}
await this.sendToPlatform(platform, 'test', testData, { maxRetries: 1, retryDelay: 1000 })

View File

@@ -32,6 +32,14 @@ const MODEL_PRICING = {
cacheRead: 1.5
},
// Claude Opus 4.1 (新模型)
'claude-opus-4-1-20250805': {
input: 15.0,
output: 75.0,
cacheWrite: 18.75,
cacheRead: 1.5
},
// Claude 3 Sonnet
'claude-3-sonnet-20240229': {
input: 3.0,
@@ -69,9 +77,57 @@ class CostCalculator {
* @returns {Object} 费用详情
*/
static calculateCost(usage, model = 'unknown') {
// 如果 usage 包含详细的 cache_creation 对象,使用 pricingService 来处理
if (usage.cache_creation && typeof usage.cache_creation === 'object') {
return pricingService.calculateCost(usage, model)
// 如果 usage 包含详细的 cache_creation 对象或是 1M 模型,使用 pricingService 来处理
if (
(usage.cache_creation && typeof usage.cache_creation === 'object') ||
(model && model.includes('[1m]'))
) {
const result = pricingService.calculateCost(usage, model)
// 转换 pricingService 返回的格式到 costCalculator 的格式
return {
model,
pricing: {
input: result.pricing.input * 1000000, // 转换为 per 1M tokens
output: result.pricing.output * 1000000,
cacheWrite: result.pricing.cacheCreate * 1000000,
cacheRead: result.pricing.cacheRead * 1000000
},
usingDynamicPricing: true,
isLongContextRequest: result.isLongContextRequest || false,
usage: {
inputTokens: usage.input_tokens || 0,
outputTokens: usage.output_tokens || 0,
cacheCreateTokens: usage.cache_creation_input_tokens || 0,
cacheReadTokens: usage.cache_read_input_tokens || 0,
totalTokens:
(usage.input_tokens || 0) +
(usage.output_tokens || 0) +
(usage.cache_creation_input_tokens || 0) +
(usage.cache_read_input_tokens || 0)
},
costs: {
input: result.inputCost,
output: result.outputCost,
cacheWrite: result.cacheCreateCost,
cacheRead: result.cacheReadCost,
total: result.totalCost
},
formatted: {
input: this.formatCost(result.inputCost),
output: this.formatCost(result.outputCost),
cacheWrite: this.formatCost(result.cacheCreateCost),
cacheRead: this.formatCost(result.cacheReadCost),
total: this.formatCost(result.totalCost)
},
debug: {
isOpenAIModel: model.includes('gpt') || model.includes('o1'),
hasCacheCreatePrice: !!result.pricing.cacheCreate,
cacheCreateTokens: usage.cache_creation_input_tokens || 0,
cacheWritePriceUsed: result.pricing.cacheCreate * 1000000,
isLongContextModel: model && model.includes('[1m]'),
isLongContextRequest: result.isLongContextRequest || false
}
}
}
// 否则使用旧的逻辑(向后兼容)

100
src/utils/dateHelper.js Normal file
View File

@@ -0,0 +1,100 @@
const config = require('../../config/config')
/**
* 格式化日期时间为指定时区的本地时间字符串
* @param {Date|number} date - Date对象或时间戳秒或毫秒
* @param {boolean} includeTimezone - 是否在输出中包含时区信息
* @returns {string} 格式化后的时间字符串
*/
function formatDateWithTimezone(date, includeTimezone = true) {
// 处理不同类型的输入
let dateObj
if (typeof date === 'number') {
// 判断是秒还是毫秒时间戳
// Unix时间戳通常小于 10^10毫秒时间戳通常大于 10^12
if (date < 10000000000) {
dateObj = new Date(date * 1000) // 秒转毫秒
} else {
dateObj = new Date(date) // 已经是毫秒
}
} else if (date instanceof Date) {
dateObj = date
} else {
dateObj = new Date(date)
}
// 获取配置的时区偏移(小时)
const timezoneOffset = config.system.timezoneOffset || 8 // 默认 UTC+8
// 计算本地时间
const offsetMs = timezoneOffset * 3600000 // 转换为毫秒
const localTime = new Date(dateObj.getTime() + offsetMs)
// 格式化为 YYYY-MM-DD HH:mm:ss
const year = localTime.getUTCFullYear()
const month = String(localTime.getUTCMonth() + 1).padStart(2, '0')
const day = String(localTime.getUTCDate()).padStart(2, '0')
const hours = String(localTime.getUTCHours()).padStart(2, '0')
const minutes = String(localTime.getUTCMinutes()).padStart(2, '0')
const seconds = String(localTime.getUTCSeconds()).padStart(2, '0')
let formattedDate = `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
// 添加时区信息
if (includeTimezone) {
const sign = timezoneOffset >= 0 ? '+' : ''
formattedDate += ` (UTC${sign}${timezoneOffset})`
}
return formattedDate
}
/**
* 获取指定时区的ISO格式时间字符串
* @param {Date|number} date - Date对象或时间戳
* @returns {string} ISO格式的时间字符串
*/
function getISOStringWithTimezone(date) {
// 先获取本地格式的时间(不含时区后缀)
const localTimeStr = formatDateWithTimezone(date, false)
// 获取时区偏移
const timezoneOffset = config.system.timezoneOffset || 8
// 构建ISO格式添加时区偏移
const sign = timezoneOffset >= 0 ? '+' : '-'
const absOffset = Math.abs(timezoneOffset)
const offsetHours = String(Math.floor(absOffset)).padStart(2, '0')
const offsetMinutes = String(Math.round((absOffset % 1) * 60)).padStart(2, '0')
// 将空格替换为T并添加时区
return `${localTimeStr.replace(' ', 'T')}${sign}${offsetHours}:${offsetMinutes}`
}
/**
* 计算时间差并格式化为人类可读的字符串
* @param {number} seconds - 秒数
* @returns {string} 格式化的时间差字符串
*/
function formatDuration(seconds) {
if (seconds < 60) {
return `${seconds}`
} else if (seconds < 3600) {
const minutes = Math.floor(seconds / 60)
return `${minutes}分钟`
} else if (seconds < 86400) {
const hours = Math.floor(seconds / 3600)
const minutes = Math.floor((seconds % 3600) / 60)
return minutes > 0 ? `${hours}小时${minutes}分钟` : `${hours}小时`
} else {
const days = Math.floor(seconds / 86400)
const hours = Math.floor((seconds % 86400) / 3600)
return hours > 0 ? `${days}${hours}小时` : `${days}`
}
}
module.exports = {
formatDateWithTimezone,
getISOStringWithTimezone,
formatDuration
}

291
src/utils/inputValidator.js Normal file
View File

@@ -0,0 +1,291 @@
/**
* 输入验证工具类
* 提供各种输入验证和清理功能,防止注入攻击
*/
class InputValidator {
/**
* 验证用户名
* @param {string} username - 用户名
* @returns {string} 验证后的用户名
* @throws {Error} 如果用户名无效
*/
validateUsername(username) {
if (!username || typeof username !== 'string') {
throw new Error('用户名必须是非空字符串')
}
const trimmed = username.trim()
// 长度检查
if (trimmed.length < 3 || trimmed.length > 64) {
throw new Error('用户名长度必须在3-64个字符之间')
}
// 格式检查:只允许字母、数字、下划线、连字符
const usernameRegex = /^[a-zA-Z0-9_-]+$/
if (!usernameRegex.test(trimmed)) {
throw new Error('用户名只能包含字母、数字、下划线和连字符')
}
// 不能以连字符开头或结尾
if (trimmed.startsWith('-') || trimmed.endsWith('-')) {
throw new Error('用户名不能以连字符开头或结尾')
}
return trimmed
}
/**
* 验证电子邮件
* @param {string} email - 电子邮件地址
* @returns {string} 验证后的电子邮件
* @throws {Error} 如果电子邮件无效
*/
validateEmail(email) {
if (!email || typeof email !== 'string') {
throw new Error('电子邮件必须是非空字符串')
}
const trimmed = email.trim().toLowerCase()
// 基本格式验证
const emailRegex =
/^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/
if (!emailRegex.test(trimmed)) {
throw new Error('电子邮件格式无效')
}
// 长度限制
if (trimmed.length > 254) {
throw new Error('电子邮件地址过长')
}
return trimmed
}
/**
* 验证密码强度
* @param {string} password - 密码
* @returns {boolean} 验证结果
*/
validatePassword(password) {
if (!password || typeof password !== 'string') {
throw new Error('密码必须是非空字符串')
}
// 最小长度
if (password.length < 8) {
throw new Error('密码至少需要8个字符')
}
// 最大长度防止DoS攻击
if (password.length > 128) {
throw new Error('密码不能超过128个字符')
}
return true
}
/**
* 验证角色
* @param {string} role - 用户角色
* @returns {string} 验证后的角色
* @throws {Error} 如果角色无效
*/
validateRole(role) {
const validRoles = ['admin', 'user', 'viewer']
if (!role || typeof role !== 'string') {
throw new Error('角色必须是非空字符串')
}
const trimmed = role.trim().toLowerCase()
if (!validRoles.includes(trimmed)) {
throw new Error(`角色必须是以下之一: ${validRoles.join(', ')}`)
}
return trimmed
}
/**
* 验证Webhook URL
* @param {string} url - Webhook URL
* @returns {string} 验证后的URL
* @throws {Error} 如果URL无效
*/
validateWebhookUrl(url) {
if (!url || typeof url !== 'string') {
throw new Error('Webhook URL必须是非空字符串')
}
const trimmed = url.trim()
// URL格式验证
try {
const urlObj = new URL(trimmed)
// 只允许HTTP和HTTPS协议
if (!['http:', 'https:'].includes(urlObj.protocol)) {
throw new Error('Webhook URL必须使用HTTP或HTTPS协议')
}
// 防止SSRF攻击禁止访问内网地址
const hostname = urlObj.hostname.toLowerCase()
const dangerousHosts = [
'localhost',
'127.0.0.1',
'0.0.0.0',
'::1',
'169.254.169.254', // AWS元数据服务
'metadata.google.internal' // GCP元数据服务
]
if (dangerousHosts.includes(hostname)) {
throw new Error('Webhook URL不能指向内部服务')
}
// 检查是否是内网IP
const ipRegex = /^(\d{1,3}\.){3}\d{1,3}$/
if (ipRegex.test(hostname)) {
const parts = hostname.split('.').map(Number)
// 检查私有IP范围
if (
parts[0] === 10 || // 10.0.0.0/8
(parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) || // 172.16.0.0/12
(parts[0] === 192 && parts[1] === 168) // 192.168.0.0/16
) {
throw new Error('Webhook URL不能指向私有IP地址')
}
}
return trimmed
} catch (error) {
if (error.message.includes('Webhook URL')) {
throw error
}
throw new Error('Webhook URL格式无效')
}
}
/**
* 验证显示名称
* @param {string} displayName - 显示名称
* @returns {string} 验证后的显示名称
* @throws {Error} 如果显示名称无效
*/
validateDisplayName(displayName) {
if (!displayName || typeof displayName !== 'string') {
throw new Error('显示名称必须是非空字符串')
}
const trimmed = displayName.trim()
// 长度检查
if (trimmed.length < 1 || trimmed.length > 100) {
throw new Error('显示名称长度必须在1-100个字符之间')
}
// 禁止特殊控制字符(排除常见的换行和制表符)
// eslint-disable-next-line no-control-regex
const controlCharRegex = /[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/
if (controlCharRegex.test(trimmed)) {
throw new Error('显示名称不能包含控制字符')
}
return trimmed
}
/**
* 清理HTML标签防止XSS
* @param {string} input - 输入字符串
* @returns {string} 清理后的字符串
*/
sanitizeHtml(input) {
if (!input || typeof input !== 'string') {
return ''
}
return input
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#x27;')
.replace(/\//g, '&#x2F;')
}
/**
* 验证API Key名称
* @param {string} name - API Key名称
* @returns {string} 验证后的名称
* @throws {Error} 如果名称无效
*/
validateApiKeyName(name) {
if (!name || typeof name !== 'string') {
throw new Error('API Key名称必须是非空字符串')
}
const trimmed = name.trim()
// 长度检查
if (trimmed.length < 1 || trimmed.length > 100) {
throw new Error('API Key名称长度必须在1-100个字符之间')
}
// 禁止特殊控制字符(排除常见的换行和制表符)
// eslint-disable-next-line no-control-regex
const controlCharRegex = /[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/
if (controlCharRegex.test(trimmed)) {
throw new Error('API Key名称不能包含控制字符')
}
return trimmed
}
/**
* 验证分页参数
* @param {number} page - 页码
* @param {number} limit - 每页数量
* @returns {{page: number, limit: number}} 验证后的分页参数
*/
validatePagination(page, limit) {
const pageNum = parseInt(page, 10) || 1
const limitNum = parseInt(limit, 10) || 20
if (pageNum < 1) {
throw new Error('页码必须大于0')
}
if (limitNum < 1 || limitNum > 100) {
throw new Error('每页数量必须在1-100之间')
}
return {
page: pageNum,
limit: limitNum
}
}
/**
* 验证UUID格式
* @param {string} uuid - UUID字符串
* @returns {string} 验证后的UUID
* @throws {Error} 如果UUID无效
*/
validateUuid(uuid) {
if (!uuid || typeof uuid !== 'string') {
throw new Error('UUID必须是非空字符串')
}
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
if (!uuidRegex.test(uuid)) {
throw new Error('UUID格式无效')
}
return uuid.toLowerCase()
}
}
module.exports = new InputValidator()

View File

@@ -1,6 +1,7 @@
const winston = require('winston')
const DailyRotateFile = require('winston-daily-rotate-file')
const config = require('../../config/config')
const { formatDateWithTimezone } = require('../utils/dateHelper')
const path = require('path')
const fs = require('fs')
const os = require('os')
@@ -95,7 +96,7 @@ const safeStringify = (obj, maxDepth = 3, fullDepth = false) => {
// 📝 增强的日志格式
const createLogFormat = (colorize = false) => {
const formats = [
winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
winston.format.timestamp({ format: () => formatDateWithTimezone(new Date(), false) }),
winston.format.errors({ stack: true })
// 移除 winston.format.metadata() 来避免自动包装
]
@@ -189,7 +190,7 @@ const securityLogger = winston.createLogger({
const authDetailLogger = winston.createLogger({
level: 'info',
format: winston.format.combine(
winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
winston.format.timestamp({ format: () => formatDateWithTimezone(new Date(), false) }),
winston.format.printf(({ level, message, timestamp, data }) => {
// 使用更深的深度和格式化的JSON输出
const jsonData = data ? JSON.stringify(data, null, 2) : '{}'

78
src/utils/modelHelper.js Normal file
View File

@@ -0,0 +1,78 @@
/**
* Model Helper Utility
*
* Provides utilities for parsing vendor-prefixed model names.
* Supports parsing model strings like "ccr,model_name" to extract vendor type and base model.
*/
/**
* Parse vendor-prefixed model string
* @param {string} modelStr - Model string, potentially with vendor prefix (e.g., "ccr,gemini-2.5-pro")
* @returns {{vendor: string|null, baseModel: string}} - Parsed vendor and base model
*/
function parseVendorPrefixedModel(modelStr) {
if (!modelStr || typeof modelStr !== 'string') {
return { vendor: null, baseModel: modelStr || '' }
}
// Trim whitespace and convert to lowercase for comparison
const trimmed = modelStr.trim()
const lowerTrimmed = trimmed.toLowerCase()
// Check for ccr prefix (case insensitive)
if (lowerTrimmed.startsWith('ccr,')) {
const parts = trimmed.split(',')
if (parts.length >= 2) {
// Extract base model (everything after the first comma, rejoined in case model name contains commas)
const baseModel = parts.slice(1).join(',').trim()
return {
vendor: 'ccr',
baseModel
}
}
}
// No recognized vendor prefix found
return {
vendor: null,
baseModel: trimmed
}
}
/**
* Check if a model string has a vendor prefix
* @param {string} modelStr - Model string to check
* @returns {boolean} - True if the model has a vendor prefix
*/
function hasVendorPrefix(modelStr) {
const { vendor } = parseVendorPrefixedModel(modelStr)
return vendor !== null
}
/**
* Get the effective model name for scheduling and processing
* This removes vendor prefixes to get the actual model name used for API calls
* @param {string} modelStr - Original model string
* @returns {string} - Effective model name without vendor prefix
*/
function getEffectiveModel(modelStr) {
const { baseModel } = parseVendorPrefixedModel(modelStr)
return baseModel
}
/**
* Get the vendor type from a model string
* @param {string} modelStr - Model string to parse
* @returns {string|null} - Vendor type ('ccr') or null if no prefix
*/
function getVendorType(modelStr) {
const { vendor } = parseVendorPrefixedModel(modelStr)
return vendor
}
module.exports = {
parseVendorPrefixedModel,
hasVendorPrefix,
getEffectiveModel,
getVendorType
}

View File

@@ -4,7 +4,7 @@ const logger = require('./logger')
class SessionHelper {
/**
* 生成会话哈希用于sticky会话保持
* 基于Anthropic的prompt caching机制优先使用cacheable内容
* 基于Anthropic的prompt caching机制优先使用metadata中的session ID
* @param {Object} requestBody - 请求体
* @returns {string|null} - 32字符的会话哈希如果无法生成则返回null
*/
@@ -13,11 +13,24 @@ class SessionHelper {
return null
}
// 1. 最高优先级使用metadata中的session ID直接使用无需hash
if (requestBody.metadata && requestBody.metadata.user_id) {
// 提取 session_xxx 部分
const userIdString = requestBody.metadata.user_id
const sessionMatch = userIdString.match(/session_([a-f0-9-]{36})/)
if (sessionMatch && sessionMatch[1]) {
const sessionId = sessionMatch[1]
// 直接返回session ID
logger.debug(`📋 Session ID extracted from metadata.user_id: ${sessionId}`)
return sessionId
}
}
let cacheableContent = ''
const system = requestBody.system || ''
const messages = requestBody.messages || []
// 1. 优先提取带有cache_control: {"type": "ephemeral"}的内容
// 2. 提取带有cache_control: {"type": "ephemeral"}的内容
// 检查system中的cacheable内容
if (Array.isArray(system)) {
for (const part of system) {
@@ -30,13 +43,13 @@ class SessionHelper {
// 检查messages中的cacheable内容
for (const msg of messages) {
const content = msg.content || ''
let hasCacheControl = false
if (Array.isArray(content)) {
for (const part of content) {
if (part && part.cache_control && part.cache_control.type === 'ephemeral') {
if (part.type === 'text') {
cacheableContent += part.text || ''
}
// 其他类型如image不参与hash计算
hasCacheControl = true
break
}
}
} else if (
@@ -44,12 +57,31 @@ class SessionHelper {
msg.cache_control &&
msg.cache_control.type === 'ephemeral'
) {
// 罕见情况,但需要检查
cacheableContent += content
hasCacheControl = true
}
if (hasCacheControl) {
for (const message of messages) {
let messageText = ''
if (typeof message.content === 'string') {
messageText = message.content
} else if (Array.isArray(message.content)) {
messageText = message.content
.filter((part) => part.type === 'text')
.map((part) => part.text || '')
.join('')
}
if (messageText) {
cacheableContent += messageText
break
}
}
break
}
}
// 2. 如果有cacheable内容直接使用
// 3. 如果有cacheable内容直接使用
if (cacheableContent) {
const hash = crypto
.createHash('sha256')
@@ -60,7 +92,7 @@ class SessionHelper {
return hash
}
// 3. Fallback: 使用system内容
// 4. Fallback: 使用system内容
if (system) {
let systemText = ''
if (typeof system === 'string') {
@@ -76,7 +108,7 @@ class SessionHelper {
}
}
// 4. 最后fallback: 使用第一条消息内容
// 5. 最后fallback: 使用第一条消息内容
if (messages.length > 0) {
const firstMessage = messages[0]
let firstMessageText = ''

View File

@@ -1,5 +1,6 @@
const logger = require('./logger')
const webhookService = require('../services/webhookService')
const { getISOStringWithTimezone } = require('./dateHelper')
class WebhookNotifier {
constructor() {
@@ -28,7 +29,7 @@ class WebhookNotifier {
errorCode:
notification.errorCode || this._getErrorCode(notification.platform, notification.status),
reason: notification.reason,
timestamp: notification.timestamp || new Date().toISOString()
timestamp: notification.timestamp || getISOStringWithTimezone(new Date())
})
} catch (error) {
logger.error('Failed to send account anomaly notification:', error)
@@ -57,6 +58,24 @@ class WebhookNotifier {
}
}
/**
* 发送账号事件通知
* @param {string} eventType - 事件类型 (account.created, account.updated, account.deleted, account.status_changed)
* @param {Object} data - 事件数据
*/
async sendAccountEvent(eventType, data) {
try {
// 使用webhookService发送通知
await webhookService.sendNotification('accountEvent', {
eventType,
...data,
timestamp: data.timestamp || getISOStringWithTimezone(new Date())
})
} catch (error) {
logger.error(`Failed to send account event (${eventType}):`, error)
}
}
/**
* 获取错误代码映射
* @param {string} platform - 平台类型
@@ -67,6 +86,7 @@ class WebhookNotifier {
const errorCodes = {
'claude-oauth': {
unauthorized: 'CLAUDE_OAUTH_UNAUTHORIZED',
blocked: 'CLAUDE_OAUTH_BLOCKED',
error: 'CLAUDE_OAUTH_ERROR',
disabled: 'CLAUDE_OAUTH_MANUALLY_DISABLED'
},
@@ -79,6 +99,12 @@ class WebhookNotifier {
error: 'GEMINI_ERROR',
unauthorized: 'GEMINI_UNAUTHORIZED',
disabled: 'GEMINI_MANUALLY_DISABLED'
},
openai: {
error: 'OPENAI_ERROR',
unauthorized: 'OPENAI_UNAUTHORIZED',
blocked: 'OPENAI_RATE_LIMITED',
disabled: 'OPENAI_MANUALLY_DISABLED'
}
}

View File

@@ -23,6 +23,14 @@ VITE_APP_TITLE=Claude Relay Service - 管理后台
# 格式http://proxy-host:port
#VITE_HTTP_PROXY=http://127.0.0.1:7890
# ========== 教程页面配置 ==========
# API 基础前缀(可选)
# 用于教程页面显示的自定义 API 前缀
# 如果不配置,则使用当前浏览器访问地址
# 示例https://api.example.com 或 https://relay.mysite.com
# VITE_API_BASE_PREFIX=https://api.example.com
# ========== 使用说明 ==========
# 1. 复制此文件为 .env.local 进行本地配置
# 2. .env.local 文件不会被提交到版本控制

View File

@@ -15,7 +15,9 @@
"element-plus": "^2.4.4",
"pinia": "^2.1.7",
"vue": "^3.3.4",
"vue-router": "^4.2.5"
"vue-router": "^4.2.5",
"xlsx": "^0.18.5",
"xlsx-js-style": "^1.2.0"
},
"devDependencies": {
"@playwright/test": "^1.55.0",
@@ -1366,6 +1368,15 @@
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
}
},
"node_modules/adler-32": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz",
"integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/ajv": {
"version": "6.12.6",
"resolved": "https://registry.npmmirror.com/ajv/-/ajv-6.12.6.tgz",
@@ -1643,6 +1654,19 @@
],
"license": "CC-BY-4.0"
},
"node_modules/cfb": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz",
"integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==",
"license": "Apache-2.0",
"dependencies": {
"adler-32": "~1.3.0",
"crc-32": "~1.2.0"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmmirror.com/chalk/-/chalk-4.1.2.tgz",
@@ -1710,6 +1734,15 @@
"node": ">= 6"
}
},
"node_modules/codepage": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz",
"integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmmirror.com/color-convert/-/color-convert-2.0.1.tgz",
@@ -1766,6 +1799,18 @@
"dev": true,
"license": "MIT"
},
"node_modules/crc-32": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
"integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
"license": "Apache-2.0",
"bin": {
"crc32": "bin/crc32.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmmirror.com/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -2304,6 +2349,15 @@
"node": ">=0.10.0"
}
},
"node_modules/exit-on-epipe": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/exit-on-epipe/-/exit-on-epipe-1.0.1.tgz",
"integrity": "sha512-h2z5mrROTxce56S+pnvAV890uu7ls7f1kEvVGJbw1OlFH3/mlJ5bkXu0KRyW94v37zzHPiUd55iLn3DA7TjWpw==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/exsolve": {
"version": "1.0.7",
"resolved": "https://registry.npmmirror.com/exsolve/-/exsolve-1.0.7.tgz",
@@ -2379,6 +2433,12 @@
"reusify": "^1.0.4"
}
},
"node_modules/fflate": {
"version": "0.3.11",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.3.11.tgz",
"integrity": "sha512-Rr5QlUeGN1mbOHlaqcSYMKVpPbgLy0AWT/W0EHxA6NGI12yO1jpoui2zBBvU2G824ltM6Ut8BFgfHSBGfkmS0A==",
"license": "MIT"
},
"node_modules/file-entry-cache": {
"version": "6.0.1",
"resolved": "https://registry.npmmirror.com/file-entry-cache/-/file-entry-cache-6.0.1.tgz",
@@ -2497,6 +2557,15 @@
"node": ">= 6"
}
},
"node_modules/frac": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz",
"integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/fraction.js": {
"version": "4.3.7",
"resolved": "https://registry.npmmirror.com/fraction.js/-/fraction.js-4.3.7.tgz",
@@ -3804,6 +3873,18 @@
}
}
},
"node_modules/printj": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/printj/-/printj-1.1.2.tgz",
"integrity": "sha512-zA2SmoLaxZyArQTOPj5LXecR+RagfPSU5Kw1qP+jkWeNlrq+eJZyY2oS68SU1Z/7/myXM4lo9716laOFAVStCQ==",
"license": "Apache-2.0",
"bin": {
"printj": "bin/printj.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmmirror.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
@@ -4083,6 +4164,18 @@
"node": ">=0.10.0"
}
},
"node_modules/ssf": {
"version": "0.11.2",
"resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz",
"integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==",
"license": "Apache-2.0",
"dependencies": {
"frac": "~1.1.2"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/string-width": {
"version": "5.1.2",
"resolved": "https://registry.npmmirror.com/string-width/-/string-width-5.1.2.tgz",
@@ -5126,6 +5219,24 @@
"node": ">= 8"
}
},
"node_modules/wmf": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz",
"integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/word": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz",
"integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/word-wrap": {
"version": "1.2.5",
"resolved": "https://registry.npmmirror.com/word-wrap/-/word-wrap-1.2.5.tgz",
@@ -5244,6 +5355,95 @@
"dev": true,
"license": "ISC"
},
"node_modules/xlsx": {
"version": "0.18.5",
"resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz",
"integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==",
"license": "Apache-2.0",
"dependencies": {
"adler-32": "~1.3.0",
"cfb": "~1.2.1",
"codepage": "~1.15.0",
"crc-32": "~1.2.1",
"ssf": "~0.11.2",
"wmf": "~1.0.1",
"word": "~0.3.0"
},
"bin": {
"xlsx": "bin/xlsx.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/xlsx-js-style": {
"version": "1.2.0",
"resolved": "https://registry.npmmirror.com/xlsx-js-style/-/xlsx-js-style-1.2.0.tgz",
"integrity": "sha512-DDT4FXFSWfT4DXMSok/m3TvmP1gvO3dn0Eu/c+eXHW5Kzmp7IczNkxg/iEPnImbG9X0Vb8QhROda5eatSR/97Q==",
"license": "Apache-2.0",
"dependencies": {
"adler-32": "~1.2.0",
"cfb": "^1.1.4",
"codepage": "~1.14.0",
"commander": "~2.17.1",
"crc-32": "~1.2.0",
"exit-on-epipe": "~1.0.1",
"fflate": "^0.3.8",
"ssf": "~0.11.2",
"wmf": "~1.0.1",
"word": "~0.3.0"
},
"bin": {
"xlsx": "bin/xlsx.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/xlsx-js-style/node_modules/adler-32": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.2.0.tgz",
"integrity": "sha512-/vUqU/UY4MVeFsg+SsK6c+/05RZXIHZMGJA+PX5JyWI0ZRcBpupnRuPLU/NXXoFwMYCPCoxIfElM2eS+DUXCqQ==",
"license": "Apache-2.0",
"dependencies": {
"exit-on-epipe": "~1.0.1",
"printj": "~1.1.0"
},
"bin": {
"adler32": "bin/adler32.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/xlsx-js-style/node_modules/codepage": {
"version": "1.14.0",
"resolved": "https://registry.npmjs.org/codepage/-/codepage-1.14.0.tgz",
"integrity": "sha512-iz3zJLhlrg37/gYRWgEPkaFTtzmnEv1h+r7NgZum2lFElYQPi0/5bnmuDfODHxfp0INEfnRqyfyeIJDbb7ahRw==",
"license": "Apache-2.0",
"dependencies": {
"commander": "~2.14.1",
"exit-on-epipe": "~1.0.1"
},
"bin": {
"codepage": "bin/codepage.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/xlsx-js-style/node_modules/codepage/node_modules/commander": {
"version": "2.14.1",
"resolved": "https://registry.npmjs.org/commander/-/commander-2.14.1.tgz",
"integrity": "sha512-+YR16o3rK53SmWHU3rEM3tPAh2rwb1yPcQX5irVn7mb0gXbwuCCrnkbV5+PBfETdfg1vui07nM6PCG1zndcjQw==",
"license": "MIT"
},
"node_modules/xlsx-js-style/node_modules/commander": {
"version": "2.17.1",
"resolved": "https://registry.npmjs.org/commander/-/commander-2.17.1.tgz",
"integrity": "sha512-wPMUt6FnH2yzG95SA6mzjQOEKUU3aLaDEmzs1ti+1E9h+CsrZghRlqEM/EJ4KscsQVG8uNN4uVreUeT8+drlgg==",
"license": "MIT"
},
"node_modules/xml-name-validator": {
"version": "4.0.0",
"resolved": "https://registry.npmmirror.com/xml-name-validator/-/xml-name-validator-4.0.0.tgz",

View File

@@ -18,7 +18,9 @@
"element-plus": "^2.4.4",
"pinia": "^2.1.7",
"vue": "^3.3.4",
"vue-router": "^4.2.5"
"vue-router": "^4.2.5",
"xlsx": "^0.18.5",
"xlsx-js-style": "^1.2.0"
},
"devDependencies": {
"@playwright/test": "^1.55.0",

View File

@@ -1,22 +1,24 @@
/* Glass效果 */
/* Glass效果 - 优化版 */
.glass {
background: var(--glass-color);
backdrop-filter: blur(20px);
/* 降低模糊强度 */
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
border: 1px solid var(--border-color);
box-shadow:
0 20px 25px -5px rgba(0, 0, 0, 0.1),
0 10px 10px -5px rgba(0, 0, 0, 0.04),
inset 0 1px 0 rgba(255, 255, 255, 0.1);
0 4px 6px -1px rgba(0, 0, 0, 0.1),
0 2px 4px -1px rgba(0, 0, 0, 0.06);
}
.glass-strong {
background: var(--surface-color);
backdrop-filter: blur(25px);
border: 1px solid rgba(255, 255, 255, 0.3);
/* 降低模糊强度 */
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.2);
box-shadow:
0 25px 50px -12px rgba(0, 0, 0, 0.25),
0 0 0 1px rgba(255, 255, 255, 0.05),
inset 0 1px 0 rgba(255, 255, 255, 0.1);
0 10px 15px -3px rgba(0, 0, 0, 0.1),
0 4px 6px -2px rgba(0, 0, 0, 0.05);
}
/* 标签按钮 */
@@ -216,13 +218,13 @@
/* 表单输入 */
.form-input {
background: rgba(255, 255, 255, 0.9);
background: rgba(255, 255, 255, 0.95);
border: 2px solid rgba(255, 255, 255, 0.3);
border-radius: 12px;
padding: 8px 12px;
font-size: 14px;
transition: all 0.3s ease;
backdrop-filter: blur(10px);
transition: all 0.2s ease;
/* 移除模糊效果,使用纯色背景 */
}
.form-input:focus {
@@ -255,18 +257,18 @@
/* 模态框 */
.modal {
backdrop-filter: blur(8px);
background: rgba(0, 0, 0, 0.4);
/* 移除模糊,使用半透明背景 */
background: rgba(0, 0, 0, 0.6);
}
.modal-content {
background: rgba(255, 255, 255, 0.95);
background: rgba(255, 255, 255, 0.98);
border-radius: 24px;
border: 1px solid rgba(255, 255, 255, 0.3);
box-shadow:
0 25px 50px -12px rgba(0, 0, 0, 0.25),
0 10px 25px -5px rgba(0, 0, 0, 0.15),
0 0 0 1px rgba(255, 255, 255, 0.05);
backdrop-filter: blur(20px);
/* 移除模糊效果 */
}
/* 弹窗滚动内容样式 */

View File

@@ -17,7 +17,7 @@
--bg-gradient-mid: #764ba2;
--bg-gradient-end: #f093fb;
--input-bg: rgba(255, 255, 255, 0.9);
--input-border: rgba(255, 255, 255, 0.3);
--input-border: rgba(209, 213, 219, 0.8);
--modal-bg: rgba(0, 0, 0, 0.4);
--table-bg: rgba(255, 255, 255, 0.95);
--table-hover: rgba(102, 126, 234, 0.05);
@@ -108,13 +108,13 @@ body::before {
.glass {
background: var(--glass-color);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
/* 降低模糊强度 */
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
border: 1px solid var(--border-color);
box-shadow:
0 20px 25px -5px rgba(0, 0, 0, 0.1),
0 10px 10px -5px rgba(0, 0, 0, 0.04),
inset 0 1px 0 rgba(255, 255, 255, 0.1);
0 4px 6px -1px rgba(0, 0, 0, 0.1),
0 2px 4px -1px rgba(0, 0, 0, 0.06);
transition:
background-color 0.3s ease,
border-color 0.3s ease;
@@ -129,13 +129,12 @@ body::before {
.glass-strong {
background: var(--glass-strong-color);
backdrop-filter: blur(25px);
-webkit-backdrop-filter: blur(25px);
/* 降低模糊强度 */
/* 移除模糊效果 */
border: 1px solid var(--border-color);
box-shadow:
0 25px 50px -12px rgba(0, 0, 0, 0.25),
0 0 0 1px rgba(255, 255, 255, 0.05),
inset 0 1px 0 rgba(255, 255, 255, 0.1);
0 10px 15px -3px rgba(0, 0, 0, 0.1),
0 4px 6px -2px rgba(0, 0, 0, 0.05);
transition:
background-color 0.3s ease,
border-color 0.3s ease;
@@ -269,8 +268,7 @@ body::before {
background-color 0.3s ease,
border-color 0.3s ease,
box-shadow 0.3s ease;
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
/* 移除模糊效果 */
}
.form-input:focus {
@@ -461,8 +459,7 @@ body::before {
background-color 0.3s ease,
border-color 0.3s ease,
box-shadow 0.3s ease;
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
/* 移除模糊效果 */
}
.form-input:focus {
@@ -511,8 +508,7 @@ body::before {
}
.modal {
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
/* 移除模糊效果 */
background: var(--modal-bg);
transition: background-color 0.3s ease;
}
@@ -522,10 +518,9 @@ body::before {
border-radius: 24px;
border: 1px solid rgba(229, 231, 235, 0.8);
box-shadow:
0 25px 50px -12px rgba(0, 0, 0, 0.25),
0 10px 25px -5px rgba(0, 0, 0, 0.15),
0 0 0 1px rgba(255, 255, 255, 0.05);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
/* 移除模糊效果 */
transition:
background-color 0.3s ease,
border-color 0.3s ease;
@@ -730,7 +725,12 @@ body::before {
}
.animate-pulse {
animation: pulse 2s infinite;
/* 移除无限脉冲动画,改为 hover 效果 */
transition: transform 0.2s ease;
}
.animate-pulse:hover {
animation: pulse 0.3s ease;
}
/* 用户菜单下拉框优化 */

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,451 @@
<template>
<Teleport to="body">
<div v-if="show" class="modal fixed inset-0 z-50 flex items-center justify-center p-3 sm:p-4">
<div
class="modal-content custom-scrollbar mx-auto max-h-[90vh] w-full max-w-2xl overflow-y-auto p-4 sm:p-6 md:p-8"
>
<div class="mb-4 flex items-center justify-between sm:mb-6">
<div class="flex items-center gap-2 sm:gap-3">
<div
class="flex h-8 w-8 items-center justify-center rounded-lg bg-gradient-to-br from-teal-500 to-emerald-600 sm:h-10 sm:w-10 sm:rounded-xl"
>
<i class="fas fa-code-branch text-sm text-white sm:text-base" />
</div>
<h3 class="text-lg font-bold text-gray-900 dark:text-gray-100 sm:text-xl">
{{ isEdit ? '编辑 CCR 账户' : '添加 CCR 账户' }}
</h3>
</div>
<button
class="p-1 text-gray-400 transition-colors hover:text-gray-600"
@click="$emit('close')"
>
<i class="fas fa-times text-lg sm:text-xl" />
</button>
</div>
<div class="space-y-6">
<!-- 基本信息 -->
<div>
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
>账户名称 *</label
>
<input
v-model="form.name"
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200"
:class="{ 'border-red-500': errors.name }"
placeholder="为账户设置一个易识别的名称"
required
type="text"
/>
<p v-if="errors.name" class="mt-1 text-xs text-red-500">{{ errors.name }}</p>
</div>
<div>
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
>描述 (可选)</label
>
<textarea
v-model="form.description"
class="form-input w-full resize-none border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200"
placeholder="账户用途说明..."
rows="3"
/>
</div>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
>API URL *</label
>
<input
v-model="form.apiUrl"
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200"
:class="{ 'border-red-500': errors.apiUrl }"
placeholder="例如https://api.example.com/v1/messages"
required
type="text"
/>
<p v-if="errors.apiUrl" class="mt-1 text-xs text-red-500">{{ errors.apiUrl }}</p>
</div>
<div>
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
>API Key {{ isEdit ? '(留空不更新)' : '*' }}</label
>
<input
v-model="form.apiKey"
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200"
:class="{ 'border-red-500': errors.apiKey }"
:placeholder="isEdit ? '留空表示不更新' : '必填'"
:required="!isEdit"
type="password"
/>
<p v-if="errors.apiKey" class="mt-1 text-xs text-red-500">{{ errors.apiKey }}</p>
</div>
</div>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
>优先级</label
>
<input
v-model.number="form.priority"
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200"
max="100"
min="1"
placeholder="默认50数字越小优先级越高"
type="number"
/>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
建议范围1-100数字越小优先级越高
</p>
</div>
<div>
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
>自定义 User-Agent (可选)</label
>
<input
v-model="form.userAgent"
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200"
placeholder="留空则透传客户端 User-Agent"
type="text"
/>
</div>
</div>
<!-- 限流设置 -->
<div>
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
>限流机制</label
>
<div class="mb-3">
<label class="inline-flex cursor-pointer items-center">
<input
v-model="enableRateLimit"
class="mr-2 rounded border-gray-300 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
type="checkbox"
/>
<span class="text-sm text-gray-700 dark:text-gray-300"
>启用限流机制429 时暂停调度</span
>
</label>
</div>
<div v-if="enableRateLimit">
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
>限流时间 (分钟)</label
>
<input
v-model.number="form.rateLimitDuration"
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200"
min="1"
placeholder="默认60分钟"
type="number"
/>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
账号被限流后暂停调度的时间分钟
</p>
</div>
</div>
<!-- 额度管理 -->
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
>每日额度限制 ($)</label
>
<input
v-model.number="form.dailyQuota"
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200"
min="0"
placeholder="0 表示不限制"
step="0.01"
type="number"
/>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
设置每日使用额度0 表示不限制
</p>
</div>
<div>
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
>额度重置时间</label
>
<input
v-model="form.quotaResetTime"
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200"
placeholder="00:00"
type="time"
/>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">每日自动重置额度的时间</p>
</div>
</div>
<!-- 模型映射表可选 -->
<div>
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
>模型映射表 (可选)</label
>
<div class="mb-3 rounded-lg bg-blue-50 p-3 dark:bg-blue-900/30">
<p class="text-xs text-blue-700 dark:text-blue-400">
<i class="fas fa-info-circle mr-1" />
留空表示支持所有模型且不修改请求配置映射后左侧模型会被识别为支持的模型右侧是实际发送的模型
</p>
</div>
<div class="mb-3 space-y-2">
<div
v-for="(mapping, index) in modelMappings"
:key="index"
class="flex items-center gap-2"
>
<input
v-model="mapping.from"
class="form-input flex-1 border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
placeholder="原始模型名称"
type="text"
/>
<i class="fas fa-arrow-right text-gray-400 dark:text-gray-500" />
<input
v-model="mapping.to"
class="form-input flex-1 border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
placeholder="映射后的模型名称"
type="text"
/>
<button
class="rounded-lg p-2 text-red-500 transition-colors hover:bg-red-50 dark:hover:bg-red-900/20"
type="button"
@click="removeModelMapping(index)"
>
<i class="fas fa-trash" />
</button>
</div>
</div>
<button
class="w-full rounded-lg border-2 border-dashed border-gray-300 px-4 py-2 text-gray-600 transition-colors hover:border-gray-400 hover:text-gray-700 dark:border-gray-600 dark:text-gray-400 dark:hover:border-gray-500 dark:hover:text-gray-300"
type="button"
@click="addModelMapping"
>
<i class="fas fa-plus mr-2" /> 添加模型映射
</button>
</div>
<!-- 代理配置 -->
<div>
<ProxyConfig v-model="form.proxy" />
</div>
<!-- 操作区 -->
<div class="mt-2 flex gap-3">
<button
class="flex-1 rounded-xl bg-gray-100 px-6 py-3 font-semibold text-gray-700 transition-colors hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
type="button"
@click="$emit('close')"
>
取消
</button>
<button
class="btn btn-primary flex-1 px-6 py-3 font-semibold"
:disabled="loading"
type="button"
@click="submit"
>
<div v-if="loading" class="loading-spinner mr-2" />
{{ loading ? (isEdit ? '保存中...' : '创建中...') : isEdit ? '保存' : '创建' }}
</button>
</div>
</div>
</div>
</div>
</Teleport>
</template>
<script setup>
import { ref, computed, watch, onMounted } from 'vue'
import { apiClient } from '@/config/api'
import { showToast } from '@/utils/toast'
import ProxyConfig from '@/components/accounts/ProxyConfig.vue'
const props = defineProps({
account: {
type: Object,
default: null
}
})
const emit = defineEmits(['close', 'success'])
const show = ref(true)
const isEdit = computed(() => !!props.account)
const loading = ref(false)
const form = ref({
name: '',
description: '',
apiUrl: '',
apiKey: '',
priority: 50,
userAgent: '',
rateLimitDuration: 60,
dailyQuota: 0,
quotaResetTime: '00:00',
proxy: null,
supportedModels: {}
})
const enableRateLimit = ref(true)
const errors = ref({})
const modelMappings = ref([]) // [{from,to}]
const buildSupportedModels = () => {
const map = {}
for (const m of modelMappings.value) {
const from = (m.from || '').trim()
const to = (m.to || '').trim()
if (from && to) map[from] = to
}
return map
}
const addModelMapping = () => {
modelMappings.value.push({ from: '', to: '' })
}
const removeModelMapping = (index) => {
modelMappings.value.splice(index, 1)
}
const validate = () => {
const e = {}
if (!form.value.name || form.value.name.trim().length === 0) e.name = '名称不能为空'
if (!form.value.apiUrl || form.value.apiUrl.trim().length === 0) e.apiUrl = 'API URL 不能为空'
if (!isEdit.value && (!form.value.apiKey || form.value.apiKey.trim().length === 0))
e.apiKey = 'API Key 不能为空'
errors.value = e
return Object.keys(e).length === 0
}
const submit = async () => {
if (!validate()) return
loading.value = true
try {
if (isEdit.value) {
// 更新
const updates = {
name: form.value.name,
description: form.value.description,
apiUrl: form.value.apiUrl,
priority: form.value.priority,
userAgent: form.value.userAgent,
rateLimitDuration: enableRateLimit.value ? Number(form.value.rateLimitDuration || 60) : 0,
dailyQuota: Number(form.value.dailyQuota || 0),
quotaResetTime: form.value.quotaResetTime || '00:00',
proxy: form.value.proxy || null,
supportedModels: buildSupportedModels()
}
if (form.value.apiKey && form.value.apiKey.trim().length > 0) {
updates.apiKey = form.value.apiKey
}
const res = await apiClient.put(`/admin/ccr-accounts/${props.account.id}`, updates)
if (res.success) {
// 不在这里显示 toast由父组件统一处理
emit('success')
} else {
showToast(res.message || '保存失败', 'error')
}
} else {
// 创建
const payload = {
name: form.value.name,
description: form.value.description,
apiUrl: form.value.apiUrl,
apiKey: form.value.apiKey,
priority: Number(form.value.priority || 50),
supportedModels: buildSupportedModels(),
userAgent: form.value.userAgent,
rateLimitDuration: enableRateLimit.value ? Number(form.value.rateLimitDuration || 60) : 0,
proxy: form.value.proxy,
accountType: 'shared',
dailyQuota: Number(form.value.dailyQuota || 0),
quotaResetTime: form.value.quotaResetTime || '00:00'
}
const res = await apiClient.post('/admin/ccr-accounts', payload)
if (res.success) {
// 不在这里显示 toast由父组件统一处理
emit('success')
} else {
showToast(res.message || '创建失败', 'error')
}
}
} catch (err) {
showToast(err.message || '请求失败', 'error')
} finally {
loading.value = false
}
}
const populateFromAccount = () => {
if (!props.account) return
const a = props.account
form.value.name = a.name || ''
form.value.description = a.description || ''
form.value.apiUrl = a.apiUrl || ''
form.value.priority = Number(a.priority || 50)
form.value.userAgent = a.userAgent || ''
form.value.rateLimitDuration = Number(a.rateLimitDuration || 60)
form.value.dailyQuota = Number(a.dailyQuota || 0)
form.value.quotaResetTime = a.quotaResetTime || '00:00'
form.value.proxy = a.proxy || null
enableRateLimit.value = form.value.rateLimitDuration > 0
// supportedModels 对象转为数组
modelMappings.value = []
const mapping = a.supportedModels || {}
if (mapping && typeof mapping === 'object') {
for (const k of Object.keys(mapping)) {
modelMappings.value.push({ from: k, to: mapping[k] })
}
}
}
onMounted(() => {
if (isEdit.value) populateFromAccount()
})
watch(
() => props.account,
() => {
if (isEdit.value) populateFromAccount()
}
)
</script>
<style scoped>
.modal-content {
background: rgba(255, 255, 255, 0.9);
border-radius: 16px;
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1);
}
:global(.dark) .modal-content {
background: rgba(17, 24, 39, 0.85);
}
.loading-spinner {
width: 20px;
height: 20px;
border: 2px solid #e5e7eb;
border-top: 2px solid #14b8a6;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
</style>

View File

@@ -30,13 +30,52 @@
</div>
</div>
<!-- 快速配置输入框 -->
<div>
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
快速配置
<span class="ml-1 text-xs font-normal text-gray-500 dark:text-gray-400">
(粘贴完整代理URL自动填充)
</span>
</label>
<div class="relative">
<input
v-model="proxyUrl"
class="form-input w-full border-gray-300 pr-10 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
placeholder="例如: socks5://username:password@host:port 或 http://host:port"
type="text"
@input="handleInput"
@keyup.enter="parseProxyUrl"
@paste="handlePaste"
/>
<button
v-if="proxyUrl"
class="absolute inset-y-0 right-0 flex items-center pr-3 text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-400"
type="button"
@click="clearProxyUrl"
>
<i class="fas fa-times" />
</button>
</div>
<p v-if="parseError" class="mt-1 text-xs text-red-500">
<i class="fas fa-exclamation-circle mr-1" />
{{ parseError }}
</p>
<p v-else-if="parseSuccess" class="mt-1 text-xs text-green-500">
<i class="fas fa-check-circle mr-1" />
代理配置已自动填充
</p>
</div>
<div class="my-3 border-t border-gray-200 dark:border-gray-600"></div>
<div>
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"
>代理类型</label
>
<select
v-model="proxy.type"
class="form-input w-full dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200"
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200"
>
<option value="socks5">SOCKS5</option>
<option value="http">HTTP</option>
@@ -51,7 +90,7 @@
>
<input
v-model="proxy.host"
class="form-input w-full dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
placeholder="例如: 192.168.1.100"
type="text"
/>
@@ -62,7 +101,7 @@
>
<input
v-model="proxy.port"
class="form-input w-full dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
placeholder="例如: 1080"
type="number"
/>
@@ -92,7 +131,7 @@
>
<input
v-model="proxy.username"
class="form-input w-full dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
placeholder="代理用户名"
type="text"
/>
@@ -104,7 +143,7 @@
<div class="relative">
<input
v-model="proxy.password"
class="form-input w-full pr-10 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
class="form-input w-full border-gray-300 pr-10 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
placeholder="代理密码"
:type="showPassword ? 'text' : 'password'"
/>
@@ -159,6 +198,11 @@ const proxy = ref({ ...props.modelValue })
const showAuth = ref(!!(proxy.value.username || proxy.value.password))
const showPassword = ref(false)
// 快速配置相关
const proxyUrl = ref('')
const parseError = ref('')
const parseSuccess = ref(false)
// 监听modelValue变化只在真正需要更新时才更新
watch(
() => props.modelValue,
@@ -246,6 +290,122 @@ function emitUpdate() {
}, 100) // 100ms 延迟
}
// 解析代理URL
function parseProxyUrl() {
parseError.value = ''
parseSuccess.value = false
if (!proxyUrl.value) {
return
}
try {
// 移除 # 后面的别名部分
const urlWithoutAlias = proxyUrl.value.split('#')[0].trim()
if (!urlWithoutAlias) {
return
}
// 正则表达式匹配代理URL格式
// 支持格式protocol://[username:password@]host:port
const proxyPattern = /^(socks5|https?):\/\/(?:([^:@]+):([^@]+)@)?([^:]+):(\d+)$/i
const match = urlWithoutAlias.match(proxyPattern)
if (!match) {
// 尝试简单格式host:port默认为socks5
const simplePattern = /^([^:]+):(\d+)$/
const simpleMatch = urlWithoutAlias.match(simplePattern)
if (simpleMatch) {
proxy.value.type = 'socks5'
proxy.value.host = simpleMatch[1]
proxy.value.port = simpleMatch[2]
proxy.value.username = ''
proxy.value.password = ''
showAuth.value = false
parseSuccess.value = true
emitUpdate()
// 3秒后清除成功提示
setTimeout(() => {
parseSuccess.value = false
}, 3000)
return
}
parseError.value = '无效的代理URL格式请检查输入'
return
}
// 解析匹配结果
const [, protocol, username, password, host, port] = match
// 填充表单
proxy.value.type = protocol.toLowerCase()
proxy.value.host = host
proxy.value.port = port
// 处理认证信息
if (username && password) {
proxy.value.username = decodeURIComponent(username)
proxy.value.password = decodeURIComponent(password)
showAuth.value = true
} else {
proxy.value.username = ''
proxy.value.password = ''
showAuth.value = false
}
parseSuccess.value = true
emitUpdate()
// 3秒后清除成功提示
setTimeout(() => {
parseSuccess.value = false
}, 3000)
} catch (error) {
// 解析代理URL失败
parseError.value = '解析失败请检查URL格式'
}
}
// 清空快速配置输入
function clearProxyUrl() {
proxyUrl.value = ''
parseError.value = ''
parseSuccess.value = false
}
// 处理粘贴事件
function handlePaste() {
// 延迟一下以确保v-model已经更新
setTimeout(() => {
parseProxyUrl()
}, 0)
}
// 处理输入事件
function handleInput() {
// 检测是否输入了代理URL格式
const value = proxyUrl.value.trim()
// 如果输入包含://说明可能是完整的代理URL
if (value.includes('://')) {
// 检查是否看起来像完整的URL有协议、主机和端口
if (
/^(socks5|https?):\/\/[^:]+:\d+/i.test(value) ||
/^(socks5|https?):\/\/[^:@]+:[^@]+@[^:]+:\d+/i.test(value)
) {
parseProxyUrl()
}
}
// 如果是简单的 host:port 格式,并且端口号输入完整
else if (/^[^:]+:\d{2,5}$/.test(value)) {
parseProxyUrl()
}
}
// 组件销毁时清理定时器
onUnmounted(() => {
if (updateTimer) {

View File

@@ -0,0 +1,254 @@
<template>
<div
v-if="show"
class="fixed inset-0 z-50 h-full w-full overflow-y-auto bg-gray-600 bg-opacity-50"
>
<div class="relative top-20 mx-auto w-96 rounded-md border bg-white p-5 shadow-lg">
<div class="mt-3">
<div class="mb-4 flex items-center justify-between">
<h3 class="text-lg font-medium text-gray-900">Change User Role</h3>
<button class="text-gray-400 hover:text-gray-600" @click="$emit('close')">
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
d="M6 18L18 6M6 6l12 12"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
/>
</svg>
</button>
</div>
<div v-if="user" class="space-y-4">
<!-- User Info -->
<div class="rounded-md bg-gray-50 p-4">
<div class="flex items-center">
<div class="flex-shrink-0">
<div class="flex h-10 w-10 items-center justify-center rounded-full bg-gray-300">
<svg
class="h-6 w-6 text-gray-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
/>
</svg>
</div>
</div>
<div class="ml-4">
<p class="text-sm font-medium text-gray-900">
{{ user.displayName || user.username }}
</p>
<p class="text-sm text-gray-500">@{{ user.username }}</p>
<div class="mt-1">
<span
:class="[
'inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium',
user.role === 'admin'
? 'bg-purple-100 text-purple-800'
: 'bg-blue-100 text-blue-800'
]"
>
Current: {{ user.role }}
</span>
</div>
</div>
</div>
</div>
<!-- Role Selection -->
<form class="space-y-4" @submit.prevent="handleSubmit">
<div>
<label class="mb-2 block text-sm font-medium text-gray-700"> New Role </label>
<div class="space-y-2">
<label class="flex items-center">
<input
v-model="selectedRole"
class="h-4 w-4 border-gray-300 text-blue-600 focus:ring-blue-500"
:disabled="loading"
type="radio"
value="user"
/>
<div class="ml-3">
<div class="text-sm font-medium text-gray-900">User</div>
<div class="text-xs text-gray-500">Regular user with basic permissions</div>
</div>
</label>
<label class="flex items-center">
<input
v-model="selectedRole"
class="h-4 w-4 border-gray-300 text-blue-600 focus:ring-blue-500"
:disabled="loading"
type="radio"
value="admin"
/>
<div class="ml-3">
<div class="text-sm font-medium text-gray-900">Administrator</div>
<div class="text-xs text-gray-500">Full access to manage users and system</div>
</div>
</label>
</div>
</div>
<!-- Warning for role changes -->
<div
v-if="selectedRole !== user.role"
class="rounded-md border border-yellow-200 bg-yellow-50 p-4"
>
<div class="flex">
<div class="flex-shrink-0">
<svg class="h-5 w-5 text-yellow-400" fill="currentColor" viewBox="0 0 20 20">
<path
clip-rule="evenodd"
d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z"
fill-rule="evenodd"
/>
</svg>
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-yellow-800">Role Change Warning</h3>
<div class="mt-2 text-sm text-yellow-700">
<p v-if="selectedRole === 'admin'">
Granting admin privileges will give this user full access to the system,
including the ability to manage other users and their API keys.
</p>
<p v-else>
Removing admin privileges will restrict this user to only managing their own
API keys and viewing their own usage statistics.
</p>
</div>
</div>
</div>
</div>
<div v-if="error" class="rounded-md border border-red-200 bg-red-50 p-4">
<div class="flex">
<div class="flex-shrink-0">
<svg class="h-5 w-5 text-red-400" fill="currentColor" viewBox="0 0 20 20">
<path
clip-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
fill-rule="evenodd"
/>
</svg>
</div>
<div class="ml-3">
<p class="text-sm text-red-700">{{ error }}</p>
</div>
</div>
</div>
<div class="flex justify-end space-x-3 pt-4">
<button
class="rounded-md border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:opacity-50"
:disabled="loading"
type="button"
@click="$emit('close')"
>
Cancel
</button>
<button
class="rounded-md border border-transparent bg-blue-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
:disabled="loading || selectedRole === user.role"
type="submit"
>
<span v-if="loading" class="flex items-center">
<svg
class="-ml-1 mr-2 h-4 w-4 animate-spin text-white"
fill="none"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
fill="currentColor"
></path>
</svg>
Updating...
</span>
<span v-else>Update Role</span>
</button>
</div>
</form>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, watch } from 'vue'
import { apiClient } from '@/config/api'
import { showToast } from '@/utils/toast'
const props = defineProps({
show: {
type: Boolean,
default: false
},
user: {
type: Object,
default: null
}
})
const emit = defineEmits(['close', 'updated'])
const loading = ref(false)
const error = ref('')
const selectedRole = ref('')
const handleSubmit = async () => {
if (!props.user || selectedRole.value === props.user.role) {
return
}
loading.value = true
error.value = ''
try {
const response = await apiClient.patch(`/users/${props.user.id}/role`, {
role: selectedRole.value
})
if (response.success) {
showToast(`User role updated to ${selectedRole.value}`, 'success')
emit('updated')
} else {
error.value = response.message || 'Failed to update user role'
}
} catch (err) {
console.error('Update user role error:', err)
error.value = err.response?.data?.message || err.message || 'Failed to update user role'
} finally {
loading.value = false
}
}
// Reset form when modal is shown
watch([() => props.show, () => props.user], ([show, user]) => {
if (show && user) {
selectedRole.value = user.role
error.value = ''
loading.value = false
}
})
</script>
<style scoped>
/* 组件特定样式 */
</style>

View File

@@ -0,0 +1,428 @@
<template>
<div
v-if="show"
class="fixed inset-0 z-50 h-full w-full overflow-y-auto bg-gray-600 bg-opacity-50"
>
<div class="relative top-10 mx-auto w-4/5 max-w-4xl rounded-md border bg-white p-5 shadow-lg">
<div class="mt-3">
<div class="mb-6 flex items-center justify-between">
<div>
<h3 class="text-lg font-medium text-gray-900">
Usage Statistics - {{ user?.displayName || user?.username }}
</h3>
<p class="text-sm text-gray-500">@{{ user?.username }} {{ user?.role }}</p>
</div>
<button class="text-gray-400 hover:text-gray-600" @click="emit('close')">
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
d="M6 18L18 6M6 6l12 12"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
/>
</svg>
</button>
</div>
<!-- Period Selector -->
<div class="mb-6">
<select
v-model="selectedPeriod"
class="block w-32 rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
@change="loadUsageStats"
>
<option value="day">Last 24 Hours</option>
<option value="week">Last 7 Days</option>
<option value="month">Last 30 Days</option>
<option value="quarter">Last 90 Days</option>
</select>
</div>
<!-- Loading State -->
<div v-if="loading" class="py-12 text-center">
<svg
class="mx-auto h-8 w-8 animate-spin text-blue-600"
fill="none"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
fill="currentColor"
></path>
</svg>
<p class="mt-2 text-sm text-gray-500">Loading usage statistics...</p>
</div>
<!-- Stats Content -->
<div v-else class="space-y-6">
<!-- Summary Cards -->
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">
<div class="overflow-hidden rounded-lg bg-blue-50 shadow">
<div class="p-5">
<div class="flex items-center">
<div class="flex-shrink-0">
<svg
class="h-6 w-6 text-blue-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
d="M13 10V3L4 14h7v7l9-11h-7z"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
/>
</svg>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="truncate text-sm font-medium text-blue-600">Requests</dt>
<dd class="text-lg font-medium text-blue-900">
{{ formatNumber(usageStats?.totalRequests || 0) }}
</dd>
</dl>
</div>
</div>
</div>
</div>
<div class="overflow-hidden rounded-lg bg-green-50 shadow">
<div class="p-5">
<div class="flex items-center">
<div class="flex-shrink-0">
<svg
class="h-6 w-6 text-green-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
/>
</svg>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="truncate text-sm font-medium text-green-600">Input Tokens</dt>
<dd class="text-lg font-medium text-green-900">
{{ formatNumber(usageStats?.totalInputTokens || 0) }}
</dd>
</dl>
</div>
</div>
</div>
</div>
<div class="overflow-hidden rounded-lg bg-purple-50 shadow">
<div class="p-5">
<div class="flex items-center">
<div class="flex-shrink-0">
<svg
class="h-6 w-6 text-purple-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
/>
</svg>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="truncate text-sm font-medium text-purple-600">Output Tokens</dt>
<dd class="text-lg font-medium text-purple-900">
{{ formatNumber(usageStats?.totalOutputTokens || 0) }}
</dd>
</dl>
</div>
</div>
</div>
</div>
<div class="overflow-hidden rounded-lg bg-yellow-50 shadow">
<div class="p-5">
<div class="flex items-center">
<div class="flex-shrink-0">
<svg
class="h-6 w-6 text-yellow-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
/>
</svg>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="truncate text-sm font-medium text-yellow-600">Total Cost</dt>
<dd class="text-lg font-medium text-yellow-900">
${{ (usageStats?.totalCost || 0).toFixed(4) }}
</dd>
</dl>
</div>
</div>
</div>
</div>
</div>
<!-- User API Keys Table -->
<div
v-if="userDetails?.apiKeys?.length > 0"
class="rounded-lg border border-gray-200 bg-white"
>
<div class="border-b border-gray-200 px-4 py-5 sm:px-6">
<h4 class="text-lg font-medium leading-6 text-gray-900">API Keys Usage</h4>
</div>
<div class="overflow-hidden">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500"
scope="col"
>
API Key
</th>
<th
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500"
scope="col"
>
Status
</th>
<th
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500"
scope="col"
>
Requests
</th>
<th
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500"
scope="col"
>
Tokens
</th>
<th
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500"
scope="col"
>
Cost
</th>
<th
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500"
scope="col"
>
Last Used
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 bg-white">
<tr v-for="apiKey in userDetails.apiKeys" :key="apiKey.id">
<td class="whitespace-nowrap px-6 py-4">
<div class="text-sm font-medium text-gray-900">{{ apiKey.name }}</div>
<div class="text-sm text-gray-500">{{ apiKey.keyPreview }}</div>
</td>
<td class="whitespace-nowrap px-6 py-4">
<span
:class="[
'inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium',
apiKey.isActive
? 'bg-green-100 text-green-800'
: 'bg-red-100 text-red-800'
]"
>
{{ apiKey.isActive ? 'Active' : 'Disabled' }}
</span>
</td>
<td class="whitespace-nowrap px-6 py-4 text-sm text-gray-900">
{{ formatNumber(apiKey.usage?.requests || 0) }}
</td>
<td class="whitespace-nowrap px-6 py-4 text-sm text-gray-900">
<div>In: {{ formatNumber(apiKey.usage?.inputTokens || 0) }}</div>
<div>Out: {{ formatNumber(apiKey.usage?.outputTokens || 0) }}</div>
</td>
<td class="whitespace-nowrap px-6 py-4 text-sm text-gray-900">
${{ (apiKey.usage?.totalCost || 0).toFixed(4) }}
</td>
<td class="whitespace-nowrap px-6 py-4 text-sm text-gray-500">
{{ apiKey.lastUsedAt ? formatDate(apiKey.lastUsedAt) : 'Never' }}
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Chart Placeholder -->
<div class="rounded-lg border border-gray-200 bg-white">
<div class="border-b border-gray-200 px-4 py-5 sm:px-6">
<h4 class="text-lg font-medium leading-6 text-gray-900">Usage Trend</h4>
</div>
<div class="p-6">
<div
class="flex h-64 items-center justify-center rounded-lg border-2 border-dashed border-gray-300"
>
<div class="text-center">
<svg
class="mx-auto h-12 w-12 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
/>
</svg>
<h3 class="mt-2 text-sm font-medium text-gray-900">Usage Chart</h3>
<p class="mt-1 text-sm text-gray-500">
Daily usage trends for {{ selectedPeriod }} period
</p>
<p class="mt-2 text-xs text-gray-400">
(Chart integration can be added with Chart.js, D3.js, or similar library)
</p>
</div>
</div>
</div>
</div>
<!-- No Data State -->
<div v-if="usageStats && usageStats.totalRequests === 0" class="py-12 text-center">
<svg
class="mx-auto h-12 w-12 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
/>
</svg>
<h3 class="mt-2 text-sm font-medium text-gray-900">No usage data</h3>
<p class="mt-1 text-sm text-gray-500">
This user hasn't made any API requests in the selected period.
</p>
</div>
</div>
<div class="mt-6 flex justify-end">
<button
class="rounded-md border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
@click="$emit('close')"
>
Close
</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, watch } from 'vue'
import { apiClient } from '@/config/api'
import { showToast } from '@/utils/toast'
const props = defineProps({
show: {
type: Boolean,
default: false
},
user: {
type: Object,
default: null
}
})
const emit = defineEmits(['close'])
const loading = ref(false)
const selectedPeriod = ref('week')
const usageStats = ref(null)
const userDetails = ref(null)
const formatNumber = (num) => {
if (num >= 1000000) {
return (num / 1000000).toFixed(1) + 'M'
} else if (num >= 1000) {
return (num / 1000).toFixed(1) + 'K'
}
return num.toString()
}
const formatDate = (dateString) => {
if (!dateString) return null
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
}
const loadUsageStats = async () => {
if (!props.user) return
loading.value = true
try {
const [statsResponse, userResponse] = await Promise.all([
apiClient.get(`/users/${props.user.id}/usage-stats`, {
params: { period: selectedPeriod.value }
}),
apiClient.get(`/users/${props.user.id}`)
])
if (statsResponse.success) {
usageStats.value = statsResponse.stats
}
if (userResponse.success) {
userDetails.value = userResponse.user
}
} catch (error) {
console.error('Failed to load user usage stats:', error)
showToast('Failed to load usage statistics', 'error')
} finally {
loading.value = false
}
}
// Watch for when modal is shown and user changes
watch([() => props.show, () => props.user], ([show, user]) => {
if (show && user) {
loadUsageStats()
}
})
</script>
<style scoped>
/* 组件特定样式 */
</style>

View File

@@ -127,7 +127,7 @@
<div class="flex gap-2">
<input
v-model="newTag"
class="form-input flex-1 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200"
class="form-input flex-1 border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200"
placeholder="输入新标签名称"
type="text"
@keypress.enter.prevent="addTag"
@@ -166,7 +166,7 @@
</label>
<input
v-model="form.rateLimitWindow"
class="form-input w-full text-sm dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200"
class="form-input w-full border-gray-300 text-sm dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200"
min="1"
placeholder="不修改"
type="number"
@@ -179,7 +179,7 @@
>
<input
v-model="form.rateLimitRequests"
class="form-input w-full text-sm dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200"
class="form-input w-full border-gray-300 text-sm dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200"
min="1"
placeholder="不修改"
type="number"
@@ -188,12 +188,14 @@
<div>
<label class="mb-1 block text-xs font-medium text-gray-700 dark:text-gray-300"
>Token 限制</label
>费用限制 (美元)</label
>
<input
v-model="form.tokenLimit"
class="form-input w-full text-sm dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200"
v-model="form.rateLimitCost"
class="form-input w-full border-gray-300 text-sm dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200"
min="0"
placeholder="不修改"
step="0.01"
type="number"
/>
</div>
@@ -208,7 +210,7 @@
</label>
<input
v-model="form.dailyCostLimit"
class="form-input w-full dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200"
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200"
min="0"
placeholder="不修改 (0 表示无限制)"
step="0.01"
@@ -216,6 +218,24 @@
/>
</div>
<!-- Opus 模型周费用限制 -->
<div>
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300">
Opus 模型周费用限制 (美元)
</label>
<input
v-model="form.weeklyOpusCostLimit"
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200"
min="0"
placeholder="不修改 (0 表示无限制)"
step="0.01"
type="number"
/>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
设置 Opus 模型的周费用限制周一到周日仅限 Claude 官方账户
</p>
</div>
<!-- 并发限制 -->
<div>
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
@@ -223,7 +243,7 @@
>
<input
v-model="form.concurrencyLimit"
class="form-input w-full dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200"
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200"
min="0"
placeholder="不修改 (0 表示无限制)"
type="number"
@@ -310,7 +330,7 @@
>
<select
v-model="form.claudeAccountId"
class="form-input w-full dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200"
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200"
:disabled="form.permissions === 'gemini' || form.permissions === 'openai'"
>
<option value="">不修改</option>
@@ -345,7 +365,7 @@
>
<select
v-model="form.geminiAccountId"
class="form-input w-full dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200"
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200"
:disabled="form.permissions === 'claude' || form.permissions === 'openai'"
>
<option value="">不修改</option>
@@ -376,7 +396,7 @@
>
<select
v-model="form.openaiAccountId"
class="form-input w-full dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200"
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200"
:disabled="form.permissions === 'claude' || form.permissions === 'gemini'"
>
<option value="">不修改</option>
@@ -407,7 +427,7 @@
>
<select
v-model="form.bedrockAccountId"
class="form-input w-full dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200"
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200"
:disabled="form.permissions === 'gemini' || form.permissions === 'openai'"
>
<option value="">不修改</option>
@@ -496,11 +516,12 @@ const unselectedTags = computed(() => {
// 表单数据
const form = reactive({
tokenLimit: '',
rateLimitCost: '', // 费用限制替代token限制
rateLimitWindow: '',
rateLimitRequests: '',
concurrencyLimit: '',
dailyCostLimit: '',
weeklyOpusCostLimit: '', // 新增Opus周费用限制
permissions: '', // 空字符串表示不修改
claudeAccountId: '',
geminiAccountId: '',
@@ -616,8 +637,8 @@ const batchUpdateApiKeys = async () => {
const updates = {}
// 只有非空值才添加到更新对象中
if (form.tokenLimit !== '' && form.tokenLimit !== null) {
updates.tokenLimit = parseInt(form.tokenLimit)
if (form.rateLimitCost !== '' && form.rateLimitCost !== null) {
updates.rateLimitCost = parseFloat(form.rateLimitCost)
}
if (form.rateLimitWindow !== '' && form.rateLimitWindow !== null) {
updates.rateLimitWindow = parseInt(form.rateLimitWindow)
@@ -631,6 +652,9 @@ const batchUpdateApiKeys = async () => {
if (form.dailyCostLimit !== '' && form.dailyCostLimit !== null) {
updates.dailyCostLimit = parseFloat(form.dailyCostLimit)
}
if (form.weeklyOpusCostLimit !== '' && form.weeklyOpusCostLimit !== null) {
updates.weeklyOpusCostLimit = parseFloat(form.weeklyOpusCostLimit)
}
// 权限设置
if (form.permissions !== '') {

View File

@@ -81,7 +81,7 @@
<div class="flex items-center gap-2">
<input
v-model.number="form.batchCount"
class="form-input w-full text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
class="form-input w-full border-gray-300 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
max="500"
min="2"
placeholder="输入数量 (2-500)"
@@ -110,19 +110,21 @@
class="mb-1.5 block text-xs font-semibold text-gray-700 dark:text-gray-300 sm:mb-2 sm:text-sm"
>名称 <span class="text-red-500">*</span></label
>
<input
v-model="form.name"
class="form-input w-full text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
:class="{ 'border-red-500': errors.name }"
:placeholder="
form.createType === 'batch'
? '输入基础名称(将自动添加序号)'
: '为您的 API Key 取一个名称'
"
required
type="text"
@input="errors.name = ''"
/>
<div>
<input
v-model="form.name"
class="form-input flex-1 border-gray-300 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
:class="{ 'border-red-500': errors.name }"
:placeholder="
form.createType === 'batch'
? '输入基础名称(将自动添加序号)'
: '为您的 API Key 取一个名称'
"
required
type="text"
@input="errors.name = ''"
/>
</div>
<p v-if="errors.name" class="mt-1 text-xs text-red-500 dark:text-red-400">
{{ errors.name }}
</p>
@@ -184,7 +186,7 @@
<div class="flex gap-2">
<input
v-model="newTag"
class="form-input flex-1 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
class="form-input flex-1 border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
placeholder="输入新标签名称"
type="text"
@keypress.enter.prevent="addTag"
@@ -228,7 +230,7 @@
>
<input
v-model="form.rateLimitWindow"
class="form-input w-full text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
class="form-input w-full border-gray-300 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
min="1"
placeholder="无限制"
type="number"
@@ -242,7 +244,7 @@
>
<input
v-model="form.rateLimitRequests"
class="form-input w-full text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
class="form-input w-full border-gray-300 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
min="1"
placeholder="无限制"
type="number"
@@ -252,17 +254,17 @@
<div>
<label class="mb-1 block text-xs font-medium text-gray-700 dark:text-gray-300"
>Token 限制</label
>费用限制 (美元)</label
>
<input
v-model="form.tokenLimit"
class="form-input w-full text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
v-model="form.rateLimitCost"
class="form-input w-full border-gray-300 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
min="0"
placeholder="无限制"
step="0.01"
type="number"
/>
<p class="ml-2 mt-0.5 text-xs text-gray-500 dark:text-gray-400">
窗口内最大Token
</p>
<p class="ml-2 mt-0.5 text-xs text-gray-500 dark:text-gray-400">窗口内最大费用</p>
</div>
</div>
@@ -275,12 +277,9 @@
<div>
<strong>示例1:</strong> 时间窗口=60请求次数=1000 每60分钟最多1000次请求
</div>
<div><strong>示例2:</strong> 时间窗口=1费用=0.1 每分钟最多$0.1费用</div>
<div>
<strong>示例2:</strong> 时间窗口=1Token=10000 每分钟最多10,000个Token
</div>
<div>
<strong>示例3:</strong> 窗口=30请求=50Token=100000
每30分钟50次请求且不超10万Token
<strong>示例3:</strong> 窗口=30请求=50费用=5 每30分钟50次请求且不超$5费用
</div>
</div>
</div>
@@ -324,7 +323,7 @@
</div>
<input
v-model="form.dailyCostLimit"
class="form-input w-full dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
min="0"
placeholder="0 表示无限制"
step="0.01"
@@ -336,13 +335,62 @@
</div>
</div>
<div>
<label class="mb-2 block text-sm font-semibold text-gray-700 dark:text-gray-300"
>Opus 模型周费用限制 (美元)</label
>
<div class="space-y-2">
<div class="flex gap-2">
<button
class="rounded bg-gray-100 px-2 py-1 text-xs font-medium hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
type="button"
@click="form.weeklyOpusCostLimit = '100'"
>
$100
</button>
<button
class="rounded bg-gray-100 px-2 py-1 text-xs font-medium hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
type="button"
@click="form.weeklyOpusCostLimit = '500'"
>
$500
</button>
<button
class="rounded bg-gray-100 px-2 py-1 text-xs font-medium hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
type="button"
@click="form.weeklyOpusCostLimit = '1000'"
>
$1000
</button>
<button
class="rounded bg-gray-100 px-2 py-1 text-xs font-medium hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
type="button"
@click="form.weeklyOpusCostLimit = ''"
>
自定义
</button>
</div>
<input
v-model="form.weeklyOpusCostLimit"
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
min="0"
placeholder="0 表示无限制"
step="0.01"
type="number"
/>
<p class="text-xs text-gray-500 dark:text-gray-400">
设置 Opus 模型的周费用限制周一到周日仅限 Claude 官方账户0 或留空表示无限制
</p>
</div>
</div>
<div>
<label class="mb-2 block text-sm font-semibold text-gray-700 dark:text-gray-300"
>并发限制 (可选)</label
>
<input
v-model="form.concurrencyLimit"
class="form-input w-full dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
min="0"
placeholder="0 表示无限制"
type="number"
@@ -358,7 +406,7 @@
>
<textarea
v-model="form.description"
class="form-input w-full resize-none text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
class="form-input w-full resize-none border-gray-300 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
placeholder="描述此 API Key 的用途..."
rows="2"
/>
@@ -366,34 +414,103 @@
<div>
<label class="mb-2 block text-sm font-semibold text-gray-700 dark:text-gray-300"
>有效期限</label
>过期设置</label
>
<select
v-model="form.expireDuration"
class="form-input w-full dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200"
@change="updateExpireAt"
<!-- 过期模式选择 -->
<div
class="mb-3 rounded-lg border border-gray-200 bg-gray-50 p-3 dark:border-gray-700 dark:bg-gray-800"
>
<option value="">永不过期</option>
<option value="1d">1 </option>
<option value="7d">7 </option>
<option value="30d">30 </option>
<option value="90d">90 </option>
<option value="180d">180 </option>
<option value="365d">365 </option>
<option value="custom">自定义日期</option>
</select>
<div v-if="form.expireDuration === 'custom'" class="mt-3">
<input
v-model="form.customExpireDate"
class="form-input w-full dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200"
:min="minDateTime"
type="datetime-local"
@change="updateCustomExpireAt"
/>
<div class="flex items-center gap-4">
<label class="flex cursor-pointer items-center">
<input
v-model="form.expirationMode"
class="mr-2 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
type="radio"
value="fixed"
/>
<span class="text-sm text-gray-700 dark:text-gray-300">固定时间过期</span>
</label>
<label class="flex cursor-pointer items-center">
<input
v-model="form.expirationMode"
class="mr-2 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
type="radio"
value="activation"
/>
<span class="text-sm text-gray-700 dark:text-gray-300">首次使用后激活</span>
</label>
</div>
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
<span v-if="form.expirationMode === 'fixed'">
<i class="fas fa-info-circle mr-1" />
固定时间模式Key 创建后立即生效按设定时间过期
</span>
<span v-else>
<i class="fas fa-info-circle mr-1" />
激活模式Key 首次使用时激活激活后按设定天数过期适合批量销售
</span>
</p>
</div>
<!-- 固定时间模式 -->
<div v-if="form.expirationMode === 'fixed'">
<select
v-model="form.expireDuration"
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200"
@change="updateExpireAt"
>
<option value="">永不过期</option>
<option value="1d">1 </option>
<option value="7d">7 </option>
<option value="30d">30 </option>
<option value="90d">90 </option>
<option value="180d">180 </option>
<option value="365d">365 </option>
<option value="custom">自定义日期</option>
</select>
<div v-if="form.expireDuration === 'custom'" class="mt-3">
<input
v-model="form.customExpireDate"
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200"
:min="minDateTime"
type="datetime-local"
@change="updateCustomExpireAt"
/>
</div>
<p v-if="form.expiresAt" class="mt-2 text-xs text-gray-500 dark:text-gray-400">
将于 {{ formatExpireDate(form.expiresAt) }} 过期
</p>
</div>
<!-- 激活模式 -->
<div v-else>
<div class="flex items-center gap-2">
<input
v-model.number="form.activationDays"
class="form-input flex-1 border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200"
max="3650"
min="1"
placeholder="输入天数"
type="number"
/>
<span class="text-sm text-gray-600 dark:text-gray-400"></span>
</div>
<div class="mt-2 flex flex-wrap gap-2">
<button
v-for="days in [30, 90, 180, 365]"
:key="days"
class="rounded-md border border-gray-300 px-3 py-1 text-xs hover:bg-gray-100 dark:border-gray-600 dark:hover:bg-gray-700"
type="button"
@click="form.activationDays = days"
>
{{ days }}
</button>
</div>
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
<i class="fas fa-clock mr-1" />
Key 将在首次使用后激活激活后 {{ form.activationDays || 30 }} 天过期
</p>
</div>
<p v-if="form.expiresAt" class="mt-2 text-xs text-gray-500 dark:text-gray-400">
将于 {{ formatExpireDate(form.expiresAt) }} 过期
</p>
</div>
<div>
@@ -739,14 +856,17 @@ const form = reactive({
batchCount: 10,
name: '',
description: '',
tokenLimit: '',
rateLimitWindow: '',
rateLimitRequests: '',
rateLimitCost: '', // 新增:费用限制
concurrencyLimit: '',
dailyCostLimit: '',
weeklyOpusCostLimit: '',
expireDuration: '',
customExpireDate: '',
expiresAt: null,
expirationMode: 'fixed', // 过期模式fixed(固定) 或 activation(激活)
activationDays: 30, // 激活后有效天数
permissions: 'all',
claudeAccountId: '',
geminiAccountId: '',
@@ -766,31 +886,61 @@ onMounted(async () => {
availableTags.value = await apiKeysStore.fetchTags()
// 初始化账号数据
if (props.accounts) {
// 合并 OpenAI 和 OpenAI-Responses 账号
const openaiAccounts = []
if (props.accounts.openai) {
props.accounts.openai.forEach((account) => {
openaiAccounts.push({
...account,
platform: 'openai'
})
})
}
if (props.accounts.openaiResponses) {
props.accounts.openaiResponses.forEach((account) => {
openaiAccounts.push({
...account,
platform: 'openai-responses'
})
})
}
localAccounts.value = {
claude: props.accounts.claude || [],
gemini: props.accounts.gemini || [],
openai: props.accounts.openai || [],
openai: openaiAccounts,
bedrock: props.accounts.bedrock || [], // 添加 Bedrock 账号
claudeGroups: props.accounts.claudeGroups || [],
geminiGroups: props.accounts.geminiGroups || [],
openaiGroups: props.accounts.openaiGroups || []
}
}
// 自动加载账号数据
await refreshAccounts()
})
// 刷新账号列表
const refreshAccounts = async () => {
accountsLoading.value = true
try {
const [claudeData, claudeConsoleData, geminiData, openaiData, bedrockData, groupsData] =
await Promise.all([
apiClient.get('/admin/claude-accounts'),
apiClient.get('/admin/claude-console-accounts'),
apiClient.get('/admin/gemini-accounts'),
apiClient.get('/admin/openai-accounts'),
apiClient.get('/admin/bedrock-accounts'), // 添加 Bedrock 账号获取
apiClient.get('/admin/account-groups')
])
const [
claudeData,
claudeConsoleData,
geminiData,
openaiData,
openaiResponsesData,
bedrockData,
groupsData
] = await Promise.all([
apiClient.get('/admin/claude-accounts'),
apiClient.get('/admin/claude-console-accounts'),
apiClient.get('/admin/gemini-accounts'),
apiClient.get('/admin/openai-accounts'),
apiClient.get('/admin/openai-responses-accounts'), // 获取 OpenAI-Responses 账号
apiClient.get('/admin/bedrock-accounts'), // 添加 Bedrock 账号获取
apiClient.get('/admin/account-groups')
])
// 合并Claude OAuth账户和Claude Console账户
const claudeAccounts = []
@@ -824,13 +974,31 @@ const refreshAccounts = async () => {
}))
}
// 合并 OpenAI 和 OpenAI-Responses 账号
const openaiAccounts = []
if (openaiData.success) {
localAccounts.value.openai = (openaiData.data || []).map((account) => ({
...account,
isDedicated: account.accountType === 'dedicated' // 保留以便向后兼容
}))
;(openaiData.data || []).forEach((account) => {
openaiAccounts.push({
...account,
platform: 'openai',
isDedicated: account.accountType === 'dedicated' // 保留以便向后兼容
})
})
}
if (openaiResponsesData.success) {
;(openaiResponsesData.data || []).forEach((account) => {
openaiAccounts.push({
...account,
platform: 'openai-responses',
isDedicated: account.accountType === 'dedicated' // 保留以便向后兼容
})
})
}
localAccounts.value.openai = openaiAccounts
if (bedrockData.success) {
localAccounts.value.bedrock = (bedrockData.data || []).map((account) => ({
...account,
@@ -985,14 +1153,32 @@ const createApiKey = async () => {
}
}
// 检查是否设置了时间窗口但费用限制为0
if (form.rateLimitWindow && (!form.rateLimitCost || parseFloat(form.rateLimitCost) === 0)) {
let confirmed = false
if (window.showConfirm) {
confirmed = await window.showConfirm(
'费用限制提醒',
'您设置了时间窗口但费用限制为0这意味着不会有费用限制。\n\n是否继续',
'继续创建',
'返回修改'
)
} else {
// 降级方案
confirmed = confirm('您设置了时间窗口但费用限制为0这意味着不会有费用限制。\n是否继续')
}
if (!confirmed) {
return
}
}
loading.value = true
try {
// 准备提交的数据
const baseData = {
description: form.description || undefined,
tokenLimit:
form.tokenLimit !== '' && form.tokenLimit !== null ? parseInt(form.tokenLimit) : null,
tokenLimit: 0, // 设置为0清除历史token限制
rateLimitWindow:
form.rateLimitWindow !== '' && form.rateLimitWindow !== null
? parseInt(form.rateLimitWindow)
@@ -1001,6 +1187,10 @@ const createApiKey = async () => {
form.rateLimitRequests !== '' && form.rateLimitRequests !== null
? parseInt(form.rateLimitRequests)
: null,
rateLimitCost:
form.rateLimitCost !== '' && form.rateLimitCost !== null
? parseFloat(form.rateLimitCost)
: null,
concurrencyLimit:
form.concurrencyLimit !== '' && form.concurrencyLimit !== null
? parseInt(form.concurrencyLimit)
@@ -1009,7 +1199,13 @@ const createApiKey = async () => {
form.dailyCostLimit !== '' && form.dailyCostLimit !== null
? parseFloat(form.dailyCostLimit)
: 0,
expiresAt: form.expiresAt || undefined,
weeklyOpusCostLimit:
form.weeklyOpusCostLimit !== '' && form.weeklyOpusCostLimit !== null
? parseFloat(form.weeklyOpusCostLimit)
: 0,
expiresAt: form.expirationMode === 'fixed' ? form.expiresAt || undefined : undefined,
expirationMode: form.expirationMode,
activationDays: form.expirationMode === 'activation' ? form.activationDays : undefined,
permissions: form.permissions,
tags: form.tags.length > 0 ? form.tags : undefined,
enableModelRestriction: form.enableModelRestriction,

View File

@@ -32,13 +32,39 @@
class="mb-1.5 block text-xs font-semibold text-gray-700 dark:text-gray-300 sm:mb-3 sm:text-sm"
>名称</label
>
<input
class="form-input w-full cursor-not-allowed bg-gray-100 text-sm dark:border-gray-600 dark:bg-gray-800 dark:text-gray-400"
disabled
type="text"
:value="form.name"
/>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400 sm:mt-2">名称不可修改</p>
<div>
<input
v-model="form.name"
class="form-input flex-1 border-gray-300 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
maxlength="100"
placeholder="请输入API Key名称"
required
type="text"
/>
</div>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400 sm:mt-2">
用于识别此 API Key 的用途
</p>
</div>
<!-- 所有者选择 -->
<div>
<label
class="mb-1.5 block text-xs font-semibold text-gray-700 dark:text-gray-300 sm:mb-3 sm:text-sm"
>所有者</label
>
<select
v-model="form.ownerId"
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200"
>
<option v-for="user in availableUsers" :key="user.id" :value="user.id">
{{ user.displayName }} ({{ user.username }})
<span v-if="user.role === 'admin'" class="text-gray-500">- 管理员</span>
</option>
</select>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400 sm:mt-2">
分配此 API Key 给指定用户或管理员管理员分配时不受用户 API Key 数量限制
</p>
</div>
<!-- 标签 -->
@@ -98,7 +124,7 @@
<div class="flex gap-2">
<input
v-model="newTag"
class="form-input flex-1 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
class="form-input flex-1 border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
placeholder="输入新标签名称"
type="text"
@keypress.enter.prevent="addTag"
@@ -142,7 +168,7 @@
>
<input
v-model="form.rateLimitWindow"
class="form-input w-full text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
class="form-input w-full border-gray-300 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
min="1"
placeholder="无限制"
type="number"
@@ -156,7 +182,7 @@
>
<input
v-model="form.rateLimitRequests"
class="form-input w-full text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
class="form-input w-full border-gray-300 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
min="1"
placeholder="无限制"
type="number"
@@ -166,17 +192,17 @@
<div>
<label class="mb-1 block text-xs font-medium text-gray-700 dark:text-gray-300"
>Token 限制</label
>费用限制 (美元)</label
>
<input
v-model="form.tokenLimit"
class="form-input w-full text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
v-model="form.rateLimitCost"
class="form-input w-full border-gray-300 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
min="0"
placeholder="无限制"
step="0.01"
type="number"
/>
<p class="ml-2 mt-0.5 text-xs text-gray-500 dark:text-gray-400">
窗口内最大Token
</p>
<p class="ml-2 mt-0.5 text-xs text-gray-500 dark:text-gray-400">窗口内最大费用</p>
</div>
</div>
@@ -189,12 +215,9 @@
<div>
<strong>示例1:</strong> 时间窗口=60请求次数=1000 每60分钟最多1000次请求
</div>
<div><strong>示例2:</strong> 时间窗口=1费用=0.1 每分钟最多$0.1费用</div>
<div>
<strong>示例2:</strong> 时间窗口=1Token=10000 每分钟最多10,000个Token
</div>
<div>
<strong>示例3:</strong> 窗口=30请求=50Token=100000
每30分钟50次请求且不超10万Token
<strong>示例3:</strong> 窗口=30请求=50费用=5 每30分钟50次请求且不超$5费用
</div>
</div>
</div>
@@ -238,7 +261,7 @@
</div>
<input
v-model="form.dailyCostLimit"
class="form-input w-full dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
min="0"
placeholder="0 表示无限制"
step="0.01"
@@ -250,13 +273,62 @@
</div>
</div>
<div>
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
>Opus 模型周费用限制 (美元)</label
>
<div class="space-y-3">
<div class="flex gap-2">
<button
class="rounded-lg bg-gray-100 px-3 py-1 text-sm font-medium hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
type="button"
@click="form.weeklyOpusCostLimit = '100'"
>
$100
</button>
<button
class="rounded-lg bg-gray-100 px-3 py-1 text-sm font-medium hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
type="button"
@click="form.weeklyOpusCostLimit = '500'"
>
$500
</button>
<button
class="rounded-lg bg-gray-100 px-3 py-1 text-sm font-medium hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
type="button"
@click="form.weeklyOpusCostLimit = '1000'"
>
$1000
</button>
<button
class="rounded-lg bg-gray-100 px-3 py-1 text-sm font-medium hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
type="button"
@click="form.weeklyOpusCostLimit = ''"
>
自定义
</button>
</div>
<input
v-model="form.weeklyOpusCostLimit"
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
min="0"
placeholder="0 表示无限制"
step="0.01"
type="number"
/>
<p class="text-xs text-gray-500 dark:text-gray-400">
设置 Opus 模型的周费用限制周一到周日仅限 Claude 官方账户0 或留空表示无限制
</p>
</div>
</div>
<div>
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
>并发限制</label
>
<input
v-model="form.concurrencyLimit"
class="form-input w-full dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
min="0"
placeholder="0 表示无限制"
type="number"
@@ -488,7 +560,7 @@
<div class="flex gap-2">
<input
v-model="form.modelInput"
class="form-input flex-1 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
class="form-input flex-1 border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
placeholder="输入模型名称,按回车添加"
type="text"
@keydown.enter.prevent="addRestrictedModel"
@@ -620,6 +692,9 @@ const localAccounts = ref({
// 支持的客户端列表
const supportedClients = ref([])
// 可用用户列表
const availableUsers = ref([])
// 标签相关
const newTag = ref('')
const availableTags = ref([])
@@ -632,11 +707,13 @@ const unselectedTags = computed(() => {
// 表单数据
const form = reactive({
name: '',
tokenLimit: '',
tokenLimit: '', // 保留用于检测历史数据
rateLimitWindow: '',
rateLimitRequests: '',
rateLimitCost: '', // 新增:费用限制
concurrencyLimit: '',
dailyCostLimit: '',
weeklyOpusCostLimit: '',
permissions: 'all',
claudeAccountId: '',
geminiAccountId: '',
@@ -648,7 +725,8 @@ const form = reactive({
enableClientRestriction: false,
allowedClients: [],
tags: [],
isActive: true
isActive: true,
ownerId: '' // 新增所有者ID
})
// 添加限制的模型
@@ -702,13 +780,32 @@ const removeTag = (index) => {
// 更新 API Key
const updateApiKey = async () => {
// 检查是否设置了时间窗口但费用限制为0
if (form.rateLimitWindow && (!form.rateLimitCost || parseFloat(form.rateLimitCost) === 0)) {
let confirmed = false
if (window.showConfirm) {
confirmed = await window.showConfirm(
'费用限制提醒',
'您设置了时间窗口但费用限制为0这意味着不会有费用限制。\n\n是否继续',
'继续保存',
'返回修改'
)
} else {
// 降级方案
confirmed = confirm('您设置了时间窗口但费用限制为0这意味着不会有费用限制。\n是否继续')
}
if (!confirmed) {
return
}
}
loading.value = true
try {
// 准备提交的数据
const data = {
tokenLimit:
form.tokenLimit !== '' && form.tokenLimit !== null ? parseInt(form.tokenLimit) : 0,
name: form.name, // 添加名称字段
tokenLimit: 0, // 清除历史token限制
rateLimitWindow:
form.rateLimitWindow !== '' && form.rateLimitWindow !== null
? parseInt(form.rateLimitWindow)
@@ -717,6 +814,10 @@ const updateApiKey = async () => {
form.rateLimitRequests !== '' && form.rateLimitRequests !== null
? parseInt(form.rateLimitRequests)
: 0,
rateLimitCost:
form.rateLimitCost !== '' && form.rateLimitCost !== null
? parseFloat(form.rateLimitCost)
: 0,
concurrencyLimit:
form.concurrencyLimit !== '' && form.concurrencyLimit !== null
? parseInt(form.concurrencyLimit)
@@ -725,6 +826,10 @@ const updateApiKey = async () => {
form.dailyCostLimit !== '' && form.dailyCostLimit !== null
? parseFloat(form.dailyCostLimit)
: 0,
weeklyOpusCostLimit:
form.weeklyOpusCostLimit !== '' && form.weeklyOpusCostLimit !== null
? parseFloat(form.weeklyOpusCostLimit)
: 0,
permissions: form.permissions,
tags: form.tags
}
@@ -782,6 +887,11 @@ const updateApiKey = async () => {
// 活跃状态
data.isActive = form.isActive
// 所有者
if (form.ownerId !== undefined) {
data.ownerId = form.ownerId
}
const result = await apiClient.put(`/admin/api-keys/${props.apiKey.id}`, data)
if (result.success) {
@@ -801,15 +911,23 @@ const updateApiKey = async () => {
const refreshAccounts = async () => {
accountsLoading.value = true
try {
const [claudeData, claudeConsoleData, geminiData, openaiData, bedrockData, groupsData] =
await Promise.all([
apiClient.get('/admin/claude-accounts'),
apiClient.get('/admin/claude-console-accounts'),
apiClient.get('/admin/gemini-accounts'),
apiClient.get('/admin/openai-accounts'),
apiClient.get('/admin/bedrock-accounts'), // 添加 Bedrock 账号获取
apiClient.get('/admin/account-groups')
])
const [
claudeData,
claudeConsoleData,
geminiData,
openaiData,
openaiResponsesData,
bedrockData,
groupsData
] = await Promise.all([
apiClient.get('/admin/claude-accounts'),
apiClient.get('/admin/claude-console-accounts'),
apiClient.get('/admin/gemini-accounts'),
apiClient.get('/admin/openai-accounts'),
apiClient.get('/admin/openai-responses-accounts'), // 获取 OpenAI-Responses 账号
apiClient.get('/admin/bedrock-accounts'), // 添加 Bedrock 账号获取
apiClient.get('/admin/account-groups')
])
// 合并Claude OAuth账户和Claude Console账户
const claudeAccounts = []
@@ -843,13 +961,31 @@ const refreshAccounts = async () => {
}))
}
// 合并 OpenAI 和 OpenAI-Responses 账号
const openaiAccounts = []
if (openaiData.success) {
localAccounts.value.openai = (openaiData.data || []).map((account) => ({
...account,
isDedicated: account.accountType === 'dedicated'
}))
;(openaiData.data || []).forEach((account) => {
openaiAccounts.push({
...account,
platform: 'openai',
isDedicated: account.accountType === 'dedicated'
})
})
}
if (openaiResponsesData.success) {
;(openaiResponsesData.data || []).forEach((account) => {
openaiAccounts.push({
...account,
platform: 'openai-responses',
isDedicated: account.accountType === 'dedicated'
})
})
}
localAccounts.value.openai = openaiAccounts
if (bedrockData.success) {
localAccounts.value.bedrock = (bedrockData.data || []).map((account) => ({
...account,
@@ -873,18 +1009,71 @@ const refreshAccounts = async () => {
}
}
// 加载用户列表
const loadUsers = async () => {
try {
const response = await apiClient.get('/admin/users')
if (response.success) {
availableUsers.value = response.data || []
}
} catch (error) {
// console.error('Failed to load users:', error)
availableUsers.value = [
{
id: 'admin',
username: 'admin',
displayName: 'Admin',
email: '',
role: 'admin'
}
]
}
}
// 初始化表单数据
onMounted(async () => {
// 加载支持的客户端和已存在的标签
supportedClients.value = await clientsStore.loadSupportedClients()
availableTags.value = await apiKeysStore.fetchTags()
try {
// 并行加载所有需要的数据
const [clients, tags] = await Promise.all([
clientsStore.loadSupportedClients(),
apiKeysStore.fetchTags(),
loadUsers()
])
supportedClients.value = clients || []
availableTags.value = tags || []
} catch (error) {
// console.error('Error loading initial data:', error)
// Fallback to empty arrays if loading fails
supportedClients.value = []
availableTags.value = []
}
// 初始化账号数据
if (props.accounts) {
// 合并 OpenAI 和 OpenAI-Responses 账号
const openaiAccounts = []
if (props.accounts.openai) {
props.accounts.openai.forEach((account) => {
openaiAccounts.push({
...account,
platform: 'openai'
})
})
}
if (props.accounts.openaiResponses) {
props.accounts.openaiResponses.forEach((account) => {
openaiAccounts.push({
...account,
platform: 'openai-responses'
})
})
}
localAccounts.value = {
claude: props.accounts.claude || [],
gemini: props.accounts.gemini || [],
openai: props.accounts.openai || [],
openai: openaiAccounts,
bedrock: props.accounts.bedrock || [], // 添加 Bedrock 账号
claudeGroups: props.accounts.claudeGroups || [],
geminiGroups: props.accounts.geminiGroups || [],
@@ -892,12 +1081,26 @@ onMounted(async () => {
}
}
// 自动加载账号数据
await refreshAccounts()
form.name = props.apiKey.name
// 处理速率限制迁移如果有tokenLimit且没有rateLimitCost提示用户
form.tokenLimit = props.apiKey.tokenLimit || ''
form.rateLimitCost = props.apiKey.rateLimitCost || ''
// 如果有历史tokenLimit但没有rateLimitCost提示用户需要重新设置
if (props.apiKey.tokenLimit > 0 && !props.apiKey.rateLimitCost) {
// 可以根据需要添加提示,或者自动迁移(这里选择让用户手动设置)
// console.log('检测到历史Token限制请考虑设置费用限制')
}
form.rateLimitWindow = props.apiKey.rateLimitWindow || ''
form.rateLimitRequests = props.apiKey.rateLimitRequests || ''
form.concurrencyLimit = props.apiKey.concurrencyLimit || ''
form.dailyCostLimit = props.apiKey.dailyCostLimit || ''
form.weeklyOpusCostLimit = props.apiKey.weeklyOpusCostLimit || ''
form.permissions = props.apiKey.permissions || 'all'
// 处理 Claude 账号(区分 OAuth 和 Console
if (props.apiKey.claudeConsoleAccountId) {
@@ -906,7 +1109,10 @@ onMounted(async () => {
form.claudeAccountId = props.apiKey.claudeAccountId || ''
}
form.geminiAccountId = props.apiKey.geminiAccountId || ''
// 处理 OpenAI 账号 - 直接使用后端传来的值(已包含 responses: 前缀)
form.openaiAccountId = props.apiKey.openaiAccountId || ''
form.bedrockAccountId = props.apiKey.bedrockAccountId || '' // 添加 Bedrock 账号ID初始化
form.restrictedModels = props.apiKey.restrictedModels || []
form.allowedClients = props.apiKey.allowedClients || []
@@ -916,6 +1122,9 @@ onMounted(async () => {
form.enableClientRestriction = props.apiKey.enableClientRestriction || false
// 初始化活跃状态,默认为 true
form.isActive = props.apiKey.isActive !== undefined ? props.apiKey.isActive : true
// 初始化所有者
form.ownerId = props.apiKey.userId || 'admin'
})
</script>

View File

@@ -39,11 +39,18 @@
>
<div class="flex items-center justify-between">
<div>
<p class="mb-1 text-xs font-medium text-gray-600 dark:text-gray-400">
当前过期时间
</p>
<p class="mb-1 text-xs font-medium text-gray-600 dark:text-gray-400">当前状态</p>
<p class="text-sm font-semibold text-gray-800 dark:text-gray-200">
<template v-if="apiKey.expiresAt">
<!-- 未激活状态 -->
<template v-if="apiKey.expirationMode === 'activation' && !apiKey.isActivated">
<i class="fas fa-pause-circle mr-1 text-blue-500" />
未激活
<span class="ml-2 text-xs font-normal text-gray-600">
(激活后 {{ apiKey.activationDays || 30 }} 天过期)
</span>
</template>
<!-- 已设置过期时间 -->
<template v-else-if="apiKey.expiresAt">
{{ formatExpireDate(apiKey.expiresAt) }}
<span
v-if="getExpiryStatus(apiKey.expiresAt)"
@@ -53,6 +60,7 @@
({{ getExpiryStatus(apiKey.expiresAt).text }})
</span>
</template>
<!-- 永不过期 -->
<template v-else>
<i class="fas fa-infinity mr-1 text-gray-500" />
永不过期
@@ -74,6 +82,21 @@
</div>
</div>
<!-- 激活按钮仅在未激活状态显示 -->
<div v-if="apiKey.expirationMode === 'activation' && !apiKey.isActivated" class="mb-4">
<button
class="w-full rounded-lg bg-gradient-to-r from-blue-500 to-blue-600 px-4 py-3 font-semibold text-white transition-all hover:from-blue-600 hover:to-blue-700 hover:shadow-lg"
@click="handleActivateNow"
>
<i class="fas fa-rocket mr-2" />
立即激活 (激活后 {{ apiKey.activationDays || 30 }} 天过期)
</button>
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
<i class="fas fa-info-circle mr-1" />
点击立即激活此 API Key激活后将在 {{ apiKey.activationDays || 30 }} 天后过期
</p>
</div>
<!-- 快捷选项 -->
<div>
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
@@ -115,7 +138,7 @@
>
<input
v-model="localForm.customExpireDate"
class="form-input w-full dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200"
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200"
:min="minDateTime"
type="datetime-local"
@change="updateCustomExpiryPreview"
@@ -370,6 +393,35 @@ const handleSave = () => {
})
}
// 立即激活
const handleActivateNow = async () => {
// 使用确认弹窗
let confirmed = true
if (window.showConfirm) {
confirmed = await window.showConfirm(
'激活 API Key',
`确定要立即激活此 API Key 吗?激活后将在 ${props.apiKey.activationDays || 30} 天后自动过期。`,
'确定激活',
'取消'
)
} else {
// 降级方案
confirmed = confirm(
`确定要立即激活此 API Key 吗?激活后将在 ${props.apiKey.activationDays || 30} 天后自动过期。`
)
}
if (!confirmed) {
return
}
saving.value = true
emit('save', {
keyId: props.apiKey.id,
activateNow: true
})
}
// 重置保存状态
const resetSaving = () => {
saving.value = false

View File

@@ -0,0 +1,94 @@
<template>
<div class="inline-flex items-center gap-1.5 rounded-md px-2 py-1" :class="badgeClass">
<div class="flex items-center gap-1">
<i :class="['text-xs', iconClass]" />
<span class="text-xs font-medium">{{ label }}</span>
</div>
<div class="flex items-center gap-1">
<span class="text-xs font-semibold">${{ current.toFixed(2) }}</span>
<span class="text-xs text-gray-500 dark:text-gray-400">/</span>
<span class="text-xs">${{ limit.toFixed(2) }}</span>
</div>
<!-- 小型进度条 -->
<div class="h-1 w-12 rounded-full bg-gray-200 dark:bg-gray-600">
<div
class="h-1 rounded-full transition-all duration-300"
:class="progressClass"
:style="{ width: progress + '%' }"
/>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
type: {
type: String,
required: true,
validator: (value) => ['daily', 'opus', 'window'].includes(value)
},
label: {
type: String,
required: true
},
current: {
type: Number,
default: 0
},
limit: {
type: Number,
required: true
}
})
const progress = computed(() => {
if (!props.limit || props.limit === 0) return 0
const percentage = (props.current / props.limit) * 100
return Math.min(percentage, 100)
})
const badgeClass = computed(() => {
switch (props.type) {
case 'daily':
return 'bg-gray-50 dark:bg-gray-700/50'
case 'opus':
return 'bg-indigo-50 dark:bg-indigo-900/20'
case 'window':
return 'bg-blue-50 dark:bg-blue-900/20'
default:
return 'bg-gray-50 dark:bg-gray-700/50'
}
})
const iconClass = computed(() => {
switch (props.type) {
case 'daily':
return 'fas fa-calendar-day text-gray-500'
case 'opus':
return 'fas fa-gem text-indigo-500'
case 'window':
return 'fas fa-clock text-blue-500'
default:
return 'fas fa-info-circle text-gray-500'
}
})
const progressClass = computed(() => {
const p = progress.value
if (p >= 100) return 'bg-red-500'
if (p >= 80) return 'bg-yellow-500'
switch (props.type) {
case 'daily':
return 'bg-green-500'
case 'opus':
return 'bg-indigo-500'
case 'window':
return 'bg-blue-500'
default:
return 'bg-gray-500'
}
})
</script>

View File

@@ -0,0 +1,258 @@
<template>
<div class="w-full">
<div class="relative h-8 w-full overflow-hidden rounded-lg shadow-sm" :class="containerClass">
<!-- 背景层 -->
<div class="absolute inset-0" :class="backgroundClass"></div>
<!-- 进度条层 -->
<div
class="absolute inset-0 h-full transition-all duration-500 ease-out"
:class="progressBarClass"
:style="{ width: progress + '%' }"
></div>
<!-- 文字层 - 使用双层文字技术确保可读性 -->
<div class="relative z-10 flex h-full items-center justify-between px-3">
<div class="flex items-center gap-1.5">
<i :class="['text-xs', iconClass]" />
<span class="text-xs font-semibold" :class="labelTextClass">{{ label }}</span>
</div>
<div class="flex items-center gap-1.5">
<span class="text-xs font-bold tabular-nums" :class="currentValueClass">
${{ current.toFixed(2) }}
</span>
<span class="text-xs font-medium" :class="limitTextClass">
/ ${{ limit.toFixed(2) }}
</span>
</div>
</div>
<!-- 闪光效果可选 -->
<div
v-if="showShine && progress > 0"
class="absolute inset-0 opacity-20"
:style="{
background:
'linear-gradient(105deg, transparent 40%, rgba(255,255,255,0.5) 50%, transparent 60%)',
animation: 'shine 3s infinite'
}"
></div>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
type: {
type: String,
required: true,
validator: (value) => ['daily', 'opus', 'window'].includes(value)
},
label: {
type: String,
required: true
},
current: {
type: Number,
default: 0
},
limit: {
type: Number,
required: true
},
showShine: {
type: Boolean,
default: false
}
})
const progress = computed(() => {
if (!props.limit || props.limit === 0) return 0
const percentage = (props.current / props.limit) * 100
return Math.min(percentage, 100)
})
// 容器样式 - 使用更柔和的边框和阴影
const containerClass = computed(() => {
return 'border border-gray-200/80 dark:border-gray-600/50 shadow-sm'
})
// 背景样式 - 使用更浅的背景色提升对比度
const backgroundClass = computed(() => {
switch (props.type) {
case 'daily':
return 'bg-gray-100/50 dark:bg-gray-800/30'
case 'opus':
return 'bg-violet-50/50 dark:bg-violet-950/20'
case 'window':
return 'bg-sky-50/50 dark:bg-sky-950/20'
default:
return 'bg-gray-100/50 dark:bg-gray-800/30'
}
})
// 进度条样式 - 使用更柔和的颜色配置
const progressBarClass = computed(() => {
const p = progress.value
if (props.type === 'daily') {
if (p >= 90) {
return 'bg-red-400 dark:bg-red-500'
} else if (p >= 70) {
return 'bg-amber-400 dark:bg-amber-500'
} else {
return 'bg-emerald-400 dark:bg-emerald-500'
}
}
if (props.type === 'opus') {
if (p >= 90) {
return 'bg-red-400 dark:bg-red-500'
} else if (p >= 70) {
return 'bg-amber-400 dark:bg-amber-500'
} else {
return 'bg-violet-400 dark:bg-violet-500'
}
}
if (props.type === 'window') {
if (p >= 90) {
return 'bg-red-400 dark:bg-red-500'
} else if (p >= 70) {
return 'bg-amber-400 dark:bg-amber-500'
} else {
return 'bg-sky-400 dark:bg-sky-500'
}
}
return 'bg-gray-300 dark:bg-gray-400'
})
// 图标类
const iconClass = computed(() => {
const p = progress.value
// 根据进度选择图标颜色
let colorClass = ''
if (p >= 90) {
colorClass = 'text-red-700 dark:text-red-400'
} else if (p >= 70) {
colorClass = 'text-orange-700 dark:text-orange-400'
} else {
switch (props.type) {
case 'daily':
colorClass = 'text-green-700 dark:text-green-400'
break
case 'opus':
colorClass = 'text-purple-700 dark:text-purple-400'
break
case 'window':
colorClass = 'text-blue-700 dark:text-blue-400'
break
default:
colorClass = 'text-gray-600 dark:text-gray-400'
}
}
let iconName = ''
switch (props.type) {
case 'daily':
iconName = 'fas fa-calendar-day'
break
case 'opus':
iconName = 'fas fa-gem'
break
case 'window':
iconName = 'fas fa-clock'
break
default:
iconName = 'fas fa-infinity'
}
return `${iconName} ${colorClass}`
})
// 标签文字颜色 - 始终保持高对比度
const labelTextClass = computed(() => {
const p = progress.value
// 根据进度条背景色智能选择文字颜色
if (p > 40) {
// 当进度条覆盖超过40%时,使用白色文字
return 'text-white drop-shadow-[0_1px_2px_rgba(0,0,0,0.8)]'
} else {
// 在浅色背景上使用深色文字
switch (props.type) {
case 'daily':
return 'text-gray-900 dark:text-gray-100'
case 'opus':
return 'text-purple-900 dark:text-purple-100'
case 'window':
return 'text-blue-900 dark:text-blue-100'
default:
return 'text-gray-900 dark:text-gray-100'
}
}
})
// 当前值文字颜色 - 最重要的数字,需要最高对比度
const currentValueClass = computed(() => {
const p = progress.value
// 判断数值是否在进度条上
if (p > 70) {
// 在彩色进度条上,使用白色+强阴影
return 'text-white drop-shadow-[0_2px_4px_rgba(0,0,0,0.9)]'
} else {
// 在浅色背景上,根据进度状态选择颜色
if (p >= 90) {
return 'text-red-700 dark:text-red-300'
} else if (p >= 70) {
return 'text-orange-700 dark:text-orange-300'
} else {
switch (props.type) {
case 'daily':
return 'text-green-800 dark:text-green-200'
case 'opus':
return 'text-purple-800 dark:text-purple-200'
case 'window':
return 'text-blue-800 dark:text-blue-200'
default:
return 'text-gray-900 dark:text-gray-100'
}
}
}
})
// 限制值文字颜色
const limitTextClass = computed(() => {
const p = progress.value
// 判断限制值是否在进度条上
if (p > 85) {
// 在进度条上
return 'text-white/90 drop-shadow-[0_1px_2px_rgba(0,0,0,0.8)]'
} else {
// 在背景上
return 'text-gray-600 dark:text-gray-400'
}
})
</script>
<style scoped>
@keyframes shine {
0% {
transform: translateX(-100%);
}
100% {
transform: translateX(200%);
}
}
/* 确保文字清晰 */
.tabular-nums {
font-variant-numeric: tabular-nums;
}
</style>

View File

@@ -167,7 +167,7 @@ const copyApiKey = async () => {
await navigator.clipboard.writeText(key)
showToast('API Key 已复制到剪贴板', 'success')
} catch (error) {
console.error('Failed to copy:', error)
// console.error('Failed to copy:', error)
// 降级方案:创建一个临时文本区域
const textArea = document.createElement('textarea')
textArea.value = key

View File

@@ -6,7 +6,7 @@
<!-- 模态框 -->
<div
class="modal-content relative mx-auto flex max-h-[90vh] w-[95%] max-w-2xl flex-col p-4 sm:w-full sm:max-w-3xl sm:p-6 md:p-8"
class="modal-content relative mx-auto flex max-h-[90vh] w-[95%] max-w-5xl flex-col p-4 sm:w-full sm:p-6 md:p-8"
>
<!-- 标题栏 -->
<div class="mb-4 flex items-center justify-between sm:mb-6">
@@ -196,6 +196,8 @@
时间窗口限制
</h5>
<WindowCountdown
:cost-limit="apiKey.rateLimitCost"
:current-cost="apiKey.currentWindowCost"
:current-requests="apiKey.currentWindowRequests"
:current-tokens="apiKey.currentWindowTokens"
label="窗口状态"

View File

@@ -33,6 +33,7 @@
</div>
</div>
<!-- Token限制向后兼容 -->
<div v-if="hasTokenLimit" class="space-y-0.5">
<div class="flex items-center justify-between text-xs">
<span class="text-gray-400">Token</span>
@@ -48,6 +49,23 @@
/>
</div>
</div>
<!-- 费用限制新功能 -->
<div v-if="hasCostLimit" class="space-y-0.5">
<div class="flex items-center justify-between text-xs">
<span class="text-gray-400">费用</span>
<span class="text-gray-600">
${{ (currentCost || 0).toFixed(2) }}/${{ costLimit.toFixed(2) }}
</span>
</div>
<div class="h-1 w-full rounded-full bg-gray-200">
<div
class="h-1 rounded-full transition-all duration-300"
:class="getCostProgressColor()"
:style="{ width: getCostProgress() + '%' }"
/>
</div>
</div>
</div>
<!-- 额外提示信息 -->
@@ -102,6 +120,14 @@ const props = defineProps({
type: Number,
default: 0
},
currentCost: {
type: Number,
default: 0
},
costLimit: {
type: Number,
default: 0
},
showProgress: {
type: Boolean,
default: true
@@ -132,6 +158,7 @@ const windowState = computed(() => {
const hasRequestLimit = computed(() => props.requestLimit > 0)
const hasTokenLimit = computed(() => props.tokenLimit > 0)
const hasCostLimit = computed(() => props.costLimit > 0)
// 方法
const formatTime = (seconds) => {
@@ -196,6 +223,19 @@ const getTokenProgressColor = () => {
return 'bg-purple-500'
}
const getCostProgress = () => {
if (!props.costLimit || props.costLimit === 0) return 0
const percentage = ((props.currentCost || 0) / props.costLimit) * 100
return Math.min(percentage, 100)
}
const getCostProgressColor = () => {
const progress = getCostProgress()
if (progress >= 100) return 'bg-red-500'
if (progress >= 80) return 'bg-yellow-500'
return 'bg-green-500'
}
// 更新倒计时
const updateCountdown = () => {
if (props.windowEndTime && remainingSeconds.value > 0) {

View File

@@ -0,0 +1,241 @@
<template>
<div class="w-full space-y-1">
<!-- 时间窗口进度条 -->
<div
class="relative h-7 w-full overflow-hidden rounded-md border border-opacity-20 bg-gradient-to-r from-blue-50 to-cyan-100 dark:from-blue-950/30 dark:to-cyan-900/30"
>
<!-- 时间进度条背景 -->
<div
class="absolute inset-0 h-full bg-gradient-to-r from-blue-500 to-cyan-500 opacity-20 transition-all duration-1000"
:style="{ width: timeProgress + '%' }"
></div>
<!-- 文字层 -->
<div class="relative z-10 flex h-full items-center justify-between px-2">
<div class="flex items-center gap-1.5">
<i class="fas fa-clock text-xs text-blue-600 dark:text-blue-400" />
<span class="text-xs font-medium text-gray-700 dark:text-gray-200">
{{ rateLimitWindow }}分钟窗口
</span>
</div>
<span
class="text-xs font-bold"
:class="
remainingSeconds > 0
? 'text-blue-700 dark:text-blue-300'
: 'text-gray-400 dark:text-gray-500'
"
>
{{ remainingSeconds > 0 ? formatTime(remainingSeconds) : '未激活' }}
</span>
</div>
</div>
<!-- 费用和请求限制如果有的话 -->
<div v-if="costLimit > 0 || requestLimit > 0" class="flex gap-1">
<!-- 费用限制进度条 -->
<div
v-if="costLimit > 0"
class="relative h-6 overflow-hidden rounded-md border border-opacity-20 bg-gradient-to-r from-green-50 to-emerald-100 dark:from-green-950/30 dark:to-emerald-900/30"
:class="requestLimit > 0 ? 'w-1/2' : 'w-full'"
>
<!-- 进度条 -->
<div
class="absolute inset-0 h-full transition-all duration-500 ease-out"
:class="getCostProgressBarClass()"
:style="{ width: costProgress + '%' }"
></div>
<!-- 文字 -->
<div class="relative z-10 flex h-full items-center justify-between px-2">
<span class="text-[10px] font-medium" :class="getCostTextClass()">费用</span>
<span class="text-[10px] font-bold" :class="getCostValueTextClass()">
${{ currentCost.toFixed(1) }}/${{ costLimit.toFixed(0) }}
</span>
</div>
</div>
<!-- 请求限制进度条 -->
<div
v-if="requestLimit > 0"
class="relative h-6 overflow-hidden rounded-md border border-opacity-20 bg-gradient-to-r from-purple-50 to-indigo-100 dark:from-purple-950/30 dark:to-indigo-900/30"
:class="costLimit > 0 ? 'w-1/2' : 'w-full'"
>
<!-- 进度条 -->
<div
class="absolute inset-0 h-full transition-all duration-500 ease-out"
:class="getRequestProgressBarClass()"
:style="{ width: requestProgress + '%' }"
></div>
<!-- 文字 -->
<div class="relative z-10 flex h-full items-center justify-between px-2">
<span class="text-[10px] font-medium" :class="getRequestTextClass()">请求</span>
<span class="text-[10px] font-bold" :class="getRequestValueTextClass()">
{{ currentRequests }}/{{ requestLimit }}
</span>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
rateLimitWindow: {
type: Number,
required: true
},
remainingSeconds: {
type: Number,
default: 0
},
currentRequests: {
type: Number,
default: 0
},
requestLimit: {
type: Number,
default: 0
},
currentCost: {
type: Number,
default: 0
},
costLimit: {
type: Number,
default: 0
},
currentTokens: {
type: Number,
default: 0
},
tokenLimit: {
type: Number,
default: 0
}
})
// 费用进度
const costProgress = computed(() => {
if (!props.costLimit || props.costLimit === 0) return 0
const percentage = (props.currentCost / props.costLimit) * 100
return Math.min(percentage, 100)
})
// 请求进度
const requestProgress = computed(() => {
if (!props.requestLimit || props.requestLimit === 0) return 0
const percentage = (props.currentRequests / props.requestLimit) * 100
return Math.min(percentage, 100)
})
// 时间进度(倒计时)
const timeProgress = computed(() => {
if (!props.rateLimitWindow || props.rateLimitWindow === 0) return 0
const totalSeconds = props.rateLimitWindow * 60
const elapsed = totalSeconds - props.remainingSeconds
return Math.max(0, (elapsed / totalSeconds) * 100)
})
// 费用进度条颜色
const getCostProgressBarClass = () => {
const p = costProgress.value
if (p >= 90) {
return 'bg-gradient-to-r from-red-500 to-rose-600'
} else if (p >= 70) {
return 'bg-gradient-to-r from-orange-500 to-amber-500'
} else {
return 'bg-gradient-to-r from-green-500 to-emerald-500'
}
}
// 请求进度条颜色
const getRequestProgressBarClass = () => {
const p = requestProgress.value
if (p >= 90) {
return 'bg-gradient-to-r from-red-500 to-pink-600'
} else if (p >= 70) {
return 'bg-gradient-to-r from-orange-500 to-yellow-500'
} else {
return 'bg-gradient-to-r from-purple-500 to-indigo-600'
}
}
// 费用文字颜色
const getCostTextClass = () => {
const p = costProgress.value
if (p > 50) {
return 'text-white drop-shadow-sm'
} else {
return 'text-gray-600 dark:text-gray-300'
}
}
const getCostValueTextClass = () => {
const p = costProgress.value
if (p > 50) {
return 'text-white drop-shadow-md'
} else {
return 'text-gray-800 dark:text-gray-200'
}
}
// 请求文字颜色
const getRequestTextClass = () => {
const p = requestProgress.value
if (p > 50) {
return 'text-white drop-shadow-sm'
} else {
return 'text-gray-600 dark:text-gray-300'
}
}
const getRequestValueTextClass = () => {
const p = requestProgress.value
if (p > 50) {
return 'text-white drop-shadow-md'
} else {
return 'text-gray-800 dark:text-gray-200'
}
}
// 格式化时间
const formatTime = (seconds) => {
if (seconds === null || seconds === undefined) return '--:--'
const hours = Math.floor(seconds / 3600)
const minutes = Math.floor((seconds % 3600) / 60)
const secs = seconds % 60
if (hours > 0) {
return `${hours}h${minutes}m`
} else if (minutes > 0) {
return `${minutes}m${secs}s`
} else {
return `${secs}s`
}
}
// 格式化Token数 - 暂时未使用
// const formatTokens = (count) => {
// if (count >= 1000000) {
// return (count / 1000000).toFixed(1) + 'M'
// } else if (count >= 1000) {
// return (count / 1000).toFixed(1) + 'K'
// }
// return count.toString()
// }
</script>
<style scoped>
.border-opacity-20 {
border-color: rgba(0, 0, 0, 0.05);
}
.dark .border-opacity-20 {
border-color: rgba(255, 255, 255, 0.1);
}
</style>

View File

@@ -0,0 +1,202 @@
<template>
<div class="card h-full p-4 md:p-6">
<h3
class="mb-3 flex flex-col text-lg font-bold text-gray-900 dark:text-gray-100 sm:flex-row sm:items-center md:mb-4 md:text-xl"
>
<span class="flex items-center">
<i class="fas fa-chart-pie mr-2 text-sm text-orange-500 md:mr-3 md:text-base" />
使用占比
</span>
<span class="text-xs font-normal text-gray-600 dark:text-gray-400 sm:ml-2 md:text-sm"
>({{ statsPeriod === 'daily' ? '今日' : '本月' }})</span
>
</h3>
<div v-if="aggregatedStats && individualStats.length > 0" class="space-y-2 md:space-y-3">
<!-- 各Key使用占比列表 -->
<div v-for="(stat, index) in topKeys" :key="stat.apiId" class="relative">
<div class="mb-1 flex items-center justify-between text-sm">
<span class="truncate font-medium text-gray-700 dark:text-gray-300">
{{ stat.name || `Key ${index + 1}` }}
</span>
<span class="text-xs text-gray-600 dark:text-gray-400">
{{ calculatePercentage(stat) }}%
</span>
</div>
<div class="h-2 w-full rounded-full bg-gray-200 dark:bg-gray-700">
<div
class="h-2 rounded-full transition-all duration-300"
:class="getProgressColor(index)"
:style="{ width: calculatePercentage(stat) + '%' }"
/>
</div>
<div
class="mt-1 flex items-center justify-between text-xs text-gray-500 dark:text-gray-400"
>
<span>{{ formatNumber(getStatUsage(stat)?.requests || 0) }}</span>
<span>{{ getStatUsage(stat)?.formattedCost || '$0.00' }}</span>
</div>
</div>
<!-- 其他Keys汇总 -->
<div v-if="otherKeysCount > 0" class="border-t border-gray-200 pt-2 dark:border-gray-700">
<div class="flex items-center justify-between text-sm text-gray-600 dark:text-gray-400">
<span>其他 {{ otherKeysCount }} 个Keys</span>
<span>{{ otherPercentage }}%</span>
</div>
</div>
</div>
<!-- 单个Key模式提示 -->
<div
v-else-if="!multiKeyMode"
class="flex h-32 items-center justify-center text-sm text-gray-500 dark:text-gray-400"
>
<div class="text-center">
<i class="fas fa-chart-pie mb-2 text-2xl" />
<p>使用占比仅在多Key查询时显示</p>
</div>
</div>
<div
v-else
class="flex h-32 items-center justify-center text-sm text-gray-500 dark:text-gray-400"
>
<i class="fas fa-chart-pie mr-2" />
暂无数据
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { storeToRefs } from 'pinia'
import { useApiStatsStore } from '@/stores/apistats'
const apiStatsStore = useApiStatsStore()
const { aggregatedStats, individualStats, statsPeriod, multiKeyMode } = storeToRefs(apiStatsStore)
// 获取当前时间段的使用数据
const getStatUsage = (stat) => {
if (!stat) return null
if (statsPeriod.value === 'daily') {
return stat.dailyUsage || stat.usage
} else {
return stat.monthlyUsage || stat.usage
}
}
// 获取TOP Keys最多显示5个
const topKeys = computed(() => {
if (!individualStats.value || individualStats.value.length === 0) return []
return [...individualStats.value]
.sort((a, b) => {
const aUsage = getStatUsage(a)
const bUsage = getStatUsage(b)
return (bUsage?.cost || 0) - (aUsage?.cost || 0)
})
.slice(0, 5)
})
// 计算其他Keys数量
const otherKeysCount = computed(() => {
if (!individualStats.value) return 0
return Math.max(0, individualStats.value.length - 5)
})
// 计算其他Keys的占比
const otherPercentage = computed(() => {
if (!individualStats.value || !aggregatedStats.value) return 0
const topKeysCost = topKeys.value.reduce((sum, stat) => {
const usage = getStatUsage(stat)
return sum + (usage?.cost || 0)
}, 0)
const totalCost =
statsPeriod.value === 'daily'
? aggregatedStats.value.dailyUsage?.cost || 0
: aggregatedStats.value.monthlyUsage?.cost || 0
if (totalCost === 0) return 0
const otherCost = totalCost - topKeysCost
return Math.max(0, Math.round((otherCost / totalCost) * 100))
})
// 计算单个Key的百分比
const calculatePercentage = (stat) => {
if (!aggregatedStats.value) return 0
const totalCost =
statsPeriod.value === 'daily'
? aggregatedStats.value.dailyUsage?.cost || 0
: aggregatedStats.value.monthlyUsage?.cost || 0
if (totalCost === 0) return 0
const usage = getStatUsage(stat)
const percentage = ((usage?.cost || 0) / totalCost) * 100
return Math.round(percentage)
}
// 获取进度条颜色
const getProgressColor = (index) => {
const colors = ['bg-blue-500', 'bg-green-500', 'bg-purple-500', 'bg-yellow-500', 'bg-pink-500']
return colors[index] || 'bg-gray-400'
}
// 格式化数字
const formatNumber = (num) => {
if (typeof num !== 'number') {
num = parseInt(num) || 0
}
if (num === 0) return '0'
if (num >= 1000000) {
return (num / 1000000).toFixed(1) + 'M'
} else if (num >= 1000) {
return (num / 1000).toFixed(1) + 'K'
} else {
return num.toLocaleString()
}
}
</script>
<style scoped>
/* 卡片样式 - 使用CSS变量 */
.card {
background: var(--surface-color);
border-radius: 16px;
border: 1px solid var(--border-color);
box-shadow:
0 10px 15px -3px rgba(0, 0, 0, 0.1),
0 4px 6px -2px rgba(0, 0, 0, 0.05);
overflow: hidden;
position: relative;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.card::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 1px;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.5), transparent);
}
.card:hover {
transform: translateY(-2px);
box-shadow:
0 20px 25px -5px rgba(0, 0, 0, 0.15),
0 10px 10px -5px rgba(0, 0, 0, 0.08);
}
:global(.dark) .card:hover {
box-shadow:
0 20px 25px -5px rgba(0, 0, 0, 0.5),
0 10px 10px -5px rgba(0, 0, 0, 0.35);
}
</style>

View File

@@ -1,24 +1,64 @@
<template>
<div class="api-input-wide-card mb-8 rounded-3xl p-6 shadow-xl">
<!-- 标题区域 -->
<div class="wide-card-title mb-6 text-center">
<h2 class="mb-2 text-2xl font-bold text-gray-900 dark:text-white">
<div class="wide-card-title mb-6">
<h2 class="mb-2 text-2xl font-bold text-gray-900 dark:text-gray-200">
<i class="fas fa-chart-line mr-3" />
使用统计查询
</h2>
<p class="text-base text-gray-600 dark:text-gray-300">查询您的 API Key 使用情况和统计数据</p>
<p class="text-base text-gray-600 dark:text-gray-400">查询您的 API Key 使用情况和统计数据</p>
</div>
<!-- 输入区域 -->
<div class="mx-auto max-w-4xl">
<div class="api-input-grid grid grid-cols-1 lg:grid-cols-4">
<!-- 控制栏 -->
<div class="control-bar mb-4 flex flex-wrap items-center justify-between gap-3">
<!-- API Key 标签 -->
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
<i class="fas fa-key mr-2" />
{{ multiKeyMode ? '输入您的 API Keys每行一个或用逗号分隔' : '输入您的 API Key' }}
</label>
<!-- 模式切换和查询按钮组 -->
<div class="button-group flex items-center gap-2">
<!-- 模式切换 -->
<div
class="mode-switch-group flex items-center rounded-lg bg-gray-100 p-1 dark:bg-gray-800"
>
<button
class="mode-switch-btn"
:class="{ active: !multiKeyMode }"
title="单一模式"
@click="multiKeyMode = false"
>
<i class="fas fa-key" />
<span class="ml-2 hidden sm:inline">单一</span>
</button>
<button
class="mode-switch-btn"
:class="{ active: multiKeyMode }"
title="聚合模式"
@click="multiKeyMode = true"
>
<i class="fas fa-layer-group" />
<span class="ml-2 hidden sm:inline">聚合</span>
<span
v-if="multiKeyMode && parsedApiKeys.length > 0"
class="ml-1 rounded-full bg-white/20 px-1.5 py-0.5 text-xs font-semibold"
>
{{ parsedApiKeys.length }}
</span>
</button>
</div>
</div>
</div>
<div class="api-input-grid grid grid-cols-1 gap-4 lg:grid-cols-4">
<!-- API Key 输入 -->
<div class="lg:col-span-3">
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-200">
<i class="fas fa-key mr-2" />
输入您的 API Key
</label>
<!-- Key 模式输入框 -->
<input
v-if="!multiKeyMode"
v-model="apiKey"
class="wide-card-input w-full"
:disabled="loading"
@@ -26,16 +66,33 @@
type="password"
@keyup.enter="queryStats"
/>
<!-- Key 模式输入框 -->
<div v-else class="relative">
<textarea
v-model="apiKey"
class="wide-card-input w-full resize-y"
:disabled="loading"
placeholder="请输入您的 API Keys支持以下格式&#10;cr_xxx&#10;cr_yyy&#10;或&#10;cr_xxx, cr_yyy"
rows="4"
@keyup.ctrl.enter="queryStats"
/>
<button
v-if="apiKey && !loading"
class="absolute right-2 top-2 text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300"
title="清空输入"
@click="clearInput"
>
<i class="fas fa-times-circle" />
</button>
</div>
</div>
<!-- 查询按钮 -->
<div class="lg:col-span-1">
<label class="mb-2 hidden text-sm font-medium text-gray-700 dark:text-gray-200 lg:block">
&nbsp;
</label>
<button
class="btn btn-primary btn-query flex h-full w-full items-center justify-center gap-2"
:disabled="loading || !apiKey.trim()"
:disabled="loading || !hasValidInput"
@click="queryStats"
>
<i v-if="loading" class="fas fa-spinner loading-spinner" />
@@ -48,19 +105,56 @@
<!-- 安全提示 -->
<div class="security-notice mt-4">
<i class="fas fa-shield-alt mr-2" />
您的 API Key 仅用于查询自己的统计数据不会被存储或用于其他用途
{{
multiKeyMode
? '您的 API Keys 仅用于查询统计数据,不会被存储。聚合模式下部分个体化信息将不显示。'
: '您的 API Key 仅用于查询自己的统计数据,不会被存储或用于其他用途'
}}
</div>
<!-- Key 模式额外提示 -->
<div
v-if="multiKeyMode"
class="mt-2 rounded-lg bg-blue-50 p-3 text-sm text-blue-700 dark:bg-blue-900/20 dark:text-blue-400"
>
<i class="fas fa-lightbulb mr-2" />
<span>提示最多支持同时查询 30 API Keys使用 Ctrl+Enter 快速查询</span>
</div>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { storeToRefs } from 'pinia'
import { useApiStatsStore } from '@/stores/apistats'
const apiStatsStore = useApiStatsStore()
const { apiKey, loading } = storeToRefs(apiStatsStore)
const { queryStats } = apiStatsStore
const { apiKey, loading, multiKeyMode } = storeToRefs(apiStatsStore)
const { queryStats, clearInput } = apiStatsStore
// 解析输入的 API Keys
const parsedApiKeys = computed(() => {
if (!multiKeyMode.value || !apiKey.value) return []
// 支持逗号和换行符分隔
const keys = apiKey.value
.split(/[,\n]+/)
.map((key) => key.trim())
.filter((key) => key.length > 0)
// 去重并限制最多30个
const uniqueKeys = [...new Set(keys)]
return uniqueKeys.slice(0, 30)
})
// 判断是否有有效输入
const hasValidInput = computed(() => {
if (multiKeyMode.value) {
return parsedApiKeys.value.length > 0
}
return apiKey.value && apiKey.value.trim().length > 0
})
</script>
<style scoped>
@@ -101,7 +195,6 @@ const { queryStats } = apiStatsStore
/* 标题样式 */
.wide-card-title h2 {
color: #1f2937;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
font-weight: 700;
}
@@ -112,12 +205,12 @@ const { queryStats } = apiStatsStore
}
.wide-card-title p {
color: #4b5563;
color: #6b7280;
text-shadow: 0 1px 1px rgba(0, 0, 0, 0.05);
}
:global(.dark) .wide-card-title p {
color: #d1d5db;
color: #9ca3af;
text-shadow: 0 1px 1px rgba(0, 0, 0, 0.2);
}
@@ -251,6 +344,93 @@ const { queryStats } = apiStatsStore
text-shadow: 0 1px 2px rgba(16, 185, 129, 0.2);
}
/* 控制栏 */
.control-bar {
padding-bottom: 0.5rem;
border-bottom: 1px solid rgba(229, 231, 235, 0.3);
}
:global(.dark) .control-bar {
border-bottom-color: rgba(75, 85, 99, 0.3);
}
/* 按钮组 */
.button-group {
display: flex;
align-items: center;
gap: 0.5rem;
}
/* 模式切换组 */
.mode-switch-group {
display: inline-flex;
padding: 4px;
background: #f3f4f6;
border-radius: 0.5rem;
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.05);
}
:global(.dark) .mode-switch-group {
background: #1f2937;
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.3);
}
/* 模式切换按钮 */
.mode-switch-btn {
display: inline-flex;
align-items: center;
padding: 6px 12px;
font-size: 0.875rem;
font-weight: 500;
color: #6b7280;
background: transparent;
border: none;
border-radius: 0.375rem;
cursor: pointer;
transition: all 0.2s ease;
white-space: nowrap;
}
:global(.dark) .mode-switch-btn {
color: #9ca3af;
}
.mode-switch-btn:hover:not(.active) {
color: #374151;
background: rgba(0, 0, 0, 0.05);
}
:global(.dark) .mode-switch-btn:hover:not(.active) {
color: #d1d5db;
background: rgba(255, 255, 255, 0.05);
}
.mode-switch-btn.active {
color: white;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
box-shadow: 0 2px 4px rgba(102, 126, 234, 0.2);
}
.mode-switch-btn.active:hover {
box-shadow: 0 4px 6px rgba(102, 126, 234, 0.3);
}
.mode-switch-btn i {
font-size: 0.875rem;
}
/* 淡入淡出动画 */
.fade-enter-active,
.fade-leave-active {
transition: all 0.3s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
transform: translateX(-10px);
}
/* 加载动画 */
.loading-spinner {
animation: spin 1s linear infinite;
@@ -267,6 +447,18 @@ const { queryStats } = apiStatsStore
}
/* 响应式优化 */
@media (max-width: 768px) {
.control-bar {
flex-direction: column;
align-items: stretch;
gap: 1rem;
}
.button-group {
justify-content: center;
}
}
@media (max-width: 768px) {
.api-input-wide-card {
padding: 1.25rem;
@@ -304,6 +496,22 @@ const { queryStats } = apiStatsStore
}
}
@media (max-width: 480px) {
.mode-toggle-btn {
padding: 5px 8px;
}
.toggle-icon {
width: 18px;
height: 18px;
}
.hint-text {
font-size: 0.7rem;
padding: 4px 8px;
}
}
@media (max-width: 480px) {
.api-input-wide-card {
padding: 1rem;

View File

@@ -1,14 +1,108 @@
<template>
<div>
<!-- 限制配置 -->
<!-- 限制配置 / 聚合模式提示 -->
<div class="card p-4 md:p-6">
<h3
class="mb-3 flex items-center text-lg font-bold text-gray-900 dark:text-gray-100 md:mb-4 md:text-xl"
>
<i class="fas fa-shield-alt mr-2 text-sm text-red-500 md:mr-3 md:text-base" />
限制配置
{{ multiKeyMode ? '限制配置(聚合查询模式)' : '限制配置' }}
</h3>
<div class="space-y-4 md:space-y-5">
<!-- Key 模式下的聚合统计信息 -->
<div v-if="multiKeyMode && aggregatedStats" class="space-y-4">
<!-- API Keys 概况 -->
<div
class="rounded-lg bg-gradient-to-r from-blue-50 to-indigo-50 p-4 dark:from-blue-900/20 dark:to-indigo-900/20"
>
<div class="mb-3 flex items-center justify-between">
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">
<i class="fas fa-layer-group mr-2 text-blue-500" />
API Keys 概况
</span>
<span
class="rounded-full bg-blue-100 px-2 py-1 text-xs font-semibold text-blue-700 dark:bg-blue-800 dark:text-blue-200"
>
{{ aggregatedStats.activeKeys }}/{{ aggregatedStats.totalKeys }}
</span>
</div>
<div class="grid grid-cols-2 gap-3">
<div class="text-center">
<div class="text-lg font-bold text-gray-900 dark:text-gray-100">
{{ aggregatedStats.totalKeys }}
</div>
<div class="text-xs text-gray-600 dark:text-gray-400">总计 Keys</div>
</div>
<div class="text-center">
<div class="text-lg font-bold text-green-600">
{{ aggregatedStats.activeKeys }}
</div>
<div class="text-xs text-gray-600 dark:text-gray-400">激活 Keys</div>
</div>
</div>
</div>
<!-- 聚合统计数据 -->
<div
class="rounded-lg bg-gradient-to-r from-purple-50 to-pink-50 p-4 dark:from-purple-900/20 dark:to-pink-900/20"
>
<div class="mb-3 flex items-center">
<i class="fas fa-chart-pie mr-2 text-purple-500" />
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">聚合统计摘要</span>
</div>
<div class="space-y-2">
<div class="flex items-center justify-between">
<span class="text-xs text-gray-600 dark:text-gray-400">
<i class="fas fa-database mr-1 text-gray-400" />
总请求数
</span>
<span class="text-sm font-medium text-gray-900 dark:text-gray-100">
{{ formatNumber(aggregatedStats.usage.requests) }}
</span>
</div>
<div class="flex items-center justify-between">
<span class="text-xs text-gray-600 dark:text-gray-400">
<i class="fas fa-coins mr-1 text-yellow-500" />
Tokens
</span>
<span class="text-sm font-medium text-gray-900 dark:text-gray-100">
{{ formatNumber(aggregatedStats.usage.allTokens) }}
</span>
</div>
<div class="flex items-center justify-between">
<span class="text-xs text-gray-600 dark:text-gray-400">
<i class="fas fa-dollar-sign mr-1 text-green-500" />
总费用
</span>
<span class="text-sm font-medium text-gray-900 dark:text-gray-100">
{{ aggregatedStats.usage.formattedCost }}
</span>
</div>
</div>
</div>
<!-- 无效 Keys 提示 -->
<div
v-if="invalidKeys && invalidKeys.length > 0"
class="rounded-lg bg-red-50 p-3 text-sm dark:bg-red-900/20"
>
<i class="fas fa-exclamation-triangle mr-2 text-red-600 dark:text-red-400" />
<span class="text-red-700 dark:text-red-300">
{{ invalidKeys.length }} 个无效的 API Key
</span>
</div>
<!-- 提示信息 -->
<div
class="rounded-lg bg-gray-50 p-3 text-xs text-gray-600 dark:bg-gray-800 dark:text-gray-400"
>
<i class="fas fa-info-circle mr-1" />
每个 API Key 有独立的限制设置聚合模式下不显示单个限制配置
</div>
</div>
<!-- 仅在单 Key 模式下显示限制配置 -->
<div v-if="!multiKeyMode" class="space-y-4 md:space-y-5">
<!-- 每日费用限制 -->
<div>
<div class="mb-2 flex items-center justify-between">
@@ -45,10 +139,14 @@
<div
v-if="
statsData.limits.rateLimitWindow > 0 &&
(statsData.limits.rateLimitRequests > 0 || statsData.limits.tokenLimit > 0)
(statsData.limits.rateLimitRequests > 0 ||
statsData.limits.tokenLimit > 0 ||
statsData.limits.rateLimitCost > 0)
"
>
<WindowCountdown
:cost-limit="statsData.limits.rateLimitCost"
:current-cost="statsData.limits.currentWindowCost"
:current-requests="statsData.limits.currentWindowRequests"
:current-tokens="statsData.limits.currentWindowTokens"
label="时间窗口限制"
@@ -64,7 +162,13 @@
<div class="mt-2 text-xs text-gray-500 dark:text-gray-400">
<i class="fas fa-info-circle mr-1" />
请求次数和Token使用量为"或"的关系,任一达到限制即触发限流
<span v-if="statsData.limits.rateLimitCost > 0">
请求次数和费用限制为"或"的关系,任一达到限制即触发限流
</span>
<span v-else-if="statsData.limits.tokenLimit > 0">
请求次数和Token使用量为"或"的关系,任一达到限制即触发限流
</span>
<span v-else> 仅限制请求次数 </span>
</div>
</div>
@@ -211,7 +315,7 @@ import { useApiStatsStore } from '@/stores/apistats'
import WindowCountdown from '@/components/apikeys/WindowCountdown.vue'
const apiStatsStore = useApiStatsStore()
const { statsData } = storeToRefs(apiStatsStore)
const { statsData, multiKeyMode, aggregatedStats, invalidKeys } = storeToRefs(apiStatsStore)
// 获取每日费用进度
const getDailyCostProgress = () => {
@@ -229,6 +333,24 @@ const getDailyCostProgressColor = () => {
if (progress >= 80) return 'bg-yellow-500'
return 'bg-green-500'
}
// 格式化数字
const formatNumber = (num) => {
if (typeof num !== 'number') {
num = parseInt(num) || 0
}
if (num === 0) return '0'
// 大数字使用简化格式
if (num >= 1000000) {
return (num / 1000000).toFixed(1) + 'M'
} else if (num >= 1000) {
return (num / 1000).toFixed(1) + 'K'
} else {
return num.toLocaleString()
}
}
</script>
<style scoped>

View File

@@ -1,14 +1,83 @@
<template>
<div class="mb-6 grid grid-cols-1 gap-4 md:mb-8 md:gap-6 lg:grid-cols-2">
<!-- API Key 基本信息 -->
<!-- API Key 基本信息 / 批量查询概要 -->
<div class="card p-4 md:p-6">
<h3
class="mb-3 flex items-center text-lg font-bold text-gray-900 dark:text-gray-100 md:mb-4 md:text-xl"
>
<i class="fas fa-info-circle mr-2 text-sm text-blue-500 md:mr-3 md:text-base" />
API Key 信息
<i
class="mr-2 text-sm md:mr-3 md:text-base"
:class="
multiKeyMode ? 'fas fa-layer-group text-purple-500' : 'fas fa-info-circle text-blue-500'
"
/>
{{ multiKeyMode ? '批量查询概要' : 'API Key 信息' }}
</h3>
<div class="space-y-2 md:space-y-3">
<!-- Key 模式下的概要信息 -->
<div v-if="multiKeyMode && aggregatedStats" class="space-y-2 md:space-y-3">
<div class="flex items-center justify-between">
<span class="text-sm text-gray-600 dark:text-gray-400 md:text-base">查询 Keys </span>
<span class="text-sm font-medium text-gray-900 dark:text-gray-100 md:text-base">
{{ aggregatedStats.totalKeys }}
</span>
</div>
<div class="flex items-center justify-between">
<span class="text-sm text-gray-600 dark:text-gray-400 md:text-base">有效 Keys </span>
<span class="text-sm font-medium text-green-600 md:text-base">
<i class="fas fa-check-circle mr-1 text-xs md:text-sm" />
{{ aggregatedStats.activeKeys }}
</span>
</div>
<div v-if="invalidKeys.length > 0" class="flex items-center justify-between">
<span class="text-sm text-gray-600 dark:text-gray-400 md:text-base">无效 Keys </span>
<span class="text-sm font-medium text-red-600 md:text-base">
<i class="fas fa-times-circle mr-1 text-xs md:text-sm" />
{{ invalidKeys.length }}
</span>
</div>
<div class="flex items-center justify-between">
<span class="text-sm text-gray-600 dark:text-gray-400 md:text-base">总请求数</span>
<span class="text-sm font-medium text-gray-900 dark:text-gray-100 md:text-base">
{{ formatNumber(aggregatedStats.usage.requests) }}
</span>
</div>
<div class="flex items-center justify-between">
<span class="text-sm text-gray-600 dark:text-gray-400 md:text-base"> Token </span>
<span class="text-sm font-medium text-gray-900 dark:text-gray-100 md:text-base">
{{ formatNumber(aggregatedStats.usage.allTokens) }}
</span>
</div>
<div class="flex items-center justify-between">
<span class="text-sm text-gray-600 dark:text-gray-400 md:text-base">总费用</span>
<span class="text-sm font-medium text-indigo-600 md:text-base">
{{ aggregatedStats.usage.formattedCost }}
</span>
</div>
<!-- Key 贡献占比可选 -->
<div
v-if="individualStats.length > 1"
class="border-t border-gray-200 pt-2 dark:border-gray-700"
>
<div class="mb-2 text-xs text-gray-500 dark:text-gray-400"> Key 贡献占比</div>
<div class="space-y-1">
<div
v-for="stat in topContributors"
:key="stat.apiId"
class="flex items-center justify-between text-xs"
>
<span class="truncate text-gray-600 dark:text-gray-400">{{ stat.name }}</span>
<span class="text-gray-900 dark:text-gray-100"
>{{ calculateContribution(stat) }}%</span
>
</div>
</div>
</div>
</div>
<!-- Key 模式下的详细信息 -->
<div v-else class="space-y-2 md:space-y-3">
<div class="flex items-center justify-between">
<span class="text-sm text-gray-600 dark:text-gray-400 md:text-base">名称</span>
<span
@@ -46,7 +115,19 @@
<span class="mt-1 flex-shrink-0 text-sm text-gray-600 dark:text-gray-400 md:text-base"
>过期时间</span
>
<div v-if="statsData.expiresAt" class="text-right">
<!-- 未激活状态 -->
<div
v-if="statsData.expirationMode === 'activation' && !statsData.isActivated"
class="text-sm font-medium text-amber-600 dark:text-amber-500 md:text-base"
>
<i class="fas fa-pause-circle mr-1 text-xs md:text-sm" />
未激活
<span class="ml-1 text-xs text-gray-500 dark:text-gray-400"
>(首次使用后{{ statsData.activationDays || 30 }}天过期)</span
>
</div>
<!-- 已设置过期时间 -->
<div v-else-if="statsData.expiresAt" class="text-right">
<div
v-if="isApiKeyExpired(statsData.expiresAt)"
class="text-sm font-medium text-red-600 md:text-base"
@@ -68,6 +149,7 @@
{{ formatExpireDate(statsData.expiresAt) }}
</div>
</div>
<!-- 永不过期 -->
<div v-else class="text-sm font-medium text-gray-400 dark:text-gray-500 md:text-base">
<i class="fas fa-infinity mr-1 text-xs md:text-sm" />
永不过期
@@ -128,12 +210,38 @@
</template>
<script setup>
/* eslint-disable no-unused-vars */
import { computed } from 'vue'
import { storeToRefs } from 'pinia'
import { useApiStatsStore } from '@/stores/apistats'
import dayjs from 'dayjs'
const apiStatsStore = useApiStatsStore()
const { statsData, statsPeriod, currentPeriodData } = storeToRefs(apiStatsStore)
const {
statsData,
statsPeriod,
currentPeriodData,
multiKeyMode,
aggregatedStats,
individualStats,
invalidKeys
} = storeToRefs(apiStatsStore)
// 计算前3个贡献最大的 Key
const topContributors = computed(() => {
if (!individualStats.value || individualStats.value.length === 0) return []
return [...individualStats.value]
.sort((a, b) => (b.usage?.allTokens || 0) - (a.usage?.allTokens || 0))
.slice(0, 3)
})
// 计算单个 Key 的贡献占比
const calculateContribution = (stat) => {
if (!aggregatedStats.value || !aggregatedStats.value.usage.allTokens) return 0
const percentage = ((stat.usage?.allTokens || 0) / aggregatedStats.value.usage.allTokens) * 100
return percentage.toFixed(1)
}
// 格式化日期
const formatDate = (dateString) => {

View File

@@ -2,7 +2,7 @@
<div ref="triggerRef" class="relative">
<!-- 选择器主体 -->
<div
class="form-input flex w-full cursor-pointer items-center justify-between dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200"
class="form-input flex w-full cursor-pointer items-center justify-between border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200"
:class="{ 'opacity-50': disabled }"
@click="!disabled && toggleDropdown()"
>
@@ -40,7 +40,7 @@
<input
ref="searchInput"
v-model="searchQuery"
class="form-input w-full text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
class="form-input w-full border-gray-300 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
placeholder="搜索账号名称..."
style="padding-left: 40px; padding-right: 36px"
type="text"
@@ -99,7 +99,13 @@
<div
class="bg-gray-50 px-4 py-2 text-xs font-semibold text-gray-500 dark:bg-gray-700 dark:text-gray-400"
>
{{ platform === 'claude' ? 'Claude OAuth 专属账号' : 'OAuth 专属账号' }}
{{
platform === 'claude'
? 'Claude OAuth 专属账号'
: platform === 'openai'
? 'OpenAI 专属账号'
: 'OAuth 专属账号'
}}
</div>
<div
v-for="account in filteredOAuthAccounts"
@@ -170,6 +176,45 @@
</div>
</div>
<!-- OpenAI-Responses 账号 OpenAI -->
<div v-if="platform === 'openai' && filteredOpenAIResponsesAccounts.length > 0">
<div
class="bg-gray-50 px-4 py-2 text-xs font-semibold text-gray-500 dark:bg-gray-700 dark:text-gray-400"
>
OpenAI-Responses 专属账号
</div>
<div
v-for="account in filteredOpenAIResponsesAccounts"
:key="account.id"
class="cursor-pointer px-4 py-2 transition-colors hover:bg-gray-50 dark:hover:bg-gray-700"
:class="{
'bg-blue-50 dark:bg-blue-900/20': modelValue === `responses:${account.id}`
}"
@click="selectAccount(`responses:${account.id}`)"
>
<div class="flex items-center justify-between">
<div>
<span class="text-gray-700 dark:text-gray-300">{{ account.name }}</span>
<span
class="ml-2 rounded-full px-2 py-0.5 text-xs"
:class="
account.isActive === 'true' || account.isActive === true
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
: account.status === 'rate_limited'
? 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400'
: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'
"
>
{{ getAccountStatusText(account) }}
</span>
</div>
<span class="text-xs text-gray-400 dark:text-gray-500">
{{ formatDate(account.createdAt) }}
</span>
</div>
</div>
</div>
<!-- 无搜索结果 -->
<div
v-if="searchQuery && !hasResults"
@@ -196,7 +241,7 @@ const props = defineProps({
platform: {
type: String,
required: true,
validator: (value) => ['claude', 'gemini'].includes(value)
validator: (value) => ['claude', 'gemini', 'openai', 'bedrock'].includes(value)
},
accounts: {
type: Array,
@@ -251,6 +296,15 @@ const selectedLabel = computed(() => {
return account ? `${account.name} (${getAccountStatusText(account)})` : ''
}
// OpenAI-Responses 账号
if (props.modelValue.startsWith('responses:')) {
const accountId = props.modelValue.substring(10)
const account = props.accounts.find(
(a) => a.id === accountId && a.platform === 'openai-responses'
)
return account ? `${account.name} (${getAccountStatusText(account)})` : ''
}
// OAuth 账号
const account = props.accounts.find((a) => a.id === props.modelValue)
return account ? `${account.name} (${getAccountStatusText(account)})` : ''
@@ -260,8 +314,11 @@ const selectedLabel = computed(() => {
const getAccountStatusText = (account) => {
if (!account) return '未知'
// 处理 OpenAI-Responses 账号isActive 可能是字符串)
const isActive = account.isActive === 'true' || account.isActive === true
// 优先使用 isActive 判断
if (account.isActive === false) {
if (!isActive) {
// 根据 status 提供更详细的状态信息
switch (account.status) {
case 'unauthorized':
@@ -272,11 +329,18 @@ const getAccountStatusText = (account) => {
return '待验证'
case 'rate_limited':
return '限流中'
case 'quota_exceeded':
return '额度超限'
default:
return '异常'
}
}
// 对于激活的账号,如果是限流状态也要显示
if (account.status === 'rate_limited') {
return '限流中'
}
return '正常'
}
@@ -289,22 +353,42 @@ const sortedAccounts = computed(() => {
})
})
// 过滤的分组
// 过滤的分组(根据平台类型过滤)
const filteredGroups = computed(() => {
if (!searchQuery.value) return props.groups
const query = searchQuery.value.toLowerCase()
return props.groups.filter((group) => group.name.toLowerCase().includes(query))
// 只显示与当前平台匹配的分组
let groups = props.groups.filter((group) => {
// 如果分组有platform属性则必须匹配当前平台
// 如果没有platform属性则认为是旧数据根据平台判断
if (group.platform) {
return group.platform === props.platform
}
// 向后兼容如果没有platform字段通过其他方式判断
return true
})
if (searchQuery.value) {
const query = searchQuery.value.toLowerCase()
groups = groups.filter((group) => group.name.toLowerCase().includes(query))
}
return groups
})
// 过滤的 OAuth 账号
const filteredOAuthAccounts = computed(() => {
let accounts = sortedAccounts.value.filter(
(a) =>
a.accountType === 'dedicated' &&
(props.platform === 'claude'
? a.platform === 'claude-oauth'
: a.platform !== 'claude-console')
)
let accounts = []
if (props.platform === 'claude') {
accounts = sortedAccounts.value.filter((a) => a.platform === 'claude-oauth')
} else if (props.platform === 'openai') {
// 对于 OpenAI只显示 openai 类型的账号
accounts = sortedAccounts.value.filter((a) => a.platform === 'openai')
} else {
// 其他平台显示所有非特殊类型的账号
accounts = sortedAccounts.value.filter(
(a) => !['claude-oauth', 'claude-console', 'openai-responses'].includes(a.platform)
)
}
if (searchQuery.value) {
const query = searchQuery.value.toLowerCase()
@@ -318,9 +402,21 @@ const filteredOAuthAccounts = computed(() => {
const filteredConsoleAccounts = computed(() => {
if (props.platform !== 'claude') return []
let accounts = sortedAccounts.value.filter(
(a) => a.accountType === 'dedicated' && a.platform === 'claude-console'
)
let accounts = sortedAccounts.value.filter((a) => a.platform === 'claude-console')
if (searchQuery.value) {
const query = searchQuery.value.toLowerCase()
accounts = accounts.filter((account) => account.name.toLowerCase().includes(query))
}
return accounts
})
// 过滤的 OpenAI-Responses 账号
const filteredOpenAIResponsesAccounts = computed(() => {
if (props.platform !== 'openai') return []
let accounts = sortedAccounts.value.filter((a) => a.platform === 'openai-responses')
if (searchQuery.value) {
const query = searchQuery.value.toLowerCase()
@@ -335,7 +431,8 @@ const hasResults = computed(() => {
return (
filteredGroups.value.length > 0 ||
filteredOAuthAccounts.value.length > 0 ||
filteredConsoleAccounts.value.length > 0
filteredConsoleAccounts.value.length > 0 ||
filteredOpenAIResponsesAccounts.value.length > 0
)
})

View File

@@ -138,57 +138,42 @@ const selectTheme = (mode) => {
.theme-toggle-button {
@apply flex items-center justify-center;
@apply h-9 w-9 rounded-full;
@apply bg-white/80 dark:bg-gray-800/80;
@apply hover:bg-white/90 dark:hover:bg-gray-700/90;
@apply bg-white/90 dark:bg-gray-800/90;
@apply hover:bg-white dark:hover:bg-gray-700;
@apply text-gray-600 dark:text-gray-300;
@apply border border-gray-200/50 dark:border-gray-600/50;
@apply transition-all duration-200 ease-out;
@apply shadow-md backdrop-blur-sm hover:shadow-lg;
/* 移除 backdrop-blur 减少 GPU 负担 */
@apply shadow-md hover:shadow-lg;
@apply hover:scale-110 active:scale-95;
position: relative;
overflow: hidden;
}
/* 添加优雅的光环效果 */
/* 简化的 hover 效果 */
.theme-toggle-button::before {
content: '';
position: absolute;
inset: -2px;
inset: 0;
border-radius: inherit;
background: conic-gradient(
from 180deg at 50% 50%,
rgba(59, 130, 246, 0.2),
rgba(147, 51, 234, 0.2),
rgba(59, 130, 246, 0.2)
);
background: radial-gradient(circle, rgba(59, 130, 246, 0.1), transparent);
opacity: 0;
transition: opacity 0.3s ease;
transition: opacity 0.2s ease;
pointer-events: none;
animation: rotate 3s linear infinite;
}
@keyframes rotate {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.theme-toggle-button:hover::before {
opacity: 0.6;
opacity: 1;
}
/* 图标样式优化 - 更生动 */
/* 图标样式优化 - 简洁高效 */
.theme-toggle-button i {
@apply text-base;
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.1));
transition: all 0.3s cubic-bezier(0.68, -0.55, 0.265, 1.55);
transition: transform 0.2s ease;
}
.theme-toggle-button:hover i {
transform: rotate(180deg) scale(1.1);
transform: scale(1.1);
}
/* 不同主题的图标颜色 */
@@ -300,13 +285,13 @@ const selectTheme = (mode) => {
pointer-events: none;
}
/* 星星装饰(深色模式) */
/* 星星装饰(深色模式) - 优化版 */
.stars {
position: absolute;
width: 100%;
height: 100%;
opacity: 0;
transition: opacity 0.4s ease;
transition: opacity 0.3s ease;
}
.theme-switch.is-dark .stars {
@@ -320,56 +305,42 @@ const selectTheme = (mode) => {
height: 2px;
background: white;
border-radius: 50%;
box-shadow: 0 0 2px white;
animation: twinkle 3s infinite;
opacity: 0.6;
}
.stars span:nth-child(1) {
top: 25%;
left: 20%;
animation-delay: 0s;
}
.stars span:nth-child(2) {
top: 40%;
left: 40%;
animation-delay: 1s;
width: 1.5px;
height: 1.5px;
}
.stars span:nth-child(3) {
top: 60%;
left: 25%;
animation-delay: 2s;
}
@keyframes twinkle {
0%,
100% {
opacity: 0;
transform: scale(0.5);
}
50% {
opacity: 1;
transform: scale(1);
}
}
/* 云朵装饰(浅色模式) */
/* 云朵装饰(浅色模式) - 优化版 */
.clouds {
position: absolute;
width: 100%;
height: 100%;
opacity: 0;
transition: opacity 0.4s ease;
transition: opacity 0.3s ease;
}
.theme-switch:not(.is-dark):not(.is-auto) .clouds {
opacity: 1;
opacity: 0.7;
}
.clouds span {
position: absolute;
background: rgba(255, 255, 255, 0.4);
background: rgba(255, 255, 255, 0.3);
border-radius: 100px;
}
@@ -378,7 +349,6 @@ const selectTheme = (mode) => {
height: 8px;
top: 40%;
left: 15%;
animation: float 4s infinite ease-in-out;
}
.clouds span:nth-child(2) {
@@ -386,18 +356,6 @@ const selectTheme = (mode) => {
height: 6px;
top: 60%;
left: 35%;
animation: float 4s infinite ease-in-out;
animation-delay: 1s;
}
@keyframes float {
0%,
100% {
transform: translateX(0);
}
50% {
transform: translateX(5px);
}
}
/* 切换滑块 */
@@ -428,16 +386,17 @@ const selectTheme = (mode) => {
0 0 0 1px rgba(255, 255, 255, 0.1);
}
/* 自动模式滑块位置 - 玻璃态设计 */
/* 自动模式滑块位置 - 优化后的半透明设计 */
.theme-switch.is-auto .switch-handle {
transform: translateY(-50%) translateX(19px);
background: rgba(255, 255, 255, 0.15);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.3);
background: rgba(255, 255, 255, 0.25);
/* 降低 blur 强度,减少 GPU 负担 */
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
border: 1px solid rgba(255, 255, 255, 0.35);
box-shadow:
0 4px 12px rgba(0, 0, 0, 0.1),
inset 0 0 8px rgba(255, 255, 255, 0.2);
inset 0 0 8px rgba(255, 255, 255, 0.25);
}
/* 滑块图标 */
@@ -471,28 +430,7 @@ const selectTheme = (mode) => {
font-size: 15px;
}
/* 滑块悬停动画 */
.theme-switch:hover .switch-handle {
animation: bounce 0.5s ease;
}
@keyframes bounce {
0%,
100% {
transform: translateY(-50%) translateX(var(--handle-x, 0));
}
50% {
transform: translateY(calc(-50% - 3px)) translateX(var(--handle-x, 0));
}
}
.theme-switch.is-dark:hover .switch-handle {
--handle-x: 38px;
}
.theme-switch.is-auto:hover .switch-handle {
--handle-x: 19px;
}
/* 移除弹跳动画,保持简洁 */
/* 分段按钮样式 - 更现代 */
.theme-segmented {

View File

@@ -20,29 +20,43 @@
</template>
<script setup>
import { ref, watch, nextTick } from 'vue'
import { ref, watch, nextTick, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import AppHeader from './AppHeader.vue'
import TabBar from './TabBar.vue'
const route = useRoute()
const router = useRouter()
const authStore = useAuthStore()
// 根据路由设置当前激活的标签
const activeTab = ref('dashboard')
const tabRouteMap = {
dashboard: '/dashboard',
apiKeys: '/api-keys',
accounts: '/accounts',
tutorial: '/tutorial',
settings: '/settings'
}
// 根据 LDAP 配置动态生成路由映射
const tabRouteMap = computed(() => {
const baseMap = {
dashboard: '/dashboard',
apiKeys: '/api-keys',
accounts: '/accounts',
tutorial: '/tutorial',
settings: '/settings'
}
// 只有在 LDAP 启用时才包含用户管理路由
if (authStore.oemSettings?.ldapEnabled) {
baseMap.userManagement = '/user-management'
}
return baseMap
})
// 初始化当前激活的标签
const initActiveTab = () => {
const currentPath = route.path
const tabKey = Object.keys(tabRouteMap).find((key) => tabRouteMap[key] === currentPath)
const tabKey = Object.keys(tabRouteMap.value).find(
(key) => tabRouteMap.value[key] === currentPath
)
if (tabKey) {
activeTab.value = tabKey
@@ -72,7 +86,7 @@ initActiveTab()
watch(
() => route.path,
(newPath) => {
const tabKey = Object.keys(tabRouteMap).find((key) => tabRouteMap[key] === newPath)
const tabKey = Object.keys(tabRouteMap.value).find((key) => tabRouteMap.value[key] === newPath)
if (tabKey) {
activeTab.value = tabKey
} else {
@@ -95,7 +109,7 @@ watch(
// 处理标签切换
const handleTabChange = async (tabKey) => {
// 如果已经在目标路由,不需要做任何事
if (tabRouteMap[tabKey] === route.path) {
if (tabRouteMap.value[tabKey] === route.path) {
return
}
@@ -104,7 +118,7 @@ const handleTabChange = async (tabKey) => {
// 使用 await 确保路由切换完成
try {
await router.push(tabRouteMap[tabKey])
await router.push(tabRouteMap.value[tabKey])
// 等待下一个DOM更新周期确保组件正确渲染
await nextTick()
} catch (err) {

View File

@@ -37,6 +37,9 @@
</template>
<script setup>
import { computed } from 'vue'
import { useAuthStore } from '@/stores/auth'
defineProps({
activeTab: {
type: String,
@@ -46,13 +49,33 @@ defineProps({
defineEmits(['tab-change'])
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: 'tutorial', name: '使用教程', shortName: '教程', icon: 'fas fa-graduation-cap' },
{ key: 'settings', name: '系统设置', shortName: '设置', icon: 'fas fa-cogs' }
]
const authStore = useAuthStore()
// 根据 LDAP 配置动态生成 tabs
const tabs = computed(() => {
const baseTabs = [
{ 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' }
]
// 只有在 LDAP 启用时才显示用户管理
if (authStore.oemSettings?.ldapEnabled) {
baseTabs.push({
key: 'userManagement',
name: '用户管理',
shortName: '用户',
icon: 'fas fa-users'
})
}
baseTabs.push(
{ key: 'tutorial', name: '使用教程', shortName: '教程', icon: 'fas fa-graduation-cap' },
{ key: 'settings', name: '系统设置', shortName: '设置', icon: 'fas fa-cogs' }
)
return baseTabs
})
</script>
<style scoped>

View File

@@ -0,0 +1,265 @@
<template>
<div
v-if="show"
class="fixed inset-0 z-50 h-full w-full overflow-y-auto bg-gray-600 bg-opacity-50"
>
<div
class="relative top-20 mx-auto w-[768px] max-w-4xl rounded-md border bg-white p-5 shadow-lg"
>
<div class="mt-3">
<div class="mb-4 flex items-center justify-between">
<h3 class="text-lg font-medium text-gray-900">Create New API Key</h3>
<button class="text-gray-400 hover:text-gray-600" @click="$emit('close')">
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
d="M6 18L18 6M6 6l12 12"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
/>
</svg>
</button>
</div>
<form class="space-y-4" @submit.prevent="handleSubmit">
<div>
<label class="block text-sm font-medium text-gray-700" for="name"> Name * </label>
<input
id="name"
v-model="form.name"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
:disabled="loading"
placeholder="Enter API key name"
required
type="text"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700" for="description">
Description
</label>
<textarea
id="description"
v-model="form.description"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
:disabled="loading"
placeholder="Optional description"
rows="3"
></textarea>
</div>
<div v-if="error" class="rounded-md border border-red-200 bg-red-50 p-3">
<div class="flex">
<div class="flex-shrink-0">
<svg class="h-5 w-5 text-red-400" fill="currentColor" viewBox="0 0 20 20">
<path
clip-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
fill-rule="evenodd"
/>
</svg>
</div>
<div class="ml-3">
<p class="text-sm text-red-700">{{ error }}</p>
</div>
</div>
</div>
<div class="flex justify-end space-x-3 pt-4">
<button
class="rounded-md border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:opacity-50"
:disabled="loading"
type="button"
@click="$emit('close')"
>
Cancel
</button>
<button
class="rounded-md border border-transparent bg-blue-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
:disabled="loading || !form.name.trim()"
type="submit"
>
<span v-if="loading" class="flex items-center">
<svg
class="-ml-1 mr-2 h-4 w-4 animate-spin text-white"
fill="none"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
fill="currentColor"
></path>
</svg>
Creating...
</span>
<span v-else>Create API Key</span>
</button>
</div>
</form>
<!-- Success Modal for showing the new API key -->
<div v-if="newApiKey" class="mt-6 rounded-md border border-green-200 bg-green-50 p-4">
<div class="flex items-start">
<div class="flex-shrink-0">
<svg class="h-5 w-5 text-green-400" fill="currentColor" viewBox="0 0 20 20">
<path
clip-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
fill-rule="evenodd"
/>
</svg>
</div>
<div class="ml-3 flex-1">
<h4 class="text-sm font-medium text-green-800">API Key Created Successfully!</h4>
<div class="mt-3">
<p class="mb-2 text-sm text-green-700">
<strong>Important:</strong> Copy your API key now. You won't be able to see it
again!
</p>
<div class="rounded-md border border-green-300 bg-white p-3">
<div class="flex items-center justify-between">
<code class="break-all font-mono text-sm text-gray-900">{{
newApiKey.key
}}</code>
<button
class="ml-3 inline-flex flex-shrink-0 items-center rounded border border-transparent bg-green-100 px-2 py-1 text-xs font-medium text-green-700 hover:bg-green-200 focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2"
@click="copyToClipboard(newApiKey.key)"
>
<svg
class="mr-1 h-3 w-3"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
/>
</svg>
Copy
</button>
</div>
</div>
</div>
<div class="mt-4 flex justify-end">
<button
class="rounded-md border border-transparent bg-green-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2"
@click="handleClose"
>
Done
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, reactive, watch } from 'vue'
import { useUserStore } from '@/stores/user'
import { showToast } from '@/utils/toast'
const props = defineProps({
show: {
type: Boolean,
default: false
}
})
const emit = defineEmits(['close', 'created'])
const userStore = useUserStore()
const loading = ref(false)
const error = ref('')
const newApiKey = ref(null)
const form = reactive({
name: '',
description: ''
})
const resetForm = () => {
form.name = ''
form.description = ''
error.value = ''
newApiKey.value = null
}
const handleSubmit = async () => {
if (!form.name.trim()) {
error.value = 'API key name is required'
return
}
loading.value = true
error.value = ''
try {
const apiKeyData = {
name: form.name.trim(),
description: form.description.trim() || undefined
}
const result = await userStore.createApiKey(apiKeyData)
if (result.success) {
newApiKey.value = result.apiKey
showToast('API key created successfully!', 'success')
} else {
error.value = result.message || 'Failed to create API key'
}
} catch (err) {
console.error('Create API key error:', err)
error.value = err.response?.data?.message || err.message || 'Failed to create API key'
} finally {
loading.value = false
}
}
const copyToClipboard = async (text) => {
try {
await navigator.clipboard.writeText(text)
showToast('API key copied to clipboard!', 'success')
} catch (err) {
console.error('Failed to copy:', err)
showToast('Failed to copy to clipboard', 'error')
}
}
const handleClose = () => {
resetForm()
emit('created')
emit('close')
}
// Reset form when modal is shown
watch(
() => props.show,
(newValue) => {
if (newValue) {
resetForm()
}
}
)
</script>
<style scoped>
/* 组件特定样式 */
</style>

View File

@@ -0,0 +1,354 @@
<template>
<div class="space-y-6">
<div class="sm:flex sm:items-center">
<div class="sm:flex-auto">
<h1 class="text-2xl font-semibold text-gray-900">My API Keys</h1>
<p class="mt-2 text-sm text-gray-700">
Manage your API keys to access Claude Relay services
</p>
</div>
<div class="mt-4 sm:ml-16 sm:mt-0 sm:flex-none">
<button
class="inline-flex items-center justify-center rounded-md border border-transparent bg-blue-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 sm:w-auto"
:disabled="activeApiKeysCount >= maxApiKeys"
@click="showCreateModal = true"
>
<svg class="-ml-1 mr-2 h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
d="M12 6v6m0 0v6m0-6h6m-6 0H6"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
/>
</svg>
Create API Key
</button>
</div>
</div>
<!-- API Keys 数量限制提示 -->
<div
v-if="activeApiKeysCount >= maxApiKeys"
class="rounded-md border border-yellow-200 bg-yellow-50 p-4"
>
<div class="flex">
<div class="flex-shrink-0">
<svg class="h-5 w-5 text-yellow-400" fill="currentColor" viewBox="0 0 20 20">
<path
clip-rule="evenodd"
d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z"
fill-rule="evenodd"
/>
</svg>
</div>
<div class="ml-3">
<p class="text-sm text-yellow-700">
You have reached the maximum number of API keys ({{ maxApiKeys }}). Please delete an
existing key to create a new one.
</p>
</div>
</div>
</div>
<!-- Loading State -->
<div v-if="loading" class="py-12 text-center">
<svg
class="mx-auto h-8 w-8 animate-spin text-blue-600"
fill="none"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
fill="currentColor"
></path>
</svg>
<p class="mt-2 text-sm text-gray-500">Loading API keys...</p>
</div>
<!-- API Keys List -->
<div v-else-if="sortedApiKeys.length > 0" class="overflow-hidden bg-white shadow sm:rounded-md">
<ul class="divide-y divide-gray-200" role="list">
<li v-for="apiKey in sortedApiKeys" :key="apiKey.id" class="px-6 py-4">
<div class="flex items-center justify-between">
<div class="flex items-center">
<div class="flex-shrink-0">
<div
:class="[
'h-2 w-2 rounded-full',
apiKey.isDeleted === 'true' || apiKey.deletedAt
? 'bg-gray-400'
: apiKey.isActive
? 'bg-green-400'
: 'bg-red-400'
]"
></div>
</div>
<div class="ml-4">
<div class="flex items-center">
<p class="text-sm font-medium text-gray-900">{{ apiKey.name }}</p>
<span
v-if="apiKey.isDeleted === 'true' || apiKey.deletedAt"
class="ml-2 inline-flex items-center rounded-full bg-gray-100 px-2.5 py-0.5 text-xs font-medium text-gray-800"
>
Deleted
</span>
<span
v-else-if="!apiKey.isActive"
class="ml-2 inline-flex items-center rounded-full bg-red-100 px-2.5 py-0.5 text-xs font-medium text-red-800"
>
Deleted
</span>
</div>
<div class="mt-1">
<p class="text-sm text-gray-500">{{ apiKey.description || 'No description' }}</p>
<div class="mt-1 flex items-center space-x-4 text-xs text-gray-400">
<span>Created: {{ formatDate(apiKey.createdAt) }}</span>
<span v-if="apiKey.isDeleted === 'true' || apiKey.deletedAt"
>Deleted: {{ formatDate(apiKey.deletedAt) }}</span
>
<span v-else-if="apiKey.lastUsedAt"
>Last used: {{ formatDate(apiKey.lastUsedAt) }}</span
>
<span v-else>Never used</span>
<span
v-if="apiKey.expiresAt && !(apiKey.isDeleted === 'true' || apiKey.deletedAt)"
>Expires: {{ formatDate(apiKey.expiresAt) }}</span
>
</div>
</div>
</div>
</div>
<div class="flex items-center space-x-2">
<!-- Usage Stats -->
<div class="text-right text-xs text-gray-500">
<div>{{ formatNumber(apiKey.usage?.requests || 0) }} requests</div>
<div v-if="apiKey.usage?.totalCost">${{ apiKey.usage.totalCost.toFixed(4) }}</div>
</div>
<!-- Actions -->
<div class="flex items-center space-x-1">
<button
class="inline-flex items-center rounded border border-transparent p-1 text-gray-400 hover:text-gray-600"
title="View API Key"
@click="showApiKey(apiKey)"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
/>
<path
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
/>
</svg>
</button>
<button
v-if="
!(apiKey.isDeleted === 'true' || apiKey.deletedAt) &&
apiKey.isActive &&
allowUserDeleteApiKeys
"
class="inline-flex items-center rounded border border-transparent p-1 text-red-400 hover:text-red-600"
title="Delete API Key"
@click="deleteApiKey(apiKey)"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
/>
</svg>
</button>
</div>
</div>
</div>
</li>
</ul>
</div>
<!-- Empty State -->
<div v-else class="py-12 text-center">
<svg
class="mx-auto h-12 w-12 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
d="M15 7a2 2 0 012 2m0 0a2 2 0 012 2m-2-2h-6m6 0v6a2 2 0 01-2 2H9a2 2 0 01-2-2V9a2 2 0 012-2h6z"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
/>
</svg>
<h3 class="mt-2 text-sm font-medium text-gray-900">No API keys</h3>
<p class="mt-1 text-sm text-gray-500">Get started by creating your first API key.</p>
<div class="mt-6">
<button
class="inline-flex items-center rounded-md border border-transparent bg-blue-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
@click="showCreateModal = true"
>
<svg class="-ml-1 mr-2 h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
d="M12 6v6m0 0v6m0-6h6m-6 0H6"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
/>
</svg>
Create API Key
</button>
</div>
</div>
<!-- Create API Key Modal -->
<CreateApiKeyModal
:show="showCreateModal"
@close="showCreateModal = false"
@created="handleApiKeyCreated"
/>
<!-- View API Key Modal -->
<ViewApiKeyModal
:api-key="selectedApiKey"
:show="showViewModal"
@close="showViewModal = false"
/>
<!-- Confirm Delete Modal -->
<ConfirmModal
confirm-class="bg-red-600 hover:bg-red-700"
confirm-text="Delete"
:message="`Are you sure you want to delete '${selectedApiKey?.name}'? This action cannot be undone.`"
:show="showDeleteModal"
title="Delete API Key"
@cancel="showDeleteModal = false"
@confirm="handleDeleteConfirm"
/>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useUserStore } from '@/stores/user'
import { showToast } from '@/utils/toast'
import CreateApiKeyModal from './CreateApiKeyModal.vue'
import ViewApiKeyModal from './ViewApiKeyModal.vue'
import ConfirmModal from '@/components/common/ConfirmModal.vue'
const userStore = useUserStore()
const loading = ref(true)
const apiKeys = ref([])
const maxApiKeys = computed(() => userStore.config?.maxApiKeysPerUser || 5)
const allowUserDeleteApiKeys = computed(() => userStore.config?.allowUserDeleteApiKeys === true)
const showCreateModal = ref(false)
const showViewModal = ref(false)
const showDeleteModal = ref(false)
const selectedApiKey = ref(null)
// Computed property to sort API keys by creation time (descending - newest first)
const sortedApiKeys = computed(() => {
return [...apiKeys.value].sort((a, b) => {
const dateA = new Date(a.createdAt)
const dateB = new Date(b.createdAt)
return dateB - dateA // Descending order
})
})
// Computed property to count only active (non-deleted) API keys
const activeApiKeysCount = computed(() => {
return apiKeys.value.filter((key) => !(key.isDeleted === 'true' || key.deletedAt)).length
})
const formatNumber = (num) => {
if (num >= 1000000) {
return (num / 1000000).toFixed(1) + 'M'
} else if (num >= 1000) {
return (num / 1000).toFixed(1) + 'K'
}
return num.toString()
}
const formatDate = (dateString) => {
if (!dateString) return null
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
}
const loadApiKeys = async () => {
loading.value = true
try {
apiKeys.value = await userStore.getUserApiKeys(true) // Include deleted keys
} catch (error) {
console.error('Failed to load API keys:', error)
showToast('Failed to load API keys', 'error')
} finally {
loading.value = false
}
}
const showApiKey = (apiKey) => {
selectedApiKey.value = apiKey
showViewModal.value = true
}
const deleteApiKey = (apiKey) => {
selectedApiKey.value = apiKey
showDeleteModal.value = true
}
const handleDeleteConfirm = async () => {
try {
const result = await userStore.deleteApiKey(selectedApiKey.value.id)
if (result.success) {
showToast('API key deleted successfully', 'success')
await loadApiKeys()
}
} catch (error) {
console.error('Failed to delete API key:', error)
showToast('Failed to delete API key', 'error')
} finally {
showDeleteModal.value = false
selectedApiKey.value = null
}
}
const handleApiKeyCreated = async () => {
showCreateModal.value = false
await loadApiKeys()
}
onMounted(() => {
loadApiKeys()
})
</script>
<style scoped>
/* 组件特定样式 */
</style>

View File

@@ -0,0 +1,397 @@
<template>
<div class="space-y-6">
<div class="sm:flex sm:items-center">
<div class="sm:flex-auto">
<h1 class="text-2xl font-semibold text-gray-900">Usage Statistics</h1>
<p class="mt-2 text-sm text-gray-700">View your API usage statistics and costs</p>
</div>
<div class="mt-4 sm:ml-16 sm:mt-0 sm:flex-none">
<select
v-model="selectedPeriod"
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
@change="loadUsageStats"
>
<option value="day">Last 24 Hours</option>
<option value="week">Last 7 Days</option>
<option value="month">Last 30 Days</option>
<option value="quarter">Last 90 Days</option>
</select>
</div>
</div>
<!-- Loading State -->
<div v-if="loading" class="py-12 text-center">
<svg
class="mx-auto h-8 w-8 animate-spin text-blue-600"
fill="none"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
fill="currentColor"
></path>
</svg>
<p class="mt-2 text-sm text-gray-500">Loading usage statistics...</p>
</div>
<!-- Stats Cards -->
<div v-else class="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-4">
<div class="overflow-hidden rounded-lg bg-white shadow">
<div class="p-5">
<div class="flex items-center">
<div class="flex-shrink-0">
<svg
class="h-6 w-6 text-blue-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
d="M13 10V3L4 14h7v7l9-11h-7z"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
/>
</svg>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="truncate text-sm font-medium text-gray-500">Total Requests</dt>
<dd class="text-lg font-medium text-gray-900">
{{ formatNumber(usageStats?.totalRequests || 0) }}
</dd>
</dl>
</div>
</div>
</div>
</div>
<div class="overflow-hidden rounded-lg bg-white shadow">
<div class="p-5">
<div class="flex items-center">
<div class="flex-shrink-0">
<svg
class="h-6 w-6 text-green-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
/>
</svg>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="truncate text-sm font-medium text-gray-500">Input Tokens</dt>
<dd class="text-lg font-medium text-gray-900">
{{ formatNumber(usageStats?.totalInputTokens || 0) }}
</dd>
</dl>
</div>
</div>
</div>
</div>
<div class="overflow-hidden rounded-lg bg-white shadow">
<div class="p-5">
<div class="flex items-center">
<div class="flex-shrink-0">
<svg
class="h-6 w-6 text-purple-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
/>
</svg>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="truncate text-sm font-medium text-gray-500">Output Tokens</dt>
<dd class="text-lg font-medium text-gray-900">
{{ formatNumber(usageStats?.totalOutputTokens || 0) }}
</dd>
</dl>
</div>
</div>
</div>
</div>
<div class="overflow-hidden rounded-lg bg-white shadow">
<div class="p-5">
<div class="flex items-center">
<div class="flex-shrink-0">
<svg
class="h-6 w-6 text-yellow-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
/>
</svg>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="truncate text-sm font-medium text-gray-500">Total Cost</dt>
<dd class="text-lg font-medium text-gray-900">
${{ (usageStats?.totalCost || 0).toFixed(4) }}
</dd>
</dl>
</div>
</div>
</div>
</div>
</div>
<!-- Daily Usage Chart -->
<div v-if="!loading && usageStats" class="rounded-lg bg-white shadow">
<div class="px-4 py-5 sm:p-6">
<h3 class="mb-4 text-lg font-medium leading-6 text-gray-900">Daily Usage Trend</h3>
<!-- Placeholder for chart - you can integrate Chart.js or similar -->
<div
class="flex h-64 items-center justify-center rounded-lg border-2 border-dashed border-gray-300"
>
<div class="text-center">
<svg
class="mx-auto h-12 w-12 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
/>
</svg>
<h3 class="mt-2 text-sm font-medium text-gray-900">Usage Chart</h3>
<p class="mt-1 text-sm text-gray-500">Daily usage trends would be displayed here</p>
<p class="mt-2 text-xs text-gray-400">
(Chart integration can be added with Chart.js, D3.js, or similar library)
</p>
</div>
</div>
</div>
</div>
<!-- Model Usage Breakdown -->
<div
v-if="!loading && usageStats && usageStats.modelStats?.length > 0"
class="rounded-lg bg-white shadow"
>
<div class="px-4 py-5 sm:p-6">
<h3 class="mb-4 text-lg font-medium leading-6 text-gray-900">Usage by Model</h3>
<div class="space-y-3">
<div
v-for="model in usageStats.modelStats"
:key="model.name"
class="flex items-center justify-between"
>
<div class="flex items-center">
<div class="flex-shrink-0">
<div class="h-2 w-2 rounded-full bg-blue-500"></div>
</div>
<div class="ml-3">
<p class="text-sm font-medium text-gray-900">{{ model.name }}</p>
</div>
</div>
<div class="text-right">
<p class="text-sm text-gray-900">{{ formatNumber(model.requests) }} requests</p>
<p class="text-xs text-gray-500">${{ model.cost.toFixed(4) }}</p>
</div>
</div>
</div>
</div>
</div>
<!-- Detailed Usage Table -->
<div v-if="!loading && userApiKeys.length > 0" class="rounded-lg bg-white shadow">
<div class="px-4 py-5 sm:p-6">
<h3 class="mb-4 text-lg font-medium leading-6 text-gray-900">Usage by API Key</h3>
<div class="overflow-hidden">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500"
scope="col"
>
API Key
</th>
<th
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500"
scope="col"
>
Requests
</th>
<th
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500"
scope="col"
>
Input Tokens
</th>
<th
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500"
scope="col"
>
Output Tokens
</th>
<th
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500"
scope="col"
>
Cost
</th>
<th
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500"
scope="col"
>
Status
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 bg-white">
<tr v-for="apiKey in userApiKeys" :key="apiKey.id">
<td class="whitespace-nowrap px-6 py-4">
<div class="text-sm font-medium text-gray-900">{{ apiKey.name }}</div>
<div class="text-sm text-gray-500">{{ apiKey.keyPreview }}</div>
</td>
<td class="whitespace-nowrap px-6 py-4 text-sm text-gray-900">
{{ formatNumber(apiKey.usage?.requests || 0) }}
</td>
<td class="whitespace-nowrap px-6 py-4 text-sm text-gray-900">
{{ formatNumber(apiKey.usage?.inputTokens || 0) }}
</td>
<td class="whitespace-nowrap px-6 py-4 text-sm text-gray-900">
{{ formatNumber(apiKey.usage?.outputTokens || 0) }}
</td>
<td class="whitespace-nowrap px-6 py-4 text-sm text-gray-900">
${{ (apiKey.usage?.totalCost || 0).toFixed(4) }}
</td>
<td class="whitespace-nowrap px-6 py-4">
<span
:class="[
'inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium',
apiKey.isDeleted === 'true' || apiKey.deletedAt
? 'bg-gray-100 text-gray-800'
: apiKey.isActive
? 'bg-green-100 text-green-800'
: 'bg-red-100 text-red-800'
]"
>
{{
apiKey.isDeleted === 'true' || apiKey.deletedAt
? 'Deleted'
: apiKey.isActive
? 'Active'
: 'Disabled'
}}
</span>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- No Data State -->
<div
v-if="!loading && (!usageStats || usageStats.totalRequests === 0)"
class="py-12 text-center"
>
<svg
class="mx-auto h-12 w-12 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
/>
</svg>
<h3 class="mt-2 text-sm font-medium text-gray-900">No usage data</h3>
<p class="mt-1 text-sm text-gray-500">
You haven't made any API requests yet. Create an API key and start using the service to see
usage statistics.
</p>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useUserStore } from '@/stores/user'
import { showToast } from '@/utils/toast'
const userStore = useUserStore()
const loading = ref(true)
const selectedPeriod = ref('week')
const usageStats = ref(null)
const userApiKeys = ref([])
const formatNumber = (num) => {
if (num >= 1000000) {
return (num / 1000000).toFixed(1) + 'M'
} else if (num >= 1000) {
return (num / 1000).toFixed(1) + 'K'
}
return num.toString()
}
const loadUsageStats = async () => {
loading.value = true
try {
const [stats, apiKeys] = await Promise.all([
userStore.getUserUsageStats({ period: selectedPeriod.value }),
userStore.getUserApiKeys(true) // Include deleted keys
])
usageStats.value = stats
userApiKeys.value = apiKeys
} catch (error) {
console.error('Failed to load usage stats:', error)
showToast('Failed to load usage statistics', 'error')
} finally {
loading.value = false
}
}
onMounted(() => {
loadUsageStats()
})
</script>
<style scoped>
/* 组件特定样式 */
</style>

View File

@@ -0,0 +1,250 @@
<template>
<div
v-if="show"
class="fixed inset-0 z-50 h-full w-full overflow-y-auto bg-gray-600 bg-opacity-50"
>
<div
class="relative top-20 mx-auto w-[768px] max-w-4xl rounded-md border bg-white p-5 shadow-lg"
>
<div class="mt-3">
<div class="mb-4 flex items-center justify-between">
<h3 class="text-lg font-medium text-gray-900">API Key Details</h3>
<button class="text-gray-400 hover:text-gray-600" @click="emit('close')">
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
d="M6 18L18 6M6 6l12 12"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
/>
</svg>
</button>
</div>
<div v-if="apiKey" class="space-y-4">
<!-- API Key Name -->
<div>
<label class="block text-sm font-medium text-gray-700">Name</label>
<p class="mt-1 text-sm text-gray-900">{{ apiKey.name }}</p>
</div>
<!-- Description -->
<div v-if="apiKey.description">
<label class="block text-sm font-medium text-gray-700">Description</label>
<p class="mt-1 text-sm text-gray-900">{{ apiKey.description }}</p>
</div>
<!-- API Key -->
<div>
<label class="block text-sm font-medium text-gray-700">API Key</label>
<div class="mt-1 flex items-center space-x-2">
<div class="flex-1">
<div v-if="showFullKey" class="rounded-md border border-gray-300 bg-gray-50 p-3">
<code class="break-all font-mono text-sm text-gray-900">{{
apiKey.key || 'Not available'
}}</code>
</div>
<div v-else class="rounded-md border border-gray-300 bg-gray-50 p-3">
<code class="font-mono text-sm text-gray-900">{{
apiKey.keyPreview || 'cr_****'
}}</code>
</div>
</div>
<div class="flex flex-col space-y-1">
<button
v-if="apiKey.key"
class="inline-flex items-center rounded border border-gray-300 bg-white px-2 py-1 text-xs font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
@click="showFullKey = !showFullKey"
>
<svg
v-if="showFullKey"
class="mr-1 h-3 w-3"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.878 9.878L3 3m6.878 6.878L12 12m-1.122-2.122L12 12m-1.122-2.122l-4.243-4.242m6.879 6.878L15 15"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
/>
</svg>
<svg
v-else
class="mr-1 h-3 w-3"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
/>
<path
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
/>
</svg>
{{ showFullKey ? 'Hide' : 'Show' }}
</button>
<button
v-if="showFullKey && apiKey.key"
class="inline-flex items-center rounded border border-gray-300 bg-white px-2 py-1 text-xs font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
@click="copyToClipboard(apiKey.key)"
>
<svg class="mr-1 h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
/>
</svg>
Copy
</button>
</div>
</div>
<p v-if="!apiKey.key" class="mt-1 text-xs text-gray-500">
Full API key is only shown when first created or regenerated
</p>
</div>
<!-- Status -->
<div>
<label class="block text-sm font-medium text-gray-700">Status</label>
<div class="mt-1">
<span
:class="[
'inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium',
apiKey.isActive ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'
]"
>
{{ apiKey.isActive ? 'Active' : 'Disabled' }}
</span>
</div>
</div>
<!-- Usage Stats -->
<div v-if="apiKey.usage" class="border-t border-gray-200 pt-4">
<label class="mb-2 block text-sm font-medium text-gray-700">Usage Statistics</label>
<div class="grid grid-cols-2 gap-4 text-sm">
<div>
<span class="text-gray-500">Requests:</span>
<span class="ml-2 font-medium">{{ formatNumber(apiKey.usage.requests || 0) }}</span>
</div>
<div>
<span class="text-gray-500">Input Tokens:</span>
<span class="ml-2 font-medium">{{
formatNumber(apiKey.usage.inputTokens || 0)
}}</span>
</div>
<div>
<span class="text-gray-500">Output Tokens:</span>
<span class="ml-2 font-medium">{{
formatNumber(apiKey.usage.outputTokens || 0)
}}</span>
</div>
<div>
<span class="text-gray-500">Total Cost:</span>
<span class="ml-2 font-medium"
>${{ (apiKey.usage.totalCost || 0).toFixed(4) }}</span
>
</div>
</div>
</div>
<!-- Timestamps -->
<div class="space-y-2 border-t border-gray-200 pt-4 text-sm">
<div class="flex justify-between">
<span class="text-gray-500">Created:</span>
<span class="text-gray-900">{{ formatDate(apiKey.createdAt) }}</span>
</div>
<div v-if="apiKey.lastUsedAt" class="flex justify-between">
<span class="text-gray-500">Last Used:</span>
<span class="text-gray-900">{{ formatDate(apiKey.lastUsedAt) }}</span>
</div>
<div v-if="apiKey.expiresAt" class="flex justify-between">
<span class="text-gray-500">Expires:</span>
<span
:class="[
'font-medium',
new Date(apiKey.expiresAt) < new Date() ? 'text-red-600' : 'text-gray-900'
]"
>
{{ formatDate(apiKey.expiresAt) }}
</span>
</div>
</div>
<div class="flex justify-end pt-4">
<button
class="rounded-md border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
@click="emit('close')"
>
Close
</button>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { showToast } from '@/utils/toast'
defineProps({
show: {
type: Boolean,
default: false
},
apiKey: {
type: Object,
default: null
}
})
const emit = defineEmits(['close'])
const showFullKey = ref(false)
const formatNumber = (num) => {
if (num >= 1000000) {
return (num / 1000000).toFixed(1) + 'M'
} else if (num >= 1000) {
return (num / 1000).toFixed(1) + 'K'
}
return num.toString()
}
const formatDate = (dateString) => {
if (!dateString) return null
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
}
const copyToClipboard = async (text) => {
try {
await navigator.clipboard.writeText(text)
showToast('Copied to clipboard!', 'success')
} catch (err) {
console.error('Failed to copy:', err)
showToast('Failed to copy to clipboard', 'error')
}
}
</script>
<style scoped>
/* 组件特定样式 */
</style>

View File

@@ -82,7 +82,16 @@ class ApiClient {
// 如果响应不成功,抛出错误
if (!response.ok) {
throw new Error(data.message || `HTTP ${response.status}`)
// 创建一个包含完整错误信息的错误对象
const error = new Error(data.message || `HTTP ${response.status}`)
// 保留完整的响应数据,以便错误处理时可以访问详细信息
error.response = {
status: response.status,
data: data
}
// 为了向后兼容,也保留原始的 message
error.message = data.message || error.message
throw error
}
return data
@@ -98,9 +107,18 @@ class ApiClient {
// GET 请求
async get(url, options = {}) {
const fullUrl = createApiUrl(url)
// 处理查询参数
let fullUrl = createApiUrl(url)
if (options.params) {
const params = new URLSearchParams(options.params)
fullUrl += '?' + params.toString()
}
// 移除 params 避免传递给 fetch
// eslint-disable-next-line no-unused-vars
const { params, ...configOptions } = options
const config = this.buildConfig({
...options,
...configOptions,
method: 'GET'
})
@@ -149,6 +167,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)

View File

@@ -76,6 +76,22 @@ class ApiStatsClient {
}
}
}
// 批量查询统计数据
async getBatchStats(apiIds) {
return this.request('/apiStats/api/batch-stats', {
method: 'POST',
body: JSON.stringify({ apiIds })
})
}
// 批量查询模型统计
async getBatchModelStats(apiIds, period = 'daily') {
return this.request('/apiStats/api/batch-model-stats', {
method: 'POST',
body: JSON.stringify({ apiIds, period })
})
}
}
export const apiStatsClient = new ApiStatsClient()

View File

@@ -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')

View File

@@ -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) {

View File

@@ -10,6 +10,7 @@ export const useAccountsStore = defineStore('accounts', () => {
const geminiAccounts = ref([])
const openaiAccounts = ref([])
const azureOpenaiAccounts = ref([])
const openaiResponsesAccounts = ref([])
const loading = ref(false)
const error = ref(null)
const sortBy = ref('')
@@ -131,6 +132,25 @@ export const useAccountsStore = defineStore('accounts', () => {
}
}
// 获取OpenAI-Responses账户列表
const fetchOpenAIResponsesAccounts = async () => {
loading.value = true
error.value = null
try {
const response = await apiClient.get('/admin/openai-responses-accounts')
if (response.success) {
openaiResponsesAccounts.value = response.data || []
} else {
throw new Error(response.message || '获取OpenAI-Responses账户失败')
}
} catch (err) {
error.value = err.message
throw err
} finally {
loading.value = false
}
}
// 获取所有账户
const fetchAllAccounts = async () => {
loading.value = true
@@ -142,7 +162,8 @@ export const useAccountsStore = defineStore('accounts', () => {
fetchBedrockAccounts(),
fetchGeminiAccounts(),
fetchOpenAIAccounts(),
fetchAzureOpenAIAccounts()
fetchAzureOpenAIAccounts(),
fetchOpenAIResponsesAccounts()
])
} catch (err) {
error.value = err.message
@@ -272,6 +293,26 @@ export const useAccountsStore = defineStore('accounts', () => {
}
}
// 创建OpenAI-Responses账户
const createOpenAIResponsesAccount = async (data) => {
loading.value = true
error.value = null
try {
const response = await apiClient.post('/admin/openai-responses-accounts', data)
if (response.success) {
await fetchOpenAIResponsesAccounts()
return response.data
} else {
throw new Error(response.message || '创建OpenAI-Responses账户失败')
}
} catch (err) {
error.value = err.message
throw err
} finally {
loading.value = false
}
}
// 更新Claude账户
const updateClaudeAccount = async (id, data) => {
loading.value = true
@@ -392,6 +433,26 @@ export const useAccountsStore = defineStore('accounts', () => {
}
}
// 更新OpenAI-Responses账户
const updateOpenAIResponsesAccount = async (id, data) => {
loading.value = true
error.value = null
try {
const response = await apiClient.put(`/admin/openai-responses-accounts/${id}`, data)
if (response.success) {
await fetchOpenAIResponsesAccounts()
return response
} else {
throw new Error(response.message || '更新OpenAI-Responses账户失败')
}
} catch (err) {
error.value = err.message
throw err
} finally {
loading.value = false
}
}
// 切换账户状态
const toggleAccount = async (platform, id) => {
loading.value = true
@@ -410,6 +471,8 @@ export const useAccountsStore = defineStore('accounts', () => {
endpoint = `/admin/openai-accounts/${id}/toggle`
} else if (platform === 'azure_openai') {
endpoint = `/admin/azure-openai-accounts/${id}/toggle`
} else if (platform === 'openai-responses') {
endpoint = `/admin/openai-responses-accounts/${id}/toggle`
} else {
endpoint = `/admin/openai-accounts/${id}/toggle`
}
@@ -428,6 +491,8 @@ export const useAccountsStore = defineStore('accounts', () => {
await fetchOpenAIAccounts()
} else if (platform === 'azure_openai') {
await fetchAzureOpenAIAccounts()
} else if (platform === 'openai-responses') {
await fetchOpenAIResponsesAccounts()
} else {
await fetchOpenAIAccounts()
}
@@ -461,6 +526,8 @@ export const useAccountsStore = defineStore('accounts', () => {
endpoint = `/admin/openai-accounts/${id}`
} else if (platform === 'azure_openai') {
endpoint = `/admin/azure-openai-accounts/${id}`
} else if (platform === 'openai-responses') {
endpoint = `/admin/openai-responses-accounts/${id}`
} else {
endpoint = `/admin/openai-accounts/${id}`
}
@@ -479,6 +546,8 @@ export const useAccountsStore = defineStore('accounts', () => {
await fetchOpenAIAccounts()
} else if (platform === 'azure_openai') {
await fetchAzureOpenAIAccounts()
} else if (platform === 'openai-responses') {
await fetchOpenAIResponsesAccounts()
} else {
await fetchOpenAIAccounts()
}
@@ -658,6 +727,7 @@ export const useAccountsStore = defineStore('accounts', () => {
geminiAccounts.value = []
openaiAccounts.value = []
azureOpenaiAccounts.value = []
openaiResponsesAccounts.value = []
loading.value = false
error.value = null
sortBy.value = ''
@@ -672,6 +742,7 @@ export const useAccountsStore = defineStore('accounts', () => {
geminiAccounts,
openaiAccounts,
azureOpenaiAccounts,
openaiResponsesAccounts,
loading,
error,
sortBy,
@@ -684,6 +755,7 @@ export const useAccountsStore = defineStore('accounts', () => {
fetchGeminiAccounts,
fetchOpenAIAccounts,
fetchAzureOpenAIAccounts,
fetchOpenAIResponsesAccounts,
fetchAllAccounts,
createClaudeAccount,
createClaudeConsoleAccount,
@@ -691,12 +763,14 @@ export const useAccountsStore = defineStore('accounts', () => {
createGeminiAccount,
createOpenAIAccount,
createAzureOpenAIAccount,
createOpenAIResponsesAccount,
updateClaudeAccount,
updateClaudeConsoleAccount,
updateBedrockAccount,
updateGeminiAccount,
updateOpenAIAccount,
updateAzureOpenAIAccount,
updateOpenAIResponsesAccount,
toggleAccount,
deleteAccount,
refreshClaudeToken,

View File

@@ -21,6 +21,14 @@ export const useApiStatsStore = defineStore('apistats', () => {
siteIconData: ''
})
// 多 Key 模式相关状态
const multiKeyMode = ref(false)
const apiKeys = ref([]) // 多个 API Key 数组
const apiIds = ref([]) // 对应的 ID 数组
const aggregatedStats = ref(null) // 聚合后的统计数据
const individualStats = ref([]) // 各个 Key 的独立数据
const invalidKeys = ref([]) // 无效的 Keys 列表
// 计算属性
const currentPeriodData = computed(() => {
const defaultData = {
@@ -34,6 +42,16 @@ export const useApiStatsStore = defineStore('apistats', () => {
formattedCost: '$0.000000'
}
// 聚合模式下使用聚合数据
if (multiKeyMode.value && aggregatedStats.value) {
if (statsPeriod.value === 'daily') {
return aggregatedStats.value.dailyUsage || defaultData
} else {
return aggregatedStats.value.monthlyUsage || defaultData
}
}
// 单个 Key 模式下使用原有逻辑
if (statsPeriod.value === 'daily') {
return dailyStats.value || defaultData
} else {
@@ -69,6 +87,11 @@ export const useApiStatsStore = defineStore('apistats', () => {
// 查询统计数据
async function queryStats() {
// 多 Key 模式处理
if (multiKeyMode.value) {
return queryBatchStats()
}
if (!apiKey.value.trim()) {
error.value = '请输入 API Key'
return
@@ -204,6 +227,12 @@ export const useApiStatsStore = defineStore('apistats', () => {
statsPeriod.value = period
// 多 Key 模式下加载批量模型统计
if (multiKeyMode.value && apiIds.value.length > 0) {
await loadBatchModelStats(period)
return
}
// 如果对应时间段的数据还没有加载,则加载它
if (
(period === 'daily' && !dailyStats.value) ||
@@ -297,6 +326,127 @@ export const useApiStatsStore = defineStore('apistats', () => {
}
}
// 批量查询统计数据
async function queryBatchStats() {
const keys = parseApiKeys()
if (keys.length === 0) {
error.value = '请输入至少一个有效的 API Key'
return
}
loading.value = true
error.value = ''
aggregatedStats.value = null
individualStats.value = []
invalidKeys.value = []
modelStats.value = []
apiKeys.value = keys
apiIds.value = []
try {
// 批量获取 API Key IDs
const idResults = await Promise.allSettled(keys.map((key) => apiStatsClient.getKeyId(key)))
const validIds = []
const validKeys = []
idResults.forEach((result, index) => {
if (result.status === 'fulfilled' && result.value.success) {
validIds.push(result.value.data.id)
validKeys.push(keys[index])
} else {
invalidKeys.value.push(keys[index])
}
})
if (validIds.length === 0) {
throw new Error('所有 API Key 都无效')
}
apiIds.value = validIds
apiKeys.value = validKeys
// 批量查询统计数据
const batchResult = await apiStatsClient.getBatchStats(validIds)
if (batchResult.success) {
aggregatedStats.value = batchResult.data.aggregated
individualStats.value = batchResult.data.individual
statsData.value = batchResult.data.aggregated // 兼容现有组件
// 设置聚合模式下的日期统计数据,以保证现有组件的兼容性
dailyStats.value = batchResult.data.aggregated.dailyUsage || null
monthlyStats.value = batchResult.data.aggregated.monthlyUsage || null
// 加载聚合的模型统计
await loadBatchModelStats(statsPeriod.value)
// 更新 URL
updateBatchURL()
} else {
throw new Error(batchResult.message || '批量查询失败')
}
} catch (err) {
console.error('Batch query error:', err)
error.value = err.message || '批量查询统计数据失败'
aggregatedStats.value = null
individualStats.value = []
} finally {
loading.value = false
}
}
// 加载批量模型统计
async function loadBatchModelStats(period = 'daily') {
if (apiIds.value.length === 0) return
modelStatsLoading.value = true
try {
const result = await apiStatsClient.getBatchModelStats(apiIds.value, period)
if (result.success) {
modelStats.value = result.data || []
} else {
throw new Error(result.message || '加载批量模型统计失败')
}
} catch (err) {
console.error('Load batch model stats error:', err)
modelStats.value = []
} finally {
modelStatsLoading.value = false
}
}
// 解析 API Keys
function parseApiKeys() {
if (!apiKey.value) return []
const keys = apiKey.value
.split(/[,\n]+/)
.map((key) => key.trim())
.filter((key) => key.length > 0)
// 去重并限制最多30个
const uniqueKeys = [...new Set(keys)]
return uniqueKeys.slice(0, 30)
}
// 更新批量查询 URL
function updateBatchURL() {
if (apiIds.value.length > 0) {
const url = new URL(window.location)
url.searchParams.set('apiIds', apiIds.value.join(','))
url.searchParams.set('batch', 'true')
window.history.pushState({}, '', url)
}
}
// 清空输入
function clearInput() {
apiKey.value = ''
}
// 清除数据
function clearData() {
statsData.value = null
@@ -306,11 +456,18 @@ export const useApiStatsStore = defineStore('apistats', () => {
error.value = ''
statsPeriod.value = 'daily'
apiId.value = null
// 清除多 Key 模式数据
apiKeys.value = []
apiIds.value = []
aggregatedStats.value = null
individualStats.value = []
invalidKeys.value = []
}
// 重置
function reset() {
apiKey.value = ''
multiKeyMode.value = false
clearData()
}
@@ -328,6 +485,13 @@ export const useApiStatsStore = defineStore('apistats', () => {
dailyStats,
monthlyStats,
oemSettings,
// 多 Key 模式状态
multiKeyMode,
apiKeys,
apiIds,
aggregatedStats,
individualStats,
invalidKeys,
// Computed
currentPeriodData,
@@ -335,13 +499,16 @@ export const useApiStatsStore = defineStore('apistats', () => {
// Actions
queryStats,
queryBatchStats,
loadAllPeriodStats,
loadPeriodStats,
loadModelStats,
loadBatchModelStats,
switchPeriod,
loadStatsWithApiId,
loadOemSettings,
clearData,
clearInput,
reset
}
})

View File

@@ -8,6 +8,7 @@ export const useSettingsStore = defineStore('settings', () => {
siteName: 'Claude Relay Service',
siteIcon: '',
siteIconData: '',
showAdminButton: true, // 控制管理后台按钮的显示
updatedAt: null
})
@@ -64,6 +65,7 @@ export const useSettingsStore = defineStore('settings', () => {
siteName: 'Claude Relay Service',
siteIcon: '',
siteIconData: '',
showAdminButton: true,
updatedAt: null
}

View File

@@ -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)
}
)
}
}
})

View File

@@ -31,6 +31,9 @@ export function showToast(message, type = 'info', title = '', duration = 3000) {
info: 'fas fa-info-circle'
}
// 处理消息中的换行符,转换为 HTML 换行
const formattedMessage = message.replace(/\n/g, '<br>')
toast.innerHTML = `
<div class="flex items-start gap-3">
<div class="flex-shrink-0 mt-0.5">
@@ -38,7 +41,7 @@ export function showToast(message, type = 'info', title = '', duration = 3000) {
</div>
<div class="flex-1 min-w-0">
${title ? `<h4 class="font-semibold text-sm mb-1">${title}</h4>` : ''}
<p class="text-sm opacity-90 leading-relaxed">${message}</p>
<p class="text-sm opacity-90 leading-relaxed">${formattedMessage}</p>
</div>
<button onclick="this.parentElement.parentElement.remove()"
class="flex-shrink-0 text-white/70 hover:text-white transition-colors ml-2">

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More