mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-22 16:43:35 +00:00
Merge remote-tracking branch 'origin/main' into dev
This commit is contained in:
@@ -55,3 +55,6 @@ WEB_LOGO_URL=/assets/logo.png
|
||||
DEBUG=false
|
||||
ENABLE_CORS=true
|
||||
TRUST_PROXY=true
|
||||
|
||||
# 🔒 客户端限制(可选)
|
||||
# ALLOW_CUSTOM_CLIENTS=false
|
||||
@@ -1,21 +1,19 @@
|
||||
name: Release on Version Change
|
||||
name: Auto Release Pipeline
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- 'VERSION'
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
|
||||
jobs:
|
||||
check-and-release:
|
||||
release-pipeline:
|
||||
runs-on: ubuntu-latest
|
||||
# 只处理由GitHub Actions提交的VERSION更新
|
||||
if: github.event.pusher.name == 'github-actions[bot]'
|
||||
# 跳过由GitHub Actions创建的提交,避免死循环
|
||||
if: github.event.pusher.name != 'github-actions[bot]' && !contains(github.event.head_commit.message, '[skip ci]')
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
@@ -23,29 +21,103 @@ jobs:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Verify only VERSION changed
|
||||
id: verify
|
||||
- name: Check if version bump is needed
|
||||
id: check
|
||||
run: |
|
||||
# 获取最后一次提交变更的文件
|
||||
CHANGED_FILES=$(git diff --name-only HEAD~1..HEAD)
|
||||
echo "Changed files: $CHANGED_FILES"
|
||||
# 获取当前提交的文件变更
|
||||
CHANGED_FILES=$(git diff --name-only HEAD~1..HEAD 2>/dev/null || git diff --name-only $(git rev-list --max-parents=0 HEAD)..HEAD)
|
||||
echo "Changed files:"
|
||||
echo "$CHANGED_FILES"
|
||||
|
||||
# 检查是否只有VERSION文件
|
||||
if [ "$CHANGED_FILES" = "VERSION" ]; then
|
||||
echo "Only VERSION file changed, proceeding with release"
|
||||
echo "should_release=true" >> $GITHUB_OUTPUT
|
||||
# 检查是否只有无关文件(.md, docs/, .github/等)
|
||||
SIGNIFICANT_CHANGES=false
|
||||
while IFS= read -r file; do
|
||||
# 跳过空行
|
||||
[ -z "$file" ] && continue
|
||||
|
||||
# 读取新版本号
|
||||
NEW_VERSION=$(cat VERSION | tr -d '[:space:]')
|
||||
echo "new_version=$NEW_VERSION" >> $GITHUB_OUTPUT
|
||||
echo "new_tag=v$NEW_VERSION" >> $GITHUB_OUTPUT
|
||||
# 检查是否是需要忽略的文件
|
||||
if [[ ! "$file" =~ \.(md|txt)$ ]] &&
|
||||
[[ ! "$file" =~ ^docs/ ]] &&
|
||||
[[ ! "$file" =~ ^\.github/ ]] &&
|
||||
[[ "$file" != "VERSION" ]] &&
|
||||
[[ "$file" != ".gitignore" ]] &&
|
||||
[[ "$file" != "LICENSE" ]]; then
|
||||
echo "Found significant change in: $file"
|
||||
SIGNIFICANT_CHANGES=true
|
||||
break
|
||||
fi
|
||||
done <<< "$CHANGED_FILES"
|
||||
|
||||
if [ "$SIGNIFICANT_CHANGES" = true ]; then
|
||||
echo "Significant changes detected, version bump needed"
|
||||
echo "needs_bump=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "Other files changed besides VERSION, skipping release"
|
||||
echo "should_release=false" >> $GITHUB_OUTPUT
|
||||
echo "No significant changes, skipping version bump"
|
||||
echo "needs_bump=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Get current version
|
||||
if: steps.check.outputs.needs_bump == 'true'
|
||||
id: get_version
|
||||
run: |
|
||||
# 获取最新的tag版本
|
||||
LATEST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "v0.0.0")
|
||||
echo "Latest tag: $LATEST_TAG"
|
||||
TAG_VERSION=${LATEST_TAG#v}
|
||||
|
||||
# 获取VERSION文件中的版本
|
||||
FILE_VERSION=$(cat VERSION | tr -d '[:space:]')
|
||||
echo "VERSION file: $FILE_VERSION"
|
||||
|
||||
# 比较tag版本和文件版本,取较大值
|
||||
function version_gt() { test "$(printf '%s\n' "$@" | sort -V | head -n 1)" != "$1"; }
|
||||
|
||||
if version_gt "$FILE_VERSION" "$TAG_VERSION"; then
|
||||
VERSION="$FILE_VERSION"
|
||||
echo "Using VERSION file: $VERSION (newer than tag)"
|
||||
else
|
||||
VERSION="$TAG_VERSION"
|
||||
echo "Using tag version: $VERSION (newer or equal to file)"
|
||||
fi
|
||||
|
||||
echo "Current version: $VERSION"
|
||||
echo "current_version=$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Calculate next version
|
||||
if: steps.check.outputs.needs_bump == 'true'
|
||||
id: next_version
|
||||
run: |
|
||||
VERSION="${{ steps.get_version.outputs.current_version }}"
|
||||
|
||||
# 分割版本号
|
||||
IFS='.' read -r -a version_parts <<< "$VERSION"
|
||||
MAJOR="${version_parts[0]:-0}"
|
||||
MINOR="${version_parts[1]:-0}"
|
||||
PATCH="${version_parts[2]:-0}"
|
||||
|
||||
# 默认递增patch版本
|
||||
NEW_PATCH=$((PATCH + 1))
|
||||
NEW_VERSION="${MAJOR}.${MINOR}.${NEW_PATCH}"
|
||||
|
||||
echo "New version: $NEW_VERSION"
|
||||
echo "new_version=$NEW_VERSION" >> $GITHUB_OUTPUT
|
||||
echo "new_tag=v$NEW_VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Update VERSION file
|
||||
if: steps.check.outputs.needs_bump == 'true'
|
||||
run: |
|
||||
echo "${{ steps.next_version.outputs.new_version }}" > VERSION
|
||||
|
||||
# 配置git
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
|
||||
# 提交VERSION文件 - 添加 [skip ci] 以避免再次触发
|
||||
git add VERSION
|
||||
git commit -m "chore: sync VERSION file with release ${{ steps.next_version.outputs.new_tag }} [skip ci]"
|
||||
|
||||
- name: Install git-cliff
|
||||
if: steps.verify.outputs.should_release == 'true'
|
||||
if: steps.check.outputs.needs_bump == 'true'
|
||||
run: |
|
||||
wget -q https://github.com/orhun/git-cliff/releases/download/v1.4.0/git-cliff-1.4.0-x86_64-unknown-linux-gnu.tar.gz
|
||||
tar -xzf git-cliff-1.4.0-x86_64-unknown-linux-gnu.tar.gz
|
||||
@@ -53,11 +125,11 @@ jobs:
|
||||
sudo mv git-cliff-1.4.0/git-cliff /usr/local/bin/
|
||||
|
||||
- name: Generate changelog
|
||||
if: steps.verify.outputs.should_release == 'true'
|
||||
if: steps.check.outputs.needs_bump == 'true'
|
||||
id: changelog
|
||||
run: |
|
||||
# 获取上一个tag以来的更新日志
|
||||
LATEST_TAG=$(git describe --tags --abbrev=0 --exclude="${{ steps.verify.outputs.new_tag }}" 2>/dev/null || echo "")
|
||||
LATEST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "")
|
||||
if [ -n "$LATEST_TAG" ]; then
|
||||
# 排除VERSION文件的提交
|
||||
CHANGELOG=$(git-cliff --config .github/cliff.toml $LATEST_TAG..HEAD --strip header | grep -v "bump version" | sed '/^$/d' || echo "- 代码优化和改进")
|
||||
@@ -69,25 +141,23 @@ jobs:
|
||||
echo "EOF" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Create and push tag
|
||||
if: steps.verify.outputs.should_release == 'true'
|
||||
if: steps.check.outputs.needs_bump == 'true'
|
||||
run: |
|
||||
NEW_TAG="${{ steps.verify.outputs.new_tag }}"
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
NEW_TAG="${{ steps.next_version.outputs.new_tag }}"
|
||||
git tag -a "$NEW_TAG" -m "Release $NEW_TAG"
|
||||
git push origin "$NEW_TAG"
|
||||
git push origin HEAD:main "$NEW_TAG"
|
||||
|
||||
- name: Create GitHub Release
|
||||
if: steps.verify.outputs.should_release == 'true'
|
||||
if: steps.check.outputs.needs_bump == 'true'
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
tag_name: ${{ steps.verify.outputs.new_tag }}
|
||||
name: Release ${{ steps.verify.outputs.new_version }}
|
||||
tag_name: ${{ steps.next_version.outputs.new_tag }}
|
||||
name: Release ${{ steps.next_version.outputs.new_version }}
|
||||
body: |
|
||||
## 🐳 Docker 镜像
|
||||
|
||||
```bash
|
||||
docker pull ${{ secrets.DOCKERHUB_USERNAME || 'weishaw' }}/claude-relay-service:${{ steps.verify.outputs.new_tag }}
|
||||
docker pull ${{ secrets.DOCKERHUB_USERNAME || 'weishaw' }}/claude-relay-service:${{ steps.next_version.outputs.new_tag }}
|
||||
docker pull ${{ secrets.DOCKERHUB_USERNAME || 'weishaw' }}/claude-relay-service:latest
|
||||
```
|
||||
|
||||
@@ -104,15 +174,15 @@ jobs:
|
||||
|
||||
# Docker构建步骤
|
||||
- name: Set up QEMU
|
||||
if: steps.verify.outputs.should_release == 'true'
|
||||
if: steps.check.outputs.needs_bump == 'true'
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
if: steps.verify.outputs.should_release == 'true'
|
||||
if: steps.check.outputs.needs_bump == 'true'
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
if: steps.verify.outputs.should_release == 'true'
|
||||
if: steps.check.outputs.needs_bump == 'true'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: docker.io
|
||||
@@ -120,31 +190,31 @@ jobs:
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
if: steps.verify.outputs.should_release == 'true'
|
||||
if: steps.check.outputs.needs_bump == 'true'
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: |
|
||||
${{ secrets.DOCKERHUB_USERNAME }}/claude-relay-service:${{ steps.verify.outputs.new_tag }}
|
||||
${{ secrets.DOCKERHUB_USERNAME }}/claude-relay-service:${{ steps.next_version.outputs.new_tag }}
|
||||
${{ secrets.DOCKERHUB_USERNAME }}/claude-relay-service:latest
|
||||
${{ secrets.DOCKERHUB_USERNAME }}/claude-relay-service:${{ steps.verify.outputs.new_version }}
|
||||
${{ secrets.DOCKERHUB_USERNAME }}/claude-relay-service:${{ steps.next_version.outputs.new_version }}
|
||||
labels: |
|
||||
org.opencontainers.image.version=${{ steps.verify.outputs.new_version }}
|
||||
org.opencontainers.image.version=${{ steps.next_version.outputs.new_version }}
|
||||
org.opencontainers.image.revision=${{ github.sha }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
- name: Send Telegram Notification
|
||||
if: steps.verify.outputs.should_release == 'true' && env.TELEGRAM_BOT_TOKEN != '' && env.TELEGRAM_CHAT_ID != ''
|
||||
if: steps.check.outputs.needs_bump == 'true' && env.TELEGRAM_BOT_TOKEN != '' && env.TELEGRAM_CHAT_ID != ''
|
||||
env:
|
||||
TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
|
||||
TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
|
||||
continue-on-error: true
|
||||
run: |
|
||||
VERSION="${{ steps.verify.outputs.new_version }}"
|
||||
TAG="${{ steps.verify.outputs.new_tag }}"
|
||||
VERSION="${{ steps.next_version.outputs.new_version }}"
|
||||
TAG="${{ steps.next_version.outputs.new_tag }}"
|
||||
REPO="${{ github.repository }}"
|
||||
|
||||
# 获取更新内容并限制长度
|
||||
102
.github/workflows/auto-version-bump.yml
vendored
102
.github/workflows/auto-version-bump.yml
vendored
@@ -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
|
||||
101
.github/workflows/docker-publish.yml
vendored
101
.github/workflows/docker-publish.yml
vendored
@@ -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中转服务"
|
||||
56
.github/workflows/release.yml
vendored
56
.github/workflows/release.yml
vendored
@@ -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
|
||||
@@ -36,9 +36,6 @@ RUN mkdir -p logs data temp
|
||||
# 🔧 预先创建配置文件
|
||||
RUN if [ ! -f "/app/config/config.js" ] && [ -f "/app/config/config.example.js" ]; then \
|
||||
cp /app/config/config.example.js /app/config/config.js; \
|
||||
fi && \
|
||||
if [ ! -f "/app/.env" ] && [ -f "/app/.env.example" ]; then \
|
||||
cp /app/.env.example /app/.env; \
|
||||
fi
|
||||
|
||||
# 🌐 暴露端口
|
||||
|
||||
119
README.md
119
README.md
@@ -6,7 +6,7 @@
|
||||
[](https://nodejs.org/)
|
||||
[](https://redis.io/)
|
||||
[](https://www.docker.com/)
|
||||
[](https://github.com/Wei-Shaw/claude-relay-service/actions/workflows/docker-publish.yml)
|
||||
[](https://github.com/Wei-Shaw/claude-relay-service/actions/workflows/auto-release-pipeline.yml)
|
||||
[](https://hub.docker.com/r/weishaw/claude-relay-service)
|
||||
|
||||
**🔐 自行搭建Claude API中转服务,支持多账户管理**
|
||||
@@ -106,7 +106,7 @@
|
||||
- 🔄 **智能切换**: 账户出问题自动换下一个
|
||||
- 🚀 **性能优化**: 连接池、缓存,减少延迟
|
||||
- 📊 **监控面板**: Web界面查看所有数据
|
||||
- 🛡️ **安全控制**: 访问限制、速率控制
|
||||
- 🛡️ **安全控制**: 访问限制、速率控制、客户端限制
|
||||
- 🌐 **代理支持**: 支持HTTP/SOCKS5代理
|
||||
|
||||
---
|
||||
@@ -232,17 +232,31 @@ npm run service:status
|
||||
# 拉取镜像(支持 amd64 和 arm64)
|
||||
docker pull weishaw/claude-relay-service:latest
|
||||
|
||||
# 使用 docker run 运行
|
||||
# 使用 docker run 运行(注意设置必需的环境变量)
|
||||
docker run -d \
|
||||
--name claude-relay \
|
||||
-p 3000:3000 \
|
||||
-v $(pwd)/data:/app/data \
|
||||
-v $(pwd)/logs:/app/logs \
|
||||
-e JWT_SECRET=your-random-secret-key-at-least-32-chars \
|
||||
-e ENCRYPTION_KEY=your-32-character-encryption-key \
|
||||
-e REDIS_HOST=redis \
|
||||
-e ADMIN_USERNAME=my_admin \
|
||||
-e ADMIN_PASSWORD=my_secure_password \
|
||||
weishaw/claude-relay-service:latest
|
||||
|
||||
# 或使用 docker-compose(推荐)
|
||||
# 创建 .env 文件用于 docker-compose 的环境变量:
|
||||
cat > .env << 'EOF'
|
||||
# 必填:安全密钥(请修改为随机值)
|
||||
JWT_SECRET=your-random-secret-key-at-least-32-chars
|
||||
ENCRYPTION_KEY=your-32-character-encryption-key
|
||||
|
||||
# 可选:管理员凭据
|
||||
ADMIN_USERNAME=cr_admin
|
||||
ADMIN_PASSWORD=your-secure-password
|
||||
EOF
|
||||
|
||||
# 创建 docker-compose.yml 文件:
|
||||
cat > docker-compose.yml << 'EOF'
|
||||
version: '3.8'
|
||||
@@ -254,6 +268,8 @@ services:
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
- JWT_SECRET=${JWT_SECRET}
|
||||
- ENCRYPTION_KEY=${ENCRYPTION_KEY}
|
||||
- REDIS_HOST=redis
|
||||
- ADMIN_USERNAME=${ADMIN_USERNAME:-}
|
||||
- ADMIN_PASSWORD=${ADMIN_PASSWORD:-}
|
||||
@@ -285,16 +301,21 @@ docker-compose up -d
|
||||
git clone https://github.com/Wei-Shaw//claude-relay-service.git
|
||||
cd claude-relay-service
|
||||
|
||||
# 2. 设置管理员账号密码(可选)
|
||||
# 方式一:自动生成(查看容器日志获取)
|
||||
# 2. 创建环境变量文件
|
||||
cat > .env << 'EOF'
|
||||
# 必填:安全密钥(请修改为随机值)
|
||||
JWT_SECRET=your-random-secret-key-at-least-32-chars
|
||||
ENCRYPTION_KEY=your-32-character-encryption-key
|
||||
|
||||
# 可选:管理员凭据
|
||||
ADMIN_USERNAME=cr_admin_custom
|
||||
ADMIN_PASSWORD=your-secure-password
|
||||
EOF
|
||||
|
||||
# 3. 启动服务
|
||||
docker-compose up -d
|
||||
|
||||
# 方式二:预设账号密码
|
||||
export ADMIN_USERNAME=cr_admin_custom
|
||||
export ADMIN_PASSWORD=your-secure-password
|
||||
docker-compose up -d
|
||||
|
||||
# 3. 查看管理员凭据
|
||||
# 4. 查看管理员凭据
|
||||
# 自动生成的情况下:
|
||||
docker logs claude-relay-service | grep "管理员"
|
||||
|
||||
@@ -310,6 +331,19 @@ docker-compose.yml 已包含:
|
||||
- ✅ Redis数据库
|
||||
- ✅ 健康检查
|
||||
- ✅ 自动重启
|
||||
- ✅ 所有配置通过环境变量管理
|
||||
|
||||
### 环境变量说明
|
||||
|
||||
#### 必填项
|
||||
- `JWT_SECRET`: JWT密钥,至少32个字符
|
||||
- `ENCRYPTION_KEY`: 加密密钥,必须是32个字符
|
||||
|
||||
#### 可选项
|
||||
- `ADMIN_USERNAME`: 管理员用户名(不设置则自动生成)
|
||||
- `ADMIN_PASSWORD`: 管理员密码(不设置则自动生成)
|
||||
- `LOG_LEVEL`: 日志级别(默认:info)
|
||||
- 更多配置项请参考 `.env.example` 文件
|
||||
|
||||
### 管理员凭据获取方式
|
||||
|
||||
@@ -364,7 +398,11 @@ docker-compose.yml 已包含:
|
||||
1. 点击「API Keys」标签
|
||||
2. 点击「创建新Key」
|
||||
3. 给Key起个名字,比如「张三的Key」
|
||||
4. 设置使用限制(可选)
|
||||
4. 设置使用限制(可选):
|
||||
- **速率限制**: 限制每个时间窗口的请求次数和Token使用量
|
||||
- **并发限制**: 限制同时处理的请求数
|
||||
- **模型限制**: 限制可访问的模型列表
|
||||
- **客户端限制**: 限制只允许特定客户端使用(如ClaudeCode、Gemini-CLI等)
|
||||
5. 保存,记下生成的Key
|
||||
|
||||
### 4. 开始使用Claude code
|
||||
@@ -464,6 +502,63 @@ npm run service:status
|
||||
- 查看更新日志了解是否有破坏性变更
|
||||
- 如果有数据库结构变更,会自动迁移
|
||||
|
||||
---
|
||||
|
||||
## 🔒 客户端限制功能
|
||||
|
||||
### 功能说明
|
||||
|
||||
客户端限制功能允许你控制每个API Key可以被哪些客户端使用,通过User-Agent识别客户端,提高API的安全性。
|
||||
|
||||
### 使用方法
|
||||
|
||||
1. **在创建或编辑API Key时启用客户端限制**:
|
||||
- 勾选"启用客户端限制"
|
||||
- 选择允许的客户端(支持多选)
|
||||
|
||||
2. **预定义客户端**:
|
||||
- **ClaudeCode**: 官方Claude CLI(匹配 `claude-cli/x.x.x (external, cli)` 格式)
|
||||
- **Gemini-CLI**: Gemini命令行工具(匹配 `GeminiCLI/vx.x.x (platform; arch)` 格式)
|
||||
|
||||
3. **调试和诊断**:
|
||||
- 系统会在日志中记录所有请求的User-Agent
|
||||
- 客户端验证失败时会返回403错误并记录详细信息
|
||||
- 通过日志可以查看实际的User-Agent格式,方便配置自定义客户端
|
||||
|
||||
### 自定义客户端配置
|
||||
|
||||
如需添加自定义客户端,可以修改 `config/config.js` 文件:
|
||||
|
||||
```javascript
|
||||
clientRestrictions: {
|
||||
predefinedClients: [
|
||||
// ... 现有客户端配置
|
||||
{
|
||||
id: 'my_custom_client',
|
||||
name: 'My Custom Client',
|
||||
description: '我的自定义客户端',
|
||||
userAgentPattern: /^MyClient\/[\d\.]+/i
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 日志示例
|
||||
|
||||
认证成功时的日志:
|
||||
```
|
||||
🔓 Authenticated request from key: 测试Key (key-id) in 5ms
|
||||
User-Agent: "claude-cli/1.0.58 (external, cli)"
|
||||
```
|
||||
|
||||
客户端限制检查日志:
|
||||
```
|
||||
🔍 Checking client restriction for key: key-id (测试Key)
|
||||
User-Agent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64)"
|
||||
Allowed clients: claude_code, gemini_cli
|
||||
🚫 Client restriction failed for key: key-id (测试Key) from 127.0.0.1, User-Agent: Mozilla/5.0...
|
||||
```
|
||||
|
||||
### 常见问题处理
|
||||
|
||||
**Redis连不上?**
|
||||
|
||||
363
cli/index.js
363
cli/index.js
@@ -4,7 +4,7 @@ const { Command } = require('commander');
|
||||
const inquirer = require('inquirer');
|
||||
const chalk = require('chalk');
|
||||
const ora = require('ora');
|
||||
const Table = require('table').table;
|
||||
const { table } = require('table');
|
||||
const bcrypt = require('bcryptjs');
|
||||
const crypto = require('crypto');
|
||||
const fs = require('fs');
|
||||
@@ -54,6 +54,43 @@ program
|
||||
});
|
||||
|
||||
|
||||
// 🔑 API Key 管理
|
||||
program
|
||||
.command('keys')
|
||||
.description('API Key 管理操作')
|
||||
.action(async () => {
|
||||
await initialize();
|
||||
|
||||
const { action } = await inquirer.prompt([{
|
||||
type: 'list',
|
||||
name: 'action',
|
||||
message: '请选择操作:',
|
||||
choices: [
|
||||
{ name: '📋 查看所有 API Keys', value: 'list' },
|
||||
{ name: '🔧 修改 API Key 过期时间', value: 'update-expiry' },
|
||||
{ name: '🔄 续期即将过期的 API Key', value: 'renew' },
|
||||
{ name: '🗑️ 删除 API Key', value: 'delete' }
|
||||
]
|
||||
}]);
|
||||
|
||||
switch (action) {
|
||||
case 'list':
|
||||
await listApiKeys();
|
||||
break;
|
||||
case 'update-expiry':
|
||||
await updateApiKeyExpiry();
|
||||
break;
|
||||
case 'renew':
|
||||
await renewApiKeys();
|
||||
break;
|
||||
case 'delete':
|
||||
await deleteApiKey();
|
||||
break;
|
||||
}
|
||||
|
||||
await redis.disconnect();
|
||||
});
|
||||
|
||||
// 📊 系统状态
|
||||
program
|
||||
.command('status')
|
||||
@@ -201,6 +238,329 @@ async function createInitialAdmin() {
|
||||
|
||||
|
||||
|
||||
// API Key 管理功能
|
||||
async function listApiKeys() {
|
||||
const spinner = ora('正在获取 API Keys...').start();
|
||||
|
||||
try {
|
||||
const apiKeys = await apiKeyService.getAllApiKeys();
|
||||
spinner.succeed(`找到 ${apiKeys.length} 个 API Keys`);
|
||||
|
||||
if (apiKeys.length === 0) {
|
||||
console.log(styles.warning('没有找到任何 API Keys'));
|
||||
return;
|
||||
}
|
||||
|
||||
const tableData = [
|
||||
['名称', 'API Key', '状态', '过期时间', '使用量', 'Token限制']
|
||||
];
|
||||
|
||||
apiKeys.forEach(key => {
|
||||
const now = new Date();
|
||||
const expiresAt = key.expiresAt ? new Date(key.expiresAt) : null;
|
||||
let expiryStatus = '永不过期';
|
||||
|
||||
if (expiresAt) {
|
||||
if (expiresAt < now) {
|
||||
expiryStatus = styles.error(`已过期 (${expiresAt.toLocaleDateString()})`);
|
||||
} else {
|
||||
const daysLeft = Math.ceil((expiresAt - now) / (1000 * 60 * 60 * 24));
|
||||
if (daysLeft <= 7) {
|
||||
expiryStatus = styles.warning(`${daysLeft}天后过期 (${expiresAt.toLocaleDateString()})`);
|
||||
} else {
|
||||
expiryStatus = styles.success(`${expiresAt.toLocaleDateString()}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tableData.push([
|
||||
key.name,
|
||||
key.apiKey ? key.apiKey.substring(0, 20) + '...' : '-',
|
||||
key.isActive ? '🟢 活跃' : '🔴 停用',
|
||||
expiryStatus,
|
||||
`${(key.usage?.total?.tokens || 0).toLocaleString()}`,
|
||||
key.tokenLimit ? key.tokenLimit.toLocaleString() : '无限制'
|
||||
]);
|
||||
});
|
||||
|
||||
console.log(styles.title('\n🔑 API Keys 列表:\n'));
|
||||
console.log(table(tableData));
|
||||
|
||||
} catch (error) {
|
||||
spinner.fail('获取 API Keys 失败');
|
||||
console.error(styles.error(error.message));
|
||||
}
|
||||
}
|
||||
|
||||
async function updateApiKeyExpiry() {
|
||||
try {
|
||||
// 获取所有 API Keys
|
||||
const apiKeys = await apiKeyService.getAllApiKeys();
|
||||
|
||||
if (apiKeys.length === 0) {
|
||||
console.log(styles.warning('没有找到任何 API Keys'));
|
||||
return;
|
||||
}
|
||||
|
||||
// 选择要修改的 API Key
|
||||
const { selectedKey } = await inquirer.prompt([{
|
||||
type: 'list',
|
||||
name: 'selectedKey',
|
||||
message: '选择要修改的 API Key:',
|
||||
choices: apiKeys.map(key => ({
|
||||
name: `${key.name} (${key.apiKey?.substring(0, 20)}...) - ${key.expiresAt ? new Date(key.expiresAt).toLocaleDateString() : '永不过期'}`,
|
||||
value: key
|
||||
}))
|
||||
}]);
|
||||
|
||||
console.log(`\n当前 API Key: ${selectedKey.name}`);
|
||||
console.log(`当前过期时间: ${selectedKey.expiresAt ? new Date(selectedKey.expiresAt).toLocaleString() : '永不过期'}`);
|
||||
|
||||
// 选择新的过期时间
|
||||
const { expiryOption } = await inquirer.prompt([{
|
||||
type: 'list',
|
||||
name: 'expiryOption',
|
||||
message: '选择新的过期时间:',
|
||||
choices: [
|
||||
{ name: '⏰ 1分后(测试用)', value: '1m' },
|
||||
{ name: '⏰ 1小时后(测试用)', value: '1h' },
|
||||
{ name: '📅 1天后', value: '1d' },
|
||||
{ name: '📅 7天后', value: '7d' },
|
||||
{ name: '📅 30天后', value: '30d' },
|
||||
{ name: '📅 90天后', value: '90d' },
|
||||
{ name: '📅 365天后', value: '365d' },
|
||||
{ name: '♾️ 永不过期', value: 'never' },
|
||||
{ name: '🎯 自定义日期时间', value: 'custom' }
|
||||
]
|
||||
}]);
|
||||
|
||||
let newExpiresAt = null;
|
||||
|
||||
if (expiryOption === 'never') {
|
||||
newExpiresAt = null;
|
||||
} else if (expiryOption === 'custom') {
|
||||
const { customDate, customTime } = await inquirer.prompt([
|
||||
{
|
||||
type: 'input',
|
||||
name: 'customDate',
|
||||
message: '输入日期 (YYYY-MM-DD):',
|
||||
default: new Date().toISOString().split('T')[0],
|
||||
validate: input => {
|
||||
const date = new Date(input);
|
||||
return !isNaN(date.getTime()) || '请输入有效的日期格式';
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'input',
|
||||
name: 'customTime',
|
||||
message: '输入时间 (HH:MM):',
|
||||
default: '00:00',
|
||||
validate: input => {
|
||||
return /^\d{2}:\d{2}$/.test(input) || '请输入有效的时间格式 (HH:MM)';
|
||||
}
|
||||
}
|
||||
]);
|
||||
|
||||
newExpiresAt = new Date(`${customDate}T${customTime}:00`).toISOString();
|
||||
} else {
|
||||
// 计算新的过期时间
|
||||
const now = new Date();
|
||||
const durations = {
|
||||
'1m': 60 * 1000,
|
||||
'1h': 60 * 60 * 1000,
|
||||
'1d': 24 * 60 * 60 * 1000,
|
||||
'7d': 7 * 24 * 60 * 60 * 1000,
|
||||
'30d': 30 * 24 * 60 * 60 * 1000,
|
||||
'90d': 90 * 24 * 60 * 60 * 1000,
|
||||
'365d': 365 * 24 * 60 * 60 * 1000
|
||||
};
|
||||
|
||||
newExpiresAt = new Date(now.getTime() + durations[expiryOption]).toISOString();
|
||||
}
|
||||
|
||||
// 确认修改
|
||||
const confirmMsg = newExpiresAt
|
||||
? `确认将过期时间修改为: ${new Date(newExpiresAt).toLocaleString()}?`
|
||||
: '确认设置为永不过期?';
|
||||
|
||||
const { confirmed } = await inquirer.prompt([{
|
||||
type: 'confirm',
|
||||
name: 'confirmed',
|
||||
message: confirmMsg,
|
||||
default: true
|
||||
}]);
|
||||
|
||||
if (!confirmed) {
|
||||
console.log(styles.info('已取消修改'));
|
||||
return;
|
||||
}
|
||||
|
||||
// 执行修改
|
||||
const spinner = ora('正在修改过期时间...').start();
|
||||
|
||||
try {
|
||||
await apiKeyService.updateApiKey(selectedKey.id, { expiresAt: newExpiresAt });
|
||||
spinner.succeed('过期时间修改成功');
|
||||
|
||||
console.log(styles.success(`\n✅ API Key "${selectedKey.name}" 的过期时间已更新`));
|
||||
console.log(`新的过期时间: ${newExpiresAt ? new Date(newExpiresAt).toLocaleString() : '永不过期'}`);
|
||||
|
||||
} catch (error) {
|
||||
spinner.fail('修改失败');
|
||||
console.error(styles.error(error.message));
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error(styles.error('操作失败:', error.message));
|
||||
}
|
||||
}
|
||||
|
||||
async function renewApiKeys() {
|
||||
const spinner = ora('正在查找即将过期的 API Keys...').start();
|
||||
|
||||
try {
|
||||
const apiKeys = await apiKeyService.getAllApiKeys();
|
||||
const now = new Date();
|
||||
const sevenDaysLater = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000);
|
||||
|
||||
// 筛选即将过期的 Keys(7天内)
|
||||
const expiringKeys = apiKeys.filter(key => {
|
||||
if (!key.expiresAt) return false;
|
||||
const expiresAt = new Date(key.expiresAt);
|
||||
return expiresAt > now && expiresAt <= sevenDaysLater;
|
||||
});
|
||||
|
||||
spinner.stop();
|
||||
|
||||
if (expiringKeys.length === 0) {
|
||||
console.log(styles.info('没有即将过期的 API Keys(7天内)'));
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(styles.warning(`\n找到 ${expiringKeys.length} 个即将过期的 API Keys:\n`));
|
||||
|
||||
expiringKeys.forEach((key, index) => {
|
||||
const daysLeft = Math.ceil((new Date(key.expiresAt) - now) / (1000 * 60 * 60 * 24));
|
||||
console.log(`${index + 1}. ${key.name} - ${daysLeft}天后过期 (${new Date(key.expiresAt).toLocaleDateString()})`);
|
||||
});
|
||||
|
||||
const { renewOption } = await inquirer.prompt([{
|
||||
type: 'list',
|
||||
name: 'renewOption',
|
||||
message: '选择续期方式:',
|
||||
choices: [
|
||||
{ name: '📅 全部续期30天', value: 'all30' },
|
||||
{ name: '📅 全部续期90天', value: 'all90' },
|
||||
{ name: '🎯 逐个选择续期', value: 'individual' }
|
||||
]
|
||||
}]);
|
||||
|
||||
if (renewOption.startsWith('all')) {
|
||||
const days = renewOption === 'all30' ? 30 : 90;
|
||||
const renewSpinner = ora(`正在为所有 API Keys 续期 ${days} 天...`).start();
|
||||
|
||||
for (const key of expiringKeys) {
|
||||
try {
|
||||
const newExpiresAt = new Date(new Date(key.expiresAt).getTime() + days * 24 * 60 * 60 * 1000).toISOString();
|
||||
await apiKeyService.updateApiKey(key.id, { expiresAt: newExpiresAt });
|
||||
} catch (error) {
|
||||
renewSpinner.fail(`续期 ${key.name} 失败: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
renewSpinner.succeed(`成功续期 ${expiringKeys.length} 个 API Keys`);
|
||||
|
||||
} else {
|
||||
// 逐个选择续期
|
||||
for (const key of expiringKeys) {
|
||||
console.log(`\n处理: ${key.name}`);
|
||||
|
||||
const { action } = await inquirer.prompt([{
|
||||
type: 'list',
|
||||
name: 'action',
|
||||
message: '选择操作:',
|
||||
choices: [
|
||||
{ name: '续期30天', value: '30' },
|
||||
{ name: '续期90天', value: '90' },
|
||||
{ name: '跳过', value: 'skip' }
|
||||
]
|
||||
}]);
|
||||
|
||||
if (action !== 'skip') {
|
||||
const days = parseInt(action);
|
||||
const newExpiresAt = new Date(new Date(key.expiresAt).getTime() + days * 24 * 60 * 60 * 1000).toISOString();
|
||||
|
||||
try {
|
||||
await apiKeyService.updateApiKey(key.id, { expiresAt: newExpiresAt });
|
||||
console.log(styles.success(`✅ 已续期 ${days} 天`));
|
||||
} catch (error) {
|
||||
console.log(styles.error(`❌ 续期失败: ${error.message}`));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
spinner.fail('操作失败');
|
||||
console.error(styles.error(error.message));
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteApiKey() {
|
||||
try {
|
||||
const apiKeys = await apiKeyService.getAllApiKeys();
|
||||
|
||||
if (apiKeys.length === 0) {
|
||||
console.log(styles.warning('没有找到任何 API Keys'));
|
||||
return;
|
||||
}
|
||||
|
||||
const { selectedKeys } = await inquirer.prompt([{
|
||||
type: 'checkbox',
|
||||
name: 'selectedKeys',
|
||||
message: '选择要删除的 API Keys (空格选择,回车确认):',
|
||||
choices: apiKeys.map(key => ({
|
||||
name: `${key.name} (${key.apiKey?.substring(0, 20)}...)`,
|
||||
value: key.id
|
||||
}))
|
||||
}]);
|
||||
|
||||
if (selectedKeys.length === 0) {
|
||||
console.log(styles.info('未选择任何 API Key'));
|
||||
return;
|
||||
}
|
||||
|
||||
const { confirmed } = await inquirer.prompt([{
|
||||
type: 'confirm',
|
||||
name: 'confirmed',
|
||||
message: styles.warning(`确认删除 ${selectedKeys.length} 个 API Keys?`),
|
||||
default: false
|
||||
}]);
|
||||
|
||||
if (!confirmed) {
|
||||
console.log(styles.info('已取消删除'));
|
||||
return;
|
||||
}
|
||||
|
||||
const spinner = ora('正在删除 API Keys...').start();
|
||||
let successCount = 0;
|
||||
|
||||
for (const keyId of selectedKeys) {
|
||||
try {
|
||||
await apiKeyService.deleteApiKey(keyId);
|
||||
successCount++;
|
||||
} catch (error) {
|
||||
spinner.fail(`删除失败: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
spinner.succeed(`成功删除 ${successCount}/${selectedKeys.length} 个 API Keys`);
|
||||
|
||||
} catch (error) {
|
||||
console.error(styles.error('删除失败:', error.message));
|
||||
}
|
||||
}
|
||||
|
||||
async function listClaudeAccounts() {
|
||||
const spinner = ora('正在获取 Claude 账户...').start();
|
||||
|
||||
@@ -251,6 +611,7 @@ if (!process.argv.slice(2).length) {
|
||||
console.log(styles.title('🚀 Claude Relay Service CLI\n'));
|
||||
console.log('使用以下命令管理服务:\n');
|
||||
console.log(' claude-relay-cli admin - 创建初始管理员账户');
|
||||
console.log(' claude-relay-cli keys - API Key 管理(查看/修改过期时间/续期/删除)');
|
||||
console.log(' claude-relay-cli status - 查看系统状态');
|
||||
console.log('\n使用 --help 查看详细帮助信息');
|
||||
}
|
||||
@@ -76,6 +76,38 @@ const config = {
|
||||
sessionSecret: process.env.WEB_SESSION_SECRET || 'CHANGE-THIS-SESSION-SECRET'
|
||||
},
|
||||
|
||||
// 🔒 客户端限制配置
|
||||
clientRestrictions: {
|
||||
// 预定义的客户端列表
|
||||
predefinedClients: [
|
||||
{
|
||||
id: 'claude_code',
|
||||
name: 'ClaudeCode',
|
||||
description: 'Official Claude Code CLI',
|
||||
// 匹配 Claude CLI 的 User-Agent
|
||||
// 示例: claude-cli/1.0.58 (external, cli)
|
||||
userAgentPattern: /^claude-cli\/[\d\.]+\s+\(/i
|
||||
},
|
||||
{
|
||||
id: 'gemini_cli',
|
||||
name: 'Gemini-CLI',
|
||||
description: 'Gemini Command Line Interface',
|
||||
// 匹配 GeminiCLI 的 User-Agent
|
||||
// 示例: GeminiCLI/v18.20.8 (darwin; arm64)
|
||||
userAgentPattern: /^GeminiCLI\/v?[\d\.]+\s+\(/i
|
||||
}
|
||||
// 添加自定义客户端示例:
|
||||
// {
|
||||
// id: 'custom_client',
|
||||
// name: 'My Custom Client',
|
||||
// description: 'My custom API client',
|
||||
// userAgentPattern: /^MyClient\/[\d\.]+/i
|
||||
// }
|
||||
],
|
||||
// 是否允许自定义客户端(未来功能)
|
||||
allowCustomClients: process.env.ALLOW_CUSTOM_CLIENTS === 'true'
|
||||
},
|
||||
|
||||
// 🛠️ 开发配置
|
||||
development: {
|
||||
debug: process.env.DEBUG === 'true',
|
||||
|
||||
@@ -1,20 +1,72 @@
|
||||
version: '3.8'
|
||||
|
||||
# Claude Relay Service Docker Compose 配置
|
||||
# 所有配置通过环境变量设置,无需映射 .env 文件
|
||||
|
||||
services:
|
||||
# 🚀 Claude Relay Service
|
||||
claude-relay:
|
||||
build: .
|
||||
container_name: claude-relay-service
|
||||
image: weishaw/claude-relay-service:latest
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${PORT:-3000}:3000"
|
||||
environment:
|
||||
# 🌐 服务器配置
|
||||
- NODE_ENV=production
|
||||
- PORT=3000
|
||||
- HOST=0.0.0.0
|
||||
|
||||
# 🔐 安全配置(必填)
|
||||
- JWT_SECRET=${JWT_SECRET} # 必填:至少32字符的随机字符串
|
||||
- ENCRYPTION_KEY=${ENCRYPTION_KEY} # 必填:32字符的加密密钥
|
||||
- ADMIN_SESSION_TIMEOUT=${ADMIN_SESSION_TIMEOUT:-86400000}
|
||||
- API_KEY_PREFIX=${API_KEY_PREFIX:-cr_}
|
||||
|
||||
# 👤 管理员凭据(可选)
|
||||
- ADMIN_USERNAME=${ADMIN_USERNAME:-}
|
||||
- ADMIN_PASSWORD=${ADMIN_PASSWORD:-}
|
||||
|
||||
# 📊 Redis 配置
|
||||
- REDIS_HOST=redis
|
||||
- REDIS_PORT=6379
|
||||
- ADMIN_USERNAME=${ADMIN_USERNAME:-} # 可选:预设管理员用户名
|
||||
- ADMIN_PASSWORD=${ADMIN_PASSWORD:-} # 可选:预设管理员密码
|
||||
- REDIS_PASSWORD=${REDIS_PASSWORD:-}
|
||||
- REDIS_DB=${REDIS_DB:-0}
|
||||
- REDIS_ENABLE_TLS=${REDIS_ENABLE_TLS:-}
|
||||
|
||||
# 🎯 Claude API 配置
|
||||
- CLAUDE_API_URL=${CLAUDE_API_URL:-https://api.anthropic.com/v1/messages}
|
||||
- CLAUDE_API_VERSION=${CLAUDE_API_VERSION:-2023-06-01}
|
||||
- CLAUDE_BETA_HEADER=${CLAUDE_BETA_HEADER:-claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14}
|
||||
|
||||
# 🌐 代理配置
|
||||
- DEFAULT_PROXY_TIMEOUT=${DEFAULT_PROXY_TIMEOUT:-60000}
|
||||
- MAX_PROXY_RETRIES=${MAX_PROXY_RETRIES:-3}
|
||||
|
||||
# 📈 使用限制
|
||||
- DEFAULT_TOKEN_LIMIT=${DEFAULT_TOKEN_LIMIT:-1000000}
|
||||
|
||||
# 📝 日志配置
|
||||
- LOG_LEVEL=${LOG_LEVEL:-info}
|
||||
- LOG_MAX_SIZE=${LOG_MAX_SIZE:-10m}
|
||||
- LOG_MAX_FILES=${LOG_MAX_FILES:-5}
|
||||
|
||||
# 🔧 系统配置
|
||||
- CLEANUP_INTERVAL=${CLEANUP_INTERVAL:-3600000}
|
||||
- TOKEN_USAGE_RETENTION=${TOKEN_USAGE_RETENTION:-2592000000}
|
||||
- HEALTH_CHECK_INTERVAL=${HEALTH_CHECK_INTERVAL:-60000}
|
||||
- SYSTEM_TIMEZONE=${SYSTEM_TIMEZONE:-Asia/Shanghai}
|
||||
- TIMEZONE_OFFSET=${TIMEZONE_OFFSET:-8}
|
||||
|
||||
# 🎨 Web 界面配置
|
||||
- WEB_TITLE=${WEB_TITLE:-Claude Relay Service}
|
||||
- WEB_DESCRIPTION=${WEB_DESCRIPTION:-Multi-account Claude API relay service}
|
||||
- WEB_LOGO_URL=${WEB_LOGO_URL:-/assets/logo.png}
|
||||
|
||||
# 🛠️ 开发配置
|
||||
- DEBUG=${DEBUG:-false}
|
||||
- ENABLE_CORS=${ENABLE_CORS:-true}
|
||||
- TRUST_PROXY=${TRUST_PROXY:-true}
|
||||
volumes:
|
||||
- ./logs:/app/logs
|
||||
- ./data:/app/data
|
||||
@@ -31,7 +83,6 @@ services:
|
||||
# 📊 Redis Database
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: claude-relay-redis
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${REDIS_PORT:-6379}:6379"
|
||||
@@ -49,7 +100,6 @@ services:
|
||||
# 📈 Redis Monitoring (Optional)
|
||||
redis-commander:
|
||||
image: rediscommander/redis-commander:latest
|
||||
container_name: claude-relay-redis-web
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${REDIS_WEB_PORT:-8081}:8081"
|
||||
@@ -65,7 +115,6 @@ services:
|
||||
# 📊 Application Monitoring (Optional)
|
||||
prometheus:
|
||||
image: prom/prometheus:latest
|
||||
container_name: claude-relay-prometheus
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${PROMETHEUS_PORT:-9090}:9090"
|
||||
@@ -86,7 +135,6 @@ services:
|
||||
# 📈 Grafana Dashboard (Optional)
|
||||
grafana:
|
||||
image: grafana/grafana:latest
|
||||
container_name: claude-relay-grafana
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${GRAFANA_PORT:-3001}:3000"
|
||||
@@ -111,6 +159,3 @@ volumes:
|
||||
networks:
|
||||
claude-relay-network:
|
||||
driver: bridge
|
||||
ipam:
|
||||
config:
|
||||
- subnet: 172.20.0.0/16
|
||||
@@ -3,12 +3,20 @@ set -e
|
||||
|
||||
echo "🚀 Claude Relay Service 启动中..."
|
||||
|
||||
# 生成随机字符串的函数
|
||||
generate_random_string() {
|
||||
length=$1
|
||||
# 使用 /dev/urandom 生成随机字符串
|
||||
tr -dc 'a-zA-Z0-9' < /dev/urandom | head -c $length
|
||||
}
|
||||
# 检查关键环境变量
|
||||
if [ -z "$JWT_SECRET" ]; then
|
||||
echo "❌ 错误: JWT_SECRET 环境变量未设置"
|
||||
echo " 请在 docker-compose.yml 中设置 JWT_SECRET"
|
||||
echo " 例如: JWT_SECRET=your-random-secret-key-at-least-32-chars"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -z "$ENCRYPTION_KEY" ]; then
|
||||
echo "❌ 错误: ENCRYPTION_KEY 环境变量未设置"
|
||||
echo " 请在 docker-compose.yml 中设置 ENCRYPTION_KEY"
|
||||
echo " 例如: ENCRYPTION_KEY=your-32-character-encryption-key"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 检查并复制配置文件
|
||||
if [ ! -f "/app/config/config.js" ]; then
|
||||
@@ -22,48 +30,17 @@ if [ ! -f "/app/config/config.js" ]; then
|
||||
fi
|
||||
fi
|
||||
|
||||
# 检查并配置 .env 文件(文件已在构建时创建)
|
||||
if [ -f "/app/.env" ]; then
|
||||
echo "📋 配置 .env 文件..."
|
||||
|
||||
# 生成随机的 JWT_SECRET (64字符)
|
||||
if [ -z "$JWT_SECRET" ]; then
|
||||
JWT_SECRET=$(grep "^JWT_SECRET=" /app/.env | cut -d'=' -f2)
|
||||
if [ -z "$JWT_SECRET" ] || [ "$JWT_SECRET" = "your-jwt-secret-here" ]; then
|
||||
JWT_SECRET=$(generate_random_string 64)
|
||||
echo "🔑 生成 JWT_SECRET"
|
||||
fi
|
||||
fi
|
||||
|
||||
# 生成随机的 ENCRYPTION_KEY (32字符)
|
||||
if [ -z "$ENCRYPTION_KEY" ]; then
|
||||
ENCRYPTION_KEY=$(grep "^ENCRYPTION_KEY=" /app/.env | cut -d'=' -f2)
|
||||
if [ -z "$ENCRYPTION_KEY" ] || [ "$ENCRYPTION_KEY" = "your-encryption-key-here" ]; then
|
||||
ENCRYPTION_KEY=$(generate_random_string 32)
|
||||
echo "🔑 生成 ENCRYPTION_KEY"
|
||||
fi
|
||||
fi
|
||||
|
||||
# 直接使用sed修改.env文件 - root用户无权限问题
|
||||
sed -i "s/JWT_SECRET=.*/JWT_SECRET=${JWT_SECRET}/" /app/.env
|
||||
sed -i "s/ENCRYPTION_KEY=.*/ENCRYPTION_KEY=${ENCRYPTION_KEY}/" /app/.env
|
||||
sed -i "s/REDIS_HOST=.*/REDIS_HOST=redis/" /app/.env
|
||||
|
||||
echo "✅ .env 已配置"
|
||||
else
|
||||
echo "❌ 错误: .env 文件不存在"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 导出环境变量
|
||||
export JWT_SECRET
|
||||
export ENCRYPTION_KEY
|
||||
# 显示配置信息
|
||||
echo "✅ 环境配置已就绪"
|
||||
echo " JWT_SECRET: [已设置]"
|
||||
echo " ENCRYPTION_KEY: [已设置]"
|
||||
echo " REDIS_HOST: ${REDIS_HOST:-localhost}"
|
||||
echo " PORT: ${PORT:-3000}"
|
||||
|
||||
# 检查是否需要初始化
|
||||
if [ ! -f "/app/data/init.json" ]; then
|
||||
echo "📋 首次启动,执行初始化设置..."
|
||||
|
||||
|
||||
# 如果设置了环境变量,显示提示
|
||||
if [ -n "$ADMIN_USERNAME" ] || [ -n "$ADMIN_PASSWORD" ]; then
|
||||
echo "📌 检测到预设的管理员凭据"
|
||||
|
||||
205
docs/UPGRADE_GUIDE.md
Normal file
205
docs/UPGRADE_GUIDE.md
Normal 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`
|
||||
|
||||
如需进一步帮助,请提供:
|
||||
- 错误日志
|
||||
- 使用的命令
|
||||
- 系统环境信息
|
||||
187
docs/api-key-expiry-guide.md
Normal file
187
docs/api-key-expiry-guide.md
Normal 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
|
||||
- 修改过期时间立即生效
|
||||
- 清理任务每小时运行一次
|
||||
177
docs/data-encryption-handling.md
Normal file
177
docs/data-encryption-handling.md
Normal 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
131
package-lock.json
generated
@@ -20,7 +20,7 @@
|
||||
"google-auth-library": "^10.1.0",
|
||||
"helmet": "^7.1.0",
|
||||
"https-proxy-agent": "^7.0.2",
|
||||
"inquirer": "^9.2.15",
|
||||
"inquirer": "^8.2.6",
|
||||
"ioredis": "^5.3.2",
|
||||
"morgan": "^1.10.0",
|
||||
"ora": "^5.4.1",
|
||||
@@ -773,15 +773,6 @@
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@inquirer/figures": {
|
||||
"version": "1.0.12",
|
||||
"resolved": "https://registry.npmmirror.com/@inquirer/figures/-/figures-1.0.12.tgz",
|
||||
"integrity": "sha512-MJttijd8rMFcKJC8NYmprWr6hD3r9Gd9qUC0XwPNwoEPWSMVJwA2MlXxF+nhZZNMY+HXsWa+o7KY2emWYIn0jQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@ioredis/commands": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmmirror.com/@ioredis/commands/-/commands-1.2.0.tgz",
|
||||
@@ -2097,7 +2088,7 @@
|
||||
},
|
||||
"node_modules/chardet": {
|
||||
"version": "0.7.0",
|
||||
"resolved": "https://registry.npmmirror.com/chardet/-/chardet-0.7.0.tgz",
|
||||
"resolved": "https://registry.npmjs.org/chardet/-/chardet-0.7.0.tgz",
|
||||
"integrity": "sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
@@ -2187,12 +2178,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/cli-width": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmmirror.com/cli-width/-/cli-width-4.1.0.tgz",
|
||||
"integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==",
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cli-width/-/cli-width-3.0.0.tgz",
|
||||
"integrity": "sha512-FxqpkPPwu1HjuN93Omfm4h8uIanXofW0RxVEW3k5RKx+mJJYSthzNhp32Kzxxy3YAEZ/Dc/EWN1vZRY0+kOhbw==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/cliui": {
|
||||
@@ -3107,7 +3098,7 @@
|
||||
},
|
||||
"node_modules/external-editor": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmmirror.com/external-editor/-/external-editor-3.1.0.tgz",
|
||||
"resolved": "https://registry.npmjs.org/external-editor/-/external-editor-3.1.0.tgz",
|
||||
"integrity": "sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -3211,6 +3202,30 @@
|
||||
"node": "^12.20 || >= 14.13"
|
||||
}
|
||||
},
|
||||
"node_modules/figures": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/figures/-/figures-3.2.0.tgz",
|
||||
"integrity": "sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"escape-string-regexp": "^1.0.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/figures/node_modules/escape-string-regexp": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz",
|
||||
"integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/file-entry-cache": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/file-entry-cache/-/file-entry-cache-6.0.1.tgz",
|
||||
@@ -3888,26 +3903,29 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/inquirer": {
|
||||
"version": "9.3.7",
|
||||
"resolved": "https://registry.npmmirror.com/inquirer/-/inquirer-9.3.7.tgz",
|
||||
"integrity": "sha512-LJKFHCSeIRq9hanN14IlOtPSTe3lNES7TYDTE2xxdAy1LS5rYphajK1qtwvj3YmQXvvk0U2Vbmcni8P9EIQW9w==",
|
||||
"version": "8.2.6",
|
||||
"resolved": "https://registry.npmjs.org/inquirer/-/inquirer-8.2.6.tgz",
|
||||
"integrity": "sha512-M1WuAmb7pn9zdFRtQYk26ZBoY043Sse0wVDdk4Bppr+JOXyQYybdtvK+l9wUibhtjdjvtoiNy8tk+EgsYIUqKg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@inquirer/figures": "^1.0.3",
|
||||
"ansi-escapes": "^4.3.2",
|
||||
"cli-width": "^4.1.0",
|
||||
"external-editor": "^3.1.0",
|
||||
"mute-stream": "1.0.0",
|
||||
"ansi-escapes": "^4.2.1",
|
||||
"chalk": "^4.1.1",
|
||||
"cli-cursor": "^3.1.0",
|
||||
"cli-width": "^3.0.0",
|
||||
"external-editor": "^3.0.3",
|
||||
"figures": "^3.0.0",
|
||||
"lodash": "^4.17.21",
|
||||
"mute-stream": "0.0.8",
|
||||
"ora": "^5.4.1",
|
||||
"run-async": "^3.0.0",
|
||||
"rxjs": "^7.8.1",
|
||||
"string-width": "^4.2.3",
|
||||
"strip-ansi": "^6.0.1",
|
||||
"wrap-ansi": "^6.2.0",
|
||||
"yoctocolors-cjs": "^2.1.2"
|
||||
"run-async": "^2.4.0",
|
||||
"rxjs": "^7.5.5",
|
||||
"string-width": "^4.1.0",
|
||||
"strip-ansi": "^6.0.0",
|
||||
"through": "^2.3.6",
|
||||
"wrap-ansi": "^6.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ioredis": {
|
||||
@@ -5005,6 +5023,12 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/lodash": {
|
||||
"version": "4.17.21",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
|
||||
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.defaults": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmmirror.com/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
|
||||
@@ -5283,13 +5307,10 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/mute-stream": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/mute-stream/-/mute-stream-1.0.0.tgz",
|
||||
"integrity": "sha512-avsJQhyd+680gKXyG/sQc0nXaC6rBkPOfyHYcFb9+hdkqQkR9bdnkJ0AMZhke0oesPqIO+mFFJ+IdBc7mst4IA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": "^14.17.0 || ^16.13.0 || >=18.0.0"
|
||||
}
|
||||
"version": "0.0.8",
|
||||
"resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz",
|
||||
"integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/natural-compare": {
|
||||
"version": "1.4.0",
|
||||
@@ -5600,7 +5621,7 @@
|
||||
},
|
||||
"node_modules/os-tmpdir": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmmirror.com/os-tmpdir/-/os-tmpdir-1.0.2.tgz",
|
||||
"resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz",
|
||||
"integrity": "sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -6169,9 +6190,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/run-async": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/run-async/-/run-async-3.0.0.tgz",
|
||||
"integrity": "sha512-540WwVDOMxA6dN6We19EcT9sc3hkXPw5mzRNGM3FkdN/vtE9NFvj5lFAPNwUDmJjXidm3v7TC1cTE7t17Ulm1Q==",
|
||||
"version": "2.4.1",
|
||||
"resolved": "https://registry.npmjs.org/run-async/-/run-async-2.4.1.tgz",
|
||||
"integrity": "sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.12.0"
|
||||
@@ -6203,7 +6224,7 @@
|
||||
},
|
||||
"node_modules/rxjs": {
|
||||
"version": "7.8.2",
|
||||
"resolved": "https://registry.npmmirror.com/rxjs/-/rxjs-7.8.2.tgz",
|
||||
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
|
||||
"integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
@@ -6894,9 +6915,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/through": {
|
||||
"version": "2.3.8",
|
||||
"resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz",
|
||||
"integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tmp": {
|
||||
"version": "0.0.33",
|
||||
"resolved": "https://registry.npmmirror.com/tmp/-/tmp-0.0.33.tgz",
|
||||
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz",
|
||||
"integrity": "sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -6956,7 +6983,7 @@
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
@@ -7263,7 +7290,7 @@
|
||||
},
|
||||
"node_modules/wrap-ansi": {
|
||||
"version": "6.2.0",
|
||||
"resolved": "https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
|
||||
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -7354,18 +7381,6 @@
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/yoctocolors-cjs": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmmirror.com/yoctocolors-cjs/-/yoctocolors-cjs-2.1.2.tgz",
|
||||
"integrity": "sha512-cYVsTjKl8b+FrnidjibDWskAv7UKOfcwaVZdp/it9n1s9fU3IkgDbhdIRKCW4JDsAlECJY0ytoVPT3sK6kideA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
16
package.json
16
package.json
@@ -10,6 +10,7 @@
|
||||
"install:web": "cd web && npm install",
|
||||
"setup": "node scripts/setup.js",
|
||||
"cli": "node cli/index.js",
|
||||
"init:costs": "node src/cli/initCosts.js",
|
||||
"service": "node scripts/manage.js",
|
||||
"service:start": "node scripts/manage.js start",
|
||||
"service:start:daemon": "node scripts/manage.js start -d",
|
||||
@@ -18,6 +19,7 @@
|
||||
"service:stop": "node scripts/manage.js stop",
|
||||
"service:restart": "node scripts/manage.js restart",
|
||||
"service:restart:daemon": "node scripts/manage.js restart -d",
|
||||
"service:logs:follow": "node scripts/manage.js logs -f",
|
||||
"service:restart:d": "node scripts/manage.js restart -d",
|
||||
"service:status": "node scripts/manage.js status",
|
||||
"service:logs": "node scripts/manage.js logs",
|
||||
@@ -25,7 +27,17 @@
|
||||
"lint": "eslint src/**/*.js",
|
||||
"docker:build": "docker build -t claude-relay-service .",
|
||||
"docker:up": "docker-compose up -d",
|
||||
"docker:down": "docker-compose down"
|
||||
"docker:down": "docker-compose down",
|
||||
"migrate:apikey-expiry": "node scripts/migrate-apikey-expiry.js",
|
||||
"migrate:apikey-expiry:dry": "node scripts/migrate-apikey-expiry.js --dry-run",
|
||||
"migrate:fix-usage-stats": "node scripts/fix-usage-stats.js",
|
||||
"data:export": "node scripts/data-transfer.js export",
|
||||
"data:import": "node scripts/data-transfer.js import",
|
||||
"data:export:sanitized": "node scripts/data-transfer.js export --sanitize",
|
||||
"data:export:enhanced": "node scripts/data-transfer-enhanced.js export",
|
||||
"data:export:encrypted": "node scripts/data-transfer-enhanced.js export --decrypt=false",
|
||||
"data:import:enhanced": "node scripts/data-transfer-enhanced.js import",
|
||||
"data:debug": "node scripts/debug-redis-keys.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.6.0",
|
||||
@@ -39,7 +51,7 @@
|
||||
"google-auth-library": "^10.1.0",
|
||||
"helmet": "^7.1.0",
|
||||
"https-proxy-agent": "^7.0.2",
|
||||
"inquirer": "^9.2.15",
|
||||
"inquirer": "^8.2.6",
|
||||
"ioredis": "^5.3.2",
|
||||
"morgan": "^1.10.0",
|
||||
"ora": "^5.4.1",
|
||||
|
||||
994
scripts/data-transfer-enhanced.js
Normal file
994
scripts/data-transfer-enhanced.js
Normal 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
517
scripts/data-transfer.js
Normal 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
123
scripts/debug-redis-keys.js
Normal 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
32
scripts/fix-inquirer.js
Normal 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
227
scripts/fix-usage-stats.js
Normal 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
284
scripts/generate-test-data.js
Executable 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);
|
||||
});
|
||||
191
scripts/migrate-apikey-expiry.js
Normal file
191
scripts/migrate-apikey-expiry.js
Normal 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);
|
||||
});
|
||||
159
scripts/test-apikey-expiry.js
Normal file
159
scripts/test-apikey-expiry.js
Normal 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);
|
||||
});
|
||||
181
scripts/test-import-encryption.js
Normal file
181
scripts/test-import-encryption.js
Normal 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);
|
||||
});
|
||||
16
src/app.js
16
src/app.js
@@ -16,6 +16,7 @@ const pricingService = require('./services/pricingService');
|
||||
const apiRoutes = require('./routes/api');
|
||||
const adminRoutes = require('./routes/admin');
|
||||
const webRoutes = require('./routes/web');
|
||||
const apiStatsRoutes = require('./routes/apiStats');
|
||||
const geminiRoutes = require('./routes/geminiRoutes');
|
||||
const openaiGeminiRoutes = require('./routes/openaiGeminiRoutes');
|
||||
const openaiClaudeRoutes = require('./routes/openaiClaudeRoutes');
|
||||
@@ -51,6 +52,16 @@ class Application {
|
||||
logger.info('🔄 Initializing admin credentials...');
|
||||
await this.initializeAdmin();
|
||||
|
||||
// 💰 初始化费用数据
|
||||
logger.info('💰 Checking cost data initialization...');
|
||||
const costInitService = require('./services/costInitService');
|
||||
const needsInit = await costInitService.needsInitialization();
|
||||
if (needsInit) {
|
||||
logger.info('💰 Initializing cost data for all API Keys...');
|
||||
const result = await costInitService.initializeAllCosts();
|
||||
logger.info(`💰 Cost initialization completed: ${result.processed} processed, ${result.errors} errors`);
|
||||
}
|
||||
|
||||
// 🛡️ 安全中间件
|
||||
this.app.use(helmet({
|
||||
contentSecurityPolicy: false, // 允许内联样式和脚本
|
||||
@@ -110,13 +121,14 @@ class Application {
|
||||
this.app.use('/claude', apiRoutes); // /claude 路由别名,与 /api 功能相同
|
||||
this.app.use('/admin', adminRoutes);
|
||||
this.app.use('/web', webRoutes);
|
||||
this.app.use('/apiStats', apiStatsRoutes);
|
||||
this.app.use('/gemini', geminiRoutes);
|
||||
this.app.use('/openai/gemini', openaiGeminiRoutes);
|
||||
this.app.use('/openai/claude', openaiClaudeRoutes);
|
||||
|
||||
// 🏠 根路径重定向到管理界面
|
||||
// 🏠 根路径重定向到API统计页面
|
||||
this.app.get('/', (req, res) => {
|
||||
res.redirect('/web');
|
||||
res.redirect('/apiStats');
|
||||
});
|
||||
|
||||
// 🏥 增强的健康检查端点
|
||||
|
||||
32
src/cli/initCosts.js
Normal file
32
src/cli/initCosts.js
Normal 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();
|
||||
@@ -2,6 +2,7 @@ const apiKeyService = require('../services/apiKeyService');
|
||||
const logger = require('../utils/logger');
|
||||
const redis = require('../models/redis');
|
||||
const { RateLimiterRedis } = require('rate-limiter-flexible');
|
||||
const config = require('../../config/config');
|
||||
|
||||
// 🔑 API Key验证中间件(优化版)
|
||||
const authenticateApiKey = async (req, res, next) => {
|
||||
@@ -42,6 +43,52 @@ const authenticateApiKey = async (req, res, next) => {
|
||||
});
|
||||
}
|
||||
|
||||
// 🔒 检查客户端限制
|
||||
if (validation.keyData.enableClientRestriction && validation.keyData.allowedClients?.length > 0) {
|
||||
const userAgent = req.headers['user-agent'] || '';
|
||||
const clientIP = req.ip || req.connection?.remoteAddress || 'unknown';
|
||||
|
||||
// 记录客户端限制检查开始
|
||||
logger.api(`🔍 Checking client restriction for key: ${validation.keyData.id} (${validation.keyData.name})`);
|
||||
logger.api(` User-Agent: "${userAgent}"`);
|
||||
logger.api(` Allowed clients: ${validation.keyData.allowedClients.join(', ')}`);
|
||||
|
||||
let clientAllowed = false;
|
||||
let matchedClient = null;
|
||||
|
||||
// 遍历允许的客户端列表
|
||||
for (const allowedClientId of validation.keyData.allowedClients) {
|
||||
// 在预定义客户端列表中查找
|
||||
const predefinedClient = config.clientRestrictions.predefinedClients.find(
|
||||
client => client.id === allowedClientId
|
||||
);
|
||||
|
||||
if (predefinedClient) {
|
||||
// 使用预定义的正则表达式匹配 User-Agent
|
||||
if (predefinedClient.userAgentPattern.test(userAgent)) {
|
||||
clientAllowed = true;
|
||||
matchedClient = predefinedClient.name;
|
||||
break;
|
||||
}
|
||||
} else if (config.clientRestrictions.allowCustomClients) {
|
||||
// 如果允许自定义客户端,这里可以添加自定义客户端的验证逻辑
|
||||
// 目前暂时跳过自定义客户端
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (!clientAllowed) {
|
||||
logger.security(`🚫 Client restriction failed for key: ${validation.keyData.id} (${validation.keyData.name}) from ${clientIP}, User-Agent: ${userAgent}`);
|
||||
return res.status(403).json({
|
||||
error: 'Client not allowed',
|
||||
message: 'Your client is not authorized to use this API key',
|
||||
allowedClients: validation.keyData.allowedClients
|
||||
});
|
||||
}
|
||||
|
||||
logger.api(`✅ Client validated: ${matchedClient} for key: ${validation.keyData.id} (${validation.keyData.name})`);
|
||||
logger.api(` Matched client: ${matchedClient} with User-Agent: "${userAgent}"`);
|
||||
}
|
||||
|
||||
// 检查并发限制
|
||||
const concurrencyLimit = validation.keyData.concurrencyLimit || 0;
|
||||
@@ -192,6 +239,27 @@ const authenticateApiKey = async (req, res, next) => {
|
||||
};
|
||||
}
|
||||
|
||||
// 检查每日费用限制
|
||||
const dailyCostLimit = validation.keyData.dailyCostLimit || 0;
|
||||
if (dailyCostLimit > 0) {
|
||||
const dailyCost = validation.keyData.dailyCost || 0;
|
||||
|
||||
if (dailyCost >= dailyCostLimit) {
|
||||
logger.security(`💰 Daily cost limit exceeded for key: ${validation.keyData.id} (${validation.keyData.name}), cost: $${dailyCost.toFixed(2)}/$${dailyCostLimit}`);
|
||||
|
||||
return res.status(429).json({
|
||||
error: 'Daily cost limit exceeded',
|
||||
message: `已达到每日费用限制 ($${dailyCostLimit})`,
|
||||
currentCost: dailyCost,
|
||||
costLimit: dailyCostLimit,
|
||||
resetAt: new Date(new Date().setHours(24, 0, 0, 0)).toISOString() // 明天0点重置
|
||||
});
|
||||
}
|
||||
|
||||
// 记录当前费用使用情况
|
||||
logger.api(`💰 Cost usage for key: ${validation.keyData.id} (${validation.keyData.name}), current: $${dailyCost.toFixed(2)}/$${dailyCostLimit}`);
|
||||
}
|
||||
|
||||
// 将验证信息添加到请求对象(只包含必要信息)
|
||||
req.apiKey = {
|
||||
id: validation.keyData.id,
|
||||
@@ -205,12 +273,18 @@ const authenticateApiKey = async (req, res, next) => {
|
||||
rateLimitRequests: validation.keyData.rateLimitRequests,
|
||||
enableModelRestriction: validation.keyData.enableModelRestriction,
|
||||
restrictedModels: validation.keyData.restrictedModels,
|
||||
enableClientRestriction: validation.keyData.enableClientRestriction,
|
||||
allowedClients: validation.keyData.allowedClients,
|
||||
dailyCostLimit: validation.keyData.dailyCostLimit,
|
||||
dailyCost: validation.keyData.dailyCost,
|
||||
usage: validation.keyData.usage
|
||||
};
|
||||
req.usage = validation.keyData.usage;
|
||||
|
||||
const authDuration = Date.now() - startTime;
|
||||
const userAgent = req.headers['user-agent'] || 'No User-Agent';
|
||||
logger.api(`🔓 Authenticated request from key: ${validation.keyData.name} (${validation.keyData.id}) in ${authDuration}ms`);
|
||||
logger.api(` User-Agent: "${userAgent}"`);
|
||||
|
||||
next();
|
||||
} catch (error) {
|
||||
|
||||
@@ -282,6 +282,104 @@ class RedisClient {
|
||||
]);
|
||||
}
|
||||
|
||||
// 📊 记录账户级别的使用统计
|
||||
async incrementAccountUsage(accountId, totalTokens, inputTokens = 0, outputTokens = 0, cacheCreateTokens = 0, cacheReadTokens = 0, model = 'unknown') {
|
||||
const now = new Date();
|
||||
const today = getDateStringInTimezone(now);
|
||||
const tzDate = getDateInTimezone(now);
|
||||
const currentMonth = `${tzDate.getFullYear()}-${String(tzDate.getMonth() + 1).padStart(2, '0')}`;
|
||||
const currentHour = `${today}:${String(getHourInTimezone(now)).padStart(2, '0')}`;
|
||||
|
||||
// 账户级别统计的键
|
||||
const accountKey = `account_usage:${accountId}`;
|
||||
const accountDaily = `account_usage:daily:${accountId}:${today}`;
|
||||
const accountMonthly = `account_usage:monthly:${accountId}:${currentMonth}`;
|
||||
const accountHourly = `account_usage:hourly:${accountId}:${currentHour}`;
|
||||
|
||||
// 账户按模型统计的键
|
||||
const accountModelDaily = `account_usage:model:daily:${accountId}:${model}:${today}`;
|
||||
const accountModelMonthly = `account_usage:model:monthly:${accountId}:${model}:${currentMonth}`;
|
||||
const accountModelHourly = `account_usage:model:hourly:${accountId}:${model}:${currentHour}`;
|
||||
|
||||
// 处理token分配
|
||||
const finalInputTokens = inputTokens || 0;
|
||||
const finalOutputTokens = outputTokens || 0;
|
||||
const finalCacheCreateTokens = cacheCreateTokens || 0;
|
||||
const finalCacheReadTokens = cacheReadTokens || 0;
|
||||
const actualTotalTokens = finalInputTokens + finalOutputTokens + finalCacheCreateTokens + finalCacheReadTokens;
|
||||
const coreTokens = finalInputTokens + finalOutputTokens;
|
||||
|
||||
await Promise.all([
|
||||
// 账户总体统计
|
||||
this.client.hincrby(accountKey, 'totalTokens', coreTokens),
|
||||
this.client.hincrby(accountKey, 'totalInputTokens', finalInputTokens),
|
||||
this.client.hincrby(accountKey, 'totalOutputTokens', finalOutputTokens),
|
||||
this.client.hincrby(accountKey, 'totalCacheCreateTokens', finalCacheCreateTokens),
|
||||
this.client.hincrby(accountKey, 'totalCacheReadTokens', finalCacheReadTokens),
|
||||
this.client.hincrby(accountKey, 'totalAllTokens', actualTotalTokens),
|
||||
this.client.hincrby(accountKey, 'totalRequests', 1),
|
||||
|
||||
// 账户每日统计
|
||||
this.client.hincrby(accountDaily, 'tokens', coreTokens),
|
||||
this.client.hincrby(accountDaily, 'inputTokens', finalInputTokens),
|
||||
this.client.hincrby(accountDaily, 'outputTokens', finalOutputTokens),
|
||||
this.client.hincrby(accountDaily, 'cacheCreateTokens', finalCacheCreateTokens),
|
||||
this.client.hincrby(accountDaily, 'cacheReadTokens', finalCacheReadTokens),
|
||||
this.client.hincrby(accountDaily, 'allTokens', actualTotalTokens),
|
||||
this.client.hincrby(accountDaily, 'requests', 1),
|
||||
|
||||
// 账户每月统计
|
||||
this.client.hincrby(accountMonthly, 'tokens', coreTokens),
|
||||
this.client.hincrby(accountMonthly, 'inputTokens', finalInputTokens),
|
||||
this.client.hincrby(accountMonthly, 'outputTokens', finalOutputTokens),
|
||||
this.client.hincrby(accountMonthly, 'cacheCreateTokens', finalCacheCreateTokens),
|
||||
this.client.hincrby(accountMonthly, 'cacheReadTokens', finalCacheReadTokens),
|
||||
this.client.hincrby(accountMonthly, 'allTokens', actualTotalTokens),
|
||||
this.client.hincrby(accountMonthly, 'requests', 1),
|
||||
|
||||
// 账户每小时统计
|
||||
this.client.hincrby(accountHourly, 'tokens', coreTokens),
|
||||
this.client.hincrby(accountHourly, 'inputTokens', finalInputTokens),
|
||||
this.client.hincrby(accountHourly, 'outputTokens', finalOutputTokens),
|
||||
this.client.hincrby(accountHourly, 'cacheCreateTokens', finalCacheCreateTokens),
|
||||
this.client.hincrby(accountHourly, 'cacheReadTokens', finalCacheReadTokens),
|
||||
this.client.hincrby(accountHourly, 'allTokens', actualTotalTokens),
|
||||
this.client.hincrby(accountHourly, 'requests', 1),
|
||||
|
||||
// 账户按模型统计 - 每日
|
||||
this.client.hincrby(accountModelDaily, 'inputTokens', finalInputTokens),
|
||||
this.client.hincrby(accountModelDaily, 'outputTokens', finalOutputTokens),
|
||||
this.client.hincrby(accountModelDaily, 'cacheCreateTokens', finalCacheCreateTokens),
|
||||
this.client.hincrby(accountModelDaily, 'cacheReadTokens', finalCacheReadTokens),
|
||||
this.client.hincrby(accountModelDaily, 'allTokens', actualTotalTokens),
|
||||
this.client.hincrby(accountModelDaily, 'requests', 1),
|
||||
|
||||
// 账户按模型统计 - 每月
|
||||
this.client.hincrby(accountModelMonthly, 'inputTokens', finalInputTokens),
|
||||
this.client.hincrby(accountModelMonthly, 'outputTokens', finalOutputTokens),
|
||||
this.client.hincrby(accountModelMonthly, 'cacheCreateTokens', finalCacheCreateTokens),
|
||||
this.client.hincrby(accountModelMonthly, 'cacheReadTokens', finalCacheReadTokens),
|
||||
this.client.hincrby(accountModelMonthly, 'allTokens', actualTotalTokens),
|
||||
this.client.hincrby(accountModelMonthly, 'requests', 1),
|
||||
|
||||
// 账户按模型统计 - 每小时
|
||||
this.client.hincrby(accountModelHourly, 'inputTokens', finalInputTokens),
|
||||
this.client.hincrby(accountModelHourly, 'outputTokens', finalOutputTokens),
|
||||
this.client.hincrby(accountModelHourly, 'cacheCreateTokens', finalCacheCreateTokens),
|
||||
this.client.hincrby(accountModelHourly, 'cacheReadTokens', finalCacheReadTokens),
|
||||
this.client.hincrby(accountModelHourly, 'allTokens', actualTotalTokens),
|
||||
this.client.hincrby(accountModelHourly, 'requests', 1),
|
||||
|
||||
// 设置过期时间
|
||||
this.client.expire(accountDaily, 86400 * 32), // 32天过期
|
||||
this.client.expire(accountMonthly, 86400 * 365), // 1年过期
|
||||
this.client.expire(accountHourly, 86400 * 7), // 7天过期
|
||||
this.client.expire(accountModelDaily, 86400 * 32), // 32天过期
|
||||
this.client.expire(accountModelMonthly, 86400 * 365), // 1年过期
|
||||
this.client.expire(accountModelHourly, 86400 * 7) // 7天过期
|
||||
]);
|
||||
}
|
||||
|
||||
async getUsageStats(keyId) {
|
||||
const totalKey = `usage:${keyId}`;
|
||||
const today = getDateStringInTimezone();
|
||||
@@ -324,11 +422,13 @@ class RedisClient {
|
||||
const allTokens = parseInt(data.totalAllTokens) || parseInt(data.allTokens) || 0;
|
||||
|
||||
const totalFromSeparate = inputTokens + outputTokens;
|
||||
// 计算实际的总tokens(包含所有类型)
|
||||
const actualAllTokens = allTokens || (inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens);
|
||||
|
||||
if (totalFromSeparate === 0 && tokens > 0) {
|
||||
// 旧数据:没有输入输出分离
|
||||
return {
|
||||
tokens,
|
||||
tokens: tokens, // 保持兼容性,但统一使用allTokens
|
||||
inputTokens: Math.round(tokens * 0.3), // 假设30%为输入
|
||||
outputTokens: Math.round(tokens * 0.7), // 假设70%为输出
|
||||
cacheCreateTokens: 0, // 旧数据没有缓存token
|
||||
@@ -337,14 +437,14 @@ class RedisClient {
|
||||
requests
|
||||
};
|
||||
} else {
|
||||
// 新数据或无数据
|
||||
// 新数据或无数据 - 统一使用allTokens作为tokens的值
|
||||
return {
|
||||
tokens,
|
||||
tokens: actualAllTokens, // 统一使用allTokens作为总数
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
cacheCreateTokens,
|
||||
cacheReadTokens,
|
||||
allTokens: allTokens || (inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens), // 计算或使用存储的值
|
||||
allTokens: actualAllTokens,
|
||||
requests
|
||||
};
|
||||
}
|
||||
@@ -367,6 +467,170 @@ class RedisClient {
|
||||
};
|
||||
}
|
||||
|
||||
// 💰 获取当日费用
|
||||
async getDailyCost(keyId) {
|
||||
const today = getDateStringInTimezone();
|
||||
const costKey = `usage:cost:daily:${keyId}:${today}`;
|
||||
const cost = await this.client.get(costKey);
|
||||
const result = parseFloat(cost || 0);
|
||||
logger.debug(`💰 Getting daily cost for ${keyId}, date: ${today}, key: ${costKey}, value: ${cost}, result: ${result}`);
|
||||
return result;
|
||||
}
|
||||
|
||||
// 💰 增加当日费用
|
||||
async incrementDailyCost(keyId, amount) {
|
||||
const today = getDateStringInTimezone();
|
||||
const tzDate = getDateInTimezone();
|
||||
const currentMonth = `${tzDate.getFullYear()}-${String(tzDate.getMonth() + 1).padStart(2, '0')}`;
|
||||
const currentHour = `${today}:${String(getHourInTimezone()).padStart(2, '0')}`;
|
||||
|
||||
const dailyKey = `usage:cost:daily:${keyId}:${today}`;
|
||||
const monthlyKey = `usage:cost:monthly:${keyId}:${currentMonth}`;
|
||||
const hourlyKey = `usage:cost:hourly:${keyId}:${currentHour}`;
|
||||
const totalKey = `usage:cost:total:${keyId}`;
|
||||
|
||||
logger.debug(`💰 Incrementing cost for ${keyId}, amount: $${amount}, date: ${today}, dailyKey: ${dailyKey}`);
|
||||
|
||||
const results = await Promise.all([
|
||||
this.client.incrbyfloat(dailyKey, amount),
|
||||
this.client.incrbyfloat(monthlyKey, amount),
|
||||
this.client.incrbyfloat(hourlyKey, amount),
|
||||
this.client.incrbyfloat(totalKey, amount),
|
||||
// 设置过期时间
|
||||
this.client.expire(dailyKey, 86400 * 30), // 30天
|
||||
this.client.expire(monthlyKey, 86400 * 90), // 90天
|
||||
this.client.expire(hourlyKey, 86400 * 7) // 7天
|
||||
]);
|
||||
|
||||
logger.debug(`💰 Cost incremented successfully, new daily total: $${results[0]}`);
|
||||
}
|
||||
|
||||
// 💰 获取费用统计
|
||||
async getCostStats(keyId) {
|
||||
const today = getDateStringInTimezone();
|
||||
const tzDate = getDateInTimezone();
|
||||
const currentMonth = `${tzDate.getFullYear()}-${String(tzDate.getMonth() + 1).padStart(2, '0')}`;
|
||||
const currentHour = `${today}:${String(getHourInTimezone()).padStart(2, '0')}`;
|
||||
|
||||
const [daily, monthly, hourly, total] = await Promise.all([
|
||||
this.client.get(`usage:cost:daily:${keyId}:${today}`),
|
||||
this.client.get(`usage:cost:monthly:${keyId}:${currentMonth}`),
|
||||
this.client.get(`usage:cost:hourly:${keyId}:${currentHour}`),
|
||||
this.client.get(`usage:cost:total:${keyId}`)
|
||||
]);
|
||||
|
||||
return {
|
||||
daily: parseFloat(daily || 0),
|
||||
monthly: parseFloat(monthly || 0),
|
||||
hourly: parseFloat(hourly || 0),
|
||||
total: parseFloat(total || 0)
|
||||
};
|
||||
}
|
||||
|
||||
// 📊 获取账户使用统计
|
||||
async getAccountUsageStats(accountId) {
|
||||
const accountKey = `account_usage:${accountId}`;
|
||||
const today = getDateStringInTimezone();
|
||||
const accountDailyKey = `account_usage:daily:${accountId}:${today}`;
|
||||
const tzDate = getDateInTimezone();
|
||||
const currentMonth = `${tzDate.getFullYear()}-${String(tzDate.getMonth() + 1).padStart(2, '0')}`;
|
||||
const accountMonthlyKey = `account_usage:monthly:${accountId}:${currentMonth}`;
|
||||
|
||||
const [total, daily, monthly] = await Promise.all([
|
||||
this.client.hgetall(accountKey),
|
||||
this.client.hgetall(accountDailyKey),
|
||||
this.client.hgetall(accountMonthlyKey)
|
||||
]);
|
||||
|
||||
// 获取账户创建时间来计算平均值
|
||||
const accountData = await this.client.hgetall(`claude_account:${accountId}`);
|
||||
const createdAt = accountData.createdAt ? new Date(accountData.createdAt) : new Date();
|
||||
const now = new Date();
|
||||
const daysSinceCreated = Math.max(1, Math.ceil((now - createdAt) / (1000 * 60 * 60 * 24)));
|
||||
|
||||
const totalTokens = parseInt(total.totalTokens) || 0;
|
||||
const totalRequests = parseInt(total.totalRequests) || 0;
|
||||
|
||||
// 计算平均RPM和TPM
|
||||
const totalMinutes = Math.max(1, daysSinceCreated * 24 * 60);
|
||||
const avgRPM = totalRequests / totalMinutes;
|
||||
const avgTPM = totalTokens / totalMinutes;
|
||||
|
||||
// 处理账户统计数据
|
||||
const handleAccountData = (data) => {
|
||||
const tokens = parseInt(data.totalTokens) || parseInt(data.tokens) || 0;
|
||||
const inputTokens = parseInt(data.totalInputTokens) || parseInt(data.inputTokens) || 0;
|
||||
const outputTokens = parseInt(data.totalOutputTokens) || parseInt(data.outputTokens) || 0;
|
||||
const requests = parseInt(data.totalRequests) || parseInt(data.requests) || 0;
|
||||
const cacheCreateTokens = parseInt(data.totalCacheCreateTokens) || parseInt(data.cacheCreateTokens) || 0;
|
||||
const cacheReadTokens = parseInt(data.totalCacheReadTokens) || parseInt(data.cacheReadTokens) || 0;
|
||||
const allTokens = parseInt(data.totalAllTokens) || parseInt(data.allTokens) || 0;
|
||||
|
||||
const actualAllTokens = allTokens || (inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens);
|
||||
|
||||
return {
|
||||
tokens: tokens,
|
||||
inputTokens: inputTokens,
|
||||
outputTokens: outputTokens,
|
||||
cacheCreateTokens: cacheCreateTokens,
|
||||
cacheReadTokens: cacheReadTokens,
|
||||
allTokens: actualAllTokens,
|
||||
requests: requests
|
||||
};
|
||||
};
|
||||
|
||||
const totalData = handleAccountData(total);
|
||||
const dailyData = handleAccountData(daily);
|
||||
const monthlyData = handleAccountData(monthly);
|
||||
|
||||
return {
|
||||
accountId: accountId,
|
||||
total: totalData,
|
||||
daily: dailyData,
|
||||
monthly: monthlyData,
|
||||
averages: {
|
||||
rpm: Math.round(avgRPM * 100) / 100,
|
||||
tpm: Math.round(avgTPM * 100) / 100,
|
||||
dailyRequests: Math.round((totalRequests / daysSinceCreated) * 100) / 100,
|
||||
dailyTokens: Math.round((totalTokens / daysSinceCreated) * 100) / 100
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// 📈 获取所有账户的使用统计
|
||||
async getAllAccountsUsageStats() {
|
||||
try {
|
||||
// 获取所有Claude账户
|
||||
const accountKeys = await this.client.keys('claude_account:*');
|
||||
const accountStats = [];
|
||||
|
||||
for (const accountKey of accountKeys) {
|
||||
const accountId = accountKey.replace('claude_account:', '');
|
||||
const accountData = await this.client.hgetall(accountKey);
|
||||
|
||||
if (accountData.name) {
|
||||
const stats = await this.getAccountUsageStats(accountId);
|
||||
accountStats.push({
|
||||
id: accountId,
|
||||
name: accountData.name,
|
||||
email: accountData.email || '',
|
||||
status: accountData.status || 'unknown',
|
||||
isActive: accountData.isActive === 'true',
|
||||
...stats
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 按当日token使用量排序
|
||||
accountStats.sort((a, b) => (b.daily.allTokens || 0) - (a.daily.allTokens || 0));
|
||||
|
||||
return accountStats;
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to get all accounts usage stats:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// 🧹 清空所有API Key的使用统计数据
|
||||
async resetAllUsageStats() {
|
||||
const client = this.getClientSafe();
|
||||
@@ -819,4 +1083,11 @@ class RedisClient {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new RedisClient();
|
||||
const redisClient = new RedisClient();
|
||||
|
||||
// 导出时区辅助函数
|
||||
redisClient.getDateInTimezone = getDateInTimezone;
|
||||
redisClient.getDateStringInTimezone = getDateStringInTimezone;
|
||||
redisClient.getHourInTimezone = getHourInTimezone;
|
||||
|
||||
module.exports = redisClient;
|
||||
@@ -12,15 +12,274 @@ const claudeCodeHeadersService = require('../services/claudeCodeHeadersService')
|
||||
const axios = require('axios');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const config = require('../../config/config');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// 🔑 API Keys 管理
|
||||
|
||||
// 调试:获取API Key费用详情
|
||||
router.get('/api-keys/:keyId/cost-debug', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { keyId } = req.params;
|
||||
const costStats = await redis.getCostStats(keyId);
|
||||
const dailyCost = await redis.getDailyCost(keyId);
|
||||
const today = redis.getDateStringInTimezone();
|
||||
const client = redis.getClientSafe();
|
||||
|
||||
// 获取所有相关的Redis键
|
||||
const costKeys = await client.keys(`usage:cost:*:${keyId}:*`);
|
||||
const keyValues = {};
|
||||
|
||||
for (const key of costKeys) {
|
||||
keyValues[key] = await client.get(key);
|
||||
}
|
||||
|
||||
res.json({
|
||||
keyId,
|
||||
today,
|
||||
dailyCost,
|
||||
costStats,
|
||||
redisKeys: keyValues,
|
||||
timezone: config.system.timezoneOffset || 8
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to get cost debug info:', error);
|
||||
res.status(500).json({ error: 'Failed to get cost debug info', message: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// 获取所有API Keys
|
||||
router.get('/api-keys', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { timeRange = 'all' } = req.query; // all, 7days, monthly
|
||||
const apiKeys = await apiKeyService.getAllApiKeys();
|
||||
|
||||
// 根据时间范围计算查询模式
|
||||
const now = new Date();
|
||||
let searchPatterns = [];
|
||||
|
||||
if (timeRange === 'today') {
|
||||
// 今日 - 使用时区日期
|
||||
const redis = require('../models/redis');
|
||||
const tzDate = redis.getDateInTimezone(now);
|
||||
const dateStr = `${tzDate.getFullYear()}-${String(tzDate.getMonth() + 1).padStart(2, '0')}-${String(tzDate.getDate()).padStart(2, '0')}`;
|
||||
searchPatterns.push(`usage:daily:*:${dateStr}`);
|
||||
} else if (timeRange === '7days') {
|
||||
// 最近7天
|
||||
const redis = require('../models/redis');
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const date = new Date(now);
|
||||
date.setDate(date.getDate() - i);
|
||||
const tzDate = redis.getDateInTimezone(date);
|
||||
const dateStr = `${tzDate.getFullYear()}-${String(tzDate.getMonth() + 1).padStart(2, '0')}-${String(tzDate.getDate()).padStart(2, '0')}`;
|
||||
searchPatterns.push(`usage:daily:*:${dateStr}`);
|
||||
}
|
||||
} else if (timeRange === 'monthly') {
|
||||
// 本月
|
||||
const redis = require('../models/redis');
|
||||
const tzDate = redis.getDateInTimezone(now);
|
||||
const currentMonth = `${tzDate.getFullYear()}-${String(tzDate.getMonth() + 1).padStart(2, '0')}`;
|
||||
searchPatterns.push(`usage:monthly:*:${currentMonth}`);
|
||||
}
|
||||
|
||||
// 为每个API Key计算准确的费用和统计数据
|
||||
for (const apiKey of apiKeys) {
|
||||
const client = redis.getClientSafe();
|
||||
|
||||
if (timeRange === 'all') {
|
||||
// 全部时间:保持原有逻辑
|
||||
if (apiKey.usage && apiKey.usage.total) {
|
||||
// 使用与展开模型统计相同的数据源
|
||||
// 获取所有时间的模型统计数据
|
||||
const monthlyKeys = await client.keys(`usage:${apiKey.id}:model:monthly:*:*`);
|
||||
const modelStatsMap = new Map();
|
||||
|
||||
// 汇总所有月份的数据
|
||||
for (const key of monthlyKeys) {
|
||||
const match = key.match(/usage:.+:model:monthly:(.+):\d{4}-\d{2}$/);
|
||||
if (!match) continue;
|
||||
|
||||
const model = match[1];
|
||||
const data = await client.hgetall(key);
|
||||
|
||||
if (data && Object.keys(data).length > 0) {
|
||||
if (!modelStatsMap.has(model)) {
|
||||
modelStatsMap.set(model, {
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cacheCreateTokens: 0,
|
||||
cacheReadTokens: 0
|
||||
});
|
||||
}
|
||||
|
||||
const stats = modelStatsMap.get(model);
|
||||
stats.inputTokens += parseInt(data.totalInputTokens) || parseInt(data.inputTokens) || 0;
|
||||
stats.outputTokens += parseInt(data.totalOutputTokens) || parseInt(data.outputTokens) || 0;
|
||||
stats.cacheCreateTokens += parseInt(data.totalCacheCreateTokens) || parseInt(data.cacheCreateTokens) || 0;
|
||||
stats.cacheReadTokens += parseInt(data.totalCacheReadTokens) || parseInt(data.cacheReadTokens) || 0;
|
||||
}
|
||||
}
|
||||
|
||||
let totalCost = 0;
|
||||
|
||||
// 计算每个模型的费用
|
||||
for (const [model, stats] of modelStatsMap) {
|
||||
const usage = {
|
||||
input_tokens: stats.inputTokens,
|
||||
output_tokens: stats.outputTokens,
|
||||
cache_creation_input_tokens: stats.cacheCreateTokens,
|
||||
cache_read_input_tokens: stats.cacheReadTokens
|
||||
};
|
||||
|
||||
const costResult = CostCalculator.calculateCost(usage, model);
|
||||
totalCost += costResult.costs.total;
|
||||
}
|
||||
|
||||
// 如果没有详细的模型数据,使用总量数据和默认模型计算
|
||||
if (modelStatsMap.size === 0) {
|
||||
const usage = {
|
||||
input_tokens: apiKey.usage.total.inputTokens || 0,
|
||||
output_tokens: apiKey.usage.total.outputTokens || 0,
|
||||
cache_creation_input_tokens: apiKey.usage.total.cacheCreateTokens || 0,
|
||||
cache_read_input_tokens: apiKey.usage.total.cacheReadTokens || 0
|
||||
};
|
||||
|
||||
const costResult = CostCalculator.calculateCost(usage, 'claude-3-5-haiku-20241022');
|
||||
totalCost = costResult.costs.total;
|
||||
}
|
||||
|
||||
// 添加格式化的费用到响应数据
|
||||
apiKey.usage.total.cost = totalCost;
|
||||
apiKey.usage.total.formattedCost = CostCalculator.formatCost(totalCost);
|
||||
}
|
||||
} else {
|
||||
// 7天或本月:重新计算统计数据
|
||||
const tempUsage = {
|
||||
requests: 0,
|
||||
tokens: 0,
|
||||
allTokens: 0, // 添加allTokens字段
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cacheCreateTokens: 0,
|
||||
cacheReadTokens: 0
|
||||
};
|
||||
|
||||
// 获取指定时间范围的统计数据
|
||||
for (const pattern of searchPatterns) {
|
||||
const keys = await client.keys(pattern.replace('*', apiKey.id));
|
||||
|
||||
for (const key of keys) {
|
||||
const data = await client.hgetall(key);
|
||||
if (data && Object.keys(data).length > 0) {
|
||||
// 使用与 redis.js incrementTokenUsage 中相同的字段名
|
||||
tempUsage.requests += parseInt(data.totalRequests) || parseInt(data.requests) || 0;
|
||||
tempUsage.tokens += parseInt(data.totalTokens) || parseInt(data.tokens) || 0;
|
||||
tempUsage.allTokens += parseInt(data.totalAllTokens) || parseInt(data.allTokens) || 0; // 读取包含所有Token的字段
|
||||
tempUsage.inputTokens += parseInt(data.totalInputTokens) || parseInt(data.inputTokens) || 0;
|
||||
tempUsage.outputTokens += parseInt(data.totalOutputTokens) || parseInt(data.outputTokens) || 0;
|
||||
tempUsage.cacheCreateTokens += parseInt(data.totalCacheCreateTokens) || parseInt(data.cacheCreateTokens) || 0;
|
||||
tempUsage.cacheReadTokens += parseInt(data.totalCacheReadTokens) || parseInt(data.cacheReadTokens) || 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 计算指定时间范围的费用
|
||||
let totalCost = 0;
|
||||
const redis = require('../models/redis');
|
||||
const tzToday = redis.getDateStringInTimezone(now);
|
||||
const tzDate = redis.getDateInTimezone(now);
|
||||
const tzMonth = `${tzDate.getFullYear()}-${String(tzDate.getMonth() + 1).padStart(2, '0')}`;
|
||||
|
||||
const modelKeys = timeRange === 'today'
|
||||
? await client.keys(`usage:${apiKey.id}:model:daily:*:${tzToday}`)
|
||||
: timeRange === '7days'
|
||||
? await client.keys(`usage:${apiKey.id}:model:daily:*:*`)
|
||||
: await client.keys(`usage:${apiKey.id}:model:monthly:*:${tzMonth}`);
|
||||
|
||||
const modelStatsMap = new Map();
|
||||
|
||||
// 过滤和汇总相应时间范围的模型数据
|
||||
for (const key of modelKeys) {
|
||||
if (timeRange === '7days') {
|
||||
// 检查是否在最近7天内
|
||||
const dateMatch = key.match(/\d{4}-\d{2}-\d{2}$/);
|
||||
if (dateMatch) {
|
||||
const keyDate = new Date(dateMatch[0]);
|
||||
const daysDiff = Math.floor((now - keyDate) / (1000 * 60 * 60 * 24));
|
||||
if (daysDiff > 6) continue;
|
||||
}
|
||||
} else if (timeRange === 'today') {
|
||||
// today选项已经在查询时过滤了,不需要额外处理
|
||||
}
|
||||
|
||||
const modelMatch = key.match(/usage:.+:model:(?:daily|monthly):(.+):\d{4}-\d{2}(?:-\d{2})?$/);
|
||||
if (!modelMatch) continue;
|
||||
|
||||
const model = modelMatch[1];
|
||||
const data = await client.hgetall(key);
|
||||
|
||||
if (data && Object.keys(data).length > 0) {
|
||||
if (!modelStatsMap.has(model)) {
|
||||
modelStatsMap.set(model, {
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cacheCreateTokens: 0,
|
||||
cacheReadTokens: 0
|
||||
});
|
||||
}
|
||||
|
||||
const stats = modelStatsMap.get(model);
|
||||
stats.inputTokens += parseInt(data.totalInputTokens) || parseInt(data.inputTokens) || 0;
|
||||
stats.outputTokens += parseInt(data.totalOutputTokens) || parseInt(data.outputTokens) || 0;
|
||||
stats.cacheCreateTokens += parseInt(data.totalCacheCreateTokens) || parseInt(data.cacheCreateTokens) || 0;
|
||||
stats.cacheReadTokens += parseInt(data.totalCacheReadTokens) || parseInt(data.cacheReadTokens) || 0;
|
||||
}
|
||||
}
|
||||
|
||||
// 计算费用
|
||||
for (const [model, stats] of modelStatsMap) {
|
||||
const usage = {
|
||||
input_tokens: stats.inputTokens,
|
||||
output_tokens: stats.outputTokens,
|
||||
cache_creation_input_tokens: stats.cacheCreateTokens,
|
||||
cache_read_input_tokens: stats.cacheReadTokens
|
||||
};
|
||||
|
||||
const costResult = CostCalculator.calculateCost(usage, model);
|
||||
totalCost += costResult.costs.total;
|
||||
}
|
||||
|
||||
// 如果没有模型数据,使用临时统计数据计算
|
||||
if (modelStatsMap.size === 0 && tempUsage.tokens > 0) {
|
||||
const usage = {
|
||||
input_tokens: tempUsage.inputTokens,
|
||||
output_tokens: tempUsage.outputTokens,
|
||||
cache_creation_input_tokens: tempUsage.cacheCreateTokens,
|
||||
cache_read_input_tokens: tempUsage.cacheReadTokens
|
||||
};
|
||||
|
||||
const costResult = CostCalculator.calculateCost(usage, 'claude-3-5-haiku-20241022');
|
||||
totalCost = costResult.costs.total;
|
||||
}
|
||||
|
||||
// 使用从Redis读取的allTokens,如果没有则计算
|
||||
const allTokens = tempUsage.allTokens || (tempUsage.inputTokens + tempUsage.outputTokens + tempUsage.cacheCreateTokens + tempUsage.cacheReadTokens);
|
||||
|
||||
// 更新API Key的usage数据为指定时间范围的数据
|
||||
apiKey.usage[timeRange] = {
|
||||
...tempUsage,
|
||||
tokens: allTokens, // 使用包含所有Token的总数
|
||||
allTokens: allTokens,
|
||||
cost: totalCost,
|
||||
formattedCost: CostCalculator.formatCost(totalCost)
|
||||
};
|
||||
|
||||
// 为了保持兼容性,也更新total字段
|
||||
apiKey.usage.total = apiKey.usage[timeRange];
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ success: true, data: apiKeys });
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to get API keys:', error);
|
||||
@@ -28,6 +287,21 @@ router.get('/api-keys', authenticateAdmin, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// 获取支持的客户端列表
|
||||
router.get('/supported-clients', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const clients = config.clientRestrictions.predefinedClients.map(client => ({
|
||||
id: client.id,
|
||||
name: client.name,
|
||||
description: client.description
|
||||
}));
|
||||
res.json({ success: true, data: clients });
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to get supported clients:', error);
|
||||
res.status(500).json({ error: 'Failed to get supported clients', message: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// 创建新的API Key
|
||||
router.post('/api-keys', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
@@ -43,7 +317,10 @@ router.post('/api-keys', authenticateAdmin, async (req, res) => {
|
||||
rateLimitWindow,
|
||||
rateLimitRequests,
|
||||
enableModelRestriction,
|
||||
restrictedModels
|
||||
restrictedModels,
|
||||
enableClientRestriction,
|
||||
allowedClients,
|
||||
dailyCostLimit
|
||||
} = req.body;
|
||||
|
||||
// 输入验证
|
||||
@@ -85,6 +362,15 @@ router.post('/api-keys', authenticateAdmin, async (req, res) => {
|
||||
return res.status(400).json({ error: 'Restricted models must be an array' });
|
||||
}
|
||||
|
||||
// 验证客户端限制字段
|
||||
if (enableClientRestriction !== undefined && typeof enableClientRestriction !== 'boolean') {
|
||||
return res.status(400).json({ error: 'Enable client restriction must be a boolean' });
|
||||
}
|
||||
|
||||
if (allowedClients !== undefined && !Array.isArray(allowedClients)) {
|
||||
return res.status(400).json({ error: 'Allowed clients must be an array' });
|
||||
}
|
||||
|
||||
const newKey = await apiKeyService.generateApiKey({
|
||||
name,
|
||||
description,
|
||||
@@ -97,7 +383,10 @@ router.post('/api-keys', authenticateAdmin, async (req, res) => {
|
||||
rateLimitWindow,
|
||||
rateLimitRequests,
|
||||
enableModelRestriction,
|
||||
restrictedModels
|
||||
restrictedModels,
|
||||
enableClientRestriction,
|
||||
allowedClients,
|
||||
dailyCostLimit
|
||||
});
|
||||
|
||||
logger.success(`🔑 Admin created new API key: ${name}`);
|
||||
@@ -112,7 +401,7 @@ router.post('/api-keys', authenticateAdmin, async (req, res) => {
|
||||
router.put('/api-keys/:keyId', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { keyId } = req.params;
|
||||
const { tokenLimit, concurrencyLimit, rateLimitWindow, rateLimitRequests, claudeAccountId, geminiAccountId, permissions, enableModelRestriction, restrictedModels } = req.body;
|
||||
const { tokenLimit, concurrencyLimit, rateLimitWindow, rateLimitRequests, claudeAccountId, geminiAccountId, permissions, enableModelRestriction, restrictedModels, enableClientRestriction, allowedClients, expiresAt, dailyCostLimit } = req.body;
|
||||
|
||||
// 只允许更新指定字段
|
||||
const updates = {};
|
||||
@@ -178,6 +467,45 @@ router.put('/api-keys/:keyId', authenticateAdmin, async (req, res) => {
|
||||
updates.restrictedModels = restrictedModels;
|
||||
}
|
||||
|
||||
// 处理客户端限制字段
|
||||
if (enableClientRestriction !== undefined) {
|
||||
if (typeof enableClientRestriction !== 'boolean') {
|
||||
return res.status(400).json({ error: 'Enable client restriction must be a boolean' });
|
||||
}
|
||||
updates.enableClientRestriction = enableClientRestriction;
|
||||
}
|
||||
|
||||
if (allowedClients !== undefined) {
|
||||
if (!Array.isArray(allowedClients)) {
|
||||
return res.status(400).json({ error: 'Allowed clients must be an array' });
|
||||
}
|
||||
updates.allowedClients = allowedClients;
|
||||
}
|
||||
|
||||
// 处理过期时间字段
|
||||
if (expiresAt !== undefined) {
|
||||
if (expiresAt === null) {
|
||||
// null 表示永不过期
|
||||
updates.expiresAt = null;
|
||||
} else {
|
||||
// 验证日期格式
|
||||
const expireDate = new Date(expiresAt);
|
||||
if (isNaN(expireDate.getTime())) {
|
||||
return res.status(400).json({ error: 'Invalid expiration date format' });
|
||||
}
|
||||
updates.expiresAt = expiresAt;
|
||||
}
|
||||
}
|
||||
|
||||
// 处理每日费用限制
|
||||
if (dailyCostLimit !== undefined && dailyCostLimit !== null && dailyCostLimit !== '') {
|
||||
const costLimit = Number(dailyCostLimit);
|
||||
if (isNaN(costLimit) || costLimit < 0) {
|
||||
return res.status(400).json({ error: 'Daily cost limit must be a non-negative number' });
|
||||
}
|
||||
updates.dailyCostLimit = costLimit;
|
||||
}
|
||||
|
||||
await apiKeyService.updateApiKey(keyId, updates);
|
||||
|
||||
logger.success(`📝 Admin updated API key: ${keyId}`);
|
||||
@@ -308,7 +636,34 @@ router.post('/claude-accounts/exchange-code', authenticateAdmin, async (req, res
|
||||
router.get('/claude-accounts', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const accounts = await claudeAccountService.getAllAccounts();
|
||||
res.json({ success: true, data: accounts });
|
||||
|
||||
// 为每个账户添加使用统计信息
|
||||
const accountsWithStats = await Promise.all(accounts.map(async (account) => {
|
||||
try {
|
||||
const usageStats = await redis.getAccountUsageStats(account.id);
|
||||
return {
|
||||
...account,
|
||||
usage: {
|
||||
daily: usageStats.daily,
|
||||
total: usageStats.total,
|
||||
averages: usageStats.averages
|
||||
}
|
||||
};
|
||||
} catch (statsError) {
|
||||
logger.warn(`⚠️ Failed to get usage stats for account ${account.id}:`, statsError.message);
|
||||
// 如果获取统计失败,返回空统计
|
||||
return {
|
||||
...account,
|
||||
usage: {
|
||||
daily: { tokens: 0, requests: 0, allTokens: 0 },
|
||||
total: { tokens: 0, requests: 0, allTokens: 0 },
|
||||
averages: { rpm: 0, tpm: 0 }
|
||||
}
|
||||
};
|
||||
}
|
||||
}));
|
||||
|
||||
res.json({ success: true, data: accountsWithStats });
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to get Claude accounts:', error);
|
||||
res.status(500).json({ error: 'Failed to get Claude accounts', message: error.message });
|
||||
@@ -495,7 +850,18 @@ router.post('/gemini-accounts/exchange-code', authenticateAdmin, async (req, res
|
||||
router.get('/gemini-accounts', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const accounts = await geminiAccountService.getAllAccounts();
|
||||
res.json({ success: true, data: accounts });
|
||||
|
||||
// 为Gemini账户添加空的使用统计(暂时)
|
||||
const accountsWithStats = accounts.map(account => ({
|
||||
...account,
|
||||
usage: {
|
||||
daily: { tokens: 0, requests: 0, allTokens: 0 },
|
||||
total: { tokens: 0, requests: 0, allTokens: 0 },
|
||||
averages: { rpm: 0, tpm: 0 }
|
||||
}
|
||||
}));
|
||||
|
||||
res.json({ success: true, data: accountsWithStats });
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to get Gemini accounts:', error);
|
||||
res.status(500).json({ error: 'Failed to get accounts', message: error.message });
|
||||
@@ -568,6 +934,73 @@ router.post('/gemini-accounts/:accountId/refresh', authenticateAdmin, async (req
|
||||
}
|
||||
});
|
||||
|
||||
// 📊 账户使用统计
|
||||
|
||||
// 获取所有账户的使用统计
|
||||
router.get('/accounts/usage-stats', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const accountsStats = await redis.getAllAccountsUsageStats();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: accountsStats,
|
||||
summary: {
|
||||
totalAccounts: accountsStats.length,
|
||||
activeToday: accountsStats.filter(account => account.daily.requests > 0).length,
|
||||
totalDailyTokens: accountsStats.reduce((sum, account) => sum + (account.daily.allTokens || 0), 0),
|
||||
totalDailyRequests: accountsStats.reduce((sum, account) => sum + (account.daily.requests || 0), 0)
|
||||
},
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to get accounts usage stats:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to get accounts usage stats',
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 获取单个账户的使用统计
|
||||
router.get('/accounts/:accountId/usage-stats', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { accountId } = req.params;
|
||||
const accountStats = await redis.getAccountUsageStats(accountId);
|
||||
|
||||
// 获取账户基本信息
|
||||
const accountData = await claudeAccountService.getAccount(accountId);
|
||||
if (!accountData) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: 'Account not found'
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
...accountStats,
|
||||
accountInfo: {
|
||||
name: accountData.name,
|
||||
email: accountData.email,
|
||||
status: accountData.status,
|
||||
isActive: accountData.isActive,
|
||||
createdAt: accountData.createdAt
|
||||
}
|
||||
},
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to get account usage stats:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to get account usage stats',
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 📊 系统统计
|
||||
|
||||
// 获取系统概览
|
||||
@@ -582,8 +1015,8 @@ router.get('/dashboard', authenticateAdmin, async (req, res) => {
|
||||
redis.getSystemAverages()
|
||||
]);
|
||||
|
||||
// 计算使用统计(包含cache tokens)
|
||||
const totalTokensUsed = apiKeys.reduce((sum, key) => sum + (key.usage?.total?.tokens || 0), 0);
|
||||
// 计算使用统计(统一使用allTokens)
|
||||
const totalTokensUsed = apiKeys.reduce((sum, key) => sum + (key.usage?.total?.allTokens || 0), 0);
|
||||
const totalRequestsUsed = apiKeys.reduce((sum, key) => sum + (key.usage?.total?.requests || 0), 0);
|
||||
const totalInputTokensUsed = apiKeys.reduce((sum, key) => sum + (key.usage?.total?.inputTokens || 0), 0);
|
||||
const totalOutputTokensUsed = apiKeys.reduce((sum, key) => sum + (key.usage?.total?.outputTokens || 0), 0);
|
||||
@@ -1794,4 +2227,91 @@ function compareVersions(current, latest) {
|
||||
return currentV.patch - latestV.patch;
|
||||
}
|
||||
|
||||
// 🎨 OEM设置管理
|
||||
|
||||
// 获取OEM设置(公开接口,用于显示)
|
||||
router.get('/oem-settings', async (req, res) => {
|
||||
try {
|
||||
const client = redis.getClient();
|
||||
const oemSettings = await client.get('oem:settings');
|
||||
|
||||
// 默认设置
|
||||
const defaultSettings = {
|
||||
siteName: 'Claude Relay Service',
|
||||
siteIcon: '',
|
||||
siteIconData: '', // Base64编码的图标数据
|
||||
updatedAt: new Date().toISOString()
|
||||
};
|
||||
|
||||
let settings = defaultSettings;
|
||||
if (oemSettings) {
|
||||
try {
|
||||
settings = { ...defaultSettings, ...JSON.parse(oemSettings) };
|
||||
} catch (err) {
|
||||
logger.warn('⚠️ Failed to parse OEM settings, using defaults:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: settings
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to get OEM settings:', error);
|
||||
res.status(500).json({ error: 'Failed to get OEM settings', message: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// 更新OEM设置
|
||||
router.put('/oem-settings', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { siteName, siteIcon, siteIconData } = req.body;
|
||||
|
||||
// 验证输入
|
||||
if (!siteName || typeof siteName !== 'string' || siteName.trim().length === 0) {
|
||||
return res.status(400).json({ error: 'Site name is required' });
|
||||
}
|
||||
|
||||
if (siteName.length > 100) {
|
||||
return res.status(400).json({ error: 'Site name must be less than 100 characters' });
|
||||
}
|
||||
|
||||
// 验证图标数据大小(如果是base64)
|
||||
if (siteIconData && siteIconData.length > 500000) { // 约375KB
|
||||
return res.status(400).json({ error: 'Icon file must be less than 350KB' });
|
||||
}
|
||||
|
||||
// 验证图标URL(如果提供)
|
||||
if (siteIcon && !siteIconData) {
|
||||
// 简单验证URL格式
|
||||
try {
|
||||
new URL(siteIcon);
|
||||
} catch (err) {
|
||||
return res.status(400).json({ error: 'Invalid icon URL format' });
|
||||
}
|
||||
}
|
||||
|
||||
const settings = {
|
||||
siteName: siteName.trim(),
|
||||
siteIcon: (siteIcon || '').trim(),
|
||||
siteIconData: (siteIconData || '').trim(), // Base64数据
|
||||
updatedAt: new Date().toISOString()
|
||||
};
|
||||
|
||||
const client = redis.getClient();
|
||||
await client.set('oem:settings', JSON.stringify(settings));
|
||||
|
||||
logger.info(`✅ OEM settings updated: ${siteName}`);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'OEM settings updated successfully',
|
||||
data: settings
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to update OEM settings:', error);
|
||||
res.status(500).json({ error: 'Failed to update OEM settings', message: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -68,8 +68,9 @@ async function handleMessagesRequest(req, res) {
|
||||
const cacheReadTokens = usageData.cache_read_input_tokens || 0;
|
||||
const model = usageData.model || 'unknown';
|
||||
|
||||
// 记录真实的token使用量(包含模型信息和所有4种token)
|
||||
apiKeyService.recordUsage(req.apiKey.id, inputTokens, outputTokens, cacheCreateTokens, cacheReadTokens, model).catch(error => {
|
||||
// 记录真实的token使用量(包含模型信息和所有4种token以及账户ID)
|
||||
const accountId = usageData.accountId;
|
||||
apiKeyService.recordUsage(req.apiKey.id, inputTokens, outputTokens, cacheCreateTokens, cacheReadTokens, model, accountId).catch(error => {
|
||||
logger.error('❌ Failed to record stream usage:', error);
|
||||
});
|
||||
|
||||
@@ -135,8 +136,9 @@ async function handleMessagesRequest(req, res) {
|
||||
const cacheReadTokens = jsonData.usage.cache_read_input_tokens || 0;
|
||||
const model = jsonData.model || req.body.model || 'unknown';
|
||||
|
||||
// 记录真实的token使用量(包含模型信息和所有4种token)
|
||||
await apiKeyService.recordUsage(req.apiKey.id, inputTokens, outputTokens, cacheCreateTokens, cacheReadTokens, model);
|
||||
// 记录真实的token使用量(包含模型信息和所有4种token以及账户ID)
|
||||
const accountId = response.accountId;
|
||||
await apiKeyService.recordUsage(req.apiKey.id, inputTokens, outputTokens, cacheCreateTokens, cacheReadTokens, model, accountId);
|
||||
|
||||
// 更新时间窗口内的token计数
|
||||
if (req.rateLimitInfo) {
|
||||
|
||||
518
src/routes/apiStats.js
Normal file
518
src/routes/apiStats.js
Normal 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;
|
||||
@@ -258,7 +258,8 @@ async function handleChatCompletion(req, res, apiKeyData) {
|
||||
outputTokens,
|
||||
cacheCreateTokens,
|
||||
cacheReadTokens,
|
||||
model
|
||||
model,
|
||||
accountId
|
||||
).catch(error => {
|
||||
logger.error('❌ Failed to record usage:', error);
|
||||
});
|
||||
@@ -327,7 +328,8 @@ async function handleChatCompletion(req, res, apiKeyData) {
|
||||
usage.output_tokens || 0,
|
||||
usage.cache_creation_input_tokens || 0,
|
||||
usage.cache_read_input_tokens || 0,
|
||||
claudeRequest.model
|
||||
claudeRequest.model,
|
||||
accountId
|
||||
).catch(error => {
|
||||
logger.error('❌ Failed to record usage:', error);
|
||||
});
|
||||
|
||||
@@ -25,7 +25,7 @@ const ALLOWED_FILES = {
|
||||
'style.css': {
|
||||
path: path.join(__dirname, '../../web/admin/style.css'),
|
||||
contentType: 'text/css; charset=utf-8'
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
// 🛡️ 安全文件服务函数
|
||||
@@ -400,6 +400,9 @@ router.get('/style.css', (req, res) => {
|
||||
serveWhitelistedFile(req, res, 'style.css');
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
// 🔑 Gemini OAuth 回调页面
|
||||
|
||||
module.exports = router;
|
||||
@@ -24,7 +24,10 @@ class ApiKeyService {
|
||||
rateLimitWindow = null,
|
||||
rateLimitRequests = null,
|
||||
enableModelRestriction = false,
|
||||
restrictedModels = []
|
||||
restrictedModels = [],
|
||||
enableClientRestriction = false,
|
||||
allowedClients = [],
|
||||
dailyCostLimit = 0
|
||||
} = options;
|
||||
|
||||
// 生成简单的API Key (64字符十六进制)
|
||||
@@ -47,6 +50,9 @@ class ApiKeyService {
|
||||
permissions: permissions || 'all',
|
||||
enableModelRestriction: String(enableModelRestriction),
|
||||
restrictedModels: JSON.stringify(restrictedModels || []),
|
||||
enableClientRestriction: String(enableClientRestriction || false),
|
||||
allowedClients: JSON.stringify(allowedClients || []),
|
||||
dailyCostLimit: String(dailyCostLimit || 0),
|
||||
createdAt: new Date().toISOString(),
|
||||
lastUsedAt: '',
|
||||
expiresAt: expiresAt || '',
|
||||
@@ -73,6 +79,9 @@ class ApiKeyService {
|
||||
permissions: keyData.permissions,
|
||||
enableModelRestriction: keyData.enableModelRestriction === 'true',
|
||||
restrictedModels: JSON.parse(keyData.restrictedModels),
|
||||
enableClientRestriction: keyData.enableClientRestriction === 'true',
|
||||
allowedClients: JSON.parse(keyData.allowedClients || '[]'),
|
||||
dailyCostLimit: parseFloat(keyData.dailyCostLimit || 0),
|
||||
createdAt: keyData.createdAt,
|
||||
expiresAt: keyData.expiresAt,
|
||||
createdBy: keyData.createdBy
|
||||
@@ -109,6 +118,9 @@ class ApiKeyService {
|
||||
// 获取使用统计(供返回数据使用)
|
||||
const usage = await redis.getUsageStats(keyData.id);
|
||||
|
||||
// 获取当日费用统计
|
||||
const dailyCost = await redis.getDailyCost(keyData.id);
|
||||
|
||||
// 更新最后使用时间(优化:只在实际API调用时更新,而不是验证时)
|
||||
// 注意:lastUsedAt的更新已移至recordUsage方法中
|
||||
|
||||
@@ -122,11 +134,22 @@ class ApiKeyService {
|
||||
restrictedModels = [];
|
||||
}
|
||||
|
||||
// 解析允许的客户端
|
||||
let allowedClients = [];
|
||||
try {
|
||||
allowedClients = keyData.allowedClients ? JSON.parse(keyData.allowedClients) : [];
|
||||
} catch (e) {
|
||||
allowedClients = [];
|
||||
}
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
keyData: {
|
||||
id: keyData.id,
|
||||
name: keyData.name,
|
||||
description: keyData.description,
|
||||
createdAt: keyData.createdAt,
|
||||
expiresAt: keyData.expiresAt,
|
||||
claudeAccountId: keyData.claudeAccountId,
|
||||
geminiAccountId: keyData.geminiAccountId,
|
||||
permissions: keyData.permissions || 'all',
|
||||
@@ -136,6 +159,10 @@ class ApiKeyService {
|
||||
rateLimitRequests: parseInt(keyData.rateLimitRequests || 0),
|
||||
enableModelRestriction: keyData.enableModelRestriction === 'true',
|
||||
restrictedModels: restrictedModels,
|
||||
enableClientRestriction: keyData.enableClientRestriction === 'true',
|
||||
allowedClients: allowedClients,
|
||||
dailyCostLimit: parseFloat(keyData.dailyCostLimit || 0),
|
||||
dailyCost: dailyCost || 0,
|
||||
usage
|
||||
}
|
||||
};
|
||||
@@ -160,12 +187,20 @@ class ApiKeyService {
|
||||
key.currentConcurrency = await redis.getConcurrency(key.id);
|
||||
key.isActive = key.isActive === 'true';
|
||||
key.enableModelRestriction = key.enableModelRestriction === 'true';
|
||||
key.enableClientRestriction = key.enableClientRestriction === 'true';
|
||||
key.permissions = key.permissions || 'all'; // 兼容旧数据
|
||||
key.dailyCostLimit = parseFloat(key.dailyCostLimit || 0);
|
||||
key.dailyCost = await redis.getDailyCost(key.id) || 0;
|
||||
try {
|
||||
key.restrictedModels = key.restrictedModels ? JSON.parse(key.restrictedModels) : [];
|
||||
} catch (e) {
|
||||
key.restrictedModels = [];
|
||||
}
|
||||
try {
|
||||
key.allowedClients = key.allowedClients ? JSON.parse(key.allowedClients) : [];
|
||||
} catch (e) {
|
||||
key.allowedClients = [];
|
||||
}
|
||||
delete key.apiKey; // 不返回哈希后的key
|
||||
}
|
||||
|
||||
@@ -185,15 +220,15 @@ class ApiKeyService {
|
||||
}
|
||||
|
||||
// 允许更新的字段
|
||||
const allowedUpdates = ['name', 'description', 'tokenLimit', 'concurrencyLimit', 'rateLimitWindow', 'rateLimitRequests', 'isActive', 'claudeAccountId', 'geminiAccountId', 'permissions', 'expiresAt', 'enableModelRestriction', 'restrictedModels'];
|
||||
const allowedUpdates = ['name', 'description', 'tokenLimit', 'concurrencyLimit', 'rateLimitWindow', 'rateLimitRequests', 'isActive', 'claudeAccountId', 'geminiAccountId', 'permissions', 'expiresAt', 'enableModelRestriction', 'restrictedModels', 'enableClientRestriction', 'allowedClients', 'dailyCostLimit'];
|
||||
const updatedData = { ...keyData };
|
||||
|
||||
for (const [field, value] of Object.entries(updates)) {
|
||||
if (allowedUpdates.includes(field)) {
|
||||
if (field === 'restrictedModels') {
|
||||
// 特殊处理 restrictedModels 数组
|
||||
if (field === 'restrictedModels' || field === 'allowedClients') {
|
||||
// 特殊处理数组字段
|
||||
updatedData[field] = JSON.stringify(value || []);
|
||||
} else if (field === 'enableModelRestriction') {
|
||||
} else if (field === 'enableModelRestriction' || field === 'enableClientRestriction') {
|
||||
// 布尔值转字符串
|
||||
updatedData[field] = String(value);
|
||||
} else {
|
||||
@@ -234,18 +269,45 @@ class ApiKeyService {
|
||||
}
|
||||
}
|
||||
|
||||
// 📊 记录使用情况(支持缓存token)
|
||||
async recordUsage(keyId, inputTokens = 0, outputTokens = 0, cacheCreateTokens = 0, cacheReadTokens = 0, model = 'unknown') {
|
||||
// 📊 记录使用情况(支持缓存token和账户级别统计)
|
||||
async recordUsage(keyId, inputTokens = 0, outputTokens = 0, cacheCreateTokens = 0, cacheReadTokens = 0, model = 'unknown', accountId = null) {
|
||||
try {
|
||||
const totalTokens = inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens;
|
||||
|
||||
// 计算费用
|
||||
const CostCalculator = require('../utils/costCalculator');
|
||||
const costInfo = CostCalculator.calculateCost({
|
||||
input_tokens: inputTokens,
|
||||
output_tokens: outputTokens,
|
||||
cache_creation_input_tokens: cacheCreateTokens,
|
||||
cache_read_input_tokens: cacheReadTokens
|
||||
}, model);
|
||||
|
||||
// 记录API Key级别的使用统计
|
||||
await redis.incrementTokenUsage(keyId, totalTokens, inputTokens, outputTokens, cacheCreateTokens, cacheReadTokens, model);
|
||||
|
||||
// 更新最后使用时间(性能优化:只在实际使用时更新)
|
||||
// 记录费用统计
|
||||
if (costInfo.costs.total > 0) {
|
||||
await redis.incrementDailyCost(keyId, costInfo.costs.total);
|
||||
logger.database(`💰 Recorded cost for ${keyId}: $${costInfo.costs.total.toFixed(6)}, model: ${model}`);
|
||||
} else {
|
||||
logger.debug(`💰 No cost recorded for ${keyId} - zero cost for model: ${model}`);
|
||||
}
|
||||
|
||||
// 获取API Key数据以确定关联的账户
|
||||
const keyData = await redis.getApiKey(keyId);
|
||||
if (keyData && Object.keys(keyData).length > 0) {
|
||||
// 更新最后使用时间
|
||||
keyData.lastUsedAt = new Date().toISOString();
|
||||
// 使用记录时不需要重新建立哈希映射
|
||||
await redis.setApiKey(keyId, keyData);
|
||||
|
||||
// 记录账户级别的使用统计(只统计实际处理请求的账户)
|
||||
if (accountId) {
|
||||
await redis.incrementAccountUsage(accountId, totalTokens, inputTokens, outputTokens, cacheCreateTokens, cacheReadTokens, model);
|
||||
logger.database(`📊 Recorded account usage: ${accountId} - ${totalTokens} tokens (API Key: ${keyId})`);
|
||||
} else {
|
||||
logger.debug('⚠️ No accountId provided for usage recording, skipping account-level statistics');
|
||||
}
|
||||
}
|
||||
|
||||
const logParts = [`Model: ${model}`, `Input: ${inputTokens}`, `Output: ${outputTokens}`];
|
||||
@@ -274,6 +336,16 @@ class ApiKeyService {
|
||||
return await redis.getUsageStats(keyId);
|
||||
}
|
||||
|
||||
// 📊 获取账户使用统计
|
||||
async getAccountUsageStats(accountId) {
|
||||
return await redis.getAccountUsageStats(accountId);
|
||||
}
|
||||
|
||||
// 📈 获取所有账户使用统计
|
||||
async getAllAccountsUsageStats() {
|
||||
return await redis.getAllAccountsUsageStats();
|
||||
}
|
||||
|
||||
|
||||
// 🧹 清理过期的API Keys
|
||||
async cleanupExpiredKeys() {
|
||||
@@ -283,14 +355,17 @@ class ApiKeyService {
|
||||
let cleanedCount = 0;
|
||||
|
||||
for (const key of apiKeys) {
|
||||
if (key.expiresAt && new Date(key.expiresAt) < now) {
|
||||
await redis.deleteApiKey(key.id);
|
||||
// 检查是否已过期且仍处于激活状态
|
||||
if (key.expiresAt && new Date(key.expiresAt) < now && key.isActive === 'true') {
|
||||
// 将过期的 API Key 标记为禁用状态,而不是直接删除
|
||||
await this.updateApiKey(key.id, { isActive: false });
|
||||
logger.info(`🔒 API Key ${key.id} (${key.name}) has expired and been disabled`);
|
||||
cleanedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if (cleanedCount > 0) {
|
||||
logger.success(`🧹 Cleaned up ${cleanedCount} expired API keys`);
|
||||
logger.success(`🧹 Disabled ${cleanedCount} expired API keys`);
|
||||
}
|
||||
|
||||
return cleanedCount;
|
||||
|
||||
@@ -444,11 +444,11 @@ class ClaudeAccountService {
|
||||
}
|
||||
|
||||
// 如果没有映射或映射无效,选择新账户
|
||||
// 优先选择最近刷新过token的账户
|
||||
// 优先选择最久未使用的账户(负载均衡)
|
||||
const sortedAccounts = activeAccounts.sort((a, b) => {
|
||||
const aLastRefresh = new Date(a.lastRefreshAt || 0).getTime();
|
||||
const bLastRefresh = new Date(b.lastRefreshAt || 0).getTime();
|
||||
return bLastRefresh - aLastRefresh;
|
||||
const aLastUsed = new Date(a.lastUsedAt || 0).getTime();
|
||||
const bLastUsed = new Date(b.lastUsedAt || 0).getTime();
|
||||
return aLastUsed - bLastUsed; // 最久未使用的优先
|
||||
});
|
||||
|
||||
const selectedAccountId = sortedAccounts[0].id;
|
||||
@@ -544,11 +544,11 @@ class ClaudeAccountService {
|
||||
return aRateLimitedAt - bRateLimitedAt; // 最早限流的优先
|
||||
});
|
||||
} else {
|
||||
// 非限流账户按最近刷新时间排序
|
||||
// 非限流账户按最后使用时间排序(最久未使用的优先)
|
||||
candidateAccounts = candidateAccounts.sort((a, b) => {
|
||||
const aLastRefresh = new Date(a.lastRefreshAt || 0).getTime();
|
||||
const bLastRefresh = new Date(b.lastRefreshAt || 0).getTime();
|
||||
return bLastRefresh - aLastRefresh;
|
||||
const aLastUsed = new Date(a.lastUsedAt || 0).getTime();
|
||||
const bLastUsed = new Date(b.lastUsedAt || 0).getTime();
|
||||
return aLastUsed - bLastUsed; // 最久未使用的优先
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -181,6 +181,8 @@ class ClaudeRelayService {
|
||||
|
||||
logger.info(`✅ API request completed - Key: ${apiKeyData.name}, Account: ${accountId}, Model: ${requestBody.model}, Input: ~${Math.round(inputTokens)} tokens, Output: ~${Math.round(outputTokens)} tokens`);
|
||||
|
||||
// 在响应中添加accountId,以便调用方记录账户级别统计
|
||||
response.accountId = accountId;
|
||||
return response;
|
||||
} catch (error) {
|
||||
logger.error(`❌ Claude relay request failed for key: ${apiKeyData.name || apiKeyData.id}:`, error.message);
|
||||
@@ -619,7 +621,10 @@ class ClaudeRelayService {
|
||||
const proxyAgent = await this._getProxyAgent(accountId);
|
||||
|
||||
// 发送流式请求并捕获usage数据
|
||||
return await this._makeClaudeStreamRequestWithUsageCapture(processedBody, accessToken, proxyAgent, clientHeaders, responseStream, usageCallback, accountId, sessionHash, streamTransformer, options);
|
||||
return await this._makeClaudeStreamRequestWithUsageCapture(processedBody, accessToken, proxyAgent, clientHeaders, responseStream, (usageData) => {
|
||||
// 在usageCallback中添加accountId
|
||||
usageCallback({ ...usageData, accountId });
|
||||
}, accountId, sessionHash, streamTransformer, options);
|
||||
} catch (error) {
|
||||
logger.error('❌ Claude stream relay with usage capture failed:', error);
|
||||
throw error;
|
||||
|
||||
182
src/services/costInitService.js
Normal file
182
src/services/costInitService.js
Normal 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();
|
||||
@@ -107,7 +107,7 @@ const securityLogger = winston.createLogger({
|
||||
|
||||
// 🌟 增强的 Winston logger
|
||||
const logger = winston.createLogger({
|
||||
level: config.logging.level,
|
||||
level: process.env.LOG_LEVEL || config.logging.level,
|
||||
format: logFormat,
|
||||
transports: [
|
||||
// 📄 文件输出
|
||||
@@ -282,10 +282,11 @@ logger.healthCheck = () => {
|
||||
|
||||
// 🎬 启动日志记录系统
|
||||
logger.start('Logger initialized', {
|
||||
level: config.logging.level,
|
||||
level: process.env.LOG_LEVEL || config.logging.level,
|
||||
directory: config.logging.dirname,
|
||||
maxSize: config.logging.maxSize,
|
||||
maxFiles: config.logging.maxFiles
|
||||
maxFiles: config.logging.maxFiles,
|
||||
envOverride: process.env.LOG_LEVEL ? true : false
|
||||
});
|
||||
|
||||
module.exports = logger;
|
||||
708
web/admin/app.js
708
web/admin/app.js
@@ -1,3 +1,4 @@
|
||||
/* global Vue, Chart, ElementPlus, ElementPlusLocaleZhCn, FileReader, document, localStorage, location, navigator, window */
|
||||
const { createApp } = Vue;
|
||||
|
||||
const app = createApp({
|
||||
@@ -24,7 +25,8 @@ const app = createApp({
|
||||
{ key: 'dashboard', name: '仪表板', icon: 'fas fa-tachometer-alt' },
|
||||
{ key: 'apiKeys', name: 'API Keys', icon: 'fas fa-key' },
|
||||
{ key: 'accounts', name: '账户管理', icon: 'fas fa-user-circle' },
|
||||
{ key: 'tutorial', name: '使用教程', icon: 'fas fa-graduation-cap' }
|
||||
{ key: 'tutorial', name: '使用教程', icon: 'fas fa-graduation-cap' },
|
||||
{ key: 'settings', name: '其他设置', icon: 'fas fa-cogs' }
|
||||
],
|
||||
|
||||
// 教程系统选择
|
||||
@@ -111,6 +113,9 @@ const app = createApp({
|
||||
// API Keys
|
||||
apiKeys: [],
|
||||
apiKeysLoading: false,
|
||||
apiKeyStatsTimeRange: 'all', // API Key统计时间范围:all, 7days, monthly
|
||||
apiKeysSortBy: '', // 当前排序字段
|
||||
apiKeysSortOrder: 'asc', // 排序顺序 'asc' 或 'desc'
|
||||
showCreateApiKeyModal: false,
|
||||
createApiKeyLoading: false,
|
||||
apiKeyForm: {
|
||||
@@ -125,7 +130,13 @@ const app = createApp({
|
||||
permissions: 'all', // 'claude', 'gemini', 'all'
|
||||
enableModelRestriction: false,
|
||||
restrictedModels: [],
|
||||
modelInput: ''
|
||||
modelInput: '',
|
||||
enableClientRestriction: false,
|
||||
allowedClients: [],
|
||||
expireDuration: '', // 过期时长选择
|
||||
customExpireDate: '', // 自定义过期日期
|
||||
expiresAt: null, // 实际的过期时间戳
|
||||
dailyCostLimit: '' // 每日费用限制
|
||||
},
|
||||
apiKeyModelStats: {}, // 存储每个key的模型统计数据
|
||||
expandedApiKeys: {}, // 跟踪展开的API Keys
|
||||
@@ -155,6 +166,18 @@ const app = createApp({
|
||||
showFullKey: false
|
||||
},
|
||||
|
||||
// API Key续期
|
||||
showRenewApiKeyModal: false,
|
||||
renewApiKeyLoading: false,
|
||||
renewApiKeyForm: {
|
||||
id: '',
|
||||
name: '',
|
||||
currentExpiresAt: null,
|
||||
renewDuration: '30d',
|
||||
customExpireDate: '',
|
||||
newExpiresAt: null
|
||||
},
|
||||
|
||||
// 编辑API Key
|
||||
showEditApiKeyModal: false,
|
||||
editApiKeyLoading: false,
|
||||
@@ -170,12 +193,20 @@ const app = createApp({
|
||||
permissions: 'all',
|
||||
enableModelRestriction: false,
|
||||
restrictedModels: [],
|
||||
modelInput: ''
|
||||
modelInput: '',
|
||||
enableClientRestriction: false,
|
||||
allowedClients: [],
|
||||
dailyCostLimit: ''
|
||||
},
|
||||
|
||||
// 支持的客户端列表
|
||||
supportedClients: [],
|
||||
|
||||
// 账户
|
||||
accounts: [],
|
||||
accountsLoading: false,
|
||||
accountSortBy: 'dailyTokens', // 默认按今日Token排序
|
||||
accountsSortOrder: 'asc', // 排序顺序 'asc' 或 'desc'
|
||||
showCreateAccountModal: false,
|
||||
createAccountLoading: false,
|
||||
accountForm: {
|
||||
@@ -269,7 +300,17 @@ const app = createApp({
|
||||
showReleaseNotes: false, // 是否显示发布说明
|
||||
autoCheckInterval: null, // 自动检查定时器
|
||||
noUpdateMessage: false // 显示"已是最新版"提醒
|
||||
}
|
||||
},
|
||||
|
||||
// OEM设置相关
|
||||
oemSettings: {
|
||||
siteName: 'Claude Relay Service',
|
||||
siteIcon: '',
|
||||
siteIconData: '', // Base64图标数据
|
||||
updatedAt: null
|
||||
},
|
||||
oemSettingsLoading: false,
|
||||
oemSettingsSaving: false
|
||||
}
|
||||
},
|
||||
|
||||
@@ -279,17 +320,104 @@ const app = createApp({
|
||||
return `${window.location.protocol}//${window.location.host}/api/`;
|
||||
},
|
||||
|
||||
// 排序后的账户列表
|
||||
sortedAccounts() {
|
||||
if (!this.accountsSortBy) {
|
||||
return this.accounts;
|
||||
}
|
||||
|
||||
return [...this.accounts].sort((a, b) => {
|
||||
let aValue = a[this.accountsSortBy];
|
||||
let bValue = b[this.accountsSortBy];
|
||||
|
||||
// 特殊处理状态字段
|
||||
if (this.accountsSortBy === 'status') {
|
||||
aValue = a.isActive ? 1 : 0;
|
||||
bValue = b.isActive ? 1 : 0;
|
||||
}
|
||||
|
||||
// 处理字符串比较
|
||||
if (typeof aValue === 'string' && typeof bValue === 'string') {
|
||||
aValue = aValue.toLowerCase();
|
||||
bValue = bValue.toLowerCase();
|
||||
}
|
||||
|
||||
// 排序
|
||||
if (this.accountsSortOrder === 'asc') {
|
||||
return aValue > bValue ? 1 : aValue < bValue ? -1 : 0;
|
||||
} else {
|
||||
return aValue < bValue ? 1 : aValue > bValue ? -1 : 0;
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
// 排序后的API Keys列表
|
||||
sortedApiKeys() {
|
||||
if (!this.apiKeysSortBy) {
|
||||
return this.apiKeys;
|
||||
}
|
||||
|
||||
return [...this.apiKeys].sort((a, b) => {
|
||||
let aValue, bValue;
|
||||
|
||||
// 特殊处理不同字段
|
||||
switch (this.apiKeysSortBy) {
|
||||
case 'status':
|
||||
aValue = a.isActive ? 1 : 0;
|
||||
bValue = b.isActive ? 1 : 0;
|
||||
break;
|
||||
case 'cost':
|
||||
// 计算费用,转换为数字比较
|
||||
aValue = this.calculateApiKeyCostNumber(a.usage);
|
||||
bValue = this.calculateApiKeyCostNumber(b.usage);
|
||||
break;
|
||||
case 'createdAt':
|
||||
case 'expiresAt':
|
||||
// 日期比较
|
||||
aValue = a[this.apiKeysSortBy] ? new Date(a[this.apiKeysSortBy]).getTime() : 0;
|
||||
bValue = b[this.apiKeysSortBy] ? new Date(b[this.apiKeysSortBy]).getTime() : 0;
|
||||
break;
|
||||
default:
|
||||
aValue = a[this.apiKeysSortBy];
|
||||
bValue = b[this.apiKeysSortBy];
|
||||
|
||||
// 处理字符串比较
|
||||
if (typeof aValue === 'string' && typeof bValue === 'string') {
|
||||
aValue = aValue.toLowerCase();
|
||||
bValue = bValue.toLowerCase();
|
||||
}
|
||||
}
|
||||
|
||||
// 排序
|
||||
if (this.apiKeysSortOrder === 'asc') {
|
||||
return aValue > bValue ? 1 : aValue < bValue ? -1 : 0;
|
||||
} else {
|
||||
return aValue < bValue ? 1 : aValue > bValue ? -1 : 0;
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
// 获取专属账号列表
|
||||
dedicatedAccounts() {
|
||||
return this.accounts.filter(account =>
|
||||
account.accountType === 'dedicated' && account.isActive === true
|
||||
);
|
||||
},
|
||||
|
||||
// 计算最小日期时间(当前时间)
|
||||
minDateTime() {
|
||||
const now = new Date();
|
||||
now.setMinutes(now.getMinutes() - now.getTimezoneOffset());
|
||||
return now.toISOString().slice(0, 16);
|
||||
}
|
||||
},
|
||||
|
||||
mounted() {
|
||||
console.log('Vue app mounted, authToken:', !!this.authToken, 'activeTab:', this.activeTab);
|
||||
|
||||
// 从URL参数中读取tab信息
|
||||
this.initializeTabFromUrl();
|
||||
|
||||
// 初始化防抖函数
|
||||
this.setTrendPeriod = this.debounce(this._setTrendPeriod, 300);
|
||||
|
||||
@@ -303,6 +431,11 @@ const app = createApp({
|
||||
}
|
||||
});
|
||||
|
||||
// 监听浏览器前进后退按钮事件
|
||||
window.addEventListener('popstate', () => {
|
||||
this.initializeTabFromUrl();
|
||||
});
|
||||
|
||||
if (this.authToken) {
|
||||
this.isLoggedIn = true;
|
||||
|
||||
@@ -315,14 +448,16 @@ const app = createApp({
|
||||
// 初始化日期筛选器和图表数据
|
||||
this.initializeDateFilter();
|
||||
|
||||
// 预加载账号列表和API Keys,以便正确显示绑定关系
|
||||
// 预加载账号列表、API Keys和支持的客户端,以便正确显示绑定关系
|
||||
Promise.all([
|
||||
this.loadAccounts(),
|
||||
this.loadApiKeys()
|
||||
this.loadApiKeys(),
|
||||
this.loadSupportedClients()
|
||||
]).then(() => {
|
||||
// 根据当前活跃标签页加载数据
|
||||
this.loadCurrentTabData();
|
||||
});
|
||||
|
||||
// 如果在仪表盘,等待Chart.js加载后初始化图表
|
||||
if (this.activeTab === 'dashboard') {
|
||||
this.waitForChartJS().then(() => {
|
||||
@@ -334,6 +469,9 @@ const app = createApp({
|
||||
} else {
|
||||
console.log('No auth token found, user needs to login');
|
||||
}
|
||||
|
||||
// 始终加载OEM设置,无论登录状态
|
||||
this.loadOemSettings();
|
||||
},
|
||||
|
||||
beforeUnmount() {
|
||||
@@ -368,6 +506,64 @@ const app = createApp({
|
||||
},
|
||||
|
||||
methods: {
|
||||
// 账户列表排序
|
||||
sortAccounts(field) {
|
||||
if (this.accountsSortBy === field) {
|
||||
// 如果点击的是当前排序字段,切换排序顺序
|
||||
this.accountsSortOrder = this.accountsSortOrder === 'asc' ? 'desc' : 'asc';
|
||||
} else {
|
||||
// 如果点击的是新字段,设置为升序
|
||||
this.accountsSortBy = field;
|
||||
this.accountsSortOrder = 'asc';
|
||||
}
|
||||
},
|
||||
|
||||
// API Keys列表排序
|
||||
sortApiKeys(field) {
|
||||
if (this.apiKeysSortBy === field) {
|
||||
// 如果点击的是当前排序字段,切换排序顺序
|
||||
this.apiKeysSortOrder = this.apiKeysSortOrder === 'asc' ? 'desc' : 'asc';
|
||||
} else {
|
||||
// 如果点击的是新字段,设置为升序
|
||||
this.apiKeysSortBy = field;
|
||||
this.apiKeysSortOrder = 'asc';
|
||||
}
|
||||
},
|
||||
|
||||
// 从URL读取tab参数并设置activeTab
|
||||
initializeTabFromUrl() {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const tabParam = urlParams.get('tab');
|
||||
|
||||
// 检查tab参数是否有效
|
||||
const validTabs = this.tabs.map(tab => tab.key);
|
||||
if (tabParam && validTabs.includes(tabParam)) {
|
||||
this.activeTab = tabParam;
|
||||
}
|
||||
},
|
||||
|
||||
// 切换tab并更新URL
|
||||
switchTab(tabKey) {
|
||||
if (this.activeTab !== tabKey) {
|
||||
this.activeTab = tabKey;
|
||||
this.updateUrlTab(tabKey);
|
||||
}
|
||||
},
|
||||
|
||||
// 更新URL中的tab参数
|
||||
updateUrlTab(tabKey) {
|
||||
const url = new URL(window.location.href);
|
||||
if (tabKey === 'dashboard') {
|
||||
// 如果是默认的dashboard标签,移除tab参数
|
||||
url.searchParams.delete('tab');
|
||||
} else {
|
||||
url.searchParams.set('tab', tabKey);
|
||||
}
|
||||
|
||||
// 使用pushState更新URL但不刷新页面
|
||||
window.history.pushState({}, '', url.toString());
|
||||
},
|
||||
|
||||
// 统一的API请求方法,处理token过期等错误
|
||||
async apiRequest(url, options = {}) {
|
||||
try {
|
||||
@@ -527,6 +723,86 @@ const app = createApp({
|
||||
});
|
||||
},
|
||||
|
||||
// 更新过期时间
|
||||
updateExpireAt() {
|
||||
const duration = this.apiKeyForm.expireDuration;
|
||||
if (!duration) {
|
||||
this.apiKeyForm.expiresAt = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (duration === 'custom') {
|
||||
// 自定义日期需要用户选择
|
||||
return;
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const durationMap = {
|
||||
'1d': 1,
|
||||
'7d': 7,
|
||||
'30d': 30,
|
||||
'90d': 90,
|
||||
'180d': 180,
|
||||
'365d': 365
|
||||
};
|
||||
|
||||
const days = durationMap[duration];
|
||||
if (days) {
|
||||
const expireDate = new Date(now.getTime() + days * 24 * 60 * 60 * 1000);
|
||||
this.apiKeyForm.expiresAt = expireDate.toISOString();
|
||||
}
|
||||
},
|
||||
|
||||
// 更新自定义过期时间
|
||||
updateCustomExpireAt() {
|
||||
if (this.apiKeyForm.customExpireDate) {
|
||||
const expireDate = new Date(this.apiKeyForm.customExpireDate);
|
||||
this.apiKeyForm.expiresAt = expireDate.toISOString();
|
||||
}
|
||||
},
|
||||
|
||||
// 格式化过期日期
|
||||
formatExpireDate(dateString) {
|
||||
if (!dateString) return '';
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
},
|
||||
|
||||
// 格式化日期时间
|
||||
formatDateTime(dateString) {
|
||||
if (!dateString) return '';
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
});
|
||||
},
|
||||
|
||||
// 检查 API Key 是否已过期
|
||||
isApiKeyExpired(expiresAt) {
|
||||
if (!expiresAt) return false;
|
||||
return new Date(expiresAt) < new Date();
|
||||
},
|
||||
|
||||
// 检查 API Key 是否即将过期(7天内)
|
||||
isApiKeyExpiringSoon(expiresAt) {
|
||||
if (!expiresAt) return false;
|
||||
const expireDate = new Date(expiresAt);
|
||||
const now = new Date();
|
||||
const daysUntilExpire = (expireDate - now) / (1000 * 60 * 60 * 24);
|
||||
return daysUntilExpire > 0 && daysUntilExpire <= 7;
|
||||
},
|
||||
|
||||
// 打开创建账户模态框
|
||||
openCreateAccountModal() {
|
||||
console.log('Opening Account modal...');
|
||||
@@ -1242,6 +1518,12 @@ const app = createApp({
|
||||
case 'tutorial':
|
||||
// 教程页面不需要加载数据
|
||||
break;
|
||||
case 'settings':
|
||||
// OEM 设置已在 mounted 时加载,避免重复加载
|
||||
if (!this.oemSettings.siteName && !this.oemSettings.siteIcon && !this.oemSettings.siteIconData) {
|
||||
this.loadOemSettings();
|
||||
}
|
||||
break;
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1647,11 +1929,23 @@ const app = createApp({
|
||||
}
|
||||
},
|
||||
|
||||
async loadSupportedClients() {
|
||||
try {
|
||||
const data = await this.apiRequest('/admin/supported-clients');
|
||||
if (data && data.success) {
|
||||
this.supportedClients = data.data || [];
|
||||
console.log('Loaded supported clients:', this.supportedClients);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load supported clients:', error);
|
||||
}
|
||||
},
|
||||
|
||||
async loadApiKeys() {
|
||||
this.apiKeysLoading = true;
|
||||
console.log('Loading API Keys...');
|
||||
console.log('Loading API Keys with time range:', this.apiKeyStatsTimeRange);
|
||||
try {
|
||||
const data = await this.apiRequest('/admin/api-keys');
|
||||
const data = await this.apiRequest(`/admin/api-keys?timeRange=${this.apiKeyStatsTimeRange}`);
|
||||
|
||||
if (!data) {
|
||||
// 如果token过期,apiRequest会返回null并刷新页面
|
||||
@@ -1737,6 +2031,9 @@ const app = createApp({
|
||||
account.boundApiKeysCount = this.apiKeys.filter(key => key.geminiAccountId === account.id).length;
|
||||
}
|
||||
});
|
||||
|
||||
// 加载完成后自动排序
|
||||
this.sortAccounts();
|
||||
} catch (error) {
|
||||
console.error('Failed to load accounts:', error);
|
||||
} finally {
|
||||
@@ -1744,6 +2041,35 @@ const app = createApp({
|
||||
}
|
||||
},
|
||||
|
||||
// 账户排序
|
||||
sortAccounts() {
|
||||
if (!this.accounts || this.accounts.length === 0) return;
|
||||
|
||||
this.accounts.sort((a, b) => {
|
||||
switch (this.accountSortBy) {
|
||||
case 'name':
|
||||
return a.name.localeCompare(b.name);
|
||||
case 'dailyTokens':
|
||||
const aTokens = (a.usage && a.usage.daily && a.usage.daily.allTokens) || 0;
|
||||
const bTokens = (b.usage && b.usage.daily && b.usage.daily.allTokens) || 0;
|
||||
return bTokens - aTokens; // 降序
|
||||
case 'dailyRequests':
|
||||
const aRequests = (a.usage && a.usage.daily && a.usage.daily.requests) || 0;
|
||||
const bRequests = (b.usage && b.usage.daily && b.usage.daily.requests) || 0;
|
||||
return bRequests - aRequests; // 降序
|
||||
case 'totalTokens':
|
||||
const aTotalTokens = (a.usage && a.usage.total && a.usage.total.allTokens) || 0;
|
||||
const bTotalTokens = (b.usage && b.usage.total && b.usage.total.allTokens) || 0;
|
||||
return bTotalTokens - aTotalTokens; // 降序
|
||||
case 'lastUsed':
|
||||
const aLastUsed = a.lastUsedAt ? new Date(a.lastUsedAt) : new Date(0);
|
||||
const bLastUsed = b.lastUsedAt ? new Date(b.lastUsedAt) : new Date(0);
|
||||
return bLastUsed - aLastUsed; // 降序(最近使用的在前)
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
async loadModelStats() {
|
||||
this.modelStatsLoading = true;
|
||||
@@ -1775,16 +2101,20 @@ const app = createApp({
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
name: this.apiKeyForm.name,
|
||||
tokenLimit: this.apiKeyForm.tokenLimit && this.apiKeyForm.tokenLimit.trim() ? parseInt(this.apiKeyForm.tokenLimit) : null,
|
||||
tokenLimit: this.apiKeyForm.tokenLimit && this.apiKeyForm.tokenLimit.toString().trim() ? parseInt(this.apiKeyForm.tokenLimit) : null,
|
||||
description: this.apiKeyForm.description || '',
|
||||
concurrencyLimit: this.apiKeyForm.concurrencyLimit && this.apiKeyForm.concurrencyLimit.trim() ? parseInt(this.apiKeyForm.concurrencyLimit) : 0,
|
||||
rateLimitWindow: this.apiKeyForm.rateLimitWindow && this.apiKeyForm.rateLimitWindow.trim() ? parseInt(this.apiKeyForm.rateLimitWindow) : null,
|
||||
rateLimitRequests: this.apiKeyForm.rateLimitRequests && this.apiKeyForm.rateLimitRequests.trim() ? parseInt(this.apiKeyForm.rateLimitRequests) : null,
|
||||
concurrencyLimit: this.apiKeyForm.concurrencyLimit && this.apiKeyForm.concurrencyLimit.toString().trim() ? parseInt(this.apiKeyForm.concurrencyLimit) : 0,
|
||||
rateLimitWindow: this.apiKeyForm.rateLimitWindow && this.apiKeyForm.rateLimitWindow.toString().trim() ? parseInt(this.apiKeyForm.rateLimitWindow) : null,
|
||||
rateLimitRequests: this.apiKeyForm.rateLimitRequests && this.apiKeyForm.rateLimitRequests.toString().trim() ? parseInt(this.apiKeyForm.rateLimitRequests) : null,
|
||||
claudeAccountId: this.apiKeyForm.claudeAccountId || null,
|
||||
geminiAccountId: this.apiKeyForm.geminiAccountId || null,
|
||||
permissions: this.apiKeyForm.permissions || 'all',
|
||||
enableModelRestriction: this.apiKeyForm.enableModelRestriction,
|
||||
restrictedModels: this.apiKeyForm.restrictedModels
|
||||
restrictedModels: this.apiKeyForm.restrictedModels,
|
||||
enableClientRestriction: this.apiKeyForm.enableClientRestriction,
|
||||
allowedClients: this.apiKeyForm.allowedClients,
|
||||
expiresAt: this.apiKeyForm.expiresAt,
|
||||
dailyCostLimit: this.apiKeyForm.dailyCostLimit && this.apiKeyForm.dailyCostLimit.toString().trim() ? parseFloat(this.apiKeyForm.dailyCostLimit) : 0
|
||||
})
|
||||
});
|
||||
|
||||
@@ -1805,7 +2135,26 @@ const app = createApp({
|
||||
|
||||
// 关闭创建弹窗并清理表单
|
||||
this.showCreateApiKeyModal = false;
|
||||
this.apiKeyForm = { name: '', tokenLimit: '', description: '', concurrencyLimit: '', rateLimitWindow: '', rateLimitRequests: '', claudeAccountId: '', enableModelRestriction: false, restrictedModels: [], modelInput: '' };
|
||||
this.apiKeyForm = {
|
||||
name: '',
|
||||
tokenLimit: '',
|
||||
description: '',
|
||||
concurrencyLimit: '',
|
||||
rateLimitWindow: '',
|
||||
rateLimitRequests: '',
|
||||
claudeAccountId: '',
|
||||
geminiAccountId: '',
|
||||
permissions: 'all',
|
||||
enableModelRestriction: false,
|
||||
restrictedModels: [],
|
||||
modelInput: '',
|
||||
enableClientRestriction: false,
|
||||
allowedClients: [],
|
||||
expireDuration: '',
|
||||
customExpireDate: '',
|
||||
expiresAt: null,
|
||||
dailyCostLimit: ''
|
||||
};
|
||||
|
||||
// 重新加载API Keys列表
|
||||
await this.loadApiKeys();
|
||||
@@ -1851,6 +2200,111 @@ const app = createApp({
|
||||
}
|
||||
},
|
||||
|
||||
// 打开续期弹窗
|
||||
openRenewApiKeyModal(key) {
|
||||
this.renewApiKeyForm = {
|
||||
id: key.id,
|
||||
name: key.name,
|
||||
currentExpiresAt: key.expiresAt,
|
||||
renewDuration: '30d',
|
||||
customExpireDate: '',
|
||||
newExpiresAt: null
|
||||
};
|
||||
this.showRenewApiKeyModal = true;
|
||||
// 立即计算新的过期时间
|
||||
this.updateRenewExpireAt();
|
||||
},
|
||||
|
||||
// 关闭续期弹窗
|
||||
closeRenewApiKeyModal() {
|
||||
this.showRenewApiKeyModal = false;
|
||||
this.renewApiKeyForm = {
|
||||
id: '',
|
||||
name: '',
|
||||
currentExpiresAt: null,
|
||||
renewDuration: '30d',
|
||||
customExpireDate: '',
|
||||
newExpiresAt: null
|
||||
};
|
||||
},
|
||||
|
||||
// 更新续期过期时间
|
||||
updateRenewExpireAt() {
|
||||
const duration = this.renewApiKeyForm.renewDuration;
|
||||
|
||||
if (duration === 'permanent') {
|
||||
this.renewApiKeyForm.newExpiresAt = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (duration === 'custom') {
|
||||
// 自定义日期需要用户选择
|
||||
return;
|
||||
}
|
||||
|
||||
// 计算新的过期时间
|
||||
const baseTime = this.renewApiKeyForm.currentExpiresAt
|
||||
? new Date(this.renewApiKeyForm.currentExpiresAt)
|
||||
: new Date();
|
||||
|
||||
// 如果当前已过期,从现在开始计算
|
||||
if (baseTime < new Date()) {
|
||||
baseTime.setTime(new Date().getTime());
|
||||
}
|
||||
|
||||
const durationMap = {
|
||||
'7d': 7,
|
||||
'30d': 30,
|
||||
'90d': 90,
|
||||
'180d': 180,
|
||||
'365d': 365
|
||||
};
|
||||
|
||||
const days = durationMap[duration];
|
||||
if (days) {
|
||||
const expireDate = new Date(baseTime.getTime() + days * 24 * 60 * 60 * 1000);
|
||||
this.renewApiKeyForm.newExpiresAt = expireDate.toISOString();
|
||||
}
|
||||
},
|
||||
|
||||
// 更新自定义续期时间
|
||||
updateCustomRenewExpireAt() {
|
||||
if (this.renewApiKeyForm.customExpireDate) {
|
||||
const expireDate = new Date(this.renewApiKeyForm.customExpireDate);
|
||||
this.renewApiKeyForm.newExpiresAt = expireDate.toISOString();
|
||||
}
|
||||
},
|
||||
|
||||
// 执行续期操作
|
||||
async renewApiKey() {
|
||||
this.renewApiKeyLoading = true;
|
||||
try {
|
||||
const data = await this.apiRequest('/admin/api-keys/' + this.renewApiKeyForm.id, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
expiresAt: this.renewApiKeyForm.newExpiresAt
|
||||
})
|
||||
});
|
||||
|
||||
if (!data) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.success) {
|
||||
this.showToast('API Key 续期成功', 'success');
|
||||
this.closeRenewApiKeyModal();
|
||||
await this.loadApiKeys();
|
||||
} else {
|
||||
this.showToast(data.message || '续期失败', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error renewing API key:', error);
|
||||
this.showToast('续期失败,请检查网络连接', 'error');
|
||||
} finally {
|
||||
this.renewApiKeyLoading = false;
|
||||
}
|
||||
},
|
||||
|
||||
openEditApiKeyModal(key) {
|
||||
this.editApiKeyForm = {
|
||||
id: key.id,
|
||||
@@ -1864,7 +2318,10 @@ const app = createApp({
|
||||
permissions: key.permissions || 'all',
|
||||
enableModelRestriction: key.enableModelRestriction || false,
|
||||
restrictedModels: key.restrictedModels ? [...key.restrictedModels] : [],
|
||||
modelInput: ''
|
||||
modelInput: '',
|
||||
enableClientRestriction: key.enableClientRestriction || false,
|
||||
allowedClients: key.allowedClients ? [...key.allowedClients] : [],
|
||||
dailyCostLimit: key.dailyCostLimit || ''
|
||||
};
|
||||
this.showEditApiKeyModal = true;
|
||||
},
|
||||
@@ -1883,7 +2340,10 @@ const app = createApp({
|
||||
permissions: 'all',
|
||||
enableModelRestriction: false,
|
||||
restrictedModels: [],
|
||||
modelInput: ''
|
||||
modelInput: '',
|
||||
enableClientRestriction: false,
|
||||
allowedClients: [],
|
||||
dailyCostLimit: ''
|
||||
};
|
||||
},
|
||||
|
||||
@@ -1901,7 +2361,10 @@ const app = createApp({
|
||||
geminiAccountId: this.editApiKeyForm.geminiAccountId || null,
|
||||
permissions: this.editApiKeyForm.permissions || 'all',
|
||||
enableModelRestriction: this.editApiKeyForm.enableModelRestriction,
|
||||
restrictedModels: this.editApiKeyForm.restrictedModels
|
||||
restrictedModels: this.editApiKeyForm.restrictedModels,
|
||||
enableClientRestriction: this.editApiKeyForm.enableClientRestriction,
|
||||
allowedClients: this.editApiKeyForm.allowedClients,
|
||||
dailyCostLimit: this.editApiKeyForm.dailyCostLimit && this.editApiKeyForm.dailyCostLimit.toString().trim() !== '' ? parseFloat(this.editApiKeyForm.dailyCostLimit) : 0
|
||||
})
|
||||
});
|
||||
|
||||
@@ -2068,7 +2531,11 @@ const app = createApp({
|
||||
// 格式化数字,添加千分符
|
||||
formatNumber(num) {
|
||||
if (num === null || num === undefined) return '0';
|
||||
return Number(num).toLocaleString();
|
||||
const number = Number(num);
|
||||
if (number >= 1000000) {
|
||||
return Math.floor(number / 1000000).toLocaleString() + 'M';
|
||||
}
|
||||
return number.toLocaleString();
|
||||
},
|
||||
|
||||
// 格式化运行时间
|
||||
@@ -2889,23 +3356,26 @@ const app = createApp({
|
||||
calculateApiKeyCost(usage) {
|
||||
if (!usage || !usage.total) return '$0.000000';
|
||||
|
||||
// 使用通用模型价格估算
|
||||
const totalInputTokens = usage.total.inputTokens || 0;
|
||||
const totalOutputTokens = usage.total.outputTokens || 0;
|
||||
const totalCacheCreateTokens = usage.total.cacheCreateTokens || 0;
|
||||
const totalCacheReadTokens = usage.total.cacheReadTokens || 0;
|
||||
// 使用后端返回的准确费用数据
|
||||
if (usage.total.formattedCost) {
|
||||
return usage.total.formattedCost;
|
||||
}
|
||||
|
||||
// 简单估算(使用Claude 3.5 Sonnet价格)
|
||||
const inputCost = (totalInputTokens / 1000000) * 3.00;
|
||||
const outputCost = (totalOutputTokens / 1000000) * 15.00;
|
||||
const cacheCreateCost = (totalCacheCreateTokens / 1000000) * 3.75;
|
||||
const cacheReadCost = (totalCacheReadTokens / 1000000) * 0.30;
|
||||
// 如果没有后端费用数据,返回默认值
|
||||
return '$0.000000';
|
||||
},
|
||||
|
||||
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);
|
||||
return '$' + totalCost.toFixed(4);
|
||||
// 使用后端返回的准确费用数据
|
||||
if (usage.total.cost) {
|
||||
return usage.total.cost;
|
||||
}
|
||||
|
||||
// 如果没有后端费用数据,返回0
|
||||
return 0;
|
||||
},
|
||||
|
||||
// 初始化日期筛选器
|
||||
@@ -3531,6 +4001,180 @@ const app = createApp({
|
||||
});
|
||||
|
||||
this.showToast('已重置筛选条件并刷新数据', 'info', '重置成功');
|
||||
},
|
||||
|
||||
// OEM设置相关方法
|
||||
async loadOemSettings() {
|
||||
this.oemSettingsLoading = true;
|
||||
try {
|
||||
const result = await this.apiRequest('/admin/oem-settings');
|
||||
if (result && result.success) {
|
||||
this.oemSettings = { ...this.oemSettings, ...result.data };
|
||||
|
||||
// 应用设置到页面
|
||||
this.applyOemSettings();
|
||||
} else {
|
||||
// 如果请求失败但不是因为认证问题,使用默认值
|
||||
console.warn('Failed to load OEM settings, using defaults');
|
||||
this.applyOemSettings();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading OEM settings:', error);
|
||||
// 加载失败时也应用默认值,确保页面正常显示
|
||||
this.applyOemSettings();
|
||||
} finally {
|
||||
this.oemSettingsLoading = false;
|
||||
}
|
||||
},
|
||||
|
||||
async saveOemSettings() {
|
||||
// 验证输入
|
||||
if (!this.oemSettings.siteName || this.oemSettings.siteName.trim() === '') {
|
||||
this.showToast('网站名称不能为空', 'error', '验证失败');
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.oemSettings.siteName.length > 100) {
|
||||
this.showToast('网站名称不能超过100个字符', 'error', '验证失败');
|
||||
return;
|
||||
}
|
||||
|
||||
this.oemSettingsSaving = true;
|
||||
try {
|
||||
const result = await this.apiRequest('/admin/oem-settings', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
siteName: this.oemSettings.siteName.trim(),
|
||||
siteIcon: this.oemSettings.siteIcon.trim(),
|
||||
siteIconData: this.oemSettings.siteIconData.trim()
|
||||
})
|
||||
});
|
||||
|
||||
if (result && result.success) {
|
||||
this.oemSettings = { ...this.oemSettings, ...result.data };
|
||||
this.showToast('OEM设置保存成功', 'success', '保存成功');
|
||||
|
||||
// 应用设置到页面
|
||||
this.applyOemSettings();
|
||||
} else {
|
||||
this.showToast(result?.message || '保存失败', 'error', '保存失败');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error saving OEM settings:', error);
|
||||
this.showToast('保存OEM设置失败', 'error', '保存失败');
|
||||
} finally {
|
||||
this.oemSettingsSaving = false;
|
||||
}
|
||||
},
|
||||
|
||||
applyOemSettings() {
|
||||
// 更新网站标题
|
||||
document.title = `${this.oemSettings.siteName} - 管理后台`;
|
||||
|
||||
// 更新页面中的所有网站名称
|
||||
const titleElements = document.querySelectorAll('.header-title');
|
||||
titleElements.forEach(el => {
|
||||
el.textContent = this.oemSettings.siteName;
|
||||
});
|
||||
|
||||
// 应用自定义CSS
|
||||
this.applyCustomCss();
|
||||
|
||||
// 应用网站图标
|
||||
this.applyFavicon();
|
||||
},
|
||||
|
||||
applyCustomCss() {
|
||||
// 移除之前的自定义CSS
|
||||
const existingStyle = document.getElementById('custom-oem-css');
|
||||
if (existingStyle) {
|
||||
existingStyle.remove();
|
||||
}
|
||||
},
|
||||
|
||||
applyFavicon() {
|
||||
const iconData = this.oemSettings.siteIconData || this.oemSettings.siteIcon;
|
||||
if (iconData && iconData.trim()) {
|
||||
// 移除现有的favicon
|
||||
const existingFavicons = document.querySelectorAll('link[rel*="icon"]');
|
||||
existingFavicons.forEach(link => link.remove());
|
||||
|
||||
// 添加新的favicon
|
||||
const link = document.createElement('link');
|
||||
link.rel = 'icon';
|
||||
|
||||
// 根据数据类型设置适当的type
|
||||
if (iconData.startsWith('data:')) {
|
||||
// Base64数据
|
||||
link.href = iconData;
|
||||
} else {
|
||||
// URL
|
||||
link.type = 'image/x-icon';
|
||||
link.href = iconData;
|
||||
}
|
||||
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
},
|
||||
|
||||
resetOemSettings() {
|
||||
this.oemSettings = {
|
||||
siteName: 'Claude Relay Service',
|
||||
siteIcon: '',
|
||||
siteIconData: '',
|
||||
updatedAt: null
|
||||
};
|
||||
},
|
||||
|
||||
// 处理图标文件上传
|
||||
async handleIconUpload(event) {
|
||||
const file = event.target.files[0];
|
||||
if (!file) return;
|
||||
|
||||
// 验证文件大小
|
||||
if (file.size > 350 * 1024) { // 350KB
|
||||
this.showToast('图标文件大小不能超过350KB', 'error', '文件太大');
|
||||
return;
|
||||
}
|
||||
|
||||
// 验证文件类型
|
||||
const allowedTypes = ['image/x-icon', 'image/png', 'image/jpeg', 'image/svg+xml'];
|
||||
if (!allowedTypes.includes(file.type) && !file.name.endsWith('.ico')) {
|
||||
this.showToast('请选择有效的图标文件格式 (.ico, .png, .jpg, .svg)', 'error', '格式错误');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 读取文件为Base64
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
this.oemSettings.siteIconData = e.target.result;
|
||||
this.oemSettings.siteIcon = ''; // 清空URL
|
||||
this.showToast('图标上传成功', 'success', '上传成功');
|
||||
};
|
||||
reader.onerror = () => {
|
||||
this.showToast('图标文件读取失败', 'error', '读取失败');
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
} catch (error) {
|
||||
console.error('Icon upload error:', error);
|
||||
this.showToast('图标上传过程中出现错误', 'error', '上传失败');
|
||||
}
|
||||
},
|
||||
|
||||
// 移除图标
|
||||
removeIcon() {
|
||||
this.oemSettings.siteIcon = '';
|
||||
this.oemSettings.siteIconData = '';
|
||||
if (this.$refs.iconFileInput) {
|
||||
this.$refs.iconFileInput.value = '';
|
||||
}
|
||||
},
|
||||
|
||||
// 处理图标加载错误
|
||||
handleIconError(event) {
|
||||
console.error('Icon load error');
|
||||
event.target.style.display = 'none';
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -39,10 +39,15 @@
|
||||
<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="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">
|
||||
<i class="fas fa-cloud text-3xl text-gray-700"></i>
|
||||
<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">
|
||||
<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>
|
||||
<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>
|
||||
</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="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">
|
||||
<i class="fas fa-cloud text-xl text-gray-700"></i>
|
||||
<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">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">
|
||||
<span class="text-sm text-gray-400 font-mono">v{{ versionInfo.current || '...' }}</span>
|
||||
@@ -202,13 +212,13 @@
|
||||
</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">
|
||||
<button
|
||||
v-for="tab in tabs"
|
||||
: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',
|
||||
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-xs text-gray-500 mt-1">活跃: {{ dashboardData.activeApiKeys || 0 }}</p>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -245,7 +255,7 @@
|
||||
</span>
|
||||
</p>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -258,7 +268,7 @@
|
||||
<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>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -271,7 +281,7 @@
|
||||
<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>
|
||||
</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>
|
||||
</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="stat-card">
|
||||
<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>
|
||||
<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>
|
||||
<span class="text-sm text-green-600 font-medium">/ {{ costsData.todayCosts.formatted.totalCost }}</span>
|
||||
</div>
|
||||
@@ -297,7 +307,7 @@
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -305,9 +315,9 @@
|
||||
|
||||
<div class="stat-card">
|
||||
<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>
|
||||
<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>
|
||||
<span class="text-sm text-green-600 font-medium">/ {{ costsData.totalCosts.formatted.totalCost }}</span>
|
||||
</div>
|
||||
@@ -320,7 +330,7 @@
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -333,7 +343,7 @@
|
||||
<p class="text-3xl font-bold text-orange-600">{{ dashboardData.systemRPM || 0 }}</p>
|
||||
<p class="text-xs text-gray-500 mt-1">每分钟请求数</p>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -346,7 +356,7 @@
|
||||
<p class="text-3xl font-bold text-rose-600">{{ dashboardData.systemTPM || 0 }}</p>
|
||||
<p class="text-xs text-gray-500 mt-1">每分钟Token数</p>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -538,12 +548,25 @@
|
||||
<h3 class="text-xl font-bold text-gray-900 mb-2">API Keys 管理</h3>
|
||||
<p class="text-gray-600">管理和监控您的 API 密钥</p>
|
||||
</div>
|
||||
<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 class="flex items-center gap-3">
|
||||
<!-- Token统计时间范围选择 -->
|
||||
<select
|
||||
v-model="apiKeyStatsTimeRange"
|
||||
@change="loadApiKeys()"
|
||||
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 v-if="apiKeysLoading" class="text-center py-12">
|
||||
@@ -563,16 +586,40 @@
|
||||
<table class="min-w-full">
|
||||
<thead class="bg-gray-50/80 backdrop-blur-sm">
|
||||
<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">状态</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 cursor-pointer hover:bg-gray-100" @click="sortApiKeys('status')">
|
||||
状态
|
||||
<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>
|
||||
</tr>
|
||||
</thead>
|
||||
<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 主行 -->
|
||||
<tr class="table-row">
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
@@ -626,6 +673,13 @@
|
||||
<span class="text-gray-600">费用:</span>
|
||||
<span class="font-medium text-green-600">{{ calculateApiKeyCost(key.usage) }}</span>
|
||||
</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">
|
||||
<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.outputTokens) || 0) }}</span>
|
||||
</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 -->
|
||||
<div class="flex justify-between text-xs text-blue-600">
|
||||
<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">
|
||||
{{ new Date(key.createdAt).toLocaleDateString() }}
|
||||
</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">
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
@@ -686,6 +764,13 @@
|
||||
>
|
||||
<i class="fas fa-edit mr-1"></i>编辑
|
||||
</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
|
||||
@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"
|
||||
@@ -878,12 +963,21 @@
|
||||
<h3 class="text-xl font-bold text-gray-900 mb-2">账户管理</h3>
|
||||
<p class="text-gray-600">管理您的 Claude 和 Gemini 账户及代理配置</p>
|
||||
</div>
|
||||
<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 class="flex gap-2">
|
||||
<select v-model="accountSortBy" @change="sortAccounts()" class="form-input px-3 py-2 text-sm">
|
||||
<option value="name">按名称排序</option>
|
||||
<option value="dailyTokens">按今日Token排序</option>
|
||||
<option value="dailyRequests">按今日请求数排序</option>
|
||||
<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 v-if="accountsLoading" class="text-center py-12">
|
||||
@@ -903,17 +997,34 @@
|
||||
<table class="min-w-full">
|
||||
<thead class="bg-gray-50/80 backdrop-blur-sm">
|
||||
<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">平台</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 cursor-pointer hover:bg-gray-100" @click="sortAccounts('name')">
|
||||
名称
|
||||
<i v-if="accountsSortBy === 'name'" :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('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>
|
||||
</tr>
|
||||
</thead>
|
||||
<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">
|
||||
<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">
|
||||
@@ -980,6 +1091,22 @@
|
||||
</div>
|
||||
<div v-else class="text-gray-400">无代理</div>
|
||||
</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">
|
||||
{{ account.lastUsedAt ? new Date(account.lastUsedAt).toLocaleDateString() : '从未使用' }}
|
||||
</td>
|
||||
@@ -1884,6 +2011,138 @@
|
||||
</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>
|
||||
|
||||
@@ -1978,6 +2237,27 @@
|
||||
</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>
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-3">并发限制 (可选)</label>
|
||||
<input
|
||||
@@ -2000,6 +2280,36 @@
|
||||
></textarea>
|
||||
</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>
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-3">服务权限</label>
|
||||
<div class="flex gap-4">
|
||||
@@ -2131,6 +2441,43 @@
|
||||
</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">
|
||||
<button
|
||||
type="button"
|
||||
@@ -2245,6 +2592,27 @@
|
||||
</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>
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-3">并发限制</label>
|
||||
<input
|
||||
@@ -2388,6 +2756,43 @@
|
||||
</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">
|
||||
<button
|
||||
type="button"
|
||||
@@ -2410,6 +2815,92 @@
|
||||
</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 展示弹窗 -->
|
||||
<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">
|
||||
|
||||
689
web/apiStats/app.js
Normal file
689
web/apiStats/app.js
Normal 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
497
web/apiStats/index.html
Normal 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
870
web/apiStats/style.css
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user