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

- +
+ + +