From 9c9afe152826867ebafff5b5fe2987539243241d Mon Sep 17 00:00:00 2001
From: shaw
Date: Sun, 3 Aug 2025 21:37:28 +0800
Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=E8=B4=A6=E6=88=B7?=
=?UTF-8?q?=E5=88=86=E7=BB=84=E7=AE=A1=E7=90=86=E5=8A=9F=E8=83=BD=E5=92=8C?=
=?UTF-8?q?=E4=BC=98=E5=8C=96=E5=93=8D=E5=BA=94=E5=BC=8F=E8=AE=BE=E8=AE=A1?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
主要更新:
- 实现账户分组管理功能,支持创建、编辑、删除分组
- 支持将账户添加到分组进行统一调度
- 优化 API Keys 页面响应式设计,解决操作栏被隐藏的问题
- 优化账户管理页面布局,合并平台/类型列,改进操作按钮布局
- 修复代理信息显示溢出问题
- 改进表格列宽分配,充分利用屏幕空间
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-Authored-By: Claude
---
README.md | 108 +-
scripts/MANAGE_UPDATE.md | 114 ++
scripts/manage.sh | 1102 +++++++++++++++++
scripts/test-apikey-expiry.js | 159 ---
scripts/test-claude-console-url.js | 28 -
scripts/test-gemini-decrypt.js | 102 --
scripts/test-group-scheduling.js | 545 ++++++++
scripts/test-import-encryption.js | 181 ---
src/app.js | 86 +-
src/routes/admin.js | 204 ++-
src/services/accountGroupService.js | 351 ++++++
src/services/claudeAccountService.js | 21 +-
src/services/unifiedClaudeScheduler.js | 138 ++-
.../src/components/accounts/AccountForm.vue | 190 ++-
.../accounts/GroupManagementModal.vue | 418 +++++++
.../components/apikeys/CreateApiKeyModal.vue | 59 +-
.../components/apikeys/EditApiKeyModal.vue | 59 +-
web/admin-spa/src/router/index.js | 25 +-
web/admin-spa/src/views/AccountsView.vue | 340 +++--
web/admin-spa/src/views/ApiKeysView.vue | 75 +-
20 files changed, 3588 insertions(+), 717 deletions(-)
create mode 100644 scripts/MANAGE_UPDATE.md
create mode 100644 scripts/manage.sh
delete mode 100644 scripts/test-apikey-expiry.js
delete mode 100755 scripts/test-claude-console-url.js
delete mode 100644 scripts/test-gemini-decrypt.js
create mode 100644 scripts/test-group-scheduling.js
delete mode 100644 scripts/test-import-encryption.js
create mode 100644 src/services/accountGroupService.js
create mode 100644 web/admin-spa/src/components/accounts/GroupManagementModal.vue
diff --git a/README.md b/README.md
index 850f971d..ce7d7126 100644
--- a/README.md
+++ b/README.md
@@ -133,6 +133,71 @@
---
+## 🚀 脚本部署(推荐)
+
+推荐使用管理脚本进行一键部署,简单快捷,自动处理所有依赖和配置。
+
+### 快速安装
+
+```bash
+# 下载并运行管理脚本
+curl -fsSL https://raw.githubusercontent.com/Wei-Shaw/claude-relay-service/main/scripts/manage.sh -o manage.sh
+chmod +x manage.sh
+./manage.sh install
+
+# 安装后可以使用 crs 命令管理服务
+crs # 显示交互式菜单
+```
+
+### 脚本功能
+
+- ✅ **一键安装**: 自动检测系统环境,安装 Node.js 18+、Redis 等依赖
+- ✅ **交互式配置**: 友好的配置向导,设置端口、Redis 连接等
+- ✅ **自动启动**: 安装完成后自动启动服务并显示访问地址
+- ✅ **便捷管理**: 通过 `crs` 命令随时管理服务状态
+
+### 管理命令
+
+```bash
+crs install # 安装服务
+crs start # 启动服务
+crs stop # 停止服务
+crs restart # 重启服务
+crs status # 查看状态
+crs update # 更新服务
+crs uninstall # 卸载服务
+```
+
+### 安装示例
+
+```bash
+$ crs install
+
+# 会依次询问:
+安装目录 (默认: ~/claude-relay-service):
+服务端口 (默认: 3000): 8080
+Redis 地址 (默认: localhost):
+Redis 端口 (默认: 6379):
+Redis 密码 (默认: 无密码):
+
+# 安装完成后自动启动并显示:
+服务已成功安装并启动!
+
+访问地址:
+ 本地 Web: http://localhost:8080/web
+ 公网 Web: http://YOUR_IP:8080/web
+
+管理员账号信息已保存到: data/init.json
+```
+
+### 系统要求
+
+- 支持系统: Ubuntu/Debian、CentOS/RedHat、Arch Linux、macOS
+- 自动安装 Node.js 18+ 和 Redis
+- Redis 使用系统默认位置,数据独立于应用
+
+---
+
## 📦 手动部署
### 第一步:环境准备
@@ -214,7 +279,7 @@ npm run setup # 会随机生成后台账号密码信息,存储在 data/init.js
# export ADMIN_PASSWORD=your-secure-password
# 启动服务
-npm run service:start:daemon # 后台运行(推荐)
+npm run service:start:daemon # 后台运行
# 查看状态
npm run service:status
@@ -222,11 +287,11 @@ npm run service:status
---
-## 🐳 Docker 部署(推荐)
+## 🐳 Docker 部署
### 使用 Docker Hub 镜像(最简单)
-> 🚀 推荐使用官方镜像,自动构建,始终保持最新版本
+> 🚀 使用官方镜像,自动构建,始终保持最新版本
```bash
# 拉取镜像(支持 amd64 和 arm64)
@@ -245,7 +310,7 @@ docker run -d \
-e ADMIN_PASSWORD=my_secure_password \
weishaw/claude-relay-service:latest
-# 或使用 docker-compose(推荐)
+# 或使用 docker-compose
# 创建 .env 文件用于 docker-compose 的环境变量:
cat > .env << 'EOF'
# 必填:安全密钥(请修改为随机值)
@@ -294,35 +359,6 @@ EOF
docker-compose up -d
```
-### 从源码构建
-
-```bash
-# 1. 克隆项目
-git clone https://github.com/Wei-Shaw//claude-relay-service.git
-cd claude-relay-service
-
-# 2. 创建环境变量文件
-cat > .env << 'EOF'
-# 必填:安全密钥(请修改为随机值)
-JWT_SECRET=your-random-secret-key-at-least-32-chars
-ENCRYPTION_KEY=your-32-character-encryption-key
-
-# 可选:管理员凭据
-ADMIN_USERNAME=cr_admin_custom
-ADMIN_PASSWORD=your-secure-password
-EOF
-
-# 3. 启动服务
-docker-compose up -d
-
-# 4. 查看管理员凭据
-# 自动生成的情况下:
-docker logs claude-relay-service | grep "管理员"
-
-# 或者直接查看挂载的文件:
-cat ./data/init.json
-```
-
### Docker Compose 配置
docker-compose.yml 已包含:
@@ -347,7 +383,7 @@ docker-compose.yml 已包含:
### 管理员凭据获取方式
-1. **查看容器日志**(推荐)
+1. **查看容器日志**
```bash
docker logs claude-relay-service
```
@@ -426,7 +462,7 @@ claude
**Claude标准格式:**
```
-# 如果工具支持Claude标准格式 那么推荐使用该接口
+# 如果工具支持Claude标准格式,请使用该接口
http://你的服务器:3000/claude/
```
@@ -588,7 +624,7 @@ redis-cli ping
**强烈建议使用Caddy反向代理(自动HTTPS)**
-推荐使用Caddy作为反向代理,它会自动申请和更新SSL证书,配置更简单:
+建议使用Caddy作为反向代理,它会自动申请和更新SSL证书,配置更简单:
**1. 安装Caddy**
```bash
diff --git a/scripts/MANAGE_UPDATE.md b/scripts/MANAGE_UPDATE.md
new file mode 100644
index 00000000..cfcc8fa2
--- /dev/null
+++ b/scripts/MANAGE_UPDATE.md
@@ -0,0 +1,114 @@
+# manage.sh 脚本更新说明
+
+## 新增功能(最新更新)
+
+### 1. 端口配置
+- 安装时会询问服务端口,默认为 3000
+- 端口配置会自动写入 .env 文件
+- 检查端口是否被占用并提示
+
+### 2. 自动启动服务
+- 安装完成后自动启动服务
+- 不再需要手动执行 `crs start`
+
+### 3. 公网 IP 显示
+- 自动获取公网 IP 地址(通过 https://ipinfo.io/json)
+- 显示本地访问和公网访问地址
+- IP 地址缓存 1 小时,避免频繁调用 API
+
+### 4. 动态端口显示
+- 所有状态显示都使用实际配置的端口
+- 交互式菜单显示实际端口和公网地址
+
+## 使用示例
+
+### 安装时的新体验
+```bash
+$ crs install
+
+# 会依次询问:
+安装目录 (默认: ~/claude-relay-service):
+服务端口 (默认: 3000): 8080
+Redis 地址 (默认: localhost):
+Redis 端口 (默认: 6379):
+Redis 密码 (默认: 无密码):
+
+# 安装完成后自动启动并显示:
+服务已成功安装并启动!
+
+访问地址:
+ 本地访问: http://localhost:8080/web
+ 公网访问: http://1.2.3.4:8080/web
+
+管理命令:
+ 查看状态: crs status
+ 停止服务: crs stop
+ 重启服务: crs restart
+```
+
+### 状态显示增强
+```bash
+$ crs status
+
+=== Claude Relay Service 状态 ===
+服务状态: 运行中
+进程 PID: 12345
+服务端口: 8080
+
+访问地址:
+ 本地访问: http://localhost:8080/web
+ 公网访问: http://1.2.3.4:8080/web
+ API 端点: http://localhost:8080/api/v1
+
+安装目录: /home/user/claude-relay-service
+
+Redis 状态:
+ 连接状态: 正常
+```
+
+## 技术细节
+
+### 公网 IP 获取
+- 主要 API: https://ipinfo.io/json
+- 备用 API: https://api.ipify.org
+- 缓存文件: /tmp/.crs_public_ip_cache
+- 缓存时间: 3600 秒(1 小时)
+
+### 端口配置存储
+- 配置文件: .env
+- 环境变量: PORT
+- 读取优先级: 命令行参数 > .env 文件 > 默认值 3000
+
+## Redis 安装说明
+
+### 系统默认安装位置
+脚本使用系统包管理器安装 Redis,会自动安装到各系统的默认位置:
+
+- **Debian/Ubuntu**:
+ - 配置文件: `/etc/redis/redis.conf`
+ - 数据目录: `/var/lib/redis`
+ - 日志文件: `/var/log/redis/redis-server.log`
+ - 通过 systemd 管理: `systemctl status redis-server`
+
+- **RedHat/CentOS**:
+ - 配置文件: `/etc/redis.conf`
+ - 数据目录: `/var/lib/redis`
+ - 日志文件: `/var/log/redis/redis.log`
+ - 通过 systemd 管理: `systemctl status redis`
+
+- **Arch Linux**:
+ - 配置文件: `/etc/redis/redis.conf`
+ - 数据目录: `/var/lib/redis`
+ - 通过 systemd 管理: `systemctl status redis`
+
+- **macOS**:
+ - 通过 Homebrew 安装
+ - 配置文件: `/usr/local/etc/redis.conf`
+ - 数据目录: `/usr/local/var/db/redis/`
+ - 通过 brew services 管理: `brew services list`
+
+### 优势
+- Redis 数据独立于应用,卸载应用不会丢失数据
+- 使用系统标准服务管理
+- 自动开机启动
+- 系统级的日志和监控
\ No newline at end of file
diff --git a/scripts/manage.sh b/scripts/manage.sh
new file mode 100644
index 00000000..e0000a40
--- /dev/null
+++ b/scripts/manage.sh
@@ -0,0 +1,1102 @@
+#!/bin/bash
+
+# Claude Relay Service 管理脚本
+# 用于安装、更新、卸载、启动、停止、重启服务
+# 可以使用 crs 快捷命令调用
+
+# 颜色定义
+RED='\033[0;31m'
+GREEN='\033[0;32m'
+YELLOW='\033[1;33m'
+BLUE='\033[0;36m' # 改为青色(Cyan),更易读
+MAGENTA='\033[0;35m'
+BOLD='\033[1m'
+NC='\033[0m' # No Color
+
+# 默认配置
+DEFAULT_INSTALL_DIR="$HOME/claude-relay-service"
+DEFAULT_REDIS_HOST="localhost"
+DEFAULT_REDIS_PORT="6379"
+DEFAULT_REDIS_PASSWORD=""
+DEFAULT_APP_PORT="3000"
+
+# 全局变量
+INSTALL_DIR=""
+APP_DIR=""
+REDIS_HOST=""
+REDIS_PORT=""
+REDIS_PASSWORD=""
+APP_PORT=""
+PUBLIC_IP_CACHE_FILE="/tmp/.crs_public_ip_cache"
+PUBLIC_IP_CACHE_DURATION=3600 # 1小时缓存
+
+# 打印带颜色的消息
+print_info() {
+ echo -e "${BLUE}[INFO]${NC} $1"
+}
+
+print_success() {
+ echo -e "${GREEN}[SUCCESS]${NC} $1"
+}
+
+print_error() {
+ echo -e "${RED}[ERROR]${NC} $1"
+}
+
+print_warning() {
+ echo -e "${YELLOW}[WARNING]${NC} $1"
+}
+
+# 检测操作系统
+detect_os() {
+ if [[ "$OSTYPE" == "linux-gnu"* ]]; then
+ if [ -f /etc/debian_version ]; then
+ OS="debian"
+ PACKAGE_MANAGER="apt-get"
+ elif [ -f /etc/redhat-release ]; then
+ OS="redhat"
+ PACKAGE_MANAGER="yum"
+ elif [ -f /etc/arch-release ]; then
+ OS="arch"
+ PACKAGE_MANAGER="pacman"
+ else
+ OS="unknown"
+ fi
+ elif [[ "$OSTYPE" == "darwin"* ]]; then
+ OS="macos"
+ PACKAGE_MANAGER="brew"
+ else
+ OS="unknown"
+ fi
+}
+
+# 检查命令是否存在
+command_exists() {
+ command -v "$1" >/dev/null 2>&1
+}
+
+# 检查端口是否被占用
+check_port() {
+ local port=$1
+ if command_exists lsof; then
+ lsof -i ":$port" >/dev/null 2>&1
+ elif command_exists netstat; then
+ netstat -tuln | grep ":$port " >/dev/null 2>&1
+ elif command_exists ss; then
+ ss -tuln | grep ":$port " >/dev/null 2>&1
+ else
+ return 1
+ fi
+}
+
+# 生成随机字符串
+generate_random_string() {
+ local length=$1
+ if command_exists openssl; then
+ openssl rand -hex $((length/2))
+ else
+ cat /dev/urandom | tr -dc 'a-zA-Z0-9' | fold -w $length | head -n 1
+ fi
+}
+
+# 获取公网IP
+get_public_ip() {
+ local cached_ip=""
+ local cache_age=0
+
+ # 检查缓存
+ if [ -f "$PUBLIC_IP_CACHE_FILE" ]; then
+ local current_time=$(date +%s)
+ 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)
+ cache_age=$((current_time - cache_time))
+
+ if [ $cache_age -lt $PUBLIC_IP_CACHE_DURATION ]; then
+ cached_ip=$(cat "$PUBLIC_IP_CACHE_FILE" 2>/dev/null)
+ if [ -n "$cached_ip" ]; then
+ echo "$cached_ip"
+ return 0
+ fi
+ fi
+ fi
+
+ # 获取新的公网IP
+ local public_ip=""
+ if command_exists curl; then
+ public_ip=$(curl -s --connect-timeout 5 https://ipinfo.io/json | grep -o '"ip":"[^"]*"' | cut -d'"' -f4 2>/dev/null)
+ elif command_exists wget; then
+ public_ip=$(wget -qO- --timeout=5 https://ipinfo.io/json | grep -o '"ip":"[^"]*"' | cut -d'"' -f4 2>/dev/null)
+ fi
+
+ # 如果获取失败,尝试备用API
+ if [ -z "$public_ip" ]; then
+ if command_exists curl; then
+ public_ip=$(curl -s --connect-timeout 5 https://api.ipify.org 2>/dev/null)
+ elif command_exists wget; then
+ public_ip=$(wget -qO- --timeout=5 https://api.ipify.org 2>/dev/null)
+ fi
+ fi
+
+ # 保存到缓存
+ if [ -n "$public_ip" ]; then
+ echo "$public_ip" > "$PUBLIC_IP_CACHE_FILE"
+ echo "$public_ip"
+ else
+ echo "localhost"
+ fi
+}
+
+# 检查Node.js版本
+check_node_version() {
+ if ! command_exists node; then
+ return 1
+ fi
+
+ local node_version=$(node -v | sed 's/v//')
+ local major_version=$(echo $node_version | cut -d. -f1)
+
+ if [ "$major_version" -lt 18 ]; then
+ return 1
+ fi
+
+ return 0
+}
+
+# 安装Node.js 18+
+install_nodejs() {
+ print_info "开始安装 Node.js 18+"
+
+ case $OS in
+ "debian")
+ # 使用 NodeSource 仓库
+ curl -fsSL https://deb.nodesource.com/setup_18.x | sudo -E bash -
+ sudo $PACKAGE_MANAGER install -y nodejs
+ ;;
+ "redhat")
+ curl -fsSL https://rpm.nodesource.com/setup_18.x | sudo bash -
+ sudo $PACKAGE_MANAGER install -y nodejs
+ ;;
+ "arch")
+ sudo $PACKAGE_MANAGER -S --noconfirm nodejs npm
+ ;;
+ "macos")
+ if ! command_exists brew; then
+ print_error "请先安装 Homebrew: https://brew.sh"
+ return 1
+ fi
+ brew install node@18
+ ;;
+ *)
+ print_error "不支持的操作系统,请手动安装 Node.js 18+"
+ return 1
+ ;;
+ esac
+
+ # 验证安装
+ if check_node_version; then
+ print_success "Node.js 安装成功: $(node -v)"
+ return 0
+ else
+ print_error "Node.js 安装失败或版本不符合要求"
+ return 1
+ fi
+}
+
+# 安装基础依赖
+install_dependencies() {
+ print_info "检查并安装基础依赖..."
+
+ local deps_to_install=()
+
+ # 检查 git
+ if ! command_exists git; then
+ deps_to_install+=("git")
+ fi
+
+ # 检查其他基础工具
+ case $OS in
+ "debian"|"redhat")
+ if ! command_exists curl; then
+ deps_to_install+=("curl")
+ fi
+ if ! command_exists wget; then
+ deps_to_install+=("wget")
+ fi
+ if ! command_exists lsof; then
+ deps_to_install+=("lsof")
+ fi
+ ;;
+ esac
+
+ # 安装缺失的依赖
+ if [ ${#deps_to_install[@]} -gt 0 ]; then
+ print_info "需要安装: ${deps_to_install[*]}"
+ case $OS in
+ "debian")
+ sudo $PACKAGE_MANAGER update
+ sudo $PACKAGE_MANAGER install -y "${deps_to_install[@]}"
+ ;;
+ "redhat")
+ sudo $PACKAGE_MANAGER install -y "${deps_to_install[@]}"
+ ;;
+ "arch")
+ sudo $PACKAGE_MANAGER -S --noconfirm "${deps_to_install[@]}"
+ ;;
+ "macos")
+ brew install "${deps_to_install[@]}"
+ ;;
+ esac
+ fi
+
+ # 检查 Node.js
+ if ! check_node_version; then
+ print_warning "未检测到 Node.js 18+ 版本"
+ install_nodejs || return 1
+ else
+ print_success "Node.js 版本检查通过: $(node -v)"
+ fi
+
+ # 检查 npm
+ if ! command_exists npm; then
+ print_error "npm 未安装"
+ return 1
+ else
+ print_success "npm 版本: $(npm -v)"
+ fi
+
+ return 0
+}
+
+# 检查Redis
+check_redis() {
+ print_info "检查 Redis 配置..."
+
+ # 交互式询问Redis配置
+ echo -e "\n${BLUE}Redis 配置${NC}"
+ echo -n "Redis 地址 (默认: $DEFAULT_REDIS_HOST): "
+ read input
+ REDIS_HOST=${input:-$DEFAULT_REDIS_HOST}
+
+ echo -n "Redis 端口 (默认: $DEFAULT_REDIS_PORT): "
+ read input
+ REDIS_PORT=${input:-$DEFAULT_REDIS_PORT}
+
+ echo -n "Redis 密码 (默认: 无密码): "
+ read -s input
+ echo
+ REDIS_PASSWORD=${input:-$DEFAULT_REDIS_PASSWORD}
+
+ # 测试Redis连接
+ print_info "测试 Redis 连接..."
+ if command_exists redis-cli; then
+ local redis_test_cmd="redis-cli -h $REDIS_HOST -p $REDIS_PORT"
+ if [ -n "$REDIS_PASSWORD" ]; then
+ redis_test_cmd="$redis_test_cmd -a '$REDIS_PASSWORD'"
+ fi
+
+ if $redis_test_cmd ping 2>/dev/null | grep -q "PONG"; then
+ print_success "Redis 连接成功"
+ return 0
+ else
+ print_error "Redis 连接失败"
+ return 1
+ fi
+ else
+ print_warning "redis-cli 未安装,跳过连接测试"
+ # 仅检查端口是否开放
+ if check_port $REDIS_PORT; then
+ print_info "检测到端口 $REDIS_PORT 已开放"
+ return 0
+ else
+ print_warning "端口 $REDIS_PORT 未开放,请确保 Redis 正在运行"
+ return 1
+ fi
+ fi
+}
+
+# 安装本地Redis(可选)
+install_local_redis() {
+ print_info "是否需要在本地安装 Redis?(y/N): "
+ read -n 1 install_redis
+ echo
+
+ if [[ ! "$install_redis" =~ ^[Yy]$ ]]; then
+ return 0
+ fi
+
+ case $OS in
+ "debian")
+ sudo $PACKAGE_MANAGER update
+ sudo $PACKAGE_MANAGER install -y redis-server
+ sudo systemctl start redis-server
+ sudo systemctl enable redis-server
+ ;;
+ "redhat")
+ sudo $PACKAGE_MANAGER install -y redis
+ sudo systemctl start redis
+ sudo systemctl enable redis
+ ;;
+ "arch")
+ sudo $PACKAGE_MANAGER -S --noconfirm redis
+ sudo systemctl start redis
+ sudo systemctl enable redis
+ ;;
+ "macos")
+ brew install redis
+ brew services start redis
+ ;;
+ *)
+ print_error "不支持的操作系统,请手动安装 Redis"
+ return 1
+ ;;
+ esac
+
+ print_success "Redis 安装完成"
+ return 0
+}
+
+
+# 检查是否已安装
+check_installation() {
+ if [ -d "$APP_DIR" ] && [ -f "$APP_DIR/package.json" ]; then
+ return 0
+ fi
+ return 1
+}
+
+# 安装服务
+install_service() {
+ print_info "开始安装 Claude Relay Service..."
+
+ # 询问安装目录
+ echo -n "安装目录 (默认: $DEFAULT_INSTALL_DIR): "
+ read input
+ INSTALL_DIR=${input:-$DEFAULT_INSTALL_DIR}
+ APP_DIR="$INSTALL_DIR/app"
+
+ # 询问服务端口
+ echo -n "服务端口 (默认: $DEFAULT_APP_PORT): "
+ read input
+ APP_PORT=${input:-$DEFAULT_APP_PORT}
+
+ # 检查端口是否被占用
+ if check_port $APP_PORT; then
+ print_warning "端口 $APP_PORT 已被占用"
+ echo -n "是否继续?(y/N): "
+ read -n 1 continue_install
+ echo
+ if [[ ! "$continue_install" =~ ^[Yy]$ ]]; then
+ return 1
+ fi
+ fi
+
+ # 检查是否已安装
+ if check_installation; then
+ print_warning "检测到已安装的服务"
+ echo -n "是否要重新安装?(y/N): "
+ read -n 1 reinstall
+ echo
+ if [[ ! "$reinstall" =~ ^[Yy]$ ]]; then
+ return 0
+ fi
+ fi
+
+ # 创建安装目录
+ mkdir -p "$INSTALL_DIR"
+
+ # 克隆项目
+ print_info "克隆项目代码..."
+ if [ -d "$APP_DIR" ]; then
+ rm -rf "$APP_DIR"
+ fi
+
+ if ! git clone https://github.com/Wei-Shaw/claude-relay-service.git "$APP_DIR"; then
+ print_error "克隆项目失败"
+ return 1
+ fi
+
+ # 进入项目目录
+ cd "$APP_DIR"
+
+ # 安装npm依赖
+ print_info "安装项目依赖..."
+ npm install
+
+ # 创建配置文件
+ print_info "创建配置文件..."
+
+ # 复制示例配置
+ if [ -f "config/config.example.js" ]; then
+ cp config/config.example.js config/config.js
+ fi
+
+ # 创建.env文件
+ cat > .env << EOF
+# 环境变量配置
+NODE_ENV=production
+PORT=$APP_PORT
+
+# JWT配置
+JWT_SECRET=$(generate_random_string 64)
+
+# 加密配置
+ENCRYPTION_KEY=$(generate_random_string 32)
+
+# Redis配置
+REDIS_HOST=$REDIS_HOST
+REDIS_PORT=$REDIS_PORT
+REDIS_PASSWORD=$REDIS_PASSWORD
+
+# 日志配置
+LOG_LEVEL=info
+EOF
+
+ # 运行setup命令
+ print_info "运行初始化设置..."
+ npm run setup
+
+ # 安装Web界面依赖
+ print_info "安装Web界面依赖..."
+ npm run install:web
+
+ # 创建systemd服务文件(Linux)
+ if [[ "$OS" == "debian" || "$OS" == "redhat" || "$OS" == "arch" ]]; then
+ create_systemd_service
+ fi
+
+ # 创建软链接
+ create_symlink
+
+ print_success "安装完成!"
+
+ # 自动启动服务
+ print_info "正在启动服务..."
+ start_service
+
+ # 等待服务启动
+ sleep 3
+
+ # 显示状态
+ show_status
+
+ # 获取公网IP
+ local public_ip=$(get_public_ip)
+
+ echo -e "\n${GREEN}服务已成功安装并启动!${NC}"
+ echo -e "\n${YELLOW}访问地址:${NC}"
+ echo -e " 本地 Web: ${GREEN}http://localhost:$APP_PORT/web${NC}"
+ echo -e " 本地 API: ${GREEN}http://localhost:$APP_PORT/api/v1${NC}"
+ if [ "$public_ip" != "localhost" ]; then
+ echo -e " 公网 Web: ${GREEN}http://$public_ip:$APP_PORT/web${NC}"
+ echo -e " 公网 API: ${GREEN}http://$public_ip:$APP_PORT/api/v1${NC}"
+ fi
+ echo -e "\n${YELLOW}管理命令:${NC}"
+ echo " 查看状态: crs status"
+ echo " 停止服务: crs stop"
+ echo " 重启服务: crs restart"
+}
+
+# 创建systemd服务
+create_systemd_service() {
+ local service_file="/etc/systemd/system/claude-relay.service"
+
+ print_info "创建 systemd 服务..."
+
+ sudo tee $service_file > /dev/null << EOF
+[Unit]
+Description=Claude Relay Service
+After=network.target redis.service
+
+[Service]
+Type=simple
+User=$USER
+WorkingDirectory=$APP_DIR
+ExecStart=$(which node) $APP_DIR/src/app.js
+Restart=on-failure
+RestartSec=10
+StandardOutput=append:$APP_DIR/logs/service.log
+StandardError=append:$APP_DIR/logs/service-error.log
+
+[Install]
+WantedBy=multi-user.target
+EOF
+
+ sudo systemctl daemon-reload
+ print_success "systemd 服务创建完成"
+}
+
+# 更新服务
+update_service() {
+ if ! check_installation; then
+ print_error "服务未安装,请先运行: $0 install"
+ return 1
+ fi
+
+ print_info "更新 Claude Relay Service..."
+
+ cd "$APP_DIR"
+
+ # 停止服务
+ stop_service
+
+ # 拉取最新代码
+ print_info "拉取最新代码..."
+ git pull origin main
+
+ # 更新依赖
+ print_info "更新依赖..."
+ npm install
+ npm run install:web
+
+ # 启动服务
+ start_service
+
+ print_success "更新完成!"
+}
+
+# 卸载服务
+uninstall_service() {
+ if [ -z "$INSTALL_DIR" ]; then
+ echo -n "请输入安装目录 (默认: $DEFAULT_INSTALL_DIR): "
+ read input
+ INSTALL_DIR=${input:-$DEFAULT_INSTALL_DIR}
+ APP_DIR="$INSTALL_DIR/app"
+ fi
+
+ if [ ! -d "$INSTALL_DIR" ]; then
+ print_error "安装目录不存在"
+ return 1
+ fi
+
+ print_warning "即将卸载 Claude Relay Service"
+ echo -n "确定要卸载吗?(y/N): "
+ read -n 1 confirm
+ echo
+
+ if [[ ! "$confirm" =~ ^[Yy]$ ]]; then
+ return 0
+ fi
+
+ # 停止服务
+ stop_service
+
+ # 删除systemd服务
+ if [ -f "/etc/systemd/system/claude-relay.service" ]; then
+ sudo systemctl disable claude-relay.service
+ sudo rm /etc/systemd/system/claude-relay.service
+ sudo systemctl daemon-reload
+ fi
+
+ # 备份数据
+ echo -n "是否备份数据?(y/N): "
+ read -n 1 backup
+ echo
+
+ if [[ "$backup" =~ ^[Yy]$ ]]; then
+ local backup_dir="$HOME/claude-relay-backup-$(date +%Y%m%d%H%M%S)"
+ mkdir -p "$backup_dir"
+
+ # Redis使用系统默认位置,不需要备份
+
+ # 备份配置文件
+ if [ -f "$APP_DIR/.env" ]; then
+ cp "$APP_DIR/.env" "$backup_dir/"
+ fi
+ if [ -f "$APP_DIR/config/config.js" ]; then
+ cp "$APP_DIR/config/config.js" "$backup_dir/"
+ fi
+
+ print_success "数据已备份到: $backup_dir"
+ fi
+
+ # 删除安装目录
+ rm -rf "$INSTALL_DIR"
+
+ print_success "卸载完成!"
+}
+
+# 启动服务
+start_service() {
+ if ! check_installation; then
+ print_error "服务未安装,请先运行: $0 install"
+ return 1
+ fi
+
+ print_info "启动服务..."
+
+ cd "$APP_DIR"
+
+ # 检查是否已运行
+ if pgrep -f "node.*claude-relay" > /dev/null; then
+ print_warning "服务已在运行"
+ return 0
+ fi
+
+ # 使用不同方式启动
+ if [ -f "/etc/systemd/system/claude-relay.service" ]; then
+ sudo systemctl start claude-relay.service
+ print_success "服务已通过 systemd 启动"
+ else
+ # 使用npm启动
+ npm run service:start:daemon
+ print_success "服务已启动"
+ fi
+
+ sleep 2
+ show_status
+}
+
+# 停止服务
+stop_service() {
+ print_info "停止服务..."
+
+ if [ -f "/etc/systemd/system/claude-relay.service" ]; then
+ sudo systemctl stop claude-relay.service
+ else
+ if command_exists pm2; then
+ cd "$APP_DIR" 2>/dev/null && npm run service:stop
+ else
+ pkill -f "node.*claude-relay" || true
+ fi
+ fi
+
+ print_success "服务已停止"
+}
+
+# 重启服务
+restart_service() {
+ print_info "重启服务..."
+ stop_service
+ sleep 2
+ start_service
+}
+
+# 显示状态
+show_status() {
+ echo -e "\n${BLUE}=== Claude Relay Service 状态 ===${NC}"
+
+ # 获取实际端口
+ local actual_port="$APP_PORT"
+ if [ -z "$actual_port" ] && [ -f "$APP_DIR/.env" ]; then
+ actual_port=$(grep "^PORT=" "$APP_DIR/.env" 2>/dev/null | cut -d'=' -f2)
+ fi
+ actual_port=${actual_port:-3000}
+
+ # 检查进程
+ if pgrep -f "node.*claude-relay" > /dev/null; then
+ echo -e "服务状态: ${GREEN}运行中${NC}"
+
+ # 获取进程信息
+ local pid=$(pgrep -f "node.*claude-relay" | head -1)
+ echo "进程 PID: $pid"
+ echo "服务端口: $actual_port"
+
+ # 获取公网IP
+ local public_ip=$(get_public_ip)
+
+ # 显示访问地址
+ echo -e "\n访问地址:"
+ echo -e " 本地 Web: ${GREEN}http://localhost:$actual_port/web${NC}"
+ echo -e " 本地 API: ${GREEN}http://localhost:$actual_port/api/v1${NC}"
+ if [ "$public_ip" != "localhost" ]; then
+ echo -e " 公网 Web: ${GREEN}http://$public_ip:$actual_port/web${NC}"
+ echo -e " 公网 API: ${GREEN}http://$public_ip:$actual_port/api/v1${NC}"
+ fi
+ else
+ echo -e "服务状态: ${RED}未运行${NC}"
+ fi
+
+ # 显示安装信息
+ if [ -n "$INSTALL_DIR" ] && [ -d "$INSTALL_DIR" ]; then
+ echo -e "\n安装目录: $INSTALL_DIR"
+ elif [ -d "$DEFAULT_INSTALL_DIR" ]; then
+ echo -e "\n安装目录: $DEFAULT_INSTALL_DIR"
+ fi
+
+ # Redis状态
+ if command_exists redis-cli; then
+ echo -e "\nRedis 状态:"
+ local redis_cmd="redis-cli"
+ if [ -n "$REDIS_HOST" ]; then
+ redis_cmd="$redis_cmd -h $REDIS_HOST"
+ fi
+ if [ -n "$REDIS_PORT" ]; then
+ redis_cmd="$redis_cmd -p $REDIS_PORT"
+ fi
+ if [ -n "$REDIS_PASSWORD" ]; then
+ redis_cmd="$redis_cmd -a '$REDIS_PASSWORD'"
+ fi
+
+ if $redis_cmd ping 2>/dev/null | grep -q "PONG"; then
+ echo -e " 连接状态: ${GREEN}正常${NC}"
+ else
+ echo -e " 连接状态: ${RED}异常${NC}"
+ fi
+ fi
+
+ echo -e "\n${BLUE}===========================${NC}"
+}
+
+# 显示帮助
+show_help() {
+ echo "Claude Relay Service 管理脚本"
+ echo ""
+ echo "用法: $0 [命令]"
+ echo ""
+ echo "命令:"
+ echo " install - 安装服务"
+ echo " update - 更新服务"
+ echo " uninstall - 卸载服务"
+ echo " start - 启动服务"
+ echo " stop - 停止服务"
+ echo " restart - 重启服务"
+ echo " status - 查看状态"
+ echo " symlink - 创建 crs 快捷命令"
+ echo " help - 显示帮助"
+ echo ""
+}
+
+# 交互式菜单
+show_menu() {
+ clear
+ echo -e "${BOLD}======================================${NC}"
+ echo -e "${BOLD} Claude Relay Service (CRS) 管理工具 ${NC}"
+ echo -e "${BOLD}======================================${NC}"
+ echo ""
+
+ # 显示当前状态
+ echo -e "${YELLOW}当前状态:${NC}"
+ if check_installation; then
+ echo -e " 安装状态: ${GREEN}已安装${NC} (目录: $INSTALL_DIR)"
+
+ # 获取实际端口
+ local actual_port="$APP_PORT"
+ if [ -z "$actual_port" ] && [ -f "$APP_DIR/.env" ]; then
+ actual_port=$(grep "^PORT=" "$APP_DIR/.env" 2>/dev/null | cut -d'=' -f2)
+ fi
+ actual_port=${actual_port:-3000}
+
+ # 检查服务状态
+ if pgrep -f "node.*claude-relay" > /dev/null; then
+ echo -e " 运行状态: ${GREEN}运行中${NC}"
+ local pid=$(pgrep -f "node.*claude-relay" | head -1)
+ echo -e " 进程 PID: $pid"
+ echo -e " 服务端口: $actual_port"
+
+ # 获取公网IP
+ local public_ip=$(get_public_ip)
+ if [ "$public_ip" != "localhost" ]; then
+ echo -e " 公网地址: ${GREEN}http://$public_ip:$actual_port/web${NC}"
+ else
+ echo -e " Web 界面: ${GREEN}http://localhost:$actual_port/web${NC}"
+ fi
+ else
+ echo -e " 运行状态: ${RED}未运行${NC}"
+ fi
+ else
+ echo -e " 安装状态: ${RED}未安装${NC}"
+ fi
+
+ # Redis状态
+ if command_exists redis-cli && [ -n "$REDIS_HOST" ]; then
+ local redis_cmd="redis-cli -h $REDIS_HOST -p ${REDIS_PORT:-6379}"
+ if [ -n "$REDIS_PASSWORD" ]; then
+ redis_cmd="$redis_cmd -a '$REDIS_PASSWORD'"
+ fi
+
+ if $redis_cmd ping 2>/dev/null | grep -q "PONG"; then
+ echo -e " Redis 状态: ${GREEN}连接正常${NC}"
+ else
+ echo -e " Redis 状态: ${RED}连接异常${NC}"
+ fi
+ fi
+
+ echo ""
+ echo -e "${BOLD}--------------------------------------${NC}"
+ echo -e "${YELLOW}请选择操作:${NC}"
+ echo ""
+
+ if ! check_installation; then
+ echo " 1) 安装服务"
+ echo " 2) 退出"
+ echo ""
+ echo -n "请输入选项 [1-2]: "
+ else
+ echo " 1) 查看状态"
+ echo " 2) 启动服务"
+ echo " 3) 停止服务"
+ echo " 4) 重启服务"
+ echo " 5) 更新服务"
+ echo " 6) 卸载服务"
+ echo " 7) 退出"
+ echo ""
+ echo -n "请输入选项 [1-7]: "
+ fi
+}
+
+# 处理菜单选择
+handle_menu_choice() {
+ local choice=$1
+
+ if ! check_installation; then
+ case $choice in
+ 1)
+ echo ""
+ # 检查依赖
+ if ! install_dependencies; then
+ print_error "依赖安装失败"
+ echo -n "按回车键继续..."
+ read
+ return 1
+ fi
+
+ # 检查Redis
+ if ! check_redis; then
+ print_warning "Redis 连接失败"
+ install_local_redis
+
+ # 重新测试连接
+ REDIS_HOST="localhost"
+ REDIS_PORT="6379"
+ if ! check_redis; then
+ print_error "Redis 配置失败,请手动安装并配置 Redis"
+ echo -n "按回车键继续..."
+ read
+ return 1
+ fi
+ fi
+
+ # 安装服务
+ install_service
+
+ # 创建软链接
+ create_symlink
+
+ echo -n "按回车键继续..."
+ read
+ ;;
+ 2)
+ echo "退出管理工具"
+ exit 0
+ ;;
+ *)
+ print_error "无效选项"
+ sleep 1
+ ;;
+ esac
+ else
+ case $choice in
+ 1)
+ echo ""
+ show_status
+ echo -n "按回车键继续..."
+ read
+ ;;
+ 2)
+ echo ""
+ start_service
+ echo -n "按回车键继续..."
+ read
+ ;;
+ 3)
+ echo ""
+ stop_service
+ echo -n "按回车键继续..."
+ read
+ ;;
+ 4)
+ echo ""
+ restart_service
+ echo -n "按回车键继续..."
+ read
+ ;;
+ 5)
+ echo ""
+ update_service
+ echo -n "按回车键继续..."
+ read
+ ;;
+ 6)
+ echo ""
+ uninstall_service
+ if [ $? -eq 0 ]; then
+ exit 0
+ fi
+ ;;
+ 7)
+ echo "退出管理工具"
+ exit 0
+ ;;
+ *)
+ print_error "无效选项"
+ sleep 1
+ ;;
+ esac
+ fi
+}
+
+# 创建软链接
+create_symlink() {
+ # 获取脚本的绝对路径
+ local script_path=""
+ if command_exists realpath; then
+ script_path="$(realpath "$0")"
+ elif command_exists readlink && readlink -f "$0" >/dev/null 2>&1; then
+ script_path="$(readlink -f "$0")"
+ else
+ # 备用方法:使用pwd和脚本名
+ script_path="$(cd "$(dirname "$0")" && pwd)/$(basename "$0")"
+ fi
+
+ local symlink_path="/usr/bin/crs"
+
+ print_info "创建命令行快捷方式..."
+ print_info "脚本路径: $script_path"
+
+ # 检查脚本文件是否存在
+ if [ ! -f "$script_path" ]; then
+ print_error "找不到脚本文件: $script_path"
+ return 1
+ fi
+
+ # 检查是否已存在
+ if [ -L "$symlink_path" ] || [ -f "$symlink_path" ]; then
+ print_warning "$symlink_path 已存在"
+ echo -n "是否覆盖?(y/N): "
+ read -n 1 overwrite
+ echo
+
+ if [[ "$overwrite" =~ ^[Yy]$ ]]; then
+ sudo rm -f "$symlink_path" || {
+ print_error "删除旧文件失败"
+ return 1
+ }
+ else
+ return 0
+ fi
+ fi
+
+ # 创建软链接
+ if sudo ln -s "$script_path" "$symlink_path"; then
+ print_success "已创建快捷命令 'crs'"
+ echo "您现在可以在任何地方使用 'crs' 命令管理服务"
+
+ # 验证软链接
+ if [ -L "$symlink_path" ]; then
+ print_info "软链接验证成功"
+ else
+ print_warning "软链接验证失败"
+ fi
+ else
+ print_error "创建软链接失败"
+ print_info "请手动执行以下命令:"
+ echo " sudo ln -s '$script_path' '$symlink_path'"
+ return 1
+ fi
+}
+
+# 加载已安装的配置
+load_config() {
+ # 尝试找到安装目录
+ if [ -z "$INSTALL_DIR" ]; then
+ if [ -d "$DEFAULT_INSTALL_DIR" ]; then
+ INSTALL_DIR="$DEFAULT_INSTALL_DIR"
+ fi
+ fi
+
+ if [ -n "$INSTALL_DIR" ]; then
+ APP_DIR="$INSTALL_DIR/app"
+
+ # 加载.env配置
+ if [ -f "$APP_DIR/.env" ]; then
+ export $(cat "$APP_DIR/.env" | grep -v '^#' | xargs)
+ # 特别加载端口配置
+ APP_PORT=$(grep "^PORT=" "$APP_DIR/.env" 2>/dev/null | cut -d'=' -f2)
+ fi
+ fi
+}
+
+# 主函数
+main() {
+ # 检测操作系统
+ detect_os
+
+ if [ "$OS" == "unknown" ]; then
+ print_error "不支持的操作系统"
+ exit 1
+ fi
+
+ # 加载配置
+ load_config
+
+ # 处理命令
+ case "$1" in
+ install)
+ # 检查依赖
+ if ! install_dependencies; then
+ print_error "依赖安装失败"
+ exit 1
+ fi
+
+ # 检查Redis
+ if ! check_redis; then
+ print_warning "Redis 连接失败"
+ install_local_redis
+
+ # 重新测试连接
+ REDIS_HOST="localhost"
+ REDIS_PORT="6379"
+ if ! check_redis; then
+ print_error "Redis 配置失败,请手动安装并配置 Redis"
+ exit 1
+ fi
+ fi
+
+ # 安装服务
+ install_service
+
+ # 创建软链接
+ create_symlink
+ ;;
+ update)
+ update_service
+ ;;
+ uninstall)
+ uninstall_service
+ ;;
+ start)
+ start_service
+ ;;
+ stop)
+ stop_service
+ ;;
+ restart)
+ restart_service
+ ;;
+ status)
+ show_status
+ ;;
+ symlink)
+ # 单独创建软链接
+ create_symlink
+ ;;
+ help)
+ show_help
+ ;;
+ "")
+ # 无参数时显示交互式菜单
+ while true; do
+ show_menu
+ read choice
+ handle_menu_choice "$choice"
+ done
+ ;;
+ *)
+ print_error "未知命令: $1"
+ echo ""
+ show_help
+ ;;
+ esac
+}
+
+# 运行主函数
+main "$@"
\ No newline at end of file
diff --git a/scripts/test-apikey-expiry.js b/scripts/test-apikey-expiry.js
deleted file mode 100644
index a799e8d5..00000000
--- a/scripts/test-apikey-expiry.js
+++ /dev/null
@@ -1,159 +0,0 @@
-#!/usr/bin/env node
-
-/**
- * 测试 API Key 过期功能
- * 快速创建和修改 API Key 过期时间以便测试
- */
-
-const apiKeyService = require('../src/services/apiKeyService');
-const redis = require('../src/models/redis');
-const logger = require('../src/utils/logger');
-const chalk = require('chalk');
-
-async function createTestApiKeys() {
- console.log(chalk.bold.blue('\n🧪 创建测试 API Keys\n'));
-
- try {
- await redis.connect();
-
- // 创建不同过期时间的测试 Keys
- const testKeys = [
- {
- name: 'Test-Expired',
- description: '已过期的测试 Key',
- expiresAt: new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString() // 1天前过期
- },
- {
- name: 'Test-1Hour',
- description: '1小时后过期的测试 Key',
- expiresAt: new Date(Date.now() + 60 * 60 * 1000).toISOString() // 1小时后
- },
- {
- name: 'Test-1Day',
- description: '1天后过期的测试 Key',
- expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString() // 1天后
- },
- {
- name: 'Test-7Days',
- description: '7天后过期的测试 Key',
- expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString() // 7天后
- },
- {
- name: 'Test-Never',
- description: '永不过期的测试 Key',
- expiresAt: null // 永不过期
- }
- ];
-
- console.log('正在创建测试 API Keys...\n');
-
- for (const keyData of testKeys) {
- try {
- const newKey = await apiKeyService.generateApiKey(keyData);
-
- const expiryInfo = keyData.expiresAt
- ? new Date(keyData.expiresAt).toLocaleString()
- : '永不过期';
-
- console.log(`✅ 创建成功: ${keyData.name}`);
- console.log(` API Key: ${newKey.apiKey}`);
- console.log(` 过期时间: ${expiryInfo}`);
- console.log('');
-
- } catch (error) {
- console.log(chalk.red(`❌ 创建失败: ${keyData.name} - ${error.message}`));
- }
- }
-
- // 运行清理任务测试
- console.log(chalk.bold.yellow('\n🔄 运行清理任务...\n'));
- const cleanedCount = await apiKeyService.cleanupExpiredKeys();
- console.log(`清理了 ${cleanedCount} 个过期的 API Keys\n`);
-
- // 显示所有 API Keys 状态
- console.log(chalk.bold.cyan('📊 当前所有 API Keys 状态:\n'));
- const allKeys = await apiKeyService.getAllApiKeys();
-
- for (const key of allKeys) {
- const now = new Date();
- const expiresAt = key.expiresAt ? new Date(key.expiresAt) : null;
- let status = '✅ 活跃';
- let expiryInfo = '永不过期';
-
- if (expiresAt) {
- if (expiresAt < now) {
- status = '❌ 已过期';
- expiryInfo = `过期于 ${expiresAt.toLocaleString()}`;
- } else {
- const hoursLeft = Math.ceil((expiresAt - now) / (1000 * 60 * 60));
- const daysLeft = Math.ceil(hoursLeft / 24);
-
- if (hoursLeft < 24) {
- expiryInfo = chalk.yellow(`${hoursLeft}小时后过期`);
- } else if (daysLeft <= 7) {
- expiryInfo = chalk.yellow(`${daysLeft}天后过期`);
- } else {
- expiryInfo = chalk.green(`${daysLeft}天后过期`);
- }
- }
- }
-
- if (!key.isActive) {
- status = '🔒 已禁用';
- }
-
- console.log(`${status} ${key.name} - ${expiryInfo}`);
- console.log(` API Key: ${key.apiKey?.substring(0, 30)}...`);
- console.log('');
- }
-
- } catch (error) {
- console.error(chalk.red('测试失败:'), error);
- } finally {
- await redis.disconnect();
- }
-}
-
-// 主函数
-async function main() {
- console.log(chalk.bold.magenta('\n===================================='));
- console.log(chalk.bold.magenta(' API Key 过期功能测试工具'));
- console.log(chalk.bold.magenta('====================================\n'));
-
- console.log('此工具将:');
- console.log('1. 创建不同过期时间的测试 API Keys');
- console.log('2. 运行清理任务禁用过期的 Keys');
- console.log('3. 显示所有 Keys 的当前状态\n');
-
- console.log(chalk.yellow('⚠️ 注意:这会在您的系统中创建真实的 API Keys\n'));
-
- const readline = require('readline').createInterface({
- input: process.stdin,
- output: process.stdout
- });
-
- readline.question('是否继续?(y/n): ', async (answer) => {
- if (answer.toLowerCase() === 'y') {
- await createTestApiKeys();
-
- console.log(chalk.bold.green('\n✅ 测试完成!\n'));
- console.log('您现在可以:');
- console.log('1. 使用 CLI 工具管理这些测试 Keys:');
- console.log(' npm run cli keys');
- console.log('');
- console.log('2. 在 Web 界面查看和管理这些 Keys');
- console.log('');
- console.log('3. 测试 API 调用时的过期验证');
- } else {
- console.log('\n已取消');
- }
-
- readline.close();
- });
-}
-
-// 运行
-main().catch(error => {
- console.error(chalk.red('错误:'), error);
- process.exit(1);
-});
\ No newline at end of file
diff --git a/scripts/test-claude-console-url.js b/scripts/test-claude-console-url.js
deleted file mode 100755
index 868315b6..00000000
--- a/scripts/test-claude-console-url.js
+++ /dev/null
@@ -1,28 +0,0 @@
-#!/usr/bin/env node
-
-// 测试Claude Console账号URL处理
-
-const testUrls = [
- 'https://api.example.com',
- 'https://api.example.com/',
- 'https://api.example.com/v1/messages',
- 'https://api.example.com/v1/messages/',
- 'https://api.example.com:8080',
- 'https://api.example.com:8080/v1/messages'
-];
-
-console.log('🧪 Testing Claude Console URL handling:\n');
-
-testUrls.forEach(url => {
- // 模拟账号服务的URL处理逻辑
- const cleanUrl = url.replace(/\/$/, ''); // 移除末尾斜杠
- const apiEndpoint = cleanUrl.endsWith('/v1/messages')
- ? cleanUrl
- : `${cleanUrl}/v1/messages`;
-
- console.log(`Input: ${url}`);
- console.log(`Output: ${apiEndpoint}`);
- console.log('---');
-});
-
-console.log('\n✅ URL normalization logic test completed');
\ No newline at end of file
diff --git a/scripts/test-gemini-decrypt.js b/scripts/test-gemini-decrypt.js
deleted file mode 100644
index 8a6ff5e2..00000000
--- a/scripts/test-gemini-decrypt.js
+++ /dev/null
@@ -1,102 +0,0 @@
-#!/usr/bin/env node
-
-/**
- * 测试 Gemini 账户解密
- */
-
-const path = require('path');
-const dotenv = require('dotenv');
-const crypto = require('crypto');
-
-// 加载环境变量
-dotenv.config({ path: path.join(__dirname, '..', '.env') });
-
-const redis = require('../src/models/redis');
-const config = require('../config/config');
-
-const ALGORITHM = 'aes-256-cbc';
-const ENCRYPTION_SALT = 'gemini-account-salt'; // 正确的盐值!
-const GEMINI_ACCOUNT_KEY_PREFIX = 'gemini_account:';
-
-// 生成加密密钥(与 geminiAccountService 完全相同)
-function generateEncryptionKey() {
- return crypto.scryptSync(config.security.encryptionKey, ENCRYPTION_SALT, 32);
-}
-
-// 解密函数(与 geminiAccountService 相同)
-function decrypt(text) {
- if (!text) return '';
- try {
- const key = generateEncryptionKey();
- // IV 是固定长度的 32 个十六进制字符(16 字节)
- const ivHex = text.substring(0, 32);
- const encryptedHex = text.substring(33); // 跳过冒号
-
- const iv = Buffer.from(ivHex, 'hex');
- const encryptedText = Buffer.from(encryptedHex, 'hex');
- const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
- let decrypted = decipher.update(encryptedText);
- decrypted = Buffer.concat([decrypted, decipher.final()]);
- return decrypted.toString();
- } catch (error) {
- console.error('解密错误:', error.message);
- return null;
- }
-}
-
-async function testDecrypt() {
- try {
- console.log('🚀 测试 Gemini 账户解密...\n');
-
- console.log('📋 加密配置:');
- console.log(` config.security.encryptionKey: ${config.security.encryptionKey}`);
- console.log(` ENCRYPTION_SALT: ${ENCRYPTION_SALT}`);
- console.log();
-
- // 连接 Redis
- console.log('📡 连接 Redis...');
- await redis.connect();
- console.log('✅ Redis 连接成功\n');
-
- const client = redis.getClient();
- const keys = await client.keys(`${GEMINI_ACCOUNT_KEY_PREFIX}*`);
-
- if (keys.length === 0) {
- console.log('❌ 没有找到 Gemini 账户');
- process.exit(1);
- }
-
- console.log(`🔍 找到 ${keys.length} 个 Gemini 账户\n`);
-
- for (const key of keys) {
- const accountData = await client.hgetall(key);
- const accountId = key.replace(GEMINI_ACCOUNT_KEY_PREFIX, '');
-
- console.log(`📋 账户: ${accountData.name} (${accountId})`);
-
- if (accountData.refreshToken) {
- console.log('🔐 尝试解密 refreshToken...');
- const decrypted = decrypt(accountData.refreshToken);
-
- if (decrypted) {
- console.log('✅ 解密成功!');
- console.log(` Token 前缀: ${decrypted.substring(0, 20)}...`);
- } else {
- console.log('❌ 解密失败');
- }
- } else {
- console.log('⚠️ 无 refreshToken');
- }
-
- console.log();
- }
-
- } catch (error) {
- console.error('❌ 测试失败:', error);
- } finally {
- await redis.disconnect();
- process.exit(0);
- }
-}
-
-testDecrypt();
\ No newline at end of file
diff --git a/scripts/test-group-scheduling.js b/scripts/test-group-scheduling.js
new file mode 100644
index 00000000..eff94ec2
--- /dev/null
+++ b/scripts/test-group-scheduling.js
@@ -0,0 +1,545 @@
+/**
+ * 分组调度功能测试脚本
+ * 用于测试账户分组管理和调度逻辑的正确性
+ */
+
+require('dotenv').config();
+const { v4: uuidv4 } = require('uuid');
+const redis = require('../src/models/redis');
+const accountGroupService = require('../src/services/accountGroupService');
+const claudeAccountService = require('../src/services/claudeAccountService');
+const claudeConsoleAccountService = require('../src/services/claudeConsoleAccountService');
+const apiKeyService = require('../src/services/apiKeyService');
+const unifiedClaudeScheduler = require('../src/services/unifiedClaudeScheduler');
+const logger = require('../src/utils/logger');
+
+// 测试配置
+const TEST_PREFIX = 'test_group_';
+const CLEANUP_ON_FINISH = true; // 测试完成后是否清理数据
+
+// 测试数据存储
+const testData = {
+ groups: [],
+ accounts: [],
+ apiKeys: []
+};
+
+// 颜色输出
+const colors = {
+ green: '\x1b[32m',
+ red: '\x1b[31m',
+ yellow: '\x1b[33m',
+ blue: '\x1b[34m',
+ reset: '\x1b[0m'
+};
+
+function log(message, type = 'info') {
+ const color = {
+ success: colors.green,
+ error: colors.red,
+ warning: colors.yellow,
+ info: colors.blue
+ }[type] || colors.reset;
+
+ console.log(`${color}${message}${colors.reset}`);
+}
+
+async function sleep(ms) {
+ return new Promise(resolve => setTimeout(resolve, ms));
+}
+
+// 清理测试数据
+async function cleanup() {
+ log('\n🧹 清理测试数据...', 'info');
+
+ // 删除测试API Keys
+ for (const apiKey of testData.apiKeys) {
+ try {
+ await apiKeyService.deleteApiKey(apiKey.id);
+ log(`✅ 删除测试API Key: ${apiKey.name}`, 'success');
+ } catch (error) {
+ log(`❌ 删除API Key失败: ${error.message}`, 'error');
+ }
+ }
+
+ // 删除测试账户
+ for (const account of testData.accounts) {
+ try {
+ if (account.type === 'claude') {
+ await claudeAccountService.deleteAccount(account.id);
+ } else if (account.type === 'claude-console') {
+ await claudeConsoleAccountService.deleteAccount(account.id);
+ }
+ log(`✅ 删除测试账户: ${account.name}`, 'success');
+ } catch (error) {
+ log(`❌ 删除账户失败: ${error.message}`, 'error');
+ }
+ }
+
+ // 删除测试分组
+ for (const group of testData.groups) {
+ try {
+ await accountGroupService.deleteGroup(group.id);
+ log(`✅ 删除测试分组: ${group.name}`, 'success');
+ } catch (error) {
+ // 可能因为还有成员而删除失败,先移除所有成员
+ if (error.message.includes('分组内还有账户')) {
+ const members = await accountGroupService.getGroupMembers(group.id);
+ for (const memberId of members) {
+ await accountGroupService.removeAccountFromGroup(memberId, group.id);
+ }
+ // 重试删除
+ await accountGroupService.deleteGroup(group.id);
+ log(`✅ 删除测试分组: ${group.name} (清空成员后)`, 'success');
+ } else {
+ log(`❌ 删除分组失败: ${error.message}`, 'error');
+ }
+ }
+ }
+}
+
+// 测试1: 创建分组
+async function test1_createGroups() {
+ log('\n📝 测试1: 创建账户分组', 'info');
+
+ try {
+ // 创建Claude分组
+ const claudeGroup = await accountGroupService.createGroup({
+ name: TEST_PREFIX + 'Claude组',
+ platform: 'claude',
+ description: '测试用Claude账户分组'
+ });
+ testData.groups.push(claudeGroup);
+ log(`✅ 创建Claude分组成功: ${claudeGroup.name} (ID: ${claudeGroup.id})`, 'success');
+
+ // 创建Gemini分组
+ const geminiGroup = await accountGroupService.createGroup({
+ name: TEST_PREFIX + 'Gemini组',
+ platform: 'gemini',
+ description: '测试用Gemini账户分组'
+ });
+ testData.groups.push(geminiGroup);
+ log(`✅ 创建Gemini分组成功: ${geminiGroup.name} (ID: ${geminiGroup.id})`, 'success');
+
+ // 验证分组信息
+ const allGroups = await accountGroupService.getAllGroups();
+ const testGroups = allGroups.filter(g => g.name.startsWith(TEST_PREFIX));
+
+ if (testGroups.length === 2) {
+ log(`✅ 分组创建验证通过,共创建 ${testGroups.length} 个测试分组`, 'success');
+ } else {
+ throw new Error(`分组数量不正确,期望2个,实际${testGroups.length}个`);
+ }
+
+ } catch (error) {
+ log(`❌ 测试1失败: ${error.message}`, 'error');
+ throw error;
+ }
+}
+
+// 测试2: 创建账户并添加到分组
+async function test2_createAccountsAndAddToGroup() {
+ log('\n📝 测试2: 创建账户并添加到分组', 'info');
+
+ try {
+ const claudeGroup = testData.groups.find(g => g.platform === 'claude');
+
+ // 创建Claude OAuth账户
+ const claudeAccount1 = await claudeAccountService.createAccount({
+ name: TEST_PREFIX + 'Claude账户1',
+ email: 'test1@example.com',
+ refreshToken: 'test_refresh_token_1',
+ accountType: 'group'
+ });
+ testData.accounts.push({ ...claudeAccount1, type: 'claude' });
+ log(`✅ 创建Claude OAuth账户1成功: ${claudeAccount1.name}`, 'success');
+
+ const claudeAccount2 = await claudeAccountService.createAccount({
+ name: TEST_PREFIX + 'Claude账户2',
+ email: 'test2@example.com',
+ refreshToken: 'test_refresh_token_2',
+ accountType: 'group'
+ });
+ testData.accounts.push({ ...claudeAccount2, type: 'claude' });
+ log(`✅ 创建Claude OAuth账户2成功: ${claudeAccount2.name}`, 'success');
+
+ // 创建Claude Console账户
+ const consoleAccount = await claudeConsoleAccountService.createAccount({
+ name: TEST_PREFIX + 'Console账户',
+ apiUrl: 'https://api.example.com',
+ apiKey: 'test_api_key',
+ accountType: 'group'
+ });
+ testData.accounts.push({ ...consoleAccount, type: 'claude-console' });
+ log(`✅ 创建Claude Console账户成功: ${consoleAccount.name}`, 'success');
+
+ // 添加账户到分组
+ await accountGroupService.addAccountToGroup(claudeAccount1.id, claudeGroup.id, 'claude');
+ log(`✅ 添加账户1到分组成功`, 'success');
+
+ await accountGroupService.addAccountToGroup(claudeAccount2.id, claudeGroup.id, 'claude');
+ log(`✅ 添加账户2到分组成功`, 'success');
+
+ await accountGroupService.addAccountToGroup(consoleAccount.id, claudeGroup.id, 'claude');
+ log(`✅ 添加Console账户到分组成功`, 'success');
+
+ // 验证分组成员
+ const members = await accountGroupService.getGroupMembers(claudeGroup.id);
+ if (members.length === 3) {
+ log(`✅ 分组成员验证通过,共有 ${members.length} 个成员`, 'success');
+ } else {
+ throw new Error(`分组成员数量不正确,期望3个,实际${members.length}个`);
+ }
+
+ } catch (error) {
+ log(`❌ 测试2失败: ${error.message}`, 'error');
+ throw error;
+ }
+}
+
+// 测试3: 平台一致性验证
+async function test3_platformConsistency() {
+ log('\n📝 测试3: 平台一致性验证', 'info');
+
+ try {
+ const claudeGroup = testData.groups.find(g => g.platform === 'claude');
+ const geminiGroup = testData.groups.find(g => g.platform === 'gemini');
+
+ // 尝试将Claude账户添加到Gemini分组(应该失败)
+ const claudeAccount = testData.accounts.find(a => a.type === 'claude');
+
+ try {
+ await accountGroupService.addAccountToGroup(claudeAccount.id, geminiGroup.id, 'claude');
+ throw new Error('平台验证失败:Claude账户不应该能添加到Gemini分组');
+ } catch (error) {
+ if (error.message.includes('平台与分组平台不匹配')) {
+ log(`✅ 平台一致性验证通过:${error.message}`, 'success');
+ } else {
+ throw error;
+ }
+ }
+
+ } catch (error) {
+ log(`❌ 测试3失败: ${error.message}`, 'error');
+ throw error;
+ }
+}
+
+// 测试4: API Key绑定分组
+async function test4_apiKeyBindGroup() {
+ log('\n📝 测试4: API Key绑定分组', 'info');
+
+ try {
+ const claudeGroup = testData.groups.find(g => g.platform === 'claude');
+
+ // 创建绑定到分组的API Key
+ const apiKey = await apiKeyService.generateApiKey({
+ name: TEST_PREFIX + 'API Key',
+ description: '测试分组调度的API Key',
+ claudeAccountId: `group:${claudeGroup.id}`,
+ permissions: 'claude'
+ });
+ testData.apiKeys.push(apiKey);
+ log(`✅ 创建API Key成功: ${apiKey.name} (绑定到分组: ${claudeGroup.name})`, 'success');
+
+ // 验证API Key信息
+ const keyInfo = await redis.getApiKey(apiKey.id);
+ if (keyInfo && keyInfo.claudeAccountId === `group:${claudeGroup.id}`) {
+ log(`✅ API Key分组绑定验证通过`, 'success');
+ } else {
+ throw new Error('API Key分组绑定信息不正确');
+ }
+
+ } catch (error) {
+ log(`❌ 测试4失败: ${error.message}`, 'error');
+ throw error;
+ }
+}
+
+// 测试5: 分组调度负载均衡
+async function test5_groupSchedulingLoadBalance() {
+ log('\n📝 测试5: 分组调度负载均衡', 'info');
+
+ try {
+ const claudeGroup = testData.groups.find(g => g.platform === 'claude');
+ const apiKey = testData.apiKeys[0];
+
+ // 记录每个账户被选中的次数
+ const selectionCount = {};
+ const totalSelections = 30;
+
+ for (let i = 0; i < totalSelections; i++) {
+ // 模拟不同的会话
+ const sessionHash = uuidv4();
+
+ const result = await unifiedClaudeScheduler.selectAccountForApiKey({
+ id: apiKey.id,
+ claudeAccountId: apiKey.claudeAccountId,
+ name: apiKey.name
+ }, sessionHash);
+
+ if (!selectionCount[result.accountId]) {
+ selectionCount[result.accountId] = 0;
+ }
+ selectionCount[result.accountId]++;
+
+ // 短暂延迟,模拟真实请求间隔
+ await sleep(50);
+ }
+
+ // 分析选择分布
+ log(`\n📊 负载均衡分布统计 (共${totalSelections}次选择):`, 'info');
+ const accounts = Object.keys(selectionCount);
+
+ for (const accountId of accounts) {
+ const count = selectionCount[accountId];
+ const percentage = ((count / totalSelections) * 100).toFixed(1);
+ const accountInfo = testData.accounts.find(a => a.id === accountId);
+ log(` ${accountInfo.name}: ${count}次 (${percentage}%)`, 'info');
+ }
+
+ // 验证是否实现了负载均衡
+ const counts = Object.values(selectionCount);
+ const avgCount = totalSelections / accounts.length;
+ const variance = counts.reduce((sum, count) => sum + Math.pow(count - avgCount, 2), 0) / counts.length;
+ const stdDev = Math.sqrt(variance);
+
+ log(`\n 平均选择次数: ${avgCount.toFixed(1)}`, 'info');
+ log(` 标准差: ${stdDev.toFixed(1)}`, 'info');
+
+ // 如果标准差小于平均值的50%,认为负载均衡效果良好
+ if (stdDev < avgCount * 0.5) {
+ log(`✅ 负载均衡验证通过,分布相对均匀`, 'success');
+ } else {
+ log(`⚠️ 负载分布不够均匀,但这可能是正常的随机波动`, 'warning');
+ }
+
+ } catch (error) {
+ log(`❌ 测试5失败: ${error.message}`, 'error');
+ throw error;
+ }
+}
+
+// 测试6: 会话粘性测试
+async function test6_stickySession() {
+ log('\n📝 测试6: 会话粘性(Sticky Session)测试', 'info');
+
+ try {
+ const apiKey = testData.apiKeys[0];
+ const sessionHash = 'test_session_' + uuidv4();
+
+ // 第一次选择
+ const firstSelection = await unifiedClaudeScheduler.selectAccountForApiKey({
+ id: apiKey.id,
+ claudeAccountId: apiKey.claudeAccountId,
+ name: apiKey.name
+ }, sessionHash);
+
+ log(` 首次选择账户: ${firstSelection.accountId}`, 'info');
+
+ // 使用相同的sessionHash多次请求
+ let consistentCount = 0;
+ const testCount = 10;
+
+ for (let i = 0; i < testCount; i++) {
+ const selection = await unifiedClaudeScheduler.selectAccountForApiKey({
+ id: apiKey.id,
+ claudeAccountId: apiKey.claudeAccountId,
+ name: apiKey.name
+ }, sessionHash);
+
+ if (selection.accountId === firstSelection.accountId) {
+ consistentCount++;
+ }
+
+ await sleep(100);
+ }
+
+ log(` 会话一致性: ${consistentCount}/${testCount} 次选择了相同账户`, 'info');
+
+ if (consistentCount === testCount) {
+ log(`✅ 会话粘性验证通过,同一会话始终选择相同账户`, 'success');
+ } else {
+ throw new Error(`会话粘性失败,只有${consistentCount}/${testCount}次选择了相同账户`);
+ }
+
+ } catch (error) {
+ log(`❌ 测试6失败: ${error.message}`, 'error');
+ throw error;
+ }
+}
+
+// 测试7: 账户可用性检查
+async function test7_accountAvailability() {
+ log('\n📝 测试7: 账户可用性检查', 'info');
+
+ try {
+ const apiKey = testData.apiKeys[0];
+ const accounts = testData.accounts.filter(a => a.type === 'claude' || a.type === 'claude-console');
+
+ // 禁用第一个账户
+ const firstAccount = accounts[0];
+ if (firstAccount.type === 'claude') {
+ await claudeAccountService.updateAccount(firstAccount.id, { isActive: false });
+ } else {
+ await claudeConsoleAccountService.updateAccount(firstAccount.id, { isActive: false });
+ }
+ log(` 已禁用账户: ${firstAccount.name}`, 'info');
+
+ // 多次选择,验证不会选择到禁用的账户
+ const selectionResults = [];
+ for (let i = 0; i < 20; i++) {
+ const sessionHash = uuidv4(); // 每次使用新会话
+ const result = await unifiedClaudeScheduler.selectAccountForApiKey({
+ id: apiKey.id,
+ claudeAccountId: apiKey.claudeAccountId,
+ name: apiKey.name
+ }, sessionHash);
+
+ selectionResults.push(result.accountId);
+ }
+
+ // 检查是否选择了禁用的账户
+ const selectedDisabled = selectionResults.includes(firstAccount.id);
+
+ if (!selectedDisabled) {
+ log(`✅ 账户可用性验证通过,未选择禁用的账户`, 'success');
+ } else {
+ throw new Error('错误:选择了已禁用的账户');
+ }
+
+ // 重新启用账户
+ if (firstAccount.type === 'claude') {
+ await claudeAccountService.updateAccount(firstAccount.id, { isActive: true });
+ } else {
+ await claudeConsoleAccountService.updateAccount(firstAccount.id, { isActive: true });
+ }
+
+ } catch (error) {
+ log(`❌ 测试7失败: ${error.message}`, 'error');
+ throw error;
+ }
+}
+
+// 测试8: 分组成员管理
+async function test8_groupMemberManagement() {
+ log('\n📝 测试8: 分组成员管理', 'info');
+
+ try {
+ const claudeGroup = testData.groups.find(g => g.platform === 'claude');
+ const account = testData.accounts.find(a => a.type === 'claude');
+
+ // 获取账户所属分组
+ const accountGroup = await accountGroupService.getAccountGroup(account.id);
+ if (accountGroup && accountGroup.id === claudeGroup.id) {
+ log(`✅ 账户分组查询验证通过`, 'success');
+ } else {
+ throw new Error('账户分组查询结果不正确');
+ }
+
+ // 从分组移除账户
+ await accountGroupService.removeAccountFromGroup(account.id, claudeGroup.id);
+ log(` 从分组移除账户: ${account.name}`, 'info');
+
+ // 验证账户已不在分组中
+ const membersAfterRemove = await accountGroupService.getGroupMembers(claudeGroup.id);
+ if (!membersAfterRemove.includes(account.id)) {
+ log(`✅ 账户移除验证通过`, 'success');
+ } else {
+ throw new Error('账户移除失败');
+ }
+
+ // 重新添加账户
+ await accountGroupService.addAccountToGroup(account.id, claudeGroup.id, 'claude');
+ log(` 重新添加账户到分组`, 'info');
+
+ } catch (error) {
+ log(`❌ 测试8失败: ${error.message}`, 'error');
+ throw error;
+ }
+}
+
+// 测试9: 空分组处理
+async function test9_emptyGroupHandling() {
+ log('\n📝 测试9: 空分组处理', 'info');
+
+ try {
+ // 创建一个空分组
+ const emptyGroup = await accountGroupService.createGroup({
+ name: TEST_PREFIX + '空分组',
+ platform: 'claude',
+ description: '测试空分组'
+ });
+ testData.groups.push(emptyGroup);
+
+ // 创建绑定到空分组的API Key
+ const apiKey = await apiKeyService.generateApiKey({
+ name: TEST_PREFIX + '空分组API Key',
+ claudeAccountId: `group:${emptyGroup.id}`,
+ permissions: 'claude'
+ });
+ testData.apiKeys.push(apiKey);
+
+ // 尝试从空分组选择账户(应该失败)
+ try {
+ await unifiedClaudeScheduler.selectAccountForApiKey({
+ id: apiKey.id,
+ claudeAccountId: apiKey.claudeAccountId,
+ name: apiKey.name
+ });
+ throw new Error('空分组选择账户应该失败');
+ } catch (error) {
+ if (error.message.includes('has no members')) {
+ log(`✅ 空分组处理验证通过:${error.message}`, 'success');
+ } else {
+ throw error;
+ }
+ }
+
+ } catch (error) {
+ log(`❌ 测试9失败: ${error.message}`, 'error');
+ throw error;
+ }
+}
+
+// 主测试函数
+async function runTests() {
+ log('\n🚀 开始分组调度功能测试\n', 'info');
+
+ try {
+ // 连接Redis
+ await redis.connect();
+ log('✅ Redis连接成功', 'success');
+
+ // 执行测试
+ await test1_createGroups();
+ await test2_createAccountsAndAddToGroup();
+ await test3_platformConsistency();
+ await test4_apiKeyBindGroup();
+ await test5_groupSchedulingLoadBalance();
+ await test6_stickySession();
+ await test7_accountAvailability();
+ await test8_groupMemberManagement();
+ await test9_emptyGroupHandling();
+
+ log('\n🎉 所有测试通过!分组调度功能工作正常', 'success');
+
+ } catch (error) {
+ log(`\n❌ 测试失败: ${error.message}`, 'error');
+ console.error(error);
+ } finally {
+ // 清理测试数据
+ if (CLEANUP_ON_FINISH) {
+ await cleanup();
+ } else {
+ log('\n⚠️ 测试数据未清理,请手动清理', 'warning');
+ }
+
+ // 关闭Redis连接
+ await redis.disconnect();
+ process.exit(0);
+ }
+}
+
+// 运行测试
+runTests();
\ No newline at end of file
diff --git a/scripts/test-import-encryption.js b/scripts/test-import-encryption.js
deleted file mode 100644
index 40cd5b3b..00000000
--- a/scripts/test-import-encryption.js
+++ /dev/null
@@ -1,181 +0,0 @@
-#!/usr/bin/env node
-
-/**
- * 测试导入加密处理
- * 验证增强版数据传输工具是否正确处理加密和未加密的导出数据
- */
-
-const fs = require('fs').promises;
-const path = require('path');
-const crypto = require('crypto');
-const config = require('../config/config');
-const logger = require('../src/utils/logger');
-
-// 模拟加密函数
-function encryptData(data, salt = 'salt') {
- if (!data || !config.security.encryptionKey) return data;
-
- const key = crypto.scryptSync(config.security.encryptionKey, salt, 32);
- const iv = crypto.randomBytes(16);
-
- const cipher = crypto.createCipheriv('aes-256-cbc', key, iv);
- let encrypted = cipher.update(data, 'utf8', 'hex');
- encrypted += cipher.final('hex');
-
- return iv.toString('hex') + ':' + encrypted;
-}
-
-// 模拟解密函数
-function decryptData(encryptedData, salt = 'salt') {
- if (!encryptedData || !config.security.encryptionKey) return encryptedData;
-
- try {
- if (encryptedData.includes(':')) {
- const parts = encryptedData.split(':');
- const key = crypto.scryptSync(config.security.encryptionKey, salt, 32);
- const iv = Buffer.from(parts[0], 'hex');
- const encrypted = parts[1];
-
- const decipher = crypto.createDecipheriv('aes-256-cbc', key, iv);
- let decrypted = decipher.update(encrypted, 'hex', 'utf8');
- decrypted += decipher.final('utf8');
- return decrypted;
- }
- return encryptedData;
- } catch (error) {
- logger.warn(`⚠️ Failed to decrypt data: ${error.message}`);
- return encryptedData;
- }
-}
-
-async function testImportHandling() {
- console.log('🧪 测试导入加密处理\n');
-
- // 测试数据
- const testClaudeAccount = {
- id: 'test-claude-123',
- name: 'Test Claude Account',
- email: 'test@example.com',
- password: 'testPassword123',
- accessToken: 'test-access-token',
- refreshToken: 'test-refresh-token',
- claudeAiOauth: {
- access_token: 'oauth-access-token',
- refresh_token: 'oauth-refresh-token',
- scopes: ['read', 'write']
- }
- };
-
- const testGeminiAccount = {
- id: 'test-gemini-456',
- name: 'Test Gemini Account',
- geminiOauth: {
- access_token: 'gemini-access-token',
- refresh_token: 'gemini-refresh-token'
- },
- accessToken: 'gemini-access-token',
- refreshToken: 'gemini-refresh-token'
- };
-
- // 1. 创建解密的导出文件(模拟 --decrypt=true)
- const decryptedExport = {
- metadata: {
- version: '2.0',
- exportDate: new Date().toISOString(),
- sanitized: false,
- decrypted: true, // 标记为已解密
- types: ['all']
- },
- data: {
- claudeAccounts: [testClaudeAccount],
- geminiAccounts: [testGeminiAccount]
- }
- };
-
- // 2. 创建加密的导出文件(模拟 --decrypt=false)
- const encryptedClaudeAccount = { ...testClaudeAccount };
- encryptedClaudeAccount.email = encryptData(encryptedClaudeAccount.email);
- encryptedClaudeAccount.password = encryptData(encryptedClaudeAccount.password);
- encryptedClaudeAccount.accessToken = encryptData(encryptedClaudeAccount.accessToken);
- encryptedClaudeAccount.refreshToken = encryptData(encryptedClaudeAccount.refreshToken);
- encryptedClaudeAccount.claudeAiOauth = encryptData(JSON.stringify(encryptedClaudeAccount.claudeAiOauth));
-
- const encryptedGeminiAccount = { ...testGeminiAccount };
- encryptedGeminiAccount.geminiOauth = encryptData(JSON.stringify(encryptedGeminiAccount.geminiOauth), 'gemini-account-salt');
- encryptedGeminiAccount.accessToken = encryptData(encryptedGeminiAccount.accessToken, 'gemini-account-salt');
- encryptedGeminiAccount.refreshToken = encryptData(encryptedGeminiAccount.refreshToken, 'gemini-account-salt');
-
- const encryptedExport = {
- metadata: {
- version: '2.0',
- exportDate: new Date().toISOString(),
- sanitized: false,
- decrypted: false, // 标记为未解密(加密状态)
- types: ['all']
- },
- data: {
- claudeAccounts: [encryptedClaudeAccount],
- geminiAccounts: [encryptedGeminiAccount]
- }
- };
-
- // 写入测试文件
- const testDir = path.join(__dirname, '../data/test-imports');
- await fs.mkdir(testDir, { recursive: true });
-
- await fs.writeFile(
- path.join(testDir, 'decrypted-export.json'),
- JSON.stringify(decryptedExport, null, 2)
- );
-
- await fs.writeFile(
- path.join(testDir, 'encrypted-export.json'),
- JSON.stringify(encryptedExport, null, 2)
- );
-
- console.log('✅ 测试文件已创建:');
- console.log(' - data/test-imports/decrypted-export.json (解密的数据)');
- console.log(' - data/test-imports/encrypted-export.json (加密的数据)\n');
-
- console.log('📋 测试场景:\n');
-
- console.log('1. 导入解密的数据(decrypted=true):');
- console.log(' - 导入时应该重新加密敏感字段');
- console.log(' - 命令: npm run data:import:enhanced -- --input=data/test-imports/decrypted-export.json\n');
-
- console.log('2. 导入加密的数据(decrypted=false):');
- console.log(' - 导入时应该保持原样(已经是加密的)');
- console.log(' - 命令: npm run data:import:enhanced -- --input=data/test-imports/encrypted-export.json\n');
-
- console.log('3. 验证导入后的数据:');
- console.log(' - 使用 CLI 查看账户状态');
- console.log(' - 命令: npm run cli accounts list\n');
-
- // 显示示例数据对比
- console.log('📊 数据对比示例:\n');
- console.log('原始数据(解密状态):');
- console.log(` email: "${testClaudeAccount.email}"`);
- console.log(` password: "${testClaudeAccount.password}"`);
- console.log(` accessToken: "${testClaudeAccount.accessToken}"\n`);
-
- console.log('加密后的数据:');
- console.log(` email: "${encryptedClaudeAccount.email.substring(0, 50)}..."`);
- console.log(` password: "${encryptedClaudeAccount.password.substring(0, 50)}..."`);
- console.log(` accessToken: "${encryptedClaudeAccount.accessToken.substring(0, 50)}..."\n`);
-
- // 验证加密/解密
- console.log('🔐 验证加密/解密功能:');
- const testString = 'test-data-123';
- const encrypted = encryptData(testString);
- const decrypted = decryptData(encrypted);
- console.log(` 原始: "${testString}"`);
- console.log(` 加密: "${encrypted.substring(0, 50)}..."`);
- console.log(` 解密: "${decrypted}"`);
- console.log(` 验证: ${testString === decrypted ? '✅ 成功' : '❌ 失败'}\n`);
-}
-
-// 运行测试
-testImportHandling().catch(error => {
- console.error('❌ 测试失败:', error);
- process.exit(1);
-});
\ No newline at end of file
diff --git a/src/app.js b/src/app.js
index 534bf465..982ebe43 100644
--- a/src/app.js
+++ b/src/app.js
@@ -67,6 +67,24 @@ class Application {
const claudeAccountService = require('./services/claudeAccountService');
await claudeAccountService.initializeSessionWindows();
+ // 超早期拦截 /admin-next/ 请求 - 在所有中间件之前
+ this.app.use((req, res, next) => {
+ if (req.path === '/admin-next/' && req.method === 'GET') {
+ logger.warn(`🚨 INTERCEPTING /admin-next/ request at the very beginning!`);
+ const adminSpaPath = path.join(__dirname, '..', 'web', 'admin-spa', 'dist');
+ const indexPath = path.join(adminSpaPath, 'index.html');
+
+ if (fs.existsSync(indexPath)) {
+ res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
+ return res.sendFile(indexPath);
+ } else {
+ logger.error('❌ index.html not found at:', indexPath);
+ return res.status(404).send('index.html not found');
+ }
+ }
+ next();
+ });
+
// 🛡️ 安全中间件
this.app.use(helmet({
contentSecurityPolicy: false, // 允许内联样式和脚本
@@ -121,6 +139,14 @@ class Application {
this.app.set('trust proxy', 1);
}
+ // 调试中间件 - 拦截所有 /admin-next 请求
+ this.app.use((req, res, next) => {
+ if (req.path.startsWith('/admin-next')) {
+ logger.info(`🔍 DEBUG: Incoming request - method: ${req.method}, path: ${req.path}, originalUrl: ${req.originalUrl}`);
+ }
+ next();
+ });
+
// 🎨 新版管理界面静态文件服务(必须在其他路由之前)
const adminSpaPath = path.join(__dirname, '..', 'web', 'admin-spa', 'dist');
if (fs.existsSync(adminSpaPath)) {
@@ -129,40 +155,54 @@ class Application {
res.redirect(301, '/admin-next/');
});
- // 安全的静态文件服务配置
- this.app.use('/admin-next/', express.static(adminSpaPath, {
- maxAge: '1d', // 缓存静态资源1天
- etag: true,
- lastModified: true,
- index: 'index.html',
- // 安全选项:禁止目录遍历
- dotfiles: 'deny', // 拒绝访问点文件
- redirect: false, // 禁止目录重定向
- // 自定义错误处理
- setHeaders: (res, path) => {
- // 为不同类型的文件设置适当的缓存策略
- if (path.endsWith('.js') || path.endsWith('.css')) {
- res.setHeader('Cache-Control', 'public, max-age=31536000, immutable'); // 1年缓存
- } else if (path.endsWith('.html')) {
- res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
- }
+ // 使用 all 方法确保捕获所有 HTTP 方法
+ this.app.all('/admin-next/', (req, res) => {
+ logger.info('🎯 HIT: /admin-next/ route handler triggered!');
+ logger.info(`Method: ${req.method}, Path: ${req.path}, URL: ${req.url}`);
+
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
+ return res.status(405).send('Method Not Allowed');
}
- }));
+
+ res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
+ res.sendFile(path.join(adminSpaPath, 'index.html'));
+ });
- // 处理SPA路由:所有未匹配的admin-next路径都返回index.html
- this.app.get('/admin-next/*', (req, res, next) => {
- // 安全检查:防止路径遍历攻击
+ // 处理所有其他 /admin-next/* 路径(但排除根路径)
+ this.app.get('/admin-next/*', (req, res) => {
+ // 如果是根路径,跳过(应该由上面的路由处理)
+ if (req.path === '/admin-next/') {
+ logger.error('❌ ERROR: /admin-next/ should not reach here!');
+ return res.status(500).send('Route configuration error');
+ }
+
const requestPath = req.path.replace('/admin-next/', '');
+
+ // 安全检查
if (requestPath.includes('..') || requestPath.includes('//') || requestPath.includes('\\')) {
return res.status(400).json({ error: 'Invalid path' });
}
- // 如果是静态资源请求但文件不存在,返回404
+ // 检查是否为静态资源
+ const filePath = path.join(adminSpaPath, requestPath);
+
+ // 如果文件存在且是静态资源
+ if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) {
+ // 设置缓存头
+ if (filePath.endsWith('.js') || filePath.endsWith('.css')) {
+ res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
+ } else if (filePath.endsWith('.html')) {
+ res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
+ }
+ return res.sendFile(filePath);
+ }
+
+ // 如果是静态资源但文件不存在
if (requestPath.match(/\.(js|css|png|jpg|jpeg|gif|svg|ico|woff|woff2|ttf)$/i)) {
return res.status(404).send('Not found');
}
- // 其他路径返回index.html(SPA路由处理)
+ // 其他所有路径返回 index.html(SPA 路由)
res.sendFile(path.join(adminSpaPath, 'index.html'));
});
diff --git a/src/routes/admin.js b/src/routes/admin.js
index e713302c..c0cba6e4 100644
--- a/src/routes/admin.js
+++ b/src/routes/admin.js
@@ -3,6 +3,7 @@ const apiKeyService = require('../services/apiKeyService');
const claudeAccountService = require('../services/claudeAccountService');
const claudeConsoleAccountService = require('../services/claudeConsoleAccountService');
const geminiAccountService = require('../services/geminiAccountService');
+const accountGroupService = require('../services/accountGroupService');
const redis = require('../models/redis');
const { authenticateAdmin } = require('../middleware/auth');
const logger = require('../utils/logger');
@@ -712,6 +713,118 @@ router.delete('/api-keys/:keyId', authenticateAdmin, async (req, res) => {
}
});
+// 👥 账户分组管理
+
+// 创建账户分组
+router.post('/account-groups', authenticateAdmin, async (req, res) => {
+ try {
+ const { name, platform, description } = req.body;
+
+ const group = await accountGroupService.createGroup({
+ name,
+ platform,
+ description
+ });
+
+ res.json({ success: true, data: group });
+ } catch (error) {
+ logger.error('❌ Failed to create account group:', error);
+ res.status(400).json({ error: error.message });
+ }
+});
+
+// 获取所有分组
+router.get('/account-groups', authenticateAdmin, async (req, res) => {
+ try {
+ const { platform } = req.query;
+ const groups = await accountGroupService.getAllGroups(platform);
+ res.json({ success: true, data: groups });
+ } catch (error) {
+ logger.error('❌ Failed to get account groups:', error);
+ res.status(500).json({ error: error.message });
+ }
+});
+
+// 获取分组详情
+router.get('/account-groups/:groupId', authenticateAdmin, async (req, res) => {
+ try {
+ const { groupId } = req.params;
+ const group = await accountGroupService.getGroup(groupId);
+
+ if (!group) {
+ return res.status(404).json({ error: '分组不存在' });
+ }
+
+ res.json({ success: true, data: group });
+ } catch (error) {
+ logger.error('❌ Failed to get account group:', error);
+ res.status(500).json({ error: error.message });
+ }
+});
+
+// 更新分组
+router.put('/account-groups/:groupId', authenticateAdmin, async (req, res) => {
+ try {
+ const { groupId } = req.params;
+ const updates = req.body;
+
+ const updatedGroup = await accountGroupService.updateGroup(groupId, updates);
+ res.json({ success: true, data: updatedGroup });
+ } catch (error) {
+ logger.error('❌ Failed to update account group:', error);
+ res.status(400).json({ error: error.message });
+ }
+});
+
+// 删除分组
+router.delete('/account-groups/:groupId', authenticateAdmin, async (req, res) => {
+ try {
+ const { groupId } = req.params;
+ await accountGroupService.deleteGroup(groupId);
+ res.json({ success: true, message: '分组删除成功' });
+ } catch (error) {
+ logger.error('❌ Failed to delete account group:', error);
+ res.status(400).json({ error: error.message });
+ }
+});
+
+// 获取分组成员
+router.get('/account-groups/:groupId/members', authenticateAdmin, async (req, res) => {
+ try {
+ const { groupId } = req.params;
+ const memberIds = await accountGroupService.getGroupMembers(groupId);
+
+ // 获取成员详细信息
+ const members = [];
+ for (const memberId of memberIds) {
+ // 尝试从不同的服务获取账户信息
+ let account = null;
+
+ // 先尝试Claude OAuth账户
+ account = await claudeAccountService.getAccount(memberId);
+
+ // 如果找不到,尝试Claude Console账户
+ if (!account) {
+ account = await claudeConsoleAccountService.getAccount(memberId);
+ }
+
+ // 如果还找不到,尝试Gemini账户
+ if (!account) {
+ account = await geminiAccountService.getAccount(memberId);
+ }
+
+ if (account) {
+ members.push(account);
+ }
+ }
+
+ res.json({ success: true, data: members });
+ } catch (error) {
+ logger.error('❌ Failed to get group members:', error);
+ res.status(500).json({ error: error.message });
+ }
+});
+
// 🏢 Claude 账户管理
// 生成OAuth授权URL
@@ -863,7 +976,8 @@ router.post('/claude-accounts', authenticateAdmin, async (req, res) => {
claudeAiOauth,
proxy,
accountType,
- priority
+ priority,
+ groupId
} = req.body;
if (!name) {
@@ -871,8 +985,13 @@ router.post('/claude-accounts', authenticateAdmin, async (req, res) => {
}
// 验证accountType的有效性
- if (accountType && !['shared', 'dedicated'].includes(accountType)) {
- return res.status(400).json({ error: 'Invalid account type. Must be "shared" or "dedicated"' });
+ if (accountType && !['shared', 'dedicated', 'group'].includes(accountType)) {
+ return res.status(400).json({ error: 'Invalid account type. Must be "shared", "dedicated" or "group"' });
+ }
+
+ // 如果是分组类型,验证groupId
+ if (accountType === 'group' && !groupId) {
+ return res.status(400).json({ error: 'Group ID is required for group type accounts' });
}
// 验证priority的有效性
@@ -892,6 +1011,11 @@ router.post('/claude-accounts', authenticateAdmin, async (req, res) => {
priority: priority || 50 // 默认优先级为50
});
+ // 如果是分组类型,将账户添加到分组
+ if (accountType === 'group' && groupId) {
+ await accountGroupService.addAccountToGroup(newAccount.id, groupId, newAccount.platform);
+ }
+
logger.success(`🏢 Admin created new Claude account: ${name} (${accountType || 'shared'})`);
res.json({ success: true, data: newAccount });
} catch (error) {
@@ -911,6 +1035,39 @@ router.put('/claude-accounts/:accountId', authenticateAdmin, async (req, res) =>
return res.status(400).json({ error: 'Priority must be a number between 1 and 100' });
}
+ // 验证accountType的有效性
+ if (updates.accountType && !['shared', 'dedicated', 'group'].includes(updates.accountType)) {
+ return res.status(400).json({ error: 'Invalid account type. Must be "shared", "dedicated" or "group"' });
+ }
+
+ // 如果更新为分组类型,验证groupId
+ if (updates.accountType === 'group' && !updates.groupId) {
+ return res.status(400).json({ error: 'Group ID is required for group type accounts' });
+ }
+
+ // 获取账户当前信息以处理分组变更
+ const currentAccount = await claudeAccountService.getAccount(accountId);
+ if (!currentAccount) {
+ return res.status(404).json({ error: 'Account not found' });
+ }
+
+ // 处理分组的变更
+ if (updates.accountType !== undefined) {
+ // 如果之前是分组类型,需要从原分组中移除
+ if (currentAccount.accountType === 'group') {
+ const oldGroup = await accountGroupService.getAccountGroup(accountId);
+ if (oldGroup) {
+ await accountGroupService.removeAccountFromGroup(accountId, oldGroup.id);
+ }
+ }
+
+ // 如果新类型是分组,添加到新分组
+ if (updates.accountType === 'group' && updates.groupId) {
+ // 从路由知道这是 Claude OAuth 账户,平台为 'claude'
+ await accountGroupService.addAccountToGroup(accountId, updates.groupId, 'claude');
+ }
+ }
+
await claudeAccountService.updateAccount(accountId, updates);
logger.success(`📝 Admin updated Claude account: ${accountId}`);
@@ -926,6 +1083,15 @@ router.delete('/claude-accounts/:accountId', authenticateAdmin, async (req, res)
try {
const { accountId } = req.params;
+ // 获取账户信息以检查是否在分组中
+ const account = await claudeAccountService.getAccount(accountId);
+ if (account && account.accountType === 'group') {
+ const group = await accountGroupService.getAccountGroup(accountId);
+ if (group) {
+ await accountGroupService.removeAccountFromGroup(accountId, group.id);
+ }
+ }
+
await claudeAccountService.deleteAccount(accountId);
logger.success(`🗑️ Admin deleted Claude account: ${accountId}`);
@@ -1026,7 +1192,8 @@ router.post('/claude-console-accounts', authenticateAdmin, async (req, res) => {
userAgent,
rateLimitDuration,
proxy,
- accountType
+ accountType,
+ groupId
} = req.body;
if (!name || !apiUrl || !apiKey) {
@@ -1039,8 +1206,13 @@ router.post('/claude-console-accounts', authenticateAdmin, async (req, res) => {
}
// 验证accountType的有效性
- if (accountType && !['shared', 'dedicated'].includes(accountType)) {
- return res.status(400).json({ error: 'Invalid account type. Must be "shared" or "dedicated"' });
+ if (accountType && !['shared', 'dedicated', 'group'].includes(accountType)) {
+ return res.status(400).json({ error: 'Invalid account type. Must be "shared", "dedicated" or "group"' });
+ }
+
+ // 如果是分组类型,验证groupId
+ if (accountType === 'group' && !groupId) {
+ return res.status(400).json({ error: 'Group ID is required for group type accounts' });
}
const newAccount = await claudeConsoleAccountService.createAccount({
@@ -1056,6 +1228,11 @@ router.post('/claude-console-accounts', authenticateAdmin, async (req, res) => {
accountType: accountType || 'shared'
});
+ // 如果是分组类型,将账户添加到分组
+ if (accountType === 'group' && groupId) {
+ await accountGroupService.addAccountToGroup(newAccount.id, groupId, 'claude');
+ }
+
logger.success(`🎮 Admin created Claude Console account: ${name}`);
res.json({ success: true, data: newAccount });
} catch (error) {
@@ -1263,8 +1440,23 @@ router.post('/gemini-accounts', authenticateAdmin, async (req, res) => {
return res.status(400).json({ error: 'Account name is required' });
}
+ // 验证accountType的有效性
+ if (accountData.accountType && !['shared', 'dedicated', 'group'].includes(accountData.accountType)) {
+ return res.status(400).json({ error: 'Invalid account type. Must be "shared", "dedicated" or "group"' });
+ }
+
+ // 如果是分组类型,验证groupId
+ if (accountData.accountType === 'group' && !accountData.groupId) {
+ return res.status(400).json({ error: 'Group ID is required for group type accounts' });
+ }
+
const newAccount = await geminiAccountService.createAccount(accountData);
+ // 如果是分组类型,将账户添加到分组
+ if (accountData.accountType === 'group' && accountData.groupId) {
+ await accountGroupService.addAccountToGroup(newAccount.id, accountData.groupId, 'gemini');
+ }
+
logger.success(`🏢 Admin created new Gemini account: ${accountData.name}`);
res.json({ success: true, data: newAccount });
} catch (error) {
diff --git a/src/services/accountGroupService.js b/src/services/accountGroupService.js
new file mode 100644
index 00000000..979bb78d
--- /dev/null
+++ b/src/services/accountGroupService.js
@@ -0,0 +1,351 @@
+const { v4: uuidv4 } = require('uuid');
+const logger = require('../utils/logger');
+const redis = require('../models/redis');
+
+class AccountGroupService {
+ constructor() {
+ this.GROUPS_KEY = 'account_groups';
+ this.GROUP_PREFIX = 'account_group:';
+ this.GROUP_MEMBERS_PREFIX = 'account_group_members:';
+ }
+
+ /**
+ * 创建账户分组
+ * @param {Object} groupData - 分组数据
+ * @param {string} groupData.name - 分组名称
+ * @param {string} groupData.platform - 平台类型 (claude/gemini)
+ * @param {string} groupData.description - 分组描述
+ * @returns {Object} 创建的分组
+ */
+ async createGroup(groupData) {
+ try {
+ const { name, platform, description = '' } = groupData;
+
+ // 验证必填字段
+ if (!name || !platform) {
+ throw new Error('分组名称和平台类型为必填项');
+ }
+
+ // 验证平台类型
+ if (!['claude', 'gemini'].includes(platform)) {
+ throw new Error('平台类型必须是 claude 或 gemini');
+ }
+
+ const client = redis.getClientSafe();
+ const groupId = uuidv4();
+ const now = new Date().toISOString();
+
+ const group = {
+ id: groupId,
+ name,
+ platform,
+ description,
+ createdAt: now,
+ updatedAt: now
+ };
+
+ // 保存分组数据
+ await client.hmset(`${this.GROUP_PREFIX}${groupId}`, group);
+
+ // 添加到分组集合
+ await client.sadd(this.GROUPS_KEY, groupId);
+
+ logger.success(`✅ 创建账户分组成功: ${name} (${platform})`);
+
+ return group;
+ } catch (error) {
+ logger.error('❌ 创建账户分组失败:', error);
+ throw error;
+ }
+ }
+
+ /**
+ * 更新分组信息
+ * @param {string} groupId - 分组ID
+ * @param {Object} updates - 更新的字段
+ * @returns {Object} 更新后的分组
+ */
+ async updateGroup(groupId, updates) {
+ try {
+ const client = redis.getClientSafe();
+ const groupKey = `${this.GROUP_PREFIX}${groupId}`;
+
+ // 检查分组是否存在
+ const exists = await client.exists(groupKey);
+ if (!exists) {
+ throw new Error('分组不存在');
+ }
+
+ // 获取现有分组数据
+ const existingGroup = await client.hgetall(groupKey);
+
+ // 不允许修改平台类型
+ if (updates.platform && updates.platform !== existingGroup.platform) {
+ throw new Error('不能修改分组的平台类型');
+ }
+
+ // 准备更新数据
+ const updateData = {
+ ...updates,
+ updatedAt: new Date().toISOString()
+ };
+
+ // 移除不允许修改的字段
+ delete updateData.id;
+ delete updateData.platform;
+ delete updateData.createdAt;
+
+ // 更新分组
+ await client.hmset(groupKey, updateData);
+
+ // 返回更新后的完整数据
+ const updatedGroup = await client.hgetall(groupKey);
+
+ logger.success(`✅ 更新账户分组成功: ${updatedGroup.name}`);
+
+ return updatedGroup;
+ } catch (error) {
+ logger.error('❌ 更新账户分组失败:', error);
+ throw error;
+ }
+ }
+
+ /**
+ * 删除分组
+ * @param {string} groupId - 分组ID
+ */
+ async deleteGroup(groupId) {
+ try {
+ const client = redis.getClientSafe();
+
+ // 检查分组是否存在
+ const group = await this.getGroup(groupId);
+ if (!group) {
+ throw new Error('分组不存在');
+ }
+
+ // 检查分组是否为空
+ const members = await this.getGroupMembers(groupId);
+ if (members.length > 0) {
+ throw new Error('分组内还有账户,无法删除');
+ }
+
+ // 检查是否有API Key绑定此分组
+ const boundApiKeys = await this.getApiKeysUsingGroup(groupId);
+ if (boundApiKeys.length > 0) {
+ throw new Error('还有API Key使用此分组,无法删除');
+ }
+
+ // 删除分组数据
+ await client.del(`${this.GROUP_PREFIX}${groupId}`);
+ await client.del(`${this.GROUP_MEMBERS_PREFIX}${groupId}`);
+
+ // 从分组集合中移除
+ await client.srem(this.GROUPS_KEY, groupId);
+
+ logger.success(`✅ 删除账户分组成功: ${group.name}`);
+ } catch (error) {
+ logger.error('❌ 删除账户分组失败:', error);
+ throw error;
+ }
+ }
+
+ /**
+ * 获取分组详情
+ * @param {string} groupId - 分组ID
+ * @returns {Object|null} 分组信息
+ */
+ async getGroup(groupId) {
+ try {
+ const client = redis.getClientSafe();
+ const groupData = await client.hgetall(`${this.GROUP_PREFIX}${groupId}`);
+
+ if (!groupData || Object.keys(groupData).length === 0) {
+ return null;
+ }
+
+ // 获取成员数量
+ const memberCount = await client.scard(`${this.GROUP_MEMBERS_PREFIX}${groupId}`);
+
+ return {
+ ...groupData,
+ memberCount: memberCount || 0
+ };
+ } catch (error) {
+ logger.error('❌ 获取分组详情失败:', error);
+ throw error;
+ }
+ }
+
+ /**
+ * 获取所有分组
+ * @param {string} platform - 平台筛选 (可选)
+ * @returns {Array} 分组列表
+ */
+ async getAllGroups(platform = null) {
+ try {
+ const client = redis.getClientSafe();
+ const groupIds = await client.smembers(this.GROUPS_KEY);
+
+ const groups = [];
+ for (const groupId of groupIds) {
+ const group = await this.getGroup(groupId);
+ if (group) {
+ // 如果指定了平台,进行筛选
+ if (!platform || group.platform === platform) {
+ groups.push(group);
+ }
+ }
+ }
+
+ // 按创建时间倒序排序
+ groups.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
+
+ return groups;
+ } catch (error) {
+ logger.error('❌ 获取分组列表失败:', error);
+ throw error;
+ }
+ }
+
+ /**
+ * 添加账户到分组
+ * @param {string} accountId - 账户ID
+ * @param {string} groupId - 分组ID
+ * @param {string} accountPlatform - 账户平台
+ */
+ async addAccountToGroup(accountId, groupId, accountPlatform) {
+ try {
+ const client = redis.getClientSafe();
+
+ // 获取分组信息
+ const group = await this.getGroup(groupId);
+ if (!group) {
+ throw new Error('分组不存在');
+ }
+
+ // 验证平台一致性 (Claude和Claude Console视为同一平台)
+ const normalizedAccountPlatform = accountPlatform === 'claude-console' ? 'claude' : accountPlatform;
+ if (normalizedAccountPlatform !== group.platform) {
+ throw new Error('账户平台与分组平台不匹配');
+ }
+
+ // 添加到分组成员集合
+ await client.sadd(`${this.GROUP_MEMBERS_PREFIX}${groupId}`, accountId);
+
+ logger.success(`✅ 添加账户到分组成功: ${accountId} -> ${group.name}`);
+ } catch (error) {
+ logger.error('❌ 添加账户到分组失败:', error);
+ throw error;
+ }
+ }
+
+ /**
+ * 从分组移除账户
+ * @param {string} accountId - 账户ID
+ * @param {string} groupId - 分组ID
+ */
+ async removeAccountFromGroup(accountId, groupId) {
+ try {
+ const client = redis.getClientSafe();
+
+ // 从分组成员集合中移除
+ await client.srem(`${this.GROUP_MEMBERS_PREFIX}${groupId}`, accountId);
+
+ logger.success(`✅ 从分组移除账户成功: ${accountId}`);
+ } catch (error) {
+ logger.error('❌ 从分组移除账户失败:', error);
+ throw error;
+ }
+ }
+
+ /**
+ * 获取分组成员
+ * @param {string} groupId - 分组ID
+ * @returns {Array} 成员ID列表
+ */
+ async getGroupMembers(groupId) {
+ try {
+ const client = redis.getClientSafe();
+ const members = await client.smembers(`${this.GROUP_MEMBERS_PREFIX}${groupId}`);
+ return members || [];
+ } catch (error) {
+ logger.error('❌ 获取分组成员失败:', error);
+ throw error;
+ }
+ }
+
+ /**
+ * 检查分组是否为空
+ * @param {string} groupId - 分组ID
+ * @returns {boolean} 是否为空
+ */
+ async isGroupEmpty(groupId) {
+ try {
+ const members = await this.getGroupMembers(groupId);
+ return members.length === 0;
+ } catch (error) {
+ logger.error('❌ 检查分组是否为空失败:', error);
+ throw error;
+ }
+ }
+
+ /**
+ * 获取使用指定分组的API Key列表
+ * @param {string} groupId - 分组ID
+ * @returns {Array} API Key列表
+ */
+ async getApiKeysUsingGroup(groupId) {
+ try {
+ const client = redis.getClientSafe();
+ const groupKey = `group:${groupId}`;
+
+ // 获取所有API Key
+ const apiKeyIds = await client.smembers('api_keys');
+ const boundApiKeys = [];
+
+ for (const keyId of apiKeyIds) {
+ const keyData = await client.hgetall(`api_key:${keyId}`);
+ if (keyData &&
+ (keyData.claudeAccountId === groupKey ||
+ keyData.geminiAccountId === groupKey)) {
+ boundApiKeys.push({
+ id: keyId,
+ name: keyData.name
+ });
+ }
+ }
+
+ return boundApiKeys;
+ } catch (error) {
+ logger.error('❌ 获取使用分组的API Key失败:', error);
+ throw error;
+ }
+ }
+
+ /**
+ * 根据账户ID获取其所属的分组
+ * @param {string} accountId - 账户ID
+ * @returns {Object|null} 分组信息
+ */
+ async getAccountGroup(accountId) {
+ try {
+ const client = redis.getClientSafe();
+ const allGroupIds = await client.smembers(this.GROUPS_KEY);
+
+ for (const groupId of allGroupIds) {
+ const isMember = await client.sismember(`${this.GROUP_MEMBERS_PREFIX}${groupId}`, accountId);
+ if (isMember) {
+ return await this.getGroup(groupId);
+ }
+ }
+
+ return null;
+ } catch (error) {
+ logger.error('❌ 获取账户所属分组失败:', error);
+ throw error;
+ }
+ }
+}
+
+module.exports = new AccountGroupService();
\ No newline at end of file
diff --git a/src/services/claudeAccountService.js b/src/services/claudeAccountService.js
index 5d849058..8a479748 100644
--- a/src/services/claudeAccountService.js
+++ b/src/services/claudeAccountService.js
@@ -68,7 +68,7 @@ class ClaudeAccountService {
lastRefreshAt: '',
status: 'active', // 有OAuth数据的账户直接设为active
errorMessage: '',
- schedulable: schedulable.toString() // 是否可被调度
+ schedulable: schedulable.toString(), // 是否可被调度
};
} else {
// 兼容旧格式
@@ -91,7 +91,7 @@ class ClaudeAccountService {
lastRefreshAt: '',
status: 'created', // created, active, expired, error
errorMessage: '',
- schedulable: schedulable.toString() // 是否可被调度
+ schedulable: schedulable.toString(), // 是否可被调度
};
}
@@ -233,6 +233,23 @@ class ClaudeAccountService {
}
}
+ // 🔍 获取账户信息
+ async getAccount(accountId) {
+ try {
+ const accountData = await redis.getClaudeAccount(accountId);
+
+ if (!accountData || Object.keys(accountData).length === 0) {
+ return null;
+ }
+
+
+ return accountData;
+ } catch (error) {
+ logger.error('❌ Failed to get Claude account:', error);
+ return null;
+ }
+ }
+
// 🎯 获取有效的访问token
async getValidAccessToken(accountId) {
try {
diff --git a/src/services/unifiedClaudeScheduler.js b/src/services/unifiedClaudeScheduler.js
index 4d5634e6..e86d98f0 100644
--- a/src/services/unifiedClaudeScheduler.js
+++ b/src/services/unifiedClaudeScheduler.js
@@ -1,5 +1,6 @@
const claudeAccountService = require('./claudeAccountService');
const claudeConsoleAccountService = require('./claudeConsoleAccountService');
+const accountGroupService = require('./accountGroupService');
const redis = require('../models/redis');
const logger = require('../utils/logger');
@@ -11,9 +12,16 @@ class UnifiedClaudeScheduler {
// 🎯 统一调度Claude账号(官方和Console)
async selectAccountForApiKey(apiKeyData, sessionHash = null, requestedModel = null) {
try {
- // 如果API Key绑定了专属账户,优先使用
- // 1. 检查Claude OAuth账户绑定
+ // 如果API Key绑定了专属账户或分组,优先使用
if (apiKeyData.claudeAccountId) {
+ // 检查是否是分组
+ if (apiKeyData.claudeAccountId.startsWith('group:')) {
+ const groupId = apiKeyData.claudeAccountId.replace('group:', '');
+ logger.info(`🎯 API key ${apiKeyData.name} is bound to group ${groupId}, selecting from group`);
+ return await this.selectAccountFromGroup(groupId, sessionHash, requestedModel, apiKeyData);
+ }
+
+ // 普通专属账户
const boundAccount = await redis.getClaudeAccount(apiKeyData.claudeAccountId);
if (boundAccount && boundAccount.isActive === 'true' && boundAccount.status !== 'error') {
logger.info(`🎯 Using bound dedicated Claude OAuth account: ${boundAccount.name} (${apiKeyData.claudeAccountId}) for API key ${apiKeyData.name}`);
@@ -360,6 +368,132 @@ class UnifiedClaudeScheduler {
throw error;
}
}
+
+ // 👥 从分组中选择账户
+ async selectAccountFromGroup(groupId, sessionHash = null, requestedModel = null, apiKeyData = null) {
+ try {
+ // 获取分组信息
+ const group = await accountGroupService.getGroup(groupId);
+ if (!group) {
+ throw new Error(`Group ${groupId} not found`);
+ }
+
+ logger.info(`👥 Selecting account from group: ${group.name} (${group.platform})`);
+
+ // 如果有会话哈希,检查是否有已映射的账户
+ if (sessionHash) {
+ const mappedAccount = await this._getSessionMapping(sessionHash);
+ if (mappedAccount) {
+ // 验证映射的账户是否属于这个分组
+ const memberIds = await accountGroupService.getGroupMembers(groupId);
+ if (memberIds.includes(mappedAccount.accountId)) {
+ const isAvailable = await this._isAccountAvailable(mappedAccount.accountId, mappedAccount.accountType);
+ if (isAvailable) {
+ logger.info(`🎯 Using sticky session account from group: ${mappedAccount.accountId} (${mappedAccount.accountType}) for session ${sessionHash}`);
+ return mappedAccount;
+ }
+ }
+ // 如果映射的账户不可用或不在分组中,删除映射
+ await this._deleteSessionMapping(sessionHash);
+ }
+ }
+
+ // 获取分组内的所有账户
+ const memberIds = await accountGroupService.getGroupMembers(groupId);
+ if (memberIds.length === 0) {
+ throw new Error(`Group ${group.name} has no members`);
+ }
+
+ const availableAccounts = [];
+
+ // 获取所有成员账户的详细信息
+ for (const memberId of memberIds) {
+ let account = null;
+ let accountType = null;
+
+ // 根据平台类型获取账户
+ if (group.platform === 'claude') {
+ // 先尝试官方账户
+ account = await redis.getClaudeAccount(memberId);
+ if (account) {
+ accountType = 'claude-official';
+ } else {
+ // 尝试Console账户
+ account = await claudeConsoleAccountService.getAccount(memberId);
+ if (account) {
+ accountType = 'claude-console';
+ }
+ }
+ } else if (group.platform === 'gemini') {
+ // Gemini暂时不支持,预留接口
+ logger.warn(`⚠️ Gemini group scheduling not yet implemented`);
+ continue;
+ }
+
+ if (!account) {
+ logger.warn(`⚠️ Account ${memberId} not found in group ${group.name}`);
+ continue;
+ }
+
+ // 检查账户是否可用
+ const isActive = accountType === 'claude-official'
+ ? account.isActive === 'true'
+ : account.isActive === true;
+
+ const status = accountType === 'claude-official'
+ ? account.status !== 'error' && account.status !== 'blocked'
+ : account.status === 'active';
+
+ if (isActive && status && account.schedulable !== false) {
+ // 检查模型支持(Console账户)
+ if (accountType === 'claude-console' && requestedModel && account.supportedModels && account.supportedModels.length > 0) {
+ if (!account.supportedModels.includes(requestedModel)) {
+ logger.info(`🚫 Account ${account.name} in group does not support model ${requestedModel}`);
+ continue;
+ }
+ }
+
+ // 检查是否被限流
+ const isRateLimited = await this.isAccountRateLimited(account.id, accountType);
+ if (!isRateLimited) {
+ availableAccounts.push({
+ ...account,
+ accountId: account.id,
+ accountType: accountType,
+ priority: parseInt(account.priority) || 50,
+ lastUsedAt: account.lastUsedAt || '0'
+ });
+ }
+ }
+ }
+
+ if (availableAccounts.length === 0) {
+ throw new Error(`No available accounts in group ${group.name}`);
+ }
+
+ // 使用现有的优先级排序逻辑
+ const sortedAccounts = this._sortAccountsByPriority(availableAccounts);
+
+ // 选择第一个账户
+ const selectedAccount = sortedAccounts[0];
+
+ // 如果有会话哈希,建立新的映射
+ if (sessionHash) {
+ await this._setSessionMapping(sessionHash, selectedAccount.accountId, selectedAccount.accountType);
+ logger.info(`🎯 Created new sticky session mapping in group: ${selectedAccount.name} (${selectedAccount.accountId}, ${selectedAccount.accountType}) for session ${sessionHash}`);
+ }
+
+ logger.info(`🎯 Selected account from group ${group.name}: ${selectedAccount.name} (${selectedAccount.accountId}, ${selectedAccount.accountType}) with priority ${selectedAccount.priority}`);
+
+ return {
+ accountId: selectedAccount.accountId,
+ accountType: selectedAccount.accountType
+ };
+ } catch (error) {
+ logger.error(`❌ Failed to select account from group ${groupId}:`, error);
+ throw error;
+ }
+ }
}
module.exports = new UnifiedClaudeScheduler();
\ No newline at end of file
diff --git a/web/admin-spa/src/components/accounts/AccountForm.vue b/web/admin-spa/src/components/accounts/AccountForm.vue
index cf696d59..6bc82c6b 100644
--- a/web/admin-spa/src/components/accounts/AccountForm.vue
+++ b/web/admin-spa/src/components/accounts/AccountForm.vue
@@ -159,12 +159,50 @@
>
专属账户
+
- 共享账户:供所有API Key使用;专属账户:仅供特定API Key使用
+ 共享账户:供所有API Key使用;专属账户:仅供特定API Key使用;分组调度:加入分组供分组内调度
+
+
+
+
+
+
+
+
+
@@ -555,12 +593,50 @@
>
专属账户
+
- 共享账户:供所有API Key使用;专属账户:仅供特定API Key使用
+ 共享账户:供所有API Key使用;专属账户:仅供特定API Key使用;分组调度:加入分组供分组内调度
+
+
+
+
+
+
+
+
+
@@ -813,17 +889,26 @@
@confirm="handleConfirm"
@cancel="handleCancel"
/>
+
+
+
\ No newline at end of file
diff --git a/web/admin-spa/src/components/apikeys/CreateApiKeyModal.vue b/web/admin-spa/src/components/apikeys/CreateApiKeyModal.vue
index 1873fa24..2a0e35a2 100644
--- a/web/admin-spa/src/components/apikeys/CreateApiKeyModal.vue
+++ b/web/admin-spa/src/components/apikeys/CreateApiKeyModal.vue
@@ -433,6 +433,18 @@
+
+
@@ -650,7 +679,7 @@ const clientsStore = useClientsStore()
const apiKeysStore = useApiKeysStore()
const loading = ref(false)
const accountsLoading = ref(false)
-const localAccounts = ref({ claude: [], gemini: [] })
+const localAccounts = ref({ claude: [], gemini: [], claudeGroups: [], geminiGroups: [] })
// 表单验证状态
const errors = ref({
@@ -702,7 +731,9 @@ onMounted(async () => {
if (props.accounts) {
localAccounts.value = {
claude: props.accounts.claude || [],
- gemini: props.accounts.gemini || []
+ gemini: props.accounts.gemini || [],
+ claudeGroups: props.accounts.claudeGroups || [],
+ geminiGroups: props.accounts.geminiGroups || []
}
}
})
@@ -711,10 +742,11 @@ onMounted(async () => {
const refreshAccounts = async () => {
accountsLoading.value = true
try {
- const [claudeData, claudeConsoleData, geminiData] = await Promise.all([
+ const [claudeData, claudeConsoleData, geminiData, groupsData] = await Promise.all([
apiClient.get('/admin/claude-accounts'),
apiClient.get('/admin/claude-console-accounts'),
- apiClient.get('/admin/gemini-accounts')
+ apiClient.get('/admin/gemini-accounts'),
+ apiClient.get('/admin/account-groups')
])
// 合并Claude OAuth账户和Claude Console账户
@@ -749,6 +781,13 @@ const refreshAccounts = async () => {
}))
}
+ // 处理分组数据
+ if (groupsData.success) {
+ const allGroups = groupsData.data || []
+ localAccounts.value.claudeGroups = allGroups.filter(g => g.platform === 'claude')
+ localAccounts.value.geminiGroups = allGroups.filter(g => g.platform === 'gemini')
+ }
+
showToast('账号列表已刷新', 'success')
} catch (error) {
showToast('刷新账号列表失败', 'error')
diff --git a/web/admin-spa/src/components/apikeys/EditApiKeyModal.vue b/web/admin-spa/src/components/apikeys/EditApiKeyModal.vue
index 7119a896..9c9df1e1 100644
--- a/web/admin-spa/src/components/apikeys/EditApiKeyModal.vue
+++ b/web/admin-spa/src/components/apikeys/EditApiKeyModal.vue
@@ -302,6 +302,18 @@
+
+
@@ -526,7 +555,7 @@ const clientsStore = useClientsStore()
const apiKeysStore = useApiKeysStore()
const loading = ref(false)
const accountsLoading = ref(false)
-const localAccounts = ref({ claude: [], gemini: [] })
+const localAccounts = ref({ claude: [], gemini: [], claudeGroups: [], geminiGroups: [] })
// 支持的客户端列表
const supportedClients = ref([])
@@ -656,10 +685,11 @@ const updateApiKey = async () => {
const refreshAccounts = async () => {
accountsLoading.value = true
try {
- const [claudeData, claudeConsoleData, geminiData] = await Promise.all([
+ const [claudeData, claudeConsoleData, geminiData, groupsData] = await Promise.all([
apiClient.get('/admin/claude-accounts'),
apiClient.get('/admin/claude-console-accounts'),
- apiClient.get('/admin/gemini-accounts')
+ apiClient.get('/admin/gemini-accounts'),
+ apiClient.get('/admin/account-groups')
])
// 合并Claude OAuth账户和Claude Console账户
@@ -694,6 +724,13 @@ const refreshAccounts = async () => {
}))
}
+ // 处理分组数据
+ if (groupsData.success) {
+ const allGroups = groupsData.data || []
+ localAccounts.value.claudeGroups = allGroups.filter(g => g.platform === 'claude')
+ localAccounts.value.geminiGroups = allGroups.filter(g => g.platform === 'gemini')
+ }
+
showToast('账号列表已刷新', 'success')
} catch (error) {
showToast('刷新账号列表失败', 'error')
@@ -712,7 +749,9 @@ onMounted(async () => {
if (props.accounts) {
localAccounts.value = {
claude: props.accounts.claude || [],
- gemini: props.accounts.gemini || []
+ gemini: props.accounts.gemini || [],
+ claudeGroups: props.accounts.claudeGroups || [],
+ geminiGroups: props.accounts.geminiGroups || []
}
}
diff --git a/web/admin-spa/src/router/index.js b/web/admin-spa/src/router/index.js
index 89c44e77..41cbf6d1 100644
--- a/web/admin-spa/src/router/index.js
+++ b/web/admin-spa/src/router/index.js
@@ -15,7 +15,19 @@ const ApiStatsView = () => import('@/views/ApiStatsView.vue')
const routes = [
{
path: '/',
- redirect: '/api-stats'
+ redirect: () => {
+ // 智能重定向:避免循环
+ const currentPath = window.location.pathname
+ const basePath = APP_CONFIG.basePath.replace(/\/$/, '') // 移除末尾斜杠
+
+ // 如果当前路径已经是 basePath 或 basePath/,重定向到 api-stats
+ if (currentPath === basePath || currentPath === basePath + '/') {
+ return '/api-stats'
+ }
+
+ // 否则保持默认重定向
+ return '/api-stats'
+ }
},
{
path: '/login',
@@ -88,6 +100,11 @@ const routes = [
component: SettingsView
}
]
+ },
+ // 捕获所有未匹配的路由
+ {
+ path: '/:pathMatch(.*)*',
+ redirect: '/api-stats'
}
]
@@ -103,10 +120,16 @@ router.beforeEach((to, from, next) => {
console.log('路由导航:', {
to: to.path,
from: from.path,
+ fullPath: to.fullPath,
requiresAuth: to.meta.requiresAuth,
isAuthenticated: authStore.isAuthenticated
})
+ // 防止重定向循环:如果已经在目标路径,直接放行
+ if (to.path === from.path && to.fullPath === from.fullPath) {
+ return next()
+ }
+
// API Stats 页面不需要认证,直接放行
if (to.path === '/api-stats' || to.path.startsWith('/api-stats')) {
next()
diff --git a/web/admin-spa/src/views/AccountsView.vue b/web/admin-spa/src/views/AccountsView.vue
index 526480c7..0a4f9c4c 100644
--- a/web/admin-spa/src/views/AccountsView.vue
+++ b/web/admin-spa/src/views/AccountsView.vue
@@ -11,27 +11,44 @@
-
+
+
+
+