Upload 221 files
Browse filesThis view is limited to 50 files because it contains too many changes.
See raw diff
- .dockerignore +78 -0
- .env.example +113 -0
- .eslintrc.cjs +85 -0
- .github/AUTO_RELEASE_GUIDE.md +164 -0
- .github/DOCKER_HUB_SETUP.md +109 -0
- .github/FUNDING.yml +12 -0
- .github/RELEASE_PROCESS.md +94 -0
- .github/TELEGRAM_SETUP.md +110 -0
- .github/WORKFLOW_USAGE.md +129 -0
- .github/cliff.toml +68 -0
- .github/secret_scanning.yml +6 -0
- .github/workflows/auto-release-pipeline.yml +490 -0
- .github/workflows/pr-lint-check.yml +320 -0
- .gitignore +251 -0
- .prettierrc +14 -0
- CLAUDE.md +275 -0
- Dockerfile +71 -0
- LICENSE +21 -0
- Makefile +259 -0
- README.md +938 -7
- README_EN.md +560 -0
- VERSION +1 -0
- cli/index.js +1025 -0
- config/config.example.js +185 -0
- docker-compose.yml +165 -0
- docker-entrypoint.sh +65 -0
- nodemon.json +5 -0
- package-lock.json +0 -0
- package.json +101 -0
- resources/model-pricing/README.md +37 -0
- resources/model-pricing/model_prices_and_context_window.json +0 -0
- scripts/analyze-log-sessions.js +606 -0
- scripts/check-redis-keys.js +53 -0
- scripts/data-transfer-enhanced.js +1132 -0
- scripts/data-transfer.js +738 -0
- scripts/debug-redis-keys.js +126 -0
- scripts/fix-inquirer.js +29 -0
- scripts/fix-usage-stats.js +227 -0
- scripts/generate-test-data.js +280 -0
- scripts/manage-session-windows.js +561 -0
- scripts/manage.js +333 -0
- scripts/manage.sh +1757 -0
- scripts/migrate-apikey-expiry.js +192 -0
- scripts/monitor-enhanced.sh +273 -0
- scripts/setup.js +128 -0
- scripts/status-unified.sh +262 -0
- scripts/test-account-display.js +143 -0
- scripts/test-api-response.js +141 -0
- scripts/test-bedrock-models.js +33 -0
- 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 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
---
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Claude Relay Service
|
| 2 |
+
|
| 3 |
+
<div align="center">
|
| 4 |
+
|
| 5 |
+
[](https://opensource.org/licenses/MIT)
|
| 6 |
+
[](https://nodejs.org/)
|
| 7 |
+
[](https://redis.io/)
|
| 8 |
+
[](https://www.docker.com/)
|
| 9 |
+
[](https://github.com/Wei-Shaw/claude-relay-service/actions/workflows/auto-release-pipeline.yml)
|
| 10 |
+
[](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 |
+
[](https://opensource.org/licenses/MIT)
|
| 6 |
+
[](https://nodejs.org/)
|
| 7 |
+
[](https://redis.io/)
|
| 8 |
+
[](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()
|