From d2f16e416c0910010d31365df92fa1d475b66eea Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 24 Jul 2025 04:07:19 +0000 Subject: [PATCH 01/69] chore: bump version to 1.1.14 --- VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VERSION b/VERSION index 5ed5faa5..e9bc1499 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.1.10 +1.1.14 From 17e9aafe6e5a873b4cb7bd9e7c9bef5eaa40521f Mon Sep 17 00:00:00 2001 From: shaw Date: Thu, 24 Jul 2025 14:01:21 +0800 Subject: [PATCH 02/69] =?UTF-8?q?refactor:=20=E9=87=8D=E6=9E=84GitHub=20Ac?= =?UTF-8?q?tions=E5=B7=A5=E4=BD=9C=E6=B5=81=E7=A8=8B=E4=B8=BA=E7=BB=9F?= =?UTF-8?q?=E4=B8=80=E7=9A=84=E8=87=AA=E5=8A=A8=E5=8F=91=E5=B8=83=E7=AE=A1?= =?UTF-8?q?=E9=81=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 删除分离的workflow文件(auto-version-bump, release-on-version, docker-publish, release) - 创建新的统一workflow: auto-release-pipeline.yml - 整合版本管理、Release创建、Docker构建和Telegram通知到单一流程 - 使用[skip ci]标记避免死循环 - 解决GitHub Action提交无法触发后续workflow的问题 --- ...-version.yml => auto-release-pipeline.yml} | 142 ++++++++++++------ .github/workflows/auto-version-bump.yml | 102 ------------- .github/workflows/docker-publish.yml | 101 ------------- .github/workflows/release.yml | 56 ------- 4 files changed, 99 insertions(+), 302 deletions(-) rename .github/workflows/{release-on-version.yml => auto-release-pipeline.yml} (54%) delete mode 100644 .github/workflows/auto-version-bump.yml delete mode 100644 .github/workflows/docker-publish.yml delete mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/release-on-version.yml b/.github/workflows/auto-release-pipeline.yml similarity index 54% rename from .github/workflows/release-on-version.yml rename to .github/workflows/auto-release-pipeline.yml index 2436da3c..8db44694 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,89 @@ 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} + 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 +111,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 +127,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 +160,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 +176,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 From 48f11b4adf3d39af5123ef1f17135705bc6812d1 Mon Sep 17 00:00:00 2001 From: shaw Date: Thu, 24 Jul 2025 14:03:50 +0800 Subject: [PATCH 03/69] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=20service:logs?= =?UTF-8?q?:follow=20=E5=91=BD=E4=BB=A4=E7=94=A8=E4=BA=8E=E5=AE=9E?= =?UTF-8?q?=E6=97=B6=E6=9F=A5=E7=9C=8B=E6=97=A5=E5=BF=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/package.json b/package.json index 8e15b483..0aa80490 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,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", From f567ad0532fcac8286d1f7ba6ce06f61e1b998e3 Mon Sep 17 00:00:00 2001 From: shaw Date: Thu, 24 Jul 2025 14:06:54 +0800 Subject: [PATCH 04/69] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E7=89=88?= =?UTF-8?q?=E6=9C=AC=E6=AF=94=E8=BE=83=E9=80=BB=E8=BE=91=EF=BC=8C=E7=A1=AE?= =?UTF-8?q?=E4=BF=9D=E6=AD=A3=E7=A1=AE=E5=A4=84=E7=90=86VERSION=E6=96=87?= =?UTF-8?q?=E4=BB=B6=E4=B8=8Etag=E4=B8=8D=E5=90=8C=E6=AD=A5=E7=9A=84?= =?UTF-8?q?=E6=83=85=E5=86=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 同时检查git tag和VERSION文件的版本号 - 使用两者中较大的版本作为基准 - 避免因VERSION文件已包含新版本号而导致版本不递增的问题 --- .github/workflows/auto-release-pipeline.yml | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/.github/workflows/auto-release-pipeline.yml b/.github/workflows/auto-release-pipeline.yml index 8db44694..eda0ce71 100644 --- a/.github/workflows/auto-release-pipeline.yml +++ b/.github/workflows/auto-release-pipeline.yml @@ -63,9 +63,23 @@ jobs: # 获取最新的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 - # 从tag中提取版本号 - VERSION=${LATEST_TAG#v} echo "Current version: $VERSION" echo "current_version=$VERSION" >> $GITHUB_OUTPUT From afdfdc8fe747e9e98165e52cbcc5ed5ee1222e05 Mon Sep 17 00:00:00 2001 From: shaw Date: Thu, 24 Jul 2025 14:18:45 +0800 Subject: [PATCH 05/69] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=E9=80=9A?= =?UTF-8?q?=E8=BF=87=20LOG=5FLEVEL=20=E7=8E=AF=E5=A2=83=E5=8F=98=E9=87=8F?= =?UTF-8?q?=E5=8A=A8=E6=80=81=E8=AE=BE=E7=BD=AE=E6=97=A5=E5=BF=97=E7=BA=A7?= =?UTF-8?q?=E5=88=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 允许在不修改配置文件的情况下调整日志级别 - 便于生产环境调试和故障排查 - 环境变量优先级高于配置文件 --- README.md | 2 +- src/utils/logger.js | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index aeac3b68..926268da 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中转服务,支持多账户管理** 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 From 36201cee6b8ac2135cdca5497d228041e02cbb51 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 24 Jul 2025 06:19:06 +0000 Subject: [PATCH 06/69] chore: sync VERSION file with release v1.1.15 [skip ci] --- VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VERSION b/VERSION index e9bc1499..645377ee 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.1.14 +1.1.15 From 38c68ca831cae620d39a596454d8809f65a5f1df Mon Sep 17 00:00:00 2001 From: shaw Date: Thu, 24 Jul 2025 14:53:34 +0800 Subject: [PATCH 07/69] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8DDocker=E9=83=A8?= =?UTF-8?q?=E7=BD=B2=E6=97=B6=E5=8A=A0=E5=AF=86=E5=AF=86=E9=92=A5=E6=8C=81?= =?UTF-8?q?=E4=B9=85=E5=8C=96=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在docker-compose.yml中添加.env文件映射,避免重建容器时生成新密钥 - 修改docker-entrypoint.sh,只在密钥不存在时生成新的,否则使用现有的 - 移除Dockerfile中自动复制.env的逻辑,改为运行时检查 - 更新README文档,添加重要提示说明.env文件映射的必要性 - 解决了每次重建容器导致之前加密数据无法解密的严重问题 --- Dockerfile | 3 --- README.md | 6 +++++- docker-compose.yml | 1 + docker-entrypoint.sh | 28 +++++++++++++++++++++++----- 4 files changed, 29 insertions(+), 9 deletions(-) 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 926268da..f01a9450 100644 --- a/README.md +++ b/README.md @@ -238,6 +238,7 @@ docker run -d \ -p 3000:3000 \ -v $(pwd)/data:/app/data \ -v $(pwd)/logs:/app/logs \ + -v $(pwd)/.env:/app/.env \ -e ADMIN_USERNAME=my_admin \ -e ADMIN_PASSWORD=my_secure_password \ weishaw/claude-relay-service:latest @@ -260,6 +261,7 @@ services: volumes: - ./logs:/app/logs - ./data:/app/data + - ./.env:/app/.env # 重要:持久化加密密钥 depends_on: - redis @@ -306,11 +308,13 @@ cat ./data/init.json docker-compose.yml 已包含: - ✅ 自动初始化管理员账号 -- ✅ 数据持久化(logs和data目录自动挂载) +- ✅ 数据持久化(logs、data目录和.env文件自动挂载) - ✅ Redis数据库 - ✅ 健康检查 - ✅ 自动重启 +> ⚠️ **重要提示**:从 v1.1.15 版本开始,`.env` 文件必须映射到本地以持久化加密密钥。如果不映射,每次重建容器都会生成新的加密密钥,导致之前加密的数据无法解密! + ### 管理员凭据获取方式 1. **查看容器日志**(推荐) diff --git a/docker-compose.yml b/docker-compose.yml index 8bd4bbf6..a10bf1c3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -18,6 +18,7 @@ services: volumes: - ./logs:/app/logs - ./data:/app/data + - ./.env:/app/.env depends_on: - redis networks: diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index 1f29f3f3..7bf3e696 100644 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -3,6 +3,18 @@ set -e echo "🚀 Claude Relay Service 启动中..." +# 检查并创建 .env 文件 +if [ ! -f "/app/.env" ]; then + echo "📋 检测到 .env 不存在,从模板创建..." + if [ -f "/app/.env.example" ]; then + cp /app/.env.example /app/.env + echo "✅ .env 已从模板创建" + else + echo "❌ 错误: .env.example 不存在" + exit 1 + fi +fi + # 生成随机字符串的函数 generate_random_string() { length=$1 @@ -31,7 +43,11 @@ if [ -f "/app/.env" ]; 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" + echo "🔑 生成新的 JWT_SECRET" + # 更新 .env 文件 + sed -i "s/JWT_SECRET=.*/JWT_SECRET=${JWT_SECRET}/" /app/.env + else + echo "✅ 使用现有的 JWT_SECRET" fi fi @@ -40,13 +56,15 @@ if [ -f "/app/.env" ]; 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" + echo "🔑 生成新的 ENCRYPTION_KEY" + # 更新 .env 文件 + sed -i "s/ENCRYPTION_KEY=.*/ENCRYPTION_KEY=${ENCRYPTION_KEY}/" /app/.env + else + 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 + # 更新 Redis 主机配置 sed -i "s/REDIS_HOST=.*/REDIS_HOST=redis/" /app/.env echo "✅ .env 已配置" From 4c572b82ba4687b28947d49ff011947b85bb1064 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 24 Jul 2025 06:53:59 +0000 Subject: [PATCH 08/69] chore: sync VERSION file with release v1.1.16 [skip ci] --- VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VERSION b/VERSION index 645377ee..63b283b2 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.1.15 +1.1.16 From 232c276c8c6d0a5005ef35dff8664952d4ca6c0f Mon Sep 17 00:00:00 2001 From: shaw Date: Thu, 24 Jul 2025 15:26:41 +0800 Subject: [PATCH 09/69] =?UTF-8?q?docs:=20=E5=8A=A0=E5=BC=BA=20Docker=20?= =?UTF-8?q?=E9=83=A8=E7=BD=B2=E6=97=B6=20.env=20=E6=96=87=E4=BB=B6?= =?UTF-8?q?=E5=88=9B=E5=BB=BA=E7=9A=84=E8=AF=B4=E6=98=8E=E5=92=8C=E6=8F=90?= =?UTF-8?q?=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在 docker-compose.yml 顶部添加醒目的注释说明必须先创建 .env 文件 - 更新 README.md,在 Docker 部署章节开头突出强调创建 .env 的重要性 - 解释为什么必须先创建文件(避免 Docker 创建成目录) - 改进 docker-compose-init.sh 脚本,增加错误检测和修复功能 - 提供清晰的步骤指引和故障排除方法 --- README.md | 22 +++++++++++++-- docker-compose-init.sh | 62 ++++++++++++++++++++++++++++++++++++++++++ docker-compose.yml | 8 +++++- docker-entrypoint.sh | 5 ++-- scripts/docker-init.sh | 52 +++++++++++++++++++++++++++++++++++ 5 files changed, 144 insertions(+), 5 deletions(-) create mode 100644 docker-compose-init.sh create mode 100644 scripts/docker-init.sh diff --git a/README.md b/README.md index f01a9450..609defe0 100644 --- a/README.md +++ b/README.md @@ -224,6 +224,18 @@ npm run service:status ## 🐳 Docker 部署(推荐) +### ⚠️ 首次部署必须执行 + +```bash +# 创建空的 .env 文件(非常重要!) +touch .env +``` + +> 🔴 **为什么必须先创建 .env 文件?** +> - Docker 在映射不存在的文件时会创建成**目录**而非文件 +> - .env 文件用于存储加密密钥,必须持久化保存 +> - 如果变成目录,容器将无法启动 + ### 使用 Docker Hub 镜像(最简单) > 🚀 推荐使用官方镜像,自动构建,始终保持最新版本 @@ -287,7 +299,13 @@ docker-compose up -d git clone https://github.com/Wei-Shaw//claude-relay-service.git cd claude-relay-service -# 2. 设置管理员账号密码(可选) +# 2. 初始化环境(重要!首次部署必须执行) +touch .env # 创建空文件,防止 Docker 创建成目录 + +# 如果 .env 已经错误地变成了目录,先删除: +# rm -rf .env && touch .env + +# 3. 设置管理员账号密码(可选) # 方式一:自动生成(查看容器日志获取) docker-compose up -d @@ -296,7 +314,7 @@ export ADMIN_USERNAME=cr_admin_custom export ADMIN_PASSWORD=your-secure-password docker-compose up -d -# 3. 查看管理员凭据 +# 4. 查看管理员凭据 # 自动生成的情况下: docker logs claude-relay-service | grep "管理员" diff --git a/docker-compose-init.sh b/docker-compose-init.sh new file mode 100644 index 00000000..37dee9a5 --- /dev/null +++ b/docker-compose-init.sh @@ -0,0 +1,62 @@ +#!/bin/bash +# Docker Compose 初始化脚本 - 用于 Docker Hub 镜像部署 + +echo "🚀 Claude Relay Service Docker 初始化脚本" +echo "============================================" + +# 检查是否在正确的目录 +if [ -f "docker-compose.yml" ]; then + echo "✅ 检测到 docker-compose.yml,继续初始化..." +else + echo "⚠️ 未检测到 docker-compose.yml 文件" + echo " 请确保在包含 docker-compose.yml 的目录下运行此脚本" + echo "" + echo "如果您是从 Docker Hub 部署,请先创建 docker-compose.yml:" + echo " 参考文档:https://github.com/Wei-Shaw/claude-relay-service#docker-部署推荐" + exit 1 +fi + +# 确保 .env 文件正确创建 +echo "" +echo "📋 检查 .env 文件..." + +if [ -d ".env" ]; then + echo "❌ 检测到 .env 是目录(Docker 创建错误)" + echo " 正在修复..." + rm -rf .env + touch .env + echo "✅ 已删除目录并创建正确的 .env 文件" +elif [ ! -f ".env" ]; then + echo "📝 创建 .env 文件..." + touch .env + echo "✅ .env 文件已创建" +else + echo "✅ .env 文件已存在" +fi + +# 创建必要的目录 +echo "" +echo "📁 创建必要的目录..." +mkdir -p data logs redis_data +echo "✅ 目录创建完成" + +# 显示文件状态 +echo "" +echo "📊 当前文件状态:" +echo " .env: $([ -f .env ] && echo "✅ 文件" || echo "❌ 不存在")" +echo " data/: $([ -d data ] && echo "✅ 目录" || echo "❌ 不存在")" +echo " logs/: $([ -d logs ] && echo "✅ 目录" || echo "❌ 不存在")" +echo " redis_data/: $([ -d redis_data ] && echo "✅ 目录" || echo "❌ 不存在")" + +echo "" +echo "🎉 初始化完成!" +echo "" +echo "下一步操作:" +echo "1. 启动服务:" +echo " docker-compose up -d" +echo "" +echo "2. 查看日志获取管理员密码:" +echo " docker-compose logs claude-relay | grep '管理员'" +echo "" +echo "3. 访问管理界面:" +echo " http://your-server:3000/web" \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index a10bf1c3..4d28380a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,11 @@ version: '3.8' +# ⚠️ 重要提示:首次运行前必须执行以下命令 +# touch .env +# +# 说明:如果不先创建 .env 文件,Docker 会将其创建为目录而非文件, +# 导致容器无法正常启动。该文件用于存储加密密钥,必须持久化。 + services: # 🚀 Claude Relay Service claude-relay: @@ -18,7 +24,7 @@ services: volumes: - ./logs:/app/logs - ./data:/app/data - - ./.env:/app/.env + - ./.env:/app/.env # 必须映射,用于持久化加密密钥 depends_on: - redis networks: diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index 7bf3e696..9b949e59 100644 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -3,12 +3,13 @@ set -e echo "🚀 Claude Relay Service 启动中..." -# 检查并创建 .env 文件 +# 检查 .env 文件 if [ ! -f "/app/.env" ]; then echo "📋 检测到 .env 不存在,从模板创建..." if [ -f "/app/.env.example" ]; then cp /app/.env.example /app/.env echo "✅ .env 已从模板创建" + echo "⚠️ 注意:.env 文件将在容器内生成,请确保已映射到宿主机以持久化" else echo "❌ 错误: .env.example 不存在" exit 1 @@ -69,7 +70,7 @@ if [ -f "/app/.env" ]; then echo "✅ .env 已配置" else - echo "❌ 错误: .env 文件不存在" + echo "❌ 错误: .env 文件处理失败" exit 1 fi diff --git a/scripts/docker-init.sh b/scripts/docker-init.sh new file mode 100644 index 00000000..ad81c254 --- /dev/null +++ b/scripts/docker-init.sh @@ -0,0 +1,52 @@ +#!/bin/bash +# Docker 初始化脚本 - 在宿主机上运行 + +echo "🚀 Claude Relay Service Docker 初始化" + +# 检查 .env 文件 +if [ ! -f ".env" ]; then + echo "📋 检测到 .env 文件不存在,从模板创建..." + + if [ -f ".env.example" ]; then + cp .env.example .env + echo "✅ .env 文件已创建" + + # 生成随机密钥 + echo "🔑 生成安全密钥..." + + # 生成64字符的JWT_SECRET + JWT_SECRET=$(openssl rand -base64 48 | tr -d "=+/" | cut -c1-64) + # 生成32字符的ENCRYPTION_KEY + ENCRYPTION_KEY=$(openssl rand -base64 24 | tr -d "=+/" | cut -c1-32) + + # 替换默认值 + if [[ "$OSTYPE" == "darwin"* ]]; then + # macOS + sed -i '' "s/JWT_SECRET=.*/JWT_SECRET=$JWT_SECRET/" .env + sed -i '' "s/ENCRYPTION_KEY=.*/ENCRYPTION_KEY=$ENCRYPTION_KEY/" .env + else + # Linux + sed -i "s/JWT_SECRET=.*/JWT_SECRET=$JWT_SECRET/" .env + sed -i "s/ENCRYPTION_KEY=.*/ENCRYPTION_KEY=$ENCRYPTION_KEY/" .env + fi + + echo "✅ 密钥已生成并保存到 .env 文件" + echo "" + echo "📌 请妥善保管 .env 文件,它包含重要的加密密钥!" + else + echo "❌ 错误:.env.example 文件不存在" + echo "请确保在项目根目录下运行此脚本" + exit 1 + fi +else + echo "✅ .env 文件已存在,跳过创建" +fi + +# 创建必要的目录 +echo "📁 创建必要的目录..." +mkdir -p data logs redis_data +echo "✅ 目录创建完成" + +echo "" +echo "🎉 初始化完成!现在可以运行:" +echo " docker-compose up -d" \ No newline at end of file From 2e511fa6f83c740adcfa2e6b1d7f1fe9d0c77f51 Mon Sep 17 00:00:00 2001 From: shaw Date: Thu, 24 Jul 2025 15:50:33 +0800 Subject: [PATCH 10/69] =?UTF-8?q?refactor:=20=E7=AE=80=E5=8C=96Docker?= =?UTF-8?q?=E9=83=A8=E7=BD=B2=EF=BC=8C=E4=BD=BF=E7=94=A8=E7=8E=AF=E5=A2=83?= =?UTF-8?q?=E5=8F=98=E9=87=8F=E6=9B=BF=E4=BB=A3.env=E6=96=87=E4=BB=B6?= =?UTF-8?q?=E6=98=A0=E5=B0=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 移除 docker-compose.yml 中的 .env 文件映射 - 添加所有必要的环境变量到 docker-compose.yml - 简化 docker-entrypoint.sh,直接使用环境变量 - 更新 README,说明通过环境变量配置的方式 - 删除不再需要的初始化脚本 - 解决了 sed -i 在某些 Docker 环境下的 'Resource busy' 错误 --- README.md | 69 ++++++++++++++++++++++--------------- docker-compose-init.sh | 62 --------------------------------- docker-compose.yml | 61 ++++++++++++++++++++++++++++----- docker-entrypoint.sh | 78 ++++++++++-------------------------------- scripts/docker-init.sh | 52 ---------------------------- 5 files changed, 112 insertions(+), 210 deletions(-) delete mode 100644 docker-compose-init.sh delete mode 100644 scripts/docker-init.sh diff --git a/README.md b/README.md index 609defe0..b9eb9ce7 100644 --- a/README.md +++ b/README.md @@ -224,18 +224,6 @@ npm run service:status ## 🐳 Docker 部署(推荐) -### ⚠️ 首次部署必须执行 - -```bash -# 创建空的 .env 文件(非常重要!) -touch .env -``` - -> 🔴 **为什么必须先创建 .env 文件?** -> - Docker 在映射不存在的文件时会创建成**目录**而非文件 -> - .env 文件用于存储加密密钥,必须持久化保存 -> - 如果变成目录,容器将无法启动 - ### 使用 Docker Hub 镜像(最简单) > 🚀 推荐使用官方镜像,自动构建,始终保持最新版本 @@ -244,18 +232,31 @@ touch .env # 拉取镜像(支持 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 \ - -v $(pwd)/.env:/app/.env \ + -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' @@ -267,13 +268,14 @@ services: ports: - "3000:3000" environment: + - JWT_SECRET=${JWT_SECRET} + - ENCRYPTION_KEY=${ENCRYPTION_KEY} - REDIS_HOST=redis - ADMIN_USERNAME=${ADMIN_USERNAME:-} - ADMIN_PASSWORD=${ADMIN_PASSWORD:-} volumes: - ./logs:/app/logs - ./data:/app/data - - ./.env:/app/.env # 重要:持久化加密密钥 depends_on: - redis @@ -299,19 +301,18 @@ docker-compose up -d git clone https://github.com/Wei-Shaw//claude-relay-service.git cd claude-relay-service -# 2. 初始化环境(重要!首次部署必须执行) -touch .env # 创建空文件,防止 Docker 创建成目录 +# 2. 创建环境变量文件 +cat > .env << 'EOF' +# 必填:安全密钥(请修改为随机值) +JWT_SECRET=your-random-secret-key-at-least-32-chars +ENCRYPTION_KEY=your-32-character-encryption-key -# 如果 .env 已经错误地变成了目录,先删除: -# rm -rf .env && touch .env +# 可选:管理员凭据 +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 +# 3. 启动服务 docker-compose up -d # 4. 查看管理员凭据 @@ -326,12 +327,24 @@ cat ./data/init.json docker-compose.yml 已包含: - ✅ 自动初始化管理员账号 -- ✅ 数据持久化(logs、data目录和.env文件自动挂载) +- ✅ 数据持久化(logs和data目录自动挂载) - ✅ Redis数据库 - ✅ 健康检查 - ✅ 自动重启 +- ✅ 所有配置通过环境变量管理 -> ⚠️ **重要提示**:从 v1.1.15 版本开始,`.env` 文件必须映射到本地以持久化加密密钥。如果不映射,每次重建容器都会生成新的加密密钥,导致之前加密的数据无法解密! +### 环境变量说明 + +#### 必填项 +- `JWT_SECRET`: JWT密钥,至少32个字符 +- `ENCRYPTION_KEY`: 加密密钥,必须是32个字符 + +#### 可选项 +- `ADMIN_USERNAME`: 管理员用户名(不设置则自动生成) +- `ADMIN_PASSWORD`: 管理员密码(不设置则自动生成) +- `LOG_LEVEL`: 日志级别(默认:info) +- `DEFAULT_TOKEN_LIMIT`: 默认Token限制(默认:1000000) +- 更多配置项请参考 `.env.example` 文件 ### 管理员凭据获取方式 diff --git a/docker-compose-init.sh b/docker-compose-init.sh deleted file mode 100644 index 37dee9a5..00000000 --- a/docker-compose-init.sh +++ /dev/null @@ -1,62 +0,0 @@ -#!/bin/bash -# Docker Compose 初始化脚本 - 用于 Docker Hub 镜像部署 - -echo "🚀 Claude Relay Service Docker 初始化脚本" -echo "============================================" - -# 检查是否在正确的目录 -if [ -f "docker-compose.yml" ]; then - echo "✅ 检测到 docker-compose.yml,继续初始化..." -else - echo "⚠️ 未检测到 docker-compose.yml 文件" - echo " 请确保在包含 docker-compose.yml 的目录下运行此脚本" - echo "" - echo "如果您是从 Docker Hub 部署,请先创建 docker-compose.yml:" - echo " 参考文档:https://github.com/Wei-Shaw/claude-relay-service#docker-部署推荐" - exit 1 -fi - -# 确保 .env 文件正确创建 -echo "" -echo "📋 检查 .env 文件..." - -if [ -d ".env" ]; then - echo "❌ 检测到 .env 是目录(Docker 创建错误)" - echo " 正在修复..." - rm -rf .env - touch .env - echo "✅ 已删除目录并创建正确的 .env 文件" -elif [ ! -f ".env" ]; then - echo "📝 创建 .env 文件..." - touch .env - echo "✅ .env 文件已创建" -else - echo "✅ .env 文件已存在" -fi - -# 创建必要的目录 -echo "" -echo "📁 创建必要的目录..." -mkdir -p data logs redis_data -echo "✅ 目录创建完成" - -# 显示文件状态 -echo "" -echo "📊 当前文件状态:" -echo " .env: $([ -f .env ] && echo "✅ 文件" || echo "❌ 不存在")" -echo " data/: $([ -d data ] && echo "✅ 目录" || echo "❌ 不存在")" -echo " logs/: $([ -d logs ] && echo "✅ 目录" || echo "❌ 不存在")" -echo " redis_data/: $([ -d redis_data ] && echo "✅ 目录" || echo "❌ 不存在")" - -echo "" -echo "🎉 初始化完成!" -echo "" -echo "下一步操作:" -echo "1. 启动服务:" -echo " docker-compose up -d" -echo "" -echo "2. 查看日志获取管理员密码:" -echo " docker-compose logs claude-relay | grep '管理员'" -echo "" -echo "3. 访问管理界面:" -echo " http://your-server:3000/web" \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 4d28380a..8f75f151 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,10 +1,7 @@ version: '3.8' -# ⚠️ 重要提示:首次运行前必须执行以下命令 -# touch .env -# -# 说明:如果不先创建 .env 文件,Docker 会将其创建为目录而非文件, -# 导致容器无法正常启动。该文件用于存储加密密钥,必须持久化。 +# Claude Relay Service Docker Compose 配置 +# 所有配置通过环境变量设置,无需映射 .env 文件 services: # 🚀 Claude Relay Service @@ -15,16 +12,64 @@ services: 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 - - ./.env:/app/.env # 必须映射,用于持久化加密密钥 depends_on: - redis networks: diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index 9b949e59..b4e37c50 100644 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -3,25 +3,20 @@ set -e echo "🚀 Claude Relay Service 启动中..." -# 检查 .env 文件 -if [ ! -f "/app/.env" ]; then - echo "📋 检测到 .env 不存在,从模板创建..." - if [ -f "/app/.env.example" ]; then - cp /app/.env.example /app/.env - echo "✅ .env 已从模板创建" - echo "⚠️ 注意:.env 文件将在容器内生成,请确保已映射到宿主机以持久化" - else - echo "❌ 错误: .env.example 不存在" - exit 1 - fi +# 检查关键环境变量 +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 -# 生成随机字符串的函数 -generate_random_string() { - length=$1 - # 使用 /dev/urandom 生成随机字符串 - tr -dc 'a-zA-Z0-9' < /dev/urandom | head -c $length -} +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 @@ -35,54 +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" - # 更新 .env 文件 - sed -i "s/JWT_SECRET=.*/JWT_SECRET=${JWT_SECRET}/" /app/.env - else - 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" - # 更新 .env 文件 - sed -i "s/ENCRYPTION_KEY=.*/ENCRYPTION_KEY=${ENCRYPTION_KEY}/" /app/.env - else - echo "✅ 使用现有的 ENCRYPTION_KEY" - fi - fi - - # 更新 Redis 主机配置 - 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/scripts/docker-init.sh b/scripts/docker-init.sh deleted file mode 100644 index ad81c254..00000000 --- a/scripts/docker-init.sh +++ /dev/null @@ -1,52 +0,0 @@ -#!/bin/bash -# Docker 初始化脚本 - 在宿主机上运行 - -echo "🚀 Claude Relay Service Docker 初始化" - -# 检查 .env 文件 -if [ ! -f ".env" ]; then - echo "📋 检测到 .env 文件不存在,从模板创建..." - - if [ -f ".env.example" ]; then - cp .env.example .env - echo "✅ .env 文件已创建" - - # 生成随机密钥 - echo "🔑 生成安全密钥..." - - # 生成64字符的JWT_SECRET - JWT_SECRET=$(openssl rand -base64 48 | tr -d "=+/" | cut -c1-64) - # 生成32字符的ENCRYPTION_KEY - ENCRYPTION_KEY=$(openssl rand -base64 24 | tr -d "=+/" | cut -c1-32) - - # 替换默认值 - if [[ "$OSTYPE" == "darwin"* ]]; then - # macOS - sed -i '' "s/JWT_SECRET=.*/JWT_SECRET=$JWT_SECRET/" .env - sed -i '' "s/ENCRYPTION_KEY=.*/ENCRYPTION_KEY=$ENCRYPTION_KEY/" .env - else - # Linux - sed -i "s/JWT_SECRET=.*/JWT_SECRET=$JWT_SECRET/" .env - sed -i "s/ENCRYPTION_KEY=.*/ENCRYPTION_KEY=$ENCRYPTION_KEY/" .env - fi - - echo "✅ 密钥已生成并保存到 .env 文件" - echo "" - echo "📌 请妥善保管 .env 文件,它包含重要的加密密钥!" - else - echo "❌ 错误:.env.example 文件不存在" - echo "请确保在项目根目录下运行此脚本" - exit 1 - fi -else - echo "✅ .env 文件已存在,跳过创建" -fi - -# 创建必要的目录 -echo "📁 创建必要的目录..." -mkdir -p data logs redis_data -echo "✅ 目录创建完成" - -echo "" -echo "🎉 初始化完成!现在可以运行:" -echo " docker-compose up -d" \ No newline at end of file From 9f3af7d006546ee9894ddd384538161cd1308532 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 24 Jul 2025 07:51:35 +0000 Subject: [PATCH 11/69] chore: sync VERSION file with release v1.1.17 [skip ci] --- VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VERSION b/VERSION index 63b283b2..cbb8cbae 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.1.16 +1.1.17 From 561f5ffc7f499885be82769b0fcb6f03d55c3114 Mon Sep 17 00:00:00 2001 From: shaw Date: Thu, 24 Jul 2025 15:54:54 +0800 Subject: [PATCH 12/69] update readme --- README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/README.md b/README.md index b9eb9ce7..7fe4cdcc 100644 --- a/README.md +++ b/README.md @@ -343,7 +343,6 @@ docker-compose.yml 已包含: - `ADMIN_USERNAME`: 管理员用户名(不设置则自动生成) - `ADMIN_PASSWORD`: 管理员密码(不设置则自动生成) - `LOG_LEVEL`: 日志级别(默认:info) -- `DEFAULT_TOKEN_LIMIT`: 默认Token限制(默认:1000000) - 更多配置项请参考 `.env.example` 文件 ### 管理员凭据获取方式 From f149be0d0c1aa90886a36cc0d4dbe246c96b3998 Mon Sep 17 00:00:00 2001 From: breaker Date: Fri, 25 Jul 2025 01:25:33 +0800 Subject: [PATCH 13/69] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=E9=80=9A?= =?UTF-8?q?=E8=BF=87URL=E5=8F=82=E6=95=B0=E5=88=87=E6=8D=A2=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E7=95=8C=E9=9D=A2=E6=A0=87=E7=AD=BE=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加URL参数解析功能,支持 ?tab=apiKeys 等参数直接跳转到指定标签页 - 切换标签页时自动更新URL,方便分享和书签 - 支持浏览器前进后退按钮,保持标签页状态同步 - 默认dashboard标签页不显示URL参数,保持简洁 --- web/admin/app.js | 42 ++++++++++++++++++++++++++++++++++++++++++ web/admin/index.html | 2 +- 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/web/admin/app.js b/web/admin/app.js index 990afadb..ecd61fee 100644 --- a/web/admin/app.js +++ b/web/admin/app.js @@ -290,6 +290,9 @@ const app = createApp({ 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 +306,11 @@ const app = createApp({ } }); + // 监听浏览器前进后退按钮事件 + window.addEventListener('popstate', () => { + this.initializeTabFromUrl(); + }); + if (this.authToken) { this.isLoggedIn = true; @@ -368,6 +376,40 @@ const app = createApp({ }, methods: { + // 从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 { diff --git a/web/admin/index.html b/web/admin/index.html index f73f12d9..274391f7 100644 --- a/web/admin/index.html +++ b/web/admin/index.html @@ -208,7 +208,7 @@ + + + + + +
+ + +
+ + +
From e53724aa61141d80a4d9bcf765f2a4745eb6ef02 Mon Sep 17 00:00:00 2001 From: KevinLiao Date: Fri, 25 Jul 2025 10:47:09 +0800 Subject: [PATCH 19/69] =?UTF-8?q?feat:=20=E6=B5=8B=E8=AF=95=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E7=94=9F=E6=88=90=E8=84=9A=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/generate-test-data.js | 284 ++++++++++++++++++++++++++++++++++ 1 file changed, 284 insertions(+) create mode 100755 scripts/generate-test-data.js 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 From bafd64488d0309f09b57d8f4d0a7d7022078a909 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 25 Jul 2025 04:55:19 +0000 Subject: [PATCH 20/69] chore: sync VERSION file with release v1.1.19 [skip ci] --- VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VERSION b/VERSION index 852ed67c..4e036596 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.1.18 +1.1.19 From fe1300de650699b935b7a1a1d170ee6967e4855b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 25 Jul 2025 07:36:49 +0000 Subject: [PATCH 21/69] chore: sync VERSION file with release v1.1.20 [skip ci] --- VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VERSION b/VERSION index 4e036596..be5b4c7b 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.1.19 +1.1.20 From 4325de90e11d4ad9dd2e0166c04156ff32a0ee80 Mon Sep 17 00:00:00 2001 From: leslie Date: Fri, 25 Jul 2025 20:27:20 +0800 Subject: [PATCH 22/69] =?UTF-8?q?=E8=A7=A3=E5=86=B3=E8=BD=AE=E8=AF=A2?= =?UTF-8?q?=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/services/claudeAccountService.js | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) 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; // 最久未使用的优先 }); } From 6f2fe2f643b43aec0f17ed3f3c479504fc99d153 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 25 Jul 2025 12:45:22 +0000 Subject: [PATCH 23/69] chore: sync VERSION file with release v1.1.21 [skip ci] --- VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VERSION b/VERSION index be5b4c7b..6f182425 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.1.20 +1.1.21 From 5522967792130a02a7e2fc8257ade6abb54e0f19 Mon Sep 17 00:00:00 2001 From: leslie Date: Fri, 25 Jul 2025 21:27:17 +0800 Subject: [PATCH 24/69] =?UTF-8?q?=E6=B7=BB=E5=8A=A0claude=E8=B4=A6?= =?UTF-8?q?=E5=8F=B7=E7=BB=B4=E5=BA=A6=E8=AE=A1=E7=AE=97token=E8=B4=B9?= =?UTF-8?q?=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/models/redis.js | 202 +++++++++++++++++++++++++++++ src/routes/admin.js | 67 ++++++++++ src/routes/api.js | 10 +- src/services/apiKeyService.js | 27 +++- src/services/claudeRelayService.js | 7 +- 5 files changed, 304 insertions(+), 9 deletions(-) diff --git a/src/models/redis.js b/src/models/redis.js index f2664f0e..f59ba93a 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(); @@ -369,6 +467,110 @@ class RedisClient { }; } + // 📊 获取账户使用统计 + 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(); diff --git a/src/routes/admin.js b/src/routes/admin.js index 16311a0f..e908dfe1 100644 --- a/src/routes/admin.js +++ b/src/routes/admin.js @@ -791,6 +791,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 + }); + } +}); + // 📊 系统统计 // 获取系统概览 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/services/apiKeyService.js b/src/services/apiKeyService.js index 718de0fc..7c397904 100644 --- a/src/services/apiKeyService.js +++ b/src/services/apiKeyService.js @@ -234,18 +234,27 @@ 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; + + // 记录API Key级别的使用统计 await redis.incrementTokenUsage(keyId, totalTokens, inputTokens, outputTokens, cacheCreateTokens, cacheReadTokens, 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); + + // 记录账户级别的使用统计 + const claudeAccountId = accountId || keyData.claudeAccountId; + if (claudeAccountId) { + await redis.incrementAccountUsage(claudeAccountId, totalTokens, inputTokens, outputTokens, cacheCreateTokens, cacheReadTokens, model); + logger.database(`📊 Recorded account usage: ${claudeAccountId} - ${totalTokens} tokens`); + } } const logParts = [`Model: ${model}`, `Input: ${inputTokens}`, `Output: ${outputTokens}`]; @@ -274,6 +283,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() { 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; From 1cf70a627f553db8fa5bacb384e7036501e81ba2 Mon Sep 17 00:00:00 2001 From: leslie Date: Fri, 25 Jul 2025 21:36:17 +0800 Subject: [PATCH 25/69] =?UTF-8?q?=E6=B7=BB=E5=8A=A0claude=E8=B4=A6?= =?UTF-8?q?=E5=8F=B7=E7=BB=B4=E5=BA=A6=E8=AE=A1=E7=AE=97token=E8=B4=B9?= =?UTF-8?q?=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/routes/admin.js | 42 ++++++++++++++++++++++++++++++++++++++++-- web/admin/app.js | 33 +++++++++++++++++++++++++++++++++ web/admin/index.html | 38 ++++++++++++++++++++++++++++++++------ 3 files changed, 105 insertions(+), 8 deletions(-) diff --git a/src/routes/admin.js b/src/routes/admin.js index e908dfe1..7280af03 100644 --- a/src/routes/admin.js +++ b/src/routes/admin.js @@ -531,7 +531,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 }); @@ -718,7 +745,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 }); diff --git a/web/admin/app.js b/web/admin/app.js index be8421cd..41a4bbb1 100644 --- a/web/admin/app.js +++ b/web/admin/app.js @@ -192,6 +192,7 @@ const app = createApp({ // 账户 accounts: [], accountsLoading: false, + accountSortBy: 'dailyTokens', // 默认按今日Token排序 showCreateAccountModal: false, createAccountLoading: false, accountForm: { @@ -1868,6 +1869,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 { @@ -1875,6 +1879,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; diff --git a/web/admin/index.html b/web/admin/index.html index 8003b1b3..656e8f39 100644 --- a/web/admin/index.html +++ b/web/admin/index.html @@ -922,12 +922,21 @@

账户管理

管理您的 Claude 和 Gemini 账户及代理配置

- +
+ + +
@@ -952,6 +961,7 @@ 类型 状态 代理 + 今日使用 最后使用 操作 @@ -1024,6 +1034,22 @@
无代理
+ +
+
+
+ {{ account.usage.daily.requests || 0 }} 次 +
+
+
+ {{ formatNumber(account.usage.daily.allTokens || 0) }} tokens +
+
+ 平均 {{ account.usage.averages.rpm.toFixed(2) }} RPM +
+
+
暂无数据
+ {{ account.lastUsedAt ? new Date(account.lastUsedAt).toLocaleDateString() : '从未使用' }} From 53e0577e1933c69bbdc0a8a599956c67d30b4921 Mon Sep 17 00:00:00 2001 From: leslie Date: Fri, 25 Jul 2025 21:48:54 +0800 Subject: [PATCH 26/69] =?UTF-8?q?=E6=B7=BB=E5=8A=A0claude=E8=B4=A6?= =?UTF-8?q?=E5=8F=B7=E7=BB=B4=E5=BA=A6=E8=AE=A1=E7=AE=97token=E8=B4=B9?= =?UTF-8?q?=E7=94=A8=E5=89=8D=E7=AB=AF=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/routes/openaiClaudeRoutes.js | 6 ++++-- src/services/apiKeyService.js | 15 ++++++++++----- 2 files changed, 14 insertions(+), 7 deletions(-) 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/services/apiKeyService.js b/src/services/apiKeyService.js index 7c397904..e53976c2 100644 --- a/src/services/apiKeyService.js +++ b/src/services/apiKeyService.js @@ -249,11 +249,16 @@ class ApiKeyService { keyData.lastUsedAt = new Date().toISOString(); await redis.setApiKey(keyId, keyData); - // 记录账户级别的使用统计 - const claudeAccountId = accountId || keyData.claudeAccountId; - if (claudeAccountId) { - await redis.incrementAccountUsage(claudeAccountId, totalTokens, inputTokens, outputTokens, cacheCreateTokens, cacheReadTokens, model); - logger.database(`📊 Recorded account usage: ${claudeAccountId} - ${totalTokens} tokens`); + // 记录账户级别的使用统计(只统计实际处理请求的账户) + if (accountId) { + await redis.incrementAccountUsage(accountId, totalTokens, inputTokens, outputTokens, cacheCreateTokens, cacheReadTokens, model); + logger.database(`📊 Recorded account usage: ${accountId} - ${totalTokens} tokens (API Key: ${keyId})`); + } else if (keyData.claudeAccountId) { + // 如果没有传入accountId,但API Key绑定了专属账户,也记录统计 + await redis.incrementAccountUsage(keyData.claudeAccountId, totalTokens, inputTokens, outputTokens, cacheCreateTokens, cacheReadTokens, model); + logger.database(`📊 Recorded account usage (from API Key binding): ${keyData.claudeAccountId} - ${totalTokens} tokens (API Key: ${keyId})`); + } else { + logger.debug(`⚠️ No accountId provided and API Key not bound to account, skipping account-level statistics`); } } From 578d3ca34bd4b299007b56ea6271b43fa4441031 Mon Sep 17 00:00:00 2001 From: leslie Date: Fri, 25 Jul 2025 22:08:30 +0800 Subject: [PATCH 27/69] =?UTF-8?q?=E9=87=8D=E5=A4=8D=E8=AE=A1=E6=AC=A1?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/services/apiKeyService.js | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/services/apiKeyService.js b/src/services/apiKeyService.js index e53976c2..052aef6e 100644 --- a/src/services/apiKeyService.js +++ b/src/services/apiKeyService.js @@ -253,12 +253,8 @@ class ApiKeyService { if (accountId) { await redis.incrementAccountUsage(accountId, totalTokens, inputTokens, outputTokens, cacheCreateTokens, cacheReadTokens, model); logger.database(`📊 Recorded account usage: ${accountId} - ${totalTokens} tokens (API Key: ${keyId})`); - } else if (keyData.claudeAccountId) { - // 如果没有传入accountId,但API Key绑定了专属账户,也记录统计 - await redis.incrementAccountUsage(keyData.claudeAccountId, totalTokens, inputTokens, outputTokens, cacheCreateTokens, cacheReadTokens, model); - logger.database(`📊 Recorded account usage (from API Key binding): ${keyData.claudeAccountId} - ${totalTokens} tokens (API Key: ${keyId})`); } else { - logger.debug(`⚠️ No accountId provided and API Key not bound to account, skipping account-level statistics`); + logger.debug(`⚠️ No accountId provided for usage recording, skipping account-level statistics`); } } From b8c7c3e9f562e5eff89752a18ed93c42848fafc4 Mon Sep 17 00:00:00 2001 From: KevinLiao Date: Fri, 25 Jul 2025 23:36:48 +0800 Subject: [PATCH 28/69] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0APIKey=20?= =?UTF-8?q?=E5=AE=A2=E6=88=B7=E7=AB=AF=E9=99=90=E5=88=B6=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 5 ++- README.md | 65 +++++++++++++++++++++++++++++- src/middleware/auth.js | 51 ++++++++++++++++++++++++ src/routes/admin.js | 50 +++++++++++++++++++++-- src/services/apiKeyService.js | 32 ++++++++++++--- web/admin/app.js | 42 +++++++++++++++++--- web/admin/index.html | 74 +++++++++++++++++++++++++++++++++++ 7 files changed, 302 insertions(+), 17 deletions(-) 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/README.md b/README.md index 7fe4cdcc..850f971d 100644 --- a/README.md +++ b/README.md @@ -106,7 +106,7 @@ - 🔄 **智能切换**: 账户出问题自动换下一个 - 🚀 **性能优化**: 连接池、缓存,减少延迟 - 📊 **监控面板**: Web界面查看所有数据 -- 🛡️ **安全控制**: 访问限制、速率控制 +- 🛡️ **安全控制**: 访问限制、速率控制、客户端限制 - 🌐 **代理支持**: 支持HTTP/SOCKS5代理 --- @@ -398,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 @@ -498,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/src/middleware/auth.js b/src/middleware/auth.js index 6bd9929b..11d56e5b 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; @@ -205,12 +252,16 @@ 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, 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/routes/admin.js b/src/routes/admin.js index 16311a0f..3788a86c 100644 --- a/src/routes/admin.js +++ b/src/routes/admin.js @@ -12,6 +12,7 @@ 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(); @@ -236,6 +237,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 { @@ -251,7 +267,9 @@ router.post('/api-keys', authenticateAdmin, async (req, res) => { rateLimitWindow, rateLimitRequests, enableModelRestriction, - restrictedModels + restrictedModels, + enableClientRestriction, + allowedClients } = req.body; // 输入验证 @@ -293,6 +311,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, @@ -305,7 +332,9 @@ router.post('/api-keys', authenticateAdmin, async (req, res) => { rateLimitWindow, rateLimitRequests, enableModelRestriction, - restrictedModels + restrictedModels, + enableClientRestriction, + allowedClients }); logger.success(`🔑 Admin created new API key: ${name}`); @@ -320,7 +349,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, expiresAt } = req.body; + const { tokenLimit, concurrencyLimit, rateLimitWindow, rateLimitRequests, claudeAccountId, geminiAccountId, permissions, enableModelRestriction, restrictedModels, enableClientRestriction, allowedClients, expiresAt } = req.body; // 只允许更新指定字段 const updates = {}; @@ -386,6 +415,21 @@ 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) { diff --git a/src/services/apiKeyService.js b/src/services/apiKeyService.js index 718de0fc..2541b352 100644 --- a/src/services/apiKeyService.js +++ b/src/services/apiKeyService.js @@ -24,7 +24,9 @@ class ApiKeyService { rateLimitWindow = null, rateLimitRequests = null, enableModelRestriction = false, - restrictedModels = [] + restrictedModels = [], + enableClientRestriction = false, + allowedClients = [] } = options; // 生成简单的API Key (64字符十六进制) @@ -47,6 +49,8 @@ class ApiKeyService { permissions: permissions || 'all', enableModelRestriction: String(enableModelRestriction), restrictedModels: JSON.stringify(restrictedModels || []), + enableClientRestriction: String(enableClientRestriction || false), + allowedClients: JSON.stringify(allowedClients || []), createdAt: new Date().toISOString(), lastUsedAt: '', expiresAt: expiresAt || '', @@ -73,6 +77,8 @@ class ApiKeyService { permissions: keyData.permissions, enableModelRestriction: keyData.enableModelRestriction === 'true', restrictedModels: JSON.parse(keyData.restrictedModels), + enableClientRestriction: keyData.enableClientRestriction === 'true', + allowedClients: JSON.parse(keyData.allowedClients || '[]'), createdAt: keyData.createdAt, expiresAt: keyData.expiresAt, createdBy: keyData.createdBy @@ -122,6 +128,14 @@ class ApiKeyService { restrictedModels = []; } + // 解析允许的客户端 + let allowedClients = []; + try { + allowedClients = keyData.allowedClients ? JSON.parse(keyData.allowedClients) : []; + } catch (e) { + allowedClients = []; + } + return { valid: true, keyData: { @@ -136,6 +150,8 @@ class ApiKeyService { rateLimitRequests: parseInt(keyData.rateLimitRequests || 0), enableModelRestriction: keyData.enableModelRestriction === 'true', restrictedModels: restrictedModels, + enableClientRestriction: keyData.enableClientRestriction === 'true', + allowedClients: allowedClients, usage } }; @@ -160,12 +176,18 @@ 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'; // 兼容旧数据 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 +207,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']; 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 { diff --git a/web/admin/app.js b/web/admin/app.js index be8421cd..9d7714c4 100644 --- a/web/admin/app.js +++ b/web/admin/app.js @@ -127,6 +127,8 @@ const app = createApp({ enableModelRestriction: false, restrictedModels: [], modelInput: '', + enableClientRestriction: false, + allowedClients: [], expireDuration: '', // 过期时长选择 customExpireDate: '', // 自定义过期日期 expiresAt: null // 实际的过期时间戳 @@ -186,9 +188,14 @@ const app = createApp({ permissions: 'all', enableModelRestriction: false, restrictedModels: [], - modelInput: '' + modelInput: '', + enableClientRestriction: false, + allowedClients: [] }, + // 支持的客户端列表 + supportedClients: [], + // 账户 accounts: [], accountsLoading: false, @@ -346,10 +353,11 @@ const app = createApp({ // 初始化日期筛选器和图表数据 this.initializeDateFilter(); - // 预加载账号列表和API Keys,以便正确显示绑定关系 + // 预加载账号列表、API Keys和支持的客户端,以便正确显示绑定关系 Promise.all([ this.loadAccounts(), - this.loadApiKeys() + this.loadApiKeys(), + this.loadSupportedClients() ]).then(() => { // 根据当前活跃标签页加载数据 this.loadCurrentTabData(); @@ -1778,6 +1786,18 @@ 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 with time range:', this.apiKeyStatsTimeRange); @@ -1916,6 +1936,8 @@ const app = createApp({ permissions: this.apiKeyForm.permissions || 'all', enableModelRestriction: this.apiKeyForm.enableModelRestriction, restrictedModels: this.apiKeyForm.restrictedModels, + enableClientRestriction: this.apiKeyForm.enableClientRestriction, + allowedClients: this.apiKeyForm.allowedClients, expiresAt: this.apiKeyForm.expiresAt }) }); @@ -1950,6 +1972,8 @@ const app = createApp({ enableModelRestriction: false, restrictedModels: [], modelInput: '', + enableClientRestriction: false, + allowedClients: [], expireDuration: '', customExpireDate: '', expiresAt: null @@ -2117,7 +2141,9 @@ 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] : [] }; this.showEditApiKeyModal = true; }, @@ -2136,7 +2162,9 @@ const app = createApp({ permissions: 'all', enableModelRestriction: false, restrictedModels: [], - modelInput: '' + modelInput: '', + enableClientRestriction: false, + allowedClients: [] }; }, @@ -2154,7 +2182,9 @@ 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 }) }); diff --git a/web/admin/index.html b/web/admin/index.html index 8003b1b3..dfc0e764 100644 --- a/web/admin/index.html +++ b/web/admin/index.html @@ -2205,6 +2205,43 @@ + +
+
+ + +
+ +
+
+ +

勾选允许使用此API Key的客户端

+
+
+ + +
+
+
+
+
+
+ +
+
+ + +
+ +
+
+ +

勾选允许使用此API Key的客户端

+
+
+ + +
+
+
+
+
+