hequ commited on
Commit
69b897d
·
verified ·
1 Parent(s): e2a80b8

Upload 221 files

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .dockerignore +78 -0
  2. .env.example +113 -0
  3. .eslintrc.cjs +85 -0
  4. .github/AUTO_RELEASE_GUIDE.md +164 -0
  5. .github/DOCKER_HUB_SETUP.md +109 -0
  6. .github/FUNDING.yml +12 -0
  7. .github/RELEASE_PROCESS.md +94 -0
  8. .github/TELEGRAM_SETUP.md +110 -0
  9. .github/WORKFLOW_USAGE.md +129 -0
  10. .github/cliff.toml +68 -0
  11. .github/secret_scanning.yml +6 -0
  12. .github/workflows/auto-release-pipeline.yml +490 -0
  13. .github/workflows/pr-lint-check.yml +320 -0
  14. .gitignore +251 -0
  15. .prettierrc +14 -0
  16. CLAUDE.md +275 -0
  17. Dockerfile +71 -0
  18. LICENSE +21 -0
  19. Makefile +259 -0
  20. README.md +938 -7
  21. README_EN.md +560 -0
  22. VERSION +1 -0
  23. cli/index.js +1025 -0
  24. config/config.example.js +185 -0
  25. docker-compose.yml +165 -0
  26. docker-entrypoint.sh +65 -0
  27. nodemon.json +5 -0
  28. package-lock.json +0 -0
  29. package.json +101 -0
  30. resources/model-pricing/README.md +37 -0
  31. resources/model-pricing/model_prices_and_context_window.json +0 -0
  32. scripts/analyze-log-sessions.js +606 -0
  33. scripts/check-redis-keys.js +53 -0
  34. scripts/data-transfer-enhanced.js +1132 -0
  35. scripts/data-transfer.js +738 -0
  36. scripts/debug-redis-keys.js +126 -0
  37. scripts/fix-inquirer.js +29 -0
  38. scripts/fix-usage-stats.js +227 -0
  39. scripts/generate-test-data.js +280 -0
  40. scripts/manage-session-windows.js +561 -0
  41. scripts/manage.js +333 -0
  42. scripts/manage.sh +1757 -0
  43. scripts/migrate-apikey-expiry.js +192 -0
  44. scripts/monitor-enhanced.sh +273 -0
  45. scripts/setup.js +128 -0
  46. scripts/status-unified.sh +262 -0
  47. scripts/test-account-display.js +143 -0
  48. scripts/test-api-response.js +141 -0
  49. scripts/test-bedrock-models.js +33 -0
  50. scripts/test-dedicated-accounts.js +133 -0
.dockerignore ADDED
@@ -0,0 +1,78 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Dependencies
2
+ node_modules/
3
+ npm-debug.log*
4
+ yarn-debug.log*
5
+ yarn-error.log*
6
+
7
+ # Environment files
8
+ .env.local
9
+ .env.*.local
10
+
11
+ # Logs
12
+ logs/
13
+ *.log
14
+
15
+ # Data files
16
+ data/
17
+ temp/
18
+
19
+ # Git
20
+ .git/
21
+ .gitignore
22
+ .gitattributes
23
+
24
+ # GitHub
25
+ .github/
26
+
27
+ # Documentation
28
+ README.md
29
+ README_EN.md
30
+ CHANGELOG.md
31
+ docs/
32
+ *.md
33
+
34
+ # Development files
35
+ .vscode/
36
+ .idea/
37
+ *.swp
38
+ *.swo
39
+ *~
40
+ .DS_Store
41
+
42
+ # Docker files
43
+ docker-compose.yml
44
+ docker-compose.*.yml
45
+ Dockerfile
46
+ .dockerignore
47
+
48
+ # Test files
49
+ test/
50
+ tests/
51
+ __tests__/
52
+ *.test.js
53
+ *.spec.js
54
+ coverage/
55
+ .nyc_output/
56
+
57
+ # Build files
58
+ # dist/ # 前端构建阶段需要复制源文件,所以不能忽略
59
+ build/
60
+ *.pid
61
+ *.seed
62
+ *.pid.lock
63
+
64
+ # 但可以忽略本地已构建的 dist 目录
65
+ web/admin-spa/dist/
66
+
67
+ # CI/CD
68
+ .travis.yml
69
+ .gitlab-ci.yml
70
+ azure-pipelines.yml
71
+
72
+ # Package manager files
73
+ # package-lock.json # 需要保留此文件以支持 npm ci
74
+ yarn.lock
75
+ pnpm-lock.yaml
76
+
77
+ # CLI
78
+ cli/
.env.example ADDED
@@ -0,0 +1,113 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # 🚀 Claude Relay Service Configuration
2
+
3
+ # 🌐 服务器配置
4
+ PORT=3000
5
+ HOST=0.0.0.0
6
+ NODE_ENV=production
7
+
8
+ # 🔐 安全配置
9
+ JWT_SECRET=your-jwt-secret-here
10
+ ADMIN_SESSION_TIMEOUT=86400000
11
+ API_KEY_PREFIX=cr_
12
+ ENCRYPTION_KEY=your-encryption-key-here
13
+
14
+ # 👤 管理员凭据(可选,不设置则自动生成)
15
+ # ADMIN_USERNAME=cr_admin_custom
16
+ # ADMIN_PASSWORD=your-secure-password
17
+
18
+ # 📊 Redis 配置
19
+ REDIS_HOST=localhost
20
+ REDIS_PORT=6379
21
+ REDIS_PASSWORD=
22
+ REDIS_DB=0
23
+ REDIS_ENABLE_TLS=
24
+
25
+ # 🔗 会话管理配置
26
+ # 粘性会话TTL配置(小时),默认1小时
27
+ STICKY_SESSION_TTL_HOURS=1
28
+ # 续期阈值(分钟),默认0分钟(不续期)
29
+ STICKY_SESSION_RENEWAL_THRESHOLD_MINUTES=15
30
+
31
+ # 🎯 Claude API 配置
32
+ CLAUDE_API_URL=https://api.anthropic.com/v1/messages
33
+ CLAUDE_API_VERSION=2023-06-01
34
+ CLAUDE_BETA_HEADER=claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14
35
+
36
+ # 🚫 529错误处理配置
37
+ # 启用529错误处理,0表示禁用,>0表示过载状态持续时间(分钟)
38
+ CLAUDE_OVERLOAD_HANDLING_MINUTES=0
39
+
40
+ # 🌐 代理配置
41
+ DEFAULT_PROXY_TIMEOUT=600000
42
+ MAX_PROXY_RETRIES=3
43
+ # IP协议族配置:true=IPv4, false=IPv6, 默认IPv4(兼容性更好)
44
+ PROXY_USE_IPV4=true
45
+
46
+ # ⏱️ 请求超时配置
47
+ REQUEST_TIMEOUT=600000 # 请求超时设置(毫秒),默认10分钟
48
+
49
+ # 📈 使用限制
50
+ DEFAULT_TOKEN_LIMIT=1000000
51
+
52
+ # 📝 日志配置
53
+ LOG_LEVEL=info
54
+ LOG_MAX_SIZE=10m
55
+ LOG_MAX_FILES=5
56
+
57
+ # 🔧 系统配置
58
+ CLEANUP_INTERVAL=3600000
59
+ TOKEN_USAGE_RETENTION=2592000000
60
+ HEALTH_CHECK_INTERVAL=60000
61
+ TIMEZONE_OFFSET=8 # UTC偏移小时数,默认+8(中国时区)
62
+ METRICS_WINDOW=5 # 实时指标统计窗口(分钟),可选1-60,默认5分钟
63
+
64
+ # 🎨 Web 界面配置
65
+ WEB_TITLE=Claude Relay Service
66
+ WEB_DESCRIPTION=Multi-account Claude API relay service with beautiful management interface
67
+ WEB_LOGO_URL=/assets/logo.png
68
+
69
+ # 🛠️ 开发配置
70
+ DEBUG=false
71
+ DEBUG_HTTP_TRAFFIC=false # 启用HTTP请求/响应调试日志(仅开发环境)
72
+ ENABLE_CORS=true
73
+ TRUST_PROXY=true
74
+
75
+ # 🔒 客户端限制(可选)
76
+ # ALLOW_CUSTOM_CLIENTS=false
77
+
78
+ # 🔐 LDAP 认证配置
79
+ LDAP_ENABLED=false
80
+ LDAP_URL=ldaps://ldap-1.test1.bj.yxops.net:636
81
+ LDAP_BIND_DN=cn=admin,dc=example,dc=com
82
+ LDAP_BIND_PASSWORD=admin_password
83
+ LDAP_SEARCH_BASE=dc=example,dc=com
84
+ LDAP_SEARCH_FILTER=(uid={{username}})
85
+ LDAP_SEARCH_ATTRIBUTES=dn,uid,cn,mail,givenName,sn
86
+ LDAP_TIMEOUT=5000
87
+ LDAP_CONNECT_TIMEOUT=10000
88
+
89
+ # 🔒 LDAP TLS/SSL 配置 (用于 ldaps:// URL)
90
+ # 是否忽略证书验证错误 (设置为false可忽略自签名证书错误)
91
+ LDAP_TLS_REJECT_UNAUTHORIZED=true
92
+ # CA 证书文件路径 (可选,用于自定义CA证书)
93
+ # LDAP_TLS_CA_FILE=/path/to/ca-cert.pem
94
+ # 客户端证书文件路径 (可选,用于双向认证)
95
+ # LDAP_TLS_CERT_FILE=/path/to/client-cert.pem
96
+ # 客户端私钥文件路径 (可选,用于双向认证)
97
+ # LDAP_TLS_KEY_FILE=/path/to/client-key.pem
98
+ # 服务器名称 (可选,用于 SNI)
99
+ # LDAP_TLS_SERVERNAME=ldap.example.com
100
+
101
+ # 🗺️ LDAP 用户属性映射
102
+ LDAP_USER_ATTR_USERNAME=uid
103
+ LDAP_USER_ATTR_DISPLAY_NAME=cn
104
+ LDAP_USER_ATTR_EMAIL=mail
105
+ LDAP_USER_ATTR_FIRST_NAME=givenName
106
+ LDAP_USER_ATTR_LAST_NAME=sn
107
+
108
+ # 👥 用户管理配置
109
+ USER_MANAGEMENT_ENABLED=false
110
+ DEFAULT_USER_ROLE=user
111
+ USER_SESSION_TIMEOUT=86400000
112
+ MAX_API_KEYS_PER_USER=1
113
+ ALLOW_USER_DELETE_API_KEYS=false
.eslintrc.cjs ADDED
@@ -0,0 +1,85 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ module.exports = {
2
+ root: true,
3
+ env: {
4
+ node: true,
5
+ es2021: true,
6
+ commonjs: true
7
+ },
8
+ extends: ['eslint:recommended', 'plugin:prettier/recommended'],
9
+ parserOptions: {
10
+ sourceType: 'module',
11
+ ecmaVersion: 'latest'
12
+ },
13
+ plugins: ['prettier'],
14
+ rules: {
15
+ // 基础规则
16
+ 'no-console': 'off', // Node.js 项目允许 console
17
+ 'consistent-return': 'off',
18
+ 'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'warn',
19
+ 'prettier/prettier': 'error',
20
+
21
+ // 变量相关
22
+ 'no-unused-vars': [
23
+ 'error',
24
+ {
25
+ argsIgnorePattern: '^_',
26
+ varsIgnorePattern: '^_',
27
+ caughtErrors: 'none'
28
+ }
29
+ ],
30
+ 'prefer-const': 'error',
31
+ 'no-var': 'error',
32
+ 'no-shadow': 'error',
33
+
34
+ // 代码质量
35
+ eqeqeq: ['error', 'always'],
36
+ curly: ['error', 'all'],
37
+ 'no-throw-literal': 'error',
38
+ 'prefer-promise-reject-errors': 'error',
39
+
40
+ // 代码风格
41
+ 'object-shorthand': 'error',
42
+ 'prefer-template': 'error',
43
+ 'template-curly-spacing': ['error', 'never'],
44
+
45
+ // Node.js 特定规则
46
+ 'no-path-concat': 'error',
47
+ 'handle-callback-err': 'error',
48
+
49
+ // ES6+ 规则
50
+ 'arrow-body-style': ['error', 'as-needed'],
51
+ 'prefer-arrow-callback': 'error',
52
+ 'prefer-destructuring': [
53
+ 'error',
54
+ {
55
+ array: false,
56
+ object: true
57
+ }
58
+ ],
59
+
60
+ // 格式化规则(由 Prettier 处理)
61
+ semi: 'off',
62
+ quotes: 'off',
63
+ indent: 'off',
64
+ 'comma-dangle': 'off'
65
+ },
66
+ overrides: [
67
+ {
68
+ // CLI 和脚本文件
69
+ files: ['cli/**/*.js', 'scripts/**/*.js'],
70
+ rules: {
71
+ 'no-process-exit': 'off' // CLI 脚本允许 process.exit
72
+ }
73
+ },
74
+ {
75
+ // 测试文件
76
+ files: ['**/*.test.js', '**/*.spec.js', 'tests/**/*.js'],
77
+ env: {
78
+ jest: true
79
+ },
80
+ rules: {
81
+ 'no-unused-expressions': 'off'
82
+ }
83
+ }
84
+ ]
85
+ }
.github/AUTO_RELEASE_GUIDE.md ADDED
@@ -0,0 +1,164 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # 自动版本发布指南
2
+
3
+ ## 📋 概述
4
+
5
+ 本项目配置了自动版本发布功能,每次推送到 `main` 分支时会自动递增版本号并创建 GitHub Release。
6
+
7
+ ## 🚀 工作原理
8
+
9
+ ### 自动版本递增规则
10
+
11
+ - **版本格式**: `v<major>.<minor>.<patch>` (例如: v1.0.2)
12
+ - **递增规则**: 每次推送到 main 分支,自动递增 patch 版本号
13
+ - v1.0.1 → v1.0.2
14
+ - v1.0.9 → v1.0.10
15
+ - v1.0.99 → v1.0.100
16
+
17
+ ### 触发条件
18
+
19
+ 当满足以下条件时,会自动创建新版本:
20
+
21
+ 1. 推送到 `main` 分支
22
+ 2. 有实际的代码变更(不包括纯文档更新)
23
+ 3. 自上次发布以来有新的提交
24
+
25
+ ## 📝 使用方法
26
+
27
+ ### 1. 常规开发流程
28
+
29
+ ```bash
30
+ # 在 dev 分支开发
31
+ git checkout dev
32
+ # ... 进行开发 ...
33
+ git add .
34
+ git commit -m "feat: 添加新功能"
35
+ git push origin dev
36
+
37
+ # 合并到 main 分支
38
+ git checkout main
39
+ git merge dev
40
+ git push origin main # 这会触发自动发布
41
+ ```
42
+
43
+ ### 2. 跳过自动发布
44
+
45
+ 如果你的提交不想触发自动发布,在 commit 消息中添加 `[skip ci]`:
46
+
47
+ ```bash
48
+ git commit -m "docs: 更新文档 [skip ci]"
49
+ ```
50
+
51
+ ### 3. 手动控制版本号
52
+
53
+ 如果需要发布大版本或中版本更新:
54
+
55
+ ```bash
56
+ # 大版本更新 (1.0.x → 2.0.0)
57
+ git tag -a v2.0.0 -m "Major release v2.0.0"
58
+ git push origin v2.0.0
59
+
60
+ # 中版本更新 (1.0.x → 1.1.0)
61
+ git tag -a v1.1.0 -m "Minor release v1.1.0"
62
+ git push origin v1.1.0
63
+ ```
64
+
65
+ ## 🔧 配置说明
66
+
67
+ ### 工作流文件
68
+
69
+ - **位置**: `.github/workflows/auto-release.yml`
70
+ - **功能**:
71
+ - 获取最新版本标签
72
+ - 计算下一个版本号
73
+ - 生成 changelog
74
+ - 创建 GitHub Release
75
+ - 更新 CHANGELOG.md 文件
76
+ - 发送 Telegram 通知(可选)
77
+
78
+ ### Changelog 生成
79
+
80
+ 使用 [git-cliff](https://github.com/orhun/git-cliff) 自动生成更新日志:
81
+
82
+ - **配置文件**: `.github/cliff.toml`
83
+ - **提交规范**: 遵循 [Conventional Commits](https://www.conventionalcommits.org/)
84
+ - `feat:` 新功能
85
+ - `fix:` Bug 修复
86
+ - `docs:` 文档更新
87
+ - `chore:` 其他变更
88
+ - `refactor:` 代码重构
89
+ - `perf:` 性能优化
90
+
91
+ ## 📊 查看发布历史
92
+
93
+ 1. **GitHub Releases 页面**:
94
+ - 访问 `https://github.com/<owner>/<repo>/releases`
95
+ - 查看所有发布版本和更新内容
96
+
97
+ 2. **CHANGELOG.md**:
98
+ - 项目根目录的 `CHANGELOG.md` 文件
99
+ - 包含完整的版本历史
100
+
101
+ ## ❓ 常见问题
102
+
103
+ ### Q: 如何查看当前版本?
104
+
105
+ ```bash
106
+ # 查看最新标签
107
+ git describe --tags --abbrev=0
108
+
109
+ # 查看所有标签
110
+ git tag -l
111
+ ```
112
+
113
+ ### Q: 自动发布失败怎么办?
114
+
115
+ 1. 检查 GitHub Actions 日志
116
+ 2. 确认是否有权限创建标签和发布
117
+ 3. 检查是否有语法错误
118
+
119
+ ### Q: 如何回滚版本?
120
+
121
+ 自动发布只是创建标签和 Release,不会影响代码:
122
+
123
+ ```bash
124
+ # 回滚到特定版本
125
+ git checkout v1.0.1
126
+
127
+ # 或者使用 Docker 镜像的特定版本
128
+ docker pull weishaw/claude-relay-service:v1.0.1
129
+ ```
130
+
131
+ ### Q: 如何修改版本递增规则?
132
+
133
+ 编辑 `.github/workflows/auto-release.yml` 中的版本计算逻辑:
134
+
135
+ ```yaml
136
+ # 当前是递增 patch 版本
137
+ NEW_PATCH=$((PATCH + 1))
138
+
139
+ # 可以改为递增 minor 版本
140
+ NEW_MINOR=$((MINOR + 1))
141
+ NEW_PATCH=0
142
+ ```
143
+
144
+ ## 📱 Telegram 通知(可选)
145
+
146
+ 自动发布系统支持发送通知到 Telegram 频道。配置后,每次发布新版本都会自动发送通知。
147
+
148
+ ### 快速设置
149
+
150
+ 1. 创建 Telegram Bot(通过 @BotFather)
151
+ 2. 将 Bot 添加到频道作为管理员
152
+ 3. 获取频道的 Chat ID
153
+ 4. 在 GitHub 仓库添加 Secrets:
154
+ - `TELEGRAM_BOT_TOKEN`
155
+ - `TELEGRAM_CHAT_ID`
156
+
157
+ 详细设置步骤请参考 [Telegram 通知设置指南](./TELEGRAM_SETUP.md)
158
+
159
+ ## 🔗 相关链接
160
+
161
+ - [GitHub Actions 工作流使用指南](./WORKFLOW_USAGE.md)
162
+ - [Telegram 通知设置指南](./TELEGRAM_SETUP.md)
163
+ - [Docker Hub 设置指南](./DOCKER_HUB_SETUP.md)
164
+ - [Git Cliff 配置文档](https://git-cliff.org/docs/configuration)
.github/DOCKER_HUB_SETUP.md ADDED
@@ -0,0 +1,109 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Docker Hub 自动发布配置指南
2
+
3
+ 本文档说明如何配置 GitHub Actions 自动构建并发布 Docker 镜像到 Docker Hub。
4
+
5
+ ## 📋 前置要求
6
+
7
+ 1. Docker Hub 账号
8
+ 2. GitHub 仓库的管理员权限
9
+
10
+ ## 🔐 配置 GitHub Secrets
11
+
12
+ 在 GitHub 仓库中配置以下 secrets:
13
+
14
+ 1. 进入仓库设置:`Settings` → `Secrets and variables` → `Actions`
15
+ 2. 点击 `New repository secret`
16
+ 3. 添加以下 secrets:
17
+
18
+ ### 必需的 Secrets
19
+
20
+ | Secret 名称 | 说明 | 如何获取 |
21
+ |------------|------|---------|
22
+ | `DOCKERHUB_USERNAME` | Docker Hub 用户名 | 你的 Docker Hub 登录用户名 |
23
+ | `DOCKERHUB_TOKEN` | Docker Hub Access Token | 见下方说明 |
24
+
25
+ ### 获取 Docker Hub Access Token
26
+
27
+ 1. 登录 [Docker Hub](https://hub.docker.com/)
28
+ 2. 点击右上角头像 → `Account Settings`
29
+ 3. 选择 `Security` → `Access Tokens`
30
+ 4. 点击 `New Access Token`
31
+ 5. 填写描述(如:`GitHub Actions`)
32
+ 6. 选择权限:`Read, Write, Delete`
33
+ 7. 点击 `Generate`
34
+ 8. **立即复制 token**(只显示一次)
35
+
36
+ ## 🚀 工作流程说明
37
+
38
+ ### 触发条件
39
+
40
+ - **自动触发**:推送到 `main` 分支
41
+ - **版本发布**:创建 `v*` 格式的 tag(如 `v1.0.0`)
42
+ - **手动触发**:在 Actions 页面手动运行
43
+
44
+ ### 镜像标签策略
45
+
46
+ 工作流会自动创建以下标签:
47
+
48
+ - `latest`:始终指向 main 分支的最新构建
49
+ - `main`:main 分支的构建
50
+ - `v1.0.0`:版本标签(当创建 tag 时)
51
+ - `1.0`:主次版本标签
52
+ - `1`:主版本标签
53
+ - `main-sha-xxxxxxx`:包含 commit SHA 的标签
54
+
55
+ ### 支持的平台
56
+
57
+ - `linux/amd64`:Intel/AMD 架构
58
+ - `linux/arm64`:ARM64 架构(如 Apple Silicon, 树莓派等)
59
+
60
+ ## 📦 使用发布的镜像
61
+
62
+ ```bash
63
+ # 拉取最新版本
64
+ docker pull weishaw/claude-relay-service:latest
65
+
66
+ # 拉取特定版本
67
+ docker pull weishaw/claude-relay-service:v1.0.0
68
+
69
+ # 运行容器
70
+ docker run -d \
71
+ --name claude-relay \
72
+ -p 3000:3000 \
73
+ -v ./data:/app/data \
74
+ -v ./logs:/app/logs \
75
+ -e ADMIN_USERNAME=my_admin \
76
+ -e ADMIN_PASSWORD=my_password \
77
+ weishaw/claude-relay-service:latest
78
+ ```
79
+
80
+ ## 🔍 验证配置
81
+
82
+ 1. 推送代码到 main 分支
83
+ 2. 在 GitHub 仓库页面点击 `Actions` 标签
84
+ 3. 查看 `Docker Build & Push` 工作流运行状态
85
+ 4. 成功后在 Docker Hub 查看镜像
86
+
87
+ ## 🛡️ 安全功能
88
+
89
+ - **漏洞扫描**:使用 Trivy 自动扫描镜像漏洞
90
+ - **扫描报告**:上传到 GitHub Security 标签页
91
+ - **自动更新 README**:同步更新 Docker Hub 的项目描述
92
+
93
+ ## ❓ 常见问题
94
+
95
+ ### 构建失败
96
+
97
+ - 检查 secrets 是否正确配置
98
+ - 确认 Docker Hub token 有足够权限
99
+ - 查看 Actions 日志详细错误信息
100
+
101
+ ### 镜像推送失败
102
+
103
+ - 确认 Docker Hub 用户名正确
104
+ - 检查是否达到 Docker Hub 免费账户限制
105
+ - Token 可能过期,需要重新生成
106
+
107
+ ### 多平台构建慢
108
+
109
+ 这是正常的,因为需要模拟不同架构。可以在不需要时修改 `platforms` 配置。
.github/FUNDING.yml ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # These are supported funding model platforms
2
+
3
+ github: # Your GitHub username for GitHub Sponsors
4
+ patreon: # Replace with your Patreon username if you have one
5
+ open_collective: # Replace with your Open Collective username if you have one
6
+ ko_fi: # Replace with your Ko-fi username if you have one
7
+ tidelift: # Replace with your Tidelift platform-name/package-name e.g., npm/babel
8
+ community_bridge: # Replace with your Community Bridge project-name
9
+ liberapay: # Replace with your Liberapay username
10
+ issuehunt: # Replace with your IssueHunt username
11
+ otechie: # Replace with your Otechie username
12
+ custom: ['https://afdian.com/a/claude-relay-service'] # Your custom donation link (Afdian)
.github/RELEASE_PROCESS.md ADDED
@@ -0,0 +1,94 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # 发布流程说明
2
+
3
+ ## 概述
4
+
5
+ 本项目采用全自动化的版本管理和发布流程。VERSION文件由GitHub Actions自动维护,无需手动修改。
6
+
7
+ ## 自动发布流程
8
+
9
+ ### 1. 工作原理
10
+
11
+ 1. **代码推送**: 当你推送代码到main分支时
12
+ 2. **自动版本更新**: `auto-version-bump.yml`会:
13
+ - 检测是否有实质性代码变更(排除.md文件、docs/目录等)
14
+ - 如果有代码变更,自动将版本号+1并更新VERSION文件
15
+ - 提交VERSION文件更新到main分支
16
+ 3. **自动发布**: `release-on-version.yml`会:
17
+ - 检测到只有VERSION文件变更的提交
18
+ - 自动创建Git tag
19
+ - 创建GitHub Release
20
+ - 构建并推送Docker镜像
21
+ - 发送Telegram通知(如果配置)
22
+
23
+ ### 2. 工作流文件说明
24
+
25
+ - **auto-version-bump.yml**: 自动检测代码变更并更新VERSION文件
26
+ - **release-on-version.yml**: 检测VERSION文件单独提交并触发发布
27
+ - **docker-publish.yml**: 在tag创建时构建Docker镜像(备用)
28
+ - **release.yml**: 在tag创建时生成Release(备用)
29
+
30
+ ### 3. 版本号规范
31
+
32
+ - 使用语义化版本号:`MAJOR.MINOR.PATCH`
33
+ - 默认自动递增PATCH版本(例如:1.1.10 → 1.1.11)
34
+ - VERSION文件只包含版本号,不包含`v`前缀
35
+ - Git tag会自动添加`v`前缀
36
+
37
+ ### 4. 触发条件
38
+
39
+ **会触发版本更新的文件变更**:
40
+ - 源代码文件(.js, .ts, .jsx, .tsx等)
41
+ - 配置文件(package.json, Dockerfile等)
42
+ - 其他功能性文件
43
+
44
+ **不会触发版本更新的文件变更**:
45
+ - Markdown文件(*.md)
46
+ - 文档目录(docs/)
47
+ - GitHub配置(.github/)
48
+ - VERSION文件本身
49
+ - .gitignore、LICENSE等
50
+
51
+ ## 使用指南
52
+
53
+ ### 正常开发流程
54
+
55
+ 1. 进行代码开发和修改
56
+ 2. 提交并推送到main分支
57
+ 3. 系统自动完成版本更新和发布
58
+
59
+ ```bash
60
+ # 正常的开发流程
61
+ git add .
62
+ git commit -m "feat: 添加新功能"
63
+ git push origin main
64
+
65
+ # GitHub Actions会自动:
66
+ # 1. 检测到代码变更
67
+ # 2. 更新VERSION文件(例如:1.1.10 → 1.1.11)
68
+ # 3. 创建新的release和Docker镜像
69
+ ```
70
+
71
+ ### 跳过版本更新
72
+
73
+ 如果只是更新文档或其他非代码文件,系统会自动识别并跳过版本更新。
74
+
75
+ ## 故障排除
76
+
77
+ ### 版本没有自动更新
78
+
79
+ 1. 检查是否有实质性代码变更
80
+ 2. 查看GitHub Actions运行日志
81
+ 3. 确认推送的是main分支
82
+
83
+ ### 需要手动触发发布
84
+
85
+ 如果需要手动控制版本:
86
+ 1. 直接修改VERSION文件
87
+ 2. 提交并推送
88
+ 3. 系统会检测到VERSION变更并触发发布
89
+
90
+ ## 注意事项
91
+
92
+ - **不要**在同一个提交中既修改代码又修改VERSION文件
93
+ - **不要**手动创建tag,让系统自动管理
94
+ - 系统会自动避免死循环(GitHub Actions的提交不会触发新的版本更新)
.github/TELEGRAM_SETUP.md ADDED
@@ -0,0 +1,110 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Telegram 自动通知设置指南
2
+
3
+ ## 📋 概述
4
+
5
+ 当 GitHub Actions 自动发布新版本时,系统会自动发送通知到你的 Telegram 频道。
6
+
7
+ ## 🚀 设置步骤
8
+
9
+ ### 1. 创建 Telegram Bot
10
+
11
+ 1. 在 Telegram 中找到 [@BotFather](https://t.me/botfather)
12
+ 2. 发送 `/newbot` 命令
13
+ 3. 按提示设置 Bot 名称(例如:Claude Relay Updates)
14
+ 4. 设置 Bot 用户名(例如:claude_relay_bot)
15
+ 5. **保存 Bot Token**(格式类似:`1234567890:ABCdefGHIjklMNOpqrsTUVwxyz`)
16
+
17
+ ### 2. 创建或选择 Telegram 频道
18
+
19
+ 1. 创建一个新频道或使用现有频道
20
+ 2. 将你的 Bot 添加为频道管理员:
21
+ - 进入频道设置
22
+ - 管理员 → 添加管理员
23
+ - 搜索你的 Bot 用户名
24
+ - 赋予发送消息权限
25
+
26
+ ### 3. 获取频道 Chat ID
27
+
28
+ 有几种方法获取频道的 Chat ID:
29
+
30
+ #### 方法 1:使用 Web Telegram
31
+ 1. 打开 https://web.telegram.org
32
+ 2. 进入你的频道
33
+ 3. 查看 URL,格式为:`https://web.telegram.org/k/#-1234567890`
34
+ 4. Chat ID 就是 `#` 后面的数字(包括负号):`-1234567890`
35
+
36
+ #### 方法 2:使用 Bot API
37
+ 1. 先在频道发送一条消息
38
+ 2. 访问:`https://api.telegram.org/bot<YOUR_BOT_TOKEN>/getUpdates`
39
+ 3. 找到你的频道消息,查看 `chat.id` 字段
40
+
41
+ #### 方法 3:使用频道用户名
42
+ 如果频道是公开的,可以直接使用 `@频道用户名` 作为 Chat ID
43
+
44
+ ### 4. 添加 GitHub Secrets
45
+
46
+ 1. 访问你的 GitHub 仓库
47
+ 2. 进入 Settings → Secrets and variables → Actions
48
+ 3. 点击 "New repository secret"
49
+ 4. 添加以下两个 Secrets:
50
+
51
+ **TELEGRAM_BOT_TOKEN**
52
+ - Name: `TELEGRAM_BOT_TOKEN`
53
+ - Value: 你的 Bot Token(例如:`1234567890:ABCdefGHIjklMNOpqrsTUVwxyz`)
54
+
55
+ **TELEGRAM_CHAT_ID**
56
+ - Name: `TELEGRAM_CHAT_ID`
57
+ - Value: 你的频道 Chat ID(例如:`-1234567890` 或 `@your_channel`)
58
+
59
+ ## ✅ 测试配置
60
+
61
+ 配置完成后,下次推送到 main 分支时,你的 Telegram 频道将收到类似这样的通知:
62
+
63
+ ```
64
+ 🚀 Claude Relay Service 新版本发布!
65
+
66
+ 📦 版本号: 1.1.3
67
+
68
+ 📝 更新内容:
69
+ - feat: 添加 Telegram 自动通知功能
70
+ - fix: 修复某个问题
71
+
72
+ 🐳 Docker 部署:
73
+ docker pull weishaw/claude-relay-service:v1.1.3
74
+ docker pull weishaw/claude-relay-service:latest
75
+
76
+ 🔗 相关链接:
77
+ • GitHub Release
78
+ • 完整更新日志
79
+ • Docker Hub
80
+
81
+ #ClaudeRelay #Update #v1_1_3
82
+ ```
83
+
84
+ ## 🔧 自定义通知
85
+
86
+ 如果你想修改通知格式,编辑 `.github/workflows/auto-release.yml` 中的 `Send Telegram Notification` 步骤。
87
+
88
+ ## ❓ 常见问题
89
+
90
+ ### Q: 通知发送失败怎么办?
91
+
92
+ 检查:
93
+ 1. Bot Token 是否正确
94
+ 2. Bot 是否已添加为频道管理员
95
+ 3. Chat ID 是否正确(注意负号)
96
+ 4. GitHub Secrets 是否正确配置
97
+
98
+ ### Q: 可以发送到多个频道吗?
99
+
100
+ 可以修改工作流,添加多个通知步骤,或使用逗号分隔多个 Chat ID。
101
+
102
+ ### Q: 通知失败会影响版本发布吗?
103
+
104
+ 不会。通知步骤配置了 `continue-on-error: true`,即使通知失败也不会影响版本发布。
105
+
106
+ ## 🔐 安全提示
107
+
108
+ - **永远不要**在代码中直接写入 Bot Token
109
+ - 始终使用 GitHub Secrets 存储敏感信息
110
+ - 定期更换 Bot Token 以保证安全
.github/WORKFLOW_USAGE.md ADDED
@@ -0,0 +1,129 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # GitHub Actions 工作流使用指南
2
+
3
+ ## 📋 概述
4
+
5
+ 本项目配置了自动化 CI/CD 流程,每次推送到 main 分支都会自动构建并发布 Docker 镜像到 Docker Hub。
6
+
7
+ ## 🚀 工作流程
8
+
9
+ ### 1. Docker 构建和发布 (`docker-publish.yml`)
10
+
11
+ **功能:**
12
+ - 自动构建多平台 Docker 镜像(amd64, arm64)
13
+ - 推送到 Docker Hub
14
+ - 执行安全漏洞扫描
15
+ - 更新 Docker Hub 描述
16
+
17
+ **触发条件:**
18
+ - 推送到 `main` 分支
19
+ - 创建版本标签(如 `v1.0.0`)
20
+ - Pull Request(仅构建,不推送)
21
+ - 手动触发
22
+
23
+ ### 2. 发布管理 (`release.yml`)
24
+
25
+ **功能:**
26
+ - 自动创建 GitHub Release
27
+ - 生成更新日志
28
+ - 关联 Docker 镜像版本
29
+
30
+ **触发条件:**
31
+ - 创建版本标签(如 `v1.0.0`)
32
+
33
+ ### 3. 自动版本发布 (`auto-release.yml`)
34
+
35
+ **功能:**
36
+ - 自动递增版本号(patch 版本)
37
+ - 自动创建版本标签
38
+ - 生成 GitHub Release
39
+ - 更新 CHANGELOG.md
40
+
41
+ **触发条件:**
42
+ - 推送到 `main` 分支(自动触发)
43
+ - 忽略纯文档更新
44
+
45
+ ## 📝 版本发布流程
46
+
47
+ ### 1. 常规更新(推送到 main)
48
+
49
+ ```bash
50
+ git add .
51
+ git commit -m "fix: 修复登录问题"
52
+ git push origin main
53
+ ```
54
+
55
+ **结果:**
56
+ - 自动构建并推送 `latest` 标签到 Docker Hub
57
+ - 更新 `main` 标签
58
+ - **自动递增版本号并创建 Release**(例如:v1.0.1 → v1.0.2)
59
+ - 生成更新日志
60
+
61
+ ### 2. 版本发布
62
+
63
+ ```bash
64
+ # 创建版本标签
65
+ git tag -a v1.0.0 -m "Release version 1.0.0"
66
+ git push origin v1.0.0
67
+ ```
68
+
69
+ **结果:**
70
+ - 构建并推送以下标签到 Docker Hub:
71
+ - `v1.0.0`(完整版本)
72
+ - `1.0`(主次版本)
73
+ - `1`(主版本)
74
+ - `latest`(最新版本)
75
+ - 创建 GitHub Release
76
+ - 生成更新日志
77
+
78
+ ## 🔧 手动触发构建
79
+
80
+ 1. 访问仓库的 Actions 页面
81
+ 2. 选择 "Docker Build & Push" 工作流
82
+ 3. 点击 "Run workflow"
83
+ 4. 选择分支并运行
84
+
85
+ ## 📊 查看构建状态
86
+
87
+ - **Actions 页面**:查看所有工作流运行历史
88
+ - **README 徽章**:实时显示构建状态
89
+ - **Docker Hub**:查看镜像标签和拉取次数
90
+
91
+ ## 🛡️ 安全扫描
92
+
93
+ 每次构建都会运行 Trivy 安全扫描:
94
+ - 扫描结果上传到 GitHub Security 标签页
95
+ - 发现高危漏洞会在 Actions 日志中警告
96
+
97
+ ## ❓ 常见问题
98
+
99
+ ### Q: 如何回滚到之前的版本?
100
+
101
+ ```bash
102
+ # 使用特定版本标签
103
+ docker pull weishaw/claude-relay-service:v1.0.0
104
+
105
+ # 或在 docker-compose.yml 中指定版本
106
+ image: weishaw/claude-relay-service:v1.0.0
107
+ ```
108
+
109
+ ### Q: 如何跳过自动构建?
110
+
111
+ 在 commit 消息中添加 `[skip ci]`:
112
+ ```bash
113
+ git commit -m "docs: 更新文档 [skip ci]"
114
+ ```
115
+
116
+ ### Q: 构建失败如何调试?
117
+
118
+ 1. 查看 Actions 日志详细错误信息
119
+ 2. 在本地测试 Docker 构建:
120
+ ```bash
121
+ docker build -t test .
122
+ ```
123
+
124
+ ## 📚 相关文档
125
+
126
+ - [自动版本发布指南](.github/AUTO_RELEASE_GUIDE.md)
127
+ - [Docker Hub 配置指南](.github/DOCKER_HUB_SETUP.md)
128
+ - [GitHub Actions 文档](https://docs.github.com/en/actions)
129
+ - [Docker 官方文档](https://docs.docker.com/)
.github/cliff.toml ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # git-cliff configuration file
2
+ # https://git-cliff.org/docs/configuration
3
+
4
+ [changelog]
5
+ # changelog header
6
+ header = """
7
+ # Changelog
8
+
9
+ All notable changes to this project will be documented in this file.
10
+ """
11
+ # template for the changelog body
12
+ body = """
13
+ {% if version %}\
14
+ ## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }}
15
+ {% else %}\
16
+ ## [unreleased]
17
+ {% endif %}\
18
+ {% for group, commits in commits | group_by(attribute="group") %}
19
+ ### {{ group | upper_first }}
20
+ {% for commit in commits %}
21
+ - {% if commit.breaking %}[**breaking**] {% endif %}{{ commit.message | upper_first }} ([{{ commit.id | truncate(length=7, end="") }}](https://github.com/Wei-Shaw/claude-relay-service/commit/{{ commit.id }}))
22
+ {%- endfor %}
23
+ {% endfor %}\n
24
+ """
25
+ # remove the leading and trailing whitespace from the template
26
+ trim = true
27
+ # changelog footer
28
+ footer = """
29
+ """
30
+
31
+ [git]
32
+ # parse the commits based on https://www.conventionalcommits.org
33
+ conventional_commits = true
34
+ # filter out the commits that are not conventional
35
+ filter_unconventional = true
36
+ # process each line of a commit as an individual commit
37
+ split_commits = false
38
+ # regex for preprocessing the commit messages
39
+ commit_preprocessors = [
40
+ { pattern = '\((\w+\s)?#([0-9]+)\)', replace = "([#${2}](https://github.com/Wei-Shaw/claude-relay-service/issues/${2}))" },
41
+ ]
42
+ # regex for parsing and grouping commits
43
+ commit_parsers = [
44
+ { message = "^feat", group = "Features" },
45
+ { message = "^fix", group = "Bug Fixes" },
46
+ { message = "^docs", group = "Documentation" },
47
+ { message = "^perf", group = "Performance" },
48
+ { message = "^refactor", group = "Refactor" },
49
+ { message = "^style", group = "Styling" },
50
+ { message = "^test", group = "Testing" },
51
+ { message = "^chore\\(release\\): prepare for", skip = true },
52
+ { message = "^chore", group = "Miscellaneous Tasks" },
53
+ { body = ".*security", group = "Security" },
54
+ ]
55
+ # protect breaking changes from being skipped due to matching a skipping commit_parser
56
+ protect_breaking_commits = false
57
+ # filter out the commits that are not matched by commit parsers
58
+ filter_commits = false
59
+ # glob pattern for matching git tags
60
+ tag_pattern = "v[0-9]*"
61
+ # regex for skipping tags
62
+ skip_tags = "v0.1.0-beta.1"
63
+ # regex for ignoring tags
64
+ ignore_tags = ""
65
+ # sort the tags topologically
66
+ topo_order = false
67
+ # sort the commits inside sections by oldest/newest order
68
+ sort_commits = "oldest"
.github/secret_scanning.yml ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ # GitHub Secret Scanning Configuration
2
+ # This file excludes specific paths from secret scanning
3
+
4
+ paths-ignore:
5
+ - 'src/services/geminiAccountService.js'
6
+ - 'data/demo/Gemini-CLI-2-API/gemini-core.js'
.github/workflows/auto-release-pipeline.yml ADDED
@@ -0,0 +1,490 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Auto Release Pipeline
2
+
3
+ on:
4
+ push:
5
+ branches:
6
+ - main
7
+
8
+ permissions:
9
+ contents: write
10
+ packages: write
11
+
12
+ jobs:
13
+ release-pipeline:
14
+ runs-on: ubuntu-latest
15
+ # 跳过由GitHub Actions创建的提交,避免死循环
16
+ if: github.event.pusher.name != 'github-actions[bot]' && !contains(github.event.head_commit.message, '[skip ci]')
17
+ steps:
18
+ - name: Checkout code
19
+ uses: actions/checkout@v4
20
+ with:
21
+ fetch-depth: 0
22
+ token: ${{ secrets.GITHUB_TOKEN }}
23
+
24
+ - name: Check if version bump is needed
25
+ id: check
26
+ run: |
27
+ # 检测是否是合并提交
28
+ PARENT_COUNT=$(git rev-list --parents -n 1 HEAD | wc -w)
29
+ PARENT_COUNT=$((PARENT_COUNT - 1))
30
+ echo "Parent count: $PARENT_COUNT"
31
+
32
+ if [ "$PARENT_COUNT" -gt 1 ]; then
33
+ # 合并提交:获取合并进来的所有文件变更
34
+ echo "Detected merge commit, getting all merged changes"
35
+ # 获取合并基准点
36
+ MERGE_BASE=$(git merge-base HEAD^1 HEAD^2 2>/dev/null || echo "")
37
+ if [ -n "$MERGE_BASE" ]; then
38
+ # 获取从合并基准到 HEAD 的所有变更
39
+ CHANGED_FILES=$(git diff --name-only $MERGE_BASE..HEAD)
40
+ else
41
+ # 如果无法获取合并基准,使用第二个父提交
42
+ CHANGED_FILES=$(git diff --name-only HEAD^2..HEAD)
43
+ fi
44
+ else
45
+ # 普通提交:获取相对于上一个提交的变更
46
+ CHANGED_FILES=$(git diff --name-only HEAD~1..HEAD 2>/dev/null || git diff --name-only $(git rev-list --max-parents=0 HEAD)..HEAD)
47
+ fi
48
+
49
+ echo "Changed files:"
50
+ echo "$CHANGED_FILES"
51
+
52
+ # 检查是否只有无关文件(.md, docs/, .github/等)
53
+ SIGNIFICANT_CHANGES=false
54
+ while IFS= read -r file; do
55
+ # 跳过空行
56
+ [ -z "$file" ] && continue
57
+
58
+ # 检查是否是需要忽略的文件
59
+ if [[ ! "$file" =~ \.(md|txt)$ ]] &&
60
+ [[ ! "$file" =~ ^docs/ ]] &&
61
+ [[ ! "$file" =~ ^\.github/ ]] &&
62
+ [[ "$file" != "VERSION" ]] &&
63
+ [[ "$file" != ".gitignore" ]] &&
64
+ [[ "$file" != "LICENSE" ]]; then
65
+ echo "Found significant change in: $file"
66
+ SIGNIFICANT_CHANGES=true
67
+ break
68
+ fi
69
+ done <<< "$CHANGED_FILES"
70
+
71
+ if [ "$SIGNIFICANT_CHANGES" = true ]; then
72
+ echo "Significant changes detected, version bump needed"
73
+ echo "needs_bump=true" >> $GITHUB_OUTPUT
74
+ else
75
+ echo "No significant changes, skipping version bump"
76
+ echo "needs_bump=false" >> $GITHUB_OUTPUT
77
+ fi
78
+
79
+ - name: Get current version
80
+ if: steps.check.outputs.needs_bump == 'true'
81
+ id: get_version
82
+ run: |
83
+ # 获取最新的tag版本
84
+ LATEST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "v0.0.0")
85
+ echo "Latest tag: $LATEST_TAG"
86
+ TAG_VERSION=${LATEST_TAG#v}
87
+
88
+ # 获取VERSION文件中的版本
89
+ FILE_VERSION=$(cat VERSION | tr -d '[:space:]')
90
+ echo "VERSION file: $FILE_VERSION"
91
+
92
+ # 比较tag版本和文件版本,取较大值
93
+ function version_gt() { test "$(printf '%s\n' "$@" | sort -V | head -n 1)" != "$1"; }
94
+
95
+ if version_gt "$FILE_VERSION" "$TAG_VERSION"; then
96
+ VERSION="$FILE_VERSION"
97
+ echo "Using VERSION file: $VERSION (newer than tag)"
98
+ else
99
+ VERSION="$TAG_VERSION"
100
+ echo "Using tag version: $VERSION (newer or equal to file)"
101
+ fi
102
+
103
+ echo "Current version: $VERSION"
104
+ echo "current_version=$VERSION" >> $GITHUB_OUTPUT
105
+
106
+ - name: Calculate next version
107
+ if: steps.check.outputs.needs_bump == 'true'
108
+ id: next_version
109
+ run: |
110
+ VERSION="${{ steps.get_version.outputs.current_version }}"
111
+
112
+ # 分割版本号
113
+ IFS='.' read -r -a version_parts <<< "$VERSION"
114
+ MAJOR="${version_parts[0]:-0}"
115
+ MINOR="${version_parts[1]:-0}"
116
+ PATCH="${version_parts[2]:-0}"
117
+
118
+ # 默认递增patch版本
119
+ NEW_PATCH=$((PATCH + 1))
120
+ NEW_VERSION="${MAJOR}.${MINOR}.${NEW_PATCH}"
121
+
122
+ echo "New version: $NEW_VERSION"
123
+ echo "new_version=$NEW_VERSION" >> $GITHUB_OUTPUT
124
+ echo "new_tag=v$NEW_VERSION" >> $GITHUB_OUTPUT
125
+
126
+ - name: Update VERSION file
127
+ if: steps.check.outputs.needs_bump == 'true'
128
+ run: |
129
+ echo "${{ steps.next_version.outputs.new_version }}" > VERSION
130
+
131
+ # 配置git
132
+ git config user.name "github-actions[bot]"
133
+ git config user.email "github-actions[bot]@users.noreply.github.com"
134
+
135
+ # 提交VERSION文件 - 添加 [skip ci] 以避免再次触发
136
+ git add VERSION
137
+ git commit -m "chore: sync VERSION file with release ${{ steps.next_version.outputs.new_tag }} [skip ci]"
138
+
139
+ # 构建前端并推送到 web-dist 分支
140
+ - name: Setup Node.js
141
+ if: steps.check.outputs.needs_bump == 'true'
142
+ uses: actions/setup-node@v4
143
+ with:
144
+ node-version: '18'
145
+ cache: 'npm'
146
+ cache-dependency-path: web/admin-spa/package-lock.json
147
+
148
+ - name: Build Frontend
149
+ if: steps.check.outputs.needs_bump == 'true'
150
+ run: |
151
+ echo "Building frontend for version ${{ steps.next_version.outputs.new_version }}..."
152
+ cd web/admin-spa
153
+ npm ci
154
+ npm run build
155
+ echo "Frontend build completed"
156
+
157
+ - name: Push Frontend Build to web-dist Branch
158
+ if: steps.check.outputs.needs_bump == 'true'
159
+ run: |
160
+ # 创建临时目录
161
+ TEMP_DIR=$(mktemp -d)
162
+ echo "Using temp directory: $TEMP_DIR"
163
+
164
+ # 复制构建产物到临时目录
165
+ cp -r web/admin-spa/dist/* "$TEMP_DIR/"
166
+
167
+ # 检查 web-dist 分支是否存在
168
+ if git ls-remote --heads origin web-dist | grep -q web-dist; then
169
+ echo "Checking out existing web-dist branch"
170
+ git fetch origin web-dist:web-dist
171
+ git checkout web-dist
172
+ else
173
+ echo "Creating new web-dist branch"
174
+ git checkout --orphan web-dist
175
+ fi
176
+
177
+ # 清空当前目录(保留 .git)
178
+ git rm -rf . 2>/dev/null || true
179
+
180
+ # 复制构建产物
181
+ cp -r "$TEMP_DIR"/* .
182
+
183
+ # 添加 README
184
+ cat > README.md << EOF
185
+ # Claude Relay Service - Web Frontend Build
186
+
187
+ This branch contains the pre-built frontend assets for Claude Relay Service.
188
+
189
+ **DO NOT EDIT FILES IN THIS BRANCH DIRECTLY**
190
+
191
+ These files are automatically generated by the CI/CD pipeline.
192
+
193
+ Version: ${{ steps.next_version.outputs.new_version }}
194
+ Build Date: $(date -u +"%Y-%m-%d %H:%M:%S UTC")
195
+ EOF
196
+
197
+ # 创建 .gitignore 文件以排除 node_modules
198
+ cat > .gitignore << EOF
199
+ node_modules/
200
+ *.log
201
+ .DS_Store
202
+ .env
203
+ EOF
204
+
205
+ # 只添加必要的文件,排除 node_modules
206
+ git add --all -- ':!node_modules'
207
+ git commit -m "chore: update frontend build for v${{ steps.next_version.outputs.new_version }} [skip ci]"
208
+ git push origin web-dist --force
209
+
210
+ # 切换回主分支
211
+ git checkout main
212
+
213
+ # 清理临时目录
214
+ rm -rf "$TEMP_DIR"
215
+
216
+ echo "Frontend build pushed to web-dist branch successfully"
217
+
218
+ - name: Install git-cliff
219
+ if: steps.check.outputs.needs_bump == 'true'
220
+ run: |
221
+ wget -q https://github.com/orhun/git-cliff/releases/download/v1.4.0/git-cliff-1.4.0-x86_64-unknown-linux-gnu.tar.gz
222
+ tar -xzf git-cliff-1.4.0-x86_64-unknown-linux-gnu.tar.gz
223
+ chmod +x git-cliff-1.4.0/git-cliff
224
+ sudo mv git-cliff-1.4.0/git-cliff /usr/local/bin/
225
+
226
+ - name: Generate changelog
227
+ if: steps.check.outputs.needs_bump == 'true'
228
+ id: changelog
229
+ run: |
230
+ # 获取上一个tag以来的更新日志
231
+ LATEST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "")
232
+ if [ -n "$LATEST_TAG" ]; then
233
+ # 排除VERSION文件的提交
234
+ CHANGELOG=$(git-cliff --config .github/cliff.toml $LATEST_TAG..HEAD --strip header | grep -v "bump version" | sed '/^$/d' || echo "- 代码优化和改进")
235
+ else
236
+ CHANGELOG=$(git-cliff --config .github/cliff.toml --strip header || echo "- 初始版本发布")
237
+ fi
238
+ echo "content<<EOF" >> $GITHUB_OUTPUT
239
+ echo "$CHANGELOG" >> $GITHUB_OUTPUT
240
+ echo "EOF" >> $GITHUB_OUTPUT
241
+
242
+ - name: Create and push tag
243
+ if: steps.check.outputs.needs_bump == 'true'
244
+ run: |
245
+ NEW_TAG="${{ steps.next_version.outputs.new_tag }}"
246
+ git tag -a "$NEW_TAG" -m "Release $NEW_TAG"
247
+ git push origin HEAD:main "$NEW_TAG"
248
+
249
+ - name: Prepare image names
250
+ id: image_names
251
+ if: steps.check.outputs.needs_bump == 'true'
252
+ run: |
253
+ DOCKER_USERNAME="${{ secrets.DOCKERHUB_USERNAME }}"
254
+ if [ -z "$DOCKER_USERNAME" ]; then
255
+ DOCKER_USERNAME="weishaw"
256
+ fi
257
+
258
+ DOCKER_IMAGE=$(echo "${DOCKER_USERNAME}/claude-relay-service" | tr '[:upper:]' '[:lower:]')
259
+ GHCR_IMAGE=$(echo "ghcr.io/${{ github.repository_owner }}/claude-relay-service" | tr '[:upper:]' '[:lower:]')
260
+
261
+ {
262
+ echo "docker_image=${DOCKER_IMAGE}"
263
+ echo "ghcr_image=${GHCR_IMAGE}"
264
+ } >> "$GITHUB_OUTPUT"
265
+
266
+ - name: Create GitHub Release
267
+ if: steps.check.outputs.needs_bump == 'true'
268
+ uses: softprops/action-gh-release@v1
269
+ with:
270
+ tag_name: ${{ steps.next_version.outputs.new_tag }}
271
+ name: Release ${{ steps.next_version.outputs.new_version }}
272
+ body: |
273
+ ## 🐳 Docker 镜像
274
+
275
+ ```bash
276
+ docker pull ${{ steps.image_names.outputs.docker_image }}:${{ steps.next_version.outputs.new_tag }}
277
+ docker pull ${{ steps.image_names.outputs.docker_image }}:latest
278
+ docker pull ${{ steps.image_names.outputs.ghcr_image }}:${{ steps.next_version.outputs.new_tag }}
279
+ docker pull ${{ steps.image_names.outputs.ghcr_image }}:latest
280
+ ```
281
+
282
+ ## 📦 主要更新
283
+
284
+ ${{ steps.changelog.outputs.content }}
285
+
286
+ ## 📋 完整更新日志
287
+
288
+ 查看 [所有版本](https://github.com/${{ github.repository }}/releases)
289
+ draft: false
290
+ prerelease: false
291
+ generate_release_notes: true
292
+
293
+ # 自动清理旧的tags和releases(保持最近50个)
294
+ - name: Cleanup old tags and releases
295
+ if: steps.check.outputs.needs_bump == 'true'
296
+ continue-on-error: true
297
+ env:
298
+ TAGS_TO_KEEP: 50
299
+ run: |
300
+ echo "🧹 自动清理旧版本,保持最近 $TAGS_TO_KEEP 个tag..."
301
+
302
+ # 获取所有版本tag并按版本号排序(从旧到新)
303
+ echo "正在获取所有tags..."
304
+ ALL_TAGS=$(git ls-remote --tags origin | grep -E 'refs/tags/v[0-9]+\.[0-9]+\.[0-9]+$' | awk '{print $2}' | sed 's|refs/tags/||' | sort -V)
305
+
306
+ # 检查是否获取到tags
307
+ if [ -z "$ALL_TAGS" ]; then
308
+ echo "⚠️ 未找到任何版本tag"
309
+ exit 0
310
+ fi
311
+
312
+ TOTAL_COUNT=$(echo "$ALL_TAGS" | wc -l)
313
+
314
+ echo "📊 当前tag统计:"
315
+ echo "- 总数: $TOTAL_COUNT"
316
+ echo "- 配置保留: $TAGS_TO_KEEP"
317
+
318
+ if [ "$TOTAL_COUNT" -gt "$TAGS_TO_KEEP" ]; then
319
+ DELETE_COUNT=$((TOTAL_COUNT - TAGS_TO_KEEP))
320
+ echo "- 将要删除: $DELETE_COUNT 个最旧的tag"
321
+
322
+ # 获取要删除的tags(最老的)
323
+ TAGS_TO_DELETE=$(echo "$ALL_TAGS" | head -n "$DELETE_COUNT")
324
+
325
+ # 显示将要删除的版本范围
326
+ OLDEST_TO_DELETE=$(echo "$TAGS_TO_DELETE" | head -1)
327
+ NEWEST_TO_DELETE=$(echo "$TAGS_TO_DELETE" | tail -1)
328
+ echo ""
329
+ echo "🗑️ 将要删除的版本范围:"
330
+ echo "- 从: $OLDEST_TO_DELETE"
331
+ echo "- 到: $NEWEST_TO_DELETE"
332
+
333
+ echo ""
334
+ echo "开始执行删除..."
335
+ SUCCESS_COUNT=0
336
+ FAIL_COUNT=0
337
+
338
+ for tag in $TAGS_TO_DELETE; do
339
+ echo -n " 删除 $tag ... "
340
+
341
+ # 先检查release是否存在
342
+ if gh release view "$tag" >/dev/null 2>&1; then
343
+ # Release存在,删除release会同时删除tag
344
+ if gh release delete "$tag" --yes --cleanup-tag 2>/dev/null; then
345
+ echo "✅ (release+tag)"
346
+ SUCCESS_COUNT=$((SUCCESS_COUNT + 1))
347
+ else
348
+ echo "❌ (release删除失败)"
349
+ FAIL_COUNT=$((FAIL_COUNT + 1))
350
+ fi
351
+ else
352
+ # Release不存在,只删除tag
353
+ if git push origin --delete "$tag" 2>/dev/null; then
354
+ echo "✅ (仅tag)"
355
+ SUCCESS_COUNT=$((SUCCESS_COUNT + 1))
356
+ else
357
+ echo "⏭️ (已不存在)"
358
+ FAIL_COUNT=$((FAIL_COUNT + 1))
359
+ fi
360
+ fi
361
+ done
362
+
363
+ echo ""
364
+ echo "📊 清理结果:"
365
+ echo "- 成功删除: $SUCCESS_COUNT"
366
+ echo "- 失败/跳过: $FAIL_COUNT"
367
+
368
+ # 重新获取并显示保留的版本范围
369
+ echo ""
370
+ echo "正在验证清理结果..."
371
+ REMAINING_TAGS=$(git ls-remote --tags origin | grep -E 'refs/tags/v[0-9]+\.[0-9]+\.[0-9]+$' | awk '{print $2}' | sed 's|refs/tags/||' | sort -V)
372
+ REMAINING_COUNT=$(echo "$REMAINING_TAGS" | wc -l)
373
+ OLDEST=$(echo "$REMAINING_TAGS" | head -1)
374
+ NEWEST=$(echo "$REMAINING_TAGS" | tail -1)
375
+
376
+ echo "✅ 清理完成!"
377
+ echo ""
378
+ echo "📌 当前保留的版本:"
379
+ echo "- 最旧版本: $OLDEST"
380
+ echo "- 最新版本: $NEWEST"
381
+ echo "- 版本总数: $REMAINING_COUNT"
382
+
383
+ # 验证是否达到预期
384
+ if [ "$REMAINING_COUNT" -le "$TAGS_TO_KEEP" ]; then
385
+ echo "- 状态: ✅ 符合预期(≤$TAGS_TO_KEEP)"
386
+ else
387
+ echo "- 状态: ⚠️ 超出预期(某些tag可能删除失败)"
388
+ fi
389
+ else
390
+ echo "✅ 当前tag数量($TOTAL_COUNT)未超过限制($TAGS_TO_KEEP),无需清理"
391
+ fi
392
+
393
+ # Docker构建步骤
394
+ - name: Set up QEMU
395
+ if: steps.check.outputs.needs_bump == 'true'
396
+ uses: docker/setup-qemu-action@v3
397
+
398
+ - name: Set up Docker Buildx
399
+ if: steps.check.outputs.needs_bump == 'true'
400
+ uses: docker/setup-buildx-action@v3
401
+
402
+ - name: Log in to Docker Hub
403
+ if: steps.check.outputs.needs_bump == 'true'
404
+ uses: docker/login-action@v3
405
+ with:
406
+ registry: docker.io
407
+ username: ${{ secrets.DOCKERHUB_USERNAME }}
408
+ password: ${{ secrets.DOCKERHUB_TOKEN }}
409
+
410
+ - name: Log in to GitHub Container Registry
411
+ if: steps.check.outputs.needs_bump == 'true'
412
+ uses: docker/login-action@v3
413
+ with:
414
+ registry: ghcr.io
415
+ username: ${{ github.repository_owner }}
416
+ password: ${{ secrets.GITHUB_TOKEN }}
417
+
418
+ - name: Build and push Docker image
419
+ if: steps.check.outputs.needs_bump == 'true'
420
+ uses: docker/build-push-action@v6
421
+ with:
422
+ context: .
423
+ platforms: linux/amd64,linux/arm64
424
+ push: true
425
+ tags: |
426
+ ${{ steps.image_names.outputs.docker_image }}:${{ steps.next_version.outputs.new_tag }}
427
+ ${{ steps.image_names.outputs.docker_image }}:latest
428
+ ${{ steps.image_names.outputs.docker_image }}:${{ steps.next_version.outputs.new_version }}
429
+ ${{ steps.image_names.outputs.ghcr_image }}:${{ steps.next_version.outputs.new_tag }}
430
+ ${{ steps.image_names.outputs.ghcr_image }}:latest
431
+ ${{ steps.image_names.outputs.ghcr_image }}:${{ steps.next_version.outputs.new_version }}
432
+ labels: |
433
+ org.opencontainers.image.version=${{ steps.next_version.outputs.new_version }}
434
+ org.opencontainers.image.revision=${{ github.sha }}
435
+ org.opencontainers.image.source=https://github.com/${{ github.repository }}
436
+ cache-from: type=gha
437
+ cache-to: type=gha,mode=max
438
+
439
+ - name: Send Telegram Notification
440
+ if: steps.check.outputs.needs_bump == 'true' && env.TELEGRAM_BOT_TOKEN != '' && env.TELEGRAM_CHAT_ID != ''
441
+ env:
442
+ TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
443
+ TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
444
+ DOCKER_IMAGE: ${{ steps.image_names.outputs.docker_image }}
445
+ GHCR_IMAGE: ${{ steps.image_names.outputs.ghcr_image }}
446
+ continue-on-error: true
447
+ run: |
448
+ VERSION="${{ steps.next_version.outputs.new_version }}"
449
+ TAG="${{ steps.next_version.outputs.new_tag }}"
450
+ REPO="${{ github.repository }}"
451
+
452
+ # 获取更新内容并限制长度
453
+ CHANGELOG="${{ steps.changelog.outputs.content }}"
454
+ CHANGELOG_TRUNCATED=$(echo "$CHANGELOG" | head -c 1000)
455
+ if [ ${#CHANGELOG} -gt 1000 ]; then
456
+ CHANGELOG_TRUNCATED="${CHANGELOG_TRUNCATED}..."
457
+ fi
458
+
459
+ # 构建消息内容
460
+ MESSAGE="🚀 *Claude Relay Service 新版本发布!*"$'\n'$'\n'
461
+ MESSAGE+="📦 版本号: \`${VERSION}\`"$'\n'$'\n'
462
+ MESSAGE+="📝 *更新内容:*"$'\n'
463
+ MESSAGE+="${CHANGELOG_TRUNCATED}"$'\n'$'\n'
464
+ MESSAGE+="🐳 *Docker 部署:*"$'\n'
465
+ MESSAGE+="\`\`\`bash"$'\n'
466
+ MESSAGE+="docker pull ${DOCKER_IMAGE}:${TAG}"$'\n'
467
+ MESSAGE+="docker pull ${DOCKER_IMAGE}:latest"$'\n'
468
+ MESSAGE+="docker pull ${GHCR_IMAGE}:${TAG}"$'\n'
469
+ MESSAGE+="docker pull ${GHCR_IMAGE}:latest"$'\n'
470
+ MESSAGE+="\`\`\`"$'\n'$'\n'
471
+ MESSAGE+="🔗 *相关链接:*"$'\n'
472
+ MESSAGE+="• [GitHub Release](https://github.com/${REPO}/releases/tag/${TAG})"$'\n'
473
+ MESSAGE+="• [完整更新日志](https://github.com/${REPO}/releases)"$'\n'
474
+ MESSAGE+="• [Docker Hub](https://hub.docker.com/r/${DOCKER_IMAGE%/*}/claude-relay-service)"$'\n'
475
+ MESSAGE+="• [GHCR](https://ghcr.io/${GHCR_IMAGE#ghcr.io/})"$'\n'$'\n'
476
+ MESSAGE+="#ClaudeRelay #Update #v${VERSION//./_}"
477
+
478
+ # 使用 jq 构建 JSON 并发送
479
+ jq -n \
480
+ --arg chat_id "${TELEGRAM_CHAT_ID}" \
481
+ --arg text "${MESSAGE}" \
482
+ '{
483
+ chat_id: $chat_id,
484
+ text: $text,
485
+ parse_mode: "Markdown",
486
+ disable_web_page_preview: false
487
+ }' | \
488
+ curl -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \
489
+ -H "Content-Type: application/json" \
490
+ -d @-
.github/workflows/pr-lint-check.yml ADDED
@@ -0,0 +1,320 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: PR Lint and Format Check
2
+
3
+ on:
4
+ pull_request:
5
+ types: [opened, synchronize, reopened]
6
+ paths:
7
+ - '**.js'
8
+ - '**.jsx'
9
+ - '**.ts'
10
+ - '**.tsx'
11
+ - '**.vue'
12
+ - '**.json'
13
+ - '**.cjs'
14
+ - '**.mjs'
15
+ - '.prettierrc'
16
+ - '.eslintrc.cjs'
17
+ - 'package.json'
18
+ - 'web/admin-spa/**'
19
+
20
+ permissions:
21
+ contents: read
22
+ pull-requests: write
23
+ issues: write
24
+
25
+ jobs:
26
+ lint-and-format:
27
+ runs-on: ubuntu-latest
28
+ name: Check Code Quality
29
+
30
+ steps:
31
+ - name: Checkout code
32
+ uses: actions/checkout@v4
33
+ with:
34
+ fetch-depth: 0
35
+
36
+ - name: Setup Node.js
37
+ uses: actions/setup-node@v4
38
+ with:
39
+ node-version: '18'
40
+ cache: 'npm'
41
+
42
+ - name: Cache dependencies
43
+ uses: actions/cache@v3
44
+ with:
45
+ path: ~/.npm
46
+ key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
47
+ restore-keys: |
48
+ ${{ runner.os }}-node-
49
+
50
+ - name: Install dependencies
51
+ run: |
52
+ npm ci --prefer-offline --no-audit
53
+ # 安装 web 目录的依赖(如果存在)
54
+ if [ -d "web/admin-spa" ] && [ -f "web/admin-spa/package.json" ]; then
55
+ cd web/admin-spa
56
+ npm ci --prefer-offline --no-audit
57
+ cd ../..
58
+ fi
59
+
60
+ - name: Get changed files
61
+ id: changed-files
62
+ uses: tj-actions/changed-files@v41
63
+ with:
64
+ files: |
65
+ **/*.js
66
+ **/*.jsx
67
+ **/*.ts
68
+ **/*.tsx
69
+ **/*.vue
70
+ **/*.cjs
71
+ **/*.mjs
72
+ **/*.json
73
+ files_ignore: |
74
+ node_modules/**
75
+ dist/**
76
+ build/**
77
+ coverage/**
78
+ .git/**
79
+ logs/**
80
+ temp/**
81
+ tmp/**
82
+
83
+ - name: Check Prettier formatting
84
+ if: steps.changed-files.outputs.any_changed == 'true'
85
+ id: prettier-check
86
+ run: |
87
+ echo "🔍 Checking Prettier formatting for changed files..."
88
+ echo "Changed files: ${{ steps.changed-files.outputs.all_changed_files }}"
89
+
90
+ # 初始化标志
91
+ PRETTIER_FAILED=false
92
+ PRETTIER_OUTPUT=""
93
+
94
+ # 检查每个改变的文件
95
+ for file in ${{ steps.changed-files.outputs.all_changed_files }}; do
96
+ if [ -f "$file" ]; then
97
+ echo "Checking: $file"
98
+
99
+ # 根据文件位置选择正确的 prettier 配置
100
+ if [[ "$file" == web/admin-spa/* ]]; then
101
+ # 前端文件:进入前端目录运行 prettier
102
+ cd web/admin-spa
103
+ RELATIVE_FILE="${file#web/admin-spa/}"
104
+ if ! npx prettier --check "$RELATIVE_FILE" 2>&1; then
105
+ PRETTIER_FAILED=true
106
+ DIFF=$(npx prettier "$RELATIVE_FILE" | diff -u "$RELATIVE_FILE" - || true)
107
+ if [ -n "$DIFF" ]; then
108
+ PRETTIER_OUTPUT="${PRETTIER_OUTPUT}❌ File needs formatting: $file\n"
109
+ PRETTIER_OUTPUT="${PRETTIER_OUTPUT}\`\`\`diff\n${DIFF}\n\`\`\`\n\n"
110
+ fi
111
+ else
112
+ echo "✅ $file is properly formatted"
113
+ fi
114
+ cd ../..
115
+ else
116
+ # 后端文件:使用根目录的 prettier
117
+ if ! npx prettier --check "$file" 2>&1; then
118
+ PRETTIER_FAILED=true
119
+ DIFF=$(npx prettier "$file" | diff -u "$file" - || true)
120
+ if [ -n "$DIFF" ]; then
121
+ PRETTIER_OUTPUT="${PRETTIER_OUTPUT}❌ File needs formatting: $file\n"
122
+ PRETTIER_OUTPUT="${PRETTIER_OUTPUT}\`\`\`diff\n${DIFF}\n\`\`\`\n\n"
123
+ fi
124
+ else
125
+ echo "✅ $file is properly formatted"
126
+ fi
127
+ fi
128
+ fi
129
+ done
130
+
131
+ # 输出结果
132
+ if [ "$PRETTIER_FAILED" = true ]; then
133
+ echo "prettier_failed=true" >> $GITHUB_OUTPUT
134
+ echo -e "$PRETTIER_OUTPUT" > prettier-report.md
135
+ echo "❌ Some files are not properly formatted."
136
+ echo "Please run: npm run format (backend) or cd web/admin-spa && npm run format (frontend)"
137
+ exit 1
138
+ else
139
+ echo "prettier_failed=false" >> $GITHUB_OUTPUT
140
+ echo "✅ All files are properly formatted"
141
+ fi
142
+
143
+ - name: Run ESLint
144
+ if: steps.changed-files.outputs.any_changed == 'true'
145
+ id: eslint-check
146
+ run: |
147
+ echo "🔍 Running ESLint on changed files..."
148
+
149
+ # 分离前端和后端文件
150
+ BACKEND_FILES=""
151
+ FRONTEND_FILES=""
152
+
153
+ for file in ${{ steps.changed-files.outputs.all_changed_files }}; do
154
+ if [[ "$file" =~ \.(js|jsx|vue|cjs|mjs)$ ]] && [ -f "$file" ]; then
155
+ if [[ "$file" == web/admin-spa/* ]]; then
156
+ FRONTEND_FILES="$FRONTEND_FILES ${file#web/admin-spa/}"
157
+ else
158
+ BACKEND_FILES="$BACKEND_FILES $file"
159
+ fi
160
+ fi
161
+ done
162
+
163
+ ESLINT_FAILED=false
164
+ ESLINT_OUTPUT=""
165
+
166
+ # 检查后端文件
167
+ if [ -n "$BACKEND_FILES" ]; then
168
+ echo "Linting backend files: $BACKEND_FILES"
169
+ set +e
170
+ BACKEND_OUTPUT=$(npx eslint $BACKEND_FILES --format stylish 2>&1)
171
+ BACKEND_EXIT_CODE=$?
172
+ set -e
173
+
174
+ if [ $BACKEND_EXIT_CODE -ne 0 ]; then
175
+ ESLINT_FAILED=true
176
+ ESLINT_OUTPUT="${ESLINT_OUTPUT}### Backend ESLint Issues\n\`\`\`\n${BACKEND_OUTPUT}\n\`\`\`\n\n"
177
+ fi
178
+ fi
179
+
180
+ # 检查前端文件
181
+ if [ -n "$FRONTEND_FILES" ]; then
182
+ echo "Linting frontend files: $FRONTEND_FILES"
183
+ cd web/admin-spa
184
+ set +e
185
+ FRONTEND_OUTPUT=$(npx eslint $FRONTEND_FILES --format stylish 2>&1)
186
+ FRONTEND_EXIT_CODE=$?
187
+ set -e
188
+ cd ../..
189
+
190
+ if [ $FRONTEND_EXIT_CODE -ne 0 ]; then
191
+ ESLINT_FAILED=true
192
+ ESLINT_OUTPUT="${ESLINT_OUTPUT}### Frontend ESLint Issues\n\`\`\`\n${FRONTEND_OUTPUT}\n\`\`\`\n\n"
193
+ fi
194
+ fi
195
+
196
+ # 输出结果
197
+ if [ "$ESLINT_FAILED" = true ]; then
198
+ echo "eslint_failed=true" >> $GITHUB_OUTPUT
199
+ echo "❌ ESLint found issues"
200
+
201
+ # 创建错误报告
202
+ echo "## ESLint Report" > eslint-report.md
203
+ echo "$ESLINT_OUTPUT" >> eslint-report.md
204
+ echo "" >> eslint-report.md
205
+ echo "Please fix these issues by running:" >> eslint-report.md
206
+ echo '```bash' >> eslint-report.md
207
+ echo "# Backend: npm run lint" >> eslint-report.md
208
+ echo "# Frontend: cd web/admin-spa && npm run lint" >> eslint-report.md
209
+ echo '```' >> eslint-report.md
210
+
211
+ exit 1
212
+ else
213
+ echo "eslint_failed=false" >> $GITHUB_OUTPUT
214
+ echo "✅ ESLint check passed"
215
+ fi
216
+
217
+ - name: Debug PR Context
218
+ if: failure()
219
+ run: |
220
+ echo "PR Number: ${{ github.event.pull_request.number }}"
221
+ echo "Repo: ${{ github.repository }}"
222
+ echo "Event Name: ${{ github.event_name }}"
223
+ echo "Actor: ${{ github.actor }}"
224
+
225
+ - name: Comment PR with results
226
+ if: failure()
227
+ continue-on-error: true # 即使评论失败也继续
228
+ uses: actions/github-script@v7
229
+ with:
230
+ github-token: ${{ secrets.GH_PAT || secrets.GITHUB_TOKEN }}
231
+ script: |
232
+ const fs = require('fs');
233
+ let comment = '## 🚨 Code Quality Check Failed\n\n';
234
+
235
+ // 读取 Prettier 报告
236
+ if (fs.existsSync('prettier-report.md')) {
237
+ const prettierReport = fs.readFileSync('prettier-report.md', 'utf8');
238
+ comment += '### Prettier Formatting Issues\n\n';
239
+ comment += prettierReport;
240
+ comment += '\n**Fix command:**\n```bash\nnpm run format\n```\n\n';
241
+ }
242
+
243
+ // 读取 ESLint 报告
244
+ if (fs.existsSync('eslint-report.md')) {
245
+ const eslintReport = fs.readFileSync('eslint-report.md', 'utf8');
246
+ comment += '### ESLint Issues\n\n';
247
+ comment += eslintReport;
248
+ }
249
+
250
+ comment += '\n---\n';
251
+ comment += '💡 **提示**: 在本地运行以下命令来自动修复大部分问题:\n';
252
+ comment += '```bash\n';
253
+ comment += '# 后端代码\n';
254
+ comment += 'npm run format # 修复后端 Prettier 格式问题\n';
255
+ comment += 'npm run lint # 修复后端 ESLint 问题\n';
256
+ comment += '\n';
257
+ comment += '# 前端代码\n';
258
+ comment += 'cd web/admin-spa\n';
259
+ comment += 'npm run format # 修复前端 Prettier 格式问题\n';
260
+ comment += 'npm run lint # 修复前端 ESLint 问题\n';
261
+ comment += '```\n';
262
+
263
+ // 查找是否已有机器人评论
264
+ const { data: comments } = await github.rest.issues.listComments({
265
+ owner: context.repo.owner,
266
+ repo: context.repo.repo,
267
+ issue_number: context.issue.number,
268
+ });
269
+
270
+ const botComment = comments.find(comment =>
271
+ comment.user.type === 'Bot' &&
272
+ comment.body.includes('Code Quality Check Failed')
273
+ );
274
+
275
+ if (botComment) {
276
+ // 更新现有评论
277
+ await github.rest.issues.updateComment({
278
+ owner: context.repo.owner,
279
+ repo: context.repo.repo,
280
+ comment_id: botComment.id,
281
+ body: comment
282
+ });
283
+ } else {
284
+ // 创建新评论
285
+ await github.rest.issues.createComment({
286
+ owner: context.repo.owner,
287
+ repo: context.repo.repo,
288
+ issue_number: context.issue.number,
289
+ body: comment
290
+ });
291
+ }
292
+
293
+ - name: Success comment
294
+ if: success() && steps.changed-files.outputs.any_changed == 'true'
295
+ continue-on-error: true # 即使评论失败也继续
296
+ uses: actions/github-script@v7
297
+ with:
298
+ github-token: ${{ secrets.GH_PAT || secrets.GITHUB_TOKEN }}
299
+ script: |
300
+ // 查找是否已有失败的评论
301
+ const { data: comments } = await github.rest.issues.listComments({
302
+ owner: context.repo.owner,
303
+ repo: context.repo.repo,
304
+ issue_number: context.issue.number,
305
+ });
306
+
307
+ const botComment = comments.find(comment =>
308
+ comment.user.type === 'Bot' &&
309
+ comment.body.includes('Code Quality Check Failed')
310
+ );
311
+
312
+ if (botComment) {
313
+ // 如果之前有失败评论,更新为成功
314
+ await github.rest.issues.updateComment({
315
+ owner: context.repo.owner,
316
+ repo: context.repo.repo,
317
+ comment_id: botComment.id,
318
+ body: '## ✅ Code Quality Check Passed\n\nAll files are properly formatted and pass linting checks!'
319
+ });
320
+ }
.gitignore ADDED
@@ -0,0 +1,251 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # fork add
2
+ docs/
3
+
4
+ # Dependencies
5
+ node_modules/
6
+ npm-debug.log*
7
+ yarn-debug.log*
8
+ yarn-error.log*
9
+ pnpm-debug.log*
10
+
11
+ # Environment variables
12
+ .env
13
+ .env.*
14
+ !.env.example
15
+
16
+ # Claude specific directories
17
+ .claude/
18
+
19
+ # MCP configuration (local only)
20
+ .mcp.json
21
+ .spec-workflow/
22
+
23
+ # Data directory (contains sensitive information)
24
+ data/
25
+ !data/.gitkeep
26
+
27
+ # Redis data directory
28
+ redis_data/
29
+
30
+ # Logs directory
31
+ logs/
32
+ *.log
33
+ startup.log
34
+ app.log
35
+
36
+ # Configuration files (may contain sensitive data)
37
+ config/config.js
38
+ !config/config.example.js
39
+
40
+ # Runtime data
41
+ pids/
42
+ *.pid
43
+ *.seed
44
+ *.pid.lock
45
+
46
+ # Coverage directory used by tools like istanbul
47
+ coverage/
48
+ *.lcov
49
+
50
+ # nyc test coverage
51
+ .nyc_output
52
+
53
+ # Grunt intermediate storage
54
+ .grunt
55
+
56
+ # Bower dependency directory
57
+ bower_components
58
+
59
+ # node-waf configuration
60
+ .lock-wscript
61
+
62
+ # Compiled binary addons
63
+ build/Release
64
+
65
+ # Dependency directories
66
+ jspm_packages/
67
+
68
+ # TypeScript cache
69
+ *.tsbuildinfo
70
+
71
+ # Optional npm cache directory
72
+ .npm
73
+
74
+ # Optional eslint cache
75
+ .eslintcache
76
+
77
+ # Optional stylelint cache
78
+ .stylelintcache
79
+
80
+ # Microbundle cache
81
+ .rpt2_cache/
82
+ .rts2_cache_cjs/
83
+ .rts2_cache_es/
84
+ .rts2_cache_umd/
85
+
86
+ # Optional REPL history
87
+ .node_repl_history
88
+
89
+ # Output of 'npm pack'
90
+ *.tgz
91
+
92
+ # Yarn Integrity file
93
+ .yarn-integrity
94
+
95
+ # parcel-bundler cache
96
+ .cache
97
+ .parcel-cache
98
+
99
+ # Next.js build output
100
+ .next
101
+
102
+ # Nuxt.js build / generate output
103
+ .nuxt
104
+ # Gatsby files
105
+ .cache/
106
+ public
107
+
108
+ # Vuepress build output
109
+ .vuepress/dist
110
+
111
+ # Serverless directories
112
+ .serverless/
113
+
114
+ # FuseBox cache
115
+ .fusebox/
116
+
117
+ # DynamoDB Local files
118
+ .dynamodb/
119
+
120
+ # TernJS port file
121
+ .tern-port
122
+
123
+ # Stores VSCode versions used for testing VSCode extensions
124
+ .vscode-test
125
+
126
+ # Temporary folders
127
+ tmp/
128
+ temp/
129
+ .tmp/
130
+ .temp/
131
+
132
+ # OS generated files
133
+ .DS_Store
134
+ .DS_Store?
135
+ ._*
136
+ .Spotlight-V100
137
+ .Trashes
138
+ ehthumbs.db
139
+ Thumbs.db
140
+ desktop.ini
141
+
142
+ # IDE files
143
+ .vscode/
144
+ .idea/
145
+ *.swp
146
+ *.swo
147
+ *~
148
+
149
+ # Backup files
150
+ *.bak
151
+ *.backup
152
+ *.backup.*
153
+ .env.backup.*
154
+ config.js.backup.*
155
+ *~
156
+
157
+ # Archive files (unless specifically needed)
158
+ *.7z
159
+ *.dmg
160
+ *.gz
161
+ *.iso
162
+ *.jar
163
+ *.rar
164
+ *.tar
165
+ *.zip
166
+
167
+ # Application specific files
168
+ # JWT secrets and encryption keys
169
+ secrets/
170
+ keys/
171
+ certs/
172
+
173
+ # Database dumps
174
+ *.sql
175
+ *.db
176
+ *.sqlite
177
+ *.sqlite3
178
+
179
+ # Redis dumps
180
+ dump.rdb
181
+ appendonly.aof
182
+
183
+ # PM2 files
184
+ ecosystem.config.js
185
+ .pm2/
186
+
187
+ # Docker files (keep main ones, ignore volumes)
188
+ .docker/
189
+ docker-volumes/
190
+
191
+ # Monitoring data
192
+ prometheus/
193
+ grafana/
194
+
195
+ # Test files and coverage
196
+ test-results/
197
+ coverage/
198
+ .nyc_output/
199
+
200
+ # Documentation build
201
+ docs/build/
202
+ docs/dist/
203
+
204
+ # Deployment files
205
+ deploy/
206
+ .deploy/
207
+
208
+ # Package lock files (choose one)
209
+ # Uncomment the one you DON'T want to track
210
+ # package-lock.json
211
+ # yarn.lock
212
+ # pnpm-lock.yaml
213
+
214
+ # Local development files
215
+ .local/
216
+ local/
217
+
218
+ # Debug files
219
+ debug.log
220
+ error.log
221
+ access.log
222
+ http-debug*.log
223
+ logs/http-debug-*.log
224
+
225
+ src/middleware/debugInterceptor.js
226
+
227
+ # Session files
228
+ sessions/
229
+
230
+ # Upload directories
231
+ uploads/
232
+ files/
233
+
234
+ # Cache directories
235
+ .cache/
236
+ cache/
237
+
238
+ # Build artifacts
239
+ build/
240
+ dist/
241
+ out/
242
+
243
+ # Runtime files
244
+ *.sock
245
+
246
+ # Old admin interface (deprecated)
247
+ web/admin/
248
+ web/apiStats/
249
+
250
+ # Admin SPA build files
251
+ web/admin-spa/dist/
.prettierrc ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "semi": false,
3
+ "trailingComma": "none",
4
+ "singleQuote": true,
5
+ "printWidth": 100,
6
+ "tabWidth": 2,
7
+ "useTabs": false,
8
+ "bracketSpacing": true,
9
+ "arrowParens": "always",
10
+ "endOfLine": "lf",
11
+ "quoteProps": "as-needed",
12
+ "bracketSameLine": false,
13
+ "proseWrap": "preserve"
14
+ }
CLAUDE.md ADDED
@@ -0,0 +1,275 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # CLAUDE.md
2
+
3
+ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4
+
5
+ 这个文件为 Claude Code (claude.ai/code) 提供在此代码库中工作的指导。
6
+
7
+ ## 项目概述
8
+
9
+ Claude Relay Service 是一个功能完整的 AI API 中转服务,支持 Claude 和 Gemini 双平台。提供多账户管理、API Key 认证、代理配置和现代化 Web 管理界面。该服务作为客户端(如 SillyTavern、Claude Code、Gemini CLI)与 AI API 之间的中间件,提供认证、限流、监控等功能。
10
+
11
+ ## 核心架构
12
+
13
+ ### 关键架构概念
14
+
15
+ - **代理认证流**: 客户端用自建API Key → 验证 → 获取Claude账户OAuth token → 转发到Anthropic
16
+ - **Token管理**: 自动监控OAuth token过期并刷新,支持10秒提前刷新策略
17
+ - **代理支持**: 每个Claude账户支持独立代理配置,OAuth token交换也通过代理进行
18
+ - **数据加密**: 敏感数据(refreshToken, accessToken)使用AES加密存储在Redis
19
+
20
+ ### 主要服务组件
21
+
22
+ - **claudeRelayService.js**: 核心代理服务,处理请求转发和流式响应
23
+ - **claudeAccountService.js**: Claude账户管理,OAuth token刷新和账户选择
24
+ - **geminiAccountService.js**: Gemini账户管理,Google OAuth token刷新和账户选择
25
+ - **apiKeyService.js**: API Key管理,验证、限流和使用统计
26
+ - **oauthHelper.js**: OAuth工具,PKCE流程实现和代理支持
27
+
28
+ ### 认证和代理流程
29
+
30
+ 1. 客户端使用自建API Key(cr\_前缀格式)发送请求
31
+ 2. authenticateApiKey中间件验证API Key有效性和速率限制
32
+ 3. claudeAccountService自动选择可用Claude账户
33
+ 4. 检查OAuth access token有效性,过期则自动刷新(使用代理)
34
+ 5. 移除客户端API Key,使用OAuth Bearer token转发请求
35
+ 6. 通过账户配置的代理发送到Anthropic API
36
+ 7. 流式或非流式返回响应,记录使用统计
37
+
38
+ ### OAuth集成
39
+
40
+ - **PKCE流程**: 完整的OAuth 2.0 PKCE实现,支持代理
41
+ - **自动刷新**: 智能token过期检测和自动刷新机制
42
+ - **代理支持**: OAuth授权和token交换全程支持代理配置
43
+ - **安全存储**: claudeAiOauth数据加密存储,包含accessToken、refreshToken、scopes
44
+
45
+ ## 常用命令
46
+
47
+ ### 基本开发命令
48
+
49
+ ````bash
50
+ # 安装依赖和初始化
51
+ npm install
52
+ npm run setup # 生成配置和管理员凭据
53
+ npm run install:web # 安装Web界面依赖
54
+
55
+ # 开发和运行
56
+ npm run dev # 开发模式(热重载)
57
+ npm start # 生产模式
58
+ npm test # 运行测试
59
+ npm run lint # 代码检查
60
+
61
+ # Docker部署
62
+ docker-compose up -d # 推荐方式
63
+ docker-compose --profile monitoring up -d # 包含监控
64
+
65
+ # 服务管理
66
+ npm run service:start:daemon # 后台启动(推荐)
67
+ npm run service:status # 查看服务状态
68
+ npm run service:logs # 查看日志
69
+ npm run service:stop # 停止服务
70
+
71
+ ### 开发环境配置
72
+ 必须配置的环境变量:
73
+ - `JWT_SECRET`: JWT密钥(32字符以上随机字符串)
74
+ - `ENCRYPTION_KEY`: 数据加密密钥(32字符固定长度)
75
+ - `REDIS_HOST`: Redis主机地址(默认localhost)
76
+ - `REDIS_PORT`: Redis端口(默认6379)
77
+ - `REDIS_PASSWORD`: Redis密码(可选)
78
+
79
+ 初始化命令:
80
+ ```bash
81
+ cp config/config.example.js config/config.js
82
+ cp .env.example .env
83
+ npm run setup # 自动生成密钥并创建管理员账户
84
+ ````
85
+
86
+ ## Web界面功能
87
+
88
+ ### OAuth账户添加流程
89
+
90
+ 1. **基本信息和代理设置**: 配置账户名称、描述和代理参数
91
+ 2. **OAuth授权**:
92
+ - 生成授权URL → 用户打开链接并登录Claude Code账号
93
+ - 授权后会显示Authorization Code → 复制并粘贴到输入框
94
+ - 系统自动交换token并创建账户
95
+
96
+ ### 核心管理功能
97
+
98
+ - **实时仪表板**: 系统统计、账户状态、使用量监控
99
+ - **API Key管理**: 创建、配额设置、使用统计查看
100
+ - **Claude账户管理**: OAuth账户添加、代理配置、状态监控
101
+ - **系统日志**: 实时日志查看,多级别过滤
102
+ - **主题系统**: 支持明亮/暗黑模式切换,自动保存用户偏好设置
103
+
104
+ ## 重要端点
105
+
106
+ ### API转发端点
107
+
108
+ - `POST /api/v1/messages` - 主要消息处理端点(支持流式)
109
+ - `GET /api/v1/models` - 模型列表(兼容性)
110
+ - `GET /api/v1/usage` - 使用统计查询
111
+ - `GET /api/v1/key-info` - API Key信息
112
+
113
+ ### OAuth管理端点
114
+
115
+ - `POST /admin/claude-accounts/generate-auth-url` - 生成OAuth授权URL(含代理)
116
+ - `POST /admin/claude-accounts/exchange-code` - 交换authorization code
117
+ - `POST /admin/claude-accounts` - 创建OAuth账户
118
+
119
+ ### 系统端点
120
+
121
+ - `GET /health` - 健康检查
122
+ - `GET /web` - Web管理界面
123
+ - `GET /admin/dashboard` - 系统概览数据
124
+
125
+ ## 故障排除
126
+
127
+ ### OAuth相关问题
128
+
129
+ 1. **代理配置错误**: 检查代理设置是否正确,OAuth token交换也需要代理
130
+ 2. **授权码无效**: 确保复制了完整的Authorization Code���没有遗漏字符
131
+ 3. **Token刷新失败**: 检查refreshToken有效性和代理配置
132
+
133
+ ### Gemini Token刷新问题
134
+
135
+ 1. **刷新失败**: 确保 refresh_token 有效且未过期
136
+ 2. **错误日志**: 查看 `logs/token-refresh-error.log` 获取详细错误信息
137
+ 3. **测试脚本**: 运行 `node scripts/test-gemini-refresh.js` 测试 token 刷新
138
+
139
+ ### 常见开发问题
140
+
141
+ 1. **Redis连接失败**: 确认Redis服务运行,检查连接配置
142
+ 2. **管理员登录失败**: 检查init.json同步到Redis,运行npm run setup
143
+ 3. **API Key格式错误**: 确保使用cr\_前缀格式
144
+ 4. **代理连接问题**: 验证SOCKS5/HTTP代理配置和认证信息
145
+
146
+ ### 调试工具
147
+
148
+ - **日志系统**: Winston结构化日志,支持不同级别
149
+ - **CLI工具**: 命令行状态查看和管理
150
+ - **Web界面**: 实时日志查看和系统监控
151
+ - **健康检查**: /health端点提供系统状态
152
+
153
+ ## 开发最佳实践
154
+
155
+ ### 代码格式化要求
156
+
157
+ - **必须使用 Prettier 格式化所有代码**
158
+ - 后端代码(src/):运行 `npx prettier --write <file>` 格式化
159
+ - 前端代码(web/admin-spa/):已安装 `prettier-plugin-tailwindcss`,运行 `npx prettier --write <file>` 格式化
160
+ - 提交前检查格式:`npx prettier --check <file>`
161
+ - 格式化所有文件:`npm run format`(如果配置了此脚本)
162
+
163
+ ### 前端开发特殊要求
164
+
165
+ - **响应式设计**: 必须兼容不同设备尺寸(手机、平板、桌面),使用 Tailwind CSS 响应式前缀(sm:、md:、lg:、xl:)
166
+ - **暗黑模式兼容**: 项目已集成完整的暗黑模式支持,所有新增/修改的UI组件都必须同时兼容明亮模式和暗黑模式
167
+ - 使用 Tailwind CSS 的 `dark:` 前缀为暗黑模式提供样式
168
+ - 文本颜色:`text-gray-700 dark:text-gray-200`
169
+ - 背景颜色:`bg-white dark:bg-gray-800`
170
+ - 边框颜色:`border-gray-200 dark:border-gray-700`
171
+ - 状态颜色保持一致:`text-blue-500`、`text-green-600`、`text-red-500` 等
172
+ - **主题切换**: 使用 `stores/theme.js` 中的 `useThemeStore()` 来实现主题切换功能
173
+ - **玻璃态效果**: 保持现有的玻璃态设计风格,在暗黑模式下调整透明度和背景色
174
+ - **图标和交互**: 确保所有图标、按钮、交互元素在两种模式下都清晰可见且易于操作
175
+
176
+ ### 代码修改原则
177
+
178
+ - 对现有文件进行修改时,首先检查代码库的现有模式和风格
179
+ - 尽可能重用现有的服务和工具函数,避免重复代码
180
+ - 遵循项目现有的错误处理和日志记录模式
181
+ - 敏感数据必须使用加密存储(参考 claudeAccountService.js 中的加密实现)
182
+
183
+ ### 测试和质量保证
184
+
185
+ - 运行 `npm run lint` 进行代码风格检查(使用 ESLint)
186
+ - 运行 `npm test` 执行测试套件(Jest + SuperTest 配置)
187
+ - 在修改核心服务后,使用 CLI 工具验证功能:`npm run cli status`
188
+ - 检查日志文件 `logs/claude-relay-*.log` 确认服务正常运行
189
+ - 注意:当前项目缺少实际测试文件,建议补充单元测试和集成测试
190
+
191
+ ### 开发工作流
192
+
193
+ - **功能开发**: 始终从理解现有代码开始,重用已有的服务和模式
194
+ - **调试流程**: 使用 Winston 日志 + Web 界面实时日志查看 + CLI 状态工具
195
+ - **代码审查**: 关注安全性(加密存储)、性能(异步处理)、错误处理
196
+ - **部署前检查**: 运行 lint → 测试 CLI 功能 → 检查日志 → Docker 构建
197
+
198
+ ### 常见文件位置
199
+
200
+ - 核心服务逻辑:`src/services/` 目录
201
+ - 路由处理:`src/routes/` 目录
202
+ - 中间件:`src/middleware/` 目录
203
+ - 配置管理:`config/config.js`
204
+ - Redis 模型:`src/models/redis.js`
205
+ - 工具函数:`src/utils/` 目录
206
+ - 前端主题管理:`web/admin-spa/src/stores/theme.js`
207
+ - 前端组件:`web/admin-spa/src/components/` 目录
208
+ - 前端页面:`web/admin-spa/src/views/` 目录
209
+
210
+ ### 重要架构决策
211
+
212
+ - 所有敏感数据(OAuth token、refreshToken)都使用 AES 加密存储在 Redis
213
+ - 每个 Claude 账户支持独立的代理配置,包括 SOCKS5 和 HTTP 代理
214
+ - API Key 使用哈希存储,支持 `cr_` 前缀格式
215
+ - 请求流程:API Key 验证 → 账户选择 → Token 刷新(如需)→ 请求转发
216
+ - 支持流式和非流式响应,客户端断开时自动清理资源
217
+
218
+ ### 核心数据流和性能优化
219
+
220
+ - **哈希映射优化**: API Key 验证从 O(n) 优化到 O(1) 查找
221
+ - **智能 Usage 捕获**: 从 SSE 流中解析真实的 token 使用数据
222
+ - **多维度统计**: 支持按时间、模型、用户的实时使用统计
223
+ - **异步处理**: 非阻塞的统计记录和日志写入
224
+ - **原子操作**: Redis 管道操作确保数据一致性
225
+
226
+ ### 安全和容错机制
227
+
228
+ - **多层加密**: API Key 哈希 + OAuth Token AES 加密
229
+ - **零信任验证**: 每个请求都需要完整的认证链
230
+ - **优雅降级**: Redis 连接失败时的回退机制
231
+ - **自动重试**: 指数退避重试策略和错误隔离
232
+ - **资源清理**: 客户端断开时的自动清理机制
233
+
234
+ ## 项目特定注意事项
235
+
236
+ ### Redis 数据��构
237
+
238
+ - **API Keys**: `api_key:{id}` (详细信息) + `api_key_hash:{hash}` (快速查找)
239
+ - **Claude 账户**: `claude_account:{id}` (加密的 OAuth 数据)
240
+ - **管理员**: `admin:{id}` + `admin_username:{username}` (用户名映射)
241
+ - **会话**: `session:{token}` (JWT 会话管理)
242
+ - **使用统计**: `usage:daily:{date}:{key}:{model}` (多维度统计)
243
+ - **系统信息**: `system_info` (系统状态缓存)
244
+
245
+ ### 流式响应处理
246
+
247
+ - 支持 SSE (Server-Sent Events) 流式传输
248
+ - 自动从流中解析 usage 数据并记录
249
+ - 客户端断开时通过 AbortController 清理资源
250
+ - 错误时发送适当的 SSE 错误事件
251
+
252
+ ### CLI 工具使用示例
253
+
254
+ ```bash
255
+ # 创建新的 API Key
256
+ npm run cli keys create -- --name "MyApp" --limit 1000
257
+
258
+ # 查看系统状态
259
+ npm run cli status
260
+
261
+ # 管理 Claude 账户
262
+ npm run cli accounts list
263
+ npm run cli accounts refresh <accountId>
264
+
265
+ # 管理员操作
266
+ npm run cli admin create -- --username admin2
267
+ npm run cli admin reset-password -- --username admin
268
+ ```
269
+
270
+ # important-instruction-reminders
271
+
272
+ Do what has been asked; nothing more, nothing less.
273
+ NEVER create files unless they're absolutely necessary for achieving your goal.
274
+ ALWAYS prefer editing an existing file to creating a new one.
275
+ NEVER proactively create documentation files (\*.md) or README files. Only create documentation files if explicitly requested by the User.
Dockerfile ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # 🎯 前端构建阶段
2
+ FROM node:18-alpine AS frontend-builder
3
+
4
+ # 📁 设置工作目录
5
+ WORKDIR /app/web/admin-spa
6
+
7
+ # 📦 复制前端依赖文件
8
+ COPY web/admin-spa/package*.json ./
9
+
10
+ # 🔽 安装前端依赖
11
+ RUN npm ci
12
+
13
+ # 📋 复制前端源代码
14
+ COPY web/admin-spa/ ./
15
+
16
+ # 🏗️ 构建前端
17
+ RUN npm run build
18
+
19
+ # 🐳 主应用阶段
20
+ FROM node:18-alpine
21
+
22
+ # 📋 设置标签
23
+ LABEL maintainer="claude-relay-service@example.com"
24
+ LABEL description="Claude Code API Relay Service"
25
+ LABEL version="1.0.0"
26
+
27
+ # 🔧 安装系统依赖
28
+ RUN apk add --no-cache \
29
+ curl \
30
+ dumb-init \
31
+ sed \
32
+ && rm -rf /var/cache/apk/*
33
+
34
+ # 📁 设置工作目录
35
+ WORKDIR /app
36
+
37
+ # 📦 复制 package 文件
38
+ COPY package*.json ./
39
+
40
+ # 🔽 安装依赖 (生产环境)
41
+ RUN npm ci --only=production && \
42
+ npm cache clean --force
43
+
44
+ # 📋 复制应用代码
45
+ COPY . .
46
+
47
+ # 📦 从构建阶段复制前端产物
48
+ COPY --from=frontend-builder /app/web/admin-spa/dist /app/web/admin-spa/dist
49
+
50
+ # 🔧 复制并设置启动脚本权限
51
+ COPY docker-entrypoint.sh /usr/local/bin/
52
+ RUN chmod +x /usr/local/bin/docker-entrypoint.sh
53
+
54
+ # 📁 创建必要目录
55
+ RUN mkdir -p logs data temp
56
+
57
+ # 🔧 预先创建配置文件
58
+ RUN if [ ! -f "/app/config/config.js" ] && [ -f "/app/config/config.example.js" ]; then \
59
+ cp /app/config/config.example.js /app/config/config.js; \
60
+ fi
61
+
62
+ # 🌐 暴露端口
63
+ EXPOSE 3000
64
+
65
+ # 🏥 健康检查
66
+ HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
67
+ CMD curl -f http://localhost:3000/health || exit 1
68
+
69
+ # 🚀 启动应用
70
+ ENTRYPOINT ["dumb-init", "--", "/usr/local/bin/docker-entrypoint.sh"]
71
+ CMD ["node", "src/app.js"]
LICENSE ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Wesley Liddick
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
Makefile ADDED
@@ -0,0 +1,259 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Claude Relay Service Makefile
2
+ # 功能完整的 AI API 中转服务,支持 Claude 和 Gemini 双平台
3
+
4
+ .PHONY: help install setup dev start test lint clean docker-up docker-down service-start service-stop service-status logs cli-admin cli-keys cli-accounts cli-status
5
+
6
+ # 默认目标:显示帮助信息
7
+ help:
8
+ @echo "Claude Relay Service - AI API 中转服务"
9
+ @echo ""
10
+ @echo "可用命令:"
11
+ @echo ""
12
+ @echo " 📦 安装和初始化:"
13
+ @echo " install - 安装项目依赖"
14
+ @echo " install-web - 安装Web界面依赖"
15
+ @echo " setup - 生成配置文件和管理员凭据"
16
+ @echo " clean - 清理依赖和构建文件"
17
+ @echo ""
18
+ @echo " 🎨 前端构建:"
19
+ @echo " build-web - 构建 Web 管理界面"
20
+ @echo " build-all - 构建完整项目(后端+前端)"
21
+ @echo ""
22
+ @echo " 🚀 开发和运行:"
23
+ @echo " dev - 开发模式运行(热重载)"
24
+ @echo " start - 生产模式运行"
25
+ @echo " test - 运行测试套件"
26
+ @echo " lint - 代码风格检查"
27
+ @echo ""
28
+ @echo " 🐳 Docker 部署:"
29
+ @echo " docker-up - 启动 Docker 服务"
30
+ @echo " docker-up-full - 启动 Docker 服务(包含监控)"
31
+ @echo " docker-down - 停止 Docker 服务"
32
+ @echo " docker-logs - 查看 Docker 日志"
33
+ @echo ""
34
+ @echo " 🔧 服务管理:"
35
+ @echo " service-start - 前台启动服务"
36
+ @echo " service-daemon - 后台启动服务(守护进程)"
37
+ @echo " service-stop - 停止服务"
38
+ @echo " service-restart - 重启服务"
39
+ @echo " service-restart-daemon - 重启服务(守护进程)"
40
+ @echo " service-status - 查看服务状态"
41
+ @echo " logs - 查看应用日志"
42
+ @echo " logs-follow - 实时查看日志"
43
+ @echo ""
44
+ @echo " ⚙️ CLI 管理工具:"
45
+ @echo " cli-admin - 管理员操作"
46
+ @echo " cli-keys - API Key 管理"
47
+ @echo " cli-accounts - Claude 账户管理"
48
+ @echo " cli-status - 系统状态查看"
49
+ @echo ""
50
+ @echo " 💡 快速开始:"
51
+ @echo " make setup && make dev"
52
+ @echo ""
53
+
54
+ # 安装和初始化
55
+ install:
56
+ @echo "📦 安装项目依赖..."
57
+ npm install
58
+
59
+ install-web:
60
+ @echo "📦 安装 Web 界面依赖..."
61
+ npm run install:web
62
+
63
+ # 前端构建
64
+ build-web:
65
+ @echo "🎨 构建 Web 管理界面..."
66
+ npm run build:web
67
+
68
+ build-all: install install-web build-web
69
+ @echo "🎉 完整项目构建完成!"
70
+
71
+ setup:
72
+ @echo "⚙️ 初始化项目配置和管理员凭据..."
73
+ @if [ ! -f config/config.js ]; then cp config/config.example.js config/config.js; fi
74
+ @if [ ! -f .env ]; then cp .env.example .env; fi
75
+ npm run setup
76
+
77
+ clean:
78
+ @echo "🧹 清理依赖和构建文件..."
79
+ rm -rf node_modules
80
+ rm -rf web/node_modules
81
+ rm -rf web/admin-spa/dist
82
+ rm -rf web/admin-spa/node_modules
83
+ rm -rf logs/*.log
84
+
85
+ # 开发和运行
86
+ dev:
87
+ @echo "🚀 启动开发模式(热重载)..."
88
+ npm run dev
89
+
90
+ start:
91
+ @echo "🚀 启动生产模式..."
92
+ npm start
93
+
94
+ test:
95
+ @echo "🧪 运行测试套件..."
96
+ npm test
97
+
98
+ lint:
99
+ @echo "🔍 执行代码风格检查..."
100
+ npm run lint
101
+
102
+ # Docker 部署
103
+ docker-up:
104
+ @echo "🐳 启动 Docker 服务..."
105
+ docker-compose up -d
106
+
107
+ docker-up-full:
108
+ @echo "🐳 启动 Docker 服务(包含监控)..."
109
+ docker-compose --profile monitoring up -d
110
+
111
+ docker-down:
112
+ @echo "🛑 停止 Docker 服务..."
113
+ docker-compose down
114
+
115
+ docker-logs:
116
+ @echo "📋 查看 Docker 服务日志..."
117
+ docker-compose logs -f
118
+
119
+ # 服务管理
120
+ service-start:
121
+ @echo "🚀 前台启动服务..."
122
+ npm run service:start
123
+
124
+ service-daemon:
125
+ @echo "🔧 后台启动服务(守护进程)..."
126
+ npm run service:start:daemon
127
+
128
+ service-stop:
129
+ @echo "🛑 停止服务..."
130
+ npm run service:stop
131
+
132
+ service-restart:
133
+ @echo "🔄 重启服务..."
134
+ npm run service:restart
135
+
136
+ service-restart-daemon:
137
+ @echo "🔄 重启服务(守护进程)..."
138
+ npm run service:restart:daemon
139
+
140
+ service-status:
141
+ @echo "📊 查看服务状态..."
142
+ npm run service:status
143
+
144
+ logs:
145
+ @echo "📋 查看应用日志..."
146
+ npm run service:logs
147
+
148
+ logs-follow:
149
+ @echo "📋 实时查看日志..."
150
+ npm run service:logs:follow
151
+
152
+ # CLI 管理工具
153
+ cli-admin:
154
+ @echo "👤 启动管理员操作 CLI..."
155
+ npm run cli admin
156
+
157
+ cli-keys:
158
+ @echo "🔑 启动 API Key 管理 CLI..."
159
+ npm run cli keys
160
+
161
+ cli-accounts:
162
+ @echo "👥 启动 Claude 账户管理 CLI..."
163
+ npm run cli accounts
164
+
165
+ cli-status:
166
+ @echo "📊 查看系统状态..."
167
+ npm run cli status
168
+
169
+ # 开发辅助命令
170
+ check-config:
171
+ @echo "🔍 检查配置文件..."
172
+ @if [ ! -f config/config.js ]; then echo "❌ config/config.js 不存在,请运行 'make setup'"; exit 1; fi
173
+ @if [ ! -f .env ]; then echo "❌ .env 不存在,请运行 'make setup'"; exit 1; fi
174
+ @echo "✅ 配置文件检查通过"
175
+
176
+ health-check:
177
+ @echo "🏥 执行健康检查..."
178
+ @curl -s http://localhost:3000/health || echo "❌ 服务未运行或不可访问"
179
+
180
+ # 快���启动组合命令
181
+ quick-start: setup dev
182
+
183
+ quick-daemon: setup service-daemon
184
+ @echo "🎉 服务已在后台启动!"
185
+ @echo "运行 'make service-status' 查看状态"
186
+ @echo "运行 'make logs-follow' 查看实时日志"
187
+
188
+ # 全栈开发环境
189
+ dev-full: install install-web build-web setup dev
190
+ @echo "🚀 全栈开发环境启动!"
191
+
192
+ # 完整部署流程
193
+ deploy: clean install install-web build-web setup test lint docker-up
194
+ @echo "🎉 部署完成!"
195
+ @echo "访问 Web 管理界面: http://localhost:3000/web"
196
+ @echo "API 端点: http://localhost:3000/api/v1/messages"
197
+
198
+ # 生产部署准备
199
+ production-build: clean install install-web build-web
200
+ @echo "🚀 生产环境构建完成!"
201
+
202
+ # 维护命令
203
+ backup-redis:
204
+ @echo "💾 备份 Redis 数据..."
205
+ @docker exec claude-relay-service-redis-1 redis-cli BGSAVE || echo "❌ Redis 备份失败"
206
+
207
+ restore-redis:
208
+ @echo "♻️ 恢复 Redis 数据..."
209
+ @echo "请手动恢复 Redis 数据文件"
210
+
211
+ # 监控和日志
212
+ monitor:
213
+ @echo "📊 启动监控面板..."
214
+ @echo "Grafana: http://localhost:3001"
215
+ @echo "Redis Commander: http://localhost:8081"
216
+
217
+ tail-logs:
218
+ @echo "📋 实时查看日志..."
219
+ tail -f logs/claude-relay-*.log
220
+
221
+ # 开发工具
222
+ format:
223
+ @echo "🎨 格式化代码..."
224
+ npm run lint -- --fix
225
+
226
+ check-deps:
227
+ @echo "🔍 检查依赖更新..."
228
+ npm outdated
229
+
230
+ update-deps:
231
+ @echo "⬆️ 更新依赖..."
232
+ npm update
233
+
234
+ # 测试相关
235
+ test-coverage:
236
+ @echo "📊 运行测试覆盖率..."
237
+ npm test -- --coverage
238
+
239
+ test-watch:
240
+ @echo "👀 监视模式运行测试..."
241
+ npm test -- --watch
242
+
243
+ # Git 相关
244
+ git-status:
245
+ @echo "📋 Git 状态..."
246
+ git status --short
247
+
248
+ git-pull:
249
+ @echo "⬇️ 拉取最新代码..."
250
+ git pull origin main
251
+
252
+ # 安全检查
253
+ security-audit:
254
+ @echo "🔒 执行安全审计..."
255
+ npm audit
256
+
257
+ security-fix:
258
+ @echo "🔧 修复安全漏洞..."
259
+ npm audit fix
README.md CHANGED
@@ -1,10 +1,941 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
- title: Cc
3
- emoji:
4
- colorFrom: gray
5
- colorTo: blue
6
- sdk: docker
7
- pinned: false
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Claude Relay Service
2
+
3
+ <div align="center">
4
+
5
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
6
+ [![Node.js](https://img.shields.io/badge/Node.js-18+-green.svg)](https://nodejs.org/)
7
+ [![Redis](https://img.shields.io/badge/Redis-6+-red.svg)](https://redis.io/)
8
+ [![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/)
9
+ [![Docker Build](https://github.com/Wei-Shaw/claude-relay-service/actions/workflows/auto-release-pipeline.yml/badge.svg)](https://github.com/Wei-Shaw/claude-relay-service/actions/workflows/auto-release-pipeline.yml)
10
+ [![Docker Pulls](https://img.shields.io/docker/pulls/weishaw/claude-relay-service)](https://hub.docker.com/r/weishaw/claude-relay-service)
11
+
12
+ **🔐 自行搭建Claude API中转服务,支持多账户管理**
13
+
14
+ [English](README_EN.md) • [快速开始](https://pincc.ai/) • [演示站点](https://demo.pincc.ai/admin-next/login) • [公告频道](https://t.me/claude_relay_service)
15
+
16
+ </div>
17
+
18
+ ---
19
+
20
+ ## 💎 Claude/Codex 拼车服务推荐
21
+
22
+ <div align="center">
23
+
24
+ | 平台 | 类型 | 服务 | 介绍 |
25
+ |:---|:---|:---|:---|
26
+ | **[pincc.ai](https://pincc.ai/)** | 🏆 **官方运营** | <small>✅ Claude Code<br>✅ Codex CLI</small> | 项目直营,提供稳定的 Claude Code / Codex CLI 拼车服务 |
27
+ | **[ctok.ai](https://ctok.ai/)** | 🤝 合作伙伴 | <small>✅ Claude Code<br>✅ Codex CLI</small> | 社区认证,提供 Claude Code / Codex CLI 拼车 |
28
+
29
+
30
+ </div>
31
+
32
+ ---
33
+
34
+ ## ⚠️ 重要提醒
35
+
36
+ **使用本项目前请仔细阅读:**
37
+
38
+ 🚨 **服务条款风险**: 使用本项目可能违反Anthropic的服务条款。请在使用前仔细阅读Anthropic的用户协议,使用本项目的一切风险由用户自行承担。
39
+
40
+ 📖 **免责声明**: 本项目仅供技术学习和研究使用,作者不对因使用本项目导致的账户封禁、服务中断或其他损失承担任何责任。
41
+
42
+
43
+ ## 🤔 这个项目适合你吗?
44
+
45
+ - 🌍 **地区限制**: 所在地区无法直接访问Claude Code服务?
46
+ - 🔒 **隐私担忧**: 担心第三方镜像服务会记录或泄露你的对话内容?
47
+ - 👥 **成本分摊**: 想和朋友一起分摊Claude Code Max订阅费用?
48
+ - ⚡ **稳定性**: 第三方镜像站经常故障不稳定,影响效率 ?
49
+
50
+ 如果有以上困惑,那这个项目可能适合你。
51
+
52
+ ### 适合的场景
53
+
54
+ ✅ **找朋友拼车**: 三五好友一起分摊Claude Code Max订阅
55
+ ✅ **隐私敏感**: 不想让第三方镜像看到你的对话内容
56
+ ✅ **技术折腾**: 有基本的技术基础,愿意自己搭建和维护
57
+ ✅ **稳定需求**: 需要长期稳定的Claude访问,不想受制于镜像站
58
+ ✅ **地区受限**: 无法直接访问Claude官方服务
59
+
60
  ---
61
+
62
+ ## 💭 为什么要自己搭?
63
+
64
+ ### 现有镜像站可能的问题
65
+
66
+ - 🕵️ **隐私风险**: 你的对话内容都被人家看得一清二楚,商业机密什么的就别想了
67
+ - 🐌 **性能不稳**: 用的人多了就慢,高峰期经常卡死
68
+ - 💰 **价格不透明**: 不知道实际成本
69
+
70
+ ### 自建的好处
71
+
72
+ - 🔐 **数据安全**: 所有接口请求都只经过你自己的服务器,直连Anthropic API
73
+ - ⚡ **性能可控**: 就你们几个人用,Max 200刀套餐基本上可以爽用Opus
74
+ - 💰 **成本透明**: 用了多少token一目了然,按官方价格换算了具体费用
75
+ - 📊 **监控完整**: 使用情况、成本分析、性能监控全都有
76
+
77
+ ---
78
+
79
+ ## 🚀 核心功能
80
+
81
+ ### 基础功能
82
+
83
+ - ✅ **多账户管理**: 可以添加多个Claude账户自动轮换
84
+ - ✅ **自定义API Key**: 给每个人分配独立的Key
85
+ - ✅ **使用统计**: 详细记录每个人用了多少token
86
+
87
+ ### 高级功能
88
+
89
+ - 🔄 **智能切换**: 账户出问题自动换下一个
90
+ - 🚀 **性能优化**: 连接池、缓存,减少延迟
91
+ - 📊 **监控面板**: Web界面查看所有数据
92
+ - 🛡️ **安全控制**: 访问限制、速率控制、客户端限制
93
+ - 🌐 **代理支持**: 支持HTTP/SOCKS5代理
94
+
95
  ---
96
 
97
+ ## 📋 部署要求
98
+
99
+ ### 硬件要求(最低配置)
100
+
101
+ - **CPU**: 1核心就够了
102
+ - **内存**: 512MB(建议1GB)
103
+ - **硬盘**: 30GB可用空间
104
+ - **网络**: 能访问到Anthropic API(建议使用US地区的机器)
105
+ - **建议**: 2核4G的基本够了,网络尽量选回国线路快一点的(为了提高速度,建议不要开代理或者设置服务器的IP直连)
106
+ - **经验**: 阿里云、腾讯云的海外主机经测试会被Cloudflare拦截,无法直接访问claude api
107
+
108
+ ### 软件要求
109
+
110
+ - **Node.js** 18或更高版本
111
+ - **Redis** 6或更高版本
112
+ - **操作系统**: 建议Linux
113
+
114
+ ### 费用估算
115
+
116
+ - **服务器**: 轻量云服务器,一个月30-60块
117
+ - **Claude订阅**: 看你怎么分摊了
118
+ - **其他**: 域名(可选)
119
+
120
+ ---
121
+
122
+ ## 🚀 脚本部署(推荐)
123
+
124
+ 推荐使用管理脚本进行一键部署,简单快捷,自动处理所有依赖和配置。
125
+
126
+ ### 快速安装
127
+
128
+ ```bash
129
+ curl -fsSL https://pincc.ai/manage.sh -o manage.sh && chmod +x manage.sh && ./manage.sh install
130
+ ```
131
+
132
+ ### 脚本功能
133
+
134
+ - ✅ **一键安装**: 自动检测系统环境,安装 Node.js 18+、Redis 等依赖
135
+ - ✅ **交互式配置**: 友好的配置向导,设置端口、Redis 连接等
136
+ - ✅ **自动启动**: 安装完成后自动启动服务并显示访问地址
137
+ - ✅ **便捷管理**: 通过 `crs` 命令随时管理服务状态
138
+
139
+ ### 管理命令
140
+
141
+ ```bash
142
+ crs install # 安装服务
143
+ crs start # 启动服务
144
+ crs stop # 停止服务
145
+ crs restart # 重启服务
146
+ crs status # 查看状态
147
+ crs update # 更新服务
148
+ crs uninstall # 卸载服务
149
+ ```
150
+
151
+ ### 安装示例
152
+
153
+ ```bash
154
+ $ crs install
155
+
156
+ # 会依次询问:
157
+ 安装目录 (默认: ~/claude-relay-service):
158
+ 服务端口 (默认: 3000): 8080
159
+ Redis 地址 (默认: localhost):
160
+ Redis 端口 (默认: 6379):
161
+ Redis 密码 (默认: 无密码):
162
+
163
+ # 安装完成后自动启动并显示:
164
+ 服务已成功安装并启动!
165
+
166
+ 访问地址:
167
+ 本地 Web: http://localhost:8080/web
168
+ 公网 Web: http://YOUR_IP:8080/web
169
+
170
+ 管理员账号信息已保存到: data/init.json
171
+ ```
172
+
173
+ ### 系统要求
174
+
175
+ - 支持系统: Ubuntu/Debian、CentOS/RedHat、Arch Linux、macOS
176
+ - 自动安装 Node.js 18+ 和 Redis
177
+ - Redis 使用系统默认位置,数据独立于应用
178
+
179
+ ---
180
+
181
+ ## 📦 手动部署
182
+
183
+ ### 第一步:环境准备
184
+
185
+ **Ubuntu/Debian用户:**
186
+
187
+ ```bash
188
+ # 安装Node.js
189
+ curl -fsSL https://deb.nodesource.com/setup_18.x | sudo -E bash -
190
+ sudo apt-get install -y nodejs
191
+
192
+ # 安装Redis
193
+ sudo apt update
194
+ sudo apt install redis-server
195
+ sudo systemctl start redis-server
196
+ ```
197
+
198
+ **CentOS/RHEL用户:**
199
+
200
+ ```bash
201
+ # 安装Node.js
202
+ curl -fsSL https://rpm.nodesource.com/setup_18.x | sudo bash -
203
+ sudo yum install -y nodejs
204
+
205
+ # 安装Redis
206
+ sudo yum install redis
207
+ sudo systemctl start redis
208
+ ```
209
+
210
+ ### 第二步:下载和配置
211
+
212
+ ```bash
213
+ # 下载项目
214
+ git clone https://github.com/Wei-Shaw//claude-relay-service.git
215
+ cd claude-relay-service
216
+
217
+ # 安装依赖
218
+ npm install
219
+
220
+ # 复制配置文件(重要!)
221
+ cp config/config.example.js config/config.js
222
+ cp .env.example .env
223
+ ```
224
+
225
+ ### 第三步:配置文件设置
226
+
227
+ **编辑 `.env` 文件:**
228
+
229
+ ```bash
230
+ # 这两个密钥随便生成,但要记住
231
+ JWT_SECRET=你的超级秘密密钥
232
+ ENCRYPTION_KEY=32位的加密密钥随便写
233
+
234
+ # Redis配置
235
+ REDIS_HOST=localhost
236
+ REDIS_PORT=6379
237
+ REDIS_PASSWORD=
238
+
239
+ ```
240
+
241
+ **编辑 `config/config.js` 文件:**
242
+
243
+ ```javascript
244
+ module.exports = {
245
+ server: {
246
+ port: 3000, // 服务端口,可以改
247
+ host: '0.0.0.0' // 不用改
248
+ },
249
+ redis: {
250
+ host: '127.0.0.1', // Redis地址
251
+ port: 6379 // Redis端口
252
+ }
253
+ // 其他配置保持默认就行
254
+ }
255
+ ```
256
+
257
+ ### 第四步:安装前端依赖并构建
258
+
259
+ ```bash
260
+ # 安装前端依赖
261
+ npm run install:web
262
+
263
+ # 构建前端(生成 dist 目录)
264
+ npm run build:web
265
+ ```
266
+
267
+ ### 第五步:启动服务
268
+
269
+ ```bash
270
+ # 初始化
271
+ npm run setup # 会随机生成后台账号密码信息,存储在 data/init.json
272
+ # 或者通过环境变量预设管理员凭据:
273
+ # export ADMIN_USERNAME=cr_admin_custom
274
+ # export ADMIN_PASSWORD=your-secure-password
275
+
276
+ # 启动服务
277
+ npm run service:start:daemon # 后台运行
278
+
279
+ # 查看状态
280
+ npm run service:status
281
+ ```
282
+
283
+ ---
284
+
285
+ ## 🐳 Docker 部署
286
+
287
+ ### Docker compose
288
+
289
+ #### 第一步:下载构建docker-compose.yml文件的脚本并执行
290
+ ```bash
291
+ curl -fsSL https://pincc.ai/crs-compose.sh -o crs-compose.sh && chmod +x crs-compose.sh && ./crs-compose.sh
292
+ ```
293
+
294
+ #### 第二步:启动
295
+ ```bash
296
+ docker-compose up -d
297
+ ```
298
+
299
+ ### Docker Compose 配置
300
+
301
+ docker-compose.yml 已包含:
302
+
303
+ - ✅ 自动初始化管理员账号
304
+ - ✅ 数据持久化(logs和data目录自动挂载)
305
+ - ✅ Redis数据库
306
+ - ✅ 健康检查
307
+ - ✅ 自动重启
308
+
309
+ ### 环境变量说明
310
+
311
+ #### 必填项
312
+
313
+ - `JWT_SECRET`: JWT密钥,至少32个字符
314
+ - `ENCRYPTION_KEY`: 加密密钥,必须是32个字符
315
+
316
+ #### 可选项
317
+
318
+ - `ADMIN_USERNAME`: 管理员用户名(不设置则自动生成)
319
+ - `ADMIN_PASSWORD`: 管理员密码(不设置则自动生成)
320
+ - `LOG_LEVEL`: 日志级别(默认:info)
321
+ - 更多配置项请参考 `.env.example` 文件
322
+
323
+ ### 管理员凭据获取方式
324
+
325
+ 1. **查看容器日志**
326
+
327
+ ```bash
328
+ docker logs claude-relay-service
329
+ ```
330
+
331
+ 2. **查看挂载的文件**
332
+
333
+ ```bash
334
+ cat ./data/init.json
335
+ ```
336
+
337
+ 3. **使用环境变量预设**
338
+ ```bash
339
+ # 在 .env 文件中设置
340
+ ADMIN_USERNAME=cr_admin_custom
341
+ ADMIN_PASSWORD=your-secure-password
342
+ ```
343
+
344
+ ---
345
+
346
+ ## 🎮 开始使用
347
+
348
+ ### 1. 打开管理界面
349
+
350
+ 浏览器访问:`http://你的服务器IP:3000/web`
351
+
352
+ 管理员账号:
353
+
354
+ - 自动生成:查看 data/init.json
355
+ - 环境变量预设:通过 ADMIN_USERNAME 和 ADMIN_PASSWORD 设置
356
+ - Docker 部署:查看容器日志 `docker logs claude-relay-service`
357
+
358
+ ### 2. 添加Claude账户
359
+
360
+ 这一步比较关键,需要OAuth授权:
361
+
362
+ 1. 点击「Claude账户」标签
363
+ 2. 如果你担心多个账号共用1个IP怕被封禁,可以选择设置静态代理IP(可选)
364
+ 3. 点击「添加账户」
365
+ 4. 点击「生成授权链接」,会打开一个新页面
366
+ 5. 在新页面完成Claude登录和授权
367
+ 6. 复制返回的Authorization Code
368
+ 7. 粘贴到页面完成添加
369
+
370
+ **注意**: 如果你在国内,这一步可能需要科学上网。
371
+
372
+ ### 3. 创建API Key
373
+
374
+ 给每个使用者分配一个Key:
375
+
376
+ 1. 点击「API Keys」标签
377
+ 2. 点击「创建新Key」
378
+ 3. 给Key起个名字,比如「张三的Key」
379
+ 4. 设置使用限制(可选):
380
+ - **速率限制**: 限制每个时间窗口的请求次数和Token使用量
381
+ - **并发限制**: 限制同时处理的请求数
382
+ - **模型限制**: 限制可访问的模型列表
383
+ - **客户端限制**: 限制只允许特定客户端使用(如ClaudeCode、Gemini-CLI等)
384
+ 5. 保存,记下生成的Key
385
+
386
+ ### 4. 开始使用 Claude Code 和 Gemini CLI
387
+
388
+ 现在你可以用自己的服务替换官方API了:
389
+
390
+ **Claude Code 设置环境变量:**
391
+
392
+ 默认使用标准 Claude 账号池:
393
+
394
+ ```bash
395
+ export ANTHROPIC_BASE_URL="http://127.0.0.1:3000/api/" # 根据实际填写你服务器的ip地址或者域名
396
+ export ANTHROPIC_AUTH_TOKEN="后台创建的API密钥"
397
+ ```
398
+
399
+ **VSCode Claude 插件配置:**
400
+
401
+ 如果使用 VSCode 的 Claude 插件,需要在 `~/.claude/config.json` 文件中配置:
402
+
403
+ ```json
404
+ {
405
+ "primaryApiKey": "crs"
406
+ }
407
+ ```
408
+
409
+ 如果该文件不存在,请手动创建。Windows 用户路径为 `C:\Users\你的用户名\.claude\config.json`。
410
+
411
+ **Gemini CLI 设置环境变量:**
412
+
413
+ ```bash
414
+ GEMINI_MODEL="gemini-2.5-pro"
415
+ GOOGLE_GEMINI_BASE_URL="http://127.0.0.1:3000/gemini" # 根据实际填写你服务器的ip地址或者域名
416
+ GEMINI_API_KEY="后台创建的API密钥" # 使用相同的API密钥即可
417
+ ```
418
+ **使用 Claude Code:**
419
+
420
+ ```bash
421
+ claude
422
+ ```
423
+
424
+ **使用 Gemini CLI:**
425
+
426
+ ```bash
427
+ gemini # 或其他 Gemini CLI 命令
428
+ ```
429
+
430
+ **Codex 配置:**
431
+
432
+ 在 `~/.codex/config.toml` 文件**开头**添加以下配置:
433
+
434
+ ```toml
435
+ model_provider = "crs"
436
+ model = "gpt-5-codex"
437
+ model_reasoning_effort = "high"
438
+ disable_response_storage = true
439
+ preferred_auth_method = "apikey"
440
+
441
+ [model_providers.crs]
442
+ name = "crs"
443
+ base_url = "http://127.0.0.1:3000/openai" # 根据实际填写你服务器的ip地址或者域名
444
+ wire_api = "responses"
445
+ requires_openai_auth = true
446
+ env_key = "CRS_OAI_KEY"
447
+ ```
448
+
449
+ 在 `~/.codex/auth.json` 文件中配置API密钥为 null:
450
+
451
+ ```json
452
+ {
453
+ "OPENAI_API_KEY": null
454
+ }
455
+ ```
456
+
457
+ 环境变量设置:
458
+
459
+ ```bash
460
+ export CRS_OAI_KEY="后台创建的API密钥"
461
+ ```
462
+
463
+ > ⚠️ 在通过 Nginx 反向代理 CRS 服务并使用 Codex CLI 时,需要在 http 块中添加 underscores_in_headers on;。因为 Nginx 默认会移除带下划线的请求头(如 session_id),一旦该头被丢弃,多账号环境下的粘性会话功能将失效。
464
+
465
+ **Droid CLI 配置:**
466
+
467
+ Droid CLI 读取 `~/.factory/config.json`。可以在该文件中添加自定义模型以指向本服务的新端点:
468
+
469
+ ```json
470
+ {
471
+ "custom_models": [
472
+ {
473
+ "model_display_name": "Sonnet 4.5 [crs]",
474
+ "model": "claude-sonnet-4-5-20250929",
475
+ "base_url": "http://127.0.0.1:3000/droid/claude",
476
+ "api_key": "后台创建的API密钥",
477
+ "provider": "anthropic",
478
+ "max_tokens": 8192
479
+ },
480
+ {
481
+ "model_display_name": "GPT5-Codex [crs]",
482
+ "model": "gpt-5-codex",
483
+ "base_url": "http://127.0.0.1:3000/droid/openai",
484
+ "api_key": "后台创建的API密钥",
485
+ "provider": "openai",
486
+ "max_tokens": 16384
487
+ }
488
+ ]
489
+ }
490
+ ```
491
+
492
+ > 💡 将示例中的 `http://127.0.0.1:3000` 替换为你的服务域名或公网地址,并写入后台生成的 API 密钥(cr_ 开头)。
493
+
494
+ ### 5. 第三方工具API接入
495
+
496
+ 本服务支持多种API端点格式,方便接入不同的第三方工具(如Cherry Studio等)。
497
+
498
+ #### Cherry Studio 接入示例
499
+
500
+ Cherry Studio支持多种AI服务的接入,下面是不同账号类型的详细配置:
501
+
502
+ **1. Claude账号接入:**
503
+
504
+ ```
505
+ # API地址
506
+ http://你的服务器:3000/claude
507
+
508
+ # 模型ID示例
509
+ claude-sonnet-4-5-20250929 # Claude Sonnet 4.5
510
+ claude-opus-4-20250514 # Claude Opus 4
511
+ ```
512
+
513
+ 配置步骤:
514
+ - 供应商类型选择"Anthropic"
515
+ - API地址填入:`http://你的服务器:3000/claude`
516
+ - API Key填入:后台创建的API密钥(cr_开头)
517
+
518
+ **2. Gemini账号接入:**
519
+
520
+ ```
521
+ # API地址
522
+ http://你的服务器:3000/gemini
523
+
524
+ # 模型ID示例
525
+ gemini-2.5-pro # Gemini 2.5 Pro
526
+ ```
527
+
528
+ 配置步骤:
529
+ - 供应商类型选择"Gemini"
530
+ - API地址填入:`http://你的服务器:3000/gemini`
531
+ - API Key填入:后台创建的API密钥(cr_开头)
532
+
533
+ **3. Codex接入:**
534
+
535
+ ```
536
+ # API地址
537
+ http://你的服务器:3000/openai
538
+
539
+ # 模型ID(固定)
540
+ gpt-5 # Codex使用固定模型ID
541
+ ```
542
+
543
+ 配置步骤���
544
+ - 供应商类型选择"Openai-Response"
545
+ - API地址填入:`http://你的服务器:3000/openai`
546
+ - API Key填入:后台创建的API密钥(cr_开头)
547
+ - **重要**:Codex只支持Openai-Response标准
548
+
549
+
550
+ **Cherry Studio 地址格式重要说明:**
551
+
552
+ - ✅ **推荐格式**:`http://你的服务器:3000/claude`(不加结尾 `/`,让 Cherry Studio 自动加上 v1)
553
+ - ✅ **等效格式**:`http://你的服务器:3000/claude/v1/`(手动指定 v1 并加结尾 `/`)
554
+ - 💡 **说明**:这两种格式在 Cherry Studio 中是完全等效的
555
+ - ❌ **错误格式**:`http://你的服务器:3000/claude/`(单独的 `/` 结尾会被 Cherry Studio 忽略 v1 版本)
556
+
557
+ #### 其他第三方工具接入
558
+
559
+ **接入要点:**
560
+
561
+ - 所有账号类型都使用相同的API密钥(在后台统一创建)
562
+ - 根据不同的路由前缀自动识别账号类型
563
+ - `/claude/` - 使用Claude账号池
564
+ - `/droid/claude/` - 使用Droid类型Claude账号池(只建议api调用或Droid Cli中使用)
565
+ - `/gemini/` - 使用Gemini账号池
566
+ - `/openai/` - 使用Codex账号(只支持Openai-Response格式)
567
+ - `/droid/openai/` - 使用Droid类型OpenAI兼容账号池(只建议api调用或Droid Cli中使用)
568
+ - 支持所有标准API端点(messages、models等)
569
+
570
+ **重要说明:**
571
+
572
+ - 确保在后台已添加对应类型的账号(Claude/Gemini/Codex)
573
+ - API密钥可以通用,系统会根据路由自动选择账号类型
574
+ - 建议为不同用户创建不同的API密钥便于使用统计
575
+
576
+ ---
577
+
578
+ ## 🔧 日常维护
579
+
580
+ ### 服务管理
581
+
582
+ ```bash
583
+ # 查看服务状态
584
+ npm run service:status
585
+
586
+ # 查看日志
587
+ npm run service:logs
588
+
589
+ # 重启服务
590
+ npm run service:restart:daemon
591
+
592
+ # 停止服务
593
+ npm run service:stop
594
+ ```
595
+
596
+ ### 监控使用情况
597
+
598
+ - **Web界面**: `http://你的域名:3000/web` - 查看使用统计
599
+ - **健康检查**: `http://你的域名:3000/health` - 确认服务正常
600
+ - **日志文件**: `logs/` 目录下的各种日志文件
601
+
602
+ ### 升级指南
603
+
604
+ 当有新版本发布时,按照以下步骤升级服务:
605
+
606
+ ```bash
607
+ # 1. 进入项目目录
608
+ cd claude-relay-service
609
+
610
+ # 2. 拉取最新代码
611
+ git pull origin main
612
+
613
+ # 如果遇到 package-lock.json 冲突,使用远程版本
614
+ git checkout --theirs package-lock.json
615
+ git add package-lock.json
616
+
617
+ # 3. 安装新的依赖(如果有)
618
+ npm install
619
+
620
+ # 4. 安装并构建前端
621
+ npm run install:web
622
+ npm run build:web
623
+
624
+ # 5. 重启服务
625
+ npm run service:restart:daemon
626
+
627
+ # 6. 检查服务状态
628
+ npm run service:status
629
+ ```
630
+
631
+ **注意事项:**
632
+
633
+ - 升级前建议备份重要配置文件(.env, config/config.js)
634
+ - 查看更新日志了解是否有破坏性变更
635
+ - 如果有数据库结构变更,会自动迁移
636
+
637
+ ---
638
+
639
+ ## 🔒 客户端限制功能
640
+
641
+ ### 功能说明
642
+
643
+ 客户端限制功能允许你控制每个API Key可以被哪些客户端使用,通过User-Agent识别客户端,提高API的安全性。
644
+
645
+ ### 使用方法
646
+
647
+ 1. **在创建或编辑API Key时启用客户端限制**:
648
+ - 勾选"启用客户端限制"
649
+ - 选择允许的客户端(支持多选)
650
+
651
+ 2. **预定义客户端**:
652
+ - **ClaudeCode**: 官方Claude CLI(匹配 `claude-cli/x.x.x (external, cli)` 格式)
653
+ - **Gemini-CLI**: Gemini命令行工具(匹配 `GeminiCLI/vx.x.x (platform; arch)` 格式)
654
+
655
+ 3. **调试和诊断**:
656
+ - 系统会在日志中记录所有请求的User-Agent
657
+ - 客户端验证失败时会返回403错误并记录详细信息
658
+ - 通过日志可以查看实际的User-Agent格式,方便配置自定义客户端
659
+
660
+
661
+ ### 日志示例
662
+
663
+ 认证成功时的日志:
664
+
665
+ ```
666
+ 🔓 Authenticated request from key: 测试Key (key-id) in 5ms
667
+ User-Agent: "claude-cli/1.0.58 (external, cli)"
668
+ ```
669
+
670
+ 客户端限制检查日志:
671
+
672
+ ```
673
+ 🔍 Checking client restriction for key: key-id (测试Key)
674
+ User-Agent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64)"
675
+ Allowed clients: claude_code, gemini_cli
676
+ 🚫 Client restriction failed for key: key-id (测试Key) from 127.0.0.1, User-Agent: Mozilla/5.0...
677
+ ```
678
+
679
+ ### 常见问题处理
680
+
681
+ **Redis连不上?**
682
+
683
+ ```bash
684
+ # 检查Redis是否启动
685
+ redis-cli ping
686
+
687
+ # 应该返回 PONG
688
+ ```
689
+
690
+ **OAuth授权失败?**
691
+
692
+ - 检查代理设置是否正确
693
+ - 确保能正常访问 claude.ai
694
+ - 清除浏览器缓存重试
695
+
696
+ **API请求失败?**
697
+
698
+ - 检查API Key是否正确
699
+ - 查看日志文件找错误信息
700
+ - 确认Claude账户状态正常
701
+
702
+ ---
703
+
704
+ ## 🛠️ 进阶
705
+
706
+ ### 反向代理部署指南
707
+
708
+ 在生产环境中,建议通过反向代理进行连接,以便使用自动 HTTPS、安全头部和性能优化。下面提供两种常用方案: **Caddy** 和 **Nginx Proxy Manager (NPM)**。
709
+
710
+ ---
711
+
712
+ ## Caddy 方案
713
+
714
+ Caddy 是一款自动管理 HTTPS 证书的 Web 服务器,配置简单、性能优秀,很适合不需要 Docker 环境的部署方案。
715
+
716
+ **1. 安装 Caddy**
717
+
718
+ ```bash
719
+ # Ubuntu/Debian
720
+ sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https
721
+ curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
722
+ curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | sudo tee /etc/apt/sources.list.d/caddy-stable.list
723
+ sudo apt update
724
+ sudo apt install caddy
725
+
726
+ # CentOS/RHEL/Fedora
727
+ sudo yum install yum-plugin-copr
728
+ sudo yum copr enable @caddy/caddy
729
+ sudo yum install caddy
730
+ ```
731
+
732
+ **2. Caddy 配置**
733
+
734
+ 编辑 `/etc/caddy/Caddyfile` :
735
+
736
+ ```caddy
737
+ your-domain.com {
738
+ # 反向代理到本地服务
739
+ reverse_proxy 127.0.0.1:3000 {
740
+ # 支持流式响应或 SSE
741
+ flush_interval -1
742
+
743
+ # 传递真实 IP
744
+ header_up X-Real-IP {remote_host}
745
+ header_up X-Forwarded-For {remote_host}
746
+ header_up X-Forwarded-Proto {scheme}
747
+
748
+ # 长读/写超时配置
749
+ transport http {
750
+ read_timeout 300s
751
+ write_timeout 300s
752
+ dial_timeout 30s
753
+ }
754
+ }
755
+
756
+ # 安全头部
757
+ header {
758
+ Strict-Transport-Security "max-age=31536000; includeSubDomains"
759
+ X-Frame-Options "DENY"
760
+ X-Content-Type-Options "nosniff"
761
+ -Server
762
+ }
763
+ }
764
+ ```
765
+
766
+ **3. 启动 Caddy**
767
+
768
+ ```bash
769
+ sudo caddy validate --config /etc/caddy/Caddyfile
770
+ sudo systemctl start caddy
771
+ sudo systemctl enable caddy
772
+ sudo systemctl status caddy
773
+ ```
774
+
775
+ **4. 服务配置**
776
+
777
+ Caddy 会自动管理 HTTPS,因此可以将服务限制在本地进行监听:
778
+
779
+ ```javascript
780
+ // config/config.js
781
+ module.exports = {
782
+ server: {
783
+ port: 3000,
784
+ host: '127.0.0.1' // 只监听本地
785
+ }
786
+ }
787
+ ```
788
+
789
+ **Caddy 特点**
790
+
791
+ * 🔒 自动 HTTPS,零配置证书管理
792
+ * 🛡️ 安全默认配置,启用现代 TLS 套件
793
+ * ⚡ HTTP/2 和流式传输支持
794
+ * 🔧 配置文件简洁,易于维护
795
+
796
+ ---
797
+
798
+ ## Nginx Proxy Manager (NPM) 方案
799
+
800
+ Nginx Proxy Manager 通过图形化界面管理反向代理和 HTTPS 证书,並以 Docker 容器部署。
801
+
802
+ **1. 在 NPM 创建新的 Proxy Host**
803
+
804
+ Details 配置如下:
805
+
806
+ | 项目 | 设置 |
807
+ | --------------------- | ----------------------- |
808
+ | Domain Names | relay.example.com |
809
+ | Scheme | http |
810
+ | Forward Hostname / IP | 192.168.0.1 (docker 机器 IP) |
811
+ | Forward Port | 3000 |
812
+ | Block Common Exploits | ☑️ |
813
+ | Websockets Support | ❌ **关闭** |
814
+ | Cache Assets | ❌ **关闭** |
815
+ | Access List | Publicly Accessible |
816
+
817
+ > 注意:
818
+ > - 请确保 Claude Relay Service **监听 host 为 `0.0.0.0` 、容器 IP 或本机 IP**,以便 NPM 实现内网连接。
819
+ > - **Websockets Support 和 Cache Assets 必须关闭**,否则会导致 SSE / 流式响应失败。
820
+
821
+ **2. Custom locations**
822
+
823
+ 無需添加任何内容,保持为空。
824
+
825
+ **3. SSL 设置**
826
+
827
+ * **SSL Certificate**: Request a new SSL Certificate (Let's Encrypt) 或已有证书
828
+ * ☑️ **Force SSL**
829
+ * ☑️ **HTTP/2 Support**
830
+ * ☑️ **HSTS Enabled**
831
+ * ☑️ **HSTS Subdomains**
832
+
833
+ **4. Advanced 配置**
834
+
835
+ Custom Nginx Configuration 中添加以下内容:
836
+
837
+ ```nginx
838
+ # 传递真实用户 IP
839
+ proxy_set_header X-Real-IP $remote_addr;
840
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
841
+ proxy_set_header X-Forwarded-Proto $scheme;
842
+
843
+ # 支持 WebSocket / SSE 等流式通信
844
+ proxy_http_version 1.1;
845
+ proxy_set_header Upgrade $http_upgrade;
846
+ proxy_set_header Connection "upgrade";
847
+ proxy_buffering off;
848
+
849
+ # 长连接 / 超时设置(适合 AI 聊天流式传输)
850
+ proxy_read_timeout 300s;
851
+ proxy_send_timeout 300s;
852
+ proxy_connect_timeout 30s;
853
+
854
+ # ---- 安全性设置 ----
855
+ # 严格 HTTPS 策略 (HSTS)
856
+ add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
857
+
858
+ # 阻挡点击劫持与内容嗅探
859
+ add_header X-Frame-Options "DENY" always;
860
+ add_header X-Content-Type-Options "nosniff" always;
861
+
862
+ # Referrer / Permissions 限制策略
863
+ add_header Referrer-Policy "no-referrer-when-downgrade" always;
864
+ add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
865
+
866
+ # 隐藏服务器信息(等效于 Caddy 的 `-Server`)
867
+ proxy_hide_header Server;
868
+
869
+ # ---- 性能微调 ----
870
+ # 关闭代理端缓存,确保即时响应(SSE / Streaming)
871
+ proxy_cache_bypass $http_upgrade;
872
+ proxy_no_cache $http_upgrade;
873
+ proxy_request_buffering off;
874
+ ```
875
+
876
+ **4. 启动和验证**
877
+
878
+ * 保存后等待 NPM 自动申请 Let's Encrypt 证书(如果有)。
879
+ * Dashboard 中查看 Proxy Host 状态,确保显示为 "Online"。
880
+ * 访问 `https://relay.example.com`,如果显示绿色锁图标即表示 HTTPS 正常。
881
+
882
+ **NPM 特点**
883
+
884
+ * 🔒 自动申请和续期证书
885
+ * 🔧 图形化界面,方便管理多服务
886
+ * ⚡ 原生支持 HTTP/2 / HTTPS
887
+ * 🚀 适合 Docker 容器部署
888
+
889
+ ---
890
+
891
+ 上述两种方案均可用于生产部署。
892
+
893
+ ---
894
+
895
+ ## 💡 使用建议
896
+
897
+ ### 账户管理
898
+
899
+ - **定期检查**: 每周看看账户状态,及时处理异常
900
+ - **合理分配**: 可以给不同的人分配��同的apikey,可以根据不同的apikey来分析用量
901
+
902
+ ### 安全建议
903
+
904
+ - **使用HTTPS**: 强烈建议使用Caddy反向代理(自动HTTPS),确保数据传输安全
905
+ - **定期备份**: 重要配置和数据要备份
906
+ - **监控日志**: 定期查看异常日志
907
+ - **更新密钥**: 定期更换JWT和加密密钥
908
+ - **防火墙设置**: 只开放必要的端口(80, 443),隐藏直接服务端口
909
+
910
+ ---
911
+
912
+ ## 🆘 遇到问题怎么办?
913
+
914
+ ### 自助排查
915
+
916
+ 1. **查看日志**: `logs/` 目录下的日志文件
917
+ 2. **检查配置**: 确认配置文件设置正确
918
+ 3. **测试连通性**: 用 curl 测试API是否正常
919
+ 4. **重启服务**: 有时候重启一下就好了
920
+
921
+ ### 寻求帮助
922
+
923
+ - **GitHub Issues**: 提交详细的错误信息
924
+ - **查看文档**: 仔细阅读错误信息和文档
925
+ - **社区讨论**: 看看其他人是否遇到类似问题
926
+
927
+ ---
928
+
929
+ ## 📄 许可证
930
+
931
+ 本项目采用 [MIT许可证](LICENSE)。
932
+
933
+ ---
934
+
935
+ <div align="center">
936
+
937
+ **⭐ 觉得有用的话给个Star呗,这是对作者最大的鼓励!**
938
+
939
+ **🤝 有问题欢迎提Issue,有改进建议欢迎PR**
940
+
941
+ </div>
README_EN.md ADDED
@@ -0,0 +1,560 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Claude Relay Service
2
+
3
+ <div align="center">
4
+
5
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
6
+ [![Node.js](https://img.shields.io/badge/Node.js-18+-green.svg)](https://nodejs.org/)
7
+ [![Redis](https://img.shields.io/badge/Redis-6+-red.svg)](https://redis.io/)
8
+ [![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/)
9
+
10
+ **🔐 Self-hosted Claude API relay service with multi-account management**
11
+
12
+ [中文文档](README.md) • [Preview](https://demo.pincc.ai/admin-next/login) • [Telegram Channel](https://t.me/claude_relay_service)
13
+
14
+ </div>
15
+
16
+ ---
17
+
18
+ ## ⭐ If You Find It Useful, Please Give It a Star!
19
+
20
+ > Open source is not easy, your Star is my motivation to continue updating 🚀
21
+ > Join [Telegram Channel](https://t.me/claude_relay_service) for the latest updates
22
+
23
+ ---
24
+
25
+ ## ⚠️ Important Notice
26
+
27
+ **Please read carefully before using this project:**
28
+
29
+ 🚨 **Terms of Service Risk**: Using this project may violate Anthropic's terms of service. Please carefully read Anthropic's user agreement before use. All risks from using this project are borne by the user.
30
+
31
+ 📖 **Disclaimer**: This project is for technical learning and research purposes only. The author is not responsible for any account bans, service interruptions, or other losses caused by using this project.
32
+
33
+ ## 🤔 Is This Project Right for You?
34
+
35
+ - 🌍 **Regional Restrictions**: Can't directly access Claude Code service in your region?
36
+ - 🔒 **Privacy Concerns**: Worried about third-party mirror services logging or leaking your conversation content?
37
+ - 👥 **Cost Sharing**: Want to share Claude Code Max subscription costs with friends?
38
+ - ⚡ **Stability Issues**: Third-party mirror sites often fail and are unstable, affecting efficiency?
39
+
40
+ If you have any of these concerns, this project might be suitable for you.
41
+
42
+ ### Suitable Scenarios
43
+
44
+ ✅ **Cost Sharing with Friends**: 3-5 friends sharing Claude Code Max subscription, enjoying Opus freely
45
+ ✅ **Privacy Sensitive**: Don't want third-party mirrors to see your conversation content
46
+ ✅ **Technical Tinkering**: Have basic technical skills, willing to build and maintain yourself
47
+ ✅ **Stability Needs**: Need long-term stable Claude access, don't want to be restricted by mirror sites
48
+ ✅ **Regional Restrictions**: Cannot directly access Claude official service
49
+
50
+ ### Unsuitable Scenarios
51
+
52
+ ❌ **Complete Beginner**: Don't understand technology at all, don't even know how to buy a server
53
+ ❌ **Occasional Use**: Use it only a few times a month, not worth the hassle
54
+ ❌ **Registration Issues**: Cannot register Claude account yourself
55
+ ❌ **Payment Issues**: No payment method to subscribe to Claude Code
56
+
57
+ **If you're just an ordinary user with low privacy requirements, just want to casually play around and quickly experience Claude, then choosing a mirror site you're familiar with would be more suitable.**
58
+
59
+ ---
60
+
61
+ ## 💭 Why Build Your Own?
62
+
63
+ ### Potential Issues with Existing Mirror Sites
64
+
65
+ - 🕵️ **Privacy Risk**: Your conversation content is completely visible to others, forget about business secrets
66
+ - 🐌 **Performance Instability**: Slow when many people use it, often crashes during peak hours
67
+ - 💰 **Price Opacity**: Don't know the actual costs
68
+
69
+ ### Benefits of Self-hosting
70
+
71
+ - 🔐 **Data Security**: All API requests only go through your own server, direct connection to Anthropic API
72
+ - ⚡ **Controllable Performance**: Only a few of you using it, Max $200 package basically allows you to enjoy Opus freely
73
+ - 💰 **Cost Transparency**: Clear view of how many tokens used, specific costs calculated at official prices
74
+ - 📊 **Complete Monitoring**: Usage statistics, cost analysis, performance monitoring all available
75
+
76
+ ---
77
+
78
+ ## 🚀 Core Features
79
+
80
+ > 📸 **[Click to view interface preview](docs/preview.md)** - See detailed screenshots of the Web management interface
81
+
82
+ ### Basic Features
83
+ - ✅ **Multi-account Management**: Add multiple Claude accounts for automatic rotation
84
+ - ✅ **Custom API Keys**: Assign independent keys to each person
85
+ - ✅ **Usage Statistics**: Detailed records of how many tokens each person used
86
+
87
+ ### Advanced Features
88
+ - 🔄 **Smart Switching**: Automatically switch to next account when one has issues
89
+ - 🚀 **Performance Optimization**: Connection pooling, caching to reduce latency
90
+ - 📊 **Monitoring Dashboard**: Web interface to view all data
91
+ - 🛡️ **Security Control**: Access restrictions, rate limiting
92
+ - 🌐 **Proxy Support**: Support for HTTP/SOCKS5 proxies
93
+
94
+ ---
95
+
96
+ ## 📋 Deployment Requirements
97
+
98
+ ### Hardware Requirements (Minimum Configuration)
99
+ - **CPU**: 1 core is sufficient
100
+ - **Memory**: 512MB (1GB recommended)
101
+ - **Storage**: 30GB available space
102
+ - **Network**: Access to Anthropic API (recommend US region servers)
103
+ - **Recommendation**: 2 cores 4GB is basically enough, choose network with good return routes to your country (to improve speed, recommend not using proxy or setting server IP for direct connection)
104
+
105
+ ### Software Requirements
106
+ - **Node.js** 18 or higher
107
+ - **Redis** 6 or higher
108
+ - **Operating System**: Linux recommended
109
+
110
+ ### Cost Estimation
111
+ - **Server**: Light cloud server, $5-10 per month
112
+ - **Claude Subscription**: Depends on how you share costs
113
+ - **Others**: Domain name (optional)
114
+
115
+ ---
116
+
117
+ ## 📦 Manual Deployment
118
+
119
+ ### Step 1: Environment Setup
120
+
121
+ **Ubuntu/Debian users:**
122
+ ```bash
123
+ # Install Node.js
124
+ curl -fsSL https://deb.nodesource.com/setup_18.x | sudo -E bash -
125
+ sudo apt-get install -y nodejs
126
+
127
+ # Install Redis
128
+ sudo apt update
129
+ sudo apt install redis-server
130
+ sudo systemctl start redis-server
131
+ ```
132
+
133
+ **CentOS/RHEL users:**
134
+ ```bash
135
+ # Install Node.js
136
+ curl -fsSL https://rpm.nodesource.com/setup_18.x | sudo bash -
137
+ sudo yum install -y nodejs
138
+
139
+ # Install Redis
140
+ sudo yum install redis
141
+ sudo systemctl start redis
142
+ ```
143
+
144
+ ### Step 2: Download and Configure
145
+
146
+ ```bash
147
+ # Download project
148
+ git clone https://github.com/Wei-Shaw/claude-relay-service.git
149
+ cd claude-relay-service
150
+
151
+ # Install dependencies
152
+ npm install
153
+
154
+ # Copy configuration files (Important!)
155
+ cp config/config.example.js config/config.js
156
+ cp .env.example .env
157
+ ```
158
+
159
+ ### Step 3: Configuration File Setup
160
+
161
+ **Edit `.env` file:**
162
+ ```bash
163
+ # Generate these two keys randomly, but remember them
164
+ JWT_SECRET=your-super-secret-key
165
+ ENCRYPTION_KEY=32-character-encryption-key-write-randomly
166
+
167
+ # Redis configuration
168
+ REDIS_HOST=localhost
169
+ REDIS_PORT=6379
170
+ REDIS_PASSWORD=
171
+ ```
172
+
173
+ **Edit `config/config.js` file:**
174
+ ```javascript
175
+ module.exports = {
176
+ server: {
177
+ port: 3000, // Service port, can be changed
178
+ host: '0.0.0.0' // Don't change
179
+ },
180
+ redis: {
181
+ host: '127.0.0.1', // Redis address
182
+ port: 6379 // Redis port
183
+ },
184
+ // Keep other configurations as default
185
+ }
186
+ ```
187
+
188
+ ### Step 4: Start Service
189
+
190
+ ```bash
191
+ # Initialize
192
+ npm run setup # Will randomly generate admin account password info, stored in data/init.json
193
+
194
+ # Start service
195
+ npm run service:start:daemon # Run in background (recommended)
196
+
197
+ # Check status
198
+ npm run service:status
199
+ ```
200
+
201
+ ---
202
+
203
+ ## 🎮 Getting Started
204
+
205
+ ### 1. Open Management Interface
206
+
207
+ Browser visit: `http://your-server-IP:3000/web`
208
+
209
+ Default admin account: Look in data/init.json
210
+
211
+ ### 2. Add Claude Account
212
+
213
+ This step is quite important, requires OAuth authorization:
214
+
215
+ 1. Click "Claude Accounts" tab
216
+ 2. If you're worried about multiple accounts sharing 1 IP getting banned, you can optionally set a static proxy IP
217
+ 3. Click "Add Account"
218
+ 4. Click "Generate Authorization Link", will open a new page
219
+ 5. Complete Claude login and authorization in the new page
220
+ 6. Copy the returned Authorization Code
221
+ 7. Paste to page to complete addition
222
+
223
+ **Note**: If you're in China, this step may require VPN.
224
+
225
+ ### 3. Create API Key
226
+
227
+ Assign a key to each user:
228
+
229
+ 1. Click "API Keys" tab
230
+ 2. Click "Create New Key"
231
+ 3. Give the key a name, like "Zhang San's Key"
232
+ 4. Set usage limits (optional)
233
+ 5. Save, note down the generated key
234
+
235
+ ### 4. Start Using Claude Code
236
+
237
+ Now you can replace the official API with your own service:
238
+
239
+ **Set environment variables:**
240
+ ```bash
241
+ export ANTHROPIC_BASE_URL="http://127.0.0.1:3000/api/" # Fill in your server's IP address or domain according to actual situation
242
+ export ANTHROPIC_AUTH_TOKEN="API key created in the backend"
243
+ ```
244
+
245
+ **Use claude:**
246
+ ```bash
247
+ claude
248
+ ```
249
+
250
+ ---
251
+
252
+ ## 🔧 Daily Maintenance
253
+
254
+ ### Service Management
255
+
256
+ ```bash
257
+ # Check service status
258
+ npm run service:status
259
+
260
+ # View logs
261
+ npm run service:logs
262
+
263
+ # Restart service
264
+ npm run service:restart:daemon
265
+
266
+ # Stop service
267
+ npm run service:stop
268
+ ```
269
+
270
+ ### Monitor Usage
271
+
272
+ - **Web Interface**: `http://your-domain:3000/web` - View usage statistics
273
+ - **Health Check**: `http://your-domain:3000/health` - Confirm service is normal
274
+ - **Log Files**: Various log files in `logs/` directory
275
+
276
+ ### Upgrade Guide
277
+
278
+ When a new version is released, follow these steps to upgrade the service:
279
+
280
+ ```bash
281
+ # 1. Navigate to project directory
282
+ cd claude-relay-service
283
+
284
+ # 2. Pull latest code
285
+ git pull origin main
286
+
287
+ # If you encounter package-lock.json conflicts, use the remote version
288
+ git checkout --theirs package-lock.json
289
+ git add package-lock.json
290
+
291
+ # 3. Install new dependencies (if any)
292
+ npm install
293
+
294
+ # 4. Restart service
295
+ npm run service:restart:daemon
296
+
297
+ # 5. Check service status
298
+ npm run service:status
299
+ ```
300
+
301
+ **Important Notes:**
302
+ - Before upgrading, it's recommended to backup important configuration files (.env, config/config.js)
303
+ - Check the changelog to understand if there are any breaking changes
304
+ - Database structure changes will be migrated automatically if needed
305
+
306
+ ### Common Issue Resolution
307
+
308
+ **Can't connect to Redis?**
309
+ ```bash
310
+ # Check if Redis is running
311
+ redis-cli ping
312
+
313
+ # Should return PONG
314
+ ```
315
+
316
+ **OAuth authorization failed?**
317
+ - Check if proxy settings are correct
318
+ - Ensure normal access to claude.ai
319
+ - Clear browser cache and retry
320
+
321
+ **API request failed?**
322
+ - Check if API Key is correct
323
+ - View log files for error information
324
+ - Confirm Claude account status is normal
325
+
326
+ ---
327
+
328
+ ## 🛠️ Advanced Usage
329
+
330
+ ### Reverse Proxy Deployment Guide
331
+
332
+ For production environments, it is recommended to use a reverse proxy for automatic HTTPS, security headers, and performance optimization. Two common solutions are provided below: **Caddy** and **Nginx Proxy Manager (NPM)**.
333
+
334
+ ---
335
+
336
+ ## Caddy Solution
337
+
338
+ Caddy is a web server that automatically manages HTTPS certificates, with simple configuration and excellent performance, ideal for deployments without Docker environments.
339
+
340
+ **1. Install Caddy**
341
+
342
+ ```bash
343
+ # Ubuntu/Debian
344
+ sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https
345
+ curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
346
+ curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | sudo tee /etc/apt/sources.list.d/caddy-stable.list
347
+ sudo apt update
348
+ sudo apt install caddy
349
+
350
+ # CentOS/RHEL/Fedora
351
+ sudo yum install yum-plugin-copr
352
+ sudo yum copr enable @caddy/caddy
353
+ sudo yum install caddy
354
+ ```
355
+
356
+ **2. Caddy Configuration**
357
+
358
+ Edit `/etc/caddy/Caddyfile`:
359
+
360
+ ```caddy
361
+ your-domain.com {
362
+ # Reverse proxy to local service
363
+ reverse_proxy 127.0.0.1:3000 {
364
+ # Support streaming responses or SSE
365
+ flush_interval -1
366
+
367
+ # Pass real IP
368
+ header_up X-Real-IP {remote_host}
369
+ header_up X-Forwarded-For {remote_host}
370
+ header_up X-Forwarded-Proto {scheme}
371
+
372
+ # Long read/write timeout configuration
373
+ transport http {
374
+ read_timeout 300s
375
+ write_timeout 300s
376
+ dial_timeout 30s
377
+ }
378
+ }
379
+
380
+ # Security headers
381
+ header {
382
+ Strict-Transport-Security "max-age=31536000; includeSubDomains"
383
+ X-Frame-Options "DENY"
384
+ X-Content-Type-Options "nosniff"
385
+ -Server
386
+ }
387
+ }
388
+ ```
389
+
390
+ **3. Start Caddy**
391
+
392
+ ```bash
393
+ sudo caddy validate --config /etc/caddy/Caddyfile
394
+ sudo systemctl start caddy
395
+ sudo systemctl enable caddy
396
+ sudo systemctl status caddy
397
+ ```
398
+
399
+ **4. Service Configuration**
400
+
401
+ Since Caddy automatically manages HTTPS, you can restrict the service to listen locally only:
402
+
403
+ ```javascript
404
+ // config/config.js
405
+ module.exports = {
406
+ server: {
407
+ port: 3000,
408
+ host: '127.0.0.1' // Listen locally only
409
+ }
410
+ }
411
+ ```
412
+
413
+ **Caddy Features**
414
+
415
+ * 🔒 Automatic HTTPS with zero-configuration certificate management
416
+ * 🛡️ Secure default configuration with modern TLS suites
417
+ * ⚡ HTTP/2 and streaming support
418
+ * 🔧 Concise configuration files, easy to maintain
419
+
420
+ ---
421
+
422
+ ## Nginx Proxy Manager (NPM) Solution
423
+
424
+ Nginx Proxy Manager manages reverse proxies and HTTPS certificates through a graphical interface, deployed as a Docker container.
425
+
426
+ **1. Create a New Proxy Host in NPM**
427
+
428
+ Configure the Details as follows:
429
+
430
+ | Item | Setting |
431
+ | --------------------- | ------------------------ |
432
+ | Domain Names | relay.example.com |
433
+ | Scheme | http |
434
+ | Forward Hostname / IP | 192.168.0.1 (docker host IP) |
435
+ | Forward Port | 3000 |
436
+ | Block Common Exploits | ☑️ |
437
+ | Websockets Support | ❌ **Disable** |
438
+ | Cache Assets | ❌ **Disable** |
439
+ | Access List | Publicly Accessible |
440
+
441
+ > Note:
442
+ > - Ensure Claude Relay Service **listens on `0.0.0.0`, container IP, or host IP** to allow NPM internal network connections.
443
+ > - **Websockets Support and Cache Assets must be disabled**, otherwise SSE / streaming responses will fail.
444
+
445
+ **2. Custom locations**
446
+
447
+ No content needed, keep it empty.
448
+
449
+ **3. SSL Settings**
450
+
451
+ * **SSL Certificate**: Request a new SSL Certificate (Let's Encrypt) or existing certificate
452
+ * ☑️ **Force SSL**
453
+ * ☑️ **HTTP/2 Support**
454
+ * ☑️ **HSTS Enabled**
455
+ * ☑️ **HSTS Subdomains**
456
+
457
+ **4. Advanced Configuration**
458
+
459
+ Add the following to Custom Nginx Configuration:
460
+
461
+ ```nginx
462
+ # Pass real user IP
463
+ proxy_set_header X-Real-IP $remote_addr;
464
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
465
+ proxy_set_header X-Forwarded-Proto $scheme;
466
+
467
+ # Support WebSocket / SSE streaming
468
+ proxy_http_version 1.1;
469
+ proxy_set_header Upgrade $http_upgrade;
470
+ proxy_set_header Connection "upgrade";
471
+ proxy_buffering off;
472
+
473
+ # Long connection / timeout settings (for AI chat streaming)
474
+ proxy_read_timeout 300s;
475
+ proxy_send_timeout 300s;
476
+ proxy_connect_timeout 30s;
477
+
478
+ # ---- Security Settings ----
479
+ # Strict HTTPS policy (HSTS)
480
+ add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
481
+
482
+ # Block clickjacking and content sniffing
483
+ add_header X-Frame-Options "DENY" always;
484
+ add_header X-Content-Type-Options "nosniff" always;
485
+
486
+ # Referrer / Permissions restriction policies
487
+ add_header Referrer-Policy "no-referrer-when-downgrade" always;
488
+ add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
489
+
490
+ # Hide server information (equivalent to Caddy's `-Server`)
491
+ proxy_hide_header Server;
492
+
493
+ # ---- Performance Tuning ----
494
+ # Disable proxy caching for real-time responses (SSE / Streaming)
495
+ proxy_cache_bypass $http_upgrade;
496
+ proxy_no_cache $http_upgrade;
497
+ proxy_request_buffering off;
498
+ ```
499
+
500
+ **5. Launch and Verify**
501
+
502
+ * After saving, wait for NPM to automatically request Let's Encrypt certificate (if applicable).
503
+ * Check Proxy Host status in Dashboard to ensure it shows "Online".
504
+ * Visit `https://relay.example.com`, if the green lock icon appears, HTTPS is working properly.
505
+
506
+ **NPM Features**
507
+
508
+ * 🔒 Automatic certificate application and renewal
509
+ * 🔧 Graphical interface for easy multi-service management
510
+ * ⚡ Native HTTP/2 / HTTPS support
511
+ * 🚀 Ideal for Docker container deployments
512
+
513
+ ---
514
+
515
+ Both solutions are suitable for production deployment. If you use a Docker environment, **Nginx Proxy Manager is more convenient**; if you want to keep software lightweight and automated, **Caddy is a better choice**.
516
+
517
+ ---
518
+
519
+ ## 💡 Usage Recommendations
520
+
521
+ ### Account Management
522
+ - **Regular Checks**: Check account status weekly, handle exceptions promptly
523
+ - **Reasonable Allocation**: Can assign different API keys to different people, analyze usage based on different API keys
524
+
525
+ ### Security Recommendations
526
+ - **Use HTTPS**: Strongly recommend using Caddy reverse proxy (automatic HTTPS) to ensure secure data transmission
527
+ - **Regular Backups**: Back up important configurations and data
528
+ - **Monitor Logs**: Regularly check exception logs
529
+ - **Update Keys**: Regularly change JWT and encryption keys
530
+ - **Firewall Settings**: Only open necessary ports (80, 443), hide direct service ports
531
+
532
+ ---
533
+
534
+ ## 🆘 What to Do When You Encounter Problems?
535
+
536
+ ### Self-troubleshooting
537
+ 1. **Check Logs**: Log files in `logs/` directory
538
+ 2. **Check Configuration**: Confirm configuration files are set correctly
539
+ 3. **Test Connectivity**: Use curl to test if API is normal
540
+ 4. **Restart Service**: Sometimes restarting fixes it
541
+
542
+ ### Seeking Help
543
+ - **GitHub Issues**: Submit detailed error information
544
+ - **Read Documentation**: Carefully read error messages and documentation
545
+ - **Community Discussion**: See if others have encountered similar problems
546
+
547
+ ---
548
+
549
+ ## 📄 License
550
+ This project uses the [MIT License](LICENSE).
551
+
552
+ ---
553
+
554
+ <div align="center">
555
+
556
+ **⭐ If you find it useful, please give it a Star, this is the greatest encouragement to the author!**
557
+
558
+ **🤝 Feel free to submit Issues for problems, welcome PRs for improvement suggestions**
559
+
560
+ </div>
VERSION ADDED
@@ -0,0 +1 @@
 
 
1
+ 1.1.175
cli/index.js ADDED
@@ -0,0 +1,1025 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env node
2
+
3
+ const { Command } = require('commander')
4
+ const inquirer = require('inquirer')
5
+ const chalk = require('chalk')
6
+ const ora = require('ora')
7
+ const { table } = require('table')
8
+ const bcrypt = require('bcryptjs')
9
+ const fs = require('fs')
10
+ const path = require('path')
11
+
12
+ const redis = require('../src/models/redis')
13
+ const apiKeyService = require('../src/services/apiKeyService')
14
+ const claudeAccountService = require('../src/services/claudeAccountService')
15
+ const bedrockAccountService = require('../src/services/bedrockAccountService')
16
+
17
+ const program = new Command()
18
+
19
+ // 🎨 样式
20
+ const styles = {
21
+ title: chalk.bold.blue,
22
+ success: chalk.green,
23
+ error: chalk.red,
24
+ warning: chalk.yellow,
25
+ info: chalk.cyan,
26
+ dim: chalk.dim
27
+ }
28
+
29
+ // 🔧 初始化
30
+ async function initialize() {
31
+ const spinner = ora('正在连接 Redis...').start()
32
+ try {
33
+ await redis.connect()
34
+ spinner.succeed('Redis 连接成功')
35
+ } catch (error) {
36
+ spinner.fail('Redis 连接失败')
37
+ console.error(styles.error(error.message))
38
+ process.exit(1)
39
+ }
40
+ }
41
+
42
+ // 🔐 管理员账户管理
43
+ program
44
+ .command('admin')
45
+ .description('管理员账户操作')
46
+ .action(async () => {
47
+ await initialize()
48
+
49
+ // 直接执行创建初始管理员
50
+ await createInitialAdmin()
51
+
52
+ await redis.disconnect()
53
+ })
54
+
55
+ // 🔑 API Key 管理
56
+ program
57
+ .command('keys')
58
+ .description('API Key 管理操作')
59
+ .action(async () => {
60
+ await initialize()
61
+
62
+ const { action } = await inquirer.prompt([
63
+ {
64
+ type: 'list',
65
+ name: 'action',
66
+ message: '请选择操作:',
67
+ choices: [
68
+ { name: '📋 查看所有 API Keys', value: 'list' },
69
+ { name: '🔧 修改 API Key 过期时间', value: 'update-expiry' },
70
+ { name: '🔄 续期即将过期的 API Key', value: 'renew' },
71
+ { name: '🗑️ 删除 API Key', value: 'delete' }
72
+ ]
73
+ }
74
+ ])
75
+
76
+ switch (action) {
77
+ case 'list':
78
+ await listApiKeys()
79
+ break
80
+ case 'update-expiry':
81
+ await updateApiKeyExpiry()
82
+ break
83
+ case 'renew':
84
+ await renewApiKeys()
85
+ break
86
+ case 'delete':
87
+ await deleteApiKey()
88
+ break
89
+ }
90
+
91
+ await redis.disconnect()
92
+ })
93
+
94
+ // 📊 系统状态
95
+ program
96
+ .command('status')
97
+ .description('查看系统状态')
98
+ .action(async () => {
99
+ await initialize()
100
+
101
+ const spinner = ora('正在获取系统状态...').start()
102
+
103
+ try {
104
+ const [, apiKeys, accounts] = await Promise.all([
105
+ redis.getSystemStats(),
106
+ apiKeyService.getAllApiKeys(),
107
+ claudeAccountService.getAllAccounts()
108
+ ])
109
+
110
+ spinner.succeed('系统状态获取成功')
111
+
112
+ console.log(styles.title('\n📊 系统状态概览\n'))
113
+
114
+ const statusData = [
115
+ ['项目', '数量', '状态'],
116
+ ['API Keys', apiKeys.length, `${apiKeys.filter((k) => k.isActive).length} 活跃`],
117
+ ['Claude 账户', accounts.length, `${accounts.filter((a) => a.isActive).length} 活跃`],
118
+ ['Redis 连接', redis.isConnected ? '已连接' : '未连接', redis.isConnected ? '🟢' : '🔴'],
119
+ ['运行时间', `${Math.floor(process.uptime() / 60)} 分钟`, '🕐']
120
+ ]
121
+
122
+ console.log(table(statusData))
123
+
124
+ // 使用统计
125
+ const totalTokens = apiKeys.reduce((sum, key) => sum + (key.usage?.total?.tokens || 0), 0)
126
+ const totalRequests = apiKeys.reduce((sum, key) => sum + (key.usage?.total?.requests || 0), 0)
127
+
128
+ console.log(styles.title('\n📈 使用统计\n'))
129
+ console.log(`总 Token 使用量: ${styles.success(totalTokens.toLocaleString())}`)
130
+ console.log(`总请求数: ${styles.success(totalRequests.toLocaleString())}`)
131
+ } catch (error) {
132
+ spinner.fail('获取系统状态失败')
133
+ console.error(styles.error(error.message))
134
+ }
135
+
136
+ await redis.disconnect()
137
+ })
138
+
139
+ // ☁️ Bedrock 账户管理
140
+ program
141
+ .command('bedrock')
142
+ .description('Bedrock 账户管理操作')
143
+ .action(async () => {
144
+ await initialize()
145
+
146
+ const { action } = await inquirer.prompt([
147
+ {
148
+ type: 'list',
149
+ name: 'action',
150
+ message: '请选择操作:',
151
+ choices: [
152
+ { name: '📋 查看所有 Bedrock 账户', value: 'list' },
153
+ { name: '➕ 创建 Bedrock 账户', value: 'create' },
154
+ { name: '✏️ 编辑 Bedrock 账户', value: 'edit' },
155
+ { name: '🔄 切换账户状态', value: 'toggle' },
156
+ { name: '🧪 测试账户连接', value: 'test' },
157
+ { name: '🗑️ 删除账户', value: 'delete' }
158
+ ]
159
+ }
160
+ ])
161
+
162
+ switch (action) {
163
+ case 'list':
164
+ await listBedrockAccounts()
165
+ break
166
+ case 'create':
167
+ await createBedrockAccount()
168
+ break
169
+ case 'edit':
170
+ await editBedrockAccount()
171
+ break
172
+ case 'toggle':
173
+ await toggleBedrockAccount()
174
+ break
175
+ case 'test':
176
+ await testBedrockAccount()
177
+ break
178
+ case 'delete':
179
+ await deleteBedrockAccount()
180
+ break
181
+ }
182
+
183
+ await redis.disconnect()
184
+ })
185
+
186
+ // 实现具体功能函数
187
+
188
+ async function createInitialAdmin() {
189
+ console.log(styles.title('\n🔐 创建初始管理员账户\n'))
190
+
191
+ // 检查是否已存在 init.json
192
+ const initFilePath = path.join(__dirname, '..', 'data', 'init.json')
193
+ if (fs.existsSync(initFilePath)) {
194
+ const existingData = JSON.parse(fs.readFileSync(initFilePath, 'utf8'))
195
+ console.log(styles.warning('⚠️ 检测到已存在管理员账户!'))
196
+ console.log(` 用户名: ${existingData.adminUsername}`)
197
+ console.log(` 创建时间: ${new Date(existingData.initializedAt).toLocaleString()}`)
198
+
199
+ const { overwrite } = await inquirer.prompt([
200
+ {
201
+ type: 'confirm',
202
+ name: 'overwrite',
203
+ message: '是否覆盖现有管理员账户?',
204
+ default: false
205
+ }
206
+ ])
207
+
208
+ if (!overwrite) {
209
+ console.log(styles.info('ℹ️ 已取消创建'))
210
+ return
211
+ }
212
+ }
213
+
214
+ const adminData = await inquirer.prompt([
215
+ {
216
+ type: 'input',
217
+ name: 'username',
218
+ message: '用户名:',
219
+ default: 'admin',
220
+ validate: (input) => input.length >= 3 || '用户名至少3个字符'
221
+ },
222
+ {
223
+ type: 'password',
224
+ name: 'password',
225
+ message: '密码:',
226
+ validate: (input) => input.length >= 8 || '密码至少8个字符'
227
+ },
228
+ {
229
+ type: 'password',
230
+ name: 'confirmPassword',
231
+ message: '确认密码:',
232
+ validate: (input, answers) => input === answers.password || '密码不匹配'
233
+ }
234
+ ])
235
+
236
+ const spinner = ora('正在创建管理员账户...').start()
237
+
238
+ try {
239
+ // 1. 先更新 init.json(唯一真实数据源)
240
+ const initData = {
241
+ initializedAt: new Date().toISOString(),
242
+ adminUsername: adminData.username,
243
+ adminPassword: adminData.password, // 保存明文密码
244
+ version: '1.0.0',
245
+ updatedAt: new Date().toISOString()
246
+ }
247
+
248
+ // 确保 data 目录存在
249
+ const dataDir = path.join(__dirname, '..', 'data')
250
+ if (!fs.existsSync(dataDir)) {
251
+ fs.mkdirSync(dataDir, { recursive: true })
252
+ }
253
+
254
+ // 写入文件
255
+ fs.writeFileSync(initFilePath, JSON.stringify(initData, null, 2))
256
+
257
+ // 2. 再更新 Redis 缓存
258
+ const passwordHash = await bcrypt.hash(adminData.password, 12)
259
+
260
+ const credentials = {
261
+ username: adminData.username,
262
+ passwordHash,
263
+ createdAt: new Date().toISOString(),
264
+ lastLogin: null,
265
+ updatedAt: new Date().toISOString()
266
+ }
267
+
268
+ await redis.setSession('admin_credentials', credentials, 0) // 永不过期
269
+
270
+ spinner.succeed('管理员账户创建成功')
271
+ console.log(`${styles.success('✅')} 用户名: ${adminData.username}`)
272
+ console.log(`${styles.success('✅')} 密码: ${adminData.password}`)
273
+ console.log(`${styles.info('ℹ️')} 请妥善保管登录凭据`)
274
+ console.log(`${styles.info('ℹ️')} 凭据已保存到: ${initFilePath}`)
275
+ console.log(`${styles.warning('⚠️')} 如果服务正在运行,请重启服务以加载新凭据`)
276
+ } catch (error) {
277
+ spinner.fail('创建管理员账户失败')
278
+ console.error(styles.error(error.message))
279
+ }
280
+ }
281
+
282
+ // API Key 管理功能
283
+ async function listApiKeys() {
284
+ const spinner = ora('正在获取 API Keys...').start()
285
+
286
+ try {
287
+ const apiKeys = await apiKeyService.getAllApiKeys()
288
+ spinner.succeed(`找到 ${apiKeys.length} 个 API Keys`)
289
+
290
+ if (apiKeys.length === 0) {
291
+ console.log(styles.warning('没有找到任何 API Keys'))
292
+ return
293
+ }
294
+
295
+ const tableData = [['名称', 'API Key', '状态', '过期时间', '使用量', 'Token限制']]
296
+
297
+ apiKeys.forEach((key) => {
298
+ const now = new Date()
299
+ const expiresAt = key.expiresAt ? new Date(key.expiresAt) : null
300
+ let expiryStatus = '永不过期'
301
+
302
+ if (expiresAt) {
303
+ if (expiresAt < now) {
304
+ expiryStatus = styles.error(`已过期 (${expiresAt.toLocaleDateString()})`)
305
+ } else {
306
+ const daysLeft = Math.ceil((expiresAt - now) / (1000 * 60 * 60 * 24))
307
+ if (daysLeft <= 7) {
308
+ expiryStatus = styles.warning(`${daysLeft}天后过期 (${expiresAt.toLocaleDateString()})`)
309
+ } else {
310
+ expiryStatus = styles.success(`${expiresAt.toLocaleDateString()}`)
311
+ }
312
+ }
313
+ }
314
+
315
+ tableData.push([
316
+ key.name,
317
+ key.apiKey ? `${key.apiKey.substring(0, 20)}...` : '-',
318
+ key.isActive ? '🟢 活跃' : '🔴 停用',
319
+ expiryStatus,
320
+ `${(key.usage?.total?.tokens || 0).toLocaleString()}`,
321
+ key.tokenLimit ? key.tokenLimit.toLocaleString() : '无限制'
322
+ ])
323
+ })
324
+
325
+ console.log(styles.title('\n🔑 API Keys 列表:\n'))
326
+ console.log(table(tableData))
327
+ } catch (error) {
328
+ spinner.fail('获取 API Keys 失败')
329
+ console.error(styles.error(error.message))
330
+ }
331
+ }
332
+
333
+ async function updateApiKeyExpiry() {
334
+ try {
335
+ // 获取所有 API Keys
336
+ const apiKeys = await apiKeyService.getAllApiKeys()
337
+
338
+ if (apiKeys.length === 0) {
339
+ console.log(styles.warning('没有找到任何 API Keys'))
340
+ return
341
+ }
342
+
343
+ // 选择要修改的 API Key
344
+ const { selectedKey } = await inquirer.prompt([
345
+ {
346
+ type: 'list',
347
+ name: 'selectedKey',
348
+ message: '选择要修改的 API Key:',
349
+ choices: apiKeys.map((key) => ({
350
+ name: `${key.name} (${key.apiKey?.substring(0, 20)}...) - ${key.expiresAt ? new Date(key.expiresAt).toLocaleDateString() : '永不过期'}`,
351
+ value: key
352
+ }))
353
+ }
354
+ ])
355
+
356
+ console.log(`\n当前 API Key: ${selectedKey.name}`)
357
+ console.log(
358
+ `当前过期时间: ${selectedKey.expiresAt ? new Date(selectedKey.expiresAt).toLocaleString() : '永不过期'}`
359
+ )
360
+
361
+ // 选择新的过期时间
362
+ const { expiryOption } = await inquirer.prompt([
363
+ {
364
+ type: 'list',
365
+ name: 'expiryOption',
366
+ message: '选择新的过期时间:',
367
+ choices: [
368
+ { name: '⏰ 1分后(测试用)', value: '1m' },
369
+ { name: '⏰ 1小时后(测试用)', value: '1h' },
370
+ { name: '📅 1天后', value: '1d' },
371
+ { name: '📅 7天后', value: '7d' },
372
+ { name: '📅 30天后', value: '30d' },
373
+ { name: '📅 90天后', value: '90d' },
374
+ { name: '📅 365天后', value: '365d' },
375
+ { name: '♾️ 永不过期', value: 'never' },
376
+ { name: '🎯 自定义日期时间', value: 'custom' }
377
+ ]
378
+ }
379
+ ])
380
+
381
+ let newExpiresAt = null
382
+
383
+ if (expiryOption === 'never') {
384
+ newExpiresAt = null
385
+ } else if (expiryOption === 'custom') {
386
+ const { customDate, customTime } = await inquirer.prompt([
387
+ {
388
+ type: 'input',
389
+ name: 'customDate',
390
+ message: '输入日期 (YYYY-MM-DD):',
391
+ default: new Date().toISOString().split('T')[0],
392
+ validate: (input) => {
393
+ const date = new Date(input)
394
+ return !isNaN(date.getTime()) || '请输入有效的日期格式'
395
+ }
396
+ },
397
+ {
398
+ type: 'input',
399
+ name: 'customTime',
400
+ message: '输入时间 (HH:MM):',
401
+ default: '00:00',
402
+ validate: (input) => /^\d{2}:\d{2}$/.test(input) || '请输入有效的时间格式 (HH:MM)'
403
+ }
404
+ ])
405
+
406
+ newExpiresAt = new Date(`${customDate}T${customTime}:00`).toISOString()
407
+ } else {
408
+ // 计算新的过期时间
409
+ const now = new Date()
410
+ const durations = {
411
+ '1m': 60 * 1000,
412
+ '1h': 60 * 60 * 1000,
413
+ '1d': 24 * 60 * 60 * 1000,
414
+ '7d': 7 * 24 * 60 * 60 * 1000,
415
+ '30d': 30 * 24 * 60 * 60 * 1000,
416
+ '90d': 90 * 24 * 60 * 60 * 1000,
417
+ '365d': 365 * 24 * 60 * 60 * 1000
418
+ }
419
+
420
+ newExpiresAt = new Date(now.getTime() + durations[expiryOption]).toISOString()
421
+ }
422
+
423
+ // 确认修改
424
+ const confirmMsg = newExpiresAt
425
+ ? `确认将过期时间修改为: ${new Date(newExpiresAt).toLocaleString()}?`
426
+ : '确认设置为永不过期?'
427
+
428
+ const { confirmed } = await inquirer.prompt([
429
+ {
430
+ type: 'confirm',
431
+ name: 'confirmed',
432
+ message: confirmMsg,
433
+ default: true
434
+ }
435
+ ])
436
+
437
+ if (!confirmed) {
438
+ console.log(styles.info('已取消修改'))
439
+ return
440
+ }
441
+
442
+ // 执行修改
443
+ const spinner = ora('正在修改过期时间...').start()
444
+
445
+ try {
446
+ await apiKeyService.updateApiKey(selectedKey.id, { expiresAt: newExpiresAt })
447
+ spinner.succeed('过期时间修改成功')
448
+
449
+ console.log(styles.success(`\n✅ API Key "${selectedKey.name}" 的过期时间已更新`))
450
+ console.log(
451
+ `新的过期时间: ${newExpiresAt ? new Date(newExpiresAt).toLocaleString() : '永不过期'}`
452
+ )
453
+ } catch (error) {
454
+ spinner.fail('修改失败')
455
+ console.error(styles.error(error.message))
456
+ }
457
+ } catch (error) {
458
+ console.error(styles.error('操作失败:', error.message))
459
+ }
460
+ }
461
+
462
+ async function renewApiKeys() {
463
+ const spinner = ora('正在查找即将过期的 API Keys...').start()
464
+
465
+ try {
466
+ const apiKeys = await apiKeyService.getAllApiKeys()
467
+ const now = new Date()
468
+ const sevenDaysLater = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000)
469
+
470
+ // 筛选即将过期的 Keys(7天内)
471
+ const expiringKeys = apiKeys.filter((key) => {
472
+ if (!key.expiresAt) {
473
+ return false
474
+ }
475
+ const expiresAt = new Date(key.expiresAt)
476
+ return expiresAt > now && expiresAt <= sevenDaysLater
477
+ })
478
+
479
+ spinner.stop()
480
+
481
+ if (expiringKeys.length === 0) {
482
+ console.log(styles.info('没有即将过期的 API Keys(7天内)'))
483
+ return
484
+ }
485
+
486
+ console.log(styles.warning(`\n找到 ${expiringKeys.length} 个即将过期的 API Keys:\n`))
487
+
488
+ expiringKeys.forEach((key, index) => {
489
+ const daysLeft = Math.ceil((new Date(key.expiresAt) - now) / (1000 * 60 * 60 * 24))
490
+ console.log(
491
+ `${index + 1}. ${key.name} - ${daysLeft}天后过期 (${new Date(key.expiresAt).toLocaleDateString()})`
492
+ )
493
+ })
494
+
495
+ const { renewOption } = await inquirer.prompt([
496
+ {
497
+ type: 'list',
498
+ name: 'renewOption',
499
+ message: '选择续期方式:',
500
+ choices: [
501
+ { name: '📅 全部续期30天', value: 'all30' },
502
+ { name: '📅 全部续期90天', value: 'all90' },
503
+ { name: '🎯 逐个选择续期', value: 'individual' }
504
+ ]
505
+ }
506
+ ])
507
+
508
+ if (renewOption.startsWith('all')) {
509
+ const days = renewOption === 'all30' ? 30 : 90
510
+ const renewSpinner = ora(`正在为所有 API Keys 续期 ${days} 天...`).start()
511
+
512
+ for (const key of expiringKeys) {
513
+ try {
514
+ const newExpiresAt = new Date(
515
+ new Date(key.expiresAt).getTime() + days * 24 * 60 * 60 * 1000
516
+ ).toISOString()
517
+ await apiKeyService.updateApiKey(key.id, { expiresAt: newExpiresAt })
518
+ } catch (error) {
519
+ renewSpinner.fail(`续期 ${key.name} 失败: ${error.message}`)
520
+ }
521
+ }
522
+
523
+ renewSpinner.succeed(`成功续期 ${expiringKeys.length} 个 API Keys`)
524
+ } else {
525
+ // 逐个选择续期
526
+ for (const key of expiringKeys) {
527
+ console.log(`\n处理: ${key.name}`)
528
+
529
+ const { action } = await inquirer.prompt([
530
+ {
531
+ type: 'list',
532
+ name: 'action',
533
+ message: '选择操作:',
534
+ choices: [
535
+ { name: '续期30天', value: '30' },
536
+ { name: '续期90天', value: '90' },
537
+ { name: '跳过', value: 'skip' }
538
+ ]
539
+ }
540
+ ])
541
+
542
+ if (action !== 'skip') {
543
+ const days = parseInt(action)
544
+ const newExpiresAt = new Date(
545
+ new Date(key.expiresAt).getTime() + days * 24 * 60 * 60 * 1000
546
+ ).toISOString()
547
+
548
+ try {
549
+ await apiKeyService.updateApiKey(key.id, { expiresAt: newExpiresAt })
550
+ console.log(styles.success(`✅ 已续期 ${days} 天`))
551
+ } catch (error) {
552
+ console.log(styles.error(`❌ 续期失败: ${error.message}`))
553
+ }
554
+ }
555
+ }
556
+ }
557
+ } catch (error) {
558
+ spinner.fail('操作失败')
559
+ console.error(styles.error(error.message))
560
+ }
561
+ }
562
+
563
+ async function deleteApiKey() {
564
+ try {
565
+ const apiKeys = await apiKeyService.getAllApiKeys()
566
+
567
+ if (apiKeys.length === 0) {
568
+ console.log(styles.warning('没有找到任何 API Keys'))
569
+ return
570
+ }
571
+
572
+ const { selectedKeys } = await inquirer.prompt([
573
+ {
574
+ type: 'checkbox',
575
+ name: 'selectedKeys',
576
+ message: '选择要删除的 API Keys (空格选择,回车确认):',
577
+ choices: apiKeys.map((key) => ({
578
+ name: `${key.name} (${key.apiKey?.substring(0, 20)}...)`,
579
+ value: key.id
580
+ }))
581
+ }
582
+ ])
583
+
584
+ if (selectedKeys.length === 0) {
585
+ console.log(styles.info('未选择任何 API Key'))
586
+ return
587
+ }
588
+
589
+ const { confirmed } = await inquirer.prompt([
590
+ {
591
+ type: 'confirm',
592
+ name: 'confirmed',
593
+ message: styles.warning(`确认删除 ${selectedKeys.length} 个 API Keys?`),
594
+ default: false
595
+ }
596
+ ])
597
+
598
+ if (!confirmed) {
599
+ console.log(styles.info('已取消删除'))
600
+ return
601
+ }
602
+
603
+ const spinner = ora('正在删除 API Keys...').start()
604
+ let successCount = 0
605
+
606
+ for (const keyId of selectedKeys) {
607
+ try {
608
+ await apiKeyService.deleteApiKey(keyId)
609
+ successCount++
610
+ } catch (error) {
611
+ spinner.fail(`删除失败: ${error.message}`)
612
+ }
613
+ }
614
+
615
+ spinner.succeed(`成功删除 ${successCount}/${selectedKeys.length} 个 API Keys`)
616
+ } catch (error) {
617
+ console.error(styles.error('删除失败:', error.message))
618
+ }
619
+ }
620
+
621
+ // async function listClaudeAccounts() {
622
+ // const spinner = ora('正在获取 Claude 账户...').start();
623
+
624
+ // try {
625
+ // const accounts = await claudeAccountService.getAllAccounts();
626
+ // spinner.succeed(`找到 ${accounts.length} 个 Claude 账户`);
627
+
628
+ // if (accounts.length === 0) {
629
+ // console.log(styles.warning('没有找到任何 Claude 账户'));
630
+ // return;
631
+ // }
632
+
633
+ // const tableData = [
634
+ // ['ID', '名称', '邮箱', '状态', '代理', '最后使用']
635
+ // ];
636
+
637
+ // accounts.forEach(account => {
638
+ // tableData.push([
639
+ // account.id.substring(0, 8) + '...',
640
+ // account.name,
641
+ // account.email || '-',
642
+ // account.isActive ? (account.status === 'active' ? '🟢 活跃' : '🟡 待激活') : '🔴 停用',
643
+ // account.proxy ? '🌐 是' : '-',
644
+ // account.lastUsedAt ? new Date(account.lastUsedAt).toLocaleDateString() : '-'
645
+ // ]);
646
+ // });
647
+
648
+ // console.log('\n🏢 Claude 账户列表:\n');
649
+ // console.log(table(tableData));
650
+
651
+ // } catch (error) {
652
+ // spinner.fail('获取 Claude 账户失败');
653
+ // console.error(styles.error(error.message));
654
+ // }
655
+ // }
656
+
657
+ // ☁️ Bedrock 账户管理函数
658
+
659
+ async function listBedrockAccounts() {
660
+ const spinner = ora('正在获取 Bedrock 账户...').start()
661
+
662
+ try {
663
+ const result = await bedrockAccountService.getAllAccounts()
664
+ if (!result.success) {
665
+ throw new Error(result.error)
666
+ }
667
+
668
+ const accounts = result.data
669
+ spinner.succeed(`找到 ${accounts.length} 个 Bedrock 账户`)
670
+
671
+ if (accounts.length === 0) {
672
+ console.log(styles.warning('没有找到任何 Bedrock 账户'))
673
+ return
674
+ }
675
+
676
+ const tableData = [['ID', '名称', '区域', '模型', '状态', '凭证类型', '创建时间']]
677
+
678
+ accounts.forEach((account) => {
679
+ tableData.push([
680
+ `${account.id.substring(0, 8)}...`,
681
+ account.name,
682
+ account.region,
683
+ account.defaultModel?.split('.').pop() || 'default',
684
+ account.isActive ? (account.schedulable ? '🟢 活跃' : '🟡 不可调度') : '🔴 停用',
685
+ account.credentialType,
686
+ account.createdAt ? new Date(account.createdAt).toLocaleDateString() : '-'
687
+ ])
688
+ })
689
+
690
+ console.log('\n☁️ Bedrock 账户列表:\n')
691
+ console.log(table(tableData))
692
+ } catch (error) {
693
+ spinner.fail('获取 Bedrock 账户失败')
694
+ console.error(styles.error(error.message))
695
+ }
696
+ }
697
+
698
+ async function createBedrockAccount() {
699
+ console.log(styles.title('\n➕ 创建 Bedrock 账户\n'))
700
+
701
+ const questions = [
702
+ {
703
+ type: 'input',
704
+ name: 'name',
705
+ message: '账户名称:',
706
+ validate: (input) => input.trim() !== ''
707
+ },
708
+ {
709
+ type: 'input',
710
+ name: 'description',
711
+ message: '描述 (可选):'
712
+ },
713
+ {
714
+ type: 'list',
715
+ name: 'region',
716
+ message: '选择 AWS 区域:',
717
+ choices: [
718
+ { name: 'us-east-1 (北弗吉尼亚)', value: 'us-east-1' },
719
+ { name: 'us-west-2 (俄勒冈)', value: 'us-west-2' },
720
+ { name: 'eu-west-1 (爱尔兰)', value: 'eu-west-1' },
721
+ { name: 'ap-southeast-1 (新加坡)', value: 'ap-southeast-1' }
722
+ ]
723
+ },
724
+ {
725
+ type: 'list',
726
+ name: 'credentialType',
727
+ message: '凭证类型:',
728
+ choices: [
729
+ { name: '默认凭证链 (环境变量/AWS配置)', value: 'default' },
730
+ { name: '访问密钥 (Access Key)', value: 'access_key' },
731
+ { name: 'Bearer Token (API Key)', value: 'bearer_token' }
732
+ ]
733
+ }
734
+ ]
735
+
736
+ // 根据凭证类型添加额外问题
737
+ const answers = await inquirer.prompt(questions)
738
+
739
+ if (answers.credentialType === 'access_key') {
740
+ const credQuestions = await inquirer.prompt([
741
+ {
742
+ type: 'input',
743
+ name: 'accessKeyId',
744
+ message: 'AWS Access Key ID:',
745
+ validate: (input) => input.trim() !== ''
746
+ },
747
+ {
748
+ type: 'password',
749
+ name: 'secretAccessKey',
750
+ message: 'AWS Secret Access Key:',
751
+ validate: (input) => input.trim() !== ''
752
+ },
753
+ {
754
+ type: 'input',
755
+ name: 'sessionToken',
756
+ message: 'Session Token (可选,用于临时凭证):'
757
+ }
758
+ ])
759
+
760
+ answers.awsCredentials = {
761
+ accessKeyId: credQuestions.accessKeyId,
762
+ secretAccessKey: credQuestions.secretAccessKey
763
+ }
764
+
765
+ if (credQuestions.sessionToken) {
766
+ answers.awsCredentials.sessionToken = credQuestions.sessionToken
767
+ }
768
+ }
769
+
770
+ const spinner = ora('正在创建 Bedrock 账户...').start()
771
+
772
+ try {
773
+ const result = await bedrockAccountService.createAccount(answers)
774
+
775
+ if (!result.success) {
776
+ throw new Error(result.error)
777
+ }
778
+
779
+ spinner.succeed('Bedrock 账户创建成功')
780
+ console.log(styles.success(`账户 ID: ${result.data.id}`))
781
+ console.log(styles.info(`名称: ${result.data.name}`))
782
+ console.log(styles.info(`区域: ${result.data.region}`))
783
+ } catch (error) {
784
+ spinner.fail('创建 Bedrock 账户失败')
785
+ console.error(styles.error(error.message))
786
+ }
787
+ }
788
+
789
+ async function testBedrockAccount() {
790
+ const spinner = ora('正在获取 Bedrock 账户...').start()
791
+
792
+ try {
793
+ const result = await bedrockAccountService.getAllAccounts()
794
+ if (!result.success || result.data.length === 0) {
795
+ spinner.fail('没有可测试的 Bedrock 账户')
796
+ return
797
+ }
798
+
799
+ spinner.succeed('账户列表获取成功')
800
+
801
+ const choices = result.data.map((account) => ({
802
+ name: `${account.name} (${account.region})`,
803
+ value: account.id
804
+ }))
805
+
806
+ const { accountId } = await inquirer.prompt([
807
+ {
808
+ type: 'list',
809
+ name: 'accountId',
810
+ message: '选择要测试的账户:',
811
+ choices
812
+ }
813
+ ])
814
+
815
+ const testSpinner = ora('正在测试账户连接...').start()
816
+
817
+ const testResult = await bedrockAccountService.testAccount(accountId)
818
+
819
+ if (testResult.success) {
820
+ testSpinner.succeed('账户连接测试成功')
821
+ console.log(styles.success(`状态: ${testResult.data.status}`))
822
+ console.log(styles.info(`区域: ${testResult.data.region}`))
823
+ console.log(styles.info(`可用模��数量: ${testResult.data.modelsCount || 'N/A'}`))
824
+ } else {
825
+ testSpinner.fail('账户连接测试失败')
826
+ console.error(styles.error(testResult.error))
827
+ }
828
+ } catch (error) {
829
+ spinner.fail('测试过程中发生错误')
830
+ console.error(styles.error(error.message))
831
+ }
832
+ }
833
+
834
+ async function toggleBedrockAccount() {
835
+ const spinner = ora('正在获取 Bedrock 账户...').start()
836
+
837
+ try {
838
+ const result = await bedrockAccountService.getAllAccounts()
839
+ if (!result.success || result.data.length === 0) {
840
+ spinner.fail('没有可操作的 Bedrock 账户')
841
+ return
842
+ }
843
+
844
+ spinner.succeed('账户列表获取成功')
845
+
846
+ const choices = result.data.map((account) => ({
847
+ name: `${account.name} (${account.isActive ? '🟢 活跃' : '🔴 停用'})`,
848
+ value: account.id
849
+ }))
850
+
851
+ const { accountId } = await inquirer.prompt([
852
+ {
853
+ type: 'list',
854
+ name: 'accountId',
855
+ message: '选择要切换状态的账户:',
856
+ choices
857
+ }
858
+ ])
859
+
860
+ const toggleSpinner = ora('正在切换账户状态...').start()
861
+
862
+ // 获取当前状态
863
+ const accountResult = await bedrockAccountService.getAccount(accountId)
864
+ if (!accountResult.success) {
865
+ throw new Error('无法获取账户信息')
866
+ }
867
+
868
+ const newStatus = !accountResult.data.isActive
869
+ const updateResult = await bedrockAccountService.updateAccount(accountId, {
870
+ isActive: newStatus
871
+ })
872
+
873
+ if (updateResult.success) {
874
+ toggleSpinner.succeed('账户状态切换成功')
875
+ console.log(styles.success(`新状态: ${newStatus ? '🟢 活跃' : '🔴 停用'}`))
876
+ } else {
877
+ throw new Error(updateResult.error)
878
+ }
879
+ } catch (error) {
880
+ spinner.fail('切换账户状态失败')
881
+ console.error(styles.error(error.message))
882
+ }
883
+ }
884
+
885
+ async function editBedrockAccount() {
886
+ const spinner = ora('正在获取 Bedrock 账户...').start()
887
+
888
+ try {
889
+ const result = await bedrockAccountService.getAllAccounts()
890
+ if (!result.success || result.data.length === 0) {
891
+ spinner.fail('没有可编辑的 Bedrock 账户')
892
+ return
893
+ }
894
+
895
+ spinner.succeed('账户列表获取成功')
896
+
897
+ const choices = result.data.map((account) => ({
898
+ name: `${account.name} (${account.region})`,
899
+ value: account.id
900
+ }))
901
+
902
+ const { accountId } = await inquirer.prompt([
903
+ {
904
+ type: 'list',
905
+ name: 'accountId',
906
+ message: '选择要编辑的账户:',
907
+ choices
908
+ }
909
+ ])
910
+
911
+ const accountResult = await bedrockAccountService.getAccount(accountId)
912
+ if (!accountResult.success) {
913
+ throw new Error('无法获取账户信息')
914
+ }
915
+
916
+ const account = accountResult.data
917
+
918
+ const updates = await inquirer.prompt([
919
+ {
920
+ type: 'input',
921
+ name: 'name',
922
+ message: '账户名称:',
923
+ default: account.name
924
+ },
925
+ {
926
+ type: 'input',
927
+ name: 'description',
928
+ message: '描述:',
929
+ default: account.description
930
+ },
931
+ {
932
+ type: 'number',
933
+ name: 'priority',
934
+ message: '优先级 (1-100):',
935
+ default: account.priority,
936
+ validate: (input) => input >= 1 && input <= 100
937
+ }
938
+ ])
939
+
940
+ const updateSpinner = ora('正在更新账户...').start()
941
+
942
+ const updateResult = await bedrockAccountService.updateAccount(accountId, updates)
943
+
944
+ if (updateResult.success) {
945
+ updateSpinner.succeed('账户更新成功')
946
+ } else {
947
+ throw new Error(updateResult.error)
948
+ }
949
+ } catch (error) {
950
+ spinner.fail('编辑账户失败')
951
+ console.error(styles.error(error.message))
952
+ }
953
+ }
954
+
955
+ async function deleteBedrockAccount() {
956
+ const spinner = ora('正在获取 Bedrock 账户...').start()
957
+
958
+ try {
959
+ const result = await bedrockAccountService.getAllAccounts()
960
+ if (!result.success || result.data.length === 0) {
961
+ spinner.fail('没有可删除的 Bedrock 账户')
962
+ return
963
+ }
964
+
965
+ spinner.succeed('账户列表获取成功')
966
+
967
+ const choices = result.data.map((account) => ({
968
+ name: `${account.name} (${account.region})`,
969
+ value: { id: account.id, name: account.name }
970
+ }))
971
+
972
+ const { account } = await inquirer.prompt([
973
+ {
974
+ type: 'list',
975
+ name: 'account',
976
+ message: '选择要删除的账户:',
977
+ choices
978
+ }
979
+ ])
980
+
981
+ const { confirm } = await inquirer.prompt([
982
+ {
983
+ type: 'confirm',
984
+ name: 'confirm',
985
+ message: `确定要删除账户 "${account.name}" 吗?此操作无法撤销!`,
986
+ default: false
987
+ }
988
+ ])
989
+
990
+ if (!confirm) {
991
+ console.log(styles.info('已取消删除'))
992
+ return
993
+ }
994
+
995
+ const deleteSpinner = ora('正在删除账户...').start()
996
+
997
+ const deleteResult = await bedrockAccountService.deleteAccount(account.id)
998
+
999
+ if (deleteResult.success) {
1000
+ deleteSpinner.succeed('账户删除成功')
1001
+ } else {
1002
+ throw new Error(deleteResult.error)
1003
+ }
1004
+ } catch (error) {
1005
+ spinner.fail('删除账户失败')
1006
+ console.error(styles.error(error.message))
1007
+ }
1008
+ }
1009
+
1010
+ // 程序信息
1011
+ program.name('claude-relay-cli').description('Claude Relay Service 命令行管理工具').version('1.0.0')
1012
+
1013
+ // 解析命令行参数
1014
+ program.parse()
1015
+
1016
+ // 如果没有提供命令,显示帮助
1017
+ if (!process.argv.slice(2).length) {
1018
+ console.log(styles.title('🚀 Claude Relay Service CLI\n'))
1019
+ console.log('使用以下命令管理服务:\n')
1020
+ console.log(' claude-relay-cli admin - 创建初始管理员账户')
1021
+ console.log(' claude-relay-cli keys - API Key 管理(查看/修改过期时间/续期/删除)')
1022
+ console.log(' claude-relay-cli bedrock - Bedrock 账户管理(创建/查看/编辑/测试/删除)')
1023
+ console.log(' claude-relay-cli status - 查看系统状态')
1024
+ console.log('\n使用 --help 查看详细帮助信息')
1025
+ }
config/config.example.js ADDED
@@ -0,0 +1,185 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const path = require('path')
2
+ require('dotenv').config()
3
+
4
+ const config = {
5
+ // 🌐 服务器配置
6
+ server: {
7
+ port: parseInt(process.env.PORT) || 3000,
8
+ host: process.env.HOST || '0.0.0.0',
9
+ nodeEnv: process.env.NODE_ENV || 'development',
10
+ trustProxy: process.env.TRUST_PROXY === 'true'
11
+ },
12
+
13
+ // 🔐 安全配置
14
+ security: {
15
+ jwtSecret: process.env.JWT_SECRET || 'CHANGE-THIS-JWT-SECRET-IN-PRODUCTION',
16
+ adminSessionTimeout: parseInt(process.env.ADMIN_SESSION_TIMEOUT) || 86400000, // 24小时
17
+ apiKeyPrefix: process.env.API_KEY_PREFIX || 'cr_',
18
+ encryptionKey: process.env.ENCRYPTION_KEY || 'CHANGE-THIS-32-CHARACTER-KEY-NOW'
19
+ },
20
+
21
+ // 📊 Redis配置
22
+ redis: {
23
+ host: process.env.REDIS_HOST || '127.0.0.1',
24
+ port: parseInt(process.env.REDIS_PORT) || 6379,
25
+ password: process.env.REDIS_PASSWORD || '',
26
+ db: parseInt(process.env.REDIS_DB) || 0,
27
+ connectTimeout: 10000,
28
+ commandTimeout: 5000,
29
+ retryDelayOnFailover: 100,
30
+ maxRetriesPerRequest: 3,
31
+ lazyConnect: true,
32
+ enableTLS: process.env.REDIS_ENABLE_TLS === 'true'
33
+ },
34
+
35
+ // 🔗 会话管理配置
36
+ session: {
37
+ // 粘性会话TTL配置(小时),默认1小时
38
+ stickyTtlHours: parseFloat(process.env.STICKY_SESSION_TTL_HOURS) || 1,
39
+ // 续期阈值(分钟),默认0分钟(不续期)
40
+ renewalThresholdMinutes: parseInt(process.env.STICKY_SESSION_RENEWAL_THRESHOLD_MINUTES) || 0
41
+ },
42
+
43
+ // 🎯 Claude API配置
44
+ claude: {
45
+ apiUrl: process.env.CLAUDE_API_URL || 'https://api.anthropic.com/v1/messages',
46
+ apiVersion: process.env.CLAUDE_API_VERSION || '2023-06-01',
47
+ betaHeader:
48
+ process.env.CLAUDE_BETA_HEADER ||
49
+ 'claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14',
50
+ overloadHandling: {
51
+ enabled: (() => {
52
+ const minutes = parseInt(process.env.CLAUDE_OVERLOAD_HANDLING_MINUTES) || 0
53
+ // 验证配置值:限制在0-1440分钟(24小时)内
54
+ return Math.max(0, Math.min(minutes, 1440))
55
+ })()
56
+ }
57
+ },
58
+
59
+ // ☁️ Bedrock API配置
60
+ bedrock: {
61
+ enabled: process.env.CLAUDE_CODE_USE_BEDROCK === '1',
62
+ defaultRegion: process.env.AWS_REGION || 'us-east-1',
63
+ smallFastModelRegion: process.env.ANTHROPIC_SMALL_FAST_MODEL_AWS_REGION,
64
+ defaultModel: process.env.ANTHROPIC_MODEL || 'us.anthropic.claude-sonnet-4-20250514-v1:0',
65
+ smallFastModel:
66
+ process.env.ANTHROPIC_SMALL_FAST_MODEL || 'us.anthropic.claude-3-5-haiku-20241022-v1:0',
67
+ maxOutputTokens: parseInt(process.env.CLAUDE_CODE_MAX_OUTPUT_TOKENS) || 4096,
68
+ maxThinkingTokens: parseInt(process.env.MAX_THINKING_TOKENS) || 1024,
69
+ enablePromptCaching: process.env.DISABLE_PROMPT_CACHING !== '1'
70
+ },
71
+
72
+ // 🌐 代理配置
73
+ proxy: {
74
+ timeout: parseInt(process.env.DEFAULT_PROXY_TIMEOUT) || 600000, // 10分钟
75
+ maxRetries: parseInt(process.env.MAX_PROXY_RETRIES) || 3,
76
+ // IP协议族配置:true=IPv4, false=IPv6, 默认IPv4(兼容性更好)
77
+ useIPv4: process.env.PROXY_USE_IPV4 !== 'false' // 默认 true,只有明确设置为 'false' 才使用 IPv6
78
+ },
79
+
80
+ // ⏱️ 请求超时配置
81
+ requestTimeout: parseInt(process.env.REQUEST_TIMEOUT) || 600000, // 默认 10 分钟
82
+
83
+ // 📈 使用限制
84
+ limits: {
85
+ defaultTokenLimit: parseInt(process.env.DEFAULT_TOKEN_LIMIT) || 1000000
86
+ },
87
+
88
+ // 📝 日志配置
89
+ logging: {
90
+ level: process.env.LOG_LEVEL || 'info',
91
+ dirname: path.join(__dirname, '..', 'logs'),
92
+ maxSize: process.env.LOG_MAX_SIZE || '10m',
93
+ maxFiles: parseInt(process.env.LOG_MAX_FILES) || 5
94
+ },
95
+
96
+ // 🔧 系统配置
97
+ system: {
98
+ cleanupInterval: parseInt(process.env.CLEANUP_INTERVAL) || 3600000, // 1小时
99
+ tokenUsageRetention: parseInt(process.env.TOKEN_USAGE_RETENTION) || 2592000000, // 30天
100
+ healthCheckInterval: parseInt(process.env.HEALTH_CHECK_INTERVAL) || 60000, // 1分钟
101
+ timezone: process.env.SYSTEM_TIMEZONE || 'Asia/Shanghai', // 默认UTC+8(中国时区)
102
+ timezoneOffset: parseInt(process.env.TIMEZONE_OFFSET) || 8 // UTC偏移小时数,默认+8
103
+ },
104
+
105
+ // 🎨 Web界面配置
106
+ web: {
107
+ title: process.env.WEB_TITLE || 'Claude Relay Service',
108
+ description:
109
+ process.env.WEB_DESCRIPTION ||
110
+ 'Multi-account Claude API relay service with beautiful management interface',
111
+ logoUrl: process.env.WEB_LOGO_URL || '/assets/logo.png',
112
+ enableCors: process.env.ENABLE_CORS === 'true',
113
+ sessionSecret: process.env.WEB_SESSION_SECRET || 'CHANGE-THIS-SESSION-SECRET'
114
+ },
115
+
116
+ // 🔐 LDAP 认证配置
117
+ ldap: {
118
+ enabled: process.env.LDAP_ENABLED === 'true',
119
+ server: {
120
+ url: process.env.LDAP_URL || 'ldap://localhost:389',
121
+ bindDN: process.env.LDAP_BIND_DN || 'cn=admin,dc=example,dc=com',
122
+ bindCredentials: process.env.LDAP_BIND_PASSWORD || 'admin',
123
+ searchBase: process.env.LDAP_SEARCH_BASE || 'dc=example,dc=com',
124
+ searchFilter: process.env.LDAP_SEARCH_FILTER || '(uid={{username}})',
125
+ searchAttributes: process.env.LDAP_SEARCH_ATTRIBUTES
126
+ ? process.env.LDAP_SEARCH_ATTRIBUTES.split(',')
127
+ : ['dn', 'uid', 'cn', 'mail', 'givenName', 'sn'],
128
+ timeout: parseInt(process.env.LDAP_TIMEOUT) || 5000,
129
+ connectTimeout: parseInt(process.env.LDAP_CONNECT_TIMEOUT) || 10000,
130
+ // TLS/SSL 配置
131
+ tls: {
132
+ // 是否忽略证书错误 (用于自签名证书)
133
+ rejectUnauthorized: process.env.LDAP_TLS_REJECT_UNAUTHORIZED !== 'false', // 默认验证证书,设置为false则忽略
134
+ // CA证书文件路径 (可选,用于自定义CA证书)
135
+ ca: process.env.LDAP_TLS_CA_FILE
136
+ ? require('fs').readFileSync(process.env.LDAP_TLS_CA_FILE)
137
+ : undefined,
138
+ // 客户端证书文件路径 (可选,用于双向认证)
139
+ cert: process.env.LDAP_TLS_CERT_FILE
140
+ ? require('fs').readFileSync(process.env.LDAP_TLS_CERT_FILE)
141
+ : undefined,
142
+ // 客户端私钥文件路径 (可选,用于双向认证)
143
+ key: process.env.LDAP_TLS_KEY_FILE
144
+ ? require('fs').readFileSync(process.env.LDAP_TLS_KEY_FILE)
145
+ : undefined,
146
+ // 服务器名称 (用于SNI,可选)
147
+ servername: process.env.LDAP_TLS_SERVERNAME || undefined
148
+ }
149
+ },
150
+ userMapping: {
151
+ username: process.env.LDAP_USER_ATTR_USERNAME || 'uid',
152
+ displayName: process.env.LDAP_USER_ATTR_DISPLAY_NAME || 'cn',
153
+ email: process.env.LDAP_USER_ATTR_EMAIL || 'mail',
154
+ firstName: process.env.LDAP_USER_ATTR_FIRST_NAME || 'givenName',
155
+ lastName: process.env.LDAP_USER_ATTR_LAST_NAME || 'sn'
156
+ }
157
+ },
158
+
159
+ // 👥 用户管理配置
160
+ userManagement: {
161
+ enabled: process.env.USER_MANAGEMENT_ENABLED === 'true',
162
+ defaultUserRole: process.env.DEFAULT_USER_ROLE || 'user',
163
+ userSessionTimeout: parseInt(process.env.USER_SESSION_TIMEOUT) || 86400000, // 24小时
164
+ maxApiKeysPerUser: parseInt(process.env.MAX_API_KEYS_PER_USER) || 1,
165
+ allowUserDeleteApiKeys: process.env.ALLOW_USER_DELETE_API_KEYS === 'true' // 默认不允许用户删除自己的API Keys
166
+ },
167
+
168
+ // 📢 Webhook通知配置
169
+ webhook: {
170
+ enabled: process.env.WEBHOOK_ENABLED !== 'false', // 默认启用
171
+ urls: process.env.WEBHOOK_URLS
172
+ ? process.env.WEBHOOK_URLS.split(',').map((url) => url.trim())
173
+ : [],
174
+ timeout: parseInt(process.env.WEBHOOK_TIMEOUT) || 10000, // 10秒超时
175
+ retries: parseInt(process.env.WEBHOOK_RETRIES) || 3 // 重试3次
176
+ },
177
+
178
+ // 🛠️ 开发配置
179
+ development: {
180
+ debug: process.env.DEBUG === 'true',
181
+ hotReload: process.env.HOT_RELOAD === 'true'
182
+ }
183
+ }
184
+
185
+ module.exports = config
docker-compose.yml ADDED
@@ -0,0 +1,165 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ version: '3.8'
2
+
3
+ # Claude Relay Service Docker Compose 配置
4
+ # 所有配置通过环境变量设置,无需映射 .env 文件
5
+
6
+ services:
7
+ # 🚀 Claude Relay Service
8
+ claude-relay:
9
+ build: .
10
+ image: weishaw/claude-relay-service:latest
11
+ restart: unless-stopped
12
+ ports:
13
+ # 绑定地址:生产环境建议使用反向代理,设置 BIND_HOST=127.0.0.1
14
+ - "${BIND_HOST:-0.0.0.0}:${PORT:-3000}:3000"
15
+ volumes:
16
+ - ./logs:/app/logs
17
+ - ./data:/app/data
18
+ environment:
19
+ # 🌐 服务器配置
20
+ - NODE_ENV=production
21
+ - PORT=3000
22
+ - HOST=0.0.0.0
23
+
24
+ # 🔐 安全配置(必填)
25
+ - JWT_SECRET=${JWT_SECRET} # 必填:至少32字符的随机字符串
26
+ - ENCRYPTION_KEY=${ENCRYPTION_KEY} # 必填:32字符的加密密钥
27
+ - ADMIN_SESSION_TIMEOUT=${ADMIN_SESSION_TIMEOUT:-86400000}
28
+ - API_KEY_PREFIX=${API_KEY_PREFIX:-cr_}
29
+
30
+ # 👤 管理员凭据(可选)
31
+ - ADMIN_USERNAME=${ADMIN_USERNAME:-}
32
+ - ADMIN_PASSWORD=${ADMIN_PASSWORD:-}
33
+
34
+ # 📊 Redis 配置
35
+ - REDIS_HOST=redis
36
+ - REDIS_PORT=6379
37
+ - REDIS_PASSWORD=${REDIS_PASSWORD:-}
38
+ - REDIS_DB=${REDIS_DB:-0}
39
+ - REDIS_ENABLE_TLS=${REDIS_ENABLE_TLS:-}
40
+
41
+ # 🎯 Claude API 配置
42
+ - CLAUDE_API_URL=${CLAUDE_API_URL:-https://api.anthropic.com/v1/messages}
43
+ - CLAUDE_API_VERSION=${CLAUDE_API_VERSION:-2023-06-01}
44
+ - CLAUDE_BETA_HEADER=${CLAUDE_BETA_HEADER:-claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14}
45
+
46
+ # 🌐 代理配置
47
+ - DEFAULT_PROXY_TIMEOUT=${DEFAULT_PROXY_TIMEOUT:-60000}
48
+ - MAX_PROXY_RETRIES=${MAX_PROXY_RETRIES:-3}
49
+
50
+ # 📈 使用限制
51
+ - DEFAULT_TOKEN_LIMIT=${DEFAULT_TOKEN_LIMIT:-1000000}
52
+
53
+ # 📝 日志配置
54
+ - LOG_LEVEL=${LOG_LEVEL:-info}
55
+ - LOG_MAX_SIZE=${LOG_MAX_SIZE:-10m}
56
+ - LOG_MAX_FILES=${LOG_MAX_FILES:-5}
57
+
58
+ # 🔧 系统配置
59
+ - CLEANUP_INTERVAL=${CLEANUP_INTERVAL:-3600000}
60
+ - TOKEN_USAGE_RETENTION=${TOKEN_USAGE_RETENTION:-2592000000}
61
+ - HEALTH_CHECK_INTERVAL=${HEALTH_CHECK_INTERVAL:-60000}
62
+ - TIMEZONE_OFFSET=${TIMEZONE_OFFSET:-8}
63
+
64
+ # 🎨 Web 界面配置
65
+ - WEB_TITLE=${WEB_TITLE:-Claude Relay Service}
66
+ - WEB_DESCRIPTION=${WEB_DESCRIPTION:-Multi-account Claude API relay service}
67
+ - WEB_LOGO_URL=${WEB_LOGO_URL:-/assets/logo.png}
68
+
69
+ # 🛠️ 开发配置
70
+ - DEBUG=${DEBUG:-false}
71
+ - ENABLE_CORS=${ENABLE_CORS:-true}
72
+ - TRUST_PROXY=${TRUST_PROXY:-true}
73
+ depends_on:
74
+ - redis
75
+ networks:
76
+ - claude-relay-network
77
+ healthcheck:
78
+ test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
79
+ interval: 30s
80
+ timeout: 10s
81
+ retries: 3
82
+
83
+ # 📊 Redis Database
84
+ redis:
85
+ image: redis:7-alpine
86
+ restart: unless-stopped
87
+ # 仅在容器网络内部暴露端口,不映射到主机
88
+ expose:
89
+ - "6379"
90
+ # 注意:如需本地调试访问,可取消下行注释(生产环境禁用)
91
+ # ports:
92
+ # - "127.0.0.1:${REDIS_PORT:-6379}:6379"
93
+ volumes:
94
+ - ./redis_data:/data
95
+ command: redis-server --save 60 1 --appendonly yes --appendfsync everysec
96
+ networks:
97
+ - claude-relay-network
98
+ healthcheck:
99
+ test: ["CMD", "redis-cli", "ping"]
100
+ interval: 30s
101
+ timeout: 10s
102
+ retries: 3
103
+
104
+ # 📈 Redis Monitoring (Optional)
105
+ redis-commander:
106
+ image: rediscommander/redis-commander:latest
107
+ restart: unless-stopped
108
+ ports:
109
+ - "127.0.0.1:${REDIS_WEB_PORT:-8081}:8081"
110
+ environment:
111
+ - REDIS_HOSTS=local:redis:6379
112
+ depends_on:
113
+ - redis
114
+ networks:
115
+ - claude-relay-network
116
+ profiles:
117
+ - monitoring
118
+
119
+ # 📊 Application Monitoring (Optional)
120
+ prometheus:
121
+ image: prom/prometheus:latest
122
+ restart: unless-stopped
123
+ ports:
124
+ - "127.0.0.1:${PROMETHEUS_PORT:-9090}:9090"
125
+ volumes:
126
+ - ./config/prometheus.yml:/etc/prometheus/prometheus.yml:ro
127
+ - prometheus_data:/prometheus
128
+ command:
129
+ - '--config.file=/etc/prometheus/prometheus.yml'
130
+ - '--storage.tsdb.path=/prometheus'
131
+ - '--web.console.libraries=/etc/prometheus/console_libraries'
132
+ - '--web.console.templates=/etc/prometheus/consoles'
133
+ - '--web.enable-lifecycle'
134
+ networks:
135
+ - claude-relay-network
136
+ profiles:
137
+ - monitoring
138
+
139
+ # 📈 Grafana Dashboard (Optional)
140
+ grafana:
141
+ image: grafana/grafana:latest
142
+ restart: unless-stopped
143
+ ports:
144
+ - "127.0.0.1:${GRAFANA_PORT:-3001}:3000"
145
+ environment:
146
+ - GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_ADMIN_PASSWORD:-admin123}
147
+ volumes:
148
+ - grafana_data:/var/lib/grafana
149
+ - ./config/grafana:/etc/grafana/provisioning
150
+ depends_on:
151
+ - prometheus
152
+ networks:
153
+ - claude-relay-network
154
+ profiles:
155
+ - monitoring
156
+
157
+ volumes:
158
+ prometheus_data:
159
+ driver: local
160
+ grafana_data:
161
+ driver: local
162
+
163
+ networks:
164
+ claude-relay-network:
165
+ driver: bridge
docker-entrypoint.sh ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/bin/sh
2
+ set -e
3
+
4
+ echo "🚀 Claude Relay Service 启动中..."
5
+
6
+ # 检查关键环境变量
7
+ if [ -z "$JWT_SECRET" ]; then
8
+ echo "❌ 错误: JWT_SECRET 环境变量未设置"
9
+ echo " 请在 docker-compose.yml 中设置 JWT_SECRET"
10
+ echo " 例如: JWT_SECRET=your-random-secret-key-at-least-32-chars"
11
+ exit 1
12
+ fi
13
+
14
+ if [ -z "$ENCRYPTION_KEY" ]; then
15
+ echo "❌ 错误: ENCRYPTION_KEY 环境变量未设置"
16
+ echo " 请在 docker-compose.yml 中设置 ENCRYPTION_KEY"
17
+ echo " 例如: ENCRYPTION_KEY=your-32-character-encryption-key"
18
+ exit 1
19
+ fi
20
+
21
+ # 检查并复制配置文件
22
+ if [ ! -f "/app/config/config.js" ]; then
23
+ echo "📋 检测到 config.js 不存在,从模板创建..."
24
+ if [ -f "/app/config/config.example.js" ]; then
25
+ cp /app/config/config.example.js /app/config/config.js
26
+ echo "✅ config.js 已创建"
27
+ else
28
+ echo "❌ 错误: config.example.js 不存在"
29
+ exit 1
30
+ fi
31
+ fi
32
+
33
+ # 显示配置信息
34
+ echo "✅ 环境配置已就绪"
35
+ echo " JWT_SECRET: [已设置]"
36
+ echo " ENCRYPTION_KEY: [已设置]"
37
+ echo " REDIS_HOST: ${REDIS_HOST:-localhost}"
38
+ echo " PORT: ${PORT:-3000}"
39
+
40
+ # 检查是否需要初始化
41
+ if [ ! -f "/app/data/init.json" ]; then
42
+ echo "📋 首次启动,执行初始化设置..."
43
+
44
+ # 如果设置了环境变量,显示提示
45
+ if [ -n "$ADMIN_USERNAME" ] || [ -n "$ADMIN_PASSWORD" ]; then
46
+ echo "📌 检测到预设的管理员凭据"
47
+ fi
48
+
49
+ # 执行初始化脚本
50
+ node /app/scripts/setup.js
51
+
52
+ echo "✅ 初始化完成"
53
+ else
54
+ echo "✅ 检测到已有配置,跳过初始化"
55
+
56
+ # 如果 init.json 存在但环境变量也设置了,显示警告
57
+ if [ -n "$ADMIN_USERNAME" ] || [ -n "$ADMIN_PASSWORD" ]; then
58
+ echo "⚠️ 警告: 检测到环境变量 ADMIN_USERNAME/ADMIN_PASSWORD,但系统已初始化"
59
+ echo " 如需使用新凭据,请删除 data/init.json 文件后重启容器"
60
+ fi
61
+ fi
62
+
63
+ # 启动应用
64
+ echo "🌐 启动 Claude Relay Service..."
65
+ exec "$@"
nodemon.json ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ {
2
+ "watch": ["src"],
3
+ "ext": "js,json",
4
+ "exec": "npm run lint && node src/app.js"
5
+ }
package-lock.json ADDED
The diff for this file is too large to render. See raw diff
 
package.json ADDED
@@ -0,0 +1,101 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "claude-relay-service",
3
+ "version": "1.0.0",
4
+ "description": "Claude Code API relay service with multi-account management, OpenAI compatibility, and API key authentication",
5
+ "main": "src/app.js",
6
+ "scripts": {
7
+ "start": "npm run lint && node src/app.js",
8
+ "dev": "nodemon",
9
+ "build:web": "cd web/admin-spa && npm run build",
10
+ "install:web": "cd web/admin-spa && npm install",
11
+ "update:pricing": "node scripts/update-model-pricing.js",
12
+ "setup": "node scripts/setup.js",
13
+ "cli": "node cli/index.js",
14
+ "init:costs": "node src/cli/initCosts.js",
15
+ "service": "node scripts/manage.js",
16
+ "service:start": "node scripts/manage.js start",
17
+ "service:start:daemon": "node scripts/manage.js start -d",
18
+ "service:start:d": "node scripts/manage.js start -d",
19
+ "service:daemon": "node scripts/manage.js start -d",
20
+ "service:stop": "node scripts/manage.js stop",
21
+ "service:restart": "node scripts/manage.js restart",
22
+ "service:restart:daemon": "node scripts/manage.js restart -d",
23
+ "service:logs:follow": "node scripts/manage.js logs -f",
24
+ "service:restart:d": "node scripts/manage.js restart -d",
25
+ "service:status": "node scripts/manage.js status",
26
+ "service:logs": "node scripts/manage.js logs",
27
+ "monitor": "bash scripts/monitor-enhanced.sh",
28
+ "status": "bash scripts/status-unified.sh",
29
+ "status:detail": "bash scripts/status-unified.sh --detail",
30
+ "test": "jest",
31
+ "lint": "eslint src/**/*.js cli/**/*.js scripts/**/*.js --fix",
32
+ "lint:check": "eslint src/**/*.js cli/**/*.js scripts/**/*.js",
33
+ "format": "prettier --write \"src/**/*.js\" \"cli/**/*.js\" \"scripts/**/*.js\"",
34
+ "format:check": "prettier --check \"src/**/*.js\" \"cli/**/*.js\" \"scripts/**/*.js\"",
35
+ "docker:build": "docker build -t claude-relay-service .",
36
+ "docker:up": "docker-compose up -d",
37
+ "docker:down": "docker-compose down",
38
+ "migrate:apikey-expiry": "node scripts/migrate-apikey-expiry.js",
39
+ "migrate:apikey-expiry:dry": "node scripts/migrate-apikey-expiry.js --dry-run",
40
+ "migrate:fix-usage-stats": "node scripts/fix-usage-stats.js",
41
+ "data:export": "node scripts/data-transfer.js export",
42
+ "data:import": "node scripts/data-transfer.js import",
43
+ "data:export:sanitized": "node scripts/data-transfer.js export --sanitize",
44
+ "data:export:enhanced": "node scripts/data-transfer-enhanced.js export",
45
+ "data:export:encrypted": "node scripts/data-transfer-enhanced.js export --decrypt=false",
46
+ "data:import:enhanced": "node scripts/data-transfer-enhanced.js import",
47
+ "data:debug": "node scripts/debug-redis-keys.js",
48
+ "test:pricing-fallback": "node scripts/test-pricing-fallback.js"
49
+ },
50
+ "dependencies": {
51
+ "@aws-sdk/client-bedrock-runtime": "^3.861.0",
52
+ "@aws-sdk/credential-providers": "^3.859.0",
53
+ "axios": "^1.6.0",
54
+ "bcryptjs": "^2.4.3",
55
+ "chalk": "^4.1.2",
56
+ "commander": "^11.1.0",
57
+ "compression": "^1.7.4",
58
+ "cors": "^2.8.5",
59
+ "dotenv": "^16.3.1",
60
+ "express": "^4.18.2",
61
+ "google-auth-library": "^10.1.0",
62
+ "helmet": "^7.1.0",
63
+ "https-proxy-agent": "^7.0.2",
64
+ "inquirer": "^8.2.6",
65
+ "ioredis": "^5.3.2",
66
+ "ldapjs": "^3.0.7",
67
+ "morgan": "^1.10.0",
68
+ "nodemailer": "^7.0.6",
69
+ "ora": "^5.4.1",
70
+ "rate-limiter-flexible": "^5.0.5",
71
+ "socks-proxy-agent": "^8.0.2",
72
+ "string-similarity": "^4.0.4",
73
+ "table": "^6.8.1",
74
+ "uuid": "^9.0.1",
75
+ "winston": "^3.11.0",
76
+ "winston-daily-rotate-file": "^4.7.1"
77
+ },
78
+ "devDependencies": {
79
+ "@types/node": "^20.8.9",
80
+ "eslint": "^8.53.0",
81
+ "eslint-config-prettier": "^10.1.8",
82
+ "eslint-plugin-prettier": "^5.5.4",
83
+ "jest": "^29.7.0",
84
+ "nodemon": "^3.0.1",
85
+ "prettier": "^3.6.2",
86
+ "supertest": "^6.3.3"
87
+ },
88
+ "engines": {
89
+ "node": ">=18.0.0"
90
+ },
91
+ "keywords": [
92
+ "claude",
93
+ "api",
94
+ "proxy",
95
+ "relay",
96
+ "claude-code",
97
+ "anthropic"
98
+ ],
99
+ "author": "Claude Relay Service",
100
+ "license": "MIT"
101
+ }
resources/model-pricing/README.md ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Model Pricing Data
2
+
3
+ This directory contains a local copy of the LiteLLM model pricing data as a fallback mechanism.
4
+
5
+ ## Source
6
+ The original file is maintained by the LiteLLM project:
7
+ - Repository: https://github.com/BerriAI/litellm
8
+ - File: https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json
9
+
10
+ ## Purpose
11
+ This local copy serves as a fallback when the remote file cannot be downloaded due to:
12
+ - Network restrictions
13
+ - Firewall rules
14
+ - DNS resolution issues
15
+ - GitHub being blocked in certain regions
16
+ - Docker container network limitations
17
+
18
+ ## Update Process
19
+ The pricingService will:
20
+ 1. First attempt to download the latest version from GitHub
21
+ 2. If download fails, use this local copy as fallback
22
+ 3. Log a warning when using the fallback file
23
+
24
+ ## Manual Update
25
+ To manually update this file with the latest pricing data:
26
+ ```bash
27
+ curl -s https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json -o model_prices_and_context_window.json
28
+ ```
29
+
30
+ ## File Format
31
+ The file contains JSON data with model pricing information including:
32
+ - Model names and identifiers
33
+ - Input/output token costs
34
+ - Context window sizes
35
+ - Model capabilities
36
+
37
+ Last updated: 2025-08-10
resources/model-pricing/model_prices_and_context_window.json ADDED
The diff for this file is too large to render. See raw diff
 
scripts/analyze-log-sessions.js ADDED
@@ -0,0 +1,606 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * 从日志文件分析Claude账户请求时间的CLI工具
5
+ * 用于恢复会话窗口数据
6
+ */
7
+
8
+ const fs = require('fs')
9
+ const path = require('path')
10
+ const readline = require('readline')
11
+ const zlib = require('zlib')
12
+ const redis = require('../src/models/redis')
13
+
14
+ class LogSessionAnalyzer {
15
+ constructor() {
16
+ // 更新正则表达式以匹配实际的日志格式
17
+ this.accountUsagePattern =
18
+ /🎯 Using sticky session shared account: (.+?) \(([a-f0-9-]{36})\) for session ([a-f0-9]+)/
19
+ this.processingPattern =
20
+ /📡 Processing streaming API request with usage capture for key: (.+?), account: ([a-f0-9-]{36}), session: ([a-f0-9]+)/
21
+ this.completedPattern = /🔗 ✅ Request completed in (\d+)ms for key: (.+)/
22
+ this.usageRecordedPattern =
23
+ /🔗 📊 Stream usage recorded \(real\) - Model: (.+?), Input: (\d+), Output: (\d+), Cache Create: (\d+), Cache Read: (\d+), Total: (\d+) tokens/
24
+ this.timestampPattern = /\[(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})\]/
25
+ this.accounts = new Map()
26
+ this.requestHistory = []
27
+ this.sessions = new Map() // 记录会话信息
28
+ }
29
+
30
+ // 解析时间戳
31
+ parseTimestamp(line) {
32
+ const match = line.match(this.timestampPattern)
33
+ if (match) {
34
+ return new Date(match[1])
35
+ }
36
+ return null
37
+ }
38
+
39
+ // 分析单个日志文件
40
+ async analyzeLogFile(filePath) {
41
+ console.log(`📖 分析日志文件: ${filePath}`)
42
+
43
+ let fileStream = fs.createReadStream(filePath)
44
+
45
+ // 如果是gz文件,需要先解压
46
+ if (filePath.endsWith('.gz')) {
47
+ console.log(' 🗜️ 检测到gz压缩文件,正在解压...')
48
+ fileStream = fileStream.pipe(zlib.createGunzip())
49
+ }
50
+
51
+ const rl = readline.createInterface({
52
+ input: fileStream,
53
+ crlfDelay: Infinity
54
+ })
55
+
56
+ let lineCount = 0
57
+ let requestCount = 0
58
+ let usageCount = 0
59
+
60
+ for await (const line of rl) {
61
+ lineCount++
62
+
63
+ // 解析时间戳
64
+ const timestamp = this.parseTimestamp(line)
65
+ if (!timestamp) {
66
+ continue
67
+ }
68
+
69
+ // 查找账户使用记录
70
+ const accountUsageMatch = line.match(this.accountUsagePattern)
71
+ if (accountUsageMatch) {
72
+ const accountName = accountUsageMatch[1]
73
+ const accountId = accountUsageMatch[2]
74
+ const sessionId = accountUsageMatch[3]
75
+
76
+ if (!this.accounts.has(accountId)) {
77
+ this.accounts.set(accountId, {
78
+ accountId,
79
+ accountName,
80
+ requests: [],
81
+ firstRequest: timestamp,
82
+ lastRequest: timestamp,
83
+ totalRequests: 0,
84
+ sessions: new Set()
85
+ })
86
+ }
87
+
88
+ const account = this.accounts.get(accountId)
89
+ account.sessions.add(sessionId)
90
+
91
+ if (timestamp < account.firstRequest) {
92
+ account.firstRequest = timestamp
93
+ }
94
+ if (timestamp > account.lastRequest) {
95
+ account.lastRequest = timestamp
96
+ }
97
+ }
98
+
99
+ // 查找请求处理记录
100
+ const processingMatch = line.match(this.processingPattern)
101
+ if (processingMatch) {
102
+ const apiKeyName = processingMatch[1]
103
+ const accountId = processingMatch[2]
104
+ const sessionId = processingMatch[3]
105
+
106
+ if (!this.accounts.has(accountId)) {
107
+ this.accounts.set(accountId, {
108
+ accountId,
109
+ accountName: 'Unknown',
110
+ requests: [],
111
+ firstRequest: timestamp,
112
+ lastRequest: timestamp,
113
+ totalRequests: 0,
114
+ sessions: new Set()
115
+ })
116
+ }
117
+
118
+ const account = this.accounts.get(accountId)
119
+ account.requests.push({
120
+ timestamp,
121
+ apiKeyName,
122
+ sessionId,
123
+ type: 'processing'
124
+ })
125
+
126
+ account.sessions.add(sessionId)
127
+ account.totalRequests++
128
+ requestCount++
129
+
130
+ if (timestamp > account.lastRequest) {
131
+ account.lastRequest = timestamp
132
+ }
133
+
134
+ // 记录到全局请求历史
135
+ this.requestHistory.push({
136
+ timestamp,
137
+ accountId,
138
+ apiKeyName,
139
+ sessionId,
140
+ type: 'processing'
141
+ })
142
+ }
143
+
144
+ // 查找请求完成记录
145
+ const completedMatch = line.match(this.completedPattern)
146
+ if (completedMatch) {
147
+ const duration = parseInt(completedMatch[1])
148
+ const apiKeyName = completedMatch[2]
149
+
150
+ // 记录到全局请求历史
151
+ this.requestHistory.push({
152
+ timestamp,
153
+ apiKeyName,
154
+ duration,
155
+ type: 'completed'
156
+ })
157
+ }
158
+
159
+ // 查找使用统计记录
160
+ const usageMatch = line.match(this.usageRecordedPattern)
161
+ if (usageMatch) {
162
+ const model = usageMatch[1]
163
+ const inputTokens = parseInt(usageMatch[2])
164
+ const outputTokens = parseInt(usageMatch[3])
165
+ const cacheCreateTokens = parseInt(usageMatch[4])
166
+ const cacheReadTokens = parseInt(usageMatch[5])
167
+ const totalTokens = parseInt(usageMatch[6])
168
+
169
+ usageCount++
170
+
171
+ // 记录到全局请求历史
172
+ this.requestHistory.push({
173
+ timestamp,
174
+ type: 'usage',
175
+ model,
176
+ inputTokens,
177
+ outputTokens,
178
+ cacheCreateTokens,
179
+ cacheReadTokens,
180
+ totalTokens
181
+ })
182
+ }
183
+ }
184
+
185
+ console.log(
186
+ ` 📊 解析完成: ${lineCount} 行, 找到 ${requestCount} 个请求记录, ${usageCount} 个使用统计`
187
+ )
188
+ }
189
+
190
+ // 分析日志目录中的所有文件
191
+ async analyzeLogDirectory(logDir = './logs') {
192
+ console.log(`🔍 扫描日志目录: ${logDir}\n`)
193
+
194
+ try {
195
+ const files = fs.readdirSync(logDir)
196
+ const logFiles = files
197
+ .filter(
198
+ (file) =>
199
+ file.includes('claude-relay') &&
200
+ (file.endsWith('.log') ||
201
+ file.endsWith('.log.1') ||
202
+ file.endsWith('.log.gz') ||
203
+ file.match(/\.log\.\d+\.gz$/) ||
204
+ file.match(/\.log\.\d+$/))
205
+ )
206
+ .sort()
207
+ .reverse() // 最新的文件优先
208
+
209
+ if (logFiles.length === 0) {
210
+ console.log('❌ 没有找到日志文件')
211
+ return
212
+ }
213
+
214
+ console.log(`📁 找到 ${logFiles.length} 个日志文件:`)
215
+ logFiles.forEach((file) => console.log(` - ${file}`))
216
+ console.log('')
217
+
218
+ // 分析每个文件
219
+ for (const file of logFiles) {
220
+ const filePath = path.join(logDir, file)
221
+ await this.analyzeLogFile(filePath)
222
+ }
223
+ } catch (error) {
224
+ console.error(`❌ 读取日志目录失败: ${error.message}`)
225
+ throw error
226
+ }
227
+ }
228
+
229
+ // 分析单个日志文件(支持直接传入文件路径)
230
+ async analyzeSingleFile(filePath) {
231
+ console.log(`🔍 分析单个日志文件: ${filePath}\n`)
232
+
233
+ try {
234
+ if (!fs.existsSync(filePath)) {
235
+ console.log('❌ 文件不存在')
236
+ return
237
+ }
238
+
239
+ await this.analyzeLogFile(filePath)
240
+ } catch (error) {
241
+ console.error(`❌ 分析文件失败: ${error.message}`)
242
+ throw error
243
+ }
244
+ }
245
+
246
+ // 计算会话窗口
247
+ calculateSessionWindow(requestTime) {
248
+ const hour = requestTime.getHours()
249
+ const windowStartHour = Math.floor(hour / 5) * 5
250
+
251
+ const windowStart = new Date(requestTime)
252
+ windowStart.setHours(windowStartHour, 0, 0, 0)
253
+
254
+ const windowEnd = new Date(windowStart)
255
+ windowEnd.setHours(windowEnd.getHours() + 5)
256
+
257
+ return { windowStart, windowEnd }
258
+ }
259
+
260
+ // 分析会话窗口
261
+ analyzeSessionWindows() {
262
+ console.log('🕐 分析会话窗口...\n')
263
+
264
+ const now = new Date()
265
+ const results = []
266
+
267
+ for (const [accountId, accountData] of this.accounts) {
268
+ const requests = accountData.requests.sort((a, b) => a.timestamp - b.timestamp)
269
+
270
+ // 按会话窗口分组请求
271
+ const windowGroups = new Map()
272
+
273
+ for (const request of requests) {
274
+ const { windowStart, windowEnd } = this.calculateSessionWindow(request.timestamp)
275
+ const windowKey = `${windowStart.getTime()}-${windowEnd.getTime()}`
276
+
277
+ if (!windowGroups.has(windowKey)) {
278
+ windowGroups.set(windowKey, {
279
+ windowStart,
280
+ windowEnd,
281
+ requests: [],
282
+ isActive: now >= windowStart && now < windowEnd
283
+ })
284
+ }
285
+
286
+ windowGroups.get(windowKey).requests.push(request)
287
+ }
288
+
289
+ // 转换为数组并排序
290
+ const windowArray = Array.from(windowGroups.values()).sort(
291
+ (a, b) => b.windowStart - a.windowStart
292
+ ) // 最新的窗口优先
293
+
294
+ const result = {
295
+ accountId,
296
+ accountName: accountData.accountName,
297
+ totalRequests: accountData.totalRequests,
298
+ firstRequest: accountData.firstRequest,
299
+ lastRequest: accountData.lastRequest,
300
+ sessions: accountData.sessions,
301
+ windows: windowArray,
302
+ currentActiveWindow: windowArray.find((w) => w.isActive) || null,
303
+ mostRecentWindow: windowArray[0] || null
304
+ }
305
+
306
+ results.push(result)
307
+ }
308
+
309
+ return results.sort((a, b) => b.lastRequest - a.lastRequest)
310
+ }
311
+
312
+ // 显示分析结果
313
+ displayResults(results) {
314
+ console.log('📊 分析结果:\n')
315
+ console.log('='.repeat(80))
316
+
317
+ for (const result of results) {
318
+ console.log(`🏢 账户: ${result.accountName || 'Unknown'} (${result.accountId})`)
319
+ console.log(` 总请求数: ${result.totalRequests}`)
320
+ console.log(` 会话数: ${result.sessions ? result.sessions.size : 0}`)
321
+ console.log(` 首次请求: ${result.firstRequest.toLocaleString()}`)
322
+ console.log(` 最后请求: ${result.lastRequest.toLocaleString()}`)
323
+
324
+ if (result.currentActiveWindow) {
325
+ console.log(
326
+ ` ✅ 当前活跃窗口: ${result.currentActiveWindow.windowStart.toLocaleString()} - ${result.currentActiveWindow.windowEnd.toLocaleString()}`
327
+ )
328
+ console.log(` 窗口内请求: ${result.currentActiveWindow.requests.length} 次`)
329
+ const progress = this.calculateWindowProgress(
330
+ result.currentActiveWindow.windowStart,
331
+ result.currentActiveWindow.windowEnd
332
+ )
333
+ console.log(` 窗口进度: ${progress}%`)
334
+ } else if (result.mostRecentWindow) {
335
+ const window = result.mostRecentWindow
336
+ console.log(
337
+ ` ⏰ 最近窗口(已过期): ${window.windowStart.toLocaleString()} - ${window.windowEnd.toLocaleString()}`
338
+ )
339
+ console.log(` 窗口内请求: ${window.requests.length} 次`)
340
+ const hoursAgo = Math.round((new Date() - window.windowEnd) / (1000 * 60 * 60))
341
+ console.log(` 过期时间: ${hoursAgo} 小时前`)
342
+ } else {
343
+ console.log(' ❌ 无会话窗口数据')
344
+ }
345
+
346
+ // 显示最近几个窗口
347
+ if (result.windows.length > 1) {
348
+ console.log(` 📈 历史窗口: ${result.windows.length} 个`)
349
+ const recentWindows = result.windows.slice(0, 3)
350
+ for (let i = 0; i < recentWindows.length; i++) {
351
+ const window = recentWindows[i]
352
+ const status = window.isActive ? '活跃' : '已过期'
353
+ console.log(
354
+ ` ${i + 1}. ${window.windowStart.toLocaleString()} - ${window.windowEnd.toLocaleString()} (${status}, ${window.requests.length}次请求)`
355
+ )
356
+ }
357
+ }
358
+
359
+ // 显示最近几个会话的API Key使用情况
360
+ const accountData = this.accounts.get(result.accountId)
361
+ if (accountData && accountData.requests && accountData.requests.length > 0) {
362
+ const apiKeyStats = {}
363
+
364
+ for (const req of accountData.requests) {
365
+ if (!apiKeyStats[req.apiKeyName]) {
366
+ apiKeyStats[req.apiKeyName] = 0
367
+ }
368
+ apiKeyStats[req.apiKeyName]++
369
+ }
370
+
371
+ console.log(' 🔑 API Key使用统计:')
372
+ for (const [keyName, count] of Object.entries(apiKeyStats)) {
373
+ console.log(` - ${keyName}: ${count} 次`)
374
+ }
375
+ }
376
+
377
+ console.log('')
378
+ }
379
+
380
+ console.log('='.repeat(80))
381
+ console.log(`总计: ${results.length} 个账户, ${this.requestHistory.length} 个日志记录\n`)
382
+ }
383
+
384
+ // 计算窗口进度百分比
385
+ calculateWindowProgress(windowStart, windowEnd) {
386
+ const now = new Date()
387
+ const totalDuration = windowEnd.getTime() - windowStart.getTime()
388
+ const elapsedTime = now.getTime() - windowStart.getTime()
389
+ return Math.max(0, Math.min(100, Math.round((elapsedTime / totalDuration) * 100)))
390
+ }
391
+
392
+ // 更新Redis中的会话窗口数据
393
+ async updateRedisSessionWindows(results, dryRun = true) {
394
+ if (dryRun) {
395
+ console.log('🧪 模拟模式 - 不会实际更新Redis数据\n')
396
+ } else {
397
+ console.log('💾 更新Redis中的会话窗口数据...\n')
398
+ await redis.connect()
399
+ }
400
+
401
+ let updatedCount = 0
402
+ let skippedCount = 0
403
+
404
+ for (const result of results) {
405
+ try {
406
+ const accountData = await redis.getClaudeAccount(result.accountId)
407
+
408
+ if (!accountData || Object.keys(accountData).length === 0) {
409
+ console.log(`⚠️ 账户 ${result.accountId} 在Redis中不存在,跳过`)
410
+ skippedCount++
411
+ continue
412
+ }
413
+
414
+ console.log(`🔄 处理账户: ${accountData.name || result.accountId}`)
415
+
416
+ // 确定要设置的会话窗口
417
+ let targetWindow = null
418
+
419
+ if (result.currentActiveWindow) {
420
+ targetWindow = result.currentActiveWindow
421
+ console.log(
422
+ ` ✅ 使用当前活跃窗口: ${targetWindow.windowStart.toLocaleString()} - ${targetWindow.windowEnd.toLocaleString()}`
423
+ )
424
+ } else if (result.mostRecentWindow) {
425
+ const window = result.mostRecentWindow
426
+ const now = new Date()
427
+
428
+ // 如果最近窗口是在过去24小时内的,可以考虑恢复
429
+ const hoursSinceWindow = (now - window.windowEnd) / (1000 * 60 * 60)
430
+
431
+ if (hoursSinceWindow <= 24) {
432
+ console.log(
433
+ ` 🕐 最近窗口在24小时内,但已过期: ${window.windowStart.toLocaleString()} - ${window.windowEnd.toLocaleString()}`
434
+ )
435
+ console.log(` ❌ 不恢复已过期窗口(${hoursSinceWindow.toFixed(1)}小时前过期)`)
436
+ } else {
437
+ console.log(' ⏰ 最近窗口超过24小时前,不予恢复')
438
+ }
439
+ }
440
+
441
+ if (targetWindow && !dryRun) {
442
+ // 更新Redis中的会话窗口数据
443
+ accountData.sessionWindowStart = targetWindow.windowStart.toISOString()
444
+ accountData.sessionWindowEnd = targetWindow.windowEnd.toISOString()
445
+ accountData.lastUsedAt = result.lastRequest.toISOString()
446
+ accountData.lastRequestTime = result.lastRequest.toISOString()
447
+
448
+ await redis.setClaudeAccount(result.accountId, accountData)
449
+ updatedCount++
450
+
451
+ console.log(' ✅ 已更新会话窗口数据')
452
+ } else if (targetWindow) {
453
+ updatedCount++
454
+ console.log(
455
+ ` 🧪 [模拟] 将设置会话窗口: ${targetWindow.windowStart.toLocaleString()} - ${targetWindow.windowEnd.toLocaleString()}`
456
+ )
457
+ } else {
458
+ skippedCount++
459
+ console.log(' ⏭️ 跳过(无有效窗口)')
460
+ }
461
+
462
+ console.log('')
463
+ } catch (error) {
464
+ console.error(`❌ 处理账户 ${result.accountId} 时出错: ${error.message}`)
465
+ skippedCount++
466
+ }
467
+ }
468
+
469
+ if (!dryRun) {
470
+ await redis.disconnect()
471
+ }
472
+
473
+ console.log('📊 更新结果:')
474
+ console.log(` ✅ 已更新: ${updatedCount}`)
475
+ console.log(` ⏭️ 已跳过: ${skippedCount}`)
476
+ console.log(` 📋 总计: ${results.length}`)
477
+ }
478
+
479
+ // 主分析函数
480
+ async analyze(options = {}) {
481
+ const { logDir = './logs', singleFile = null, updateRedis = false, dryRun = true } = options
482
+
483
+ try {
484
+ console.log('🔍 Claude账户会话窗口分析工具\n')
485
+
486
+ // 分析日志文件
487
+ if (singleFile) {
488
+ await this.analyzeSingleFile(singleFile)
489
+ } else {
490
+ await this.analyzeLogDirectory(logDir)
491
+ }
492
+
493
+ if (this.accounts.size === 0) {
494
+ console.log('❌ 没有找到任何Claude账户的请求记录')
495
+ return []
496
+ }
497
+
498
+ // 分析会话窗口
499
+ const results = this.analyzeSessionWindows()
500
+
501
+ // 显示结果
502
+ this.displayResults(results)
503
+
504
+ // 更新Redis(如果需要)
505
+ if (updateRedis) {
506
+ await this.updateRedisSessionWindows(results, dryRun)
507
+ }
508
+
509
+ return results
510
+ } catch (error) {
511
+ console.error('❌ 分析失败:', error)
512
+ throw error
513
+ }
514
+ }
515
+ }
516
+
517
+ // 命令行参数解析
518
+ function parseArgs() {
519
+ const args = process.argv.slice(2)
520
+ const options = {
521
+ logDir: './logs',
522
+ singleFile: null,
523
+ updateRedis: false,
524
+ dryRun: true
525
+ }
526
+
527
+ for (const arg of args) {
528
+ if (arg.startsWith('--log-dir=')) {
529
+ options.logDir = arg.split('=')[1]
530
+ } else if (arg.startsWith('--file=')) {
531
+ options.singleFile = arg.split('=')[1]
532
+ } else if (arg === '--update-redis') {
533
+ options.updateRedis = true
534
+ } else if (arg === '--no-dry-run') {
535
+ options.dryRun = false
536
+ } else if (arg === '--help' || arg === '-h') {
537
+ showHelp()
538
+ process.exit(0)
539
+ }
540
+ }
541
+
542
+ return options
543
+ }
544
+
545
+ // 显示帮助信息
546
+ function showHelp() {
547
+ console.log(`
548
+ Claude账户会话窗口日志分析工具
549
+
550
+ 从日志文件中分析Claude账户的请求时间,计算会话窗口,并可选择性地更新Redis数据。
551
+
552
+ 用法:
553
+ node scripts/analyze-log-sessions.js [选项]
554
+
555
+ 选项:
556
+ --log-dir=PATH 日志文件目录 (默认: ./logs)
557
+ --file=PATH 分析单个日志文件
558
+ --update-redis 更新Redis中的会话窗口数据
559
+ --no-dry-run 实际执行Redis更新(默认为模拟模式)
560
+ --help, -h 显示此帮助信息
561
+
562
+ 示例:
563
+ # 分析默认日志目录
564
+ node scripts/analyze-log-sessions.js
565
+
566
+ # 分析指定目录的日志
567
+ node scripts/analyze-log-sessions.js --log-dir=/path/to/logs
568
+
569
+ # 分析单个日志文件
570
+ node scripts/analyze-log-sessions.js --file=/path/to/logfile.log
571
+
572
+ # 模拟更新Redis数据(不实际更新)
573
+ node scripts/analyze-log-sessions.js --file=/path/to/logfile.log --update-redis
574
+
575
+ # 实际更新Redis数据
576
+ node scripts/analyze-log-sessions.js --file=/path/to/logfile.log --update-redis --no-dry-run
577
+
578
+ 会话窗口规则:
579
+ - Claude官方规定每5小时为一个会话窗口
580
+ - 窗口按整点对齐(如 05:00-10:00, 10:00-15:00)
581
+ - 只有当前时间在窗口内的才被认为是活跃窗口
582
+ - 工具会自动识别并恢复活跃的会话窗口
583
+ `)
584
+ }
585
+
586
+ // 主函数
587
+ async function main() {
588
+ try {
589
+ const options = parseArgs()
590
+
591
+ const analyzer = new LogSessionAnalyzer()
592
+ await analyzer.analyze(options)
593
+
594
+ console.log('🎉 分析完成')
595
+ } catch (error) {
596
+ console.error('💥 程序执行失败:', error)
597
+ process.exit(1)
598
+ }
599
+ }
600
+
601
+ // 如果直接运行此脚本
602
+ if (require.main === module) {
603
+ main()
604
+ }
605
+
606
+ module.exports = LogSessionAnalyzer
scripts/check-redis-keys.js ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * 检查 Redis 中的所有键
3
+ */
4
+
5
+ const redis = require('../src/models/redis')
6
+
7
+ async function checkRedisKeys() {
8
+ console.log('🔍 检查 Redis 中的所有键...\n')
9
+
10
+ try {
11
+ // 确保 Redis 已连接
12
+ await redis.connect()
13
+
14
+ // 获取所有键
15
+ const allKeys = await redis.client.keys('*')
16
+ console.log(`找到 ${allKeys.length} 个键\n`)
17
+
18
+ // 按类型分组
19
+ const keysByType = {}
20
+
21
+ allKeys.forEach((key) => {
22
+ const prefix = key.split(':')[0]
23
+ if (!keysByType[prefix]) {
24
+ keysByType[prefix] = []
25
+ }
26
+ keysByType[prefix].push(key)
27
+ })
28
+
29
+ // 显示各类型的键
30
+ Object.keys(keysByType)
31
+ .sort()
32
+ .forEach((type) => {
33
+ console.log(`\n📁 ${type}: ${keysByType[type].length} 个`)
34
+
35
+ // 显示前 5 个键作为示例
36
+ const keysToShow = keysByType[type].slice(0, 5)
37
+ keysToShow.forEach((key) => {
38
+ console.log(` - ${key}`)
39
+ })
40
+
41
+ if (keysByType[type].length > 5) {
42
+ console.log(` ... 还有 ${keysByType[type].length - 5} 个`)
43
+ }
44
+ })
45
+ } catch (error) {
46
+ console.error('❌ 错误:', error)
47
+ console.error(error.stack)
48
+ } finally {
49
+ process.exit(0)
50
+ }
51
+ }
52
+
53
+ checkRedisKeys()
scripts/data-transfer-enhanced.js ADDED
@@ -0,0 +1,1132 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * 增强版数据导出/导入工具
5
+ * 支持加密数据的处理
6
+ */
7
+
8
+ const fs = require('fs').promises
9
+ const crypto = require('crypto')
10
+ const redis = require('../src/models/redis')
11
+ const logger = require('../src/utils/logger')
12
+ const readline = require('readline')
13
+ const config = require('../config/config')
14
+
15
+ // 解析命令行参数
16
+ const args = process.argv.slice(2)
17
+ const command = args[0]
18
+ const params = {}
19
+
20
+ args.slice(1).forEach((arg) => {
21
+ const [key, value] = arg.split('=')
22
+ params[key.replace('--', '')] = value || true
23
+ })
24
+
25
+ // 创建 readline 接口
26
+ const rl = readline.createInterface({
27
+ input: process.stdin,
28
+ output: process.stdout
29
+ })
30
+
31
+ async function askConfirmation(question) {
32
+ return new Promise((resolve) => {
33
+ rl.question(`${question} (yes/no): `, (answer) => {
34
+ resolve(answer.toLowerCase() === 'yes' || answer.toLowerCase() === 'y')
35
+ })
36
+ })
37
+ }
38
+
39
+ // Claude 账户解密函数
40
+ function decryptClaudeData(encryptedData) {
41
+ if (!encryptedData || !config.security.encryptionKey) {
42
+ return encryptedData
43
+ }
44
+
45
+ try {
46
+ if (encryptedData.includes(':')) {
47
+ const parts = encryptedData.split(':')
48
+ const key = crypto.scryptSync(config.security.encryptionKey, 'salt', 32)
49
+ const iv = Buffer.from(parts[0], 'hex')
50
+ const encrypted = parts[1]
51
+
52
+ const decipher = crypto.createDecipheriv('aes-256-cbc', key, iv)
53
+ let decrypted = decipher.update(encrypted, 'hex', 'utf8')
54
+ decrypted += decipher.final('utf8')
55
+ return decrypted
56
+ }
57
+ return encryptedData
58
+ } catch (error) {
59
+ logger.warn(`⚠️ Failed to decrypt data: ${error.message}`)
60
+ return encryptedData
61
+ }
62
+ }
63
+
64
+ // Gemini 账户解密函数
65
+ function decryptGeminiData(encryptedData) {
66
+ if (!encryptedData || !config.security.encryptionKey) {
67
+ return encryptedData
68
+ }
69
+
70
+ try {
71
+ if (encryptedData.includes(':')) {
72
+ const parts = encryptedData.split(':')
73
+ const key = crypto.scryptSync(config.security.encryptionKey, 'gemini-account-salt', 32)
74
+ const iv = Buffer.from(parts[0], 'hex')
75
+ const encrypted = parts[1]
76
+
77
+ const decipher = crypto.createDecipheriv('aes-256-cbc', key, iv)
78
+ let decrypted = decipher.update(encrypted, 'hex', 'utf8')
79
+ decrypted += decipher.final('utf8')
80
+ return decrypted
81
+ }
82
+ return encryptedData
83
+ } catch (error) {
84
+ logger.warn(`⚠️ Failed to decrypt data: ${error.message}`)
85
+ return encryptedData
86
+ }
87
+ }
88
+
89
+ // API Key 哈希函数(与apiKeyService保持一致)
90
+ function hashApiKey(apiKey) {
91
+ if (!apiKey || !config.security.encryptionKey) {
92
+ return apiKey
93
+ }
94
+
95
+ return crypto
96
+ .createHash('sha256')
97
+ .update(apiKey + config.security.encryptionKey)
98
+ .digest('hex')
99
+ }
100
+
101
+ // 检查是否为明文API Key(通过格式判断,不依赖前缀)
102
+ function isPlaintextApiKey(apiKey) {
103
+ if (!apiKey || typeof apiKey !== 'string') {
104
+ return false
105
+ }
106
+
107
+ // SHA256哈希值固定为64个十六进制字符,如果是哈希值则返回false
108
+ if (apiKey.length === 64 && /^[a-f0-9]+$/i.test(apiKey)) {
109
+ return false // 已经是哈希值
110
+ }
111
+
112
+ // 其他情况都认为是明文API Key(包括sk-ant-、cr_、自定义前缀等)
113
+ return true
114
+ }
115
+
116
+ // 数据加密函数(用于导入)
117
+ function encryptClaudeData(data) {
118
+ if (!data || !config.security.encryptionKey) {
119
+ return data
120
+ }
121
+
122
+ const key = crypto.scryptSync(config.security.encryptionKey, 'salt', 32)
123
+ const iv = crypto.randomBytes(16)
124
+
125
+ const cipher = crypto.createCipheriv('aes-256-cbc', key, iv)
126
+ let encrypted = cipher.update(data, 'utf8', 'hex')
127
+ encrypted += cipher.final('hex')
128
+
129
+ return `${iv.toString('hex')}:${encrypted}`
130
+ }
131
+
132
+ function encryptGeminiData(data) {
133
+ if (!data || !config.security.encryptionKey) {
134
+ return data
135
+ }
136
+
137
+ const key = crypto.scryptSync(config.security.encryptionKey, 'gemini-account-salt', 32)
138
+ const iv = crypto.randomBytes(16)
139
+
140
+ const cipher = crypto.createCipheriv('aes-256-cbc', key, iv)
141
+ let encrypted = cipher.update(data, 'utf8', 'hex')
142
+ encrypted += cipher.final('hex')
143
+
144
+ return `${iv.toString('hex')}:${encrypted}`
145
+ }
146
+
147
+ // 导出使用统计数据
148
+ async function exportUsageStats(keyId) {
149
+ try {
150
+ const stats = {
151
+ total: {},
152
+ daily: {},
153
+ monthly: {},
154
+ hourly: {},
155
+ models: {}
156
+ }
157
+
158
+ // 导出总统计
159
+ const totalKey = `usage:${keyId}`
160
+ const totalData = await redis.client.hgetall(totalKey)
161
+ if (totalData && Object.keys(totalData).length > 0) {
162
+ stats.total = totalData
163
+ }
164
+
165
+ // 导出每日统计(最近30天)
166
+ const today = new Date()
167
+ for (let i = 0; i < 30; i++) {
168
+ const date = new Date(today)
169
+ date.setDate(date.getDate() - i)
170
+ const dateStr = date.toISOString().split('T')[0]
171
+ const dailyKey = `usage:daily:${keyId}:${dateStr}`
172
+
173
+ const dailyData = await redis.client.hgetall(dailyKey)
174
+ if (dailyData && Object.keys(dailyData).length > 0) {
175
+ stats.daily[dateStr] = dailyData
176
+ }
177
+ }
178
+
179
+ // 导出每月统计(最近12个月)
180
+ for (let i = 0; i < 12; i++) {
181
+ const date = new Date(today)
182
+ date.setMonth(date.getMonth() - i)
183
+ const monthStr = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`
184
+ const monthlyKey = `usage:monthly:${keyId}:${monthStr}`
185
+
186
+ const monthlyData = await redis.client.hgetall(monthlyKey)
187
+ if (monthlyData && Object.keys(monthlyData).length > 0) {
188
+ stats.monthly[monthStr] = monthlyData
189
+ }
190
+ }
191
+
192
+ // 导出小时统计(最近24小时)
193
+ for (let i = 0; i < 24; i++) {
194
+ const date = new Date(today)
195
+ date.setHours(date.getHours() - i)
196
+ const dateStr = date.toISOString().split('T')[0]
197
+ const hour = String(date.getHours()).padStart(2, '0')
198
+ const hourKey = `${dateStr}:${hour}`
199
+ const hourlyKey = `usage:hourly:${keyId}:${hourKey}`
200
+
201
+ const hourlyData = await redis.client.hgetall(hourlyKey)
202
+ if (hourlyData && Object.keys(hourlyData).length > 0) {
203
+ stats.hourly[hourKey] = hourlyData
204
+ }
205
+ }
206
+
207
+ // 导出模型统计
208
+ // 每日模型统计
209
+ const modelDailyPattern = `usage:${keyId}:model:daily:*`
210
+ const modelDailyKeys = await redis.client.keys(modelDailyPattern)
211
+ for (const key of modelDailyKeys) {
212
+ const match = key.match(/usage:.+:model:daily:(.+):(\d{4}-\d{2}-\d{2})$/)
213
+ if (match) {
214
+ const model = match[1]
215
+ const date = match[2]
216
+ const data = await redis.client.hgetall(key)
217
+ if (data && Object.keys(data).length > 0) {
218
+ if (!stats.models[model]) {
219
+ stats.models[model] = { daily: {}, monthly: {} }
220
+ }
221
+ stats.models[model].daily[date] = data
222
+ }
223
+ }
224
+ }
225
+
226
+ // 每月模型统计
227
+ const modelMonthlyPattern = `usage:${keyId}:model:monthly:*`
228
+ const modelMonthlyKeys = await redis.client.keys(modelMonthlyPattern)
229
+ for (const key of modelMonthlyKeys) {
230
+ const match = key.match(/usage:.+:model:monthly:(.+):(\d{4}-\d{2})$/)
231
+ if (match) {
232
+ const model = match[1]
233
+ const month = match[2]
234
+ const data = await redis.client.hgetall(key)
235
+ if (data && Object.keys(data).length > 0) {
236
+ if (!stats.models[model]) {
237
+ stats.models[model] = { daily: {}, monthly: {} }
238
+ }
239
+ stats.models[model].monthly[month] = data
240
+ }
241
+ }
242
+ }
243
+
244
+ return stats
245
+ } catch (error) {
246
+ logger.warn(`⚠️ Failed to export usage stats for ${keyId}: ${error.message}`)
247
+ return null
248
+ }
249
+ }
250
+
251
+ // 导入使用统计数据
252
+ async function importUsageStats(keyId, stats) {
253
+ try {
254
+ if (!stats) {
255
+ return
256
+ }
257
+
258
+ const pipeline = redis.client.pipeline()
259
+ let importCount = 0
260
+
261
+ // 导入总统计
262
+ if (stats.total && Object.keys(stats.total).length > 0) {
263
+ for (const [field, value] of Object.entries(stats.total)) {
264
+ pipeline.hset(`usage:${keyId}`, field, value)
265
+ }
266
+ importCount++
267
+ }
268
+
269
+ // 导入每日统计
270
+ if (stats.daily) {
271
+ for (const [date, data] of Object.entries(stats.daily)) {
272
+ for (const [field, value] of Object.entries(data)) {
273
+ pipeline.hset(`usage:daily:${keyId}:${date}`, field, value)
274
+ }
275
+ importCount++
276
+ }
277
+ }
278
+
279
+ // 导入每月统计
280
+ if (stats.monthly) {
281
+ for (const [month, data] of Object.entries(stats.monthly)) {
282
+ for (const [field, value] of Object.entries(data)) {
283
+ pipeline.hset(`usage:monthly:${keyId}:${month}`, field, value)
284
+ }
285
+ importCount++
286
+ }
287
+ }
288
+
289
+ // 导入小时统计
290
+ if (stats.hourly) {
291
+ for (const [hour, data] of Object.entries(stats.hourly)) {
292
+ for (const [field, value] of Object.entries(data)) {
293
+ pipeline.hset(`usage:hourly:${keyId}:${hour}`, field, value)
294
+ }
295
+ importCount++
296
+ }
297
+ }
298
+
299
+ // 导入模型统计
300
+ if (stats.models) {
301
+ for (const [model, modelStats] of Object.entries(stats.models)) {
302
+ // 每日模型统计
303
+ if (modelStats.daily) {
304
+ for (const [date, data] of Object.entries(modelStats.daily)) {
305
+ for (const [field, value] of Object.entries(data)) {
306
+ pipeline.hset(`usage:${keyId}:model:daily:${model}:${date}`, field, value)
307
+ }
308
+ importCount++
309
+ }
310
+ }
311
+
312
+ // 每月模型统计
313
+ if (modelStats.monthly) {
314
+ for (const [month, data] of Object.entries(modelStats.monthly)) {
315
+ for (const [field, value] of Object.entries(data)) {
316
+ pipeline.hset(`usage:${keyId}:model:monthly:${model}:${month}`, field, value)
317
+ }
318
+ importCount++
319
+ }
320
+ }
321
+ }
322
+ }
323
+
324
+ await pipeline.exec()
325
+ logger.info(` 📊 Imported ${importCount} usage stat entries for API Key ${keyId}`)
326
+ } catch (error) {
327
+ logger.warn(`⚠️ Failed to import usage stats for ${keyId}: ${error.message}`)
328
+ }
329
+ }
330
+
331
+ // 数据脱敏函数
332
+ function sanitizeData(data, type) {
333
+ const sanitized = { ...data }
334
+
335
+ switch (type) {
336
+ case 'apikey':
337
+ if (sanitized.apiKey) {
338
+ sanitized.apiKey = `${sanitized.apiKey.substring(0, 10)}...[REDACTED]`
339
+ }
340
+ break
341
+
342
+ case 'claude_account':
343
+ if (sanitized.email) {
344
+ sanitized.email = '[REDACTED]'
345
+ }
346
+ if (sanitized.password) {
347
+ sanitized.password = '[REDACTED]'
348
+ }
349
+ if (sanitized.accessToken) {
350
+ sanitized.accessToken = '[REDACTED]'
351
+ }
352
+ if (sanitized.refreshToken) {
353
+ sanitized.refreshToken = '[REDACTED]'
354
+ }
355
+ if (sanitized.claudeAiOauth) {
356
+ sanitized.claudeAiOauth = '[REDACTED]'
357
+ }
358
+ if (sanitized.proxyPassword) {
359
+ sanitized.proxyPassword = '[REDACTED]'
360
+ }
361
+ break
362
+
363
+ case 'gemini_account':
364
+ if (sanitized.geminiOauth) {
365
+ sanitized.geminiOauth = '[REDACTED]'
366
+ }
367
+ if (sanitized.accessToken) {
368
+ sanitized.accessToken = '[REDACTED]'
369
+ }
370
+ if (sanitized.refreshToken) {
371
+ sanitized.refreshToken = '[REDACTED]'
372
+ }
373
+ if (sanitized.proxyPassword) {
374
+ sanitized.proxyPassword = '[REDACTED]'
375
+ }
376
+ break
377
+
378
+ case 'admin':
379
+ if (sanitized.password) {
380
+ sanitized.password = '[REDACTED]'
381
+ }
382
+ break
383
+ }
384
+
385
+ return sanitized
386
+ }
387
+
388
+ // 导出数据
389
+ async function exportData() {
390
+ try {
391
+ const outputFile = params.output || `backup-${new Date().toISOString().split('T')[0]}.json`
392
+ const types = params.types ? params.types.split(',') : ['all']
393
+ const shouldSanitize = params.sanitize === true
394
+ const shouldDecrypt = params.decrypt !== false // 默认解密
395
+
396
+ logger.info('🔄 Starting data export...')
397
+ logger.info(`📁 Output file: ${outputFile}`)
398
+ logger.info(`📋 Data types: ${types.join(', ')}`)
399
+ logger.info(`🔒 Sanitize sensitive data: ${shouldSanitize ? 'YES' : 'NO'}`)
400
+ logger.info(`🔓 Decrypt data: ${shouldDecrypt ? 'YES' : 'NO'}`)
401
+
402
+ await redis.connect()
403
+ logger.success('✅ Connected to Redis')
404
+
405
+ const exportDataObj = {
406
+ metadata: {
407
+ version: '2.0',
408
+ exportDate: new Date().toISOString(),
409
+ sanitized: shouldSanitize,
410
+ decrypted: shouldDecrypt,
411
+ types
412
+ },
413
+ data: {}
414
+ }
415
+
416
+ // 导出 API Keys
417
+ if (types.includes('all') || types.includes('apikeys')) {
418
+ logger.info('📤 Exporting API Keys...')
419
+ const keys = await redis.client.keys('apikey:*')
420
+ const apiKeys = []
421
+
422
+ for (const key of keys) {
423
+ if (key === 'apikey:hash_map') {
424
+ continue
425
+ }
426
+
427
+ const data = await redis.client.hgetall(key)
428
+ if (data && Object.keys(data).length > 0) {
429
+ // 获取该 API Key 的 ID
430
+ const keyId = data.id
431
+
432
+ // 导出使用统计数据
433
+ if (keyId && (types.includes('all') || types.includes('stats'))) {
434
+ data.usageStats = await exportUsageStats(keyId)
435
+ }
436
+
437
+ apiKeys.push(shouldSanitize ? sanitizeData(data, 'apikey') : data)
438
+ }
439
+ }
440
+
441
+ exportDataObj.data.apiKeys = apiKeys
442
+ logger.success(`✅ Exported ${apiKeys.length} API Keys`)
443
+ }
444
+
445
+ // 导出 Claude 账户
446
+ if (types.includes('all') || types.includes('accounts')) {
447
+ logger.info('📤 Exporting Claude accounts...')
448
+ const keys = await redis.client.keys('claude:account:*')
449
+ logger.info(`Found ${keys.length} Claude account keys in Redis`)
450
+ const accounts = []
451
+
452
+ for (const key of keys) {
453
+ const data = await redis.client.hgetall(key)
454
+
455
+ if (data && Object.keys(data).length > 0) {
456
+ // 解密敏感字段
457
+ if (shouldDecrypt && !shouldSanitize) {
458
+ if (data.email) {
459
+ data.email = decryptClaudeData(data.email)
460
+ }
461
+ if (data.password) {
462
+ data.password = decryptClaudeData(data.password)
463
+ }
464
+ if (data.accessToken) {
465
+ data.accessToken = decryptClaudeData(data.accessToken)
466
+ }
467
+ if (data.refreshToken) {
468
+ data.refreshToken = decryptClaudeData(data.refreshToken)
469
+ }
470
+ if (data.claudeAiOauth) {
471
+ const decrypted = decryptClaudeData(data.claudeAiOauth)
472
+ try {
473
+ data.claudeAiOauth = JSON.parse(decrypted)
474
+ } catch (e) {
475
+ data.claudeAiOauth = decrypted
476
+ }
477
+ }
478
+ }
479
+
480
+ accounts.push(shouldSanitize ? sanitizeData(data, 'claude_account') : data)
481
+ }
482
+ }
483
+
484
+ exportDataObj.data.claudeAccounts = accounts
485
+ logger.success(`✅ Exported ${accounts.length} Claude accounts`)
486
+
487
+ // 导出 Gemini 账户
488
+ logger.info('📤 Exporting Gemini accounts...')
489
+ const geminiKeys = await redis.client.keys('gemini_account:*')
490
+ logger.info(`Found ${geminiKeys.length} Gemini account keys in Redis`)
491
+ const geminiAccounts = []
492
+
493
+ for (const key of geminiKeys) {
494
+ const data = await redis.client.hgetall(key)
495
+
496
+ if (data && Object.keys(data).length > 0) {
497
+ // 解密敏感字段
498
+ if (shouldDecrypt && !shouldSanitize) {
499
+ if (data.geminiOauth) {
500
+ const decrypted = decryptGeminiData(data.geminiOauth)
501
+ try {
502
+ data.geminiOauth = JSON.parse(decrypted)
503
+ } catch (e) {
504
+ data.geminiOauth = decrypted
505
+ }
506
+ }
507
+ if (data.accessToken) {
508
+ data.accessToken = decryptGeminiData(data.accessToken)
509
+ }
510
+ if (data.refreshToken) {
511
+ data.refreshToken = decryptGeminiData(data.refreshToken)
512
+ }
513
+ }
514
+
515
+ geminiAccounts.push(shouldSanitize ? sanitizeData(data, 'gemini_account') : data)
516
+ }
517
+ }
518
+
519
+ exportDataObj.data.geminiAccounts = geminiAccounts
520
+ logger.success(`✅ Exported ${geminiAccounts.length} Gemini accounts`)
521
+ }
522
+
523
+ // 导出管理员
524
+ if (types.includes('all') || types.includes('admins')) {
525
+ logger.info('📤 Exporting admins...')
526
+ const keys = await redis.client.keys('admin:*')
527
+ const admins = []
528
+
529
+ for (const key of keys) {
530
+ if (key.includes('admin_username:')) {
531
+ continue
532
+ }
533
+
534
+ const data = await redis.client.hgetall(key)
535
+ if (data && Object.keys(data).length > 0) {
536
+ admins.push(shouldSanitize ? sanitizeData(data, 'admin') : data)
537
+ }
538
+ }
539
+
540
+ exportDataObj.data.admins = admins
541
+ logger.success(`✅ Exported ${admins.length} admins`)
542
+ }
543
+
544
+ // 导出全局模型统计(如果需要)
545
+ if (types.includes('all') || types.includes('stats')) {
546
+ logger.info('📤 Exporting global model statistics...')
547
+ const globalStats = {
548
+ daily: {},
549
+ monthly: {},
550
+ hourly: {}
551
+ }
552
+
553
+ // 导出全局每日模型统计
554
+ const globalDailyPattern = 'usage:model:daily:*'
555
+ const globalDailyKeys = await redis.client.keys(globalDailyPattern)
556
+ for (const key of globalDailyKeys) {
557
+ const match = key.match(/usage:model:daily:(.+):(\d{4}-\d{2}-\d{2})$/)
558
+ if (match) {
559
+ const model = match[1]
560
+ const date = match[2]
561
+ const data = await redis.client.hgetall(key)
562
+ if (data && Object.keys(data).length > 0) {
563
+ if (!globalStats.daily[date]) {
564
+ globalStats.daily[date] = {}
565
+ }
566
+ globalStats.daily[date][model] = data
567
+ }
568
+ }
569
+ }
570
+
571
+ // 导出全局每月模型统计
572
+ const globalMonthlyPattern = 'usage:model:monthly:*'
573
+ const globalMonthlyKeys = await redis.client.keys(globalMonthlyPattern)
574
+ for (const key of globalMonthlyKeys) {
575
+ const match = key.match(/usage:model:monthly:(.+):(\d{4}-\d{2})$/)
576
+ if (match) {
577
+ const model = match[1]
578
+ const month = match[2]
579
+ const data = await redis.client.hgetall(key)
580
+ if (data && Object.keys(data).length > 0) {
581
+ if (!globalStats.monthly[month]) {
582
+ globalStats.monthly[month] = {}
583
+ }
584
+ globalStats.monthly[month][model] = data
585
+ }
586
+ }
587
+ }
588
+
589
+ // 导出全局每小时模型统计
590
+ const globalHourlyPattern = 'usage:model:hourly:*'
591
+ const globalHourlyKeys = await redis.client.keys(globalHourlyPattern)
592
+ for (const key of globalHourlyKeys) {
593
+ const match = key.match(/usage:model:hourly:(.+):(\d{4}-\d{2}-\d{2}:\d{2})$/)
594
+ if (match) {
595
+ const model = match[1]
596
+ const hour = match[2]
597
+ const data = await redis.client.hgetall(key)
598
+ if (data && Object.keys(data).length > 0) {
599
+ if (!globalStats.hourly[hour]) {
600
+ globalStats.hourly[hour] = {}
601
+ }
602
+ globalStats.hourly[hour][model] = data
603
+ }
604
+ }
605
+ }
606
+
607
+ exportDataObj.data.globalModelStats = globalStats
608
+ logger.success('✅ Exported global model statistics')
609
+ }
610
+
611
+ // 写入文件
612
+ await fs.writeFile(outputFile, JSON.stringify(exportDataObj, null, 2))
613
+
614
+ // 显示导出摘要
615
+ console.log(`\n${'='.repeat(60)}`)
616
+ console.log('✅ Export Complete!')
617
+ console.log('='.repeat(60))
618
+ console.log(`Output file: ${outputFile}`)
619
+ console.log(`File size: ${(await fs.stat(outputFile)).size} bytes`)
620
+
621
+ if (exportDataObj.data.apiKeys) {
622
+ console.log(`API Keys: ${exportDataObj.data.apiKeys.length}`)
623
+ }
624
+ if (exportDataObj.data.claudeAccounts) {
625
+ console.log(`Claude Accounts: ${exportDataObj.data.claudeAccounts.length}`)
626
+ }
627
+ if (exportDataObj.data.geminiAccounts) {
628
+ console.log(`Gemini Accounts: ${exportDataObj.data.geminiAccounts.length}`)
629
+ }
630
+ if (exportDataObj.data.admins) {
631
+ console.log(`Admins: ${exportDataObj.data.admins.length}`)
632
+ }
633
+ console.log('='.repeat(60))
634
+
635
+ if (shouldSanitize) {
636
+ logger.warn('⚠️ Sensitive data has been sanitized in this export.')
637
+ }
638
+ if (shouldDecrypt) {
639
+ logger.info('🔓 Encrypted data has been decrypted for portability.')
640
+ }
641
+ } catch (error) {
642
+ logger.error('💥 Export failed:', error)
643
+ process.exit(1)
644
+ } finally {
645
+ await redis.disconnect()
646
+ rl.close()
647
+ }
648
+ }
649
+
650
+ // 显示帮助信息
651
+ function showHelp() {
652
+ console.log(`
653
+ Enhanced Data Transfer Tool for Claude Relay Service
654
+
655
+ This tool handles encrypted data export/import between environments.
656
+
657
+ Usage:
658
+ node scripts/data-transfer-enhanced.js <command> [options]
659
+
660
+ Commands:
661
+ export Export data from Redis to a JSON file
662
+ import Import data from a JSON file to Redis
663
+
664
+ Export Options:
665
+ --output=FILE Output filename (default: backup-YYYY-MM-DD.json)
666
+ --types=TYPE,... Data types: apikeys,accounts,admins,stats,all (default: all)
667
+ stats: Include usage statistics with API keys
668
+ --sanitize Remove sensitive data from export
669
+ --decrypt=false Keep data encrypted (default: true - decrypt for portability)
670
+
671
+ Import Options:
672
+ --input=FILE Input filename (required)
673
+ --force Overwrite existing data without asking
674
+ --skip-conflicts Skip conflicting data without asking
675
+
676
+ Important Notes:
677
+ - The tool automatically handles encryption/decryption during import
678
+ - If importing decrypted data, it will be re-encrypted automatically
679
+ - If importing encrypted data, it will be stored as-is
680
+ - Sanitized exports cannot be properly imported (missing sensitive data)
681
+ - Automatic handling of plaintext API Keys
682
+ * Uses your configured API_KEY_PREFIX from config (sk-, cr_, etc.)
683
+ * Automatically detects plaintext vs hashed API Keys by format
684
+ * Plaintext API Keys are automatically hashed during import
685
+ * Hash mappings are created correctly for plaintext keys
686
+ * Supports custom prefixes and legacy format detection
687
+ * No manual conversion needed - just import your backup file
688
+
689
+ Examples:
690
+ # Export all data with decryption (for migration)
691
+ node scripts/data-transfer-enhanced.js export
692
+
693
+ # Export without decrypting (for backup)
694
+ node scripts/data-transfer-enhanced.js export --decrypt=false
695
+
696
+ # Import data (auto-handles encryption and plaintext API keys)
697
+ node scripts/data-transfer-enhanced.js import --input=backup.json
698
+
699
+ # Import with force overwrite
700
+ node scripts/data-transfer-enhanced.js import --input=backup.json --force
701
+ `)
702
+ }
703
+
704
+ // 导入数据
705
+ async function importData() {
706
+ try {
707
+ const inputFile = params.input
708
+ if (!inputFile) {
709
+ logger.error('❌ Please specify input file with --input=filename.json')
710
+ process.exit(1)
711
+ }
712
+
713
+ const forceOverwrite = params.force === true
714
+ const skipConflicts = params['skip-conflicts'] === true
715
+
716
+ logger.info('🔄 Starting data import...')
717
+ logger.info(`📁 Input file: ${inputFile}`)
718
+ logger.info(
719
+ `⚡ Mode: ${forceOverwrite ? 'FORCE OVERWRITE' : skipConflicts ? 'SKIP CONFLICTS' : 'ASK ON CONFLICT'}`
720
+ )
721
+
722
+ // 读取文件
723
+ const fileContent = await fs.readFile(inputFile, 'utf8')
724
+ const importDataObj = JSON.parse(fileContent)
725
+
726
+ // 验证文件格式
727
+ if (!importDataObj.metadata || !importDataObj.data) {
728
+ logger.error('❌ Invalid backup file format')
729
+ process.exit(1)
730
+ }
731
+
732
+ logger.info(`📅 Backup date: ${importDataObj.metadata.exportDate}`)
733
+ logger.info(`🔒 Sanitized: ${importDataObj.metadata.sanitized ? 'YES' : 'NO'}`)
734
+ logger.info(`🔓 Decrypted: ${importDataObj.metadata.decrypted ? 'YES' : 'NO'}`)
735
+
736
+ if (importDataObj.metadata.sanitized) {
737
+ logger.warn('⚠️ This backup contains sanitized data. Sensitive fields will be missing!')
738
+ const proceed = await askConfirmation('Continue with sanitized data?')
739
+ if (!proceed) {
740
+ logger.info('❌ Import cancelled')
741
+ return
742
+ }
743
+ }
744
+
745
+ // 显示导入摘要
746
+ console.log(`\n${'='.repeat(60)}`)
747
+ console.log('📋 Import Summary:')
748
+ console.log('='.repeat(60))
749
+ if (importDataObj.data.apiKeys) {
750
+ console.log(`API Keys to import: ${importDataObj.data.apiKeys.length}`)
751
+ }
752
+ if (importDataObj.data.claudeAccounts) {
753
+ console.log(`Claude Accounts to import: ${importDataObj.data.claudeAccounts.length}`)
754
+ }
755
+ if (importDataObj.data.geminiAccounts) {
756
+ console.log(`Gemini Accounts to import: ${importDataObj.data.geminiAccounts.length}`)
757
+ }
758
+ if (importDataObj.data.admins) {
759
+ console.log(`Admins to import: ${importDataObj.data.admins.length}`)
760
+ }
761
+ console.log(`${'='.repeat(60)}\n`)
762
+
763
+ // 确认导入
764
+ const confirmed = await askConfirmation('⚠️ Proceed with import?')
765
+ if (!confirmed) {
766
+ logger.info('❌ Import cancelled')
767
+ return
768
+ }
769
+
770
+ // 连接 Redis
771
+ await redis.connect()
772
+ logger.success('✅ Connected to Redis')
773
+
774
+ const stats = {
775
+ imported: 0,
776
+ skipped: 0,
777
+ errors: 0
778
+ }
779
+
780
+ // 导入 API Keys
781
+ if (importDataObj.data.apiKeys) {
782
+ logger.info('\n📥 Importing API Keys...')
783
+ for (const apiKey of importDataObj.data.apiKeys) {
784
+ try {
785
+ const exists = await redis.client.exists(`apikey:${apiKey.id}`)
786
+
787
+ if (exists && !forceOverwrite) {
788
+ if (skipConflicts) {
789
+ logger.warn(`⏭️ Skipped existing API Key: ${apiKey.name} (${apiKey.id})`)
790
+ stats.skipped++
791
+ continue
792
+ } else {
793
+ const overwrite = await askConfirmation(
794
+ `API Key "${apiKey.name}" (${apiKey.id}) exists. Overwrite?`
795
+ )
796
+ if (!overwrite) {
797
+ stats.skipped++
798
+ continue
799
+ }
800
+ }
801
+ }
802
+
803
+ // 保存使用统计数据以便单独导入
804
+ const { usageStats } = apiKey
805
+
806
+ // 从apiKey对象中删除usageStats字段,避免存储到主键中
807
+ const apiKeyData = { ...apiKey }
808
+ delete apiKeyData.usageStats
809
+
810
+ // 检查并处理API Key哈希
811
+ let plainTextApiKey = null
812
+ let hashedApiKey = null
813
+
814
+ if (apiKeyData.apiKey && isPlaintextApiKey(apiKeyData.apiKey)) {
815
+ // 如果是明文API Key,保存明文并计算哈希
816
+ plainTextApiKey = apiKeyData.apiKey
817
+ hashedApiKey = hashApiKey(plainTextApiKey)
818
+ logger.info(`🔐 Detected plaintext API Key for: ${apiKey.name} (${apiKey.id})`)
819
+ } else if (apiKeyData.apiKey) {
820
+ // 如果已经是哈希值,直接使用
821
+ hashedApiKey = apiKeyData.apiKey
822
+ logger.info(`🔍 Using existing hashed API Key for: ${apiKey.name} (${apiKey.id})`)
823
+ }
824
+
825
+ // API Key字段始终存储哈希值
826
+ if (hashedApiKey) {
827
+ apiKeyData.apiKey = hashedApiKey
828
+ }
829
+
830
+ // 使用 hset 存储到哈希表
831
+ const pipeline = redis.client.pipeline()
832
+ for (const [field, value] of Object.entries(apiKeyData)) {
833
+ pipeline.hset(`apikey:${apiKey.id}`, field, value)
834
+ }
835
+ await pipeline.exec()
836
+
837
+ // 更新哈希映射:hash_map的key必须是哈希值
838
+ if (!importDataObj.metadata.sanitized && hashedApiKey) {
839
+ await redis.client.hset('apikey:hash_map', hashedApiKey, apiKey.id)
840
+ logger.info(
841
+ `📝 Updated hash mapping: ${hashedApiKey.substring(0, 8)}... -> ${apiKey.id}`
842
+ )
843
+ }
844
+
845
+ // 导入使用统计数据
846
+ if (usageStats) {
847
+ await importUsageStats(apiKey.id, usageStats)
848
+ }
849
+
850
+ logger.success(`✅ Imported API Key: ${apiKey.name} (${apiKey.id})`)
851
+ stats.imported++
852
+ } catch (error) {
853
+ logger.error(`❌ Failed to import API Key ${apiKey.id}:`, error.message)
854
+ stats.errors++
855
+ }
856
+ }
857
+ }
858
+
859
+ // 导入 Claude 账户
860
+ if (importDataObj.data.claudeAccounts) {
861
+ logger.info('\n📥 Importing Claude accounts...')
862
+ for (const account of importDataObj.data.claudeAccounts) {
863
+ try {
864
+ const exists = await redis.client.exists(`claude:account:${account.id}`)
865
+
866
+ if (exists && !forceOverwrite) {
867
+ if (skipConflicts) {
868
+ logger.warn(`⏭️ Skipped existing Claude account: ${account.name} (${account.id})`)
869
+ stats.skipped++
870
+ continue
871
+ } else {
872
+ const overwrite = await askConfirmation(
873
+ `Claude account "${account.name}" (${account.id}) exists. Overwrite?`
874
+ )
875
+ if (!overwrite) {
876
+ stats.skipped++
877
+ continue
878
+ }
879
+ }
880
+ }
881
+
882
+ // 复制账户数据以避免修改原始数据
883
+ const accountData = { ...account }
884
+
885
+ // 如果数据已解密且不是脱敏数据,需要重新加密
886
+ if (importDataObj.metadata.decrypted && !importDataObj.metadata.sanitized) {
887
+ logger.info(`🔐 Re-encrypting sensitive data for Claude account: ${account.name}`)
888
+
889
+ if (accountData.email) {
890
+ accountData.email = encryptClaudeData(accountData.email)
891
+ }
892
+ if (accountData.password) {
893
+ accountData.password = encryptClaudeData(accountData.password)
894
+ }
895
+ if (accountData.accessToken) {
896
+ accountData.accessToken = encryptClaudeData(accountData.accessToken)
897
+ }
898
+ if (accountData.refreshToken) {
899
+ accountData.refreshToken = encryptClaudeData(accountData.refreshToken)
900
+ }
901
+ if (accountData.claudeAiOauth) {
902
+ // 如果是对象,先序列化再加密
903
+ const oauthStr =
904
+ typeof accountData.claudeAiOauth === 'object'
905
+ ? JSON.stringify(accountData.claudeAiOauth)
906
+ : accountData.claudeAiOauth
907
+ accountData.claudeAiOauth = encryptClaudeData(oauthStr)
908
+ }
909
+ }
910
+
911
+ // 使用 hset 存储到哈希表
912
+ const pipeline = redis.client.pipeline()
913
+ for (const [field, value] of Object.entries(accountData)) {
914
+ if (field === 'claudeAiOauth' && typeof value === 'object') {
915
+ // 确保对象被序列化
916
+ pipeline.hset(`claude:account:${account.id}`, field, JSON.stringify(value))
917
+ } else {
918
+ pipeline.hset(`claude:account:${account.id}`, field, value)
919
+ }
920
+ }
921
+ await pipeline.exec()
922
+
923
+ logger.success(`✅ Imported Claude account: ${account.name} (${account.id})`)
924
+ stats.imported++
925
+ } catch (error) {
926
+ logger.error(`❌ Failed to import Claude account ${account.id}:`, error.message)
927
+ stats.errors++
928
+ }
929
+ }
930
+ }
931
+
932
+ // 导入 Gemini 账户
933
+ if (importDataObj.data.geminiAccounts) {
934
+ logger.info('\n📥 Importing Gemini accounts...')
935
+ for (const account of importDataObj.data.geminiAccounts) {
936
+ try {
937
+ const exists = await redis.client.exists(`gemini_account:${account.id}`)
938
+
939
+ if (exists && !forceOverwrite) {
940
+ if (skipConflicts) {
941
+ logger.warn(`⏭️ Skipped existing Gemini account: ${account.name} (${account.id})`)
942
+ stats.skipped++
943
+ continue
944
+ } else {
945
+ const overwrite = await askConfirmation(
946
+ `Gemini account "${account.name}" (${account.id}) exists. Overwrite?`
947
+ )
948
+ if (!overwrite) {
949
+ stats.skipped++
950
+ continue
951
+ }
952
+ }
953
+ }
954
+
955
+ // 复制账户数据以避免修改原始数据
956
+ const accountData = { ...account }
957
+
958
+ // 如果数据已解密且不是脱敏数据,需要重新加密
959
+ if (importDataObj.metadata.decrypted && !importDataObj.metadata.sanitized) {
960
+ logger.info(`🔐 Re-encrypting sensitive data for Gemini account: ${account.name}`)
961
+
962
+ if (accountData.geminiOauth) {
963
+ const oauthStr =
964
+ typeof accountData.geminiOauth === 'object'
965
+ ? JSON.stringify(accountData.geminiOauth)
966
+ : accountData.geminiOauth
967
+ accountData.geminiOauth = encryptGeminiData(oauthStr)
968
+ }
969
+ if (accountData.accessToken) {
970
+ accountData.accessToken = encryptGeminiData(accountData.accessToken)
971
+ }
972
+ if (accountData.refreshToken) {
973
+ accountData.refreshToken = encryptGeminiData(accountData.refreshToken)
974
+ }
975
+ }
976
+
977
+ // 使用 hset 存储到哈希表
978
+ const pipeline = redis.client.pipeline()
979
+ for (const [field, value] of Object.entries(accountData)) {
980
+ pipeline.hset(`gemini_account:${account.id}`, field, value)
981
+ }
982
+ await pipeline.exec()
983
+
984
+ logger.success(`✅ Imported Gemini account: ${account.name} (${account.id})`)
985
+ stats.imported++
986
+ } catch (error) {
987
+ logger.error(`❌ Failed to import Gemini account ${account.id}:`, error.message)
988
+ stats.errors++
989
+ }
990
+ }
991
+ }
992
+
993
+ // 导入管理员账户
994
+ if (importDataObj.data.admins) {
995
+ logger.info('\n📥 Importing admins...')
996
+ for (const admin of importDataObj.data.admins) {
997
+ try {
998
+ const exists = await redis.client.exists(`admin:${admin.id}`)
999
+
1000
+ if (exists && !forceOverwrite) {
1001
+ if (skipConflicts) {
1002
+ logger.warn(`⏭️ Skipped existing admin: ${admin.username} (${admin.id})`)
1003
+ stats.skipped++
1004
+ continue
1005
+ } else {
1006
+ const overwrite = await askConfirmation(
1007
+ `Admin "${admin.username}" (${admin.id}) exists. Overwrite?`
1008
+ )
1009
+ if (!overwrite) {
1010
+ stats.skipped++
1011
+ continue
1012
+ }
1013
+ }
1014
+ }
1015
+
1016
+ // 使用 hset 存储到哈希表
1017
+ const pipeline = redis.client.pipeline()
1018
+ for (const [field, value] of Object.entries(admin)) {
1019
+ pipeline.hset(`admin:${admin.id}`, field, value)
1020
+ }
1021
+ await pipeline.exec()
1022
+
1023
+ // 更新用户名映射
1024
+ await redis.client.set(`admin_username:${admin.username}`, admin.id)
1025
+
1026
+ logger.success(`✅ Imported admin: ${admin.username} (${admin.id})`)
1027
+ stats.imported++
1028
+ } catch (error) {
1029
+ logger.error(`❌ Failed to import admin ${admin.id}:`, error.message)
1030
+ stats.errors++
1031
+ }
1032
+ }
1033
+ }
1034
+
1035
+ // 导入全局模型统计
1036
+ if (importDataObj.data.globalModelStats) {
1037
+ logger.info('\n📥 Importing global model statistics...')
1038
+ try {
1039
+ const globalStats = importDataObj.data.globalModelStats
1040
+ const pipeline = redis.client.pipeline()
1041
+ let globalStatCount = 0
1042
+
1043
+ // 导入每日统计
1044
+ if (globalStats.daily) {
1045
+ for (const [date, models] of Object.entries(globalStats.daily)) {
1046
+ for (const [model, data] of Object.entries(models)) {
1047
+ for (const [field, value] of Object.entries(data)) {
1048
+ pipeline.hset(`usage:model:daily:${model}:${date}`, field, value)
1049
+ }
1050
+ globalStatCount++
1051
+ }
1052
+ }
1053
+ }
1054
+
1055
+ // 导入每月统计
1056
+ if (globalStats.monthly) {
1057
+ for (const [month, models] of Object.entries(globalStats.monthly)) {
1058
+ for (const [model, data] of Object.entries(models)) {
1059
+ for (const [field, value] of Object.entries(data)) {
1060
+ pipeline.hset(`usage:model:monthly:${model}:${month}`, field, value)
1061
+ }
1062
+ globalStatCount++
1063
+ }
1064
+ }
1065
+ }
1066
+
1067
+ // 导入每小时统计
1068
+ if (globalStats.hourly) {
1069
+ for (const [hour, models] of Object.entries(globalStats.hourly)) {
1070
+ for (const [model, data] of Object.entries(models)) {
1071
+ for (const [field, value] of Object.entries(data)) {
1072
+ pipeline.hset(`usage:model:hourly:${model}:${hour}`, field, value)
1073
+ }
1074
+ globalStatCount++
1075
+ }
1076
+ }
1077
+ }
1078
+
1079
+ await pipeline.exec()
1080
+ logger.success(`✅ Imported ${globalStatCount} global model stat entries`)
1081
+ stats.imported += globalStatCount
1082
+ } catch (error) {
1083
+ logger.error('❌ Failed to import global model stats:', error.message)
1084
+ stats.errors++
1085
+ }
1086
+ }
1087
+
1088
+ // 显示导入结果
1089
+ console.log(`\n${'='.repeat(60)}`)
1090
+ console.log('✅ Import Complete!')
1091
+ console.log('='.repeat(60))
1092
+ console.log(`Successfully imported: ${stats.imported}`)
1093
+ console.log(`Skipped: ${stats.skipped}`)
1094
+ console.log(`Errors: ${stats.errors}`)
1095
+ console.log('='.repeat(60))
1096
+ } catch (error) {
1097
+ logger.error('💥 Import failed:', error)
1098
+ process.exit(1)
1099
+ } finally {
1100
+ await redis.disconnect()
1101
+ rl.close()
1102
+ }
1103
+ }
1104
+
1105
+ // 主函数
1106
+ async function main() {
1107
+ if (!command || command === '--help' || command === 'help') {
1108
+ showHelp()
1109
+ process.exit(0)
1110
+ }
1111
+
1112
+ switch (command) {
1113
+ case 'export':
1114
+ await exportData()
1115
+ break
1116
+
1117
+ case 'import':
1118
+ await importData()
1119
+ break
1120
+
1121
+ default:
1122
+ logger.error(`❌ Unknown command: ${command}`)
1123
+ showHelp()
1124
+ process.exit(1)
1125
+ }
1126
+ }
1127
+
1128
+ // 运行
1129
+ main().catch((error) => {
1130
+ logger.error('💥 Unexpected error:', error)
1131
+ process.exit(1)
1132
+ })
scripts/data-transfer.js ADDED
@@ -0,0 +1,738 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * 数据导出/导入工具
5
+ *
6
+ * 使用方法:
7
+ * 导出: node scripts/data-transfer.js export --output=backup.json [options]
8
+ * 导入: node scripts/data-transfer.js import --input=backup.json [options]
9
+ *
10
+ * 选项:
11
+ * --types: 要导出/导入的数据类型(apikeys,accounts,admins,all)
12
+ * --sanitize: 导出时脱敏敏感数据
13
+ * --force: 导入时强制覆盖已存在的数据
14
+ * --skip-conflicts: 导入时跳过冲突的数据
15
+ */
16
+
17
+ const fs = require('fs').promises
18
+ const redis = require('../src/models/redis')
19
+ const logger = require('../src/utils/logger')
20
+ const readline = require('readline')
21
+
22
+ // 解析命令行参数
23
+ const args = process.argv.slice(2)
24
+ const command = args[0]
25
+ const params = {}
26
+
27
+ args.slice(1).forEach((arg) => {
28
+ const [key, value] = arg.split('=')
29
+ params[key.replace('--', '')] = value || true
30
+ })
31
+
32
+ // 创建 readline 接口
33
+ const rl = readline.createInterface({
34
+ input: process.stdin,
35
+ output: process.stdout
36
+ })
37
+
38
+ async function askConfirmation(question) {
39
+ return new Promise((resolve) => {
40
+ rl.question(`${question} (yes/no): `, (answer) => {
41
+ resolve(answer.toLowerCase() === 'yes' || answer.toLowerCase() === 'y')
42
+ })
43
+ })
44
+ }
45
+
46
+ // 数据脱敏函数
47
+ function sanitizeData(data, type) {
48
+ const sanitized = { ...data }
49
+
50
+ switch (type) {
51
+ case 'apikey':
52
+ // 隐藏 API Key 的大部分内容
53
+ if (sanitized.apiKey) {
54
+ sanitized.apiKey = `${sanitized.apiKey.substring(0, 10)}...[REDACTED]`
55
+ }
56
+ break
57
+
58
+ case 'claude_account':
59
+ case 'gemini_account':
60
+ // 隐藏 OAuth tokens
61
+ if (sanitized.accessToken) {
62
+ sanitized.accessToken = '[REDACTED]'
63
+ }
64
+ if (sanitized.refreshToken) {
65
+ sanitized.refreshToken = '[REDACTED]'
66
+ }
67
+ if (sanitized.claudeAiOauth) {
68
+ sanitized.claudeAiOauth = '[REDACTED]'
69
+ }
70
+ // 隐藏代理密码
71
+ if (sanitized.proxyPassword) {
72
+ sanitized.proxyPassword = '[REDACTED]'
73
+ }
74
+ break
75
+
76
+ case 'admin':
77
+ // 隐藏管理员密码
78
+ if (sanitized.password) {
79
+ sanitized.password = '[REDACTED]'
80
+ }
81
+ break
82
+ }
83
+
84
+ return sanitized
85
+ }
86
+
87
+ // CSV 字段映射配置
88
+ const CSV_FIELD_MAPPING = {
89
+ // 基本信息
90
+ id: 'ID',
91
+ name: '名称',
92
+ description: '描述',
93
+ isActive: '状态',
94
+ createdAt: '创建时间',
95
+ lastUsedAt: '最后使用时间',
96
+ createdBy: '创建者',
97
+
98
+ // API Key 信息
99
+ apiKey: 'API密钥',
100
+ tokenLimit: '令牌限制',
101
+
102
+ // 过期设置
103
+ expirationMode: '过期模式',
104
+ expiresAt: '过期时间',
105
+ activationDays: '激活天数',
106
+ activationUnit: '激活单位',
107
+ isActivated: '已激活',
108
+ activatedAt: '激活时间',
109
+
110
+ // 权限设置
111
+ permissions: '服务权限',
112
+
113
+ // 限制设置
114
+ rateLimitWindow: '速率窗口(分钟)',
115
+ rateLimitRequests: '请求次数限制',
116
+ rateLimitCost: '费用限制(美元)',
117
+ concurrencyLimit: '并发限制',
118
+ dailyCostLimit: '日费用限制(美元)',
119
+ totalCostLimit: '总费用限制(美元)',
120
+ weeklyOpusCostLimit: '周Opus费用限制(美元)',
121
+
122
+ // 账户绑定
123
+ claudeAccountId: 'Claude专属账户',
124
+ claudeConsoleAccountId: 'Claude控制台账户',
125
+ geminiAccountId: 'Gemini专属账户',
126
+ openaiAccountId: 'OpenAI专属账户',
127
+ azureOpenaiAccountId: 'Azure OpenAI专属账户',
128
+ bedrockAccountId: 'Bedrock专属账户',
129
+
130
+ // 限制配置
131
+ enableModelRestriction: '启用模型限制',
132
+ restrictedModels: '限制的模型',
133
+ enableClientRestriction: '启用客户端限制',
134
+ allowedClients: '允许的客户端',
135
+
136
+ // 标签和用户
137
+ tags: '标签',
138
+ userId: '用户ID',
139
+ userUsername: '用户名',
140
+
141
+ // 其他信息
142
+ icon: '图标'
143
+ }
144
+
145
+ // 数据格式化函数
146
+ function formatCSVValue(key, value, shouldSanitize = false) {
147
+ if (!value || value === '' || value === 'null' || value === 'undefined') {
148
+ return ''
149
+ }
150
+
151
+ switch (key) {
152
+ case 'apiKey':
153
+ if (shouldSanitize && value.length > 10) {
154
+ return `${value.substring(0, 10)}...[已脱敏]`
155
+ }
156
+ return value
157
+
158
+ case 'isActive':
159
+ case 'isActivated':
160
+ case 'enableModelRestriction':
161
+ case 'enableClientRestriction':
162
+ return value === 'true' ? '是' : '否'
163
+
164
+ case 'expirationMode':
165
+ return value === 'activation' ? '首次使用后激活' : value === 'fixed' ? '固定时间' : value
166
+
167
+ case 'activationUnit':
168
+ return value === 'hours' ? '小时' : value === 'days' ? '天' : value
169
+
170
+ case 'permissions':
171
+ switch (value) {
172
+ case 'all':
173
+ return '全部服务'
174
+ case 'claude':
175
+ return '仅Claude'
176
+ case 'gemini':
177
+ return '仅Gemini'
178
+ case 'openai':
179
+ return '仅OpenAI'
180
+ default:
181
+ return value
182
+ }
183
+
184
+ case 'restrictedModels':
185
+ case 'allowedClients':
186
+ case 'tags':
187
+ try {
188
+ const parsed = JSON.parse(value)
189
+ return Array.isArray(parsed) ? parsed.join('; ') : value
190
+ } catch {
191
+ return value
192
+ }
193
+
194
+ case 'createdAt':
195
+ case 'lastUsedAt':
196
+ case 'activatedAt':
197
+ case 'expiresAt':
198
+ if (value) {
199
+ try {
200
+ return new Date(value).toLocaleString('zh-CN', {
201
+ year: 'numeric',
202
+ month: '2-digit',
203
+ day: '2-digit',
204
+ hour: '2-digit',
205
+ minute: '2-digit',
206
+ second: '2-digit'
207
+ })
208
+ } catch {
209
+ return value
210
+ }
211
+ }
212
+ return ''
213
+
214
+ case 'rateLimitWindow':
215
+ case 'rateLimitRequests':
216
+ case 'concurrencyLimit':
217
+ case 'activationDays':
218
+ case 'tokenLimit':
219
+ return value === '0' || value === 0 ? '无限制' : value
220
+
221
+ case 'rateLimitCost':
222
+ case 'dailyCostLimit':
223
+ case 'totalCostLimit':
224
+ case 'weeklyOpusCostLimit':
225
+ return value === '0' || value === 0 ? '无限制' : `$${value}`
226
+
227
+ default:
228
+ return value
229
+ }
230
+ }
231
+
232
+ // 转义 CSV 字段
233
+ function escapeCSVField(field) {
234
+ if (field === null || field === undefined) {
235
+ return ''
236
+ }
237
+
238
+ const str = String(field)
239
+
240
+ // 如果包含逗号、引号或换行符,需要用引号包围
241
+ if (str.includes(',') || str.includes('"') || str.includes('\n') || str.includes('\r')) {
242
+ // 先转义引号(双引号变成两个双引号)
243
+ const escaped = str.replace(/"/g, '""')
244
+ return `"${escaped}"`
245
+ }
246
+
247
+ return str
248
+ }
249
+
250
+ // 转换数据为 CSV 格式
251
+ function convertToCSV(exportDataObj, shouldSanitize = false) {
252
+ if (!exportDataObj.data.apiKeys || exportDataObj.data.apiKeys.length === 0) {
253
+ throw new Error('CSV format only supports API Keys export. Please use --types=apikeys')
254
+ }
255
+
256
+ const { apiKeys } = exportDataObj.data
257
+ const fields = Object.keys(CSV_FIELD_MAPPING)
258
+ const headers = Object.values(CSV_FIELD_MAPPING)
259
+
260
+ // 生成标题行
261
+ const csvLines = [headers.map(escapeCSVField).join(',')]
262
+
263
+ // 生成数据行
264
+ for (const apiKey of apiKeys) {
265
+ const row = fields.map((field) => {
266
+ const value = formatCSVValue(field, apiKey[field], shouldSanitize)
267
+ return escapeCSVField(value)
268
+ })
269
+ csvLines.push(row.join(','))
270
+ }
271
+
272
+ return csvLines.join('\n')
273
+ }
274
+
275
+ // 导出数据
276
+ async function exportData() {
277
+ try {
278
+ const format = params.format || 'json'
279
+ const fileExtension = format === 'csv' ? '.csv' : '.json'
280
+ const defaultFileName = `backup-${new Date().toISOString().split('T')[0]}${fileExtension}`
281
+ const outputFile = params.output || defaultFileName
282
+ const types = params.types ? params.types.split(',') : ['all']
283
+ const shouldSanitize = params.sanitize === true
284
+
285
+ // CSV 格式验证
286
+ if (format === 'csv' && !types.includes('apikeys') && !types.includes('all')) {
287
+ logger.error('❌ CSV format only supports API Keys export. Please use --types=apikeys')
288
+ process.exit(1)
289
+ }
290
+
291
+ logger.info('🔄 Starting data export...')
292
+ logger.info(`📁 Output file: ${outputFile}`)
293
+ logger.info(`📋 Data types: ${types.join(', ')}`)
294
+ logger.info(`📄 Output format: ${format.toUpperCase()}`)
295
+ logger.info(`🔒 Sanitize sensitive data: ${shouldSanitize ? 'YES' : 'NO'}`)
296
+
297
+ // 连接 Redis
298
+ await redis.connect()
299
+ logger.success('✅ Connected to Redis')
300
+
301
+ const exportDataObj = {
302
+ metadata: {
303
+ version: '1.0',
304
+ exportDate: new Date().toISOString(),
305
+ sanitized: shouldSanitize,
306
+ types
307
+ },
308
+ data: {}
309
+ }
310
+
311
+ // 导出 API Keys
312
+ if (types.includes('all') || types.includes('apikeys')) {
313
+ logger.info('📤 Exporting API Keys...')
314
+ const keys = await redis.client.keys('apikey:*')
315
+ const apiKeys = []
316
+
317
+ for (const key of keys) {
318
+ if (key === 'apikey:hash_map') {
319
+ continue
320
+ }
321
+
322
+ // 使用 hgetall 而不是 get,因为数据存储在哈希表中
323
+ const data = await redis.client.hgetall(key)
324
+
325
+ if (data && Object.keys(data).length > 0) {
326
+ apiKeys.push(shouldSanitize ? sanitizeData(data, 'apikey') : data)
327
+ }
328
+ }
329
+
330
+ exportDataObj.data.apiKeys = apiKeys
331
+ logger.success(`✅ Exported ${apiKeys.length} API Keys`)
332
+ }
333
+
334
+ // 导出 Claude 账户
335
+ if (types.includes('all') || types.includes('accounts')) {
336
+ logger.info('📤 Exporting Claude accounts...')
337
+ // 注意:Claude 账户使用 claude:account: 前缀,不是 claude_account:
338
+ const keys = await redis.client.keys('claude:account:*')
339
+ logger.info(`Found ${keys.length} Claude account keys in Redis`)
340
+ const accounts = []
341
+
342
+ for (const key of keys) {
343
+ // 使用 hgetall 而不是 get,因为数据存储在哈希表中
344
+ const data = await redis.client.hgetall(key)
345
+
346
+ if (data && Object.keys(data).length > 0) {
347
+ // 解析 JSON 字段(如果存在)
348
+ if (data.claudeAiOauth) {
349
+ try {
350
+ data.claudeAiOauth = JSON.parse(data.claudeAiOauth)
351
+ } catch (e) {
352
+ // 保���原样
353
+ }
354
+ }
355
+ accounts.push(shouldSanitize ? sanitizeData(data, 'claude_account') : data)
356
+ }
357
+ }
358
+
359
+ exportDataObj.data.claudeAccounts = accounts
360
+ logger.success(`✅ Exported ${accounts.length} Claude accounts`)
361
+
362
+ // 导出 Gemini 账户
363
+ logger.info('📤 Exporting Gemini accounts...')
364
+ const geminiKeys = await redis.client.keys('gemini_account:*')
365
+ logger.info(`Found ${geminiKeys.length} Gemini account keys in Redis`)
366
+ const geminiAccounts = []
367
+
368
+ for (const key of geminiKeys) {
369
+ // 使用 hgetall 而不是 get,因为数据存储在哈希表中
370
+ const data = await redis.client.hgetall(key)
371
+
372
+ if (data && Object.keys(data).length > 0) {
373
+ geminiAccounts.push(shouldSanitize ? sanitizeData(data, 'gemini_account') : data)
374
+ }
375
+ }
376
+
377
+ exportDataObj.data.geminiAccounts = geminiAccounts
378
+ logger.success(`✅ Exported ${geminiAccounts.length} Gemini accounts`)
379
+ }
380
+
381
+ // 导出管理员
382
+ if (types.includes('all') || types.includes('admins')) {
383
+ logger.info('📤 Exporting admins...')
384
+ const keys = await redis.client.keys('admin:*')
385
+ const admins = []
386
+
387
+ for (const key of keys) {
388
+ if (key.includes('admin_username:')) {
389
+ continue
390
+ }
391
+
392
+ // 使用 hgetall 而不是 get,因为数据存储在哈希表中
393
+ const data = await redis.client.hgetall(key)
394
+
395
+ if (data && Object.keys(data).length > 0) {
396
+ admins.push(shouldSanitize ? sanitizeData(data, 'admin') : data)
397
+ }
398
+ }
399
+
400
+ exportDataObj.data.admins = admins
401
+ logger.success(`✅ Exported ${admins.length} admins`)
402
+ }
403
+
404
+ // 根据格式写入文件
405
+ let fileContent
406
+ if (format === 'csv') {
407
+ fileContent = convertToCSV(exportDataObj, shouldSanitize)
408
+ // 添加 UTF-8 BOM 以便 Excel 正确识别中文
409
+ fileContent = `\ufeff${fileContent}`
410
+ await fs.writeFile(outputFile, fileContent, 'utf8')
411
+ } else {
412
+ await fs.writeFile(outputFile, JSON.stringify(exportDataObj, null, 2))
413
+ }
414
+
415
+ // 显示导出摘要
416
+ console.log(`\n${'='.repeat(60)}`)
417
+ console.log('✅ Export Complete!')
418
+ console.log('='.repeat(60))
419
+ console.log(`Output file: ${outputFile}`)
420
+ console.log(`File size: ${(await fs.stat(outputFile)).size} bytes`)
421
+
422
+ if (exportDataObj.data.apiKeys) {
423
+ console.log(`API Keys: ${exportDataObj.data.apiKeys.length}`)
424
+ }
425
+ if (exportDataObj.data.claudeAccounts) {
426
+ console.log(`Claude Accounts: ${exportDataObj.data.claudeAccounts.length}`)
427
+ }
428
+ if (exportDataObj.data.geminiAccounts) {
429
+ console.log(`Gemini Accounts: ${exportDataObj.data.geminiAccounts.length}`)
430
+ }
431
+ if (exportDataObj.data.admins) {
432
+ console.log(`Admins: ${exportDataObj.data.admins.length}`)
433
+ }
434
+ console.log('='.repeat(60))
435
+
436
+ if (shouldSanitize) {
437
+ logger.warn('⚠️ Sensitive data has been sanitized in this export.')
438
+ }
439
+ } catch (error) {
440
+ logger.error('💥 Export failed:', error)
441
+ process.exit(1)
442
+ } finally {
443
+ await redis.disconnect()
444
+ rl.close()
445
+ }
446
+ }
447
+
448
+ // 导入数据
449
+ async function importData() {
450
+ try {
451
+ const inputFile = params.input
452
+ if (!inputFile) {
453
+ logger.error('❌ Please specify input file with --input=filename.json')
454
+ process.exit(1)
455
+ }
456
+
457
+ const forceOverwrite = params.force === true
458
+ const skipConflicts = params['skip-conflicts'] === true
459
+
460
+ logger.info('🔄 Starting data import...')
461
+ logger.info(`📁 Input file: ${inputFile}`)
462
+ logger.info(
463
+ `⚡ Mode: ${forceOverwrite ? 'FORCE OVERWRITE' : skipConflicts ? 'SKIP CONFLICTS' : 'ASK ON CONFLICT'}`
464
+ )
465
+
466
+ // 读取文件
467
+ const fileContent = await fs.readFile(inputFile, 'utf8')
468
+ const importDataObj = JSON.parse(fileContent)
469
+
470
+ // 验证文件格式
471
+ if (!importDataObj.metadata || !importDataObj.data) {
472
+ logger.error('❌ Invalid backup file format')
473
+ process.exit(1)
474
+ }
475
+
476
+ logger.info(`📅 Backup date: ${importDataObj.metadata.exportDate}`)
477
+ logger.info(`🔒 Sanitized: ${importDataObj.metadata.sanitized ? 'YES' : 'NO'}`)
478
+
479
+ if (importDataObj.metadata.sanitized) {
480
+ logger.warn('⚠️ This backup contains sanitized data. Sensitive fields will be missing!')
481
+ const proceed = await askConfirmation('Continue with sanitized data?')
482
+ if (!proceed) {
483
+ logger.info('❌ Import cancelled')
484
+ return
485
+ }
486
+ }
487
+
488
+ // 显示导入摘要
489
+ console.log(`\n${'='.repeat(60)}`)
490
+ console.log('📋 Import Summary:')
491
+ console.log('='.repeat(60))
492
+ if (importDataObj.data.apiKeys) {
493
+ console.log(`API Keys to import: ${importDataObj.data.apiKeys.length}`)
494
+ }
495
+ if (importDataObj.data.claudeAccounts) {
496
+ console.log(`Claude Accounts to import: ${importDataObj.data.claudeAccounts.length}`)
497
+ }
498
+ if (importDataObj.data.geminiAccounts) {
499
+ console.log(`Gemini Accounts to import: ${importDataObj.data.geminiAccounts.length}`)
500
+ }
501
+ if (importDataObj.data.admins) {
502
+ console.log(`Admins to import: ${importDataObj.data.admins.length}`)
503
+ }
504
+ console.log(`${'='.repeat(60)}\n`)
505
+
506
+ // 确认导入
507
+ const confirmed = await askConfirmation('⚠️ Proceed with import?')
508
+ if (!confirmed) {
509
+ logger.info('❌ Import cancelled')
510
+ return
511
+ }
512
+
513
+ // 连接 Redis
514
+ await redis.connect()
515
+ logger.success('✅ Connected to Redis')
516
+
517
+ const stats = {
518
+ imported: 0,
519
+ skipped: 0,
520
+ errors: 0
521
+ }
522
+
523
+ // 导入 API Keys
524
+ if (importDataObj.data.apiKeys) {
525
+ logger.info('\n📥 Importing API Keys...')
526
+ for (const apiKey of importDataObj.data.apiKeys) {
527
+ try {
528
+ const exists = await redis.client.exists(`apikey:${apiKey.id}`)
529
+
530
+ if (exists && !forceOverwrite) {
531
+ if (skipConflicts) {
532
+ logger.warn(`⏭️ Skipped existing API Key: ${apiKey.name} (${apiKey.id})`)
533
+ stats.skipped++
534
+ continue
535
+ } else {
536
+ const overwrite = await askConfirmation(
537
+ `API Key "${apiKey.name}" (${apiKey.id}) exists. Overwrite?`
538
+ )
539
+ if (!overwrite) {
540
+ stats.skipped++
541
+ continue
542
+ }
543
+ }
544
+ }
545
+
546
+ // 使用 hset 存储到哈希表
547
+ const pipeline = redis.client.pipeline()
548
+ for (const [field, value] of Object.entries(apiKey)) {
549
+ pipeline.hset(`apikey:${apiKey.id}`, field, value)
550
+ }
551
+ await pipeline.exec()
552
+
553
+ // 更新哈希映射
554
+ if (apiKey.apiKey && !importDataObj.metadata.sanitized) {
555
+ await redis.client.hset('apikey:hash_map', apiKey.apiKey, apiKey.id)
556
+ }
557
+
558
+ logger.success(`✅ Imported API Key: ${apiKey.name} (${apiKey.id})`)
559
+ stats.imported++
560
+ } catch (error) {
561
+ logger.error(`❌ Failed to import API Key ${apiKey.id}:`, error.message)
562
+ stats.errors++
563
+ }
564
+ }
565
+ }
566
+
567
+ // 导入 Claude 账户
568
+ if (importDataObj.data.claudeAccounts) {
569
+ logger.info('\n📥 Importing Claude accounts...')
570
+ for (const account of importDataObj.data.claudeAccounts) {
571
+ try {
572
+ const exists = await redis.client.exists(`claude_account:${account.id}`)
573
+
574
+ if (exists && !forceOverwrite) {
575
+ if (skipConflicts) {
576
+ logger.warn(`⏭️ Skipped existing Claude account: ${account.name} (${account.id})`)
577
+ stats.skipped++
578
+ continue
579
+ } else {
580
+ const overwrite = await askConfirmation(
581
+ `Claude account "${account.name}" (${account.id}) exists. Overwrite?`
582
+ )
583
+ if (!overwrite) {
584
+ stats.skipped++
585
+ continue
586
+ }
587
+ }
588
+ }
589
+
590
+ // 使用 hset 存储到哈希表
591
+ const pipeline = redis.client.pipeline()
592
+ for (const [field, value] of Object.entries(account)) {
593
+ // 如果是对象,需要序列化
594
+ if (field === 'claudeAiOauth' && typeof value === 'object') {
595
+ pipeline.hset(`claude_account:${account.id}`, field, JSON.stringify(value))
596
+ } else {
597
+ pipeline.hset(`claude_account:${account.id}`, field, value)
598
+ }
599
+ }
600
+ await pipeline.exec()
601
+ logger.success(`✅ Imported Claude account: ${account.name} (${account.id})`)
602
+ stats.imported++
603
+ } catch (error) {
604
+ logger.error(`❌ Failed to import Claude account ${account.id}:`, error.message)
605
+ stats.errors++
606
+ }
607
+ }
608
+ }
609
+
610
+ // 导入 Gemini 账户
611
+ if (importDataObj.data.geminiAccounts) {
612
+ logger.info('\n📥 Importing Gemini accounts...')
613
+ for (const account of importDataObj.data.geminiAccounts) {
614
+ try {
615
+ const exists = await redis.client.exists(`gemini_account:${account.id}`)
616
+
617
+ if (exists && !forceOverwrite) {
618
+ if (skipConflicts) {
619
+ logger.warn(`⏭️ Skipped existing Gemini account: ${account.name} (${account.id})`)
620
+ stats.skipped++
621
+ continue
622
+ } else {
623
+ const overwrite = await askConfirmation(
624
+ `Gemini account "${account.name}" (${account.id}) exists. Overwrite?`
625
+ )
626
+ if (!overwrite) {
627
+ stats.skipped++
628
+ continue
629
+ }
630
+ }
631
+ }
632
+
633
+ // 使用 hset 存储到哈希表
634
+ const pipeline = redis.client.pipeline()
635
+ for (const [field, value] of Object.entries(account)) {
636
+ pipeline.hset(`gemini_account:${account.id}`, field, value)
637
+ }
638
+ await pipeline.exec()
639
+ logger.success(`✅ Imported Gemini account: ${account.name} (${account.id})`)
640
+ stats.imported++
641
+ } catch (error) {
642
+ logger.error(`❌ Failed to import Gemini account ${account.id}:`, error.message)
643
+ stats.errors++
644
+ }
645
+ }
646
+ }
647
+
648
+ // 显示导入结果
649
+ console.log(`\n${'='.repeat(60)}`)
650
+ console.log('✅ Import Complete!')
651
+ console.log('='.repeat(60))
652
+ console.log(`Successfully imported: ${stats.imported}`)
653
+ console.log(`Skipped: ${stats.skipped}`)
654
+ console.log(`Errors: ${stats.errors}`)
655
+ console.log('='.repeat(60))
656
+ } catch (error) {
657
+ logger.error('💥 Import failed:', error)
658
+ process.exit(1)
659
+ } finally {
660
+ await redis.disconnect()
661
+ rl.close()
662
+ }
663
+ }
664
+
665
+ // 显示帮助信息
666
+ function showHelp() {
667
+ console.log(`
668
+ Data Transfer Tool for Claude Relay Service
669
+
670
+ This tool allows you to export and import data between environments.
671
+
672
+ Usage:
673
+ node scripts/data-transfer.js <command> [options]
674
+
675
+ Commands:
676
+ export Export data from Redis to a JSON file
677
+ import Import data from a JSON file to Redis
678
+
679
+ Export Options:
680
+ --output=FILE Output filename (default: backup-YYYY-MM-DD.json/.csv)
681
+ --types=TYPE,... Data types to export: apikeys,accounts,admins,all (default: all)
682
+ --format=FORMAT Output format: json,csv (default: json)
683
+ --sanitize Remove sensitive data from export
684
+
685
+ Import Options:
686
+ --input=FILE Input filename (required)
687
+ --force Overwrite existing data without asking
688
+ --skip-conflicts Skip conflicting data without asking
689
+
690
+ Examples:
691
+ # Export all data
692
+ node scripts/data-transfer.js export
693
+
694
+ # Export only API keys with sanitized data
695
+ node scripts/data-transfer.js export --types=apikeys --sanitize
696
+
697
+ # Import data, skip conflicts
698
+ node scripts/data-transfer.js import --input=backup.json --skip-conflicts
699
+
700
+ # Export specific data types
701
+ node scripts/data-transfer.js export --types=apikeys,accounts --output=prod-data.json
702
+
703
+ # Export API keys to CSV format
704
+ node scripts/data-transfer.js export --types=apikeys --format=csv --sanitize
705
+
706
+ # Export to CSV with custom filename
707
+ node scripts/data-transfer.js export --types=apikeys --format=csv --output=api-keys.csv
708
+ `)
709
+ }
710
+
711
+ // 主函数
712
+ async function main() {
713
+ if (!command || command === '--help' || command === 'help') {
714
+ showHelp()
715
+ process.exit(0)
716
+ }
717
+
718
+ switch (command) {
719
+ case 'export':
720
+ await exportData()
721
+ break
722
+
723
+ case 'import':
724
+ await importData()
725
+ break
726
+
727
+ default:
728
+ logger.error(`❌ Unknown command: ${command}`)
729
+ showHelp()
730
+ process.exit(1)
731
+ }
732
+ }
733
+
734
+ // 运行
735
+ main().catch((error) => {
736
+ logger.error('💥 Unexpected error:', error)
737
+ process.exit(1)
738
+ })
scripts/debug-redis-keys.js ADDED
@@ -0,0 +1,126 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Redis 键调试工具
5
+ * 用于查看 Redis 中存储的所有键和数据结构
6
+ */
7
+
8
+ const redis = require('../src/models/redis')
9
+ const logger = require('../src/utils/logger')
10
+
11
+ async function debugRedisKeys() {
12
+ try {
13
+ logger.info('🔄 Connecting to Redis...')
14
+ await redis.connect()
15
+ logger.success('✅ Connected to Redis')
16
+
17
+ // 获取所有键
18
+ const allKeys = await redis.client.keys('*')
19
+ logger.info(`\n📊 Total keys in Redis: ${allKeys.length}\n`)
20
+
21
+ // 按类型分组
22
+ const keysByType = {
23
+ apiKeys: [],
24
+ claudeAccounts: [],
25
+ geminiAccounts: [],
26
+ admins: [],
27
+ sessions: [],
28
+ usage: [],
29
+ other: []
30
+ }
31
+
32
+ // 分类键
33
+ for (const key of allKeys) {
34
+ if (key.startsWith('apikey:')) {
35
+ keysByType.apiKeys.push(key)
36
+ } else if (key.startsWith('claude_account:')) {
37
+ keysByType.claudeAccounts.push(key)
38
+ } else if (key.startsWith('gemini_account:')) {
39
+ keysByType.geminiAccounts.push(key)
40
+ } else if (key.startsWith('admin:') || key.startsWith('admin_username:')) {
41
+ keysByType.admins.push(key)
42
+ } else if (key.startsWith('session:')) {
43
+ keysByType.sessions.push(key)
44
+ } else if (
45
+ key.includes('usage') ||
46
+ key.includes('rate_limit') ||
47
+ key.includes('concurrency')
48
+ ) {
49
+ keysByType.usage.push(key)
50
+ } else {
51
+ keysByType.other.push(key)
52
+ }
53
+ }
54
+
55
+ // 显示分类结果
56
+ console.log('='.repeat(60))
57
+ console.log('📂 Keys by Category:')
58
+ console.log('='.repeat(60))
59
+ console.log(`API Keys: ${keysByType.apiKeys.length}`)
60
+ console.log(`Claude Accounts: ${keysByType.claudeAccounts.length}`)
61
+ console.log(`Gemini Accounts: ${keysByType.geminiAccounts.length}`)
62
+ console.log(`Admins: ${keysByType.admins.length}`)
63
+ console.log(`Sessions: ${keysByType.sessions.length}`)
64
+ console.log(`Usage/Rate Limit: ${keysByType.usage.length}`)
65
+ console.log(`Other: ${keysByType.other.length}`)
66
+ console.log('='.repeat(60))
67
+
68
+ // 详细显示每个类别的键
69
+ if (keysByType.apiKeys.length > 0) {
70
+ console.log('\n🔑 API Keys:')
71
+ for (const key of keysByType.apiKeys.slice(0, 5)) {
72
+ console.log(` - ${key}`)
73
+ }
74
+ if (keysByType.apiKeys.length > 5) {
75
+ console.log(` ... and ${keysByType.apiKeys.length - 5} more`)
76
+ }
77
+ }
78
+
79
+ if (keysByType.claudeAccounts.length > 0) {
80
+ console.log('\n🤖 Claude Accounts:')
81
+ for (const key of keysByType.claudeAccounts) {
82
+ console.log(` - ${key}`)
83
+ }
84
+ }
85
+
86
+ if (keysByType.geminiAccounts.length > 0) {
87
+ console.log('\n💎 Gemini Accounts:')
88
+ for (const key of keysByType.geminiAccounts) {
89
+ console.log(` - ${key}`)
90
+ }
91
+ }
92
+
93
+ if (keysByType.other.length > 0) {
94
+ console.log('\n❓ Other Keys:')
95
+ for (const key of keysByType.other.slice(0, 10)) {
96
+ console.log(` - ${key}`)
97
+ }
98
+ if (keysByType.other.length > 10) {
99
+ console.log(` ... and ${keysByType.other.length - 10} more`)
100
+ }
101
+ }
102
+
103
+ // 检查数据类型
104
+ console.log(`\n${'='.repeat(60)}`)
105
+ console.log('🔍 Checking Data Types:')
106
+ console.log('='.repeat(60))
107
+
108
+ // 随机检查几个键的类型
109
+ const sampleKeys = allKeys.slice(0, Math.min(10, allKeys.length))
110
+ for (const key of sampleKeys) {
111
+ const type = await redis.client.type(key)
112
+ console.log(`${key} => ${type}`)
113
+ }
114
+ } catch (error) {
115
+ logger.error('💥 Debug failed:', error)
116
+ } finally {
117
+ await redis.disconnect()
118
+ logger.info('👋 Disconnected from Redis')
119
+ }
120
+ }
121
+
122
+ // 运行调试
123
+ debugRedisKeys().catch((error) => {
124
+ logger.error('💥 Unexpected error:', error)
125
+ process.exit(1)
126
+ })
scripts/fix-inquirer.js ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * 修复 inquirer ESM 问题
5
+ * 降级到支持 CommonJS 的版本
6
+ */
7
+
8
+ const { execSync } = require('child_process')
9
+
10
+ console.log('🔧 修复 inquirer ESM 兼容性问题...\n')
11
+
12
+ try {
13
+ // 卸载当前版本
14
+ console.log('📦 卸载当前 inquirer 版本...')
15
+ execSync('npm uninstall inquirer', { stdio: 'inherit' })
16
+
17
+ // 安装兼容 CommonJS 的版本 (8.x 是最后支持 CommonJS 的主要版本)
18
+ console.log('\n📦 安装兼容版本 inquirer@8.2.6...')
19
+ execSync('npm install inquirer@8.2.6', { stdio: 'inherit' })
20
+
21
+ console.log('\n✅ 修复完成!')
22
+ console.log('\n现在可以正常使用 CLI 工具了:')
23
+ console.log(' npm run cli admin')
24
+ console.log(' npm run cli keys')
25
+ console.log(' npm run cli status')
26
+ } catch (error) {
27
+ console.error('❌ 修复失败:', error.message)
28
+ process.exit(1)
29
+ }
scripts/fix-usage-stats.js ADDED
@@ -0,0 +1,227 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * 数据迁移脚本:修复历史使用统计数据
5
+ *
6
+ * 功能:
7
+ * 1. 统一 totalTokens 和 allTokens 字段
8
+ * 2. 确保 allTokens 包含所有类型的 tokens
9
+ * 3. 修复历史数据的不一致性
10
+ *
11
+ * 使用方法:
12
+ * node scripts/fix-usage-stats.js [--dry-run]
13
+ */
14
+
15
+ require('dotenv').config()
16
+ const redis = require('../src/models/redis')
17
+ const logger = require('../src/utils/logger')
18
+
19
+ // 解析命令行参数
20
+ const args = process.argv.slice(2)
21
+ const isDryRun = args.includes('--dry-run')
22
+
23
+ async function fixUsageStats() {
24
+ try {
25
+ logger.info('🔧 开始修复使用统计数据...')
26
+ if (isDryRun) {
27
+ logger.info('📝 DRY RUN 模式 - 不会实际修改数据')
28
+ }
29
+
30
+ // 连接到 Redis
31
+ await redis.connect()
32
+ logger.success('✅ 已连接到 Redis')
33
+
34
+ const client = redis.getClientSafe()
35
+
36
+ // 统计信息
37
+ const stats = {
38
+ totalKeys: 0,
39
+ fixedTotalKeys: 0,
40
+ fixedDailyKeys: 0,
41
+ fixedMonthlyKeys: 0,
42
+ fixedModelKeys: 0,
43
+ errors: 0
44
+ }
45
+
46
+ // 1. 修复 API Key 级别的总统计
47
+ logger.info('\n📊 修复 API Key 总统计数据...')
48
+ const apiKeyPattern = 'apikey:*'
49
+ const apiKeys = await client.keys(apiKeyPattern)
50
+ stats.totalKeys = apiKeys.length
51
+
52
+ for (const apiKeyKey of apiKeys) {
53
+ const keyId = apiKeyKey.replace('apikey:', '')
54
+ const usageKey = `usage:${keyId}`
55
+
56
+ try {
57
+ const usageData = await client.hgetall(usageKey)
58
+ if (usageData && Object.keys(usageData).length > 0) {
59
+ const inputTokens = parseInt(usageData.totalInputTokens) || 0
60
+ const outputTokens = parseInt(usageData.totalOutputTokens) || 0
61
+ const cacheCreateTokens = parseInt(usageData.totalCacheCreateTokens) || 0
62
+ const cacheReadTokens = parseInt(usageData.totalCacheReadTokens) || 0
63
+ const currentAllTokens = parseInt(usageData.totalAllTokens) || 0
64
+
65
+ // 计算正确的 allTokens
66
+ const correctAllTokens = inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens
67
+
68
+ if (currentAllTokens !== correctAllTokens && correctAllTokens > 0) {
69
+ logger.info(` 修复 ${keyId}: ${currentAllTokens} -> ${correctAllTokens}`)
70
+
71
+ if (!isDryRun) {
72
+ await client.hset(usageKey, 'totalAllTokens', correctAllTokens)
73
+ }
74
+ stats.fixedTotalKeys++
75
+ }
76
+ }
77
+ } catch (error) {
78
+ logger.error(` 错误处理 ${keyId}: ${error.message}`)
79
+ stats.errors++
80
+ }
81
+ }
82
+
83
+ // 2. 修复每日统计数据
84
+ logger.info('\n📅 修复每日统计数据...')
85
+ const dailyPattern = 'usage:daily:*'
86
+ const dailyKeys = await client.keys(dailyPattern)
87
+
88
+ for (const dailyKey of dailyKeys) {
89
+ try {
90
+ const data = await client.hgetall(dailyKey)
91
+ if (data && Object.keys(data).length > 0) {
92
+ const inputTokens = parseInt(data.inputTokens) || 0
93
+ const outputTokens = parseInt(data.outputTokens) || 0
94
+ const cacheCreateTokens = parseInt(data.cacheCreateTokens) || 0
95
+ const cacheReadTokens = parseInt(data.cacheReadTokens) || 0
96
+ const currentAllTokens = parseInt(data.allTokens) || 0
97
+
98
+ const correctAllTokens = inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens
99
+
100
+ if (currentAllTokens !== correctAllTokens && correctAllTokens > 0) {
101
+ if (!isDryRun) {
102
+ await client.hset(dailyKey, 'allTokens', correctAllTokens)
103
+ }
104
+ stats.fixedDailyKeys++
105
+ }
106
+ }
107
+ } catch (error) {
108
+ logger.error(` 错误处理 ${dailyKey}: ${error.message}`)
109
+ stats.errors++
110
+ }
111
+ }
112
+
113
+ // 3. 修复每月统计数据
114
+ logger.info('\n📆 修复每月统计数据...')
115
+ const monthlyPattern = 'usage:monthly:*'
116
+ const monthlyKeys = await client.keys(monthlyPattern)
117
+
118
+ for (const monthlyKey of monthlyKeys) {
119
+ try {
120
+ const data = await client.hgetall(monthlyKey)
121
+ if (data && Object.keys(data).length > 0) {
122
+ const inputTokens = parseInt(data.inputTokens) || 0
123
+ const outputTokens = parseInt(data.outputTokens) || 0
124
+ const cacheCreateTokens = parseInt(data.cacheCreateTokens) || 0
125
+ const cacheReadTokens = parseInt(data.cacheReadTokens) || 0
126
+ const currentAllTokens = parseInt(data.allTokens) || 0
127
+
128
+ const correctAllTokens = inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens
129
+
130
+ if (currentAllTokens !== correctAllTokens && correctAllTokens > 0) {
131
+ if (!isDryRun) {
132
+ await client.hset(monthlyKey, 'allTokens', correctAllTokens)
133
+ }
134
+ stats.fixedMonthlyKeys++
135
+ }
136
+ }
137
+ } catch (error) {
138
+ logger.error(` 错误处理 ${monthlyKey}: ${error.message}`)
139
+ stats.errors++
140
+ }
141
+ }
142
+
143
+ // 4. 修复模型级别的统计数据
144
+ logger.info('\n🤖 修复模型级别统计数据...')
145
+ const modelPatterns = [
146
+ 'usage:model:daily:*',
147
+ 'usage:model:monthly:*',
148
+ 'usage:*:model:daily:*',
149
+ 'usage:*:model:monthly:*'
150
+ ]
151
+
152
+ for (const pattern of modelPatterns) {
153
+ const modelKeys = await client.keys(pattern)
154
+
155
+ for (const modelKey of modelKeys) {
156
+ try {
157
+ const data = await client.hgetall(modelKey)
158
+ if (data && Object.keys(data).length > 0) {
159
+ const inputTokens = parseInt(data.inputTokens) || 0
160
+ const outputTokens = parseInt(data.outputTokens) || 0
161
+ const cacheCreateTokens = parseInt(data.cacheCreateTokens) || 0
162
+ const cacheReadTokens = parseInt(data.cacheReadTokens) || 0
163
+ const currentAllTokens = parseInt(data.allTokens) || 0
164
+
165
+ const correctAllTokens =
166
+ inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens
167
+
168
+ if (currentAllTokens !== correctAllTokens && correctAllTokens > 0) {
169
+ if (!isDryRun) {
170
+ await client.hset(modelKey, 'allTokens', correctAllTokens)
171
+ }
172
+ stats.fixedModelKeys++
173
+ }
174
+ }
175
+ } catch (error) {
176
+ logger.error(` 错误处理 ${modelKey}: ${error.message}`)
177
+ stats.errors++
178
+ }
179
+ }
180
+ }
181
+
182
+ // 5. 验证修复结果
183
+ if (!isDryRun) {
184
+ logger.info('\n✅ 验证修复结果...')
185
+
186
+ // 随机抽样验证
187
+ const sampleSize = Math.min(5, apiKeys.length)
188
+ for (let i = 0; i < sampleSize; i++) {
189
+ const randomIndex = Math.floor(Math.random() * apiKeys.length)
190
+ const keyId = apiKeys[randomIndex].replace('apikey:', '')
191
+ const usage = await redis.getUsageStats(keyId)
192
+
193
+ logger.info(` 样本 ${keyId}:`)
194
+ logger.info(` Total tokens: ${usage.total.tokens}`)
195
+ logger.info(` All tokens: ${usage.total.allTokens}`)
196
+ logger.info(` 一致性: ${usage.total.tokens === usage.total.allTokens ? '✅' : '❌'}`)
197
+ }
198
+ }
199
+
200
+ // 打印统计结果
201
+ logger.info('\n📊 修复统计:')
202
+ logger.info(` 总 API Keys: ${stats.totalKeys}`)
203
+ logger.info(` 修复的总统计: ${stats.fixedTotalKeys}`)
204
+ logger.info(` 修复的日统计: ${stats.fixedDailyKeys}`)
205
+ logger.info(` 修复的月统计: ${stats.fixedMonthlyKeys}`)
206
+ logger.info(` 修复的模型统计: ${stats.fixedModelKeys}`)
207
+ logger.info(` 错误数: ${stats.errors}`)
208
+
209
+ if (isDryRun) {
210
+ logger.info('\n💡 这是 DRY RUN - 没有实际修改数据')
211
+ logger.info(' 运行不带 --dry-run 参数来实际执行修复')
212
+ } else {
213
+ logger.success('\n✅ 数据修复完成!')
214
+ }
215
+ } catch (error) {
216
+ logger.error('❌ 修复过程出错:', error)
217
+ process.exit(1)
218
+ } finally {
219
+ await redis.disconnect()
220
+ }
221
+ }
222
+
223
+ // 执行修复
224
+ fixUsageStats().catch((error) => {
225
+ logger.error('❌ 未处理的错误:', error)
226
+ process.exit(1)
227
+ })
scripts/generate-test-data.js ADDED
@@ -0,0 +1,280 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * 历史数据生成脚本
5
+ * 用于测试不同时间范围的Token统计功能
6
+ *
7
+ * 使用方法:
8
+ * node scripts/generate-test-data.js [--clean]
9
+ *
10
+ * 选项:
11
+ * --clean: 清除所有测试数据
12
+ */
13
+
14
+ const redis = require('../src/models/redis')
15
+ const logger = require('../src/utils/logger')
16
+
17
+ // 解析命令行参数
18
+ const args = process.argv.slice(2)
19
+ const shouldClean = args.includes('--clean')
20
+
21
+ // 模拟的模型列表
22
+ const models = [
23
+ 'claude-sonnet-4-20250514',
24
+ 'claude-3-5-sonnet-20241022',
25
+ 'claude-3-5-haiku-20241022',
26
+ 'claude-3-opus-20240229'
27
+ ]
28
+
29
+ // 生成指定日期的数据
30
+ async function generateDataForDate(apiKeyId, date, dayOffset) {
31
+ const client = redis.getClientSafe()
32
+ const dateStr = date.toISOString().split('T')[0]
33
+ const month = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`
34
+
35
+ // 根据日期偏移量调整数据量(越近的日期数据越多)
36
+ const requestCount = Math.max(5, 20 - dayOffset * 2) // 5-20个请求
37
+
38
+ logger.info(`📊 Generating ${requestCount} requests for ${dateStr}`)
39
+
40
+ for (let i = 0; i < requestCount; i++) {
41
+ // 随机选择模型
42
+ const model = models[Math.floor(Math.random() * models.length)]
43
+
44
+ // 生成随机Token数据
45
+ const inputTokens = Math.floor(Math.random() * 2000) + 500 // 500-2500
46
+ const outputTokens = Math.floor(Math.random() * 3000) + 1000 // 1000-4000
47
+ const cacheCreateTokens = Math.random() > 0.7 ? Math.floor(Math.random() * 1000) : 0 // 30%概率有缓存创建
48
+ const cacheReadTokens = Math.random() > 0.5 ? Math.floor(Math.random() * 500) : 0 // 50%概率有缓存读取
49
+
50
+ const coreTokens = inputTokens + outputTokens
51
+ const allTokens = inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens
52
+
53
+ // 更新各种统计键
54
+ const totalKey = `usage:${apiKeyId}`
55
+ const dailyKey = `usage:daily:${apiKeyId}:${dateStr}`
56
+ const monthlyKey = `usage:monthly:${apiKeyId}:${month}`
57
+ const modelDailyKey = `usage:model:daily:${model}:${dateStr}`
58
+ const modelMonthlyKey = `usage:model:monthly:${model}:${month}`
59
+ const keyModelDailyKey = `usage:${apiKeyId}:model:daily:${model}:${dateStr}`
60
+ const keyModelMonthlyKey = `usage:${apiKeyId}:model:monthly:${model}:${month}`
61
+
62
+ await Promise.all([
63
+ // 总计数据
64
+ client.hincrby(totalKey, 'totalTokens', coreTokens),
65
+ client.hincrby(totalKey, 'totalInputTokens', inputTokens),
66
+ client.hincrby(totalKey, 'totalOutputTokens', outputTokens),
67
+ client.hincrby(totalKey, 'totalCacheCreateTokens', cacheCreateTokens),
68
+ client.hincrby(totalKey, 'totalCacheReadTokens', cacheReadTokens),
69
+ client.hincrby(totalKey, 'totalAllTokens', allTokens),
70
+ client.hincrby(totalKey, 'totalRequests', 1),
71
+
72
+ // 每日统计
73
+ client.hincrby(dailyKey, 'tokens', coreTokens),
74
+ client.hincrby(dailyKey, 'inputTokens', inputTokens),
75
+ client.hincrby(dailyKey, 'outputTokens', outputTokens),
76
+ client.hincrby(dailyKey, 'cacheCreateTokens', cacheCreateTokens),
77
+ client.hincrby(dailyKey, 'cacheReadTokens', cacheReadTokens),
78
+ client.hincrby(dailyKey, 'allTokens', allTokens),
79
+ client.hincrby(dailyKey, 'requests', 1),
80
+
81
+ // 每月统计
82
+ client.hincrby(monthlyKey, 'tokens', coreTokens),
83
+ client.hincrby(monthlyKey, 'inputTokens', inputTokens),
84
+ client.hincrby(monthlyKey, 'outputTokens', outputTokens),
85
+ client.hincrby(monthlyKey, 'cacheCreateTokens', cacheCreateTokens),
86
+ client.hincrby(monthlyKey, 'cacheReadTokens', cacheReadTokens),
87
+ client.hincrby(monthlyKey, 'allTokens', allTokens),
88
+ client.hincrby(monthlyKey, 'requests', 1),
89
+
90
+ // 模型统计 - 每日
91
+ client.hincrby(modelDailyKey, 'totalInputTokens', inputTokens),
92
+ client.hincrby(modelDailyKey, 'totalOutputTokens', outputTokens),
93
+ client.hincrby(modelDailyKey, 'totalCacheCreateTokens', cacheCreateTokens),
94
+ client.hincrby(modelDailyKey, 'totalCacheReadTokens', cacheReadTokens),
95
+ client.hincrby(modelDailyKey, 'totalAllTokens', allTokens),
96
+ client.hincrby(modelDailyKey, 'requests', 1),
97
+
98
+ // 模型统计 - 每月
99
+ client.hincrby(modelMonthlyKey, 'totalInputTokens', inputTokens),
100
+ client.hincrby(modelMonthlyKey, 'totalOutputTokens', outputTokens),
101
+ client.hincrby(modelMonthlyKey, 'totalCacheCreateTokens', cacheCreateTokens),
102
+ client.hincrby(modelMonthlyKey, 'totalCacheReadTokens', cacheReadTokens),
103
+ client.hincrby(modelMonthlyKey, 'totalAllTokens', allTokens),
104
+ client.hincrby(modelMonthlyKey, 'requests', 1),
105
+
106
+ // API Key级别的模型统计 - 每日
107
+ // 同时存储带total前缀和不带前缀的字段,保持兼容性
108
+ client.hincrby(keyModelDailyKey, 'inputTokens', inputTokens),
109
+ client.hincrby(keyModelDailyKey, 'outputTokens', outputTokens),
110
+ client.hincrby(keyModelDailyKey, 'cacheCreateTokens', cacheCreateTokens),
111
+ client.hincrby(keyModelDailyKey, 'cacheReadTokens', cacheReadTokens),
112
+ client.hincrby(keyModelDailyKey, 'allTokens', allTokens),
113
+ client.hincrby(keyModelDailyKey, 'totalInputTokens', inputTokens),
114
+ client.hincrby(keyModelDailyKey, 'totalOutputTokens', outputTokens),
115
+ client.hincrby(keyModelDailyKey, 'totalCacheCreateTokens', cacheCreateTokens),
116
+ client.hincrby(keyModelDailyKey, 'totalCacheReadTokens', cacheReadTokens),
117
+ client.hincrby(keyModelDailyKey, 'totalAllTokens', allTokens),
118
+ client.hincrby(keyModelDailyKey, 'requests', 1),
119
+
120
+ // API Key级别的模型统计 - 每月
121
+ client.hincrby(keyModelMonthlyKey, 'inputTokens', inputTokens),
122
+ client.hincrby(keyModelMonthlyKey, 'outputTokens', outputTokens),
123
+ client.hincrby(keyModelMonthlyKey, 'cacheCreateTokens', cacheCreateTokens),
124
+ client.hincrby(keyModelMonthlyKey, 'cacheReadTokens', cacheReadTokens),
125
+ client.hincrby(keyModelMonthlyKey, 'allTokens', allTokens),
126
+ client.hincrby(keyModelMonthlyKey, 'totalInputTokens', inputTokens),
127
+ client.hincrby(keyModelMonthlyKey, 'totalOutputTokens', outputTokens),
128
+ client.hincrby(keyModelMonthlyKey, 'totalCacheCreateTokens', cacheCreateTokens),
129
+ client.hincrby(keyModelMonthlyKey, 'totalCacheReadTokens', cacheReadTokens),
130
+ client.hincrby(keyModelMonthlyKey, 'totalAllTokens', allTokens),
131
+ client.hincrby(keyModelMonthlyKey, 'requests', 1)
132
+ ])
133
+ }
134
+ }
135
+
136
+ // 清除测试数据
137
+ async function cleanTestData() {
138
+ const client = redis.getClientSafe()
139
+ const apiKeyService = require('../src/services/apiKeyService')
140
+
141
+ logger.info('🧹 Cleaning test data...')
142
+
143
+ // 获取所有API Keys
144
+ const allKeys = await apiKeyService.getAllApiKeys()
145
+
146
+ // 找出所有测试 API Keys
147
+ const testKeys = allKeys.filter((key) => key.name && key.name.startsWith('Test API Key'))
148
+
149
+ for (const testKey of testKeys) {
150
+ const apiKeyId = testKey.id
151
+
152
+ // 获取所有相关的键
153
+ const patterns = [
154
+ `usage:${apiKeyId}`,
155
+ `usage:daily:${apiKeyId}:*`,
156
+ `usage:monthly:${apiKeyId}:*`,
157
+ `usage:${apiKeyId}:model:daily:*`,
158
+ `usage:${apiKeyId}:model:monthly:*`
159
+ ]
160
+
161
+ for (const pattern of patterns) {
162
+ const keys = await client.keys(pattern)
163
+ if (keys.length > 0) {
164
+ await client.del(...keys)
165
+ logger.info(`🗑️ Deleted ${keys.length} keys matching pattern: ${pattern}`)
166
+ }
167
+ }
168
+
169
+ // 删除 API Key 本身
170
+ await apiKeyService.deleteApiKey(apiKeyId)
171
+ logger.info(`🗑️ Deleted test API Key: ${testKey.name} (${apiKeyId})`)
172
+ }
173
+
174
+ // 清除模型统计
175
+ const modelPatterns = ['usage:model:daily:*', 'usage:model:monthly:*']
176
+
177
+ for (const pattern of modelPatterns) {
178
+ const keys = await client.keys(pattern)
179
+ if (keys.length > 0) {
180
+ await client.del(...keys)
181
+ logger.info(`🗑️ Deleted ${keys.length} keys matching pattern: ${pattern}`)
182
+ }
183
+ }
184
+ }
185
+
186
+ // 主函数
187
+ async function main() {
188
+ try {
189
+ await redis.connect()
190
+ logger.success('✅ Connected to Redis')
191
+
192
+ // 创建测试API Keys
193
+ const apiKeyService = require('../src/services/apiKeyService')
194
+ const testApiKeys = []
195
+ const createdKeys = []
196
+
197
+ // 总是创建新的测试 API Keys
198
+ logger.info('📝 Creating test API Keys...')
199
+
200
+ for (let i = 1; i <= 3; i++) {
201
+ const newKey = await apiKeyService.generateApiKey({
202
+ name: `Test API Key ${i}`,
203
+ description: `Test key for historical data generation ${i}`,
204
+ tokenLimit: 10000000,
205
+ concurrencyLimit: 10,
206
+ rateLimitWindow: 60,
207
+ rateLimitRequests: 100
208
+ })
209
+
210
+ testApiKeys.push(newKey.id)
211
+ createdKeys.push(newKey)
212
+ logger.success(`✅ Created test API Key: ${newKey.name} (${newKey.id})`)
213
+ logger.info(` 🔑 API Key: ${newKey.apiKey}`)
214
+ }
215
+
216
+ if (shouldClean) {
217
+ await cleanTestData()
218
+ logger.success('✅ Test data cleaned successfully')
219
+ return
220
+ }
221
+
222
+ // 生成历史数据
223
+ const now = new Date()
224
+
225
+ for (const apiKeyId of testApiKeys) {
226
+ logger.info(`\n🔄 Generating data for API Key: ${apiKeyId}`)
227
+
228
+ // 生成过去30天的数据
229
+ for (let dayOffset = 0; dayOffset < 30; dayOffset++) {
230
+ const date = new Date(now)
231
+ date.setDate(date.getDate() - dayOffset)
232
+
233
+ await generateDataForDate(apiKeyId, date, dayOffset)
234
+ }
235
+
236
+ logger.success(`✅ Generated 30 days of historical data for API Key: ${apiKeyId}`)
237
+ }
238
+
239
+ // 显示统计摘要
240
+ logger.info('\n📊 Test Data Summary:')
241
+ logger.info('='.repeat(60))
242
+
243
+ for (const apiKeyId of testApiKeys) {
244
+ const totalKey = `usage:${apiKeyId}`
245
+ const totalData = await redis.getClientSafe().hgetall(totalKey)
246
+
247
+ if (totalData && Object.keys(totalData).length > 0) {
248
+ logger.info(`\nAPI Key: ${apiKeyId}`)
249
+ logger.info(` Total Requests: ${totalData.totalRequests || 0}`)
250
+ logger.info(` Total Tokens (Core): ${totalData.totalTokens || 0}`)
251
+ logger.info(` Total Tokens (All): ${totalData.totalAllTokens || 0}`)
252
+ logger.info(` Input Tokens: ${totalData.totalInputTokens || 0}`)
253
+ logger.info(` Output Tokens: ${totalData.totalOutputTokens || 0}`)
254
+ logger.info(` Cache Create Tokens: ${totalData.totalCacheCreateTokens || 0}`)
255
+ logger.info(` Cache Read Tokens: ${totalData.totalCacheReadTokens || 0}`)
256
+ }
257
+ }
258
+
259
+ logger.info(`\n${'='.repeat(60)}`)
260
+ logger.success('\n✅ Test data generation completed!')
261
+ logger.info('\n📋 Created API Keys:')
262
+ for (const key of createdKeys) {
263
+ logger.info(`- ${key.name}: ${key.apiKey}`)
264
+ }
265
+ logger.info('\n💡 Tips:')
266
+ logger.info('- Check the admin panel to see the different time ranges')
267
+ logger.info('- Use --clean flag to remove all test data and API Keys')
268
+ logger.info('- The script generates more recent data to simulate real usage patterns')
269
+ } catch (error) {
270
+ logger.error('❌ Error:', error)
271
+ } finally {
272
+ await redis.disconnect()
273
+ }
274
+ }
275
+
276
+ // 运行脚本
277
+ main().catch((error) => {
278
+ logger.error('💥 Unexpected error:', error)
279
+ process.exit(1)
280
+ })
scripts/manage-session-windows.js ADDED
@@ -0,0 +1,561 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * 会话窗口管理脚本
5
+ * 用于调试、恢复和管理Claude账户的会话窗口
6
+ */
7
+
8
+ const redis = require('../src/models/redis')
9
+ const claudeAccountService = require('../src/services/claudeAccountService')
10
+ const readline = require('readline')
11
+
12
+ // 创建readline接口
13
+ const rl = readline.createInterface({
14
+ input: process.stdin,
15
+ output: process.stdout
16
+ })
17
+
18
+ // 辅助函数:询问用户输入
19
+ function askQuestion(question) {
20
+ return new Promise((resolve) => {
21
+ rl.question(question, resolve)
22
+ })
23
+ }
24
+
25
+ // 辅助函数:解析时间输入
26
+ function parseTimeInput(input) {
27
+ const now = new Date()
28
+
29
+ // 如果是 HH:MM 格式
30
+ const timeMatch = input.match(/^(\d{1,2}):(\d{2})$/)
31
+ if (timeMatch) {
32
+ const hour = parseInt(timeMatch[1])
33
+ const minute = parseInt(timeMatch[2])
34
+
35
+ if (hour >= 0 && hour <= 23 && minute >= 0 && minute <= 59) {
36
+ const time = new Date(now)
37
+ time.setHours(hour, minute, 0, 0)
38
+ return time
39
+ }
40
+ }
41
+
42
+ // 如果是相对时间(如 "2小时前")
43
+ const relativeMatch = input.match(/^(\d+)(小时|分钟)前$/)
44
+ if (relativeMatch) {
45
+ const amount = parseInt(relativeMatch[1])
46
+ const unit = relativeMatch[2]
47
+ const time = new Date(now)
48
+
49
+ if (unit === '小时') {
50
+ time.setHours(time.getHours() - amount)
51
+ } else if (unit === '分钟') {
52
+ time.setMinutes(time.getMinutes() - amount)
53
+ }
54
+
55
+ return time
56
+ }
57
+
58
+ // 如果是 ISO 格式或其他日期格式
59
+ const parsedDate = new Date(input)
60
+ if (!isNaN(parsedDate.getTime())) {
61
+ return parsedDate
62
+ }
63
+
64
+ return null
65
+ }
66
+
67
+ // 辅助函数:显示可用的时间窗口选项
68
+ function showTimeWindowOptions() {
69
+ const now = new Date()
70
+ console.log('\n⏰ 可用的5小时时间窗口:')
71
+
72
+ for (let hour = 0; hour < 24; hour += 5) {
73
+ const start = hour
74
+ const end = hour + 5
75
+ const startStr = `${String(start).padStart(2, '0')}:00`
76
+ const endStr = `${String(end).padStart(2, '0')}:00`
77
+
78
+ const currentHour = now.getHours()
79
+ const isActive = currentHour >= start && currentHour < end
80
+ const status = isActive ? ' 🟢 (当前活跃)' : ''
81
+
82
+ console.log(` ${start / 5 + 1}. ${startStr} - ${endStr}${status}`)
83
+ }
84
+ console.log('')
85
+ }
86
+
87
+ const commands = {
88
+ // 调试所有账户的会话窗口状态
89
+ async debug() {
90
+ console.log('🔍 开始调试会话窗口状态...\n')
91
+
92
+ const accounts = await redis.getAllClaudeAccounts()
93
+ console.log(`📊 找到 ${accounts.length} 个Claude账户\n`)
94
+
95
+ const stats = {
96
+ total: accounts.length,
97
+ hasWindow: 0,
98
+ hasLastUsed: 0,
99
+ canRecover: 0,
100
+ expired: 0
101
+ }
102
+
103
+ for (const account of accounts) {
104
+ console.log(`🏢 ${account.name} (${account.id})`)
105
+ console.log(` 状态: ${account.isActive === 'true' ? '✅ 活跃' : '❌ 禁用'}`)
106
+
107
+ if (account.sessionWindowStart && account.sessionWindowEnd) {
108
+ stats.hasWindow++
109
+ const windowStart = new Date(account.sessionWindowStart)
110
+ const windowEnd = new Date(account.sessionWindowEnd)
111
+ const now = new Date()
112
+ const isActive = now < windowEnd
113
+
114
+ console.log(` 会话窗口: ${windowStart.toLocaleString()} - ${windowEnd.toLocaleString()}`)
115
+ console.log(` 窗口状态: ${isActive ? '✅ 活跃' : '❌ 已过期'}`)
116
+
117
+ // 只有在窗口已过期时才显示可恢复窗口
118
+ if (!isActive && account.lastUsedAt) {
119
+ const lastUsed = new Date(account.lastUsedAt)
120
+ const recoveredWindowStart = claudeAccountService._calculateSessionWindowStart(lastUsed)
121
+ const recoveredWindowEnd =
122
+ claudeAccountService._calculateSessionWindowEnd(recoveredWindowStart)
123
+
124
+ if (now < recoveredWindowEnd) {
125
+ stats.canRecover++
126
+ console.log(
127
+ ` 可恢复窗口: ✅ ${recoveredWindowStart.toLocaleString()} - ${recoveredWindowEnd.toLocaleString()}`
128
+ )
129
+ } else {
130
+ stats.expired++
131
+ console.log(
132
+ ` 可恢复窗口: ❌ 已过期 (${recoveredWindowStart.toLocaleString()} - ${recoveredWindowEnd.toLocaleString()})`
133
+ )
134
+ }
135
+ }
136
+ } else {
137
+ console.log(' 会话窗口: ❌ 无')
138
+
139
+ // 没有会话窗口时,检查是否有可恢复的窗口
140
+ if (account.lastUsedAt) {
141
+ const lastUsed = new Date(account.lastUsedAt)
142
+ const now = new Date()
143
+ const windowStart = claudeAccountService._calculateSessionWindowStart(lastUsed)
144
+ const windowEnd = claudeAccountService._calculateSessionWindowEnd(windowStart)
145
+
146
+ if (now < windowEnd) {
147
+ stats.canRecover++
148
+ console.log(
149
+ ` 可恢复窗口: ✅ ${windowStart.toLocaleString()} - ${windowEnd.toLocaleString()}`
150
+ )
151
+ } else {
152
+ stats.expired++
153
+ console.log(
154
+ ` 可恢复窗口: ❌ 已过期 (${windowStart.toLocaleString()} - ${windowEnd.toLocaleString()})`
155
+ )
156
+ }
157
+ }
158
+ }
159
+
160
+ if (account.lastUsedAt) {
161
+ stats.hasLastUsed++
162
+ const lastUsed = new Date(account.lastUsedAt)
163
+ const now = new Date()
164
+ const minutesAgo = Math.round((now - lastUsed) / (1000 * 60))
165
+
166
+ console.log(` 最后使用: ${lastUsed.toLocaleString()} (${minutesAgo}分钟前)`)
167
+ } else {
168
+ console.log(' 最后使用: ❌ 无记录')
169
+ }
170
+
171
+ console.log('')
172
+ }
173
+
174
+ console.log('📈 汇总统计:')
175
+ console.log(` 总账户数: ${stats.total}`)
176
+ console.log(` 有会话窗口: ${stats.hasWindow}`)
177
+ console.log(` 有使用记录: ${stats.hasLastUsed}`)
178
+ console.log(` 可以恢复: ${stats.canRecover}`)
179
+ console.log(` 窗口已过期: ${stats.expired}`)
180
+ },
181
+
182
+ // 初始化会话窗口(默认行为)
183
+ async init() {
184
+ console.log('🔄 初始化会话窗口...\n')
185
+ const result = await claudeAccountService.initializeSessionWindows()
186
+
187
+ console.log('\n📊 初始化结果:')
188
+ console.log(` 总账户数: ${result.total}`)
189
+ console.log(` 成功初始化: ${result.initialized}`)
190
+ console.log(` 已跳过(已有窗口): ${result.skipped}`)
191
+ console.log(` 窗口已过期: ${result.expired}`)
192
+ console.log(` 无使用数据: ${result.noData}`)
193
+
194
+ if (result.error) {
195
+ console.log(` 错误: ${result.error}`)
196
+ }
197
+ },
198
+
199
+ // 强制重新计算所有会话窗口
200
+ async force() {
201
+ console.log('🔄 强制重新计算所有会话窗口...\n')
202
+ const result = await claudeAccountService.initializeSessionWindows(true)
203
+
204
+ console.log('\n📊 强制重算结果:')
205
+ console.log(` 总账户数: ${result.total}`)
206
+ console.log(` 成功初始化: ${result.initialized}`)
207
+ console.log(` 窗口已过期: ${result.expired}`)
208
+ console.log(` 无使用数据: ${result.noData}`)
209
+
210
+ if (result.error) {
211
+ console.log(` 错误: ${result.error}`)
212
+ }
213
+ },
214
+
215
+ // 清除所有会话窗口
216
+ async clear() {
217
+ console.log('🗑️ 清除所有会话窗口...\n')
218
+
219
+ const accounts = await redis.getAllClaudeAccounts()
220
+ let clearedCount = 0
221
+
222
+ for (const account of accounts) {
223
+ if (account.sessionWindowStart || account.sessionWindowEnd) {
224
+ delete account.sessionWindowStart
225
+ delete account.sessionWindowEnd
226
+ delete account.lastRequestTime
227
+
228
+ await redis.setClaudeAccount(account.id, account)
229
+ clearedCount++
230
+
231
+ console.log(`✅ 清除账户 ${account.name} (${account.id}) 的会话窗口`)
232
+ }
233
+ }
234
+
235
+ console.log(`\n📊 清除完成: 共清除 ${clearedCount} 个账户的会话窗口`)
236
+ },
237
+
238
+ // 创建测试会话窗口(将lastUsedAt设置为当前时间)
239
+ async test() {
240
+ console.log('🧪 创建测试会话窗口...\n')
241
+
242
+ const accounts = await redis.getAllClaudeAccounts()
243
+ if (accounts.length === 0) {
244
+ console.log('❌ 没有找到Claude账户')
245
+ return
246
+ }
247
+
248
+ const now = new Date()
249
+ let updatedCount = 0
250
+
251
+ for (const account of accounts) {
252
+ if (account.isActive === 'true') {
253
+ // 设置为当前时间(模拟刚刚使用)
254
+ account.lastUsedAt = now.toISOString()
255
+
256
+ // 计算新的会话窗口
257
+ const windowStart = claudeAccountService._calculateSessionWindowStart(now)
258
+ const windowEnd = claudeAccountService._calculateSessionWindowEnd(windowStart)
259
+
260
+ account.sessionWindowStart = windowStart.toISOString()
261
+ account.sessionWindowEnd = windowEnd.toISOString()
262
+ account.lastRequestTime = now.toISOString()
263
+
264
+ await redis.setClaudeAccount(account.id, account)
265
+ updatedCount++
266
+
267
+ console.log(`✅ 为账户 ${account.name} 创建测试会话窗口:`)
268
+ console.log(` 窗口时间: ${windowStart.toLocaleString()} - ${windowEnd.toLocaleString()}`)
269
+ console.log(` 最后使用: ${now.toLocaleString()}\n`)
270
+ }
271
+ }
272
+
273
+ console.log(`📊 测试完成: 为 ${updatedCount} 个活跃账户创建了测试会话窗口`)
274
+ },
275
+
276
+ // 手动设置账户的会话窗口
277
+ async set() {
278
+ console.log('🔧 手动设置会话窗口...\n')
279
+
280
+ // 获取所有账户
281
+ const accounts = await redis.getAllClaudeAccounts()
282
+ if (accounts.length === 0) {
283
+ console.log('❌ 没有找到Claude账户')
284
+ return
285
+ }
286
+
287
+ // 显示账户列表
288
+ console.log('📋 可用的Claude账户:')
289
+ accounts.forEach((account, index) => {
290
+ const status = account.isActive === 'true' ? '✅' : '❌'
291
+ const hasWindow = account.sessionWindowStart ? '🕐' : '➖'
292
+ console.log(` ${index + 1}. ${status} ${hasWindow} ${account.name} (${account.id})`)
293
+ })
294
+
295
+ // 让用户选择账户
296
+ const accountIndex = await askQuestion('\n请选择账户 (输入编号): ')
297
+ const selectedIndex = parseInt(accountIndex) - 1
298
+
299
+ if (selectedIndex < 0 || selectedIndex >= accounts.length) {
300
+ console.log('❌ 无效的账户编号')
301
+ return
302
+ }
303
+
304
+ const selectedAccount = accounts[selectedIndex]
305
+ console.log(`\n🎯 已选择账户: ${selectedAccount.name}`)
306
+
307
+ // 显示当前会话窗口状态
308
+ if (selectedAccount.sessionWindowStart && selectedAccount.sessionWindowEnd) {
309
+ const windowStart = new Date(selectedAccount.sessionWindowStart)
310
+ const windowEnd = new Date(selectedAccount.sessionWindowEnd)
311
+ const now = new Date()
312
+ const isActive = now >= windowStart && now < windowEnd
313
+
314
+ console.log('📊 当前会话窗口:')
315
+ console.log(` 时间: ${windowStart.toLocaleString()} - ${windowEnd.toLocaleString()}`)
316
+ console.log(` 状态: ${isActive ? '✅ 活跃' : '❌ 已过期'}`)
317
+ } else {
318
+ console.log('📊 当前会话窗口: ❌ 无')
319
+ }
320
+
321
+ // 显示设置选项
322
+ console.log('\n🛠️ 设置选项:')
323
+ console.log(' 1. 使用预设时间窗口')
324
+ console.log(' 2. 自定义最后使用时间')
325
+ console.log(' 3. 直接设置窗口时间')
326
+ console.log(' 4. 清除会话窗口')
327
+
328
+ const option = await askQuestion('\n请选择设置方式 (1-4): ')
329
+
330
+ switch (option) {
331
+ case '1':
332
+ await setPresetWindow(selectedAccount)
333
+ break
334
+ case '2':
335
+ await setCustomLastUsed(selectedAccount)
336
+ break
337
+ case '3':
338
+ await setDirectWindow(selectedAccount)
339
+ break
340
+ case '4':
341
+ await clearAccountWindow(selectedAccount)
342
+ break
343
+ default:
344
+ console.log('❌ 无效的选项')
345
+ return
346
+ }
347
+ },
348
+
349
+ // 显示帮助信息
350
+ help() {
351
+ console.log('🔧 会话窗口管理脚本\n')
352
+ console.log('用法: node scripts/manage-session-windows.js <command>\n')
353
+ console.log('命令:')
354
+ console.log(' debug - 调试所有账户的会话窗口状态')
355
+ console.log(' init - 初始化会话窗口(跳过已有窗口的账户)')
356
+ console.log(' force - 强制重新计算所有会话窗口')
357
+ console.log(' test - 创建测试会话窗口(设置当前时间为使用时间)')
358
+ console.log(' set - 手动设置特定账户的会话窗口 🆕')
359
+ console.log(' clear - 清除所有会话窗口')
360
+ console.log(' help - 显示此帮助信息\n')
361
+ console.log('示例:')
362
+ console.log(' node scripts/manage-session-windows.js debug')
363
+ console.log(' node scripts/manage-session-windows.js set')
364
+ console.log(' node scripts/manage-session-windows.js test')
365
+ console.log(' node scripts/manage-session-windows.js force')
366
+ }
367
+ }
368
+
369
+ // 设置函数实现
370
+
371
+ // 使用预设时间窗口
372
+ async function setPresetWindow(account) {
373
+ showTimeWindowOptions()
374
+
375
+ const windowChoice = await askQuestion('请选择时间窗口 (1-5): ')
376
+ const windowIndex = parseInt(windowChoice) - 1
377
+
378
+ if (windowIndex < 0 || windowIndex >= 5) {
379
+ console.log('❌ 无效的窗口选择')
380
+ return
381
+ }
382
+
383
+ const now = new Date()
384
+ const startHour = windowIndex * 5
385
+
386
+ // 创建窗口开始时间
387
+ const windowStart = new Date(now)
388
+ windowStart.setHours(startHour, 0, 0, 0)
389
+
390
+ // 创建窗口结束时间
391
+ const windowEnd = new Date(windowStart)
392
+ windowEnd.setHours(windowEnd.getHours() + 5)
393
+
394
+ // 如果选择的窗口已经过期,则设置为明天的同一时间段
395
+ if (windowEnd <= now) {
396
+ windowStart.setDate(windowStart.getDate() + 1)
397
+ windowEnd.setDate(windowEnd.getDate() + 1)
398
+ }
399
+
400
+ // 询问是否要设置为当前时间作为最后使用时间
401
+ const setLastUsed = await askQuestion('是否设置当前时间为最后使用时间? (y/N): ')
402
+
403
+ // 更新账户数据
404
+ account.sessionWindowStart = windowStart.toISOString()
405
+ account.sessionWindowEnd = windowEnd.toISOString()
406
+ account.lastRequestTime = now.toISOString()
407
+
408
+ if (setLastUsed.toLowerCase() === 'y' || setLastUsed.toLowerCase() === 'yes') {
409
+ account.lastUsedAt = now.toISOString()
410
+ }
411
+
412
+ await redis.setClaudeAccount(account.id, account)
413
+
414
+ console.log('\n✅ 已设置会话窗口:')
415
+ console.log(` 账户: ${account.name}`)
416
+ console.log(` 窗口: ${windowStart.toLocaleString()} - ${windowEnd.toLocaleString()}`)
417
+ console.log(` 状态: ${now >= windowStart && now < windowEnd ? '✅ 活跃' : '⏰ 未来窗口'}`)
418
+ }
419
+
420
+ // 自定义最后使用时间
421
+ async function setCustomLastUsed(account) {
422
+ console.log('\n📝 设置最后使用时间:')
423
+ console.log('支持的时间格式:')
424
+ console.log(' - HH:MM (如: 14:30)')
425
+ console.log(' - 相对时间 (如: 2小时前, 30分钟前)')
426
+ console.log(' - ISO格式 (如: 2025-07-28T14:30:00)')
427
+
428
+ const timeInput = await askQuestion('\n请输入最后使用时间: ')
429
+ const lastUsedTime = parseTimeInput(timeInput)
430
+
431
+ if (!lastUsedTime) {
432
+ console.log('❌ 无效的时间格式')
433
+ return
434
+ }
435
+
436
+ // 基于最后使用时间计算会话窗口
437
+ const windowStart = claudeAccountService._calculateSessionWindowStart(lastUsedTime)
438
+ const windowEnd = claudeAccountService._calculateSessionWindowEnd(windowStart)
439
+
440
+ // 更新账户数据
441
+ account.lastUsedAt = lastUsedTime.toISOString()
442
+ account.sessionWindowStart = windowStart.toISOString()
443
+ account.sessionWindowEnd = windowEnd.toISOString()
444
+ account.lastRequestTime = lastUsedTime.toISOString()
445
+
446
+ await redis.setClaudeAccount(account.id, account)
447
+
448
+ console.log('\n✅ 已设置会话窗口:')
449
+ console.log(` 账户: ${account.name}`)
450
+ console.log(` 最后使用: ${lastUsedTime.toLocaleString()}`)
451
+ console.log(` 窗口: ${windowStart.toLocaleString()} - ${windowEnd.toLocaleString()}`)
452
+
453
+ const now = new Date()
454
+ console.log(` 状态: ${now >= windowStart && now < windowEnd ? '✅ 活跃' : '❌ 已过期'}`)
455
+ }
456
+
457
+ // 直接设置窗口时间
458
+ async function setDirectWindow(account) {
459
+ console.log('\n⏰ 直接设置窗口时间:')
460
+
461
+ const startInput = await askQuestion('请输入窗口开始时间 (HH:MM): ')
462
+ const startTime = parseTimeInput(startInput)
463
+
464
+ if (!startTime) {
465
+ console.log('❌ 无效的开始时间格式')
466
+ return
467
+ }
468
+
469
+ // 自动计算结束时间(开始时间+5小时)
470
+ const endTime = new Date(startTime)
471
+ endTime.setHours(endTime.getHours() + 5)
472
+
473
+ // 如果跨天,询问是否确认
474
+ if (endTime.getDate() !== startTime.getDate()) {
475
+ const confirm = await askQuestion(`窗口将跨天到次日 ${endTime.getHours()}:00,确认吗? (y/N): `)
476
+ if (confirm.toLowerCase() !== 'y' && confirm.toLowerCase() !== 'yes') {
477
+ console.log('❌ 已取消设置')
478
+ return
479
+ }
480
+ }
481
+
482
+ const now = new Date()
483
+
484
+ // 更新账户数据
485
+ account.sessionWindowStart = startTime.toISOString()
486
+ account.sessionWindowEnd = endTime.toISOString()
487
+ account.lastRequestTime = now.toISOString()
488
+
489
+ // 询问是否更新最后使用时间
490
+ const updateLastUsed = await askQuestion('是否将最后使用时间设置为窗口开始时间? (y/N): ')
491
+ if (updateLastUsed.toLowerCase() === 'y' || updateLastUsed.toLowerCase() === 'yes') {
492
+ account.lastUsedAt = startTime.toISOString()
493
+ }
494
+
495
+ await redis.setClaudeAccount(account.id, account)
496
+
497
+ console.log('\n✅ 已设置会话窗口:')
498
+ console.log(` 账户: ${account.name}`)
499
+ console.log(` 窗口: ${startTime.toLocaleString()} - ${endTime.toLocaleString()}`)
500
+ console.log(
501
+ ` 状态: ${now >= startTime && now < endTime ? '✅ 活跃' : now < startTime ? '⏰ 未来窗口' : '❌ 已过期'}`
502
+ )
503
+ }
504
+
505
+ // 清除账户会话窗口
506
+ async function clearAccountWindow(account) {
507
+ const confirm = await askQuestion(`确认清除账户 "${account.name}" 的会话窗口吗? (y/N): `)
508
+
509
+ if (confirm.toLowerCase() !== 'y' && confirm.toLowerCase() !== 'yes') {
510
+ console.log('❌ 已取消操作')
511
+ return
512
+ }
513
+
514
+ // 清除会话窗口相关数据
515
+ delete account.sessionWindowStart
516
+ delete account.sessionWindowEnd
517
+ delete account.lastRequestTime
518
+
519
+ await redis.setClaudeAccount(account.id, account)
520
+
521
+ console.log(`\n✅ 已清除账户 "${account.name}" 的会话窗口`)
522
+ }
523
+
524
+ async function main() {
525
+ const command = process.argv[2] || 'help'
526
+
527
+ if (!commands[command]) {
528
+ console.error(`❌ 未知命令: ${command}`)
529
+ commands.help()
530
+ process.exit(1)
531
+ }
532
+
533
+ if (command === 'help') {
534
+ commands.help()
535
+ return
536
+ }
537
+
538
+ try {
539
+ // 连接Redis
540
+ await redis.connect()
541
+
542
+ // 执行命令
543
+ await commands[command]()
544
+ } catch (error) {
545
+ console.error('❌ 执行失败:', error)
546
+ process.exit(1)
547
+ } finally {
548
+ await redis.disconnect()
549
+ rl.close()
550
+ }
551
+ }
552
+
553
+ // 如果直接运行此脚本
554
+ if (require.main === module) {
555
+ main().then(() => {
556
+ console.log('\n🎉 操作完成')
557
+ process.exit(0)
558
+ })
559
+ }
560
+
561
+ module.exports = { commands }
scripts/manage.js ADDED
@@ -0,0 +1,333 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env node
2
+
3
+ const { spawn, exec } = require('child_process')
4
+ const fs = require('fs')
5
+ const path = require('path')
6
+ const process = require('process')
7
+
8
+ const PID_FILE = path.join(__dirname, '..', 'claude-relay-service.pid')
9
+ const LOG_FILE = path.join(__dirname, '..', 'logs', 'service.log')
10
+ const ERROR_LOG_FILE = path.join(__dirname, '..', 'logs', 'service-error.log')
11
+ const APP_FILE = path.join(__dirname, '..', 'src', 'app.js')
12
+
13
+ class ServiceManager {
14
+ constructor() {
15
+ this.ensureLogDir()
16
+ }
17
+
18
+ ensureLogDir() {
19
+ const logDir = path.dirname(LOG_FILE)
20
+ if (!fs.existsSync(logDir)) {
21
+ fs.mkdirSync(logDir, { recursive: true })
22
+ }
23
+ }
24
+
25
+ getPid() {
26
+ try {
27
+ if (fs.existsSync(PID_FILE)) {
28
+ const pid = parseInt(fs.readFileSync(PID_FILE, 'utf8').trim())
29
+ return pid
30
+ }
31
+ } catch (error) {
32
+ console.error('读取PID文件失败:', error.message)
33
+ }
34
+ return null
35
+ }
36
+
37
+ isProcessRunning(pid) {
38
+ try {
39
+ process.kill(pid, 0)
40
+ return true
41
+ } catch (error) {
42
+ return false
43
+ }
44
+ }
45
+
46
+ writePid(pid) {
47
+ try {
48
+ fs.writeFileSync(PID_FILE, pid.toString())
49
+ console.log(`✅ PID ${pid} 已保存到 ${PID_FILE}`)
50
+ } catch (error) {
51
+ console.error('写入PID文件失败:', error.message)
52
+ }
53
+ }
54
+
55
+ removePidFile() {
56
+ try {
57
+ if (fs.existsSync(PID_FILE)) {
58
+ fs.unlinkSync(PID_FILE)
59
+ console.log('🗑️ 已清理PID文件')
60
+ }
61
+ } catch (error) {
62
+ console.error('清理PID文件失败:', error.message)
63
+ }
64
+ }
65
+
66
+ getStatus() {
67
+ const pid = this.getPid()
68
+ if (pid && this.isProcessRunning(pid)) {
69
+ return { running: true, pid }
70
+ }
71
+ return { running: false, pid: null }
72
+ }
73
+
74
+ start(daemon = false) {
75
+ const status = this.getStatus()
76
+ if (status.running) {
77
+ console.log(`⚠️ 服务已在运行中 (PID: ${status.pid})`)
78
+ return false
79
+ }
80
+
81
+ console.log('🚀 启动 Claude Relay Service...')
82
+
83
+ if (daemon) {
84
+ // 后台运行模式 - 使用nohup实现真正的后台运行
85
+ const { exec: execChild } = require('child_process')
86
+
87
+ const command = `nohup node "${APP_FILE}" > "${LOG_FILE}" 2> "${ERROR_LOG_FILE}" & echo $!`
88
+
89
+ execChild(command, (error, stdout) => {
90
+ if (error) {
91
+ console.error('❌ 后台启动失败:', error.message)
92
+ return
93
+ }
94
+
95
+ const pid = parseInt(stdout.trim())
96
+ if (pid && !isNaN(pid)) {
97
+ this.writePid(pid)
98
+ console.log(`🔄 服务已在后台启动 (PID: ${pid})`)
99
+ console.log(`📝 日志文件: ${LOG_FILE}`)
100
+ console.log(`❌ 错误日志: ${ERROR_LOG_FILE}`)
101
+ console.log('✅ 终端现在可以安全关闭')
102
+ } else {
103
+ console.error('❌ 无法获取进程ID')
104
+ }
105
+ })
106
+
107
+ // 给exec一点时间执行
108
+ setTimeout(() => {
109
+ process.exit(0)
110
+ }, 1000)
111
+ } else {
112
+ // 前台运行模式
113
+ const child = spawn('node', [APP_FILE], {
114
+ stdio: 'inherit'
115
+ })
116
+
117
+ console.log(`🔄 服务已启动 (PID: ${child.pid})`)
118
+
119
+ this.writePid(child.pid)
120
+
121
+ // 监听进程退出
122
+ child.on('exit', (code, signal) => {
123
+ this.removePidFile()
124
+ if (code !== 0) {
125
+ console.log(`💥 进程退出 (代码: ${code}, 信号: ${signal})`)
126
+ }
127
+ })
128
+
129
+ child.on('error', (error) => {
130
+ console.error('❌ 启动失败:', error.message)
131
+ this.removePidFile()
132
+ })
133
+ }
134
+
135
+ return true
136
+ }
137
+
138
+ stop() {
139
+ const status = this.getStatus()
140
+ if (!status.running) {
141
+ console.log('⚠️ 服务未在运行')
142
+ this.removePidFile() // 清理可能存在的过期PID文件
143
+ return false
144
+ }
145
+
146
+ console.log(`🛑 停止服务 (PID: ${status.pid})...`)
147
+
148
+ try {
149
+ // 优雅关闭:先发送SIGTERM
150
+ process.kill(status.pid, 'SIGTERM')
151
+
152
+ // 等待进程退出
153
+ let attempts = 0
154
+ const maxAttempts = 30 // 30秒超时
155
+
156
+ const checkExit = setInterval(() => {
157
+ attempts++
158
+ if (!this.isProcessRunning(status.pid)) {
159
+ clearInterval(checkExit)
160
+ console.log('✅ 服务已停止')
161
+ this.removePidFile()
162
+ return
163
+ }
164
+
165
+ if (attempts >= maxAttempts) {
166
+ clearInterval(checkExit)
167
+ console.log('⚠️ 优雅关闭超时,强制终止进程...')
168
+ try {
169
+ process.kill(status.pid, 'SIGKILL')
170
+ console.log('✅ 服务已强制停止')
171
+ } catch (error) {
172
+ console.error('❌ 强制停止失败:', error.message)
173
+ }
174
+ this.removePidFile()
175
+ }
176
+ }, 1000)
177
+ } catch (error) {
178
+ console.error('❌ 停止服务失败:', error.message)
179
+ this.removePidFile()
180
+ return false
181
+ }
182
+
183
+ return true
184
+ }
185
+
186
+ restart(daemon = false) {
187
+ console.log('🔄 重启服务...')
188
+ this.stop()
189
+ // 等待停止完成
190
+ setTimeout(() => {
191
+ this.start(daemon)
192
+ }, 2000)
193
+
194
+ return true
195
+ }
196
+
197
+ status() {
198
+ const status = this.getStatus()
199
+ if (status.running) {
200
+ console.log(`✅ 服务正在运行 (PID: ${status.pid})`)
201
+
202
+ // 显示进程信息
203
+ exec(`ps -p ${status.pid} -o pid,ppid,pcpu,pmem,etime,cmd --no-headers`, (error, stdout) => {
204
+ if (!error && stdout.trim()) {
205
+ console.log('\n📊 进程信息:')
206
+ console.log('PID\tPPID\tCPU%\tMEM%\tTIME\t\tCOMMAND')
207
+ console.log(stdout.trim())
208
+ }
209
+ })
210
+ } else {
211
+ console.log('❌ 服务未运行')
212
+ }
213
+ return status.running
214
+ }
215
+
216
+ logs(lines = 50) {
217
+ console.log(`📖 最近 ${lines} 行日志:\n`)
218
+
219
+ exec(`tail -n ${lines} ${LOG_FILE}`, (error, stdout) => {
220
+ if (error) {
221
+ console.error('读取日志失败:', error.message)
222
+ return
223
+ }
224
+ console.log(stdout)
225
+ })
226
+ }
227
+
228
+ help() {
229
+ console.log(`
230
+ 🔧 Claude Relay Service 进程管理器
231
+
232
+ 用法: npm run service <command> [options]
233
+
234
+ 重要提示:
235
+ 如果要传递参数,请在npm run命令中使用 -- 分隔符
236
+ npm run service <command> -- [options]
237
+
238
+ 命令:
239
+ start [-d|--daemon] 启动服务 (-d: 后台运行)
240
+ stop 停止服务
241
+ restart [-d|--daemon] 重启服务 (-d: 后台运行)
242
+ status 查看服务状态
243
+ logs [lines] 查看日志 (默认50行)
244
+ help 显示帮助信息
245
+
246
+ 命令缩写:
247
+ s, start 启动服务
248
+ r, restart 重启服务
249
+ st, status 查看状态
250
+ l, log, logs 查看日志
251
+ halt, stop 停止服务
252
+ h, help 显示帮助
253
+
254
+ 示例:
255
+ npm run service start # 前台启动
256
+ npm run service -- start -d # 后台启动(正确方式)
257
+ npm run service:start:d # 后台启动(推荐快捷方式)
258
+ npm run service:daemon # 后台启动(推荐快捷方式)
259
+ npm run service stop # 停止服务
260
+ npm run service -- restart -d # 后台重启(正确方式)
261
+ npm run service:restart:d # 后台重启(推荐快捷方式)
262
+ npm run service status # 查看状态
263
+ npm run service logs # 查看日志
264
+ npm run service -- logs 100 # 查看最近100行日志
265
+
266
+ 推荐的快捷方式(无需 -- 分隔符):
267
+ npm run service:start:d # 等同于 npm run service -- start -d
268
+ npm run service:restart:d # 等同于 npm run service -- restart -d
269
+ npm run service:daemon # 等同于 npm run service -- start -d
270
+
271
+ 直接使用脚本(推荐):
272
+ node scripts/manage.js start -d # 后台启动
273
+ node scripts/manage.js restart -d # 后台重启
274
+ node scripts/manage.js status # 查看状态
275
+ node scripts/manage.js logs 100 # 查看最近100行日志
276
+
277
+ 文件位置:
278
+ PID文件: ${PID_FILE}
279
+ 日志文件: ${LOG_FILE}
280
+ 错误日志: ${ERROR_LOG_FILE}
281
+ `)
282
+ }
283
+ }
284
+
285
+ // 主程序
286
+ function main() {
287
+ const manager = new ServiceManager()
288
+ const args = process.argv.slice(2)
289
+ const command = args[0]
290
+ const isDaemon = args.includes('-d') || args.includes('--daemon')
291
+
292
+ switch (command) {
293
+ case 'start':
294
+ case 's':
295
+ manager.start(isDaemon)
296
+ break
297
+ case 'stop':
298
+ case 'halt':
299
+ manager.stop()
300
+ break
301
+ case 'restart':
302
+ case 'r':
303
+ manager.restart(isDaemon)
304
+ break
305
+ case 'status':
306
+ case 'st':
307
+ manager.status()
308
+ break
309
+ case 'logs':
310
+ case 'log':
311
+ case 'l': {
312
+ const lines = parseInt(args[1]) || 50
313
+ manager.logs(lines)
314
+ break
315
+ }
316
+ case 'help':
317
+ case '--help':
318
+ case '-h':
319
+ case 'h':
320
+ manager.help()
321
+ break
322
+ default:
323
+ console.log('❌ 未知命令:', command)
324
+ manager.help()
325
+ process.exit(1)
326
+ }
327
+ }
328
+
329
+ if (require.main === module) {
330
+ main()
331
+ }
332
+
333
+ module.exports = ServiceManager
scripts/manage.sh ADDED
@@ -0,0 +1,1757 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/bin/bash
2
+
3
+ # Claude Relay Service 管理脚本
4
+ # 用于安装、更新、卸载、启动、停止、重启服务
5
+ # 可以使用 crs 快捷命令调用
6
+
7
+ # 颜色定义
8
+ RED='\033[0;31m'
9
+ GREEN='\033[0;32m'
10
+ YELLOW='\033[1;33m'
11
+ BLUE='\033[0;36m' # 改为青色(Cyan),更易读
12
+ MAGENTA='\033[0;35m'
13
+ BOLD='\033[1m'
14
+ NC='\033[0m' # No Color
15
+
16
+ # 默认配置
17
+ DEFAULT_INSTALL_DIR="$HOME/claude-relay-service"
18
+ DEFAULT_REDIS_HOST="localhost"
19
+ DEFAULT_REDIS_PORT="6379"
20
+ DEFAULT_REDIS_PASSWORD=""
21
+ DEFAULT_APP_PORT="3000"
22
+
23
+ # 全局变量
24
+ INSTALL_DIR=""
25
+ APP_DIR=""
26
+ REDIS_HOST=""
27
+ REDIS_PORT=""
28
+ REDIS_PASSWORD=""
29
+ APP_PORT=""
30
+ PUBLIC_IP_CACHE_FILE="/tmp/.crs_public_ip_cache"
31
+ PUBLIC_IP_CACHE_DURATION=3600 # 1小时缓存
32
+
33
+ # 打印带颜色的消息
34
+ print_info() {
35
+ echo -e "${BLUE}[INFO]${NC} $1"
36
+ }
37
+
38
+ print_success() {
39
+ echo -e "${GREEN}[SUCCESS]${NC} $1"
40
+ }
41
+
42
+ print_error() {
43
+ echo -e "${RED}[ERROR]${NC} $1"
44
+ }
45
+
46
+ print_warning() {
47
+ echo -e "${YELLOW}[WARNING]${NC} $1"
48
+ }
49
+
50
+ # 检测操作系统
51
+ detect_os() {
52
+ if [[ "$OSTYPE" == "linux-gnu"* ]]; then
53
+ if [ -f /etc/debian_version ]; then
54
+ OS="debian"
55
+ PACKAGE_MANAGER="apt-get"
56
+ elif [ -f /etc/redhat-release ]; then
57
+ OS="redhat"
58
+ PACKAGE_MANAGER="yum"
59
+ elif [ -f /etc/arch-release ]; then
60
+ OS="arch"
61
+ PACKAGE_MANAGER="pacman"
62
+ else
63
+ OS="unknown"
64
+ fi
65
+ elif [[ "$OSTYPE" == "darwin"* ]]; then
66
+ OS="macos"
67
+ PACKAGE_MANAGER="brew"
68
+ else
69
+ OS="unknown"
70
+ fi
71
+ }
72
+
73
+ # 检查命令是否存在
74
+ command_exists() {
75
+ command -v "$1" >/dev/null 2>&1
76
+ }
77
+
78
+ # 检查端口是否被占用
79
+ check_port() {
80
+ local port=$1
81
+ if command_exists lsof; then
82
+ lsof -i ":$port" >/dev/null 2>&1
83
+ elif command_exists netstat; then
84
+ netstat -tuln | grep ":$port " >/dev/null 2>&1
85
+ elif command_exists ss; then
86
+ ss -tuln | grep ":$port " >/dev/null 2>&1
87
+ else
88
+ return 1
89
+ fi
90
+ }
91
+
92
+ # 生成随机字符串
93
+ generate_random_string() {
94
+ local length=$1
95
+ if command_exists openssl; then
96
+ openssl rand -hex $((length/2))
97
+ else
98
+ cat /dev/urandom | tr -dc 'a-zA-Z0-9' | fold -w $length | head -n 1
99
+ fi
100
+ }
101
+
102
+ # 获取公网IP
103
+ get_public_ip() {
104
+ local cached_ip=""
105
+ local cache_age=0
106
+
107
+ # 检查缓存
108
+ if [ -f "$PUBLIC_IP_CACHE_FILE" ]; then
109
+ local current_time=$(date +%s)
110
+ local cache_time=$(stat -c %Y "$PUBLIC_IP_CACHE_FILE" 2>/dev/null || stat -f %m "$PUBLIC_IP_CACHE_FILE" 2>/dev/null || echo 0)
111
+ cache_age=$((current_time - cache_time))
112
+
113
+ if [ $cache_age -lt $PUBLIC_IP_CACHE_DURATION ]; then
114
+ cached_ip=$(cat "$PUBLIC_IP_CACHE_FILE" 2>/dev/null)
115
+ if [ -n "$cached_ip" ]; then
116
+ echo "$cached_ip"
117
+ return 0
118
+ fi
119
+ fi
120
+ fi
121
+
122
+ # 获取新的公网IP
123
+ local public_ip=""
124
+ if command_exists curl; then
125
+ public_ip=$(curl -s --connect-timeout 5 https://ipinfo.io/json | grep -o '"ip":"[^"]*"' | cut -d'"' -f4 2>/dev/null)
126
+ elif command_exists wget; then
127
+ public_ip=$(wget -qO- --timeout=5 https://ipinfo.io/json | grep -o '"ip":"[^"]*"' | cut -d'"' -f4 2>/dev/null)
128
+ fi
129
+
130
+ # 如果获取失败,尝试备用API
131
+ if [ -z "$public_ip" ]; then
132
+ if command_exists curl; then
133
+ public_ip=$(curl -s --connect-timeout 5 https://api.ipify.org 2>/dev/null)
134
+ elif command_exists wget; then
135
+ public_ip=$(wget -qO- --timeout=5 https://api.ipify.org 2>/dev/null)
136
+ fi
137
+ fi
138
+
139
+ # 保存到缓存
140
+ if [ -n "$public_ip" ]; then
141
+ echo "$public_ip" > "$PUBLIC_IP_CACHE_FILE"
142
+ echo "$public_ip"
143
+ else
144
+ echo "localhost"
145
+ fi
146
+ }
147
+
148
+ # 检查Node.js版本
149
+ check_node_version() {
150
+ if ! command_exists node; then
151
+ return 1
152
+ fi
153
+
154
+ local node_version=$(node -v | sed 's/v//')
155
+ local major_version=$(echo $node_version | cut -d. -f1)
156
+
157
+ if [ "$major_version" -lt 18 ]; then
158
+ return 1
159
+ fi
160
+
161
+ return 0
162
+ }
163
+
164
+ # 安装Node.js 18+
165
+ install_nodejs() {
166
+ print_info "开始安装 Node.js 18+"
167
+
168
+ case $OS in
169
+ "debian")
170
+ # 使用 NodeSource 仓库
171
+ curl -fsSL https://deb.nodesource.com/setup_18.x | sudo -E bash -
172
+ sudo $PACKAGE_MANAGER install -y nodejs
173
+ ;;
174
+ "redhat")
175
+ curl -fsSL https://rpm.nodesource.com/setup_18.x | sudo bash -
176
+ sudo $PACKAGE_MANAGER install -y nodejs
177
+ ;;
178
+ "arch")
179
+ sudo $PACKAGE_MANAGER -S --noconfirm nodejs npm
180
+ ;;
181
+ "macos")
182
+ if ! command_exists brew; then
183
+ print_error "请先安装 Homebrew: https://brew.sh"
184
+ return 1
185
+ fi
186
+ brew install node@18
187
+ ;;
188
+ *)
189
+ print_error "不支持的操作系统,请手动安装 Node.js 18+"
190
+ return 1
191
+ ;;
192
+ esac
193
+
194
+ # 验证安装
195
+ if check_node_version; then
196
+ print_success "Node.js 安装成功: $(node -v)"
197
+ return 0
198
+ else
199
+ print_error "Node.js 安装失败或版本不符合要求"
200
+ return 1
201
+ fi
202
+ }
203
+
204
+ # 安装基础依赖
205
+ install_dependencies() {
206
+ print_info "检查并安装基础依赖..."
207
+
208
+ local deps_to_install=()
209
+
210
+ # 检查 git
211
+ if ! command_exists git; then
212
+ deps_to_install+=("git")
213
+ fi
214
+
215
+ # 检查其他基础工具
216
+ case $OS in
217
+ "debian"|"redhat")
218
+ if ! command_exists curl; then
219
+ deps_to_install+=("curl")
220
+ fi
221
+ if ! command_exists wget; then
222
+ deps_to_install+=("wget")
223
+ fi
224
+ if ! command_exists lsof; then
225
+ deps_to_install+=("lsof")
226
+ fi
227
+ ;;
228
+ esac
229
+
230
+ # 安装缺失的依赖
231
+ if [ ${#deps_to_install[@]} -gt 0 ]; then
232
+ print_info "需要安装: ${deps_to_install[*]}"
233
+ case $OS in
234
+ "debian")
235
+ sudo $PACKAGE_MANAGER update
236
+ sudo $PACKAGE_MANAGER install -y "${deps_to_install[@]}"
237
+ ;;
238
+ "redhat")
239
+ sudo $PACKAGE_MANAGER install -y "${deps_to_install[@]}"
240
+ ;;
241
+ "arch")
242
+ sudo $PACKAGE_MANAGER -S --noconfirm "${deps_to_install[@]}"
243
+ ;;
244
+ "macos")
245
+ brew install "${deps_to_install[@]}"
246
+ ;;
247
+ esac
248
+ fi
249
+
250
+ # 检查 Node.js
251
+ if ! check_node_version; then
252
+ print_warning "未检测到 Node.js 18+ 版本"
253
+ install_nodejs || return 1
254
+ else
255
+ print_success "Node.js 版本检查通过: $(node -v)"
256
+ fi
257
+
258
+ # 检查 npm
259
+ if ! command_exists npm; then
260
+ print_error "npm 未安装"
261
+ return 1
262
+ else
263
+ print_success "npm 版本: $(npm -v)"
264
+ fi
265
+
266
+ return 0
267
+ }
268
+
269
+ # 检查Redis
270
+ check_redis() {
271
+ print_info "检查 Redis 配置..."
272
+
273
+ # 交互式询问Redis配置
274
+ echo -e "\n${BLUE}Redis 配置${NC}"
275
+ echo -n "Redis 地址 (默认: $DEFAULT_REDIS_HOST): "
276
+ read input
277
+ REDIS_HOST=${input:-$DEFAULT_REDIS_HOST}
278
+
279
+ echo -n "Redis 端口 (默认: $DEFAULT_REDIS_PORT): "
280
+ read input
281
+ REDIS_PORT=${input:-$DEFAULT_REDIS_PORT}
282
+
283
+ echo -n "Redis 密码 (默认: 无密码): "
284
+ read -s input
285
+ echo
286
+ REDIS_PASSWORD=${input:-$DEFAULT_REDIS_PASSWORD}
287
+
288
+ # 测试Redis连接
289
+ print_info "测试 Redis 连接..."
290
+ if command_exists redis-cli; then
291
+ local redis_args=(-h "$REDIS_HOST" -p "$REDIS_PORT")
292
+ if [ -n "$REDIS_PASSWORD" ]; then
293
+ redis_args+=(-a "$REDIS_PASSWORD")
294
+ fi
295
+
296
+ if redis-cli "${redis_args[@]}" ping 2>/dev/null | grep -q "PONG"; then
297
+ print_success "Redis 连接成功"
298
+ return 0
299
+ else
300
+ print_error "Redis 连接失败"
301
+ return 1
302
+ fi
303
+ else
304
+ print_warning "redis-cli 未安装,跳过连接测试"
305
+ # 仅检查端口是否开放
306
+ if check_port $REDIS_PORT; then
307
+ print_info "检测到端口 $REDIS_PORT 已开放"
308
+ return 0
309
+ else
310
+ print_warning "端口 $REDIS_PORT 未开放,请确保 Redis 正在运行"
311
+ return 1
312
+ fi
313
+ fi
314
+ }
315
+
316
+ # 安装本地Redis(可选)
317
+ install_local_redis() {
318
+ print_info "是否需要在本地安装 Redis?(y/N): "
319
+ read -n 1 install_redis
320
+ echo
321
+
322
+ if [[ ! "$install_redis" =~ ^[Yy]$ ]]; then
323
+ return 0
324
+ fi
325
+
326
+ case $OS in
327
+ "debian")
328
+ sudo $PACKAGE_MANAGER update
329
+ sudo $PACKAGE_MANAGER install -y redis-server
330
+ sudo systemctl start redis-server
331
+ sudo systemctl enable redis-server
332
+ ;;
333
+ "redhat")
334
+ sudo $PACKAGE_MANAGER install -y redis
335
+ sudo systemctl start redis
336
+ sudo systemctl enable redis
337
+ ;;
338
+ "arch")
339
+ sudo $PACKAGE_MANAGER -S --noconfirm redis
340
+ sudo systemctl start redis
341
+ sudo systemctl enable redis
342
+ ;;
343
+ "macos")
344
+ brew install redis
345
+ brew services start redis
346
+ ;;
347
+ *)
348
+ print_error "不支持的操作系统,请手动安装 Redis"
349
+ return 1
350
+ ;;
351
+ esac
352
+
353
+ print_success "Redis 安装完成"
354
+ return 0
355
+ }
356
+
357
+
358
+ # 检查是否已安装
359
+ check_installation() {
360
+ if [ -d "$APP_DIR" ] && [ -f "$APP_DIR/package.json" ]; then
361
+ return 0
362
+ fi
363
+ return 1
364
+ }
365
+
366
+ # 安装服务
367
+ install_service() {
368
+ print_info "开始安装 Claude Relay Service..."
369
+
370
+ # 询问安装目录
371
+ echo -n "安装目录 (默认: $DEFAULT_INSTALL_DIR): "
372
+ read input
373
+ INSTALL_DIR=${input:-$DEFAULT_INSTALL_DIR}
374
+ APP_DIR="$INSTALL_DIR/app"
375
+
376
+ # 询问服务端口
377
+ echo -n "服务端口 (默认: $DEFAULT_APP_PORT): "
378
+ read input
379
+ APP_PORT=${input:-$DEFAULT_APP_PORT}
380
+
381
+ # 检查端口是否被占用
382
+ if check_port $APP_PORT; then
383
+ print_warning "端口 $APP_PORT 已被占用"
384
+ echo -n "是否继续?(y/N): "
385
+ read -n 1 continue_install
386
+ echo
387
+ if [[ ! "$continue_install" =~ ^[Yy]$ ]]; then
388
+ return 1
389
+ fi
390
+ fi
391
+
392
+ # 检查是否已安装
393
+ if check_installation; then
394
+ print_warning "检测到已安装的服务"
395
+ echo -n "是否要重新安装?(y/N): "
396
+ read -n 1 reinstall
397
+ echo
398
+ if [[ ! "$reinstall" =~ ^[Yy]$ ]]; then
399
+ return 0
400
+ fi
401
+ fi
402
+
403
+ # 创建安装目录
404
+ mkdir -p "$INSTALL_DIR"
405
+
406
+ # 克隆项目
407
+ print_info "克隆项目代码..."
408
+ if [ -d "$APP_DIR" ]; then
409
+ rm -rf "$APP_DIR"
410
+ fi
411
+
412
+ if ! git clone https://github.com/Wei-Shaw/claude-relay-service.git "$APP_DIR"; then
413
+ print_error "克隆项目失败"
414
+ return 1
415
+ fi
416
+
417
+ # 进入项目目录
418
+ cd "$APP_DIR"
419
+
420
+ # 安装npm依赖
421
+ print_info "安装项目依赖..."
422
+ npm install
423
+
424
+ # 确保脚本有执行权限(仅在权限不正确时设置)
425
+ if [ -f "$APP_DIR/scripts/manage.sh" ] && [ ! -x "$APP_DIR/scripts/manage.sh" ]; then
426
+ chmod +x "$APP_DIR/scripts/manage.sh"
427
+ print_success "已设置脚本执行权限"
428
+ fi
429
+
430
+ # 创建配置文件
431
+ print_info "创建配置文件..."
432
+
433
+ # 复制示例配置
434
+ if [ -f "config/config.example.js" ]; then
435
+ cp config/config.example.js config/config.js
436
+ fi
437
+
438
+ # 创建.env文件
439
+ cat > .env << EOF
440
+ # 环境变量配置
441
+ NODE_ENV=production
442
+ PORT=$APP_PORT
443
+
444
+ # JWT配置
445
+ JWT_SECRET=$(generate_random_string 64)
446
+
447
+ # 加密配置
448
+ ENCRYPTION_KEY=$(generate_random_string 32)
449
+
450
+ # Redis配置
451
+ REDIS_HOST=$REDIS_HOST
452
+ REDIS_PORT=$REDIS_PORT
453
+ REDIS_PASSWORD=$REDIS_PASSWORD
454
+
455
+ # 日志配置
456
+ LOG_LEVEL=info
457
+ EOF
458
+
459
+ # 运行setup命令
460
+ print_info "运行初始化设置..."
461
+ npm run setup
462
+
463
+ # 获取预构建的前端文件
464
+ print_info "获取预构建的前端文件..."
465
+
466
+ # 创建目标目录
467
+ mkdir -p web/admin-spa/dist
468
+
469
+ # 从 web-dist 分支获取构建好的文件
470
+ if git ls-remote --heads origin web-dist | grep -q web-dist; then
471
+ print_info "从 web-dist 分支下载前端文件..."
472
+
473
+ # 创建临时目录用于 clone
474
+ TEMP_CLONE_DIR=$(mktemp -d)
475
+
476
+ # 使用 sparse-checkout 来只获取需要的文件
477
+ git clone --depth 1 --branch web-dist --single-branch \
478
+ https://github.com/Wei-Shaw/claude-relay-service.git \
479
+ "$TEMP_CLONE_DIR" 2>/dev/null || {
480
+ # 如果 HTTPS 失败,尝试使用当前仓库的 remote URL
481
+ REPO_URL=$(git config --get remote.origin.url)
482
+ git clone --depth 1 --branch web-dist --single-branch "$REPO_URL" "$TEMP_CLONE_DIR"
483
+ }
484
+
485
+ # 复制文件到目标目录(排除 .git 和 README.md)
486
+ rsync -av --exclude='.git' --exclude='README.md' "$TEMP_CLONE_DIR/" web/admin-spa/dist/ 2>/dev/null || {
487
+ # 如果没有 rsync,使用 cp
488
+ cp -r "$TEMP_CLONE_DIR"/* web/admin-spa/dist/ 2>/dev/null
489
+ rm -rf web/admin-spa/dist/.git 2>/dev/null
490
+ rm -f web/admin-spa/dist/README.md 2>/dev/null
491
+ }
492
+
493
+ # 清理临时目录
494
+ rm -rf "$TEMP_CLONE_DIR"
495
+
496
+ print_success "前端文件下载完成"
497
+ else
498
+ print_warning "web-dist 分支不存在,尝试本地构建..."
499
+
500
+ # 检查是否有 Node.js 和 npm
501
+ if command_exists npm; then
502
+ # 回退到原始构建方式
503
+ if [ -f "web/admin-spa/package.json" ]; then
504
+ print_info "开始本地构建前端..."
505
+ cd web/admin-spa
506
+ npm install
507
+ npm run build
508
+ cd ../..
509
+ print_success "前端本地构建完成"
510
+ else
511
+ print_error "无法找到前端项目文件"
512
+ fi
513
+ else
514
+ print_error "无法获取前端文件,且本地环境不支持构建"
515
+ print_info "请确保仓库已正确配置 web-dist 分支"
516
+ fi
517
+ fi
518
+
519
+ # 创建软链接
520
+ create_symlink
521
+
522
+ print_success "安装完成!"
523
+
524
+ # 自动启动服务
525
+ print_info "正在启动服务..."
526
+ start_service
527
+
528
+ # 等待服务启动
529
+ sleep 3
530
+
531
+ # 显示状态
532
+ show_status
533
+
534
+ # 获取公网IP
535
+ local public_ip=$(get_public_ip)
536
+
537
+ echo -e "\n${GREEN}服务已成功安装并启动!${NC}"
538
+ echo -e "\n${YELLOW}访问地址:${NC}"
539
+ echo -e " 本地 Web: ${GREEN}http://localhost:$APP_PORT/web${NC}"
540
+ echo -e " 本地 API: ${GREEN}http://localhost:$APP_PORT/api/v1${NC}"
541
+ if [ "$public_ip" != "localhost" ]; then
542
+ echo -e " 公网 Web: ${GREEN}http://$public_ip:$APP_PORT/web${NC}"
543
+ echo -e " 公网 API: ${GREEN}http://$public_ip:$APP_PORT/api/v1${NC}"
544
+ fi
545
+ echo -e "\n${YELLOW}管理命令:${NC}"
546
+ echo " 查看状态: crs status"
547
+ echo " 停止服务: crs stop"
548
+ echo " 重启服务: crs restart"
549
+ }
550
+
551
+
552
+ # 更新服务
553
+ update_service() {
554
+ if ! check_installation; then
555
+ print_error "服务未安装,请先运行: $0 install"
556
+ return 1
557
+ fi
558
+
559
+ print_info "更新 Claude Relay Service..."
560
+
561
+ cd "$APP_DIR"
562
+
563
+ # 保存当前运行状态
564
+ local was_running=false
565
+ if pgrep -f "node.*src/app.js" > /dev/null; then
566
+ was_running=true
567
+ print_info "检测到服务正在运行,将在更新后自动重启..."
568
+ stop_service
569
+ fi
570
+
571
+ # 备份配置文件(只备份.env,config.js可从example恢复)
572
+ print_info "备份配置文件..."
573
+ if [ -f ".env" ]; then
574
+ cp .env .env.backup.$(date +%Y%m%d%H%M%S)
575
+ fi
576
+
577
+ # 检查本地修改
578
+ print_info "检查本地文件修改..."
579
+ local has_changes=false
580
+ if git status --porcelain | grep -v "^??" | grep -q .; then
581
+ has_changes=true
582
+ print_warning "检测到本地文件已修改:"
583
+ git status --short | grep -v "^??"
584
+ echo ""
585
+ echo -e "${YELLOW}警告:更新将使用远程版本覆盖本地修改!${NC}"
586
+
587
+ # 创建本地修改的备份
588
+ local backup_branch="backup-$(date +%Y%m%d-%H%M%S)"
589
+ print_info "创建本地修改备份分支: $backup_branch"
590
+ git stash push -m "Backup before update $(date +%Y-%m-%d)" >/dev/null 2>&1
591
+ git branch "$backup_branch" 2>/dev/null || true
592
+
593
+ echo -e "${GREEN}已创建备份分支: $backup_branch${NC}"
594
+ echo "如需恢复,可执行: git checkout $backup_branch"
595
+ echo ""
596
+
597
+ echo -n "是否继续更新?(y/N): "
598
+ read -n 1 confirm_update
599
+ echo
600
+
601
+ if [[ ! "$confirm_update" =~ ^[Yy]$ ]]; then
602
+ print_info "已取消更新"
603
+ # 恢复 stash 的修改
604
+ git stash pop >/dev/null 2>&1 || true
605
+ # 如果之前在运行,重新启动服务
606
+ if [ "$was_running" = true ]; then
607
+ print_info "重新启动服务..."
608
+ start_service
609
+ fi
610
+ return 0
611
+ fi
612
+ fi
613
+
614
+ # 获取最新代码(强制使用远程版本)
615
+ print_info "获取最新代码..."
616
+
617
+ # 先获取远程更新
618
+ if ! git fetch origin main; then
619
+ print_error "获取远程代码失败,请检查网络连接"
620
+ return 1
621
+ fi
622
+
623
+ # 强制重置到远程版本
624
+ print_info "应用远程更新..."
625
+ if ! git reset --hard origin/main; then
626
+ print_error "重置到远程版本失败"
627
+ # 尝试恢复
628
+ print_info "尝试恢复..."
629
+ git reset --hard HEAD
630
+ return 1
631
+ fi
632
+
633
+ # 清理未跟踪的文件(可选,保留用户新建的文件)
634
+ # git clean -fd # 注释掉,避免删除用户的新文件
635
+
636
+ print_success "代码已更新到最新版本"
637
+
638
+ # 更新依赖
639
+ print_info "更新依赖..."
640
+ npm install
641
+
642
+ # 确保脚本有执行权限(仅在权限不正确时设置)
643
+ if [ -f "$APP_DIR/scripts/manage.sh" ] && [ ! -x "$APP_DIR/scripts/manage.sh" ]; then
644
+ chmod +x "$APP_DIR/scripts/manage.sh"
645
+ fi
646
+
647
+ # 获取最新的预构建前端文件
648
+ print_info "更新前端文件..."
649
+
650
+ # 创建目标目录
651
+ mkdir -p web/admin-spa/dist
652
+
653
+ # 清理旧的前端文件(保留用户自定义文件)
654
+ if [ -d "web/admin-spa/dist" ]; then
655
+ print_info "清理旧的前端文件..."
656
+ # 只删除已知的前端文件,保留用户可能添加的自定义文件
657
+ rm -rf web/admin-spa/dist/assets 2>/dev/null
658
+ rm -f web/admin-spa/dist/index.html 2>/dev/null
659
+ rm -f web/admin-spa/dist/favicon.ico 2>/dev/null
660
+ fi
661
+
662
+ # 从 web-dist 分支获取构建好的文件
663
+ if git ls-remote --heads origin web-dist | grep -q web-dist; then
664
+ print_info "从 web-dist 分支下载最新前端文件..."
665
+
666
+ # 创建临时目录用于 clone
667
+ TEMP_CLONE_DIR=$(mktemp -d)
668
+
669
+ # 添加错误处理
670
+ if [ ! -d "$TEMP_CLONE_DIR" ]; then
671
+ print_error "无法创建临时目录"
672
+ return 1
673
+ fi
674
+
675
+ # 使用 sparse-checkout 来只获取需要的文件,添加重试机制
676
+ local clone_success=false
677
+ for attempt in 1 2 3; do
678
+ print_info "尝试下载前端文件 (第 $attempt 次)..."
679
+
680
+ if git clone --depth 1 --branch web-dist --single-branch \
681
+ https://github.com/Wei-Shaw/claude-relay-service.git \
682
+ "$TEMP_CLONE_DIR" 2>/dev/null; then
683
+ clone_success=true
684
+ break
685
+ fi
686
+
687
+ # 如果 HTTPS 失败,尝试使用当前仓库的 remote URL
688
+ REPO_URL=$(git config --get remote.origin.url)
689
+ if git clone --depth 1 --branch web-dist --single-branch "$REPO_URL" "$TEMP_CLONE_DIR" 2>/dev/null; then
690
+ clone_success=true
691
+ break
692
+ fi
693
+
694
+ if [ $attempt -lt 3 ]; then
695
+ print_warning "下载失败,等待 2 秒后重试..."
696
+ sleep 2
697
+ fi
698
+ done
699
+
700
+ if [ "$clone_success" = false ]; then
701
+ print_error "无法下载前端文件"
702
+ rm -rf "$TEMP_CLONE_DIR"
703
+ return 1
704
+ fi
705
+
706
+ # 复制文件到目标目录(排除 .git 和 README.md)
707
+ rsync -av --exclude='.git' --exclude='README.md' "$TEMP_CLONE_DIR/" web/admin-spa/dist/ 2>/dev/null || {
708
+ # 如果没有 rsync,使用 cp
709
+ cp -r "$TEMP_CLONE_DIR"/* web/admin-spa/dist/ 2>/dev/null
710
+ rm -rf web/admin-spa/dist/.git 2>/dev/null
711
+ rm -f web/admin-spa/dist/README.md 2>/dev/null
712
+ }
713
+
714
+ # 清理临时目录
715
+ rm -rf "$TEMP_CLONE_DIR"
716
+
717
+ print_success "前端文件更新完成"
718
+ else
719
+ print_warning "web-dist 分支不存在,尝试本地构建..."
720
+
721
+ # 检查是否有 Node.js 和 npm
722
+ if command_exists npm; then
723
+ # 回退到原始构建方式
724
+ if [ -f "web/admin-spa/package.json" ]; then
725
+ print_info "开始本地构建前端..."
726
+ cd web/admin-spa
727
+ npm install
728
+ npm run build
729
+ cd ../..
730
+ print_success "前端本地构建完成"
731
+ else
732
+ print_error "无法找到前端项目文件"
733
+ fi
734
+ else
735
+ print_error "无法获取前端文件,且本地环境不支持构建"
736
+ print_info "请确保仓库已正确配置 web-dist 分支"
737
+ fi
738
+ fi
739
+
740
+ # 更新软链接到最新版本
741
+ create_symlink
742
+
743
+ # 如果之前在运行,则重新启动服务
744
+ if [ "$was_running" = true ]; then
745
+ print_info "重新启动服务..."
746
+ start_service
747
+ fi
748
+
749
+ print_success "更新完成!"
750
+
751
+ # 显示更新摘要
752
+ echo ""
753
+ echo -e "${BLUE}=== 更新摘要 ===${NC}"
754
+
755
+ # 显示版本信息
756
+ if [ -f "$APP_DIR/VERSION" ]; then
757
+ echo -e "当前版本: ${GREEN}$(cat "$APP_DIR/VERSION")${NC}"
758
+ fi
759
+
760
+ # 显示最新的提交信息
761
+ local latest_commit=$(git log -1 --oneline 2>/dev/null)
762
+ if [ -n "$latest_commit" ]; then
763
+ echo -e "最新提交: ${GREEN}$latest_commit${NC}"
764
+ fi
765
+
766
+ # 显示备份信息
767
+ echo -e "\n${YELLOW}配置文件备份:${NC}"
768
+ ls -la .env.backup.* 2>/dev/null | tail -3 || echo " 无备份文件"
769
+
770
+ # 提醒用户检查配置
771
+ echo -e "\n${YELLOW}提示:${NC}"
772
+ echo " - 配置文件已自动备份"
773
+ echo " - 如有本地修改已保存到备份分支"
774
+ echo " - 建议检查 .env 和 config/config.js 配置"
775
+
776
+ echo -e "\n${BLUE}==================${NC}"
777
+ }
778
+
779
+ # 卸载服务
780
+ uninstall_service() {
781
+ if [ -z "$INSTALL_DIR" ]; then
782
+ echo -n "请输入安装目录 (默认: $DEFAULT_INSTALL_DIR): "
783
+ read input
784
+ INSTALL_DIR=${input:-$DEFAULT_INSTALL_DIR}
785
+ APP_DIR="$INSTALL_DIR/app"
786
+ fi
787
+
788
+ if [ ! -d "$INSTALL_DIR" ]; then
789
+ print_error "安装目录不存在"
790
+ return 1
791
+ fi
792
+
793
+ print_warning "即将卸载 Claude Relay Service"
794
+ echo -n "确定要卸载吗?(y/N): "
795
+ read -n 1 confirm
796
+ echo
797
+
798
+ if [[ ! "$confirm" =~ ^[Yy]$ ]]; then
799
+ return 0
800
+ fi
801
+
802
+ # 停止服务
803
+ stop_service
804
+
805
+ # 备份数据
806
+ echo -n "是否备份数据?(y/N): "
807
+ read -n 1 backup
808
+ echo
809
+
810
+ if [[ "$backup" =~ ^[Yy]$ ]]; then
811
+ local backup_dir="$HOME/claude-relay-backup-$(date +%Y%m%d%H%M%S)"
812
+ mkdir -p "$backup_dir"
813
+
814
+ # Redis使用系统默认位置,不需要备份
815
+
816
+ # 备份配置文件
817
+ if [ -f "$APP_DIR/.env" ]; then
818
+ cp "$APP_DIR/.env" "$backup_dir/"
819
+ fi
820
+ if [ -f "$APP_DIR/config/config.js" ]; then
821
+ cp "$APP_DIR/config/config.js" "$backup_dir/"
822
+ fi
823
+
824
+ print_success "数据已备份到: $backup_dir"
825
+ fi
826
+
827
+ # 删除安装目录
828
+ rm -rf "$INSTALL_DIR"
829
+
830
+ print_success "卸载完成!"
831
+ }
832
+
833
+ # 启动服务
834
+ start_service() {
835
+ if ! check_installation; then
836
+ print_error "服务未安装,请先运行: $0 install"
837
+ return 1
838
+ fi
839
+
840
+ print_info "启动服务..."
841
+
842
+ cd "$APP_DIR"
843
+
844
+ # 检查是否已运行
845
+ if pgrep -f "node.*src/app.js" > /dev/null; then
846
+ print_warning "服务已在运行"
847
+ return 0
848
+ fi
849
+
850
+ # 确保日志目录存在
851
+ mkdir -p "$APP_DIR/logs"
852
+
853
+ # 检查pm2是否可用并且不是从package.json脚本调用的
854
+ if command_exists pm2 && [ "$1" != "--no-pm2" ]; then
855
+ print_info "使用 pm2 启动服务..."
856
+ # 直接使用pm2启动,避免循环调用
857
+ pm2 start "$APP_DIR/src/app.js" --name "claude-relay" --log "$APP_DIR/logs/pm2.log" 2>/dev/null
858
+ sleep 2
859
+
860
+ # 检查是否启动成功
861
+ if pm2 list 2>/dev/null | grep -q "claude-relay"; then
862
+ print_success "服务已通过 pm2 启动"
863
+ pm2 save 2>/dev/null || true
864
+ else
865
+ print_warning "pm2 启动失败,尝试直接启动..."
866
+ start_service_direct
867
+ fi
868
+ else
869
+ start_service_direct
870
+ fi
871
+
872
+ sleep 2
873
+
874
+ # 验证服务是否成功启动
875
+ if pgrep -f "node.*src/app.js" > /dev/null; then
876
+ show_status
877
+ else
878
+ print_error "服务启动失败,请查看日志: $APP_DIR/logs/service.log"
879
+ if [ -f "$APP_DIR/logs/service.log" ]; then
880
+ echo "最近的错误日志:"
881
+ tail -n 20 "$APP_DIR/logs/service.log"
882
+ fi
883
+ return 1
884
+ fi
885
+ }
886
+
887
+ # 直接启动服务(不使用pm2)
888
+ start_service_direct() {
889
+ print_info "使用后台进程启动服务..."
890
+
891
+ # 使用setsid创建新会话,确保进程完全脱离终端
892
+ if command_exists setsid; then
893
+ # setsid方式(推荐)
894
+ setsid nohup node "$APP_DIR/src/app.js" > "$APP_DIR/logs/service.log" 2>&1 < /dev/null &
895
+ local pid=$!
896
+ sleep 1
897
+
898
+ # 获取实际的子进程PID
899
+ local real_pid=$(pgrep -f "node.*src/app.js" | head -1)
900
+ if [ -n "$real_pid" ]; then
901
+ echo $real_pid > "$APP_DIR/.pid"
902
+ print_success "服务已在后台启动 (PID: $real_pid)"
903
+ else
904
+ echo $pid > "$APP_DIR/.pid"
905
+ print_success "服务已在后台启动 (PID: $pid)"
906
+ fi
907
+ else
908
+ # 备用方式:使用nohup和disown
909
+ nohup node "$APP_DIR/src/app.js" > "$APP_DIR/logs/service.log" 2>&1 < /dev/null &
910
+ local pid=$!
911
+ disown $pid 2>/dev/null || true
912
+ echo $pid > "$APP_DIR/.pid"
913
+ print_success "服务已在后台启动 (PID: $pid)"
914
+ fi
915
+ }
916
+
917
+ # 停止服务
918
+ stop_service() {
919
+ print_info "停止服务..."
920
+
921
+ # 尝试使用pm2停止
922
+ if command_exists pm2 && [ -n "$APP_DIR" ] && [ -d "$APP_DIR" ]; then
923
+ cd "$APP_DIR" 2>/dev/null
924
+ pm2 stop claude-relay 2>/dev/null || true
925
+ pm2 delete claude-relay 2>/dev/null || true
926
+ fi
927
+
928
+ # 使用PID文件停止
929
+ if [ -f "$APP_DIR/.pid" ]; then
930
+ local pid=$(cat "$APP_DIR/.pid")
931
+ if kill -0 $pid 2>/dev/null; then
932
+ kill $pid
933
+ rm -f "$APP_DIR/.pid"
934
+ fi
935
+ fi
936
+
937
+ # 强制停止所有相关进程
938
+ pkill -f "node.*src/app.js" 2>/dev/null || true
939
+
940
+ # 等待进程完全退出(最多等待10秒)
941
+ local wait_count=0
942
+ while pgrep -f "node.*src/app.js" > /dev/null; do
943
+ if [ $wait_count -ge 10 ]; then
944
+ print_warning "进程停止超时,尝试强制终止..."
945
+ pkill -9 -f "node.*src/app.js" 2>/dev/null || true
946
+ sleep 1
947
+ break
948
+ fi
949
+ sleep 1
950
+ wait_count=$((wait_count + 1))
951
+ done
952
+
953
+ # 最终确认进程已停止
954
+ if pgrep -f "node.*src/app.js" > /dev/null; then
955
+ print_error "无法完全停止服务进程"
956
+ return 1
957
+ fi
958
+
959
+ print_success "服务已停止"
960
+ }
961
+
962
+ # 重启服务
963
+ restart_service() {
964
+ print_info "重启服务..."
965
+
966
+ # 停止服务并检查结果
967
+ if ! stop_service; then
968
+ print_error "停止服务失败"
969
+ return 1
970
+ fi
971
+
972
+ # 短暂等待,确保端口释放
973
+ sleep 1
974
+
975
+ # 启动服务,如果失败则重试
976
+ local retry_count=0
977
+ while [ $retry_count -lt 3 ]; do
978
+ # 清除可能的僵尸进程检测
979
+ if ! pgrep -f "node.*src/app.js" > /dev/null; then
980
+ # 进程确实已停止,可以启动
981
+ if start_service; then
982
+ return 0
983
+ fi
984
+ fi
985
+
986
+ retry_count=$((retry_count + 1))
987
+ if [ $retry_count -lt 3 ]; then
988
+ print_warning "启动失败,等待2秒后重试(第 $retry_count 次)..."
989
+ sleep 2
990
+ fi
991
+ done
992
+
993
+ print_error "重启服务失败"
994
+ return 1
995
+ }
996
+
997
+ # 更新模型价格
998
+ update_model_pricing() {
999
+ if ! check_installation; then
1000
+ print_error "服务未安装,请先运行: $0 install"
1001
+ return 1
1002
+ fi
1003
+
1004
+ print_info "更新模型价格数据..."
1005
+
1006
+ cd "$APP_DIR"
1007
+
1008
+ # 运行更新脚本
1009
+ if npm run update:pricing; then
1010
+ print_success "模型价格数据更新完成"
1011
+
1012
+ # 显示更新后的信息
1013
+ if [ -f "data/model_pricing.json" ]; then
1014
+ local model_count=$(grep -o '"[^"]*"\s*:' data/model_pricing.json | wc -l)
1015
+ local file_size=$(du -h data/model_pricing.json | cut -f1)
1016
+ echo -e "\n更新信息:"
1017
+ echo -e " 模型数量: ${GREEN}$model_count${NC}"
1018
+ echo -e " 文件大小: ${GREEN}$file_size${NC}"
1019
+ echo -e " 文件位置: $APP_DIR/data/model_pricing.json"
1020
+ fi
1021
+ else
1022
+ print_error "模型价格数据更新失败"
1023
+ return 1
1024
+ fi
1025
+ }
1026
+
1027
+ # 切换分支
1028
+ switch_branch() {
1029
+ if ! check_installation; then
1030
+ print_error "服务未安装,请先运行: $0 install"
1031
+ return 1
1032
+ fi
1033
+
1034
+ cd "$APP_DIR"
1035
+
1036
+ # 获取当前分支
1037
+ local current_branch=$(git branch --show-current 2>/dev/null)
1038
+ if [ -z "$current_branch" ]; then
1039
+ print_error "无法获取当前分支信息"
1040
+ return 1
1041
+ fi
1042
+
1043
+ print_info "当前分支: ${GREEN}$current_branch${NC}"
1044
+
1045
+ # 获取所有远程分支
1046
+ print_info "获取远程分支列表..."
1047
+ git fetch origin --prune >/dev/null 2>&1
1048
+
1049
+ # 显示可用分支
1050
+ echo -e "\n${YELLOW}可用分支:${NC}"
1051
+ local branches=$(git branch -r | grep -v HEAD | sed 's/origin\///' | sed 's/^ *//')
1052
+ local branch_array=()
1053
+ local i=1
1054
+
1055
+ while IFS= read -r branch; do
1056
+ if [ "$branch" = "$current_branch" ]; then
1057
+ echo -e " $i) $branch ${GREEN}(当前)${NC}"
1058
+ else
1059
+ echo " $i) $branch"
1060
+ fi
1061
+ branch_array+=("$branch")
1062
+ ((i++))
1063
+ done <<< "$branches"
1064
+
1065
+ echo ""
1066
+ echo -n "请选择要切换的分支 (输入编号或分支名,0 取消): "
1067
+ read branch_choice
1068
+
1069
+ # 处理用户输入
1070
+ local target_branch=""
1071
+ if [ "$branch_choice" = "0" ]; then
1072
+ print_info "已取消切换"
1073
+ return 0
1074
+ elif [[ "$branch_choice" =~ ^[0-9]+$ ]]; then
1075
+ # 用户输入的是编号
1076
+ local index=$((branch_choice - 1))
1077
+ if [ $index -ge 0 ] && [ $index -lt ${#branch_array[@]} ]; then
1078
+ target_branch="${branch_array[$index]}"
1079
+ else
1080
+ print_error "无效的编号"
1081
+ return 1
1082
+ fi
1083
+ else
1084
+ # 用户输入的是分支名
1085
+ target_branch="$branch_choice"
1086
+ # 验证分支是否存在
1087
+ if ! echo "$branches" | grep -q "^$target_branch$"; then
1088
+ print_error "分支 '$target_branch' 不存在"
1089
+ return 1
1090
+ fi
1091
+ fi
1092
+
1093
+ # 如果是同一个分支,无需切换
1094
+ if [ "$target_branch" = "$current_branch" ]; then
1095
+ print_info "已经在分支 $target_branch 上"
1096
+ return 0
1097
+ fi
1098
+
1099
+ print_info "准备切换到分支: ${GREEN}$target_branch${NC}"
1100
+
1101
+ # 保存当前运行状态
1102
+ local was_running=false
1103
+ if pgrep -f "node.*src/app.js" > /dev/null; then
1104
+ was_running=true
1105
+ print_info "检测到服务正在运行,将在切换后自动重启..."
1106
+ stop_service
1107
+ fi
1108
+
1109
+ # 处理本地修改(主要是权限变更导致的)
1110
+ print_info "检查本地修改..."
1111
+
1112
+ # 先重置所有权限相关的修改(特别是manage.sh的权限)
1113
+ git status --porcelain | while read -r line; do
1114
+ local file=$(echo "$line" | awk '{print $2}')
1115
+ if [ -n "$file" ]; then
1116
+ # 检查是否只是权限变更
1117
+ if git diff --summary "$file" 2>/dev/null | grep -q "mode change"; then
1118
+ print_info "重置文件权限变更: $file"
1119
+ git checkout HEAD -- "$file" 2>/dev/null || true
1120
+ fi
1121
+ fi
1122
+ done
1123
+
1124
+ # 检查是否还有其他实质性修改
1125
+ if git status --porcelain | grep -v "^??" | grep -q .; then
1126
+ print_warning "检测到本地文件修改:"
1127
+ git status --short | grep -v "^??"
1128
+ echo ""
1129
+ echo -n "是否要保存这些修改?(y/N): "
1130
+ read -n 1 save_changes
1131
+ echo
1132
+
1133
+ if [[ "$save_changes" =~ ^[Yy]$ ]]; then
1134
+ # 暂存修改
1135
+ print_info "暂存本地修改..."
1136
+ git stash push -m "Branch switch from $current_branch to $target_branch $(date +%Y-%m-%d)" >/dev/null 2>&1
1137
+ else
1138
+ # 丢弃修改
1139
+ print_info "丢弃本地修改..."
1140
+ git reset --hard HEAD >/dev/null 2>&1
1141
+ fi
1142
+ fi
1143
+
1144
+ # 切换分支
1145
+ print_info "切换分支..."
1146
+
1147
+ # 检查本地是否已有该分支
1148
+ if git show-ref --verify --quiet "refs/heads/$target_branch"; then
1149
+ # 本地已有分支,切换并更新
1150
+ if ! git checkout "$target_branch" 2>/dev/null; then
1151
+ print_error "切换分支失败"
1152
+ return 1
1153
+ fi
1154
+
1155
+ # 更新到最新
1156
+ print_info "更新到远程最新版本..."
1157
+ git pull origin "$target_branch" --rebase 2>/dev/null || {
1158
+ # 如果rebase失败,使用reset
1159
+ print_warning "更新失败,强制同步到远程版本..."
1160
+ git fetch origin "$target_branch"
1161
+ git reset --hard "origin/$target_branch"
1162
+ }
1163
+ else
1164
+ # 创建并切换到新分支
1165
+ if ! git checkout -b "$target_branch" "origin/$target_branch" 2>/dev/null; then
1166
+ print_error "创建并切换分支失败"
1167
+ return 1
1168
+ fi
1169
+ fi
1170
+
1171
+ print_success "已切换到分支: $target_branch"
1172
+
1173
+ # 确保脚本有执行权限(切换分支后必须执行)
1174
+ if [ -f "$APP_DIR/scripts/manage.sh" ]; then
1175
+ chmod +x "$APP_DIR/scripts/manage.sh"
1176
+ print_info "已设置脚本执行权限"
1177
+ fi
1178
+
1179
+ # 更新依赖(如果package.json有变化)
1180
+ if git diff "$current_branch..$target_branch" --name-only | grep -q "package.json"; then
1181
+ print_info "检测到 package.json 变化,更新依赖..."
1182
+ npm install
1183
+ fi
1184
+
1185
+ # 更新前端文件(如果切换到不同版本)
1186
+ if [ "$target_branch" != "$current_branch" ]; then
1187
+ print_info "更新前端文件..."
1188
+
1189
+ # 创建目标目录
1190
+ mkdir -p web/admin-spa/dist
1191
+
1192
+ # 清理旧的前端文件
1193
+ if [ -d "web/admin-spa/dist" ]; then
1194
+ rm -rf web/admin-spa/dist/* 2>/dev/null || true
1195
+ fi
1196
+
1197
+ # 尝试从对应的 web-dist 分支获取前端文件
1198
+ if git ls-remote --heads origin "web-dist-$target_branch" | grep -q "web-dist-$target_branch"; then
1199
+ print_info "从 web-dist-$target_branch 分支下载前端文件..."
1200
+ local web_branch="web-dist-$target_branch"
1201
+ elif git ls-remote --heads origin web-dist | grep -q web-dist; then
1202
+ print_info "从 web-dist 分支下载前端文件..."
1203
+ local web_branch="web-dist"
1204
+ else
1205
+ print_warning "未找到预构建的前端文件"
1206
+ web_branch=""
1207
+ fi
1208
+
1209
+ if [ -n "$web_branch" ]; then
1210
+ # 创建临时目录用于 clone
1211
+ TEMP_CLONE_DIR=$(mktemp -d)
1212
+
1213
+ # 下载前端文件
1214
+ if git clone --depth 1 --branch "$web_branch" --single-branch \
1215
+ https://github.com/Wei-Shaw/claude-relay-service.git \
1216
+ "$TEMP_CLONE_DIR" 2>/dev/null; then
1217
+
1218
+ # 复制文件到目标目录
1219
+ rsync -av --exclude='.git' --exclude='README.md' "$TEMP_CLONE_DIR/" web/admin-spa/dist/ 2>/dev/null || {
1220
+ cp -r "$TEMP_CLONE_DIR"/* web/admin-spa/dist/ 2>/dev/null
1221
+ rm -rf web/admin-spa/dist/.git 2>/dev/null
1222
+ rm -f web/admin-spa/dist/README.md 2>/dev/null
1223
+ }
1224
+
1225
+ print_success "前端文件更新完成"
1226
+ else
1227
+ print_warning "下载前端文件失败"
1228
+ fi
1229
+
1230
+ # 清理临时目录
1231
+ rm -rf "$TEMP_CLONE_DIR"
1232
+ fi
1233
+ fi
1234
+
1235
+ # 检查是否有暂存的修改可以恢复
1236
+ if [[ "$save_changes" =~ ^[Yy]$ ]] && git stash list | grep -q "Branch switch from $current_branch to $target_branch"; then
1237
+ echo ""
1238
+ echo -n "是否要恢复之前暂存的修改?(y/N): "
1239
+ read -n 1 restore_stash
1240
+ echo
1241
+
1242
+ if [[ "$restore_stash" =~ ^[Yy]$ ]]; then
1243
+ print_info "恢复暂存的修改..."
1244
+ git stash pop >/dev/null 2>&1 || print_warning "恢复修改时出现冲突,请手动解决"
1245
+ fi
1246
+ fi
1247
+
1248
+ # 如果之前在运行,则重新启动服务
1249
+ if [ "$was_running" = true ]; then
1250
+ print_info "重新启动服务..."
1251
+ start_service
1252
+ fi
1253
+
1254
+ # 显示切换后的信息
1255
+ echo ""
1256
+ echo -e "${GREEN}=== 分支切换完成 ===${NC}"
1257
+ echo -e "当前分支: ${GREEN}$target_branch${NC}"
1258
+
1259
+ # 显示版本信息
1260
+ if [ -f "$APP_DIR/VERSION" ]; then
1261
+ echo -e "当前版本: ${GREEN}$(cat "$APP_DIR/VERSION")${NC}"
1262
+ fi
1263
+
1264
+ # 显示最新提交
1265
+ local latest_commit=$(git log -1 --oneline 2>/dev/null)
1266
+ if [ -n "$latest_commit" ]; then
1267
+ echo -e "最新提交: ${GREEN}$latest_commit${NC}"
1268
+ fi
1269
+
1270
+ echo ""
1271
+ print_info "提示:如遇到问题,可以运行 'crs update' 强制更新到最新版本"
1272
+ }
1273
+
1274
+ # 显示状态
1275
+ show_status() {
1276
+ echo -e "\n${BLUE}=== Claude Relay Service 状态 ===${NC}"
1277
+
1278
+ # 获取实际端口
1279
+ local actual_port="$APP_PORT"
1280
+ if [ -z "$actual_port" ] && [ -f "$APP_DIR/.env" ]; then
1281
+ actual_port=$(grep "^PORT=" "$APP_DIR/.env" 2>/dev/null | cut -d'=' -f2)
1282
+ fi
1283
+ actual_port=${actual_port:-3000}
1284
+
1285
+ # 检查进程
1286
+ local pid=$(pgrep -f "node.*src/app.js" | head -1)
1287
+ if [ -n "$pid" ]; then
1288
+ echo -e "服务状态: ${GREEN}运行中${NC}"
1289
+ echo "进程 PID: $pid"
1290
+
1291
+ # 显示进程信息
1292
+ if command_exists ps; then
1293
+ local proc_info=$(ps -p $pid -o comm,etime,rss --no-headers 2>/dev/null)
1294
+ if [ -n "$proc_info" ]; then
1295
+ echo "进程信息: $proc_info"
1296
+ fi
1297
+ fi
1298
+ echo "服务端口: $actual_port"
1299
+
1300
+ # 获取公网IP
1301
+ local public_ip=$(get_public_ip)
1302
+
1303
+ # 显示访问地址
1304
+ echo -e "\n访问地址:"
1305
+ echo -e " 本地 Web: ${GREEN}http://localhost:$actual_port/web${NC}"
1306
+ echo -e " 本地 API: ${GREEN}http://localhost:$actual_port/api/v1${NC}"
1307
+ if [ "$public_ip" != "localhost" ]; then
1308
+ echo -e " 公网 Web: ${GREEN}http://$public_ip:$actual_port/web${NC}"
1309
+ echo -e " 公网 API: ${GREEN}http://$public_ip:$actual_port/api/v1${NC}"
1310
+ fi
1311
+ else
1312
+ echo -e "服务状态: ${RED}未运行${NC}"
1313
+ fi
1314
+
1315
+ # 显示安装信息
1316
+ if [ -n "$INSTALL_DIR" ] && [ -d "$INSTALL_DIR" ]; then
1317
+ echo -e "\n安装目录: $INSTALL_DIR"
1318
+ elif [ -d "$DEFAULT_INSTALL_DIR" ]; then
1319
+ echo -e "\n安装目录: $DEFAULT_INSTALL_DIR"
1320
+ fi
1321
+
1322
+ # Redis状态
1323
+ if command_exists redis-cli; then
1324
+ echo -e "\nRedis 状态:"
1325
+ local redis_cmd="redis-cli"
1326
+ if [ -n "$REDIS_HOST" ]; then
1327
+ redis_cmd="$redis_cmd -h $REDIS_HOST"
1328
+ fi
1329
+ if [ -n "$REDIS_PORT" ]; then
1330
+ redis_cmd="$redis_cmd -p $REDIS_PORT"
1331
+ fi
1332
+ if [ -n "$REDIS_PASSWORD" ]; then
1333
+ redis_cmd="$redis_cmd -a '$REDIS_PASSWORD'"
1334
+ fi
1335
+
1336
+ if $redis_cmd ping 2>/dev/null | grep -q "PONG"; then
1337
+ echo -e " 连接状态: ${GREEN}正常${NC}"
1338
+ else
1339
+ echo -e " 连接状态: ${RED}异常${NC}"
1340
+ fi
1341
+ fi
1342
+
1343
+ echo -e "\n${BLUE}===========================${NC}"
1344
+ }
1345
+
1346
+ # 显示帮助
1347
+ show_help() {
1348
+ echo "Claude Relay Service 管理脚本"
1349
+ echo ""
1350
+ echo "用法: $0 [命令]"
1351
+ echo ""
1352
+ echo "命令:"
1353
+ echo " install - 安装服务"
1354
+ echo " update - 更新服务"
1355
+ echo " uninstall - 卸载服务"
1356
+ echo " start - 启动服务"
1357
+ echo " stop - 停止服务"
1358
+ echo " restart - 重启服务"
1359
+ echo " status - 查看状态"
1360
+ echo " switch-branch - 切换分支"
1361
+ echo " update-pricing - 更新模型价格数据"
1362
+ echo " symlink - 创建 crs 快捷命令"
1363
+ echo " help - 显示帮助"
1364
+ echo ""
1365
+ }
1366
+
1367
+ # 交互式菜单
1368
+ show_menu() {
1369
+ clear
1370
+ echo -e "${BOLD}======================================${NC}"
1371
+ echo -e "${BOLD} Claude Relay Service (CRS) 管理工具 ${NC}"
1372
+ echo -e "${BOLD}======================================${NC}"
1373
+ echo ""
1374
+
1375
+ # 显示当前状态
1376
+ echo -e "${YELLOW}当前状态:${NC}"
1377
+ if check_installation; then
1378
+ echo -e " 安装状态: ${GREEN}已安装${NC} (目录: $INSTALL_DIR)"
1379
+
1380
+ # 获取实际端口
1381
+ local actual_port="$APP_PORT"
1382
+ if [ -z "$actual_port" ] && [ -f "$APP_DIR/.env" ]; then
1383
+ actual_port=$(grep "^PORT=" "$APP_DIR/.env" 2>/dev/null | cut -d'=' -f2)
1384
+ fi
1385
+ actual_port=${actual_port:-3000}
1386
+
1387
+ # 检查服务状态
1388
+ local pid=$(pgrep -f "node.*src/app.js" | head -1)
1389
+ if [ -n "$pid" ]; then
1390
+ echo -e " 运行状态: ${GREEN}运行中${NC}"
1391
+ echo -e " 进程 PID: $pid"
1392
+ echo -e " 服务端口: $actual_port"
1393
+
1394
+ # 获取公网IP
1395
+ local public_ip=$(get_public_ip)
1396
+ if [ "$public_ip" != "localhost" ]; then
1397
+ echo -e " 公网地址: ${GREEN}http://$public_ip:$actual_port/web${NC}"
1398
+ else
1399
+ echo -e " Web 界面: ${GREEN}http://localhost:$actual_port/web${NC}"
1400
+ fi
1401
+ else
1402
+ echo -e " 运行状态: ${RED}未运行${NC}"
1403
+ fi
1404
+ else
1405
+ echo -e " 安装状态: ${RED}未安装${NC}"
1406
+ fi
1407
+
1408
+ # Redis状态
1409
+ if command_exists redis-cli && [ -n "$REDIS_HOST" ]; then
1410
+ local redis_cmd="redis-cli -h $REDIS_HOST -p ${REDIS_PORT:-6379}"
1411
+ if [ -n "$REDIS_PASSWORD" ]; then
1412
+ redis_cmd="$redis_cmd -a '$REDIS_PASSWORD'"
1413
+ fi
1414
+
1415
+ if $redis_cmd ping 2>/dev/null | grep -q "PONG"; then
1416
+ echo -e " Redis 状态: ${GREEN}连接正常${NC}"
1417
+ else
1418
+ echo -e " Redis 状态: ${RED}连接异常${NC}"
1419
+ fi
1420
+ fi
1421
+
1422
+ echo ""
1423
+ echo -e "${BOLD}--------------------------------------${NC}"
1424
+ echo -e "${YELLOW}请选择操作:${NC}"
1425
+ echo ""
1426
+
1427
+ if ! check_installation; then
1428
+ echo " 1) 安装服务"
1429
+ echo " 2) 退出"
1430
+ echo ""
1431
+ echo -n "请输入选项 [1-2]: "
1432
+ else
1433
+ echo " 1) 查看状态"
1434
+ echo " 2) 启动服务"
1435
+ echo " 3) 停止服务"
1436
+ echo " 4) 重启服务"
1437
+ echo " 5) 更新服务"
1438
+ echo " 6) 切换分支"
1439
+ echo " 7) 更新模型价格"
1440
+ echo " 8) 卸载服务"
1441
+ echo " 9) 退出"
1442
+ echo ""
1443
+ echo -n "请输入选项 [1-9]: "
1444
+ fi
1445
+ }
1446
+
1447
+ # 处理菜单选择
1448
+ handle_menu_choice() {
1449
+ local choice=$1
1450
+
1451
+ if ! check_installation; then
1452
+ case $choice in
1453
+ 1)
1454
+ echo ""
1455
+ # 检查依赖
1456
+ if ! install_dependencies; then
1457
+ print_error "依赖安装失败"
1458
+ echo -n "按回车键继续..."
1459
+ read
1460
+ return 1
1461
+ fi
1462
+
1463
+ # 检查Redis
1464
+ if ! check_redis; then
1465
+ print_warning "Redis 连接失败"
1466
+ install_local_redis
1467
+
1468
+ # 重新测试连接
1469
+ REDIS_HOST="localhost"
1470
+ REDIS_PORT="6379"
1471
+ if ! check_redis; then
1472
+ print_error "Redis 配置失败,请手动安装并配置 Redis"
1473
+ echo -n "按回车键继续..."
1474
+ read
1475
+ return 1
1476
+ fi
1477
+ fi
1478
+
1479
+ # 安装服务
1480
+ install_service
1481
+
1482
+ # 创建软链接
1483
+ create_symlink
1484
+
1485
+ echo -n "按回车键继续..."
1486
+ read
1487
+ ;;
1488
+ 2)
1489
+ echo "退出管理工具"
1490
+ exit 0
1491
+ ;;
1492
+ *)
1493
+ print_error "无效选项"
1494
+ sleep 1
1495
+ ;;
1496
+ esac
1497
+ else
1498
+ case $choice in
1499
+ 1)
1500
+ echo ""
1501
+ show_status
1502
+ echo -n "按回车键继续..."
1503
+ read
1504
+ ;;
1505
+ 2)
1506
+ echo ""
1507
+ start_service
1508
+ echo -n "按回车键继续..."
1509
+ read
1510
+ ;;
1511
+ 3)
1512
+ echo ""
1513
+ stop_service
1514
+ echo -n "按回车键继续..."
1515
+ read
1516
+ ;;
1517
+ 4)
1518
+ echo ""
1519
+ restart_service
1520
+ echo -n "按回车键继续..."
1521
+ read
1522
+ ;;
1523
+ 5)
1524
+ echo ""
1525
+ update_service
1526
+ echo -n "按回车键继续..."
1527
+ read
1528
+ ;;
1529
+ 6)
1530
+ echo ""
1531
+ switch_branch
1532
+ echo -n "按回车键继续..."
1533
+ read
1534
+ ;;
1535
+ 7)
1536
+ echo ""
1537
+ update_model_pricing
1538
+ echo -n "按回车键继续..."
1539
+ read
1540
+ ;;
1541
+ 8)
1542
+ echo ""
1543
+ uninstall_service
1544
+ if [ $? -eq 0 ]; then
1545
+ exit 0
1546
+ fi
1547
+ ;;
1548
+ 9)
1549
+ echo "退出管理工具"
1550
+ exit 0
1551
+ ;;
1552
+ *)
1553
+ print_error "无效选项"
1554
+ sleep 1
1555
+ ;;
1556
+ esac
1557
+ fi
1558
+ }
1559
+
1560
+ # 创建软链接
1561
+ create_symlink() {
1562
+ # 获取脚本的绝对路径
1563
+ local script_path=""
1564
+
1565
+ # 优先使用项目中的 manage.sh(在 app/scripts 目录下)
1566
+ if [ -n "$APP_DIR" ] && [ -f "$APP_DIR/scripts/manage.sh" ]; then
1567
+ script_path="$APP_DIR/scripts/manage.sh"
1568
+ # 确保脚本有执行权限
1569
+ chmod +x "$script_path" 2>/dev/null || sudo chmod +x "$script_path" 2>/dev/null || true
1570
+ elif [ -f "/app/scripts/manage.sh" ] && [ "$(basename "$0")" = "manage.sh" ]; then
1571
+ # Docker 容器中的路径
1572
+ script_path="/app/scripts/manage.sh"
1573
+ elif command_exists realpath; then
1574
+ script_path="$(realpath "$0")"
1575
+ elif command_exists readlink && readlink -f "$0" >/dev/null 2>&1; then
1576
+ script_path="$(readlink -f "$0")"
1577
+ else
1578
+ # 备用方法:使用pwd和脚本名
1579
+ script_path="$(cd "$(dirname "$0")" && pwd)/$(basename "$0")"
1580
+ fi
1581
+
1582
+ local symlink_path="/usr/bin/crs"
1583
+
1584
+ print_info "创建命令行快捷方式..."
1585
+ print_info "APP_DIR: $APP_DIR"
1586
+ print_info "脚本路径: $script_path"
1587
+
1588
+ # 检查脚本文件是否存在
1589
+ if [ ! -f "$script_path" ]; then
1590
+ print_error "找不到脚本文件: $script_path"
1591
+ print_info "当前目录: $(pwd)"
1592
+ print_info "脚本参数 \$0: $0"
1593
+ if [ -n "$APP_DIR" ]; then
1594
+ print_info "检查项目目录结构:"
1595
+ ls -la "$APP_DIR/" 2>/dev/null | head -5
1596
+ if [ -d "$APP_DIR/scripts" ]; then
1597
+ print_info "scripts 目录内容:"
1598
+ ls -la "$APP_DIR/scripts/" 2>/dev/null | grep manage.sh
1599
+ fi
1600
+ fi
1601
+ return 1
1602
+ fi
1603
+
1604
+ # 如果已存在,直接删除并重新创建(默认使用代码中的最新版本)
1605
+ if [ -L "$symlink_path" ] || [ -f "$symlink_path" ]; then
1606
+ print_info "更新已存在的软链接..."
1607
+ sudo rm -f "$symlink_path" 2>/dev/null || {
1608
+ print_error "删除旧文件失败"
1609
+ return 1
1610
+ }
1611
+ fi
1612
+
1613
+ # 创建软链接
1614
+ if sudo ln -s "$script_path" "$symlink_path"; then
1615
+ print_success "已创建快捷命令 'crs'"
1616
+ echo "您现在可以在任何地方使用 'crs' 命令管理服务"
1617
+
1618
+ # 验证软链接
1619
+ if [ -L "$symlink_path" ]; then
1620
+ print_info "软链接验证成功"
1621
+ else
1622
+ print_warning "软链接验证失败"
1623
+ fi
1624
+ else
1625
+ print_error "创建软链接失败"
1626
+ print_info "请手动执行以下命令:"
1627
+ echo " sudo ln -s '$script_path' '$symlink_path'"
1628
+ return 1
1629
+ fi
1630
+ }
1631
+
1632
+ # 加载已安装的配置
1633
+ load_config() {
1634
+ # 尝试找到安装目录
1635
+ if [ -z "$INSTALL_DIR" ]; then
1636
+ if [ -d "$DEFAULT_INSTALL_DIR" ]; then
1637
+ INSTALL_DIR="$DEFAULT_INSTALL_DIR"
1638
+ fi
1639
+ fi
1640
+
1641
+ if [ -n "$INSTALL_DIR" ]; then
1642
+ # 检查是否使用了标准的安装结构(项目在 app 子目录)
1643
+ if [ -d "$INSTALL_DIR/app" ] && [ -f "$INSTALL_DIR/app/package.json" ]; then
1644
+ APP_DIR="$INSTALL_DIR/app"
1645
+ # 检查是否直接克隆了项目(项目在根目录)
1646
+ elif [ -f "$INSTALL_DIR/package.json" ]; then
1647
+ APP_DIR="$INSTALL_DIR"
1648
+ else
1649
+ APP_DIR="$INSTALL_DIR/app"
1650
+ fi
1651
+
1652
+ # 加载.env配置
1653
+ if [ -f "$APP_DIR/.env" ]; then
1654
+ export $(cat "$APP_DIR/.env" | grep -v '^#' | xargs)
1655
+ # 特别加载端口配置
1656
+ APP_PORT=$(grep "^PORT=" "$APP_DIR/.env" 2>/dev/null | cut -d'=' -f2)
1657
+ fi
1658
+ fi
1659
+ }
1660
+
1661
+ # 主函数
1662
+ main() {
1663
+ # 检测操作系统
1664
+ detect_os
1665
+
1666
+ if [ "$OS" == "unknown" ]; then
1667
+ print_error "不支持的操作系统"
1668
+ exit 1
1669
+ fi
1670
+
1671
+ # 加载配置
1672
+ load_config
1673
+
1674
+ # 处理命令
1675
+ case "$1" in
1676
+ install)
1677
+ # 检查依赖
1678
+ if ! install_dependencies; then
1679
+ print_error "依赖安装失败"
1680
+ exit 1
1681
+ fi
1682
+
1683
+ # 检查Redis
1684
+ if ! check_redis; then
1685
+ print_warning "Redis 连接失败"
1686
+ install_local_redis
1687
+
1688
+ # 重新测试连接
1689
+ REDIS_HOST="localhost"
1690
+ REDIS_PORT="6379"
1691
+ if ! check_redis; then
1692
+ print_error "Redis 配置失败,请手动安装并配置 Redis"
1693
+ exit 1
1694
+ fi
1695
+ fi
1696
+
1697
+ # 安装服务
1698
+ install_service
1699
+
1700
+ # 创建软链接
1701
+ create_symlink
1702
+ ;;
1703
+ update)
1704
+ update_service
1705
+ ;;
1706
+ uninstall)
1707
+ uninstall_service
1708
+ ;;
1709
+ start)
1710
+ start_service
1711
+ ;;
1712
+ stop)
1713
+ stop_service
1714
+ ;;
1715
+ restart)
1716
+ restart_service
1717
+ ;;
1718
+ status)
1719
+ show_status
1720
+ ;;
1721
+ switch-branch)
1722
+ switch_branch
1723
+ ;;
1724
+ update-pricing)
1725
+ update_model_pricing
1726
+ ;;
1727
+ symlink)
1728
+ # 单独创建软链接
1729
+ # 确保 APP_DIR 已设置
1730
+ if [ -z "$APP_DIR" ]; then
1731
+ print_error "请先安装项目后再创建软链接"
1732
+ print_info "运行: $0 install"
1733
+ exit 1
1734
+ fi
1735
+ create_symlink
1736
+ ;;
1737
+ help)
1738
+ show_help
1739
+ ;;
1740
+ "")
1741
+ # 无参数时显示交互式菜单
1742
+ while true; do
1743
+ show_menu
1744
+ read choice
1745
+ handle_menu_choice "$choice"
1746
+ done
1747
+ ;;
1748
+ *)
1749
+ print_error "未知命令: $1"
1750
+ echo ""
1751
+ show_help
1752
+ ;;
1753
+ esac
1754
+ }
1755
+
1756
+ # 运行主函数
1757
+ main "$@"
scripts/migrate-apikey-expiry.js ADDED
@@ -0,0 +1,192 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * 数据迁移脚本:为现有 API Key 设置默认有效期
5
+ *
6
+ * 使用方法:
7
+ * node scripts/migrate-apikey-expiry.js [--days=30] [--dry-run]
8
+ *
9
+ * 参数:
10
+ * --days: 设置默认有效期天数(默认30天)
11
+ * --dry-run: 仅模拟运行,不实际修改数据
12
+ */
13
+
14
+ const redis = require('../src/models/redis')
15
+ const logger = require('../src/utils/logger')
16
+ const readline = require('readline')
17
+
18
+ // 解析命令行参数
19
+ const args = process.argv.slice(2)
20
+ const params = {}
21
+ args.forEach((arg) => {
22
+ const [key, value] = arg.split('=')
23
+ params[key.replace('--', '')] = value || true
24
+ })
25
+
26
+ const DEFAULT_DAYS = params.days ? parseInt(params.days) : 30
27
+ const DRY_RUN = params['dry-run'] === true
28
+
29
+ // 创建 readline 接口用于用户确认
30
+ const rl = readline.createInterface({
31
+ input: process.stdin,
32
+ output: process.stdout
33
+ })
34
+
35
+ async function askConfirmation(question) {
36
+ return new Promise((resolve) => {
37
+ rl.question(`${question} (yes/no): `, (answer) => {
38
+ resolve(answer.toLowerCase() === 'yes' || answer.toLowerCase() === 'y')
39
+ })
40
+ })
41
+ }
42
+
43
+ async function migrateApiKeys() {
44
+ try {
45
+ logger.info('🔄 Starting API Key expiry migration...')
46
+ logger.info(`📅 Default expiry period: ${DEFAULT_DAYS} days`)
47
+ logger.info(`🔍 Mode: ${DRY_RUN ? 'DRY RUN (no changes will be made)' : 'LIVE RUN'}`)
48
+
49
+ // 连接 Redis
50
+ await redis.connect()
51
+ logger.success('✅ Connected to Redis')
52
+
53
+ // 获取所有 API Keys
54
+ const apiKeys = await redis.getAllApiKeys()
55
+ logger.info(`📊 Found ${apiKeys.length} API Keys in total`)
56
+
57
+ // 统计信息
58
+ const stats = {
59
+ total: apiKeys.length,
60
+ needsMigration: 0,
61
+ alreadyHasExpiry: 0,
62
+ migrated: 0,
63
+ errors: 0
64
+ }
65
+
66
+ // 需要迁移的 Keys
67
+ const keysToMigrate = []
68
+
69
+ // 分析每个 API Key
70
+ for (const key of apiKeys) {
71
+ if (!key.expiresAt || key.expiresAt === 'null' || key.expiresAt === '') {
72
+ keysToMigrate.push(key)
73
+ stats.needsMigration++
74
+ logger.info(`📌 API Key "${key.name}" (${key.id}) needs migration`)
75
+ } else {
76
+ stats.alreadyHasExpiry++
77
+ const expiryDate = new Date(key.expiresAt)
78
+ logger.info(
79
+ `✓ API Key "${key.name}" (${key.id}) already has expiry: ${expiryDate.toLocaleString()}`
80
+ )
81
+ }
82
+ }
83
+
84
+ if (keysToMigrate.length === 0) {
85
+ logger.success('✨ No API Keys need migration!')
86
+ return
87
+ }
88
+
89
+ // 显示迁移摘要
90
+ console.log(`\n${'='.repeat(60)}`)
91
+ console.log('📋 Migration Summary:')
92
+ console.log('='.repeat(60))
93
+ console.log(`Total API Keys: ${stats.total}`)
94
+ console.log(`Already have expiry: ${stats.alreadyHasExpiry}`)
95
+ console.log(`Need migration: ${stats.needsMigration}`)
96
+ console.log(`Default expiry: ${DEFAULT_DAYS} days from now`)
97
+ console.log(`${'='.repeat(60)}\n`)
98
+
99
+ // 如果不是 dry run,请求确认
100
+ if (!DRY_RUN) {
101
+ const confirmed = await askConfirmation(
102
+ `⚠️ This will set expiry dates for ${keysToMigrate.length} API Keys. Continue?`
103
+ )
104
+
105
+ if (!confirmed) {
106
+ logger.warn('❌ Migration cancelled by user')
107
+ return
108
+ }
109
+ }
110
+
111
+ // 计算新的过期时间
112
+ const newExpiryDate = new Date()
113
+ newExpiryDate.setDate(newExpiryDate.getDate() + DEFAULT_DAYS)
114
+ const newExpiryISO = newExpiryDate.toISOString()
115
+
116
+ logger.info(`\n🚀 Starting migration... New expiry date: ${newExpiryDate.toLocaleString()}`)
117
+
118
+ // 执行迁移
119
+ for (const key of keysToMigrate) {
120
+ try {
121
+ if (!DRY_RUN) {
122
+ // 直接更新 Redis 中的数据
123
+ // 使用 hset 更新单个字段
124
+ await redis.client.hset(`apikey:${key.id}`, 'expiresAt', newExpiryISO)
125
+ logger.success(`✅ Migrated: "${key.name}" (${key.id})`)
126
+ } else {
127
+ logger.info(`[DRY RUN] Would migrate: "${key.name}" (${key.id})`)
128
+ }
129
+ stats.migrated++
130
+ } catch (error) {
131
+ logger.error(`❌ Error migrating "${key.name}" (${key.id}):`, error.message)
132
+ stats.errors++
133
+ }
134
+ }
135
+
136
+ // 显示最终结果
137
+ console.log(`\n${'='.repeat(60)}`)
138
+ console.log('✅ Migration Complete!')
139
+ console.log('='.repeat(60))
140
+ console.log(`Successfully migrated: ${stats.migrated}`)
141
+ console.log(`Errors: ${stats.errors}`)
142
+ console.log(`New expiry date: ${newExpiryDate.toLocaleString()}`)
143
+ console.log(`${'='.repeat(60)}\n`)
144
+
145
+ if (DRY_RUN) {
146
+ logger.warn('⚠️ This was a DRY RUN. No actual changes were made.')
147
+ logger.info('💡 Run without --dry-run flag to apply changes.')
148
+ }
149
+ } catch (error) {
150
+ logger.error('💥 Migration failed:', error)
151
+ process.exit(1)
152
+ } finally {
153
+ // 清理
154
+ rl.close()
155
+ await redis.disconnect()
156
+ logger.info('👋 Disconnected from Redis')
157
+ }
158
+ }
159
+
160
+ // 显示帮助信息
161
+ if (params.help) {
162
+ console.log(`
163
+ API Key Expiry Migration Script
164
+
165
+ This script adds expiry dates to existing API Keys that don't have one.
166
+
167
+ Usage:
168
+ node scripts/migrate-apikey-expiry.js [options]
169
+
170
+ Options:
171
+ --days=NUMBER Set default expiry days (default: 30)
172
+ --dry-run Simulate the migration without making changes
173
+ --help Show this help message
174
+
175
+ Examples:
176
+ # Set 30-day expiry for all API Keys without expiry
177
+ node scripts/migrate-apikey-expiry.js
178
+
179
+ # Set 90-day expiry
180
+ node scripts/migrate-apikey-expiry.js --days=90
181
+
182
+ # Test run without making changes
183
+ node scripts/migrate-apikey-expiry.js --dry-run
184
+ `)
185
+ process.exit(0)
186
+ }
187
+
188
+ // 运行迁移
189
+ migrateApiKeys().catch((error) => {
190
+ logger.error('💥 Unexpected error:', error)
191
+ process.exit(1)
192
+ })
scripts/monitor-enhanced.sh ADDED
@@ -0,0 +1,273 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/bin/bash
2
+
3
+ # Claude Relay Service - 增强版实时监控脚本
4
+ # 结合并发监控和系统状态的完整监控方案
5
+
6
+ # 加载环境变量
7
+ if [ -f .env ]; then
8
+ export $(grep -v '^#' .env | xargs)
9
+ fi
10
+
11
+ echo "🔍 Claude Relay Service - 增强版实时监控"
12
+ echo "按 Ctrl+C 退出 | 按 's' 切换详细/简单模式"
13
+ echo "========================================"
14
+
15
+ # 获取服务配置
16
+ SERVICE_HOST=${HOST:-127.0.0.1}
17
+ SERVICE_PORT=${PORT:-3000}
18
+
19
+ # 如果HOST是0.0.0.0,客户端应该连接localhost
20
+ if [ "$SERVICE_HOST" = "0.0.0.0" ]; then
21
+ SERVICE_HOST="127.0.0.1"
22
+ fi
23
+
24
+ SERVICE_URL="http://${SERVICE_HOST}:${SERVICE_PORT}"
25
+
26
+ # 获取Redis配置
27
+ REDIS_HOST=${REDIS_HOST:-127.0.0.1}
28
+ REDIS_PORT=${REDIS_PORT:-6379}
29
+ REDIS_CMD="redis-cli -h $REDIS_HOST -p $REDIS_PORT"
30
+
31
+ if [ ! -z "$REDIS_PASSWORD" ]; then
32
+ REDIS_CMD="redis-cli -h $REDIS_HOST -p $REDIS_PORT -a $REDIS_PASSWORD"
33
+ fi
34
+
35
+ # 检查Redis连接
36
+ if ! $REDIS_CMD ping > /dev/null 2>&1; then
37
+ echo "❌ Redis连接失败,请检查Redis服务是否运行"
38
+ echo " 配置: $REDIS_HOST:$REDIS_PORT"
39
+ exit 1
40
+ fi
41
+
42
+ # 显示模式: simple(简单) / detailed(详细)
43
+ DISPLAY_MODE="simple"
44
+
45
+ # 获取API Key详细信息
46
+ get_api_key_info() {
47
+ local api_key_id=$1
48
+ local api_key_name=$($REDIS_CMD hget "apikey:$api_key_id" name 2>/dev/null)
49
+ local concurrency_limit=$($REDIS_CMD hget "apikey:$api_key_id" concurrencyLimit 2>/dev/null)
50
+ local token_limit=$($REDIS_CMD hget "apikey:$api_key_id" tokenLimit 2>/dev/null)
51
+ local created_at=$($REDIS_CMD hget "apikey:$api_key_id" createdAt 2>/dev/null)
52
+
53
+ if [ -z "$api_key_name" ]; then
54
+ api_key_name="Unknown"
55
+ fi
56
+
57
+ if [ -z "$concurrency_limit" ] || [ "$concurrency_limit" = "0" ]; then
58
+ concurrency_limit="无限制"
59
+ fi
60
+
61
+ if [ -z "$token_limit" ] || [ "$token_limit" = "0" ]; then
62
+ token_limit="无限制"
63
+ else
64
+ token_limit=$(printf "%'d" $token_limit)
65
+ fi
66
+
67
+ echo "$api_key_name|$concurrency_limit|$token_limit|$created_at"
68
+ }
69
+
70
+ # 获取使用统计信息
71
+ get_usage_stats() {
72
+ local api_key_id=$1
73
+ local today=$(date '+%Y-%m-%d')
74
+ local current_month=$(date '+%Y-%m')
75
+
76
+ # 获取总体使用量
77
+ local total_requests=$($REDIS_CMD hget "usage:$api_key_id" totalRequests 2>/dev/null)
78
+ local total_tokens=$($REDIS_CMD hget "usage:$api_key_id" totalTokens 2>/dev/null)
79
+
80
+ # 获取今日使用量
81
+ local daily_requests=$($REDIS_CMD hget "usage:daily:$api_key_id:$today" requests 2>/dev/null)
82
+ local daily_tokens=$($REDIS_CMD hget "usage:daily:$api_key_id:$today" tokens 2>/dev/null)
83
+
84
+ total_requests=${total_requests:-0}
85
+ total_tokens=${total_tokens:-0}
86
+ daily_requests=${daily_requests:-0}
87
+ daily_tokens=${daily_tokens:-0}
88
+
89
+ echo "$total_requests|$total_tokens|$daily_requests|$daily_tokens"
90
+ }
91
+
92
+ # 格式化数字
93
+ format_number() {
94
+ local num=$1
95
+ if [ "$num" -ge 1000000 ]; then
96
+ echo "$(echo "scale=1; $num / 1000000" | bc 2>/dev/null)M"
97
+ elif [ "$num" -ge 1000 ]; then
98
+ echo "$(echo "scale=1; $num / 1000" | bc 2>/dev/null)K"
99
+ else
100
+ echo "$num"
101
+ fi
102
+ }
103
+
104
+ # 获取系统信息
105
+ get_system_info() {
106
+ # Redis信息
107
+ local redis_info=$($REDIS_CMD info server 2>/dev/null)
108
+ local redis_memory_info=$($REDIS_CMD info memory 2>/dev/null)
109
+
110
+ local redis_version=$(echo "$redis_info" | grep redis_version | cut -d: -f2 | tr -d '\r' 2>/dev/null)
111
+ local redis_uptime=$(echo "$redis_info" | grep uptime_in_seconds | cut -d: -f2 | tr -d '\r' 2>/dev/null)
112
+ local used_memory=$(echo "$redis_memory_info" | grep used_memory_human | cut -d: -f2 | tr -d '\r' 2>/dev/null)
113
+
114
+ local redis_uptime_hours=0
115
+ if [ ! -z "$redis_uptime" ]; then
116
+ redis_uptime_hours=$((redis_uptime / 3600))
117
+ fi
118
+
119
+ # 服务状态
120
+ local service_status="unknown"
121
+ local service_uptime="0"
122
+ if command -v curl > /dev/null 2>&1; then
123
+ local health_response=$(curl -s ${SERVICE_URL}/health 2>/dev/null)
124
+ if [ $? -eq 0 ]; then
125
+ service_status=$(echo "$health_response" | grep -o '"status":"[^"]*"' | cut -d'"' -f4 | head -1)
126
+ service_uptime=$(echo "$health_response" | grep -o '"uptime":[^,}]*' | cut -d: -f2 | head -1)
127
+ fi
128
+ fi
129
+
130
+ local service_uptime_hours="0"
131
+ if [ ! -z "$service_uptime" ] && [ "$service_uptime" != "null" ]; then
132
+ service_uptime_hours=$(echo "scale=1; $service_uptime / 3600" | bc 2>/dev/null)
133
+ fi
134
+
135
+ echo "$redis_version|$redis_uptime_hours|$used_memory|$service_status|$service_uptime_hours"
136
+ }
137
+
138
+ # 主监控函数
139
+ monitor_enhanced() {
140
+ while true; do
141
+ clear
142
+ echo "🔍 Claude Relay Service - 增强版实时监控 | $(date '+%Y-%m-%d %H:%M:%S')"
143
+ echo "模式: $DISPLAY_MODE | 服务: $SERVICE_URL | Redis: $REDIS_HOST:$REDIS_PORT"
144
+ echo "========================================"
145
+
146
+ # 获取系统信息
147
+ local system_info=$(get_system_info)
148
+ local redis_version=$(echo "$system_info" | cut -d'|' -f1)
149
+ local redis_uptime=$(echo "$system_info" | cut -d'|' -f2)
150
+ local redis_memory=$(echo "$system_info" | cut -d'|' -f3)
151
+ local service_status=$(echo "$system_info" | cut -d'|' -f4)
152
+ local service_uptime=$(echo "$system_info" | cut -d'|' -f5)
153
+
154
+ # 系统状态概览
155
+ echo "🏥 系统状态概览:"
156
+ if [ "$service_status" = "healthy" ]; then
157
+ echo " ✅ 服务: 健康 (运行 ${service_uptime}h)"
158
+ else
159
+ echo " ⚠️ 服务: 异常 ($service_status)"
160
+ fi
161
+ echo " 📊 Redis: v${redis_version} (运行 ${redis_uptime}h, 内存 ${redis_memory})"
162
+ echo ""
163
+
164
+ # 获取并发信息
165
+ local concurrency_keys=$($REDIS_CMD --scan --pattern "concurrency:*" 2>/dev/null)
166
+ local total_concurrent=0
167
+ local active_keys=0
168
+ local concurrent_details=""
169
+
170
+ if [ ! -z "$concurrency_keys" ]; then
171
+ for key in $concurrency_keys; do
172
+ local count=$($REDIS_CMD get "$key" 2>/dev/null)
173
+ if [ ! -z "$count" ] && [ "$count" -gt 0 ]; then
174
+ local api_key_id=${key#concurrency:}
175
+ local key_info=$(get_api_key_info "$api_key_id")
176
+ local key_name=$(echo "$key_info" | cut -d'|' -f1)
177
+ local concurrency_limit=$(echo "$key_info" | cut -d'|' -f2)
178
+
179
+ concurrent_details="${concurrent_details}${key_name}:${count}/${concurrency_limit} "
180
+ total_concurrent=$((total_concurrent + count))
181
+ active_keys=$((active_keys + 1))
182
+ fi
183
+ done
184
+ fi
185
+
186
+ # 并发状态显示
187
+ echo "📊 当前并发状态:"
188
+ if [ $total_concurrent -eq 0 ]; then
189
+ echo " 💤 无活跃并发连接"
190
+ else
191
+ echo " 🔥 总并发: $total_concurrent 个连接 ($active_keys 个API Key)"
192
+ if [ "$DISPLAY_MODE" = "detailed" ]; then
193
+ echo " 📋 详情: $concurrent_details"
194
+ fi
195
+ fi
196
+ echo ""
197
+
198
+ # API Key统计
199
+ local total_keys=$($REDIS_CMD keys "apikey:*" 2>/dev/null | grep -v "apikey:hash_map" | wc -l)
200
+ local total_accounts=$($REDIS_CMD keys "claude:account:*" 2>/dev/null | wc -l)
201
+
202
+ echo "📋 资源统计:"
203
+ echo " 🔑 API Keys: $total_keys 个"
204
+ echo " 🏢 Claude账户: $total_accounts 个"
205
+
206
+ # 详细模式显示更多信息
207
+ if [ "$DISPLAY_MODE" = "detailed" ]; then
208
+ echo ""
209
+ echo "📈 使用统计 (今日/总计):"
210
+
211
+ # 获取所有API Key
212
+ local api_keys=$($REDIS_CMD keys "apikey:*" 2>/dev/null | grep -v "apikey:hash_map")
213
+ local total_daily_requests=0
214
+ local total_daily_tokens=0
215
+ local total_requests=0
216
+ local total_tokens=0
217
+
218
+ if [ ! -z "$api_keys" ]; then
219
+ for key in $api_keys; do
220
+ local api_key_id=${key#apikey:}
221
+ local key_info=$(get_api_key_info "$api_key_id")
222
+ local key_name=$(echo "$key_info" | cut -d'|' -f1)
223
+ local usage_info=$(get_usage_stats "$api_key_id")
224
+
225
+ local key_total_requests=$(echo "$usage_info" | cut -d'|' -f1)
226
+ local key_total_tokens=$(echo "$usage_info" | cut -d'|' -f2)
227
+ local key_daily_requests=$(echo "$usage_info" | cut -d'|' -f3)
228
+ local key_daily_tokens=$(echo "$usage_info" | cut -d'|' -f4)
229
+
230
+ total_daily_requests=$((total_daily_requests + key_daily_requests))
231
+ total_daily_tokens=$((total_daily_tokens + key_daily_tokens))
232
+ total_requests=$((total_requests + key_total_requests))
233
+ total_tokens=$((total_tokens + key_total_tokens))
234
+
235
+ if [ $((key_daily_requests + key_total_requests)) -gt 0 ]; then
236
+ echo " 📱 $key_name: ${key_daily_requests}req/$(format_number $key_daily_tokens) | ${key_total_requests}req/$(format_number $key_total_tokens)"
237
+ fi
238
+ done
239
+ fi
240
+
241
+ echo " 🌍 系统总计: ${total_daily_requests}req/$(format_number $total_daily_tokens) | ${total_requests}req/$(format_number $total_tokens)"
242
+ fi
243
+
244
+ echo ""
245
+ echo "🔄 刷新间隔: 5秒 | 按 Ctrl+C 退出 | 按 Enter 切换详细/简单模式"
246
+
247
+ # 非阻塞读取用户输入
248
+ read -t 5 user_input
249
+ if [ $? -eq 0 ]; then
250
+ case "$user_input" in
251
+ "s"|"S"|"")
252
+ if [ "$DISPLAY_MODE" = "simple" ]; then
253
+ DISPLAY_MODE="detailed"
254
+ else
255
+ DISPLAY_MODE="simple"
256
+ fi
257
+ ;;
258
+ esac
259
+ fi
260
+ done
261
+ }
262
+
263
+ # 信号处理
264
+ cleanup() {
265
+ echo ""
266
+ echo "👋 监控已停止"
267
+ exit 0
268
+ }
269
+
270
+ trap cleanup SIGINT SIGTERM
271
+
272
+ # 开始监控
273
+ monitor_enhanced
scripts/setup.js ADDED
@@ -0,0 +1,128 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const fs = require('fs')
2
+ const path = require('path')
3
+ const crypto = require('crypto')
4
+ const chalk = require('chalk')
5
+ const ora = require('ora')
6
+
7
+ const config = require('../config/config')
8
+
9
+ async function setup() {
10
+ console.log(chalk.blue.bold('\n🚀 Claude Relay Service 初始化设置\n'))
11
+
12
+ const spinner = ora('正在进行初始化设置...').start()
13
+
14
+ try {
15
+ // 1. 创建必要目录
16
+ const directories = ['logs', 'data', 'temp']
17
+
18
+ directories.forEach((dir) => {
19
+ const dirPath = path.join(__dirname, '..', dir)
20
+ if (!fs.existsSync(dirPath)) {
21
+ fs.mkdirSync(dirPath, { recursive: true })
22
+ }
23
+ })
24
+
25
+ // 2. 生成环境配置文件
26
+ if (!fs.existsSync(path.join(__dirname, '..', '.env'))) {
27
+ const envTemplate = fs.readFileSync(path.join(__dirname, '..', '.env.example'), 'utf8')
28
+
29
+ // 生成随机密钥
30
+ const jwtSecret = crypto.randomBytes(64).toString('hex')
31
+ const encryptionKey = crypto.randomBytes(32).toString('hex')
32
+
33
+ const envContent = envTemplate
34
+ .replace('your-jwt-secret-here', jwtSecret)
35
+ .replace('your-encryption-key-here', encryptionKey)
36
+
37
+ fs.writeFileSync(path.join(__dirname, '..', '.env'), envContent)
38
+ }
39
+
40
+ // 3. 生成或使用环境变量中的管理员凭据
41
+ const adminUsername =
42
+ process.env.ADMIN_USERNAME || `cr_admin_${crypto.randomBytes(4).toString('hex')}`
43
+ const adminPassword =
44
+ process.env.ADMIN_PASSWORD ||
45
+ crypto
46
+ .randomBytes(16)
47
+ .toString('base64')
48
+ .replace(/[^a-zA-Z0-9]/g, '')
49
+ .substring(0, 16)
50
+
51
+ // 如果使用了环境变量,显示提示
52
+ if (process.env.ADMIN_USERNAME || process.env.ADMIN_PASSWORD) {
53
+ console.log(chalk.yellow('\n📌 使用环境变量中的管理员凭据'))
54
+ }
55
+
56
+ // 4. 创建初始化完成标记文件
57
+ const initData = {
58
+ initializedAt: new Date().toISOString(),
59
+ adminUsername,
60
+ adminPassword,
61
+ version: '1.0.0'
62
+ }
63
+
64
+ fs.writeFileSync(
65
+ path.join(__dirname, '..', 'data', 'init.json'),
66
+ JSON.stringify(initData, null, 2)
67
+ )
68
+
69
+ spinner.succeed('初始化设置完成')
70
+
71
+ console.log(chalk.green('\n✅ 设置完成!\n'))
72
+ console.log(chalk.yellow('📋 重要信息:\n'))
73
+ console.log(` 管理员用户名: ${chalk.cyan(adminUsername)}`)
74
+ console.log(` 管理员密码: ${chalk.cyan(adminPassword)}`)
75
+
76
+ // 如果是自动生成的凭据,强调需要保存
77
+ if (!process.env.ADMIN_USERNAME && !process.env.ADMIN_PASSWORD) {
78
+ console.log(chalk.red('\n⚠️ 请立即保存这些凭据!首次登录后建议修改密码。'))
79
+ console.log(
80
+ chalk.yellow(
81
+ '\n💡 提示: 也可以通过环境变量 ADMIN_USERNAME 和 ADMIN_PASSWORD 预设管理员凭据。\n'
82
+ )
83
+ )
84
+ } else {
85
+ console.log(chalk.green('\n✅ 已使用预设的管理员凭据。\n'))
86
+ }
87
+
88
+ console.log(chalk.blue('🚀 启动服务:\n'))
89
+ console.log(' npm start - 启动生产服务')
90
+ console.log(' npm run dev - 启动开发服务')
91
+ console.log(' npm run cli admin - 管理员CLI工具\n')
92
+
93
+ console.log(chalk.blue('🌐 访问地址:\n'))
94
+ console.log(` Web管理界面: http://localhost:${config.server.port}/web`)
95
+ console.log(` API端点: http://localhost:${config.server.port}/api/v1/messages`)
96
+ console.log(` 健康检查: http://localhost:${config.server.port}/health\n`)
97
+ } catch (error) {
98
+ spinner.fail('初始化设置失败')
99
+ console.error(chalk.red('❌ 错误:'), error.message)
100
+ process.exit(1)
101
+ }
102
+ }
103
+
104
+ // 检查是否已初始化
105
+ function checkInitialized() {
106
+ const initFile = path.join(__dirname, '..', 'data', 'init.json')
107
+ if (fs.existsSync(initFile)) {
108
+ const initData = JSON.parse(fs.readFileSync(initFile, 'utf8'))
109
+ console.log(chalk.yellow('⚠️ 服务已经初始化过了!'))
110
+ console.log(` 初始化时间: ${new Date(initData.initializedAt).toLocaleString()}`)
111
+ console.log(` 管理员用户名: ${initData.adminUsername}`)
112
+ console.log('\n如需重新初始化,请删除 data/init.json 文件后再运行此命令。')
113
+ console.log(chalk.red('\n⚠️ 重要提示:'))
114
+ console.log(' 1. 删除 init.json 文件后运行 npm run setup')
115
+ console.log(' 2. 生成新的账号密码后,需要重启服务才能生效')
116
+ console.log(' 3. 使用 npm run service:restart 重启服务\n')
117
+ return true
118
+ }
119
+ return false
120
+ }
121
+
122
+ if (require.main === module) {
123
+ if (!checkInitialized()) {
124
+ setup()
125
+ }
126
+ }
127
+
128
+ module.exports = { setup, checkInitialized }
scripts/status-unified.sh ADDED
@@ -0,0 +1,262 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/bin/bash
2
+
3
+ # Claude Relay Service - 统一状态检查脚本
4
+ # 提供完整的系统状态概览
5
+
6
+ # 加载环境变量
7
+ if [ -f .env ]; then
8
+ export $(grep -v '^#' .env | xargs)
9
+ fi
10
+
11
+ # 参数处理
12
+ DETAIL_MODE=false
13
+ if [ "$1" = "--detail" ] || [ "$1" = "-d" ]; then
14
+ DETAIL_MODE=true
15
+ fi
16
+
17
+ echo "🔍 Claude Relay Service - 系统状态检查"
18
+ if [ "$DETAIL_MODE" = true ]; then
19
+ echo "模式: 详细信息"
20
+ else
21
+ echo "模式: 概览 (使用 --detail 查看详细信息)"
22
+ fi
23
+ echo "========================================"
24
+
25
+ # 获取服务配置
26
+ SERVICE_HOST=${HOST:-127.0.0.1}
27
+ SERVICE_PORT=${PORT:-3000}
28
+
29
+ if [ "$SERVICE_HOST" = "0.0.0.0" ]; then
30
+ SERVICE_HOST="127.0.0.1"
31
+ fi
32
+
33
+ SERVICE_URL="http://${SERVICE_HOST}:${SERVICE_PORT}"
34
+
35
+ # 获取Redis配置
36
+ REDIS_HOST=${REDIS_HOST:-127.0.0.1}
37
+ REDIS_PORT=${REDIS_PORT:-6379}
38
+ REDIS_CMD="redis-cli -h $REDIS_HOST -p $REDIS_PORT"
39
+
40
+ if [ ! -z "$REDIS_PASSWORD" ]; then
41
+ REDIS_CMD="redis-cli -h $REDIS_HOST -p $REDIS_PORT -a $REDIS_PASSWORD"
42
+ fi
43
+
44
+ # 检查Redis连接
45
+ echo "🔍 连接检查:"
46
+ if $REDIS_CMD ping > /dev/null 2>&1; then
47
+ echo " ✅ Redis连接正常 ($REDIS_HOST:$REDIS_PORT)"
48
+ else
49
+ echo " ❌ Redis连接失败 ($REDIS_HOST:$REDIS_PORT)"
50
+ exit 1
51
+ fi
52
+
53
+ # 检查服务状态
54
+ if command -v curl > /dev/null 2>&1; then
55
+ health_response=$(curl -s ${SERVICE_URL}/health 2>/dev/null)
56
+ if [ $? -eq 0 ]; then
57
+ health_status=$(echo "$health_response" | grep -o '"status":"[^"]*"' | cut -d'"' -f4 | head -1)
58
+ if [ "$health_status" = "healthy" ]; then
59
+ echo " ✅ 服务状态正常 ($SERVICE_URL)"
60
+ else
61
+ echo " ⚠️ 服务状态异常: $health_status ($SERVICE_URL)"
62
+ fi
63
+ else
64
+ echo " ❌ 服务无法访问 ($SERVICE_URL)"
65
+ fi
66
+ else
67
+ echo " ⚠️ curl命令不可用,无法检查服务状态"
68
+ fi
69
+
70
+ echo ""
71
+
72
+ # 格式化数字函数
73
+ format_number() {
74
+ local num=$1
75
+ if [ "$num" -ge 1000000 ]; then
76
+ echo "$(echo "scale=1; $num / 1000000" | bc 2>/dev/null)M"
77
+ elif [ "$num" -ge 1000 ]; then
78
+ echo "$(echo "scale=1; $num / 1000" | bc 2>/dev/null)K"
79
+ else
80
+ echo "$num"
81
+ fi
82
+ }
83
+
84
+ # 系统信息
85
+ echo "🏥 系统信息:"
86
+
87
+ # Redis信息
88
+ redis_info=$($REDIS_CMD info server 2>/dev/null)
89
+ redis_memory_info=$($REDIS_CMD info memory 2>/dev/null)
90
+
91
+ redis_version=$(echo "$redis_info" | grep redis_version | cut -d: -f2 | tr -d '\r' 2>/dev/null)
92
+ redis_uptime=$(echo "$redis_info" | grep uptime_in_seconds | cut -d: -f2 | tr -d '\r' 2>/dev/null)
93
+ used_memory=$(echo "$redis_memory_info" | grep used_memory_human | cut -d: -f2 | tr -d '\r' 2>/dev/null)
94
+
95
+ if [ ! -z "$redis_version" ]; then
96
+ echo " 📊 Redis版本: $redis_version"
97
+ fi
98
+
99
+ if [ ! -z "$redis_uptime" ]; then
100
+ uptime_hours=$((redis_uptime / 3600))
101
+ echo " ⏱️ Redis运行时间: $uptime_hours 小时"
102
+ fi
103
+
104
+ if [ ! -z "$used_memory" ]; then
105
+ echo " 💾 Redis内存使用: $used_memory"
106
+ fi
107
+
108
+ # 服务信息
109
+ if command -v curl > /dev/null 2>&1; then
110
+ health_response=$(curl -s ${SERVICE_URL}/health 2>/dev/null)
111
+ if [ $? -eq 0 ]; then
112
+ uptime=$(echo "$health_response" | grep -o '"uptime":[^,}]*' | cut -d: -f2 | head -1)
113
+
114
+ if [ ! -z "$uptime" ] && [ "$uptime" != "null" ]; then
115
+ uptime_hours=$(echo "scale=1; $uptime / 3600" | bc 2>/dev/null)
116
+ if [ ! -z "$uptime_hours" ]; then
117
+ echo " ⏰ 服务运行时间: $uptime_hours 小时"
118
+ fi
119
+ fi
120
+
121
+ # 检查端口
122
+ if netstat -ln 2>/dev/null | grep -q ":${SERVICE_PORT} "; then
123
+ echo " 🔌 端口${SERVICE_PORT}: 正在监听"
124
+ else
125
+ echo " ❌ 端口${SERVICE_PORT}: 未监听"
126
+ fi
127
+ fi
128
+ fi
129
+
130
+ echo ""
131
+
132
+ # 并发状态
133
+ echo "📊 并发状态:"
134
+ concurrency_keys=$($REDIS_CMD --scan --pattern "concurrency:*" 2>/dev/null)
135
+
136
+ if [ -z "$concurrency_keys" ]; then
137
+ echo " 💤 当前无活跃并发连接"
138
+ else
139
+ total_concurrent=0
140
+ active_keys=0
141
+
142
+ for key in $concurrency_keys; do
143
+ count=$($REDIS_CMD get "$key" 2>/dev/null)
144
+ if [ ! -z "$count" ] && [ "$count" -gt 0 ]; then
145
+ api_key_id=${key#concurrency:}
146
+
147
+ if [ "$DETAIL_MODE" = true ]; then
148
+ api_key_name=$($REDIS_CMD hget "apikey:$api_key_id" name 2>/dev/null)
149
+ concurrency_limit=$($REDIS_CMD hget "apikey:$api_key_id" concurrencyLimit 2>/dev/null)
150
+
151
+ if [ -z "$api_key_name" ]; then
152
+ api_key_name="Unknown"
153
+ fi
154
+
155
+ if [ -z "$concurrency_limit" ] || [ "$concurrency_limit" = "0" ]; then
156
+ limit_text="无限制"
157
+ else
158
+ limit_text="$concurrency_limit"
159
+ fi
160
+
161
+ echo " 🔑 $api_key_name: $count 个并发 (限制: $limit_text)"
162
+ fi
163
+
164
+ total_concurrent=$((total_concurrent + count))
165
+ active_keys=$((active_keys + 1))
166
+ fi
167
+ done
168
+
169
+ echo " 📈 总计: $total_concurrent 个活跃并发连接 ($active_keys 个API Key)"
170
+ fi
171
+
172
+ echo ""
173
+
174
+ # 资源统计
175
+ echo "📋 资源统计:"
176
+
177
+ total_keys=$($REDIS_CMD keys "apikey:*" 2>/dev/null | grep -v "apikey:hash_map" | wc -l)
178
+ total_accounts=$($REDIS_CMD keys "claude:account:*" 2>/dev/null | wc -l)
179
+
180
+ echo " 🔑 API Key总数: $total_keys"
181
+ echo " 🏢 Claude账户数: $total_accounts"
182
+
183
+ # 详细模式下的使用统计
184
+ if [ "$DETAIL_MODE" = true ]; then
185
+ echo ""
186
+ echo "📈 使用统计:"
187
+
188
+ today=$(date '+%Y-%m-%d')
189
+ current_month=$(date '+%Y-%m')
190
+
191
+ # 系统总体统计
192
+ total_daily_requests=0
193
+ total_daily_tokens=0
194
+ total_requests=0
195
+ total_tokens=0
196
+
197
+ api_keys=$($REDIS_CMD keys "apikey:*" 2>/dev/null | grep -v "apikey:hash_map")
198
+
199
+ if [ ! -z "$api_keys" ]; then
200
+ echo " 📱 API Key详情:"
201
+
202
+ for key in $api_keys; do
203
+ api_key_id=${key#apikey:}
204
+
205
+ # API Key基本信息
206
+ api_key_name=$($REDIS_CMD hget "apikey:$api_key_id" name 2>/dev/null)
207
+ token_limit=$($REDIS_CMD hget "apikey:$api_key_id" tokenLimit 2>/dev/null)
208
+ created_at=$($REDIS_CMD hget "apikey:$api_key_id" createdAt 2>/dev/null)
209
+
210
+ # 使用统计
211
+ key_total_requests=$($REDIS_CMD hget "usage:$api_key_id" totalRequests 2>/dev/null)
212
+ key_total_tokens=$($REDIS_CMD hget "usage:$api_key_id" totalTokens 2>/dev/null)
213
+ key_daily_requests=$($REDIS_CMD hget "usage:daily:$api_key_id:$today" requests 2>/dev/null)
214
+ key_daily_tokens=$($REDIS_CMD hget "usage:daily:$api_key_id:$today" tokens 2>/dev/null)
215
+
216
+ # 默认值处理
217
+ api_key_name=${api_key_name:-"Unknown"}
218
+ token_limit=${token_limit:-0}
219
+ key_total_requests=${key_total_requests:-0}
220
+ key_total_tokens=${key_total_tokens:-0}
221
+ key_daily_requests=${key_daily_requests:-0}
222
+ key_daily_tokens=${key_daily_tokens:-0}
223
+
224
+ # 格式化Token限制
225
+ if [ "$token_limit" = "0" ]; then
226
+ limit_text="无限制"
227
+ else
228
+ limit_text=$(format_number $token_limit)
229
+ fi
230
+
231
+ # 创建时间格式化
232
+ if [ ! -z "$created_at" ]; then
233
+ created_date=$(echo "$created_at" | cut -d'T' -f1)
234
+ else
235
+ created_date="未知"
236
+ fi
237
+
238
+ echo " • $api_key_name (创建: $created_date, 限制: $limit_text)"
239
+ echo " 今日: ${key_daily_requests}请求 / $(format_number $key_daily_tokens)tokens"
240
+ echo " 总计: ${key_total_requests}请求 / $(format_number $key_total_tokens)tokens"
241
+ echo ""
242
+
243
+ # 累计统计
244
+ total_daily_requests=$((total_daily_requests + key_daily_requests))
245
+ total_daily_tokens=$((total_daily_tokens + key_daily_tokens))
246
+ total_requests=$((total_requests + key_total_requests))
247
+ total_tokens=$((total_tokens + key_total_tokens))
248
+ done
249
+ fi
250
+
251
+ echo " 🌍 系统总计:"
252
+ echo " 今日: ${total_daily_requests}请求 / $(format_number $total_daily_tokens)tokens"
253
+ echo " 总计: ${total_requests}请求 / $(format_number $total_tokens)tokens"
254
+ fi
255
+
256
+ echo ""
257
+ echo "✅ 状态检查完成 - $(date '+%Y-%m-%d %H:%M:%S')"
258
+
259
+ if [ "$DETAIL_MODE" = false ]; then
260
+ echo ""
261
+ echo "💡 使用 'npm run status -- --detail' 查看详细信息"
262
+ fi
scripts/test-account-display.js ADDED
@@ -0,0 +1,143 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * 测试账号显示问题是否已修复
3
+ */
4
+
5
+ const axios = require('axios')
6
+ const config = require('../config/config')
7
+
8
+ // 从 init.json 读取管理员凭据
9
+ const fs = require('fs')
10
+ const path = require('path')
11
+
12
+ async function testAccountDisplay() {
13
+ console.log('🔍 测试账号显示问题...\n')
14
+
15
+ try {
16
+ // 读取管理员凭据
17
+ const initPath = path.join(__dirname, '..', 'config', 'init.json')
18
+ if (!fs.existsSync(initPath)) {
19
+ console.error('❌ 找不到 init.json 文件,请运行 npm run setup')
20
+ process.exit(1)
21
+ }
22
+
23
+ const initData = JSON.parse(fs.readFileSync(initPath, 'utf8'))
24
+ const adminUser = initData.admins?.[0]
25
+ if (!adminUser) {
26
+ console.error('❌ 没有找到管理员账号')
27
+ process.exit(1)
28
+ }
29
+
30
+ const baseURL = `http://localhost:${config.server.port}`
31
+
32
+ // 登录获取 token
33
+ console.log('🔐 登录管理员账号...')
34
+ const loginResp = await axios.post(`${baseURL}/admin/login`, {
35
+ username: adminUser.username,
36
+ password: adminUser.password
37
+ })
38
+
39
+ if (!loginResp.data.success) {
40
+ console.error('❌ 登录失败')
41
+ process.exit(1)
42
+ }
43
+
44
+ const { token } = loginResp.data
45
+ console.log('✅ 登录成功\n')
46
+
47
+ // 设置请求头
48
+ const headers = {
49
+ Authorization: `Bearer ${token}`,
50
+ 'Content-Type': 'application/json'
51
+ }
52
+
53
+ // 获取 Claude OAuth 账号
54
+ console.log('📋 获取 Claude OAuth 账号...')
55
+ const claudeResp = await axios.get(`${baseURL}/admin/claude-accounts`, { headers })
56
+ const claudeAccounts = claudeResp.data.data || []
57
+
58
+ console.log(`找到 ${claudeAccounts.length} 个 Claude OAuth 账号`)
59
+
60
+ // 分类显示
61
+ const claudeDedicated = claudeAccounts.filter((a) => a.accountType === 'dedicated')
62
+ const claudeGroup = claudeAccounts.filter((a) => a.accountType === 'group')
63
+ const claudeShared = claudeAccounts.filter((a) => a.accountType === 'shared')
64
+
65
+ console.log(`- 专属账号: ${claudeDedicated.length} 个`)
66
+ console.log(`- 分组账号: ${claudeGroup.length} 个`)
67
+ console.log(`- 共享账号: ${claudeShared.length} 个`)
68
+
69
+ // 检查 platform 字段
70
+ console.log('\n检查 platform 字段:')
71
+ claudeAccounts.slice(0, 3).forEach((acc) => {
72
+ console.log(`- ${acc.name}: platform=${acc.platform}, accountType=${acc.accountType}`)
73
+ })
74
+
75
+ // 获取 Claude Console 账号
76
+ console.log('\n📋 获取 Claude Console 账号...')
77
+ const consoleResp = await axios.get(`${baseURL}/admin/claude-console-accounts`, { headers })
78
+ const consoleAccounts = consoleResp.data.data || []
79
+
80
+ console.log(`找到 ${consoleAccounts.length} 个 Claude Console 账号`)
81
+
82
+ // 分类显示
83
+ const consoleDedicated = consoleAccounts.filter((a) => a.accountType === 'dedicated')
84
+ const consoleGroup = consoleAccounts.filter((a) => a.accountType === 'group')
85
+ const consoleShared = consoleAccounts.filter((a) => a.accountType === 'shared')
86
+
87
+ console.log(`- 专属账号: ${consoleDedicated.length} 个`)
88
+ console.log(`- 分组账号: ${consoleGroup.length} 个`)
89
+ console.log(`- 共享账号: ${consoleShared.length} 个`)
90
+
91
+ // 检查 platform 字段
92
+ console.log('\n检查 platform 字段:')
93
+ consoleAccounts.slice(0, 3).forEach((acc) => {
94
+ console.log(`- ${acc.name}: platform=${acc.platform}, accountType=${acc.accountType}`)
95
+ })
96
+
97
+ // 获取账号分组
98
+ console.log('\n📋 获取账号分组...')
99
+ const groupsResp = await axios.get(`${baseURL}/admin/account-groups`, { headers })
100
+ const groups = groupsResp.data.data || []
101
+
102
+ console.log(`找到 ${groups.length} 个账号分组`)
103
+
104
+ const claudeGroups = groups.filter((g) => g.platform === 'claude')
105
+ const geminiGroups = groups.filter((g) => g.platform === 'gemini')
106
+
107
+ console.log(`- Claude 分组: ${claudeGroups.length} 个`)
108
+ console.log(`- Gemini 分组: ${geminiGroups.length} 个`)
109
+
110
+ // 测试结果总结
111
+ console.log('\n📊 测试结果总结:')
112
+ console.log('✅ Claude OAuth 账号已包含 platform 字段')
113
+ console.log('✅ Claude Console 账号已包含 platform 字段')
114
+ console.log('✅ 账号分组功能正常')
115
+
116
+ const totalDedicated = claudeDedicated.length + consoleDedicated.length
117
+ const totalGroups = claudeGroups.length
118
+
119
+ if (totalDedicated > 0) {
120
+ console.log(`\n✅ 共有 ${totalDedicated} 个专属账号应该显示在下拉框中`)
121
+ } else {
122
+ console.log('\n⚠️ 没有找到专属账号,请在账号管理页面设置账号类型为"专属账户"')
123
+ }
124
+
125
+ if (totalGroups > 0) {
126
+ console.log(`✅ 共有 ${totalGroups} 个分组应该显示在下拉框中`)
127
+ }
128
+
129
+ console.log('\n💡 请在浏览器中测试创建/编辑 API Key,检查下拉框是否正确显示三个类别:')
130
+ console.log(' 1. 调度分组')
131
+ console.log(' 2. Claude OAuth 账号')
132
+ console.log(' 3. Claude Console 账号')
133
+ } catch (error) {
134
+ console.error('❌ 测试失败:', error.message)
135
+ if (error.response) {
136
+ console.error('响应数据:', error.response.data)
137
+ }
138
+ } finally {
139
+ process.exit(0)
140
+ }
141
+ }
142
+
143
+ testAccountDisplay()
scripts/test-api-response.js ADDED
@@ -0,0 +1,141 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * 测试 API 响应中的账号数据
3
+ */
4
+
5
+ const redis = require('../src/models/redis')
6
+ const claudeAccountService = require('../src/services/claudeAccountService')
7
+ const claudeConsoleAccountService = require('../src/services/claudeConsoleAccountService')
8
+ const accountGroupService = require('../src/services/accountGroupService')
9
+
10
+ async function testApiResponse() {
11
+ console.log('🔍 测试 API 响应数据...\n')
12
+
13
+ try {
14
+ // 确保 Redis 已连接
15
+ await redis.connect()
16
+
17
+ // 1. 测试 Claude OAuth 账号服务
18
+ console.log('📋 测试 Claude OAuth 账号服务...')
19
+ const claudeAccounts = await claudeAccountService.getAllAccounts()
20
+ console.log(`找到 ${claudeAccounts.length} 个 Claude OAuth 账号`)
21
+
22
+ // 检查前3个账号的数据结构
23
+ console.log('\n账号数据结构示例:')
24
+ claudeAccounts.slice(0, 3).forEach((acc) => {
25
+ console.log(`\n账号: ${acc.name}`)
26
+ console.log(` - ID: ${acc.id}`)
27
+ console.log(` - accountType: ${acc.accountType}`)
28
+ console.log(` - platform: ${acc.platform}`)
29
+ console.log(` - status: ${acc.status}`)
30
+ console.log(` - isActive: ${acc.isActive}`)
31
+ })
32
+
33
+ // 统计专属账号
34
+ const claudeDedicated = claudeAccounts.filter((a) => a.accountType === 'dedicated')
35
+ const claudeGroup = claudeAccounts.filter((a) => a.accountType === 'group')
36
+
37
+ console.log('\n统计结果:')
38
+ console.log(` - 专属账号: ${claudeDedicated.length} 个`)
39
+ console.log(` - 分组账号: ${claudeGroup.length} 个`)
40
+
41
+ // 2. 测试 Claude Console 账号服务
42
+ console.log('\n\n📋 测试 Claude Console 账号服务...')
43
+ const consoleAccounts = await claudeConsoleAccountService.getAllAccounts()
44
+ console.log(`找到 ${consoleAccounts.length} 个 Claude Console 账号`)
45
+
46
+ // 检查前3个账号的数据结构
47
+ console.log('\n账号数据结构示例:')
48
+ consoleAccounts.slice(0, 3).forEach((acc) => {
49
+ console.log(`\n账号: ${acc.name}`)
50
+ console.log(` - ID: ${acc.id}`)
51
+ console.log(` - accountType: ${acc.accountType}`)
52
+ console.log(` - platform: ${acc.platform}`)
53
+ console.log(` - status: ${acc.status}`)
54
+ console.log(` - isActive: ${acc.isActive}`)
55
+ })
56
+
57
+ // 统计专属账号
58
+ const consoleDedicated = consoleAccounts.filter((a) => a.accountType === 'dedicated')
59
+ const consoleGroup = consoleAccounts.filter((a) => a.accountType === 'group')
60
+
61
+ console.log('\n统计结果:')
62
+ console.log(` - 专属账号: ${consoleDedicated.length} 个`)
63
+ console.log(` - 分组账号: ${consoleGroup.length} 个`)
64
+
65
+ // 3. 测试账号分组服务
66
+ console.log('\n\n📋 测试账号分组服务...')
67
+ const groups = await accountGroupService.getAllGroups()
68
+ console.log(`找到 ${groups.length} 个账号分组`)
69
+
70
+ // 显示分组信息
71
+ groups.forEach((group) => {
72
+ console.log(`\n分组: ${group.name}`)
73
+ console.log(` - ID: ${group.id}`)
74
+ console.log(` - platform: ${group.platform}`)
75
+ console.log(` - memberCount: ${group.memberCount}`)
76
+ })
77
+
78
+ // 4. 验证结果
79
+ console.log('\n\n📊 验证结果:')
80
+
81
+ // 检查 platform 字段
82
+ const claudeWithPlatform = claudeAccounts.filter((a) => a.platform === 'claude')
83
+ const consoleWithPlatform = consoleAccounts.filter((a) => a.platform === 'claude-console')
84
+
85
+ if (claudeWithPlatform.length === claudeAccounts.length) {
86
+ console.log('✅ 所有 Claude OAuth 账号都有正确的 platform 字段')
87
+ } else {
88
+ console.log(
89
+ `⚠️ 只有 ${claudeWithPlatform.length}/${claudeAccounts.length} 个 Claude OAuth 账号有正确的 platform 字段`
90
+ )
91
+ }
92
+
93
+ if (consoleWithPlatform.length === consoleAccounts.length) {
94
+ console.log('✅ 所有 Claude Console 账号都有正确的 platform 字段')
95
+ } else {
96
+ console.log(
97
+ `⚠️ 只有 ${consoleWithPlatform.length}/${consoleAccounts.length} 个 Claude Console 账号有正确的 platform 字段`
98
+ )
99
+ }
100
+
101
+ // 总结
102
+ const totalDedicated = claudeDedicated.length + consoleDedicated.length
103
+ const totalGroup = claudeGroup.length + consoleGroup.length
104
+ const totalGroups = groups.filter((g) => g.platform === 'claude').length
105
+
106
+ console.log('\n📈 总结:')
107
+ console.log(
108
+ `- 专属账号总数: ${totalDedicated} 个 (Claude OAuth: ${claudeDedicated.length}, Console: ${consoleDedicated.length})`
109
+ )
110
+ console.log(
111
+ `- 分组账号总数: ${totalGroup} 个 (Claude OAuth: ${claudeGroup.length}, Console: ${consoleGroup.length})`
112
+ )
113
+ console.log(`- 账号分组总数: ${totalGroups} 个`)
114
+
115
+ if (totalDedicated + totalGroups > 0) {
116
+ console.log('\n✅ 前端下拉框应该能够显示:')
117
+ if (totalGroups > 0) {
118
+ console.log(' - 调度分组')
119
+ }
120
+ if (claudeDedicated.length > 0) {
121
+ console.log(' - Claude OAuth 专属账号 (仅 dedicated 类型)')
122
+ }
123
+ if (consoleDedicated.length > 0) {
124
+ console.log(' - Claude Console 专属账号 (仅 dedicated 类型)')
125
+ }
126
+ } else {
127
+ console.log('\n⚠️ 没有找到任何专属账号或分组,请检查账号配置')
128
+ }
129
+
130
+ console.log('\n💡 说明:')
131
+ console.log('- 专属账号下拉框只显示 accountType="dedicated" 的账号')
132
+ console.log('- accountType="group" 的账号通过分组调度,不在专属账号中显示')
133
+ } catch (error) {
134
+ console.error('❌ 测试失败:', error)
135
+ console.error(error.stack)
136
+ } finally {
137
+ process.exit(0)
138
+ }
139
+ }
140
+
141
+ testApiResponse()
scripts/test-bedrock-models.js ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env node
2
+
3
+ const bedrockRelayService = require('../src/services/bedrockRelayService')
4
+
5
+ async function testBedrockModels() {
6
+ try {
7
+ console.log('🧪 测试Bedrock模型配置...')
8
+
9
+ // 测试可用模型列表
10
+ const models = await bedrockRelayService.getAvailableModels()
11
+ console.log(`📋 找到 ${models.length} 个可用模型:`)
12
+ models.forEach((model) => {
13
+ console.log(` - ${model.id} (${model.name})`)
14
+ })
15
+
16
+ // 测试默认模型
17
+ console.log(`\n🎯 系统默认模型: ${bedrockRelayService.defaultModel}`)
18
+ console.log(`🎯 系统默认小模型: ${bedrockRelayService.defaultSmallModel}`)
19
+
20
+ console.log('\n✅ Bedrock模型配置测试完成')
21
+ process.exit(0)
22
+ } catch (error) {
23
+ console.error('❌ Bedrock模型测试失败:', error)
24
+ process.exit(1)
25
+ }
26
+ }
27
+
28
+ // 如果直接运行此脚本
29
+ if (require.main === module) {
30
+ testBedrockModels()
31
+ }
32
+
33
+ module.exports = { testBedrockModels }
scripts/test-dedicated-accounts.js ADDED
@@ -0,0 +1,133 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * 测试专属账号显示问题
3
+ */
4
+
5
+ const redis = require('../src/models/redis')
6
+
7
+ async function testDedicatedAccounts() {
8
+ console.log('🔍 检查专属账号...\n')
9
+
10
+ try {
11
+ // 确保 Redis 已连接
12
+ await redis.connect()
13
+
14
+ // 获取所有 Claude 账号
15
+ const claudeKeys = await redis.client.keys('claude:account:*')
16
+ console.log(`找到 ${claudeKeys.length} 个 Claude 账号\n`)
17
+
18
+ const dedicatedAccounts = []
19
+ const groupAccounts = []
20
+ const sharedAccounts = []
21
+
22
+ for (const key of claudeKeys) {
23
+ const account = await redis.client.hgetall(key)
24
+ const accountType = account.accountType || 'shared'
25
+
26
+ const accountInfo = {
27
+ id: account.id,
28
+ name: account.name,
29
+ accountType,
30
+ status: account.status,
31
+ isActive: account.isActive,
32
+ createdAt: account.createdAt
33
+ }
34
+
35
+ if (accountType === 'dedicated') {
36
+ dedicatedAccounts.push(accountInfo)
37
+ } else if (accountType === 'group') {
38
+ groupAccounts.push(accountInfo)
39
+ } else {
40
+ sharedAccounts.push(accountInfo)
41
+ }
42
+ }
43
+
44
+ console.log('📊 账号统计:')
45
+ console.log(`- 专属账号: ${dedicatedAccounts.length} 个`)
46
+ console.log(`- 分组账号: ${groupAccounts.length} 个`)
47
+ console.log(`- 共享账号: ${sharedAccounts.length} 个`)
48
+ console.log('')
49
+
50
+ if (dedicatedAccounts.length > 0) {
51
+ console.log('✅ 专属账号列表:')
52
+ dedicatedAccounts.forEach((acc) => {
53
+ console.log(` - ${acc.name} (ID: ${acc.id}, 状态: ${acc.status})`)
54
+ })
55
+ console.log('')
56
+ } else {
57
+ console.log('⚠️ 没有找到专属账号!')
58
+ console.log('💡 提示: 请确保在账号管理页面将账号类型设置为"专属账户"')
59
+ console.log('')
60
+ }
61
+
62
+ if (groupAccounts.length > 0) {
63
+ console.log('📁 分组账号列表:')
64
+ groupAccounts.forEach((acc) => {
65
+ console.log(` - ${acc.name} (ID: ${acc.id}, 状态: ${acc.status})`)
66
+ })
67
+ console.log('')
68
+ }
69
+
70
+ // 检查分组
71
+ const groupKeys = await redis.client.keys('account_group:*')
72
+ console.log(`\n找到 ${groupKeys.length} 个账号分组`)
73
+
74
+ if (groupKeys.length > 0) {
75
+ console.log('📋 分组列表:')
76
+ for (const key of groupKeys) {
77
+ const group = await redis.client.hgetall(key)
78
+ console.log(
79
+ ` - ${group.name} (平台: ${group.platform}, 成员数: ${group.memberCount || 0})`
80
+ )
81
+ }
82
+ }
83
+
84
+ // 检查 Claude Console 账号
85
+ const consoleKeys = await redis.client.keys('claude_console_account:*')
86
+ console.log(`\n找到 ${consoleKeys.length} 个 Claude Console 账号`)
87
+
88
+ const dedicatedConsoleAccounts = []
89
+ const groupConsoleAccounts = []
90
+
91
+ for (const key of consoleKeys) {
92
+ const account = await redis.client.hgetall(key)
93
+ const accountType = account.accountType || 'shared'
94
+
95
+ if (accountType === 'dedicated') {
96
+ dedicatedConsoleAccounts.push({
97
+ id: account.id,
98
+ name: account.name,
99
+ accountType,
100
+ status: account.status
101
+ })
102
+ } else if (accountType === 'group') {
103
+ groupConsoleAccounts.push({
104
+ id: account.id,
105
+ name: account.name,
106
+ accountType,
107
+ status: account.status
108
+ })
109
+ }
110
+ }
111
+
112
+ if (dedicatedConsoleAccounts.length > 0) {
113
+ console.log('\n✅ Claude Console 专属账号:')
114
+ dedicatedConsoleAccounts.forEach((acc) => {
115
+ console.log(` - ${acc.name} (ID: ${acc.id}, 状态: ${acc.status})`)
116
+ })
117
+ }
118
+
119
+ if (groupConsoleAccounts.length > 0) {
120
+ console.log('\n📁 Claude Console 分组账号:')
121
+ groupConsoleAccounts.forEach((acc) => {
122
+ console.log(` - ${acc.name} (ID: ${acc.id}, 状态: ${acc.status})`)
123
+ })
124
+ }
125
+ } catch (error) {
126
+ console.error('❌ 错误:', error)
127
+ console.error(error.stack)
128
+ } finally {
129
+ process.exit(0)
130
+ }
131
+ }
132
+
133
+ testDedicatedAccounts()