Merge remote-tracking branch 'origin/main' into dev

This commit is contained in:
shaw
2025-07-28 09:30:33 +08:00
45 changed files with 9066 additions and 553 deletions

View File

@@ -55,3 +55,6 @@ WEB_LOGO_URL=/assets/logo.png
DEBUG=false DEBUG=false
ENABLE_CORS=true ENABLE_CORS=true
TRUST_PROXY=true TRUST_PROXY=true
# 🔒 客户端限制(可选)
# ALLOW_CUSTOM_CLIENTS=false

View File

@@ -1,21 +1,19 @@
name: Release on Version Change name: Auto Release Pipeline
on: on:
push: push:
branches: branches:
- main - main
paths:
- 'VERSION'
permissions: permissions:
contents: write contents: write
packages: write packages: write
jobs: jobs:
check-and-release: release-pipeline:
runs-on: ubuntu-latest runs-on: ubuntu-latest
# 只处理由GitHub Actions提交的VERSION更新 # 跳过由GitHub Actions创建的提交,避免死循环
if: github.event.pusher.name == 'github-actions[bot]' if: github.event.pusher.name != 'github-actions[bot]' && !contains(github.event.head_commit.message, '[skip ci]')
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v4 uses: actions/checkout@v4
@@ -23,29 +21,103 @@ jobs:
fetch-depth: 0 fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }} token: ${{ secrets.GITHUB_TOKEN }}
- name: Verify only VERSION changed - name: Check if version bump is needed
id: verify id: check
run: | run: |
# 获取最后一次提交变更的文件 # 获取当前提交的文件变更
CHANGED_FILES=$(git diff --name-only HEAD~1..HEAD) 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: $CHANGED_FILES" echo "Changed files:"
echo "$CHANGED_FILES"
# 检查是否只有VERSION文件 # 检查是否只有无关文件(.md, docs/, .github/等)
if [ "$CHANGED_FILES" = "VERSION" ]; then SIGNIFICANT_CHANGES=false
echo "Only VERSION file changed, proceeding with release" while IFS= read -r file; do
echo "should_release=true" >> $GITHUB_OUTPUT # 跳过空行
[ -z "$file" ] && continue
# 读取新版本号 # 检查是否是需要忽略的文件
NEW_VERSION=$(cat VERSION | tr -d '[:space:]') if [[ ! "$file" =~ \.(md|txt)$ ]] &&
echo "new_version=$NEW_VERSION" >> $GITHUB_OUTPUT [[ ! "$file" =~ ^docs/ ]] &&
echo "new_tag=v$NEW_VERSION" >> $GITHUB_OUTPUT [[ ! "$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 else
echo "Other files changed besides VERSION, skipping release" echo "No significant changes, skipping version bump"
echo "should_release=false" >> $GITHUB_OUTPUT echo "needs_bump=false" >> $GITHUB_OUTPUT
fi fi
- name: Get current version
if: steps.check.outputs.needs_bump == 'true'
id: get_version
run: |
# 获取最新的tag版本
LATEST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "v0.0.0")
echo "Latest tag: $LATEST_TAG"
TAG_VERSION=${LATEST_TAG#v}
# 获取VERSION文件中的版本
FILE_VERSION=$(cat VERSION | tr -d '[:space:]')
echo "VERSION file: $FILE_VERSION"
# 比较tag版本和文件版本取较大值
function version_gt() { test "$(printf '%s\n' "$@" | sort -V | head -n 1)" != "$1"; }
if version_gt "$FILE_VERSION" "$TAG_VERSION"; then
VERSION="$FILE_VERSION"
echo "Using VERSION file: $VERSION (newer than tag)"
else
VERSION="$TAG_VERSION"
echo "Using tag version: $VERSION (newer or equal to file)"
fi
echo "Current version: $VERSION"
echo "current_version=$VERSION" >> $GITHUB_OUTPUT
- name: Calculate next version
if: steps.check.outputs.needs_bump == 'true'
id: next_version
run: |
VERSION="${{ steps.get_version.outputs.current_version }}"
# 分割版本号
IFS='.' read -r -a version_parts <<< "$VERSION"
MAJOR="${version_parts[0]:-0}"
MINOR="${version_parts[1]:-0}"
PATCH="${version_parts[2]:-0}"
# 默认递增patch版本
NEW_PATCH=$((PATCH + 1))
NEW_VERSION="${MAJOR}.${MINOR}.${NEW_PATCH}"
echo "New version: $NEW_VERSION"
echo "new_version=$NEW_VERSION" >> $GITHUB_OUTPUT
echo "new_tag=v$NEW_VERSION" >> $GITHUB_OUTPUT
- name: Update VERSION file
if: steps.check.outputs.needs_bump == 'true'
run: |
echo "${{ steps.next_version.outputs.new_version }}" > VERSION
# 配置git
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
# 提交VERSION文件 - 添加 [skip ci] 以避免再次触发
git add VERSION
git commit -m "chore: sync VERSION file with release ${{ steps.next_version.outputs.new_tag }} [skip ci]"
- name: Install git-cliff - name: Install git-cliff
if: steps.verify.outputs.should_release == 'true' if: steps.check.outputs.needs_bump == 'true'
run: | 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 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 tar -xzf git-cliff-1.4.0-x86_64-unknown-linux-gnu.tar.gz
@@ -53,11 +125,11 @@ jobs:
sudo mv git-cliff-1.4.0/git-cliff /usr/local/bin/ sudo mv git-cliff-1.4.0/git-cliff /usr/local/bin/
- name: Generate changelog - name: Generate changelog
if: steps.verify.outputs.should_release == 'true' if: steps.check.outputs.needs_bump == 'true'
id: changelog id: changelog
run: | run: |
# 获取上一个tag以来的更新日志 # 获取上一个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 if [ -n "$LATEST_TAG" ]; then
# 排除VERSION文件的提交 # 排除VERSION文件的提交
CHANGELOG=$(git-cliff --config .github/cliff.toml $LATEST_TAG..HEAD --strip header | grep -v "bump version" | sed '/^$/d' || echo "- 代码优化和改进") CHANGELOG=$(git-cliff --config .github/cliff.toml $LATEST_TAG..HEAD --strip header | grep -v "bump version" | sed '/^$/d' || echo "- 代码优化和改进")
@@ -69,25 +141,23 @@ jobs:
echo "EOF" >> $GITHUB_OUTPUT echo "EOF" >> $GITHUB_OUTPUT
- name: Create and push tag - name: Create and push tag
if: steps.verify.outputs.should_release == 'true' if: steps.check.outputs.needs_bump == 'true'
run: | run: |
NEW_TAG="${{ steps.verify.outputs.new_tag }}" NEW_TAG="${{ steps.next_version.outputs.new_tag }}"
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git tag -a "$NEW_TAG" -m "Release $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 - name: Create GitHub Release
if: steps.verify.outputs.should_release == 'true' if: steps.check.outputs.needs_bump == 'true'
uses: softprops/action-gh-release@v1 uses: softprops/action-gh-release@v1
with: with:
tag_name: ${{ steps.verify.outputs.new_tag }} tag_name: ${{ steps.next_version.outputs.new_tag }}
name: Release ${{ steps.verify.outputs.new_version }} name: Release ${{ steps.next_version.outputs.new_version }}
body: | body: |
## 🐳 Docker 镜像 ## 🐳 Docker 镜像
```bash ```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 docker pull ${{ secrets.DOCKERHUB_USERNAME || 'weishaw' }}/claude-relay-service:latest
``` ```
@@ -104,15 +174,15 @@ jobs:
# Docker构建步骤 # Docker构建步骤
- name: Set up QEMU - name: Set up QEMU
if: steps.verify.outputs.should_release == 'true' if: steps.check.outputs.needs_bump == 'true'
uses: docker/setup-qemu-action@v3 uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx - 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 uses: docker/setup-buildx-action@v3
- name: Log in to Docker Hub - 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 uses: docker/login-action@v3
with: with:
registry: docker.io registry: docker.io
@@ -120,31 +190,31 @@ jobs:
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push Docker image - 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 uses: docker/build-push-action@v5
with: with:
context: . context: .
platforms: linux/amd64,linux/arm64 platforms: linux/amd64,linux/arm64
push: true push: true
tags: | 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: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: | 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 }} org.opencontainers.image.revision=${{ github.sha }}
cache-from: type=gha cache-from: type=gha
cache-to: type=gha,mode=max cache-to: type=gha,mode=max
- name: Send Telegram Notification - 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: env:
TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }} TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }} TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
continue-on-error: true continue-on-error: true
run: | run: |
VERSION="${{ steps.verify.outputs.new_version }}" VERSION="${{ steps.next_version.outputs.new_version }}"
TAG="${{ steps.verify.outputs.new_tag }}" TAG="${{ steps.next_version.outputs.new_tag }}"
REPO="${{ github.repository }}" REPO="${{ github.repository }}"
# 获取更新内容并限制长度 # 获取更新内容并限制长度

View File

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

View File

@@ -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中转服务"

View File

@@ -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<<EOF" >> $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

View File

@@ -36,9 +36,6 @@ RUN mkdir -p logs data temp
# 🔧 预先创建配置文件 # 🔧 预先创建配置文件
RUN if [ ! -f "/app/config/config.js" ] && [ -f "/app/config/config.example.js" ]; then \ RUN if [ ! -f "/app/config/config.js" ] && [ -f "/app/config/config.example.js" ]; then \
cp /app/config/config.example.js /app/config/config.js; \ 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 fi
# 🌐 暴露端口 # 🌐 暴露端口

119
README.md
View File

@@ -6,7 +6,7 @@
[![Node.js](https://img.shields.io/badge/Node.js-18+-green.svg)](https://nodejs.org/) [![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/) [![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](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) [![Docker Pulls](https://img.shields.io/docker/pulls/weishaw/claude-relay-service)](https://hub.docker.com/r/weishaw/claude-relay-service)
**🔐 自行搭建Claude API中转服务支持多账户管理** **🔐 自行搭建Claude API中转服务支持多账户管理**
@@ -106,7 +106,7 @@
- 🔄 **智能切换**: 账户出问题自动换下一个 - 🔄 **智能切换**: 账户出问题自动换下一个
- 🚀 **性能优化**: 连接池、缓存,减少延迟 - 🚀 **性能优化**: 连接池、缓存,减少延迟
- 📊 **监控面板**: Web界面查看所有数据 - 📊 **监控面板**: Web界面查看所有数据
- 🛡️ **安全控制**: 访问限制、速率控制 - 🛡️ **安全控制**: 访问限制、速率控制、客户端限制
- 🌐 **代理支持**: 支持HTTP/SOCKS5代理 - 🌐 **代理支持**: 支持HTTP/SOCKS5代理
--- ---
@@ -232,17 +232,31 @@ npm run service:status
# 拉取镜像(支持 amd64 和 arm64 # 拉取镜像(支持 amd64 和 arm64
docker pull weishaw/claude-relay-service:latest docker pull weishaw/claude-relay-service:latest
# 使用 docker run 运行 # 使用 docker run 运行(注意设置必需的环境变量)
docker run -d \ docker run -d \
--name claude-relay \ --name claude-relay \
-p 3000:3000 \ -p 3000:3000 \
-v $(pwd)/data:/app/data \ -v $(pwd)/data:/app/data \
-v $(pwd)/logs:/app/logs \ -v $(pwd)/logs:/app/logs \
-e JWT_SECRET=your-random-secret-key-at-least-32-chars \
-e ENCRYPTION_KEY=your-32-character-encryption-key \
-e REDIS_HOST=redis \
-e ADMIN_USERNAME=my_admin \ -e ADMIN_USERNAME=my_admin \
-e ADMIN_PASSWORD=my_secure_password \ -e ADMIN_PASSWORD=my_secure_password \
weishaw/claude-relay-service:latest weishaw/claude-relay-service:latest
# 或使用 docker-compose推荐 # 或使用 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 文件: # 创建 docker-compose.yml 文件:
cat > docker-compose.yml << 'EOF' cat > docker-compose.yml << 'EOF'
version: '3.8' version: '3.8'
@@ -254,6 +268,8 @@ services:
ports: ports:
- "3000:3000" - "3000:3000"
environment: environment:
- JWT_SECRET=${JWT_SECRET}
- ENCRYPTION_KEY=${ENCRYPTION_KEY}
- REDIS_HOST=redis - REDIS_HOST=redis
- ADMIN_USERNAME=${ADMIN_USERNAME:-} - ADMIN_USERNAME=${ADMIN_USERNAME:-}
- ADMIN_PASSWORD=${ADMIN_PASSWORD:-} - ADMIN_PASSWORD=${ADMIN_PASSWORD:-}
@@ -285,16 +301,21 @@ docker-compose up -d
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 cd claude-relay-service
# 2. 设置管理员账号密码(可选) # 2. 创建环境变量文件
# 方式一:自动生成(查看容器日志获取) cat > .env << 'EOF'
# 必填:安全密钥(请修改为随机值)
JWT_SECRET=your-random-secret-key-at-least-32-chars
ENCRYPTION_KEY=your-32-character-encryption-key
# 可选:管理员凭据
ADMIN_USERNAME=cr_admin_custom
ADMIN_PASSWORD=your-secure-password
EOF
# 3. 启动服务
docker-compose up -d docker-compose up -d
# 方式二:预设账号密码 # 4. 查看管理员凭据
export ADMIN_USERNAME=cr_admin_custom
export ADMIN_PASSWORD=your-secure-password
docker-compose up -d
# 3. 查看管理员凭据
# 自动生成的情况下: # 自动生成的情况下:
docker logs claude-relay-service | grep "管理员" docker logs claude-relay-service | grep "管理员"
@@ -310,6 +331,19 @@ docker-compose.yml 已包含:
- ✅ Redis数据库 - ✅ Redis数据库
- ✅ 健康检查 - ✅ 健康检查
- ✅ 自动重启 - ✅ 自动重启
- ✅ 所有配置通过环境变量管理
### 环境变量说明
#### 必填项
- `JWT_SECRET`: JWT密钥至少32个字符
- `ENCRYPTION_KEY`: 加密密钥必须是32个字符
#### 可选项
- `ADMIN_USERNAME`: 管理员用户名(不设置则自动生成)
- `ADMIN_PASSWORD`: 管理员密码(不设置则自动生成)
- `LOG_LEVEL`: 日志级别默认info
- 更多配置项请参考 `.env.example` 文件
### 管理员凭据获取方式 ### 管理员凭据获取方式
@@ -364,7 +398,11 @@ docker-compose.yml 已包含:
1. 点击「API Keys」标签 1. 点击「API Keys」标签
2. 点击「创建新Key」 2. 点击「创建新Key」
3. 给Key起个名字比如「张三的Key」 3. 给Key起个名字比如「张三的Key」
4. 设置使用限制(可选) 4. 设置使用限制(可选)
- **速率限制**: 限制每个时间窗口的请求次数和Token使用量
- **并发限制**: 限制同时处理的请求数
- **模型限制**: 限制可访问的模型列表
- **客户端限制**: 限制只允许特定客户端使用如ClaudeCode、Gemini-CLI等
5. 保存记下生成的Key 5. 保存记下生成的Key
### 4. 开始使用Claude code ### 4. 开始使用Claude code
@@ -464,6 +502,63 @@ npm run service:status
- 查看更新日志了解是否有破坏性变更 - 查看更新日志了解是否有破坏性变更
- 如果有数据库结构变更,会自动迁移 - 如果有数据库结构变更,会自动迁移
---
## 🔒 客户端限制功能
### 功能说明
客户端限制功能允许你控制每个API Key可以被哪些客户端使用通过User-Agent识别客户端提高API的安全性。
### 使用方法
1. **在创建或编辑API Key时启用客户端限制**
- 勾选"启用客户端限制"
- 选择允许的客户端(支持多选)
2. **预定义客户端**
- **ClaudeCode**: 官方Claude CLI匹配 `claude-cli/x.x.x (external, cli)` 格式)
- **Gemini-CLI**: Gemini命令行工具匹配 `GeminiCLI/vx.x.x (platform; arch)` 格式)
3. **调试和诊断**
- 系统会在日志中记录所有请求的User-Agent
- 客户端验证失败时会返回403错误并记录详细信息
- 通过日志可以查看实际的User-Agent格式方便配置自定义客户端
### 自定义客户端配置
如需添加自定义客户端,可以修改 `config/config.js` 文件:
```javascript
clientRestrictions: {
predefinedClients: [
// ... 现有客户端配置
{
id: 'my_custom_client',
name: 'My Custom Client',
description: '我的自定义客户端',
userAgentPattern: /^MyClient\/[\d\.]+/i
}
]
}
```
### 日志示例
认证成功时的日志:
```
🔓 Authenticated request from key: 测试Key (key-id) in 5ms
User-Agent: "claude-cli/1.0.58 (external, cli)"
```
客户端限制检查日志:
```
🔍 Checking client restriction for key: key-id (测试Key)
User-Agent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64)"
Allowed clients: claude_code, gemini_cli
🚫 Client restriction failed for key: key-id (测试Key) from 127.0.0.1, User-Agent: Mozilla/5.0...
```
### 常见问题处理 ### 常见问题处理
**Redis连不上** **Redis连不上**

View File

@@ -1 +1 @@
1.1.10 1.1.40

View File

@@ -4,7 +4,7 @@ const { Command } = require('commander');
const inquirer = require('inquirer'); const inquirer = require('inquirer');
const chalk = require('chalk'); const chalk = require('chalk');
const ora = require('ora'); const ora = require('ora');
const Table = require('table').table; const { table } = require('table');
const bcrypt = require('bcryptjs'); const bcrypt = require('bcryptjs');
const crypto = require('crypto'); const crypto = require('crypto');
const fs = require('fs'); const fs = require('fs');
@@ -54,6 +54,43 @@ program
}); });
// 🔑 API Key 管理
program
.command('keys')
.description('API Key 管理操作')
.action(async () => {
await initialize();
const { action } = await inquirer.prompt([{
type: 'list',
name: 'action',
message: '请选择操作:',
choices: [
{ name: '📋 查看所有 API Keys', value: 'list' },
{ name: '🔧 修改 API Key 过期时间', value: 'update-expiry' },
{ name: '🔄 续期即将过期的 API Key', value: 'renew' },
{ name: '🗑️ 删除 API Key', value: 'delete' }
]
}]);
switch (action) {
case 'list':
await listApiKeys();
break;
case 'update-expiry':
await updateApiKeyExpiry();
break;
case 'renew':
await renewApiKeys();
break;
case 'delete':
await deleteApiKey();
break;
}
await redis.disconnect();
});
// 📊 系统状态 // 📊 系统状态
program program
.command('status') .command('status')
@@ -201,6 +238,329 @@ async function createInitialAdmin() {
// API Key 管理功能
async function listApiKeys() {
const spinner = ora('正在获取 API Keys...').start();
try {
const apiKeys = await apiKeyService.getAllApiKeys();
spinner.succeed(`找到 ${apiKeys.length} 个 API Keys`);
if (apiKeys.length === 0) {
console.log(styles.warning('没有找到任何 API Keys'));
return;
}
const tableData = [
['名称', 'API Key', '状态', '过期时间', '使用量', 'Token限制']
];
apiKeys.forEach(key => {
const now = new Date();
const expiresAt = key.expiresAt ? new Date(key.expiresAt) : null;
let expiryStatus = '永不过期';
if (expiresAt) {
if (expiresAt < now) {
expiryStatus = styles.error(`已过期 (${expiresAt.toLocaleDateString()})`);
} else {
const daysLeft = Math.ceil((expiresAt - now) / (1000 * 60 * 60 * 24));
if (daysLeft <= 7) {
expiryStatus = styles.warning(`${daysLeft}天后过期 (${expiresAt.toLocaleDateString()})`);
} else {
expiryStatus = styles.success(`${expiresAt.toLocaleDateString()}`);
}
}
}
tableData.push([
key.name,
key.apiKey ? key.apiKey.substring(0, 20) + '...' : '-',
key.isActive ? '🟢 活跃' : '🔴 停用',
expiryStatus,
`${(key.usage?.total?.tokens || 0).toLocaleString()}`,
key.tokenLimit ? key.tokenLimit.toLocaleString() : '无限制'
]);
});
console.log(styles.title('\n🔑 API Keys 列表:\n'));
console.log(table(tableData));
} catch (error) {
spinner.fail('获取 API Keys 失败');
console.error(styles.error(error.message));
}
}
async function updateApiKeyExpiry() {
try {
// 获取所有 API Keys
const apiKeys = await apiKeyService.getAllApiKeys();
if (apiKeys.length === 0) {
console.log(styles.warning('没有找到任何 API Keys'));
return;
}
// 选择要修改的 API Key
const { selectedKey } = await inquirer.prompt([{
type: 'list',
name: 'selectedKey',
message: '选择要修改的 API Key:',
choices: apiKeys.map(key => ({
name: `${key.name} (${key.apiKey?.substring(0, 20)}...) - ${key.expiresAt ? new Date(key.expiresAt).toLocaleDateString() : '永不过期'}`,
value: key
}))
}]);
console.log(`\n当前 API Key: ${selectedKey.name}`);
console.log(`当前过期时间: ${selectedKey.expiresAt ? new Date(selectedKey.expiresAt).toLocaleString() : '永不过期'}`);
// 选择新的过期时间
const { expiryOption } = await inquirer.prompt([{
type: 'list',
name: 'expiryOption',
message: '选择新的过期时间:',
choices: [
{ name: '⏰ 1分后测试用', value: '1m' },
{ name: '⏰ 1小时后测试用', value: '1h' },
{ name: '📅 1天后', value: '1d' },
{ name: '📅 7天后', value: '7d' },
{ name: '📅 30天后', value: '30d' },
{ name: '📅 90天后', value: '90d' },
{ name: '📅 365天后', value: '365d' },
{ name: '♾️ 永不过期', value: 'never' },
{ name: '🎯 自定义日期时间', value: 'custom' }
]
}]);
let newExpiresAt = null;
if (expiryOption === 'never') {
newExpiresAt = null;
} else if (expiryOption === 'custom') {
const { customDate, customTime } = await inquirer.prompt([
{
type: 'input',
name: 'customDate',
message: '输入日期 (YYYY-MM-DD):',
default: new Date().toISOString().split('T')[0],
validate: input => {
const date = new Date(input);
return !isNaN(date.getTime()) || '请输入有效的日期格式';
}
},
{
type: 'input',
name: 'customTime',
message: '输入时间 (HH:MM):',
default: '00:00',
validate: input => {
return /^\d{2}:\d{2}$/.test(input) || '请输入有效的时间格式 (HH:MM)';
}
}
]);
newExpiresAt = new Date(`${customDate}T${customTime}:00`).toISOString();
} else {
// 计算新的过期时间
const now = new Date();
const durations = {
'1m': 60 * 1000,
'1h': 60 * 60 * 1000,
'1d': 24 * 60 * 60 * 1000,
'7d': 7 * 24 * 60 * 60 * 1000,
'30d': 30 * 24 * 60 * 60 * 1000,
'90d': 90 * 24 * 60 * 60 * 1000,
'365d': 365 * 24 * 60 * 60 * 1000
};
newExpiresAt = new Date(now.getTime() + durations[expiryOption]).toISOString();
}
// 确认修改
const confirmMsg = newExpiresAt
? `确认将过期时间修改为: ${new Date(newExpiresAt).toLocaleString()}?`
: '确认设置为永不过期?';
const { confirmed } = await inquirer.prompt([{
type: 'confirm',
name: 'confirmed',
message: confirmMsg,
default: true
}]);
if (!confirmed) {
console.log(styles.info('已取消修改'));
return;
}
// 执行修改
const spinner = ora('正在修改过期时间...').start();
try {
await apiKeyService.updateApiKey(selectedKey.id, { expiresAt: newExpiresAt });
spinner.succeed('过期时间修改成功');
console.log(styles.success(`\n✅ API Key "${selectedKey.name}" 的过期时间已更新`));
console.log(`新的过期时间: ${newExpiresAt ? new Date(newExpiresAt).toLocaleString() : '永不过期'}`);
} catch (error) {
spinner.fail('修改失败');
console.error(styles.error(error.message));
}
} catch (error) {
console.error(styles.error('操作失败:', error.message));
}
}
async function renewApiKeys() {
const spinner = ora('正在查找即将过期的 API Keys...').start();
try {
const apiKeys = await apiKeyService.getAllApiKeys();
const now = new Date();
const sevenDaysLater = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000);
// 筛选即将过期的 Keys7天内
const expiringKeys = apiKeys.filter(key => {
if (!key.expiresAt) return false;
const expiresAt = new Date(key.expiresAt);
return expiresAt > now && expiresAt <= sevenDaysLater;
});
spinner.stop();
if (expiringKeys.length === 0) {
console.log(styles.info('没有即将过期的 API Keys7天内'));
return;
}
console.log(styles.warning(`\n找到 ${expiringKeys.length} 个即将过期的 API Keys:\n`));
expiringKeys.forEach((key, index) => {
const daysLeft = Math.ceil((new Date(key.expiresAt) - now) / (1000 * 60 * 60 * 24));
console.log(`${index + 1}. ${key.name} - ${daysLeft}天后过期 (${new Date(key.expiresAt).toLocaleDateString()})`);
});
const { renewOption } = await inquirer.prompt([{
type: 'list',
name: 'renewOption',
message: '选择续期方式:',
choices: [
{ name: '📅 全部续期30天', value: 'all30' },
{ name: '📅 全部续期90天', value: 'all90' },
{ name: '🎯 逐个选择续期', value: 'individual' }
]
}]);
if (renewOption.startsWith('all')) {
const days = renewOption === 'all30' ? 30 : 90;
const renewSpinner = ora(`正在为所有 API Keys 续期 ${days} 天...`).start();
for (const key of expiringKeys) {
try {
const newExpiresAt = new Date(new Date(key.expiresAt).getTime() + days * 24 * 60 * 60 * 1000).toISOString();
await apiKeyService.updateApiKey(key.id, { expiresAt: newExpiresAt });
} catch (error) {
renewSpinner.fail(`续期 ${key.name} 失败: ${error.message}`);
}
}
renewSpinner.succeed(`成功续期 ${expiringKeys.length} 个 API Keys`);
} else {
// 逐个选择续期
for (const key of expiringKeys) {
console.log(`\n处理: ${key.name}`);
const { action } = await inquirer.prompt([{
type: 'list',
name: 'action',
message: '选择操作:',
choices: [
{ name: '续期30天', value: '30' },
{ name: '续期90天', value: '90' },
{ name: '跳过', value: 'skip' }
]
}]);
if (action !== 'skip') {
const days = parseInt(action);
const newExpiresAt = new Date(new Date(key.expiresAt).getTime() + days * 24 * 60 * 60 * 1000).toISOString();
try {
await apiKeyService.updateApiKey(key.id, { expiresAt: newExpiresAt });
console.log(styles.success(`✅ 已续期 ${days}`));
} catch (error) {
console.log(styles.error(`❌ 续期失败: ${error.message}`));
}
}
}
}
} catch (error) {
spinner.fail('操作失败');
console.error(styles.error(error.message));
}
}
async function deleteApiKey() {
try {
const apiKeys = await apiKeyService.getAllApiKeys();
if (apiKeys.length === 0) {
console.log(styles.warning('没有找到任何 API Keys'));
return;
}
const { selectedKeys } = await inquirer.prompt([{
type: 'checkbox',
name: 'selectedKeys',
message: '选择要删除的 API Keys (空格选择,回车确认):',
choices: apiKeys.map(key => ({
name: `${key.name} (${key.apiKey?.substring(0, 20)}...)`,
value: key.id
}))
}]);
if (selectedKeys.length === 0) {
console.log(styles.info('未选择任何 API Key'));
return;
}
const { confirmed } = await inquirer.prompt([{
type: 'confirm',
name: 'confirmed',
message: styles.warning(`确认删除 ${selectedKeys.length} 个 API Keys?`),
default: false
}]);
if (!confirmed) {
console.log(styles.info('已取消删除'));
return;
}
const spinner = ora('正在删除 API Keys...').start();
let successCount = 0;
for (const keyId of selectedKeys) {
try {
await apiKeyService.deleteApiKey(keyId);
successCount++;
} catch (error) {
spinner.fail(`删除失败: ${error.message}`);
}
}
spinner.succeed(`成功删除 ${successCount}/${selectedKeys.length} 个 API Keys`);
} catch (error) {
console.error(styles.error('删除失败:', error.message));
}
}
async function listClaudeAccounts() { async function listClaudeAccounts() {
const spinner = ora('正在获取 Claude 账户...').start(); const spinner = ora('正在获取 Claude 账户...').start();
@@ -251,6 +611,7 @@ if (!process.argv.slice(2).length) {
console.log(styles.title('🚀 Claude Relay Service CLI\n')); console.log(styles.title('🚀 Claude Relay Service CLI\n'));
console.log('使用以下命令管理服务:\n'); console.log('使用以下命令管理服务:\n');
console.log(' claude-relay-cli admin - 创建初始管理员账户'); console.log(' claude-relay-cli admin - 创建初始管理员账户');
console.log(' claude-relay-cli keys - API Key 管理(查看/修改过期时间/续期/删除)');
console.log(' claude-relay-cli status - 查看系统状态'); console.log(' claude-relay-cli status - 查看系统状态');
console.log('\n使用 --help 查看详细帮助信息'); console.log('\n使用 --help 查看详细帮助信息');
} }

View File

@@ -76,6 +76,38 @@ const config = {
sessionSecret: process.env.WEB_SESSION_SECRET || 'CHANGE-THIS-SESSION-SECRET' sessionSecret: process.env.WEB_SESSION_SECRET || 'CHANGE-THIS-SESSION-SECRET'
}, },
// 🔒 客户端限制配置
clientRestrictions: {
// 预定义的客户端列表
predefinedClients: [
{
id: 'claude_code',
name: 'ClaudeCode',
description: 'Official Claude Code CLI',
// 匹配 Claude CLI 的 User-Agent
// 示例: claude-cli/1.0.58 (external, cli)
userAgentPattern: /^claude-cli\/[\d\.]+\s+\(/i
},
{
id: 'gemini_cli',
name: 'Gemini-CLI',
description: 'Gemini Command Line Interface',
// 匹配 GeminiCLI 的 User-Agent
// 示例: GeminiCLI/v18.20.8 (darwin; arm64)
userAgentPattern: /^GeminiCLI\/v?[\d\.]+\s+\(/i
}
// 添加自定义客户端示例:
// {
// id: 'custom_client',
// name: 'My Custom Client',
// description: 'My custom API client',
// userAgentPattern: /^MyClient\/[\d\.]+/i
// }
],
// 是否允许自定义客户端(未来功能)
allowCustomClients: process.env.ALLOW_CUSTOM_CLIENTS === 'true'
},
// 🛠️ 开发配置 // 🛠️ 开发配置
development: { development: {
debug: process.env.DEBUG === 'true', debug: process.env.DEBUG === 'true',

View File

@@ -1,20 +1,72 @@
version: '3.8' version: '3.8'
# Claude Relay Service Docker Compose 配置
# 所有配置通过环境变量设置,无需映射 .env 文件
services: services:
# 🚀 Claude Relay Service # 🚀 Claude Relay Service
claude-relay: claude-relay:
build: . build: .
container_name: claude-relay-service image: weishaw/claude-relay-service:latest
restart: unless-stopped restart: unless-stopped
ports: ports:
- "${PORT:-3000}:3000" - "${PORT:-3000}:3000"
environment: environment:
# 🌐 服务器配置
- NODE_ENV=production - NODE_ENV=production
- PORT=3000 - 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_HOST=redis
- REDIS_PORT=6379 - REDIS_PORT=6379
- ADMIN_USERNAME=${ADMIN_USERNAME:-} # 可选:预设管理员用户名 - REDIS_PASSWORD=${REDIS_PASSWORD:-}
- ADMIN_PASSWORD=${ADMIN_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: volumes:
- ./logs:/app/logs - ./logs:/app/logs
- ./data:/app/data - ./data:/app/data
@@ -31,7 +83,6 @@ services:
# 📊 Redis Database # 📊 Redis Database
redis: redis:
image: redis:7-alpine image: redis:7-alpine
container_name: claude-relay-redis
restart: unless-stopped restart: unless-stopped
ports: ports:
- "${REDIS_PORT:-6379}:6379" - "${REDIS_PORT:-6379}:6379"
@@ -49,7 +100,6 @@ services:
# 📈 Redis Monitoring (Optional) # 📈 Redis Monitoring (Optional)
redis-commander: redis-commander:
image: rediscommander/redis-commander:latest image: rediscommander/redis-commander:latest
container_name: claude-relay-redis-web
restart: unless-stopped restart: unless-stopped
ports: ports:
- "${REDIS_WEB_PORT:-8081}:8081" - "${REDIS_WEB_PORT:-8081}:8081"
@@ -65,7 +115,6 @@ services:
# 📊 Application Monitoring (Optional) # 📊 Application Monitoring (Optional)
prometheus: prometheus:
image: prom/prometheus:latest image: prom/prometheus:latest
container_name: claude-relay-prometheus
restart: unless-stopped restart: unless-stopped
ports: ports:
- "${PROMETHEUS_PORT:-9090}:9090" - "${PROMETHEUS_PORT:-9090}:9090"
@@ -86,7 +135,6 @@ services:
# 📈 Grafana Dashboard (Optional) # 📈 Grafana Dashboard (Optional)
grafana: grafana:
image: grafana/grafana:latest image: grafana/grafana:latest
container_name: claude-relay-grafana
restart: unless-stopped restart: unless-stopped
ports: ports:
- "${GRAFANA_PORT:-3001}:3000" - "${GRAFANA_PORT:-3001}:3000"
@@ -111,6 +159,3 @@ volumes:
networks: networks:
claude-relay-network: claude-relay-network:
driver: bridge driver: bridge
ipam:
config:
- subnet: 172.20.0.0/16

View File

@@ -3,12 +3,20 @@ set -e
echo "🚀 Claude Relay Service 启动中..." echo "🚀 Claude Relay Service 启动中..."
# 生成随机字符串的函数 # 检查关键环境变量
generate_random_string() { if [ -z "$JWT_SECRET" ]; then
length=$1 echo "❌ 错误: JWT_SECRET 环境变量未设置"
# 使用 /dev/urandom 生成随机字符串 echo " 请在 docker-compose.yml 中设置 JWT_SECRET"
tr -dc 'a-zA-Z0-9' < /dev/urandom | head -c $length echo " 例如: JWT_SECRET=your-random-secret-key-at-least-32-chars"
} exit 1
fi
if [ -z "$ENCRYPTION_KEY" ]; then
echo "❌ 错误: ENCRYPTION_KEY 环境变量未设置"
echo " 请在 docker-compose.yml 中设置 ENCRYPTION_KEY"
echo " 例如: ENCRYPTION_KEY=your-32-character-encryption-key"
exit 1
fi
# 检查并复制配置文件 # 检查并复制配置文件
if [ ! -f "/app/config/config.js" ]; then if [ ! -f "/app/config/config.js" ]; then
@@ -22,48 +30,17 @@ if [ ! -f "/app/config/config.js" ]; then
fi fi
fi fi
# 检查并配置 .env 文件(文件已在构建时创建) # 显示配置信息
if [ -f "/app/.env" ]; then echo "✅ 环境配置已就绪"
echo "📋 配置 .env 文件..." echo " JWT_SECRET: [已设置]"
echo " ENCRYPTION_KEY: [已设置]"
# 生成随机的 JWT_SECRET (64字符) echo " REDIS_HOST: ${REDIS_HOST:-localhost}"
if [ -z "$JWT_SECRET" ]; then echo " PORT: ${PORT:-3000}"
JWT_SECRET=$(grep "^JWT_SECRET=" /app/.env | cut -d'=' -f2)
if [ -z "$JWT_SECRET" ] || [ "$JWT_SECRET" = "your-jwt-secret-here" ]; then
JWT_SECRET=$(generate_random_string 64)
echo "🔑 生成 JWT_SECRET"
fi
fi
# 生成随机的 ENCRYPTION_KEY (32字符)
if [ -z "$ENCRYPTION_KEY" ]; then
ENCRYPTION_KEY=$(grep "^ENCRYPTION_KEY=" /app/.env | cut -d'=' -f2)
if [ -z "$ENCRYPTION_KEY" ] || [ "$ENCRYPTION_KEY" = "your-encryption-key-here" ]; then
ENCRYPTION_KEY=$(generate_random_string 32)
echo "🔑 生成 ENCRYPTION_KEY"
fi
fi
# 直接使用sed修改.env文件 - root用户无权限问题
sed -i "s/JWT_SECRET=.*/JWT_SECRET=${JWT_SECRET}/" /app/.env
sed -i "s/ENCRYPTION_KEY=.*/ENCRYPTION_KEY=${ENCRYPTION_KEY}/" /app/.env
sed -i "s/REDIS_HOST=.*/REDIS_HOST=redis/" /app/.env
echo "✅ .env 已配置"
else
echo "❌ 错误: .env 文件不存在"
exit 1
fi
# 导出环境变量
export JWT_SECRET
export ENCRYPTION_KEY
# 检查是否需要初始化 # 检查是否需要初始化
if [ ! -f "/app/data/init.json" ]; then if [ ! -f "/app/data/init.json" ]; then
echo "📋 首次启动,执行初始化设置..." echo "📋 首次启动,执行初始化设置..."
# 如果设置了环境变量,显示提示 # 如果设置了环境变量,显示提示
if [ -n "$ADMIN_USERNAME" ] || [ -n "$ADMIN_PASSWORD" ]; then if [ -n "$ADMIN_USERNAME" ] || [ -n "$ADMIN_PASSWORD" ]; then
echo "📌 检测到预设的管理员凭据" echo "📌 检测到预设的管理员凭据"

205
docs/UPGRADE_GUIDE.md Normal file
View File

@@ -0,0 +1,205 @@
# 升级指南 - API Key 有效期功能
本指南说明如何从旧版本安全升级到支持 API Key 有效期限制的新版本。
## 升级前准备
### 1. 备份现有数据
在升级前,强烈建议备份您的生产数据:
```bash
# 导出所有数据(包含敏感信息)
npm run data:export -- --output=prod-backup-$(date +%Y%m%d).json
# 或导出脱敏数据(用于测试环境)
npm run data:export:sanitized -- --output=prod-backup-sanitized-$(date +%Y%m%d).json
```
### 2. 确认备份完整性
检查导出的文件,确保包含所有必要的数据:
```bash
# 查看备份文件信息
cat prod-backup-*.json | jq '.metadata'
# 查看数据统计
cat prod-backup-*.json | jq '.data | keys'
```
## 升级步骤
### 1. 停止服务
```bash
# 停止 Claude Relay Service
npm run service:stop
# 或如果使用 Docker
docker-compose down
```
### 2. 更新代码
```bash
# 拉取最新代码
git pull origin main
# 安装依赖
npm install
# 更新 Web 界面依赖
npm run install:web
```
### 3. 运行数据迁移
为现有的 API Key 设置默认 30 天有效期:
```bash
# 先进行模拟运行,查看将要修改的数据
npm run migrate:apikey-expiry:dry
# 确认无误后,执行实际迁移
npm run migrate:apikey-expiry
```
如果您想设置不同的默认有效期:
```bash
# 设置 90 天有效期
npm run migrate:apikey-expiry -- --days=90
```
### 4. 启动服务
```bash
# 启动服务
npm run service:start:daemon
# 或使用 Docker
docker-compose up -d
```
### 5. 验证升级
1. 登录 Web 管理界面
2. 检查 API Key 列表,确认显示过期时间列
3. 测试创建新的 API Key确认可以设置过期时间
4. 测试续期功能是否正常工作
## 从生产环境导入数据(用于测试)
如果您需要在测试环境中使用生产数据:
### 1. 在生产环境导出数据
```bash
# 导出脱敏数据(推荐用于测试)
npm run data:export:sanitized -- --output=prod-export.json
# 或只导出特定类型的数据
npm run data:export -- --types=apikeys,accounts --sanitize --output=prod-partial.json
```
### 2. 传输文件到测试环境
使用安全的方式传输文件,如 SCP
```bash
scp prod-export.json user@test-server:/path/to/claude-relay-service/
```
### 3. 在测试环境导入数据
```bash
# 导入数据,遇到冲突时询问
npm run data:import -- --input=prod-export.json
# 或跳过所有冲突
npm run data:import -- --input=prod-export.json --skip-conflicts
# 或强制覆盖所有数据(谨慎使用)
npm run data:import -- --input=prod-export.json --force
```
## 回滚方案
如果升级后遇到问题,可以按以下步骤回滚:
### 1. 停止服务
```bash
npm run service:stop
```
### 2. 恢复代码
```bash
# 切换到之前的版本
git checkout <previous-version-tag>
# 重新安装依赖
npm install
```
### 3. 恢复数据(如需要)
```bash
# 从备份恢复数据
npm run data:import -- --input=prod-backup-<date>.json --force
```
### 4. 重启服务
```bash
npm run service:start:daemon
```
## 注意事项
1. **数据迁移是幂等的**:迁移脚本可以安全地多次运行,已有过期时间的 API Key 不会被修改。
2. **过期的 API Key 处理**
- 过期的 API Key 会被自动禁用,而不是删除
- 管理员可以通过续期功能重新激活过期的 Key
3. **定时任务**
- 系统会每小时自动检查并禁用过期的 API Key
- 该任务在 `config.system.cleanupInterval` 中配置
4. **API 兼容性**
- 新增的过期时间功能完全向后兼容
- 现有的 API 调用不会受到影响
## 常见问题
### Q: 如果不想某些 API Key 过期怎么办?
A: 您可以通过 Web 界面将特定 API Key 设置为"永不过期",或在续期时选择"设为永不过期"。
### Q: 迁移脚本会影响已经设置了过期时间的 API Key 吗?
A: 不会。迁移脚本只会处理没有设置过期时间的 API Key。
### Q: 如何批量修改 API Key 的过期时间?
A: 您可以修改迁移脚本,或使用数据导出/导入工具批量处理。
### Q: 导出的脱敏数据可以用于生产环境吗?
A: 不建议。脱敏数据缺少关键的认证信息(如 OAuth tokens仅适用于测试环境。
## 技术支持
如遇到问题,请检查:
1. 服务日志:`npm run service:logs`
2. Redis 连接:确保 Redis 服务正常运行
3. 配置文件:检查 `.env``config/config.js`
如需进一步帮助,请提供:
- 错误日志
- 使用的命令
- 系统环境信息

View File

@@ -0,0 +1,187 @@
# API Key 过期时间管理指南
## 概述
Claude Relay Service 支持为 API Keys 设置过期时间,提供了灵活的过期管理功能,方便进行权限控制和安全管理。
## 功能特性
- ✅ 创建时设置过期时间
- ✅ 随时修改过期时间
- ✅ 自动禁用过期的 Keys
- ✅ 手动续期功能
- ✅ 批量续期支持
- ✅ Web 界面和 CLI 双重管理
## CLI 管理工具
### 1. 查看 API Keys
```bash
npm run cli keys
# 选择 "📋 查看所有 API Keys"
```
显示内容包括:
- 名称和部分 Key
- 活跃/禁用状态
- 过期时间(带颜色提示)
- Token 使用量
- Token 限制
### 2. 修改过期时间
```bash
npm run cli keys
# 选择 "🔧 修改 API Key 过期时间"
```
支持的过期选项:
-**1小时后**(测试用)
- 📅 **1天后**
- 📅 **7天后**
- 📅 **30天后**
- 📅 **90天后**
- 📅 **365天后**
- ♾️ **永不过期**
- 🎯 **自定义日期时间**
### 3. 批量续期
```bash
npm run cli keys
# 选择 "🔄 续期即将过期的 API Key"
```
功能:
- 查找7天内即将过期的 Keys
- 支持全部续期30天或90天
- 支持逐个选择续期
### 4. 删除 API Keys
```bash
npm run cli keys
# 选择 "🗑️ 删除 API Key"
```
## Web 界面功能
### 创建时设置过期
在创建 API Key 时,可以选择:
- 永不过期
- 1天、7天、30天、90天、180天、365天
- 自定义日期
### 查看过期状态
API Key 列表中显示:
- 🔴 已过期(红色)
- 🟡 即将过期7天内黄色
- 🟢 正常(绿色)
- ♾️ 永不过期
### 手动续期
对于已过期的 API Keys
1. 点击"续期"按钮
2. 选择新的过期时间
3. 确认更新
## 自动清理机制
系统每小时自动运行清理任务:
- 检查所有 API Keys 的过期时间
- 将过期的 Keys 标记为禁用(`isActive = false`
- 不删除数据,保留历史记录
- 记录清理日志
## 测试工具
### 1. 快速测试脚本
```bash
node scripts/test-apikey-expiry.js
```
创建5个测试 Keys
- 已过期1天前
- 1小时后过期
- 1天后过期
- 7天后过期
- 永不过期
### 2. 迁移脚本
为现有 API Keys 设置默认30天过期时间
```bash
# 预览(不实际修改)
npm run migrate:apikey-expiry:dry
# 执行迁移
npm run migrate:apikey-expiry
```
## 使用场景
### 1. 临时访问
为临时用户或测试创建短期 Key
```bash
# 创建1天有效期的测试 Key
# 在 Web 界面或 CLI 中选择"1天"
```
### 2. 定期更新
为安全考虑,定期更新 Keys
```bash
# 每30天自动过期需要续期
# 创建时选择"30天"
```
### 3. 长期合作
为可信任的长期用户:
```bash
# 选择"365天"或"永不过期"
```
### 4. 测试过期功能
快速测试过期验证:
```bash
# 1. 创建1小时后过期的 Key
npm run cli keys
# 选择修改过期时间 -> 选择测试 Key -> 1小时后
# 2. 等待或手动触发清理
# 3. 验证 API 调用被拒绝
```
## API 响应
过期的 API Key 调用时返回:
```json
{
"error": "Unauthorized",
"message": "Invalid or inactive API key"
}
```
## 最佳实践
1. **定期审查**:定期检查即将过期的 Keys
2. **提前通知**:在过期前通知用户续期
3. **分级管理**:根据用户级别设置不同过期策略
4. **测试验证**:新功能上线前充分测试过期机制
5. **备份恢复**:使用数据导出工具备份 Key 信息
## 注意事项
- 过期的 Keys 不会被删除,只是禁用
- 可以随时续期已过期的 Keys
- 修改过期时间立即生效
- 清理任务每小时运行一次

View File

@@ -0,0 +1,177 @@
# 数据导入/导出加密处理指南
## 概述
Claude Relay Service 使用 AES-256-CBC 加密算法来保护敏感数据。本文档详细说明了数据导入/导出工具如何处理加密和未加密的数据。
## 加密机制
### 加密的数据类型
1. **Claude 账户**
- email
- password
- accessToken
- refreshToken
- claudeAiOauth (OAuth 数据)
- 使用 salt: `'salt'`
2. **Gemini 账户**
- geminiOauth (OAuth 数据)
- accessToken
- refreshToken
- 使用 salt: `'gemini-account-salt'`
### 加密格式
加密后的数据格式:`{iv}:{encryptedData}`
- `iv`: 16字节的初始化向量hex格式
- `encryptedData`: 加密后的数据hex格式
## 导出功能
### 1. 解密导出(默认)
```bash
npm run data:export:enhanced
# 或
node scripts/data-transfer-enhanced.js export --decrypt=true
```
- **用途**:数据迁移到其他环境
- **特点**
- `metadata.decrypted = true`
- 敏感数据以明文形式导出
- 便于在不同加密密钥的环境间迁移
### 2. 加密导出
```bash
npm run data:export:encrypted
# 或
node scripts/data-transfer-enhanced.js export --decrypt=false
```
- **用途**:备份或在相同加密密钥的环境间传输
- **特点**
- `metadata.decrypted = false`
- 保持数据的加密状态
- 必须在相同的 ENCRYPTION_KEY 环境下才能使用
### 3. 脱敏导出
```bash
node scripts/data-transfer-enhanced.js export --sanitize
```
- **用途**:分享数据结构或调试
- **特点**
- `metadata.sanitized = true`
- 敏感字段被替换为 `[REDACTED]`
- 不能用于实际导入
## 导入功能
### 自动加密处理逻辑
```javascript
if (importData.metadata.decrypted && !importData.metadata.sanitized) {
// 数据已解密且不是脱敏的,需要重新加密
// 自动加密所有敏感字段
} else {
// 数据已加密或是脱敏的,保持原样
}
```
### 导入场景
#### 场景 1导入解密的数据
- **输入**`metadata.decrypted = true`
- **处理**:自动加密所有敏感字段
- **结果**:数据以加密形式存储在 Redis
#### 场景 2导入加密的数据
- **输入**`metadata.decrypted = false`
- **处理**:直接存储,不做加密处理
- **结果**:保持原有加密状态
- **注意**:必须使用相同的 ENCRYPTION_KEY
#### 场景 3导入脱敏的数据
- **输入**`metadata.sanitized = true`
- **处理**:警告并询问是否继续
- **结果**:导入但缺少敏感数据,账户可能无法正常工作
## 使用示例
### 1. 跨环境迁移
```bash
# 在生产环境导出(解密)
npm run data:export:enhanced -- --output=prod-data.json
# 在测试环境导入(自动加密)
npm run data:import:enhanced -- --input=prod-data.json
```
### 2. 同环境备份恢复
```bash
# 备份(保持加密)
npm run data:export:encrypted -- --output=backup.json
# 恢复(保持加密)
npm run data:import:enhanced -- --input=backup.json
```
### 3. 选择性导入
```bash
# 跳过已存在的数据
npm run data:import:enhanced -- --input=data.json --skip-conflicts
# 强制覆盖所有数据
npm run data:import:enhanced -- --input=data.json --force
```
## 安全建议
1. **加密密钥管理**
- 使用强随机密钥至少32字符
- 不同环境使用不同的密钥
- 定期轮换密钥
2. **导出文件保护**
- 解密的导出文件包含明文敏感数据
- 应立即加密存储或传输
- 使用后及时删除
3. **权限控制**
- 限制导出/导入工具的访问权限
- 审计所有数据导出操作
- 使用脱敏导出进行非生产用途
## 故障排除
### 常见问题
1. **导入后账户无法使用**
- 检查 ENCRYPTION_KEY 是否正确
- 确认不是导入了脱敏数据
- 验证加密字段格式是否正确
2. **加密/解密失败**
- 确保 ENCRYPTION_KEY 长度为32字符
- 检查加密数据格式 `{iv}:{data}`
- 查看日志中的解密警告
3. **数据不完整**
- 检查导出时是否使用了 --types 限制
- 确认 Redis 连接正常
- 验证账户前缀claude:account: vs claude_account:
## 测试工具
运行测试脚本验证加密处理:
```bash
node scripts/test-import-encryption.js
```
该脚本会:
1. 创建测试导出文件(加密和解密版本)
2. 显示加密前后的数据对比
3. 提供测试导入命令
4. 验证加密/解密功能

131
package-lock.json generated
View File

@@ -20,7 +20,7 @@
"google-auth-library": "^10.1.0", "google-auth-library": "^10.1.0",
"helmet": "^7.1.0", "helmet": "^7.1.0",
"https-proxy-agent": "^7.0.2", "https-proxy-agent": "^7.0.2",
"inquirer": "^9.2.15", "inquirer": "^8.2.6",
"ioredis": "^5.3.2", "ioredis": "^5.3.2",
"morgan": "^1.10.0", "morgan": "^1.10.0",
"ora": "^5.4.1", "ora": "^5.4.1",
@@ -773,15 +773,6 @@
"dev": true, "dev": true,
"license": "BSD-3-Clause" "license": "BSD-3-Clause"
}, },
"node_modules/@inquirer/figures": {
"version": "1.0.12",
"resolved": "https://registry.npmmirror.com/@inquirer/figures/-/figures-1.0.12.tgz",
"integrity": "sha512-MJttijd8rMFcKJC8NYmprWr6hD3r9Gd9qUC0XwPNwoEPWSMVJwA2MlXxF+nhZZNMY+HXsWa+o7KY2emWYIn0jQ==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/@ioredis/commands": { "node_modules/@ioredis/commands": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmmirror.com/@ioredis/commands/-/commands-1.2.0.tgz", "resolved": "https://registry.npmmirror.com/@ioredis/commands/-/commands-1.2.0.tgz",
@@ -2097,7 +2088,7 @@
}, },
"node_modules/chardet": { "node_modules/chardet": {
"version": "0.7.0", "version": "0.7.0",
"resolved": "https://registry.npmmirror.com/chardet/-/chardet-0.7.0.tgz", "resolved": "https://registry.npmjs.org/chardet/-/chardet-0.7.0.tgz",
"integrity": "sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==", "integrity": "sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==",
"license": "MIT" "license": "MIT"
}, },
@@ -2187,12 +2178,12 @@
} }
}, },
"node_modules/cli-width": { "node_modules/cli-width": {
"version": "4.1.0", "version": "3.0.0",
"resolved": "https://registry.npmmirror.com/cli-width/-/cli-width-4.1.0.tgz", "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-3.0.0.tgz",
"integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", "integrity": "sha512-FxqpkPPwu1HjuN93Omfm4h8uIanXofW0RxVEW3k5RKx+mJJYSthzNhp32Kzxxy3YAEZ/Dc/EWN1vZRY0+kOhbw==",
"license": "ISC", "license": "ISC",
"engines": { "engines": {
"node": ">= 12" "node": ">= 10"
} }
}, },
"node_modules/cliui": { "node_modules/cliui": {
@@ -3107,7 +3098,7 @@
}, },
"node_modules/external-editor": { "node_modules/external-editor": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmmirror.com/external-editor/-/external-editor-3.1.0.tgz", "resolved": "https://registry.npmjs.org/external-editor/-/external-editor-3.1.0.tgz",
"integrity": "sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew==", "integrity": "sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -3211,6 +3202,30 @@
"node": "^12.20 || >= 14.13" "node": "^12.20 || >= 14.13"
} }
}, },
"node_modules/figures": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/figures/-/figures-3.2.0.tgz",
"integrity": "sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==",
"license": "MIT",
"dependencies": {
"escape-string-regexp": "^1.0.5"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/figures/node_modules/escape-string-regexp": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz",
"integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==",
"license": "MIT",
"engines": {
"node": ">=0.8.0"
}
},
"node_modules/file-entry-cache": { "node_modules/file-entry-cache": {
"version": "6.0.1", "version": "6.0.1",
"resolved": "https://registry.npmmirror.com/file-entry-cache/-/file-entry-cache-6.0.1.tgz", "resolved": "https://registry.npmmirror.com/file-entry-cache/-/file-entry-cache-6.0.1.tgz",
@@ -3888,26 +3903,29 @@
"license": "ISC" "license": "ISC"
}, },
"node_modules/inquirer": { "node_modules/inquirer": {
"version": "9.3.7", "version": "8.2.6",
"resolved": "https://registry.npmmirror.com/inquirer/-/inquirer-9.3.7.tgz", "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-8.2.6.tgz",
"integrity": "sha512-LJKFHCSeIRq9hanN14IlOtPSTe3lNES7TYDTE2xxdAy1LS5rYphajK1qtwvj3YmQXvvk0U2Vbmcni8P9EIQW9w==", "integrity": "sha512-M1WuAmb7pn9zdFRtQYk26ZBoY043Sse0wVDdk4Bppr+JOXyQYybdtvK+l9wUibhtjdjvtoiNy8tk+EgsYIUqKg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@inquirer/figures": "^1.0.3", "ansi-escapes": "^4.2.1",
"ansi-escapes": "^4.3.2", "chalk": "^4.1.1",
"cli-width": "^4.1.0", "cli-cursor": "^3.1.0",
"external-editor": "^3.1.0", "cli-width": "^3.0.0",
"mute-stream": "1.0.0", "external-editor": "^3.0.3",
"figures": "^3.0.0",
"lodash": "^4.17.21",
"mute-stream": "0.0.8",
"ora": "^5.4.1", "ora": "^5.4.1",
"run-async": "^3.0.0", "run-async": "^2.4.0",
"rxjs": "^7.8.1", "rxjs": "^7.5.5",
"string-width": "^4.2.3", "string-width": "^4.1.0",
"strip-ansi": "^6.0.1", "strip-ansi": "^6.0.0",
"wrap-ansi": "^6.2.0", "through": "^2.3.6",
"yoctocolors-cjs": "^2.1.2" "wrap-ansi": "^6.0.1"
}, },
"engines": { "engines": {
"node": ">=18" "node": ">=12.0.0"
} }
}, },
"node_modules/ioredis": { "node_modules/ioredis": {
@@ -5005,6 +5023,12 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/lodash": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
"license": "MIT"
},
"node_modules/lodash.defaults": { "node_modules/lodash.defaults": {
"version": "4.2.0", "version": "4.2.0",
"resolved": "https://registry.npmmirror.com/lodash.defaults/-/lodash.defaults-4.2.0.tgz", "resolved": "https://registry.npmmirror.com/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
@@ -5283,13 +5307,10 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/mute-stream": { "node_modules/mute-stream": {
"version": "1.0.0", "version": "0.0.8",
"resolved": "https://registry.npmmirror.com/mute-stream/-/mute-stream-1.0.0.tgz", "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz",
"integrity": "sha512-avsJQhyd+680gKXyG/sQc0nXaC6rBkPOfyHYcFb9+hdkqQkR9bdnkJ0AMZhke0oesPqIO+mFFJ+IdBc7mst4IA==", "integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==",
"license": "ISC", "license": "ISC"
"engines": {
"node": "^14.17.0 || ^16.13.0 || >=18.0.0"
}
}, },
"node_modules/natural-compare": { "node_modules/natural-compare": {
"version": "1.4.0", "version": "1.4.0",
@@ -5600,7 +5621,7 @@
}, },
"node_modules/os-tmpdir": { "node_modules/os-tmpdir": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmmirror.com/os-tmpdir/-/os-tmpdir-1.0.2.tgz", "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz",
"integrity": "sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==", "integrity": "sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@@ -6169,9 +6190,9 @@
} }
}, },
"node_modules/run-async": { "node_modules/run-async": {
"version": "3.0.0", "version": "2.4.1",
"resolved": "https://registry.npmmirror.com/run-async/-/run-async-3.0.0.tgz", "resolved": "https://registry.npmjs.org/run-async/-/run-async-2.4.1.tgz",
"integrity": "sha512-540WwVDOMxA6dN6We19EcT9sc3hkXPw5mzRNGM3FkdN/vtE9NFvj5lFAPNwUDmJjXidm3v7TC1cTE7t17Ulm1Q==", "integrity": "sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=0.12.0" "node": ">=0.12.0"
@@ -6203,7 +6224,7 @@
}, },
"node_modules/rxjs": { "node_modules/rxjs": {
"version": "7.8.2", "version": "7.8.2",
"resolved": "https://registry.npmmirror.com/rxjs/-/rxjs-7.8.2.tgz", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
"integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
@@ -6894,9 +6915,15 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/through": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz",
"integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==",
"license": "MIT"
},
"node_modules/tmp": { "node_modules/tmp": {
"version": "0.0.33", "version": "0.0.33",
"resolved": "https://registry.npmmirror.com/tmp/-/tmp-0.0.33.tgz", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz",
"integrity": "sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==", "integrity": "sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -6956,7 +6983,7 @@
}, },
"node_modules/tslib": { "node_modules/tslib": {
"version": "2.8.1", "version": "2.8.1",
"resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD" "license": "0BSD"
}, },
@@ -7263,7 +7290,7 @@
}, },
"node_modules/wrap-ansi": { "node_modules/wrap-ansi": {
"version": "6.2.0", "version": "6.2.0",
"resolved": "https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-6.2.0.tgz", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -7354,18 +7381,6 @@
"funding": { "funding": {
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
},
"node_modules/yoctocolors-cjs": {
"version": "2.1.2",
"resolved": "https://registry.npmmirror.com/yoctocolors-cjs/-/yoctocolors-cjs-2.1.2.tgz",
"integrity": "sha512-cYVsTjKl8b+FrnidjibDWskAv7UKOfcwaVZdp/it9n1s9fU3IkgDbhdIRKCW4JDsAlECJY0ytoVPT3sK6kideA==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
} }
} }
} }

View File

@@ -10,6 +10,7 @@
"install:web": "cd web && npm install", "install:web": "cd web && npm install",
"setup": "node scripts/setup.js", "setup": "node scripts/setup.js",
"cli": "node cli/index.js", "cli": "node cli/index.js",
"init:costs": "node src/cli/initCosts.js",
"service": "node scripts/manage.js", "service": "node scripts/manage.js",
"service:start": "node scripts/manage.js start", "service:start": "node scripts/manage.js start",
"service:start:daemon": "node scripts/manage.js start -d", "service:start:daemon": "node scripts/manage.js start -d",
@@ -18,6 +19,7 @@
"service:stop": "node scripts/manage.js stop", "service:stop": "node scripts/manage.js stop",
"service:restart": "node scripts/manage.js restart", "service:restart": "node scripts/manage.js restart",
"service:restart:daemon": "node scripts/manage.js restart -d", "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:restart:d": "node scripts/manage.js restart -d",
"service:status": "node scripts/manage.js status", "service:status": "node scripts/manage.js status",
"service:logs": "node scripts/manage.js logs", "service:logs": "node scripts/manage.js logs",
@@ -25,7 +27,17 @@
"lint": "eslint src/**/*.js", "lint": "eslint src/**/*.js",
"docker:build": "docker build -t claude-relay-service .", "docker:build": "docker build -t claude-relay-service .",
"docker:up": "docker-compose up -d", "docker:up": "docker-compose up -d",
"docker:down": "docker-compose down" "docker:down": "docker-compose down",
"migrate:apikey-expiry": "node scripts/migrate-apikey-expiry.js",
"migrate:apikey-expiry:dry": "node scripts/migrate-apikey-expiry.js --dry-run",
"migrate:fix-usage-stats": "node scripts/fix-usage-stats.js",
"data:export": "node scripts/data-transfer.js export",
"data:import": "node scripts/data-transfer.js import",
"data:export:sanitized": "node scripts/data-transfer.js export --sanitize",
"data:export:enhanced": "node scripts/data-transfer-enhanced.js export",
"data:export:encrypted": "node scripts/data-transfer-enhanced.js export --decrypt=false",
"data:import:enhanced": "node scripts/data-transfer-enhanced.js import",
"data:debug": "node scripts/debug-redis-keys.js"
}, },
"dependencies": { "dependencies": {
"axios": "^1.6.0", "axios": "^1.6.0",
@@ -39,7 +51,7 @@
"google-auth-library": "^10.1.0", "google-auth-library": "^10.1.0",
"helmet": "^7.1.0", "helmet": "^7.1.0",
"https-proxy-agent": "^7.0.2", "https-proxy-agent": "^7.0.2",
"inquirer": "^9.2.15", "inquirer": "^8.2.6",
"ioredis": "^5.3.2", "ioredis": "^5.3.2",
"morgan": "^1.10.0", "morgan": "^1.10.0",
"ora": "^5.4.1", "ora": "^5.4.1",

View File

@@ -0,0 +1,994 @@
#!/usr/bin/env node
/**
* 增强版数据导出/导入工具
* 支持加密数据的处理
*/
const fs = require('fs').promises;
const crypto = require('crypto');
const redis = require('../src/models/redis');
const logger = require('../src/utils/logger');
const readline = require('readline');
const config = require('../config/config');
// 解析命令行参数
const args = process.argv.slice(2);
const command = args[0];
const params = {};
args.slice(1).forEach(arg => {
const [key, value] = arg.split('=');
params[key.replace('--', '')] = value || true;
});
// 创建 readline 接口
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout
});
async function askConfirmation(question) {
return new Promise((resolve) => {
rl.question(question + ' (yes/no): ', (answer) => {
resolve(answer.toLowerCase() === 'yes' || answer.toLowerCase() === 'y');
});
});
}
// Claude 账户解密函数
function decryptClaudeData(encryptedData) {
if (!encryptedData || !config.security.encryptionKey) return encryptedData;
try {
if (encryptedData.includes(':')) {
const parts = encryptedData.split(':');
const key = crypto.scryptSync(config.security.encryptionKey, 'salt', 32);
const iv = Buffer.from(parts[0], 'hex');
const encrypted = parts[1];
const decipher = crypto.createDecipheriv('aes-256-cbc', key, iv);
let decrypted = decipher.update(encrypted, 'hex', 'utf8');
decrypted += decipher.final('utf8');
return decrypted;
}
return encryptedData;
} catch (error) {
logger.warn(`⚠️ Failed to decrypt data: ${error.message}`);
return encryptedData;
}
}
// Gemini 账户解密函数
function decryptGeminiData(encryptedData) {
if (!encryptedData || !config.security.encryptionKey) return encryptedData;
try {
if (encryptedData.includes(':')) {
const parts = encryptedData.split(':');
const key = crypto.scryptSync(config.security.encryptionKey, 'gemini-account-salt', 32);
const iv = Buffer.from(parts[0], 'hex');
const encrypted = parts[1];
const decipher = crypto.createDecipheriv('aes-256-cbc', key, iv);
let decrypted = decipher.update(encrypted, 'hex', 'utf8');
decrypted += decipher.final('utf8');
return decrypted;
}
return encryptedData;
} catch (error) {
logger.warn(`⚠️ Failed to decrypt data: ${error.message}`);
return encryptedData;
}
}
// 数据加密函数(用于导入)
function encryptClaudeData(data) {
if (!data || !config.security.encryptionKey) return data;
const key = crypto.scryptSync(config.security.encryptionKey, 'salt', 32);
const iv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv('aes-256-cbc', key, iv);
let encrypted = cipher.update(data, 'utf8', 'hex');
encrypted += cipher.final('hex');
return iv.toString('hex') + ':' + encrypted;
}
function encryptGeminiData(data) {
if (!data || !config.security.encryptionKey) return data;
const key = crypto.scryptSync(config.security.encryptionKey, 'gemini-account-salt', 32);
const iv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv('aes-256-cbc', key, iv);
let encrypted = cipher.update(data, 'utf8', 'hex');
encrypted += cipher.final('hex');
return iv.toString('hex') + ':' + encrypted;
}
// 导出使用统计数据
async function exportUsageStats(keyId) {
try {
const stats = {
total: {},
daily: {},
monthly: {},
hourly: {},
models: {}
};
// 导出总统计
const totalKey = `usage:${keyId}`;
const totalData = await redis.client.hgetall(totalKey);
if (totalData && Object.keys(totalData).length > 0) {
stats.total = totalData;
}
// 导出每日统计最近30天
const today = new Date();
for (let i = 0; i < 30; i++) {
const date = new Date(today);
date.setDate(date.getDate() - i);
const dateStr = date.toISOString().split('T')[0];
const dailyKey = `usage:daily:${keyId}:${dateStr}`;
const dailyData = await redis.client.hgetall(dailyKey);
if (dailyData && Object.keys(dailyData).length > 0) {
stats.daily[dateStr] = dailyData;
}
}
// 导出每月统计最近12个月
for (let i = 0; i < 12; i++) {
const date = new Date(today);
date.setMonth(date.getMonth() - i);
const monthStr = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`;
const monthlyKey = `usage:monthly:${keyId}:${monthStr}`;
const monthlyData = await redis.client.hgetall(monthlyKey);
if (monthlyData && Object.keys(monthlyData).length > 0) {
stats.monthly[monthStr] = monthlyData;
}
}
// 导出小时统计最近24小时
for (let i = 0; i < 24; i++) {
const date = new Date(today);
date.setHours(date.getHours() - i);
const dateStr = date.toISOString().split('T')[0];
const hour = String(date.getHours()).padStart(2, '0');
const hourKey = `${dateStr}:${hour}`;
const hourlyKey = `usage:hourly:${keyId}:${hourKey}`;
const hourlyData = await redis.client.hgetall(hourlyKey);
if (hourlyData && Object.keys(hourlyData).length > 0) {
stats.hourly[hourKey] = hourlyData;
}
}
// 导出模型统计
// 每日模型统计
const modelDailyPattern = `usage:${keyId}:model:daily:*`;
const modelDailyKeys = await redis.client.keys(modelDailyPattern);
for (const key of modelDailyKeys) {
const match = key.match(/usage:.+:model:daily:(.+):(\d{4}-\d{2}-\d{2})$/);
if (match) {
const model = match[1];
const date = match[2];
const data = await redis.client.hgetall(key);
if (data && Object.keys(data).length > 0) {
if (!stats.models[model]) stats.models[model] = { daily: {}, monthly: {} };
stats.models[model].daily[date] = data;
}
}
}
// 每月模型统计
const modelMonthlyPattern = `usage:${keyId}:model:monthly:*`;
const modelMonthlyKeys = await redis.client.keys(modelMonthlyPattern);
for (const key of modelMonthlyKeys) {
const match = key.match(/usage:.+:model:monthly:(.+):(\d{4}-\d{2})$/);
if (match) {
const model = match[1];
const month = match[2];
const data = await redis.client.hgetall(key);
if (data && Object.keys(data).length > 0) {
if (!stats.models[model]) stats.models[model] = { daily: {}, monthly: {} };
stats.models[model].monthly[month] = data;
}
}
}
return stats;
} catch (error) {
logger.warn(`⚠️ Failed to export usage stats for ${keyId}: ${error.message}`);
return null;
}
}
// 导入使用统计数据
async function importUsageStats(keyId, stats) {
try {
if (!stats) return;
const pipeline = redis.client.pipeline();
let importCount = 0;
// 导入总统计
if (stats.total && Object.keys(stats.total).length > 0) {
for (const [field, value] of Object.entries(stats.total)) {
pipeline.hset(`usage:${keyId}`, field, value);
}
importCount++;
}
// 导入每日统计
if (stats.daily) {
for (const [date, data] of Object.entries(stats.daily)) {
for (const [field, value] of Object.entries(data)) {
pipeline.hset(`usage:daily:${keyId}:${date}`, field, value);
}
importCount++;
}
}
// 导入每月统计
if (stats.monthly) {
for (const [month, data] of Object.entries(stats.monthly)) {
for (const [field, value] of Object.entries(data)) {
pipeline.hset(`usage:monthly:${keyId}:${month}`, field, value);
}
importCount++;
}
}
// 导入小时统计
if (stats.hourly) {
for (const [hour, data] of Object.entries(stats.hourly)) {
for (const [field, value] of Object.entries(data)) {
pipeline.hset(`usage:hourly:${keyId}:${hour}`, field, value);
}
importCount++;
}
}
// 导入模型统计
if (stats.models) {
for (const [model, modelStats] of Object.entries(stats.models)) {
// 每日模型统计
if (modelStats.daily) {
for (const [date, data] of Object.entries(modelStats.daily)) {
for (const [field, value] of Object.entries(data)) {
pipeline.hset(`usage:${keyId}:model:daily:${model}:${date}`, field, value);
}
importCount++;
}
}
// 每月模型统计
if (modelStats.monthly) {
for (const [month, data] of Object.entries(modelStats.monthly)) {
for (const [field, value] of Object.entries(data)) {
pipeline.hset(`usage:${keyId}:model:monthly:${model}:${month}`, field, value);
}
importCount++;
}
}
}
}
await pipeline.exec();
logger.info(` 📊 Imported ${importCount} usage stat entries for API Key ${keyId}`);
} catch (error) {
logger.warn(`⚠️ Failed to import usage stats for ${keyId}: ${error.message}`);
}
}
// 数据脱敏函数
function sanitizeData(data, type) {
const sanitized = { ...data };
switch (type) {
case 'apikey':
if (sanitized.apiKey) {
sanitized.apiKey = sanitized.apiKey.substring(0, 10) + '...[REDACTED]';
}
break;
case 'claude_account':
if (sanitized.email) sanitized.email = '[REDACTED]';
if (sanitized.password) sanitized.password = '[REDACTED]';
if (sanitized.accessToken) sanitized.accessToken = '[REDACTED]';
if (sanitized.refreshToken) sanitized.refreshToken = '[REDACTED]';
if (sanitized.claudeAiOauth) sanitized.claudeAiOauth = '[REDACTED]';
if (sanitized.proxyPassword) sanitized.proxyPassword = '[REDACTED]';
break;
case 'gemini_account':
if (sanitized.geminiOauth) sanitized.geminiOauth = '[REDACTED]';
if (sanitized.accessToken) sanitized.accessToken = '[REDACTED]';
if (sanitized.refreshToken) sanitized.refreshToken = '[REDACTED]';
if (sanitized.proxyPassword) sanitized.proxyPassword = '[REDACTED]';
break;
case 'admin':
if (sanitized.password) sanitized.password = '[REDACTED]';
break;
}
return sanitized;
}
// 导出数据
async function exportData() {
try {
const outputFile = params.output || `backup-${new Date().toISOString().split('T')[0]}.json`;
const types = params.types ? params.types.split(',') : ['all'];
const shouldSanitize = params.sanitize === true;
const shouldDecrypt = params.decrypt !== false; // 默认解密
logger.info('🔄 Starting data export...');
logger.info(`📁 Output file: ${outputFile}`);
logger.info(`📋 Data types: ${types.join(', ')}`);
logger.info(`🔒 Sanitize sensitive data: ${shouldSanitize ? 'YES' : 'NO'}`);
logger.info(`🔓 Decrypt data: ${shouldDecrypt ? 'YES' : 'NO'}`);
await redis.connect();
logger.success('✅ Connected to Redis');
const exportData = {
metadata: {
version: '2.0',
exportDate: new Date().toISOString(),
sanitized: shouldSanitize,
decrypted: shouldDecrypt,
types: types
},
data: {}
};
// 导出 API Keys
if (types.includes('all') || types.includes('apikeys')) {
logger.info('📤 Exporting API Keys...');
const keys = await redis.client.keys('apikey:*');
const apiKeys = [];
for (const key of keys) {
if (key === 'apikey:hash_map') continue;
const data = await redis.client.hgetall(key);
if (data && Object.keys(data).length > 0) {
// 获取该 API Key 的 ID
const keyId = data.id;
// 导出使用统计数据
if (keyId && (types.includes('all') || types.includes('stats'))) {
data.usageStats = await exportUsageStats(keyId);
}
apiKeys.push(shouldSanitize ? sanitizeData(data, 'apikey') : data);
}
}
exportData.data.apiKeys = apiKeys;
logger.success(`✅ Exported ${apiKeys.length} API Keys`);
}
// 导出 Claude 账户
if (types.includes('all') || types.includes('accounts')) {
logger.info('📤 Exporting Claude accounts...');
const keys = await redis.client.keys('claude:account:*');
logger.info(`Found ${keys.length} Claude account keys in Redis`);
const accounts = [];
for (const key of keys) {
const data = await redis.client.hgetall(key);
if (data && Object.keys(data).length > 0) {
// 解密敏感字段
if (shouldDecrypt && !shouldSanitize) {
if (data.email) data.email = decryptClaudeData(data.email);
if (data.password) data.password = decryptClaudeData(data.password);
if (data.accessToken) data.accessToken = decryptClaudeData(data.accessToken);
if (data.refreshToken) data.refreshToken = decryptClaudeData(data.refreshToken);
if (data.claudeAiOauth) {
const decrypted = decryptClaudeData(data.claudeAiOauth);
try {
data.claudeAiOauth = JSON.parse(decrypted);
} catch (e) {
data.claudeAiOauth = decrypted;
}
}
}
accounts.push(shouldSanitize ? sanitizeData(data, 'claude_account') : data);
}
}
exportData.data.claudeAccounts = accounts;
logger.success(`✅ Exported ${accounts.length} Claude accounts`);
// 导出 Gemini 账户
logger.info('📤 Exporting Gemini accounts...');
const geminiKeys = await redis.client.keys('gemini_account:*');
logger.info(`Found ${geminiKeys.length} Gemini account keys in Redis`);
const geminiAccounts = [];
for (const key of geminiKeys) {
const data = await redis.client.hgetall(key);
if (data && Object.keys(data).length > 0) {
// 解密敏感字段
if (shouldDecrypt && !shouldSanitize) {
if (data.geminiOauth) {
const decrypted = decryptGeminiData(data.geminiOauth);
try {
data.geminiOauth = JSON.parse(decrypted);
} catch (e) {
data.geminiOauth = decrypted;
}
}
if (data.accessToken) data.accessToken = decryptGeminiData(data.accessToken);
if (data.refreshToken) data.refreshToken = decryptGeminiData(data.refreshToken);
}
geminiAccounts.push(shouldSanitize ? sanitizeData(data, 'gemini_account') : data);
}
}
exportData.data.geminiAccounts = geminiAccounts;
logger.success(`✅ Exported ${geminiAccounts.length} Gemini accounts`);
}
// 导出管理员
if (types.includes('all') || types.includes('admins')) {
logger.info('📤 Exporting admins...');
const keys = await redis.client.keys('admin:*');
const admins = [];
for (const key of keys) {
if (key.includes('admin_username:')) continue;
const data = await redis.client.hgetall(key);
if (data && Object.keys(data).length > 0) {
admins.push(shouldSanitize ? sanitizeData(data, 'admin') : data);
}
}
exportData.data.admins = admins;
logger.success(`✅ Exported ${admins.length} admins`);
}
// 导出全局模型统计(如果需要)
if (types.includes('all') || types.includes('stats')) {
logger.info('📤 Exporting global model statistics...');
const globalStats = {
daily: {},
monthly: {},
hourly: {}
};
// 导出全局每日模型统计
const globalDailyPattern = 'usage:model:daily:*';
const globalDailyKeys = await redis.client.keys(globalDailyPattern);
for (const key of globalDailyKeys) {
const match = key.match(/usage:model:daily:(.+):(\d{4}-\d{2}-\d{2})$/);
if (match) {
const model = match[1];
const date = match[2];
const data = await redis.client.hgetall(key);
if (data && Object.keys(data).length > 0) {
if (!globalStats.daily[date]) globalStats.daily[date] = {};
globalStats.daily[date][model] = data;
}
}
}
// 导出全局每月模型统计
const globalMonthlyPattern = 'usage:model:monthly:*';
const globalMonthlyKeys = await redis.client.keys(globalMonthlyPattern);
for (const key of globalMonthlyKeys) {
const match = key.match(/usage:model:monthly:(.+):(\d{4}-\d{2})$/);
if (match) {
const model = match[1];
const month = match[2];
const data = await redis.client.hgetall(key);
if (data && Object.keys(data).length > 0) {
if (!globalStats.monthly[month]) globalStats.monthly[month] = {};
globalStats.monthly[month][model] = data;
}
}
}
// 导出全局每小时模型统计
const globalHourlyPattern = 'usage:model:hourly:*';
const globalHourlyKeys = await redis.client.keys(globalHourlyPattern);
for (const key of globalHourlyKeys) {
const match = key.match(/usage:model:hourly:(.+):(\d{4}-\d{2}-\d{2}:\d{2})$/);
if (match) {
const model = match[1];
const hour = match[2];
const data = await redis.client.hgetall(key);
if (data && Object.keys(data).length > 0) {
if (!globalStats.hourly[hour]) globalStats.hourly[hour] = {};
globalStats.hourly[hour][model] = data;
}
}
}
exportData.data.globalModelStats = globalStats;
logger.success(`✅ Exported global model statistics`);
}
// 写入文件
await fs.writeFile(outputFile, JSON.stringify(exportData, null, 2));
// 显示导出摘要
console.log('\n' + '='.repeat(60));
console.log('✅ Export Complete!');
console.log('='.repeat(60));
console.log(`Output file: ${outputFile}`);
console.log(`File size: ${(await fs.stat(outputFile)).size} bytes`);
if (exportData.data.apiKeys) {
console.log(`API Keys: ${exportData.data.apiKeys.length}`);
}
if (exportData.data.claudeAccounts) {
console.log(`Claude Accounts: ${exportData.data.claudeAccounts.length}`);
}
if (exportData.data.geminiAccounts) {
console.log(`Gemini Accounts: ${exportData.data.geminiAccounts.length}`);
}
if (exportData.data.admins) {
console.log(`Admins: ${exportData.data.admins.length}`);
}
console.log('='.repeat(60));
if (shouldSanitize) {
logger.warn('⚠️ Sensitive data has been sanitized in this export.');
}
if (shouldDecrypt) {
logger.info('🔓 Encrypted data has been decrypted for portability.');
}
} catch (error) {
logger.error('💥 Export failed:', error);
process.exit(1);
} finally {
await redis.disconnect();
rl.close();
}
}
// 显示帮助信息
function showHelp() {
console.log(`
Enhanced Data Transfer Tool for Claude Relay Service
This tool handles encrypted data export/import between environments.
Usage:
node scripts/data-transfer-enhanced.js <command> [options]
Commands:
export Export data from Redis to a JSON file
import Import data from a JSON file to Redis
Export Options:
--output=FILE Output filename (default: backup-YYYY-MM-DD.json)
--types=TYPE,... Data types: apikeys,accounts,admins,stats,all (default: all)
stats: Include usage statistics with API keys
--sanitize Remove sensitive data from export
--decrypt=false Keep data encrypted (default: true - decrypt for portability)
Import Options:
--input=FILE Input filename (required)
--force Overwrite existing data without asking
--skip-conflicts Skip conflicting data without asking
Important Notes:
- The tool automatically handles encryption/decryption during import
- If importing decrypted data, it will be re-encrypted automatically
- If importing encrypted data, it will be stored as-is
- Sanitized exports cannot be properly imported (missing sensitive data)
Examples:
# Export all data with decryption (for migration)
node scripts/data-transfer-enhanced.js export
# Export without decrypting (for backup)
node scripts/data-transfer-enhanced.js export --decrypt=false
# Import data (auto-handles encryption)
node scripts/data-transfer-enhanced.js import --input=backup.json
# Import with force overwrite
node scripts/data-transfer-enhanced.js import --input=backup.json --force
`);
}
// 导入数据
async function importData() {
try {
const inputFile = params.input;
if (!inputFile) {
logger.error('❌ Please specify input file with --input=filename.json');
process.exit(1);
}
const forceOverwrite = params.force === true;
const skipConflicts = params['skip-conflicts'] === true;
logger.info('🔄 Starting data import...');
logger.info(`📁 Input file: ${inputFile}`);
logger.info(`⚡ Mode: ${forceOverwrite ? 'FORCE OVERWRITE' : (skipConflicts ? 'SKIP CONFLICTS' : 'ASK ON CONFLICT')}`);
// 读取文件
const fileContent = await fs.readFile(inputFile, 'utf8');
const importData = JSON.parse(fileContent);
// 验证文件格式
if (!importData.metadata || !importData.data) {
logger.error('❌ Invalid backup file format');
process.exit(1);
}
logger.info(`📅 Backup date: ${importData.metadata.exportDate}`);
logger.info(`🔒 Sanitized: ${importData.metadata.sanitized ? 'YES' : 'NO'}`);
logger.info(`🔓 Decrypted: ${importData.metadata.decrypted ? 'YES' : 'NO'}`);
if (importData.metadata.sanitized) {
logger.warn('⚠️ This backup contains sanitized data. Sensitive fields will be missing!');
const proceed = await askConfirmation('Continue with sanitized data?');
if (!proceed) {
logger.info('❌ Import cancelled');
return;
}
}
// 显示导入摘要
console.log('\n' + '='.repeat(60));
console.log('📋 Import Summary:');
console.log('='.repeat(60));
if (importData.data.apiKeys) {
console.log(`API Keys to import: ${importData.data.apiKeys.length}`);
}
if (importData.data.claudeAccounts) {
console.log(`Claude Accounts to import: ${importData.data.claudeAccounts.length}`);
}
if (importData.data.geminiAccounts) {
console.log(`Gemini Accounts to import: ${importData.data.geminiAccounts.length}`);
}
if (importData.data.admins) {
console.log(`Admins to import: ${importData.data.admins.length}`);
}
console.log('='.repeat(60) + '\n');
// 确认导入
const confirmed = await askConfirmation('⚠️ Proceed with import?');
if (!confirmed) {
logger.info('❌ Import cancelled');
return;
}
// 连接 Redis
await redis.connect();
logger.success('✅ Connected to Redis');
const stats = {
imported: 0,
skipped: 0,
errors: 0
};
// 导入 API Keys
if (importData.data.apiKeys) {
logger.info('\n📥 Importing API Keys...');
for (const apiKey of importData.data.apiKeys) {
try {
const exists = await redis.client.exists(`apikey:${apiKey.id}`);
if (exists && !forceOverwrite) {
if (skipConflicts) {
logger.warn(`⏭️ Skipped existing API Key: ${apiKey.name} (${apiKey.id})`);
stats.skipped++;
continue;
} else {
const overwrite = await askConfirmation(`API Key "${apiKey.name}" (${apiKey.id}) exists. Overwrite?`);
if (!overwrite) {
stats.skipped++;
continue;
}
}
}
// 保存使用统计数据以便单独导入
const usageStats = apiKey.usageStats;
// 从apiKey对象中删除usageStats字段避免存储到主键中
const apiKeyData = { ...apiKey };
delete apiKeyData.usageStats;
// 使用 hset 存储到哈希表
const pipeline = redis.client.pipeline();
for (const [field, value] of Object.entries(apiKeyData)) {
pipeline.hset(`apikey:${apiKey.id}`, field, value);
}
await pipeline.exec();
// 更新哈希映射
if (apiKey.apiKey && !importData.metadata.sanitized) {
await redis.client.hset('apikey:hash_map', apiKey.apiKey, apiKey.id);
}
// 导入使用统计数据
if (usageStats) {
await importUsageStats(apiKey.id, usageStats);
}
logger.success(`✅ Imported API Key: ${apiKey.name} (${apiKey.id})`);
stats.imported++;
} catch (error) {
logger.error(`❌ Failed to import API Key ${apiKey.id}:`, error.message);
stats.errors++;
}
}
}
// 导入 Claude 账户
if (importData.data.claudeAccounts) {
logger.info('\n📥 Importing Claude accounts...');
for (const account of importData.data.claudeAccounts) {
try {
const exists = await redis.client.exists(`claude:account:${account.id}`);
if (exists && !forceOverwrite) {
if (skipConflicts) {
logger.warn(`⏭️ Skipped existing Claude account: ${account.name} (${account.id})`);
stats.skipped++;
continue;
} else {
const overwrite = await askConfirmation(`Claude account "${account.name}" (${account.id}) exists. Overwrite?`);
if (!overwrite) {
stats.skipped++;
continue;
}
}
}
// 复制账户数据以避免修改原始数据
const accountData = { ...account };
// 如果数据已解密且不是脱敏数据,需要重新加密
if (importData.metadata.decrypted && !importData.metadata.sanitized) {
logger.info(`🔐 Re-encrypting sensitive data for Claude account: ${account.name}`);
if (accountData.email) accountData.email = encryptClaudeData(accountData.email);
if (accountData.password) accountData.password = encryptClaudeData(accountData.password);
if (accountData.accessToken) accountData.accessToken = encryptClaudeData(accountData.accessToken);
if (accountData.refreshToken) accountData.refreshToken = encryptClaudeData(accountData.refreshToken);
if (accountData.claudeAiOauth) {
// 如果是对象,先序列化再加密
const oauthStr = typeof accountData.claudeAiOauth === 'object' ?
JSON.stringify(accountData.claudeAiOauth) : accountData.claudeAiOauth;
accountData.claudeAiOauth = encryptClaudeData(oauthStr);
}
}
// 使用 hset 存储到哈希表
const pipeline = redis.client.pipeline();
for (const [field, value] of Object.entries(accountData)) {
if (field === 'claudeAiOauth' && typeof value === 'object') {
// 确保对象被序列化
pipeline.hset(`claude:account:${account.id}`, field, JSON.stringify(value));
} else {
pipeline.hset(`claude:account:${account.id}`, field, value);
}
}
await pipeline.exec();
logger.success(`✅ Imported Claude account: ${account.name} (${account.id})`);
stats.imported++;
} catch (error) {
logger.error(`❌ Failed to import Claude account ${account.id}:`, error.message);
stats.errors++;
}
}
}
// 导入 Gemini 账户
if (importData.data.geminiAccounts) {
logger.info('\n📥 Importing Gemini accounts...');
for (const account of importData.data.geminiAccounts) {
try {
const exists = await redis.client.exists(`gemini_account:${account.id}`);
if (exists && !forceOverwrite) {
if (skipConflicts) {
logger.warn(`⏭️ Skipped existing Gemini account: ${account.name} (${account.id})`);
stats.skipped++;
continue;
} else {
const overwrite = await askConfirmation(`Gemini account "${account.name}" (${account.id}) exists. Overwrite?`);
if (!overwrite) {
stats.skipped++;
continue;
}
}
}
// 复制账户数据以避免修改原始数据
const accountData = { ...account };
// 如果数据已解密且不是脱敏数据,需要重新加密
if (importData.metadata.decrypted && !importData.metadata.sanitized) {
logger.info(`🔐 Re-encrypting sensitive data for Gemini account: ${account.name}`);
if (accountData.geminiOauth) {
const oauthStr = typeof accountData.geminiOauth === 'object' ?
JSON.stringify(accountData.geminiOauth) : accountData.geminiOauth;
accountData.geminiOauth = encryptGeminiData(oauthStr);
}
if (accountData.accessToken) accountData.accessToken = encryptGeminiData(accountData.accessToken);
if (accountData.refreshToken) accountData.refreshToken = encryptGeminiData(accountData.refreshToken);
}
// 使用 hset 存储到哈希表
const pipeline = redis.client.pipeline();
for (const [field, value] of Object.entries(accountData)) {
pipeline.hset(`gemini_account:${account.id}`, field, value);
}
await pipeline.exec();
logger.success(`✅ Imported Gemini account: ${account.name} (${account.id})`);
stats.imported++;
} catch (error) {
logger.error(`❌ Failed to import Gemini account ${account.id}:`, error.message);
stats.errors++;
}
}
}
// 导入管理员账户
if (importData.data.admins) {
logger.info('\n📥 Importing admins...');
for (const admin of importData.data.admins) {
try {
const exists = await redis.client.exists(`admin:${admin.id}`);
if (exists && !forceOverwrite) {
if (skipConflicts) {
logger.warn(`⏭️ Skipped existing admin: ${admin.username} (${admin.id})`);
stats.skipped++;
continue;
} else {
const overwrite = await askConfirmation(`Admin "${admin.username}" (${admin.id}) exists. Overwrite?`);
if (!overwrite) {
stats.skipped++;
continue;
}
}
}
// 使用 hset 存储到哈希表
const pipeline = redis.client.pipeline();
for (const [field, value] of Object.entries(admin)) {
pipeline.hset(`admin:${admin.id}`, field, value);
}
await pipeline.exec();
// 更新用户名映射
await redis.client.set(`admin_username:${admin.username}`, admin.id);
logger.success(`✅ Imported admin: ${admin.username} (${admin.id})`);
stats.imported++;
} catch (error) {
logger.error(`❌ Failed to import admin ${admin.id}:`, error.message);
stats.errors++;
}
}
}
// 导入全局模型统计
if (importData.data.globalModelStats) {
logger.info('\n📥 Importing global model statistics...');
try {
const globalStats = importData.data.globalModelStats;
const pipeline = redis.client.pipeline();
let globalStatCount = 0;
// 导入每日统计
if (globalStats.daily) {
for (const [date, models] of Object.entries(globalStats.daily)) {
for (const [model, data] of Object.entries(models)) {
for (const [field, value] of Object.entries(data)) {
pipeline.hset(`usage:model:daily:${model}:${date}`, field, value);
}
globalStatCount++;
}
}
}
// 导入每月统计
if (globalStats.monthly) {
for (const [month, models] of Object.entries(globalStats.monthly)) {
for (const [model, data] of Object.entries(models)) {
for (const [field, value] of Object.entries(data)) {
pipeline.hset(`usage:model:monthly:${model}:${month}`, field, value);
}
globalStatCount++;
}
}
}
// 导入每小时统计
if (globalStats.hourly) {
for (const [hour, models] of Object.entries(globalStats.hourly)) {
for (const [model, data] of Object.entries(models)) {
for (const [field, value] of Object.entries(data)) {
pipeline.hset(`usage:model:hourly:${model}:${hour}`, field, value);
}
globalStatCount++;
}
}
}
await pipeline.exec();
logger.success(`✅ Imported ${globalStatCount} global model stat entries`);
stats.imported += globalStatCount;
} catch (error) {
logger.error(`❌ Failed to import global model stats:`, error.message);
stats.errors++;
}
}
// 显示导入结果
console.log('\n' + '='.repeat(60));
console.log('✅ Import Complete!');
console.log('='.repeat(60));
console.log(`Successfully imported: ${stats.imported}`);
console.log(`Skipped: ${stats.skipped}`);
console.log(`Errors: ${stats.errors}`);
console.log('='.repeat(60));
} catch (error) {
logger.error('💥 Import failed:', error);
process.exit(1);
} finally {
await redis.disconnect();
rl.close();
}
}
// 主函数
async function main() {
if (!command || command === '--help' || command === 'help') {
showHelp();
process.exit(0);
}
switch (command) {
case 'export':
await exportData();
break;
case 'import':
await importData();
break;
default:
logger.error(`❌ Unknown command: ${command}`);
showHelp();
process.exit(1);
}
}
// 运行
main().catch(error => {
logger.error('💥 Unexpected error:', error);
process.exit(1);
});

517
scripts/data-transfer.js Normal file
View File

@@ -0,0 +1,517 @@
#!/usr/bin/env node
/**
* 数据导出/导入工具
*
* 使用方法:
* 导出: node scripts/data-transfer.js export --output=backup.json [options]
* 导入: node scripts/data-transfer.js import --input=backup.json [options]
*
* 选项:
* --types: 要导出/导入的数据类型apikeys,accounts,admins,all
* --sanitize: 导出时脱敏敏感数据
* --force: 导入时强制覆盖已存在的数据
* --skip-conflicts: 导入时跳过冲突的数据
*/
const fs = require('fs').promises;
const path = require('path');
const crypto = require('crypto');
const redis = require('../src/models/redis');
const logger = require('../src/utils/logger');
const readline = require('readline');
// 解析命令行参数
const args = process.argv.slice(2);
const command = args[0];
const params = {};
args.slice(1).forEach(arg => {
const [key, value] = arg.split('=');
params[key.replace('--', '')] = value || true;
});
// 创建 readline 接口
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout
});
async function askConfirmation(question) {
return new Promise((resolve) => {
rl.question(question + ' (yes/no): ', (answer) => {
resolve(answer.toLowerCase() === 'yes' || answer.toLowerCase() === 'y');
});
});
}
// 数据脱敏函数
function sanitizeData(data, type) {
const sanitized = { ...data };
switch (type) {
case 'apikey':
// 隐藏 API Key 的大部分内容
if (sanitized.apiKey) {
sanitized.apiKey = sanitized.apiKey.substring(0, 10) + '...[REDACTED]';
}
break;
case 'claude_account':
case 'gemini_account':
// 隐藏 OAuth tokens
if (sanitized.accessToken) {
sanitized.accessToken = '[REDACTED]';
}
if (sanitized.refreshToken) {
sanitized.refreshToken = '[REDACTED]';
}
if (sanitized.claudeAiOauth) {
sanitized.claudeAiOauth = '[REDACTED]';
}
// 隐藏代理密码
if (sanitized.proxyPassword) {
sanitized.proxyPassword = '[REDACTED]';
}
break;
case 'admin':
// 隐藏管理员密码
if (sanitized.password) {
sanitized.password = '[REDACTED]';
}
break;
}
return sanitized;
}
// 导出数据
async function exportData() {
try {
const outputFile = params.output || `backup-${new Date().toISOString().split('T')[0]}.json`;
const types = params.types ? params.types.split(',') : ['all'];
const shouldSanitize = params.sanitize === true;
logger.info('🔄 Starting data export...');
logger.info(`📁 Output file: ${outputFile}`);
logger.info(`📋 Data types: ${types.join(', ')}`);
logger.info(`🔒 Sanitize sensitive data: ${shouldSanitize ? 'YES' : 'NO'}`);
// 连接 Redis
await redis.connect();
logger.success('✅ Connected to Redis');
const exportData = {
metadata: {
version: '1.0',
exportDate: new Date().toISOString(),
sanitized: shouldSanitize,
types: types
},
data: {}
};
// 导出 API Keys
if (types.includes('all') || types.includes('apikeys')) {
logger.info('📤 Exporting API Keys...');
const keys = await redis.client.keys('apikey:*');
const apiKeys = [];
for (const key of keys) {
if (key === 'apikey:hash_map') continue;
// 使用 hgetall 而不是 get因为数据存储在哈希表中
const data = await redis.client.hgetall(key);
if (data && Object.keys(data).length > 0) {
apiKeys.push(shouldSanitize ? sanitizeData(data, 'apikey') : data);
}
}
exportData.data.apiKeys = apiKeys;
logger.success(`✅ Exported ${apiKeys.length} API Keys`);
}
// 导出 Claude 账户
if (types.includes('all') || types.includes('accounts')) {
logger.info('📤 Exporting Claude accounts...');
// 注意Claude 账户使用 claude:account: 前缀,不是 claude_account:
const keys = await redis.client.keys('claude:account:*');
logger.info(`Found ${keys.length} Claude account keys in Redis`);
const accounts = [];
for (const key of keys) {
// 使用 hgetall 而不是 get因为数据存储在哈希表中
const data = await redis.client.hgetall(key);
if (data && Object.keys(data).length > 0) {
// 解析 JSON 字段(如果存在)
if (data.claudeAiOauth) {
try {
data.claudeAiOauth = JSON.parse(data.claudeAiOauth);
} catch (e) {
// 保持原样
}
}
accounts.push(shouldSanitize ? sanitizeData(data, 'claude_account') : data);
}
}
exportData.data.claudeAccounts = accounts;
logger.success(`✅ Exported ${accounts.length} Claude accounts`);
// 导出 Gemini 账户
logger.info('📤 Exporting Gemini accounts...');
const geminiKeys = await redis.client.keys('gemini_account:*');
logger.info(`Found ${geminiKeys.length} Gemini account keys in Redis`);
const geminiAccounts = [];
for (const key of geminiKeys) {
// 使用 hgetall 而不是 get因为数据存储在哈希表中
const data = await redis.client.hgetall(key);
if (data && Object.keys(data).length > 0) {
geminiAccounts.push(shouldSanitize ? sanitizeData(data, 'gemini_account') : data);
}
}
exportData.data.geminiAccounts = geminiAccounts;
logger.success(`✅ Exported ${geminiAccounts.length} Gemini accounts`);
}
// 导出管理员
if (types.includes('all') || types.includes('admins')) {
logger.info('📤 Exporting admins...');
const keys = await redis.client.keys('admin:*');
const admins = [];
for (const key of keys) {
if (key.includes('admin_username:')) continue;
// 使用 hgetall 而不是 get因为数据存储在哈希表中
const data = await redis.client.hgetall(key);
if (data && Object.keys(data).length > 0) {
admins.push(shouldSanitize ? sanitizeData(data, 'admin') : data);
}
}
exportData.data.admins = admins;
logger.success(`✅ Exported ${admins.length} admins`);
}
// 写入文件
await fs.writeFile(outputFile, JSON.stringify(exportData, null, 2));
// 显示导出摘要
console.log('\n' + '='.repeat(60));
console.log('✅ Export Complete!');
console.log('='.repeat(60));
console.log(`Output file: ${outputFile}`);
console.log(`File size: ${(await fs.stat(outputFile)).size} bytes`);
if (exportData.data.apiKeys) {
console.log(`API Keys: ${exportData.data.apiKeys.length}`);
}
if (exportData.data.claudeAccounts) {
console.log(`Claude Accounts: ${exportData.data.claudeAccounts.length}`);
}
if (exportData.data.geminiAccounts) {
console.log(`Gemini Accounts: ${exportData.data.geminiAccounts.length}`);
}
if (exportData.data.admins) {
console.log(`Admins: ${exportData.data.admins.length}`);
}
console.log('='.repeat(60));
if (shouldSanitize) {
logger.warn('⚠️ Sensitive data has been sanitized in this export.');
}
} catch (error) {
logger.error('💥 Export failed:', error);
process.exit(1);
} finally {
await redis.disconnect();
rl.close();
}
}
// 导入数据
async function importData() {
try {
const inputFile = params.input;
if (!inputFile) {
logger.error('❌ Please specify input file with --input=filename.json');
process.exit(1);
}
const forceOverwrite = params.force === true;
const skipConflicts = params['skip-conflicts'] === true;
logger.info('🔄 Starting data import...');
logger.info(`📁 Input file: ${inputFile}`);
logger.info(`⚡ Mode: ${forceOverwrite ? 'FORCE OVERWRITE' : (skipConflicts ? 'SKIP CONFLICTS' : 'ASK ON CONFLICT')}`);
// 读取文件
const fileContent = await fs.readFile(inputFile, 'utf8');
const importData = JSON.parse(fileContent);
// 验证文件格式
if (!importData.metadata || !importData.data) {
logger.error('❌ Invalid backup file format');
process.exit(1);
}
logger.info(`📅 Backup date: ${importData.metadata.exportDate}`);
logger.info(`🔒 Sanitized: ${importData.metadata.sanitized ? 'YES' : 'NO'}`);
if (importData.metadata.sanitized) {
logger.warn('⚠️ This backup contains sanitized data. Sensitive fields will be missing!');
const proceed = await askConfirmation('Continue with sanitized data?');
if (!proceed) {
logger.info('❌ Import cancelled');
return;
}
}
// 显示导入摘要
console.log('\n' + '='.repeat(60));
console.log('📋 Import Summary:');
console.log('='.repeat(60));
if (importData.data.apiKeys) {
console.log(`API Keys to import: ${importData.data.apiKeys.length}`);
}
if (importData.data.claudeAccounts) {
console.log(`Claude Accounts to import: ${importData.data.claudeAccounts.length}`);
}
if (importData.data.geminiAccounts) {
console.log(`Gemini Accounts to import: ${importData.data.geminiAccounts.length}`);
}
if (importData.data.admins) {
console.log(`Admins to import: ${importData.data.admins.length}`);
}
console.log('='.repeat(60) + '\n');
// 确认导入
const confirmed = await askConfirmation('⚠️ Proceed with import?');
if (!confirmed) {
logger.info('❌ Import cancelled');
return;
}
// 连接 Redis
await redis.connect();
logger.success('✅ Connected to Redis');
const stats = {
imported: 0,
skipped: 0,
errors: 0
};
// 导入 API Keys
if (importData.data.apiKeys) {
logger.info('\n📥 Importing API Keys...');
for (const apiKey of importData.data.apiKeys) {
try {
const exists = await redis.client.exists(`apikey:${apiKey.id}`);
if (exists && !forceOverwrite) {
if (skipConflicts) {
logger.warn(`⏭️ Skipped existing API Key: ${apiKey.name} (${apiKey.id})`);
stats.skipped++;
continue;
} else {
const overwrite = await askConfirmation(`API Key "${apiKey.name}" (${apiKey.id}) exists. Overwrite?`);
if (!overwrite) {
stats.skipped++;
continue;
}
}
}
// 使用 hset 存储到哈希表
const pipeline = redis.client.pipeline();
for (const [field, value] of Object.entries(apiKey)) {
pipeline.hset(`apikey:${apiKey.id}`, field, value);
}
await pipeline.exec();
// 更新哈希映射
if (apiKey.apiKey && !importData.metadata.sanitized) {
await redis.client.hset('apikey:hash_map', apiKey.apiKey, apiKey.id);
}
logger.success(`✅ Imported API Key: ${apiKey.name} (${apiKey.id})`);
stats.imported++;
} catch (error) {
logger.error(`❌ Failed to import API Key ${apiKey.id}:`, error.message);
stats.errors++;
}
}
}
// 导入 Claude 账户
if (importData.data.claudeAccounts) {
logger.info('\n📥 Importing Claude accounts...');
for (const account of importData.data.claudeAccounts) {
try {
const exists = await redis.client.exists(`claude_account:${account.id}`);
if (exists && !forceOverwrite) {
if (skipConflicts) {
logger.warn(`⏭️ Skipped existing Claude account: ${account.name} (${account.id})`);
stats.skipped++;
continue;
} else {
const overwrite = await askConfirmation(`Claude account "${account.name}" (${account.id}) exists. Overwrite?`);
if (!overwrite) {
stats.skipped++;
continue;
}
}
}
// 使用 hset 存储到哈希表
const pipeline = redis.client.pipeline();
for (const [field, value] of Object.entries(account)) {
// 如果是对象,需要序列化
if (field === 'claudeAiOauth' && typeof value === 'object') {
pipeline.hset(`claude_account:${account.id}`, field, JSON.stringify(value));
} else {
pipeline.hset(`claude_account:${account.id}`, field, value);
}
}
await pipeline.exec();
logger.success(`✅ Imported Claude account: ${account.name} (${account.id})`);
stats.imported++;
} catch (error) {
logger.error(`❌ Failed to import Claude account ${account.id}:`, error.message);
stats.errors++;
}
}
}
// 导入 Gemini 账户
if (importData.data.geminiAccounts) {
logger.info('\n📥 Importing Gemini accounts...');
for (const account of importData.data.geminiAccounts) {
try {
const exists = await redis.client.exists(`gemini_account:${account.id}`);
if (exists && !forceOverwrite) {
if (skipConflicts) {
logger.warn(`⏭️ Skipped existing Gemini account: ${account.name} (${account.id})`);
stats.skipped++;
continue;
} else {
const overwrite = await askConfirmation(`Gemini account "${account.name}" (${account.id}) exists. Overwrite?`);
if (!overwrite) {
stats.skipped++;
continue;
}
}
}
// 使用 hset 存储到哈希表
const pipeline = redis.client.pipeline();
for (const [field, value] of Object.entries(account)) {
pipeline.hset(`gemini_account:${account.id}`, field, value);
}
await pipeline.exec();
logger.success(`✅ Imported Gemini account: ${account.name} (${account.id})`);
stats.imported++;
} catch (error) {
logger.error(`❌ Failed to import Gemini account ${account.id}:`, error.message);
stats.errors++;
}
}
}
// 显示导入结果
console.log('\n' + '='.repeat(60));
console.log('✅ Import Complete!');
console.log('='.repeat(60));
console.log(`Successfully imported: ${stats.imported}`);
console.log(`Skipped: ${stats.skipped}`);
console.log(`Errors: ${stats.errors}`);
console.log('='.repeat(60));
} catch (error) {
logger.error('💥 Import failed:', error);
process.exit(1);
} finally {
await redis.disconnect();
rl.close();
}
}
// 显示帮助信息
function showHelp() {
console.log(`
Data Transfer Tool for Claude Relay Service
This tool allows you to export and import data between environments.
Usage:
node scripts/data-transfer.js <command> [options]
Commands:
export Export data from Redis to a JSON file
import Import data from a JSON file to Redis
Export Options:
--output=FILE Output filename (default: backup-YYYY-MM-DD.json)
--types=TYPE,... Data types to export: apikeys,accounts,admins,all (default: all)
--sanitize Remove sensitive data from export
Import Options:
--input=FILE Input filename (required)
--force Overwrite existing data without asking
--skip-conflicts Skip conflicting data without asking
Examples:
# Export all data
node scripts/data-transfer.js export
# Export only API keys with sanitized data
node scripts/data-transfer.js export --types=apikeys --sanitize
# Import data, skip conflicts
node scripts/data-transfer.js import --input=backup.json --skip-conflicts
# Export specific data types
node scripts/data-transfer.js export --types=apikeys,accounts --output=prod-data.json
`);
}
// 主函数
async function main() {
if (!command || command === '--help' || command === 'help') {
showHelp();
process.exit(0);
}
switch (command) {
case 'export':
await exportData();
break;
case 'import':
await importData();
break;
default:
logger.error(`❌ Unknown command: ${command}`);
showHelp();
process.exit(1);
}
}
// 运行
main().catch(error => {
logger.error('💥 Unexpected error:', error);
process.exit(1);
});

123
scripts/debug-redis-keys.js Normal file
View File

@@ -0,0 +1,123 @@
#!/usr/bin/env node
/**
* Redis 键调试工具
* 用于查看 Redis 中存储的所有键和数据结构
*/
const redis = require('../src/models/redis');
const logger = require('../src/utils/logger');
async function debugRedisKeys() {
try {
logger.info('🔄 Connecting to Redis...');
await redis.connect();
logger.success('✅ Connected to Redis');
// 获取所有键
const allKeys = await redis.client.keys('*');
logger.info(`\n📊 Total keys in Redis: ${allKeys.length}\n`);
// 按类型分组
const keysByType = {
apiKeys: [],
claudeAccounts: [],
geminiAccounts: [],
admins: [],
sessions: [],
usage: [],
other: []
};
// 分类键
for (const key of allKeys) {
if (key.startsWith('apikey:')) {
keysByType.apiKeys.push(key);
} else if (key.startsWith('claude_account:')) {
keysByType.claudeAccounts.push(key);
} else if (key.startsWith('gemini_account:')) {
keysByType.geminiAccounts.push(key);
} else if (key.startsWith('admin:') || key.startsWith('admin_username:')) {
keysByType.admins.push(key);
} else if (key.startsWith('session:')) {
keysByType.sessions.push(key);
} else if (key.includes('usage') || key.includes('rate_limit') || key.includes('concurrency')) {
keysByType.usage.push(key);
} else {
keysByType.other.push(key);
}
}
// 显示分类结果
console.log('='.repeat(60));
console.log('📂 Keys by Category:');
console.log('='.repeat(60));
console.log(`API Keys: ${keysByType.apiKeys.length}`);
console.log(`Claude Accounts: ${keysByType.claudeAccounts.length}`);
console.log(`Gemini Accounts: ${keysByType.geminiAccounts.length}`);
console.log(`Admins: ${keysByType.admins.length}`);
console.log(`Sessions: ${keysByType.sessions.length}`);
console.log(`Usage/Rate Limit: ${keysByType.usage.length}`);
console.log(`Other: ${keysByType.other.length}`);
console.log('='.repeat(60));
// 详细显示每个类别的键
if (keysByType.apiKeys.length > 0) {
console.log('\n🔑 API Keys:');
for (const key of keysByType.apiKeys.slice(0, 5)) {
console.log(` - ${key}`);
}
if (keysByType.apiKeys.length > 5) {
console.log(` ... and ${keysByType.apiKeys.length - 5} more`);
}
}
if (keysByType.claudeAccounts.length > 0) {
console.log('\n🤖 Claude Accounts:');
for (const key of keysByType.claudeAccounts) {
console.log(` - ${key}`);
}
}
if (keysByType.geminiAccounts.length > 0) {
console.log('\n💎 Gemini Accounts:');
for (const key of keysByType.geminiAccounts) {
console.log(` - ${key}`);
}
}
if (keysByType.other.length > 0) {
console.log('\n❓ Other Keys:');
for (const key of keysByType.other.slice(0, 10)) {
console.log(` - ${key}`);
}
if (keysByType.other.length > 10) {
console.log(` ... and ${keysByType.other.length - 10} more`);
}
}
// 检查数据类型
console.log('\n' + '='.repeat(60));
console.log('🔍 Checking Data Types:');
console.log('='.repeat(60));
// 随机检查几个键的类型
const sampleKeys = allKeys.slice(0, Math.min(10, allKeys.length));
for (const key of sampleKeys) {
const type = await redis.client.type(key);
console.log(`${key} => ${type}`);
}
} catch (error) {
logger.error('💥 Debug failed:', error);
} finally {
await redis.disconnect();
logger.info('👋 Disconnected from Redis');
}
}
// 运行调试
debugRedisKeys().catch(error => {
logger.error('💥 Unexpected error:', error);
process.exit(1);
});

32
scripts/fix-inquirer.js Normal file
View File

@@ -0,0 +1,32 @@
#!/usr/bin/env node
/**
* 修复 inquirer ESM 问题
* 降级到支持 CommonJS 的版本
*/
const { execSync } = require('child_process');
const fs = require('fs');
const path = require('path');
console.log('🔧 修复 inquirer ESM 兼容性问题...\n');
try {
// 卸载当前版本
console.log('📦 卸载当前 inquirer 版本...');
execSync('npm uninstall inquirer', { stdio: 'inherit' });
// 安装兼容 CommonJS 的版本 (8.x 是最后支持 CommonJS 的主要版本)
console.log('\n📦 安装兼容版本 inquirer@8.2.6...');
execSync('npm install inquirer@8.2.6', { stdio: 'inherit' });
console.log('\n✅ 修复完成!');
console.log('\n现在可以正常使用 CLI 工具了:');
console.log(' npm run cli admin');
console.log(' npm run cli keys');
console.log(' npm run cli status');
} catch (error) {
console.error('❌ 修复失败:', error.message);
process.exit(1);
}

227
scripts/fix-usage-stats.js Normal file
View File

@@ -0,0 +1,227 @@
#!/usr/bin/env node
/**
* 数据迁移脚本:修复历史使用统计数据
*
* 功能:
* 1. 统一 totalTokens 和 allTokens 字段
* 2. 确保 allTokens 包含所有类型的 tokens
* 3. 修复历史数据的不一致性
*
* 使用方法:
* node scripts/fix-usage-stats.js [--dry-run]
*/
require('dotenv').config();
const redis = require('../src/models/redis');
const logger = require('../src/utils/logger');
// 解析命令行参数
const args = process.argv.slice(2);
const isDryRun = args.includes('--dry-run');
async function fixUsageStats() {
try {
logger.info('🔧 开始修复使用统计数据...');
if (isDryRun) {
logger.info('📝 DRY RUN 模式 - 不会实际修改数据');
}
// 连接到 Redis
await redis.connect();
logger.success('✅ 已连接到 Redis');
const client = redis.getClientSafe();
// 统计信息
let stats = {
totalKeys: 0,
fixedTotalKeys: 0,
fixedDailyKeys: 0,
fixedMonthlyKeys: 0,
fixedModelKeys: 0,
errors: 0
};
// 1. 修复 API Key 级别的总统计
logger.info('\n📊 修复 API Key 总统计数据...');
const apiKeyPattern = 'apikey:*';
const apiKeys = await client.keys(apiKeyPattern);
stats.totalKeys = apiKeys.length;
for (const apiKeyKey of apiKeys) {
const keyId = apiKeyKey.replace('apikey:', '');
const usageKey = `usage:${keyId}`;
try {
const usageData = await client.hgetall(usageKey);
if (usageData && Object.keys(usageData).length > 0) {
const inputTokens = parseInt(usageData.totalInputTokens) || 0;
const outputTokens = parseInt(usageData.totalOutputTokens) || 0;
const cacheCreateTokens = parseInt(usageData.totalCacheCreateTokens) || 0;
const cacheReadTokens = parseInt(usageData.totalCacheReadTokens) || 0;
const currentAllTokens = parseInt(usageData.totalAllTokens) || 0;
// 计算正确的 allTokens
const correctAllTokens = inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens;
if (currentAllTokens !== correctAllTokens && correctAllTokens > 0) {
logger.info(` 修复 ${keyId}: ${currentAllTokens} -> ${correctAllTokens}`);
if (!isDryRun) {
await client.hset(usageKey, 'totalAllTokens', correctAllTokens);
}
stats.fixedTotalKeys++;
}
}
} catch (error) {
logger.error(` 错误处理 ${keyId}: ${error.message}`);
stats.errors++;
}
}
// 2. 修复每日统计数据
logger.info('\n📅 修复每日统计数据...');
const dailyPattern = 'usage:daily:*';
const dailyKeys = await client.keys(dailyPattern);
for (const dailyKey of dailyKeys) {
try {
const data = await client.hgetall(dailyKey);
if (data && Object.keys(data).length > 0) {
const inputTokens = parseInt(data.inputTokens) || 0;
const outputTokens = parseInt(data.outputTokens) || 0;
const cacheCreateTokens = parseInt(data.cacheCreateTokens) || 0;
const cacheReadTokens = parseInt(data.cacheReadTokens) || 0;
const currentAllTokens = parseInt(data.allTokens) || 0;
const correctAllTokens = inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens;
if (currentAllTokens !== correctAllTokens && correctAllTokens > 0) {
if (!isDryRun) {
await client.hset(dailyKey, 'allTokens', correctAllTokens);
}
stats.fixedDailyKeys++;
}
}
} catch (error) {
logger.error(` 错误处理 ${dailyKey}: ${error.message}`);
stats.errors++;
}
}
// 3. 修复每月统计数据
logger.info('\n📆 修复每月统计数据...');
const monthlyPattern = 'usage:monthly:*';
const monthlyKeys = await client.keys(monthlyPattern);
for (const monthlyKey of monthlyKeys) {
try {
const data = await client.hgetall(monthlyKey);
if (data && Object.keys(data).length > 0) {
const inputTokens = parseInt(data.inputTokens) || 0;
const outputTokens = parseInt(data.outputTokens) || 0;
const cacheCreateTokens = parseInt(data.cacheCreateTokens) || 0;
const cacheReadTokens = parseInt(data.cacheReadTokens) || 0;
const currentAllTokens = parseInt(data.allTokens) || 0;
const correctAllTokens = inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens;
if (currentAllTokens !== correctAllTokens && correctAllTokens > 0) {
if (!isDryRun) {
await client.hset(monthlyKey, 'allTokens', correctAllTokens);
}
stats.fixedMonthlyKeys++;
}
}
} catch (error) {
logger.error(` 错误处理 ${monthlyKey}: ${error.message}`);
stats.errors++;
}
}
// 4. 修复模型级别的统计数据
logger.info('\n🤖 修复模型级别统计数据...');
const modelPatterns = [
'usage:model:daily:*',
'usage:model:monthly:*',
'usage:*:model:daily:*',
'usage:*:model:monthly:*'
];
for (const pattern of modelPatterns) {
const modelKeys = await client.keys(pattern);
for (const modelKey of modelKeys) {
try {
const data = await client.hgetall(modelKey);
if (data && Object.keys(data).length > 0) {
const inputTokens = parseInt(data.inputTokens) || 0;
const outputTokens = parseInt(data.outputTokens) || 0;
const cacheCreateTokens = parseInt(data.cacheCreateTokens) || 0;
const cacheReadTokens = parseInt(data.cacheReadTokens) || 0;
const currentAllTokens = parseInt(data.allTokens) || 0;
const correctAllTokens = inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens;
if (currentAllTokens !== correctAllTokens && correctAllTokens > 0) {
if (!isDryRun) {
await client.hset(modelKey, 'allTokens', correctAllTokens);
}
stats.fixedModelKeys++;
}
}
} catch (error) {
logger.error(` 错误处理 ${modelKey}: ${error.message}`);
stats.errors++;
}
}
}
// 5. 验证修复结果
if (!isDryRun) {
logger.info('\n✅ 验证修复结果...');
// 随机抽样验证
const sampleSize = Math.min(5, apiKeys.length);
for (let i = 0; i < sampleSize; i++) {
const randomIndex = Math.floor(Math.random() * apiKeys.length);
const keyId = apiKeys[randomIndex].replace('apikey:', '');
const usage = await redis.getUsageStats(keyId);
logger.info(` 样本 ${keyId}:`);
logger.info(` Total tokens: ${usage.total.tokens}`);
logger.info(` All tokens: ${usage.total.allTokens}`);
logger.info(` 一致性: ${usage.total.tokens === usage.total.allTokens ? '✅' : '❌'}`);
}
}
// 打印统计结果
logger.info('\n📊 修复统计:');
logger.info(` 总 API Keys: ${stats.totalKeys}`);
logger.info(` 修复的总统计: ${stats.fixedTotalKeys}`);
logger.info(` 修复的日统计: ${stats.fixedDailyKeys}`);
logger.info(` 修复的月统计: ${stats.fixedMonthlyKeys}`);
logger.info(` 修复的模型统计: ${stats.fixedModelKeys}`);
logger.info(` 错误数: ${stats.errors}`);
if (isDryRun) {
logger.info('\n💡 这是 DRY RUN - 没有实际修改数据');
logger.info(' 运行不带 --dry-run 参数来实际执行修复');
} else {
logger.success('\n✅ 数据修复完成!');
}
} catch (error) {
logger.error('❌ 修复过程出错:', error);
process.exit(1);
} finally {
await redis.disconnect();
}
}
// 执行修复
fixUsageStats().catch(error => {
logger.error('❌ 未处理的错误:', error);
process.exit(1);
});

284
scripts/generate-test-data.js Executable file
View File

@@ -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);
});

View File

@@ -0,0 +1,191 @@
#!/usr/bin/env node
/**
* 数据迁移脚本:为现有 API Key 设置默认有效期
*
* 使用方法:
* node scripts/migrate-apikey-expiry.js [--days=30] [--dry-run]
*
* 参数:
* --days: 设置默认有效期天数默认30天
* --dry-run: 仅模拟运行,不实际修改数据
*/
const redis = require('../src/models/redis');
const logger = require('../src/utils/logger');
const readline = require('readline');
// 解析命令行参数
const args = process.argv.slice(2);
const params = {};
args.forEach(arg => {
const [key, value] = arg.split('=');
params[key.replace('--', '')] = value || true;
});
const DEFAULT_DAYS = params.days ? parseInt(params.days) : 30;
const DRY_RUN = params['dry-run'] === true;
// 创建 readline 接口用于用户确认
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout
});
async function askConfirmation(question) {
return new Promise((resolve) => {
rl.question(question + ' (yes/no): ', (answer) => {
resolve(answer.toLowerCase() === 'yes' || answer.toLowerCase() === 'y');
});
});
}
async function migrateApiKeys() {
try {
logger.info('🔄 Starting API Key expiry migration...');
logger.info(`📅 Default expiry period: ${DEFAULT_DAYS} days`);
logger.info(`🔍 Mode: ${DRY_RUN ? 'DRY RUN (no changes will be made)' : 'LIVE RUN'}`);
// 连接 Redis
await redis.connect();
logger.success('✅ Connected to Redis');
// 获取所有 API Keys
const apiKeys = await redis.getAllApiKeys();
logger.info(`📊 Found ${apiKeys.length} API Keys in total`);
// 统计信息
const stats = {
total: apiKeys.length,
needsMigration: 0,
alreadyHasExpiry: 0,
migrated: 0,
errors: 0
};
// 需要迁移的 Keys
const keysToMigrate = [];
// 分析每个 API Key
for (const key of apiKeys) {
if (!key.expiresAt || key.expiresAt === 'null' || key.expiresAt === '') {
keysToMigrate.push(key);
stats.needsMigration++;
logger.info(`📌 API Key "${key.name}" (${key.id}) needs migration`);
} else {
stats.alreadyHasExpiry++;
const expiryDate = new Date(key.expiresAt);
logger.info(`✓ API Key "${key.name}" (${key.id}) already has expiry: ${expiryDate.toLocaleString()}`);
}
}
if (keysToMigrate.length === 0) {
logger.success('✨ No API Keys need migration!');
return;
}
// 显示迁移摘要
console.log('\n' + '='.repeat(60));
console.log('📋 Migration Summary:');
console.log('='.repeat(60));
console.log(`Total API Keys: ${stats.total}`);
console.log(`Already have expiry: ${stats.alreadyHasExpiry}`);
console.log(`Need migration: ${stats.needsMigration}`);
console.log(`Default expiry: ${DEFAULT_DAYS} days from now`);
console.log('='.repeat(60) + '\n');
// 如果不是 dry run请求确认
if (!DRY_RUN) {
const confirmed = await askConfirmation(
`⚠️ This will set expiry dates for ${keysToMigrate.length} API Keys. Continue?`
);
if (!confirmed) {
logger.warn('❌ Migration cancelled by user');
return;
}
}
// 计算新的过期时间
const newExpiryDate = new Date();
newExpiryDate.setDate(newExpiryDate.getDate() + DEFAULT_DAYS);
const newExpiryISO = newExpiryDate.toISOString();
logger.info(`\n🚀 Starting migration... New expiry date: ${newExpiryDate.toLocaleString()}`);
// 执行迁移
for (const key of keysToMigrate) {
try {
if (!DRY_RUN) {
// 直接更新 Redis 中的数据
// 使用 hset 更新单个字段
await redis.client.hset(`apikey:${key.id}`, 'expiresAt', newExpiryISO);
logger.success(`✅ Migrated: "${key.name}" (${key.id})`);
} else {
logger.info(`[DRY RUN] Would migrate: "${key.name}" (${key.id})`);
}
stats.migrated++;
} catch (error) {
logger.error(`❌ Error migrating "${key.name}" (${key.id}):`, error.message);
stats.errors++;
}
}
// 显示最终结果
console.log('\n' + '='.repeat(60));
console.log('✅ Migration Complete!');
console.log('='.repeat(60));
console.log(`Successfully migrated: ${stats.migrated}`);
console.log(`Errors: ${stats.errors}`);
console.log(`New expiry date: ${newExpiryDate.toLocaleString()}`);
console.log('='.repeat(60) + '\n');
if (DRY_RUN) {
logger.warn('⚠️ This was a DRY RUN. No actual changes were made.');
logger.info('💡 Run without --dry-run flag to apply changes.');
}
} catch (error) {
logger.error('💥 Migration failed:', error);
process.exit(1);
} finally {
// 清理
rl.close();
await redis.disconnect();
logger.info('👋 Disconnected from Redis');
}
}
// 显示帮助信息
if (params.help) {
console.log(`
API Key Expiry Migration Script
This script adds expiry dates to existing API Keys that don't have one.
Usage:
node scripts/migrate-apikey-expiry.js [options]
Options:
--days=NUMBER Set default expiry days (default: 30)
--dry-run Simulate the migration without making changes
--help Show this help message
Examples:
# Set 30-day expiry for all API Keys without expiry
node scripts/migrate-apikey-expiry.js
# Set 90-day expiry
node scripts/migrate-apikey-expiry.js --days=90
# Test run without making changes
node scripts/migrate-apikey-expiry.js --dry-run
`);
process.exit(0);
}
// 运行迁移
migrateApiKeys().catch(error => {
logger.error('💥 Unexpected error:', error);
process.exit(1);
});

View File

@@ -0,0 +1,159 @@
#!/usr/bin/env node
/**
* 测试 API Key 过期功能
* 快速创建和修改 API Key 过期时间以便测试
*/
const apiKeyService = require('../src/services/apiKeyService');
const redis = require('../src/models/redis');
const logger = require('../src/utils/logger');
const chalk = require('chalk');
async function createTestApiKeys() {
console.log(chalk.bold.blue('\n🧪 创建测试 API Keys\n'));
try {
await redis.connect();
// 创建不同过期时间的测试 Keys
const testKeys = [
{
name: 'Test-Expired',
description: '已过期的测试 Key',
expiresAt: new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString() // 1天前过期
},
{
name: 'Test-1Hour',
description: '1小时后过期的测试 Key',
expiresAt: new Date(Date.now() + 60 * 60 * 1000).toISOString() // 1小时后
},
{
name: 'Test-1Day',
description: '1天后过期的测试 Key',
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString() // 1天后
},
{
name: 'Test-7Days',
description: '7天后过期的测试 Key',
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString() // 7天后
},
{
name: 'Test-Never',
description: '永不过期的测试 Key',
expiresAt: null // 永不过期
}
];
console.log('正在创建测试 API Keys...\n');
for (const keyData of testKeys) {
try {
const newKey = await apiKeyService.generateApiKey(keyData);
const expiryInfo = keyData.expiresAt
? new Date(keyData.expiresAt).toLocaleString()
: '永不过期';
console.log(`✅ 创建成功: ${keyData.name}`);
console.log(` API Key: ${newKey.apiKey}`);
console.log(` 过期时间: ${expiryInfo}`);
console.log('');
} catch (error) {
console.log(chalk.red(`❌ 创建失败: ${keyData.name} - ${error.message}`));
}
}
// 运行清理任务测试
console.log(chalk.bold.yellow('\n🔄 运行清理任务...\n'));
const cleanedCount = await apiKeyService.cleanupExpiredKeys();
console.log(`清理了 ${cleanedCount} 个过期的 API Keys\n`);
// 显示所有 API Keys 状态
console.log(chalk.bold.cyan('📊 当前所有 API Keys 状态:\n'));
const allKeys = await apiKeyService.getAllApiKeys();
for (const key of allKeys) {
const now = new Date();
const expiresAt = key.expiresAt ? new Date(key.expiresAt) : null;
let status = '✅ 活跃';
let expiryInfo = '永不过期';
if (expiresAt) {
if (expiresAt < now) {
status = '❌ 已过期';
expiryInfo = `过期于 ${expiresAt.toLocaleString()}`;
} else {
const hoursLeft = Math.ceil((expiresAt - now) / (1000 * 60 * 60));
const daysLeft = Math.ceil(hoursLeft / 24);
if (hoursLeft < 24) {
expiryInfo = chalk.yellow(`${hoursLeft}小时后过期`);
} else if (daysLeft <= 7) {
expiryInfo = chalk.yellow(`${daysLeft}天后过期`);
} else {
expiryInfo = chalk.green(`${daysLeft}天后过期`);
}
}
}
if (!key.isActive) {
status = '🔒 已禁用';
}
console.log(`${status} ${key.name} - ${expiryInfo}`);
console.log(` API Key: ${key.apiKey?.substring(0, 30)}...`);
console.log('');
}
} catch (error) {
console.error(chalk.red('测试失败:'), error);
} finally {
await redis.disconnect();
}
}
// 主函数
async function main() {
console.log(chalk.bold.magenta('\n===================================='));
console.log(chalk.bold.magenta(' API Key 过期功能测试工具'));
console.log(chalk.bold.magenta('====================================\n'));
console.log('此工具将:');
console.log('1. 创建不同过期时间的测试 API Keys');
console.log('2. 运行清理任务禁用过期的 Keys');
console.log('3. 显示所有 Keys 的当前状态\n');
console.log(chalk.yellow('⚠️ 注意:这会在您的系统中创建真实的 API Keys\n'));
const readline = require('readline').createInterface({
input: process.stdin,
output: process.stdout
});
readline.question('是否继续?(y/n): ', async (answer) => {
if (answer.toLowerCase() === 'y') {
await createTestApiKeys();
console.log(chalk.bold.green('\n✅ 测试完成!\n'));
console.log('您现在可以:');
console.log('1. 使用 CLI 工具管理这些测试 Keys:');
console.log(' npm run cli keys');
console.log('');
console.log('2. 在 Web 界面查看和管理这些 Keys');
console.log('');
console.log('3. 测试 API 调用时的过期验证');
} else {
console.log('\n已取消');
}
readline.close();
});
}
// 运行
main().catch(error => {
console.error(chalk.red('错误:'), error);
process.exit(1);
});

View File

@@ -0,0 +1,181 @@
#!/usr/bin/env node
/**
* 测试导入加密处理
* 验证增强版数据传输工具是否正确处理加密和未加密的导出数据
*/
const fs = require('fs').promises;
const path = require('path');
const crypto = require('crypto');
const config = require('../config/config');
const logger = require('../src/utils/logger');
// 模拟加密函数
function encryptData(data, salt = 'salt') {
if (!data || !config.security.encryptionKey) return data;
const key = crypto.scryptSync(config.security.encryptionKey, salt, 32);
const iv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv('aes-256-cbc', key, iv);
let encrypted = cipher.update(data, 'utf8', 'hex');
encrypted += cipher.final('hex');
return iv.toString('hex') + ':' + encrypted;
}
// 模拟解密函数
function decryptData(encryptedData, salt = 'salt') {
if (!encryptedData || !config.security.encryptionKey) return encryptedData;
try {
if (encryptedData.includes(':')) {
const parts = encryptedData.split(':');
const key = crypto.scryptSync(config.security.encryptionKey, salt, 32);
const iv = Buffer.from(parts[0], 'hex');
const encrypted = parts[1];
const decipher = crypto.createDecipheriv('aes-256-cbc', key, iv);
let decrypted = decipher.update(encrypted, 'hex', 'utf8');
decrypted += decipher.final('utf8');
return decrypted;
}
return encryptedData;
} catch (error) {
logger.warn(`⚠️ Failed to decrypt data: ${error.message}`);
return encryptedData;
}
}
async function testImportHandling() {
console.log('🧪 测试导入加密处理\n');
// 测试数据
const testClaudeAccount = {
id: 'test-claude-123',
name: 'Test Claude Account',
email: 'test@example.com',
password: 'testPassword123',
accessToken: 'test-access-token',
refreshToken: 'test-refresh-token',
claudeAiOauth: {
access_token: 'oauth-access-token',
refresh_token: 'oauth-refresh-token',
scopes: ['read', 'write']
}
};
const testGeminiAccount = {
id: 'test-gemini-456',
name: 'Test Gemini Account',
geminiOauth: {
access_token: 'gemini-access-token',
refresh_token: 'gemini-refresh-token'
},
accessToken: 'gemini-access-token',
refreshToken: 'gemini-refresh-token'
};
// 1. 创建解密的导出文件(模拟 --decrypt=true
const decryptedExport = {
metadata: {
version: '2.0',
exportDate: new Date().toISOString(),
sanitized: false,
decrypted: true, // 标记为已解密
types: ['all']
},
data: {
claudeAccounts: [testClaudeAccount],
geminiAccounts: [testGeminiAccount]
}
};
// 2. 创建加密的导出文件(模拟 --decrypt=false
const encryptedClaudeAccount = { ...testClaudeAccount };
encryptedClaudeAccount.email = encryptData(encryptedClaudeAccount.email);
encryptedClaudeAccount.password = encryptData(encryptedClaudeAccount.password);
encryptedClaudeAccount.accessToken = encryptData(encryptedClaudeAccount.accessToken);
encryptedClaudeAccount.refreshToken = encryptData(encryptedClaudeAccount.refreshToken);
encryptedClaudeAccount.claudeAiOauth = encryptData(JSON.stringify(encryptedClaudeAccount.claudeAiOauth));
const encryptedGeminiAccount = { ...testGeminiAccount };
encryptedGeminiAccount.geminiOauth = encryptData(JSON.stringify(encryptedGeminiAccount.geminiOauth), 'gemini-account-salt');
encryptedGeminiAccount.accessToken = encryptData(encryptedGeminiAccount.accessToken, 'gemini-account-salt');
encryptedGeminiAccount.refreshToken = encryptData(encryptedGeminiAccount.refreshToken, 'gemini-account-salt');
const encryptedExport = {
metadata: {
version: '2.0',
exportDate: new Date().toISOString(),
sanitized: false,
decrypted: false, // 标记为未解密(加密状态)
types: ['all']
},
data: {
claudeAccounts: [encryptedClaudeAccount],
geminiAccounts: [encryptedGeminiAccount]
}
};
// 写入测试文件
const testDir = path.join(__dirname, '../data/test-imports');
await fs.mkdir(testDir, { recursive: true });
await fs.writeFile(
path.join(testDir, 'decrypted-export.json'),
JSON.stringify(decryptedExport, null, 2)
);
await fs.writeFile(
path.join(testDir, 'encrypted-export.json'),
JSON.stringify(encryptedExport, null, 2)
);
console.log('✅ 测试文件已创建:');
console.log(' - data/test-imports/decrypted-export.json (解密的数据)');
console.log(' - data/test-imports/encrypted-export.json (加密的数据)\n');
console.log('📋 测试场景:\n');
console.log('1. 导入解密的数据decrypted=true');
console.log(' - 导入时应该重新加密敏感字段');
console.log(' - 命令: npm run data:import:enhanced -- --input=data/test-imports/decrypted-export.json\n');
console.log('2. 导入加密的数据decrypted=false');
console.log(' - 导入时应该保持原样(已经是加密的)');
console.log(' - 命令: npm run data:import:enhanced -- --input=data/test-imports/encrypted-export.json\n');
console.log('3. 验证导入后的数据:');
console.log(' - 使用 CLI 查看账户状态');
console.log(' - 命令: npm run cli accounts list\n');
// 显示示例数据对比
console.log('📊 数据对比示例:\n');
console.log('原始数据(解密状态):');
console.log(` email: "${testClaudeAccount.email}"`);
console.log(` password: "${testClaudeAccount.password}"`);
console.log(` accessToken: "${testClaudeAccount.accessToken}"\n`);
console.log('加密后的数据:');
console.log(` email: "${encryptedClaudeAccount.email.substring(0, 50)}..."`);
console.log(` password: "${encryptedClaudeAccount.password.substring(0, 50)}..."`);
console.log(` accessToken: "${encryptedClaudeAccount.accessToken.substring(0, 50)}..."\n`);
// 验证加密/解密
console.log('🔐 验证加密/解密功能:');
const testString = 'test-data-123';
const encrypted = encryptData(testString);
const decrypted = decryptData(encrypted);
console.log(` 原始: "${testString}"`);
console.log(` 加密: "${encrypted.substring(0, 50)}..."`);
console.log(` 解密: "${decrypted}"`);
console.log(` 验证: ${testString === decrypted ? '✅ 成功' : '❌ 失败'}\n`);
}
// 运行测试
testImportHandling().catch(error => {
console.error('❌ 测试失败:', error);
process.exit(1);
});

View File

@@ -16,6 +16,7 @@ const pricingService = require('./services/pricingService');
const apiRoutes = require('./routes/api'); const apiRoutes = require('./routes/api');
const adminRoutes = require('./routes/admin'); const adminRoutes = require('./routes/admin');
const webRoutes = require('./routes/web'); const webRoutes = require('./routes/web');
const apiStatsRoutes = require('./routes/apiStats');
const geminiRoutes = require('./routes/geminiRoutes'); const geminiRoutes = require('./routes/geminiRoutes');
const openaiGeminiRoutes = require('./routes/openaiGeminiRoutes'); const openaiGeminiRoutes = require('./routes/openaiGeminiRoutes');
const openaiClaudeRoutes = require('./routes/openaiClaudeRoutes'); const openaiClaudeRoutes = require('./routes/openaiClaudeRoutes');
@@ -51,6 +52,16 @@ class Application {
logger.info('🔄 Initializing admin credentials...'); logger.info('🔄 Initializing admin credentials...');
await this.initializeAdmin(); await this.initializeAdmin();
// 💰 初始化费用数据
logger.info('💰 Checking cost data initialization...');
const costInitService = require('./services/costInitService');
const needsInit = await costInitService.needsInitialization();
if (needsInit) {
logger.info('💰 Initializing cost data for all API Keys...');
const result = await costInitService.initializeAllCosts();
logger.info(`💰 Cost initialization completed: ${result.processed} processed, ${result.errors} errors`);
}
// 🛡️ 安全中间件 // 🛡️ 安全中间件
this.app.use(helmet({ this.app.use(helmet({
contentSecurityPolicy: false, // 允许内联样式和脚本 contentSecurityPolicy: false, // 允许内联样式和脚本
@@ -110,13 +121,14 @@ class Application {
this.app.use('/claude', apiRoutes); // /claude 路由别名,与 /api 功能相同 this.app.use('/claude', apiRoutes); // /claude 路由别名,与 /api 功能相同
this.app.use('/admin', adminRoutes); this.app.use('/admin', adminRoutes);
this.app.use('/web', webRoutes); this.app.use('/web', webRoutes);
this.app.use('/apiStats', apiStatsRoutes);
this.app.use('/gemini', geminiRoutes); this.app.use('/gemini', geminiRoutes);
this.app.use('/openai/gemini', openaiGeminiRoutes); this.app.use('/openai/gemini', openaiGeminiRoutes);
this.app.use('/openai/claude', openaiClaudeRoutes); this.app.use('/openai/claude', openaiClaudeRoutes);
// 🏠 根路径重定向到管理界 // 🏠 根路径重定向到API统计页
this.app.get('/', (req, res) => { this.app.get('/', (req, res) => {
res.redirect('/web'); res.redirect('/apiStats');
}); });
// 🏥 增强的健康检查端点 // 🏥 增强的健康检查端点

32
src/cli/initCosts.js Normal file
View File

@@ -0,0 +1,32 @@
#!/usr/bin/env node
const costInitService = require('../services/costInitService');
const logger = require('../utils/logger');
const redis = require('../models/redis');
async function main() {
try {
// 连接Redis
await redis.connect();
console.log('💰 Starting cost data initialization...\n');
// 执行初始化
const result = await costInitService.initializeAllCosts();
console.log('\n✅ Cost initialization completed!');
console.log(` Processed: ${result.processed} API Keys`);
console.log(` Errors: ${result.errors}`);
// 断开连接
await redis.disconnect();
process.exit(0);
} catch (error) {
console.error('\n❌ Cost initialization failed:', error.message);
logger.error('Cost initialization failed:', error);
process.exit(1);
}
}
// 运行主函数
main();

View File

@@ -2,6 +2,7 @@ const apiKeyService = require('../services/apiKeyService');
const logger = require('../utils/logger'); const logger = require('../utils/logger');
const redis = require('../models/redis'); const redis = require('../models/redis');
const { RateLimiterRedis } = require('rate-limiter-flexible'); const { RateLimiterRedis } = require('rate-limiter-flexible');
const config = require('../../config/config');
// 🔑 API Key验证中间件优化版 // 🔑 API Key验证中间件优化版
const authenticateApiKey = async (req, res, next) => { 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; const concurrencyLimit = validation.keyData.concurrencyLimit || 0;
@@ -192,6 +239,27 @@ const authenticateApiKey = async (req, res, next) => {
}; };
} }
// 检查每日费用限制
const dailyCostLimit = validation.keyData.dailyCostLimit || 0;
if (dailyCostLimit > 0) {
const dailyCost = validation.keyData.dailyCost || 0;
if (dailyCost >= dailyCostLimit) {
logger.security(`💰 Daily cost limit exceeded for key: ${validation.keyData.id} (${validation.keyData.name}), cost: $${dailyCost.toFixed(2)}/$${dailyCostLimit}`);
return res.status(429).json({
error: 'Daily cost limit exceeded',
message: `已达到每日费用限制 ($${dailyCostLimit})`,
currentCost: dailyCost,
costLimit: dailyCostLimit,
resetAt: new Date(new Date().setHours(24, 0, 0, 0)).toISOString() // 明天0点重置
});
}
// 记录当前费用使用情况
logger.api(`💰 Cost usage for key: ${validation.keyData.id} (${validation.keyData.name}), current: $${dailyCost.toFixed(2)}/$${dailyCostLimit}`);
}
// 将验证信息添加到请求对象(只包含必要信息) // 将验证信息添加到请求对象(只包含必要信息)
req.apiKey = { req.apiKey = {
id: validation.keyData.id, id: validation.keyData.id,
@@ -205,12 +273,18 @@ const authenticateApiKey = async (req, res, next) => {
rateLimitRequests: validation.keyData.rateLimitRequests, rateLimitRequests: validation.keyData.rateLimitRequests,
enableModelRestriction: validation.keyData.enableModelRestriction, enableModelRestriction: validation.keyData.enableModelRestriction,
restrictedModels: validation.keyData.restrictedModels, restrictedModels: validation.keyData.restrictedModels,
enableClientRestriction: validation.keyData.enableClientRestriction,
allowedClients: validation.keyData.allowedClients,
dailyCostLimit: validation.keyData.dailyCostLimit,
dailyCost: validation.keyData.dailyCost,
usage: validation.keyData.usage usage: validation.keyData.usage
}; };
req.usage = validation.keyData.usage; req.usage = validation.keyData.usage;
const authDuration = Date.now() - startTime; 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(`🔓 Authenticated request from key: ${validation.keyData.name} (${validation.keyData.id}) in ${authDuration}ms`);
logger.api(` User-Agent: "${userAgent}"`);
next(); next();
} catch (error) { } catch (error) {

View File

@@ -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) { async getUsageStats(keyId) {
const totalKey = `usage:${keyId}`; const totalKey = `usage:${keyId}`;
const today = getDateStringInTimezone(); const today = getDateStringInTimezone();
@@ -324,11 +422,13 @@ class RedisClient {
const allTokens = parseInt(data.totalAllTokens) || parseInt(data.allTokens) || 0; const allTokens = parseInt(data.totalAllTokens) || parseInt(data.allTokens) || 0;
const totalFromSeparate = inputTokens + outputTokens; const totalFromSeparate = inputTokens + outputTokens;
// 计算实际的总tokens包含所有类型
const actualAllTokens = allTokens || (inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens);
if (totalFromSeparate === 0 && tokens > 0) { if (totalFromSeparate === 0 && tokens > 0) {
// 旧数据:没有输入输出分离 // 旧数据:没有输入输出分离
return { return {
tokens, tokens: tokens, // 保持兼容性但统一使用allTokens
inputTokens: Math.round(tokens * 0.3), // 假设30%为输入 inputTokens: Math.round(tokens * 0.3), // 假设30%为输入
outputTokens: Math.round(tokens * 0.7), // 假设70%为输出 outputTokens: Math.round(tokens * 0.7), // 假设70%为输出
cacheCreateTokens: 0, // 旧数据没有缓存token cacheCreateTokens: 0, // 旧数据没有缓存token
@@ -337,14 +437,14 @@ class RedisClient {
requests requests
}; };
} else { } else {
// 新数据或无数据 // 新数据或无数据 - 统一使用allTokens作为tokens的值
return { return {
tokens, tokens: actualAllTokens, // 统一使用allTokens作为总数
inputTokens, inputTokens,
outputTokens, outputTokens,
cacheCreateTokens, cacheCreateTokens,
cacheReadTokens, cacheReadTokens,
allTokens: allTokens || (inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens), // 计算或使用存储的值 allTokens: actualAllTokens,
requests requests
}; };
} }
@@ -367,6 +467,170 @@ class RedisClient {
}; };
} }
// 💰 获取当日费用
async getDailyCost(keyId) {
const today = getDateStringInTimezone();
const costKey = `usage:cost:daily:${keyId}:${today}`;
const cost = await this.client.get(costKey);
const result = parseFloat(cost || 0);
logger.debug(`💰 Getting daily cost for ${keyId}, date: ${today}, key: ${costKey}, value: ${cost}, result: ${result}`);
return result;
}
// 💰 增加当日费用
async incrementDailyCost(keyId, amount) {
const today = getDateStringInTimezone();
const tzDate = getDateInTimezone();
const currentMonth = `${tzDate.getFullYear()}-${String(tzDate.getMonth() + 1).padStart(2, '0')}`;
const currentHour = `${today}:${String(getHourInTimezone()).padStart(2, '0')}`;
const dailyKey = `usage:cost:daily:${keyId}:${today}`;
const monthlyKey = `usage:cost:monthly:${keyId}:${currentMonth}`;
const hourlyKey = `usage:cost:hourly:${keyId}:${currentHour}`;
const totalKey = `usage:cost:total:${keyId}`;
logger.debug(`💰 Incrementing cost for ${keyId}, amount: $${amount}, date: ${today}, dailyKey: ${dailyKey}`);
const results = await Promise.all([
this.client.incrbyfloat(dailyKey, amount),
this.client.incrbyfloat(monthlyKey, amount),
this.client.incrbyfloat(hourlyKey, amount),
this.client.incrbyfloat(totalKey, amount),
// 设置过期时间
this.client.expire(dailyKey, 86400 * 30), // 30天
this.client.expire(monthlyKey, 86400 * 90), // 90天
this.client.expire(hourlyKey, 86400 * 7) // 7天
]);
logger.debug(`💰 Cost incremented successfully, new daily total: $${results[0]}`);
}
// 💰 获取费用统计
async getCostStats(keyId) {
const today = getDateStringInTimezone();
const tzDate = getDateInTimezone();
const currentMonth = `${tzDate.getFullYear()}-${String(tzDate.getMonth() + 1).padStart(2, '0')}`;
const currentHour = `${today}:${String(getHourInTimezone()).padStart(2, '0')}`;
const [daily, monthly, hourly, total] = await Promise.all([
this.client.get(`usage:cost:daily:${keyId}:${today}`),
this.client.get(`usage:cost:monthly:${keyId}:${currentMonth}`),
this.client.get(`usage:cost:hourly:${keyId}:${currentHour}`),
this.client.get(`usage:cost:total:${keyId}`)
]);
return {
daily: parseFloat(daily || 0),
monthly: parseFloat(monthly || 0),
hourly: parseFloat(hourly || 0),
total: parseFloat(total || 0)
};
}
// 📊 获取账户使用统计
async getAccountUsageStats(accountId) {
const accountKey = `account_usage:${accountId}`;
const today = getDateStringInTimezone();
const accountDailyKey = `account_usage:daily:${accountId}:${today}`;
const tzDate = getDateInTimezone();
const currentMonth = `${tzDate.getFullYear()}-${String(tzDate.getMonth() + 1).padStart(2, '0')}`;
const accountMonthlyKey = `account_usage:monthly:${accountId}:${currentMonth}`;
const [total, daily, monthly] = await Promise.all([
this.client.hgetall(accountKey),
this.client.hgetall(accountDailyKey),
this.client.hgetall(accountMonthlyKey)
]);
// 获取账户创建时间来计算平均值
const accountData = await this.client.hgetall(`claude_account:${accountId}`);
const createdAt = accountData.createdAt ? new Date(accountData.createdAt) : new Date();
const now = new Date();
const daysSinceCreated = Math.max(1, Math.ceil((now - createdAt) / (1000 * 60 * 60 * 24)));
const totalTokens = parseInt(total.totalTokens) || 0;
const totalRequests = parseInt(total.totalRequests) || 0;
// 计算平均RPM和TPM
const totalMinutes = Math.max(1, daysSinceCreated * 24 * 60);
const avgRPM = totalRequests / totalMinutes;
const avgTPM = totalTokens / totalMinutes;
// 处理账户统计数据
const handleAccountData = (data) => {
const tokens = parseInt(data.totalTokens) || parseInt(data.tokens) || 0;
const inputTokens = parseInt(data.totalInputTokens) || parseInt(data.inputTokens) || 0;
const outputTokens = parseInt(data.totalOutputTokens) || parseInt(data.outputTokens) || 0;
const requests = parseInt(data.totalRequests) || parseInt(data.requests) || 0;
const cacheCreateTokens = parseInt(data.totalCacheCreateTokens) || parseInt(data.cacheCreateTokens) || 0;
const cacheReadTokens = parseInt(data.totalCacheReadTokens) || parseInt(data.cacheReadTokens) || 0;
const allTokens = parseInt(data.totalAllTokens) || parseInt(data.allTokens) || 0;
const actualAllTokens = allTokens || (inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens);
return {
tokens: tokens,
inputTokens: inputTokens,
outputTokens: outputTokens,
cacheCreateTokens: cacheCreateTokens,
cacheReadTokens: cacheReadTokens,
allTokens: actualAllTokens,
requests: requests
};
};
const totalData = handleAccountData(total);
const dailyData = handleAccountData(daily);
const monthlyData = handleAccountData(monthly);
return {
accountId: accountId,
total: totalData,
daily: dailyData,
monthly: monthlyData,
averages: {
rpm: Math.round(avgRPM * 100) / 100,
tpm: Math.round(avgTPM * 100) / 100,
dailyRequests: Math.round((totalRequests / daysSinceCreated) * 100) / 100,
dailyTokens: Math.round((totalTokens / daysSinceCreated) * 100) / 100
}
};
}
// 📈 获取所有账户的使用统计
async getAllAccountsUsageStats() {
try {
// 获取所有Claude账户
const accountKeys = await this.client.keys('claude_account:*');
const accountStats = [];
for (const accountKey of accountKeys) {
const accountId = accountKey.replace('claude_account:', '');
const accountData = await this.client.hgetall(accountKey);
if (accountData.name) {
const stats = await this.getAccountUsageStats(accountId);
accountStats.push({
id: accountId,
name: accountData.name,
email: accountData.email || '',
status: accountData.status || 'unknown',
isActive: accountData.isActive === 'true',
...stats
});
}
}
// 按当日token使用量排序
accountStats.sort((a, b) => (b.daily.allTokens || 0) - (a.daily.allTokens || 0));
return accountStats;
} catch (error) {
logger.error('❌ Failed to get all accounts usage stats:', error);
return [];
}
}
// 🧹 清空所有API Key的使用统计数据 // 🧹 清空所有API Key的使用统计数据
async resetAllUsageStats() { async resetAllUsageStats() {
const client = this.getClientSafe(); const client = this.getClientSafe();
@@ -819,4 +1083,11 @@ class RedisClient {
} }
} }
module.exports = new RedisClient(); const redisClient = new RedisClient();
// 导出时区辅助函数
redisClient.getDateInTimezone = getDateInTimezone;
redisClient.getDateStringInTimezone = getDateStringInTimezone;
redisClient.getHourInTimezone = getHourInTimezone;
module.exports = redisClient;

View File

@@ -12,15 +12,274 @@ const claudeCodeHeadersService = require('../services/claudeCodeHeadersService')
const axios = require('axios'); const axios = require('axios');
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
const config = require('../../config/config');
const router = express.Router(); const router = express.Router();
// 🔑 API Keys 管理 // 🔑 API Keys 管理
// 调试获取API Key费用详情
router.get('/api-keys/:keyId/cost-debug', authenticateAdmin, async (req, res) => {
try {
const { keyId } = req.params;
const costStats = await redis.getCostStats(keyId);
const dailyCost = await redis.getDailyCost(keyId);
const today = redis.getDateStringInTimezone();
const client = redis.getClientSafe();
// 获取所有相关的Redis键
const costKeys = await client.keys(`usage:cost:*:${keyId}:*`);
const keyValues = {};
for (const key of costKeys) {
keyValues[key] = await client.get(key);
}
res.json({
keyId,
today,
dailyCost,
costStats,
redisKeys: keyValues,
timezone: config.system.timezoneOffset || 8
});
} catch (error) {
logger.error('❌ Failed to get cost debug info:', error);
res.status(500).json({ error: 'Failed to get cost debug info', message: error.message });
}
});
// 获取所有API Keys // 获取所有API Keys
router.get('/api-keys', authenticateAdmin, async (req, res) => { router.get('/api-keys', authenticateAdmin, async (req, res) => {
try { try {
const { timeRange = 'all' } = req.query; // all, 7days, monthly
const apiKeys = await apiKeyService.getAllApiKeys(); const apiKeys = await apiKeyService.getAllApiKeys();
// 根据时间范围计算查询模式
const now = new Date();
let searchPatterns = [];
if (timeRange === 'today') {
// 今日 - 使用时区日期
const redis = require('../models/redis');
const tzDate = redis.getDateInTimezone(now);
const dateStr = `${tzDate.getFullYear()}-${String(tzDate.getMonth() + 1).padStart(2, '0')}-${String(tzDate.getDate()).padStart(2, '0')}`;
searchPatterns.push(`usage:daily:*:${dateStr}`);
} else if (timeRange === '7days') {
// 最近7天
const redis = require('../models/redis');
for (let i = 0; i < 7; i++) {
const date = new Date(now);
date.setDate(date.getDate() - i);
const tzDate = redis.getDateInTimezone(date);
const dateStr = `${tzDate.getFullYear()}-${String(tzDate.getMonth() + 1).padStart(2, '0')}-${String(tzDate.getDate()).padStart(2, '0')}`;
searchPatterns.push(`usage:daily:*:${dateStr}`);
}
} else if (timeRange === 'monthly') {
// 本月
const redis = require('../models/redis');
const tzDate = redis.getDateInTimezone(now);
const currentMonth = `${tzDate.getFullYear()}-${String(tzDate.getMonth() + 1).padStart(2, '0')}`;
searchPatterns.push(`usage:monthly:*:${currentMonth}`);
}
// 为每个API Key计算准确的费用和统计数据
for (const apiKey of apiKeys) {
const client = redis.getClientSafe();
if (timeRange === 'all') {
// 全部时间:保持原有逻辑
if (apiKey.usage && apiKey.usage.total) {
// 使用与展开模型统计相同的数据源
// 获取所有时间的模型统计数据
const monthlyKeys = await client.keys(`usage:${apiKey.id}:model:monthly:*:*`);
const modelStatsMap = new Map();
// 汇总所有月份的数据
for (const key of monthlyKeys) {
const match = key.match(/usage:.+:model:monthly:(.+):\d{4}-\d{2}$/);
if (!match) continue;
const model = match[1];
const data = await client.hgetall(key);
if (data && Object.keys(data).length > 0) {
if (!modelStatsMap.has(model)) {
modelStatsMap.set(model, {
inputTokens: 0,
outputTokens: 0,
cacheCreateTokens: 0,
cacheReadTokens: 0
});
}
const stats = modelStatsMap.get(model);
stats.inputTokens += parseInt(data.totalInputTokens) || parseInt(data.inputTokens) || 0;
stats.outputTokens += parseInt(data.totalOutputTokens) || parseInt(data.outputTokens) || 0;
stats.cacheCreateTokens += parseInt(data.totalCacheCreateTokens) || parseInt(data.cacheCreateTokens) || 0;
stats.cacheReadTokens += parseInt(data.totalCacheReadTokens) || parseInt(data.cacheReadTokens) || 0;
}
}
let totalCost = 0;
// 计算每个模型的费用
for (const [model, stats] of modelStatsMap) {
const usage = {
input_tokens: stats.inputTokens,
output_tokens: stats.outputTokens,
cache_creation_input_tokens: stats.cacheCreateTokens,
cache_read_input_tokens: stats.cacheReadTokens
};
const costResult = CostCalculator.calculateCost(usage, model);
totalCost += costResult.costs.total;
}
// 如果没有详细的模型数据,使用总量数据和默认模型计算
if (modelStatsMap.size === 0) {
const usage = {
input_tokens: apiKey.usage.total.inputTokens || 0,
output_tokens: apiKey.usage.total.outputTokens || 0,
cache_creation_input_tokens: apiKey.usage.total.cacheCreateTokens || 0,
cache_read_input_tokens: apiKey.usage.total.cacheReadTokens || 0
};
const costResult = CostCalculator.calculateCost(usage, 'claude-3-5-haiku-20241022');
totalCost = costResult.costs.total;
}
// 添加格式化的费用到响应数据
apiKey.usage.total.cost = totalCost;
apiKey.usage.total.formattedCost = CostCalculator.formatCost(totalCost);
}
} else {
// 7天或本月重新计算统计数据
const tempUsage = {
requests: 0,
tokens: 0,
allTokens: 0, // 添加allTokens字段
inputTokens: 0,
outputTokens: 0,
cacheCreateTokens: 0,
cacheReadTokens: 0
};
// 获取指定时间范围的统计数据
for (const pattern of searchPatterns) {
const keys = await client.keys(pattern.replace('*', apiKey.id));
for (const key of keys) {
const data = await client.hgetall(key);
if (data && Object.keys(data).length > 0) {
// 使用与 redis.js incrementTokenUsage 中相同的字段名
tempUsage.requests += parseInt(data.totalRequests) || parseInt(data.requests) || 0;
tempUsage.tokens += parseInt(data.totalTokens) || parseInt(data.tokens) || 0;
tempUsage.allTokens += parseInt(data.totalAllTokens) || parseInt(data.allTokens) || 0; // 读取包含所有Token的字段
tempUsage.inputTokens += parseInt(data.totalInputTokens) || parseInt(data.inputTokens) || 0;
tempUsage.outputTokens += parseInt(data.totalOutputTokens) || parseInt(data.outputTokens) || 0;
tempUsage.cacheCreateTokens += parseInt(data.totalCacheCreateTokens) || parseInt(data.cacheCreateTokens) || 0;
tempUsage.cacheReadTokens += parseInt(data.totalCacheReadTokens) || parseInt(data.cacheReadTokens) || 0;
}
}
}
// 计算指定时间范围的费用
let totalCost = 0;
const redis = require('../models/redis');
const tzToday = redis.getDateStringInTimezone(now);
const tzDate = redis.getDateInTimezone(now);
const tzMonth = `${tzDate.getFullYear()}-${String(tzDate.getMonth() + 1).padStart(2, '0')}`;
const modelKeys = timeRange === 'today'
? await client.keys(`usage:${apiKey.id}:model:daily:*:${tzToday}`)
: timeRange === '7days'
? await client.keys(`usage:${apiKey.id}:model:daily:*:*`)
: await client.keys(`usage:${apiKey.id}:model:monthly:*:${tzMonth}`);
const modelStatsMap = new Map();
// 过滤和汇总相应时间范围的模型数据
for (const key of modelKeys) {
if (timeRange === '7days') {
// 检查是否在最近7天内
const dateMatch = key.match(/\d{4}-\d{2}-\d{2}$/);
if (dateMatch) {
const keyDate = new Date(dateMatch[0]);
const daysDiff = Math.floor((now - keyDate) / (1000 * 60 * 60 * 24));
if (daysDiff > 6) continue;
}
} else if (timeRange === 'today') {
// today选项已经在查询时过滤了不需要额外处理
}
const modelMatch = key.match(/usage:.+:model:(?:daily|monthly):(.+):\d{4}-\d{2}(?:-\d{2})?$/);
if (!modelMatch) continue;
const model = modelMatch[1];
const data = await client.hgetall(key);
if (data && Object.keys(data).length > 0) {
if (!modelStatsMap.has(model)) {
modelStatsMap.set(model, {
inputTokens: 0,
outputTokens: 0,
cacheCreateTokens: 0,
cacheReadTokens: 0
});
}
const stats = modelStatsMap.get(model);
stats.inputTokens += parseInt(data.totalInputTokens) || parseInt(data.inputTokens) || 0;
stats.outputTokens += parseInt(data.totalOutputTokens) || parseInt(data.outputTokens) || 0;
stats.cacheCreateTokens += parseInt(data.totalCacheCreateTokens) || parseInt(data.cacheCreateTokens) || 0;
stats.cacheReadTokens += parseInt(data.totalCacheReadTokens) || parseInt(data.cacheReadTokens) || 0;
}
}
// 计算费用
for (const [model, stats] of modelStatsMap) {
const usage = {
input_tokens: stats.inputTokens,
output_tokens: stats.outputTokens,
cache_creation_input_tokens: stats.cacheCreateTokens,
cache_read_input_tokens: stats.cacheReadTokens
};
const costResult = CostCalculator.calculateCost(usage, model);
totalCost += costResult.costs.total;
}
// 如果没有模型数据,使用临时统计数据计算
if (modelStatsMap.size === 0 && tempUsage.tokens > 0) {
const usage = {
input_tokens: tempUsage.inputTokens,
output_tokens: tempUsage.outputTokens,
cache_creation_input_tokens: tempUsage.cacheCreateTokens,
cache_read_input_tokens: tempUsage.cacheReadTokens
};
const costResult = CostCalculator.calculateCost(usage, 'claude-3-5-haiku-20241022');
totalCost = costResult.costs.total;
}
// 使用从Redis读取的allTokens如果没有则计算
const allTokens = tempUsage.allTokens || (tempUsage.inputTokens + tempUsage.outputTokens + tempUsage.cacheCreateTokens + tempUsage.cacheReadTokens);
// 更新API Key的usage数据为指定时间范围的数据
apiKey.usage[timeRange] = {
...tempUsage,
tokens: allTokens, // 使用包含所有Token的总数
allTokens: allTokens,
cost: totalCost,
formattedCost: CostCalculator.formatCost(totalCost)
};
// 为了保持兼容性也更新total字段
apiKey.usage.total = apiKey.usage[timeRange];
}
}
res.json({ success: true, data: apiKeys }); res.json({ success: true, data: apiKeys });
} catch (error) { } catch (error) {
logger.error('❌ Failed to get API keys:', error); logger.error('❌ Failed to get API keys:', error);
@@ -28,6 +287,21 @@ router.get('/api-keys', authenticateAdmin, async (req, res) => {
} }
}); });
// 获取支持的客户端列表
router.get('/supported-clients', authenticateAdmin, async (req, res) => {
try {
const clients = config.clientRestrictions.predefinedClients.map(client => ({
id: client.id,
name: client.name,
description: client.description
}));
res.json({ success: true, data: clients });
} catch (error) {
logger.error('❌ Failed to get supported clients:', error);
res.status(500).json({ error: 'Failed to get supported clients', message: error.message });
}
});
// 创建新的API Key // 创建新的API Key
router.post('/api-keys', authenticateAdmin, async (req, res) => { router.post('/api-keys', authenticateAdmin, async (req, res) => {
try { try {
@@ -43,7 +317,10 @@ router.post('/api-keys', authenticateAdmin, async (req, res) => {
rateLimitWindow, rateLimitWindow,
rateLimitRequests, rateLimitRequests,
enableModelRestriction, enableModelRestriction,
restrictedModels restrictedModels,
enableClientRestriction,
allowedClients,
dailyCostLimit
} = req.body; } = req.body;
// 输入验证 // 输入验证
@@ -85,6 +362,15 @@ router.post('/api-keys', authenticateAdmin, async (req, res) => {
return res.status(400).json({ error: 'Restricted models must be an array' }); 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({ const newKey = await apiKeyService.generateApiKey({
name, name,
description, description,
@@ -97,7 +383,10 @@ router.post('/api-keys', authenticateAdmin, async (req, res) => {
rateLimitWindow, rateLimitWindow,
rateLimitRequests, rateLimitRequests,
enableModelRestriction, enableModelRestriction,
restrictedModels restrictedModels,
enableClientRestriction,
allowedClients,
dailyCostLimit
}); });
logger.success(`🔑 Admin created new API key: ${name}`); logger.success(`🔑 Admin created new API key: ${name}`);
@@ -112,7 +401,7 @@ router.post('/api-keys', authenticateAdmin, async (req, res) => {
router.put('/api-keys/:keyId', authenticateAdmin, async (req, res) => { router.put('/api-keys/:keyId', authenticateAdmin, async (req, res) => {
try { try {
const { keyId } = req.params; const { keyId } = req.params;
const { tokenLimit, concurrencyLimit, rateLimitWindow, rateLimitRequests, claudeAccountId, geminiAccountId, permissions, enableModelRestriction, restrictedModels } = req.body; const { tokenLimit, concurrencyLimit, rateLimitWindow, rateLimitRequests, claudeAccountId, geminiAccountId, permissions, enableModelRestriction, restrictedModels, enableClientRestriction, allowedClients, expiresAt, dailyCostLimit } = req.body;
// 只允许更新指定字段 // 只允许更新指定字段
const updates = {}; const updates = {};
@@ -178,6 +467,45 @@ router.put('/api-keys/:keyId', authenticateAdmin, async (req, res) => {
updates.restrictedModels = restrictedModels; updates.restrictedModels = restrictedModels;
} }
// 处理客户端限制字段
if (enableClientRestriction !== undefined) {
if (typeof enableClientRestriction !== 'boolean') {
return res.status(400).json({ error: 'Enable client restriction must be a boolean' });
}
updates.enableClientRestriction = enableClientRestriction;
}
if (allowedClients !== undefined) {
if (!Array.isArray(allowedClients)) {
return res.status(400).json({ error: 'Allowed clients must be an array' });
}
updates.allowedClients = allowedClients;
}
// 处理过期时间字段
if (expiresAt !== undefined) {
if (expiresAt === null) {
// null 表示永不过期
updates.expiresAt = null;
} else {
// 验证日期格式
const expireDate = new Date(expiresAt);
if (isNaN(expireDate.getTime())) {
return res.status(400).json({ error: 'Invalid expiration date format' });
}
updates.expiresAt = expiresAt;
}
}
// 处理每日费用限制
if (dailyCostLimit !== undefined && dailyCostLimit !== null && dailyCostLimit !== '') {
const costLimit = Number(dailyCostLimit);
if (isNaN(costLimit) || costLimit < 0) {
return res.status(400).json({ error: 'Daily cost limit must be a non-negative number' });
}
updates.dailyCostLimit = costLimit;
}
await apiKeyService.updateApiKey(keyId, updates); await apiKeyService.updateApiKey(keyId, updates);
logger.success(`📝 Admin updated API key: ${keyId}`); logger.success(`📝 Admin updated API key: ${keyId}`);
@@ -308,7 +636,34 @@ router.post('/claude-accounts/exchange-code', authenticateAdmin, async (req, res
router.get('/claude-accounts', authenticateAdmin, async (req, res) => { router.get('/claude-accounts', authenticateAdmin, async (req, res) => {
try { try {
const accounts = await claudeAccountService.getAllAccounts(); 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) { } catch (error) {
logger.error('❌ Failed to get Claude accounts:', error); logger.error('❌ Failed to get Claude accounts:', error);
res.status(500).json({ error: 'Failed to get Claude accounts', message: error.message }); res.status(500).json({ error: 'Failed to get Claude accounts', message: error.message });
@@ -495,7 +850,18 @@ router.post('/gemini-accounts/exchange-code', authenticateAdmin, async (req, res
router.get('/gemini-accounts', authenticateAdmin, async (req, res) => { router.get('/gemini-accounts', authenticateAdmin, async (req, res) => {
try { try {
const accounts = await geminiAccountService.getAllAccounts(); 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) { } catch (error) {
logger.error('❌ Failed to get Gemini accounts:', error); logger.error('❌ Failed to get Gemini accounts:', error);
res.status(500).json({ error: 'Failed to get accounts', message: error.message }); res.status(500).json({ error: 'Failed to get accounts', message: error.message });
@@ -568,6 +934,73 @@ router.post('/gemini-accounts/:accountId/refresh', authenticateAdmin, async (req
} }
}); });
// 📊 账户使用统计
// 获取所有账户的使用统计
router.get('/accounts/usage-stats', authenticateAdmin, async (req, res) => {
try {
const accountsStats = await redis.getAllAccountsUsageStats();
res.json({
success: true,
data: accountsStats,
summary: {
totalAccounts: accountsStats.length,
activeToday: accountsStats.filter(account => account.daily.requests > 0).length,
totalDailyTokens: accountsStats.reduce((sum, account) => sum + (account.daily.allTokens || 0), 0),
totalDailyRequests: accountsStats.reduce((sum, account) => sum + (account.daily.requests || 0), 0)
},
timestamp: new Date().toISOString()
});
} catch (error) {
logger.error('❌ Failed to get accounts usage stats:', error);
res.status(500).json({
success: false,
error: 'Failed to get accounts usage stats',
message: error.message
});
}
});
// 获取单个账户的使用统计
router.get('/accounts/:accountId/usage-stats', authenticateAdmin, async (req, res) => {
try {
const { accountId } = req.params;
const accountStats = await redis.getAccountUsageStats(accountId);
// 获取账户基本信息
const accountData = await claudeAccountService.getAccount(accountId);
if (!accountData) {
return res.status(404).json({
success: false,
error: 'Account not found'
});
}
res.json({
success: true,
data: {
...accountStats,
accountInfo: {
name: accountData.name,
email: accountData.email,
status: accountData.status,
isActive: accountData.isActive,
createdAt: accountData.createdAt
}
},
timestamp: new Date().toISOString()
});
} catch (error) {
logger.error('❌ Failed to get account usage stats:', error);
res.status(500).json({
success: false,
error: 'Failed to get account usage stats',
message: error.message
});
}
});
// 📊 系统统计 // 📊 系统统计
// 获取系统概览 // 获取系统概览
@@ -582,8 +1015,8 @@ router.get('/dashboard', authenticateAdmin, async (req, res) => {
redis.getSystemAverages() redis.getSystemAverages()
]); ]);
// 计算使用统计(包含cache tokens // 计算使用统计(统一使用allTokens
const totalTokensUsed = apiKeys.reduce((sum, key) => sum + (key.usage?.total?.tokens || 0), 0); const totalTokensUsed = apiKeys.reduce((sum, key) => sum + (key.usage?.total?.allTokens || 0), 0);
const totalRequestsUsed = apiKeys.reduce((sum, key) => sum + (key.usage?.total?.requests || 0), 0); const totalRequestsUsed = apiKeys.reduce((sum, key) => sum + (key.usage?.total?.requests || 0), 0);
const totalInputTokensUsed = apiKeys.reduce((sum, key) => sum + (key.usage?.total?.inputTokens || 0), 0); const totalInputTokensUsed = apiKeys.reduce((sum, key) => sum + (key.usage?.total?.inputTokens || 0), 0);
const totalOutputTokensUsed = apiKeys.reduce((sum, key) => sum + (key.usage?.total?.outputTokens || 0), 0); const totalOutputTokensUsed = apiKeys.reduce((sum, key) => sum + (key.usage?.total?.outputTokens || 0), 0);
@@ -1794,4 +2227,91 @@ function compareVersions(current, latest) {
return currentV.patch - latestV.patch; return currentV.patch - latestV.patch;
} }
// 🎨 OEM设置管理
// 获取OEM设置公开接口用于显示
router.get('/oem-settings', async (req, res) => {
try {
const client = redis.getClient();
const oemSettings = await client.get('oem:settings');
// 默认设置
const defaultSettings = {
siteName: 'Claude Relay Service',
siteIcon: '',
siteIconData: '', // Base64编码的图标数据
updatedAt: new Date().toISOString()
};
let settings = defaultSettings;
if (oemSettings) {
try {
settings = { ...defaultSettings, ...JSON.parse(oemSettings) };
} catch (err) {
logger.warn('⚠️ Failed to parse OEM settings, using defaults:', err.message);
}
}
res.json({
success: true,
data: settings
});
} catch (error) {
logger.error('❌ Failed to get OEM settings:', error);
res.status(500).json({ error: 'Failed to get OEM settings', message: error.message });
}
});
// 更新OEM设置
router.put('/oem-settings', authenticateAdmin, async (req, res) => {
try {
const { siteName, siteIcon, siteIconData } = req.body;
// 验证输入
if (!siteName || typeof siteName !== 'string' || siteName.trim().length === 0) {
return res.status(400).json({ error: 'Site name is required' });
}
if (siteName.length > 100) {
return res.status(400).json({ error: 'Site name must be less than 100 characters' });
}
// 验证图标数据大小如果是base64
if (siteIconData && siteIconData.length > 500000) { // 约375KB
return res.status(400).json({ error: 'Icon file must be less than 350KB' });
}
// 验证图标URL如果提供
if (siteIcon && !siteIconData) {
// 简单验证URL格式
try {
new URL(siteIcon);
} catch (err) {
return res.status(400).json({ error: 'Invalid icon URL format' });
}
}
const settings = {
siteName: siteName.trim(),
siteIcon: (siteIcon || '').trim(),
siteIconData: (siteIconData || '').trim(), // Base64数据
updatedAt: new Date().toISOString()
};
const client = redis.getClient();
await client.set('oem:settings', JSON.stringify(settings));
logger.info(`✅ OEM settings updated: ${siteName}`);
res.json({
success: true,
message: 'OEM settings updated successfully',
data: settings
});
} catch (error) {
logger.error('❌ Failed to update OEM settings:', error);
res.status(500).json({ error: 'Failed to update OEM settings', message: error.message });
}
});
module.exports = router; module.exports = router;

View File

@@ -68,8 +68,9 @@ async function handleMessagesRequest(req, res) {
const cacheReadTokens = usageData.cache_read_input_tokens || 0; const cacheReadTokens = usageData.cache_read_input_tokens || 0;
const model = usageData.model || 'unknown'; const model = usageData.model || 'unknown';
// 记录真实的token使用量包含模型信息和所有4种token // 记录真实的token使用量包含模型信息和所有4种token以及账户ID
apiKeyService.recordUsage(req.apiKey.id, inputTokens, outputTokens, cacheCreateTokens, cacheReadTokens, model).catch(error => { 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); 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 cacheReadTokens = jsonData.usage.cache_read_input_tokens || 0;
const model = jsonData.model || req.body.model || 'unknown'; const model = jsonData.model || req.body.model || 'unknown';
// 记录真实的token使用量包含模型信息和所有4种token // 记录真实的token使用量包含模型信息和所有4种token以及账户ID
await apiKeyService.recordUsage(req.apiKey.id, inputTokens, outputTokens, cacheCreateTokens, cacheReadTokens, model); const accountId = response.accountId;
await apiKeyService.recordUsage(req.apiKey.id, inputTokens, outputTokens, cacheCreateTokens, cacheReadTokens, model, accountId);
// 更新时间窗口内的token计数 // 更新时间窗口内的token计数
if (req.rateLimitInfo) { if (req.rateLimitInfo) {

518
src/routes/apiStats.js Normal file
View File

@@ -0,0 +1,518 @@
const express = require('express');
const path = require('path');
const fs = require('fs');
const redis = require('../models/redis');
const logger = require('../utils/logger');
const apiKeyService = require('../services/apiKeyService');
const CostCalculator = require('../utils/costCalculator');
const router = express.Router();
// 🛡️ 安全文件服务函数
function serveStaticFile(req, res, filename, contentType) {
const filePath = path.join(__dirname, '../../web/apiStats', filename);
try {
// 检查文件是否存在
if (!fs.existsSync(filePath)) {
logger.error(`❌ API Stats file not found: ${filePath}`);
return res.status(404).json({ error: 'File not found' });
}
// 读取并返回文件内容
const content = fs.readFileSync(filePath, 'utf8');
res.setHeader('Content-Type', contentType);
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
res.setHeader('Pragma', 'no-cache');
res.setHeader('Expires', '0');
res.send(content);
logger.info(`📄 Served API Stats file: ${filename}`);
} catch (error) {
logger.error(`❌ Error serving API Stats file ${filename}:`, error);
res.status(500).json({ error: 'Internal server error' });
}
}
// 🏠 API Stats 主页面
router.get('/', (req, res) => {
serveStaticFile(req, res, 'index.html', 'text/html; charset=utf-8');
});
// 📱 JavaScript 文件
router.get('/app.js', (req, res) => {
serveStaticFile(req, res, 'app.js', 'application/javascript; charset=utf-8');
});
// 🎨 CSS 文件
router.get('/style.css', (req, res) => {
serveStaticFile(req, res, 'style.css', 'text/css; charset=utf-8');
});
// 🔑 获取 API Key 对应的 ID
router.post('/api/get-key-id', async (req, res) => {
try {
const { apiKey } = req.body;
if (!apiKey) {
return res.status(400).json({
error: 'API Key is required',
message: 'Please provide your API Key'
});
}
// 基本API Key格式验证
if (typeof apiKey !== 'string' || apiKey.length < 10 || apiKey.length > 512) {
return res.status(400).json({
error: 'Invalid API key format',
message: 'API key format is invalid'
});
}
// 验证API Key
const validation = await apiKeyService.validateApiKey(apiKey);
if (!validation.valid) {
const clientIP = req.ip || req.connection?.remoteAddress || 'unknown';
logger.security(`🔒 Invalid API key in get-key-id: ${validation.error} from ${clientIP}`);
return res.status(401).json({
error: 'Invalid API key',
message: validation.error
});
}
const keyData = validation.keyData;
res.json({
success: true,
data: {
id: keyData.id
}
});
} catch (error) {
logger.error('❌ Failed to get API key ID:', error);
res.status(500).json({
error: 'Internal server error',
message: 'Failed to retrieve API key ID'
});
}
});
// 📊 用户API Key统计查询接口 - 安全的自查询接口
router.post('/api/user-stats', async (req, res) => {
try {
const { apiKey, apiId } = req.body;
let keyData;
let keyId;
if (apiId) {
// 通过 apiId 查询
if (typeof apiId !== 'string' || !apiId.match(/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i)) {
return res.status(400).json({
error: 'Invalid API ID format',
message: 'API ID must be a valid UUID'
});
}
// 直接通过 ID 获取 API Key 数据
keyData = await redis.getApiKey(apiId);
if (!keyData || Object.keys(keyData).length === 0) {
logger.security(`🔒 API key not found for ID: ${apiId} from ${req.ip || 'unknown'}`);
return res.status(404).json({
error: 'API key not found',
message: 'The specified API key does not exist'
});
}
// 检查是否激活
if (keyData.isActive !== 'true') {
return res.status(403).json({
error: 'API key is disabled',
message: 'This API key has been disabled'
});
}
// 检查是否过期
if (keyData.expiresAt && new Date() > new Date(keyData.expiresAt)) {
return res.status(403).json({
error: 'API key has expired',
message: 'This API key has expired'
});
}
keyId = apiId;
// 获取使用统计
const usage = await redis.getUsageStats(keyId);
// 获取当日费用统计
const dailyCost = await redis.getDailyCost(keyId);
// 处理数据格式,与 validateApiKey 返回的格式保持一致
// 解析限制模型数据
let restrictedModels = [];
try {
restrictedModels = keyData.restrictedModels ? JSON.parse(keyData.restrictedModels) : [];
} catch (e) {
restrictedModels = [];
}
// 解析允许的客户端数据
let allowedClients = [];
try {
allowedClients = keyData.allowedClients ? JSON.parse(keyData.allowedClients) : [];
} catch (e) {
allowedClients = [];
}
// 格式化 keyData
keyData = {
...keyData,
tokenLimit: parseInt(keyData.tokenLimit) || 0,
concurrencyLimit: parseInt(keyData.concurrencyLimit) || 0,
rateLimitWindow: parseInt(keyData.rateLimitWindow) || 0,
rateLimitRequests: parseInt(keyData.rateLimitRequests) || 0,
dailyCostLimit: parseFloat(keyData.dailyCostLimit) || 0,
dailyCost: dailyCost || 0,
enableModelRestriction: keyData.enableModelRestriction === 'true',
restrictedModels: restrictedModels,
enableClientRestriction: keyData.enableClientRestriction === 'true',
allowedClients: allowedClients,
permissions: keyData.permissions || 'all',
usage: usage // 使用完整的 usage 数据,而不是只有 total
};
} else if (apiKey) {
// 通过 apiKey 查询(保持向后兼容)
if (typeof apiKey !== 'string' || apiKey.length < 10 || apiKey.length > 512) {
logger.security(`🔒 Invalid API key format in user stats query from ${req.ip || 'unknown'}`);
return res.status(400).json({
error: 'Invalid API key format',
message: 'API key format is invalid'
});
}
// 验证API Key重用现有的验证逻辑
const validation = await apiKeyService.validateApiKey(apiKey);
if (!validation.valid) {
const clientIP = req.ip || req.connection?.remoteAddress || 'unknown';
logger.security(`🔒 Invalid API key in user stats query: ${validation.error} from ${clientIP}`);
return res.status(401).json({
error: 'Invalid API key',
message: validation.error
});
}
keyData = validation.keyData;
keyId = keyData.id;
} else {
logger.security(`🔒 Missing API key or ID in user stats query from ${req.ip || 'unknown'}`);
return res.status(400).json({
error: 'API Key or ID is required',
message: 'Please provide your API Key or API ID'
});
}
// 记录合法查询
logger.api(`📊 User stats query from key: ${keyData.name} (${keyId}) from ${req.ip || 'unknown'}`);
// 获取验证结果中的完整keyData包含isActive状态和cost信息
const fullKeyData = keyData;
// 计算总费用 - 使用与模型统计相同的逻辑(按模型分别计算)
let totalCost = 0;
let formattedCost = '$0.000000';
try {
const client = redis.getClientSafe();
// 获取所有月度模型统计与model-stats接口相同的逻辑
const allModelKeys = await client.keys(`usage:${keyId}:model:monthly:*:*`);
const modelUsageMap = new Map();
for (const key of allModelKeys) {
const modelMatch = key.match(/usage:.+:model:monthly:(.+):(\d{4}-\d{2})$/);
if (!modelMatch) continue;
const model = modelMatch[1];
const data = await client.hgetall(key);
if (data && Object.keys(data).length > 0) {
if (!modelUsageMap.has(model)) {
modelUsageMap.set(model, {
inputTokens: 0,
outputTokens: 0,
cacheCreateTokens: 0,
cacheReadTokens: 0
});
}
const modelUsage = modelUsageMap.get(model);
modelUsage.inputTokens += parseInt(data.inputTokens) || 0;
modelUsage.outputTokens += parseInt(data.outputTokens) || 0;
modelUsage.cacheCreateTokens += parseInt(data.cacheCreateTokens) || 0;
modelUsage.cacheReadTokens += parseInt(data.cacheReadTokens) || 0;
}
}
// 按模型计算费用并汇总
for (const [model, usage] of modelUsageMap) {
const usageData = {
input_tokens: usage.inputTokens,
output_tokens: usage.outputTokens,
cache_creation_input_tokens: usage.cacheCreateTokens,
cache_read_input_tokens: usage.cacheReadTokens
};
const costResult = CostCalculator.calculateCost(usageData, model);
totalCost += costResult.costs.total;
}
// 如果没有模型级别的详细数据,回退到总体数据计算
if (modelUsageMap.size === 0 && fullKeyData.usage?.total?.allTokens > 0) {
const usage = fullKeyData.usage.total;
const costUsage = {
input_tokens: usage.inputTokens || 0,
output_tokens: usage.outputTokens || 0,
cache_creation_input_tokens: usage.cacheCreateTokens || 0,
cache_read_input_tokens: usage.cacheReadTokens || 0
};
const costResult = CostCalculator.calculateCost(costUsage, 'claude-3-5-sonnet-20241022');
totalCost = costResult.costs.total;
}
formattedCost = CostCalculator.formatCost(totalCost);
} catch (error) {
logger.warn(`Failed to calculate detailed cost for key ${keyId}:`, error);
// 回退到简单计算
if (fullKeyData.usage?.total?.allTokens > 0) {
const usage = fullKeyData.usage.total;
const costUsage = {
input_tokens: usage.inputTokens || 0,
output_tokens: usage.outputTokens || 0,
cache_creation_input_tokens: usage.cacheCreateTokens || 0,
cache_read_input_tokens: usage.cacheReadTokens || 0
};
const costResult = CostCalculator.calculateCost(costUsage, 'claude-3-5-sonnet-20241022');
totalCost = costResult.costs.total;
formattedCost = costResult.formatted.total;
}
}
// 构建响应数据只返回该API Key自己的信息确保不泄露其他信息
const responseData = {
id: keyId,
name: fullKeyData.name,
description: keyData.description || '',
isActive: true, // 如果能通过validateApiKey验证说明一定是激活的
createdAt: keyData.createdAt,
expiresAt: keyData.expiresAt,
permissions: fullKeyData.permissions,
// 使用统计(使用验证结果中的完整数据)
usage: {
total: {
...(fullKeyData.usage?.total || {
requests: 0,
tokens: 0,
allTokens: 0,
inputTokens: 0,
outputTokens: 0,
cacheCreateTokens: 0,
cacheReadTokens: 0
}),
cost: totalCost,
formattedCost: formattedCost
}
},
// 限制信息(只显示配置,不显示当前使用量)
limits: {
tokenLimit: fullKeyData.tokenLimit || 0,
concurrencyLimit: fullKeyData.concurrencyLimit || 0,
rateLimitWindow: fullKeyData.rateLimitWindow || 0,
rateLimitRequests: fullKeyData.rateLimitRequests || 0,
dailyCostLimit: fullKeyData.dailyCostLimit || 0
},
// 绑定的账户信息只显示ID不显示敏感信息
accounts: {
claudeAccountId: fullKeyData.claudeAccountId && fullKeyData.claudeAccountId !== '' ? fullKeyData.claudeAccountId : null,
geminiAccountId: fullKeyData.geminiAccountId && fullKeyData.geminiAccountId !== '' ? fullKeyData.geminiAccountId : null
},
// 模型和客户端限制信息
restrictions: {
enableModelRestriction: fullKeyData.enableModelRestriction || false,
restrictedModels: fullKeyData.restrictedModels || [],
enableClientRestriction: fullKeyData.enableClientRestriction || false,
allowedClients: fullKeyData.allowedClients || []
}
};
res.json({
success: true,
data: responseData
});
} catch (error) {
logger.error('❌ Failed to process user stats query:', error);
res.status(500).json({
error: 'Internal server error',
message: 'Failed to retrieve API key statistics'
});
}
});
// 📊 用户模型统计查询接口 - 安全的自查询接口
router.post('/api/user-model-stats', async (req, res) => {
try {
const { apiKey, apiId, period = 'monthly' } = req.body;
let keyData;
let keyId;
if (apiId) {
// 通过 apiId 查询
if (typeof apiId !== 'string' || !apiId.match(/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i)) {
return res.status(400).json({
error: 'Invalid API ID format',
message: 'API ID must be a valid UUID'
});
}
// 直接通过 ID 获取 API Key 数据
keyData = await redis.getApiKey(apiId);
if (!keyData || Object.keys(keyData).length === 0) {
logger.security(`🔒 API key not found for ID: ${apiId} from ${req.ip || 'unknown'}`);
return res.status(404).json({
error: 'API key not found',
message: 'The specified API key does not exist'
});
}
// 检查是否激活
if (keyData.isActive !== 'true') {
return res.status(403).json({
error: 'API key is disabled',
message: 'This API key has been disabled'
});
}
keyId = apiId;
// 获取使用统计
const usage = await redis.getUsageStats(keyId);
keyData.usage = { total: usage.total };
} else if (apiKey) {
// 通过 apiKey 查询(保持向后兼容)
// 验证API Key
const validation = await apiKeyService.validateApiKey(apiKey);
if (!validation.valid) {
const clientIP = req.ip || req.connection?.remoteAddress || 'unknown';
logger.security(`🔒 Invalid API key in user model stats query: ${validation.error} from ${clientIP}`);
return res.status(401).json({
error: 'Invalid API key',
message: validation.error
});
}
keyData = validation.keyData;
keyId = keyData.id;
} else {
logger.security(`🔒 Missing API key or ID in user model stats query from ${req.ip || 'unknown'}`);
return res.status(400).json({
error: 'API Key or ID is required',
message: 'Please provide your API Key or API ID'
});
}
logger.api(`📊 User model stats query from key: ${keyData.name} (${keyId}) for period: ${period}`);
// 重用管理后台的模型统计逻辑但只返回该API Key的数据
const client = redis.getClientSafe();
// 使用与管理页面相同的时区处理逻辑
const tzDate = redis.getDateInTimezone();
const today = redis.getDateStringInTimezone();
const currentMonth = `${tzDate.getFullYear()}-${String(tzDate.getMonth() + 1).padStart(2, '0')}`;
const pattern = period === 'daily' ?
`usage:${keyId}:model:daily:*:${today}` :
`usage:${keyId}:model:monthly:*:${currentMonth}`;
const keys = await client.keys(pattern);
const modelStats = [];
for (const key of keys) {
const match = key.match(period === 'daily' ?
/usage:.+:model:daily:(.+):\d{4}-\d{2}-\d{2}$/ :
/usage:.+:model:monthly:(.+):\d{4}-\d{2}$/
);
if (!match) continue;
const model = match[1];
const data = await client.hgetall(key);
if (data && Object.keys(data).length > 0) {
const usage = {
input_tokens: parseInt(data.inputTokens) || 0,
output_tokens: parseInt(data.outputTokens) || 0,
cache_creation_input_tokens: parseInt(data.cacheCreateTokens) || 0,
cache_read_input_tokens: parseInt(data.cacheReadTokens) || 0
};
const costData = CostCalculator.calculateCost(usage, model);
modelStats.push({
model,
requests: parseInt(data.requests) || 0,
inputTokens: usage.input_tokens,
outputTokens: usage.output_tokens,
cacheCreateTokens: usage.cache_creation_input_tokens,
cacheReadTokens: usage.cache_read_input_tokens,
allTokens: parseInt(data.allTokens) || 0,
costs: costData.costs,
formatted: costData.formatted,
pricing: costData.pricing
});
}
}
// 如果没有详细的模型数据,不显示历史数据以避免混淆
// 只有在查询特定时间段时返回空数组,表示该时间段确实没有数据
if (modelStats.length === 0) {
logger.info(`📊 No model stats found for key ${keyId} in period ${period}`);
}
// 按总token数降序排列
modelStats.sort((a, b) => b.allTokens - a.allTokens);
res.json({
success: true,
data: modelStats,
period: period
});
} catch (error) {
logger.error('❌ Failed to process user model stats query:', error);
res.status(500).json({
error: 'Internal server error',
message: 'Failed to retrieve model statistics'
});
}
});
module.exports = router;

View File

@@ -258,7 +258,8 @@ async function handleChatCompletion(req, res, apiKeyData) {
outputTokens, outputTokens,
cacheCreateTokens, cacheCreateTokens,
cacheReadTokens, cacheReadTokens,
model model,
accountId
).catch(error => { ).catch(error => {
logger.error('❌ Failed to record usage:', error); logger.error('❌ Failed to record usage:', error);
}); });
@@ -327,7 +328,8 @@ async function handleChatCompletion(req, res, apiKeyData) {
usage.output_tokens || 0, usage.output_tokens || 0,
usage.cache_creation_input_tokens || 0, usage.cache_creation_input_tokens || 0,
usage.cache_read_input_tokens || 0, usage.cache_read_input_tokens || 0,
claudeRequest.model claudeRequest.model,
accountId
).catch(error => { ).catch(error => {
logger.error('❌ Failed to record usage:', error); logger.error('❌ Failed to record usage:', error);
}); });

View File

@@ -25,7 +25,7 @@ const ALLOWED_FILES = {
'style.css': { 'style.css': {
path: path.join(__dirname, '../../web/admin/style.css'), path: path.join(__dirname, '../../web/admin/style.css'),
contentType: 'text/css; charset=utf-8' contentType: 'text/css; charset=utf-8'
} },
}; };
// 🛡️ 安全文件服务函数 // 🛡️ 安全文件服务函数
@@ -400,6 +400,9 @@ router.get('/style.css', (req, res) => {
serveWhitelistedFile(req, res, 'style.css'); serveWhitelistedFile(req, res, 'style.css');
}); });
// 🔑 Gemini OAuth 回调页面 // 🔑 Gemini OAuth 回调页面
module.exports = router; module.exports = router;

View File

@@ -24,7 +24,10 @@ class ApiKeyService {
rateLimitWindow = null, rateLimitWindow = null,
rateLimitRequests = null, rateLimitRequests = null,
enableModelRestriction = false, enableModelRestriction = false,
restrictedModels = [] restrictedModels = [],
enableClientRestriction = false,
allowedClients = [],
dailyCostLimit = 0
} = options; } = options;
// 生成简单的API Key (64字符十六进制) // 生成简单的API Key (64字符十六进制)
@@ -47,6 +50,9 @@ class ApiKeyService {
permissions: permissions || 'all', permissions: permissions || 'all',
enableModelRestriction: String(enableModelRestriction), enableModelRestriction: String(enableModelRestriction),
restrictedModels: JSON.stringify(restrictedModels || []), restrictedModels: JSON.stringify(restrictedModels || []),
enableClientRestriction: String(enableClientRestriction || false),
allowedClients: JSON.stringify(allowedClients || []),
dailyCostLimit: String(dailyCostLimit || 0),
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
lastUsedAt: '', lastUsedAt: '',
expiresAt: expiresAt || '', expiresAt: expiresAt || '',
@@ -73,6 +79,9 @@ class ApiKeyService {
permissions: keyData.permissions, permissions: keyData.permissions,
enableModelRestriction: keyData.enableModelRestriction === 'true', enableModelRestriction: keyData.enableModelRestriction === 'true',
restrictedModels: JSON.parse(keyData.restrictedModels), restrictedModels: JSON.parse(keyData.restrictedModels),
enableClientRestriction: keyData.enableClientRestriction === 'true',
allowedClients: JSON.parse(keyData.allowedClients || '[]'),
dailyCostLimit: parseFloat(keyData.dailyCostLimit || 0),
createdAt: keyData.createdAt, createdAt: keyData.createdAt,
expiresAt: keyData.expiresAt, expiresAt: keyData.expiresAt,
createdBy: keyData.createdBy createdBy: keyData.createdBy
@@ -109,6 +118,9 @@ class ApiKeyService {
// 获取使用统计(供返回数据使用) // 获取使用统计(供返回数据使用)
const usage = await redis.getUsageStats(keyData.id); const usage = await redis.getUsageStats(keyData.id);
// 获取当日费用统计
const dailyCost = await redis.getDailyCost(keyData.id);
// 更新最后使用时间优化只在实际API调用时更新而不是验证时 // 更新最后使用时间优化只在实际API调用时更新而不是验证时
// 注意lastUsedAt的更新已移至recordUsage方法中 // 注意lastUsedAt的更新已移至recordUsage方法中
@@ -122,11 +134,22 @@ class ApiKeyService {
restrictedModels = []; restrictedModels = [];
} }
// 解析允许的客户端
let allowedClients = [];
try {
allowedClients = keyData.allowedClients ? JSON.parse(keyData.allowedClients) : [];
} catch (e) {
allowedClients = [];
}
return { return {
valid: true, valid: true,
keyData: { keyData: {
id: keyData.id, id: keyData.id,
name: keyData.name, name: keyData.name,
description: keyData.description,
createdAt: keyData.createdAt,
expiresAt: keyData.expiresAt,
claudeAccountId: keyData.claudeAccountId, claudeAccountId: keyData.claudeAccountId,
geminiAccountId: keyData.geminiAccountId, geminiAccountId: keyData.geminiAccountId,
permissions: keyData.permissions || 'all', permissions: keyData.permissions || 'all',
@@ -136,6 +159,10 @@ class ApiKeyService {
rateLimitRequests: parseInt(keyData.rateLimitRequests || 0), rateLimitRequests: parseInt(keyData.rateLimitRequests || 0),
enableModelRestriction: keyData.enableModelRestriction === 'true', enableModelRestriction: keyData.enableModelRestriction === 'true',
restrictedModels: restrictedModels, restrictedModels: restrictedModels,
enableClientRestriction: keyData.enableClientRestriction === 'true',
allowedClients: allowedClients,
dailyCostLimit: parseFloat(keyData.dailyCostLimit || 0),
dailyCost: dailyCost || 0,
usage usage
} }
}; };
@@ -160,12 +187,20 @@ class ApiKeyService {
key.currentConcurrency = await redis.getConcurrency(key.id); key.currentConcurrency = await redis.getConcurrency(key.id);
key.isActive = key.isActive === 'true'; key.isActive = key.isActive === 'true';
key.enableModelRestriction = key.enableModelRestriction === 'true'; key.enableModelRestriction = key.enableModelRestriction === 'true';
key.enableClientRestriction = key.enableClientRestriction === 'true';
key.permissions = key.permissions || 'all'; // 兼容旧数据 key.permissions = key.permissions || 'all'; // 兼容旧数据
key.dailyCostLimit = parseFloat(key.dailyCostLimit || 0);
key.dailyCost = await redis.getDailyCost(key.id) || 0;
try { try {
key.restrictedModels = key.restrictedModels ? JSON.parse(key.restrictedModels) : []; key.restrictedModels = key.restrictedModels ? JSON.parse(key.restrictedModels) : [];
} catch (e) { } catch (e) {
key.restrictedModels = []; key.restrictedModels = [];
} }
try {
key.allowedClients = key.allowedClients ? JSON.parse(key.allowedClients) : [];
} catch (e) {
key.allowedClients = [];
}
delete key.apiKey; // 不返回哈希后的key delete key.apiKey; // 不返回哈希后的key
} }
@@ -185,15 +220,15 @@ class ApiKeyService {
} }
// 允许更新的字段 // 允许更新的字段
const allowedUpdates = ['name', 'description', 'tokenLimit', 'concurrencyLimit', 'rateLimitWindow', 'rateLimitRequests', 'isActive', 'claudeAccountId', 'geminiAccountId', 'permissions', 'expiresAt', 'enableModelRestriction', 'restrictedModels']; const allowedUpdates = ['name', 'description', 'tokenLimit', 'concurrencyLimit', 'rateLimitWindow', 'rateLimitRequests', 'isActive', 'claudeAccountId', 'geminiAccountId', 'permissions', 'expiresAt', 'enableModelRestriction', 'restrictedModels', 'enableClientRestriction', 'allowedClients', 'dailyCostLimit'];
const updatedData = { ...keyData }; const updatedData = { ...keyData };
for (const [field, value] of Object.entries(updates)) { for (const [field, value] of Object.entries(updates)) {
if (allowedUpdates.includes(field)) { if (allowedUpdates.includes(field)) {
if (field === 'restrictedModels') { if (field === 'restrictedModels' || field === 'allowedClients') {
// 特殊处理 restrictedModels 数组 // 特殊处理数组字段
updatedData[field] = JSON.stringify(value || []); updatedData[field] = JSON.stringify(value || []);
} else if (field === 'enableModelRestriction') { } else if (field === 'enableModelRestriction' || field === 'enableClientRestriction') {
// 布尔值转字符串 // 布尔值转字符串
updatedData[field] = String(value); updatedData[field] = String(value);
} else { } else {
@@ -234,18 +269,45 @@ class ApiKeyService {
} }
} }
// 📊 记录使用情况支持缓存token // 📊 记录使用情况支持缓存token和账户级别统计
async recordUsage(keyId, inputTokens = 0, outputTokens = 0, cacheCreateTokens = 0, cacheReadTokens = 0, model = 'unknown') { async recordUsage(keyId, inputTokens = 0, outputTokens = 0, cacheCreateTokens = 0, cacheReadTokens = 0, model = 'unknown', accountId = null) {
try { try {
const totalTokens = inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens; const totalTokens = inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens;
// 计算费用
const CostCalculator = require('../utils/costCalculator');
const costInfo = CostCalculator.calculateCost({
input_tokens: inputTokens,
output_tokens: outputTokens,
cache_creation_input_tokens: cacheCreateTokens,
cache_read_input_tokens: cacheReadTokens
}, model);
// 记录API Key级别的使用统计
await redis.incrementTokenUsage(keyId, totalTokens, inputTokens, outputTokens, cacheCreateTokens, cacheReadTokens, model); await redis.incrementTokenUsage(keyId, totalTokens, inputTokens, outputTokens, cacheCreateTokens, cacheReadTokens, model);
// 更新最后使用时间(性能优化:只在实际使用时更新) // 记录费用统计
if (costInfo.costs.total > 0) {
await redis.incrementDailyCost(keyId, costInfo.costs.total);
logger.database(`💰 Recorded cost for ${keyId}: $${costInfo.costs.total.toFixed(6)}, model: ${model}`);
} else {
logger.debug(`💰 No cost recorded for ${keyId} - zero cost for model: ${model}`);
}
// 获取API Key数据以确定关联的账户
const keyData = await redis.getApiKey(keyId); const keyData = await redis.getApiKey(keyId);
if (keyData && Object.keys(keyData).length > 0) { if (keyData && Object.keys(keyData).length > 0) {
// 更新最后使用时间
keyData.lastUsedAt = new Date().toISOString(); keyData.lastUsedAt = new Date().toISOString();
// 使用记录时不需要重新建立哈希映射
await redis.setApiKey(keyId, keyData); await redis.setApiKey(keyId, keyData);
// 记录账户级别的使用统计(只统计实际处理请求的账户)
if (accountId) {
await redis.incrementAccountUsage(accountId, totalTokens, inputTokens, outputTokens, cacheCreateTokens, cacheReadTokens, model);
logger.database(`📊 Recorded account usage: ${accountId} - ${totalTokens} tokens (API Key: ${keyId})`);
} else {
logger.debug('⚠️ No accountId provided for usage recording, skipping account-level statistics');
}
} }
const logParts = [`Model: ${model}`, `Input: ${inputTokens}`, `Output: ${outputTokens}`]; const logParts = [`Model: ${model}`, `Input: ${inputTokens}`, `Output: ${outputTokens}`];
@@ -274,6 +336,16 @@ class ApiKeyService {
return await redis.getUsageStats(keyId); return await redis.getUsageStats(keyId);
} }
// 📊 获取账户使用统计
async getAccountUsageStats(accountId) {
return await redis.getAccountUsageStats(accountId);
}
// 📈 获取所有账户使用统计
async getAllAccountsUsageStats() {
return await redis.getAllAccountsUsageStats();
}
// 🧹 清理过期的API Keys // 🧹 清理过期的API Keys
async cleanupExpiredKeys() { async cleanupExpiredKeys() {
@@ -283,14 +355,17 @@ class ApiKeyService {
let cleanedCount = 0; let cleanedCount = 0;
for (const key of apiKeys) { for (const key of apiKeys) {
if (key.expiresAt && new Date(key.expiresAt) < now) { // 检查是否已过期且仍处于激活状态
await redis.deleteApiKey(key.id); if (key.expiresAt && new Date(key.expiresAt) < now && key.isActive === 'true') {
// 将过期的 API Key 标记为禁用状态,而不是直接删除
await this.updateApiKey(key.id, { isActive: false });
logger.info(`🔒 API Key ${key.id} (${key.name}) has expired and been disabled`);
cleanedCount++; cleanedCount++;
} }
} }
if (cleanedCount > 0) { if (cleanedCount > 0) {
logger.success(`🧹 Cleaned up ${cleanedCount} expired API keys`); logger.success(`🧹 Disabled ${cleanedCount} expired API keys`);
} }
return cleanedCount; return cleanedCount;

View File

@@ -444,11 +444,11 @@ class ClaudeAccountService {
} }
// 如果没有映射或映射无效,选择新账户 // 如果没有映射或映射无效,选择新账户
// 优先选择最近刷新过token的账户 // 优先选择最久未使用的账户(负载均衡)
const sortedAccounts = activeAccounts.sort((a, b) => { const sortedAccounts = activeAccounts.sort((a, b) => {
const aLastRefresh = new Date(a.lastRefreshAt || 0).getTime(); const aLastUsed = new Date(a.lastUsedAt || 0).getTime();
const bLastRefresh = new Date(b.lastRefreshAt || 0).getTime(); const bLastUsed = new Date(b.lastUsedAt || 0).getTime();
return bLastRefresh - aLastRefresh; return aLastUsed - bLastUsed; // 最久未使用的优先
}); });
const selectedAccountId = sortedAccounts[0].id; const selectedAccountId = sortedAccounts[0].id;
@@ -544,11 +544,11 @@ class ClaudeAccountService {
return aRateLimitedAt - bRateLimitedAt; // 最早限流的优先 return aRateLimitedAt - bRateLimitedAt; // 最早限流的优先
}); });
} else { } else {
// 非限流账户按最近刷新时间排序 // 非限流账户按最后使用时间排序(最久未使用的优先)
candidateAccounts = candidateAccounts.sort((a, b) => { candidateAccounts = candidateAccounts.sort((a, b) => {
const aLastRefresh = new Date(a.lastRefreshAt || 0).getTime(); const aLastUsed = new Date(a.lastUsedAt || 0).getTime();
const bLastRefresh = new Date(b.lastRefreshAt || 0).getTime(); const bLastUsed = new Date(b.lastUsedAt || 0).getTime();
return bLastRefresh - aLastRefresh; return aLastUsed - bLastUsed; // 最久未使用的优先
}); });
} }

View File

@@ -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`); 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; return response;
} catch (error) { } catch (error) {
logger.error(`❌ Claude relay request failed for key: ${apiKeyData.name || apiKeyData.id}:`, error.message); 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); const proxyAgent = await this._getProxyAgent(accountId);
// 发送流式请求并捕获usage数据 // 发送流式请求并捕获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) { } catch (error) {
logger.error('❌ Claude stream relay with usage capture failed:', error); logger.error('❌ Claude stream relay with usage capture failed:', error);
throw error; throw error;

View File

@@ -0,0 +1,182 @@
const redis = require('../models/redis');
const apiKeyService = require('./apiKeyService');
const CostCalculator = require('../utils/costCalculator');
const logger = require('../utils/logger');
class CostInitService {
/**
* 初始化所有API Key的费用数据
* 扫描历史使用记录并计算费用
*/
async initializeAllCosts() {
try {
logger.info('💰 Starting cost initialization for all API Keys...');
const apiKeys = await apiKeyService.getAllApiKeys();
const client = redis.getClientSafe();
let processedCount = 0;
let errorCount = 0;
for (const apiKey of apiKeys) {
try {
await this.initializeApiKeyCosts(apiKey.id, client);
processedCount++;
if (processedCount % 10 === 0) {
logger.info(`💰 Processed ${processedCount} API Keys...`);
}
} catch (error) {
errorCount++;
logger.error(`❌ Failed to initialize costs for API Key ${apiKey.id}:`, error);
}
}
logger.success(`💰 Cost initialization completed! Processed: ${processedCount}, Errors: ${errorCount}`);
return { processed: processedCount, errors: errorCount };
} catch (error) {
logger.error('❌ Failed to initialize costs:', error);
throw error;
}
}
/**
* 初始化单个API Key的费用数据
*/
async initializeApiKeyCosts(apiKeyId, client) {
// 获取所有时间的模型使用统计
const modelKeys = await client.keys(`usage:${apiKeyId}:model:*:*:*`);
// 按日期分组统计
const dailyCosts = new Map(); // date -> cost
const monthlyCosts = new Map(); // month -> cost
const hourlyCosts = new Map(); // hour -> cost
for (const key of modelKeys) {
// 解析key格式: usage:{keyId}:model:{period}:{model}:{date}
const match = key.match(/usage:(.+):model:(daily|monthly|hourly):(.+):(\d{4}-\d{2}(?:-\d{2})?(?::\d{2})?)$/);
if (!match) continue;
const [, , period, model, dateStr] = match;
// 获取使用数据
const data = await client.hgetall(key);
if (!data || Object.keys(data).length === 0) continue;
// 计算费用
const usage = {
input_tokens: parseInt(data.totalInputTokens) || parseInt(data.inputTokens) || 0,
output_tokens: parseInt(data.totalOutputTokens) || parseInt(data.outputTokens) || 0,
cache_creation_input_tokens: parseInt(data.totalCacheCreateTokens) || parseInt(data.cacheCreateTokens) || 0,
cache_read_input_tokens: parseInt(data.totalCacheReadTokens) || parseInt(data.cacheReadTokens) || 0
};
const costResult = CostCalculator.calculateCost(usage, model);
const cost = costResult.costs.total;
// 根据period分组累加费用
if (period === 'daily') {
const currentCost = dailyCosts.get(dateStr) || 0;
dailyCosts.set(dateStr, currentCost + cost);
} else if (period === 'monthly') {
const currentCost = monthlyCosts.get(dateStr) || 0;
monthlyCosts.set(dateStr, currentCost + cost);
} else if (period === 'hourly') {
const currentCost = hourlyCosts.get(dateStr) || 0;
hourlyCosts.set(dateStr, currentCost + cost);
}
}
// 将计算出的费用写入Redis
const promises = [];
// 写入每日费用
for (const [date, cost] of dailyCosts) {
const key = `usage:cost:daily:${apiKeyId}:${date}`;
promises.push(
client.set(key, cost.toString()),
client.expire(key, 86400 * 30) // 30天过期
);
}
// 写入每月费用
for (const [month, cost] of monthlyCosts) {
const key = `usage:cost:monthly:${apiKeyId}:${month}`;
promises.push(
client.set(key, cost.toString()),
client.expire(key, 86400 * 90) // 90天过期
);
}
// 写入每小时费用
for (const [hour, cost] of hourlyCosts) {
const key = `usage:cost:hourly:${apiKeyId}:${hour}`;
promises.push(
client.set(key, cost.toString()),
client.expire(key, 86400 * 7) // 7天过期
);
}
// 计算总费用
let totalCost = 0;
for (const cost of dailyCosts.values()) {
totalCost += cost;
}
// 写入总费用
if (totalCost > 0) {
const totalKey = `usage:cost:total:${apiKeyId}`;
promises.push(client.set(totalKey, totalCost.toString()));
}
await Promise.all(promises);
logger.debug(`💰 Initialized costs for API Key ${apiKeyId}: Daily entries: ${dailyCosts.size}, Total cost: $${totalCost.toFixed(2)}`);
}
/**
* 检查是否需要初始化费用数据
*/
async needsInitialization() {
try {
const client = redis.getClientSafe();
// 检查是否有任何费用数据
const costKeys = await client.keys('usage:cost:*');
// 如果没有费用数据,需要初始化
if (costKeys.length === 0) {
logger.info('💰 No cost data found, initialization needed');
return true;
}
// 检查是否有使用数据但没有对应的费用数据
const sampleKeys = await client.keys('usage:*:model:daily:*:*');
if (sampleKeys.length > 10) {
// 抽样检查
const sampleSize = Math.min(10, sampleKeys.length);
for (let i = 0; i < sampleSize; i++) {
const usageKey = sampleKeys[Math.floor(Math.random() * sampleKeys.length)];
const match = usageKey.match(/usage:(.+):model:daily:(.+):(\d{4}-\d{2}-\d{2})$/);
if (match) {
const [, keyId, , date] = match;
const costKey = `usage:cost:daily:${keyId}:${date}`;
const hasCost = await client.exists(costKey);
if (!hasCost) {
logger.info(`💰 Found usage without cost data for key ${keyId} on ${date}, initialization needed`);
return true;
}
}
}
}
logger.info('💰 Cost data appears to be up to date');
return false;
} catch (error) {
logger.error('❌ Failed to check initialization status:', error);
return false;
}
}
}
module.exports = new CostInitService();

View File

@@ -107,7 +107,7 @@ const securityLogger = winston.createLogger({
// 🌟 增强的 Winston logger // 🌟 增强的 Winston logger
const logger = winston.createLogger({ const logger = winston.createLogger({
level: config.logging.level, level: process.env.LOG_LEVEL || config.logging.level,
format: logFormat, format: logFormat,
transports: [ transports: [
// 📄 文件输出 // 📄 文件输出
@@ -282,10 +282,11 @@ logger.healthCheck = () => {
// 🎬 启动日志记录系统 // 🎬 启动日志记录系统
logger.start('Logger initialized', { logger.start('Logger initialized', {
level: config.logging.level, level: process.env.LOG_LEVEL || config.logging.level,
directory: config.logging.dirname, directory: config.logging.dirname,
maxSize: config.logging.maxSize, maxSize: config.logging.maxSize,
maxFiles: config.logging.maxFiles maxFiles: config.logging.maxFiles,
envOverride: process.env.LOG_LEVEL ? true : false
}); });
module.exports = logger; module.exports = logger;

View File

@@ -1,3 +1,4 @@
/* global Vue, Chart, ElementPlus, ElementPlusLocaleZhCn, FileReader, document, localStorage, location, navigator, window */
const { createApp } = Vue; const { createApp } = Vue;
const app = createApp({ const app = createApp({
@@ -24,7 +25,8 @@ const app = createApp({
{ key: 'dashboard', name: '仪表板', icon: 'fas fa-tachometer-alt' }, { key: 'dashboard', name: '仪表板', icon: 'fas fa-tachometer-alt' },
{ key: 'apiKeys', name: 'API Keys', icon: 'fas fa-key' }, { key: 'apiKeys', name: 'API Keys', icon: 'fas fa-key' },
{ key: 'accounts', name: '账户管理', icon: 'fas fa-user-circle' }, { key: 'accounts', name: '账户管理', icon: 'fas fa-user-circle' },
{ key: 'tutorial', name: '使用教程', icon: 'fas fa-graduation-cap' } { key: 'tutorial', name: '使用教程', icon: 'fas fa-graduation-cap' },
{ key: 'settings', name: '其他设置', icon: 'fas fa-cogs' }
], ],
// 教程系统选择 // 教程系统选择
@@ -111,6 +113,9 @@ const app = createApp({
// API Keys // API Keys
apiKeys: [], apiKeys: [],
apiKeysLoading: false, apiKeysLoading: false,
apiKeyStatsTimeRange: 'all', // API Key统计时间范围all, 7days, monthly
apiKeysSortBy: '', // 当前排序字段
apiKeysSortOrder: 'asc', // 排序顺序 'asc' 或 'desc'
showCreateApiKeyModal: false, showCreateApiKeyModal: false,
createApiKeyLoading: false, createApiKeyLoading: false,
apiKeyForm: { apiKeyForm: {
@@ -125,7 +130,13 @@ const app = createApp({
permissions: 'all', // 'claude', 'gemini', 'all' permissions: 'all', // 'claude', 'gemini', 'all'
enableModelRestriction: false, enableModelRestriction: false,
restrictedModels: [], restrictedModels: [],
modelInput: '' modelInput: '',
enableClientRestriction: false,
allowedClients: [],
expireDuration: '', // 过期时长选择
customExpireDate: '', // 自定义过期日期
expiresAt: null, // 实际的过期时间戳
dailyCostLimit: '' // 每日费用限制
}, },
apiKeyModelStats: {}, // 存储每个key的模型统计数据 apiKeyModelStats: {}, // 存储每个key的模型统计数据
expandedApiKeys: {}, // 跟踪展开的API Keys expandedApiKeys: {}, // 跟踪展开的API Keys
@@ -155,6 +166,18 @@ const app = createApp({
showFullKey: false showFullKey: false
}, },
// API Key续期
showRenewApiKeyModal: false,
renewApiKeyLoading: false,
renewApiKeyForm: {
id: '',
name: '',
currentExpiresAt: null,
renewDuration: '30d',
customExpireDate: '',
newExpiresAt: null
},
// 编辑API Key // 编辑API Key
showEditApiKeyModal: false, showEditApiKeyModal: false,
editApiKeyLoading: false, editApiKeyLoading: false,
@@ -170,12 +193,20 @@ const app = createApp({
permissions: 'all', permissions: 'all',
enableModelRestriction: false, enableModelRestriction: false,
restrictedModels: [], restrictedModels: [],
modelInput: '' modelInput: '',
enableClientRestriction: false,
allowedClients: [],
dailyCostLimit: ''
}, },
// 支持的客户端列表
supportedClients: [],
// 账户 // 账户
accounts: [], accounts: [],
accountsLoading: false, accountsLoading: false,
accountSortBy: 'dailyTokens', // 默认按今日Token排序
accountsSortOrder: 'asc', // 排序顺序 'asc' 或 'desc'
showCreateAccountModal: false, showCreateAccountModal: false,
createAccountLoading: false, createAccountLoading: false,
accountForm: { accountForm: {
@@ -269,7 +300,17 @@ const app = createApp({
showReleaseNotes: false, // 是否显示发布说明 showReleaseNotes: false, // 是否显示发布说明
autoCheckInterval: null, // 自动检查定时器 autoCheckInterval: null, // 自动检查定时器
noUpdateMessage: false // 显示"已是最新版"提醒 noUpdateMessage: false // 显示"已是最新版"提醒
} },
// OEM设置相关
oemSettings: {
siteName: 'Claude Relay Service',
siteIcon: '',
siteIconData: '', // Base64图标数据
updatedAt: null
},
oemSettingsLoading: false,
oemSettingsSaving: false
} }
}, },
@@ -279,17 +320,104 @@ const app = createApp({
return `${window.location.protocol}//${window.location.host}/api/`; return `${window.location.protocol}//${window.location.host}/api/`;
}, },
// 排序后的账户列表
sortedAccounts() {
if (!this.accountsSortBy) {
return this.accounts;
}
return [...this.accounts].sort((a, b) => {
let aValue = a[this.accountsSortBy];
let bValue = b[this.accountsSortBy];
// 特殊处理状态字段
if (this.accountsSortBy === 'status') {
aValue = a.isActive ? 1 : 0;
bValue = b.isActive ? 1 : 0;
}
// 处理字符串比较
if (typeof aValue === 'string' && typeof bValue === 'string') {
aValue = aValue.toLowerCase();
bValue = bValue.toLowerCase();
}
// 排序
if (this.accountsSortOrder === 'asc') {
return aValue > bValue ? 1 : aValue < bValue ? -1 : 0;
} else {
return aValue < bValue ? 1 : aValue > bValue ? -1 : 0;
}
});
},
// 排序后的API Keys列表
sortedApiKeys() {
if (!this.apiKeysSortBy) {
return this.apiKeys;
}
return [...this.apiKeys].sort((a, b) => {
let aValue, bValue;
// 特殊处理不同字段
switch (this.apiKeysSortBy) {
case 'status':
aValue = a.isActive ? 1 : 0;
bValue = b.isActive ? 1 : 0;
break;
case 'cost':
// 计算费用,转换为数字比较
aValue = this.calculateApiKeyCostNumber(a.usage);
bValue = this.calculateApiKeyCostNumber(b.usage);
break;
case 'createdAt':
case 'expiresAt':
// 日期比较
aValue = a[this.apiKeysSortBy] ? new Date(a[this.apiKeysSortBy]).getTime() : 0;
bValue = b[this.apiKeysSortBy] ? new Date(b[this.apiKeysSortBy]).getTime() : 0;
break;
default:
aValue = a[this.apiKeysSortBy];
bValue = b[this.apiKeysSortBy];
// 处理字符串比较
if (typeof aValue === 'string' && typeof bValue === 'string') {
aValue = aValue.toLowerCase();
bValue = bValue.toLowerCase();
}
}
// 排序
if (this.apiKeysSortOrder === 'asc') {
return aValue > bValue ? 1 : aValue < bValue ? -1 : 0;
} else {
return aValue < bValue ? 1 : aValue > bValue ? -1 : 0;
}
});
},
// 获取专属账号列表 // 获取专属账号列表
dedicatedAccounts() { dedicatedAccounts() {
return this.accounts.filter(account => return this.accounts.filter(account =>
account.accountType === 'dedicated' && account.isActive === true account.accountType === 'dedicated' && account.isActive === true
); );
},
// 计算最小日期时间(当前时间)
minDateTime() {
const now = new Date();
now.setMinutes(now.getMinutes() - now.getTimezoneOffset());
return now.toISOString().slice(0, 16);
} }
}, },
mounted() { mounted() {
console.log('Vue app mounted, authToken:', !!this.authToken, 'activeTab:', this.activeTab); console.log('Vue app mounted, authToken:', !!this.authToken, 'activeTab:', this.activeTab);
// 从URL参数中读取tab信息
this.initializeTabFromUrl();
// 初始化防抖函数 // 初始化防抖函数
this.setTrendPeriod = this.debounce(this._setTrendPeriod, 300); this.setTrendPeriod = this.debounce(this._setTrendPeriod, 300);
@@ -303,6 +431,11 @@ const app = createApp({
} }
}); });
// 监听浏览器前进后退按钮事件
window.addEventListener('popstate', () => {
this.initializeTabFromUrl();
});
if (this.authToken) { if (this.authToken) {
this.isLoggedIn = true; this.isLoggedIn = true;
@@ -315,14 +448,16 @@ const app = createApp({
// 初始化日期筛选器和图表数据 // 初始化日期筛选器和图表数据
this.initializeDateFilter(); this.initializeDateFilter();
// 预加载账号列表API Keys以便正确显示绑定关系 // 预加载账号列表API Keys和支持的客户端,以便正确显示绑定关系
Promise.all([ Promise.all([
this.loadAccounts(), this.loadAccounts(),
this.loadApiKeys() this.loadApiKeys(),
this.loadSupportedClients()
]).then(() => { ]).then(() => {
// 根据当前活跃标签页加载数据 // 根据当前活跃标签页加载数据
this.loadCurrentTabData(); this.loadCurrentTabData();
}); });
// 如果在仪表盘等待Chart.js加载后初始化图表 // 如果在仪表盘等待Chart.js加载后初始化图表
if (this.activeTab === 'dashboard') { if (this.activeTab === 'dashboard') {
this.waitForChartJS().then(() => { this.waitForChartJS().then(() => {
@@ -334,6 +469,9 @@ const app = createApp({
} else { } else {
console.log('No auth token found, user needs to login'); console.log('No auth token found, user needs to login');
} }
// 始终加载OEM设置无论登录状态
this.loadOemSettings();
}, },
beforeUnmount() { beforeUnmount() {
@@ -368,6 +506,64 @@ const app = createApp({
}, },
methods: { methods: {
// 账户列表排序
sortAccounts(field) {
if (this.accountsSortBy === field) {
// 如果点击的是当前排序字段,切换排序顺序
this.accountsSortOrder = this.accountsSortOrder === 'asc' ? 'desc' : 'asc';
} else {
// 如果点击的是新字段,设置为升序
this.accountsSortBy = field;
this.accountsSortOrder = 'asc';
}
},
// API Keys列表排序
sortApiKeys(field) {
if (this.apiKeysSortBy === field) {
// 如果点击的是当前排序字段,切换排序顺序
this.apiKeysSortOrder = this.apiKeysSortOrder === 'asc' ? 'desc' : 'asc';
} else {
// 如果点击的是新字段,设置为升序
this.apiKeysSortBy = field;
this.apiKeysSortOrder = 'asc';
}
},
// 从URL读取tab参数并设置activeTab
initializeTabFromUrl() {
const urlParams = new URLSearchParams(window.location.search);
const tabParam = urlParams.get('tab');
// 检查tab参数是否有效
const validTabs = this.tabs.map(tab => tab.key);
if (tabParam && validTabs.includes(tabParam)) {
this.activeTab = tabParam;
}
},
// 切换tab并更新URL
switchTab(tabKey) {
if (this.activeTab !== tabKey) {
this.activeTab = tabKey;
this.updateUrlTab(tabKey);
}
},
// 更新URL中的tab参数
updateUrlTab(tabKey) {
const url = new URL(window.location.href);
if (tabKey === 'dashboard') {
// 如果是默认的dashboard标签移除tab参数
url.searchParams.delete('tab');
} else {
url.searchParams.set('tab', tabKey);
}
// 使用pushState更新URL但不刷新页面
window.history.pushState({}, '', url.toString());
},
// 统一的API请求方法处理token过期等错误 // 统一的API请求方法处理token过期等错误
async apiRequest(url, options = {}) { async apiRequest(url, options = {}) {
try { try {
@@ -527,6 +723,86 @@ const app = createApp({
}); });
}, },
// 更新过期时间
updateExpireAt() {
const duration = this.apiKeyForm.expireDuration;
if (!duration) {
this.apiKeyForm.expiresAt = null;
return;
}
if (duration === 'custom') {
// 自定义日期需要用户选择
return;
}
const now = new Date();
const durationMap = {
'1d': 1,
'7d': 7,
'30d': 30,
'90d': 90,
'180d': 180,
'365d': 365
};
const days = durationMap[duration];
if (days) {
const expireDate = new Date(now.getTime() + days * 24 * 60 * 60 * 1000);
this.apiKeyForm.expiresAt = expireDate.toISOString();
}
},
// 更新自定义过期时间
updateCustomExpireAt() {
if (this.apiKeyForm.customExpireDate) {
const expireDate = new Date(this.apiKeyForm.customExpireDate);
this.apiKeyForm.expiresAt = expireDate.toISOString();
}
},
// 格式化过期日期
formatExpireDate(dateString) {
if (!dateString) return '';
const date = new Date(dateString);
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
},
// 格式化日期时间
formatDateTime(dateString) {
if (!dateString) return '';
const date = new Date(dateString);
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
});
},
// 检查 API Key 是否已过期
isApiKeyExpired(expiresAt) {
if (!expiresAt) return false;
return new Date(expiresAt) < new Date();
},
// 检查 API Key 是否即将过期7天内
isApiKeyExpiringSoon(expiresAt) {
if (!expiresAt) return false;
const expireDate = new Date(expiresAt);
const now = new Date();
const daysUntilExpire = (expireDate - now) / (1000 * 60 * 60 * 24);
return daysUntilExpire > 0 && daysUntilExpire <= 7;
},
// 打开创建账户模态框 // 打开创建账户模态框
openCreateAccountModal() { openCreateAccountModal() {
console.log('Opening Account modal...'); console.log('Opening Account modal...');
@@ -1242,6 +1518,12 @@ const app = createApp({
case 'tutorial': case 'tutorial':
// 教程页面不需要加载数据 // 教程页面不需要加载数据
break; break;
case 'settings':
// OEM 设置已在 mounted 时加载,避免重复加载
if (!this.oemSettings.siteName && !this.oemSettings.siteIcon && !this.oemSettings.siteIconData) {
this.loadOemSettings();
}
break;
} }
}, },
@@ -1647,11 +1929,23 @@ const app = createApp({
} }
}, },
async loadSupportedClients() {
try {
const data = await this.apiRequest('/admin/supported-clients');
if (data && data.success) {
this.supportedClients = data.data || [];
console.log('Loaded supported clients:', this.supportedClients);
}
} catch (error) {
console.error('Failed to load supported clients:', error);
}
},
async loadApiKeys() { async loadApiKeys() {
this.apiKeysLoading = true; this.apiKeysLoading = true;
console.log('Loading API Keys...'); console.log('Loading API Keys with time range:', this.apiKeyStatsTimeRange);
try { try {
const data = await this.apiRequest('/admin/api-keys'); const data = await this.apiRequest(`/admin/api-keys?timeRange=${this.apiKeyStatsTimeRange}`);
if (!data) { if (!data) {
// 如果token过期apiRequest会返回null并刷新页面 // 如果token过期apiRequest会返回null并刷新页面
@@ -1737,6 +2031,9 @@ const app = createApp({
account.boundApiKeysCount = this.apiKeys.filter(key => key.geminiAccountId === account.id).length; account.boundApiKeysCount = this.apiKeys.filter(key => key.geminiAccountId === account.id).length;
} }
}); });
// 加载完成后自动排序
this.sortAccounts();
} catch (error) { } catch (error) {
console.error('Failed to load accounts:', error); console.error('Failed to load accounts:', error);
} finally { } finally {
@@ -1744,6 +2041,35 @@ const app = createApp({
} }
}, },
// 账户排序
sortAccounts() {
if (!this.accounts || this.accounts.length === 0) return;
this.accounts.sort((a, b) => {
switch (this.accountSortBy) {
case 'name':
return a.name.localeCompare(b.name);
case 'dailyTokens':
const aTokens = (a.usage && a.usage.daily && a.usage.daily.allTokens) || 0;
const bTokens = (b.usage && b.usage.daily && b.usage.daily.allTokens) || 0;
return bTokens - aTokens; // 降序
case 'dailyRequests':
const aRequests = (a.usage && a.usage.daily && a.usage.daily.requests) || 0;
const bRequests = (b.usage && b.usage.daily && b.usage.daily.requests) || 0;
return bRequests - aRequests; // 降序
case 'totalTokens':
const aTotalTokens = (a.usage && a.usage.total && a.usage.total.allTokens) || 0;
const bTotalTokens = (b.usage && b.usage.total && b.usage.total.allTokens) || 0;
return bTotalTokens - aTotalTokens; // 降序
case 'lastUsed':
const aLastUsed = a.lastUsedAt ? new Date(a.lastUsedAt) : new Date(0);
const bLastUsed = b.lastUsedAt ? new Date(b.lastUsedAt) : new Date(0);
return bLastUsed - aLastUsed; // 降序(最近使用的在前)
default:
return 0;
}
});
},
async loadModelStats() { async loadModelStats() {
this.modelStatsLoading = true; this.modelStatsLoading = true;
@@ -1775,16 +2101,20 @@ const app = createApp({
method: 'POST', method: 'POST',
body: JSON.stringify({ body: JSON.stringify({
name: this.apiKeyForm.name, name: this.apiKeyForm.name,
tokenLimit: this.apiKeyForm.tokenLimit && this.apiKeyForm.tokenLimit.trim() ? parseInt(this.apiKeyForm.tokenLimit) : null, tokenLimit: this.apiKeyForm.tokenLimit && this.apiKeyForm.tokenLimit.toString().trim() ? parseInt(this.apiKeyForm.tokenLimit) : null,
description: this.apiKeyForm.description || '', description: this.apiKeyForm.description || '',
concurrencyLimit: this.apiKeyForm.concurrencyLimit && this.apiKeyForm.concurrencyLimit.trim() ? parseInt(this.apiKeyForm.concurrencyLimit) : 0, concurrencyLimit: this.apiKeyForm.concurrencyLimit && this.apiKeyForm.concurrencyLimit.toString().trim() ? parseInt(this.apiKeyForm.concurrencyLimit) : 0,
rateLimitWindow: this.apiKeyForm.rateLimitWindow && this.apiKeyForm.rateLimitWindow.trim() ? parseInt(this.apiKeyForm.rateLimitWindow) : null, rateLimitWindow: this.apiKeyForm.rateLimitWindow && this.apiKeyForm.rateLimitWindow.toString().trim() ? parseInt(this.apiKeyForm.rateLimitWindow) : null,
rateLimitRequests: this.apiKeyForm.rateLimitRequests && this.apiKeyForm.rateLimitRequests.trim() ? parseInt(this.apiKeyForm.rateLimitRequests) : null, rateLimitRequests: this.apiKeyForm.rateLimitRequests && this.apiKeyForm.rateLimitRequests.toString().trim() ? parseInt(this.apiKeyForm.rateLimitRequests) : null,
claudeAccountId: this.apiKeyForm.claudeAccountId || null, claudeAccountId: this.apiKeyForm.claudeAccountId || null,
geminiAccountId: this.apiKeyForm.geminiAccountId || null, geminiAccountId: this.apiKeyForm.geminiAccountId || null,
permissions: this.apiKeyForm.permissions || 'all', permissions: this.apiKeyForm.permissions || 'all',
enableModelRestriction: this.apiKeyForm.enableModelRestriction, enableModelRestriction: this.apiKeyForm.enableModelRestriction,
restrictedModels: this.apiKeyForm.restrictedModels restrictedModels: this.apiKeyForm.restrictedModels,
enableClientRestriction: this.apiKeyForm.enableClientRestriction,
allowedClients: this.apiKeyForm.allowedClients,
expiresAt: this.apiKeyForm.expiresAt,
dailyCostLimit: this.apiKeyForm.dailyCostLimit && this.apiKeyForm.dailyCostLimit.toString().trim() ? parseFloat(this.apiKeyForm.dailyCostLimit) : 0
}) })
}); });
@@ -1805,7 +2135,26 @@ const app = createApp({
// 关闭创建弹窗并清理表单 // 关闭创建弹窗并清理表单
this.showCreateApiKeyModal = false; this.showCreateApiKeyModal = false;
this.apiKeyForm = { name: '', tokenLimit: '', description: '', concurrencyLimit: '', rateLimitWindow: '', rateLimitRequests: '', claudeAccountId: '', enableModelRestriction: false, restrictedModels: [], modelInput: '' }; this.apiKeyForm = {
name: '',
tokenLimit: '',
description: '',
concurrencyLimit: '',
rateLimitWindow: '',
rateLimitRequests: '',
claudeAccountId: '',
geminiAccountId: '',
permissions: 'all',
enableModelRestriction: false,
restrictedModels: [],
modelInput: '',
enableClientRestriction: false,
allowedClients: [],
expireDuration: '',
customExpireDate: '',
expiresAt: null,
dailyCostLimit: ''
};
// 重新加载API Keys列表 // 重新加载API Keys列表
await this.loadApiKeys(); await this.loadApiKeys();
@@ -1851,6 +2200,111 @@ const app = createApp({
} }
}, },
// 打开续期弹窗
openRenewApiKeyModal(key) {
this.renewApiKeyForm = {
id: key.id,
name: key.name,
currentExpiresAt: key.expiresAt,
renewDuration: '30d',
customExpireDate: '',
newExpiresAt: null
};
this.showRenewApiKeyModal = true;
// 立即计算新的过期时间
this.updateRenewExpireAt();
},
// 关闭续期弹窗
closeRenewApiKeyModal() {
this.showRenewApiKeyModal = false;
this.renewApiKeyForm = {
id: '',
name: '',
currentExpiresAt: null,
renewDuration: '30d',
customExpireDate: '',
newExpiresAt: null
};
},
// 更新续期过期时间
updateRenewExpireAt() {
const duration = this.renewApiKeyForm.renewDuration;
if (duration === 'permanent') {
this.renewApiKeyForm.newExpiresAt = null;
return;
}
if (duration === 'custom') {
// 自定义日期需要用户选择
return;
}
// 计算新的过期时间
const baseTime = this.renewApiKeyForm.currentExpiresAt
? new Date(this.renewApiKeyForm.currentExpiresAt)
: new Date();
// 如果当前已过期,从现在开始计算
if (baseTime < new Date()) {
baseTime.setTime(new Date().getTime());
}
const durationMap = {
'7d': 7,
'30d': 30,
'90d': 90,
'180d': 180,
'365d': 365
};
const days = durationMap[duration];
if (days) {
const expireDate = new Date(baseTime.getTime() + days * 24 * 60 * 60 * 1000);
this.renewApiKeyForm.newExpiresAt = expireDate.toISOString();
}
},
// 更新自定义续期时间
updateCustomRenewExpireAt() {
if (this.renewApiKeyForm.customExpireDate) {
const expireDate = new Date(this.renewApiKeyForm.customExpireDate);
this.renewApiKeyForm.newExpiresAt = expireDate.toISOString();
}
},
// 执行续期操作
async renewApiKey() {
this.renewApiKeyLoading = true;
try {
const data = await this.apiRequest('/admin/api-keys/' + this.renewApiKeyForm.id, {
method: 'PUT',
body: JSON.stringify({
expiresAt: this.renewApiKeyForm.newExpiresAt
})
});
if (!data) {
return;
}
if (data.success) {
this.showToast('API Key 续期成功', 'success');
this.closeRenewApiKeyModal();
await this.loadApiKeys();
} else {
this.showToast(data.message || '续期失败', 'error');
}
} catch (error) {
console.error('Error renewing API key:', error);
this.showToast('续期失败,请检查网络连接', 'error');
} finally {
this.renewApiKeyLoading = false;
}
},
openEditApiKeyModal(key) { openEditApiKeyModal(key) {
this.editApiKeyForm = { this.editApiKeyForm = {
id: key.id, id: key.id,
@@ -1864,7 +2318,10 @@ const app = createApp({
permissions: key.permissions || 'all', permissions: key.permissions || 'all',
enableModelRestriction: key.enableModelRestriction || false, enableModelRestriction: key.enableModelRestriction || false,
restrictedModels: key.restrictedModels ? [...key.restrictedModels] : [], restrictedModels: key.restrictedModels ? [...key.restrictedModels] : [],
modelInput: '' modelInput: '',
enableClientRestriction: key.enableClientRestriction || false,
allowedClients: key.allowedClients ? [...key.allowedClients] : [],
dailyCostLimit: key.dailyCostLimit || ''
}; };
this.showEditApiKeyModal = true; this.showEditApiKeyModal = true;
}, },
@@ -1883,7 +2340,10 @@ const app = createApp({
permissions: 'all', permissions: 'all',
enableModelRestriction: false, enableModelRestriction: false,
restrictedModels: [], restrictedModels: [],
modelInput: '' modelInput: '',
enableClientRestriction: false,
allowedClients: [],
dailyCostLimit: ''
}; };
}, },
@@ -1901,7 +2361,10 @@ const app = createApp({
geminiAccountId: this.editApiKeyForm.geminiAccountId || null, geminiAccountId: this.editApiKeyForm.geminiAccountId || null,
permissions: this.editApiKeyForm.permissions || 'all', permissions: this.editApiKeyForm.permissions || 'all',
enableModelRestriction: this.editApiKeyForm.enableModelRestriction, enableModelRestriction: this.editApiKeyForm.enableModelRestriction,
restrictedModels: this.editApiKeyForm.restrictedModels restrictedModels: this.editApiKeyForm.restrictedModels,
enableClientRestriction: this.editApiKeyForm.enableClientRestriction,
allowedClients: this.editApiKeyForm.allowedClients,
dailyCostLimit: this.editApiKeyForm.dailyCostLimit && this.editApiKeyForm.dailyCostLimit.toString().trim() !== '' ? parseFloat(this.editApiKeyForm.dailyCostLimit) : 0
}) })
}); });
@@ -2068,7 +2531,11 @@ const app = createApp({
// 格式化数字,添加千分符 // 格式化数字,添加千分符
formatNumber(num) { formatNumber(num) {
if (num === null || num === undefined) return '0'; if (num === null || num === undefined) return '0';
return Number(num).toLocaleString(); const number = Number(num);
if (number >= 1000000) {
return Math.floor(number / 1000000).toLocaleString() + 'M';
}
return number.toLocaleString();
}, },
// 格式化运行时间 // 格式化运行时间
@@ -2889,23 +3356,26 @@ const app = createApp({
calculateApiKeyCost(usage) { calculateApiKeyCost(usage) {
if (!usage || !usage.total) return '$0.000000'; if (!usage || !usage.total) return '$0.000000';
// 使用通用模型价格估算 // 使用后端返回的准确费用数据
const totalInputTokens = usage.total.inputTokens || 0; if (usage.total.formattedCost) {
const totalOutputTokens = usage.total.outputTokens || 0; return usage.total.formattedCost;
const totalCacheCreateTokens = usage.total.cacheCreateTokens || 0; }
const totalCacheReadTokens = usage.total.cacheReadTokens || 0;
// 简单估算使用Claude 3.5 Sonnet价格 // 如果没有后端费用数据,返回默认值
const inputCost = (totalInputTokens / 1000000) * 3.00; return '$0.000000';
const outputCost = (totalOutputTokens / 1000000) * 15.00; },
const cacheCreateCost = (totalCacheCreateTokens / 1000000) * 3.75;
const cacheReadCost = (totalCacheReadTokens / 1000000) * 0.30;
const totalCost = inputCost + outputCost + cacheCreateCost + cacheReadCost; // 计算API Key费用数值用于排序
calculateApiKeyCostNumber(usage) {
if (!usage || !usage.total) return 0;
if (totalCost < 0.000001) return '$0.000000'; // 使用后端返回的准确费用数据
if (totalCost < 0.01) return '$' + totalCost.toFixed(6); if (usage.total.cost) {
return '$' + totalCost.toFixed(4); return usage.total.cost;
}
// 如果没有后端费用数据返回0
return 0;
}, },
// 初始化日期筛选器 // 初始化日期筛选器
@@ -3531,6 +4001,180 @@ const app = createApp({
}); });
this.showToast('已重置筛选条件并刷新数据', 'info', '重置成功'); this.showToast('已重置筛选条件并刷新数据', 'info', '重置成功');
},
// OEM设置相关方法
async loadOemSettings() {
this.oemSettingsLoading = true;
try {
const result = await this.apiRequest('/admin/oem-settings');
if (result && result.success) {
this.oemSettings = { ...this.oemSettings, ...result.data };
// 应用设置到页面
this.applyOemSettings();
} else {
// 如果请求失败但不是因为认证问题,使用默认值
console.warn('Failed to load OEM settings, using defaults');
this.applyOemSettings();
}
} catch (error) {
console.error('Error loading OEM settings:', error);
// 加载失败时也应用默认值,确保页面正常显示
this.applyOemSettings();
} finally {
this.oemSettingsLoading = false;
}
},
async saveOemSettings() {
// 验证输入
if (!this.oemSettings.siteName || this.oemSettings.siteName.trim() === '') {
this.showToast('网站名称不能为空', 'error', '验证失败');
return;
}
if (this.oemSettings.siteName.length > 100) {
this.showToast('网站名称不能超过100个字符', 'error', '验证失败');
return;
}
this.oemSettingsSaving = true;
try {
const result = await this.apiRequest('/admin/oem-settings', {
method: 'PUT',
body: JSON.stringify({
siteName: this.oemSettings.siteName.trim(),
siteIcon: this.oemSettings.siteIcon.trim(),
siteIconData: this.oemSettings.siteIconData.trim()
})
});
if (result && result.success) {
this.oemSettings = { ...this.oemSettings, ...result.data };
this.showToast('OEM设置保存成功', 'success', '保存成功');
// 应用设置到页面
this.applyOemSettings();
} else {
this.showToast(result?.message || '保存失败', 'error', '保存失败');
}
} catch (error) {
console.error('Error saving OEM settings:', error);
this.showToast('保存OEM设置失败', 'error', '保存失败');
} finally {
this.oemSettingsSaving = false;
}
},
applyOemSettings() {
// 更新网站标题
document.title = `${this.oemSettings.siteName} - 管理后台`;
// 更新页面中的所有网站名称
const titleElements = document.querySelectorAll('.header-title');
titleElements.forEach(el => {
el.textContent = this.oemSettings.siteName;
});
// 应用自定义CSS
this.applyCustomCss();
// 应用网站图标
this.applyFavicon();
},
applyCustomCss() {
// 移除之前的自定义CSS
const existingStyle = document.getElementById('custom-oem-css');
if (existingStyle) {
existingStyle.remove();
}
},
applyFavicon() {
const iconData = this.oemSettings.siteIconData || this.oemSettings.siteIcon;
if (iconData && iconData.trim()) {
// 移除现有的favicon
const existingFavicons = document.querySelectorAll('link[rel*="icon"]');
existingFavicons.forEach(link => link.remove());
// 添加新的favicon
const link = document.createElement('link');
link.rel = 'icon';
// 根据数据类型设置适当的type
if (iconData.startsWith('data:')) {
// Base64数据
link.href = iconData;
} else {
// URL
link.type = 'image/x-icon';
link.href = iconData;
}
document.head.appendChild(link);
}
},
resetOemSettings() {
this.oemSettings = {
siteName: 'Claude Relay Service',
siteIcon: '',
siteIconData: '',
updatedAt: null
};
},
// 处理图标文件上传
async handleIconUpload(event) {
const file = event.target.files[0];
if (!file) return;
// 验证文件大小
if (file.size > 350 * 1024) { // 350KB
this.showToast('图标文件大小不能超过350KB', 'error', '文件太大');
return;
}
// 验证文件类型
const allowedTypes = ['image/x-icon', 'image/png', 'image/jpeg', 'image/svg+xml'];
if (!allowedTypes.includes(file.type) && !file.name.endsWith('.ico')) {
this.showToast('请选择有效的图标文件格式 (.ico, .png, .jpg, .svg)', 'error', '格式错误');
return;
}
try {
// 读取文件为Base64
const reader = new FileReader();
reader.onload = (e) => {
this.oemSettings.siteIconData = e.target.result;
this.oemSettings.siteIcon = ''; // 清空URL
this.showToast('图标上传成功', 'success', '上传成功');
};
reader.onerror = () => {
this.showToast('图标文件读取失败', 'error', '读取失败');
};
reader.readAsDataURL(file);
} catch (error) {
console.error('Icon upload error:', error);
this.showToast('图标上传过程中出现错误', 'error', '上传失败');
}
},
// 移除图标
removeIcon() {
this.oemSettings.siteIcon = '';
this.oemSettings.siteIconData = '';
if (this.$refs.iconFileInput) {
this.$refs.iconFileInput.value = '';
}
},
// 处理图标加载错误
handleIconError(event) {
console.error('Icon load error');
event.target.style.display = 'none';
} }
} }
}); });

View File

@@ -39,10 +39,15 @@
<div v-if="!isLoggedIn" class="flex items-center justify-center min-h-screen p-6"> <div v-if="!isLoggedIn" class="flex items-center justify-center min-h-screen p-6">
<div class="glass-strong rounded-3xl p-10 w-full max-w-md shadow-2xl"> <div class="glass-strong rounded-3xl p-10 w-full max-w-md shadow-2xl">
<div class="text-center mb-8"> <div class="text-center mb-8">
<div class="w-20 h-20 mx-auto mb-6 bg-gradient-to-br from-blue-500/20 to-purple-500/20 border border-gray-300/30 rounded-2xl flex items-center justify-center backdrop-blur-sm"> <div class="w-20 h-20 mx-auto mb-6 bg-gradient-to-br from-blue-500/20 to-purple-500/20 border border-gray-300/30 rounded-2xl flex items-center justify-center backdrop-blur-sm overflow-hidden">
<i class="fas fa-cloud text-3xl text-gray-700"></i> <img v-if="oemSettings.siteIconData || oemSettings.siteIcon"
:src="oemSettings.siteIconData || oemSettings.siteIcon"
alt="Logo"
class="w-12 h-12 object-contain"
@error="(e) => e.target.style.display = 'none'">
<i v-else class="fas fa-cloud text-3xl text-gray-700"></i>
</div> </div>
<h1 class="text-3xl font-bold text-white mb-2 header-title">Claude Relay Service</h1> <h1 class="text-3xl font-bold text-white mb-2 header-title">{{ oemSettings.siteName || 'Claude Relay Service' }}</h1>
<p class="text-white/80 text-lg">管理后台</p> <p class="text-white/80 text-lg">管理后台</p>
</div> </div>
@@ -92,12 +97,17 @@
<div class="glass-strong rounded-3xl p-6 mb-8 shadow-xl" style="z-index: 10; position: relative;"> <div class="glass-strong rounded-3xl p-6 mb-8 shadow-xl" style="z-index: 10; position: relative;">
<div class="flex flex-col md:flex-row justify-between items-center gap-4"> <div class="flex flex-col md:flex-row justify-between items-center gap-4">
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
<div class="w-12 h-12 bg-gradient-to-br from-blue-500/20 to-purple-500/20 border border-gray-300/30 rounded-xl flex items-center justify-center backdrop-blur-sm flex-shrink-0"> <div class="w-12 h-12 bg-gradient-to-br from-blue-500/20 to-purple-500/20 border border-gray-300/30 rounded-xl flex items-center justify-center backdrop-blur-sm flex-shrink-0 overflow-hidden">
<i class="fas fa-cloud text-xl text-gray-700"></i> <img v-if="oemSettings.siteIconData || oemSettings.siteIcon"
:src="oemSettings.siteIconData || oemSettings.siteIcon"
alt="Logo"
class="w-8 h-8 object-contain"
@error="(e) => e.target.style.display = 'none'">
<i v-else class="fas fa-cloud text-xl text-gray-700"></i>
</div> </div>
<div class="flex flex-col justify-center min-h-[48px]"> <div class="flex flex-col justify-center min-h-[48px]">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<h1 class="text-2xl font-bold text-white header-title leading-tight">Claude Relay Service</h1> <h1 class="text-2xl font-bold text-white header-title leading-tight">{{ oemSettings.siteName || 'Claude Relay Service' }}</h1>
<!-- 版本信息 --> <!-- 版本信息 -->
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<span class="text-sm text-gray-400 font-mono">v{{ versionInfo.current || '...' }}</span> <span class="text-sm text-gray-400 font-mono">v{{ versionInfo.current || '...' }}</span>
@@ -202,13 +212,13 @@
</div> </div>
<!-- 主内容区域 --> <!-- 主内容区域 -->
<div class="glass-strong rounded-3xl p-6 shadow-xl" style="z-index: 1;"> <div class="glass-strong rounded-3xl p-6 shadow-xl" style="z-index: 1; min-height: calc(100vh - 240px);">
<!-- 标签栏 --> <!-- 标签栏 -->
<div class="flex flex-wrap gap-2 mb-8 bg-white/10 rounded-2xl p-2 backdrop-blur-sm"> <div class="flex flex-wrap gap-2 mb-8 bg-white/10 rounded-2xl p-2 backdrop-blur-sm">
<button <button
v-for="tab in tabs" v-for="tab in tabs"
:key="tab.key" :key="tab.key"
@click="activeTab = tab.key" @click="switchTab(tab.key)"
:class="['tab-btn flex-1 py-3 px-6 text-sm font-semibold transition-all duration-300', :class="['tab-btn flex-1 py-3 px-6 text-sm font-semibold transition-all duration-300',
activeTab === tab.key ? 'active' : 'text-gray-700 hover:bg-white/10 hover:text-gray-900']" activeTab === tab.key ? 'active' : 'text-gray-700 hover:bg-white/10 hover:text-gray-900']"
> >
@@ -227,7 +237,7 @@
<p class="text-3xl font-bold text-gray-900">{{ dashboardData.totalApiKeys }}</p> <p class="text-3xl font-bold text-gray-900">{{ dashboardData.totalApiKeys }}</p>
<p class="text-xs text-gray-500 mt-1">活跃: {{ dashboardData.activeApiKeys || 0 }}</p> <p class="text-xs text-gray-500 mt-1">活跃: {{ dashboardData.activeApiKeys || 0 }}</p>
</div> </div>
<div class="stat-icon bg-gradient-to-br from-blue-500 to-blue-600"> <div class="stat-icon flex-shrink-0 bg-gradient-to-br from-blue-500 to-blue-600">
<i class="fas fa-key"></i> <i class="fas fa-key"></i>
</div> </div>
</div> </div>
@@ -245,7 +255,7 @@
</span> </span>
</p> </p>
</div> </div>
<div class="stat-icon bg-gradient-to-br from-green-500 to-green-600"> <div class="stat-icon flex-shrink-0 bg-gradient-to-br from-green-500 to-green-600">
<i class="fas fa-user-circle"></i> <i class="fas fa-user-circle"></i>
</div> </div>
</div> </div>
@@ -258,7 +268,7 @@
<p class="text-3xl font-bold text-gray-900">{{ dashboardData.todayRequests }}</p> <p class="text-3xl font-bold text-gray-900">{{ dashboardData.todayRequests }}</p>
<p class="text-xs text-gray-500 mt-1">总请求: {{ formatNumber(dashboardData.totalRequests || 0) }}</p> <p class="text-xs text-gray-500 mt-1">总请求: {{ formatNumber(dashboardData.totalRequests || 0) }}</p>
</div> </div>
<div class="stat-icon bg-gradient-to-br from-purple-500 to-purple-600"> <div class="stat-icon flex-shrink-0 bg-gradient-to-br from-purple-500 to-purple-600">
<i class="fas fa-chart-line"></i> <i class="fas fa-chart-line"></i>
</div> </div>
</div> </div>
@@ -271,7 +281,7 @@
<p class="text-3xl font-bold text-green-600">{{ dashboardData.systemStatus }}</p> <p class="text-3xl font-bold text-green-600">{{ dashboardData.systemStatus }}</p>
<p class="text-xs text-gray-500 mt-1">运行时间: {{ formatUptime(dashboardData.uptime) }}</p> <p class="text-xs text-gray-500 mt-1">运行时间: {{ formatUptime(dashboardData.uptime) }}</p>
</div> </div>
<div class="stat-icon bg-gradient-to-br from-yellow-500 to-orange-500"> <div class="stat-icon flex-shrink-0 bg-gradient-to-br from-yellow-500 to-orange-500">
<i class="fas fa-heartbeat"></i> <i class="fas fa-heartbeat"></i>
</div> </div>
</div> </div>
@@ -282,9 +292,9 @@
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-6 mb-8"> <div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-6 mb-8">
<div class="stat-card"> <div class="stat-card">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div class="flex-1"> <div class="flex-1 mr-8">
<p class="text-sm font-semibold text-gray-600 mb-1">今日Token</p> <p class="text-sm font-semibold text-gray-600 mb-1">今日Token</p>
<div class="flex items-baseline gap-2 mb-2"> <div class="flex items-baseline gap-2 mb-2 flex-wrap">
<p class="text-3xl font-bold text-blue-600">{{ formatNumber((dashboardData.todayInputTokens || 0) + (dashboardData.todayOutputTokens || 0) + (dashboardData.todayCacheCreateTokens || 0) + (dashboardData.todayCacheReadTokens || 0)) }}</p> <p class="text-3xl font-bold text-blue-600">{{ formatNumber((dashboardData.todayInputTokens || 0) + (dashboardData.todayOutputTokens || 0) + (dashboardData.todayCacheCreateTokens || 0) + (dashboardData.todayCacheReadTokens || 0)) }}</p>
<span class="text-sm text-green-600 font-medium">/ {{ costsData.todayCosts.formatted.totalCost }}</span> <span class="text-sm text-green-600 font-medium">/ {{ costsData.todayCosts.formatted.totalCost }}</span>
</div> </div>
@@ -297,7 +307,7 @@
</div> </div>
</div> </div>
</div> </div>
<div class="stat-icon bg-gradient-to-br from-indigo-500 to-indigo-600"> <div class="stat-icon flex-shrink-0 bg-gradient-to-br from-indigo-500 to-indigo-600">
<i class="fas fa-coins"></i> <i class="fas fa-coins"></i>
</div> </div>
</div> </div>
@@ -305,9 +315,9 @@
<div class="stat-card"> <div class="stat-card">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div class="flex-1"> <div class="flex-1 mr-8">
<p class="text-sm font-semibold text-gray-600 mb-1">总Token消耗</p> <p class="text-sm font-semibold text-gray-600 mb-1">总Token消耗</p>
<div class="flex items-baseline gap-2 mb-2"> <div class="flex items-baseline gap-2 mb-2 flex-wrap">
<p class="text-3xl font-bold text-emerald-600">{{ formatNumber((dashboardData.totalInputTokens || 0) + (dashboardData.totalOutputTokens || 0) + (dashboardData.totalCacheCreateTokens || 0) + (dashboardData.totalCacheReadTokens || 0)) }}</p> <p class="text-3xl font-bold text-emerald-600">{{ formatNumber((dashboardData.totalInputTokens || 0) + (dashboardData.totalOutputTokens || 0) + (dashboardData.totalCacheCreateTokens || 0) + (dashboardData.totalCacheReadTokens || 0)) }}</p>
<span class="text-sm text-green-600 font-medium">/ {{ costsData.totalCosts.formatted.totalCost }}</span> <span class="text-sm text-green-600 font-medium">/ {{ costsData.totalCosts.formatted.totalCost }}</span>
</div> </div>
@@ -320,7 +330,7 @@
</div> </div>
</div> </div>
</div> </div>
<div class="stat-icon bg-gradient-to-br from-emerald-500 to-emerald-600"> <div class="stat-icon flex-shrink-0 bg-gradient-to-br from-emerald-500 to-emerald-600">
<i class="fas fa-database"></i> <i class="fas fa-database"></i>
</div> </div>
</div> </div>
@@ -333,7 +343,7 @@
<p class="text-3xl font-bold text-orange-600">{{ dashboardData.systemRPM || 0 }}</p> <p class="text-3xl font-bold text-orange-600">{{ dashboardData.systemRPM || 0 }}</p>
<p class="text-xs text-gray-500 mt-1">每分钟请求数</p> <p class="text-xs text-gray-500 mt-1">每分钟请求数</p>
</div> </div>
<div class="stat-icon bg-gradient-to-br from-orange-500 to-orange-600"> <div class="stat-icon flex-shrink-0 bg-gradient-to-br from-orange-500 to-orange-600">
<i class="fas fa-tachometer-alt"></i> <i class="fas fa-tachometer-alt"></i>
</div> </div>
</div> </div>
@@ -346,7 +356,7 @@
<p class="text-3xl font-bold text-rose-600">{{ dashboardData.systemTPM || 0 }}</p> <p class="text-3xl font-bold text-rose-600">{{ dashboardData.systemTPM || 0 }}</p>
<p class="text-xs text-gray-500 mt-1">每分钟Token数</p> <p class="text-xs text-gray-500 mt-1">每分钟Token数</p>
</div> </div>
<div class="stat-icon bg-gradient-to-br from-rose-500 to-rose-600"> <div class="stat-icon flex-shrink-0 bg-gradient-to-br from-rose-500 to-rose-600">
<i class="fas fa-rocket"></i> <i class="fas fa-rocket"></i>
</div> </div>
</div> </div>
@@ -538,12 +548,25 @@
<h3 class="text-xl font-bold text-gray-900 mb-2">API Keys 管理</h3> <h3 class="text-xl font-bold text-gray-900 mb-2">API Keys 管理</h3>
<p class="text-gray-600">管理和监控您的 API 密钥</p> <p class="text-gray-600">管理和监控您的 API 密钥</p>
</div> </div>
<button <div class="flex items-center gap-3">
@click.stop="openCreateApiKeyModal" <!-- Token统计时间范围选择 -->
class="btn btn-primary px-6 py-3 flex items-center gap-2" <select
> v-model="apiKeyStatsTimeRange"
<i class="fas fa-plus"></i>创建新 Key @change="loadApiKeys()"
</button> class="form-input px-3 py-2 text-sm"
>
<option value="today">今日</option>
<option value="7days">最近7天</option>
<option value="monthly">本月</option>
<option value="all">全部时间</option>
</select>
<button
@click.stop="openCreateApiKeyModal"
class="btn btn-primary px-6 py-3 flex items-center gap-2"
>
<i class="fas fa-plus"></i>创建新 Key
</button>
</div>
</div> </div>
<div v-if="apiKeysLoading" class="text-center py-12"> <div v-if="apiKeysLoading" class="text-center py-12">
@@ -563,16 +586,40 @@
<table class="min-w-full"> <table class="min-w-full">
<thead class="bg-gray-50/80 backdrop-blur-sm"> <thead class="bg-gray-50/80 backdrop-blur-sm">
<tr> <tr>
<th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">名称</th> <th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider cursor-pointer hover:bg-gray-100" @click="sortApiKeys('name')">
名称
<i v-if="apiKeysSortBy === 'name'" :class="['fas', apiKeysSortOrder === 'asc' ? 'fa-sort-up' : 'fa-sort-down', 'ml-1']"></i>
<i v-else class="fas fa-sort ml-1 text-gray-400"></i>
</th>
<th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">API Key</th> <th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">API Key</th>
<th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">状态</th> <th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider cursor-pointer hover:bg-gray-100" @click="sortApiKeys('status')">
<th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">使用统计</th> 状态
<th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">创建时间</th> <i v-if="apiKeysSortBy === 'status'" :class="['fas', apiKeysSortOrder === 'asc' ? 'fa-sort-up' : 'fa-sort-down', 'ml-1']"></i>
<i v-else class="fas fa-sort ml-1 text-gray-400"></i>
</th>
<th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">
使用统计
<span class="cursor-pointer hover:bg-gray-100 px-2 py-1 rounded" @click="sortApiKeys('cost')">
(费用
<i v-if="apiKeysSortBy === 'cost'" :class="['fas', apiKeysSortOrder === 'asc' ? 'fa-sort-up' : 'fa-sort-down', 'ml-1']"></i>
<i v-else class="fas fa-sort ml-1 text-gray-400"></i>)
</span>
</th>
<th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider cursor-pointer hover:bg-gray-100" @click="sortApiKeys('createdAt')">
创建时间
<i v-if="apiKeysSortBy === 'createdAt'" :class="['fas', apiKeysSortOrder === 'asc' ? 'fa-sort-up' : 'fa-sort-down', 'ml-1']"></i>
<i v-else class="fas fa-sort ml-1 text-gray-400"></i>
</th>
<th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider cursor-pointer hover:bg-gray-100" @click="sortApiKeys('expiresAt')">
过期时间
<i v-if="apiKeysSortBy === 'expiresAt'" :class="['fas', apiKeysSortOrder === 'asc' ? 'fa-sort-up' : 'fa-sort-down', 'ml-1']"></i>
<i v-else class="fas fa-sort ml-1 text-gray-400"></i>
</th>
<th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">操作</th> <th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">操作</th>
</tr> </tr>
</thead> </thead>
<tbody class="divide-y divide-gray-200/50"> <tbody class="divide-y divide-gray-200/50">
<template v-for="key in apiKeys" :key="key.id"> <template v-for="key in sortedApiKeys" :key="key.id">
<!-- API Key 主行 --> <!-- API Key 主行 -->
<tr class="table-row"> <tr class="table-row">
<td class="px-6 py-4 whitespace-nowrap"> <td class="px-6 py-4 whitespace-nowrap">
@@ -626,6 +673,13 @@
<span class="text-gray-600">费用:</span> <span class="text-gray-600">费用:</span>
<span class="font-medium text-green-600">{{ calculateApiKeyCost(key.usage) }}</span> <span class="font-medium text-green-600">{{ calculateApiKeyCost(key.usage) }}</span>
</div> </div>
<!-- 每日费用限制 -->
<div v-if="key.dailyCostLimit > 0" class="flex justify-between text-sm">
<span class="text-gray-600">今日费用:</span>
<span :class="['font-medium', (key.dailyCost || 0) >= key.dailyCostLimit ? 'text-red-600' : 'text-blue-600']">
${{ (key.dailyCost || 0).toFixed(2) }} / ${{ key.dailyCostLimit.toFixed(2) }}
</span>
</div>
<!-- 并发限制 --> <!-- 并发限制 -->
<div class="flex justify-between text-sm"> <div class="flex justify-between text-sm">
<span class="text-gray-600">并发限制:</span> <span class="text-gray-600">并发限制:</span>
@@ -654,6 +708,11 @@
<span>输入: {{ formatNumber((key.usage && key.usage.total && key.usage.total.inputTokens) || 0) }}</span> <span>输入: {{ formatNumber((key.usage && key.usage.total && key.usage.total.inputTokens) || 0) }}</span>
<span>输出: {{ formatNumber((key.usage && key.usage.total && key.usage.total.outputTokens) || 0) }}</span> <span>输出: {{ formatNumber((key.usage && key.usage.total && key.usage.total.outputTokens) || 0) }}</span>
</div> </div>
<!-- 缓存Token细节 -->
<div v-if="((key.usage && key.usage.total && key.usage.total.cacheCreateTokens) || 0) > 0 || ((key.usage && key.usage.total && key.usage.total.cacheReadTokens) || 0) > 0" class="flex justify-between text-xs text-orange-500">
<span>缓存创建: {{ formatNumber((key.usage && key.usage.total && key.usage.total.cacheCreateTokens) || 0) }}</span>
<span>缓存读取: {{ formatNumber((key.usage && key.usage.total && key.usage.total.cacheReadTokens) || 0) }}</span>
</div>
<!-- RPM/TPM --> <!-- RPM/TPM -->
<div class="flex justify-between text-xs text-blue-600"> <div class="flex justify-between text-xs text-blue-600">
<span>RPM: {{ (key.usage && key.usage.averages && key.usage.averages.rpm) || 0 }}</span> <span>RPM: {{ (key.usage && key.usage.averages && key.usage.averages.rpm) || 0 }}</span>
@@ -678,6 +737,25 @@
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500"> <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{{ new Date(key.createdAt).toLocaleDateString() }} {{ new Date(key.createdAt).toLocaleDateString() }}
</td> </td>
<td class="px-6 py-4 whitespace-nowrap text-sm">
<div v-if="key.expiresAt">
<div v-if="isApiKeyExpired(key.expiresAt)" class="text-red-600">
<i class="fas fa-exclamation-circle mr-1"></i>
已过期
</div>
<div v-else-if="isApiKeyExpiringSoon(key.expiresAt)" class="text-orange-600">
<i class="fas fa-clock mr-1"></i>
{{ formatExpireDate(key.expiresAt) }}
</div>
<div v-else class="text-gray-600">
{{ formatExpireDate(key.expiresAt) }}
</div>
</div>
<div v-else class="text-gray-400">
<i class="fas fa-infinity mr-1"></i>
永不过期
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm"> <td class="px-6 py-4 whitespace-nowrap text-sm">
<div class="flex gap-2"> <div class="flex gap-2">
<button <button
@@ -686,6 +764,13 @@
> >
<i class="fas fa-edit mr-1"></i>编辑 <i class="fas fa-edit mr-1"></i>编辑
</button> </button>
<button
v-if="key.expiresAt && (isApiKeyExpired(key.expiresAt) || isApiKeyExpiringSoon(key.expiresAt))"
@click="openRenewApiKeyModal(key)"
class="text-green-600 hover:text-green-900 font-medium hover:bg-green-50 px-3 py-1 rounded-lg transition-colors"
>
<i class="fas fa-clock mr-1"></i>续期
</button>
<button <button
@click="deleteApiKey(key.id)" @click="deleteApiKey(key.id)"
class="text-red-600 hover:text-red-900 font-medium hover:bg-red-50 px-3 py-1 rounded-lg transition-colors" class="text-red-600 hover:text-red-900 font-medium hover:bg-red-50 px-3 py-1 rounded-lg transition-colors"
@@ -878,12 +963,21 @@
<h3 class="text-xl font-bold text-gray-900 mb-2">账户管理</h3> <h3 class="text-xl font-bold text-gray-900 mb-2">账户管理</h3>
<p class="text-gray-600">管理您的 Claude 和 Gemini 账户及代理配置</p> <p class="text-gray-600">管理您的 Claude 和 Gemini 账户及代理配置</p>
</div> </div>
<button <div class="flex gap-2">
@click.stop="openCreateAccountModal" <select v-model="accountSortBy" @change="sortAccounts()" class="form-input px-3 py-2 text-sm">
class="btn btn-success px-6 py-3 flex items-center gap-2" <option value="name">按名称排序</option>
> <option value="dailyTokens">按今日Token排序</option>
<i class="fas fa-plus"></i>添加账户 <option value="dailyRequests">按今日请求数排序</option>
</button> <option value="totalTokens">按总Token排序</option>
<option value="lastUsed">按最后使用排序</option>
</select>
<button
@click.stop="openCreateAccountModal"
class="btn btn-success px-6 py-3 flex items-center gap-2"
>
<i class="fas fa-plus"></i>添加账户
</button>
</div>
</div> </div>
<div v-if="accountsLoading" class="text-center py-12"> <div v-if="accountsLoading" class="text-center py-12">
@@ -903,17 +997,34 @@
<table class="min-w-full"> <table class="min-w-full">
<thead class="bg-gray-50/80 backdrop-blur-sm"> <thead class="bg-gray-50/80 backdrop-blur-sm">
<tr> <tr>
<th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">名称</th> <th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider cursor-pointer hover:bg-gray-100" @click="sortAccounts('name')">
<th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">平台</th> 名称
<th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">类型</th> <i v-if="accountsSortBy === 'name'" :class="['fas', accountsSortOrder === 'asc' ? 'fa-sort-up' : 'fa-sort-down', 'ml-1']"></i>
<th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">状态</th> <i v-else class="fas fa-sort ml-1 text-gray-400"></i>
</th>
<th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider cursor-pointer hover:bg-gray-100" @click="sortAccounts('platform')">
平台
<i v-if="accountsSortBy === 'platform'" :class="['fas', accountsSortOrder === 'asc' ? 'fa-sort-up' : 'fa-sort-down', 'ml-1']"></i>
<i v-else class="fas fa-sort ml-1 text-gray-400"></i>
</th>
<th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider cursor-pointer hover:bg-gray-100" @click="sortAccounts('accountType')">
类型
<i v-if="accountsSortBy === 'accountType'" :class="['fas', accountsSortOrder === 'asc' ? 'fa-sort-up' : 'fa-sort-down', 'ml-1']"></i>
<i v-else class="fas fa-sort ml-1 text-gray-400"></i>
</th>
<th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider cursor-pointer hover:bg-gray-100" @click="sortAccounts('status')">
状态
<i v-if="accountsSortBy === 'status'" :class="['fas', accountsSortOrder === 'asc' ? 'fa-sort-up' : 'fa-sort-down', 'ml-1']"></i>
<i v-else class="fas fa-sort ml-1 text-gray-400"></i>
</th>
<th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">代理</th> <th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">代理</th>
<th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">今日使用</th>
<th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">最后使用</th> <th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">最后使用</th>
<th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">操作</th> <th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">操作</th>
</tr> </tr>
</thead> </thead>
<tbody class="divide-y divide-gray-200/50"> <tbody class="divide-y divide-gray-200/50">
<tr v-for="account in accounts" :key="account.id" class="table-row"> <tr v-for="account in sortedAccounts" :key="account.id" class="table-row">
<td class="px-6 py-4 whitespace-nowrap"> <td class="px-6 py-4 whitespace-nowrap">
<div class="flex items-center"> <div class="flex items-center">
<div class="w-8 h-8 bg-gradient-to-br from-green-500 to-green-600 rounded-lg flex items-center justify-center mr-3"> <div class="w-8 h-8 bg-gradient-to-br from-green-500 to-green-600 rounded-lg flex items-center justify-center mr-3">
@@ -980,6 +1091,22 @@
</div> </div>
<div v-else class="text-gray-400">无代理</div> <div v-else class="text-gray-400">无代理</div>
</td> </td>
<td class="px-6 py-4 whitespace-nowrap text-sm">
<div v-if="account.usage && account.usage.daily" class="space-y-1">
<div class="flex items-center gap-2">
<div class="w-2 h-2 bg-green-500 rounded-full"></div>
<span class="text-sm font-medium text-gray-900">{{ account.usage.daily.requests || 0 }} 次</span>
</div>
<div class="flex items-center gap-2">
<div class="w-2 h-2 bg-blue-500 rounded-full"></div>
<span class="text-xs text-gray-600">{{ formatNumber(account.usage.daily.allTokens || 0) }} tokens</span>
</div>
<div v-if="account.usage.averages && account.usage.averages.rpm > 0" class="text-xs text-gray-500">
平均 {{ account.usage.averages.rpm.toFixed(2) }} RPM
</div>
</div>
<div v-else class="text-gray-400 text-xs">暂无数据</div>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500"> <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{{ account.lastUsedAt ? new Date(account.lastUsedAt).toLocaleDateString() : '从未使用' }} {{ account.lastUsedAt ? new Date(account.lastUsedAt).toLocaleDateString() : '从未使用' }}
</td> </td>
@@ -1884,6 +2011,138 @@
</div> </div>
</div> </div>
</div> </div>
<!-- 其他设置页面 -->
<div v-if="activeTab === 'settings'" class="tab-content">
<div class="card p-6">
<div class="flex flex-col md:flex-row justify-between items-center gap-4 mb-6">
<div>
<h3 class="text-xl font-bold text-gray-900 mb-2">其他设置</h3>
<p class="text-gray-600">自定义网站名称和图标</p>
</div>
</div>
<div v-if="oemSettingsLoading" class="text-center py-12">
<div class="loading-spinner mx-auto mb-4"></div>
<p class="text-gray-500">正在加载设置...</p>
</div>
<div v-else class="table-container">
<table class="min-w-full">
<tbody class="divide-y divide-gray-200/50">
<!-- 网站名称 -->
<tr class="table-row">
<td class="px-6 py-4 whitespace-nowrap w-48">
<div class="flex items-center">
<div class="w-8 h-8 bg-gradient-to-br from-blue-500 to-blue-600 rounded-lg flex items-center justify-center mr-3">
<i class="fas fa-font text-white text-xs"></i>
</div>
<div>
<div class="text-sm font-semibold text-gray-900">网站名称</div>
<div class="text-xs text-gray-500">品牌标识</div>
</div>
</div>
</td>
<td class="px-6 py-4">
<input
v-model="oemSettings.siteName"
type="text"
class="form-input w-full max-w-md"
placeholder="TokenDance"
maxlength="100"
>
<p class="text-xs text-gray-500 mt-1">将显示在浏览器标题和页面头部</p>
</td>
</tr>
<!-- 网站图标 -->
<tr class="table-row">
<td class="px-6 py-4 whitespace-nowrap w-48">
<div class="flex items-center">
<div class="w-8 h-8 bg-gradient-to-br from-purple-500 to-purple-600 rounded-lg flex items-center justify-center mr-3">
<i class="fas fa-image text-white text-xs"></i>
</div>
<div>
<div class="text-sm font-semibold text-gray-900">网站图标</div>
<div class="text-xs text-gray-500">Favicon</div>
</div>
</div>
</td>
<td class="px-6 py-4">
<div class="space-y-3">
<!-- 图标预览 -->
<div v-if="oemSettings.siteIconData || oemSettings.siteIcon" class="inline-flex items-center gap-3 p-3 bg-gray-50 rounded-lg">
<img
:src="oemSettings.siteIconData || oemSettings.siteIcon"
alt="图标预览"
class="w-8 h-8"
@error="handleIconError"
>
<span class="text-sm text-gray-600">当前图标</span>
<button
@click="removeIcon"
class="text-red-600 hover:text-red-900 font-medium hover:bg-red-50 px-3 py-1 rounded-lg transition-colors"
>
<i class="fas fa-trash mr-1"></i>删除
</button>
</div>
<!-- 文件上传 -->
<div>
<input
type="file"
ref="iconFileInput"
@change="handleIconUpload"
accept=".ico,.png,.jpg,.jpeg,.svg"
class="hidden"
>
<button
@click="$refs.iconFileInput.click()"
class="btn btn-success px-4 py-2"
>
<i class="fas fa-upload mr-2"></i>
上传图标
</button>
<span class="text-xs text-gray-500 ml-3">支持 .ico, .png, .jpg, .svg 格式,最大 350KB</span>
</div>
</div>
</td>
</tr>
<!-- 操作按钮 -->
<tr>
<td class="px-6 py-6" colspan="2">
<div class="flex items-center justify-between">
<div class="flex gap-3">
<button
@click="saveOemSettings"
:disabled="oemSettingsSaving"
class="btn btn-primary px-6 py-3"
>
<div v-if="oemSettingsSaving" class="loading-spinner mr-2"></div>
<i v-else class="fas fa-save mr-2"></i>
{{ oemSettingsSaving ? '保存中...' : '保存设置' }}
</button>
<button
@click="resetOemSettings"
class="btn bg-gray-100 text-gray-700 hover:bg-gray-200 px-6 py-3"
>
<i class="fas fa-undo mr-2"></i>
重置为默认
</button>
</div>
<div v-if="oemSettings.updatedAt" class="text-sm text-gray-500">
<i class="fas fa-clock mr-1"></i>
最后更新:{{ formatDateTime(oemSettings.updatedAt) }}
</div>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div> </div>
</div> </div>
@@ -1978,6 +2237,27 @@
</div> </div>
</div> </div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-3">每日费用限制 (美元)</label>
<div class="space-y-3">
<div class="flex gap-2">
<button type="button" @click="apiKeyForm.dailyCostLimit = '50'" class="px-3 py-1 bg-gray-100 hover:bg-gray-200 rounded-lg text-sm font-medium">$50</button>
<button type="button" @click="apiKeyForm.dailyCostLimit = '100'" class="px-3 py-1 bg-gray-100 hover:bg-gray-200 rounded-lg text-sm font-medium">$100</button>
<button type="button" @click="apiKeyForm.dailyCostLimit = '200'" class="px-3 py-1 bg-gray-100 hover:bg-gray-200 rounded-lg text-sm font-medium">$200</button>
<button type="button" @click="apiKeyForm.dailyCostLimit = ''" class="px-3 py-1 bg-gray-100 hover:bg-gray-200 rounded-lg text-sm font-medium">自定义</button>
</div>
<input
v-model="apiKeyForm.dailyCostLimit"
type="number"
min="0"
step="0.01"
placeholder="0 表示无限制"
class="form-input w-full"
>
<p class="text-xs text-gray-500">设置此 API Key 每日的费用限制超过限制将拒绝请求0 或留空表示无限制</p>
</div>
</div>
<div> <div>
<label class="block text-sm font-semibold text-gray-700 mb-3">并发限制 (可选)</label> <label class="block text-sm font-semibold text-gray-700 mb-3">并发限制 (可选)</label>
<input <input
@@ -2000,6 +2280,36 @@
></textarea> ></textarea>
</div> </div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-3">有效期限</label>
<select
v-model="apiKeyForm.expireDuration"
@change="updateExpireAt"
class="form-input w-full"
>
<option value="">永不过期</option>
<option value="1d">1 天</option>
<option value="7d">7 天</option>
<option value="30d">30 天</option>
<option value="90d">90 天</option>
<option value="180d">180 天</option>
<option value="365d">365 天</option>
<option value="custom">自定义日期</option>
</select>
<div v-if="apiKeyForm.expireDuration === 'custom'" class="mt-3">
<input
v-model="apiKeyForm.customExpireDate"
type="datetime-local"
class="form-input w-full"
:min="minDateTime"
@change="updateCustomExpireAt"
>
</div>
<p v-if="apiKeyForm.expiresAt" class="text-xs text-gray-500 mt-2">
将于 {{ formatExpireDate(apiKeyForm.expiresAt) }} 过期
</p>
</div>
<div> <div>
<label class="block text-sm font-semibold text-gray-700 mb-3">服务权限</label> <label class="block text-sm font-semibold text-gray-700 mb-3">服务权限</label>
<div class="flex gap-4"> <div class="flex gap-4">
@@ -2131,6 +2441,43 @@
</div> </div>
</div> </div>
<!-- 客户端限制 -->
<div>
<div class="flex items-center mb-3">
<input
type="checkbox"
v-model="apiKeyForm.enableClientRestriction"
id="enableClientRestriction"
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500"
>
<label for="enableClientRestriction" class="ml-2 text-sm font-semibold text-gray-700 cursor-pointer">
启用客户端限制
</label>
</div>
<div v-if="apiKeyForm.enableClientRestriction" class="space-y-3">
<div>
<label class="block text-sm font-medium text-gray-600 mb-2">允许的客户端</label>
<p class="text-xs text-gray-500 mb-3">勾选允许使用此API Key的客户端</p>
<div class="space-y-2">
<div v-for="client in supportedClients" :key="client.id" class="flex items-start">
<input
type="checkbox"
:id="`client_${client.id}`"
:value="client.id"
v-model="apiKeyForm.allowedClients"
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 mt-0.5"
>
<label :for="`client_${client.id}`" class="ml-2 flex-1 cursor-pointer">
<span class="text-sm font-medium text-gray-700">{{ client.name }}</span>
<span class="text-xs text-gray-500 block">{{ client.description }}</span>
</label>
</div>
</div>
</div>
</div>
</div>
<div class="flex gap-3 pt-4"> <div class="flex gap-3 pt-4">
<button <button
type="button" type="button"
@@ -2245,6 +2592,27 @@
</div> </div>
</div> </div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-3">每日费用限制 (美元)</label>
<div class="space-y-3">
<div class="flex gap-2">
<button type="button" @click="editApiKeyForm.dailyCostLimit = '50'" class="px-3 py-1 bg-gray-100 hover:bg-gray-200 rounded-lg text-sm font-medium">$50</button>
<button type="button" @click="editApiKeyForm.dailyCostLimit = '100'" class="px-3 py-1 bg-gray-100 hover:bg-gray-200 rounded-lg text-sm font-medium">$100</button>
<button type="button" @click="editApiKeyForm.dailyCostLimit = '200'" class="px-3 py-1 bg-gray-100 hover:bg-gray-200 rounded-lg text-sm font-medium">$200</button>
<button type="button" @click="editApiKeyForm.dailyCostLimit = ''" class="px-3 py-1 bg-gray-100 hover:bg-gray-200 rounded-lg text-sm font-medium">自定义</button>
</div>
<input
v-model="editApiKeyForm.dailyCostLimit"
type="number"
min="0"
step="0.01"
placeholder="0 表示无限制"
class="form-input w-full"
>
<p class="text-xs text-gray-500">设置此 API Key 每日的费用限制超过限制将拒绝请求0 或留空表示无限制</p>
</div>
</div>
<div> <div>
<label class="block text-sm font-semibold text-gray-700 mb-3">并发限制</label> <label class="block text-sm font-semibold text-gray-700 mb-3">并发限制</label>
<input <input
@@ -2388,6 +2756,43 @@
</div> </div>
</div> </div>
<!-- 客户端限制 -->
<div>
<div class="flex items-center mb-3">
<input
type="checkbox"
v-model="editApiKeyForm.enableClientRestriction"
id="editEnableClientRestriction"
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500"
>
<label for="editEnableClientRestriction" class="ml-2 text-sm font-semibold text-gray-700 cursor-pointer">
启用客户端限制
</label>
</div>
<div v-if="editApiKeyForm.enableClientRestriction" class="space-y-3">
<div>
<label class="block text-sm font-medium text-gray-600 mb-2">允许的客户端</label>
<p class="text-xs text-gray-500 mb-3">勾选允许使用此API Key的客户端</p>
<div class="space-y-2">
<div v-for="client in supportedClients" :key="client.id" class="flex items-start">
<input
type="checkbox"
:id="`edit_client_${client.id}`"
:value="client.id"
v-model="editApiKeyForm.allowedClients"
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 mt-0.5"
>
<label :for="`edit_client_${client.id}`" class="ml-2 flex-1 cursor-pointer">
<span class="text-sm font-medium text-gray-700">{{ client.name }}</span>
<span class="text-xs text-gray-500 block">{{ client.description }}</span>
</label>
</div>
</div>
</div>
</div>
</div>
<div class="flex gap-3 pt-4"> <div class="flex gap-3 pt-4">
<button <button
type="button" type="button"
@@ -2410,6 +2815,92 @@
</div> </div>
</div> </div>
<!-- API Key 续期弹窗 -->
<div v-if="showRenewApiKeyModal" class="fixed inset-0 modal z-50 flex items-center justify-center p-4">
<div class="modal-content w-full max-w-md p-8 mx-auto max-h-[90vh] flex flex-col">
<div class="flex items-center justify-between mb-6">
<div class="flex items-center gap-3">
<div class="w-10 h-10 bg-gradient-to-br from-green-500 to-green-600 rounded-xl flex items-center justify-center">
<i class="fas fa-clock text-white"></i>
</div>
<h3 class="text-xl font-bold text-gray-900">续期 API Key</h3>
</div>
<button
@click="closeRenewApiKeyModal"
class="text-gray-400 hover:text-gray-600 transition-colors"
>
<i class="fas fa-times text-xl"></i>
</button>
</div>
<div class="space-y-6 modal-scroll-content custom-scrollbar flex-1">
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4">
<div class="flex items-start gap-3">
<div class="w-8 h-8 bg-blue-500 rounded-lg flex items-center justify-center flex-shrink-0">
<i class="fas fa-info text-white text-sm"></i>
</div>
<div>
<h4 class="font-semibold text-gray-800 mb-1">API Key 信息</h4>
<p class="text-sm text-gray-700">{{ renewApiKeyForm.name }}</p>
<p class="text-xs text-gray-600 mt-1">
当前过期时间:{{ renewApiKeyForm.currentExpiresAt ? formatExpireDate(renewApiKeyForm.currentExpiresAt) : '永不过期' }}
</p>
</div>
</div>
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-3">续期时长</label>
<select
v-model="renewApiKeyForm.renewDuration"
@change="updateRenewExpireAt"
class="form-input w-full"
>
<option value="7d">延长 7 天</option>
<option value="30d">延长 30 天</option>
<option value="90d">延长 90 天</option>
<option value="180d">延长 180 天</option>
<option value="365d">延长 365 天</option>
<option value="custom">自定义日期</option>
<option value="permanent">设为永不过期</option>
</select>
<div v-if="renewApiKeyForm.renewDuration === 'custom'" class="mt-3">
<input
v-model="renewApiKeyForm.customExpireDate"
type="datetime-local"
class="form-input w-full"
:min="minDateTime"
@change="updateCustomRenewExpireAt"
>
</div>
<p v-if="renewApiKeyForm.newExpiresAt" class="text-xs text-gray-500 mt-2">
新的过期时间:{{ formatExpireDate(renewApiKeyForm.newExpiresAt) }}
</p>
</div>
</div>
<div class="flex gap-3 pt-4">
<button
type="button"
@click="closeRenewApiKeyModal"
class="flex-1 px-6 py-3 bg-gray-100 text-gray-700 rounded-xl font-semibold hover:bg-gray-200 transition-colors"
>
取消
</button>
<button
type="button"
@click="renewApiKey"
:disabled="renewApiKeyLoading || !renewApiKeyForm.renewDuration"
class="btn btn-primary flex-1 py-3 px-6 font-semibold"
>
<div v-if="renewApiKeyLoading" class="loading-spinner mr-2"></div>
<i v-else class="fas fa-clock mr-2"></i>
{{ renewApiKeyLoading ? '续期中...' : '确认续期' }}
</button>
</div>
</div>
</div>
<!-- 新创建的 API Key 展示弹窗 --> <!-- 新创建的 API Key 展示弹窗 -->
<div v-if="showNewApiKeyModal" class="fixed inset-0 modal z-50 flex items-center justify-center p-4"> <div v-if="showNewApiKeyModal" class="fixed inset-0 modal z-50 flex items-center justify-center p-4">
<div class="modal-content w-full max-w-lg p-8 mx-auto max-h-[90vh] overflow-y-auto custom-scrollbar"> <div class="modal-content w-full max-w-lg p-8 mx-auto max-h-[90vh] overflow-y-auto custom-scrollbar">

689
web/apiStats/app.js Normal file
View File

@@ -0,0 +1,689 @@
// 初始化 dayjs 插件
dayjs.extend(dayjs_plugin_relativeTime);
dayjs.extend(dayjs_plugin_timezone);
dayjs.extend(dayjs_plugin_utc);
const { createApp } = Vue;
const app = createApp({
data() {
return {
// 用户输入
apiKey: '',
apiId: null, // 存储 API Key 对应的 ID
// 状态控制
loading: false,
modelStatsLoading: false,
error: '',
showAdminButton: true, // 控制管理后端按钮显示
// 时间范围控制
statsPeriod: 'daily', // 默认今日
// 数据
statsData: null,
modelStats: [],
// 分时间段的统计数据
dailyStats: null,
monthlyStats: null,
// OEM设置
oemSettings: {
siteName: 'Claude Relay Service',
siteIcon: '',
siteIconData: ''
}
};
},
methods: {
// 🔍 查询统计数据
async queryStats() {
if (!this.apiKey.trim()) {
this.error = '请输入 API Key';
return;
}
this.loading = true;
this.error = '';
this.statsData = null;
this.modelStats = [];
this.apiId = null;
try {
// 首先获取 API Key 对应的 ID
const idResponse = await fetch('/apiStats/api/get-key-id', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
apiKey: this.apiKey
})
});
const idResult = await idResponse.json();
if (!idResponse.ok) {
throw new Error(idResult.message || '获取 API Key ID 失败');
}
if (idResult.success) {
this.apiId = idResult.data.id;
// 使用 apiId 查询统计数据
const response = await fetch('/apiStats/api/user-stats', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
apiId: this.apiId
})
});
const result = await response.json();
if (!response.ok) {
throw new Error(result.message || '查询失败');
}
if (result.success) {
this.statsData = result.data;
// 同时加载今日和本月的统计数据
await this.loadAllPeriodStats();
// 清除错误信息
this.error = '';
// 更新 URL
this.updateURL();
} else {
throw new Error(result.message || '查询失败');
}
} else {
throw new Error(idResult.message || '获取 API Key ID 失败');
}
} catch (error) {
console.error('Query stats error:', error);
this.error = error.message || '查询统计数据失败,请检查您的 API Key 是否正确';
this.statsData = null;
this.modelStats = [];
this.apiId = null;
} finally {
this.loading = false;
}
},
// 📊 加载所有时间段的统计数据
async loadAllPeriodStats() {
if (!this.apiId) {
return;
}
// 并行加载今日和本月的数据
await Promise.all([
this.loadPeriodStats('daily'),
this.loadPeriodStats('monthly')
]);
// 加载当前选择时间段的模型统计
await this.loadModelStats(this.statsPeriod);
},
// 📊 加载指定时间段的统计数据
async loadPeriodStats(period) {
try {
const response = await fetch('/apiStats/api/user-model-stats', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
apiId: this.apiId,
period: period
})
});
const result = await response.json();
if (response.ok && result.success) {
// 计算汇总数据
const modelData = result.data || [];
const summary = {
requests: 0,
inputTokens: 0,
outputTokens: 0,
cacheCreateTokens: 0,
cacheReadTokens: 0,
allTokens: 0,
cost: 0,
formattedCost: '$0.000000'
};
modelData.forEach(model => {
summary.requests += model.requests || 0;
summary.inputTokens += model.inputTokens || 0;
summary.outputTokens += model.outputTokens || 0;
summary.cacheCreateTokens += model.cacheCreateTokens || 0;
summary.cacheReadTokens += model.cacheReadTokens || 0;
summary.allTokens += model.allTokens || 0;
summary.cost += model.costs?.total || 0;
});
summary.formattedCost = this.formatCost(summary.cost);
// 存储到对应的时间段数据
if (period === 'daily') {
this.dailyStats = summary;
} else {
this.monthlyStats = summary;
}
} else {
console.warn(`Failed to load ${period} stats:`, result.message);
}
} catch (error) {
console.error(`Load ${period} stats error:`, error);
}
},
// 📊 加载模型统计数据
async loadModelStats(period = 'daily') {
if (!this.apiId) {
return;
}
this.modelStatsLoading = true;
try {
const response = await fetch('/apiStats/api/user-model-stats', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
apiId: this.apiId,
period: period
})
});
const result = await response.json();
if (!response.ok) {
throw new Error(result.message || '加载模型统计失败');
}
if (result.success) {
this.modelStats = result.data || [];
} else {
throw new Error(result.message || '加载模型统计失败');
}
} catch (error) {
console.error('Load model stats error:', error);
this.modelStats = [];
// 不显示错误,因为模型统计是可选的
} finally {
this.modelStatsLoading = false;
}
},
// 🔄 切换时间范围
async switchPeriod(period) {
if (this.statsPeriod === period || this.modelStatsLoading) {
return;
}
this.statsPeriod = period;
// 如果对应时间段的数据还没有加载,则加载它
if ((period === 'daily' && !this.dailyStats) ||
(period === 'monthly' && !this.monthlyStats)) {
await this.loadPeriodStats(period);
}
// 加载对应的模型统计
await this.loadModelStats(period);
},
// 📅 格式化日期
formatDate(dateString) {
if (!dateString) return '无';
try {
// 使用 dayjs 格式化日期
const date = dayjs(dateString);
return date.format('YYYY年MM月DD日 HH:mm');
} catch (error) {
return '格式错误';
}
},
// 📅 格式化过期日期
formatExpireDate(dateString) {
if (!dateString) return '';
const date = new Date(dateString);
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
},
// 🔍 检查 API Key 是否已过期
isApiKeyExpired(expiresAt) {
if (!expiresAt) return false;
return new Date(expiresAt) < new Date();
},
// ⏰ 检查 API Key 是否即将过期7天内
isApiKeyExpiringSoon(expiresAt) {
if (!expiresAt) return false;
const expireDate = new Date(expiresAt);
const now = new Date();
const daysUntilExpire = (expireDate - now) / (1000 * 60 * 60 * 24);
return daysUntilExpire > 0 && daysUntilExpire <= 7;
},
// 🔢 格式化数字
formatNumber(num) {
if (typeof num !== 'number') {
num = parseInt(num) || 0;
}
if (num === 0) return '0';
// 大数字使用简化格式
if (num >= 1000000) {
return (num / 1000000).toFixed(1) + 'M';
} else if (num >= 1000) {
return (num / 1000).toFixed(1) + 'K';
} else {
return num.toLocaleString();
}
},
// 💰 格式化费用
formatCost(cost) {
if (typeof cost !== 'number' || cost === 0) {
return '$0.000000';
}
// 根据数值大小选择精度
if (cost >= 1) {
return '$' + cost.toFixed(2);
} else if (cost >= 0.01) {
return '$' + cost.toFixed(4);
} else {
return '$' + cost.toFixed(6);
}
},
// 🔐 格式化权限
formatPermissions(permissions) {
const permissionMap = {
'claude': 'Claude',
'gemini': 'Gemini',
'all': '全部模型'
};
return permissionMap[permissions] || permissions || '未知';
},
// 💾 处理错误
handleError(error, defaultMessage = '操作失败') {
console.error('Error:', error);
let errorMessage = defaultMessage;
if (error.response) {
// HTTP 错误响应
if (error.response.data && error.response.data.message) {
errorMessage = error.response.data.message;
} else if (error.response.status === 401) {
errorMessage = 'API Key 无效或已过期';
} else if (error.response.status === 403) {
errorMessage = '没有权限访问该数据';
} else if (error.response.status === 429) {
errorMessage = '请求过于频繁,请稍后再试';
} else if (error.response.status >= 500) {
errorMessage = '服务器内部错误,请稍后再试';
}
} else if (error.message) {
errorMessage = error.message;
}
this.error = errorMessage;
},
// 📋 复制到剪贴板
async copyToClipboard(text) {
try {
await navigator.clipboard.writeText(text);
this.showToast('已复制到剪贴板', 'success');
} catch (error) {
console.error('Copy failed:', error);
this.showToast('复制失败', 'error');
}
},
// 🍞 显示 Toast 通知
showToast(message, type = 'info') {
// 简单的 toast 实现
const toast = document.createElement('div');
toast.className = `fixed top-4 right-4 z-50 px-6 py-3 rounded-lg shadow-lg text-white transform transition-all duration-300 ${
type === 'success' ? 'bg-green-500' :
type === 'error' ? 'bg-red-500' :
'bg-blue-500'
}`;
toast.textContent = message;
document.body.appendChild(toast);
// 显示动画
setTimeout(() => {
toast.style.transform = 'translateX(0)';
toast.style.opacity = '1';
}, 100);
// 自动隐藏
setTimeout(() => {
toast.style.transform = 'translateX(100%)';
toast.style.opacity = '0';
setTimeout(() => {
document.body.removeChild(toast);
}, 300);
}, 3000);
},
// 🧹 清除数据
clearData() {
this.statsData = null;
this.modelStats = [];
this.dailyStats = null;
this.monthlyStats = null;
this.error = '';
this.statsPeriod = 'daily'; // 重置为默认值
this.apiId = null;
},
// 加载OEM设置
async loadOemSettings() {
try {
const response = await fetch('/admin/oem-settings', {
headers: {
'Content-Type': 'application/json'
}
});
if (response.ok) {
const result = await response.json();
if (result && result.success && result.data) {
this.oemSettings = { ...this.oemSettings, ...result.data };
// 应用设置到页面
this.applyOemSettings();
}
}
} catch (error) {
console.error('Error loading OEM settings:', error);
// 静默失败,使用默认值
}
},
// 应用OEM设置
applyOemSettings() {
// 更新网站标题
document.title = `API Key 统计 - ${this.oemSettings.siteName}`;
// 应用网站图标
const iconData = this.oemSettings.siteIconData || this.oemSettings.siteIcon;
if (iconData && iconData.trim()) {
// 移除现有的favicon
const existingFavicons = document.querySelectorAll('link[rel*="icon"]');
existingFavicons.forEach(link => link.remove());
// 添加新的favicon
const link = document.createElement('link');
link.rel = 'icon';
// 根据数据类型设置适当的type
if (iconData.startsWith('data:')) {
// Base64数据
link.href = iconData;
} else {
// URL
link.type = 'image/x-icon';
link.href = iconData;
}
document.head.appendChild(link);
}
},
// 🔄 刷新数据
async refreshData() {
if (this.statsData && this.apiKey) {
await this.queryStats();
}
},
// 📊 刷新当前时间段数据
async refreshCurrentPeriod() {
if (this.apiId) {
await this.loadPeriodStats(this.statsPeriod);
await this.loadModelStats(this.statsPeriod);
}
},
// 🔄 更新 URL
updateURL() {
if (this.apiId) {
const url = new URL(window.location);
url.searchParams.set('apiId', this.apiId);
window.history.pushState({}, '', url);
}
},
// 📊 使用 apiId 直接加载数据
async loadStatsWithApiId() {
if (!this.apiId) {
return;
}
this.loading = true;
this.error = '';
this.statsData = null;
this.modelStats = [];
try {
// 使用 apiId 查询统计数据
const response = await fetch('/apiStats/api/user-stats', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
apiId: this.apiId
})
});
const result = await response.json();
if (!response.ok) {
throw new Error(result.message || '查询失败');
}
if (result.success) {
this.statsData = result.data;
// 同时加载今日和本月的统计数据
await this.loadAllPeriodStats();
// 清除错误信息
this.error = '';
} else {
throw new Error(result.message || '查询失败');
}
} catch (error) {
console.error('Load stats with apiId error:', error);
this.error = error.message || '查询统计数据失败';
this.statsData = null;
this.modelStats = [];
} finally {
this.loading = false;
}
}
},
computed: {
// 📊 当前时间段的数据
currentPeriodData() {
if (this.statsPeriod === 'daily') {
return this.dailyStats || {
requests: 0,
inputTokens: 0,
outputTokens: 0,
cacheCreateTokens: 0,
cacheReadTokens: 0,
allTokens: 0,
cost: 0,
formattedCost: '$0.000000'
};
} else {
return this.monthlyStats || {
requests: 0,
inputTokens: 0,
outputTokens: 0,
cacheCreateTokens: 0,
cacheReadTokens: 0,
allTokens: 0,
cost: 0,
formattedCost: '$0.000000'
};
}
},
// 📊 使用率计算(基于当前时间段)
usagePercentages() {
if (!this.statsData || !this.currentPeriodData) {
return {
tokenUsage: 0,
costUsage: 0,
requestUsage: 0
};
}
const current = this.currentPeriodData;
const limits = this.statsData.limits;
return {
tokenUsage: limits.tokenLimit > 0 ? Math.min((current.allTokens / limits.tokenLimit) * 100, 100) : 0,
costUsage: limits.dailyCostLimit > 0 ? Math.min((current.cost / limits.dailyCostLimit) * 100, 100) : 0,
requestUsage: limits.rateLimitRequests > 0 ? Math.min((current.requests / limits.rateLimitRequests) * 100, 100) : 0
};
},
// 📈 统计摘要(基于当前时间段)
statsSummary() {
if (!this.statsData || !this.currentPeriodData) return null;
const current = this.currentPeriodData;
return {
totalRequests: current.requests || 0,
totalTokens: current.allTokens || 0,
totalCost: current.cost || 0,
formattedCost: current.formattedCost || '$0.000000',
inputTokens: current.inputTokens || 0,
outputTokens: current.outputTokens || 0,
cacheCreateTokens: current.cacheCreateTokens || 0,
cacheReadTokens: current.cacheReadTokens || 0
};
}
},
watch: {
// 监听 API Key 变化
apiKey(newValue) {
if (!newValue) {
this.clearData();
}
// 清除之前的错误
if (this.error) {
this.error = '';
}
}
},
mounted() {
// 页面加载完成后的初始化
console.log('User Stats Page loaded');
// 加载OEM设置
this.loadOemSettings();
// 检查 URL 参数是否有预填的 API Key用于开发测试
const urlParams = new URLSearchParams(window.location.search);
const presetApiId = urlParams.get('apiId');
const presetApiKey = urlParams.get('apiKey');
if (presetApiId && presetApiId.match(/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i)) {
// 如果 URL 中有 apiId直接使用 apiId 加载数据
this.apiId = presetApiId;
this.showAdminButton = false; // 隐藏管理后端按钮
this.loadStatsWithApiId();
} else if (presetApiKey && presetApiKey.length > 10) {
// 向后兼容,支持 apiKey 参数
this.apiKey = presetApiKey;
}
// 添加键盘快捷键
document.addEventListener('keydown', (event) => {
// Ctrl/Cmd + Enter 查询
if ((event.ctrlKey || event.metaKey) && event.key === 'Enter') {
if (!this.loading && this.apiKey.trim()) {
this.queryStats();
}
event.preventDefault();
}
// ESC 清除数据
if (event.key === 'Escape') {
this.clearData();
this.apiKey = '';
}
});
// 定期清理无效的 toast 元素
setInterval(() => {
const toasts = document.querySelectorAll('[class*="fixed top-4 right-4"]');
toasts.forEach(toast => {
if (toast.style.opacity === '0') {
try {
document.body.removeChild(toast);
} catch (e) {
// 忽略已经被移除的元素
}
}
});
}, 5000);
},
// 组件销毁前清理
beforeUnmount() {
// 清理事件监听器
document.removeEventListener('keydown', this.handleKeyDown);
}
});
// 挂载应用
app.mount('#app');

497
web/apiStats/index.html Normal file
View File

@@ -0,0 +1,497 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>API Key 统计</title>
<!-- 🎨 样式 -->
<link rel="stylesheet" href="/apiStats/style.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<script src="https://cdn.tailwindcss.com"></script>
<style>
[v-cloak] {
display: none;
}
/* 调整间距使其与管理页面一致 */
.stat-card {
background: linear-gradient(135deg, rgba(255, 255, 255, 0.95) 0%, rgba(255, 255, 255, 0.8) 100%);
border-radius: 20px;
border: 1px solid rgba(255, 255, 255, 0.3);
padding: 24px;
position: relative;
overflow: hidden;
transition: all 0.3s ease;
}
.stat-card:hover {
transform: translateY(-4px);
box-shadow:
0 20px 25px -5px rgba(0, 0, 0, 0.1),
0 10px 10px -5px rgba(0, 0, 0, 0.04);
}
/* 与管理页面一致的按钮样式 */
.glass-button {
background: var(--glass-color, rgba(255, 255, 255, 0.1));
backdrop-filter: blur(20px);
border: 1px solid var(--border-color, rgba(255, 255, 255, 0.2));
}
/* 调整卡片样式 */
.card {
background: var(--surface-color, rgba(255, 255, 255, 0.95));
border-radius: 16px;
border: 1px solid rgba(255, 255, 255, 0.2);
box-shadow:
0 10px 15px -3px rgba(0, 0, 0, 0.1),
0 4px 6px -2px rgba(0, 0, 0, 0.05);
overflow: hidden;
position: relative;
}
</style>
<!-- 🔧 Vue 3 -->
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
<!-- 📊 Charts -->
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<!-- 🧮 工具库 -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/dayjs/1.11.9/dayjs.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/dayjs/1.11.9/plugin/relativeTime.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/dayjs/1.11.9/plugin/timezone.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/dayjs/1.11.9/plugin/utc.min.js"></script>
</head>
<body class="min-h-screen">
<div id="app" v-cloak class="min-h-screen p-6">
<!-- 🎯 顶部导航 -->
<div class="glass-strong rounded-3xl p-6 mb-8 shadow-xl">
<div class="flex flex-col md:flex-row justify-between items-center gap-4">
<div class="flex items-center gap-4">
<div class="w-12 h-12 bg-gradient-to-br from-blue-500/20 to-purple-500/20 border border-gray-300/30 rounded-xl flex items-center justify-center backdrop-blur-sm flex-shrink-0 overflow-hidden">
<img v-if="oemSettings.siteIconData || oemSettings.siteIcon"
:src="oemSettings.siteIconData || oemSettings.siteIcon"
alt="Logo"
class="w-8 h-8 object-contain"
@error="(e) => e.target.style.display = 'none'">
<i v-else class="fas fa-cloud text-xl text-gray-700"></i>
</div>
<div class="flex flex-col justify-center min-h-[48px]">
<div class="flex items-center gap-3">
<h1 class="text-2xl font-bold text-white header-title leading-tight">{{ oemSettings.siteName || 'Claude Relay Service' }}</h1>
</div>
<p class="text-gray-600 text-sm leading-tight mt-0.5">API Key 使用统计</p>
</div>
</div>
<div class="flex items-center gap-3">
<a href="/web" class="glass-button rounded-xl px-4 py-2 text-gray-700 hover:bg-white/20 transition-colors flex items-center gap-2">
<i class="fas fa-cog text-sm"></i>
<span class="text-sm font-medium">管理后台</span>
</a>
</div>
</div>
</div>
<!-- 🔑 API Key 输入区域 -->
<div class="api-input-wide-card glass-strong rounded-3xl p-6 mb-8 shadow-xl">
<!-- 📊 标题区域 -->
<div class="wide-card-title text-center mb-6">
<h2 class="text-2xl font-bold mb-2">
<i class="fas fa-chart-line mr-3"></i>
使用统计查询
</h2>
<p class="text-base text-gray-600">查询您的 API Key 使用情况和统计数据</p>
</div>
<!-- 🔍 输入区域 -->
<div class="max-w-4xl mx-auto">
<div class="api-input-grid grid grid-cols-1 lg:grid-cols-4">
<!-- API Key 输入 -->
<div class="lg:col-span-3">
<label class="block text-sm font-medium mb-2 text-gray-700">
<i class="fas fa-key mr-2"></i>
输入您的 API Key
</label>
<input
v-model="apiKey"
type="password"
placeholder="请输入您的 API Key (cr_...)"
class="wide-card-input w-full"
@keyup.enter="queryStats"
:disabled="loading"
>
</div>
<!-- 查询按钮 -->
<div class="lg:col-span-1">
<button
@click="queryStats"
:disabled="loading || !apiKey.trim()"
class="btn btn-primary w-full px-6 py-3 flex items-center justify-center gap-2"
>
<i v-if="loading" class="fas fa-spinner loading-spinner"></i>
<i v-else class="fas fa-search"></i>
{{ loading ? '查询中...' : '查询统计' }}
</button>
</div>
</div>
<!-- 安全提示 -->
<div class="security-notice mt-4">
<i class="fas fa-shield-alt mr-2"></i>
您的 API Key 仅用于查询自己的统计数据,不会被存储或用于其他用途
</div>
</div>
</div>
<!-- ❌ 错误提示 -->
<div v-if="error" class="mb-8">
<div class="bg-red-500/20 border border-red-500/30 rounded-xl p-4 text-red-800 backdrop-blur-sm">
<i class="fas fa-exclamation-triangle mr-2"></i>
{{ error }}
</div>
</div>
<!-- 📊 统计数据展示区域 -->
<div v-if="statsData" class="fade-in">
<!-- 主要内容卡片 -->
<div class="glass-strong rounded-3xl p-6 shadow-xl">
<!-- 📅 时间范围选择器 -->
<div class="mb-6 pb-6 border-b border-gray-200">
<div class="flex flex-col md:flex-row items-start md:items-center justify-between gap-4">
<div class="flex items-center gap-3">
<i class="fas fa-clock text-blue-500 text-lg"></i>
<span class="text-lg font-medium text-gray-700">统计时间范围</span>
</div>
<div class="flex gap-2">
<button
@click="switchPeriod('daily')"
:class="['period-btn', { 'active': statsPeriod === 'daily' }]"
class="px-6 py-2 text-sm font-medium flex items-center gap-2"
:disabled="loading || modelStatsLoading"
>
<i class="fas fa-calendar-day"></i>
今日
</button>
<button
@click="switchPeriod('monthly')"
:class="['period-btn', { 'active': statsPeriod === 'monthly' }]"
class="px-6 py-2 text-sm font-medium flex items-center gap-2"
:disabled="loading || modelStatsLoading"
>
<i class="fas fa-calendar-alt"></i>
本月
</button>
</div>
</div>
</div>
<!-- 📈 基本信息卡片 -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
<!-- API Key 基本信息 -->
<div class="card p-6">
<h3 class="text-xl font-bold mb-4 flex items-center text-gray-900">
<i class="fas fa-info-circle mr-3 text-blue-500"></i>
API Key 信息
</h3>
<div class="space-y-3">
<div class="flex justify-between items-center">
<span class="text-gray-600">名称</span>
<span class="font-medium text-gray-900">{{ statsData.name }}</span>
</div>
<div class="flex justify-between items-center">
<span class="text-gray-600">状态</span>
<span :class="statsData.isActive ? 'text-green-600' : 'text-red-600'" class="font-medium">
<i :class="statsData.isActive ? 'fas fa-check-circle' : 'fas fa-times-circle'" class="mr-1"></i>
{{ statsData.isActive ? '活跃' : '已停用' }}
</span>
</div>
<div class="flex justify-between items-center">
<span class="text-gray-600">权限</span>
<span class="font-medium text-gray-900">{{ formatPermissions(statsData.permissions) }}</span>
</div>
<div class="flex justify-between items-center">
<span class="text-gray-600">创建时间</span>
<span class="font-medium text-gray-900">{{ formatDate(statsData.createdAt) }}</span>
</div>
<div class="flex justify-between items-center">
<span class="text-gray-600">过期时间</span>
<div v-if="statsData.expiresAt">
<div v-if="isApiKeyExpired(statsData.expiresAt)" class="text-red-600 font-medium">
<i class="fas fa-exclamation-circle mr-1"></i>
已过期
</div>
<div v-else-if="isApiKeyExpiringSoon(statsData.expiresAt)" class="text-orange-600 font-medium">
<i class="fas fa-clock mr-1"></i>
{{ formatExpireDate(statsData.expiresAt) }}
</div>
<div v-else class="text-gray-900 font-medium">
{{ formatExpireDate(statsData.expiresAt) }}
</div>
</div>
<div v-else class="text-gray-400 font-medium">
<i class="fas fa-infinity mr-1"></i>
永不过期
</div>
</div>
</div>
</div>
<!-- 使用统计概览 -->
<div class="card p-6">
<h3 class="text-xl font-bold mb-4 flex items-center text-gray-900">
<i class="fas fa-chart-bar mr-3 text-green-500"></i>
使用统计概览 <span class="text-sm font-normal text-gray-600 ml-2">({{ statsPeriod === 'daily' ? '今日' : '本月' }})</span>
</h3>
<div class="grid grid-cols-2 gap-4">
<div class="stat-card text-center">
<div class="text-3xl font-bold text-green-600">{{ formatNumber(currentPeriodData.requests) }}</div>
<div class="text-sm text-gray-600">{{ statsPeriod === 'daily' ? '今日' : '本月' }}请求数</div>
</div>
<div class="stat-card text-center">
<div class="text-3xl font-bold text-blue-600">{{ formatNumber(currentPeriodData.allTokens) }}</div>
<div class="text-sm text-gray-600">{{ statsPeriod === 'daily' ? '今日' : '本月' }}Token数</div>
</div>
<div class="stat-card text-center">
<div class="text-3xl font-bold text-purple-600">{{ currentPeriodData.formattedCost || '$0.000000' }}</div>
<div class="text-sm text-gray-600">{{ statsPeriod === 'daily' ? '今日' : '本月' }}费用</div>
</div>
<div class="stat-card text-center">
<div class="text-3xl font-bold text-yellow-600">{{ formatNumber(currentPeriodData.inputTokens) }}</div>
<div class="text-sm text-gray-600">{{ statsPeriod === 'daily' ? '今日' : '本月' }}输入Token</div>
</div>
</div>
</div>
</div>
<!-- 📋 详细使用数据 -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
<!-- Token 分类统计 -->
<div class="card p-6">
<h3 class="text-xl font-bold mb-4 flex items-center text-gray-900">
<i class="fas fa-coins mr-3 text-yellow-500"></i>
Token 使用分布 <span class="text-sm font-normal text-gray-600 ml-2">({{ statsPeriod === 'daily' ? '今日' : '本月' }})</span>
</h3>
<div class="space-y-3">
<div class="flex justify-between items-center">
<span class="text-gray-600 flex items-center">
<i class="fas fa-arrow-right mr-2 text-green-500"></i>
输入 Token
</span>
<span class="font-medium text-gray-900">{{ formatNumber(currentPeriodData.inputTokens) }}</span>
</div>
<div class="flex justify-between items-center">
<span class="text-gray-600 flex items-center">
<i class="fas fa-arrow-left mr-2 text-blue-500"></i>
输出 Token
</span>
<span class="font-medium text-gray-900">{{ formatNumber(currentPeriodData.outputTokens) }}</span>
</div>
<div class="flex justify-between items-center">
<span class="text-gray-600 flex items-center">
<i class="fas fa-save mr-2 text-purple-500"></i>
缓存创建 Token
</span>
<span class="font-medium text-gray-900">{{ formatNumber(currentPeriodData.cacheCreateTokens) }}</span>
</div>
<div class="flex justify-between items-center">
<span class="text-gray-600 flex items-center">
<i class="fas fa-download mr-2 text-orange-500"></i>
缓存读取 Token
</span>
<span class="font-medium text-gray-900">{{ formatNumber(currentPeriodData.cacheReadTokens) }}</span>
</div>
</div>
<div class="mt-4 pt-4 border-t border-gray-200">
<div class="flex justify-between items-center font-bold text-gray-900">
<span>{{ statsPeriod === 'daily' ? '今日' : '本月' }}总计</span>
<span class="text-xl">{{ formatNumber(currentPeriodData.allTokens) }}</span>
</div>
</div>
</div>
<!-- 限制设置 -->
<div class="card p-6">
<h3 class="text-xl font-bold mb-4 flex items-center text-gray-900">
<i class="fas fa-shield-alt mr-3 text-red-500"></i>
限制配置
</h3>
<div class="space-y-3">
<div class="flex justify-between items-center">
<span class="text-gray-600">Token 限制</span>
<span class="font-medium text-gray-900">{{ statsData.limits.tokenLimit > 0 ? formatNumber(statsData.limits.tokenLimit) : '无限制' }}</span>
</div>
<div class="flex justify-between items-center">
<span class="text-gray-600">并发限制</span>
<span class="font-medium text-gray-900">{{ statsData.limits.concurrencyLimit > 0 ? statsData.limits.concurrencyLimit : '无限制' }}</span>
</div>
<div class="flex justify-between items-center">
<span class="text-gray-600">速率限制</span>
<span class="font-medium text-gray-900">
{{ statsData.limits.rateLimitRequests > 0 && statsData.limits.rateLimitWindow > 0
? `${statsData.limits.rateLimitRequests}次/${statsData.limits.rateLimitWindow}分钟`
: '无限制' }}
</span>
</div>
<div class="flex justify-between items-center">
<span class="text-gray-600">每日费用限制</span>
<span class="font-medium text-gray-900">{{ statsData.limits.dailyCostLimit > 0 ? '$' + statsData.limits.dailyCostLimit : '无限制' }}</span>
</div>
<div class="flex justify-between items-center">
<span class="text-gray-600">模型限制</span>
<span class="font-medium text-gray-900">
<span v-if="statsData.restrictions.enableModelRestriction && statsData.restrictions.restrictedModels.length > 0"
class="text-orange-600">
<i class="fas fa-exclamation-triangle mr-1"></i>
限制 {{ statsData.restrictions.restrictedModels.length }} 个模型
</span>
<span v-else class="text-green-600">
<i class="fas fa-check-circle mr-1"></i>
允许所有模型
</span>
</span>
</div>
<div class="flex justify-between items-center">
<span class="text-gray-600">客户端限制</span>
<span class="font-medium text-gray-900">
<span v-if="statsData.restrictions.enableClientRestriction && statsData.restrictions.allowedClients.length > 0"
class="text-orange-600">
<i class="fas fa-exclamation-triangle mr-1"></i>
限制 {{ statsData.restrictions.allowedClients.length }} 个客户端
</span>
<span v-else class="text-green-600">
<i class="fas fa-check-circle mr-1"></i>
允许所有客户端
</span>
</span>
</div>
</div>
</div>
</div>
<!-- 📋 详细限制信息 -->
<div v-if="(statsData.restrictions.enableModelRestriction && statsData.restrictions.restrictedModels.length > 0) ||
(statsData.restrictions.enableClientRestriction && statsData.restrictions.allowedClients.length > 0)"
class="card p-6 mb-8">
<h3 class="text-xl font-bold mb-4 flex items-center text-gray-900">
<i class="fas fa-list-alt mr-3 text-amber-500"></i>
详细限制信息
</h3>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- 模型限制详情 -->
<div v-if="statsData.restrictions.enableModelRestriction && statsData.restrictions.restrictedModels.length > 0"
class="bg-amber-50 border border-amber-200 rounded-lg p-4">
<h4 class="font-bold text-amber-800 mb-3 flex items-center">
<i class="fas fa-robot mr-2"></i>
受限模型列表
</h4>
<div class="space-y-2">
<div v-for="model in statsData.restrictions.restrictedModels"
:key="model"
class="bg-white rounded px-3 py-2 text-sm border border-amber-200">
<i class="fas fa-ban mr-2 text-red-500"></i>
<span class="text-gray-800">{{ model }}</span>
</div>
</div>
<p class="text-xs text-amber-700 mt-3">
<i class="fas fa-info-circle mr-1"></i>
此 API Key 不能访问以上列出的模型
</p>
</div>
<!-- 客户端限制详情 -->
<div v-if="statsData.restrictions.enableClientRestriction && statsData.restrictions.allowedClients.length > 0"
class="bg-blue-50 border border-blue-200 rounded-lg p-4">
<h4 class="font-bold text-blue-800 mb-3 flex items-center">
<i class="fas fa-desktop mr-2"></i>
允许的客户端
</h4>
<div class="space-y-2">
<div v-for="client in statsData.restrictions.allowedClients"
:key="client"
class="bg-white rounded px-3 py-2 text-sm border border-blue-200">
<i class="fas fa-check mr-2 text-green-500"></i>
<span class="text-gray-800">{{ client }}</span>
</div>
</div>
<p class="text-xs text-blue-700 mt-3">
<i class="fas fa-info-circle mr-1"></i>
此 API Key 只能被以上列出的客户端使用
</p>
</div>
</div>
</div>
<!-- 📊 模型使用统计 -->
<div class="card p-6 mb-8">
<div class="mb-6">
<h3 class="text-xl font-bold flex items-center text-gray-900">
<i class="fas fa-robot mr-3 text-indigo-500"></i>
模型使用统计 <span class="text-sm font-normal text-gray-600 ml-2">({{ statsPeriod === 'daily' ? '今日' : '本月' }})</span>
</h3>
</div>
<!-- 模型统计加载状态 -->
<div v-if="modelStatsLoading" class="text-center py-8">
<i class="fas fa-spinner loading-spinner text-2xl mb-2 text-gray-600"></i>
<p class="text-gray-600">加载模型统计数据中...</p>
</div>
<!-- 模型统计数据 -->
<div v-else-if="modelStats.length > 0" class="space-y-4">
<div
v-for="(model, index) in modelStats"
:key="index"
class="model-usage-item"
>
<div class="flex justify-between items-start mb-3">
<div>
<h4 class="font-bold text-lg text-gray-900">{{ model.model }}</h4>
<p class="text-gray-600 text-sm">{{ model.requests }} 次请求</p>
</div>
<div class="text-right">
<div class="text-lg font-bold text-green-600">{{ model.formatted?.total || '$0.000000' }}</div>
<div class="text-sm text-gray-600">总费用</div>
</div>
</div>
<div class="grid grid-cols-2 md:grid-cols-4 gap-3 text-sm">
<div class="bg-gray-50 rounded p-2">
<div class="text-gray-600">输入 Token</div>
<div class="font-medium text-gray-900">{{ formatNumber(model.inputTokens) }}</div>
</div>
<div class="bg-gray-50 rounded p-2">
<div class="text-gray-600">输出 Token</div>
<div class="font-medium text-gray-900">{{ formatNumber(model.outputTokens) }}</div>
</div>
<div class="bg-gray-50 rounded p-2">
<div class="text-gray-600">缓存创建</div>
<div class="font-medium text-gray-900">{{ formatNumber(model.cacheCreateTokens) }}</div>
</div>
<div class="bg-gray-50 rounded p-2">
<div class="text-gray-600">缓存读取</div>
<div class="font-medium text-gray-900">{{ formatNumber(model.cacheReadTokens) }}</div>
</div>
</div>
</div>
</div>
<!-- 无模型数据 -->
<div v-else class="text-center py-8 text-gray-500">
<i class="fas fa-chart-pie text-3xl mb-3"></i>
<p>暂无{{ statsPeriod === 'daily' ? '今日' : '本月' }}模型使用数据</p>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 📱 JavaScript -->
<script src="/apiStats/app.js"></script>
</body>
</html>

870
web/apiStats/style.css Normal file
View File

@@ -0,0 +1,870 @@
/* 🎨 用户统计页面自定义样式 - 与管理页面保持一致 */
/* CSS 变量 - 与管理页面保持一致 */
:root {
--primary-color: #667eea;
--secondary-color: #764ba2;
--accent-color: #f093fb;
--success-color: #10b981;
--warning-color: #f59e0b;
--error-color: #ef4444;
--surface-color: rgba(255, 255, 255, 0.95);
--glass-color: rgba(255, 255, 255, 0.1);
--text-primary: #1f2937;
--text-secondary: #6b7280;
--border-color: rgba(255, 255, 255, 0.2);
}
/* 📱 响应式布局优化 */
@media (max-width: 768px) {
.container {
padding-left: 1rem;
padding-right: 1rem;
}
.card {
margin-bottom: 1rem;
}
.grid {
grid-template-columns: 1fr;
gap: 1rem;
}
.stat-card {
padding: 0.75rem;
}
.stat-card .text-2xl {
font-size: 1.5rem;
}
.model-usage-item .grid {
grid-template-columns: 1fr 1fr;
gap: 0.5rem;
}
.text-4xl {
font-size: 2rem;
}
.input-field, .btn-primary {
padding: 0.75rem 1rem;
}
}
@media (max-width: 480px) {
.container {
padding-left: 0.5rem;
padding-right: 0.5rem;
}
.text-4xl {
font-size: 1.75rem;
}
.text-lg {
font-size: 1rem;
}
.card {
padding: 1rem;
}
.stat-card {
padding: 0.5rem;
}
.stat-card .text-2xl {
font-size: 1.25rem;
}
.stat-card .text-sm {
font-size: 0.75rem;
}
.model-usage-item .grid {
grid-template-columns: 1fr;
}
.flex.gap-3 {
flex-direction: column;
gap: 0.75rem;
}
.btn-primary {
width: 100%;
justify-content: center;
}
}
/* 🌈 渐变背景 - 与管理页面一致 */
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 50%, var(--accent-color) 100%);
background-attachment: fixed;
min-height: 100vh;
margin: 0;
overflow-x: hidden;
}
body::before {
content: '';
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background:
radial-gradient(circle at 20% 80%, rgba(240, 147, 251, 0.2) 0%, transparent 50%),
radial-gradient(circle at 80% 20%, rgba(102, 126, 234, 0.2) 0%, transparent 50%),
radial-gradient(circle at 40% 40%, rgba(118, 75, 162, 0.1) 0%, transparent 50%);
pointer-events: none;
z-index: -1;
}
.gradient-bg {
/* 移除原有的渐变使用body的背景 */
}
/* ✨ 卡片样式 - 与管理页面一致 */
.glass {
background: var(--glass-color);
backdrop-filter: blur(20px);
border: 1px solid var(--border-color);
box-shadow:
0 20px 25px -5px rgba(0, 0, 0, 0.1),
0 10px 10px -5px rgba(0, 0, 0, 0.04),
inset 0 1px 0 rgba(255, 255, 255, 0.1);
}
.glass-strong {
background: var(--surface-color);
backdrop-filter: blur(25px);
border: 1px solid rgba(255, 255, 255, 0.3);
box-shadow:
0 25px 50px -12px rgba(0, 0, 0, 0.25),
0 0 0 1px rgba(255, 255, 255, 0.05),
inset 0 1px 0 rgba(255, 255, 255, 0.1);
}
.card {
background: var(--surface-color);
border-radius: 16px;
border: 1px solid rgba(255, 255, 255, 0.2);
box-shadow:
0 10px 15px -3px rgba(0, 0, 0, 0.1),
0 4px 6px -2px rgba(0, 0, 0, 0.05);
overflow: hidden;
position: relative;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.card::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 1px;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.5), transparent);
}
.card:hover {
transform: translateY(-2px);
box-shadow:
0 20px 25px -5px rgba(0, 0, 0, 0.15),
0 10px 10px -5px rgba(0, 0, 0, 0.08);
}
/* 🎯 统计卡片样式 - 与管理页面一致 */
.stat-card {
background: linear-gradient(135deg, rgba(255, 255, 255, 0.95) 0%, rgba(255, 255, 255, 0.8) 100%);
border-radius: 20px;
border: 1px solid rgba(255, 255, 255, 0.3);
padding: 24px;
position: relative;
overflow: hidden;
transition: all 0.3s ease;
}
.stat-card::before {
content: '';
position: absolute;
top: -50%;
left: -50%;
width: 200%;
height: 200%;
background: radial-gradient(circle, rgba(255, 255, 255, 0.1) 0%, transparent 70%);
opacity: 0;
transition: opacity 0.3s ease;
}
.stat-card:hover {
transform: translateY(-4px);
box-shadow:
0 20px 25px -5px rgba(0, 0, 0, 0.1),
0 10px 10px -5px rgba(0, 0, 0, 0.04);
}
.stat-card:hover::before {
opacity: 1;
}
/* 🔍 输入框样式 - 与管理页面一致 */
.form-input {
background: rgba(255, 255, 255, 0.9);
border: 2px solid rgba(255, 255, 255, 0.3);
border-radius: 12px;
padding: 16px;
font-size: 16px;
transition: all 0.3s ease;
backdrop-filter: blur(10px);
color: var(--text-primary);
}
.form-input::placeholder {
color: var(--text-secondary);
}
.form-input:focus {
outline: none;
border-color: var(--primary-color);
box-shadow:
0 0 0 3px rgba(102, 126, 234, 0.1),
0 10px 15px -3px rgba(0, 0, 0, 0.1);
background: rgba(255, 255, 255, 0.95);
}
/* 兼容旧的 input-field 类名 */
.input-field {
background: rgba(255, 255, 255, 0.9);
border: 2px solid rgba(255, 255, 255, 0.3);
border-radius: 12px;
padding: 16px;
font-size: 16px;
transition: all 0.3s ease;
backdrop-filter: blur(10px);
color: var(--text-primary);
}
.input-field::placeholder {
color: var(--text-secondary);
}
.input-field:focus {
outline: none;
border-color: var(--primary-color);
box-shadow:
0 0 0 3px rgba(102, 126, 234, 0.1),
0 10px 15px -3px rgba(0, 0, 0, 0.1);
background: rgba(255, 255, 255, 0.95);
}
/* ====== 系统标题样式 ====== */
.header-title {
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
font-weight: 700;
letter-spacing: -0.025em;
}
/* ====== 玻璃按钮样式 ====== */
.glass-button {
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%) !important;
border: 1px solid rgba(255, 255, 255, 0.2) !important;
backdrop-filter: blur(10px) !important;
-webkit-backdrop-filter: blur(10px) !important;
border-radius: 12px !important;
transition: all 0.3s ease !important;
color: white !important;
text-decoration: none !important;
box-shadow:
0 10px 15px -3px rgba(102, 126, 234, 0.3),
0 4px 6px -2px rgba(102, 126, 234, 0.05) !important;
position: relative;
overflow: hidden;
}
.glass-button::before {
content: '';
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent);
transition: left 0.5s;
}
.glass-button:hover::before {
left: 100%;
}
.glass-button:hover {
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%) !important;
border-color: rgba(255, 255, 255, 0.3) !important;
transform: translateY(-1px) !important;
box-shadow:
0 20px 25px -5px rgba(102, 126, 234, 0.3),
0 10px 10px -5px rgba(102, 126, 234, 0.1) !important;
color: white !important;
text-decoration: none !important;
}
/* 🎨 按钮样式 - 与管理页面一致 */
.btn {
font-weight: 500;
border-radius: 12px;
border: none;
cursor: pointer;
transition: all 0.3s ease;
position: relative;
overflow: hidden;
letter-spacing: 0.025em;
}
.btn::before {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 0;
height: 0;
background: rgba(255, 255, 255, 0.2);
border-radius: 50%;
transform: translate(-50%, -50%);
transition: width 0.3s ease, height 0.3s ease;
}
.btn:active::before {
width: 300px;
height: 300px;
}
.btn-primary {
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
color: white;
box-shadow:
0 10px 15px -3px rgba(102, 126, 234, 0.3),
0 4px 6px -2px rgba(102, 126, 234, 0.05);
}
.btn-primary:hover {
transform: translateY(-1px);
box-shadow:
0 20px 25px -5px rgba(102, 126, 234, 0.3),
0 10px 10px -5px rgba(102, 126, 234, 0.1);
}
.btn-primary:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
}
/* 🎯 修复时间范围按钮样式 */
.btn-primary {
border-radius: 12px !important;
}
.btn {
border-radius: 12px !important;
}
/* 🎯 时间范围按钮 - 与管理页面 tab-btn 样式一致 */
.period-btn {
position: relative;
overflow: hidden;
border-radius: 12px;
font-weight: 500;
letter-spacing: 0.025em;
transition: all 0.3s ease;
border: none;
cursor: pointer;
}
.period-btn::before {
content: '';
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent);
transition: left 0.5s;
}
.period-btn:hover::before {
left: 100%;
}
.period-btn.active {
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
color: white;
box-shadow:
0 10px 15px -3px rgba(102, 126, 234, 0.3),
0 4px 6px -2px rgba(102, 126, 234, 0.05);
transform: translateY(-1px);
}
.period-btn:not(.active) {
color: #374151;
background: transparent;
}
.period-btn:not(.active):hover {
background: rgba(255, 255, 255, 0.1);
color: #1f2937;
}
/* 📊 模型使用项样式 - 与管理页面保持一致 */
.model-usage-item {
background: rgba(255, 255, 255, 0.95);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 12px;
padding: 16px;
transition: all 0.3s ease;
position: relative;
overflow: hidden;
}
.model-usage-item::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 1px;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.5), transparent);
}
.model-usage-item:hover {
transform: translateY(-2px);
box-shadow:
0 10px 15px -3px rgba(0, 0, 0, 0.1),
0 4px 6px -2px rgba(0, 0, 0, 0.05);
border-color: rgba(255, 255, 255, 0.3);
}
/* 🔄 加载动画增强 */
.loading-spinner {
animation: spin 1s linear infinite;
filter: drop-shadow(0 0 4px rgba(255, 255, 255, 0.5));
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
/* 🌟 动画效果 */
.fade-in {
animation: fadeIn 0.6s ease-out;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.slide-in {
animation: slideIn 0.4s ease-out;
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateX(-30px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
/* 🎯 焦点样式增强 */
.input-field:focus-visible,
.btn-primary:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.5);
outline-offset: 2px;
}
/* 📱 滚动条样式 */
::-webkit-scrollbar {
width: 8px;
}
::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.1);
border-radius: 4px;
}
::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.3);
border-radius: 4px;
transition: background 0.3s ease;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.5);
}
/* 🚨 错误状态样式 */
.error-border {
border-color: #ef4444 !important;
box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.1);
}
/* 🎉 成功状态样式 */
.success-border {
border-color: #10b981 !important;
box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.1);
}
/* 🌙 深色模式适配 */
@media (prefers-color-scheme: dark) {
.card {
background: rgba(0, 0, 0, 0.2);
border-color: rgba(255, 255, 255, 0.15);
}
.stat-card {
background: linear-gradient(135deg, rgba(255, 255, 255, 0.1) 0%, rgba(255, 255, 255, 0.05) 100%);
}
.input-field {
background: rgba(0, 0, 0, 0.3);
border-color: rgba(255, 255, 255, 0.3);
}
}
/* 🔍 高对比度模式支持 */
@media (prefers-contrast: high) {
.card {
border-width: 2px;
border-color: rgba(255, 255, 255, 0.5);
}
.input-field {
border-width: 2px;
border-color: rgba(255, 255, 255, 0.6);
}
.btn-primary {
border: 2px solid rgba(255, 255, 255, 0.5);
}
}
/* 📊 数据可视化增强 */
.chart-container {
position: relative;
background: rgba(255, 255, 255, 0.05);
border-radius: 12px;
padding: 20px;
border: 1px solid rgba(255, 255, 255, 0.1);
backdrop-filter: blur(10px);
}
/* 🎨 图标动画 */
.fas {
transition: transform 0.3s ease;
}
.card:hover .fas {
transform: scale(1.1);
}
/* 💫 悬浮效果 */
.hover-lift {
transition: transform 0.3s ease, box-shadow 0.3s ease;
}
.hover-lift:hover {
transform: translateY(-4px);
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.15);
}
/* 🎯 选中状态 */
.selected {
background: rgba(255, 255, 255, 0.2) !important;
border-color: rgba(255, 255, 255, 0.4) !important;
transform: scale(1.02);
}
/* 🌈 彩虹边框效果 */
.rainbow-border {
position: relative;
background: linear-gradient(45deg, #ff6b6b, #4ecdc4, #45b7d1, #96ceb4, #feca57);
background-size: 400% 400%;
animation: gradientBG 15s ease infinite;
padding: 2px;
border-radius: 12px;
}
.rainbow-border > * {
background: rgba(0, 0, 0, 0.8);
border-radius: 10px;
}
@keyframes gradientBG {
0% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
100% { background-position: 0% 50%; }
}
/* 🎯 单层宽卡片样式优化 */
.api-input-wide-card {
background: var(--surface-color);
backdrop-filter: blur(25px);
border: 1px solid rgba(255, 255, 255, 0.3);
box-shadow:
0 25px 50px -12px rgba(0, 0, 0, 0.25),
0 0 0 1px rgba(255, 255, 255, 0.05),
inset 0 1px 0 rgba(255, 255, 255, 0.1);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.api-input-wide-card:hover {
box-shadow:
0 32px 64px -12px rgba(0, 0, 0, 0.35),
0 0 0 1px rgba(255, 255, 255, 0.08),
inset 0 1px 0 rgba(255, 255, 255, 0.15);
transform: translateY(-1px);
}
/* 🎯 宽卡片内标题样式 */
.wide-card-title h2 {
color: #1f2937 !important;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
font-weight: 700;
}
.wide-card-title p {
color: #4b5563 !important;
text-shadow: 0 1px 1px rgba(0, 0, 0, 0.05);
}
.wide-card-title .fas.fa-chart-line {
color: #3b82f6 !important;
text-shadow: 0 1px 2px rgba(59, 130, 246, 0.2);
}
/* 🎯 网格布局优化 */
.api-input-grid {
align-items: end;
gap: 1rem;
}
@media (min-width: 1024px) {
.api-input-grid {
grid-template-columns: 3fr 1fr;
gap: 1.5rem;
}
}
/* 🎯 输入框在宽卡片中的样式调整 */
.wide-card-input {
background: rgba(255, 255, 255, 0.95);
border: 2px solid rgba(255, 255, 255, 0.4);
border-radius: 12px;
padding: 14px 16px;
font-size: 16px;
transition: all 0.3s ease;
backdrop-filter: blur(10px);
color: var(--text-primary);
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
}
.wide-card-input::placeholder {
color: #9ca3af;
}
.wide-card-input:focus {
outline: none;
border-color: #60a5fa;
box-shadow:
0 0 0 3px rgba(96, 165, 250, 0.2),
0 10px 15px -3px rgba(0, 0, 0, 0.1);
background: white;
}
/* 🎯 安全提示样式优化 */
.security-notice {
background: rgba(255, 255, 255, 0.15);
border: 1px solid rgba(255, 255, 255, 0.25);
backdrop-filter: blur(10px);
border-radius: 8px;
padding: 12px 16px;
color: #374151;
font-size: 0.875rem;
transition: all 0.3s ease;
}
.security-notice:hover {
background: rgba(255, 255, 255, 0.2);
border-color: rgba(255, 255, 255, 0.35);
}
.security-notice .fas.fa-shield-alt {
color: #10b981 !important;
text-shadow: 0 1px 2px rgba(16, 185, 129, 0.2);
}
/* 🎯 时间范围选择器在卡片内的样式优化 */
.time-range-section {
background: rgba(255, 255, 255, 0.05);
border-radius: 12px;
padding: 16px;
margin-bottom: 24px;
border: 1px solid rgba(255, 255, 255, 0.1);
transition: all 0.3s ease;
}
.time-range-section:hover {
background: rgba(255, 255, 255, 0.08);
border-color: rgba(255, 255, 255, 0.15);
}
/* 📱 响应式优化 - 宽卡片布局 */
@media (max-width: 768px) {
.api-input-wide-card {
padding: 1.25rem !important;
margin-left: 1rem;
margin-right: 1rem;
}
.wide-card-title {
margin-bottom: 1.25rem !important;
}
.wide-card-title h2 {
font-size: 1.5rem !important;
}
.wide-card-title p {
font-size: 0.875rem !important;
}
.api-input-grid {
grid-template-columns: 1fr !important;
gap: 1rem !important;
}
.wide-card-input {
padding: 12px 14px !important;
font-size: 16px !important;
}
.security-notice {
padding: 10px 14px !important;
font-size: 0.8rem !important;
}
}
@media (max-width: 480px) {
.api-input-wide-card {
padding: 1rem !important;
margin-left: 0.5rem;
margin-right: 0.5rem;
}
.wide-card-title h2 {
font-size: 1.25rem !important;
}
.wide-card-title p {
font-size: 0.8rem !important;
}
}
/* 📱 响应式优化 - 时间范围选择器 */
@media (max-width: 768px) {
.time-range-section .flex {
flex-direction: column;
align-items: flex-start !important;
gap: 1rem;
}
.time-range-section .flex .flex {
width: 100%;
justify-content: center;
}
.period-btn {
flex: 1;
justify-content: center;
}
}
/* 📱 触摸设备优化 */
@media (hover: none) and (pointer: coarse) {
.card:hover {
transform: none;
box-shadow: 0 8px 32px rgba(31, 38, 135, 0.37);
}
.btn-primary:hover {
transform: none;
background-position: 0% 0;
}
.model-usage-item:hover {
transform: none;
background: rgba(255, 255, 255, 0.05);
}
.time-range-section:hover {
background: rgba(255, 255, 255, 0.05);
border-color: rgba(255, 255, 255, 0.1);
}
.query-title-section:hover {
background: linear-gradient(135deg, rgba(59, 130, 246, 0.05) 0%, rgba(99, 102, 241, 0.05) 100%);
border-color: rgba(59, 130, 246, 0.1);
}
.api-input-wide-card:hover {
transform: none;
box-shadow:
0 25px 50px -12px rgba(0, 0, 0, 0.25),
0 0 0 1px rgba(255, 255, 255, 0.05),
inset 0 1px 0 rgba(255, 255, 255, 0.1);
}
.security-notice:hover {
background: rgba(255, 255, 255, 0.15);
border-color: rgba(255, 255, 255, 0.25);
}
}
/* 🎯 打印样式 */
@media print {
.gradient-bg {
background: white !important;
color: black !important;
}
.card {
border: 1px solid #ccc !important;
background: white !important;
box-shadow: none !important;
}
.btn-primary {
display: none !important;
}
.input-field {
border: 1px solid #ccc !important;
background: white !important;
}
}