diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..e82b3f66 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,76 @@ +# Dependencies +node_modules/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Environment files +.env +.env.local +.env.*.local + +# Logs +logs/ +*.log + +# Data files +data/ +temp/ + +# Git +.git/ +.gitignore +.gitattributes + +# GitHub +.github/ + +# Documentation +README.md +README_EN.md +CHANGELOG.md +docs/ +*.md + +# Development files +.vscode/ +.idea/ +*.swp +*.swo +*~ +.DS_Store + +# Docker files +docker-compose.yml +docker-compose.*.yml +Dockerfile +.dockerignore + +# Test files +test/ +tests/ +__tests__/ +*.test.js +*.spec.js +coverage/ +.nyc_output/ + +# Build files +dist/ +build/ +*.pid +*.seed +*.pid.lock + +# CI/CD +.travis.yml +.gitlab-ci.yml +azure-pipelines.yml + +# Package manager files +package-lock.json +yarn.lock +pnpm-lock.yaml + +# CLI +cli/ \ No newline at end of file diff --git a/.env.example b/.env.example index debac4de..2b6f8e1f 100644 --- a/.env.example +++ b/.env.example @@ -11,6 +11,10 @@ ADMIN_SESSION_TIMEOUT=86400000 API_KEY_PREFIX=cr_ ENCRYPTION_KEY=your-encryption-key-here +# 👤 管理员凭据(可选,不设置则自动生成) +# ADMIN_USERNAME=cr_admin_custom +# ADMIN_PASSWORD=your-secure-password + # 📊 Redis 配置 REDIS_HOST=localhost REDIS_PORT=6379 diff --git a/.github/DOCKER_HUB_SETUP.md b/.github/DOCKER_HUB_SETUP.md new file mode 100644 index 00000000..b7718864 --- /dev/null +++ b/.github/DOCKER_HUB_SETUP.md @@ -0,0 +1,109 @@ +# Docker Hub 自动发布配置指南 + +本文档说明如何配置 GitHub Actions 自动构建并发布 Docker 镜像到 Docker Hub。 + +## 📋 前置要求 + +1. Docker Hub 账号 +2. GitHub 仓库的管理员权限 + +## 🔐 配置 GitHub Secrets + +在 GitHub 仓库中配置以下 secrets: + +1. 进入仓库设置:`Settings` → `Secrets and variables` → `Actions` +2. 点击 `New repository secret` +3. 添加以下 secrets: + +### 必需的 Secrets + +| Secret 名称 | 说明 | 如何获取 | +|------------|------|---------| +| `DOCKERHUB_USERNAME` | Docker Hub 用户名 | 你的 Docker Hub 登录用户名 | +| `DOCKERHUB_TOKEN` | Docker Hub Access Token | 见下方说明 | + +### 获取 Docker Hub Access Token + +1. 登录 [Docker Hub](https://hub.docker.com/) +2. 点击右上角头像 → `Account Settings` +3. 选择 `Security` → `Access Tokens` +4. 点击 `New Access Token` +5. 填写描述(如:`GitHub Actions`) +6. 选择权限:`Read, Write, Delete` +7. 点击 `Generate` +8. **立即复制 token**(只显示一次) + +## 🚀 工作流程说明 + +### 触发条件 + +- **自动触发**:推送到 `main` 分支 +- **版本发布**:创建 `v*` 格式的 tag(如 `v1.0.0`) +- **手动触发**:在 Actions 页面手动运行 + +### 镜像标签策略 + +工作流会自动创建以下标签: + +- `latest`:始终指向 main 分支的最新构建 +- `main`:main 分支的构建 +- `v1.0.0`:版本标签(当创建 tag 时) +- `1.0`:主次版本标签 +- `1`:主版本标签 +- `main-sha-xxxxxxx`:包含 commit SHA 的标签 + +### 支持的平台 + +- `linux/amd64`:Intel/AMD 架构 +- `linux/arm64`:ARM64 架构(如 Apple Silicon, 树莓派等) + +## 📦 使用发布的镜像 + +```bash +# 拉取最新版本 +docker pull weishaw/claude-relay-service:latest + +# 拉取特定版本 +docker pull weishaw/claude-relay-service:v1.0.0 + +# 运行容器 +docker run -d \ + --name claude-relay \ + -p 3000:3000 \ + -v ./data:/app/data \ + -v ./logs:/app/logs \ + -e ADMIN_USERNAME=my_admin \ + -e ADMIN_PASSWORD=my_password \ + weishaw/claude-relay-service:latest +``` + +## 🔍 验证配置 + +1. 推送代码到 main 分支 +2. 在 GitHub 仓库页面点击 `Actions` 标签 +3. 查看 `Docker Build & Push` 工作流运行状态 +4. 成功后在 Docker Hub 查看镜像 + +## 🛡️ 安全功能 + +- **漏洞扫描**:使用 Trivy 自动扫描镜像漏洞 +- **扫描报告**:上传到 GitHub Security 标签页 +- **自动更新 README**:同步更新 Docker Hub 的项目描述 + +## ❓ 常见问题 + +### 构建失败 + +- 检查 secrets 是否正确配置 +- 确认 Docker Hub token 有足够权限 +- 查看 Actions 日志详细错误信息 + +### 镜像推送失败 + +- 确认 Docker Hub 用户名正确 +- 检查是否达到 Docker Hub 免费账户限制 +- Token 可能过期,需要重新生成 + +### 多平台构建慢 + +这是正常的,因为需要模拟不同架构。可以在不需要时修改 `platforms` 配置。 \ No newline at end of file diff --git a/.github/WORKFLOW_USAGE.md b/.github/WORKFLOW_USAGE.md new file mode 100644 index 00000000..d6eed21a --- /dev/null +++ b/.github/WORKFLOW_USAGE.md @@ -0,0 +1,114 @@ +# GitHub Actions 工作流使用指南 + +## 📋 概述 + +本项目配置了自动化 CI/CD 流程,每次推送到 main 分支都会自动构建并发布 Docker 镜像到 Docker Hub。 + +## 🚀 工作流程 + +### 1. Docker 构建和发布 (`docker-publish.yml`) + +**功能:** +- 自动构建多平台 Docker 镜像(amd64, arm64) +- 推送到 Docker Hub +- 执行安全漏洞扫描 +- 更新 Docker Hub 描述 + +**触发条件:** +- 推送到 `main` 分支 +- 创建版本标签(如 `v1.0.0`) +- Pull Request(仅构建,不推送) +- 手动触发 + +### 2. 发布管理 (`release.yml`) + +**功能:** +- 自动创建 GitHub Release +- 生成更新日志 +- 关联 Docker 镜像版本 + +**触发条件:** +- 创建版本标签(如 `v1.0.0`) + +## 📝 版本发布流程 + +### 1. 常规更新(推送到 main) + +```bash +git add . +git commit -m "fix: 修复登录问题" +git push origin main +``` + +**结果:** +- 自动构建并推送 `latest` 标签到 Docker Hub +- 更新 `main` 标签 + +### 2. 版本发布 + +```bash +# 创建版本标签 +git tag -a v1.0.0 -m "Release version 1.0.0" +git push origin v1.0.0 +``` + +**结果:** +- 构建并推送以下标签到 Docker Hub: + - `v1.0.0`(完整版本) + - `1.0`(主次版本) + - `1`(主版本) + - `latest`(最新版本) +- 创建 GitHub Release +- 生成更新日志 + +## 🔧 手动触发构建 + +1. 访问仓库的 Actions 页面 +2. 选择 "Docker Build & Push" 工作流 +3. 点击 "Run workflow" +4. 选择分支并运行 + +## 📊 查看构建状态 + +- **Actions 页面**:查看所有工作流运行历史 +- **README 徽章**:实时显示构建状态 +- **Docker Hub**:查看镜像标签和拉取次数 + +## 🛡️ 安全扫描 + +每次构建都会运行 Trivy 安全扫描: +- 扫描结果上传到 GitHub Security 标签页 +- 发现高危漏洞会在 Actions 日志中警告 + +## ❓ 常见问题 + +### Q: 如何回滚到之前的版本? + +```bash +# 使用特定版本标签 +docker pull weishaw/claude-relay-service:v1.0.0 + +# 或在 docker-compose.yml 中指定版本 +image: weishaw/claude-relay-service:v1.0.0 +``` + +### Q: 如何跳过自动构建? + +在 commit 消息中添加 `[skip ci]`: +```bash +git commit -m "docs: 更新文档 [skip ci]" +``` + +### Q: 构建失败如何调试? + +1. 查看 Actions 日志详细错误信息 +2. 在本地测试 Docker 构建: + ```bash + docker build -t test . + ``` + +## 📚 相关文档 + +- [Docker Hub 配置指南](.github/DOCKER_HUB_SETUP.md) +- [GitHub Actions 文档](https://docs.github.com/en/actions) +- [Docker 官方文档](https://docs.docker.com/) \ No newline at end of file diff --git a/.github/cliff.toml b/.github/cliff.toml new file mode 100644 index 00000000..aad09349 --- /dev/null +++ b/.github/cliff.toml @@ -0,0 +1,68 @@ +# git-cliff configuration file +# https://git-cliff.org/docs/configuration + +[changelog] +# changelog header +header = """ +# Changelog + +All notable changes to this project will be documented in this file. +""" +# template for the changelog body +body = """ +{% if version %}\ + ## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }} +{% else %}\ + ## [unreleased] +{% endif %}\ +{% for group, commits in commits | group_by(attribute="group") %} + ### {{ group | upper_first }} + {% for commit in commits %} + - {% if commit.breaking %}[**breaking**] {% endif %}{{ commit.message | upper_first }} ([{{ commit.id | truncate(length=7, end="") }}](https://github.com/orhun/git-cliff/commit/{{ commit.id }})) + {%- endfor %} +{% endfor %}\n +""" +# remove the leading and trailing whitespace from the template +trim = true +# changelog footer +footer = """ +""" + +[git] +# parse the commits based on https://www.conventionalcommits.org +conventional_commits = true +# filter out the commits that are not conventional +filter_unconventional = true +# process each line of a commit as an individual commit +split_commits = false +# regex for preprocessing the commit messages +commit_preprocessors = [ + { pattern = '\((\w+\s)?#([0-9]+)\)', replace = "([#${2}](https://github.com/orhun/git-cliff/issues/${2}))" }, +] +# regex for parsing and grouping commits +commit_parsers = [ + { message = "^feat", group = "Features" }, + { message = "^fix", group = "Bug Fixes" }, + { message = "^docs", group = "Documentation" }, + { message = "^perf", group = "Performance" }, + { message = "^refactor", group = "Refactor" }, + { message = "^style", group = "Styling" }, + { message = "^test", group = "Testing" }, + { message = "^chore\\(release\\): prepare for", skip = true }, + { message = "^chore", group = "Miscellaneous Tasks" }, + { body = ".*security", group = "Security" }, +] +# protect breaking changes from being skipped due to matching a skipping commit_parser +protect_breaking_commits = false +# filter out the commits that are not matched by commit parsers +filter_commits = false +# glob pattern for matching git tags +tag_pattern = "v[0-9]*" +# regex for skipping tags +skip_tags = "v0.1.0-beta.1" +# regex for ignoring tags +ignore_tags = "" +# sort the tags topologically +topo_order = false +# sort the commits inside sections by oldest/newest order +sort_commits = "oldest" \ No newline at end of file diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml new file mode 100644 index 00000000..66dc14ea --- /dev/null +++ b/.github/workflows/docker-publish.yml @@ -0,0 +1,101 @@ +name: Docker Build & Push + +on: + push: + branches: [ main ] + tags: + - 'v*' + pull_request: + branches: [ main ] + 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}} + type=semver,pattern={{major}}.{{minor}} + type=semver,pattern={{major}} + type=sha,prefix={{branch}}- + 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' + + 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 new file mode 100644 index 00000000..4756bf6a --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,48 @@ +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: Generate changelog + id: changelog + uses: orhun/git-cliff-action@v3 + with: + config: .github/cliff.toml + args: --latest --strip header + + - 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 }} + + ## 📋 完整更新日志 + + 查看 [CHANGELOG.md](https://github.com/${{ github.repository }}/blob/main/CHANGELOG.md) + + draft: false + prerelease: false + generate_release_notes: true \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index f8e96857..63c60a39 100644 --- a/Dockerfile +++ b/Dockerfile @@ -29,6 +29,10 @@ RUN npm ci --only=production && \ # 📋 复制应用代码 COPY --chown=claude:nodejs . . +# 🔧 复制并设置启动脚本权限 +COPY --chown=claude:nodejs docker-entrypoint.sh /usr/local/bin/ +RUN chmod +x /usr/local/bin/docker-entrypoint.sh + # 📁 创建必要目录 RUN mkdir -p logs data temp && \ chown -R claude:nodejs logs data temp @@ -44,5 +48,5 @@ HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ CMD curl -f http://localhost:3000/health || exit 1 # 🚀 启动应用 -ENTRYPOINT ["dumb-init", "--"] +ENTRYPOINT ["dumb-init", "--", "/usr/local/bin/docker-entrypoint.sh"] CMD ["node", "src/app.js"] \ No newline at end of file diff --git a/README.md b/README.md index a97ae99a..4f97574c 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,8 @@ [](https://nodejs.org/) [](https://redis.io/) [](https://www.docker.com/) +[](https://github.com/Wei-Shaw/claude-relay-service/actions/workflows/docker-publish.yml) +[](https://hub.docker.com/r/weishaw/claude-relay-service) **🔐 自行搭建Claude API中转服务,支持多账户管理** @@ -157,7 +159,7 @@ sudo systemctl start redis ```bash # 下载项目 -git clone https://github.com/Wei-Shaw/claude-relay-service.git +git clone https://github.com/Wei-Shaw//claude-relay-service.git cd claude-relay-service # 安装依赖 @@ -202,6 +204,9 @@ module.exports = { ```bash # 初始化 npm run setup # 会随机生成后台账号密码信息,存储在 data/init.json +# 或者通过环境变量预设管理员凭据: +# export ADMIN_USERNAME=cr_admin_custom +# export ADMIN_PASSWORD=your-secure-password # 启动服务 npm run service:start:daemon # 后台运行(推荐) @@ -212,13 +217,126 @@ npm run service:status --- +## 🐳 Docker 部署(推荐) + +### 使用 Docker Hub 镜像(最简单) + +> 🚀 推荐使用官方镜像,自动构建,始终保持最新版本 + +```bash +# 拉取镜像(支持 amd64 和 arm64) +docker pull weishaw/claude-relay-service:latest + +# 使用 docker run 运行 +docker run -d \ + --name claude-relay \ + -p 3000:3000 \ + -v $(pwd)/data:/app/data \ + -v $(pwd)/logs:/app/logs \ + -e ADMIN_USERNAME=my_admin \ + -e ADMIN_PASSWORD=my_secure_password \ + weishaw/claude-relay-service:latest + +# 或使用 docker-compose(推荐) +# 创建 docker-compose.yml 文件: +cat > docker-compose.yml << 'EOF' +version: '3.8' +services: + claude-relay: + image: weishaw/claude-relay-service:latest + container_name: claude-relay-service + restart: unless-stopped + ports: + - "3000:3000" + environment: + - REDIS_HOST=redis + - ADMIN_USERNAME=${ADMIN_USERNAME:-} + - ADMIN_PASSWORD=${ADMIN_PASSWORD:-} + volumes: + - ./logs:/app/logs + - ./data:/app/data + depends_on: + - redis + + redis: + image: redis:7-alpine + container_name: claude-relay-redis + restart: unless-stopped + volumes: + - redis_data:/data + +volumes: + redis_data: +EOF + +# 启动服务 +docker-compose up -d +``` + +### 从源码构建 + +```bash +# 1. 克隆项目 +git clone https://github.com/Wei-Shaw//claude-relay-service.git +cd claude-relay-service + +# 2. 设置管理员账号密码(可选) +# 方式一:自动生成(查看容器日志获取) +docker-compose up -d + +# 方式二:预设账号密码 +export ADMIN_USERNAME=cr_admin_custom +export ADMIN_PASSWORD=your-secure-password +docker-compose up -d + +# 3. 查看管理员凭据 +# 自动生成的情况下: +docker logs claude-relay-service | grep "管理员" + +# 或者直接查看挂载的文件: +cat ./data/init.json +``` + +### Docker Compose 配置 + +docker-compose.yml 已包含: +- ✅ 自动初始化管理员账号 +- ✅ 数据持久化(logs和data目录自动挂载) +- ✅ Redis数据库 +- ✅ 健康检查 +- ✅ 自动重启 + +### 管理员凭据获取方式 + +1. **查看容器日志**(推荐) + ```bash + docker logs claude-relay-service + ``` + +2. **查看挂载的文件** + ```bash + cat ./data/init.json + ``` + +3. **使用环境变量预设** + ```bash + # 在 .env 文件中设置 + ADMIN_USERNAME=cr_admin_custom + ADMIN_PASSWORD=your-secure-password + ``` + +--- + ## 🎮 开始使用 ### 1. 打开管理界面 浏览器访问:`http://你的服务器IP:3000/web` -默认管理员账号:data/init.json 中寻找 +管理员账号: +- 自动生成:查看 data/init.json +- 环境变量预设:通过 ADMIN_USERNAME 和 ADMIN_PASSWORD 设置 +- Docker 部署:查看容器日志 `docker logs claude-relay-service` ### 2. 添加Claude账户 diff --git a/docker-compose.yml b/docker-compose.yml index 1978f786..cf4754d4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -13,6 +13,8 @@ services: - PORT=3000 - REDIS_HOST=redis - REDIS_PORT=6379 + - ADMIN_USERNAME=${ADMIN_USERNAME:-} # 可选:预设管理员用户名 + - ADMIN_PASSWORD=${ADMIN_PASSWORD:-} # 可选:预设管理员密码 volumes: - ./logs:/app/logs - ./data:/app/data diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh new file mode 100644 index 00000000..96748011 --- /dev/null +++ b/docker-entrypoint.sh @@ -0,0 +1,31 @@ +#!/bin/sh +set -e + +echo "🚀 Claude Relay Service 启动中..." + +# 检查是否需要初始化 +if [ ! -f "/app/data/init.json" ]; then + echo "📋 首次启动,执行初始化设置..." + + # 如果设置了环境变量,显示提示 + if [ -n "$ADMIN_USERNAME" ] || [ -n "$ADMIN_PASSWORD" ]; then + echo "📌 检测到预设的管理员凭据" + fi + + # 执行初始化脚本 + node /app/scripts/setup.js + + echo "✅ 初始化完成" +else + echo "✅ 检测到已有配置,跳过初始化" + + # 如果 init.json 存在但环境变量也设置了,显示警告 + if [ -n "$ADMIN_USERNAME" ] || [ -n "$ADMIN_PASSWORD" ]; then + echo "⚠️ 警告: 检测到环境变量 ADMIN_USERNAME/ADMIN_PASSWORD,但系统已初始化" + echo " 如需使用新凭据,请删除 data/init.json 文件后重启容器" + fi +fi + +# 启动应用 +echo "🌐 启动 Claude Relay Service..." +exec "$@" \ No newline at end of file diff --git a/scripts/setup.js b/scripts/setup.js index 56e6c789..16947b19 100644 --- a/scripts/setup.js +++ b/scripts/setup.js @@ -42,9 +42,14 @@ async function setup() { fs.writeFileSync(path.join(__dirname, '..', '.env'), envContent); } - // 3. 生成随机管理员凭据 - const adminUsername = `cr_admin_${crypto.randomBytes(4).toString('hex')}`; - const adminPassword = crypto.randomBytes(16).toString('base64').replace(/[^a-zA-Z0-9]/g, '').substring(0, 16); + // 3. 生成或使用环境变量中的管理员凭据 + const adminUsername = process.env.ADMIN_USERNAME || `cr_admin_${crypto.randomBytes(4).toString('hex')}`; + const adminPassword = process.env.ADMIN_PASSWORD || crypto.randomBytes(16).toString('base64').replace(/[^a-zA-Z0-9]/g, '').substring(0, 16); + + // 如果使用了环境变量,显示提示 + if (process.env.ADMIN_USERNAME || process.env.ADMIN_PASSWORD) { + console.log(chalk.yellow('\n📌 使用环境变量中的管理员凭据')); + } // 4. 创建初始化完成标记文件 const initData = { @@ -65,7 +70,14 @@ async function setup() { console.log(chalk.yellow('📋 重要信息:\n')); console.log(` 管理员用户名: ${chalk.cyan(adminUsername)}`); console.log(` 管理员密码: ${chalk.cyan(adminPassword)}`); - console.log(chalk.red('\n⚠️ 请立即保存这些凭据!首次登录后建议修改密码。\n')); + + // 如果是自动生成的凭据,强调需要保存 + if (!process.env.ADMIN_USERNAME && !process.env.ADMIN_PASSWORD) { + console.log(chalk.red('\n⚠️ 请立即保存这些凭据!首次登录后建议修改密码。')); + console.log(chalk.yellow('\n💡 提示: 也可以通过环境变量 ADMIN_USERNAME 和 ADMIN_PASSWORD 预设管理员凭据。\n')); + } else { + console.log(chalk.green('\n✅ 已使用预设的管理员凭据。\n')); + } console.log(chalk.blue('🚀 启动服务:\n')); console.log(' npm start - 启动生产服务'); diff --git a/src/app.js b/src/app.js index 00b69c1b..754194f0 100644 --- a/src/app.js +++ b/src/app.js @@ -270,6 +270,12 @@ class Application { logger.info(`📊 Metrics: http://${config.server.host}:${config.server.port}/metrics`); }); + const serverTimeout = 600000; // 默认10分钟 + this.server.timeout = serverTimeout; + this.server.keepAliveTimeout = serverTimeout + 5000; // keepAlive 稍长一点 + logger.info(`⏱️ Server timeout set to ${serverTimeout}ms (${serverTimeout/1000}s)`); + + // 🔄 定期清理任务 this.startCleanupTasks(); diff --git a/src/models/redis.js b/src/models/redis.js index 9d6655f2..2e324e49 100644 --- a/src/models/redis.js +++ b/src/models/redis.js @@ -139,18 +139,24 @@ class RedisClient { // 📊 使用统计相关操作(支持缓存token统计和模型信息) async incrementTokenUsage(keyId, tokens, inputTokens = 0, outputTokens = 0, cacheCreateTokens = 0, cacheReadTokens = 0, model = 'unknown') { const key = `usage:${keyId}`; - const today = new Date().toISOString().split('T')[0]; - const currentMonth = `${new Date().getFullYear()}-${String(new Date().getMonth() + 1).padStart(2, '0')}`; + const now = new Date(); + const today = now.toISOString().split('T')[0]; + const currentMonth = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`; + const currentHour = `${today}:${String(now.getHours()).padStart(2, '0')}`; // 新增小时级别 + const daily = `usage:daily:${keyId}:${today}`; const monthly = `usage:monthly:${keyId}:${currentMonth}`; + const hourly = `usage:hourly:${keyId}:${currentHour}`; // 新增小时级别key // 按模型统计的键 const modelDaily = `usage:model:daily:${model}:${today}`; const modelMonthly = `usage:model:monthly:${model}:${currentMonth}`; + const modelHourly = `usage:model:hourly:${model}:${currentHour}`; // 新增模型小时级别 // API Key级别的模型统计 const keyModelDaily = `usage:${keyId}:model:daily:${model}:${today}`; const keyModelMonthly = `usage:${keyId}:model:monthly:${model}:${currentMonth}`; + const keyModelHourly = `usage:${keyId}:model:hourly:${model}:${currentHour}`; // 新增API Key模型小时级别 // 智能处理输入输出token分配 const finalInputTokens = inputTokens || 0; @@ -218,13 +224,40 @@ class RedisClient { this.client.hincrby(keyModelMonthly, 'cacheReadTokens', finalCacheReadTokens), this.client.hincrby(keyModelMonthly, 'allTokens', totalTokens), this.client.hincrby(keyModelMonthly, 'requests', 1), + + // 小时级别统计 + this.client.hincrby(hourly, 'tokens', coreTokens), + this.client.hincrby(hourly, 'inputTokens', finalInputTokens), + this.client.hincrby(hourly, 'outputTokens', finalOutputTokens), + this.client.hincrby(hourly, 'cacheCreateTokens', finalCacheCreateTokens), + this.client.hincrby(hourly, 'cacheReadTokens', finalCacheReadTokens), + this.client.hincrby(hourly, 'allTokens', totalTokens), + this.client.hincrby(hourly, 'requests', 1), + // 按模型统计 - 每小时 + this.client.hincrby(modelHourly, 'inputTokens', finalInputTokens), + this.client.hincrby(modelHourly, 'outputTokens', finalOutputTokens), + this.client.hincrby(modelHourly, 'cacheCreateTokens', finalCacheCreateTokens), + this.client.hincrby(modelHourly, 'cacheReadTokens', finalCacheReadTokens), + this.client.hincrby(modelHourly, 'allTokens', totalTokens), + this.client.hincrby(modelHourly, 'requests', 1), + // API Key级别的模型统计 - 每小时 + this.client.hincrby(keyModelHourly, 'inputTokens', finalInputTokens), + this.client.hincrby(keyModelHourly, 'outputTokens', finalOutputTokens), + this.client.hincrby(keyModelHourly, 'cacheCreateTokens', finalCacheCreateTokens), + this.client.hincrby(keyModelHourly, 'cacheReadTokens', finalCacheReadTokens), + this.client.hincrby(keyModelHourly, 'allTokens', totalTokens), + this.client.hincrby(keyModelHourly, 'requests', 1), + // 设置过期时间 this.client.expire(daily, 86400 * 32), // 32天过期 this.client.expire(monthly, 86400 * 365), // 1年过期 + this.client.expire(hourly, 86400 * 7), // 小时统计7天过期 this.client.expire(modelDaily, 86400 * 32), // 模型每日统计32天过期 this.client.expire(modelMonthly, 86400 * 365), // 模型每月统计1年过期 + this.client.expire(modelHourly, 86400 * 7), // 模型小时统计7天过期 this.client.expire(keyModelDaily, 86400 * 32), // API Key模型每日统计32天过期 - this.client.expire(keyModelMonthly, 86400 * 365) // API Key模型每月统计1年过期 + this.client.expire(keyModelMonthly, 86400 * 365), // API Key模型每月统计1年过期 + this.client.expire(keyModelHourly, 86400 * 7) // API Key模型小时统计7天过期 ]); } diff --git a/src/routes/admin.js b/src/routes/admin.js index 24944ed6..703162ba 100644 --- a/src/routes/admin.js +++ b/src/routes/admin.js @@ -351,6 +351,7 @@ router.get('/dashboard', authenticateAdmin, async (req, res) => { const activeApiKeys = apiKeys.filter(key => key.isActive).length; const activeAccounts = accounts.filter(acc => acc.isActive && acc.status === 'active').length; + const rateLimitedAccounts = accounts.filter(acc => acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited).length; const dashboard = { overview: { @@ -358,6 +359,7 @@ router.get('/dashboard', authenticateAdmin, async (req, res) => { activeApiKeys, totalClaudeAccounts: accounts.length, activeClaudeAccounts: activeAccounts, + rateLimitedClaudeAccounts: rateLimitedAccounts, totalTokensUsed, totalRequestsUsed, totalInputTokensUsed, @@ -528,22 +530,140 @@ router.post('/cleanup', authenticateAdmin, async (req, res) => { // 获取使用趋势数据 router.get('/usage-trend', authenticateAdmin, async (req, res) => { try { - const { days = 7 } = req.query; - const daysCount = parseInt(days) || 7; + const { days = 7, granularity = 'day', startDate, endDate } = req.query; const client = redis.getClientSafe(); const trendData = []; - const today = new Date(); - // 获取过去N天的数据 - for (let i = 0; i < daysCount; i++) { - const date = new Date(today); - date.setDate(date.getDate() - i); - const dateStr = date.toISOString().split('T')[0]; + if (granularity === 'hour') { + // 小时粒度统计 + let startTime, endTime; - // 汇总当天所有API Key的使用数据 - const pattern = `usage:daily:*:${dateStr}`; - const keys = await client.keys(pattern); + if (startDate && endDate) { + // 使用自定义时间范围 + startTime = new Date(startDate); + endTime = new Date(endDate); + } else { + // 默认最近24小时 + endTime = new Date(); + startTime = new Date(endTime.getTime() - 24 * 60 * 60 * 1000); + } + + // 确保时间范围不超过24小时 + const timeDiff = endTime - startTime; + if (timeDiff > 24 * 60 * 60 * 1000) { + return res.status(400).json({ + error: '小时粒度查询时间范围不能超过24小时' + }); + } + + // 按小时遍历 + const currentHour = new Date(startTime); + currentHour.setMinutes(0, 0, 0); + + while (currentHour <= endTime) { + const dateStr = currentHour.toISOString().split('T')[0]; + const hour = String(currentHour.getHours()).padStart(2, '0'); + const hourKey = `${dateStr}:${hour}`; + + // 获取当前小时的模型统计数据 + const modelPattern = `usage:model:hourly:*:${hourKey}`; + const modelKeys = await client.keys(modelPattern); + + let hourInputTokens = 0; + let hourOutputTokens = 0; + let hourRequests = 0; + let hourCacheCreateTokens = 0; + let hourCacheReadTokens = 0; + let hourCost = 0; + + for (const modelKey of modelKeys) { + const modelMatch = modelKey.match(/usage:model:hourly:(.+):\d{4}-\d{2}-\d{2}:\d{2}$/); + if (!modelMatch) continue; + + const model = modelMatch[1]; + const data = await client.hgetall(modelKey); + + if (data && Object.keys(data).length > 0) { + const modelInputTokens = parseInt(data.inputTokens) || 0; + const modelOutputTokens = parseInt(data.outputTokens) || 0; + const modelCacheCreateTokens = parseInt(data.cacheCreateTokens) || 0; + const modelCacheReadTokens = parseInt(data.cacheReadTokens) || 0; + const modelRequests = parseInt(data.requests) || 0; + + hourInputTokens += modelInputTokens; + hourOutputTokens += modelOutputTokens; + hourCacheCreateTokens += modelCacheCreateTokens; + hourCacheReadTokens += modelCacheReadTokens; + hourRequests += modelRequests; + + const modelUsage = { + input_tokens: modelInputTokens, + output_tokens: modelOutputTokens, + cache_creation_input_tokens: modelCacheCreateTokens, + cache_read_input_tokens: modelCacheReadTokens + }; + const modelCostResult = CostCalculator.calculateCost(modelUsage, model); + hourCost += modelCostResult.costs.total; + } + } + + // 如果没有模型级别的数据,尝试API Key级别的数据 + if (modelKeys.length === 0) { + const pattern = `usage:hourly:*:${hourKey}`; + const keys = await client.keys(pattern); + + for (const key of keys) { + const data = await client.hgetall(key); + if (data) { + hourInputTokens += parseInt(data.inputTokens) || 0; + hourOutputTokens += parseInt(data.outputTokens) || 0; + hourRequests += parseInt(data.requests) || 0; + hourCacheCreateTokens += parseInt(data.cacheCreateTokens) || 0; + hourCacheReadTokens += parseInt(data.cacheReadTokens) || 0; + } + } + + const usage = { + input_tokens: hourInputTokens, + output_tokens: hourOutputTokens, + cache_creation_input_tokens: hourCacheCreateTokens, + cache_read_input_tokens: hourCacheReadTokens + }; + const costResult = CostCalculator.calculateCost(usage, 'unknown'); + hourCost = costResult.costs.total; + } + + trendData.push({ + date: hourKey, + hour: currentHour.toISOString(), + inputTokens: hourInputTokens, + outputTokens: hourOutputTokens, + requests: hourRequests, + cacheCreateTokens: hourCacheCreateTokens, + cacheReadTokens: hourCacheReadTokens, + totalTokens: hourInputTokens + hourOutputTokens + hourCacheCreateTokens + hourCacheReadTokens, + cost: hourCost + }); + + // 移到下一个小时 + currentHour.setHours(currentHour.getHours() + 1); + } + + } else { + // 天粒度统计(保持原有逻辑) + const daysCount = parseInt(days) || 7; + const today = new Date(); + + // 获取过去N天的数据 + for (let i = 0; i < daysCount; i++) { + const date = new Date(today); + date.setDate(date.getDate() - i); + const dateStr = date.toISOString().split('T')[0]; + + // 汇总当天所有API Key的使用数据 + const pattern = `usage:daily:*:${dateStr}`; + const keys = await client.keys(pattern); let dayInputTokens = 0; let dayOutputTokens = 0; @@ -553,7 +673,7 @@ router.get('/usage-trend', authenticateAdmin, async (req, res) => { let dayCost = 0; // 按模型统计使用量 - const modelUsageMap = new Map(); + // const modelUsageMap = new Map(); // 获取当天所有模型的使用数据 const modelPattern = `usage:model:daily:*:${dateStr}`; @@ -630,10 +750,16 @@ router.get('/usage-trend', authenticateAdmin, async (req, res) => { }); } - // 按日期正序排列 - trendData.sort((a, b) => new Date(a.date) - new Date(b.date)); + } - res.json({ success: true, data: trendData }); + // 按日期正序排列 + if (granularity === 'hour') { + trendData.sort((a, b) => new Date(a.hour) - new Date(b.hour)); + } else { + trendData.sort((a, b) => new Date(a.date) - new Date(b.date)); + } + + res.json({ success: true, data: trendData, granularity }); } catch (error) { logger.error('❌ Failed to get usage trend:', error); res.status(500).json({ error: 'Failed to get usage trend', message: error.message }); @@ -833,6 +959,152 @@ router.get('/api-keys/:keyId/model-stats', authenticateAdmin, async (req, res) = }); +// 获取按API Key分组的使用趋势 +router.get('/api-keys-usage-trend', authenticateAdmin, async (req, res) => { + try { + const { granularity = 'day', days = 7, startDate, endDate } = req.query; + + logger.info(`📊 Getting API keys usage trend, granularity: ${granularity}, days: ${days}`); + + const client = redis.getClientSafe(); + const trendData = []; + + // 获取所有API Keys + const apiKeys = await apiKeyService.getAllApiKeys(); + const apiKeyMap = new Map(apiKeys.map(key => [key.id, key])); + + if (granularity === 'hour') { + // 小时粒度统计 + let endTime, startTime; + + if (startDate && endDate) { + // 自定义时间范围 + startTime = new Date(startDate); + endTime = new Date(endDate); + } else { + // 默认近24小时 + endTime = new Date(); + startTime = new Date(endTime.getTime() - 24 * 60 * 60 * 1000); + } + + // 按小时遍历 + const currentHour = new Date(startTime); + currentHour.setMinutes(0, 0, 0); + + while (currentHour <= endTime) { + const hourKey = currentHour.toISOString().split(':')[0].replace('T', ':'); + + // 获取这个小时所有API Key的数据 + const pattern = `usage:hourly:*:${hourKey}`; + const keys = await client.keys(pattern); + + const hourData = { + hour: currentHour.toISOString(), + apiKeys: {} + }; + + for (const key of keys) { + const match = key.match(/usage:hourly:(.+?):\d{4}-\d{2}-\d{2}:\d{2}/); + if (!match) continue; + + const apiKeyId = match[1]; + const data = await client.hgetall(key); + + if (data && apiKeyMap.has(apiKeyId)) { + const totalTokens = (parseInt(data.inputTokens) || 0) + + (parseInt(data.outputTokens) || 0) + + (parseInt(data.cacheCreateTokens) || 0) + + (parseInt(data.cacheReadTokens) || 0); + + hourData.apiKeys[apiKeyId] = { + name: apiKeyMap.get(apiKeyId).name, + tokens: totalTokens + }; + } + } + + trendData.push(hourData); + currentHour.setHours(currentHour.getHours() + 1); + } + + } else { + // 天粒度统计 + const daysCount = parseInt(days) || 7; + const today = new Date(); + + // 获取过去N天的数据 + for (let i = 0; i < daysCount; i++) { + const date = new Date(today); + date.setDate(date.getDate() - i); + const dateStr = date.toISOString().split('T')[0]; + + // 获取这一天所有API Key的数据 + const pattern = `usage:daily:*:${dateStr}`; + const keys = await client.keys(pattern); + + const dayData = { + date: dateStr, + apiKeys: {} + }; + + for (const key of keys) { + const match = key.match(/usage:daily:(.+?):\d{4}-\d{2}-\d{2}/); + if (!match) continue; + + const apiKeyId = match[1]; + const data = await client.hgetall(key); + + if (data && apiKeyMap.has(apiKeyId)) { + const totalTokens = (parseInt(data.inputTokens) || 0) + + (parseInt(data.outputTokens) || 0) + + (parseInt(data.cacheCreateTokens) || 0) + + (parseInt(data.cacheReadTokens) || 0); + + dayData.apiKeys[apiKeyId] = { + name: apiKeyMap.get(apiKeyId).name, + tokens: totalTokens + }; + } + } + + trendData.push(dayData); + } + } + + // 按时间正序排列 + if (granularity === 'hour') { + trendData.sort((a, b) => new Date(a.hour) - new Date(b.hour)); + } else { + trendData.sort((a, b) => new Date(a.date) - new Date(b.date)); + } + + // 计算每个API Key的总token数,用于排序 + const apiKeyTotals = new Map(); + for (const point of trendData) { + for (const [apiKeyId, data] of Object.entries(point.apiKeys)) { + apiKeyTotals.set(apiKeyId, (apiKeyTotals.get(apiKeyId) || 0) + data.tokens); + } + } + + // 获取前10个使用量最多的API Key + const topApiKeys = Array.from(apiKeyTotals.entries()) + .sort((a, b) => b[1] - a[1]) + .slice(0, 10) + .map(([apiKeyId]) => apiKeyId); + + res.json({ + success: true, + data: trendData, + granularity, + topApiKeys, + totalApiKeys: apiKeyTotals.size + }); + } catch (error) { + logger.error('❌ Failed to get API keys usage trend:', error); + res.status(500).json({ error: 'Failed to get API keys usage trend', message: error.message }); + } +}); + // 计算总体使用费用 router.get('/usage-costs', authenticateAdmin, async (req, res) => { try { diff --git a/src/routes/web.js b/src/routes/web.js index ac2dbbc6..4e6eba68 100644 --- a/src/routes/web.js +++ b/src/routes/web.js @@ -217,7 +217,7 @@ router.post('/auth/change-password', async (req, res) => { try { const initData = JSON.parse(fs.readFileSync(initFilePath, 'utf8')); - const oldData = { ...initData }; // 备份旧数据 + // const oldData = { ...initData }; // 备份旧数据 // 更新 init.json initData.adminUsername = updatedUsername; @@ -252,12 +252,12 @@ router.post('/auth/change-password', async (req, res) => { // 清除当前会话(强制用户重新登录) await redis.deleteSession(token); - logger.success(`🔐 Admin password changed successfully for user: ${updatedAdminData.username}`); + logger.success(`🔐 Admin password changed successfully for user: ${updatedUsername}`); res.json({ success: true, message: 'Password changed successfully. Please login again.', - newUsername: updatedAdminData.username + newUsername: updatedUsername }); } catch (error) { diff --git a/src/services/claudeAccountService.js b/src/services/claudeAccountService.js index 7dce5925..1af25b2a 100644 --- a/src/services/claudeAccountService.js +++ b/src/services/claudeAccountService.js @@ -228,22 +228,35 @@ class ClaudeAccountService { try { const accounts = await redis.getAllClaudeAccounts(); - // 处理返回数据,移除敏感信息 - return accounts.map(account => ({ - id: account.id, - name: account.name, - description: account.description, - email: account.email ? this._maskEmail(this._decryptSensitiveData(account.email)) : '', - isActive: account.isActive === 'true', - proxy: account.proxy ? JSON.parse(account.proxy) : null, - status: account.status, - errorMessage: account.errorMessage, - accountType: account.accountType || 'shared', // 兼容旧数据,默认为共享 - createdAt: account.createdAt, - lastUsedAt: account.lastUsedAt, - lastRefreshAt: account.lastRefreshAt, - expiresAt: account.expiresAt + // 处理返回数据,移除敏感信息并添加限流状态 + const processedAccounts = await Promise.all(accounts.map(async account => { + // 获取限流状态信息 + const rateLimitInfo = await this.getAccountRateLimitInfo(account.id); + + return { + id: account.id, + name: account.name, + description: account.description, + email: account.email ? this._maskEmail(this._decryptSensitiveData(account.email)) : '', + isActive: account.isActive === 'true', + proxy: account.proxy ? JSON.parse(account.proxy) : null, + status: account.status, + errorMessage: account.errorMessage, + accountType: account.accountType || 'shared', // 兼容旧数据,默认为共享 + createdAt: account.createdAt, + lastUsedAt: account.lastUsedAt, + lastRefreshAt: account.lastRefreshAt, + expiresAt: account.expiresAt, + // 添加限流状态信息 + rateLimitStatus: rateLimitInfo ? { + isRateLimited: rateLimitInfo.isRateLimited, + rateLimitedAt: rateLimitInfo.rateLimitedAt, + minutesRemaining: rateLimitInfo.minutesRemaining + } : null + }; })); + + return processedAccounts; } catch (error) { logger.error('❌ Failed to get Claude accounts:', error); throw error; @@ -405,8 +418,15 @@ class ClaudeAccountService { // 验证映射的账户是否仍然在共享池中且可用 const mappedAccount = sharedAccounts.find(acc => acc.id === mappedAccountId); if (mappedAccount) { - logger.info(`🎯 Using sticky session shared account: ${mappedAccount.name} (${mappedAccountId}) for session ${sessionHash}`); - return mappedAccountId; + // 如果映射的账户被限流了,删除映射并重新选择 + const isRateLimited = await this.isAccountRateLimited(mappedAccountId); + if (isRateLimited) { + logger.warn(`⚠️ Mapped account ${mappedAccountId} is rate limited, selecting new account`); + await redis.deleteSessionAccountMapping(sessionHash); + } else { + logger.info(`🎯 Using sticky session shared account: ${mappedAccount.name} (${mappedAccountId}) for session ${sessionHash}`); + return mappedAccountId; + } } else { logger.warn(`⚠️ Mapped shared account ${mappedAccountId} is no longer available, selecting new account`); // 清理无效的映射 @@ -415,21 +435,54 @@ class ClaudeAccountService { } } - // 从共享池选择账户(负载均衡) - const sortedAccounts = sharedAccounts.sort((a, b) => { - const aLastRefresh = new Date(a.lastRefreshAt || 0).getTime(); - const bLastRefresh = new Date(b.lastRefreshAt || 0).getTime(); - return bLastRefresh - aLastRefresh; - }); - const selectedAccountId = sortedAccounts[0].id; + // 将账户分为限流和非限流两组 + const nonRateLimitedAccounts = []; + const rateLimitedAccounts = []; + + for (const account of sharedAccounts) { + const isRateLimited = await this.isAccountRateLimited(account.id); + if (isRateLimited) { + const rateLimitInfo = await this.getAccountRateLimitInfo(account.id); + account._rateLimitInfo = rateLimitInfo; // 临时存储限流信息 + rateLimitedAccounts.push(account); + } else { + nonRateLimitedAccounts.push(account); + } + } + + // 优先从非限流账户中选择 + let candidateAccounts = nonRateLimitedAccounts; + + // 如果没有非限流账户,则从限流账户中选择(按限流时间排序,最早限流的优先) + if (candidateAccounts.length === 0) { + logger.warn('⚠️ All shared accounts are rate limited, selecting from rate limited pool'); + candidateAccounts = rateLimitedAccounts.sort((a, b) => { + const aRateLimitedAt = new Date(a._rateLimitInfo.rateLimitedAt).getTime(); + const bRateLimitedAt = new Date(b._rateLimitInfo.rateLimitedAt).getTime(); + 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; + }); + } + + if (candidateAccounts.length === 0) { + throw new Error('No available shared Claude accounts'); + } + + const selectedAccountId = candidateAccounts[0].id; // 如果有会话哈希,建立新的映射 if (sessionHash) { await redis.setSessionAccountMapping(sessionHash, selectedAccountId, 3600); // 1小时过期 - logger.info(`🎯 Created new sticky session mapping for shared account: ${sortedAccounts[0].name} (${selectedAccountId}) for session ${sessionHash}`); + logger.info(`🎯 Created new sticky session mapping for shared account: ${candidateAccounts[0].name} (${selectedAccountId}) for session ${sessionHash}`); } - logger.info(`🎯 Selected shared account: ${sortedAccounts[0].name} (${selectedAccountId}) for API key ${apiKeyData.name}`); + logger.info(`🎯 Selected shared account: ${candidateAccounts[0].name} (${selectedAccountId}) for API key ${apiKeyData.name}`); return selectedAccountId; } catch (error) { logger.error('❌ Failed to select account for API key:', error); @@ -570,6 +623,118 @@ class ClaudeAccountService { return 0; } } + + // 🚫 标记账号为限流状态 + async markAccountRateLimited(accountId, sessionHash = null) { + try { + const accountData = await redis.getClaudeAccount(accountId); + if (!accountData || Object.keys(accountData).length === 0) { + throw new Error('Account not found'); + } + + // 设置限流状态和时间 + accountData.rateLimitedAt = new Date().toISOString(); + accountData.rateLimitStatus = 'limited'; + await redis.setClaudeAccount(accountId, accountData); + + // 如果有会话哈希,删除粘性会话映射 + if (sessionHash) { + await redis.deleteSessionAccountMapping(sessionHash); + logger.info(`🗑️ Deleted sticky session mapping for rate limited account: ${accountId}`); + } + + logger.warn(`🚫 Account marked as rate limited: ${accountData.name} (${accountId})`); + return { success: true }; + } catch (error) { + logger.error(`❌ Failed to mark account as rate limited: ${accountId}`, error); + throw error; + } + } + + // ✅ 移除账号的限流状态 + async removeAccountRateLimit(accountId) { + try { + const accountData = await redis.getClaudeAccount(accountId); + if (!accountData || Object.keys(accountData).length === 0) { + throw new Error('Account not found'); + } + + // 清除限流状态 + delete accountData.rateLimitedAt; + delete accountData.rateLimitStatus; + await redis.setClaudeAccount(accountId, accountData); + + logger.success(`✅ Rate limit removed for account: ${accountData.name} (${accountId})`); + return { success: true }; + } catch (error) { + logger.error(`❌ Failed to remove rate limit for account: ${accountId}`, error); + throw error; + } + } + + // 🔍 检查账号是否处于限流状态 + async isAccountRateLimited(accountId) { + try { + const accountData = await redis.getClaudeAccount(accountId); + if (!accountData || Object.keys(accountData).length === 0) { + return false; + } + + // 检查是否有限流状态 + if (accountData.rateLimitStatus === 'limited' && accountData.rateLimitedAt) { + const rateLimitedAt = new Date(accountData.rateLimitedAt); + const now = new Date(); + const hoursSinceRateLimit = (now - rateLimitedAt) / (1000 * 60 * 60); + + // 如果限流超过1小时,自动解除 + if (hoursSinceRateLimit >= 1) { + await this.removeAccountRateLimit(accountId); + return false; + } + + return true; + } + + return false; + } catch (error) { + logger.error(`❌ Failed to check rate limit status for account: ${accountId}`, error); + return false; + } + } + + // 📊 获取账号的限流信息 + async getAccountRateLimitInfo(accountId) { + try { + const accountData = await redis.getClaudeAccount(accountId); + if (!accountData || Object.keys(accountData).length === 0) { + return null; + } + + if (accountData.rateLimitStatus === 'limited' && accountData.rateLimitedAt) { + const rateLimitedAt = new Date(accountData.rateLimitedAt); + const now = new Date(); + const minutesSinceRateLimit = Math.floor((now - rateLimitedAt) / (1000 * 60)); + const minutesRemaining = Math.max(0, 60 - minutesSinceRateLimit); + + return { + isRateLimited: minutesRemaining > 0, + rateLimitedAt: accountData.rateLimitedAt, + minutesSinceRateLimit, + minutesRemaining + }; + } + + return { + isRateLimited: false, + rateLimitedAt: null, + minutesSinceRateLimit: 0, + minutesRemaining: 0 + }; + } catch (error) { + logger.error(`❌ Failed to get rate limit info for account: ${accountId}`, error); + return null; + } + } } module.exports = new ClaudeAccountService(); \ No newline at end of file diff --git a/src/services/claudeRelayService.js b/src/services/claudeRelayService.js index 7d144dcf..5161b1cd 100644 --- a/src/services/claudeRelayService.js +++ b/src/services/claudeRelayService.js @@ -72,6 +72,35 @@ class ClaudeRelayService { clientResponse.removeListener('close', handleClientDisconnect); } + // 检查响应是否为限流错误 + if (response.statusCode !== 200 && response.statusCode !== 201) { + let isRateLimited = false; + try { + const responseBody = typeof response.body === 'string' ? JSON.parse(response.body) : response.body; + if (responseBody && responseBody.error && responseBody.error.message && + responseBody.error.message.toLowerCase().includes('exceed your account\'s rate limit')) { + isRateLimited = true; + } + } catch (e) { + // 如果解析失败,检查原始字符串 + if (response.body && response.body.toLowerCase().includes('exceed your account\'s rate limit')) { + isRateLimited = true; + } + } + + if (isRateLimited) { + logger.warn(`🚫 Rate limit detected for account ${accountId}, status: ${response.statusCode}`); + // 标记账号为限流状态并删除粘性会话映射 + await claudeAccountService.markAccountRateLimited(accountId, sessionHash); + } + } else if (response.statusCode === 200 || response.statusCode === 201) { + // 如果请求成功,检查并移除限流状态 + const isRateLimited = await claudeAccountService.isAccountRateLimited(accountId); + if (isRateLimited) { + await claudeAccountService.removeAccountRateLimit(accountId); + } + } + // 记录成功的API调用 const inputTokens = requestBody.messages ? requestBody.messages.reduce((sum, msg) => sum + (msg.content?.length || 0), 0) / 4 : 0; // 粗略估算 @@ -408,7 +437,7 @@ class ClaudeRelayService { const proxyAgent = await this._getProxyAgent(accountId); // 发送流式请求并捕获usage数据 - return await this._makeClaudeStreamRequestWithUsageCapture(processedBody, accessToken, proxyAgent, clientHeaders, responseStream, usageCallback); + return await this._makeClaudeStreamRequestWithUsageCapture(processedBody, accessToken, proxyAgent, clientHeaders, responseStream, usageCallback, accountId, sessionHash); } catch (error) { logger.error('❌ Claude stream relay with usage capture failed:', error); throw error; @@ -416,7 +445,7 @@ class ClaudeRelayService { } // 🌊 发送流式请求到Claude API(带usage数据捕获) - async _makeClaudeStreamRequestWithUsageCapture(body, accessToken, proxyAgent, clientHeaders, responseStream, usageCallback) { + async _makeClaudeStreamRequestWithUsageCapture(body, accessToken, proxyAgent, clientHeaders, responseStream, usageCallback, accountId, sessionHash) { return new Promise((resolve, reject) => { const url = new URL(this.claudeApiUrl); @@ -457,6 +486,7 @@ class ClaudeRelayService { let buffer = ''; let finalUsageReported = false; // 防止重复统计的标志 let collectedUsageData = {}; // 收集来自不同事件的usage数据 + let rateLimitDetected = false; // 限流检测标志 // 监听数据块,解析SSE并寻找usage信息 res.on('data', (chunk) => { @@ -517,6 +547,13 @@ class ClaudeRelayService { } } + // 检查是否有限流错误 + if (data.type === 'error' && data.error && data.error.message && + data.error.message.toLowerCase().includes('exceed your account\'s rate limit')) { + rateLimitDetected = true; + logger.warn(`🚫 Rate limit detected in stream for account ${accountId}`); + } + } catch (parseError) { // 忽略JSON解析错误,继续处理 logger.debug('🔍 SSE line not JSON or no usage data:', line.slice(0, 100)); @@ -525,7 +562,7 @@ class ClaudeRelayService { } }); - res.on('end', () => { + res.on('end', async () => { // 处理缓冲区中剩余的数据 if (buffer.trim()) { responseStream.write(buffer); @@ -537,6 +574,18 @@ class ClaudeRelayService { logger.warn('⚠️ Stream completed but no usage data was captured! This indicates a problem with SSE parsing or Claude API response format.'); } + // 处理限流状态 + if (rateLimitDetected || res.statusCode === 429) { + // 标记账号为限流状态并删除粘性会话映射 + await claudeAccountService.markAccountRateLimited(accountId, sessionHash); + } else if (res.statusCode === 200) { + // 如果请求成功,检查并移除限流状态 + const isRateLimited = await claudeAccountService.isAccountRateLimited(accountId); + if (isRateLimited) { + await claudeAccountService.removeAccountRateLimit(accountId); + } + } + logger.debug('🌊 Claude stream response with usage capture completed'); resolve(); }); diff --git a/web/admin/app.js b/web/admin/app.js index 6f7f10fe..999f84e0 100644 --- a/web/admin/app.js +++ b/web/admin/app.js @@ -77,6 +77,15 @@ const app = createApp({ usageTrendChart: null, trendPeriod: 7, trendData: [], + trendGranularity: 'day', // 新增:趋势图粒度(day/hour) + + // API Keys 使用趋势 + apiKeysUsageTrendChart: null, + apiKeysTrendData: { + data: [], + topApiKeys: [], + totalApiKeys: 0 + }, // 统一的日期筛选 dateFilter: { @@ -91,6 +100,10 @@ const app = createApp({ { value: '30days', label: '近30天', days: 30 } ] }, + defaultTime: [ + new Date(2000, 1, 1, 0, 0, 0), + new Date(2000, 2, 1, 23, 59, 59), + ], showDateRangePicker: false, // 日期范围选择器显示状态 dateRangeInputValue: '', // 日期范围显示文本 @@ -247,8 +260,11 @@ const app = createApp({ // 初始化日期筛选器和图表数据 this.initializeDateFilter(); - // 预加载账号列表,以便在API Keys页面能正确显示绑定账号名称 - this.loadAccounts().then(() => { + // 预加载账号列表和API Keys,以便正确显示绑定关系 + Promise.all([ + this.loadAccounts(), + this.loadApiKeys() + ]).then(() => { // 根据当前活跃标签页加载数据 this.loadCurrentTabData(); }); @@ -257,6 +273,7 @@ const app = createApp({ this.waitForChartJS().then(() => { this.loadDashboardModelStats(); this.loadUsageTrend(); + this.loadApiKeysUsageTrend(); }); } } else { @@ -422,6 +439,10 @@ const app = createApp({ // 验证账户类型切换 if (this.editAccountForm.accountType === 'shared' && this.editAccountForm.originalAccountType === 'dedicated') { + // 确保API Keys数据已加载,以便正确计算绑定数量 + if (this.apiKeys.length === 0) { + await this.loadApiKeys(); + } const boundKeysCount = this.getBoundApiKeysCount(this.editAccountForm.id); if (boundKeysCount > 0) { this.showToast(`无法切换到共享账户,该账户绑定了 ${boundKeysCount} 个API Key,请先解绑所有API Key`, 'error', '切换失败'); @@ -756,6 +777,7 @@ const app = createApp({ this.waitForChartJS().then(() => { this.loadDashboardModelStats(); this.loadUsageTrend(); + this.loadApiKeysUsageTrend(); }); break; case 'apiKeys': @@ -766,7 +788,11 @@ const app = createApp({ ]); break; case 'accounts': - this.loadAccounts(); + // 加载账户时同时加载API Keys,以便正确计算绑定数量 + Promise.all([ + this.loadAccounts(), + this.loadApiKeys() + ]); break; case 'models': this.loadModelStats(); @@ -819,6 +845,19 @@ const app = createApp({ } this.usageTrendChart = null; } + + // 清理API Keys使用趋势图表 + if (this.apiKeysUsageTrendChart) { + try { + // 先停止所有动画 + this.apiKeysUsageTrendChart.stop(); + // 再销毁图表 + this.apiKeysUsageTrendChart.destroy(); + } catch (error) { + console.warn('Error destroying API keys usage trend chart:', error); + } + this.apiKeysUsageTrendChart = null; + } }, // 检查DOM元素是否存在且有效 @@ -1017,6 +1056,7 @@ const app = createApp({ activeApiKeys: overview.activeApiKeys || 0, totalAccounts: overview.totalClaudeAccounts || 0, activeAccounts: overview.activeClaudeAccounts || 0, + rateLimitedAccounts: overview.rateLimitedClaudeAccounts || 0, todayRequests: recentActivity.requestsToday || 0, totalRequests: overview.totalRequestsUsed || 0, todayTokens: recentActivity.tokensToday || 0, @@ -1263,6 +1303,11 @@ const app = createApp({ }, async deleteAccount(accountId) { + // 确保API Keys数据已加载,以便正确计算绑定数量 + if (this.apiKeys.length === 0) { + await this.loadApiKeys(); + } + // 检查是否有API Key绑定到此账号 const boundKeysCount = this.getBoundApiKeysCount(accountId); if (boundKeysCount > 0) { @@ -1529,11 +1574,68 @@ const app = createApp({ await this.loadUsageTrend(); }, + // 加载API Keys使用趋势数据 + async loadApiKeysUsageTrend() { + console.log('Loading API keys usage trend data, granularity:', this.trendGranularity); + try { + let url = '/admin/api-keys-usage-trend?'; + + if (this.trendGranularity === 'hour') { + // 小时粒度,传递开始和结束时间 + url += `granularity=hour`; + if (this.dateFilter.customRange && this.dateFilter.customRange.length === 2) { + url += `&startDate=${encodeURIComponent(this.dateFilter.customRange[0])}`; + url += `&endDate=${encodeURIComponent(this.dateFilter.customRange[1])}`; + } + } else { + // 天粒度,传递天数 + url += `granularity=day&days=${this.trendPeriod}`; + } + + const response = await fetch(url, { + headers: { 'Authorization': 'Bearer ' + this.authToken } + }); + + if (!response.ok) { + console.error('API keys usage trend API error:', response.status, response.statusText); + return; + } + + const data = await response.json(); + + if (data.success) { + this.apiKeysTrendData = { + data: data.data || [], + topApiKeys: data.topApiKeys || [], + totalApiKeys: data.totalApiKeys || 0 + }; + console.log('Loaded API keys trend data:', this.apiKeysTrendData); + this.updateApiKeysUsageTrendChart(); + } + } catch (error) { + console.error('Failed to load API keys usage trend:', error); + } + }, + // 加载使用趋势数据 async loadUsageTrend() { - console.log('Loading usage trend data, period:', this.trendPeriod, 'authToken:', !!this.authToken); + console.log('Loading usage trend data, period:', this.trendPeriod, 'granularity:', this.trendGranularity, 'authToken:', !!this.authToken); try { - const response = await fetch('/admin/usage-trend?days=' + this.trendPeriod, { + let url = '/admin/usage-trend?'; + + if (this.trendGranularity === 'hour') { + // 小时粒度,传递开始和结束时间 + url += `granularity=hour`; + if (this.dateFilter.customRange && this.dateFilter.customRange.length === 2) { + url += `&startDate=${encodeURIComponent(this.dateFilter.customRange[0])}`; + url += `&endDate=${encodeURIComponent(this.dateFilter.customRange[1])}`; + } + } else { + // 天粒度,传递天数 + url += `granularity=day&days=${this.trendPeriod}`; + } + + const response = await fetch(url, { headers: { 'Authorization': 'Bearer ' + this.authToken } }); @@ -1601,7 +1703,23 @@ const app = createApp({ return; } - const labels = this.trendData.map(item => item.date); + // 根据粒度格式化标签 + const labels = this.trendData.map(item => { + if (this.trendGranularity === 'hour') { + // 小时粒度:从hour字段提取时间 + if (item.hour) { + const date = new Date(item.hour); + return `${String(date.getHours()).padStart(2, '0')}:00`; + } + // 后备方案:从date字段解析 + const [, time] = item.date.split(':'); + return `${time}:00`; + } else { + // 天粒度:显示日期 + return item.date; + } + }); + const inputData = this.trendData.map(item => item.inputTokens || 0); const outputData = this.trendData.map(item => item.outputTokens || 0); const cacheCreateData = this.trendData.map(item => item.cacheCreateTokens || 0); @@ -1676,6 +1794,19 @@ const app = createApp({ intersect: false, }, scales: { + x: { + type: 'category', + display: true, + title: { + display: true, + text: this.trendGranularity === 'hour' ? '时间' : '日期' + }, + ticks: { + autoSkip: true, + maxRotation: this.trendGranularity === 'hour' ? 45 : 0, + minRotation: this.trendGranularity === 'hour' ? 45 : 0 + } + }, y: { type: 'linear', display: true, @@ -1711,6 +1842,25 @@ const app = createApp({ mode: 'index', intersect: false, callbacks: { + title: (tooltipItems) => { + if (tooltipItems.length === 0) return ''; + const index = tooltipItems[0].dataIndex; + const item = this.trendData[index]; + + if (this.trendGranularity === 'hour' && item.hour) { + // 小时粒度:显示完整的日期时间 + const date = new Date(item.hour); + return date.toLocaleString('zh-CN', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit' + }); + } + // 天粒度:保持原有标签 + return tooltipItems[0].label; + }, label: function(context) { const label = context.dataset.label || ''; let value = context.parsed.y; @@ -1739,6 +1889,178 @@ const app = createApp({ } }, + // 更新API Keys使用趋势图 + updateApiKeysUsageTrendChart() { + // 检查Chart.js是否已加载 + if (typeof Chart === 'undefined') { + console.warn('Chart.js not loaded yet, retrying...'); + setTimeout(() => this.updateApiKeysUsageTrendChart(), 500); + return; + } + + // 严格检查DOM元素是否有效 + if (!this.isElementValid('apiKeysUsageTrendChart')) { + console.error('API keys usage trend chart canvas element not found or invalid'); + return; + } + + const ctx = document.getElementById('apiKeysUsageTrendChart'); + + // 安全销毁现有图表 + if (this.apiKeysUsageTrendChart) { + try { + this.apiKeysUsageTrendChart.destroy(); + } catch (error) { + console.warn('Error destroying API keys usage trend chart:', error); + } + this.apiKeysUsageTrendChart = null; + } + + // 如果没有数据,不创建图表 + if (!this.apiKeysTrendData.data || this.apiKeysTrendData.data.length === 0) { + console.warn('No API keys trend data available, skipping chart creation'); + return; + } + + // 准备数据 + const labels = this.apiKeysTrendData.data.map(item => { + if (this.trendGranularity === 'hour') { + const date = new Date(item.hour); + return `${String(date.getHours()).padStart(2, '0')}:00`; + } + return item.date; + }); + + // 获取所有API Key的数据集 + const datasets = []; + const colors = [ + 'rgb(102, 126, 234)', + 'rgb(240, 147, 251)', + 'rgb(59, 130, 246)', + 'rgb(147, 51, 234)', + 'rgb(34, 197, 94)', + 'rgb(251, 146, 60)', + 'rgb(239, 68, 68)', + 'rgb(16, 185, 129)', + 'rgb(245, 158, 11)', + 'rgb(236, 72, 153)' + ]; + + // 只显示前10个使用量最多的API Key + this.apiKeysTrendData.topApiKeys.forEach((apiKeyId, index) => { + const data = this.apiKeysTrendData.data.map(item => { + return item.apiKeys[apiKeyId] ? item.apiKeys[apiKeyId].tokens : 0; + }); + + // 获取API Key名称 + const apiKeyName = this.apiKeysTrendData.data.find(item => + item.apiKeys[apiKeyId] + )?.apiKeys[apiKeyId]?.name || `API Key ${apiKeyId}`; + + datasets.push({ + label: apiKeyName, + data: data, + borderColor: colors[index % colors.length], + backgroundColor: colors[index % colors.length] + '20', + tension: 0.3, + fill: false + }); + }); + + try { + // 最后一次检查元素有效性 + if (!this.isElementValid('apiKeysUsageTrendChart')) { + throw new Error('Canvas element is not valid for chart creation'); + } + + this.apiKeysUsageTrendChart = new Chart(ctx, { + type: 'line', + data: { + labels: labels, + datasets: datasets + }, + options: { + responsive: true, + maintainAspectRatio: false, + animation: false, // 禁用动画防止异步渲染问题 + interaction: { + mode: 'index', + intersect: false, + }, + scales: { + x: { + type: 'category', + display: true, + title: { + display: true, + text: this.trendGranularity === 'hour' ? '时间' : '日期' + }, + ticks: { + autoSkip: true, + maxRotation: this.trendGranularity === 'hour' ? 45 : 0, + minRotation: this.trendGranularity === 'hour' ? 45 : 0 + } + }, + y: { + type: 'linear', + display: true, + position: 'left', + title: { + display: true, + text: 'Token 数量' + }, + ticks: { + callback: function(value) { + return value.toLocaleString(); + } + } + } + }, + plugins: { + legend: { + position: 'top', + labels: { + usePointStyle: true, + padding: 15 + } + }, + tooltip: { + mode: 'index', + intersect: false, + callbacks: { + title: (tooltipItems) => { + if (tooltipItems.length === 0) return ''; + const index = tooltipItems[0].dataIndex; + const item = this.apiKeysTrendData.data[index]; + + if (this.trendGranularity === 'hour' && item.hour) { + const date = new Date(item.hour); + return date.toLocaleString('zh-CN', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit' + }); + } + return tooltipItems[0].label; + }, + label: function(context) { + const label = context.dataset.label || ''; + const value = context.parsed.y; + return label + ': ' + value.toLocaleString() + ' tokens'; + } + } + } + } + } + }); + } catch (error) { + console.error('Error creating API keys usage trend chart:', error); + this.apiKeysUsageTrendChart = null; + } + }, + // 切换API Key模型统计展开状态 toggleApiKeyModelStats(keyId) { if (!keyId) { @@ -1933,20 +2255,51 @@ const app = createApp({ // 根据预设计算并设置自定义时间框的值 const option = this.dateFilter.presetOptions.find(opt => opt.value === preset); if (option) { - const today = new Date(); - const startDate = new Date(today); - startDate.setDate(today.getDate() - (option.days - 1)); + const now = new Date(); + let startDate, endDate; + + if (this.trendGranularity === 'hour') { + // 小时粒度的预设处理 + if (preset === 'last24h') { + endDate = new Date(now); + startDate = new Date(now.getTime() - 24 * 60 * 60 * 1000); + } else if (preset === 'yesterday') { + // 昨天的00:00到23:59 + startDate = new Date(now); + startDate.setDate(startDate.getDate() - 1); + startDate.setHours(0, 0, 0, 0); + endDate = new Date(startDate); + endDate.setHours(23, 59, 59, 999); + } else if (preset === 'dayBefore') { + // 前天的00:00到23:59 + startDate = new Date(now); + startDate.setDate(startDate.getDate() - 2); + startDate.setHours(0, 0, 0, 0); + endDate = new Date(startDate); + endDate.setHours(23, 59, 59, 999); + } + } else { + // 天粒度的预设处理(保持原有逻辑) + endDate = new Date(now); + startDate = new Date(now); + startDate.setDate(now.getDate() - (option.days - 1)); + startDate.setHours(0, 0, 0, 0); + endDate.setHours(23, 59, 59, 999); + } // 格式化为 Element Plus 需要的格式 const formatDate = (date) => { return date.getFullYear() + '-' + String(date.getMonth() + 1).padStart(2, '0') + '-' + - String(date.getDate()).padStart(2, '0') + ' 00:00:00'; + String(date.getDate()).padStart(2, '0') + ' ' + + String(date.getHours()).padStart(2, '0') + ':' + + String(date.getMinutes()).padStart(2, '0') + ':' + + String(date.getSeconds()).padStart(2, '0'); }; this.dateFilter.customRange = [ formatDate(startDate), - formatDate(today) + formatDate(endDate) ]; } @@ -2105,6 +2458,61 @@ const app = createApp({ // 重新加载数据 this.loadDashboardModelStats(); this.loadUsageTrend(); + this.loadApiKeysUsageTrend(); + }, + + // 设置趋势图粒度 + setTrendGranularity(granularity) { + console.log('Setting trend granularity to:', granularity); + this.trendGranularity = granularity; + + // 根据粒度更新预设选项 + if (granularity === 'hour') { + this.dateFilter.presetOptions = [ + { value: 'last24h', label: '近24小时', hours: 24 }, + { value: 'yesterday', label: '昨天', hours: 24 }, + { value: 'dayBefore', label: '前天', hours: 24 } + ]; + + // 检查当前自定义日期范围是否超过24小时 + if (this.dateFilter.type === 'custom' && this.dateFilter.customRange && this.dateFilter.customRange.length === 2) { + const start = new Date(this.dateFilter.customRange[0]); + const end = new Date(this.dateFilter.customRange[1]); + const hoursDiff = (end - start) / (1000 * 60 * 60); + + if (hoursDiff > 24) { + this.showToast('切换到小时粒度,日期范围已调整为近24小时', 'info'); + this.dateFilter.preset = 'last24h'; + this.setDateFilterPreset('last24h'); + } + } else if (['today', '7days', '30days'].includes(this.dateFilter.preset)) { + // 预设不兼容,切换到近24小时 + this.dateFilter.preset = 'last24h'; + this.setDateFilterPreset('last24h'); + } + } else { + // 恢复天粒度的选项 + this.dateFilter.presetOptions = [ + { value: 'today', label: '今天', days: 1 }, + { value: '7days', label: '近7天', days: 7 }, + { value: '30days', label: '近30天', days: 30 } + ]; + + // 如果当前是小时粒度的预设,切换到天粒度的默认预设 + if (['last24h', 'yesterday', 'dayBefore'].includes(this.dateFilter.preset)) { + this.dateFilter.preset = '7days'; + this.setDateFilterPreset('7days'); + } else if (this.dateFilter.type === 'custom') { + // 自定义日期范围在天粒度下通常不需要调整,因为24小时肯定在31天内 + // 只需要重新加载数据 + this.refreshChartsData(); + return; + } + } + + // 重新加载数据 + this.loadUsageTrend(); + this.loadApiKeysUsageTrend(); }, // API Keys 日期筛选方法 @@ -2293,22 +2701,47 @@ const app = createApp({ // 检查日期范围限制 const start = new Date(value[0]); const end = new Date(value[1]); - const daysDiff = Math.ceil((end - start) / (1000 * 60 * 60 * 24)) + 1; - if (daysDiff > 31) { - this.showToast('日期范围不能超过31天', 'warning', '范围限制'); - // 重置为默认7天 - this.dateFilter.customRange = null; - this.dateFilter.type = 'preset'; - this.dateFilter.preset = '7days'; - return; + if (this.trendGranularity === 'hour') { + // 小时粒度:限制24小时 + const hoursDiff = (end - start) / (1000 * 60 * 60); + if (hoursDiff > 24) { + this.showToast('小时粒度下日期范围不能超过24小时', 'warning', '范围限制'); + // 调整结束时间为开始时间后24小时 + const newEnd = new Date(start.getTime() + 24 * 60 * 60 * 1000); + const formatDate = (date) => { + return date.getFullYear() + '-' + + String(date.getMonth() + 1).padStart(2, '0') + '-' + + String(date.getDate()).padStart(2, '0') + ' ' + + String(date.getHours()).padStart(2, '0') + ':' + + String(date.getMinutes()).padStart(2, '0') + ':' + + String(date.getSeconds()).padStart(2, '0'); + }; + this.dateFilter.customRange = [ + formatDate(start), + formatDate(newEnd) + ]; + this.dateFilter.customEnd = newEnd.toISOString().split('T')[0]; + return; + } + } else { + // 天粒度:限制31天 + const daysDiff = Math.ceil((end - start) / (1000 * 60 * 60 * 24)) + 1; + if (daysDiff > 31) { + this.showToast('日期范围不能超过31天', 'warning', '范围限制'); + // 重置为默认7天 + this.dateFilter.customRange = null; + this.dateFilter.type = 'preset'; + this.dateFilter.preset = '7days'; + return; + } } this.refreshChartsData(); } else if (value === null) { // 清空时恢复默认 this.dateFilter.type = 'preset'; - this.dateFilter.preset = '7days'; + this.dateFilter.preset = this.trendGranularity === 'hour' ? 'last24h' : '7days'; this.dateFilter.customStart = ''; this.dateFilter.customEnd = ''; this.refreshChartsData(); diff --git a/web/admin/index.html b/web/admin/index.html index bd0fa72e..6bdc9716 100644 --- a/web/admin/index.html +++ b/web/admin/index.html @@ -160,7 +160,12 @@
Claude账户
{{ dashboardData.totalAccounts }}
-活跃: {{ dashboardData.activeAccounts || 0 }}
++ 活跃: {{ dashboardData.activeAccounts || 0 }} + + | 限流: {{ dashboardData.rateLimitedAccounts }} + +