diff --git a/.env.example b/.env.example index 5c3f9f0b..33a34d06 100644 --- a/.env.example +++ b/.env.example @@ -54,4 +54,7 @@ WEB_LOGO_URL=/assets/logo.png # 🛠️ 开发配置 DEBUG=false ENABLE_CORS=true -TRUST_PROXY=true \ No newline at end of file +TRUST_PROXY=true + +# 🔒 客户端限制(可选) +# ALLOW_CUSTOM_CLIENTS=false \ No newline at end of file diff --git a/.github/workflows/release-on-version.yml b/.github/workflows/auto-release-pipeline.yml similarity index 50% rename from .github/workflows/release-on-version.yml rename to .github/workflows/auto-release-pipeline.yml index 2436da3c..eda0ce71 100644 --- a/.github/workflows/release-on-version.yml +++ b/.github/workflows/auto-release-pipeline.yml @@ -1,21 +1,19 @@ -name: Release on Version Change +name: Auto Release Pipeline on: push: branches: - main - paths: - - 'VERSION' permissions: contents: write packages: write jobs: - check-and-release: + release-pipeline: runs-on: ubuntu-latest - # 只处理由GitHub Actions提交的VERSION更新 - if: github.event.pusher.name == 'github-actions[bot]' + # 跳过由GitHub Actions创建的提交,避免死循环 + if: github.event.pusher.name != 'github-actions[bot]' && !contains(github.event.head_commit.message, '[skip ci]') steps: - name: Checkout code uses: actions/checkout@v4 @@ -23,29 +21,103 @@ jobs: fetch-depth: 0 token: ${{ secrets.GITHUB_TOKEN }} - - name: Verify only VERSION changed - id: verify + - name: Check if version bump is needed + id: check run: | - # 获取最后一次提交变更的文件 - CHANGED_FILES=$(git diff --name-only HEAD~1..HEAD) - echo "Changed files: $CHANGED_FILES" + # 获取当前提交的文件变更 + CHANGED_FILES=$(git diff --name-only HEAD~1..HEAD 2>/dev/null || git diff --name-only $(git rev-list --max-parents=0 HEAD)..HEAD) + echo "Changed files:" + echo "$CHANGED_FILES" - # 检查是否只有VERSION文件 - if [ "$CHANGED_FILES" = "VERSION" ]; then - echo "Only VERSION file changed, proceeding with release" - echo "should_release=true" >> $GITHUB_OUTPUT + # 检查是否只有无关文件(.md, docs/, .github/等) + SIGNIFICANT_CHANGES=false + while IFS= read -r file; do + # 跳过空行 + [ -z "$file" ] && continue - # 读取新版本号 - NEW_VERSION=$(cat VERSION | tr -d '[:space:]') - echo "new_version=$NEW_VERSION" >> $GITHUB_OUTPUT - echo "new_tag=v$NEW_VERSION" >> $GITHUB_OUTPUT + # 检查是否是需要忽略的文件 + if [[ ! "$file" =~ \.(md|txt)$ ]] && + [[ ! "$file" =~ ^docs/ ]] && + [[ ! "$file" =~ ^\.github/ ]] && + [[ "$file" != "VERSION" ]] && + [[ "$file" != ".gitignore" ]] && + [[ "$file" != "LICENSE" ]]; then + echo "Found significant change in: $file" + SIGNIFICANT_CHANGES=true + break + fi + done <<< "$CHANGED_FILES" + + if [ "$SIGNIFICANT_CHANGES" = true ]; then + echo "Significant changes detected, version bump needed" + echo "needs_bump=true" >> $GITHUB_OUTPUT else - echo "Other files changed besides VERSION, skipping release" - echo "should_release=false" >> $GITHUB_OUTPUT + echo "No significant changes, skipping version bump" + echo "needs_bump=false" >> $GITHUB_OUTPUT fi + - name: Get current version + if: steps.check.outputs.needs_bump == 'true' + id: get_version + run: | + # 获取最新的tag版本 + LATEST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "v0.0.0") + echo "Latest tag: $LATEST_TAG" + TAG_VERSION=${LATEST_TAG#v} + + # 获取VERSION文件中的版本 + FILE_VERSION=$(cat VERSION | tr -d '[:space:]') + echo "VERSION file: $FILE_VERSION" + + # 比较tag版本和文件版本,取较大值 + function version_gt() { test "$(printf '%s\n' "$@" | sort -V | head -n 1)" != "$1"; } + + if version_gt "$FILE_VERSION" "$TAG_VERSION"; then + VERSION="$FILE_VERSION" + echo "Using VERSION file: $VERSION (newer than tag)" + else + VERSION="$TAG_VERSION" + echo "Using tag version: $VERSION (newer or equal to file)" + fi + + echo "Current version: $VERSION" + echo "current_version=$VERSION" >> $GITHUB_OUTPUT + + - name: Calculate next version + if: steps.check.outputs.needs_bump == 'true' + id: next_version + run: | + VERSION="${{ steps.get_version.outputs.current_version }}" + + # 分割版本号 + IFS='.' read -r -a version_parts <<< "$VERSION" + MAJOR="${version_parts[0]:-0}" + MINOR="${version_parts[1]:-0}" + PATCH="${version_parts[2]:-0}" + + # 默认递增patch版本 + NEW_PATCH=$((PATCH + 1)) + NEW_VERSION="${MAJOR}.${MINOR}.${NEW_PATCH}" + + echo "New version: $NEW_VERSION" + echo "new_version=$NEW_VERSION" >> $GITHUB_OUTPUT + echo "new_tag=v$NEW_VERSION" >> $GITHUB_OUTPUT + + - name: Update VERSION file + if: steps.check.outputs.needs_bump == 'true' + run: | + echo "${{ steps.next_version.outputs.new_version }}" > VERSION + + # 配置git + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + # 提交VERSION文件 - 添加 [skip ci] 以避免再次触发 + git add VERSION + git commit -m "chore: sync VERSION file with release ${{ steps.next_version.outputs.new_tag }} [skip ci]" + - name: Install git-cliff - if: steps.verify.outputs.should_release == 'true' + if: steps.check.outputs.needs_bump == 'true' run: | wget -q https://github.com/orhun/git-cliff/releases/download/v1.4.0/git-cliff-1.4.0-x86_64-unknown-linux-gnu.tar.gz tar -xzf git-cliff-1.4.0-x86_64-unknown-linux-gnu.tar.gz @@ -53,11 +125,11 @@ jobs: sudo mv git-cliff-1.4.0/git-cliff /usr/local/bin/ - name: Generate changelog - if: steps.verify.outputs.should_release == 'true' + if: steps.check.outputs.needs_bump == 'true' id: changelog run: | # 获取上一个tag以来的更新日志 - LATEST_TAG=$(git describe --tags --abbrev=0 --exclude="${{ steps.verify.outputs.new_tag }}" 2>/dev/null || echo "") + LATEST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "") if [ -n "$LATEST_TAG" ]; then # 排除VERSION文件的提交 CHANGELOG=$(git-cliff --config .github/cliff.toml $LATEST_TAG..HEAD --strip header | grep -v "bump version" | sed '/^$/d' || echo "- 代码优化和改进") @@ -69,25 +141,23 @@ jobs: echo "EOF" >> $GITHUB_OUTPUT - name: Create and push tag - if: steps.verify.outputs.should_release == 'true' + if: steps.check.outputs.needs_bump == 'true' run: | - NEW_TAG="${{ steps.verify.outputs.new_tag }}" - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" + NEW_TAG="${{ steps.next_version.outputs.new_tag }}" git tag -a "$NEW_TAG" -m "Release $NEW_TAG" - git push origin "$NEW_TAG" + git push origin HEAD:main "$NEW_TAG" - name: Create GitHub Release - if: steps.verify.outputs.should_release == 'true' + if: steps.check.outputs.needs_bump == 'true' uses: softprops/action-gh-release@v1 with: - tag_name: ${{ steps.verify.outputs.new_tag }} - name: Release ${{ steps.verify.outputs.new_version }} + tag_name: ${{ steps.next_version.outputs.new_tag }} + name: Release ${{ steps.next_version.outputs.new_version }} body: | ## 🐳 Docker 镜像 ```bash - docker pull ${{ secrets.DOCKERHUB_USERNAME || 'weishaw' }}/claude-relay-service:${{ steps.verify.outputs.new_tag }} + docker pull ${{ secrets.DOCKERHUB_USERNAME || 'weishaw' }}/claude-relay-service:${{ steps.next_version.outputs.new_tag }} docker pull ${{ secrets.DOCKERHUB_USERNAME || 'weishaw' }}/claude-relay-service:latest ``` @@ -104,15 +174,15 @@ jobs: # Docker构建步骤 - name: Set up QEMU - if: steps.verify.outputs.should_release == 'true' + if: steps.check.outputs.needs_bump == 'true' uses: docker/setup-qemu-action@v3 - name: Set up Docker Buildx - if: steps.verify.outputs.should_release == 'true' + if: steps.check.outputs.needs_bump == 'true' uses: docker/setup-buildx-action@v3 - name: Log in to Docker Hub - if: steps.verify.outputs.should_release == 'true' + if: steps.check.outputs.needs_bump == 'true' uses: docker/login-action@v3 with: registry: docker.io @@ -120,31 +190,31 @@ jobs: password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Build and push Docker image - if: steps.verify.outputs.should_release == 'true' + if: steps.check.outputs.needs_bump == 'true' uses: docker/build-push-action@v5 with: context: . platforms: linux/amd64,linux/arm64 push: true tags: | - ${{ secrets.DOCKERHUB_USERNAME }}/claude-relay-service:${{ steps.verify.outputs.new_tag }} + ${{ secrets.DOCKERHUB_USERNAME }}/claude-relay-service:${{ steps.next_version.outputs.new_tag }} ${{ secrets.DOCKERHUB_USERNAME }}/claude-relay-service:latest - ${{ secrets.DOCKERHUB_USERNAME }}/claude-relay-service:${{ steps.verify.outputs.new_version }} + ${{ secrets.DOCKERHUB_USERNAME }}/claude-relay-service:${{ steps.next_version.outputs.new_version }} labels: | - org.opencontainers.image.version=${{ steps.verify.outputs.new_version }} + org.opencontainers.image.version=${{ steps.next_version.outputs.new_version }} org.opencontainers.image.revision=${{ github.sha }} cache-from: type=gha cache-to: type=gha,mode=max - name: Send Telegram Notification - if: steps.verify.outputs.should_release == 'true' && env.TELEGRAM_BOT_TOKEN != '' && env.TELEGRAM_CHAT_ID != '' + if: steps.check.outputs.needs_bump == 'true' && env.TELEGRAM_BOT_TOKEN != '' && env.TELEGRAM_CHAT_ID != '' env: TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }} TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }} continue-on-error: true run: | - VERSION="${{ steps.verify.outputs.new_version }}" - TAG="${{ steps.verify.outputs.new_tag }}" + VERSION="${{ steps.next_version.outputs.new_version }}" + TAG="${{ steps.next_version.outputs.new_tag }}" REPO="${{ github.repository }}" # 获取更新内容并限制长度 diff --git a/.github/workflows/auto-version-bump.yml b/.github/workflows/auto-version-bump.yml deleted file mode 100644 index ea5a6834..00000000 --- a/.github/workflows/auto-version-bump.yml +++ /dev/null @@ -1,102 +0,0 @@ -name: Auto Version Bump - -on: - push: - branches: - - main - -permissions: - contents: write - -jobs: - version-bump: - runs-on: ubuntu-latest - # 跳过由GitHub Actions创建的提交,避免死循环 - if: github.event.pusher.name != 'github-actions[bot]' - steps: - - name: Checkout code - uses: actions/checkout@v4 - with: - fetch-depth: 0 - token: ${{ secrets.GITHUB_TOKEN }} - - - name: Check if version bump is needed - id: check - run: | - # 获取当前提交的文件变更 - CHANGED_FILES=$(git diff --name-only HEAD~1..HEAD 2>/dev/null || git diff --name-only $(git rev-list --max-parents=0 HEAD)..HEAD) - echo "Changed files:" - echo "$CHANGED_FILES" - - # 检查是否只有无关文件(.md, docs/, .github/等) - SIGNIFICANT_CHANGES=false - while IFS= read -r file; do - # 跳过空行 - [ -z "$file" ] && continue - - # 检查是否是需要忽略的文件 - if [[ ! "$file" =~ \.(md|txt)$ ]] && - [[ ! "$file" =~ ^docs/ ]] && - [[ ! "$file" =~ ^\.github/ ]] && - [[ "$file" != "VERSION" ]] && - [[ "$file" != ".gitignore" ]] && - [[ "$file" != "LICENSE" ]]; then - echo "Found significant change in: $file" - SIGNIFICANT_CHANGES=true - break - fi - done <<< "$CHANGED_FILES" - - if [ "$SIGNIFICANT_CHANGES" = true ]; then - echo "Significant changes detected, version bump needed" - echo "needs_bump=true" >> $GITHUB_OUTPUT - else - echo "No significant changes, skipping version bump" - echo "needs_bump=false" >> $GITHUB_OUTPUT - fi - - - name: Get current version - if: steps.check.outputs.needs_bump == 'true' - id: get_version - run: | - # 获取最新的tag版本 - LATEST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "v0.0.0") - echo "Latest tag: $LATEST_TAG" - - # 从tag中提取版本号 - VERSION=${LATEST_TAG#v} - echo "Current version: $VERSION" - echo "current_version=$VERSION" >> $GITHUB_OUTPUT - - - name: Calculate next version - if: steps.check.outputs.needs_bump == 'true' - id: next_version - run: | - VERSION="${{ steps.get_version.outputs.current_version }}" - - # 分割版本号 - IFS='.' read -r -a version_parts <<< "$VERSION" - MAJOR="${version_parts[0]:-0}" - MINOR="${version_parts[1]:-0}" - PATCH="${version_parts[2]:-0}" - - # 默认递增patch版本 - NEW_PATCH=$((PATCH + 1)) - NEW_VERSION="${MAJOR}.${MINOR}.${NEW_PATCH}" - - echo "New version: $NEW_VERSION" - echo "new_version=$NEW_VERSION" >> $GITHUB_OUTPUT - - - name: Update VERSION file - if: steps.check.outputs.needs_bump == 'true' - run: | - echo "${{ steps.next_version.outputs.new_version }}" > VERSION - - # 配置git - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - - # 提交VERSION文件 - git add VERSION - git commit -m "chore: bump version to ${{ steps.next_version.outputs.new_version }}" - git push origin main \ No newline at end of file diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml deleted file mode 100644 index 4d34f1a4..00000000 --- a/.github/workflows/docker-publish.yml +++ /dev/null @@ -1,101 +0,0 @@ -name: Docker Build & Push - -on: - push: - tags: - - 'v*' - workflow_dispatch: - -env: - REGISTRY: docker.io - IMAGE_NAME: ${{ secrets.DOCKERHUB_USERNAME }}/claude-relay-service - -jobs: - build: - runs-on: ubuntu-latest - permissions: - contents: read - packages: write - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Set up QEMU - uses: docker/setup-qemu-action@v3 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Log in to Docker Hub - if: github.event_name != 'pull_request' - uses: docker/login-action@v3 - with: - registry: ${{ env.REGISTRY }} - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - - - name: Extract metadata - id: meta - uses: docker/metadata-action@v5 - with: - images: ${{ env.IMAGE_NAME }} - tags: | - type=ref,event=branch - type=ref,event=pr - type=semver,pattern={{version}},priority=1000 - type=semver,pattern={{major}}.{{minor}} - type=semver,pattern={{major}} - type=sha,prefix=sha-,format=short - type=raw,value=latest,enable={{is_default_branch}} - - - name: Build and push Docker image - uses: docker/build-push-action@v5 - with: - context: . - platforms: linux/amd64,linux/arm64 - push: ${{ github.event_name != 'pull_request' }} - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} - cache-from: type=gha - cache-to: type=gha,mode=max - - test: - needs: build - runs-on: ubuntu-latest - if: github.event_name != 'pull_request' - permissions: - contents: read - security-events: write - - steps: - - name: Run Trivy vulnerability scanner - uses: aquasecurity/trivy-action@master - with: - image-ref: ${{ env.IMAGE_NAME }}:latest - format: 'sarif' - output: 'trivy-results.sarif' - - - name: Upload Trivy scan results to GitHub Security tab - uses: github/codeql-action/upload-sarif@v3 - if: always() - with: - sarif_file: 'trivy-results.sarif' - - update-description: - needs: build - runs-on: ubuntu-latest - if: github.event_name == 'push' && github.ref == 'refs/heads/main' - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Update Docker Hub Description - uses: peter-evans/dockerhub-description@v3 - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - repository: ${{ secrets.DOCKERHUB_USERNAME }}/claude-relay-service - readme-filepath: ./README.md - short-description: "Claude Code API Relay Service - 多账户管理的Claude API中转服务" \ No newline at end of file diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index 603fee45..00000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,56 +0,0 @@ -name: Create Release - -on: - push: - tags: - - 'v*' - -permissions: - contents: write - -jobs: - release: - runs-on: ubuntu-latest - steps: - - name: Checkout code - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Install git-cliff - run: | - wget -q https://github.com/orhun/git-cliff/releases/download/v1.4.0/git-cliff-1.4.0-x86_64-unknown-linux-gnu.tar.gz - tar -xzf git-cliff-1.4.0-x86_64-unknown-linux-gnu.tar.gz - chmod +x git-cliff-1.4.0/git-cliff - sudo mv git-cliff-1.4.0/git-cliff /usr/local/bin/ - - - name: Generate changelog - id: changelog - run: | - CHANGELOG=$(git-cliff --config .github/cliff.toml --latest --strip header) - echo "content<> $GITHUB_OUTPUT - echo "$CHANGELOG" >> $GITHUB_OUTPUT - echo "EOF" >> $GITHUB_OUTPUT - - - name: Create Release - uses: softprops/action-gh-release@v1 - with: - body: | - ## 🐳 Docker 镜像 - - ```bash - docker pull ${{ secrets.DOCKERHUB_USERNAME }}/claude-relay-service:${{ github.ref_name }} - docker pull ${{ secrets.DOCKERHUB_USERNAME }}/claude-relay-service:latest - ``` - - ## 📦 主要更新 - - ${{ steps.changelog.outputs.content }} - - ## 📋 完整更新日志 - - 查看 [所有版本](https://github.com/${{ github.repository }}/releases) - - draft: false - prerelease: false - generate_release_notes: true \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 78d022fd..339da465 100644 --- a/Dockerfile +++ b/Dockerfile @@ -36,9 +36,6 @@ RUN mkdir -p logs data temp # 🔧 预先创建配置文件 RUN if [ ! -f "/app/config/config.js" ] && [ -f "/app/config/config.example.js" ]; then \ cp /app/config/config.example.js /app/config/config.js; \ - fi && \ - if [ ! -f "/app/.env" ] && [ -f "/app/.env.example" ]; then \ - cp /app/.env.example /app/.env; \ fi # 🌐 暴露端口 diff --git a/README.md b/README.md index aeac3b68..850f971d 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ [![Node.js](https://img.shields.io/badge/Node.js-18+-green.svg)](https://nodejs.org/) [![Redis](https://img.shields.io/badge/Redis-6+-red.svg)](https://redis.io/) [![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/) -[![Docker Build](https://github.com/Wei-Shaw/claude-relay-service/actions/workflows/docker-publish.yml/badge.svg)](https://github.com/Wei-Shaw/claude-relay-service/actions/workflows/docker-publish.yml) +[![Docker Build](https://github.com/Wei-Shaw/claude-relay-service/actions/workflows/auto-release-pipeline.yml/badge.svg)](https://github.com/Wei-Shaw/claude-relay-service/actions/workflows/auto-release-pipeline.yml) [![Docker Pulls](https://img.shields.io/docker/pulls/weishaw/claude-relay-service)](https://hub.docker.com/r/weishaw/claude-relay-service) **🔐 自行搭建Claude API中转服务,支持多账户管理** @@ -106,7 +106,7 @@ - 🔄 **智能切换**: 账户出问题自动换下一个 - 🚀 **性能优化**: 连接池、缓存,减少延迟 - 📊 **监控面板**: Web界面查看所有数据 -- 🛡️ **安全控制**: 访问限制、速率控制 +- 🛡️ **安全控制**: 访问限制、速率控制、客户端限制 - 🌐 **代理支持**: 支持HTTP/SOCKS5代理 --- @@ -232,17 +232,31 @@ npm run service:status # 拉取镜像(支持 amd64 和 arm64) docker pull weishaw/claude-relay-service:latest -# 使用 docker run 运行 +# 使用 docker run 运行(注意设置必需的环境变量) docker run -d \ --name claude-relay \ -p 3000:3000 \ -v $(pwd)/data:/app/data \ -v $(pwd)/logs:/app/logs \ + -e JWT_SECRET=your-random-secret-key-at-least-32-chars \ + -e ENCRYPTION_KEY=your-32-character-encryption-key \ + -e REDIS_HOST=redis \ -e ADMIN_USERNAME=my_admin \ -e ADMIN_PASSWORD=my_secure_password \ weishaw/claude-relay-service:latest # 或使用 docker-compose(推荐) +# 创建 .env 文件用于 docker-compose 的环境变量: +cat > .env << 'EOF' +# 必填:安全密钥(请修改为随机值) +JWT_SECRET=your-random-secret-key-at-least-32-chars +ENCRYPTION_KEY=your-32-character-encryption-key + +# 可选:管理员凭据 +ADMIN_USERNAME=cr_admin +ADMIN_PASSWORD=your-secure-password +EOF + # 创建 docker-compose.yml 文件: cat > docker-compose.yml << 'EOF' version: '3.8' @@ -254,6 +268,8 @@ services: ports: - "3000:3000" environment: + - JWT_SECRET=${JWT_SECRET} + - ENCRYPTION_KEY=${ENCRYPTION_KEY} - REDIS_HOST=redis - ADMIN_USERNAME=${ADMIN_USERNAME:-} - ADMIN_PASSWORD=${ADMIN_PASSWORD:-} @@ -285,16 +301,21 @@ docker-compose up -d git clone https://github.com/Wei-Shaw//claude-relay-service.git cd claude-relay-service -# 2. 设置管理员账号密码(可选) -# 方式一:自动生成(查看容器日志获取) +# 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 -# 方式二:预设账号密码 -export ADMIN_USERNAME=cr_admin_custom -export ADMIN_PASSWORD=your-secure-password -docker-compose up -d - -# 3. 查看管理员凭据 +# 4. 查看管理员凭据 # 自动生成的情况下: docker logs claude-relay-service | grep "管理员" @@ -310,6 +331,19 @@ docker-compose.yml 已包含: - ✅ Redis数据库 - ✅ 健康检查 - ✅ 自动重启 +- ✅ 所有配置通过环境变量管理 + +### 环境变量说明 + +#### 必填项 +- `JWT_SECRET`: JWT密钥,至少32个字符 +- `ENCRYPTION_KEY`: 加密密钥,必须是32个字符 + +#### 可选项 +- `ADMIN_USERNAME`: 管理员用户名(不设置则自动生成) +- `ADMIN_PASSWORD`: 管理员密码(不设置则自动生成) +- `LOG_LEVEL`: 日志级别(默认:info) +- 更多配置项请参考 `.env.example` 文件 ### 管理员凭据获取方式 @@ -364,7 +398,11 @@ docker-compose.yml 已包含: 1. 点击「API Keys」标签 2. 点击「创建新Key」 3. 给Key起个名字,比如「张三的Key」 -4. 设置使用限制(可选) +4. 设置使用限制(可选): + - **速率限制**: 限制每个时间窗口的请求次数和Token使用量 + - **并发限制**: 限制同时处理的请求数 + - **模型限制**: 限制可访问的模型列表 + - **客户端限制**: 限制只允许特定客户端使用(如ClaudeCode、Gemini-CLI等) 5. 保存,记下生成的Key ### 4. 开始使用Claude code @@ -464,6 +502,63 @@ npm run service:status - 查看更新日志了解是否有破坏性变更 - 如果有数据库结构变更,会自动迁移 +--- + +## 🔒 客户端限制功能 + +### 功能说明 + +客户端限制功能允许你控制每个API Key可以被哪些客户端使用,通过User-Agent识别客户端,提高API的安全性。 + +### 使用方法 + +1. **在创建或编辑API Key时启用客户端限制**: + - 勾选"启用客户端限制" + - 选择允许的客户端(支持多选) + +2. **预定义客户端**: + - **ClaudeCode**: 官方Claude CLI(匹配 `claude-cli/x.x.x (external, cli)` 格式) + - **Gemini-CLI**: Gemini命令行工具(匹配 `GeminiCLI/vx.x.x (platform; arch)` 格式) + +3. **调试和诊断**: + - 系统会在日志中记录所有请求的User-Agent + - 客户端验证失败时会返回403错误并记录详细信息 + - 通过日志可以查看实际的User-Agent格式,方便配置自定义客户端 + +### 自定义客户端配置 + +如需添加自定义客户端,可以修改 `config/config.js` 文件: + +```javascript +clientRestrictions: { + predefinedClients: [ + // ... 现有客户端配置 + { + id: 'my_custom_client', + name: 'My Custom Client', + description: '我的自定义客户端', + userAgentPattern: /^MyClient\/[\d\.]+/i + } + ] +} +``` + +### 日志示例 + +认证成功时的日志: +``` +🔓 Authenticated request from key: 测试Key (key-id) in 5ms + User-Agent: "claude-cli/1.0.58 (external, cli)" +``` + +客户端限制检查日志: +``` +🔍 Checking client restriction for key: key-id (测试Key) + User-Agent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64)" + Allowed clients: claude_code, gemini_cli +🚫 Client restriction failed for key: key-id (测试Key) from 127.0.0.1, User-Agent: Mozilla/5.0... +``` + ### 常见问题处理 **Redis连不上?** diff --git a/VERSION b/VERSION index 5ed5faa5..2818446a 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.1.10 +1.1.40 diff --git a/cli/index.js b/cli/index.js index 78f6f959..586cf20b 100644 --- a/cli/index.js +++ b/cli/index.js @@ -4,7 +4,7 @@ const { Command } = require('commander'); const inquirer = require('inquirer'); const chalk = require('chalk'); const ora = require('ora'); -const Table = require('table').table; +const { table } = require('table'); const bcrypt = require('bcryptjs'); const crypto = require('crypto'); const fs = require('fs'); @@ -54,6 +54,43 @@ program }); +// 🔑 API Key 管理 +program + .command('keys') + .description('API Key 管理操作') + .action(async () => { + await initialize(); + + const { action } = await inquirer.prompt([{ + type: 'list', + name: 'action', + message: '请选择操作:', + choices: [ + { name: '📋 查看所有 API Keys', value: 'list' }, + { name: '🔧 修改 API Key 过期时间', value: 'update-expiry' }, + { name: '🔄 续期即将过期的 API Key', value: 'renew' }, + { name: '🗑️ 删除 API Key', value: 'delete' } + ] + }]); + + switch (action) { + case 'list': + await listApiKeys(); + break; + case 'update-expiry': + await updateApiKeyExpiry(); + break; + case 'renew': + await renewApiKeys(); + break; + case 'delete': + await deleteApiKey(); + break; + } + + await redis.disconnect(); + }); + // 📊 系统状态 program .command('status') @@ -201,6 +238,329 @@ async function createInitialAdmin() { +// API Key 管理功能 +async function listApiKeys() { + const spinner = ora('正在获取 API Keys...').start(); + + try { + const apiKeys = await apiKeyService.getAllApiKeys(); + spinner.succeed(`找到 ${apiKeys.length} 个 API Keys`); + + if (apiKeys.length === 0) { + console.log(styles.warning('没有找到任何 API Keys')); + return; + } + + const tableData = [ + ['名称', 'API Key', '状态', '过期时间', '使用量', 'Token限制'] + ]; + + apiKeys.forEach(key => { + const now = new Date(); + const expiresAt = key.expiresAt ? new Date(key.expiresAt) : null; + let expiryStatus = '永不过期'; + + if (expiresAt) { + if (expiresAt < now) { + expiryStatus = styles.error(`已过期 (${expiresAt.toLocaleDateString()})`); + } else { + const daysLeft = Math.ceil((expiresAt - now) / (1000 * 60 * 60 * 24)); + if (daysLeft <= 7) { + expiryStatus = styles.warning(`${daysLeft}天后过期 (${expiresAt.toLocaleDateString()})`); + } else { + expiryStatus = styles.success(`${expiresAt.toLocaleDateString()}`); + } + } + } + + tableData.push([ + key.name, + key.apiKey ? key.apiKey.substring(0, 20) + '...' : '-', + key.isActive ? '🟢 活跃' : '🔴 停用', + expiryStatus, + `${(key.usage?.total?.tokens || 0).toLocaleString()}`, + key.tokenLimit ? key.tokenLimit.toLocaleString() : '无限制' + ]); + }); + + console.log(styles.title('\n🔑 API Keys 列表:\n')); + console.log(table(tableData)); + + } catch (error) { + spinner.fail('获取 API Keys 失败'); + console.error(styles.error(error.message)); + } +} + +async function updateApiKeyExpiry() { + try { + // 获取所有 API Keys + const apiKeys = await apiKeyService.getAllApiKeys(); + + if (apiKeys.length === 0) { + console.log(styles.warning('没有找到任何 API Keys')); + return; + } + + // 选择要修改的 API Key + const { selectedKey } = await inquirer.prompt([{ + type: 'list', + name: 'selectedKey', + message: '选择要修改的 API Key:', + choices: apiKeys.map(key => ({ + name: `${key.name} (${key.apiKey?.substring(0, 20)}...) - ${key.expiresAt ? new Date(key.expiresAt).toLocaleDateString() : '永不过期'}`, + value: key + })) + }]); + + console.log(`\n当前 API Key: ${selectedKey.name}`); + console.log(`当前过期时间: ${selectedKey.expiresAt ? new Date(selectedKey.expiresAt).toLocaleString() : '永不过期'}`); + + // 选择新的过期时间 + const { expiryOption } = await inquirer.prompt([{ + type: 'list', + name: 'expiryOption', + message: '选择新的过期时间:', + choices: [ + { name: '⏰ 1分后(测试用)', value: '1m' }, + { name: '⏰ 1小时后(测试用)', value: '1h' }, + { name: '📅 1天后', value: '1d' }, + { name: '📅 7天后', value: '7d' }, + { name: '📅 30天后', value: '30d' }, + { name: '📅 90天后', value: '90d' }, + { name: '📅 365天后', value: '365d' }, + { name: '♾️ 永不过期', value: 'never' }, + { name: '🎯 自定义日期时间', value: 'custom' } + ] + }]); + + let newExpiresAt = null; + + if (expiryOption === 'never') { + newExpiresAt = null; + } else if (expiryOption === 'custom') { + const { customDate, customTime } = await inquirer.prompt([ + { + type: 'input', + name: 'customDate', + message: '输入日期 (YYYY-MM-DD):', + default: new Date().toISOString().split('T')[0], + validate: input => { + const date = new Date(input); + return !isNaN(date.getTime()) || '请输入有效的日期格式'; + } + }, + { + type: 'input', + name: 'customTime', + message: '输入时间 (HH:MM):', + default: '00:00', + validate: input => { + return /^\d{2}:\d{2}$/.test(input) || '请输入有效的时间格式 (HH:MM)'; + } + } + ]); + + newExpiresAt = new Date(`${customDate}T${customTime}:00`).toISOString(); + } else { + // 计算新的过期时间 + const now = new Date(); + const durations = { + '1m': 60 * 1000, + '1h': 60 * 60 * 1000, + '1d': 24 * 60 * 60 * 1000, + '7d': 7 * 24 * 60 * 60 * 1000, + '30d': 30 * 24 * 60 * 60 * 1000, + '90d': 90 * 24 * 60 * 60 * 1000, + '365d': 365 * 24 * 60 * 60 * 1000 + }; + + newExpiresAt = new Date(now.getTime() + durations[expiryOption]).toISOString(); + } + + // 确认修改 + const confirmMsg = newExpiresAt + ? `确认将过期时间修改为: ${new Date(newExpiresAt).toLocaleString()}?` + : '确认设置为永不过期?'; + + const { confirmed } = await inquirer.prompt([{ + type: 'confirm', + name: 'confirmed', + message: confirmMsg, + default: true + }]); + + if (!confirmed) { + console.log(styles.info('已取消修改')); + return; + } + + // 执行修改 + const spinner = ora('正在修改过期时间...').start(); + + try { + await apiKeyService.updateApiKey(selectedKey.id, { expiresAt: newExpiresAt }); + spinner.succeed('过期时间修改成功'); + + console.log(styles.success(`\n✅ API Key "${selectedKey.name}" 的过期时间已更新`)); + console.log(`新的过期时间: ${newExpiresAt ? new Date(newExpiresAt).toLocaleString() : '永不过期'}`); + + } catch (error) { + spinner.fail('修改失败'); + console.error(styles.error(error.message)); + } + + } catch (error) { + console.error(styles.error('操作失败:', error.message)); + } +} + +async function renewApiKeys() { + const spinner = ora('正在查找即将过期的 API Keys...').start(); + + try { + const apiKeys = await apiKeyService.getAllApiKeys(); + const now = new Date(); + const sevenDaysLater = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000); + + // 筛选即将过期的 Keys(7天内) + const expiringKeys = apiKeys.filter(key => { + if (!key.expiresAt) return false; + const expiresAt = new Date(key.expiresAt); + return expiresAt > now && expiresAt <= sevenDaysLater; + }); + + spinner.stop(); + + if (expiringKeys.length === 0) { + console.log(styles.info('没有即将过期的 API Keys(7天内)')); + return; + } + + console.log(styles.warning(`\n找到 ${expiringKeys.length} 个即将过期的 API Keys:\n`)); + + expiringKeys.forEach((key, index) => { + const daysLeft = Math.ceil((new Date(key.expiresAt) - now) / (1000 * 60 * 60 * 24)); + console.log(`${index + 1}. ${key.name} - ${daysLeft}天后过期 (${new Date(key.expiresAt).toLocaleDateString()})`); + }); + + const { renewOption } = await inquirer.prompt([{ + type: 'list', + name: 'renewOption', + message: '选择续期方式:', + choices: [ + { name: '📅 全部续期30天', value: 'all30' }, + { name: '📅 全部续期90天', value: 'all90' }, + { name: '🎯 逐个选择续期', value: 'individual' } + ] + }]); + + if (renewOption.startsWith('all')) { + const days = renewOption === 'all30' ? 30 : 90; + const renewSpinner = ora(`正在为所有 API Keys 续期 ${days} 天...`).start(); + + for (const key of expiringKeys) { + try { + const newExpiresAt = new Date(new Date(key.expiresAt).getTime() + days * 24 * 60 * 60 * 1000).toISOString(); + await apiKeyService.updateApiKey(key.id, { expiresAt: newExpiresAt }); + } catch (error) { + renewSpinner.fail(`续期 ${key.name} 失败: ${error.message}`); + } + } + + renewSpinner.succeed(`成功续期 ${expiringKeys.length} 个 API Keys`); + + } else { + // 逐个选择续期 + for (const key of expiringKeys) { + console.log(`\n处理: ${key.name}`); + + const { action } = await inquirer.prompt([{ + type: 'list', + name: 'action', + message: '选择操作:', + choices: [ + { name: '续期30天', value: '30' }, + { name: '续期90天', value: '90' }, + { name: '跳过', value: 'skip' } + ] + }]); + + if (action !== 'skip') { + const days = parseInt(action); + const newExpiresAt = new Date(new Date(key.expiresAt).getTime() + days * 24 * 60 * 60 * 1000).toISOString(); + + try { + await apiKeyService.updateApiKey(key.id, { expiresAt: newExpiresAt }); + console.log(styles.success(`✅ 已续期 ${days} 天`)); + } catch (error) { + console.log(styles.error(`❌ 续期失败: ${error.message}`)); + } + } + } + } + + } catch (error) { + spinner.fail('操作失败'); + console.error(styles.error(error.message)); + } +} + +async function deleteApiKey() { + try { + const apiKeys = await apiKeyService.getAllApiKeys(); + + if (apiKeys.length === 0) { + console.log(styles.warning('没有找到任何 API Keys')); + return; + } + + const { selectedKeys } = await inquirer.prompt([{ + type: 'checkbox', + name: 'selectedKeys', + message: '选择要删除的 API Keys (空格选择,回车确认):', + choices: apiKeys.map(key => ({ + name: `${key.name} (${key.apiKey?.substring(0, 20)}...)`, + value: key.id + })) + }]); + + if (selectedKeys.length === 0) { + console.log(styles.info('未选择任何 API Key')); + return; + } + + const { confirmed } = await inquirer.prompt([{ + type: 'confirm', + name: 'confirmed', + message: styles.warning(`确认删除 ${selectedKeys.length} 个 API Keys?`), + default: false + }]); + + if (!confirmed) { + console.log(styles.info('已取消删除')); + return; + } + + const spinner = ora('正在删除 API Keys...').start(); + let successCount = 0; + + for (const keyId of selectedKeys) { + try { + await apiKeyService.deleteApiKey(keyId); + successCount++; + } catch (error) { + spinner.fail(`删除失败: ${error.message}`); + } + } + + spinner.succeed(`成功删除 ${successCount}/${selectedKeys.length} 个 API Keys`); + + } catch (error) { + console.error(styles.error('删除失败:', error.message)); + } +} + async function listClaudeAccounts() { const spinner = ora('正在获取 Claude 账户...').start(); @@ -251,6 +611,7 @@ if (!process.argv.slice(2).length) { console.log(styles.title('🚀 Claude Relay Service CLI\n')); console.log('使用以下命令管理服务:\n'); console.log(' claude-relay-cli admin - 创建初始管理员账户'); + console.log(' claude-relay-cli keys - API Key 管理(查看/修改过期时间/续期/删除)'); console.log(' claude-relay-cli status - 查看系统状态'); console.log('\n使用 --help 查看详细帮助信息'); } \ No newline at end of file diff --git a/config/config.example.js b/config/config.example.js index 320c2a19..fb32691c 100644 --- a/config/config.example.js +++ b/config/config.example.js @@ -76,6 +76,38 @@ const config = { sessionSecret: process.env.WEB_SESSION_SECRET || 'CHANGE-THIS-SESSION-SECRET' }, + // 🔒 客户端限制配置 + clientRestrictions: { + // 预定义的客户端列表 + predefinedClients: [ + { + id: 'claude_code', + name: 'ClaudeCode', + description: 'Official Claude Code CLI', + // 匹配 Claude CLI 的 User-Agent + // 示例: claude-cli/1.0.58 (external, cli) + userAgentPattern: /^claude-cli\/[\d\.]+\s+\(/i + }, + { + id: 'gemini_cli', + name: 'Gemini-CLI', + description: 'Gemini Command Line Interface', + // 匹配 GeminiCLI 的 User-Agent + // 示例: GeminiCLI/v18.20.8 (darwin; arm64) + userAgentPattern: /^GeminiCLI\/v?[\d\.]+\s+\(/i + } + // 添加自定义客户端示例: + // { + // id: 'custom_client', + // name: 'My Custom Client', + // description: 'My custom API client', + // userAgentPattern: /^MyClient\/[\d\.]+/i + // } + ], + // 是否允许自定义客户端(未来功能) + allowCustomClients: process.env.ALLOW_CUSTOM_CLIENTS === 'true' + }, + // 🛠️ 开发配置 development: { debug: process.env.DEBUG === 'true', diff --git a/docker-compose.yml b/docker-compose.yml index 8bd4bbf6..769e3b63 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,20 +1,72 @@ version: '3.8' +# Claude Relay Service Docker Compose 配置 +# 所有配置通过环境变量设置,无需映射 .env 文件 + services: # 🚀 Claude Relay Service claude-relay: build: . - container_name: claude-relay-service + image: weishaw/claude-relay-service:latest restart: unless-stopped ports: - "${PORT:-3000}:3000" environment: + # 🌐 服务器配置 - NODE_ENV=production - PORT=3000 + - HOST=0.0.0.0 + + # 🔐 安全配置(必填) + - JWT_SECRET=${JWT_SECRET} # 必填:至少32字符的随机字符串 + - ENCRYPTION_KEY=${ENCRYPTION_KEY} # 必填:32字符的加密密钥 + - ADMIN_SESSION_TIMEOUT=${ADMIN_SESSION_TIMEOUT:-86400000} + - API_KEY_PREFIX=${API_KEY_PREFIX:-cr_} + + # 👤 管理员凭据(可选) + - ADMIN_USERNAME=${ADMIN_USERNAME:-} + - ADMIN_PASSWORD=${ADMIN_PASSWORD:-} + + # 📊 Redis 配置 - REDIS_HOST=redis - REDIS_PORT=6379 - - ADMIN_USERNAME=${ADMIN_USERNAME:-} # 可选:预设管理员用户名 - - ADMIN_PASSWORD=${ADMIN_PASSWORD:-} # 可选:预设管理员密码 + - REDIS_PASSWORD=${REDIS_PASSWORD:-} + - REDIS_DB=${REDIS_DB:-0} + - REDIS_ENABLE_TLS=${REDIS_ENABLE_TLS:-} + + # 🎯 Claude API 配置 + - CLAUDE_API_URL=${CLAUDE_API_URL:-https://api.anthropic.com/v1/messages} + - CLAUDE_API_VERSION=${CLAUDE_API_VERSION:-2023-06-01} + - CLAUDE_BETA_HEADER=${CLAUDE_BETA_HEADER:-claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14} + + # 🌐 代理配置 + - DEFAULT_PROXY_TIMEOUT=${DEFAULT_PROXY_TIMEOUT:-60000} + - MAX_PROXY_RETRIES=${MAX_PROXY_RETRIES:-3} + + # 📈 使用限制 + - DEFAULT_TOKEN_LIMIT=${DEFAULT_TOKEN_LIMIT:-1000000} + + # 📝 日志配置 + - LOG_LEVEL=${LOG_LEVEL:-info} + - LOG_MAX_SIZE=${LOG_MAX_SIZE:-10m} + - LOG_MAX_FILES=${LOG_MAX_FILES:-5} + + # 🔧 系统配置 + - CLEANUP_INTERVAL=${CLEANUP_INTERVAL:-3600000} + - TOKEN_USAGE_RETENTION=${TOKEN_USAGE_RETENTION:-2592000000} + - HEALTH_CHECK_INTERVAL=${HEALTH_CHECK_INTERVAL:-60000} + - SYSTEM_TIMEZONE=${SYSTEM_TIMEZONE:-Asia/Shanghai} + - TIMEZONE_OFFSET=${TIMEZONE_OFFSET:-8} + + # 🎨 Web 界面配置 + - WEB_TITLE=${WEB_TITLE:-Claude Relay Service} + - WEB_DESCRIPTION=${WEB_DESCRIPTION:-Multi-account Claude API relay service} + - WEB_LOGO_URL=${WEB_LOGO_URL:-/assets/logo.png} + + # 🛠️ 开发配置 + - DEBUG=${DEBUG:-false} + - ENABLE_CORS=${ENABLE_CORS:-true} + - TRUST_PROXY=${TRUST_PROXY:-true} volumes: - ./logs:/app/logs - ./data:/app/data @@ -31,7 +83,6 @@ services: # 📊 Redis Database redis: image: redis:7-alpine - container_name: claude-relay-redis restart: unless-stopped ports: - "${REDIS_PORT:-6379}:6379" @@ -49,7 +100,6 @@ services: # 📈 Redis Monitoring (Optional) redis-commander: image: rediscommander/redis-commander:latest - container_name: claude-relay-redis-web restart: unless-stopped ports: - "${REDIS_WEB_PORT:-8081}:8081" @@ -65,7 +115,6 @@ services: # 📊 Application Monitoring (Optional) prometheus: image: prom/prometheus:latest - container_name: claude-relay-prometheus restart: unless-stopped ports: - "${PROMETHEUS_PORT:-9090}:9090" @@ -86,7 +135,6 @@ services: # 📈 Grafana Dashboard (Optional) grafana: image: grafana/grafana:latest - container_name: claude-relay-grafana restart: unless-stopped ports: - "${GRAFANA_PORT:-3001}:3000" @@ -110,7 +158,4 @@ volumes: networks: claude-relay-network: - driver: bridge - ipam: - config: - - subnet: 172.20.0.0/16 \ No newline at end of file + driver: bridge \ No newline at end of file diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index 1f29f3f3..b4e37c50 100644 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -3,12 +3,20 @@ set -e echo "🚀 Claude Relay Service 启动中..." -# 生成随机字符串的函数 -generate_random_string() { - length=$1 - # 使用 /dev/urandom 生成随机字符串 - tr -dc 'a-zA-Z0-9' < /dev/urandom | head -c $length -} +# 检查关键环境变量 +if [ -z "$JWT_SECRET" ]; then + echo "❌ 错误: JWT_SECRET 环境变量未设置" + echo " 请在 docker-compose.yml 中设置 JWT_SECRET" + echo " 例如: JWT_SECRET=your-random-secret-key-at-least-32-chars" + exit 1 +fi + +if [ -z "$ENCRYPTION_KEY" ]; then + echo "❌ 错误: ENCRYPTION_KEY 环境变量未设置" + echo " 请在 docker-compose.yml 中设置 ENCRYPTION_KEY" + echo " 例如: ENCRYPTION_KEY=your-32-character-encryption-key" + exit 1 +fi # 检查并复制配置文件 if [ ! -f "/app/config/config.js" ]; then @@ -22,48 +30,17 @@ if [ ! -f "/app/config/config.js" ]; then fi fi -# 检查并配置 .env 文件(文件已在构建时创建) -if [ -f "/app/.env" ]; then - echo "📋 配置 .env 文件..." - - # 生成随机的 JWT_SECRET (64字符) - if [ -z "$JWT_SECRET" ]; then - JWT_SECRET=$(grep "^JWT_SECRET=" /app/.env | cut -d'=' -f2) - if [ -z "$JWT_SECRET" ] || [ "$JWT_SECRET" = "your-jwt-secret-here" ]; then - JWT_SECRET=$(generate_random_string 64) - echo "🔑 生成 JWT_SECRET" - fi - fi - - # 生成随机的 ENCRYPTION_KEY (32字符) - if [ -z "$ENCRYPTION_KEY" ]; then - ENCRYPTION_KEY=$(grep "^ENCRYPTION_KEY=" /app/.env | cut -d'=' -f2) - if [ -z "$ENCRYPTION_KEY" ] || [ "$ENCRYPTION_KEY" = "your-encryption-key-here" ]; then - ENCRYPTION_KEY=$(generate_random_string 32) - echo "🔑 生成 ENCRYPTION_KEY" - fi - fi - - # 直接使用sed修改.env文件 - root用户无权限问题 - sed -i "s/JWT_SECRET=.*/JWT_SECRET=${JWT_SECRET}/" /app/.env - sed -i "s/ENCRYPTION_KEY=.*/ENCRYPTION_KEY=${ENCRYPTION_KEY}/" /app/.env - sed -i "s/REDIS_HOST=.*/REDIS_HOST=redis/" /app/.env - - echo "✅ .env 已配置" -else - echo "❌ 错误: .env 文件不存在" - exit 1 -fi - -# 导出环境变量 -export JWT_SECRET -export ENCRYPTION_KEY +# 显示配置信息 +echo "✅ 环境配置已就绪" +echo " JWT_SECRET: [已设置]" +echo " ENCRYPTION_KEY: [已设置]" +echo " REDIS_HOST: ${REDIS_HOST:-localhost}" +echo " PORT: ${PORT:-3000}" # 检查是否需要初始化 if [ ! -f "/app/data/init.json" ]; then echo "📋 首次启动,执行初始化设置..." - # 如果设置了环境变量,显示提示 if [ -n "$ADMIN_USERNAME" ] || [ -n "$ADMIN_PASSWORD" ]; then echo "📌 检测到预设的管理员凭据" diff --git a/docs/UPGRADE_GUIDE.md b/docs/UPGRADE_GUIDE.md new file mode 100644 index 00000000..84f0c9a4 --- /dev/null +++ b/docs/UPGRADE_GUIDE.md @@ -0,0 +1,205 @@ +# 升级指南 - API Key 有效期功能 + +本指南说明如何从旧版本安全升级到支持 API Key 有效期限制的新版本。 + +## 升级前准备 + +### 1. 备份现有数据 + +在升级前,强烈建议备份您的生产数据: + +```bash +# 导出所有数据(包含敏感信息) +npm run data:export -- --output=prod-backup-$(date +%Y%m%d).json + +# 或导出脱敏数据(用于测试环境) +npm run data:export:sanitized -- --output=prod-backup-sanitized-$(date +%Y%m%d).json +``` + +### 2. 确认备份完整性 + +检查导出的文件,确保包含所有必要的数据: + +```bash +# 查看备份文件信息 +cat prod-backup-*.json | jq '.metadata' + +# 查看数据统计 +cat prod-backup-*.json | jq '.data | keys' +``` + +## 升级步骤 + +### 1. 停止服务 + +```bash +# 停止 Claude Relay Service +npm run service:stop + +# 或如果使用 Docker +docker-compose down +``` + +### 2. 更新代码 + +```bash +# 拉取最新代码 +git pull origin main + +# 安装依赖 +npm install + +# 更新 Web 界面依赖 +npm run install:web +``` + +### 3. 运行数据迁移 + +为现有的 API Key 设置默认 30 天有效期: + +```bash +# 先进行模拟运行,查看将要修改的数据 +npm run migrate:apikey-expiry:dry + +# 确认无误后,执行实际迁移 +npm run migrate:apikey-expiry +``` + +如果您想设置不同的默认有效期: + +```bash +# 设置 90 天有效期 +npm run migrate:apikey-expiry -- --days=90 +``` + +### 4. 启动服务 + +```bash +# 启动服务 +npm run service:start:daemon + +# 或使用 Docker +docker-compose up -d +``` + +### 5. 验证升级 + +1. 登录 Web 管理界面 +2. 检查 API Key 列表,确认显示过期时间列 +3. 测试创建新的 API Key,确认可以设置过期时间 +4. 测试续期功能是否正常工作 + +## 从生产环境导入数据(用于测试) + +如果您需要在测试环境中使用生产数据: + +### 1. 在生产环境导出数据 + +```bash +# 导出脱敏数据(推荐用于测试) +npm run data:export:sanitized -- --output=prod-export.json + +# 或只导出特定类型的数据 +npm run data:export -- --types=apikeys,accounts --sanitize --output=prod-partial.json +``` + +### 2. 传输文件到测试环境 + +使用安全的方式传输文件,如 SCP: + +```bash +scp prod-export.json user@test-server:/path/to/claude-relay-service/ +``` + +### 3. 在测试环境导入数据 + +```bash +# 导入数据,遇到冲突时询问 +npm run data:import -- --input=prod-export.json + +# 或跳过所有冲突 +npm run data:import -- --input=prod-export.json --skip-conflicts + +# 或强制覆盖所有数据(谨慎使用) +npm run data:import -- --input=prod-export.json --force +``` + +## 回滚方案 + +如果升级后遇到问题,可以按以下步骤回滚: + +### 1. 停止服务 + +```bash +npm run service:stop +``` + +### 2. 恢复代码 + +```bash +# 切换到之前的版本 +git checkout + +# 重新安装依赖 +npm install +``` + +### 3. 恢复数据(如需要) + +```bash +# 从备份恢复数据 +npm run data:import -- --input=prod-backup-.json --force +``` + +### 4. 重启服务 + +```bash +npm run service:start:daemon +``` + +## 注意事项 + +1. **数据迁移是幂等的**:迁移脚本可以安全地多次运行,已有过期时间的 API Key 不会被修改。 + +2. **过期的 API Key 处理**: + - 过期的 API Key 会被自动禁用,而不是删除 + - 管理员可以通过续期功能重新激活过期的 Key + +3. **定时任务**: + - 系统会每小时自动检查并禁用过期的 API Key + - 该任务在 `config.system.cleanupInterval` 中配置 + +4. **API 兼容性**: + - 新增的过期时间功能完全向后兼容 + - 现有的 API 调用不会受到影响 + +## 常见问题 + +### Q: 如果不想某些 API Key 过期怎么办? + +A: 您可以通过 Web 界面将特定 API Key 设置为"永不过期",或在续期时选择"设为永不过期"。 + +### Q: 迁移脚本会影响已经设置了过期时间的 API Key 吗? + +A: 不会。迁移脚本只会处理没有设置过期时间的 API Key。 + +### Q: 如何批量修改 API Key 的过期时间? + +A: 您可以修改迁移脚本,或使用数据导出/导入工具批量处理。 + +### Q: 导出的脱敏数据可以用于生产环境吗? + +A: 不建议。脱敏数据缺少关键的认证信息(如 OAuth tokens),仅适用于测试环境。 + +## 技术支持 + +如遇到问题,请检查: + +1. 服务日志:`npm run service:logs` +2. Redis 连接:确保 Redis 服务正常运行 +3. 配置文件:检查 `.env` 和 `config/config.js` + +如需进一步帮助,请提供: +- 错误日志 +- 使用的命令 +- 系统环境信息 \ No newline at end of file diff --git a/docs/api-key-expiry-guide.md b/docs/api-key-expiry-guide.md new file mode 100644 index 00000000..68f64bb0 --- /dev/null +++ b/docs/api-key-expiry-guide.md @@ -0,0 +1,187 @@ +# API Key 过期时间管理指南 + +## 概述 + +Claude Relay Service 支持为 API Keys 设置过期时间,提供了灵活的过期管理功能,方便进行权限控制和安全管理。 + +## 功能特性 + +- ✅ 创建时设置过期时间 +- ✅ 随时修改过期时间 +- ✅ 自动禁用过期的 Keys +- ✅ 手动续期功能 +- ✅ 批量续期支持 +- ✅ Web 界面和 CLI 双重管理 + +## CLI 管理工具 + +### 1. 查看 API Keys + +```bash +npm run cli keys +# 选择 "📋 查看所有 API Keys" +``` + +显示内容包括: +- 名称和部分 Key +- 活跃/禁用状态 +- 过期时间(带颜色提示) +- Token 使用量 +- Token 限制 + +### 2. 修改过期时间 + +```bash +npm run cli keys +# 选择 "🔧 修改 API Key 过期时间" +``` + +支持的过期选项: +- ⏰ **1小时后**(测试用) +- 📅 **1天后** +- 📅 **7天后** +- 📅 **30天后** +- 📅 **90天后** +- 📅 **365天后** +- ♾️ **永不过期** +- 🎯 **自定义日期时间** + +### 3. 批量续期 + +```bash +npm run cli keys +# 选择 "🔄 续期即将过期的 API Key" +``` + +功能: +- 查找7天内即将过期的 Keys +- 支持全部续期30天或90天 +- 支持逐个选择续期 + +### 4. 删除 API Keys + +```bash +npm run cli keys +# 选择 "🗑️ 删除 API Key" +``` + +## Web 界面功能 + +### 创建时设置过期 + +在创建 API Key 时,可以选择: +- 永不过期 +- 1天、7天、30天、90天、180天、365天 +- 自定义日期 + +### 查看过期状态 + +API Key 列表中显示: +- 🔴 已过期(红色) +- 🟡 即将过期(7天内,黄色) +- 🟢 正常(绿色) +- ♾️ 永不过期 + +### 手动续期 + +对于已过期的 API Keys: +1. 点击"续期"按钮 +2. 选择新的过期时间 +3. 确认更新 + +## 自动清理机制 + +系统每小时自动运行清理任务: +- 检查所有 API Keys 的过期时间 +- 将过期的 Keys 标记为禁用(`isActive = false`) +- 不删除数据,保留历史记录 +- 记录清理日志 + +## 测试工具 + +### 1. 快速测试脚本 + +```bash +node scripts/test-apikey-expiry.js +``` + +创建5个测试 Keys: +- 已过期(1天前) +- 1小时后过期 +- 1天后过期 +- 7天后过期 +- 永不过期 + +### 2. 迁移脚本 + +为现有 API Keys 设置默认30天过期时间: + +```bash +# 预览(不实际修改) +npm run migrate:apikey-expiry:dry + +# 执行迁移 +npm run migrate:apikey-expiry +``` + +## 使用场景 + +### 1. 临时访问 + +为临时用户或测试创建短期 Key: +```bash +# 创建1天有效期的测试 Key +# 在 Web 界面或 CLI 中选择"1天" +``` + +### 2. 定期更新 + +为安全考虑,定期更新 Keys: +```bash +# 每30天自动过期,需要续期 +# 创建时选择"30天" +``` + +### 3. 长期合作 + +为可信任的长期用户: +```bash +# 选择"365天"或"永不过期" +``` + +### 4. 测试过期功能 + +快速测试过期验证: +```bash +# 1. 创建1小时后过期的 Key +npm run cli keys +# 选择修改过期时间 -> 选择测试 Key -> 1小时后 + +# 2. 等待或手动触发清理 +# 3. 验证 API 调用被拒绝 +``` + +## API 响应 + +过期的 API Key 调用时返回: +```json +{ + "error": "Unauthorized", + "message": "Invalid or inactive API key" +} +``` + +## 最佳实践 + +1. **定期审查**:定期检查即将过期的 Keys +2. **提前通知**:在过期前通知用户续期 +3. **分级管理**:根据用户级别设置不同过期策略 +4. **测试验证**:新功能上线前充分测试过期机制 +5. **备份恢复**:使用数据导出工具备份 Key 信息 + +## 注意事项 + +- 过期的 Keys 不会被删除,只是禁用 +- 可以随时续期已过期的 Keys +- 修改过期时间立即生效 +- 清理任务每小时运行一次 \ No newline at end of file diff --git a/docs/data-encryption-handling.md b/docs/data-encryption-handling.md new file mode 100644 index 00000000..afe358d8 --- /dev/null +++ b/docs/data-encryption-handling.md @@ -0,0 +1,177 @@ +# 数据导入/导出加密处理指南 + +## 概述 + +Claude Relay Service 使用 AES-256-CBC 加密算法来保护敏感数据。本文档详细说明了数据导入/导出工具如何处理加密和未加密的数据。 + +## 加密机制 + +### 加密的数据类型 + +1. **Claude 账户** + - email + - password + - accessToken + - refreshToken + - claudeAiOauth (OAuth 数据) + - 使用 salt: `'salt'` + +2. **Gemini 账户** + - geminiOauth (OAuth 数据) + - accessToken + - refreshToken + - 使用 salt: `'gemini-account-salt'` + +### 加密格式 + +加密后的数据格式:`{iv}:{encryptedData}` +- `iv`: 16字节的初始化向量(hex格式) +- `encryptedData`: 加密后的数据(hex格式) + +## 导出功能 + +### 1. 解密导出(默认) +```bash +npm run data:export:enhanced +# 或 +node scripts/data-transfer-enhanced.js export --decrypt=true +``` + +- **用途**:数据迁移到其他环境 +- **特点**: + - `metadata.decrypted = true` + - 敏感数据以明文形式导出 + - 便于在不同加密密钥的环境间迁移 + +### 2. 加密导出 +```bash +npm run data:export:encrypted +# 或 +node scripts/data-transfer-enhanced.js export --decrypt=false +``` + +- **用途**:备份或在相同加密密钥的环境间传输 +- **特点**: + - `metadata.decrypted = false` + - 保持数据的加密状态 + - 必须在相同的 ENCRYPTION_KEY 环境下才能使用 + +### 3. 脱敏导出 +```bash +node scripts/data-transfer-enhanced.js export --sanitize +``` + +- **用途**:分享数据结构或调试 +- **特点**: + - `metadata.sanitized = true` + - 敏感字段被替换为 `[REDACTED]` + - 不能用于实际导入 + +## 导入功能 + +### 自动加密处理逻辑 + +```javascript +if (importData.metadata.decrypted && !importData.metadata.sanitized) { + // 数据已解密且不是脱敏的,需要重新加密 + // 自动加密所有敏感字段 +} else { + // 数据已加密或是脱敏的,保持原样 +} +``` + +### 导入场景 + +#### 场景 1:导入解密的数据 +- **输入**:`metadata.decrypted = true` +- **处理**:自动加密所有敏感字段 +- **结果**:数据以加密形式存储在 Redis + +#### 场景 2:导入加密的数据 +- **输入**:`metadata.decrypted = false` +- **处理**:直接存储,不做加密处理 +- **结果**:保持原有加密状态 +- **注意**:必须使用相同的 ENCRYPTION_KEY + +#### 场景 3:导入脱敏的数据 +- **输入**:`metadata.sanitized = true` +- **处理**:警告并询问是否继续 +- **结果**:导入但缺少敏感数据,账户可能无法正常工作 + +## 使用示例 + +### 1. 跨环境迁移 +```bash +# 在生产环境导出(解密) +npm run data:export:enhanced -- --output=prod-data.json + +# 在测试环境导入(自动加密) +npm run data:import:enhanced -- --input=prod-data.json +``` + +### 2. 同环境备份恢复 +```bash +# 备份(保持加密) +npm run data:export:encrypted -- --output=backup.json + +# 恢复(保持加密) +npm run data:import:enhanced -- --input=backup.json +``` + +### 3. 选择性导入 +```bash +# 跳过已存在的数据 +npm run data:import:enhanced -- --input=data.json --skip-conflicts + +# 强制覆盖所有数据 +npm run data:import:enhanced -- --input=data.json --force +``` + +## 安全建议 + +1. **加密密钥管理** + - 使用强随机密钥(至少32字符) + - 不同环境使用不同的密钥 + - 定期轮换密钥 + +2. **导出文件保护** + - 解密的导出文件包含明文敏感数据 + - 应立即加密存储或传输 + - 使用后及时删除 + +3. **权限控制** + - 限制导出/导入工具的访问权限 + - 审计所有数据导出操作 + - 使用脱敏导出进行非生产用途 + +## 故障排除 + +### 常见问题 + +1. **导入后账户无法使用** + - 检查 ENCRYPTION_KEY 是否正确 + - 确认不是导入了脱敏数据 + - 验证加密字段格式是否正确 + +2. **加密/解密失败** + - 确保 ENCRYPTION_KEY 长度为32字符 + - 检查加密数据格式 `{iv}:{data}` + - 查看日志中的解密警告 + +3. **数据不完整** + - 检查导出时是否使用了 --types 限制 + - 确认 Redis 连接正常 + - 验证账户前缀(claude:account: vs claude_account:) + +## 测试工具 + +运行测试脚本验证加密处理: +```bash +node scripts/test-import-encryption.js +``` + +该脚本会: +1. 创建测试导出文件(加密和解密版本) +2. 显示加密前后的数据对比 +3. 提供测试导入命令 +4. 验证加密/解密功能 \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 63c3e441..2142691c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,7 +20,7 @@ "google-auth-library": "^10.1.0", "helmet": "^7.1.0", "https-proxy-agent": "^7.0.2", - "inquirer": "^9.2.15", + "inquirer": "^8.2.6", "ioredis": "^5.3.2", "morgan": "^1.10.0", "ora": "^5.4.1", @@ -773,15 +773,6 @@ "dev": true, "license": "BSD-3-Clause" }, - "node_modules/@inquirer/figures": { - "version": "1.0.12", - "resolved": "https://registry.npmmirror.com/@inquirer/figures/-/figures-1.0.12.tgz", - "integrity": "sha512-MJttijd8rMFcKJC8NYmprWr6hD3r9Gd9qUC0XwPNwoEPWSMVJwA2MlXxF+nhZZNMY+HXsWa+o7KY2emWYIn0jQ==", - "license": "MIT", - "engines": { - "node": ">=18" - } - }, "node_modules/@ioredis/commands": { "version": "1.2.0", "resolved": "https://registry.npmmirror.com/@ioredis/commands/-/commands-1.2.0.tgz", @@ -2097,7 +2088,7 @@ }, "node_modules/chardet": { "version": "0.7.0", - "resolved": "https://registry.npmmirror.com/chardet/-/chardet-0.7.0.tgz", + "resolved": "https://registry.npmjs.org/chardet/-/chardet-0.7.0.tgz", "integrity": "sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==", "license": "MIT" }, @@ -2187,12 +2178,12 @@ } }, "node_modules/cli-width": { - "version": "4.1.0", - "resolved": "https://registry.npmmirror.com/cli-width/-/cli-width-4.1.0.tgz", - "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-3.0.0.tgz", + "integrity": "sha512-FxqpkPPwu1HjuN93Omfm4h8uIanXofW0RxVEW3k5RKx+mJJYSthzNhp32Kzxxy3YAEZ/Dc/EWN1vZRY0+kOhbw==", "license": "ISC", "engines": { - "node": ">= 12" + "node": ">= 10" } }, "node_modules/cliui": { @@ -3107,7 +3098,7 @@ }, "node_modules/external-editor": { "version": "3.1.0", - "resolved": "https://registry.npmmirror.com/external-editor/-/external-editor-3.1.0.tgz", + "resolved": "https://registry.npmjs.org/external-editor/-/external-editor-3.1.0.tgz", "integrity": "sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew==", "license": "MIT", "dependencies": { @@ -3211,6 +3202,30 @@ "node": "^12.20 || >= 14.13" } }, + "node_modules/figures": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/figures/-/figures-3.2.0.tgz", + "integrity": "sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==", + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^1.0.5" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/figures/node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, "node_modules/file-entry-cache": { "version": "6.0.1", "resolved": "https://registry.npmmirror.com/file-entry-cache/-/file-entry-cache-6.0.1.tgz", @@ -3888,26 +3903,29 @@ "license": "ISC" }, "node_modules/inquirer": { - "version": "9.3.7", - "resolved": "https://registry.npmmirror.com/inquirer/-/inquirer-9.3.7.tgz", - "integrity": "sha512-LJKFHCSeIRq9hanN14IlOtPSTe3lNES7TYDTE2xxdAy1LS5rYphajK1qtwvj3YmQXvvk0U2Vbmcni8P9EIQW9w==", + "version": "8.2.6", + "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-8.2.6.tgz", + "integrity": "sha512-M1WuAmb7pn9zdFRtQYk26ZBoY043Sse0wVDdk4Bppr+JOXyQYybdtvK+l9wUibhtjdjvtoiNy8tk+EgsYIUqKg==", "license": "MIT", "dependencies": { - "@inquirer/figures": "^1.0.3", - "ansi-escapes": "^4.3.2", - "cli-width": "^4.1.0", - "external-editor": "^3.1.0", - "mute-stream": "1.0.0", + "ansi-escapes": "^4.2.1", + "chalk": "^4.1.1", + "cli-cursor": "^3.1.0", + "cli-width": "^3.0.0", + "external-editor": "^3.0.3", + "figures": "^3.0.0", + "lodash": "^4.17.21", + "mute-stream": "0.0.8", "ora": "^5.4.1", - "run-async": "^3.0.0", - "rxjs": "^7.8.1", - "string-width": "^4.2.3", - "strip-ansi": "^6.0.1", - "wrap-ansi": "^6.2.0", - "yoctocolors-cjs": "^2.1.2" + "run-async": "^2.4.0", + "rxjs": "^7.5.5", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0", + "through": "^2.3.6", + "wrap-ansi": "^6.0.1" }, "engines": { - "node": ">=18" + "node": ">=12.0.0" } }, "node_modules/ioredis": { @@ -5005,6 +5023,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "license": "MIT" + }, "node_modules/lodash.defaults": { "version": "4.2.0", "resolved": "https://registry.npmmirror.com/lodash.defaults/-/lodash.defaults-4.2.0.tgz", @@ -5283,13 +5307,10 @@ "license": "MIT" }, "node_modules/mute-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmmirror.com/mute-stream/-/mute-stream-1.0.0.tgz", - "integrity": "sha512-avsJQhyd+680gKXyG/sQc0nXaC6rBkPOfyHYcFb9+hdkqQkR9bdnkJ0AMZhke0oesPqIO+mFFJ+IdBc7mst4IA==", - "license": "ISC", - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz", + "integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==", + "license": "ISC" }, "node_modules/natural-compare": { "version": "1.4.0", @@ -5600,7 +5621,7 @@ }, "node_modules/os-tmpdir": { "version": "1.0.2", - "resolved": "https://registry.npmmirror.com/os-tmpdir/-/os-tmpdir-1.0.2.tgz", + "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz", "integrity": "sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==", "license": "MIT", "engines": { @@ -6169,9 +6190,9 @@ } }, "node_modules/run-async": { - "version": "3.0.0", - "resolved": "https://registry.npmmirror.com/run-async/-/run-async-3.0.0.tgz", - "integrity": "sha512-540WwVDOMxA6dN6We19EcT9sc3hkXPw5mzRNGM3FkdN/vtE9NFvj5lFAPNwUDmJjXidm3v7TC1cTE7t17Ulm1Q==", + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/run-async/-/run-async-2.4.1.tgz", + "integrity": "sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ==", "license": "MIT", "engines": { "node": ">=0.12.0" @@ -6203,7 +6224,7 @@ }, "node_modules/rxjs": { "version": "7.8.2", - "resolved": "https://registry.npmmirror.com/rxjs/-/rxjs-7.8.2.tgz", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", "license": "Apache-2.0", "dependencies": { @@ -6894,9 +6915,15 @@ "dev": true, "license": "MIT" }, + "node_modules/through": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", + "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==", + "license": "MIT" + }, "node_modules/tmp": { "version": "0.0.33", - "resolved": "https://registry.npmmirror.com/tmp/-/tmp-0.0.33.tgz", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz", "integrity": "sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==", "license": "MIT", "dependencies": { @@ -6956,7 +6983,7 @@ }, "node_modules/tslib": { "version": "2.8.1", - "resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, @@ -7263,7 +7290,7 @@ }, "node_modules/wrap-ansi": { "version": "6.2.0", - "resolved": "https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", "license": "MIT", "dependencies": { @@ -7354,18 +7381,6 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } - }, - "node_modules/yoctocolors-cjs": { - "version": "2.1.2", - "resolved": "https://registry.npmmirror.com/yoctocolors-cjs/-/yoctocolors-cjs-2.1.2.tgz", - "integrity": "sha512-cYVsTjKl8b+FrnidjibDWskAv7UKOfcwaVZdp/it9n1s9fU3IkgDbhdIRKCW4JDsAlECJY0ytoVPT3sK6kideA==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } } } } diff --git a/package.json b/package.json index 8e15b483..0bb9bd01 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "install:web": "cd web && npm install", "setup": "node scripts/setup.js", "cli": "node cli/index.js", + "init:costs": "node src/cli/initCosts.js", "service": "node scripts/manage.js", "service:start": "node scripts/manage.js start", "service:start:daemon": "node scripts/manage.js start -d", @@ -18,6 +19,7 @@ "service:stop": "node scripts/manage.js stop", "service:restart": "node scripts/manage.js restart", "service:restart:daemon": "node scripts/manage.js restart -d", + "service:logs:follow": "node scripts/manage.js logs -f", "service:restart:d": "node scripts/manage.js restart -d", "service:status": "node scripts/manage.js status", "service:logs": "node scripts/manage.js logs", @@ -25,7 +27,17 @@ "lint": "eslint src/**/*.js", "docker:build": "docker build -t claude-relay-service .", "docker:up": "docker-compose up -d", - "docker:down": "docker-compose down" + "docker:down": "docker-compose down", + "migrate:apikey-expiry": "node scripts/migrate-apikey-expiry.js", + "migrate:apikey-expiry:dry": "node scripts/migrate-apikey-expiry.js --dry-run", + "migrate:fix-usage-stats": "node scripts/fix-usage-stats.js", + "data:export": "node scripts/data-transfer.js export", + "data:import": "node scripts/data-transfer.js import", + "data:export:sanitized": "node scripts/data-transfer.js export --sanitize", + "data:export:enhanced": "node scripts/data-transfer-enhanced.js export", + "data:export:encrypted": "node scripts/data-transfer-enhanced.js export --decrypt=false", + "data:import:enhanced": "node scripts/data-transfer-enhanced.js import", + "data:debug": "node scripts/debug-redis-keys.js" }, "dependencies": { "axios": "^1.6.0", @@ -39,7 +51,7 @@ "google-auth-library": "^10.1.0", "helmet": "^7.1.0", "https-proxy-agent": "^7.0.2", - "inquirer": "^9.2.15", + "inquirer": "^8.2.6", "ioredis": "^5.3.2", "morgan": "^1.10.0", "ora": "^5.4.1", diff --git a/scripts/data-transfer-enhanced.js b/scripts/data-transfer-enhanced.js new file mode 100644 index 00000000..59390266 --- /dev/null +++ b/scripts/data-transfer-enhanced.js @@ -0,0 +1,994 @@ +#!/usr/bin/env node + +/** + * 增强版数据导出/导入工具 + * 支持加密数据的处理 + */ + +const fs = require('fs').promises; +const crypto = require('crypto'); +const redis = require('../src/models/redis'); +const logger = require('../src/utils/logger'); +const readline = require('readline'); +const config = require('../config/config'); + +// 解析命令行参数 +const args = process.argv.slice(2); +const command = args[0]; +const params = {}; + +args.slice(1).forEach(arg => { + const [key, value] = arg.split('='); + params[key.replace('--', '')] = value || true; +}); + +// 创建 readline 接口 +const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout +}); + +async function askConfirmation(question) { + return new Promise((resolve) => { + rl.question(question + ' (yes/no): ', (answer) => { + resolve(answer.toLowerCase() === 'yes' || answer.toLowerCase() === 'y'); + }); + }); +} + +// Claude 账户解密函数 +function decryptClaudeData(encryptedData) { + 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; + } +} + +// Gemini 账户解密函数 +function decryptGeminiData(encryptedData) { + if (!encryptedData || !config.security.encryptionKey) return encryptedData; + + try { + if (encryptedData.includes(':')) { + const parts = encryptedData.split(':'); + const key = crypto.scryptSync(config.security.encryptionKey, 'gemini-account-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; + } +} + +// 数据加密函数(用于导入) +function encryptClaudeData(data) { + 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 encryptGeminiData(data) { + if (!data || !config.security.encryptionKey) return data; + + const key = crypto.scryptSync(config.security.encryptionKey, 'gemini-account-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; +} + +// 导出使用统计数据 +async function exportUsageStats(keyId) { + try { + const stats = { + total: {}, + daily: {}, + monthly: {}, + hourly: {}, + models: {} + }; + + // 导出总统计 + const totalKey = `usage:${keyId}`; + const totalData = await redis.client.hgetall(totalKey); + if (totalData && Object.keys(totalData).length > 0) { + stats.total = totalData; + } + + // 导出每日统计(最近30天) + const today = new Date(); + for (let i = 0; i < 30; i++) { + const date = new Date(today); + date.setDate(date.getDate() - i); + const dateStr = date.toISOString().split('T')[0]; + const dailyKey = `usage:daily:${keyId}:${dateStr}`; + + const dailyData = await redis.client.hgetall(dailyKey); + if (dailyData && Object.keys(dailyData).length > 0) { + stats.daily[dateStr] = dailyData; + } + } + + // 导出每月统计(最近12个月) + for (let i = 0; i < 12; i++) { + const date = new Date(today); + date.setMonth(date.getMonth() - i); + const monthStr = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`; + const monthlyKey = `usage:monthly:${keyId}:${monthStr}`; + + const monthlyData = await redis.client.hgetall(monthlyKey); + if (monthlyData && Object.keys(monthlyData).length > 0) { + stats.monthly[monthStr] = monthlyData; + } + } + + // 导出小时统计(最近24小时) + for (let i = 0; i < 24; i++) { + const date = new Date(today); + date.setHours(date.getHours() - i); + const dateStr = date.toISOString().split('T')[0]; + const hour = String(date.getHours()).padStart(2, '0'); + const hourKey = `${dateStr}:${hour}`; + const hourlyKey = `usage:hourly:${keyId}:${hourKey}`; + + const hourlyData = await redis.client.hgetall(hourlyKey); + if (hourlyData && Object.keys(hourlyData).length > 0) { + stats.hourly[hourKey] = hourlyData; + } + } + + // 导出模型统计 + // 每日模型统计 + const modelDailyPattern = `usage:${keyId}:model:daily:*`; + const modelDailyKeys = await redis.client.keys(modelDailyPattern); + for (const key of modelDailyKeys) { + const match = key.match(/usage:.+:model:daily:(.+):(\d{4}-\d{2}-\d{2})$/); + if (match) { + const model = match[1]; + const date = match[2]; + const data = await redis.client.hgetall(key); + if (data && Object.keys(data).length > 0) { + if (!stats.models[model]) stats.models[model] = { daily: {}, monthly: {} }; + stats.models[model].daily[date] = data; + } + } + } + + // 每月模型统计 + const modelMonthlyPattern = `usage:${keyId}:model:monthly:*`; + const modelMonthlyKeys = await redis.client.keys(modelMonthlyPattern); + for (const key of modelMonthlyKeys) { + const match = key.match(/usage:.+:model:monthly:(.+):(\d{4}-\d{2})$/); + if (match) { + const model = match[1]; + const month = match[2]; + const data = await redis.client.hgetall(key); + if (data && Object.keys(data).length > 0) { + if (!stats.models[model]) stats.models[model] = { daily: {}, monthly: {} }; + stats.models[model].monthly[month] = data; + } + } + } + + return stats; + } catch (error) { + logger.warn(`⚠️ Failed to export usage stats for ${keyId}: ${error.message}`); + return null; + } +} + +// 导入使用统计数据 +async function importUsageStats(keyId, stats) { + try { + if (!stats) return; + + const pipeline = redis.client.pipeline(); + let importCount = 0; + + // 导入总统计 + if (stats.total && Object.keys(stats.total).length > 0) { + for (const [field, value] of Object.entries(stats.total)) { + pipeline.hset(`usage:${keyId}`, field, value); + } + importCount++; + } + + // 导入每日统计 + if (stats.daily) { + for (const [date, data] of Object.entries(stats.daily)) { + for (const [field, value] of Object.entries(data)) { + pipeline.hset(`usage:daily:${keyId}:${date}`, field, value); + } + importCount++; + } + } + + // 导入每月统计 + if (stats.monthly) { + for (const [month, data] of Object.entries(stats.monthly)) { + for (const [field, value] of Object.entries(data)) { + pipeline.hset(`usage:monthly:${keyId}:${month}`, field, value); + } + importCount++; + } + } + + // 导入小时统计 + if (stats.hourly) { + for (const [hour, data] of Object.entries(stats.hourly)) { + for (const [field, value] of Object.entries(data)) { + pipeline.hset(`usage:hourly:${keyId}:${hour}`, field, value); + } + importCount++; + } + } + + // 导入模型统计 + if (stats.models) { + for (const [model, modelStats] of Object.entries(stats.models)) { + // 每日模型统计 + if (modelStats.daily) { + for (const [date, data] of Object.entries(modelStats.daily)) { + for (const [field, value] of Object.entries(data)) { + pipeline.hset(`usage:${keyId}:model:daily:${model}:${date}`, field, value); + } + importCount++; + } + } + + // 每月模型统计 + if (modelStats.monthly) { + for (const [month, data] of Object.entries(modelStats.monthly)) { + for (const [field, value] of Object.entries(data)) { + pipeline.hset(`usage:${keyId}:model:monthly:${model}:${month}`, field, value); + } + importCount++; + } + } + } + } + + await pipeline.exec(); + logger.info(` 📊 Imported ${importCount} usage stat entries for API Key ${keyId}`); + + } catch (error) { + logger.warn(`⚠️ Failed to import usage stats for ${keyId}: ${error.message}`); + } +} + +// 数据脱敏函数 +function sanitizeData(data, type) { + const sanitized = { ...data }; + + switch (type) { + case 'apikey': + if (sanitized.apiKey) { + sanitized.apiKey = sanitized.apiKey.substring(0, 10) + '...[REDACTED]'; + } + break; + + case 'claude_account': + if (sanitized.email) sanitized.email = '[REDACTED]'; + if (sanitized.password) sanitized.password = '[REDACTED]'; + if (sanitized.accessToken) sanitized.accessToken = '[REDACTED]'; + if (sanitized.refreshToken) sanitized.refreshToken = '[REDACTED]'; + if (sanitized.claudeAiOauth) sanitized.claudeAiOauth = '[REDACTED]'; + if (sanitized.proxyPassword) sanitized.proxyPassword = '[REDACTED]'; + break; + + case 'gemini_account': + if (sanitized.geminiOauth) sanitized.geminiOauth = '[REDACTED]'; + if (sanitized.accessToken) sanitized.accessToken = '[REDACTED]'; + if (sanitized.refreshToken) sanitized.refreshToken = '[REDACTED]'; + if (sanitized.proxyPassword) sanitized.proxyPassword = '[REDACTED]'; + break; + + case 'admin': + if (sanitized.password) sanitized.password = '[REDACTED]'; + break; + } + + return sanitized; +} + +// 导出数据 +async function exportData() { + try { + const outputFile = params.output || `backup-${new Date().toISOString().split('T')[0]}.json`; + const types = params.types ? params.types.split(',') : ['all']; + const shouldSanitize = params.sanitize === true; + const shouldDecrypt = params.decrypt !== false; // 默认解密 + + logger.info('🔄 Starting data export...'); + logger.info(`📁 Output file: ${outputFile}`); + logger.info(`📋 Data types: ${types.join(', ')}`); + logger.info(`🔒 Sanitize sensitive data: ${shouldSanitize ? 'YES' : 'NO'}`); + logger.info(`🔓 Decrypt data: ${shouldDecrypt ? 'YES' : 'NO'}`); + + await redis.connect(); + logger.success('✅ Connected to Redis'); + + const exportData = { + metadata: { + version: '2.0', + exportDate: new Date().toISOString(), + sanitized: shouldSanitize, + decrypted: shouldDecrypt, + types: types + }, + data: {} + }; + + // 导出 API Keys + if (types.includes('all') || types.includes('apikeys')) { + logger.info('📤 Exporting API Keys...'); + const keys = await redis.client.keys('apikey:*'); + const apiKeys = []; + + for (const key of keys) { + if (key === 'apikey:hash_map') continue; + + const data = await redis.client.hgetall(key); + if (data && Object.keys(data).length > 0) { + // 获取该 API Key 的 ID + const keyId = data.id; + + // 导出使用统计数据 + if (keyId && (types.includes('all') || types.includes('stats'))) { + data.usageStats = await exportUsageStats(keyId); + } + + apiKeys.push(shouldSanitize ? sanitizeData(data, 'apikey') : data); + } + } + + exportData.data.apiKeys = apiKeys; + logger.success(`✅ Exported ${apiKeys.length} API Keys`); + } + + // 导出 Claude 账户 + if (types.includes('all') || types.includes('accounts')) { + logger.info('📤 Exporting Claude accounts...'); + const keys = await redis.client.keys('claude:account:*'); + logger.info(`Found ${keys.length} Claude account keys in Redis`); + const accounts = []; + + for (const key of keys) { + const data = await redis.client.hgetall(key); + + if (data && Object.keys(data).length > 0) { + // 解密敏感字段 + if (shouldDecrypt && !shouldSanitize) { + if (data.email) data.email = decryptClaudeData(data.email); + if (data.password) data.password = decryptClaudeData(data.password); + if (data.accessToken) data.accessToken = decryptClaudeData(data.accessToken); + if (data.refreshToken) data.refreshToken = decryptClaudeData(data.refreshToken); + if (data.claudeAiOauth) { + const decrypted = decryptClaudeData(data.claudeAiOauth); + try { + data.claudeAiOauth = JSON.parse(decrypted); + } catch (e) { + data.claudeAiOauth = decrypted; + } + } + } + + accounts.push(shouldSanitize ? sanitizeData(data, 'claude_account') : data); + } + } + + exportData.data.claudeAccounts = accounts; + logger.success(`✅ Exported ${accounts.length} Claude accounts`); + + // 导出 Gemini 账户 + logger.info('📤 Exporting Gemini accounts...'); + const geminiKeys = await redis.client.keys('gemini_account:*'); + logger.info(`Found ${geminiKeys.length} Gemini account keys in Redis`); + const geminiAccounts = []; + + for (const key of geminiKeys) { + const data = await redis.client.hgetall(key); + + if (data && Object.keys(data).length > 0) { + // 解密敏感字段 + if (shouldDecrypt && !shouldSanitize) { + if (data.geminiOauth) { + const decrypted = decryptGeminiData(data.geminiOauth); + try { + data.geminiOauth = JSON.parse(decrypted); + } catch (e) { + data.geminiOauth = decrypted; + } + } + if (data.accessToken) data.accessToken = decryptGeminiData(data.accessToken); + if (data.refreshToken) data.refreshToken = decryptGeminiData(data.refreshToken); + } + + geminiAccounts.push(shouldSanitize ? sanitizeData(data, 'gemini_account') : data); + } + } + + exportData.data.geminiAccounts = geminiAccounts; + logger.success(`✅ Exported ${geminiAccounts.length} Gemini accounts`); + } + + // 导出管理员 + if (types.includes('all') || types.includes('admins')) { + logger.info('📤 Exporting admins...'); + const keys = await redis.client.keys('admin:*'); + const admins = []; + + for (const key of keys) { + if (key.includes('admin_username:')) continue; + + const data = await redis.client.hgetall(key); + if (data && Object.keys(data).length > 0) { + admins.push(shouldSanitize ? sanitizeData(data, 'admin') : data); + } + } + + exportData.data.admins = admins; + logger.success(`✅ Exported ${admins.length} admins`); + } + + // 导出全局模型统计(如果需要) + if (types.includes('all') || types.includes('stats')) { + logger.info('📤 Exporting global model statistics...'); + const globalStats = { + daily: {}, + monthly: {}, + hourly: {} + }; + + // 导出全局每日模型统计 + const globalDailyPattern = 'usage:model:daily:*'; + const globalDailyKeys = await redis.client.keys(globalDailyPattern); + for (const key of globalDailyKeys) { + const match = key.match(/usage:model:daily:(.+):(\d{4}-\d{2}-\d{2})$/); + if (match) { + const model = match[1]; + const date = match[2]; + const data = await redis.client.hgetall(key); + if (data && Object.keys(data).length > 0) { + if (!globalStats.daily[date]) globalStats.daily[date] = {}; + globalStats.daily[date][model] = data; + } + } + } + + // 导出全局每月模型统计 + const globalMonthlyPattern = 'usage:model:monthly:*'; + const globalMonthlyKeys = await redis.client.keys(globalMonthlyPattern); + for (const key of globalMonthlyKeys) { + const match = key.match(/usage:model:monthly:(.+):(\d{4}-\d{2})$/); + if (match) { + const model = match[1]; + const month = match[2]; + const data = await redis.client.hgetall(key); + if (data && Object.keys(data).length > 0) { + if (!globalStats.monthly[month]) globalStats.monthly[month] = {}; + globalStats.monthly[month][model] = data; + } + } + } + + // 导出全局每小时模型统计 + const globalHourlyPattern = 'usage:model:hourly:*'; + const globalHourlyKeys = await redis.client.keys(globalHourlyPattern); + for (const key of globalHourlyKeys) { + const match = key.match(/usage:model:hourly:(.+):(\d{4}-\d{2}-\d{2}:\d{2})$/); + if (match) { + const model = match[1]; + const hour = match[2]; + const data = await redis.client.hgetall(key); + if (data && Object.keys(data).length > 0) { + if (!globalStats.hourly[hour]) globalStats.hourly[hour] = {}; + globalStats.hourly[hour][model] = data; + } + } + } + + exportData.data.globalModelStats = globalStats; + logger.success(`✅ Exported global model statistics`); + } + + // 写入文件 + await fs.writeFile(outputFile, JSON.stringify(exportData, null, 2)); + + // 显示导出摘要 + console.log('\n' + '='.repeat(60)); + console.log('✅ Export Complete!'); + console.log('='.repeat(60)); + console.log(`Output file: ${outputFile}`); + console.log(`File size: ${(await fs.stat(outputFile)).size} bytes`); + + if (exportData.data.apiKeys) { + console.log(`API Keys: ${exportData.data.apiKeys.length}`); + } + if (exportData.data.claudeAccounts) { + console.log(`Claude Accounts: ${exportData.data.claudeAccounts.length}`); + } + if (exportData.data.geminiAccounts) { + console.log(`Gemini Accounts: ${exportData.data.geminiAccounts.length}`); + } + if (exportData.data.admins) { + console.log(`Admins: ${exportData.data.admins.length}`); + } + console.log('='.repeat(60)); + + if (shouldSanitize) { + logger.warn('⚠️ Sensitive data has been sanitized in this export.'); + } + if (shouldDecrypt) { + logger.info('🔓 Encrypted data has been decrypted for portability.'); + } + + } catch (error) { + logger.error('💥 Export failed:', error); + process.exit(1); + } finally { + await redis.disconnect(); + rl.close(); + } +} + +// 显示帮助信息 +function showHelp() { + console.log(` +Enhanced Data Transfer Tool for Claude Relay Service + +This tool handles encrypted data export/import between environments. + +Usage: + node scripts/data-transfer-enhanced.js [options] + +Commands: + export Export data from Redis to a JSON file + import Import data from a JSON file to Redis + +Export Options: + --output=FILE Output filename (default: backup-YYYY-MM-DD.json) + --types=TYPE,... Data types: apikeys,accounts,admins,stats,all (default: all) + stats: Include usage statistics with API keys + --sanitize Remove sensitive data from export + --decrypt=false Keep data encrypted (default: true - decrypt for portability) + +Import Options: + --input=FILE Input filename (required) + --force Overwrite existing data without asking + --skip-conflicts Skip conflicting data without asking + +Important Notes: + - The tool automatically handles encryption/decryption during import + - If importing decrypted data, it will be re-encrypted automatically + - If importing encrypted data, it will be stored as-is + - Sanitized exports cannot be properly imported (missing sensitive data) + +Examples: + # Export all data with decryption (for migration) + node scripts/data-transfer-enhanced.js export + + # Export without decrypting (for backup) + node scripts/data-transfer-enhanced.js export --decrypt=false + + # Import data (auto-handles encryption) + node scripts/data-transfer-enhanced.js import --input=backup.json + + # Import with force overwrite + node scripts/data-transfer-enhanced.js import --input=backup.json --force +`); +} + +// 导入数据 +async function importData() { + try { + const inputFile = params.input; + if (!inputFile) { + logger.error('❌ Please specify input file with --input=filename.json'); + process.exit(1); + } + + const forceOverwrite = params.force === true; + const skipConflicts = params['skip-conflicts'] === true; + + logger.info('🔄 Starting data import...'); + logger.info(`📁 Input file: ${inputFile}`); + logger.info(`⚡ Mode: ${forceOverwrite ? 'FORCE OVERWRITE' : (skipConflicts ? 'SKIP CONFLICTS' : 'ASK ON CONFLICT')}`); + + // 读取文件 + const fileContent = await fs.readFile(inputFile, 'utf8'); + const importData = JSON.parse(fileContent); + + // 验证文件格式 + if (!importData.metadata || !importData.data) { + logger.error('❌ Invalid backup file format'); + process.exit(1); + } + + logger.info(`📅 Backup date: ${importData.metadata.exportDate}`); + logger.info(`🔒 Sanitized: ${importData.metadata.sanitized ? 'YES' : 'NO'}`); + logger.info(`🔓 Decrypted: ${importData.metadata.decrypted ? 'YES' : 'NO'}`); + + if (importData.metadata.sanitized) { + logger.warn('⚠️ This backup contains sanitized data. Sensitive fields will be missing!'); + const proceed = await askConfirmation('Continue with sanitized data?'); + if (!proceed) { + logger.info('❌ Import cancelled'); + return; + } + } + + // 显示导入摘要 + console.log('\n' + '='.repeat(60)); + console.log('📋 Import Summary:'); + console.log('='.repeat(60)); + if (importData.data.apiKeys) { + console.log(`API Keys to import: ${importData.data.apiKeys.length}`); + } + if (importData.data.claudeAccounts) { + console.log(`Claude Accounts to import: ${importData.data.claudeAccounts.length}`); + } + if (importData.data.geminiAccounts) { + console.log(`Gemini Accounts to import: ${importData.data.geminiAccounts.length}`); + } + if (importData.data.admins) { + console.log(`Admins to import: ${importData.data.admins.length}`); + } + console.log('='.repeat(60) + '\n'); + + // 确认导入 + const confirmed = await askConfirmation('⚠️ Proceed with import?'); + if (!confirmed) { + logger.info('❌ Import cancelled'); + return; + } + + // 连接 Redis + await redis.connect(); + logger.success('✅ Connected to Redis'); + + const stats = { + imported: 0, + skipped: 0, + errors: 0 + }; + + // 导入 API Keys + if (importData.data.apiKeys) { + logger.info('\n📥 Importing API Keys...'); + for (const apiKey of importData.data.apiKeys) { + try { + const exists = await redis.client.exists(`apikey:${apiKey.id}`); + + if (exists && !forceOverwrite) { + if (skipConflicts) { + logger.warn(`⏭️ Skipped existing API Key: ${apiKey.name} (${apiKey.id})`); + stats.skipped++; + continue; + } else { + const overwrite = await askConfirmation(`API Key "${apiKey.name}" (${apiKey.id}) exists. Overwrite?`); + if (!overwrite) { + stats.skipped++; + continue; + } + } + } + + // 保存使用统计数据以便单独导入 + const usageStats = apiKey.usageStats; + + // 从apiKey对象中删除usageStats字段,避免存储到主键中 + const apiKeyData = { ...apiKey }; + delete apiKeyData.usageStats; + + // 使用 hset 存储到哈希表 + const pipeline = redis.client.pipeline(); + for (const [field, value] of Object.entries(apiKeyData)) { + pipeline.hset(`apikey:${apiKey.id}`, field, value); + } + await pipeline.exec(); + + // 更新哈希映射 + if (apiKey.apiKey && !importData.metadata.sanitized) { + await redis.client.hset('apikey:hash_map', apiKey.apiKey, apiKey.id); + } + + // 导入使用统计数据 + if (usageStats) { + await importUsageStats(apiKey.id, usageStats); + } + + logger.success(`✅ Imported API Key: ${apiKey.name} (${apiKey.id})`); + stats.imported++; + } catch (error) { + logger.error(`❌ Failed to import API Key ${apiKey.id}:`, error.message); + stats.errors++; + } + } + } + + // 导入 Claude 账户 + if (importData.data.claudeAccounts) { + logger.info('\n📥 Importing Claude accounts...'); + for (const account of importData.data.claudeAccounts) { + try { + const exists = await redis.client.exists(`claude:account:${account.id}`); + + if (exists && !forceOverwrite) { + if (skipConflicts) { + logger.warn(`⏭️ Skipped existing Claude account: ${account.name} (${account.id})`); + stats.skipped++; + continue; + } else { + const overwrite = await askConfirmation(`Claude account "${account.name}" (${account.id}) exists. Overwrite?`); + if (!overwrite) { + stats.skipped++; + continue; + } + } + } + + // 复制账户数据以避免修改原始数据 + const accountData = { ...account }; + + // 如果数据已解密且不是脱敏数据,需要重新加密 + if (importData.metadata.decrypted && !importData.metadata.sanitized) { + logger.info(`🔐 Re-encrypting sensitive data for Claude account: ${account.name}`); + + if (accountData.email) accountData.email = encryptClaudeData(accountData.email); + if (accountData.password) accountData.password = encryptClaudeData(accountData.password); + if (accountData.accessToken) accountData.accessToken = encryptClaudeData(accountData.accessToken); + if (accountData.refreshToken) accountData.refreshToken = encryptClaudeData(accountData.refreshToken); + if (accountData.claudeAiOauth) { + // 如果是对象,先序列化再加密 + const oauthStr = typeof accountData.claudeAiOauth === 'object' ? + JSON.stringify(accountData.claudeAiOauth) : accountData.claudeAiOauth; + accountData.claudeAiOauth = encryptClaudeData(oauthStr); + } + } + + // 使用 hset 存储到哈希表 + const pipeline = redis.client.pipeline(); + for (const [field, value] of Object.entries(accountData)) { + if (field === 'claudeAiOauth' && typeof value === 'object') { + // 确保对象被序列化 + pipeline.hset(`claude:account:${account.id}`, field, JSON.stringify(value)); + } else { + pipeline.hset(`claude:account:${account.id}`, field, value); + } + } + await pipeline.exec(); + + logger.success(`✅ Imported Claude account: ${account.name} (${account.id})`); + stats.imported++; + } catch (error) { + logger.error(`❌ Failed to import Claude account ${account.id}:`, error.message); + stats.errors++; + } + } + } + + // 导入 Gemini 账户 + if (importData.data.geminiAccounts) { + logger.info('\n📥 Importing Gemini accounts...'); + for (const account of importData.data.geminiAccounts) { + try { + const exists = await redis.client.exists(`gemini_account:${account.id}`); + + if (exists && !forceOverwrite) { + if (skipConflicts) { + logger.warn(`⏭️ Skipped existing Gemini account: ${account.name} (${account.id})`); + stats.skipped++; + continue; + } else { + const overwrite = await askConfirmation(`Gemini account "${account.name}" (${account.id}) exists. Overwrite?`); + if (!overwrite) { + stats.skipped++; + continue; + } + } + } + + // 复制账户数据以避免修改原始数据 + const accountData = { ...account }; + + // 如果数据已解密且不是脱敏数据,需要重新加密 + if (importData.metadata.decrypted && !importData.metadata.sanitized) { + logger.info(`🔐 Re-encrypting sensitive data for Gemini account: ${account.name}`); + + if (accountData.geminiOauth) { + const oauthStr = typeof accountData.geminiOauth === 'object' ? + JSON.stringify(accountData.geminiOauth) : accountData.geminiOauth; + accountData.geminiOauth = encryptGeminiData(oauthStr); + } + if (accountData.accessToken) accountData.accessToken = encryptGeminiData(accountData.accessToken); + if (accountData.refreshToken) accountData.refreshToken = encryptGeminiData(accountData.refreshToken); + } + + // 使用 hset 存储到哈希表 + const pipeline = redis.client.pipeline(); + for (const [field, value] of Object.entries(accountData)) { + pipeline.hset(`gemini_account:${account.id}`, field, value); + } + await pipeline.exec(); + + logger.success(`✅ Imported Gemini account: ${account.name} (${account.id})`); + stats.imported++; + } catch (error) { + logger.error(`❌ Failed to import Gemini account ${account.id}:`, error.message); + stats.errors++; + } + } + } + + // 导入管理员账户 + if (importData.data.admins) { + logger.info('\n📥 Importing admins...'); + for (const admin of importData.data.admins) { + try { + const exists = await redis.client.exists(`admin:${admin.id}`); + + if (exists && !forceOverwrite) { + if (skipConflicts) { + logger.warn(`⏭️ Skipped existing admin: ${admin.username} (${admin.id})`); + stats.skipped++; + continue; + } else { + const overwrite = await askConfirmation(`Admin "${admin.username}" (${admin.id}) exists. Overwrite?`); + if (!overwrite) { + stats.skipped++; + continue; + } + } + } + + // 使用 hset 存储到哈希表 + const pipeline = redis.client.pipeline(); + for (const [field, value] of Object.entries(admin)) { + pipeline.hset(`admin:${admin.id}`, field, value); + } + await pipeline.exec(); + + // 更新用户名映射 + await redis.client.set(`admin_username:${admin.username}`, admin.id); + + logger.success(`✅ Imported admin: ${admin.username} (${admin.id})`); + stats.imported++; + } catch (error) { + logger.error(`❌ Failed to import admin ${admin.id}:`, error.message); + stats.errors++; + } + } + } + + // 导入全局模型统计 + if (importData.data.globalModelStats) { + logger.info('\n📥 Importing global model statistics...'); + try { + const globalStats = importData.data.globalModelStats; + const pipeline = redis.client.pipeline(); + let globalStatCount = 0; + + // 导入每日统计 + if (globalStats.daily) { + for (const [date, models] of Object.entries(globalStats.daily)) { + for (const [model, data] of Object.entries(models)) { + for (const [field, value] of Object.entries(data)) { + pipeline.hset(`usage:model:daily:${model}:${date}`, field, value); + } + globalStatCount++; + } + } + } + + // 导入每月统计 + if (globalStats.monthly) { + for (const [month, models] of Object.entries(globalStats.monthly)) { + for (const [model, data] of Object.entries(models)) { + for (const [field, value] of Object.entries(data)) { + pipeline.hset(`usage:model:monthly:${model}:${month}`, field, value); + } + globalStatCount++; + } + } + } + + // 导入每小时统计 + if (globalStats.hourly) { + for (const [hour, models] of Object.entries(globalStats.hourly)) { + for (const [model, data] of Object.entries(models)) { + for (const [field, value] of Object.entries(data)) { + pipeline.hset(`usage:model:hourly:${model}:${hour}`, field, value); + } + globalStatCount++; + } + } + } + + await pipeline.exec(); + logger.success(`✅ Imported ${globalStatCount} global model stat entries`); + stats.imported += globalStatCount; + } catch (error) { + logger.error(`❌ Failed to import global model stats:`, error.message); + stats.errors++; + } + } + + // 显示导入结果 + console.log('\n' + '='.repeat(60)); + console.log('✅ Import Complete!'); + console.log('='.repeat(60)); + console.log(`Successfully imported: ${stats.imported}`); + console.log(`Skipped: ${stats.skipped}`); + console.log(`Errors: ${stats.errors}`); + console.log('='.repeat(60)); + + } catch (error) { + logger.error('💥 Import failed:', error); + process.exit(1); + } finally { + await redis.disconnect(); + rl.close(); + } +} + +// 主函数 +async function main() { + if (!command || command === '--help' || command === 'help') { + showHelp(); + process.exit(0); + } + + switch (command) { + case 'export': + await exportData(); + break; + + case 'import': + await importData(); + break; + + default: + logger.error(`❌ Unknown command: ${command}`); + showHelp(); + process.exit(1); + } +} + +// 运行 +main().catch(error => { + logger.error('💥 Unexpected error:', error); + process.exit(1); +}); \ No newline at end of file diff --git a/scripts/data-transfer.js b/scripts/data-transfer.js new file mode 100644 index 00000000..86cc9f0b --- /dev/null +++ b/scripts/data-transfer.js @@ -0,0 +1,517 @@ +#!/usr/bin/env node + +/** + * 数据导出/导入工具 + * + * 使用方法: + * 导出: node scripts/data-transfer.js export --output=backup.json [options] + * 导入: node scripts/data-transfer.js import --input=backup.json [options] + * + * 选项: + * --types: 要导出/导入的数据类型(apikeys,accounts,admins,all) + * --sanitize: 导出时脱敏敏感数据 + * --force: 导入时强制覆盖已存在的数据 + * --skip-conflicts: 导入时跳过冲突的数据 + */ + +const fs = require('fs').promises; +const path = require('path'); +const crypto = require('crypto'); +const redis = require('../src/models/redis'); +const logger = require('../src/utils/logger'); +const readline = require('readline'); + +// 解析命令行参数 +const args = process.argv.slice(2); +const command = args[0]; +const params = {}; + +args.slice(1).forEach(arg => { + const [key, value] = arg.split('='); + params[key.replace('--', '')] = value || true; +}); + +// 创建 readline 接口 +const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout +}); + +async function askConfirmation(question) { + return new Promise((resolve) => { + rl.question(question + ' (yes/no): ', (answer) => { + resolve(answer.toLowerCase() === 'yes' || answer.toLowerCase() === 'y'); + }); + }); +} + +// 数据脱敏函数 +function sanitizeData(data, type) { + const sanitized = { ...data }; + + switch (type) { + case 'apikey': + // 隐藏 API Key 的大部分内容 + if (sanitized.apiKey) { + sanitized.apiKey = sanitized.apiKey.substring(0, 10) + '...[REDACTED]'; + } + break; + + case 'claude_account': + case 'gemini_account': + // 隐藏 OAuth tokens + if (sanitized.accessToken) { + sanitized.accessToken = '[REDACTED]'; + } + if (sanitized.refreshToken) { + sanitized.refreshToken = '[REDACTED]'; + } + if (sanitized.claudeAiOauth) { + sanitized.claudeAiOauth = '[REDACTED]'; + } + // 隐藏代理密码 + if (sanitized.proxyPassword) { + sanitized.proxyPassword = '[REDACTED]'; + } + break; + + case 'admin': + // 隐藏管理员密码 + if (sanitized.password) { + sanitized.password = '[REDACTED]'; + } + break; + } + + return sanitized; +} + +// 导出数据 +async function exportData() { + try { + const outputFile = params.output || `backup-${new Date().toISOString().split('T')[0]}.json`; + const types = params.types ? params.types.split(',') : ['all']; + const shouldSanitize = params.sanitize === true; + + logger.info('🔄 Starting data export...'); + logger.info(`📁 Output file: ${outputFile}`); + logger.info(`📋 Data types: ${types.join(', ')}`); + logger.info(`🔒 Sanitize sensitive data: ${shouldSanitize ? 'YES' : 'NO'}`); + + // 连接 Redis + await redis.connect(); + logger.success('✅ Connected to Redis'); + + const exportData = { + metadata: { + version: '1.0', + exportDate: new Date().toISOString(), + sanitized: shouldSanitize, + types: types + }, + data: {} + }; + + // 导出 API Keys + if (types.includes('all') || types.includes('apikeys')) { + logger.info('📤 Exporting API Keys...'); + const keys = await redis.client.keys('apikey:*'); + const apiKeys = []; + + for (const key of keys) { + if (key === 'apikey:hash_map') continue; + + // 使用 hgetall 而不是 get,因为数据存储在哈希表中 + const data = await redis.client.hgetall(key); + + if (data && Object.keys(data).length > 0) { + apiKeys.push(shouldSanitize ? sanitizeData(data, 'apikey') : data); + } + } + + exportData.data.apiKeys = apiKeys; + logger.success(`✅ Exported ${apiKeys.length} API Keys`); + } + + // 导出 Claude 账户 + if (types.includes('all') || types.includes('accounts')) { + logger.info('📤 Exporting Claude accounts...'); + // 注意:Claude 账户使用 claude:account: 前缀,不是 claude_account: + const keys = await redis.client.keys('claude:account:*'); + logger.info(`Found ${keys.length} Claude account keys in Redis`); + const accounts = []; + + for (const key of keys) { + // 使用 hgetall 而不是 get,因为数据存储在哈希表中 + const data = await redis.client.hgetall(key); + + if (data && Object.keys(data).length > 0) { + // 解析 JSON 字段(如果存在) + if (data.claudeAiOauth) { + try { + data.claudeAiOauth = JSON.parse(data.claudeAiOauth); + } catch (e) { + // 保持原样 + } + } + accounts.push(shouldSanitize ? sanitizeData(data, 'claude_account') : data); + } + } + + exportData.data.claudeAccounts = accounts; + logger.success(`✅ Exported ${accounts.length} Claude accounts`); + + // 导出 Gemini 账户 + logger.info('📤 Exporting Gemini accounts...'); + const geminiKeys = await redis.client.keys('gemini_account:*'); + logger.info(`Found ${geminiKeys.length} Gemini account keys in Redis`); + const geminiAccounts = []; + + for (const key of geminiKeys) { + // 使用 hgetall 而不是 get,因为数据存储在哈希表中 + const data = await redis.client.hgetall(key); + + if (data && Object.keys(data).length > 0) { + geminiAccounts.push(shouldSanitize ? sanitizeData(data, 'gemini_account') : data); + } + } + + exportData.data.geminiAccounts = geminiAccounts; + logger.success(`✅ Exported ${geminiAccounts.length} Gemini accounts`); + } + + // 导出管理员 + if (types.includes('all') || types.includes('admins')) { + logger.info('📤 Exporting admins...'); + const keys = await redis.client.keys('admin:*'); + const admins = []; + + for (const key of keys) { + if (key.includes('admin_username:')) continue; + + // 使用 hgetall 而不是 get,因为数据存储在哈希表中 + const data = await redis.client.hgetall(key); + + if (data && Object.keys(data).length > 0) { + admins.push(shouldSanitize ? sanitizeData(data, 'admin') : data); + } + } + + exportData.data.admins = admins; + logger.success(`✅ Exported ${admins.length} admins`); + } + + // 写入文件 + await fs.writeFile(outputFile, JSON.stringify(exportData, null, 2)); + + // 显示导出摘要 + console.log('\n' + '='.repeat(60)); + console.log('✅ Export Complete!'); + console.log('='.repeat(60)); + console.log(`Output file: ${outputFile}`); + console.log(`File size: ${(await fs.stat(outputFile)).size} bytes`); + + if (exportData.data.apiKeys) { + console.log(`API Keys: ${exportData.data.apiKeys.length}`); + } + if (exportData.data.claudeAccounts) { + console.log(`Claude Accounts: ${exportData.data.claudeAccounts.length}`); + } + if (exportData.data.geminiAccounts) { + console.log(`Gemini Accounts: ${exportData.data.geminiAccounts.length}`); + } + if (exportData.data.admins) { + console.log(`Admins: ${exportData.data.admins.length}`); + } + console.log('='.repeat(60)); + + if (shouldSanitize) { + logger.warn('⚠️ Sensitive data has been sanitized in this export.'); + } + + } catch (error) { + logger.error('💥 Export failed:', error); + process.exit(1); + } finally { + await redis.disconnect(); + rl.close(); + } +} + +// 导入数据 +async function importData() { + try { + const inputFile = params.input; + if (!inputFile) { + logger.error('❌ Please specify input file with --input=filename.json'); + process.exit(1); + } + + const forceOverwrite = params.force === true; + const skipConflicts = params['skip-conflicts'] === true; + + logger.info('🔄 Starting data import...'); + logger.info(`📁 Input file: ${inputFile}`); + logger.info(`⚡ Mode: ${forceOverwrite ? 'FORCE OVERWRITE' : (skipConflicts ? 'SKIP CONFLICTS' : 'ASK ON CONFLICT')}`); + + // 读取文件 + const fileContent = await fs.readFile(inputFile, 'utf8'); + const importData = JSON.parse(fileContent); + + // 验证文件格式 + if (!importData.metadata || !importData.data) { + logger.error('❌ Invalid backup file format'); + process.exit(1); + } + + logger.info(`📅 Backup date: ${importData.metadata.exportDate}`); + logger.info(`🔒 Sanitized: ${importData.metadata.sanitized ? 'YES' : 'NO'}`); + + if (importData.metadata.sanitized) { + logger.warn('⚠️ This backup contains sanitized data. Sensitive fields will be missing!'); + const proceed = await askConfirmation('Continue with sanitized data?'); + if (!proceed) { + logger.info('❌ Import cancelled'); + return; + } + } + + // 显示导入摘要 + console.log('\n' + '='.repeat(60)); + console.log('📋 Import Summary:'); + console.log('='.repeat(60)); + if (importData.data.apiKeys) { + console.log(`API Keys to import: ${importData.data.apiKeys.length}`); + } + if (importData.data.claudeAccounts) { + console.log(`Claude Accounts to import: ${importData.data.claudeAccounts.length}`); + } + if (importData.data.geminiAccounts) { + console.log(`Gemini Accounts to import: ${importData.data.geminiAccounts.length}`); + } + if (importData.data.admins) { + console.log(`Admins to import: ${importData.data.admins.length}`); + } + console.log('='.repeat(60) + '\n'); + + // 确认导入 + const confirmed = await askConfirmation('⚠️ Proceed with import?'); + if (!confirmed) { + logger.info('❌ Import cancelled'); + return; + } + + // 连接 Redis + await redis.connect(); + logger.success('✅ Connected to Redis'); + + const stats = { + imported: 0, + skipped: 0, + errors: 0 + }; + + // 导入 API Keys + if (importData.data.apiKeys) { + logger.info('\n📥 Importing API Keys...'); + for (const apiKey of importData.data.apiKeys) { + try { + const exists = await redis.client.exists(`apikey:${apiKey.id}`); + + if (exists && !forceOverwrite) { + if (skipConflicts) { + logger.warn(`⏭️ Skipped existing API Key: ${apiKey.name} (${apiKey.id})`); + stats.skipped++; + continue; + } else { + const overwrite = await askConfirmation(`API Key "${apiKey.name}" (${apiKey.id}) exists. Overwrite?`); + if (!overwrite) { + stats.skipped++; + continue; + } + } + } + + // 使用 hset 存储到哈希表 + const pipeline = redis.client.pipeline(); + for (const [field, value] of Object.entries(apiKey)) { + pipeline.hset(`apikey:${apiKey.id}`, field, value); + } + await pipeline.exec(); + + // 更新哈希映射 + if (apiKey.apiKey && !importData.metadata.sanitized) { + await redis.client.hset('apikey:hash_map', apiKey.apiKey, apiKey.id); + } + + logger.success(`✅ Imported API Key: ${apiKey.name} (${apiKey.id})`); + stats.imported++; + } catch (error) { + logger.error(`❌ Failed to import API Key ${apiKey.id}:`, error.message); + stats.errors++; + } + } + } + + // 导入 Claude 账户 + if (importData.data.claudeAccounts) { + logger.info('\n📥 Importing Claude accounts...'); + for (const account of importData.data.claudeAccounts) { + try { + const exists = await redis.client.exists(`claude_account:${account.id}`); + + if (exists && !forceOverwrite) { + if (skipConflicts) { + logger.warn(`⏭️ Skipped existing Claude account: ${account.name} (${account.id})`); + stats.skipped++; + continue; + } else { + const overwrite = await askConfirmation(`Claude account "${account.name}" (${account.id}) exists. Overwrite?`); + if (!overwrite) { + stats.skipped++; + continue; + } + } + } + + // 使用 hset 存储到哈希表 + const pipeline = redis.client.pipeline(); + for (const [field, value] of Object.entries(account)) { + // 如果是对象,需要序列化 + if (field === 'claudeAiOauth' && typeof value === 'object') { + pipeline.hset(`claude_account:${account.id}`, field, JSON.stringify(value)); + } else { + pipeline.hset(`claude_account:${account.id}`, field, value); + } + } + await pipeline.exec(); + logger.success(`✅ Imported Claude account: ${account.name} (${account.id})`); + stats.imported++; + } catch (error) { + logger.error(`❌ Failed to import Claude account ${account.id}:`, error.message); + stats.errors++; + } + } + } + + // 导入 Gemini 账户 + if (importData.data.geminiAccounts) { + logger.info('\n📥 Importing Gemini accounts...'); + for (const account of importData.data.geminiAccounts) { + try { + const exists = await redis.client.exists(`gemini_account:${account.id}`); + + if (exists && !forceOverwrite) { + if (skipConflicts) { + logger.warn(`⏭️ Skipped existing Gemini account: ${account.name} (${account.id})`); + stats.skipped++; + continue; + } else { + const overwrite = await askConfirmation(`Gemini account "${account.name}" (${account.id}) exists. Overwrite?`); + if (!overwrite) { + stats.skipped++; + continue; + } + } + } + + // 使用 hset 存储到哈希表 + const pipeline = redis.client.pipeline(); + for (const [field, value] of Object.entries(account)) { + pipeline.hset(`gemini_account:${account.id}`, field, value); + } + await pipeline.exec(); + logger.success(`✅ Imported Gemini account: ${account.name} (${account.id})`); + stats.imported++; + } catch (error) { + logger.error(`❌ Failed to import Gemini account ${account.id}:`, error.message); + stats.errors++; + } + } + } + + // 显示导入结果 + console.log('\n' + '='.repeat(60)); + console.log('✅ Import Complete!'); + console.log('='.repeat(60)); + console.log(`Successfully imported: ${stats.imported}`); + console.log(`Skipped: ${stats.skipped}`); + console.log(`Errors: ${stats.errors}`); + console.log('='.repeat(60)); + + } catch (error) { + logger.error('💥 Import failed:', error); + process.exit(1); + } finally { + await redis.disconnect(); + rl.close(); + } +} + +// 显示帮助信息 +function showHelp() { + console.log(` +Data Transfer Tool for Claude Relay Service + +This tool allows you to export and import data between environments. + +Usage: + node scripts/data-transfer.js [options] + +Commands: + export Export data from Redis to a JSON file + import Import data from a JSON file to Redis + +Export Options: + --output=FILE Output filename (default: backup-YYYY-MM-DD.json) + --types=TYPE,... Data types to export: apikeys,accounts,admins,all (default: all) + --sanitize Remove sensitive data from export + +Import Options: + --input=FILE Input filename (required) + --force Overwrite existing data without asking + --skip-conflicts Skip conflicting data without asking + +Examples: + # Export all data + node scripts/data-transfer.js export + + # Export only API keys with sanitized data + node scripts/data-transfer.js export --types=apikeys --sanitize + + # Import data, skip conflicts + node scripts/data-transfer.js import --input=backup.json --skip-conflicts + + # Export specific data types + node scripts/data-transfer.js export --types=apikeys,accounts --output=prod-data.json +`); +} + +// 主函数 +async function main() { + if (!command || command === '--help' || command === 'help') { + showHelp(); + process.exit(0); + } + + switch (command) { + case 'export': + await exportData(); + break; + + case 'import': + await importData(); + break; + + default: + logger.error(`❌ Unknown command: ${command}`); + showHelp(); + process.exit(1); + } +} + +// 运行 +main().catch(error => { + logger.error('💥 Unexpected error:', error); + process.exit(1); +}); \ No newline at end of file diff --git a/scripts/debug-redis-keys.js b/scripts/debug-redis-keys.js new file mode 100644 index 00000000..22b25050 --- /dev/null +++ b/scripts/debug-redis-keys.js @@ -0,0 +1,123 @@ +#!/usr/bin/env node + +/** + * Redis 键调试工具 + * 用于查看 Redis 中存储的所有键和数据结构 + */ + +const redis = require('../src/models/redis'); +const logger = require('../src/utils/logger'); + +async function debugRedisKeys() { + try { + logger.info('🔄 Connecting to Redis...'); + await redis.connect(); + logger.success('✅ Connected to Redis'); + + // 获取所有键 + const allKeys = await redis.client.keys('*'); + logger.info(`\n📊 Total keys in Redis: ${allKeys.length}\n`); + + // 按类型分组 + const keysByType = { + apiKeys: [], + claudeAccounts: [], + geminiAccounts: [], + admins: [], + sessions: [], + usage: [], + other: [] + }; + + // 分类键 + for (const key of allKeys) { + if (key.startsWith('apikey:')) { + keysByType.apiKeys.push(key); + } else if (key.startsWith('claude_account:')) { + keysByType.claudeAccounts.push(key); + } else if (key.startsWith('gemini_account:')) { + keysByType.geminiAccounts.push(key); + } else if (key.startsWith('admin:') || key.startsWith('admin_username:')) { + keysByType.admins.push(key); + } else if (key.startsWith('session:')) { + keysByType.sessions.push(key); + } else if (key.includes('usage') || key.includes('rate_limit') || key.includes('concurrency')) { + keysByType.usage.push(key); + } else { + keysByType.other.push(key); + } + } + + // 显示分类结果 + console.log('='.repeat(60)); + console.log('📂 Keys by Category:'); + console.log('='.repeat(60)); + console.log(`API Keys: ${keysByType.apiKeys.length}`); + console.log(`Claude Accounts: ${keysByType.claudeAccounts.length}`); + console.log(`Gemini Accounts: ${keysByType.geminiAccounts.length}`); + console.log(`Admins: ${keysByType.admins.length}`); + console.log(`Sessions: ${keysByType.sessions.length}`); + console.log(`Usage/Rate Limit: ${keysByType.usage.length}`); + console.log(`Other: ${keysByType.other.length}`); + console.log('='.repeat(60)); + + // 详细显示每个类别的键 + if (keysByType.apiKeys.length > 0) { + console.log('\n🔑 API Keys:'); + for (const key of keysByType.apiKeys.slice(0, 5)) { + console.log(` - ${key}`); + } + if (keysByType.apiKeys.length > 5) { + console.log(` ... and ${keysByType.apiKeys.length - 5} more`); + } + } + + if (keysByType.claudeAccounts.length > 0) { + console.log('\n🤖 Claude Accounts:'); + for (const key of keysByType.claudeAccounts) { + console.log(` - ${key}`); + } + } + + if (keysByType.geminiAccounts.length > 0) { + console.log('\n💎 Gemini Accounts:'); + for (const key of keysByType.geminiAccounts) { + console.log(` - ${key}`); + } + } + + if (keysByType.other.length > 0) { + console.log('\n❓ Other Keys:'); + for (const key of keysByType.other.slice(0, 10)) { + console.log(` - ${key}`); + } + if (keysByType.other.length > 10) { + console.log(` ... and ${keysByType.other.length - 10} more`); + } + } + + // 检查数据类型 + console.log('\n' + '='.repeat(60)); + console.log('🔍 Checking Data Types:'); + console.log('='.repeat(60)); + + // 随机检查几个键的类型 + const sampleKeys = allKeys.slice(0, Math.min(10, allKeys.length)); + for (const key of sampleKeys) { + const type = await redis.client.type(key); + console.log(`${key} => ${type}`); + } + + } catch (error) { + logger.error('💥 Debug failed:', error); + } finally { + await redis.disconnect(); + logger.info('👋 Disconnected from Redis'); + } +} + +// 运行调试 +debugRedisKeys().catch(error => { + logger.error('💥 Unexpected error:', error); + process.exit(1); +}); \ No newline at end of file diff --git a/scripts/fix-inquirer.js b/scripts/fix-inquirer.js new file mode 100644 index 00000000..9afa8d5f --- /dev/null +++ b/scripts/fix-inquirer.js @@ -0,0 +1,32 @@ +#!/usr/bin/env node + +/** + * 修复 inquirer ESM 问题 + * 降级到支持 CommonJS 的版本 + */ + +const { execSync } = require('child_process'); +const fs = require('fs'); +const path = require('path'); + +console.log('🔧 修复 inquirer ESM 兼容性问题...\n'); + +try { + // 卸载当前版本 + console.log('📦 卸载当前 inquirer 版本...'); + execSync('npm uninstall inquirer', { stdio: 'inherit' }); + + // 安装兼容 CommonJS 的版本 (8.x 是最后支持 CommonJS 的主要版本) + console.log('\n📦 安装兼容版本 inquirer@8.2.6...'); + execSync('npm install inquirer@8.2.6', { stdio: 'inherit' }); + + console.log('\n✅ 修复完成!'); + console.log('\n现在可以正常使用 CLI 工具了:'); + console.log(' npm run cli admin'); + console.log(' npm run cli keys'); + console.log(' npm run cli status'); + +} catch (error) { + console.error('❌ 修复失败:', error.message); + process.exit(1); +} \ No newline at end of file diff --git a/scripts/fix-usage-stats.js b/scripts/fix-usage-stats.js new file mode 100644 index 00000000..b725cc4a --- /dev/null +++ b/scripts/fix-usage-stats.js @@ -0,0 +1,227 @@ +#!/usr/bin/env node + +/** + * 数据迁移脚本:修复历史使用统计数据 + * + * 功能: + * 1. 统一 totalTokens 和 allTokens 字段 + * 2. 确保 allTokens 包含所有类型的 tokens + * 3. 修复历史数据的不一致性 + * + * 使用方法: + * node scripts/fix-usage-stats.js [--dry-run] + */ + +require('dotenv').config(); +const redis = require('../src/models/redis'); +const logger = require('../src/utils/logger'); + +// 解析命令行参数 +const args = process.argv.slice(2); +const isDryRun = args.includes('--dry-run'); + +async function fixUsageStats() { + try { + logger.info('🔧 开始修复使用统计数据...'); + if (isDryRun) { + logger.info('📝 DRY RUN 模式 - 不会实际修改数据'); + } + + // 连接到 Redis + await redis.connect(); + logger.success('✅ 已连接到 Redis'); + + const client = redis.getClientSafe(); + + // 统计信息 + let stats = { + totalKeys: 0, + fixedTotalKeys: 0, + fixedDailyKeys: 0, + fixedMonthlyKeys: 0, + fixedModelKeys: 0, + errors: 0 + }; + + // 1. 修复 API Key 级别的总统计 + logger.info('\n📊 修复 API Key 总统计数据...'); + const apiKeyPattern = 'apikey:*'; + const apiKeys = await client.keys(apiKeyPattern); + stats.totalKeys = apiKeys.length; + + for (const apiKeyKey of apiKeys) { + const keyId = apiKeyKey.replace('apikey:', ''); + const usageKey = `usage:${keyId}`; + + try { + const usageData = await client.hgetall(usageKey); + if (usageData && Object.keys(usageData).length > 0) { + const inputTokens = parseInt(usageData.totalInputTokens) || 0; + const outputTokens = parseInt(usageData.totalOutputTokens) || 0; + const cacheCreateTokens = parseInt(usageData.totalCacheCreateTokens) || 0; + const cacheReadTokens = parseInt(usageData.totalCacheReadTokens) || 0; + const currentAllTokens = parseInt(usageData.totalAllTokens) || 0; + + // 计算正确的 allTokens + const correctAllTokens = inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens; + + if (currentAllTokens !== correctAllTokens && correctAllTokens > 0) { + logger.info(` 修复 ${keyId}: ${currentAllTokens} -> ${correctAllTokens}`); + + if (!isDryRun) { + await client.hset(usageKey, 'totalAllTokens', correctAllTokens); + } + stats.fixedTotalKeys++; + } + } + } catch (error) { + logger.error(` 错误处理 ${keyId}: ${error.message}`); + stats.errors++; + } + } + + // 2. 修复每日统计数据 + logger.info('\n📅 修复每日统计数据...'); + const dailyPattern = 'usage:daily:*'; + const dailyKeys = await client.keys(dailyPattern); + + for (const dailyKey of dailyKeys) { + try { + const data = await client.hgetall(dailyKey); + if (data && Object.keys(data).length > 0) { + const inputTokens = parseInt(data.inputTokens) || 0; + const outputTokens = parseInt(data.outputTokens) || 0; + const cacheCreateTokens = parseInt(data.cacheCreateTokens) || 0; + const cacheReadTokens = parseInt(data.cacheReadTokens) || 0; + const currentAllTokens = parseInt(data.allTokens) || 0; + + const correctAllTokens = inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens; + + if (currentAllTokens !== correctAllTokens && correctAllTokens > 0) { + if (!isDryRun) { + await client.hset(dailyKey, 'allTokens', correctAllTokens); + } + stats.fixedDailyKeys++; + } + } + } catch (error) { + logger.error(` 错误处理 ${dailyKey}: ${error.message}`); + stats.errors++; + } + } + + // 3. 修复每月统计数据 + logger.info('\n📆 修复每月统计数据...'); + const monthlyPattern = 'usage:monthly:*'; + const monthlyKeys = await client.keys(monthlyPattern); + + for (const monthlyKey of monthlyKeys) { + try { + const data = await client.hgetall(monthlyKey); + if (data && Object.keys(data).length > 0) { + const inputTokens = parseInt(data.inputTokens) || 0; + const outputTokens = parseInt(data.outputTokens) || 0; + const cacheCreateTokens = parseInt(data.cacheCreateTokens) || 0; + const cacheReadTokens = parseInt(data.cacheReadTokens) || 0; + const currentAllTokens = parseInt(data.allTokens) || 0; + + const correctAllTokens = inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens; + + if (currentAllTokens !== correctAllTokens && correctAllTokens > 0) { + if (!isDryRun) { + await client.hset(monthlyKey, 'allTokens', correctAllTokens); + } + stats.fixedMonthlyKeys++; + } + } + } catch (error) { + logger.error(` 错误处理 ${monthlyKey}: ${error.message}`); + stats.errors++; + } + } + + // 4. 修复模型级别的统计数据 + logger.info('\n🤖 修复模型级别统计数据...'); + const modelPatterns = [ + 'usage:model:daily:*', + 'usage:model:monthly:*', + 'usage:*:model:daily:*', + 'usage:*:model:monthly:*' + ]; + + for (const pattern of modelPatterns) { + const modelKeys = await client.keys(pattern); + + for (const modelKey of modelKeys) { + try { + const data = await client.hgetall(modelKey); + if (data && Object.keys(data).length > 0) { + const inputTokens = parseInt(data.inputTokens) || 0; + const outputTokens = parseInt(data.outputTokens) || 0; + const cacheCreateTokens = parseInt(data.cacheCreateTokens) || 0; + const cacheReadTokens = parseInt(data.cacheReadTokens) || 0; + const currentAllTokens = parseInt(data.allTokens) || 0; + + const correctAllTokens = inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens; + + if (currentAllTokens !== correctAllTokens && correctAllTokens > 0) { + if (!isDryRun) { + await client.hset(modelKey, 'allTokens', correctAllTokens); + } + stats.fixedModelKeys++; + } + } + } catch (error) { + logger.error(` 错误处理 ${modelKey}: ${error.message}`); + stats.errors++; + } + } + } + + // 5. 验证修复结果 + if (!isDryRun) { + logger.info('\n✅ 验证修复结果...'); + + // 随机抽样验证 + const sampleSize = Math.min(5, apiKeys.length); + for (let i = 0; i < sampleSize; i++) { + const randomIndex = Math.floor(Math.random() * apiKeys.length); + const keyId = apiKeys[randomIndex].replace('apikey:', ''); + const usage = await redis.getUsageStats(keyId); + + logger.info(` 样本 ${keyId}:`); + logger.info(` Total tokens: ${usage.total.tokens}`); + logger.info(` All tokens: ${usage.total.allTokens}`); + logger.info(` 一致性: ${usage.total.tokens === usage.total.allTokens ? '✅' : '❌'}`); + } + } + + // 打印统计结果 + logger.info('\n📊 修复统计:'); + logger.info(` 总 API Keys: ${stats.totalKeys}`); + logger.info(` 修复的总统计: ${stats.fixedTotalKeys}`); + logger.info(` 修复的日统计: ${stats.fixedDailyKeys}`); + logger.info(` 修复的月统计: ${stats.fixedMonthlyKeys}`); + logger.info(` 修复的模型统计: ${stats.fixedModelKeys}`); + logger.info(` 错误数: ${stats.errors}`); + + if (isDryRun) { + logger.info('\n💡 这是 DRY RUN - 没有实际修改数据'); + logger.info(' 运行不带 --dry-run 参数来实际执行修复'); + } else { + logger.success('\n✅ 数据修复完成!'); + } + + } catch (error) { + logger.error('❌ 修复过程出错:', error); + process.exit(1); + } finally { + await redis.disconnect(); + } +} + +// 执行修复 +fixUsageStats().catch(error => { + logger.error('❌ 未处理的错误:', error); + process.exit(1); +}); \ No newline at end of file diff --git a/scripts/generate-test-data.js b/scripts/generate-test-data.js new file mode 100755 index 00000000..1cfc8ddb --- /dev/null +++ b/scripts/generate-test-data.js @@ -0,0 +1,284 @@ +#!/usr/bin/env node + +/** + * 历史数据生成脚本 + * 用于测试不同时间范围的Token统计功能 + * + * 使用方法: + * node scripts/generate-test-data.js [--clean] + * + * 选项: + * --clean: 清除所有测试数据 + */ + +const redis = require('../src/models/redis'); +const logger = require('../src/utils/logger'); + +// 解析命令行参数 +const args = process.argv.slice(2); +const shouldClean = args.includes('--clean'); + +// 模拟的模型列表 +const models = [ + 'claude-sonnet-4-20250514', + 'claude-3-5-sonnet-20241022', + 'claude-3-5-haiku-20241022', + 'claude-3-opus-20240229' +]; + +// 生成指定日期的数据 +async function generateDataForDate(apiKeyId, date, dayOffset) { + const client = redis.getClientSafe(); + const dateStr = date.toISOString().split('T')[0]; + const month = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`; + + // 根据日期偏移量调整数据量(越近的日期数据越多) + const requestCount = Math.max(5, 20 - dayOffset * 2); // 5-20个请求 + + logger.info(`📊 Generating ${requestCount} requests for ${dateStr}`); + + for (let i = 0; i < requestCount; i++) { + // 随机选择模型 + const model = models[Math.floor(Math.random() * models.length)]; + + // 生成随机Token数据 + const inputTokens = Math.floor(Math.random() * 2000) + 500; // 500-2500 + const outputTokens = Math.floor(Math.random() * 3000) + 1000; // 1000-4000 + const cacheCreateTokens = Math.random() > 0.7 ? Math.floor(Math.random() * 1000) : 0; // 30%概率有缓存创建 + const cacheReadTokens = Math.random() > 0.5 ? Math.floor(Math.random() * 500) : 0; // 50%概率有缓存读取 + + const coreTokens = inputTokens + outputTokens; + const allTokens = inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens; + + // 更新各种统计键 + const totalKey = `usage:${apiKeyId}`; + const dailyKey = `usage:daily:${apiKeyId}:${dateStr}`; + const monthlyKey = `usage:monthly:${apiKeyId}:${month}`; + const modelDailyKey = `usage:model:daily:${model}:${dateStr}`; + const modelMonthlyKey = `usage:model:monthly:${model}:${month}`; + const keyModelDailyKey = `usage:${apiKeyId}:model:daily:${model}:${dateStr}`; + const keyModelMonthlyKey = `usage:${apiKeyId}:model:monthly:${model}:${month}`; + + await Promise.all([ + // 总计数据 + client.hincrby(totalKey, 'totalTokens', coreTokens), + client.hincrby(totalKey, 'totalInputTokens', inputTokens), + client.hincrby(totalKey, 'totalOutputTokens', outputTokens), + client.hincrby(totalKey, 'totalCacheCreateTokens', cacheCreateTokens), + client.hincrby(totalKey, 'totalCacheReadTokens', cacheReadTokens), + client.hincrby(totalKey, 'totalAllTokens', allTokens), + client.hincrby(totalKey, 'totalRequests', 1), + + // 每日统计 + client.hincrby(dailyKey, 'tokens', coreTokens), + client.hincrby(dailyKey, 'inputTokens', inputTokens), + client.hincrby(dailyKey, 'outputTokens', outputTokens), + client.hincrby(dailyKey, 'cacheCreateTokens', cacheCreateTokens), + client.hincrby(dailyKey, 'cacheReadTokens', cacheReadTokens), + client.hincrby(dailyKey, 'allTokens', allTokens), + client.hincrby(dailyKey, 'requests', 1), + + // 每月统计 + client.hincrby(monthlyKey, 'tokens', coreTokens), + client.hincrby(monthlyKey, 'inputTokens', inputTokens), + client.hincrby(monthlyKey, 'outputTokens', outputTokens), + client.hincrby(monthlyKey, 'cacheCreateTokens', cacheCreateTokens), + client.hincrby(monthlyKey, 'cacheReadTokens', cacheReadTokens), + client.hincrby(monthlyKey, 'allTokens', allTokens), + client.hincrby(monthlyKey, 'requests', 1), + + // 模型统计 - 每日 + client.hincrby(modelDailyKey, 'totalInputTokens', inputTokens), + client.hincrby(modelDailyKey, 'totalOutputTokens', outputTokens), + client.hincrby(modelDailyKey, 'totalCacheCreateTokens', cacheCreateTokens), + client.hincrby(modelDailyKey, 'totalCacheReadTokens', cacheReadTokens), + client.hincrby(modelDailyKey, 'totalAllTokens', allTokens), + client.hincrby(modelDailyKey, 'requests', 1), + + // 模型统计 - 每月 + client.hincrby(modelMonthlyKey, 'totalInputTokens', inputTokens), + client.hincrby(modelMonthlyKey, 'totalOutputTokens', outputTokens), + client.hincrby(modelMonthlyKey, 'totalCacheCreateTokens', cacheCreateTokens), + client.hincrby(modelMonthlyKey, 'totalCacheReadTokens', cacheReadTokens), + client.hincrby(modelMonthlyKey, 'totalAllTokens', allTokens), + client.hincrby(modelMonthlyKey, 'requests', 1), + + // API Key级别的模型统计 - 每日 + // 同时存储带total前缀和不带前缀的字段,保持兼容性 + client.hincrby(keyModelDailyKey, 'inputTokens', inputTokens), + client.hincrby(keyModelDailyKey, 'outputTokens', outputTokens), + client.hincrby(keyModelDailyKey, 'cacheCreateTokens', cacheCreateTokens), + client.hincrby(keyModelDailyKey, 'cacheReadTokens', cacheReadTokens), + client.hincrby(keyModelDailyKey, 'allTokens', allTokens), + client.hincrby(keyModelDailyKey, 'totalInputTokens', inputTokens), + client.hincrby(keyModelDailyKey, 'totalOutputTokens', outputTokens), + client.hincrby(keyModelDailyKey, 'totalCacheCreateTokens', cacheCreateTokens), + client.hincrby(keyModelDailyKey, 'totalCacheReadTokens', cacheReadTokens), + client.hincrby(keyModelDailyKey, 'totalAllTokens', allTokens), + client.hincrby(keyModelDailyKey, 'requests', 1), + + // API Key级别的模型统计 - 每月 + client.hincrby(keyModelMonthlyKey, 'inputTokens', inputTokens), + client.hincrby(keyModelMonthlyKey, 'outputTokens', outputTokens), + client.hincrby(keyModelMonthlyKey, 'cacheCreateTokens', cacheCreateTokens), + client.hincrby(keyModelMonthlyKey, 'cacheReadTokens', cacheReadTokens), + client.hincrby(keyModelMonthlyKey, 'allTokens', allTokens), + client.hincrby(keyModelMonthlyKey, 'totalInputTokens', inputTokens), + client.hincrby(keyModelMonthlyKey, 'totalOutputTokens', outputTokens), + client.hincrby(keyModelMonthlyKey, 'totalCacheCreateTokens', cacheCreateTokens), + client.hincrby(keyModelMonthlyKey, 'totalCacheReadTokens', cacheReadTokens), + client.hincrby(keyModelMonthlyKey, 'totalAllTokens', allTokens), + client.hincrby(keyModelMonthlyKey, 'requests', 1), + ]); + } +} + +// 清除测试数据 +async function cleanTestData() { + const client = redis.getClientSafe(); + const apiKeyService = require('../src/services/apiKeyService'); + + logger.info('🧹 Cleaning test data...'); + + // 获取所有API Keys + const allKeys = await apiKeyService.getAllApiKeys(); + + // 找出所有测试 API Keys + const testKeys = allKeys.filter(key => key.name && key.name.startsWith('Test API Key')); + + for (const testKey of testKeys) { + const apiKeyId = testKey.id; + + // 获取所有相关的键 + const patterns = [ + `usage:${apiKeyId}`, + `usage:daily:${apiKeyId}:*`, + `usage:monthly:${apiKeyId}:*`, + `usage:${apiKeyId}:model:daily:*`, + `usage:${apiKeyId}:model:monthly:*` + ]; + + for (const pattern of patterns) { + const keys = await client.keys(pattern); + if (keys.length > 0) { + await client.del(...keys); + logger.info(`🗑️ Deleted ${keys.length} keys matching pattern: ${pattern}`); + } + } + + // 删除 API Key 本身 + await apiKeyService.deleteApiKey(apiKeyId); + logger.info(`🗑️ Deleted test API Key: ${testKey.name} (${apiKeyId})`); + } + + // 清除模型统计 + const modelPatterns = [ + 'usage:model:daily:*', + 'usage:model:monthly:*' + ]; + + for (const pattern of modelPatterns) { + const keys = await client.keys(pattern); + if (keys.length > 0) { + await client.del(...keys); + logger.info(`🗑️ Deleted ${keys.length} keys matching pattern: ${pattern}`); + } + } +} + +// 主函数 +async function main() { + try { + await redis.connect(); + logger.success('✅ Connected to Redis'); + + // 创建测试API Keys + const apiKeyService = require('../src/services/apiKeyService'); + let testApiKeys = []; + let createdKeys = []; + + // 总是创建新的测试 API Keys + logger.info('📝 Creating test API Keys...'); + + for (let i = 1; i <= 3; i++) { + const newKey = await apiKeyService.generateApiKey({ + name: `Test API Key ${i}`, + description: `Test key for historical data generation ${i}`, + tokenLimit: 10000000, + concurrencyLimit: 10, + rateLimitWindow: 60, + rateLimitRequests: 100 + }); + + testApiKeys.push(newKey.id); + createdKeys.push(newKey); + logger.success(`✅ Created test API Key: ${newKey.name} (${newKey.id})`); + logger.info(` 🔑 API Key: ${newKey.apiKey}`); + } + + if (shouldClean) { + await cleanTestData(); + logger.success('✅ Test data cleaned successfully'); + return; + } + + // 生成历史数据 + const now = new Date(); + + for (const apiKeyId of testApiKeys) { + logger.info(`\n🔄 Generating data for API Key: ${apiKeyId}`); + + // 生成过去30天的数据 + for (let dayOffset = 0; dayOffset < 30; dayOffset++) { + const date = new Date(now); + date.setDate(date.getDate() - dayOffset); + + await generateDataForDate(apiKeyId, date, dayOffset); + } + + logger.success(`✅ Generated 30 days of historical data for API Key: ${apiKeyId}`); + } + + // 显示统计摘要 + logger.info('\n📊 Test Data Summary:'); + logger.info('='.repeat(60)); + + for (const apiKeyId of testApiKeys) { + const totalKey = `usage:${apiKeyId}`; + const totalData = await redis.getClientSafe().hgetall(totalKey); + + if (totalData && Object.keys(totalData).length > 0) { + logger.info(`\nAPI Key: ${apiKeyId}`); + logger.info(` Total Requests: ${totalData.totalRequests || 0}`); + logger.info(` Total Tokens (Core): ${totalData.totalTokens || 0}`); + logger.info(` Total Tokens (All): ${totalData.totalAllTokens || 0}`); + logger.info(` Input Tokens: ${totalData.totalInputTokens || 0}`); + logger.info(` Output Tokens: ${totalData.totalOutputTokens || 0}`); + logger.info(` Cache Create Tokens: ${totalData.totalCacheCreateTokens || 0}`); + logger.info(` Cache Read Tokens: ${totalData.totalCacheReadTokens || 0}`); + } + } + + logger.info('\n' + '='.repeat(60)); + logger.success('\n✅ Test data generation completed!'); + logger.info('\n📋 Created API Keys:'); + for (const key of createdKeys) { + logger.info(`- ${key.name}: ${key.apiKey}`); + } + logger.info('\n💡 Tips:'); + logger.info('- Check the admin panel to see the different time ranges'); + logger.info('- Use --clean flag to remove all test data and API Keys'); + logger.info('- The script generates more recent data to simulate real usage patterns'); + + } catch (error) { + logger.error('❌ Error:', error); + } finally { + await redis.disconnect(); + } +} + +// 运行脚本 +main().catch(error => { + logger.error('💥 Unexpected error:', error); + process.exit(1); +}); \ No newline at end of file diff --git a/scripts/migrate-apikey-expiry.js b/scripts/migrate-apikey-expiry.js new file mode 100644 index 00000000..a7d85fc2 --- /dev/null +++ b/scripts/migrate-apikey-expiry.js @@ -0,0 +1,191 @@ +#!/usr/bin/env node + +/** + * 数据迁移脚本:为现有 API Key 设置默认有效期 + * + * 使用方法: + * node scripts/migrate-apikey-expiry.js [--days=30] [--dry-run] + * + * 参数: + * --days: 设置默认有效期天数(默认30天) + * --dry-run: 仅模拟运行,不实际修改数据 + */ + +const redis = require('../src/models/redis'); +const logger = require('../src/utils/logger'); +const readline = require('readline'); + +// 解析命令行参数 +const args = process.argv.slice(2); +const params = {}; +args.forEach(arg => { + const [key, value] = arg.split('='); + params[key.replace('--', '')] = value || true; +}); + +const DEFAULT_DAYS = params.days ? parseInt(params.days) : 30; +const DRY_RUN = params['dry-run'] === true; + +// 创建 readline 接口用于用户确认 +const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout +}); + +async function askConfirmation(question) { + return new Promise((resolve) => { + rl.question(question + ' (yes/no): ', (answer) => { + resolve(answer.toLowerCase() === 'yes' || answer.toLowerCase() === 'y'); + }); + }); +} + +async function migrateApiKeys() { + try { + logger.info('🔄 Starting API Key expiry migration...'); + logger.info(`📅 Default expiry period: ${DEFAULT_DAYS} days`); + logger.info(`🔍 Mode: ${DRY_RUN ? 'DRY RUN (no changes will be made)' : 'LIVE RUN'}`); + + // 连接 Redis + await redis.connect(); + logger.success('✅ Connected to Redis'); + + // 获取所有 API Keys + const apiKeys = await redis.getAllApiKeys(); + logger.info(`📊 Found ${apiKeys.length} API Keys in total`); + + // 统计信息 + const stats = { + total: apiKeys.length, + needsMigration: 0, + alreadyHasExpiry: 0, + migrated: 0, + errors: 0 + }; + + // 需要迁移的 Keys + const keysToMigrate = []; + + // 分析每个 API Key + for (const key of apiKeys) { + if (!key.expiresAt || key.expiresAt === 'null' || key.expiresAt === '') { + keysToMigrate.push(key); + stats.needsMigration++; + logger.info(`📌 API Key "${key.name}" (${key.id}) needs migration`); + } else { + stats.alreadyHasExpiry++; + const expiryDate = new Date(key.expiresAt); + logger.info(`✓ API Key "${key.name}" (${key.id}) already has expiry: ${expiryDate.toLocaleString()}`); + } + } + + if (keysToMigrate.length === 0) { + logger.success('✨ No API Keys need migration!'); + return; + } + + // 显示迁移摘要 + console.log('\n' + '='.repeat(60)); + console.log('📋 Migration Summary:'); + console.log('='.repeat(60)); + console.log(`Total API Keys: ${stats.total}`); + console.log(`Already have expiry: ${stats.alreadyHasExpiry}`); + console.log(`Need migration: ${stats.needsMigration}`); + console.log(`Default expiry: ${DEFAULT_DAYS} days from now`); + console.log('='.repeat(60) + '\n'); + + // 如果不是 dry run,请求确认 + if (!DRY_RUN) { + const confirmed = await askConfirmation( + `⚠️ This will set expiry dates for ${keysToMigrate.length} API Keys. Continue?` + ); + + if (!confirmed) { + logger.warn('❌ Migration cancelled by user'); + return; + } + } + + // 计算新的过期时间 + const newExpiryDate = new Date(); + newExpiryDate.setDate(newExpiryDate.getDate() + DEFAULT_DAYS); + const newExpiryISO = newExpiryDate.toISOString(); + + logger.info(`\n🚀 Starting migration... New expiry date: ${newExpiryDate.toLocaleString()}`); + + // 执行迁移 + for (const key of keysToMigrate) { + try { + if (!DRY_RUN) { + // 直接更新 Redis 中的数据 + // 使用 hset 更新单个字段 + await redis.client.hset(`apikey:${key.id}`, 'expiresAt', newExpiryISO); + logger.success(`✅ Migrated: "${key.name}" (${key.id})`); + } else { + logger.info(`[DRY RUN] Would migrate: "${key.name}" (${key.id})`); + } + stats.migrated++; + } catch (error) { + logger.error(`❌ Error migrating "${key.name}" (${key.id}):`, error.message); + stats.errors++; + } + } + + // 显示最终结果 + console.log('\n' + '='.repeat(60)); + console.log('✅ Migration Complete!'); + console.log('='.repeat(60)); + console.log(`Successfully migrated: ${stats.migrated}`); + console.log(`Errors: ${stats.errors}`); + console.log(`New expiry date: ${newExpiryDate.toLocaleString()}`); + console.log('='.repeat(60) + '\n'); + + if (DRY_RUN) { + logger.warn('⚠️ This was a DRY RUN. No actual changes were made.'); + logger.info('💡 Run without --dry-run flag to apply changes.'); + } + + } catch (error) { + logger.error('💥 Migration failed:', error); + process.exit(1); + } finally { + // 清理 + rl.close(); + await redis.disconnect(); + logger.info('👋 Disconnected from Redis'); + } +} + +// 显示帮助信息 +if (params.help) { + console.log(` +API Key Expiry Migration Script + +This script adds expiry dates to existing API Keys that don't have one. + +Usage: + node scripts/migrate-apikey-expiry.js [options] + +Options: + --days=NUMBER Set default expiry days (default: 30) + --dry-run Simulate the migration without making changes + --help Show this help message + +Examples: + # Set 30-day expiry for all API Keys without expiry + node scripts/migrate-apikey-expiry.js + + # Set 90-day expiry + node scripts/migrate-apikey-expiry.js --days=90 + + # Test run without making changes + node scripts/migrate-apikey-expiry.js --dry-run +`); + process.exit(0); +} + +// 运行迁移 +migrateApiKeys().catch(error => { + logger.error('💥 Unexpected error:', error); + process.exit(1); +}); \ No newline at end of file diff --git a/scripts/test-apikey-expiry.js b/scripts/test-apikey-expiry.js new file mode 100644 index 00000000..a799e8d5 --- /dev/null +++ b/scripts/test-apikey-expiry.js @@ -0,0 +1,159 @@ +#!/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-import-encryption.js b/scripts/test-import-encryption.js new file mode 100644 index 00000000..40cd5b3b --- /dev/null +++ b/scripts/test-import-encryption.js @@ -0,0 +1,181 @@ +#!/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 42168da5..c26665ce 100644 --- a/src/app.js +++ b/src/app.js @@ -16,6 +16,7 @@ const pricingService = require('./services/pricingService'); const apiRoutes = require('./routes/api'); const adminRoutes = require('./routes/admin'); const webRoutes = require('./routes/web'); +const apiStatsRoutes = require('./routes/apiStats'); const geminiRoutes = require('./routes/geminiRoutes'); const openaiGeminiRoutes = require('./routes/openaiGeminiRoutes'); const openaiClaudeRoutes = require('./routes/openaiClaudeRoutes'); @@ -51,6 +52,16 @@ class Application { logger.info('🔄 Initializing admin credentials...'); await this.initializeAdmin(); + // 💰 初始化费用数据 + logger.info('💰 Checking cost data initialization...'); + const costInitService = require('./services/costInitService'); + const needsInit = await costInitService.needsInitialization(); + if (needsInit) { + logger.info('💰 Initializing cost data for all API Keys...'); + const result = await costInitService.initializeAllCosts(); + logger.info(`💰 Cost initialization completed: ${result.processed} processed, ${result.errors} errors`); + } + // 🛡️ 安全中间件 this.app.use(helmet({ contentSecurityPolicy: false, // 允许内联样式和脚本 @@ -110,13 +121,14 @@ class Application { this.app.use('/claude', apiRoutes); // /claude 路由别名,与 /api 功能相同 this.app.use('/admin', adminRoutes); this.app.use('/web', webRoutes); + this.app.use('/apiStats', apiStatsRoutes); this.app.use('/gemini', geminiRoutes); this.app.use('/openai/gemini', openaiGeminiRoutes); this.app.use('/openai/claude', openaiClaudeRoutes); - // 🏠 根路径重定向到管理界面 + // 🏠 根路径重定向到API统计页面 this.app.get('/', (req, res) => { - res.redirect('/web'); + res.redirect('/apiStats'); }); // 🏥 增强的健康检查端点 diff --git a/src/cli/initCosts.js b/src/cli/initCosts.js new file mode 100644 index 00000000..0dc4aff8 --- /dev/null +++ b/src/cli/initCosts.js @@ -0,0 +1,32 @@ +#!/usr/bin/env node + +const costInitService = require('../services/costInitService'); +const logger = require('../utils/logger'); +const redis = require('../models/redis'); + +async function main() { + try { + // 连接Redis + await redis.connect(); + + console.log('💰 Starting cost data initialization...\n'); + + // 执行初始化 + const result = await costInitService.initializeAllCosts(); + + console.log('\n✅ Cost initialization completed!'); + console.log(` Processed: ${result.processed} API Keys`); + console.log(` Errors: ${result.errors}`); + + // 断开连接 + await redis.disconnect(); + process.exit(0); + } catch (error) { + console.error('\n❌ Cost initialization failed:', error.message); + logger.error('Cost initialization failed:', error); + process.exit(1); + } +} + +// 运行主函数 +main(); \ No newline at end of file diff --git a/src/middleware/auth.js b/src/middleware/auth.js index 6bd9929b..d27add4a 100644 --- a/src/middleware/auth.js +++ b/src/middleware/auth.js @@ -2,6 +2,7 @@ const apiKeyService = require('../services/apiKeyService'); const logger = require('../utils/logger'); const redis = require('../models/redis'); const { RateLimiterRedis } = require('rate-limiter-flexible'); +const config = require('../../config/config'); // 🔑 API Key验证中间件(优化版) const authenticateApiKey = async (req, res, next) => { @@ -42,6 +43,52 @@ const authenticateApiKey = async (req, res, next) => { }); } + // 🔒 检查客户端限制 + if (validation.keyData.enableClientRestriction && validation.keyData.allowedClients?.length > 0) { + const userAgent = req.headers['user-agent'] || ''; + const clientIP = req.ip || req.connection?.remoteAddress || 'unknown'; + + // 记录客户端限制检查开始 + logger.api(`🔍 Checking client restriction for key: ${validation.keyData.id} (${validation.keyData.name})`); + logger.api(` User-Agent: "${userAgent}"`); + logger.api(` Allowed clients: ${validation.keyData.allowedClients.join(', ')}`); + + let clientAllowed = false; + let matchedClient = null; + + // 遍历允许的客户端列表 + for (const allowedClientId of validation.keyData.allowedClients) { + // 在预定义客户端列表中查找 + const predefinedClient = config.clientRestrictions.predefinedClients.find( + client => client.id === allowedClientId + ); + + if (predefinedClient) { + // 使用预定义的正则表达式匹配 User-Agent + if (predefinedClient.userAgentPattern.test(userAgent)) { + clientAllowed = true; + matchedClient = predefinedClient.name; + break; + } + } else if (config.clientRestrictions.allowCustomClients) { + // 如果允许自定义客户端,这里可以添加自定义客户端的验证逻辑 + // 目前暂时跳过自定义客户端 + continue; + } + } + + if (!clientAllowed) { + logger.security(`🚫 Client restriction failed for key: ${validation.keyData.id} (${validation.keyData.name}) from ${clientIP}, User-Agent: ${userAgent}`); + return res.status(403).json({ + error: 'Client not allowed', + message: 'Your client is not authorized to use this API key', + allowedClients: validation.keyData.allowedClients + }); + } + + logger.api(`✅ Client validated: ${matchedClient} for key: ${validation.keyData.id} (${validation.keyData.name})`); + logger.api(` Matched client: ${matchedClient} with User-Agent: "${userAgent}"`); + } // 检查并发限制 const concurrencyLimit = validation.keyData.concurrencyLimit || 0; @@ -192,6 +239,27 @@ const authenticateApiKey = async (req, res, next) => { }; } + // 检查每日费用限制 + const dailyCostLimit = validation.keyData.dailyCostLimit || 0; + if (dailyCostLimit > 0) { + const dailyCost = validation.keyData.dailyCost || 0; + + if (dailyCost >= dailyCostLimit) { + logger.security(`💰 Daily cost limit exceeded for key: ${validation.keyData.id} (${validation.keyData.name}), cost: $${dailyCost.toFixed(2)}/$${dailyCostLimit}`); + + return res.status(429).json({ + error: 'Daily cost limit exceeded', + message: `已达到每日费用限制 ($${dailyCostLimit})`, + currentCost: dailyCost, + costLimit: dailyCostLimit, + resetAt: new Date(new Date().setHours(24, 0, 0, 0)).toISOString() // 明天0点重置 + }); + } + + // 记录当前费用使用情况 + logger.api(`💰 Cost usage for key: ${validation.keyData.id} (${validation.keyData.name}), current: $${dailyCost.toFixed(2)}/$${dailyCostLimit}`); + } + // 将验证信息添加到请求对象(只包含必要信息) req.apiKey = { id: validation.keyData.id, @@ -205,12 +273,18 @@ const authenticateApiKey = async (req, res, next) => { rateLimitRequests: validation.keyData.rateLimitRequests, enableModelRestriction: validation.keyData.enableModelRestriction, restrictedModels: validation.keyData.restrictedModels, + enableClientRestriction: validation.keyData.enableClientRestriction, + allowedClients: validation.keyData.allowedClients, + dailyCostLimit: validation.keyData.dailyCostLimit, + dailyCost: validation.keyData.dailyCost, usage: validation.keyData.usage }; req.usage = validation.keyData.usage; const authDuration = Date.now() - startTime; + const userAgent = req.headers['user-agent'] || 'No User-Agent'; logger.api(`🔓 Authenticated request from key: ${validation.keyData.name} (${validation.keyData.id}) in ${authDuration}ms`); + logger.api(` User-Agent: "${userAgent}"`); next(); } catch (error) { diff --git a/src/models/redis.js b/src/models/redis.js index 45ce4fbb..9473724b 100644 --- a/src/models/redis.js +++ b/src/models/redis.js @@ -282,6 +282,104 @@ class RedisClient { ]); } + // 📊 记录账户级别的使用统计 + async incrementAccountUsage(accountId, totalTokens, inputTokens = 0, outputTokens = 0, cacheCreateTokens = 0, cacheReadTokens = 0, model = 'unknown') { + const now = new Date(); + const today = getDateStringInTimezone(now); + const tzDate = getDateInTimezone(now); + const currentMonth = `${tzDate.getFullYear()}-${String(tzDate.getMonth() + 1).padStart(2, '0')}`; + const currentHour = `${today}:${String(getHourInTimezone(now)).padStart(2, '0')}`; + + // 账户级别统计的键 + const accountKey = `account_usage:${accountId}`; + const accountDaily = `account_usage:daily:${accountId}:${today}`; + const accountMonthly = `account_usage:monthly:${accountId}:${currentMonth}`; + const accountHourly = `account_usage:hourly:${accountId}:${currentHour}`; + + // 账户按模型统计的键 + const accountModelDaily = `account_usage:model:daily:${accountId}:${model}:${today}`; + const accountModelMonthly = `account_usage:model:monthly:${accountId}:${model}:${currentMonth}`; + const accountModelHourly = `account_usage:model:hourly:${accountId}:${model}:${currentHour}`; + + // 处理token分配 + const finalInputTokens = inputTokens || 0; + const finalOutputTokens = outputTokens || 0; + const finalCacheCreateTokens = cacheCreateTokens || 0; + const finalCacheReadTokens = cacheReadTokens || 0; + const actualTotalTokens = finalInputTokens + finalOutputTokens + finalCacheCreateTokens + finalCacheReadTokens; + const coreTokens = finalInputTokens + finalOutputTokens; + + await Promise.all([ + // 账户总体统计 + this.client.hincrby(accountKey, 'totalTokens', coreTokens), + this.client.hincrby(accountKey, 'totalInputTokens', finalInputTokens), + this.client.hincrby(accountKey, 'totalOutputTokens', finalOutputTokens), + this.client.hincrby(accountKey, 'totalCacheCreateTokens', finalCacheCreateTokens), + this.client.hincrby(accountKey, 'totalCacheReadTokens', finalCacheReadTokens), + this.client.hincrby(accountKey, 'totalAllTokens', actualTotalTokens), + this.client.hincrby(accountKey, 'totalRequests', 1), + + // 账户每日统计 + this.client.hincrby(accountDaily, 'tokens', coreTokens), + this.client.hincrby(accountDaily, 'inputTokens', finalInputTokens), + this.client.hincrby(accountDaily, 'outputTokens', finalOutputTokens), + this.client.hincrby(accountDaily, 'cacheCreateTokens', finalCacheCreateTokens), + this.client.hincrby(accountDaily, 'cacheReadTokens', finalCacheReadTokens), + this.client.hincrby(accountDaily, 'allTokens', actualTotalTokens), + this.client.hincrby(accountDaily, 'requests', 1), + + // 账户每月统计 + this.client.hincrby(accountMonthly, 'tokens', coreTokens), + this.client.hincrby(accountMonthly, 'inputTokens', finalInputTokens), + this.client.hincrby(accountMonthly, 'outputTokens', finalOutputTokens), + this.client.hincrby(accountMonthly, 'cacheCreateTokens', finalCacheCreateTokens), + this.client.hincrby(accountMonthly, 'cacheReadTokens', finalCacheReadTokens), + this.client.hincrby(accountMonthly, 'allTokens', actualTotalTokens), + this.client.hincrby(accountMonthly, 'requests', 1), + + // 账户每小时统计 + this.client.hincrby(accountHourly, 'tokens', coreTokens), + this.client.hincrby(accountHourly, 'inputTokens', finalInputTokens), + this.client.hincrby(accountHourly, 'outputTokens', finalOutputTokens), + this.client.hincrby(accountHourly, 'cacheCreateTokens', finalCacheCreateTokens), + this.client.hincrby(accountHourly, 'cacheReadTokens', finalCacheReadTokens), + this.client.hincrby(accountHourly, 'allTokens', actualTotalTokens), + this.client.hincrby(accountHourly, 'requests', 1), + + // 账户按模型统计 - 每日 + this.client.hincrby(accountModelDaily, 'inputTokens', finalInputTokens), + this.client.hincrby(accountModelDaily, 'outputTokens', finalOutputTokens), + this.client.hincrby(accountModelDaily, 'cacheCreateTokens', finalCacheCreateTokens), + this.client.hincrby(accountModelDaily, 'cacheReadTokens', finalCacheReadTokens), + this.client.hincrby(accountModelDaily, 'allTokens', actualTotalTokens), + this.client.hincrby(accountModelDaily, 'requests', 1), + + // 账户按模型统计 - 每月 + this.client.hincrby(accountModelMonthly, 'inputTokens', finalInputTokens), + this.client.hincrby(accountModelMonthly, 'outputTokens', finalOutputTokens), + this.client.hincrby(accountModelMonthly, 'cacheCreateTokens', finalCacheCreateTokens), + this.client.hincrby(accountModelMonthly, 'cacheReadTokens', finalCacheReadTokens), + this.client.hincrby(accountModelMonthly, 'allTokens', actualTotalTokens), + this.client.hincrby(accountModelMonthly, 'requests', 1), + + // 账户按模型统计 - 每小时 + this.client.hincrby(accountModelHourly, 'inputTokens', finalInputTokens), + this.client.hincrby(accountModelHourly, 'outputTokens', finalOutputTokens), + this.client.hincrby(accountModelHourly, 'cacheCreateTokens', finalCacheCreateTokens), + this.client.hincrby(accountModelHourly, 'cacheReadTokens', finalCacheReadTokens), + this.client.hincrby(accountModelHourly, 'allTokens', actualTotalTokens), + this.client.hincrby(accountModelHourly, 'requests', 1), + + // 设置过期时间 + this.client.expire(accountDaily, 86400 * 32), // 32天过期 + this.client.expire(accountMonthly, 86400 * 365), // 1年过期 + this.client.expire(accountHourly, 86400 * 7), // 7天过期 + this.client.expire(accountModelDaily, 86400 * 32), // 32天过期 + this.client.expire(accountModelMonthly, 86400 * 365), // 1年过期 + this.client.expire(accountModelHourly, 86400 * 7) // 7天过期 + ]); + } + async getUsageStats(keyId) { const totalKey = `usage:${keyId}`; const today = getDateStringInTimezone(); @@ -324,11 +422,13 @@ class RedisClient { const allTokens = parseInt(data.totalAllTokens) || parseInt(data.allTokens) || 0; const totalFromSeparate = inputTokens + outputTokens; + // 计算实际的总tokens(包含所有类型) + const actualAllTokens = allTokens || (inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens); if (totalFromSeparate === 0 && tokens > 0) { // 旧数据:没有输入输出分离 return { - tokens, + tokens: tokens, // 保持兼容性,但统一使用allTokens inputTokens: Math.round(tokens * 0.3), // 假设30%为输入 outputTokens: Math.round(tokens * 0.7), // 假设70%为输出 cacheCreateTokens: 0, // 旧数据没有缓存token @@ -337,14 +437,14 @@ class RedisClient { requests }; } else { - // 新数据或无数据 + // 新数据或无数据 - 统一使用allTokens作为tokens的值 return { - tokens, + tokens: actualAllTokens, // 统一使用allTokens作为总数 inputTokens, outputTokens, cacheCreateTokens, cacheReadTokens, - allTokens: allTokens || (inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens), // 计算或使用存储的值 + allTokens: actualAllTokens, requests }; } @@ -367,6 +467,170 @@ class RedisClient { }; } + // 💰 获取当日费用 + async getDailyCost(keyId) { + const today = getDateStringInTimezone(); + const costKey = `usage:cost:daily:${keyId}:${today}`; + const cost = await this.client.get(costKey); + const result = parseFloat(cost || 0); + logger.debug(`💰 Getting daily cost for ${keyId}, date: ${today}, key: ${costKey}, value: ${cost}, result: ${result}`); + return result; + } + + // 💰 增加当日费用 + async incrementDailyCost(keyId, amount) { + const today = getDateStringInTimezone(); + const tzDate = getDateInTimezone(); + const currentMonth = `${tzDate.getFullYear()}-${String(tzDate.getMonth() + 1).padStart(2, '0')}`; + const currentHour = `${today}:${String(getHourInTimezone()).padStart(2, '0')}`; + + const dailyKey = `usage:cost:daily:${keyId}:${today}`; + const monthlyKey = `usage:cost:monthly:${keyId}:${currentMonth}`; + const hourlyKey = `usage:cost:hourly:${keyId}:${currentHour}`; + const totalKey = `usage:cost:total:${keyId}`; + + logger.debug(`💰 Incrementing cost for ${keyId}, amount: $${amount}, date: ${today}, dailyKey: ${dailyKey}`); + + const results = await Promise.all([ + this.client.incrbyfloat(dailyKey, amount), + this.client.incrbyfloat(monthlyKey, amount), + this.client.incrbyfloat(hourlyKey, amount), + this.client.incrbyfloat(totalKey, amount), + // 设置过期时间 + this.client.expire(dailyKey, 86400 * 30), // 30天 + this.client.expire(monthlyKey, 86400 * 90), // 90天 + this.client.expire(hourlyKey, 86400 * 7) // 7天 + ]); + + logger.debug(`💰 Cost incremented successfully, new daily total: $${results[0]}`); + } + + // 💰 获取费用统计 + async getCostStats(keyId) { + const today = getDateStringInTimezone(); + const tzDate = getDateInTimezone(); + const currentMonth = `${tzDate.getFullYear()}-${String(tzDate.getMonth() + 1).padStart(2, '0')}`; + const currentHour = `${today}:${String(getHourInTimezone()).padStart(2, '0')}`; + + const [daily, monthly, hourly, total] = await Promise.all([ + this.client.get(`usage:cost:daily:${keyId}:${today}`), + this.client.get(`usage:cost:monthly:${keyId}:${currentMonth}`), + this.client.get(`usage:cost:hourly:${keyId}:${currentHour}`), + this.client.get(`usage:cost:total:${keyId}`) + ]); + + return { + daily: parseFloat(daily || 0), + monthly: parseFloat(monthly || 0), + hourly: parseFloat(hourly || 0), + total: parseFloat(total || 0) + }; + } + + // 📊 获取账户使用统计 + async getAccountUsageStats(accountId) { + const accountKey = `account_usage:${accountId}`; + const today = getDateStringInTimezone(); + const accountDailyKey = `account_usage:daily:${accountId}:${today}`; + const tzDate = getDateInTimezone(); + const currentMonth = `${tzDate.getFullYear()}-${String(tzDate.getMonth() + 1).padStart(2, '0')}`; + const accountMonthlyKey = `account_usage:monthly:${accountId}:${currentMonth}`; + + const [total, daily, monthly] = await Promise.all([ + this.client.hgetall(accountKey), + this.client.hgetall(accountDailyKey), + this.client.hgetall(accountMonthlyKey) + ]); + + // 获取账户创建时间来计算平均值 + const accountData = await this.client.hgetall(`claude_account:${accountId}`); + const createdAt = accountData.createdAt ? new Date(accountData.createdAt) : new Date(); + const now = new Date(); + const daysSinceCreated = Math.max(1, Math.ceil((now - createdAt) / (1000 * 60 * 60 * 24))); + + const totalTokens = parseInt(total.totalTokens) || 0; + const totalRequests = parseInt(total.totalRequests) || 0; + + // 计算平均RPM和TPM + const totalMinutes = Math.max(1, daysSinceCreated * 24 * 60); + const avgRPM = totalRequests / totalMinutes; + const avgTPM = totalTokens / totalMinutes; + + // 处理账户统计数据 + const handleAccountData = (data) => { + const tokens = parseInt(data.totalTokens) || parseInt(data.tokens) || 0; + const inputTokens = parseInt(data.totalInputTokens) || parseInt(data.inputTokens) || 0; + const outputTokens = parseInt(data.totalOutputTokens) || parseInt(data.outputTokens) || 0; + const requests = parseInt(data.totalRequests) || parseInt(data.requests) || 0; + const cacheCreateTokens = parseInt(data.totalCacheCreateTokens) || parseInt(data.cacheCreateTokens) || 0; + const cacheReadTokens = parseInt(data.totalCacheReadTokens) || parseInt(data.cacheReadTokens) || 0; + const allTokens = parseInt(data.totalAllTokens) || parseInt(data.allTokens) || 0; + + const actualAllTokens = allTokens || (inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens); + + return { + tokens: tokens, + inputTokens: inputTokens, + outputTokens: outputTokens, + cacheCreateTokens: cacheCreateTokens, + cacheReadTokens: cacheReadTokens, + allTokens: actualAllTokens, + requests: requests + }; + }; + + const totalData = handleAccountData(total); + const dailyData = handleAccountData(daily); + const monthlyData = handleAccountData(monthly); + + return { + accountId: accountId, + total: totalData, + daily: dailyData, + monthly: monthlyData, + averages: { + rpm: Math.round(avgRPM * 100) / 100, + tpm: Math.round(avgTPM * 100) / 100, + dailyRequests: Math.round((totalRequests / daysSinceCreated) * 100) / 100, + dailyTokens: Math.round((totalTokens / daysSinceCreated) * 100) / 100 + } + }; + } + + // 📈 获取所有账户的使用统计 + async getAllAccountsUsageStats() { + try { + // 获取所有Claude账户 + const accountKeys = await this.client.keys('claude_account:*'); + const accountStats = []; + + for (const accountKey of accountKeys) { + const accountId = accountKey.replace('claude_account:', ''); + const accountData = await this.client.hgetall(accountKey); + + if (accountData.name) { + const stats = await this.getAccountUsageStats(accountId); + accountStats.push({ + id: accountId, + name: accountData.name, + email: accountData.email || '', + status: accountData.status || 'unknown', + isActive: accountData.isActive === 'true', + ...stats + }); + } + } + + // 按当日token使用量排序 + accountStats.sort((a, b) => (b.daily.allTokens || 0) - (a.daily.allTokens || 0)); + + return accountStats; + } catch (error) { + logger.error('❌ Failed to get all accounts usage stats:', error); + return []; + } + } + // 🧹 清空所有API Key的使用统计数据 async resetAllUsageStats() { const client = this.getClientSafe(); @@ -819,4 +1083,11 @@ class RedisClient { } } -module.exports = new RedisClient(); \ No newline at end of file +const redisClient = new RedisClient(); + +// 导出时区辅助函数 +redisClient.getDateInTimezone = getDateInTimezone; +redisClient.getDateStringInTimezone = getDateStringInTimezone; +redisClient.getHourInTimezone = getHourInTimezone; + +module.exports = redisClient; \ No newline at end of file diff --git a/src/routes/admin.js b/src/routes/admin.js index a8a2fbb9..8c55fa87 100644 --- a/src/routes/admin.js +++ b/src/routes/admin.js @@ -12,15 +12,274 @@ const claudeCodeHeadersService = require('../services/claudeCodeHeadersService') const axios = require('axios'); const fs = require('fs'); const path = require('path'); +const config = require('../../config/config'); const router = express.Router(); // 🔑 API Keys 管理 +// 调试:获取API Key费用详情 +router.get('/api-keys/:keyId/cost-debug', authenticateAdmin, async (req, res) => { + try { + const { keyId } = req.params; + const costStats = await redis.getCostStats(keyId); + const dailyCost = await redis.getDailyCost(keyId); + const today = redis.getDateStringInTimezone(); + const client = redis.getClientSafe(); + + // 获取所有相关的Redis键 + const costKeys = await client.keys(`usage:cost:*:${keyId}:*`); + const keyValues = {}; + + for (const key of costKeys) { + keyValues[key] = await client.get(key); + } + + res.json({ + keyId, + today, + dailyCost, + costStats, + redisKeys: keyValues, + timezone: config.system.timezoneOffset || 8 + }); + } catch (error) { + logger.error('❌ Failed to get cost debug info:', error); + res.status(500).json({ error: 'Failed to get cost debug info', message: error.message }); + } +}); + // 获取所有API Keys router.get('/api-keys', authenticateAdmin, async (req, res) => { try { + const { timeRange = 'all' } = req.query; // all, 7days, monthly const apiKeys = await apiKeyService.getAllApiKeys(); + + // 根据时间范围计算查询模式 + const now = new Date(); + let searchPatterns = []; + + if (timeRange === 'today') { + // 今日 - 使用时区日期 + const redis = require('../models/redis'); + const tzDate = redis.getDateInTimezone(now); + const dateStr = `${tzDate.getFullYear()}-${String(tzDate.getMonth() + 1).padStart(2, '0')}-${String(tzDate.getDate()).padStart(2, '0')}`; + searchPatterns.push(`usage:daily:*:${dateStr}`); + } else if (timeRange === '7days') { + // 最近7天 + const redis = require('../models/redis'); + for (let i = 0; i < 7; i++) { + const date = new Date(now); + date.setDate(date.getDate() - i); + const tzDate = redis.getDateInTimezone(date); + const dateStr = `${tzDate.getFullYear()}-${String(tzDate.getMonth() + 1).padStart(2, '0')}-${String(tzDate.getDate()).padStart(2, '0')}`; + searchPatterns.push(`usage:daily:*:${dateStr}`); + } + } else if (timeRange === 'monthly') { + // 本月 + const redis = require('../models/redis'); + const tzDate = redis.getDateInTimezone(now); + const currentMonth = `${tzDate.getFullYear()}-${String(tzDate.getMonth() + 1).padStart(2, '0')}`; + searchPatterns.push(`usage:monthly:*:${currentMonth}`); + } + + // 为每个API Key计算准确的费用和统计数据 + for (const apiKey of apiKeys) { + const client = redis.getClientSafe(); + + if (timeRange === 'all') { + // 全部时间:保持原有逻辑 + if (apiKey.usage && apiKey.usage.total) { + // 使用与展开模型统计相同的数据源 + // 获取所有时间的模型统计数据 + const monthlyKeys = await client.keys(`usage:${apiKey.id}:model:monthly:*:*`); + const modelStatsMap = new Map(); + + // 汇总所有月份的数据 + for (const key of monthlyKeys) { + const match = key.match(/usage:.+:model:monthly:(.+):\d{4}-\d{2}$/); + if (!match) continue; + + const model = match[1]; + const data = await client.hgetall(key); + + if (data && Object.keys(data).length > 0) { + if (!modelStatsMap.has(model)) { + modelStatsMap.set(model, { + inputTokens: 0, + outputTokens: 0, + cacheCreateTokens: 0, + cacheReadTokens: 0 + }); + } + + const stats = modelStatsMap.get(model); + stats.inputTokens += parseInt(data.totalInputTokens) || parseInt(data.inputTokens) || 0; + stats.outputTokens += parseInt(data.totalOutputTokens) || parseInt(data.outputTokens) || 0; + stats.cacheCreateTokens += parseInt(data.totalCacheCreateTokens) || parseInt(data.cacheCreateTokens) || 0; + stats.cacheReadTokens += parseInt(data.totalCacheReadTokens) || parseInt(data.cacheReadTokens) || 0; + } + } + + let totalCost = 0; + + // 计算每个模型的费用 + for (const [model, stats] of modelStatsMap) { + const usage = { + input_tokens: stats.inputTokens, + output_tokens: stats.outputTokens, + cache_creation_input_tokens: stats.cacheCreateTokens, + cache_read_input_tokens: stats.cacheReadTokens + }; + + const costResult = CostCalculator.calculateCost(usage, model); + totalCost += costResult.costs.total; + } + + // 如果没有详细的模型数据,使用总量数据和默认模型计算 + if (modelStatsMap.size === 0) { + const usage = { + input_tokens: apiKey.usage.total.inputTokens || 0, + output_tokens: apiKey.usage.total.outputTokens || 0, + cache_creation_input_tokens: apiKey.usage.total.cacheCreateTokens || 0, + cache_read_input_tokens: apiKey.usage.total.cacheReadTokens || 0 + }; + + const costResult = CostCalculator.calculateCost(usage, 'claude-3-5-haiku-20241022'); + totalCost = costResult.costs.total; + } + + // 添加格式化的费用到响应数据 + apiKey.usage.total.cost = totalCost; + apiKey.usage.total.formattedCost = CostCalculator.formatCost(totalCost); + } + } else { + // 7天或本月:重新计算统计数据 + const tempUsage = { + requests: 0, + tokens: 0, + allTokens: 0, // 添加allTokens字段 + inputTokens: 0, + outputTokens: 0, + cacheCreateTokens: 0, + cacheReadTokens: 0 + }; + + // 获取指定时间范围的统计数据 + for (const pattern of searchPatterns) { + const keys = await client.keys(pattern.replace('*', apiKey.id)); + + for (const key of keys) { + const data = await client.hgetall(key); + if (data && Object.keys(data).length > 0) { + // 使用与 redis.js incrementTokenUsage 中相同的字段名 + tempUsage.requests += parseInt(data.totalRequests) || parseInt(data.requests) || 0; + tempUsage.tokens += parseInt(data.totalTokens) || parseInt(data.tokens) || 0; + tempUsage.allTokens += parseInt(data.totalAllTokens) || parseInt(data.allTokens) || 0; // 读取包含所有Token的字段 + tempUsage.inputTokens += parseInt(data.totalInputTokens) || parseInt(data.inputTokens) || 0; + tempUsage.outputTokens += parseInt(data.totalOutputTokens) || parseInt(data.outputTokens) || 0; + tempUsage.cacheCreateTokens += parseInt(data.totalCacheCreateTokens) || parseInt(data.cacheCreateTokens) || 0; + tempUsage.cacheReadTokens += parseInt(data.totalCacheReadTokens) || parseInt(data.cacheReadTokens) || 0; + } + } + } + + // 计算指定时间范围的费用 + let totalCost = 0; + const redis = require('../models/redis'); + const tzToday = redis.getDateStringInTimezone(now); + const tzDate = redis.getDateInTimezone(now); + const tzMonth = `${tzDate.getFullYear()}-${String(tzDate.getMonth() + 1).padStart(2, '0')}`; + + const modelKeys = timeRange === 'today' + ? await client.keys(`usage:${apiKey.id}:model:daily:*:${tzToday}`) + : timeRange === '7days' + ? await client.keys(`usage:${apiKey.id}:model:daily:*:*`) + : await client.keys(`usage:${apiKey.id}:model:monthly:*:${tzMonth}`); + + const modelStatsMap = new Map(); + + // 过滤和汇总相应时间范围的模型数据 + for (const key of modelKeys) { + if (timeRange === '7days') { + // 检查是否在最近7天内 + const dateMatch = key.match(/\d{4}-\d{2}-\d{2}$/); + if (dateMatch) { + const keyDate = new Date(dateMatch[0]); + const daysDiff = Math.floor((now - keyDate) / (1000 * 60 * 60 * 24)); + if (daysDiff > 6) continue; + } + } else if (timeRange === 'today') { + // today选项已经在查询时过滤了,不需要额外处理 + } + + const modelMatch = key.match(/usage:.+:model:(?:daily|monthly):(.+):\d{4}-\d{2}(?:-\d{2})?$/); + if (!modelMatch) continue; + + const model = modelMatch[1]; + const data = await client.hgetall(key); + + if (data && Object.keys(data).length > 0) { + if (!modelStatsMap.has(model)) { + modelStatsMap.set(model, { + inputTokens: 0, + outputTokens: 0, + cacheCreateTokens: 0, + cacheReadTokens: 0 + }); + } + + const stats = modelStatsMap.get(model); + stats.inputTokens += parseInt(data.totalInputTokens) || parseInt(data.inputTokens) || 0; + stats.outputTokens += parseInt(data.totalOutputTokens) || parseInt(data.outputTokens) || 0; + stats.cacheCreateTokens += parseInt(data.totalCacheCreateTokens) || parseInt(data.cacheCreateTokens) || 0; + stats.cacheReadTokens += parseInt(data.totalCacheReadTokens) || parseInt(data.cacheReadTokens) || 0; + } + } + + // 计算费用 + for (const [model, stats] of modelStatsMap) { + const usage = { + input_tokens: stats.inputTokens, + output_tokens: stats.outputTokens, + cache_creation_input_tokens: stats.cacheCreateTokens, + cache_read_input_tokens: stats.cacheReadTokens + }; + + const costResult = CostCalculator.calculateCost(usage, model); + totalCost += costResult.costs.total; + } + + // 如果没有模型数据,使用临时统计数据计算 + if (modelStatsMap.size === 0 && tempUsage.tokens > 0) { + const usage = { + input_tokens: tempUsage.inputTokens, + output_tokens: tempUsage.outputTokens, + cache_creation_input_tokens: tempUsage.cacheCreateTokens, + cache_read_input_tokens: tempUsage.cacheReadTokens + }; + + const costResult = CostCalculator.calculateCost(usage, 'claude-3-5-haiku-20241022'); + totalCost = costResult.costs.total; + } + + // 使用从Redis读取的allTokens,如果没有则计算 + const allTokens = tempUsage.allTokens || (tempUsage.inputTokens + tempUsage.outputTokens + tempUsage.cacheCreateTokens + tempUsage.cacheReadTokens); + + // 更新API Key的usage数据为指定时间范围的数据 + apiKey.usage[timeRange] = { + ...tempUsage, + tokens: allTokens, // 使用包含所有Token的总数 + allTokens: allTokens, + cost: totalCost, + formattedCost: CostCalculator.formatCost(totalCost) + }; + + // 为了保持兼容性,也更新total字段 + apiKey.usage.total = apiKey.usage[timeRange]; + } + } + res.json({ success: true, data: apiKeys }); } catch (error) { logger.error('❌ Failed to get API keys:', error); @@ -28,6 +287,21 @@ router.get('/api-keys', authenticateAdmin, async (req, res) => { } }); +// 获取支持的客户端列表 +router.get('/supported-clients', authenticateAdmin, async (req, res) => { + try { + const clients = config.clientRestrictions.predefinedClients.map(client => ({ + id: client.id, + name: client.name, + description: client.description + })); + res.json({ success: true, data: clients }); + } catch (error) { + logger.error('❌ Failed to get supported clients:', error); + res.status(500).json({ error: 'Failed to get supported clients', message: error.message }); + } +}); + // 创建新的API Key router.post('/api-keys', authenticateAdmin, async (req, res) => { try { @@ -43,7 +317,10 @@ router.post('/api-keys', authenticateAdmin, async (req, res) => { rateLimitWindow, rateLimitRequests, enableModelRestriction, - restrictedModels + restrictedModels, + enableClientRestriction, + allowedClients, + dailyCostLimit } = req.body; // 输入验证 @@ -85,6 +362,15 @@ router.post('/api-keys', authenticateAdmin, async (req, res) => { return res.status(400).json({ error: 'Restricted models must be an array' }); } + // 验证客户端限制字段 + if (enableClientRestriction !== undefined && typeof enableClientRestriction !== 'boolean') { + return res.status(400).json({ error: 'Enable client restriction must be a boolean' }); + } + + if (allowedClients !== undefined && !Array.isArray(allowedClients)) { + return res.status(400).json({ error: 'Allowed clients must be an array' }); + } + const newKey = await apiKeyService.generateApiKey({ name, description, @@ -97,7 +383,10 @@ router.post('/api-keys', authenticateAdmin, async (req, res) => { rateLimitWindow, rateLimitRequests, enableModelRestriction, - restrictedModels + restrictedModels, + enableClientRestriction, + allowedClients, + dailyCostLimit }); logger.success(`🔑 Admin created new API key: ${name}`); @@ -112,7 +401,7 @@ router.post('/api-keys', authenticateAdmin, async (req, res) => { router.put('/api-keys/:keyId', authenticateAdmin, async (req, res) => { try { const { keyId } = req.params; - const { tokenLimit, concurrencyLimit, rateLimitWindow, rateLimitRequests, claudeAccountId, geminiAccountId, permissions, enableModelRestriction, restrictedModels } = req.body; + const { tokenLimit, concurrencyLimit, rateLimitWindow, rateLimitRequests, claudeAccountId, geminiAccountId, permissions, enableModelRestriction, restrictedModels, enableClientRestriction, allowedClients, expiresAt, dailyCostLimit } = req.body; // 只允许更新指定字段 const updates = {}; @@ -178,6 +467,45 @@ router.put('/api-keys/:keyId', authenticateAdmin, async (req, res) => { updates.restrictedModels = restrictedModels; } + // 处理客户端限制字段 + if (enableClientRestriction !== undefined) { + if (typeof enableClientRestriction !== 'boolean') { + return res.status(400).json({ error: 'Enable client restriction must be a boolean' }); + } + updates.enableClientRestriction = enableClientRestriction; + } + + if (allowedClients !== undefined) { + if (!Array.isArray(allowedClients)) { + return res.status(400).json({ error: 'Allowed clients must be an array' }); + } + updates.allowedClients = allowedClients; + } + + // 处理过期时间字段 + if (expiresAt !== undefined) { + if (expiresAt === null) { + // null 表示永不过期 + updates.expiresAt = null; + } else { + // 验证日期格式 + const expireDate = new Date(expiresAt); + if (isNaN(expireDate.getTime())) { + return res.status(400).json({ error: 'Invalid expiration date format' }); + } + updates.expiresAt = expiresAt; + } + } + + // 处理每日费用限制 + if (dailyCostLimit !== undefined && dailyCostLimit !== null && dailyCostLimit !== '') { + const costLimit = Number(dailyCostLimit); + if (isNaN(costLimit) || costLimit < 0) { + return res.status(400).json({ error: 'Daily cost limit must be a non-negative number' }); + } + updates.dailyCostLimit = costLimit; + } + await apiKeyService.updateApiKey(keyId, updates); logger.success(`📝 Admin updated API key: ${keyId}`); @@ -308,7 +636,34 @@ router.post('/claude-accounts/exchange-code', authenticateAdmin, async (req, res router.get('/claude-accounts', authenticateAdmin, async (req, res) => { try { const accounts = await claudeAccountService.getAllAccounts(); - res.json({ success: true, data: accounts }); + + // 为每个账户添加使用统计信息 + const accountsWithStats = await Promise.all(accounts.map(async (account) => { + try { + const usageStats = await redis.getAccountUsageStats(account.id); + return { + ...account, + usage: { + daily: usageStats.daily, + total: usageStats.total, + averages: usageStats.averages + } + }; + } catch (statsError) { + logger.warn(`⚠️ Failed to get usage stats for account ${account.id}:`, statsError.message); + // 如果获取统计失败,返回空统计 + return { + ...account, + usage: { + daily: { tokens: 0, requests: 0, allTokens: 0 }, + total: { tokens: 0, requests: 0, allTokens: 0 }, + averages: { rpm: 0, tpm: 0 } + } + }; + } + })); + + res.json({ success: true, data: accountsWithStats }); } catch (error) { logger.error('❌ Failed to get Claude accounts:', error); res.status(500).json({ error: 'Failed to get Claude accounts', message: error.message }); @@ -495,7 +850,18 @@ router.post('/gemini-accounts/exchange-code', authenticateAdmin, async (req, res router.get('/gemini-accounts', authenticateAdmin, async (req, res) => { try { const accounts = await geminiAccountService.getAllAccounts(); - res.json({ success: true, data: accounts }); + + // 为Gemini账户添加空的使用统计(暂时) + const accountsWithStats = accounts.map(account => ({ + ...account, + usage: { + daily: { tokens: 0, requests: 0, allTokens: 0 }, + total: { tokens: 0, requests: 0, allTokens: 0 }, + averages: { rpm: 0, tpm: 0 } + } + })); + + res.json({ success: true, data: accountsWithStats }); } catch (error) { logger.error('❌ Failed to get Gemini accounts:', error); res.status(500).json({ error: 'Failed to get accounts', message: error.message }); @@ -568,6 +934,73 @@ router.post('/gemini-accounts/:accountId/refresh', authenticateAdmin, async (req } }); +// 📊 账户使用统计 + +// 获取所有账户的使用统计 +router.get('/accounts/usage-stats', authenticateAdmin, async (req, res) => { + try { + const accountsStats = await redis.getAllAccountsUsageStats(); + + res.json({ + success: true, + data: accountsStats, + summary: { + totalAccounts: accountsStats.length, + activeToday: accountsStats.filter(account => account.daily.requests > 0).length, + totalDailyTokens: accountsStats.reduce((sum, account) => sum + (account.daily.allTokens || 0), 0), + totalDailyRequests: accountsStats.reduce((sum, account) => sum + (account.daily.requests || 0), 0) + }, + timestamp: new Date().toISOString() + }); + } catch (error) { + logger.error('❌ Failed to get accounts usage stats:', error); + res.status(500).json({ + success: false, + error: 'Failed to get accounts usage stats', + message: error.message + }); + } +}); + +// 获取单个账户的使用统计 +router.get('/accounts/:accountId/usage-stats', authenticateAdmin, async (req, res) => { + try { + const { accountId } = req.params; + const accountStats = await redis.getAccountUsageStats(accountId); + + // 获取账户基本信息 + const accountData = await claudeAccountService.getAccount(accountId); + if (!accountData) { + return res.status(404).json({ + success: false, + error: 'Account not found' + }); + } + + res.json({ + success: true, + data: { + ...accountStats, + accountInfo: { + name: accountData.name, + email: accountData.email, + status: accountData.status, + isActive: accountData.isActive, + createdAt: accountData.createdAt + } + }, + timestamp: new Date().toISOString() + }); + } catch (error) { + logger.error('❌ Failed to get account usage stats:', error); + res.status(500).json({ + success: false, + error: 'Failed to get account usage stats', + message: error.message + }); + } +}); + // 📊 系统统计 // 获取系统概览 @@ -582,8 +1015,8 @@ router.get('/dashboard', authenticateAdmin, async (req, res) => { redis.getSystemAverages() ]); - // 计算使用统计(包含cache tokens) - const totalTokensUsed = apiKeys.reduce((sum, key) => sum + (key.usage?.total?.tokens || 0), 0); + // 计算使用统计(统一使用allTokens) + const totalTokensUsed = apiKeys.reduce((sum, key) => sum + (key.usage?.total?.allTokens || 0), 0); const totalRequestsUsed = apiKeys.reduce((sum, key) => sum + (key.usage?.total?.requests || 0), 0); const totalInputTokensUsed = apiKeys.reduce((sum, key) => sum + (key.usage?.total?.inputTokens || 0), 0); const totalOutputTokensUsed = apiKeys.reduce((sum, key) => sum + (key.usage?.total?.outputTokens || 0), 0); @@ -1794,4 +2227,91 @@ function compareVersions(current, latest) { return currentV.patch - latestV.patch; } +// 🎨 OEM设置管理 + +// 获取OEM设置(公开接口,用于显示) +router.get('/oem-settings', async (req, res) => { + try { + const client = redis.getClient(); + const oemSettings = await client.get('oem:settings'); + + // 默认设置 + const defaultSettings = { + siteName: 'Claude Relay Service', + siteIcon: '', + siteIconData: '', // Base64编码的图标数据 + updatedAt: new Date().toISOString() + }; + + let settings = defaultSettings; + if (oemSettings) { + try { + settings = { ...defaultSettings, ...JSON.parse(oemSettings) }; + } catch (err) { + logger.warn('⚠️ Failed to parse OEM settings, using defaults:', err.message); + } + } + + res.json({ + success: true, + data: settings + }); + } catch (error) { + logger.error('❌ Failed to get OEM settings:', error); + res.status(500).json({ error: 'Failed to get OEM settings', message: error.message }); + } +}); + +// 更新OEM设置 +router.put('/oem-settings', authenticateAdmin, async (req, res) => { + try { + const { siteName, siteIcon, siteIconData } = req.body; + + // 验证输入 + if (!siteName || typeof siteName !== 'string' || siteName.trim().length === 0) { + return res.status(400).json({ error: 'Site name is required' }); + } + + if (siteName.length > 100) { + return res.status(400).json({ error: 'Site name must be less than 100 characters' }); + } + + // 验证图标数据大小(如果是base64) + if (siteIconData && siteIconData.length > 500000) { // 约375KB + return res.status(400).json({ error: 'Icon file must be less than 350KB' }); + } + + // 验证图标URL(如果提供) + if (siteIcon && !siteIconData) { + // 简单验证URL格式 + try { + new URL(siteIcon); + } catch (err) { + return res.status(400).json({ error: 'Invalid icon URL format' }); + } + } + + const settings = { + siteName: siteName.trim(), + siteIcon: (siteIcon || '').trim(), + siteIconData: (siteIconData || '').trim(), // Base64数据 + updatedAt: new Date().toISOString() + }; + + const client = redis.getClient(); + await client.set('oem:settings', JSON.stringify(settings)); + + logger.info(`✅ OEM settings updated: ${siteName}`); + + res.json({ + success: true, + message: 'OEM settings updated successfully', + data: settings + }); + } catch (error) { + logger.error('❌ Failed to update OEM settings:', error); + res.status(500).json({ error: 'Failed to update OEM settings', message: error.message }); + } +}); + module.exports = router; \ No newline at end of file diff --git a/src/routes/api.js b/src/routes/api.js index 8f38347b..3027de5e 100644 --- a/src/routes/api.js +++ b/src/routes/api.js @@ -68,8 +68,9 @@ async function handleMessagesRequest(req, res) { const cacheReadTokens = usageData.cache_read_input_tokens || 0; const model = usageData.model || 'unknown'; - // 记录真实的token使用量(包含模型信息和所有4种token) - apiKeyService.recordUsage(req.apiKey.id, inputTokens, outputTokens, cacheCreateTokens, cacheReadTokens, model).catch(error => { + // 记录真实的token使用量(包含模型信息和所有4种token以及账户ID) + const accountId = usageData.accountId; + apiKeyService.recordUsage(req.apiKey.id, inputTokens, outputTokens, cacheCreateTokens, cacheReadTokens, model, accountId).catch(error => { logger.error('❌ Failed to record stream usage:', error); }); @@ -135,8 +136,9 @@ async function handleMessagesRequest(req, res) { const cacheReadTokens = jsonData.usage.cache_read_input_tokens || 0; const model = jsonData.model || req.body.model || 'unknown'; - // 记录真实的token使用量(包含模型信息和所有4种token) - await apiKeyService.recordUsage(req.apiKey.id, inputTokens, outputTokens, cacheCreateTokens, cacheReadTokens, model); + // 记录真实的token使用量(包含模型信息和所有4种token以及账户ID) + const accountId = response.accountId; + await apiKeyService.recordUsage(req.apiKey.id, inputTokens, outputTokens, cacheCreateTokens, cacheReadTokens, model, accountId); // 更新时间窗口内的token计数 if (req.rateLimitInfo) { diff --git a/src/routes/apiStats.js b/src/routes/apiStats.js new file mode 100644 index 00000000..d190285b --- /dev/null +++ b/src/routes/apiStats.js @@ -0,0 +1,518 @@ +const express = require('express'); +const path = require('path'); +const fs = require('fs'); +const redis = require('../models/redis'); +const logger = require('../utils/logger'); +const apiKeyService = require('../services/apiKeyService'); +const CostCalculator = require('../utils/costCalculator'); + +const router = express.Router(); + +// 🛡️ 安全文件服务函数 +function serveStaticFile(req, res, filename, contentType) { + const filePath = path.join(__dirname, '../../web/apiStats', filename); + + try { + // 检查文件是否存在 + if (!fs.existsSync(filePath)) { + logger.error(`❌ API Stats file not found: ${filePath}`); + return res.status(404).json({ error: 'File not found' }); + } + + // 读取并返回文件内容 + const content = fs.readFileSync(filePath, 'utf8'); + res.setHeader('Content-Type', contentType); + res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate'); + res.setHeader('Pragma', 'no-cache'); + res.setHeader('Expires', '0'); + res.send(content); + + logger.info(`📄 Served API Stats file: ${filename}`); + } catch (error) { + logger.error(`❌ Error serving API Stats file ${filename}:`, error); + res.status(500).json({ error: 'Internal server error' }); + } +} + +// 🏠 API Stats 主页面 +router.get('/', (req, res) => { + serveStaticFile(req, res, 'index.html', 'text/html; charset=utf-8'); +}); + +// 📱 JavaScript 文件 +router.get('/app.js', (req, res) => { + serveStaticFile(req, res, 'app.js', 'application/javascript; charset=utf-8'); +}); + +// 🎨 CSS 文件 +router.get('/style.css', (req, res) => { + serveStaticFile(req, res, 'style.css', 'text/css; charset=utf-8'); +}); + +// 🔑 获取 API Key 对应的 ID +router.post('/api/get-key-id', async (req, res) => { + try { + const { apiKey } = req.body; + + if (!apiKey) { + return res.status(400).json({ + error: 'API Key is required', + message: 'Please provide your API Key' + }); + } + + // 基本API Key格式验证 + if (typeof apiKey !== 'string' || apiKey.length < 10 || apiKey.length > 512) { + return res.status(400).json({ + error: 'Invalid API key format', + message: 'API key format is invalid' + }); + } + + // 验证API Key + const validation = await apiKeyService.validateApiKey(apiKey); + + if (!validation.valid) { + const clientIP = req.ip || req.connection?.remoteAddress || 'unknown'; + logger.security(`🔒 Invalid API key in get-key-id: ${validation.error} from ${clientIP}`); + return res.status(401).json({ + error: 'Invalid API key', + message: validation.error + }); + } + + const keyData = validation.keyData; + + res.json({ + success: true, + data: { + id: keyData.id + } + }); + + } catch (error) { + logger.error('❌ Failed to get API key ID:', error); + res.status(500).json({ + error: 'Internal server error', + message: 'Failed to retrieve API key ID' + }); + } +}); + +// 📊 用户API Key统计查询接口 - 安全的自查询接口 +router.post('/api/user-stats', async (req, res) => { + try { + const { apiKey, apiId } = req.body; + + let keyData; + let keyId; + + if (apiId) { + // 通过 apiId 查询 + if (typeof apiId !== 'string' || !apiId.match(/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i)) { + return res.status(400).json({ + error: 'Invalid API ID format', + message: 'API ID must be a valid UUID' + }); + } + + // 直接通过 ID 获取 API Key 数据 + keyData = await redis.getApiKey(apiId); + + if (!keyData || Object.keys(keyData).length === 0) { + logger.security(`🔒 API key not found for ID: ${apiId} from ${req.ip || 'unknown'}`); + return res.status(404).json({ + error: 'API key not found', + message: 'The specified API key does not exist' + }); + } + + // 检查是否激活 + if (keyData.isActive !== 'true') { + return res.status(403).json({ + error: 'API key is disabled', + message: 'This API key has been disabled' + }); + } + + // 检查是否过期 + if (keyData.expiresAt && new Date() > new Date(keyData.expiresAt)) { + return res.status(403).json({ + error: 'API key has expired', + message: 'This API key has expired' + }); + } + + keyId = apiId; + + // 获取使用统计 + const usage = await redis.getUsageStats(keyId); + + // 获取当日费用统计 + const dailyCost = await redis.getDailyCost(keyId); + + // 处理数据格式,与 validateApiKey 返回的格式保持一致 + // 解析限制模型数据 + let restrictedModels = []; + try { + restrictedModels = keyData.restrictedModels ? JSON.parse(keyData.restrictedModels) : []; + } catch (e) { + restrictedModels = []; + } + + // 解析允许的客户端数据 + let allowedClients = []; + try { + allowedClients = keyData.allowedClients ? JSON.parse(keyData.allowedClients) : []; + } catch (e) { + allowedClients = []; + } + + // 格式化 keyData + keyData = { + ...keyData, + tokenLimit: parseInt(keyData.tokenLimit) || 0, + concurrencyLimit: parseInt(keyData.concurrencyLimit) || 0, + rateLimitWindow: parseInt(keyData.rateLimitWindow) || 0, + rateLimitRequests: parseInt(keyData.rateLimitRequests) || 0, + dailyCostLimit: parseFloat(keyData.dailyCostLimit) || 0, + dailyCost: dailyCost || 0, + enableModelRestriction: keyData.enableModelRestriction === 'true', + restrictedModels: restrictedModels, + enableClientRestriction: keyData.enableClientRestriction === 'true', + allowedClients: allowedClients, + permissions: keyData.permissions || 'all', + usage: usage // 使用完整的 usage 数据,而不是只有 total + }; + + } else if (apiKey) { + // 通过 apiKey 查询(保持向后兼容) + if (typeof apiKey !== 'string' || apiKey.length < 10 || apiKey.length > 512) { + logger.security(`🔒 Invalid API key format in user stats query from ${req.ip || 'unknown'}`); + return res.status(400).json({ + error: 'Invalid API key format', + message: 'API key format is invalid' + }); + } + + // 验证API Key(重用现有的验证逻辑) + const validation = await apiKeyService.validateApiKey(apiKey); + + if (!validation.valid) { + const clientIP = req.ip || req.connection?.remoteAddress || 'unknown'; + logger.security(`🔒 Invalid API key in user stats query: ${validation.error} from ${clientIP}`); + return res.status(401).json({ + error: 'Invalid API key', + message: validation.error + }); + } + + keyData = validation.keyData; + keyId = keyData.id; + + } else { + logger.security(`🔒 Missing API key or ID in user stats query from ${req.ip || 'unknown'}`); + return res.status(400).json({ + error: 'API Key or ID is required', + message: 'Please provide your API Key or API ID' + }); + } + + // 记录合法查询 + logger.api(`📊 User stats query from key: ${keyData.name} (${keyId}) from ${req.ip || 'unknown'}`); + + // 获取验证结果中的完整keyData(包含isActive状态和cost信息) + const fullKeyData = keyData; + + // 计算总费用 - 使用与模型统计相同的逻辑(按模型分别计算) + let totalCost = 0; + let formattedCost = '$0.000000'; + + try { + const client = redis.getClientSafe(); + + // 获取所有月度模型统计(与model-stats接口相同的逻辑) + const allModelKeys = await client.keys(`usage:${keyId}:model:monthly:*:*`); + const modelUsageMap = new Map(); + + for (const key of allModelKeys) { + const modelMatch = key.match(/usage:.+:model:monthly:(.+):(\d{4}-\d{2})$/); + if (!modelMatch) continue; + + const model = modelMatch[1]; + const data = await client.hgetall(key); + + if (data && Object.keys(data).length > 0) { + if (!modelUsageMap.has(model)) { + modelUsageMap.set(model, { + inputTokens: 0, + outputTokens: 0, + cacheCreateTokens: 0, + cacheReadTokens: 0 + }); + } + + const modelUsage = modelUsageMap.get(model); + modelUsage.inputTokens += parseInt(data.inputTokens) || 0; + modelUsage.outputTokens += parseInt(data.outputTokens) || 0; + modelUsage.cacheCreateTokens += parseInt(data.cacheCreateTokens) || 0; + modelUsage.cacheReadTokens += parseInt(data.cacheReadTokens) || 0; + } + } + + // 按模型计算费用并汇总 + for (const [model, usage] of modelUsageMap) { + const usageData = { + input_tokens: usage.inputTokens, + output_tokens: usage.outputTokens, + cache_creation_input_tokens: usage.cacheCreateTokens, + cache_read_input_tokens: usage.cacheReadTokens + }; + + const costResult = CostCalculator.calculateCost(usageData, model); + totalCost += costResult.costs.total; + } + + // 如果没有模型级别的详细数据,回退到总体数据计算 + if (modelUsageMap.size === 0 && fullKeyData.usage?.total?.allTokens > 0) { + const usage = fullKeyData.usage.total; + const costUsage = { + input_tokens: usage.inputTokens || 0, + output_tokens: usage.outputTokens || 0, + cache_creation_input_tokens: usage.cacheCreateTokens || 0, + cache_read_input_tokens: usage.cacheReadTokens || 0 + }; + + const costResult = CostCalculator.calculateCost(costUsage, 'claude-3-5-sonnet-20241022'); + totalCost = costResult.costs.total; + } + + formattedCost = CostCalculator.formatCost(totalCost); + + } catch (error) { + logger.warn(`Failed to calculate detailed cost for key ${keyId}:`, error); + // 回退到简单计算 + if (fullKeyData.usage?.total?.allTokens > 0) { + const usage = fullKeyData.usage.total; + const costUsage = { + input_tokens: usage.inputTokens || 0, + output_tokens: usage.outputTokens || 0, + cache_creation_input_tokens: usage.cacheCreateTokens || 0, + cache_read_input_tokens: usage.cacheReadTokens || 0 + }; + + const costResult = CostCalculator.calculateCost(costUsage, 'claude-3-5-sonnet-20241022'); + totalCost = costResult.costs.total; + formattedCost = costResult.formatted.total; + } + } + + // 构建响应数据(只返回该API Key自己的信息,确保不泄露其他信息) + const responseData = { + id: keyId, + name: fullKeyData.name, + description: keyData.description || '', + isActive: true, // 如果能通过validateApiKey验证,说明一定是激活的 + createdAt: keyData.createdAt, + expiresAt: keyData.expiresAt, + permissions: fullKeyData.permissions, + + // 使用统计(使用验证结果中的完整数据) + usage: { + total: { + ...(fullKeyData.usage?.total || { + requests: 0, + tokens: 0, + allTokens: 0, + inputTokens: 0, + outputTokens: 0, + cacheCreateTokens: 0, + cacheReadTokens: 0 + }), + cost: totalCost, + formattedCost: formattedCost + } + }, + + // 限制信息(只显示配置,不显示当前使用量) + limits: { + tokenLimit: fullKeyData.tokenLimit || 0, + concurrencyLimit: fullKeyData.concurrencyLimit || 0, + rateLimitWindow: fullKeyData.rateLimitWindow || 0, + rateLimitRequests: fullKeyData.rateLimitRequests || 0, + dailyCostLimit: fullKeyData.dailyCostLimit || 0 + }, + + // 绑定的账户信息(只显示ID,不显示敏感信息) + accounts: { + claudeAccountId: fullKeyData.claudeAccountId && fullKeyData.claudeAccountId !== '' ? fullKeyData.claudeAccountId : null, + geminiAccountId: fullKeyData.geminiAccountId && fullKeyData.geminiAccountId !== '' ? fullKeyData.geminiAccountId : null + }, + + // 模型和客户端限制信息 + restrictions: { + enableModelRestriction: fullKeyData.enableModelRestriction || false, + restrictedModels: fullKeyData.restrictedModels || [], + enableClientRestriction: fullKeyData.enableClientRestriction || false, + allowedClients: fullKeyData.allowedClients || [] + } + }; + + res.json({ + success: true, + data: responseData + }); + + } catch (error) { + logger.error('❌ Failed to process user stats query:', error); + res.status(500).json({ + error: 'Internal server error', + message: 'Failed to retrieve API key statistics' + }); + } +}); + +// 📊 用户模型统计查询接口 - 安全的自查询接口 +router.post('/api/user-model-stats', async (req, res) => { + try { + const { apiKey, apiId, period = 'monthly' } = req.body; + + let keyData; + let keyId; + + if (apiId) { + // 通过 apiId 查询 + if (typeof apiId !== 'string' || !apiId.match(/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i)) { + return res.status(400).json({ + error: 'Invalid API ID format', + message: 'API ID must be a valid UUID' + }); + } + + // 直接通过 ID 获取 API Key 数据 + keyData = await redis.getApiKey(apiId); + + if (!keyData || Object.keys(keyData).length === 0) { + logger.security(`🔒 API key not found for ID: ${apiId} from ${req.ip || 'unknown'}`); + return res.status(404).json({ + error: 'API key not found', + message: 'The specified API key does not exist' + }); + } + + // 检查是否激活 + if (keyData.isActive !== 'true') { + return res.status(403).json({ + error: 'API key is disabled', + message: 'This API key has been disabled' + }); + } + + keyId = apiId; + + // 获取使用统计 + const usage = await redis.getUsageStats(keyId); + keyData.usage = { total: usage.total }; + + } else if (apiKey) { + // 通过 apiKey 查询(保持向后兼容) + // 验证API Key + const validation = await apiKeyService.validateApiKey(apiKey); + + if (!validation.valid) { + const clientIP = req.ip || req.connection?.remoteAddress || 'unknown'; + logger.security(`🔒 Invalid API key in user model stats query: ${validation.error} from ${clientIP}`); + return res.status(401).json({ + error: 'Invalid API key', + message: validation.error + }); + } + + keyData = validation.keyData; + keyId = keyData.id; + + } else { + logger.security(`🔒 Missing API key or ID in user model stats query from ${req.ip || 'unknown'}`); + return res.status(400).json({ + error: 'API Key or ID is required', + message: 'Please provide your API Key or API ID' + }); + } + + logger.api(`📊 User model stats query from key: ${keyData.name} (${keyId}) for period: ${period}`); + + // 重用管理后台的模型统计逻辑,但只返回该API Key的数据 + const client = redis.getClientSafe(); + // 使用与管理页面相同的时区处理逻辑 + const tzDate = redis.getDateInTimezone(); + const today = redis.getDateStringInTimezone(); + const currentMonth = `${tzDate.getFullYear()}-${String(tzDate.getMonth() + 1).padStart(2, '0')}`; + + const pattern = period === 'daily' ? + `usage:${keyId}:model:daily:*:${today}` : + `usage:${keyId}:model:monthly:*:${currentMonth}`; + + const keys = await client.keys(pattern); + const modelStats = []; + + for (const key of keys) { + const match = key.match(period === 'daily' ? + /usage:.+:model:daily:(.+):\d{4}-\d{2}-\d{2}$/ : + /usage:.+:model:monthly:(.+):\d{4}-\d{2}$/ + ); + + if (!match) continue; + + const model = match[1]; + const data = await client.hgetall(key); + + if (data && Object.keys(data).length > 0) { + const usage = { + input_tokens: parseInt(data.inputTokens) || 0, + output_tokens: parseInt(data.outputTokens) || 0, + cache_creation_input_tokens: parseInt(data.cacheCreateTokens) || 0, + cache_read_input_tokens: parseInt(data.cacheReadTokens) || 0 + }; + + const costData = CostCalculator.calculateCost(usage, model); + + modelStats.push({ + model, + requests: parseInt(data.requests) || 0, + inputTokens: usage.input_tokens, + outputTokens: usage.output_tokens, + cacheCreateTokens: usage.cache_creation_input_tokens, + cacheReadTokens: usage.cache_read_input_tokens, + allTokens: parseInt(data.allTokens) || 0, + costs: costData.costs, + formatted: costData.formatted, + pricing: costData.pricing + }); + } + } + + // 如果没有详细的模型数据,不显示历史数据以避免混淆 + // 只有在查询特定时间段时返回空数组,表示该时间段确实没有数据 + if (modelStats.length === 0) { + logger.info(`📊 No model stats found for key ${keyId} in period ${period}`); + } + + // 按总token数降序排列 + modelStats.sort((a, b) => b.allTokens - a.allTokens); + + res.json({ + success: true, + data: modelStats, + period: period + }); + + } catch (error) { + logger.error('❌ Failed to process user model stats query:', error); + res.status(500).json({ + error: 'Internal server error', + message: 'Failed to retrieve model statistics' + }); + } +}); + +module.exports = router; \ No newline at end of file diff --git a/src/routes/openaiClaudeRoutes.js b/src/routes/openaiClaudeRoutes.js index b72c1257..247fc127 100644 --- a/src/routes/openaiClaudeRoutes.js +++ b/src/routes/openaiClaudeRoutes.js @@ -258,7 +258,8 @@ async function handleChatCompletion(req, res, apiKeyData) { outputTokens, cacheCreateTokens, cacheReadTokens, - model + model, + accountId ).catch(error => { logger.error('❌ Failed to record usage:', error); }); @@ -327,7 +328,8 @@ async function handleChatCompletion(req, res, apiKeyData) { usage.output_tokens || 0, usage.cache_creation_input_tokens || 0, usage.cache_read_input_tokens || 0, - claudeRequest.model + claudeRequest.model, + accountId ).catch(error => { logger.error('❌ Failed to record usage:', error); }); diff --git a/src/routes/web.js b/src/routes/web.js index 8e229363..7aacae26 100644 --- a/src/routes/web.js +++ b/src/routes/web.js @@ -25,7 +25,7 @@ const ALLOWED_FILES = { 'style.css': { path: path.join(__dirname, '../../web/admin/style.css'), contentType: 'text/css; charset=utf-8' - } + }, }; // 🛡️ 安全文件服务函数 @@ -400,6 +400,9 @@ router.get('/style.css', (req, res) => { serveWhitelistedFile(req, res, 'style.css'); }); + + + // 🔑 Gemini OAuth 回调页面 module.exports = router; \ No newline at end of file diff --git a/src/services/apiKeyService.js b/src/services/apiKeyService.js index c9527767..f7a1797b 100644 --- a/src/services/apiKeyService.js +++ b/src/services/apiKeyService.js @@ -24,7 +24,10 @@ class ApiKeyService { rateLimitWindow = null, rateLimitRequests = null, enableModelRestriction = false, - restrictedModels = [] + restrictedModels = [], + enableClientRestriction = false, + allowedClients = [], + dailyCostLimit = 0 } = options; // 生成简单的API Key (64字符十六进制) @@ -47,6 +50,9 @@ class ApiKeyService { permissions: permissions || 'all', enableModelRestriction: String(enableModelRestriction), restrictedModels: JSON.stringify(restrictedModels || []), + enableClientRestriction: String(enableClientRestriction || false), + allowedClients: JSON.stringify(allowedClients || []), + dailyCostLimit: String(dailyCostLimit || 0), createdAt: new Date().toISOString(), lastUsedAt: '', expiresAt: expiresAt || '', @@ -73,6 +79,9 @@ class ApiKeyService { permissions: keyData.permissions, enableModelRestriction: keyData.enableModelRestriction === 'true', restrictedModels: JSON.parse(keyData.restrictedModels), + enableClientRestriction: keyData.enableClientRestriction === 'true', + allowedClients: JSON.parse(keyData.allowedClients || '[]'), + dailyCostLimit: parseFloat(keyData.dailyCostLimit || 0), createdAt: keyData.createdAt, expiresAt: keyData.expiresAt, createdBy: keyData.createdBy @@ -108,6 +117,9 @@ class ApiKeyService { // 获取使用统计(供返回数据使用) const usage = await redis.getUsageStats(keyData.id); + + // 获取当日费用统计 + const dailyCost = await redis.getDailyCost(keyData.id); // 更新最后使用时间(优化:只在实际API调用时更新,而不是验证时) // 注意:lastUsedAt的更新已移至recordUsage方法中 @@ -122,11 +134,22 @@ class ApiKeyService { restrictedModels = []; } + // 解析允许的客户端 + let allowedClients = []; + try { + allowedClients = keyData.allowedClients ? JSON.parse(keyData.allowedClients) : []; + } catch (e) { + allowedClients = []; + } + return { valid: true, keyData: { id: keyData.id, name: keyData.name, + description: keyData.description, + createdAt: keyData.createdAt, + expiresAt: keyData.expiresAt, claudeAccountId: keyData.claudeAccountId, geminiAccountId: keyData.geminiAccountId, permissions: keyData.permissions || 'all', @@ -136,6 +159,10 @@ class ApiKeyService { rateLimitRequests: parseInt(keyData.rateLimitRequests || 0), enableModelRestriction: keyData.enableModelRestriction === 'true', restrictedModels: restrictedModels, + enableClientRestriction: keyData.enableClientRestriction === 'true', + allowedClients: allowedClients, + dailyCostLimit: parseFloat(keyData.dailyCostLimit || 0), + dailyCost: dailyCost || 0, usage } }; @@ -160,12 +187,20 @@ class ApiKeyService { key.currentConcurrency = await redis.getConcurrency(key.id); key.isActive = key.isActive === 'true'; key.enableModelRestriction = key.enableModelRestriction === 'true'; + key.enableClientRestriction = key.enableClientRestriction === 'true'; key.permissions = key.permissions || 'all'; // 兼容旧数据 + key.dailyCostLimit = parseFloat(key.dailyCostLimit || 0); + key.dailyCost = await redis.getDailyCost(key.id) || 0; try { key.restrictedModels = key.restrictedModels ? JSON.parse(key.restrictedModels) : []; } catch (e) { key.restrictedModels = []; } + try { + key.allowedClients = key.allowedClients ? JSON.parse(key.allowedClients) : []; + } catch (e) { + key.allowedClients = []; + } delete key.apiKey; // 不返回哈希后的key } @@ -185,15 +220,15 @@ class ApiKeyService { } // 允许更新的字段 - const allowedUpdates = ['name', 'description', 'tokenLimit', 'concurrencyLimit', 'rateLimitWindow', 'rateLimitRequests', 'isActive', 'claudeAccountId', 'geminiAccountId', 'permissions', 'expiresAt', 'enableModelRestriction', 'restrictedModels']; + const allowedUpdates = ['name', 'description', 'tokenLimit', 'concurrencyLimit', 'rateLimitWindow', 'rateLimitRequests', 'isActive', 'claudeAccountId', 'geminiAccountId', 'permissions', 'expiresAt', 'enableModelRestriction', 'restrictedModels', 'enableClientRestriction', 'allowedClients', 'dailyCostLimit']; const updatedData = { ...keyData }; for (const [field, value] of Object.entries(updates)) { if (allowedUpdates.includes(field)) { - if (field === 'restrictedModels') { - // 特殊处理 restrictedModels 数组 + if (field === 'restrictedModels' || field === 'allowedClients') { + // 特殊处理数组字段 updatedData[field] = JSON.stringify(value || []); - } else if (field === 'enableModelRestriction') { + } else if (field === 'enableModelRestriction' || field === 'enableClientRestriction') { // 布尔值转字符串 updatedData[field] = String(value); } else { @@ -234,18 +269,45 @@ class ApiKeyService { } } - // 📊 记录使用情况(支持缓存token) - async recordUsage(keyId, inputTokens = 0, outputTokens = 0, cacheCreateTokens = 0, cacheReadTokens = 0, model = 'unknown') { + // 📊 记录使用情况(支持缓存token和账户级别统计) + async recordUsage(keyId, inputTokens = 0, outputTokens = 0, cacheCreateTokens = 0, cacheReadTokens = 0, model = 'unknown', accountId = null) { try { const totalTokens = inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens; + + // 计算费用 + const CostCalculator = require('../utils/costCalculator'); + const costInfo = CostCalculator.calculateCost({ + input_tokens: inputTokens, + output_tokens: outputTokens, + cache_creation_input_tokens: cacheCreateTokens, + cache_read_input_tokens: cacheReadTokens + }, model); + + // 记录API Key级别的使用统计 await redis.incrementTokenUsage(keyId, totalTokens, inputTokens, outputTokens, cacheCreateTokens, cacheReadTokens, model); - // 更新最后使用时间(性能优化:只在实际使用时更新) + // 记录费用统计 + if (costInfo.costs.total > 0) { + await redis.incrementDailyCost(keyId, costInfo.costs.total); + logger.database(`💰 Recorded cost for ${keyId}: $${costInfo.costs.total.toFixed(6)}, model: ${model}`); + } else { + logger.debug(`💰 No cost recorded for ${keyId} - zero cost for model: ${model}`); + } + + // 获取API Key数据以确定关联的账户 const keyData = await redis.getApiKey(keyId); if (keyData && Object.keys(keyData).length > 0) { + // 更新最后使用时间 keyData.lastUsedAt = new Date().toISOString(); - // 使用记录时不需要重新建立哈希映射 await redis.setApiKey(keyId, keyData); + + // 记录账户级别的使用统计(只统计实际处理请求的账户) + if (accountId) { + await redis.incrementAccountUsage(accountId, totalTokens, inputTokens, outputTokens, cacheCreateTokens, cacheReadTokens, model); + logger.database(`📊 Recorded account usage: ${accountId} - ${totalTokens} tokens (API Key: ${keyId})`); + } else { + logger.debug('⚠️ No accountId provided for usage recording, skipping account-level statistics'); + } } const logParts = [`Model: ${model}`, `Input: ${inputTokens}`, `Output: ${outputTokens}`]; @@ -274,6 +336,16 @@ class ApiKeyService { return await redis.getUsageStats(keyId); } + // 📊 获取账户使用统计 + async getAccountUsageStats(accountId) { + return await redis.getAccountUsageStats(accountId); + } + + // 📈 获取所有账户使用统计 + async getAllAccountsUsageStats() { + return await redis.getAllAccountsUsageStats(); + } + // 🧹 清理过期的API Keys async cleanupExpiredKeys() { @@ -283,14 +355,17 @@ class ApiKeyService { let cleanedCount = 0; for (const key of apiKeys) { - if (key.expiresAt && new Date(key.expiresAt) < now) { - await redis.deleteApiKey(key.id); + // 检查是否已过期且仍处于激活状态 + if (key.expiresAt && new Date(key.expiresAt) < now && key.isActive === 'true') { + // 将过期的 API Key 标记为禁用状态,而不是直接删除 + await this.updateApiKey(key.id, { isActive: false }); + logger.info(`🔒 API Key ${key.id} (${key.name}) has expired and been disabled`); cleanedCount++; } } if (cleanedCount > 0) { - logger.success(`🧹 Cleaned up ${cleanedCount} expired API keys`); + logger.success(`🧹 Disabled ${cleanedCount} expired API keys`); } return cleanedCount; diff --git a/src/services/claudeAccountService.js b/src/services/claudeAccountService.js index 3428b80f..c93ed79d 100644 --- a/src/services/claudeAccountService.js +++ b/src/services/claudeAccountService.js @@ -444,11 +444,11 @@ class ClaudeAccountService { } // 如果没有映射或映射无效,选择新账户 - // 优先选择最近刷新过token的账户 + // 优先选择最久未使用的账户(负载均衡) const sortedAccounts = activeAccounts.sort((a, b) => { - const aLastRefresh = new Date(a.lastRefreshAt || 0).getTime(); - const bLastRefresh = new Date(b.lastRefreshAt || 0).getTime(); - return bLastRefresh - aLastRefresh; + const aLastUsed = new Date(a.lastUsedAt || 0).getTime(); + const bLastUsed = new Date(b.lastUsedAt || 0).getTime(); + return aLastUsed - bLastUsed; // 最久未使用的优先 }); const selectedAccountId = sortedAccounts[0].id; @@ -544,11 +544,11 @@ class ClaudeAccountService { return aRateLimitedAt - bRateLimitedAt; // 最早限流的优先 }); } else { - // 非限流账户按最近刷新时间排序 + // 非限流账户按最后使用时间排序(最久未使用的优先) candidateAccounts = candidateAccounts.sort((a, b) => { - const aLastRefresh = new Date(a.lastRefreshAt || 0).getTime(); - const bLastRefresh = new Date(b.lastRefreshAt || 0).getTime(); - return bLastRefresh - aLastRefresh; + const aLastUsed = new Date(a.lastUsedAt || 0).getTime(); + const bLastUsed = new Date(b.lastUsedAt || 0).getTime(); + return aLastUsed - bLastUsed; // 最久未使用的优先 }); } diff --git a/src/services/claudeRelayService.js b/src/services/claudeRelayService.js index 76585547..f246063d 100644 --- a/src/services/claudeRelayService.js +++ b/src/services/claudeRelayService.js @@ -181,6 +181,8 @@ class ClaudeRelayService { logger.info(`✅ API request completed - Key: ${apiKeyData.name}, Account: ${accountId}, Model: ${requestBody.model}, Input: ~${Math.round(inputTokens)} tokens, Output: ~${Math.round(outputTokens)} tokens`); + // 在响应中添加accountId,以便调用方记录账户级别统计 + response.accountId = accountId; return response; } catch (error) { logger.error(`❌ Claude relay request failed for key: ${apiKeyData.name || apiKeyData.id}:`, error.message); @@ -619,7 +621,10 @@ class ClaudeRelayService { const proxyAgent = await this._getProxyAgent(accountId); // 发送流式请求并捕获usage数据 - return await this._makeClaudeStreamRequestWithUsageCapture(processedBody, accessToken, proxyAgent, clientHeaders, responseStream, usageCallback, accountId, sessionHash, streamTransformer, options); + return await this._makeClaudeStreamRequestWithUsageCapture(processedBody, accessToken, proxyAgent, clientHeaders, responseStream, (usageData) => { + // 在usageCallback中添加accountId + usageCallback({ ...usageData, accountId }); + }, accountId, sessionHash, streamTransformer, options); } catch (error) { logger.error('❌ Claude stream relay with usage capture failed:', error); throw error; diff --git a/src/services/costInitService.js b/src/services/costInitService.js new file mode 100644 index 00000000..e61063dd --- /dev/null +++ b/src/services/costInitService.js @@ -0,0 +1,182 @@ +const redis = require('../models/redis'); +const apiKeyService = require('./apiKeyService'); +const CostCalculator = require('../utils/costCalculator'); +const logger = require('../utils/logger'); + +class CostInitService { + /** + * 初始化所有API Key的费用数据 + * 扫描历史使用记录并计算费用 + */ + async initializeAllCosts() { + try { + logger.info('💰 Starting cost initialization for all API Keys...'); + + const apiKeys = await apiKeyService.getAllApiKeys(); + const client = redis.getClientSafe(); + + let processedCount = 0; + let errorCount = 0; + + for (const apiKey of apiKeys) { + try { + await this.initializeApiKeyCosts(apiKey.id, client); + processedCount++; + + if (processedCount % 10 === 0) { + logger.info(`💰 Processed ${processedCount} API Keys...`); + } + } catch (error) { + errorCount++; + logger.error(`❌ Failed to initialize costs for API Key ${apiKey.id}:`, error); + } + } + + logger.success(`💰 Cost initialization completed! Processed: ${processedCount}, Errors: ${errorCount}`); + return { processed: processedCount, errors: errorCount }; + } catch (error) { + logger.error('❌ Failed to initialize costs:', error); + throw error; + } + } + + /** + * 初始化单个API Key的费用数据 + */ + async initializeApiKeyCosts(apiKeyId, client) { + // 获取所有时间的模型使用统计 + const modelKeys = await client.keys(`usage:${apiKeyId}:model:*:*:*`); + + // 按日期分组统计 + const dailyCosts = new Map(); // date -> cost + const monthlyCosts = new Map(); // month -> cost + const hourlyCosts = new Map(); // hour -> cost + + for (const key of modelKeys) { + // 解析key格式: usage:{keyId}:model:{period}:{model}:{date} + const match = key.match(/usage:(.+):model:(daily|monthly|hourly):(.+):(\d{4}-\d{2}(?:-\d{2})?(?::\d{2})?)$/); + if (!match) continue; + + const [, , period, model, dateStr] = match; + + // 获取使用数据 + const data = await client.hgetall(key); + if (!data || Object.keys(data).length === 0) continue; + + // 计算费用 + const usage = { + input_tokens: parseInt(data.totalInputTokens) || parseInt(data.inputTokens) || 0, + output_tokens: parseInt(data.totalOutputTokens) || parseInt(data.outputTokens) || 0, + cache_creation_input_tokens: parseInt(data.totalCacheCreateTokens) || parseInt(data.cacheCreateTokens) || 0, + cache_read_input_tokens: parseInt(data.totalCacheReadTokens) || parseInt(data.cacheReadTokens) || 0 + }; + + const costResult = CostCalculator.calculateCost(usage, model); + const cost = costResult.costs.total; + + // 根据period分组累加费用 + if (period === 'daily') { + const currentCost = dailyCosts.get(dateStr) || 0; + dailyCosts.set(dateStr, currentCost + cost); + } else if (period === 'monthly') { + const currentCost = monthlyCosts.get(dateStr) || 0; + monthlyCosts.set(dateStr, currentCost + cost); + } else if (period === 'hourly') { + const currentCost = hourlyCosts.get(dateStr) || 0; + hourlyCosts.set(dateStr, currentCost + cost); + } + } + + // 将计算出的费用写入Redis + const promises = []; + + // 写入每日费用 + for (const [date, cost] of dailyCosts) { + const key = `usage:cost:daily:${apiKeyId}:${date}`; + promises.push( + client.set(key, cost.toString()), + client.expire(key, 86400 * 30) // 30天过期 + ); + } + + // 写入每月费用 + for (const [month, cost] of monthlyCosts) { + const key = `usage:cost:monthly:${apiKeyId}:${month}`; + promises.push( + client.set(key, cost.toString()), + client.expire(key, 86400 * 90) // 90天过期 + ); + } + + // 写入每小时费用 + for (const [hour, cost] of hourlyCosts) { + const key = `usage:cost:hourly:${apiKeyId}:${hour}`; + promises.push( + client.set(key, cost.toString()), + client.expire(key, 86400 * 7) // 7天过期 + ); + } + + // 计算总费用 + let totalCost = 0; + for (const cost of dailyCosts.values()) { + totalCost += cost; + } + + // 写入总费用 + if (totalCost > 0) { + const totalKey = `usage:cost:total:${apiKeyId}`; + promises.push(client.set(totalKey, totalCost.toString())); + } + + await Promise.all(promises); + + logger.debug(`💰 Initialized costs for API Key ${apiKeyId}: Daily entries: ${dailyCosts.size}, Total cost: $${totalCost.toFixed(2)}`); + } + + /** + * 检查是否需要初始化费用数据 + */ + async needsInitialization() { + try { + const client = redis.getClientSafe(); + + // 检查是否有任何费用数据 + const costKeys = await client.keys('usage:cost:*'); + + // 如果没有费用数据,需要初始化 + if (costKeys.length === 0) { + logger.info('💰 No cost data found, initialization needed'); + return true; + } + + // 检查是否有使用数据但没有对应的费用数据 + const sampleKeys = await client.keys('usage:*:model:daily:*:*'); + if (sampleKeys.length > 10) { + // 抽样检查 + const sampleSize = Math.min(10, sampleKeys.length); + for (let i = 0; i < sampleSize; i++) { + const usageKey = sampleKeys[Math.floor(Math.random() * sampleKeys.length)]; + const match = usageKey.match(/usage:(.+):model:daily:(.+):(\d{4}-\d{2}-\d{2})$/); + if (match) { + const [, keyId, , date] = match; + const costKey = `usage:cost:daily:${keyId}:${date}`; + const hasCost = await client.exists(costKey); + if (!hasCost) { + logger.info(`💰 Found usage without cost data for key ${keyId} on ${date}, initialization needed`); + return true; + } + } + } + } + + logger.info('💰 Cost data appears to be up to date'); + return false; + } catch (error) { + logger.error('❌ Failed to check initialization status:', error); + return false; + } + } +} + +module.exports = new CostInitService(); \ No newline at end of file diff --git a/src/utils/logger.js b/src/utils/logger.js index 0572a31a..e905e90a 100644 --- a/src/utils/logger.js +++ b/src/utils/logger.js @@ -107,7 +107,7 @@ const securityLogger = winston.createLogger({ // 🌟 增强的 Winston logger const logger = winston.createLogger({ - level: config.logging.level, + level: process.env.LOG_LEVEL || config.logging.level, format: logFormat, transports: [ // 📄 文件输出 @@ -282,10 +282,11 @@ logger.healthCheck = () => { // 🎬 启动日志记录系统 logger.start('Logger initialized', { - level: config.logging.level, + level: process.env.LOG_LEVEL || config.logging.level, directory: config.logging.dirname, maxSize: config.logging.maxSize, - maxFiles: config.logging.maxFiles + maxFiles: config.logging.maxFiles, + envOverride: process.env.LOG_LEVEL ? true : false }); module.exports = logger; \ No newline at end of file diff --git a/web/admin/app.js b/web/admin/app.js index 990afadb..ff580da9 100644 --- a/web/admin/app.js +++ b/web/admin/app.js @@ -1,3 +1,4 @@ +/* global Vue, Chart, ElementPlus, ElementPlusLocaleZhCn, FileReader, document, localStorage, location, navigator, window */ const { createApp } = Vue; const app = createApp({ @@ -24,7 +25,8 @@ const app = createApp({ { key: 'dashboard', name: '仪表板', icon: 'fas fa-tachometer-alt' }, { key: 'apiKeys', name: 'API Keys', icon: 'fas fa-key' }, { key: 'accounts', name: '账户管理', icon: 'fas fa-user-circle' }, - { key: 'tutorial', name: '使用教程', icon: 'fas fa-graduation-cap' } + { key: 'tutorial', name: '使用教程', icon: 'fas fa-graduation-cap' }, + { key: 'settings', name: '其他设置', icon: 'fas fa-cogs' } ], // 教程系统选择 @@ -111,6 +113,9 @@ const app = createApp({ // API Keys apiKeys: [], apiKeysLoading: false, + apiKeyStatsTimeRange: 'all', // API Key统计时间范围:all, 7days, monthly + apiKeysSortBy: '', // 当前排序字段 + apiKeysSortOrder: 'asc', // 排序顺序 'asc' 或 'desc' showCreateApiKeyModal: false, createApiKeyLoading: false, apiKeyForm: { @@ -125,7 +130,13 @@ const app = createApp({ permissions: 'all', // 'claude', 'gemini', 'all' enableModelRestriction: false, restrictedModels: [], - modelInput: '' + modelInput: '', + enableClientRestriction: false, + allowedClients: [], + expireDuration: '', // 过期时长选择 + customExpireDate: '', // 自定义过期日期 + expiresAt: null, // 实际的过期时间戳 + dailyCostLimit: '' // 每日费用限制 }, apiKeyModelStats: {}, // 存储每个key的模型统计数据 expandedApiKeys: {}, // 跟踪展开的API Keys @@ -154,6 +165,18 @@ const app = createApp({ description: '', showFullKey: false }, + + // API Key续期 + showRenewApiKeyModal: false, + renewApiKeyLoading: false, + renewApiKeyForm: { + id: '', + name: '', + currentExpiresAt: null, + renewDuration: '30d', + customExpireDate: '', + newExpiresAt: null + }, // 编辑API Key showEditApiKeyModal: false, @@ -170,12 +193,20 @@ const app = createApp({ permissions: 'all', enableModelRestriction: false, restrictedModels: [], - modelInput: '' + modelInput: '', + enableClientRestriction: false, + allowedClients: [], + dailyCostLimit: '' }, + // 支持的客户端列表 + supportedClients: [], + // 账户 accounts: [], accountsLoading: false, + accountSortBy: 'dailyTokens', // 默认按今日Token排序 + accountsSortOrder: 'asc', // 排序顺序 'asc' 或 'desc' showCreateAccountModal: false, createAccountLoading: false, accountForm: { @@ -269,7 +300,17 @@ const app = createApp({ showReleaseNotes: false, // 是否显示发布说明 autoCheckInterval: null, // 自动检查定时器 noUpdateMessage: false // 显示"已是最新版"提醒 - } + }, + + // OEM设置相关 + oemSettings: { + siteName: 'Claude Relay Service', + siteIcon: '', + siteIconData: '', // Base64图标数据 + updatedAt: null + }, + oemSettingsLoading: false, + oemSettingsSaving: false } }, @@ -279,17 +320,104 @@ const app = createApp({ return `${window.location.protocol}//${window.location.host}/api/`; }, + // 排序后的账户列表 + sortedAccounts() { + if (!this.accountsSortBy) { + return this.accounts; + } + + return [...this.accounts].sort((a, b) => { + let aValue = a[this.accountsSortBy]; + let bValue = b[this.accountsSortBy]; + + // 特殊处理状态字段 + if (this.accountsSortBy === 'status') { + aValue = a.isActive ? 1 : 0; + bValue = b.isActive ? 1 : 0; + } + + // 处理字符串比较 + if (typeof aValue === 'string' && typeof bValue === 'string') { + aValue = aValue.toLowerCase(); + bValue = bValue.toLowerCase(); + } + + // 排序 + if (this.accountsSortOrder === 'asc') { + return aValue > bValue ? 1 : aValue < bValue ? -1 : 0; + } else { + return aValue < bValue ? 1 : aValue > bValue ? -1 : 0; + } + }); + }, + + // 排序后的API Keys列表 + sortedApiKeys() { + if (!this.apiKeysSortBy) { + return this.apiKeys; + } + + return [...this.apiKeys].sort((a, b) => { + let aValue, bValue; + + // 特殊处理不同字段 + switch (this.apiKeysSortBy) { + case 'status': + aValue = a.isActive ? 1 : 0; + bValue = b.isActive ? 1 : 0; + break; + case 'cost': + // 计算费用,转换为数字比较 + aValue = this.calculateApiKeyCostNumber(a.usage); + bValue = this.calculateApiKeyCostNumber(b.usage); + break; + case 'createdAt': + case 'expiresAt': + // 日期比较 + aValue = a[this.apiKeysSortBy] ? new Date(a[this.apiKeysSortBy]).getTime() : 0; + bValue = b[this.apiKeysSortBy] ? new Date(b[this.apiKeysSortBy]).getTime() : 0; + break; + default: + aValue = a[this.apiKeysSortBy]; + bValue = b[this.apiKeysSortBy]; + + // 处理字符串比较 + if (typeof aValue === 'string' && typeof bValue === 'string') { + aValue = aValue.toLowerCase(); + bValue = bValue.toLowerCase(); + } + } + + // 排序 + if (this.apiKeysSortOrder === 'asc') { + return aValue > bValue ? 1 : aValue < bValue ? -1 : 0; + } else { + return aValue < bValue ? 1 : aValue > bValue ? -1 : 0; + } + }); + }, + // 获取专属账号列表 dedicatedAccounts() { return this.accounts.filter(account => account.accountType === 'dedicated' && account.isActive === true ); + }, + + // 计算最小日期时间(当前时间) + minDateTime() { + const now = new Date(); + now.setMinutes(now.getMinutes() - now.getTimezoneOffset()); + return now.toISOString().slice(0, 16); } }, mounted() { console.log('Vue app mounted, authToken:', !!this.authToken, 'activeTab:', this.activeTab); + // 从URL参数中读取tab信息 + this.initializeTabFromUrl(); + // 初始化防抖函数 this.setTrendPeriod = this.debounce(this._setTrendPeriod, 300); @@ -303,6 +431,11 @@ const app = createApp({ } }); + // 监听浏览器前进后退按钮事件 + window.addEventListener('popstate', () => { + this.initializeTabFromUrl(); + }); + if (this.authToken) { this.isLoggedIn = true; @@ -315,14 +448,16 @@ const app = createApp({ // 初始化日期筛选器和图表数据 this.initializeDateFilter(); - // 预加载账号列表和API Keys,以便正确显示绑定关系 + // 预加载账号列表、API Keys和支持的客户端,以便正确显示绑定关系 Promise.all([ this.loadAccounts(), - this.loadApiKeys() + this.loadApiKeys(), + this.loadSupportedClients() ]).then(() => { // 根据当前活跃标签页加载数据 this.loadCurrentTabData(); }); + // 如果在仪表盘,等待Chart.js加载后初始化图表 if (this.activeTab === 'dashboard') { this.waitForChartJS().then(() => { @@ -334,6 +469,9 @@ const app = createApp({ } else { console.log('No auth token found, user needs to login'); } + + // 始终加载OEM设置,无论登录状态 + this.loadOemSettings(); }, beforeUnmount() { @@ -368,6 +506,64 @@ const app = createApp({ }, methods: { + // 账户列表排序 + sortAccounts(field) { + if (this.accountsSortBy === field) { + // 如果点击的是当前排序字段,切换排序顺序 + this.accountsSortOrder = this.accountsSortOrder === 'asc' ? 'desc' : 'asc'; + } else { + // 如果点击的是新字段,设置为升序 + this.accountsSortBy = field; + this.accountsSortOrder = 'asc'; + } + }, + + // API Keys列表排序 + sortApiKeys(field) { + if (this.apiKeysSortBy === field) { + // 如果点击的是当前排序字段,切换排序顺序 + this.apiKeysSortOrder = this.apiKeysSortOrder === 'asc' ? 'desc' : 'asc'; + } else { + // 如果点击的是新字段,设置为升序 + this.apiKeysSortBy = field; + this.apiKeysSortOrder = 'asc'; + } + }, + + // 从URL读取tab参数并设置activeTab + initializeTabFromUrl() { + const urlParams = new URLSearchParams(window.location.search); + const tabParam = urlParams.get('tab'); + + // 检查tab参数是否有效 + const validTabs = this.tabs.map(tab => tab.key); + if (tabParam && validTabs.includes(tabParam)) { + this.activeTab = tabParam; + } + }, + + // 切换tab并更新URL + switchTab(tabKey) { + if (this.activeTab !== tabKey) { + this.activeTab = tabKey; + this.updateUrlTab(tabKey); + } + }, + + // 更新URL中的tab参数 + updateUrlTab(tabKey) { + const url = new URL(window.location.href); + if (tabKey === 'dashboard') { + // 如果是默认的dashboard标签,移除tab参数 + url.searchParams.delete('tab'); + } else { + url.searchParams.set('tab', tabKey); + } + + // 使用pushState更新URL但不刷新页面 + window.history.pushState({}, '', url.toString()); + }, + // 统一的API请求方法,处理token过期等错误 async apiRequest(url, options = {}) { try { @@ -527,6 +723,86 @@ const app = createApp({ }); }, + // 更新过期时间 + updateExpireAt() { + const duration = this.apiKeyForm.expireDuration; + if (!duration) { + this.apiKeyForm.expiresAt = null; + return; + } + + if (duration === 'custom') { + // 自定义日期需要用户选择 + return; + } + + const now = new Date(); + const durationMap = { + '1d': 1, + '7d': 7, + '30d': 30, + '90d': 90, + '180d': 180, + '365d': 365 + }; + + const days = durationMap[duration]; + if (days) { + const expireDate = new Date(now.getTime() + days * 24 * 60 * 60 * 1000); + this.apiKeyForm.expiresAt = expireDate.toISOString(); + } + }, + + // 更新自定义过期时间 + updateCustomExpireAt() { + if (this.apiKeyForm.customExpireDate) { + const expireDate = new Date(this.apiKeyForm.customExpireDate); + this.apiKeyForm.expiresAt = expireDate.toISOString(); + } + }, + + // 格式化过期日期 + formatExpireDate(dateString) { + if (!dateString) return ''; + const date = new Date(dateString); + return date.toLocaleString('zh-CN', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit' + }); + }, + + // 格式化日期时间 + formatDateTime(dateString) { + if (!dateString) return ''; + const date = new Date(dateString); + return date.toLocaleString('zh-CN', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit' + }); + }, + + // 检查 API Key 是否已过期 + isApiKeyExpired(expiresAt) { + if (!expiresAt) return false; + return new Date(expiresAt) < new Date(); + }, + + // 检查 API Key 是否即将过期(7天内) + isApiKeyExpiringSoon(expiresAt) { + if (!expiresAt) return false; + const expireDate = new Date(expiresAt); + const now = new Date(); + const daysUntilExpire = (expireDate - now) / (1000 * 60 * 60 * 24); + return daysUntilExpire > 0 && daysUntilExpire <= 7; + }, + // 打开创建账户模态框 openCreateAccountModal() { console.log('Opening Account modal...'); @@ -1242,6 +1518,12 @@ const app = createApp({ case 'tutorial': // 教程页面不需要加载数据 break; + case 'settings': + // OEM 设置已在 mounted 时加载,避免重复加载 + if (!this.oemSettings.siteName && !this.oemSettings.siteIcon && !this.oemSettings.siteIconData) { + this.loadOemSettings(); + } + break; } }, @@ -1647,11 +1929,23 @@ const app = createApp({ } }, + async loadSupportedClients() { + try { + const data = await this.apiRequest('/admin/supported-clients'); + if (data && data.success) { + this.supportedClients = data.data || []; + console.log('Loaded supported clients:', this.supportedClients); + } + } catch (error) { + console.error('Failed to load supported clients:', error); + } + }, + async loadApiKeys() { this.apiKeysLoading = true; - console.log('Loading API Keys...'); + console.log('Loading API Keys with time range:', this.apiKeyStatsTimeRange); try { - const data = await this.apiRequest('/admin/api-keys'); + const data = await this.apiRequest(`/admin/api-keys?timeRange=${this.apiKeyStatsTimeRange}`); if (!data) { // 如果token过期,apiRequest会返回null并刷新页面 @@ -1737,6 +2031,9 @@ const app = createApp({ account.boundApiKeysCount = this.apiKeys.filter(key => key.geminiAccountId === account.id).length; } }); + + // 加载完成后自动排序 + this.sortAccounts(); } catch (error) { console.error('Failed to load accounts:', error); } finally { @@ -1744,6 +2041,35 @@ const app = createApp({ } }, + // 账户排序 + sortAccounts() { + if (!this.accounts || this.accounts.length === 0) return; + + this.accounts.sort((a, b) => { + switch (this.accountSortBy) { + case 'name': + return a.name.localeCompare(b.name); + case 'dailyTokens': + const aTokens = (a.usage && a.usage.daily && a.usage.daily.allTokens) || 0; + const bTokens = (b.usage && b.usage.daily && b.usage.daily.allTokens) || 0; + return bTokens - aTokens; // 降序 + case 'dailyRequests': + const aRequests = (a.usage && a.usage.daily && a.usage.daily.requests) || 0; + const bRequests = (b.usage && b.usage.daily && b.usage.daily.requests) || 0; + return bRequests - aRequests; // 降序 + case 'totalTokens': + const aTotalTokens = (a.usage && a.usage.total && a.usage.total.allTokens) || 0; + const bTotalTokens = (b.usage && b.usage.total && b.usage.total.allTokens) || 0; + return bTotalTokens - aTotalTokens; // 降序 + case 'lastUsed': + const aLastUsed = a.lastUsedAt ? new Date(a.lastUsedAt) : new Date(0); + const bLastUsed = b.lastUsedAt ? new Date(b.lastUsedAt) : new Date(0); + return bLastUsed - aLastUsed; // 降序(最近使用的在前) + default: + return 0; + } + }); + }, async loadModelStats() { this.modelStatsLoading = true; @@ -1775,16 +2101,20 @@ const app = createApp({ method: 'POST', body: JSON.stringify({ name: this.apiKeyForm.name, - tokenLimit: this.apiKeyForm.tokenLimit && this.apiKeyForm.tokenLimit.trim() ? parseInt(this.apiKeyForm.tokenLimit) : null, + tokenLimit: this.apiKeyForm.tokenLimit && this.apiKeyForm.tokenLimit.toString().trim() ? parseInt(this.apiKeyForm.tokenLimit) : null, description: this.apiKeyForm.description || '', - concurrencyLimit: this.apiKeyForm.concurrencyLimit && this.apiKeyForm.concurrencyLimit.trim() ? parseInt(this.apiKeyForm.concurrencyLimit) : 0, - rateLimitWindow: this.apiKeyForm.rateLimitWindow && this.apiKeyForm.rateLimitWindow.trim() ? parseInt(this.apiKeyForm.rateLimitWindow) : null, - rateLimitRequests: this.apiKeyForm.rateLimitRequests && this.apiKeyForm.rateLimitRequests.trim() ? parseInt(this.apiKeyForm.rateLimitRequests) : null, + concurrencyLimit: this.apiKeyForm.concurrencyLimit && this.apiKeyForm.concurrencyLimit.toString().trim() ? parseInt(this.apiKeyForm.concurrencyLimit) : 0, + rateLimitWindow: this.apiKeyForm.rateLimitWindow && this.apiKeyForm.rateLimitWindow.toString().trim() ? parseInt(this.apiKeyForm.rateLimitWindow) : null, + rateLimitRequests: this.apiKeyForm.rateLimitRequests && this.apiKeyForm.rateLimitRequests.toString().trim() ? parseInt(this.apiKeyForm.rateLimitRequests) : null, claudeAccountId: this.apiKeyForm.claudeAccountId || null, geminiAccountId: this.apiKeyForm.geminiAccountId || null, permissions: this.apiKeyForm.permissions || 'all', enableModelRestriction: this.apiKeyForm.enableModelRestriction, - restrictedModels: this.apiKeyForm.restrictedModels + restrictedModels: this.apiKeyForm.restrictedModels, + enableClientRestriction: this.apiKeyForm.enableClientRestriction, + allowedClients: this.apiKeyForm.allowedClients, + expiresAt: this.apiKeyForm.expiresAt, + dailyCostLimit: this.apiKeyForm.dailyCostLimit && this.apiKeyForm.dailyCostLimit.toString().trim() ? parseFloat(this.apiKeyForm.dailyCostLimit) : 0 }) }); @@ -1805,7 +2135,26 @@ const app = createApp({ // 关闭创建弹窗并清理表单 this.showCreateApiKeyModal = false; - this.apiKeyForm = { name: '', tokenLimit: '', description: '', concurrencyLimit: '', rateLimitWindow: '', rateLimitRequests: '', claudeAccountId: '', enableModelRestriction: false, restrictedModels: [], modelInput: '' }; + this.apiKeyForm = { + name: '', + tokenLimit: '', + description: '', + concurrencyLimit: '', + rateLimitWindow: '', + rateLimitRequests: '', + claudeAccountId: '', + geminiAccountId: '', + permissions: 'all', + enableModelRestriction: false, + restrictedModels: [], + modelInput: '', + enableClientRestriction: false, + allowedClients: [], + expireDuration: '', + customExpireDate: '', + expiresAt: null, + dailyCostLimit: '' + }; // 重新加载API Keys列表 await this.loadApiKeys(); @@ -1851,6 +2200,111 @@ const app = createApp({ } }, + // 打开续期弹窗 + openRenewApiKeyModal(key) { + this.renewApiKeyForm = { + id: key.id, + name: key.name, + currentExpiresAt: key.expiresAt, + renewDuration: '30d', + customExpireDate: '', + newExpiresAt: null + }; + this.showRenewApiKeyModal = true; + // 立即计算新的过期时间 + this.updateRenewExpireAt(); + }, + + // 关闭续期弹窗 + closeRenewApiKeyModal() { + this.showRenewApiKeyModal = false; + this.renewApiKeyForm = { + id: '', + name: '', + currentExpiresAt: null, + renewDuration: '30d', + customExpireDate: '', + newExpiresAt: null + }; + }, + + // 更新续期过期时间 + updateRenewExpireAt() { + const duration = this.renewApiKeyForm.renewDuration; + + if (duration === 'permanent') { + this.renewApiKeyForm.newExpiresAt = null; + return; + } + + if (duration === 'custom') { + // 自定义日期需要用户选择 + return; + } + + // 计算新的过期时间 + const baseTime = this.renewApiKeyForm.currentExpiresAt + ? new Date(this.renewApiKeyForm.currentExpiresAt) + : new Date(); + + // 如果当前已过期,从现在开始计算 + if (baseTime < new Date()) { + baseTime.setTime(new Date().getTime()); + } + + const durationMap = { + '7d': 7, + '30d': 30, + '90d': 90, + '180d': 180, + '365d': 365 + }; + + const days = durationMap[duration]; + if (days) { + const expireDate = new Date(baseTime.getTime() + days * 24 * 60 * 60 * 1000); + this.renewApiKeyForm.newExpiresAt = expireDate.toISOString(); + } + }, + + // 更新自定义续期时间 + updateCustomRenewExpireAt() { + if (this.renewApiKeyForm.customExpireDate) { + const expireDate = new Date(this.renewApiKeyForm.customExpireDate); + this.renewApiKeyForm.newExpiresAt = expireDate.toISOString(); + } + }, + + // 执行续期操作 + async renewApiKey() { + this.renewApiKeyLoading = true; + try { + const data = await this.apiRequest('/admin/api-keys/' + this.renewApiKeyForm.id, { + method: 'PUT', + body: JSON.stringify({ + expiresAt: this.renewApiKeyForm.newExpiresAt + }) + }); + + if (!data) { + return; + } + + if (data.success) { + this.showToast('API Key 续期成功', 'success'); + this.closeRenewApiKeyModal(); + await this.loadApiKeys(); + } else { + this.showToast(data.message || '续期失败', 'error'); + } + } catch (error) { + console.error('Error renewing API key:', error); + this.showToast('续期失败,请检查网络连接', 'error'); + } finally { + this.renewApiKeyLoading = false; + } + }, + openEditApiKeyModal(key) { this.editApiKeyForm = { id: key.id, @@ -1864,7 +2318,10 @@ const app = createApp({ permissions: key.permissions || 'all', enableModelRestriction: key.enableModelRestriction || false, restrictedModels: key.restrictedModels ? [...key.restrictedModels] : [], - modelInput: '' + modelInput: '', + enableClientRestriction: key.enableClientRestriction || false, + allowedClients: key.allowedClients ? [...key.allowedClients] : [], + dailyCostLimit: key.dailyCostLimit || '' }; this.showEditApiKeyModal = true; }, @@ -1883,7 +2340,10 @@ const app = createApp({ permissions: 'all', enableModelRestriction: false, restrictedModels: [], - modelInput: '' + modelInput: '', + enableClientRestriction: false, + allowedClients: [], + dailyCostLimit: '' }; }, @@ -1901,7 +2361,10 @@ const app = createApp({ geminiAccountId: this.editApiKeyForm.geminiAccountId || null, permissions: this.editApiKeyForm.permissions || 'all', enableModelRestriction: this.editApiKeyForm.enableModelRestriction, - restrictedModels: this.editApiKeyForm.restrictedModels + restrictedModels: this.editApiKeyForm.restrictedModels, + enableClientRestriction: this.editApiKeyForm.enableClientRestriction, + allowedClients: this.editApiKeyForm.allowedClients, + dailyCostLimit: this.editApiKeyForm.dailyCostLimit && this.editApiKeyForm.dailyCostLimit.toString().trim() !== '' ? parseFloat(this.editApiKeyForm.dailyCostLimit) : 0 }) }); @@ -2068,7 +2531,11 @@ const app = createApp({ // 格式化数字,添加千分符 formatNumber(num) { if (num === null || num === undefined) return '0'; - return Number(num).toLocaleString(); + const number = Number(num); + if (number >= 1000000) { + return Math.floor(number / 1000000).toLocaleString() + 'M'; + } + return number.toLocaleString(); }, // 格式化运行时间 @@ -2889,23 +3356,26 @@ const app = createApp({ calculateApiKeyCost(usage) { if (!usage || !usage.total) return '$0.000000'; - // 使用通用模型价格估算 - const totalInputTokens = usage.total.inputTokens || 0; - const totalOutputTokens = usage.total.outputTokens || 0; - const totalCacheCreateTokens = usage.total.cacheCreateTokens || 0; - const totalCacheReadTokens = usage.total.cacheReadTokens || 0; + // 使用后端返回的准确费用数据 + if (usage.total.formattedCost) { + return usage.total.formattedCost; + } - // 简单估算(使用Claude 3.5 Sonnet价格) - const inputCost = (totalInputTokens / 1000000) * 3.00; - const outputCost = (totalOutputTokens / 1000000) * 15.00; - const cacheCreateCost = (totalCacheCreateTokens / 1000000) * 3.75; - const cacheReadCost = (totalCacheReadTokens / 1000000) * 0.30; + // 如果没有后端费用数据,返回默认值 + return '$0.000000'; + }, + + // 计算API Key费用数值(用于排序) + calculateApiKeyCostNumber(usage) { + if (!usage || !usage.total) return 0; - const totalCost = inputCost + outputCost + cacheCreateCost + cacheReadCost; + // 使用后端返回的准确费用数据 + if (usage.total.cost) { + return usage.total.cost; + } - if (totalCost < 0.000001) return '$0.000000'; - if (totalCost < 0.01) return '$' + totalCost.toFixed(6); - return '$' + totalCost.toFixed(4); + // 如果没有后端费用数据,返回0 + return 0; }, // 初始化日期筛选器 @@ -3531,6 +4001,180 @@ const app = createApp({ }); this.showToast('已重置筛选条件并刷新数据', 'info', '重置成功'); + }, + + // OEM设置相关方法 + async loadOemSettings() { + this.oemSettingsLoading = true; + try { + const result = await this.apiRequest('/admin/oem-settings'); + if (result && result.success) { + this.oemSettings = { ...this.oemSettings, ...result.data }; + + // 应用设置到页面 + this.applyOemSettings(); + } else { + // 如果请求失败但不是因为认证问题,使用默认值 + console.warn('Failed to load OEM settings, using defaults'); + this.applyOemSettings(); + } + } catch (error) { + console.error('Error loading OEM settings:', error); + // 加载失败时也应用默认值,确保页面正常显示 + this.applyOemSettings(); + } finally { + this.oemSettingsLoading = false; + } + }, + + async saveOemSettings() { + // 验证输入 + if (!this.oemSettings.siteName || this.oemSettings.siteName.trim() === '') { + this.showToast('网站名称不能为空', 'error', '验证失败'); + return; + } + + if (this.oemSettings.siteName.length > 100) { + this.showToast('网站名称不能超过100个字符', 'error', '验证失败'); + return; + } + + this.oemSettingsSaving = true; + try { + const result = await this.apiRequest('/admin/oem-settings', { + method: 'PUT', + body: JSON.stringify({ + siteName: this.oemSettings.siteName.trim(), + siteIcon: this.oemSettings.siteIcon.trim(), + siteIconData: this.oemSettings.siteIconData.trim() + }) + }); + + if (result && result.success) { + this.oemSettings = { ...this.oemSettings, ...result.data }; + this.showToast('OEM设置保存成功', 'success', '保存成功'); + + // 应用设置到页面 + this.applyOemSettings(); + } else { + this.showToast(result?.message || '保存失败', 'error', '保存失败'); + } + } catch (error) { + console.error('Error saving OEM settings:', error); + this.showToast('保存OEM设置失败', 'error', '保存失败'); + } finally { + this.oemSettingsSaving = false; + } + }, + + applyOemSettings() { + // 更新网站标题 + document.title = `${this.oemSettings.siteName} - 管理后台`; + + // 更新页面中的所有网站名称 + const titleElements = document.querySelectorAll('.header-title'); + titleElements.forEach(el => { + el.textContent = this.oemSettings.siteName; + }); + + // 应用自定义CSS + this.applyCustomCss(); + + // 应用网站图标 + this.applyFavicon(); + }, + + applyCustomCss() { + // 移除之前的自定义CSS + const existingStyle = document.getElementById('custom-oem-css'); + if (existingStyle) { + existingStyle.remove(); + } + }, + + applyFavicon() { + const iconData = this.oemSettings.siteIconData || this.oemSettings.siteIcon; + if (iconData && iconData.trim()) { + // 移除现有的favicon + const existingFavicons = document.querySelectorAll('link[rel*="icon"]'); + existingFavicons.forEach(link => link.remove()); + + // 添加新的favicon + const link = document.createElement('link'); + link.rel = 'icon'; + + // 根据数据类型设置适当的type + if (iconData.startsWith('data:')) { + // Base64数据 + link.href = iconData; + } else { + // URL + link.type = 'image/x-icon'; + link.href = iconData; + } + + document.head.appendChild(link); + } + }, + + resetOemSettings() { + this.oemSettings = { + siteName: 'Claude Relay Service', + siteIcon: '', + siteIconData: '', + updatedAt: null + }; + }, + + // 处理图标文件上传 + async handleIconUpload(event) { + const file = event.target.files[0]; + if (!file) return; + + // 验证文件大小 + if (file.size > 350 * 1024) { // 350KB + this.showToast('图标文件大小不能超过350KB', 'error', '文件太大'); + return; + } + + // 验证文件类型 + const allowedTypes = ['image/x-icon', 'image/png', 'image/jpeg', 'image/svg+xml']; + if (!allowedTypes.includes(file.type) && !file.name.endsWith('.ico')) { + this.showToast('请选择有效的图标文件格式 (.ico, .png, .jpg, .svg)', 'error', '格式错误'); + return; + } + + try { + // 读取文件为Base64 + const reader = new FileReader(); + reader.onload = (e) => { + this.oemSettings.siteIconData = e.target.result; + this.oemSettings.siteIcon = ''; // 清空URL + this.showToast('图标上传成功', 'success', '上传成功'); + }; + reader.onerror = () => { + this.showToast('图标文件读取失败', 'error', '读取失败'); + }; + reader.readAsDataURL(file); + } catch (error) { + console.error('Icon upload error:', error); + this.showToast('图标上传过程中出现错误', 'error', '上传失败'); + } + }, + + // 移除图标 + removeIcon() { + this.oemSettings.siteIcon = ''; + this.oemSettings.siteIconData = ''; + if (this.$refs.iconFileInput) { + this.$refs.iconFileInput.value = ''; + } + }, + + // 处理图标加载错误 + handleIconError(event) { + console.error('Icon load error'); + event.target.style.display = 'none'; } } }); diff --git a/web/admin/index.html b/web/admin/index.html index f73f12d9..7a4a6cd4 100644 --- a/web/admin/index.html +++ b/web/admin/index.html @@ -39,10 +39,15 @@
-
- +
+ Logo +
-

Claude Relay Service

+

{{ oemSettings.siteName || 'Claude Relay Service' }}

管理后台

@@ -92,12 +97,17 @@
-
- +
+ Logo +
-

Claude Relay Service

+

{{ oemSettings.siteName || 'Claude Relay Service' }}

v{{ versionInfo.current || '...' }} @@ -202,13 +212,13 @@
-
+
-
+
@@ -245,7 +255,7 @@

-
+
@@ -258,7 +268,7 @@

{{ dashboardData.todayRequests }}

总请求: {{ formatNumber(dashboardData.totalRequests || 0) }}

-
+
@@ -271,7 +281,7 @@

{{ dashboardData.systemStatus }}

运行时间: {{ formatUptime(dashboardData.uptime) }}

-
+
@@ -282,9 +292,9 @@
-
+

今日Token

-
+

{{ formatNumber((dashboardData.todayInputTokens || 0) + (dashboardData.todayOutputTokens || 0) + (dashboardData.todayCacheCreateTokens || 0) + (dashboardData.todayCacheReadTokens || 0)) }}

/ {{ costsData.todayCosts.formatted.totalCost }}
@@ -297,7 +307,7 @@
-
+
@@ -305,9 +315,9 @@
-
+

总Token消耗

-
+

{{ formatNumber((dashboardData.totalInputTokens || 0) + (dashboardData.totalOutputTokens || 0) + (dashboardData.totalCacheCreateTokens || 0) + (dashboardData.totalCacheReadTokens || 0)) }}

/ {{ costsData.totalCosts.formatted.totalCost }}
@@ -320,7 +330,7 @@
-
+
@@ -333,7 +343,7 @@

{{ dashboardData.systemRPM || 0 }}

每分钟请求数

-
+
@@ -346,7 +356,7 @@

{{ dashboardData.systemTPM || 0 }}

每分钟Token数

-
+
@@ -538,12 +548,25 @@

API Keys 管理

管理和监控您的 API 密钥

- +
+ + + +
@@ -563,16 +586,40 @@ - + - - - + + + + -
名称 + 名称 + + + API Key状态使用统计创建时间 + 状态 + + + + 使用统计 + + (费用 + + ) + + + 创建时间 + + + + 过期时间 + + + 操作