Spaces:
Sleeping
Sleeping
lijunke commited on
Commit ·
18081cf
0
Parent(s):
deploy: clean start with hf metadata
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .env.example +36 -0
- .gitattributes +2 -0
- .github/workflows/docker-build.yml +55 -0
- .gitignore +50 -0
- Dockerfile +68 -0
- LICENSE +21 -0
- README.md +388 -0
- core/__init__.py +6 -0
- core/account.py +1185 -0
- core/auth.py +53 -0
- core/base_task_service.py +338 -0
- core/cfmail_client.py +331 -0
- core/child_reaper.py +84 -0
- core/config.py +554 -0
- core/database.py +191 -0
- core/duckmail_client.py +304 -0
- core/freemail_client.py +325 -0
- core/gemini_automation.py +1103 -0
- core/google_api.py +339 -0
- core/gptmail_client.py +219 -0
- core/jwt.py +102 -0
- core/login_service.py +557 -0
- core/mail_providers/__init__.py +3 -0
- core/mail_providers/factory.py +94 -0
- core/mail_utils.py +29 -0
- core/message.py +154 -0
- core/microsoft_mail_client.py +221 -0
- core/moemail_client.py +355 -0
- core/proxy_utils.py +222 -0
- core/register_service.py +289 -0
- core/session_auth.py +68 -0
- core/storage.py +1127 -0
- core/uptime.py +150 -0
- docker-compose.yml +24 -0
- docs/DISCLAIMER.md +43 -0
- docs/DISCLAIMER_EN.md +34 -0
- docs/README_EN.md +377 -0
- docs/SUPPORTED_FILE_TYPES.md +383 -0
- docs/logo.svg +53 -0
- docs/script/download.js +110 -0
- entrypoint.sh +14 -0
- frontend/.gitignore +24 -0
- frontend/README.md +71 -0
- frontend/index.html +14 -0
- frontend/package-lock.json +1628 -0
- frontend/package.json +30 -0
- frontend/postcss.config.js +6 -0
- frontend/public/logo.svg +53 -0
- frontend/public/vendor/echarts/echarts.min.js +0 -0
- frontend/src/App.vue +9 -0
.env.example
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ============================================
|
| 2 |
+
# Gemini Business2API 配置示例
|
| 3 |
+
# ============================================
|
| 4 |
+
|
| 5 |
+
# 管理员密钥(必需,用于登录管理面板)
|
| 6 |
+
# 明文示例:
|
| 7 |
+
ADMIN_KEY=your-admin-secret-key
|
| 8 |
+
|
| 9 |
+
# 服务端口(可选,默认 7860)
|
| 10 |
+
# PORT=7860
|
| 11 |
+
|
| 12 |
+
# ============================================
|
| 13 |
+
# 数据库配置(可选,用于无持久化存储的环境如 HF Spaces)
|
| 14 |
+
# ============================================
|
| 15 |
+
# 支持 PostgreSQL 数据库存储(账户/设置/统计)
|
| 16 |
+
# 未设置时使用本地文件存储(原有行为)
|
| 17 |
+
#
|
| 18 |
+
# 示例(Neon PostgreSQL):
|
| 19 |
+
# DATABASE_URL=postgresql://user:password@ep-xxx.aws.neon.tech/dbname?sslmode=require
|
| 20 |
+
#
|
| 21 |
+
# 注意:使用数据库存储需要安装 asyncpg:pip install asyncpg
|
| 22 |
+
# DATABASE_URL=
|
| 23 |
+
|
| 24 |
+
# ============================================
|
| 25 |
+
# 其他配置请在管理面板的"系统设置"中配置
|
| 26 |
+
# 包括:API密钥、代理、图片生成、重试策略等
|
| 27 |
+
# 配置保存在 data/settings.yaml
|
| 28 |
+
# ============================================
|
| 29 |
+
|
| 30 |
+
# ============================================
|
| 31 |
+
# 账户配置
|
| 32 |
+
# ============================================
|
| 33 |
+
# 使用 accounts.json 文件
|
| 34 |
+
# 账户配置保存在 accounts.json 文件中
|
| 35 |
+
# 首次启动时会自动创建空配置
|
| 36 |
+
# 请在管理面板中添加账户,或直接编辑 accounts.json
|
.gitattributes
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Auto detect text files and perform LF normalization
|
| 2 |
+
* text=auto
|
.github/workflows/docker-build.yml
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: Build Multi-Arch Docker Image
|
| 2 |
+
|
| 3 |
+
on:
|
| 4 |
+
push:
|
| 5 |
+
branches:
|
| 6 |
+
- main
|
| 7 |
+
tags:
|
| 8 |
+
- "v*"
|
| 9 |
+
workflow_dispatch:
|
| 10 |
+
|
| 11 |
+
jobs:
|
| 12 |
+
build:
|
| 13 |
+
runs-on: ubuntu-latest
|
| 14 |
+
steps:
|
| 15 |
+
- name: Checkout code
|
| 16 |
+
uses: actions/checkout@v4
|
| 17 |
+
|
| 18 |
+
- name: Set up QEMU
|
| 19 |
+
uses: docker/setup-qemu-action@v3
|
| 20 |
+
|
| 21 |
+
- name: Set up Docker Buildx
|
| 22 |
+
uses: docker/setup-buildx-action@v3
|
| 23 |
+
|
| 24 |
+
- name: Login to Docker Hub
|
| 25 |
+
uses: docker/login-action@v3
|
| 26 |
+
with:
|
| 27 |
+
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
| 28 |
+
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
| 29 |
+
|
| 30 |
+
- name: Extract metadata
|
| 31 |
+
id: meta
|
| 32 |
+
uses: docker/metadata-action@v5
|
| 33 |
+
with:
|
| 34 |
+
images: ${{ secrets.DOCKERHUB_USERNAME }}/gemini-business2api
|
| 35 |
+
tags: |
|
| 36 |
+
type=ref,event=branch
|
| 37 |
+
type=ref,event=pr
|
| 38 |
+
type=semver,pattern={{version}}
|
| 39 |
+
type=semver,pattern={{major}}.{{minor}}
|
| 40 |
+
type=raw,value=latest,enable={{is_default_branch}}
|
| 41 |
+
|
| 42 |
+
- name: Build and push
|
| 43 |
+
uses: docker/build-push-action@v5
|
| 44 |
+
with:
|
| 45 |
+
context: .
|
| 46 |
+
platforms: linux/amd64,linux/arm64
|
| 47 |
+
push: true
|
| 48 |
+
tags: ${{ steps.meta.outputs.tags }}
|
| 49 |
+
labels: ${{ steps.meta.outputs.labels }}
|
| 50 |
+
cache-from: type=gha
|
| 51 |
+
cache-to: type=gha,mode=max
|
| 52 |
+
# 优化构建参数
|
| 53 |
+
build-args: |
|
| 54 |
+
BUILDKIT_INLINE_CACHE=1
|
| 55 |
+
provenance: false
|
.gitignore
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Python
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.py[cod]
|
| 4 |
+
*$py.class
|
| 5 |
+
*.so
|
| 6 |
+
.Python
|
| 7 |
+
build/
|
| 8 |
+
develop-eggs/
|
| 9 |
+
dist/
|
| 10 |
+
downloads/
|
| 11 |
+
eggs/
|
| 12 |
+
.eggs/
|
| 13 |
+
lib64/
|
| 14 |
+
parts/
|
| 15 |
+
sdist/
|
| 16 |
+
var/
|
| 17 |
+
wheels/
|
| 18 |
+
*.egg-info/
|
| 19 |
+
.installed.cfg
|
| 20 |
+
*.egg
|
| 21 |
+
|
| 22 |
+
# Python lib directory (but not frontend/src/lib)
|
| 23 |
+
/lib/
|
| 24 |
+
|
| 25 |
+
# Virtual Environment
|
| 26 |
+
venv/
|
| 27 |
+
env/
|
| 28 |
+
ENV/
|
| 29 |
+
.venv
|
| 30 |
+
# IDE
|
| 31 |
+
.vscode/
|
| 32 |
+
.idea/
|
| 33 |
+
*.swp
|
| 34 |
+
*.swo
|
| 35 |
+
*~
|
| 36 |
+
|
| 37 |
+
# Project specific
|
| 38 |
+
.env
|
| 39 |
+
*.log
|
| 40 |
+
|
| 41 |
+
# Generated files
|
| 42 |
+
data/
|
| 43 |
+
logs/
|
| 44 |
+
static/
|
| 45 |
+
|
| 46 |
+
# OS
|
| 47 |
+
.DS_Store
|
| 48 |
+
Thumbs.db
|
| 49 |
+
old_version.py
|
| 50 |
+
docs/img/
|
Dockerfile
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Stage 1: 构建前端
|
| 2 |
+
FROM node:20-slim AS frontend-builder
|
| 3 |
+
WORKDIR /app/frontend
|
| 4 |
+
|
| 5 |
+
# 先复制 package 文件利用 Docker 缓存
|
| 6 |
+
COPY frontend/package.json frontend/package-lock.json ./
|
| 7 |
+
RUN npm install --silent
|
| 8 |
+
|
| 9 |
+
# 复制前端源码并构建
|
| 10 |
+
COPY frontend/ ./
|
| 11 |
+
RUN npm run build
|
| 12 |
+
|
| 13 |
+
# Stage 2: 最终运行时镜像
|
| 14 |
+
FROM python:3.11-slim
|
| 15 |
+
WORKDIR /app
|
| 16 |
+
|
| 17 |
+
ENV PYTHONDONTWRITEBYTECODE=1 \
|
| 18 |
+
PYTHONUNBUFFERED=1 \
|
| 19 |
+
TZ=Asia/Shanghai
|
| 20 |
+
|
| 21 |
+
# 安装 Python 依赖和浏览器依赖(合并为单一 RUN 指令以减少层数)
|
| 22 |
+
COPY requirements.txt .
|
| 23 |
+
RUN apt-get update && \
|
| 24 |
+
apt-get install -y --no-install-recommends \
|
| 25 |
+
gcc \
|
| 26 |
+
curl \
|
| 27 |
+
tzdata \
|
| 28 |
+
chromium chromium-driver \
|
| 29 |
+
dbus dbus-x11 \
|
| 30 |
+
xvfb xauth \
|
| 31 |
+
libglib2.0-0 libnss3 libnspr4 libatk1.0-0 libatk-bridge2.0-0 \
|
| 32 |
+
libcups2 libdrm2 libxkbcommon0 libxcomposite1 libxdamage1 \
|
| 33 |
+
libxfixes3 libxrandr2 libgbm1 libasound2 libpango-1.0-0 \
|
| 34 |
+
libcairo2 fonts-liberation fonts-noto-cjk && \
|
| 35 |
+
ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone && \
|
| 36 |
+
pip install --no-cache-dir -r requirements.txt && \
|
| 37 |
+
apt-get purge -y gcc && \
|
| 38 |
+
apt-get autoremove -y && \
|
| 39 |
+
rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
|
| 40 |
+
|
| 41 |
+
# 复制后端代码
|
| 42 |
+
COPY main.py .
|
| 43 |
+
COPY core ./core
|
| 44 |
+
COPY util ./util
|
| 45 |
+
COPY scripts ./scripts
|
| 46 |
+
|
| 47 |
+
# 从 builder 阶段只复制构建好的静态文件
|
| 48 |
+
COPY --from=frontend-builder /app/static ./static
|
| 49 |
+
|
| 50 |
+
# 创建数据目录
|
| 51 |
+
RUN mkdir -p ./data
|
| 52 |
+
|
| 53 |
+
# 复制启动脚本
|
| 54 |
+
COPY entrypoint.sh .
|
| 55 |
+
RUN chmod +x entrypoint.sh
|
| 56 |
+
|
| 57 |
+
# 声明数据卷
|
| 58 |
+
VOLUME ["/app/data"]
|
| 59 |
+
|
| 60 |
+
# 声明端口
|
| 61 |
+
EXPOSE 7860
|
| 62 |
+
|
| 63 |
+
# 健康检查
|
| 64 |
+
HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \
|
| 65 |
+
CMD curl -f http://localhost:7860/health || exit 1
|
| 66 |
+
|
| 67 |
+
# 启动服务
|
| 68 |
+
CMD ["./entrypoint.sh"]
|
LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
MIT License
|
| 2 |
+
|
| 3 |
+
Copyright (c) 2025 yu
|
| 4 |
+
|
| 5 |
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
| 6 |
+
of this software and associated documentation files (the "Software"), to deal
|
| 7 |
+
in the Software without restriction, including without limitation the rights
|
| 8 |
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
| 9 |
+
copies of the Software, and to permit persons to whom the Software is
|
| 10 |
+
furnished to do so, subject to the following conditions:
|
| 11 |
+
|
| 12 |
+
The above copyright notice and this permission notice shall be included in all
|
| 13 |
+
copies or substantial portions of the Software.
|
| 14 |
+
|
| 15 |
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
| 16 |
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
| 17 |
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
| 18 |
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
| 19 |
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
| 20 |
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
| 21 |
+
SOFTWARE.
|
README.md
ADDED
|
@@ -0,0 +1,388 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: Gemini Business2api
|
| 3 |
+
emoji: ♊
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: green
|
| 6 |
+
sdk: docker
|
| 7 |
+
pinned: false
|
| 8 |
+
---
|
| 9 |
+
|
| 10 |
+
<p align="center">
|
| 11 |
+
<img src="docs/logo.svg" width="120" alt="Gemini Business2API logo" />
|
| 12 |
+
</p>
|
| 13 |
+
<h1 align="center">Gemini Business2API</h1>
|
| 14 |
+
<p align="center">赋予硅基生物以灵魂</p>
|
| 15 |
+
<p align="center">当时明月在 · 曾照彩云归</p>
|
| 16 |
+
<p align="center">
|
| 17 |
+
<strong>简体中文</strong> | <a href="docs/README_EN.md">English</a>
|
| 18 |
+
</p>
|
| 19 |
+
<p align="center"><img src="https://img.shields.io/badge/License-MIT-yellow.svg" /> <img src="https://img.shields.io/badge/Python-3.11-3776AB?logo=python&logoColor=white" /> <img src="https://img.shields.io/badge/FastAPI-0.110-009688?logo=fastapi&logoColor=white" /> <img src="https://img.shields.io/badge/Vue-3-4FC08D?logo=vue.js&logoColor=white" /> <img src="https://img.shields.io/badge/Vite-7-646CFF?logo=vite&logoColor=white" /> <img src="https://img.shields.io/badge/Docker-ready-2496ED?logo=docker&logoColor=white" /></p>
|
| 20 |
+
|
| 21 |
+
<p align="center">将 Gemini Business 转换为 OpenAI 兼容接口,支持多账号负载均衡、图像生成、视频生成、多模态能力与内置管理面板。</p>
|
| 22 |
+
|
| 23 |
+
---
|
| 24 |
+
|
| 25 |
+
## 📜 开源协议与声明
|
| 26 |
+
|
| 27 |
+
**开源协议**: MIT License - 查看 [LICENSE](LICENSE) 文件了解详情
|
| 28 |
+
|
| 29 |
+
### ⚠️ 严禁滥用:禁止将本工具用于商业用途或任何形式的滥用(无论规模大小)
|
| 30 |
+
|
| 31 |
+
**本工具严禁用于以下行为:**
|
| 32 |
+
- 商业用途或盈利性使用
|
| 33 |
+
- 任何形式的批量操作或自动化滥用(无论规模大小)
|
| 34 |
+
- 破坏市场秩序或恶意竞争
|
| 35 |
+
- 违反 Google 服务条款的任何行为
|
| 36 |
+
- 违反 Microsoft 服务条款的任何行为
|
| 37 |
+
|
| 38 |
+
**违规后果**:滥用行为可能导致账号永久封禁、法律追责,一切后果由使用者自行承担。
|
| 39 |
+
|
| 40 |
+
**合法用途**:本项目仅限个人学习、技术研究与非商业性技术交流。
|
| 41 |
+
|
| 42 |
+
📖 **完整声明与免责条款**:[DISCLAIMER.md](docs/DISCLAIMER.md)
|
| 43 |
+
|
| 44 |
+
---
|
| 45 |
+
|
| 46 |
+
## ✨ 功能特性
|
| 47 |
+
|
| 48 |
+
- ✅ OpenAI API 完全兼容 - 无缝对接现有工具
|
| 49 |
+
- ✅ 多账号负载均衡 - 轮询与故障自动切换
|
| 50 |
+
- ✅ 自动化账号管理 - 支持自动注册与登录,集成多种临时邮箱,支持无头浏览器模式
|
| 51 |
+
- ✅ 流式输出 - 实时响应
|
| 52 |
+
- ✅ 多模态输入 - 100+ 文件类型(图片、PDF、Office 文档、音频、视频、代码等)
|
| 53 |
+
- ✅ 图片生成 & 图生图 - 模型可配置,Base64 或 URL 返回
|
| 54 |
+
- ✅ 视频生成 - 专用模型,支持 HTML/URL/Markdown 输出格式
|
| 55 |
+
- ✅ 智能文件处理 - 自动识别文件类型,支持 URL 与 Base64
|
| 56 |
+
- ✅ 日志与监控 - 实时状态与统计信息
|
| 57 |
+
- ✅ 代理支持 - 通过设置面板配置
|
| 58 |
+
- ✅ 内置管理面板 - 在线配置与账号管理
|
| 59 |
+
- ✅ PostgreSQL / SQLite 存储 - 账户/设置/统计持久化
|
| 60 |
+
|
| 61 |
+
## 🤖 模型功能
|
| 62 |
+
|
| 63 |
+
| 模型ID | 识图 | 原生联网 | 文件多模态 | 图片生成 | 视频生成 |
|
| 64 |
+
| ------------------------ | ---- | -------- | ---------- | -------- | -------- |
|
| 65 |
+
| `gemini-auto` | ✅ | ✅ | ✅ | 可选 | - |
|
| 66 |
+
| `gemini-2.5-flash` | ✅ | ✅ | ✅ | 可选 | - |
|
| 67 |
+
| `gemini-2.5-pro` | ✅ | ✅ | ✅ | 可选 | - |
|
| 68 |
+
| `gemini-3-flash-preview` | ✅ | ✅ | ✅ | 可选 | - |
|
| 69 |
+
| `gemini-3-pro-preview` | ✅ | ✅ | ✅ | 可选 | - |
|
| 70 |
+
| `gemini-3.1-pro-preview` | ✅ | ✅ | ✅ | 可选 | - |
|
| 71 |
+
| `gemini-imagen` | ✅ | ✅ | ✅ | ✅ | - |
|
| 72 |
+
| `gemini-veo` | ✅ | ✅ | ✅ | - | ✅ |
|
| 73 |
+
|
| 74 |
+
> `gemini-imagen`:专用图片生成模型 · `gemini-veo`:专用视频生成模型
|
| 75 |
+
|
| 76 |
+
---
|
| 77 |
+
|
| 78 |
+
## 🚀 快速开始
|
| 79 |
+
|
| 80 |
+
### 方式一:Docker Compose(推荐)
|
| 81 |
+
|
| 82 |
+
**支持 ARM64 和 AMD64 架构**
|
| 83 |
+
|
| 84 |
+
```bash
|
| 85 |
+
git clone https://github.com/Dreamy-rain/gemini-business2api.git
|
| 86 |
+
cd gemini-business2api
|
| 87 |
+
cp .env.example .env
|
| 88 |
+
# 编辑 .env 设置 ADMIN_KEY
|
| 89 |
+
|
| 90 |
+
docker-compose up -d
|
| 91 |
+
|
| 92 |
+
# 查看日志
|
| 93 |
+
docker-compose logs -f
|
| 94 |
+
|
| 95 |
+
# 更新到最新版本
|
| 96 |
+
docker-compose pull && docker-compose up -d
|
| 97 |
+
```
|
| 98 |
+
|
| 99 |
+
---
|
| 100 |
+
|
| 101 |
+
### 方式二:安装脚本
|
| 102 |
+
|
| 103 |
+
> **前置要求**:Git、Node.js & npm(构建前端用)。脚本会自动安装 Python 3.11 和 uv。
|
| 104 |
+
|
| 105 |
+
**Linux / macOS / WSL:**
|
| 106 |
+
```bash
|
| 107 |
+
git clone https://github.com/Dreamy-rain/gemini-business2api.git
|
| 108 |
+
cd gemini-business2api
|
| 109 |
+
bash setup.sh
|
| 110 |
+
# 编辑 .env 设置 ADMIN_KEY
|
| 111 |
+
source .venv/bin/activate
|
| 112 |
+
python main.py
|
| 113 |
+
# pm2 后台运行
|
| 114 |
+
pm2 start main.py --name gemini-api --interpreter ./.venv/bin/python3
|
| 115 |
+
```
|
| 116 |
+
|
| 117 |
+
**Windows:**
|
| 118 |
+
```cmd
|
| 119 |
+
git clone https://github.com/Dreamy-rain/gemini-business2api.git
|
| 120 |
+
cd gemini-business2api
|
| 121 |
+
setup.bat
|
| 122 |
+
# 编辑 .env 设置 ADMIN_KEY
|
| 123 |
+
.venv\Scripts\activate.bat
|
| 124 |
+
python main.py
|
| 125 |
+
# pm2 后台运行
|
| 126 |
+
pm2 start main.py --name gemini-api --interpreter ./.venv/Scripts/python.exe
|
| 127 |
+
```
|
| 128 |
+
|
| 129 |
+
安装脚本会自动完成:uv 安装、Python 3.11 下载、依赖安装、前端构建、`.env` 创建。
|
| 130 |
+
更新项目时重新运行同一脚本即可。
|
| 131 |
+
|
| 132 |
+
---
|
| 133 |
+
|
| 134 |
+
### 方式三:手动部署
|
| 135 |
+
|
| 136 |
+
```bash
|
| 137 |
+
git clone https://github.com/Dreamy-rain/gemini-business2api.git
|
| 138 |
+
cd gemini-business2api
|
| 139 |
+
|
| 140 |
+
curl -LsSf https://astral.sh/uv/install.sh | sh
|
| 141 |
+
uv python install 3.11
|
| 142 |
+
|
| 143 |
+
cd frontend && npm install && npm run build && cd ..
|
| 144 |
+
|
| 145 |
+
uv venv --python 3.11 .venv
|
| 146 |
+
source .venv/bin/activate # Windows: .venv\Scripts\activate.bat
|
| 147 |
+
uv pip install -r requirements.txt
|
| 148 |
+
|
| 149 |
+
cp .env.example .env
|
| 150 |
+
# 编辑 .env 设置 ADMIN_KEY
|
| 151 |
+
python main.py
|
| 152 |
+
```
|
| 153 |
+
|
| 154 |
+
---
|
| 155 |
+
|
| 156 |
+
### 访问方式
|
| 157 |
+
|
| 158 |
+
- **管理面板**:`http://localhost:7860/`(使用 `ADMIN_KEY` 登录)
|
| 159 |
+
- **API 接口**:`http://localhost:7860/v1/chat/completions`
|
| 160 |
+
|
| 161 |
+
---
|
| 162 |
+
|
| 163 |
+
## 🗄️ 数据库持久化
|
| 164 |
+
|
| 165 |
+
设置 `DATABASE_URL` 可将账户、设置、统计写入数据库,避免容器重启丢数据。未设置时自动使用 SQLite(本地 `data.db`)。
|
| 166 |
+
|
| 167 |
+
**配置方式:**
|
| 168 |
+
- 本地部署 → 写入 `.env`
|
| 169 |
+
- 云平台 → 在平台环境变量中设置
|
| 170 |
+
|
| 171 |
+
```
|
| 172 |
+
DATABASE_URL=postgresql://user:password@host/dbname?sslmode=require
|
| 173 |
+
```
|
| 174 |
+
|
| 175 |
+
**免费 PostgreSQL 推荐:**
|
| 176 |
+
|
| 177 |
+
| 服务 | 免费额度 | 获取方式 |
|
| 178 |
+
|------|---------|---------|
|
| 179 |
+
| [Neon](https://neon.tech) | 512MB 存储 / 100 CPUH 月 | 注册 → Create Project → 复制 Connection string |
|
| 180 |
+
| [Aiven](https://aiven.io) | 额度更充裕 | 注册 → 创建 PostgreSQL 服务 → 复制连接串 |
|
| 181 |
+
|
| 182 |
+
> `postgres://` 和 `postgresql://` 两种格式均可直接使用,无需手动转换。
|
| 183 |
+
|
| 184 |
+
<details>
|
| 185 |
+
<summary>⚠️ 常见问题:定期保存失败 / ConnectionDoesNotExistError</summary>
|
| 186 |
+
|
| 187 |
+
如果日志出现类似以下错误:
|
| 188 |
+
|
| 189 |
+
```
|
| 190 |
+
ERROR [COOLDOWN] 冷却期保存失败: connection was closed in the middle of operation
|
| 191 |
+
asyncpg.exceptions.ConnectionDoesNotExistError: connection was closed in the middle of operation
|
| 192 |
+
```
|
| 193 |
+
|
| 194 |
+
这是因为部分免费 PostgreSQL 服务(如 Aiven 免费版)会主动关闭长时间空闲的连接。**不影响正常使用**,下次操作会自动重新连接。如频繁出现,建议换用 [Neon](https://neon.tech) 或升级数据库套餐。
|
| 195 |
+
|
| 196 |
+
</details>
|
| 197 |
+
|
| 198 |
+
<details>
|
| 199 |
+
<summary>📦 数据库迁移(从旧版升级)</summary>
|
| 200 |
+
|
| 201 |
+
如果有旧的本地文件(`accounts.json` / `settings.yaml` / `stats.json`),运行迁移脚本:
|
| 202 |
+
|
| 203 |
+
```bash
|
| 204 |
+
python scripts/migrate_to_database.py
|
| 205 |
+
```
|
| 206 |
+
|
| 207 |
+
迁移脚本会自动检测环境(PostgreSQL / SQLite),迁移完成后自动重命名旧文件。
|
| 208 |
+
|
| 209 |
+
|
| 210 |
+
</details>
|
| 211 |
+
|
| 212 |
+
---
|
| 213 |
+
|
| 214 |
+
## 📡 API 接口
|
| 215 |
+
|
| 216 |
+
完全兼容 OpenAI API 格式,可直接对接 ChatGPT-Next-Web、LobeChat、OpenCat 等客户端。
|
| 217 |
+
|
| 218 |
+
| 接口 | 方法 | 说明 |
|
| 219 |
+
|------|------|------|
|
| 220 |
+
| `/v1/chat/completions` | POST | 对话补全(支持流式) |
|
| 221 |
+
| `/v1/models` | GET | 获取可用模型列表 |
|
| 222 |
+
| `/v1/images/generations` | POST | 图片生成(文生图) |
|
| 223 |
+
| `/v1/images/edits` | POST | 图片编辑(图生图) |
|
| 224 |
+
| `/health` | GET | 健康检查 |
|
| 225 |
+
|
| 226 |
+
**调用示例:**
|
| 227 |
+
|
| 228 |
+
```bash
|
| 229 |
+
curl http://localhost:7860/v1/chat/completions \
|
| 230 |
+
-H "Authorization: Bearer your-api-key" \
|
| 231 |
+
-H "Content-Type: application/json" \
|
| 232 |
+
-d '{
|
| 233 |
+
"model": "gemini-2.5-flash",
|
| 234 |
+
"messages": [{"role": "user", "content": "你好"}],
|
| 235 |
+
"stream": true
|
| 236 |
+
}'
|
| 237 |
+
```
|
| 238 |
+
|
| 239 |
+
> `API_KEY` 在管理面板 → 系统设置中配置,留空则公开访问,支持多个 Key 逗号分隔。
|
| 240 |
+
|
| 241 |
+
---
|
| 242 |
+
|
| 243 |
+
## 📧 邮箱提供商配置
|
| 244 |
+
|
| 245 |
+
项目支持 4 种临时邮箱,用于自动注册账号。在 **管理面板 → 系统设置 → 临时邮箱提供商** 中切换。
|
| 246 |
+
|
| 247 |
+
### Moemail(默认推荐)
|
| 248 |
+
|
| 249 |
+
开源临时邮箱服务,开箱即用。
|
| 250 |
+
|
| 251 |
+
- **项目地址**:[github.com/beilunyang/moemail](https://github.com/beilunyang/moemail)
|
| 252 |
+
- **官网**:[moemail.app](https://moemail.app)
|
| 253 |
+
- **配置项**:API 地址 + API Key + 域名(可选)
|
| 254 |
+
|
| 255 |
+
### DuckMail
|
| 256 |
+
|
| 257 |
+
临时邮箱 API 服务,推荐配置自定义域名。
|
| 258 |
+
|
| 259 |
+
- **域名管理**:[domain.duckmail.sbs](https://domain.duckmail.sbs/)
|
| 260 |
+
- **配置项**:API 地址 + API Key + 注册域名
|
| 261 |
+
|
| 262 |
+
### GPTMail
|
| 263 |
+
|
| 264 |
+
临时邮箱 API 服务,无需密码即可使用。
|
| 265 |
+
|
| 266 |
+
- **默认地址**:`https://mail.chatgpt.org.uk`
|
| 267 |
+
- **默认 API Key**:`gpt-test`
|
| 268 |
+
- **配置项**:API 地址 + API Key + 域名(可选)
|
| 269 |
+
|
| 270 |
+
### Freemail
|
| 271 |
+
|
| 272 |
+
需要自行搭建的临时邮箱服务,适合有服务器的用户。
|
| 273 |
+
|
| 274 |
+
- **项目地址**:[github.com/idinging/freemail](https://github.com/idinging/freemail)
|
| 275 |
+
- **配置项**:自部署服务地址 + JWT Token + 域名(可选)
|
| 276 |
+
|
| 277 |
+
> **提示**:所有邮箱配置均在管理面板中完成,无需手动编辑配置文件。Microsoft 邮箱登录也在管理面板中操作。
|
| 278 |
+
|
| 279 |
+
---
|
| 280 |
+
|
| 281 |
+
## 🌐 推荐部署平台
|
| 282 |
+
|
| 283 |
+
除本地 Docker Compose 外,以下平台均支持 Docker 镜像部署:
|
| 284 |
+
|
| 285 |
+
| 平台 | 免费额度 | 特点 |
|
| 286 |
+
|------|---------|------|
|
| 287 |
+
| [Render](https://render.com) | ✅ 有 | 支持 Docker、自动 SSL、免费 PostgreSQL |
|
| 288 |
+
| [Railway](https://railway.app) | $5/月额度 | 一键 Docker 部署、自带��据库 |
|
| 289 |
+
| [Fly.io](https://fly.io) | ✅ 有 | 全球边缘部署、支持持久化卷 |
|
| 290 |
+
| [Claw Cloud](https://claw.cloud) | ✅ 有 | 容器云平台,简单易用 |
|
| 291 |
+
| 自建 VPS(推荐) | — | 完全可控,配合 Docker Compose |
|
| 292 |
+
|
| 293 |
+
> Docker 镜像:`cooooookk/gemini-business2api:latest`
|
| 294 |
+
>
|
| 295 |
+
> 部署时设置环境变量 `ADMIN_KEY` 和 `DATABASE_URL` 即可。
|
| 296 |
+
|
| 297 |
+
### Zeabur 部署教程
|
| 298 |
+
|
| 299 |
+
1. Fork 本仓库到你的 GitHub
|
| 300 |
+
2. 登录 [Zeabur](https://zeabur.com) → **创建项目** → **共享集群** → **部署新服务** → **连接 GitHub** → 选择 Fork 的仓库
|
| 301 |
+
3. 添加环境变量:
|
| 302 |
+
|
| 303 |
+
| 变量名 | 必填 | 说明 |
|
| 304 |
+
|--------|------|------|
|
| 305 |
+
| `ADMIN_KEY` | ✅ | 管理面板登录密钥 |
|
| 306 |
+
| `DATABASE_URL` | 可选 | PostgreSQL 连接串(推荐配置,避免重启丢数据) |
|
| 307 |
+
|
| 308 |
+
4. **持久化挂载目录**(重要):
|
| 309 |
+
|
| 310 |
+
在服务设置中添加持久化存储:
|
| 311 |
+
|
| 312 |
+
| 硬盘 ID | 挂载目录 |
|
| 313 |
+
|---------|---------|
|
| 314 |
+
| `data` | `/app/data` |
|
| 315 |
+
|
| 316 |
+
5. 点击 **重新部署** 使配置生效
|
| 317 |
+
|
| 318 |
+
**更新方式**:GitHub 仓库 → **Sync fork** → **Update branch**,Zeabur 会自动重新部署。
|
| 319 |
+
|
| 320 |
+
---
|
| 321 |
+
|
| 322 |
+
## 🔄 独立刷新服务
|
| 323 |
+
|
| 324 |
+
如果需要将账号刷新服务单独部署(与主 API 分离),可使用 [`refresh-worker` 分支](https://github.com/Dreamy-rain/gemini-business2api/tree/refresh-worker):
|
| 325 |
+
|
| 326 |
+
```bash
|
| 327 |
+
git clone -b refresh-worker https://github.com/Dreamy-rain/gemini-business2api.git gemini-refresh-worker
|
| 328 |
+
cd gemini-refresh-worker
|
| 329 |
+
cp .env.example .env
|
| 330 |
+
# 编辑 .env 设置 DATABASE_URL
|
| 331 |
+
docker-compose up -d
|
| 332 |
+
```
|
| 333 |
+
|
| 334 |
+
该服务从数据库读取账号,独立执行定时刷新,支持 cron 调度、分批执行、冷却防重复。适合需要刷新服务与 API 服务分离部署的场景。
|
| 335 |
+
|
| 336 |
+
---
|
| 337 |
+
|
| 338 |
+
## 🌐 Socks5 免费代理池
|
| 339 |
+
|
| 340 |
+
自动注册/刷新账号时可配置代理以提高成功率。推荐使用免费 Socks5 代理池:
|
| 341 |
+
|
| 342 |
+
- **项目地址**:[github.com/Dreamy-rain/socks5-proxy](https://github.com/Dreamy-rain/socks5-proxy)
|
| 343 |
+
- **说明**:免费代理不太稳定,但能一定程度提高注册成功率
|
| 344 |
+
- **使用方式**:在管理面板 → 系统设置 → 代理设置中配置
|
| 345 |
+
|
| 346 |
+
---
|
| 347 |
+
|
| 348 |
+
## 📸 功能展示
|
| 349 |
+
|
| 350 |
+
### 管理系统
|
| 351 |
+
|
| 352 |
+
<table>
|
| 353 |
+
<tr>
|
| 354 |
+
<td><img src="docs/img/1.png" alt="管理系统 1" /></td>
|
| 355 |
+
<td><img src="docs/img/2.png" alt="管理系统 2" /></td>
|
| 356 |
+
</tr>
|
| 357 |
+
<tr>
|
| 358 |
+
<td><img src="docs/img/3.png" alt="管理系统 3" /></td>
|
| 359 |
+
<td><img src="docs/img/4.png" alt="管理系统 4" /></td>
|
| 360 |
+
</tr>
|
| 361 |
+
<tr>
|
| 362 |
+
<td><img src="docs/img/5.png" alt="管理系统 5" /></td>
|
| 363 |
+
<td><img src="docs/img/6.png" alt="管理系统 6" /></td>
|
| 364 |
+
</tr>
|
| 365 |
+
</table>
|
| 366 |
+
|
| 367 |
+
### 图片效果
|
| 368 |
+
|
| 369 |
+
<table>
|
| 370 |
+
<tr>
|
| 371 |
+
<td><img src="docs/img/img_1.png" alt="图片效果 1" /></td>
|
| 372 |
+
<td><img src="docs/img/img_2.png" alt="图片效果 2" /></td>
|
| 373 |
+
</tr>
|
| 374 |
+
<tr>
|
| 375 |
+
<td><img src="docs/img/img_3.png" alt="图片效果 3" /></td>
|
| 376 |
+
<td><img src="docs/img/img_4.png" alt="图片效果 4" /></td>
|
| 377 |
+
</tr>
|
| 378 |
+
</table>
|
| 379 |
+
|
| 380 |
+
### 更多文档
|
| 381 |
+
|
| 382 |
+
- 支持的文件类型:[SUPPORTED_FILE_TYPES.md](docs/SUPPORTED_FILE_TYPES.md)
|
| 383 |
+
|
| 384 |
+
## ⭐ Star History
|
| 385 |
+
|
| 386 |
+
[](https://www.star-history.com/#Dreamy-rain/gemini-business2api&type=date&legend=top-left)
|
| 387 |
+
|
| 388 |
+
**如果这个项目对你有帮助,请给个 ⭐ Star!**
|
core/__init__.py
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Core 模块
|
| 3 |
+
包含认证、模板生成等核心功能
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
__all__ = ['auth', 'templates']
|
core/account.py
ADDED
|
@@ -0,0 +1,1185 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""账户管理模块
|
| 2 |
+
|
| 3 |
+
负责账户配置、多账户协调和会话缓存管理
|
| 4 |
+
"""
|
| 5 |
+
import asyncio
|
| 6 |
+
import json
|
| 7 |
+
import logging
|
| 8 |
+
import os
|
| 9 |
+
import random
|
| 10 |
+
import threading
|
| 11 |
+
import time
|
| 12 |
+
from dataclasses import dataclass
|
| 13 |
+
from datetime import datetime, timedelta, timezone
|
| 14 |
+
from typing import Dict, List, Optional, TYPE_CHECKING, Iterable
|
| 15 |
+
|
| 16 |
+
from fastapi import HTTPException
|
| 17 |
+
|
| 18 |
+
# 导入存储层(支持数据库)
|
| 19 |
+
from core import storage
|
| 20 |
+
|
| 21 |
+
if TYPE_CHECKING:
|
| 22 |
+
from core.jwt import JWTManager
|
| 23 |
+
|
| 24 |
+
logger = logging.getLogger(__name__)
|
| 25 |
+
|
| 26 |
+
# HTTP错误名称映射
|
| 27 |
+
HTTP_ERROR_NAMES = {
|
| 28 |
+
400: "参数错误",
|
| 29 |
+
401: "认证错误",
|
| 30 |
+
403: "权限错误",
|
| 31 |
+
429: "限流",
|
| 32 |
+
502: "网关错误",
|
| 33 |
+
503: "服务不可用"
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
# 配额类型定义
|
| 37 |
+
QUOTA_TYPES = {
|
| 38 |
+
"text": "对话",
|
| 39 |
+
"images": "绘图",
|
| 40 |
+
"videos": "视频"
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
@dataclass
|
| 44 |
+
class AccountConfig:
|
| 45 |
+
"""单个账户配置"""
|
| 46 |
+
account_id: str
|
| 47 |
+
secure_c_ses: str
|
| 48 |
+
host_c_oses: Optional[str]
|
| 49 |
+
csesidx: str
|
| 50 |
+
config_id: str
|
| 51 |
+
expires_at: Optional[str] = None # 账户过期时间 (格式: "2025-12-23 10:59:21")
|
| 52 |
+
disabled: bool = False # 手动禁用状态
|
| 53 |
+
mail_provider: Optional[str] = None
|
| 54 |
+
mail_address: Optional[str] = None
|
| 55 |
+
mail_password: Optional[str] = None
|
| 56 |
+
mail_client_id: Optional[str] = None
|
| 57 |
+
mail_refresh_token: Optional[str] = None
|
| 58 |
+
mail_tenant: Optional[str] = None
|
| 59 |
+
# 邮箱自定义配置字段(用于账户级别的邮箱服务配置)
|
| 60 |
+
mail_base_url: Optional[str] = None
|
| 61 |
+
mail_jwt_token: Optional[str] = None
|
| 62 |
+
mail_verify_ssl: Optional[bool] = None
|
| 63 |
+
mail_domain: Optional[str] = None
|
| 64 |
+
mail_api_key: Optional[str] = None
|
| 65 |
+
trial_end: Optional[str] = None # 试用到期日 (格式: "2026-03-25",独立于cookie过期)
|
| 66 |
+
|
| 67 |
+
def get_remaining_hours(self) -> Optional[float]:
|
| 68 |
+
"""计算账户剩余小时数"""
|
| 69 |
+
if not self.expires_at:
|
| 70 |
+
return None
|
| 71 |
+
try:
|
| 72 |
+
# 解析过期时间(假设为北京时间)
|
| 73 |
+
beijing_tz = timezone(timedelta(hours=8))
|
| 74 |
+
expire_time = datetime.strptime(self.expires_at, "%Y-%m-%d %H:%M:%S")
|
| 75 |
+
expire_time = expire_time.replace(tzinfo=beijing_tz)
|
| 76 |
+
|
| 77 |
+
# 当前时间(北京时间)
|
| 78 |
+
now = datetime.now(beijing_tz)
|
| 79 |
+
|
| 80 |
+
# 计算剩余时间
|
| 81 |
+
remaining = (expire_time - now).total_seconds() / 3600
|
| 82 |
+
return remaining
|
| 83 |
+
except Exception:
|
| 84 |
+
return None
|
| 85 |
+
|
| 86 |
+
def is_expired(self) -> bool:
|
| 87 |
+
"""检查账户是否已过期"""
|
| 88 |
+
remaining = self.get_remaining_hours()
|
| 89 |
+
if remaining is None:
|
| 90 |
+
return False # 未设置过期时间,默认不过期
|
| 91 |
+
return remaining <= 0
|
| 92 |
+
|
| 93 |
+
def get_trial_days_remaining(self) -> Optional[int]:
|
| 94 |
+
"""计算试用期剩余天数(基于 trial_end 字段)"""
|
| 95 |
+
if not self.trial_end:
|
| 96 |
+
return None
|
| 97 |
+
try:
|
| 98 |
+
beijing_tz = timezone(timedelta(hours=8))
|
| 99 |
+
end_date = datetime.strptime(self.trial_end, "%Y-%m-%d")
|
| 100 |
+
end_date = end_date.replace(tzinfo=beijing_tz)
|
| 101 |
+
now = datetime.now(beijing_tz)
|
| 102 |
+
remaining = (end_date.date() - now.date()).days
|
| 103 |
+
return max(0, remaining)
|
| 104 |
+
except Exception:
|
| 105 |
+
return None
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
@dataclass(frozen=True)
|
| 109 |
+
class CooldownConfig:
|
| 110 |
+
text: int
|
| 111 |
+
images: int
|
| 112 |
+
videos: int
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
@dataclass(frozen=True)
|
| 116 |
+
class RetryPolicy:
|
| 117 |
+
cooldowns: CooldownConfig
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
def format_account_expiration(remaining_hours: Optional[float]) -> tuple:
|
| 121 |
+
"""
|
| 122 |
+
格式化账户过期时间显示(基于12小时过期周期)
|
| 123 |
+
|
| 124 |
+
Args:
|
| 125 |
+
remaining_hours: 剩余小时数(None表示未设置过期时间)
|
| 126 |
+
|
| 127 |
+
Returns:
|
| 128 |
+
(status, status_color, expire_display) 元组
|
| 129 |
+
"""
|
| 130 |
+
if remaining_hours is None:
|
| 131 |
+
# 未设置过期时间时显示为"未设置"
|
| 132 |
+
return ("未设置", "#9e9e9e", "未设置")
|
| 133 |
+
elif remaining_hours <= 0:
|
| 134 |
+
return ("已过期", "#f44336", "已过期")
|
| 135 |
+
elif remaining_hours < 3: # 少于3小时
|
| 136 |
+
return ("即将过期", "#ff9800", f"{remaining_hours:.1f} 小时")
|
| 137 |
+
else: # 3小时及以上,统一显示小时
|
| 138 |
+
return ("正常", "#4caf50", f"{remaining_hours:.1f} 小时")
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
class AccountManager:
|
| 142 |
+
"""单个账户管理器"""
|
| 143 |
+
def __init__(
|
| 144 |
+
self,
|
| 145 |
+
config: AccountConfig,
|
| 146 |
+
http_client,
|
| 147 |
+
user_agent: str,
|
| 148 |
+
retry_policy: RetryPolicy,
|
| 149 |
+
):
|
| 150 |
+
self.config = config
|
| 151 |
+
self.http_client = http_client
|
| 152 |
+
self.user_agent = user_agent
|
| 153 |
+
# 冷却时间配置
|
| 154 |
+
self.rate_limit_cooldown_seconds = retry_policy.cooldowns.text # 向后兼容
|
| 155 |
+
self.text_rate_limit_cooldown_seconds = retry_policy.cooldowns.text
|
| 156 |
+
self.images_rate_limit_cooldown_seconds = retry_policy.cooldowns.images
|
| 157 |
+
self.videos_rate_limit_cooldown_seconds = retry_policy.cooldowns.videos
|
| 158 |
+
self.jwt_manager: Optional['JWTManager'] = None # 延迟初始化
|
| 159 |
+
self.is_available = True
|
| 160 |
+
self.last_error_time = 0.0 # 保留用于统计
|
| 161 |
+
self.quota_cooldowns: Dict[str, float] = {} # 按配额类型的冷却时间戳
|
| 162 |
+
self.daily_usage: Dict[str, int] = {"text": 0, "images": 0, "videos": 0} # 每日使用计数
|
| 163 |
+
self.daily_usage_date: str = "" # 计数日期(北京时间,格式 "2026-02-24")
|
| 164 |
+
self.conversation_count = 0 # 累计成功次数(用于统计展示)
|
| 165 |
+
self.failure_count = 0 # 累计失败次数(用于统计展示)
|
| 166 |
+
self.session_usage_count = 0 # 本次启动后使用次数(用于均衡轮询)
|
| 167 |
+
self.disabled_reason: Optional[str] = None # 自动禁用原因(如 "403 Access Restricted")
|
| 168 |
+
|
| 169 |
+
def handle_non_http_error(self, error_context: str = "", request_id: str = "", quota_type: Optional[str] = None) -> None:
|
| 170 |
+
"""
|
| 171 |
+
统一处理非HTTP错误(网络错误、解析错误等)- 只记录日志,不触发冷却
|
| 172 |
+
|
| 173 |
+
Args:
|
| 174 |
+
error_context: 错误上下文(如"JWT获取"、"聊天请求")
|
| 175 |
+
request_id: 请求ID(用于日志)
|
| 176 |
+
quota_type: 配额类型(保留参数以保持接口兼容性)
|
| 177 |
+
|
| 178 |
+
注意:网络错误、超时等是临时问题,应该直接切换账户重试,不标记配额冷却
|
| 179 |
+
"""
|
| 180 |
+
req_tag = f"[req_{request_id}] " if request_id else ""
|
| 181 |
+
|
| 182 |
+
# 只记录日志,不触发冷却
|
| 183 |
+
# 网络错误是临时的,应该直接切换账户重试
|
| 184 |
+
logger.warning(
|
| 185 |
+
f"[ACCOUNT] [{self.config.account_id}] {req_tag}"
|
| 186 |
+
f"{error_context}失败,将切换账户重试(不触发冷却)"
|
| 187 |
+
)
|
| 188 |
+
|
| 189 |
+
def _get_quota_cooldown_seconds(self, quota_type: Optional[str]) -> int:
|
| 190 |
+
if quota_type == "images":
|
| 191 |
+
return self.images_rate_limit_cooldown_seconds
|
| 192 |
+
if quota_type == "videos":
|
| 193 |
+
return self.videos_rate_limit_cooldown_seconds
|
| 194 |
+
return self.text_rate_limit_cooldown_seconds
|
| 195 |
+
|
| 196 |
+
def apply_retry_policy(self, retry_policy: RetryPolicy) -> None:
|
| 197 |
+
"""Apply updated retry policy to this account manager."""
|
| 198 |
+
self.rate_limit_cooldown_seconds = retry_policy.cooldowns.text # 向后兼容
|
| 199 |
+
self.text_rate_limit_cooldown_seconds = retry_policy.cooldowns.text
|
| 200 |
+
self.images_rate_limit_cooldown_seconds = retry_policy.cooldowns.images
|
| 201 |
+
self.videos_rate_limit_cooldown_seconds = retry_policy.cooldowns.videos
|
| 202 |
+
|
| 203 |
+
def _get_quota_period(self) -> str:
|
| 204 |
+
"""获取当前配额周期标识(北京时间16:00为分界,对齐Google太平洋时间午夜重置)"""
|
| 205 |
+
beijing_tz = timezone(timedelta(hours=8))
|
| 206 |
+
now = datetime.now(beijing_tz)
|
| 207 |
+
# 16:00前属于前一天的配额周期,16:00后属于当天的配额周期
|
| 208 |
+
if now.hour < 16:
|
| 209 |
+
period_date = now.date() - timedelta(days=1)
|
| 210 |
+
else:
|
| 211 |
+
period_date = now.date()
|
| 212 |
+
return period_date.strftime("%Y-%m-%d")
|
| 213 |
+
|
| 214 |
+
def _reset_daily_usage_if_needed(self) -> None:
|
| 215 |
+
"""跨配额周期自动重置每日计数器(懒重置,北京时间16:00刷新)"""
|
| 216 |
+
period = self._get_quota_period()
|
| 217 |
+
if self.daily_usage_date != period:
|
| 218 |
+
self.daily_usage = {"text": 0, "images": 0, "videos": 0}
|
| 219 |
+
self.daily_usage_date = period
|
| 220 |
+
|
| 221 |
+
def increment_daily_usage(self, quota_type: str) -> None:
|
| 222 |
+
"""请求成功后增加每日使用计数"""
|
| 223 |
+
if quota_type not in QUOTA_TYPES:
|
| 224 |
+
return
|
| 225 |
+
self._reset_daily_usage_if_needed()
|
| 226 |
+
self.daily_usage[quota_type] += 1
|
| 227 |
+
|
| 228 |
+
def handle_http_error(self, status_code: int, error_detail: str = "", request_id: str = "", quota_type: Optional[str] = None) -> None:
|
| 229 |
+
"""
|
| 230 |
+
统一处理HTTP错误 - 按错误类型分类处理
|
| 231 |
+
|
| 232 |
+
Args:
|
| 233 |
+
status_code: HTTP状态码
|
| 234 |
+
error_detail: 错误详情
|
| 235 |
+
request_id: 请求ID(用于日志)
|
| 236 |
+
quota_type: 配额类型("text", "images", "videos"),用于按类型冷却
|
| 237 |
+
|
| 238 |
+
处理逻辑:
|
| 239 |
+
- 400: 参数错误,不计入失败(客户端问题)
|
| 240 |
+
- 401/403: 认证错误,冷却 text 配额(等效冷却整个账户)
|
| 241 |
+
- 429: 按配额类型冷却(配额耗尽)
|
| 242 |
+
- 502/503/504/其他: 只记录日志,不触发冷却(临时服务器错误,应直接切换账户重试)
|
| 243 |
+
"""
|
| 244 |
+
req_tag = f"[req_{request_id}] " if request_id else ""
|
| 245 |
+
|
| 246 |
+
# 400参数错误:不计入失败(客户端问题)
|
| 247 |
+
if status_code == 400:
|
| 248 |
+
logger.warning(
|
| 249 |
+
f"[ACCOUNT] [{self.config.account_id}] {req_tag}"
|
| 250 |
+
f"HTTP 400参数错误(不计入失败){': ' + error_detail[:100] if error_detail else ''}"
|
| 251 |
+
)
|
| 252 |
+
return
|
| 253 |
+
|
| 254 |
+
# 403权限错误:Google 返回 403 意味着账户被限制访问,自动禁用
|
| 255 |
+
# (JWT 刷新或 API 调用返回 403 都是永久性封禁,非���时问题)
|
| 256 |
+
if status_code == 403:
|
| 257 |
+
self.config.disabled = True
|
| 258 |
+
self.disabled_reason = "403 Access Restricted"
|
| 259 |
+
logger.error(
|
| 260 |
+
f"[ACCOUNT] [{self.config.account_id}] {req_tag}"
|
| 261 |
+
f"⛔ 账户遇到 403 权限错误,已自动禁用"
|
| 262 |
+
f"{': ' + error_detail[:200] if error_detail else ''}"
|
| 263 |
+
)
|
| 264 |
+
return
|
| 265 |
+
|
| 266 |
+
# 401认证错误:冷却 text 配额(等效冷却整个账户,但可自动恢复)
|
| 267 |
+
if status_code == 401:
|
| 268 |
+
self.quota_cooldowns["text"] = time.time()
|
| 269 |
+
cooldown_seconds = self.text_rate_limit_cooldown_seconds
|
| 270 |
+
logger.warning(
|
| 271 |
+
f"[ACCOUNT] [{self.config.account_id}] {req_tag}"
|
| 272 |
+
f"遇到认证错误,账户将休息{cooldown_seconds}秒后自动恢复"
|
| 273 |
+
f"{': ' + error_detail[:100] if error_detail else ''}"
|
| 274 |
+
)
|
| 275 |
+
return
|
| 276 |
+
|
| 277 |
+
# 429配额错误:按配额类型冷却
|
| 278 |
+
if status_code == 429:
|
| 279 |
+
if not quota_type or quota_type not in QUOTA_TYPES:
|
| 280 |
+
quota_type = "text"
|
| 281 |
+
|
| 282 |
+
self.quota_cooldowns[quota_type] = time.time()
|
| 283 |
+
cooldown_seconds = self._get_quota_cooldown_seconds(quota_type)
|
| 284 |
+
logger.warning(
|
| 285 |
+
f"[ACCOUNT] [{self.config.account_id}] {req_tag}"
|
| 286 |
+
f"遇到429配额错误,{QUOTA_TYPES[quota_type]}配额将休息{cooldown_seconds}秒后自动恢复"
|
| 287 |
+
f"{': ' + error_detail[:100] if error_detail else ''}"
|
| 288 |
+
)
|
| 289 |
+
return
|
| 290 |
+
|
| 291 |
+
# 502/503/504/其他错误:只记录日志,不触发冷却
|
| 292 |
+
# 这些是临时服务器错误,应该直接重试切换账户,不标记配额
|
| 293 |
+
error_type = HTTP_ERROR_NAMES.get(status_code, f"HTTP {status_code}")
|
| 294 |
+
logger.warning(
|
| 295 |
+
f"[ACCOUNT] [{self.config.account_id}] {req_tag}"
|
| 296 |
+
f"遇到{error_type}错误,将切换账户重试(不触发冷却)"
|
| 297 |
+
f"{': ' + error_detail[:100] if error_detail else ''}"
|
| 298 |
+
)
|
| 299 |
+
|
| 300 |
+
def is_quota_available(self, quota_type: str) -> bool:
|
| 301 |
+
"""检查指定配额是否可用(先检查每日上限,再检查冷却)。"""
|
| 302 |
+
if quota_type not in QUOTA_TYPES:
|
| 303 |
+
return True
|
| 304 |
+
|
| 305 |
+
# 主动配额计数检查
|
| 306 |
+
from core.config import config
|
| 307 |
+
quota_limits = config.quota_limits
|
| 308 |
+
if quota_limits.enabled:
|
| 309 |
+
self._reset_daily_usage_if_needed()
|
| 310 |
+
limit = getattr(quota_limits, f"{quota_type}_daily_limit", 0)
|
| 311 |
+
if limit > 0 and self.daily_usage.get(quota_type, 0) >= limit:
|
| 312 |
+
return False
|
| 313 |
+
|
| 314 |
+
# 被动冷却检查(兜底)
|
| 315 |
+
cooldown_time = self.quota_cooldowns.get(quota_type)
|
| 316 |
+
if not cooldown_time:
|
| 317 |
+
return True
|
| 318 |
+
|
| 319 |
+
elapsed = time.time() - cooldown_time
|
| 320 |
+
cooldown_seconds = self._get_quota_cooldown_seconds(quota_type)
|
| 321 |
+
if elapsed < cooldown_seconds:
|
| 322 |
+
return False
|
| 323 |
+
|
| 324 |
+
# 冷却已过期,清理
|
| 325 |
+
del self.quota_cooldowns[quota_type]
|
| 326 |
+
return True
|
| 327 |
+
|
| 328 |
+
def are_quotas_available(self, quota_types: Optional[Iterable[str]] = None) -> bool:
|
| 329 |
+
"""
|
| 330 |
+
检查多个配额类型是否都可用。
|
| 331 |
+
|
| 332 |
+
注意:如果对话配额受限,所有配额都不可用(对话是基础功能)
|
| 333 |
+
"""
|
| 334 |
+
if not quota_types:
|
| 335 |
+
return True
|
| 336 |
+
if isinstance(quota_types, str):
|
| 337 |
+
quota_types = [quota_types]
|
| 338 |
+
|
| 339 |
+
# 如果对话配额受限,所有配额都不可用
|
| 340 |
+
if not self.is_quota_available("text"):
|
| 341 |
+
return False
|
| 342 |
+
|
| 343 |
+
# 检查其他配额
|
| 344 |
+
return all(self.is_quota_available(qt) for qt in quota_types if qt != "text")
|
| 345 |
+
|
| 346 |
+
async def get_jwt(self, request_id: str = "") -> str:
|
| 347 |
+
"""获取 JWT token (带错误处理)"""
|
| 348 |
+
# 检查账户是否过期
|
| 349 |
+
if self.config.is_expired():
|
| 350 |
+
self.is_available = False
|
| 351 |
+
logger.warning(f"[ACCOUNT] [{self.config.account_id}] 账户已过期,已自动禁用")
|
| 352 |
+
raise HTTPException(403, f"Account {self.config.account_id} has expired")
|
| 353 |
+
|
| 354 |
+
try:
|
| 355 |
+
if self.jwt_manager is None:
|
| 356 |
+
# 延迟初始化 JWTManager (避免循环依赖)
|
| 357 |
+
from core.jwt import JWTManager
|
| 358 |
+
self.jwt_manager = JWTManager(self.config, self.http_client, self.user_agent)
|
| 359 |
+
jwt = await self.jwt_manager.get(request_id)
|
| 360 |
+
self.is_available = True
|
| 361 |
+
return jwt
|
| 362 |
+
except Exception as e:
|
| 363 |
+
# 使用统一的错误处理入口
|
| 364 |
+
if isinstance(e, HTTPException):
|
| 365 |
+
self.handle_http_error(e.status_code, str(e.detail) if hasattr(e, 'detail') else "", request_id)
|
| 366 |
+
else:
|
| 367 |
+
self.handle_non_http_error("JWT获取", request_id)
|
| 368 |
+
raise
|
| 369 |
+
|
| 370 |
+
def should_retry(self) -> bool:
|
| 371 |
+
"""检查账户是否可重试 - 简化版:账户始终可用(由配额冷却控制)"""
|
| 372 |
+
# 账户本身始终可用,具体功能由配额冷却控制
|
| 373 |
+
return True
|
| 374 |
+
|
| 375 |
+
def get_cooldown_info(self) -> tuple[int, str | None]:
|
| 376 |
+
"""获取账户冷却信息(只有配额冷却)"""
|
| 377 |
+
current_time = time.time()
|
| 378 |
+
|
| 379 |
+
# 检查配额冷却(找出最长的剩余冷却时间)
|
| 380 |
+
max_quota_remaining = 0
|
| 381 |
+
limited_quota_types = [] # 存储配额类型(text/images/videos)
|
| 382 |
+
quota_icons = {"text": "💬", "images": "🎨", "videos": "🎬"}
|
| 383 |
+
|
| 384 |
+
for quota_type in QUOTA_TYPES:
|
| 385 |
+
if quota_type in self.quota_cooldowns:
|
| 386 |
+
cooldown_time = self.quota_cooldowns[quota_type]
|
| 387 |
+
elapsed = current_time - cooldown_time
|
| 388 |
+
cooldown_seconds = self._get_quota_cooldown_seconds(quota_type)
|
| 389 |
+
if elapsed < cooldown_seconds:
|
| 390 |
+
remaining = int(cooldown_seconds - elapsed)
|
| 391 |
+
if remaining > max_quota_remaining:
|
| 392 |
+
max_quota_remaining = remaining
|
| 393 |
+
limited_quota_types.append(quota_type)
|
| 394 |
+
|
| 395 |
+
# 如果有配额冷却,返回最长的冷却时间和简化的描述
|
| 396 |
+
if max_quota_remaining > 0:
|
| 397 |
+
# 生成 emoji 图标组合
|
| 398 |
+
icons = "".join([quota_icons[qt] for qt in limited_quota_types])
|
| 399 |
+
|
| 400 |
+
# 判断是否全部冷却
|
| 401 |
+
if len(limited_quota_types) == 3:
|
| 402 |
+
return (max_quota_remaining, f"{icons} 全部冷却")
|
| 403 |
+
elif len(limited_quota_types) == 1:
|
| 404 |
+
# 单个配额冷却
|
| 405 |
+
quota_name = QUOTA_TYPES[limited_quota_types[0]]
|
| 406 |
+
return (max_quota_remaining, f"{icons} {quota_name}冷却")
|
| 407 |
+
else:
|
| 408 |
+
# 多个配额冷却(但不是全部)
|
| 409 |
+
quota_names = "/".join([QUOTA_TYPES[qt] for qt in limited_quota_types])
|
| 410 |
+
return (max_quota_remaining, f"{icons} {quota_names}冷却")
|
| 411 |
+
|
| 412 |
+
# 没有冷却,返回正常状态
|
| 413 |
+
return (0, None)
|
| 414 |
+
|
| 415 |
+
def get_quota_status(self) -> Dict[str, any]:
|
| 416 |
+
"""
|
| 417 |
+
获取配额状态(被动检测 + 主动计数)
|
| 418 |
+
|
| 419 |
+
Returns:
|
| 420 |
+
{
|
| 421 |
+
"quotas": {
|
| 422 |
+
"text": {"available": bool, "remaining_seconds": int, "daily_used": int, "daily_limit": int},
|
| 423 |
+
"images": {"available": bool, "remaining_seconds": int, "daily_used": int, "daily_limit": int},
|
| 424 |
+
"videos": {"available": bool, "remaining_seconds": int, "daily_used": int, "daily_limit": int}
|
| 425 |
+
},
|
| 426 |
+
"limited_count": int, # 受限配额数量
|
| 427 |
+
"total_count": int, # 总配额数量
|
| 428 |
+
"is_expired": bool # 账户是否过期/禁用
|
| 429 |
+
}
|
| 430 |
+
"""
|
| 431 |
+
# 获取配额上限配置
|
| 432 |
+
from core.config import config as app_config
|
| 433 |
+
quota_limits = app_config.quota_limits
|
| 434 |
+
|
| 435 |
+
# 检查账户是否过期或被禁用
|
| 436 |
+
is_expired = self.config.is_expired() or self.config.disabled
|
| 437 |
+
if is_expired:
|
| 438 |
+
# 账户过期或被禁用,所有配额不可用
|
| 439 |
+
quotas = {quota_type: {"available": False} for quota_type in QUOTA_TYPES}
|
| 440 |
+
return {
|
| 441 |
+
"quotas": quotas,
|
| 442 |
+
"limited_count": len(QUOTA_TYPES),
|
| 443 |
+
"total_count": len(QUOTA_TYPES),
|
| 444 |
+
"is_expired": True
|
| 445 |
+
}
|
| 446 |
+
|
| 447 |
+
current_time = time.time()
|
| 448 |
+
self._reset_daily_usage_if_needed()
|
| 449 |
+
|
| 450 |
+
quotas = {}
|
| 451 |
+
limited_count = 0
|
| 452 |
+
expired_quotas = [] # 收集已过期的配额类型
|
| 453 |
+
text_limited = False # 对话配额是否受限
|
| 454 |
+
|
| 455 |
+
# 第一遍:检查所有配额状态
|
| 456 |
+
for quota_type in QUOTA_TYPES:
|
| 457 |
+
quota_info: Dict[str, any] = {}
|
| 458 |
+
|
| 459 |
+
# 添加每日使用量信息
|
| 460 |
+
if quota_limits.enabled:
|
| 461 |
+
daily_limit = getattr(quota_limits, f"{quota_type}_daily_limit", 0)
|
| 462 |
+
quota_info["daily_used"] = self.daily_usage.get(quota_type, 0)
|
| 463 |
+
quota_info["daily_limit"] = daily_limit
|
| 464 |
+
|
| 465 |
+
# 检查每日上限
|
| 466 |
+
if daily_limit > 0 and quota_info["daily_used"] >= daily_limit:
|
| 467 |
+
quota_info["available"] = False
|
| 468 |
+
quota_info["reason"] = "每日配额已用完"
|
| 469 |
+
limited_count += 1
|
| 470 |
+
if quota_type == "text":
|
| 471 |
+
text_limited = True
|
| 472 |
+
quotas[quota_type] = quota_info
|
| 473 |
+
continue
|
| 474 |
+
|
| 475 |
+
# 检查被动冷却
|
| 476 |
+
if quota_type in self.quota_cooldowns:
|
| 477 |
+
cooldown_time = self.quota_cooldowns[quota_type]
|
| 478 |
+
elapsed = current_time - cooldown_time
|
| 479 |
+
cooldown_seconds = self._get_quota_cooldown_seconds(quota_type)
|
| 480 |
+
if elapsed < cooldown_seconds:
|
| 481 |
+
remaining = int(cooldown_seconds - elapsed)
|
| 482 |
+
quota_info["available"] = False
|
| 483 |
+
quota_info["remaining_seconds"] = remaining
|
| 484 |
+
limited_count += 1
|
| 485 |
+
if quota_type == "text":
|
| 486 |
+
text_limited = True
|
| 487 |
+
quotas[quota_type] = quota_info
|
| 488 |
+
continue
|
| 489 |
+
else:
|
| 490 |
+
expired_quotas.append(quota_type)
|
| 491 |
+
|
| 492 |
+
quota_info["available"] = True
|
| 493 |
+
quotas[quota_type] = quota_info
|
| 494 |
+
|
| 495 |
+
# 统一删除已过期的配额冷却
|
| 496 |
+
for quota_type in expired_quotas:
|
| 497 |
+
del self.quota_cooldowns[quota_type]
|
| 498 |
+
|
| 499 |
+
# 如果对话配额受限,所有配额都标记为不可用(对话是基础功能)
|
| 500 |
+
if text_limited:
|
| 501 |
+
for quota_type in QUOTA_TYPES:
|
| 502 |
+
if quota_type != "text" and quotas[quota_type].get("available", False):
|
| 503 |
+
quotas[quota_type]["available"] = False
|
| 504 |
+
quotas[quota_type]["reason"] = "对话配额受限"
|
| 505 |
+
limited_count += 1
|
| 506 |
+
|
| 507 |
+
return {
|
| 508 |
+
"quotas": quotas,
|
| 509 |
+
"limited_count": limited_count,
|
| 510 |
+
"total_count": len(QUOTA_TYPES),
|
| 511 |
+
"is_expired": False
|
| 512 |
+
}
|
| 513 |
+
|
| 514 |
+
|
| 515 |
+
class MultiAccountManager:
|
| 516 |
+
"""多账户协调器"""
|
| 517 |
+
def __init__(self, session_cache_ttl_seconds: int):
|
| 518 |
+
self.accounts: Dict[str, AccountManager] = {}
|
| 519 |
+
self.account_list: List[str] = [] # 账户ID列表 (用于轮询)
|
| 520 |
+
self.current_index = 0
|
| 521 |
+
self._cache_lock = asyncio.Lock() # 缓存操作专用锁
|
| 522 |
+
self._counter_lock = threading.Lock() # 轮询计数器锁
|
| 523 |
+
self._request_counter = 0 # 请求计数器
|
| 524 |
+
self._last_account_count = 0 # 可用账户数量
|
| 525 |
+
# 全局会话缓存:{conv_key: {"account_id": str, "session_id": str, "updated_at": float}}
|
| 526 |
+
self.global_session_cache: Dict[str, dict] = {}
|
| 527 |
+
self.cache_max_size = 1000 # 最大缓存条目数
|
| 528 |
+
self.cache_ttl = session_cache_ttl_seconds # 缓存过期时间(秒)
|
| 529 |
+
# Session级别锁:防止同一对话的并发请求冲突
|
| 530 |
+
self._session_locks: Dict[str, asyncio.Lock] = {}
|
| 531 |
+
self._session_locks_lock = asyncio.Lock() # 保护锁字典的锁
|
| 532 |
+
self._session_locks_max_size = 2000 # 最大锁数量
|
| 533 |
+
|
| 534 |
+
def _clean_expired_cache(self):
|
| 535 |
+
"""清理过期的缓存条目"""
|
| 536 |
+
current_time = time.time()
|
| 537 |
+
expired_keys = [
|
| 538 |
+
key for key, value in self.global_session_cache.items()
|
| 539 |
+
if current_time - value["updated_at"] > self.cache_ttl
|
| 540 |
+
]
|
| 541 |
+
for key in expired_keys:
|
| 542 |
+
del self.global_session_cache[key]
|
| 543 |
+
if expired_keys:
|
| 544 |
+
logger.info(f"[CACHE] 清理 {len(expired_keys)} 个过期会话缓存")
|
| 545 |
+
|
| 546 |
+
def _ensure_cache_size(self):
|
| 547 |
+
"""确保缓存不超过最大大小(LRU策略)"""
|
| 548 |
+
if len(self.global_session_cache) > self.cache_max_size:
|
| 549 |
+
# 按更新时间排序,删除最旧的20%
|
| 550 |
+
sorted_items = sorted(
|
| 551 |
+
self.global_session_cache.items(),
|
| 552 |
+
key=lambda x: x[1]["updated_at"]
|
| 553 |
+
)
|
| 554 |
+
remove_count = len(sorted_items) - int(self.cache_max_size * 0.8)
|
| 555 |
+
for key, _ in sorted_items[:remove_count]:
|
| 556 |
+
del self.global_session_cache[key]
|
| 557 |
+
logger.info(f"[CACHE] LRU清理 {remove_count} 个最旧会话缓存")
|
| 558 |
+
|
| 559 |
+
async def start_background_cleanup(self):
|
| 560 |
+
"""启动后台缓存清理任务(每5分钟执行一次)"""
|
| 561 |
+
try:
|
| 562 |
+
while True:
|
| 563 |
+
await asyncio.sleep(300) # 5分钟
|
| 564 |
+
async with self._cache_lock:
|
| 565 |
+
self._clean_expired_cache()
|
| 566 |
+
self._ensure_cache_size()
|
| 567 |
+
except asyncio.CancelledError:
|
| 568 |
+
logger.info("[CACHE] 后台清理任务已停止")
|
| 569 |
+
except Exception as e:
|
| 570 |
+
logger.error(f"[CACHE] 后台清理任务异常: {e}")
|
| 571 |
+
|
| 572 |
+
async def set_session_cache(self, conv_key: str, account_id: str, session_id: str):
|
| 573 |
+
"""线程安全地设置会话缓存"""
|
| 574 |
+
async with self._cache_lock:
|
| 575 |
+
self.global_session_cache[conv_key] = {
|
| 576 |
+
"account_id": account_id,
|
| 577 |
+
"session_id": session_id,
|
| 578 |
+
"updated_at": time.time()
|
| 579 |
+
}
|
| 580 |
+
# 检查缓存大小
|
| 581 |
+
self._ensure_cache_size()
|
| 582 |
+
|
| 583 |
+
async def update_session_time(self, conv_key: str):
|
| 584 |
+
"""线程安全地更新会话时间戳"""
|
| 585 |
+
async with self._cache_lock:
|
| 586 |
+
if conv_key in self.global_session_cache:
|
| 587 |
+
self.global_session_cache[conv_key]["updated_at"] = time.time()
|
| 588 |
+
|
| 589 |
+
async def acquire_session_lock(self, conv_key: str) -> asyncio.Lock:
|
| 590 |
+
"""获取指定对话的锁(用于防止同一对话的并发请求冲突)"""
|
| 591 |
+
async with self._session_locks_lock:
|
| 592 |
+
# 清理过多的锁(LRU策略:删除不在缓存中的锁)
|
| 593 |
+
if len(self._session_locks) > self._session_locks_max_size:
|
| 594 |
+
# 只保留���前缓存中存在的锁
|
| 595 |
+
valid_keys = set(self.global_session_cache.keys())
|
| 596 |
+
keys_to_remove = [k for k in self._session_locks if k not in valid_keys]
|
| 597 |
+
for k in keys_to_remove[:len(keys_to_remove)//2]: # 删除一半无效锁
|
| 598 |
+
del self._session_locks[k]
|
| 599 |
+
|
| 600 |
+
if conv_key not in self._session_locks:
|
| 601 |
+
self._session_locks[conv_key] = asyncio.Lock()
|
| 602 |
+
return self._session_locks[conv_key]
|
| 603 |
+
|
| 604 |
+
def update_http_client(self, http_client):
|
| 605 |
+
"""更新所有账户使用的 http_client(用于代理变更后重建客户端)"""
|
| 606 |
+
for account_mgr in self.accounts.values():
|
| 607 |
+
account_mgr.http_client = http_client
|
| 608 |
+
if account_mgr.jwt_manager is not None:
|
| 609 |
+
account_mgr.jwt_manager.http_client = http_client
|
| 610 |
+
|
| 611 |
+
def add_account(
|
| 612 |
+
self,
|
| 613 |
+
config: AccountConfig,
|
| 614 |
+
http_client,
|
| 615 |
+
user_agent: str,
|
| 616 |
+
retry_policy: RetryPolicy,
|
| 617 |
+
global_stats: dict,
|
| 618 |
+
):
|
| 619 |
+
"""添加账户"""
|
| 620 |
+
manager = AccountManager(config, http_client, user_agent, retry_policy)
|
| 621 |
+
# 从统计数据加载对话次数
|
| 622 |
+
if "account_conversations" in global_stats:
|
| 623 |
+
manager.conversation_count = global_stats["account_conversations"].get(config.account_id, 0)
|
| 624 |
+
if "account_failures" in global_stats:
|
| 625 |
+
manager.failure_count = global_stats["account_failures"].get(config.account_id, 0)
|
| 626 |
+
self.accounts[config.account_id] = manager
|
| 627 |
+
self.account_list.append(config.account_id)
|
| 628 |
+
logger.debug(f"[MULTI] [ACCOUNT] 添加账户: {config.account_id}")
|
| 629 |
+
|
| 630 |
+
def get_available_accounts(
|
| 631 |
+
self,
|
| 632 |
+
required_quota_types: Optional[Iterable[str]] = None
|
| 633 |
+
) -> List[AccountManager]:
|
| 634 |
+
"""获取可用账户列表(过滤掉禁用、过期、冷却中的账户)
|
| 635 |
+
|
| 636 |
+
Args:
|
| 637 |
+
required_quota_types: 需要的配额类型列表(如 ["text"], ["images"], ["text", "videos"])
|
| 638 |
+
|
| 639 |
+
Returns:
|
| 640 |
+
可用账户列表
|
| 641 |
+
|
| 642 |
+
过滤规则:
|
| 643 |
+
1. disabled=True → 跳过(手动禁用)
|
| 644 |
+
2. is_expired() → 跳过(账户过期)
|
| 645 |
+
3. are_quotas_available() → 跳过(配额冷却中)
|
| 646 |
+
"""
|
| 647 |
+
available = []
|
| 648 |
+
|
| 649 |
+
for acc in self.accounts.values():
|
| 650 |
+
# 1. 检查手动禁用
|
| 651 |
+
if acc.config.disabled:
|
| 652 |
+
continue
|
| 653 |
+
|
| 654 |
+
# 2. 检查账户过期
|
| 655 |
+
if acc.config.is_expired():
|
| 656 |
+
continue
|
| 657 |
+
|
| 658 |
+
# 3. 检查配额可用性(包括冷却检查)
|
| 659 |
+
if not acc.are_quotas_available(required_quota_types):
|
| 660 |
+
continue
|
| 661 |
+
|
| 662 |
+
available.append(acc)
|
| 663 |
+
|
| 664 |
+
return available
|
| 665 |
+
|
| 666 |
+
async def get_account(
|
| 667 |
+
self,
|
| 668 |
+
account_id: Optional[str] = None,
|
| 669 |
+
request_id: str = "",
|
| 670 |
+
required_quota_types: Optional[Iterable[str]] = None
|
| 671 |
+
) -> AccountManager:
|
| 672 |
+
"""获取账户 - Round-Robin轮询
|
| 673 |
+
|
| 674 |
+
Args:
|
| 675 |
+
account_id: 指定账户ID(可选,如果指定则直接返回该账户)
|
| 676 |
+
request_id: 请求ID(用于日志)
|
| 677 |
+
required_quota_types: 需要的配额类型列表
|
| 678 |
+
|
| 679 |
+
Returns:
|
| 680 |
+
可用的账户管理器
|
| 681 |
+
|
| 682 |
+
Raises:
|
| 683 |
+
HTTPException(404): 指定的账户不存在
|
| 684 |
+
HTTPException(503): 没有可用账户
|
| 685 |
+
"""
|
| 686 |
+
req_tag = f"[req_{request_id}] " if request_id else ""
|
| 687 |
+
|
| 688 |
+
# 指定账户ID时直接返回
|
| 689 |
+
if account_id:
|
| 690 |
+
if account_id not in self.accounts:
|
| 691 |
+
raise HTTPException(404, f"Account {account_id} not found")
|
| 692 |
+
account = self.accounts[account_id]
|
| 693 |
+
if not account.should_retry():
|
| 694 |
+
raise HTTPException(503, f"Account {account_id} temporarily unavailable")
|
| 695 |
+
if not account.are_quotas_available(required_quota_types):
|
| 696 |
+
raise HTTPException(503, f"Account {account_id} quota temporarily unavailable")
|
| 697 |
+
return account
|
| 698 |
+
|
| 699 |
+
# 获取可用账户列表
|
| 700 |
+
available_accounts = self.get_available_accounts(required_quota_types)
|
| 701 |
+
|
| 702 |
+
if not available_accounts:
|
| 703 |
+
raise HTTPException(503, "No available accounts")
|
| 704 |
+
|
| 705 |
+
# 轮询选择
|
| 706 |
+
with self._counter_lock:
|
| 707 |
+
if len(available_accounts) != self._last_account_count:
|
| 708 |
+
self._request_counter = random.randint(0, 999999)
|
| 709 |
+
self._last_account_count = len(available_accounts)
|
| 710 |
+
index = self._request_counter % len(available_accounts)
|
| 711 |
+
self._request_counter += 1
|
| 712 |
+
|
| 713 |
+
selected = available_accounts[index]
|
| 714 |
+
selected.session_usage_count += 1
|
| 715 |
+
|
| 716 |
+
logger.info(f"[MULTI] [ACCOUNT] {req_tag}选择账户: {selected.config.account_id} "
|
| 717 |
+
f"(索引: {index}/{len(available_accounts)}, 使用: {selected.session_usage_count})")
|
| 718 |
+
return selected
|
| 719 |
+
|
| 720 |
+
|
| 721 |
+
# ---------- 配置管理 ----------
|
| 722 |
+
|
| 723 |
+
def save_accounts_to_file(accounts_data: list):
|
| 724 |
+
"""保存账户配置(仅数据库模式)。"""
|
| 725 |
+
if not storage.is_database_enabled():
|
| 726 |
+
raise RuntimeError("Database is not enabled")
|
| 727 |
+
saved = storage.save_accounts_sync(accounts_data)
|
| 728 |
+
if not saved:
|
| 729 |
+
raise RuntimeError("Database write failed")
|
| 730 |
+
|
| 731 |
+
|
| 732 |
+
def load_accounts_from_source() -> list:
|
| 733 |
+
"""从环境变量或数据库加载账户配置。"""
|
| 734 |
+
env_accounts = os.environ.get('ACCOUNTS_CONFIG')
|
| 735 |
+
if env_accounts:
|
| 736 |
+
try:
|
| 737 |
+
accounts_data = json.loads(env_accounts)
|
| 738 |
+
if accounts_data:
|
| 739 |
+
logger.info(f"[CONFIG] 从环境变量加载配置,共 {len(accounts_data)} 个账户")
|
| 740 |
+
else:
|
| 741 |
+
logger.warning("[CONFIG] 环境变量 ACCOUNTS_CONFIG 为空")
|
| 742 |
+
return accounts_data
|
| 743 |
+
except Exception as e:
|
| 744 |
+
logger.error(f"[CONFIG] 环境变量加载失败: {str(e)}")
|
| 745 |
+
|
| 746 |
+
if storage.is_database_enabled():
|
| 747 |
+
try:
|
| 748 |
+
accounts_data = storage.load_accounts_sync()
|
| 749 |
+
|
| 750 |
+
# 严格模式:数据库连接失败时抛出异常,阻止应用启动
|
| 751 |
+
if accounts_data is None:
|
| 752 |
+
logger.error("[CONFIG] ❌ 数据库连接失败")
|
| 753 |
+
logger.error("[CONFIG] 请检查 DATABASE_URL 配置或网络连接")
|
| 754 |
+
raise RuntimeError("数据库连接失败,应用无法启动")
|
| 755 |
+
|
| 756 |
+
if accounts_data:
|
| 757 |
+
logger.info(f"[CONFIG] 从数据库加载配置,共 {len(accounts_data)} 个账户")
|
| 758 |
+
else:
|
| 759 |
+
logger.warning("[CONFIG] 数据库中账户配置为空")
|
| 760 |
+
logger.warning("[CONFIG] 如需迁移数据,请运行: python scripts/migrate_to_database.py")
|
| 761 |
+
|
| 762 |
+
return accounts_data
|
| 763 |
+
except RuntimeError:
|
| 764 |
+
# 重新抛出 RuntimeError(数据库连接失败)
|
| 765 |
+
raise
|
| 766 |
+
except Exception as e:
|
| 767 |
+
logger.error(f"[CONFIG] ❌ 数据库加载失败: {e}")
|
| 768 |
+
raise RuntimeError(f"数据库加载失败: {e}")
|
| 769 |
+
|
| 770 |
+
logger.error("[CONFIG] 未启用数据库且未提供 ACCOUNTS_CONFIG")
|
| 771 |
+
return []
|
| 772 |
+
|
| 773 |
+
|
| 774 |
+
def get_account_id(acc: dict, index: int) -> str:
|
| 775 |
+
"""获取账户ID(有显式ID则使用,否则生成默认ID)"""
|
| 776 |
+
return acc.get("id", f"account_{index}")
|
| 777 |
+
|
| 778 |
+
|
| 779 |
+
def load_multi_account_config(
|
| 780 |
+
http_client,
|
| 781 |
+
user_agent: str,
|
| 782 |
+
retry_policy: RetryPolicy,
|
| 783 |
+
session_cache_ttl_seconds: int,
|
| 784 |
+
global_stats: dict
|
| 785 |
+
) -> MultiAccountManager:
|
| 786 |
+
"""从文件或环境变量加载多账户配置"""
|
| 787 |
+
manager = MultiAccountManager(session_cache_ttl_seconds)
|
| 788 |
+
|
| 789 |
+
accounts_data = load_accounts_from_source()
|
| 790 |
+
|
| 791 |
+
for i, acc in enumerate(accounts_data, 1):
|
| 792 |
+
# 验证必需字段
|
| 793 |
+
required_fields = ["secure_c_ses", "csesidx", "config_id"]
|
| 794 |
+
missing_fields = [f for f in required_fields if f not in acc]
|
| 795 |
+
if missing_fields:
|
| 796 |
+
raise ValueError(f"账户 {i} 缺少必需字段: {', '.join(missing_fields)}")
|
| 797 |
+
|
| 798 |
+
config = AccountConfig(
|
| 799 |
+
account_id=get_account_id(acc, i),
|
| 800 |
+
secure_c_ses=acc["secure_c_ses"],
|
| 801 |
+
host_c_oses=acc.get("host_c_oses"),
|
| 802 |
+
csesidx=acc["csesidx"],
|
| 803 |
+
config_id=acc["config_id"],
|
| 804 |
+
expires_at=acc.get("expires_at"),
|
| 805 |
+
disabled=acc.get("disabled", False), # 读取手动禁用状态,默认为False
|
| 806 |
+
mail_provider=acc.get("mail_provider"),
|
| 807 |
+
mail_address=acc.get("mail_address"),
|
| 808 |
+
mail_password=acc.get("mail_password") or acc.get("email_password"),
|
| 809 |
+
mail_client_id=acc.get("mail_client_id"),
|
| 810 |
+
mail_refresh_token=acc.get("mail_refresh_token"),
|
| 811 |
+
mail_tenant=acc.get("mail_tenant"),
|
| 812 |
+
trial_end=acc.get("trial_end"),
|
| 813 |
+
)
|
| 814 |
+
|
| 815 |
+
# 检查账户是否已过期(已过期也加载到管理面板)
|
| 816 |
+
is_expired = config.is_expired()
|
| 817 |
+
if is_expired:
|
| 818 |
+
logger.debug(f"[CONFIG] 账户 {config.account_id} 已过期,仍加载用于展示")
|
| 819 |
+
|
| 820 |
+
manager.add_account(config, http_client, user_agent, retry_policy, global_stats)
|
| 821 |
+
|
| 822 |
+
# 从数据库恢复冷却状态和统计数据
|
| 823 |
+
account_mgr = manager.accounts[config.account_id]
|
| 824 |
+
if "quota_cooldowns" in acc:
|
| 825 |
+
account_mgr.quota_cooldowns = dict(acc["quota_cooldowns"])
|
| 826 |
+
if "conversation_count" in acc:
|
| 827 |
+
account_mgr.conversation_count = int(acc.get("conversation_count", 0))
|
| 828 |
+
if "failure_count" in acc:
|
| 829 |
+
account_mgr.failure_count = int(acc.get("failure_count", 0))
|
| 830 |
+
if "daily_usage" in acc:
|
| 831 |
+
account_mgr.daily_usage = dict(acc["daily_usage"])
|
| 832 |
+
if "daily_usage_date" in acc:
|
| 833 |
+
account_mgr.daily_usage_date = str(acc.get("daily_usage_date", ""))
|
| 834 |
+
|
| 835 |
+
if is_expired:
|
| 836 |
+
manager.accounts[config.account_id].is_available = False
|
| 837 |
+
|
| 838 |
+
if not manager.accounts:
|
| 839 |
+
logger.warning(f"[CONFIG] 没有有效的账户配置,服务将启动但无法处理请���,请在管理面板添加账户")
|
| 840 |
+
else:
|
| 841 |
+
logger.info(f"[CONFIG] 成功加载 {len(manager.accounts)} 个账户")
|
| 842 |
+
return manager
|
| 843 |
+
|
| 844 |
+
|
| 845 |
+
def reload_accounts(
|
| 846 |
+
multi_account_mgr: MultiAccountManager,
|
| 847 |
+
http_client,
|
| 848 |
+
user_agent: str,
|
| 849 |
+
retry_policy: RetryPolicy,
|
| 850 |
+
session_cache_ttl_seconds: int,
|
| 851 |
+
global_stats: dict
|
| 852 |
+
) -> MultiAccountManager:
|
| 853 |
+
"""Reload account config and preserve runtime cooldown/error state."""
|
| 854 |
+
# Preserve stats + runtime state to avoid clearing cooldowns on reload.
|
| 855 |
+
old_stats = {}
|
| 856 |
+
for account_id, account_mgr in multi_account_mgr.accounts.items():
|
| 857 |
+
old_stats[account_id] = {
|
| 858 |
+
"conversation_count": account_mgr.conversation_count,
|
| 859 |
+
"failure_count": account_mgr.failure_count,
|
| 860 |
+
"is_available": account_mgr.is_available,
|
| 861 |
+
"last_error_time": account_mgr.last_error_time,
|
| 862 |
+
"session_usage_count": account_mgr.session_usage_count,
|
| 863 |
+
"quota_cooldowns": dict(account_mgr.quota_cooldowns),
|
| 864 |
+
"daily_usage": dict(account_mgr.daily_usage),
|
| 865 |
+
"daily_usage_date": account_mgr.daily_usage_date,
|
| 866 |
+
}
|
| 867 |
+
|
| 868 |
+
# Clear session cache and reload config.
|
| 869 |
+
multi_account_mgr.global_session_cache.clear()
|
| 870 |
+
new_mgr = load_multi_account_config(
|
| 871 |
+
http_client,
|
| 872 |
+
user_agent,
|
| 873 |
+
retry_policy,
|
| 874 |
+
session_cache_ttl_seconds,
|
| 875 |
+
global_stats
|
| 876 |
+
)
|
| 877 |
+
|
| 878 |
+
# Restore stats + runtime state.
|
| 879 |
+
for account_id, stats in old_stats.items():
|
| 880 |
+
if account_id in new_mgr.accounts:
|
| 881 |
+
account_mgr = new_mgr.accounts[account_id]
|
| 882 |
+
account_mgr.conversation_count = stats["conversation_count"]
|
| 883 |
+
account_mgr.failure_count = stats.get("failure_count", 0)
|
| 884 |
+
account_mgr.last_error_time = stats.get("last_error_time", 0.0)
|
| 885 |
+
account_mgr.session_usage_count = stats.get("session_usage_count", 0)
|
| 886 |
+
account_mgr.daily_usage = stats.get("daily_usage", {"text": 0, "images": 0, "videos": 0})
|
| 887 |
+
account_mgr.daily_usage_date = stats.get("daily_usage_date", "")
|
| 888 |
+
|
| 889 |
+
# Smart restore: consider new config's expired/disabled state
|
| 890 |
+
old_available = stats.get("is_available", True)
|
| 891 |
+
old_cooldowns = stats.get("quota_cooldowns", {})
|
| 892 |
+
if account_mgr.config.is_expired() or account_mgr.config.disabled:
|
| 893 |
+
# Still expired/disabled → preserve old state
|
| 894 |
+
account_mgr.is_available = False
|
| 895 |
+
account_mgr.quota_cooldowns = old_cooldowns
|
| 896 |
+
elif not old_available and not old_cooldowns:
|
| 897 |
+
# Was unavailable with no cooldowns (i.e. expired/disabled),
|
| 898 |
+
# now recovered → mark available and clear cooldowns
|
| 899 |
+
account_mgr.is_available = True
|
| 900 |
+
account_mgr.quota_cooldowns = {}
|
| 901 |
+
logger.info(f"[CONFIG] Account {account_id} recovered from expired state, cooldowns cleared")
|
| 902 |
+
else:
|
| 903 |
+
# Normal case: preserve runtime state (e.g. quota cooldowns)
|
| 904 |
+
account_mgr.is_available = old_available
|
| 905 |
+
account_mgr.quota_cooldowns = old_cooldowns
|
| 906 |
+
|
| 907 |
+
logger.debug(f"[CONFIG] Account {account_id} refreshed; runtime state preserved")
|
| 908 |
+
|
| 909 |
+
logger.info(
|
| 910 |
+
f"[CONFIG] Reloaded config; accounts={len(new_mgr.accounts)}; cooldown/error state preserved"
|
| 911 |
+
)
|
| 912 |
+
return new_mgr
|
| 913 |
+
|
| 914 |
+
|
| 915 |
+
def update_accounts_config(
|
| 916 |
+
accounts_data: list,
|
| 917 |
+
multi_account_mgr: MultiAccountManager,
|
| 918 |
+
http_client,
|
| 919 |
+
user_agent: str,
|
| 920 |
+
retry_policy: RetryPolicy,
|
| 921 |
+
session_cache_ttl_seconds: int,
|
| 922 |
+
global_stats: dict
|
| 923 |
+
) -> MultiAccountManager:
|
| 924 |
+
"""更新账户配置(保存到文件并重新加载)"""
|
| 925 |
+
save_accounts_to_file(accounts_data)
|
| 926 |
+
return reload_accounts(
|
| 927 |
+
multi_account_mgr,
|
| 928 |
+
http_client,
|
| 929 |
+
user_agent,
|
| 930 |
+
retry_policy,
|
| 931 |
+
session_cache_ttl_seconds,
|
| 932 |
+
global_stats
|
| 933 |
+
)
|
| 934 |
+
|
| 935 |
+
|
| 936 |
+
def delete_account(
|
| 937 |
+
account_id: str,
|
| 938 |
+
multi_account_mgr: MultiAccountManager,
|
| 939 |
+
http_client,
|
| 940 |
+
user_agent: str,
|
| 941 |
+
retry_policy: RetryPolicy,
|
| 942 |
+
session_cache_ttl_seconds: int,
|
| 943 |
+
global_stats: dict
|
| 944 |
+
) -> MultiAccountManager:
|
| 945 |
+
"""删除单个账户"""
|
| 946 |
+
if storage.is_database_enabled():
|
| 947 |
+
deleted = storage.delete_accounts_sync([account_id])
|
| 948 |
+
if deleted <= 0:
|
| 949 |
+
raise ValueError(f"账户 {account_id} 不存在")
|
| 950 |
+
return reload_accounts(
|
| 951 |
+
multi_account_mgr,
|
| 952 |
+
http_client,
|
| 953 |
+
user_agent,
|
| 954 |
+
retry_policy,
|
| 955 |
+
session_cache_ttl_seconds,
|
| 956 |
+
global_stats
|
| 957 |
+
)
|
| 958 |
+
|
| 959 |
+
accounts_data = load_accounts_from_source()
|
| 960 |
+
|
| 961 |
+
filtered = [
|
| 962 |
+
acc for i, acc in enumerate(accounts_data, 1)
|
| 963 |
+
if get_account_id(acc, i) != account_id
|
| 964 |
+
]
|
| 965 |
+
|
| 966 |
+
if len(filtered) == len(accounts_data):
|
| 967 |
+
raise ValueError(f"账户 {account_id} 不存在")
|
| 968 |
+
|
| 969 |
+
save_accounts_to_file(filtered)
|
| 970 |
+
return reload_accounts(
|
| 971 |
+
multi_account_mgr,
|
| 972 |
+
http_client,
|
| 973 |
+
user_agent,
|
| 974 |
+
retry_policy,
|
| 975 |
+
session_cache_ttl_seconds,
|
| 976 |
+
global_stats
|
| 977 |
+
)
|
| 978 |
+
|
| 979 |
+
|
| 980 |
+
def update_account_disabled_status(
|
| 981 |
+
account_id: str,
|
| 982 |
+
disabled: bool,
|
| 983 |
+
multi_account_mgr: MultiAccountManager,
|
| 984 |
+
) -> MultiAccountManager:
|
| 985 |
+
"""更新账户的禁用状态(优化版:优先数据库直写)。"""
|
| 986 |
+
if storage.is_database_enabled():
|
| 987 |
+
updated = storage.update_account_disabled_sync(account_id, disabled)
|
| 988 |
+
if not updated:
|
| 989 |
+
raise ValueError(f"账户 {account_id} 不存在")
|
| 990 |
+
if account_id in multi_account_mgr.accounts:
|
| 991 |
+
multi_account_mgr.accounts[account_id].config.disabled = disabled
|
| 992 |
+
return multi_account_mgr
|
| 993 |
+
|
| 994 |
+
if account_id not in multi_account_mgr.accounts:
|
| 995 |
+
raise ValueError(f"账户 {account_id} 不存在")
|
| 996 |
+
account_mgr = multi_account_mgr.accounts[account_id]
|
| 997 |
+
account_mgr.config.disabled = disabled
|
| 998 |
+
|
| 999 |
+
accounts_data = load_accounts_from_source()
|
| 1000 |
+
for i, acc in enumerate(accounts_data, 1):
|
| 1001 |
+
if get_account_id(acc, i) == account_id:
|
| 1002 |
+
acc["disabled"] = disabled
|
| 1003 |
+
break
|
| 1004 |
+
|
| 1005 |
+
save_accounts_to_file(accounts_data)
|
| 1006 |
+
|
| 1007 |
+
status_text = "已禁用" if disabled else "已启用"
|
| 1008 |
+
logger.info(f"[CONFIG] 账户 {account_id} {status_text}")
|
| 1009 |
+
return multi_account_mgr
|
| 1010 |
+
|
| 1011 |
+
|
| 1012 |
+
def bulk_update_account_disabled_status(
|
| 1013 |
+
account_ids: list[str],
|
| 1014 |
+
disabled: bool,
|
| 1015 |
+
multi_account_mgr: MultiAccountManager,
|
| 1016 |
+
) -> tuple[int, list[str]]:
|
| 1017 |
+
"""批量更新账户禁用状态,单次最多20个。"""
|
| 1018 |
+
if storage.is_database_enabled():
|
| 1019 |
+
updated, missing = storage.bulk_update_accounts_disabled_sync(account_ids, disabled)
|
| 1020 |
+
for account_id in account_ids:
|
| 1021 |
+
if account_id in multi_account_mgr.accounts:
|
| 1022 |
+
multi_account_mgr.accounts[account_id].config.disabled = disabled
|
| 1023 |
+
errors = [f"{account_id}: 账户不存在" for account_id in missing]
|
| 1024 |
+
status_text = "已禁用" if disabled else "已启用"
|
| 1025 |
+
logger.info(f"[CONFIG] 批量{status_text} {updated}/{len(account_ids)} 个账户")
|
| 1026 |
+
return updated, errors
|
| 1027 |
+
|
| 1028 |
+
success_count = 0
|
| 1029 |
+
errors = []
|
| 1030 |
+
|
| 1031 |
+
for account_id in account_ids:
|
| 1032 |
+
if account_id not in multi_account_mgr.accounts:
|
| 1033 |
+
errors.append(f"{account_id}: 账户不存在")
|
| 1034 |
+
continue
|
| 1035 |
+
account_mgr = multi_account_mgr.accounts[account_id]
|
| 1036 |
+
account_mgr.config.disabled = disabled
|
| 1037 |
+
success_count += 1
|
| 1038 |
+
|
| 1039 |
+
accounts_data = load_accounts_from_source()
|
| 1040 |
+
account_id_set = set(account_ids)
|
| 1041 |
+
|
| 1042 |
+
for i, acc in enumerate(accounts_data, 1):
|
| 1043 |
+
acc_id = get_account_id(acc, i)
|
| 1044 |
+
if acc_id in account_id_set:
|
| 1045 |
+
acc["disabled"] = disabled
|
| 1046 |
+
|
| 1047 |
+
save_accounts_to_file(accounts_data)
|
| 1048 |
+
|
| 1049 |
+
status_text = "已禁用" if disabled else "已启用"
|
| 1050 |
+
logger.info(f"[CONFIG] 批量{status_text} {success_count}/{len(account_ids)} 个账户")
|
| 1051 |
+
return success_count, errors
|
| 1052 |
+
|
| 1053 |
+
|
| 1054 |
+
def bulk_delete_accounts(
|
| 1055 |
+
account_ids: list[str],
|
| 1056 |
+
multi_account_mgr: MultiAccountManager,
|
| 1057 |
+
http_client,
|
| 1058 |
+
user_agent: str,
|
| 1059 |
+
retry_policy: RetryPolicy,
|
| 1060 |
+
session_cache_ttl_seconds: int,
|
| 1061 |
+
global_stats: dict
|
| 1062 |
+
) -> tuple[MultiAccountManager, int, list[str]]:
|
| 1063 |
+
"""批量删除账户,单次最多20个。"""
|
| 1064 |
+
if storage.is_database_enabled():
|
| 1065 |
+
existing_ids = set(multi_account_mgr.accounts.keys())
|
| 1066 |
+
missing = [account_id for account_id in account_ids if account_id not in existing_ids]
|
| 1067 |
+
deleted = storage.delete_accounts_sync(account_ids)
|
| 1068 |
+
errors = [f"{account_id}: 账户不存在" for account_id in missing]
|
| 1069 |
+
if deleted > 0:
|
| 1070 |
+
multi_account_mgr = reload_accounts(
|
| 1071 |
+
multi_account_mgr,
|
| 1072 |
+
http_client,
|
| 1073 |
+
user_agent,
|
| 1074 |
+
retry_policy,
|
| 1075 |
+
session_cache_ttl_seconds,
|
| 1076 |
+
global_stats
|
| 1077 |
+
)
|
| 1078 |
+
logger.info(f"[CONFIG] 批量删除 {deleted}/{len(account_ids)} 个账户")
|
| 1079 |
+
return multi_account_mgr, deleted, errors
|
| 1080 |
+
|
| 1081 |
+
errors = []
|
| 1082 |
+
account_id_set = set(account_ids)
|
| 1083 |
+
|
| 1084 |
+
accounts_data = load_accounts_from_source()
|
| 1085 |
+
kept: list[dict] = []
|
| 1086 |
+
deleted_ids: list[str] = []
|
| 1087 |
+
|
| 1088 |
+
for i, acc in enumerate(accounts_data, 1):
|
| 1089 |
+
acc_id = get_account_id(acc, i)
|
| 1090 |
+
if acc_id in account_id_set:
|
| 1091 |
+
deleted_ids.append(acc_id)
|
| 1092 |
+
continue
|
| 1093 |
+
kept.append(acc)
|
| 1094 |
+
|
| 1095 |
+
missing = account_id_set.difference(deleted_ids)
|
| 1096 |
+
for account_id in missing:
|
| 1097 |
+
errors.append(f"{account_id}: 账户不存在")
|
| 1098 |
+
|
| 1099 |
+
if deleted_ids:
|
| 1100 |
+
save_accounts_to_file(kept)
|
| 1101 |
+
multi_account_mgr = reload_accounts(
|
| 1102 |
+
multi_account_mgr,
|
| 1103 |
+
http_client,
|
| 1104 |
+
user_agent,
|
| 1105 |
+
retry_policy,
|
| 1106 |
+
session_cache_ttl_seconds,
|
| 1107 |
+
global_stats
|
| 1108 |
+
)
|
| 1109 |
+
|
| 1110 |
+
success_count = len(deleted_ids)
|
| 1111 |
+
logger.info(f"[CONFIG] 批量删除 {success_count}/{len(account_ids)} 个账户")
|
| 1112 |
+
return multi_account_mgr, success_count, errors
|
| 1113 |
+
|
| 1114 |
+
|
| 1115 |
+
async def save_account_cooldown_state(account_id: str, account_mgr: AccountManager) -> bool:
|
| 1116 |
+
"""保存单个账户的冷却状态到数据库(优化版:单条更新)"""
|
| 1117 |
+
if not storage.is_database_enabled():
|
| 1118 |
+
return False
|
| 1119 |
+
|
| 1120 |
+
try:
|
| 1121 |
+
cooldown_data = {
|
| 1122 |
+
"quota_cooldowns": dict(account_mgr.quota_cooldowns),
|
| 1123 |
+
"conversation_count": account_mgr.conversation_count,
|
| 1124 |
+
"failure_count": account_mgr.failure_count,
|
| 1125 |
+
"daily_usage": dict(account_mgr.daily_usage),
|
| 1126 |
+
"daily_usage_date": account_mgr.daily_usage_date,
|
| 1127 |
+
}
|
| 1128 |
+
|
| 1129 |
+
success = await storage.update_account_cooldown(account_id, cooldown_data)
|
| 1130 |
+
if success:
|
| 1131 |
+
logger.debug(f"[COOLDOWN] 账户 {account_id} 冷却状态已保存")
|
| 1132 |
+
else:
|
| 1133 |
+
logger.warning(f"[COOLDOWN] 账户 {account_id} 不存在")
|
| 1134 |
+
return success
|
| 1135 |
+
except Exception as e:
|
| 1136 |
+
logger.error(f"[COOLDOWN] 保存账户 {account_id} 冷却状态失败: {e}")
|
| 1137 |
+
return False
|
| 1138 |
+
|
| 1139 |
+
|
| 1140 |
+
def save_account_cooldown_state_sync(account_id: str, account_mgr: AccountManager) -> bool:
|
| 1141 |
+
"""保存单个账户的冷却状态到数据库(同步版本)"""
|
| 1142 |
+
try:
|
| 1143 |
+
return asyncio.run(save_account_cooldown_state(account_id, account_mgr))
|
| 1144 |
+
except Exception as e:
|
| 1145 |
+
logger.error(f"[COOLDOWN] 同步保存账户 {account_id} 冷却状态失败: {e}")
|
| 1146 |
+
return False
|
| 1147 |
+
|
| 1148 |
+
|
| 1149 |
+
async def save_all_cooldown_states(multi_account_mgr: MultiAccountManager) -> int:
|
| 1150 |
+
"""保存有冷却状态的账户到数据库(优化版:批量更新)"""
|
| 1151 |
+
if not storage.is_database_enabled():
|
| 1152 |
+
return 0
|
| 1153 |
+
|
| 1154 |
+
# 收集需要保存的账户
|
| 1155 |
+
updates = []
|
| 1156 |
+
for account_id, account_mgr in multi_account_mgr.accounts.items():
|
| 1157 |
+
has_cooldown = (
|
| 1158 |
+
account_mgr.quota_cooldowns or
|
| 1159 |
+
account_mgr.conversation_count > 0 or
|
| 1160 |
+
account_mgr.failure_count > 0 or
|
| 1161 |
+
any(v > 0 for v in account_mgr.daily_usage.values())
|
| 1162 |
+
)
|
| 1163 |
+
|
| 1164 |
+
if has_cooldown:
|
| 1165 |
+
cooldown_data = {
|
| 1166 |
+
"quota_cooldowns": dict(account_mgr.quota_cooldowns),
|
| 1167 |
+
"conversation_count": account_mgr.conversation_count,
|
| 1168 |
+
"failure_count": account_mgr.failure_count,
|
| 1169 |
+
"daily_usage": dict(account_mgr.daily_usage),
|
| 1170 |
+
"daily_usage_date": account_mgr.daily_usage_date,
|
| 1171 |
+
}
|
| 1172 |
+
updates.append((account_id, cooldown_data))
|
| 1173 |
+
|
| 1174 |
+
if not updates:
|
| 1175 |
+
logger.info(f"[COOLDOWN] 无需保存:所有账户无冷却状态")
|
| 1176 |
+
return 0
|
| 1177 |
+
|
| 1178 |
+
success_count, missing = await storage.bulk_update_accounts_cooldown(updates)
|
| 1179 |
+
|
| 1180 |
+
if missing:
|
| 1181 |
+
logger.warning(f"[COOLDOWN] {len(missing)} 个账户不存在: {missing[:5]}")
|
| 1182 |
+
|
| 1183 |
+
logger.info(f"[COOLDOWN] 批量保存冷却状态: {success_count}/{len(updates)} 个账户(跳过 {len(multi_account_mgr.accounts) - len(updates)} 个无状态账户)")
|
| 1184 |
+
return success_count
|
| 1185 |
+
|
core/auth.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
API认证模块
|
| 3 |
+
提供API Key验证功能(用于API端点)
|
| 4 |
+
管理端点使用Session认证(见core/session_auth.py)
|
| 5 |
+
"""
|
| 6 |
+
from typing import Optional
|
| 7 |
+
from fastapi import HTTPException
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def verify_api_key(api_key_value: str, authorization: Optional[str] = None) -> bool:
|
| 11 |
+
"""
|
| 12 |
+
验证 API Key(支持多个密钥,用逗号分隔)
|
| 13 |
+
|
| 14 |
+
Args:
|
| 15 |
+
api_key_value: 配置的API Key值(如果为空则跳过验证,多个密钥用逗号分隔)
|
| 16 |
+
authorization: Authorization Header中的值
|
| 17 |
+
|
| 18 |
+
Returns:
|
| 19 |
+
验证通过返回True,否则抛出HTTPException
|
| 20 |
+
|
| 21 |
+
支持格式:
|
| 22 |
+
1. Bearer YOUR_API_KEY
|
| 23 |
+
2. YOUR_API_KEY
|
| 24 |
+
|
| 25 |
+
多密钥配置示例:
|
| 26 |
+
API_KEY=key1,key2,key3
|
| 27 |
+
"""
|
| 28 |
+
# 如果未配置 API_KEY,则跳过验证
|
| 29 |
+
if not api_key_value:
|
| 30 |
+
return True
|
| 31 |
+
|
| 32 |
+
# 检查 Authorization header
|
| 33 |
+
if not authorization:
|
| 34 |
+
raise HTTPException(
|
| 35 |
+
status_code=401,
|
| 36 |
+
detail="Missing Authorization header"
|
| 37 |
+
)
|
| 38 |
+
|
| 39 |
+
# 提取token(支持Bearer格式)
|
| 40 |
+
token = authorization
|
| 41 |
+
if authorization.startswith("Bearer "):
|
| 42 |
+
token = authorization[7:]
|
| 43 |
+
|
| 44 |
+
# 解析多个密钥(用逗号分隔)
|
| 45 |
+
valid_keys = [key.strip() for key in api_key_value.split(",") if key.strip()]
|
| 46 |
+
|
| 47 |
+
if token not in valid_keys:
|
| 48 |
+
raise HTTPException(
|
| 49 |
+
status_code=401,
|
| 50 |
+
detail="Invalid API Key"
|
| 51 |
+
)
|
| 52 |
+
|
| 53 |
+
return True
|
core/base_task_service.py
ADDED
|
@@ -0,0 +1,338 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
基础任务服务类
|
| 3 |
+
提供通用的任务管理、日志记录和账户更新功能
|
| 4 |
+
"""
|
| 5 |
+
import asyncio
|
| 6 |
+
import logging
|
| 7 |
+
import threading
|
| 8 |
+
import time
|
| 9 |
+
from concurrent.futures import ThreadPoolExecutor
|
| 10 |
+
from dataclasses import dataclass, field
|
| 11 |
+
from enum import Enum
|
| 12 |
+
from typing import Any, Awaitable, Callable, Deque, Dict, Generic, List, Optional, TypeVar
|
| 13 |
+
from collections import deque
|
| 14 |
+
|
| 15 |
+
from core.account import RetryPolicy, update_accounts_config
|
| 16 |
+
|
| 17 |
+
logger = logging.getLogger("gemini.base_task")
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
class TaskCancelledError(Exception):
|
| 21 |
+
"""用于在线程/回调中快速中断任务执行。"""
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
class TaskStatus(str, Enum):
|
| 25 |
+
"""任务状态枚举"""
|
| 26 |
+
PENDING = "pending"
|
| 27 |
+
RUNNING = "running"
|
| 28 |
+
SUCCESS = "success"
|
| 29 |
+
FAILED = "failed"
|
| 30 |
+
CANCELLED = "cancelled"
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
@dataclass
|
| 34 |
+
class BaseTask:
|
| 35 |
+
"""基础任务数据类"""
|
| 36 |
+
id: str
|
| 37 |
+
status: TaskStatus = TaskStatus.PENDING
|
| 38 |
+
progress: int = 0
|
| 39 |
+
success_count: int = 0
|
| 40 |
+
fail_count: int = 0
|
| 41 |
+
created_at: float = field(default_factory=time.time)
|
| 42 |
+
finished_at: Optional[float] = None
|
| 43 |
+
results: List[Dict[str, Any]] = field(default_factory=list)
|
| 44 |
+
error: Optional[str] = None
|
| 45 |
+
logs: List[Dict[str, str]] = field(default_factory=list)
|
| 46 |
+
cancel_requested: bool = False
|
| 47 |
+
cancel_reason: Optional[str] = None
|
| 48 |
+
|
| 49 |
+
def to_dict(self) -> dict:
|
| 50 |
+
"""转换为字典"""
|
| 51 |
+
return {
|
| 52 |
+
"id": self.id,
|
| 53 |
+
"status": self.status.value,
|
| 54 |
+
"progress": self.progress,
|
| 55 |
+
"success_count": self.success_count,
|
| 56 |
+
"fail_count": self.fail_count,
|
| 57 |
+
"created_at": self.created_at,
|
| 58 |
+
"finished_at": self.finished_at,
|
| 59 |
+
"results": self.results,
|
| 60 |
+
"error": self.error,
|
| 61 |
+
"logs": self.logs,
|
| 62 |
+
"cancel_requested": self.cancel_requested,
|
| 63 |
+
"cancel_reason": self.cancel_reason,
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
T = TypeVar('T', bound=BaseTask)
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
class BaseTaskService(Generic[T]):
|
| 71 |
+
"""
|
| 72 |
+
基础任务服务类
|
| 73 |
+
提供通用的任务管理、日志记录和账户更新功能
|
| 74 |
+
"""
|
| 75 |
+
|
| 76 |
+
def __init__(
|
| 77 |
+
self,
|
| 78 |
+
multi_account_mgr,
|
| 79 |
+
http_client,
|
| 80 |
+
user_agent: str,
|
| 81 |
+
retry_policy: RetryPolicy,
|
| 82 |
+
session_cache_ttl_seconds: int,
|
| 83 |
+
global_stats_provider: Callable[[], dict],
|
| 84 |
+
set_multi_account_mgr: Optional[Callable[[Any], None]] = None,
|
| 85 |
+
log_prefix: str = "TASK",
|
| 86 |
+
) -> None:
|
| 87 |
+
"""
|
| 88 |
+
初始化基础任务服务
|
| 89 |
+
|
| 90 |
+
Args:
|
| 91 |
+
multi_account_mgr: 多账户管理器
|
| 92 |
+
http_client: HTTP客户端
|
| 93 |
+
user_agent: 用户代理
|
| 94 |
+
retry_policy: 重试策略
|
| 95 |
+
session_cache_ttl_seconds: 会话缓存TTL秒数
|
| 96 |
+
global_stats_provider: 全局统计提供者
|
| 97 |
+
set_multi_account_mgr: 设置多账户管理器的回调
|
| 98 |
+
log_prefix: 日志前缀
|
| 99 |
+
"""
|
| 100 |
+
self._executor = ThreadPoolExecutor(max_workers=1)
|
| 101 |
+
self._tasks: Dict[str, T] = {}
|
| 102 |
+
self._current_task_id: Optional[str] = None
|
| 103 |
+
self._last_task_id: Optional[str] = None
|
| 104 |
+
self._lock = asyncio.Lock()
|
| 105 |
+
self._log_lock = threading.Lock()
|
| 106 |
+
self._log_prefix = log_prefix
|
| 107 |
+
self._pending_task_ids: Deque[str] = deque()
|
| 108 |
+
self._worker_task: Optional[asyncio.Task] = None
|
| 109 |
+
self._current_asyncio_task: Optional[asyncio.Task] = None
|
| 110 |
+
self._cancel_hooks: Dict[str, List[Callable[[], None]]] = {}
|
| 111 |
+
self._cancel_hooks_lock = threading.Lock()
|
| 112 |
+
|
| 113 |
+
self.multi_account_mgr = multi_account_mgr
|
| 114 |
+
self.http_client = http_client
|
| 115 |
+
self.user_agent = user_agent
|
| 116 |
+
self.retry_policy = retry_policy
|
| 117 |
+
self.session_cache_ttl_seconds = session_cache_ttl_seconds
|
| 118 |
+
self.global_stats_provider = global_stats_provider
|
| 119 |
+
self.set_multi_account_mgr = set_multi_account_mgr
|
| 120 |
+
|
| 121 |
+
def get_task(self, task_id: str) -> Optional[T]:
|
| 122 |
+
"""获取指定任务"""
|
| 123 |
+
return self._tasks.get(task_id)
|
| 124 |
+
|
| 125 |
+
def get_current_task(self) -> Optional[T]:
|
| 126 |
+
"""获取当前任务"""
|
| 127 |
+
if self._current_task_id:
|
| 128 |
+
current = self._tasks.get(self._current_task_id)
|
| 129 |
+
if current:
|
| 130 |
+
return current
|
| 131 |
+
# 若当前无运行任务,返回队列中最早的 pending 任务(用于前端显示“等待中”)
|
| 132 |
+
for task_id in list(self._pending_task_ids):
|
| 133 |
+
task = self._tasks.get(task_id)
|
| 134 |
+
if task and task.status == TaskStatus.PENDING:
|
| 135 |
+
return task
|
| 136 |
+
return None
|
| 137 |
+
|
| 138 |
+
def get_pending_task_ids(self) -> List[str]:
|
| 139 |
+
"""返回待执行任务ID列表(调试/展示用)。"""
|
| 140 |
+
return list(self._pending_task_ids)
|
| 141 |
+
|
| 142 |
+
async def cancel_task(self, task_id: str, reason: str = "cancelled") -> Optional[T]:
|
| 143 |
+
"""请求取消任务(支持 pending/running)。"""
|
| 144 |
+
async with self._lock:
|
| 145 |
+
task = self._tasks.get(task_id)
|
| 146 |
+
if not task:
|
| 147 |
+
return None
|
| 148 |
+
|
| 149 |
+
if task.status == TaskStatus.PENDING:
|
| 150 |
+
# 从队列移除并直接标记取消
|
| 151 |
+
try:
|
| 152 |
+
self._pending_task_ids.remove(task_id)
|
| 153 |
+
except ValueError:
|
| 154 |
+
pass
|
| 155 |
+
task.cancel_requested = True
|
| 156 |
+
task.cancel_reason = reason
|
| 157 |
+
task.status = TaskStatus.CANCELLED
|
| 158 |
+
task.finished_at = time.time()
|
| 159 |
+
self._append_log(task, "warning", f"task cancelled while pending: {reason}")
|
| 160 |
+
self._save_task_history_best_effort(task)
|
| 161 |
+
self._last_task_id = task.id
|
| 162 |
+
return task
|
| 163 |
+
|
| 164 |
+
if task.status == TaskStatus.RUNNING:
|
| 165 |
+
task.cancel_requested = True
|
| 166 |
+
task.cancel_reason = reason
|
| 167 |
+
self._append_log(task, "warning", f"cancel requested: {reason}")
|
| 168 |
+
# 尝试立即触发取消回调(例如关闭浏览器)
|
| 169 |
+
self._fire_cancel_hooks(task_id)
|
| 170 |
+
# 尝试取消当前 await(例如 run_in_executor 等待点)
|
| 171 |
+
if self._current_asyncio_task and not self._current_asyncio_task.done():
|
| 172 |
+
self._current_asyncio_task.cancel()
|
| 173 |
+
return task
|
| 174 |
+
|
| 175 |
+
return task
|
| 176 |
+
|
| 177 |
+
async def _enqueue_task(self, task: T) -> None:
|
| 178 |
+
"""将任务加入队列并启动 worker。"""
|
| 179 |
+
self._pending_task_ids.append(task.id)
|
| 180 |
+
if not self._worker_task or self._worker_task.done():
|
| 181 |
+
self._worker_task = asyncio.create_task(self._run_worker())
|
| 182 |
+
|
| 183 |
+
async def _run_worker(self) -> None:
|
| 184 |
+
"""串行执行队列任务(单线程 executor + 单 worker)。"""
|
| 185 |
+
while True:
|
| 186 |
+
async with self._lock:
|
| 187 |
+
next_task: Optional[T] = None
|
| 188 |
+
# 清理不存在/非pending的ID
|
| 189 |
+
while self._pending_task_ids:
|
| 190 |
+
task_id = self._pending_task_ids[0]
|
| 191 |
+
task = self._tasks.get(task_id)
|
| 192 |
+
if not task or task.status != TaskStatus.PENDING:
|
| 193 |
+
self._pending_task_ids.popleft()
|
| 194 |
+
continue
|
| 195 |
+
next_task = task
|
| 196 |
+
self._pending_task_ids.popleft()
|
| 197 |
+
self._current_task_id = task.id
|
| 198 |
+
break
|
| 199 |
+
|
| 200 |
+
if not next_task:
|
| 201 |
+
break
|
| 202 |
+
|
| 203 |
+
await self._run_one_task(next_task)
|
| 204 |
+
|
| 205 |
+
async with self._lock:
|
| 206 |
+
if self._current_task_id == next_task.id:
|
| 207 |
+
self._current_task_id = None
|
| 208 |
+
|
| 209 |
+
async def _run_one_task(self, task: T) -> None:
|
| 210 |
+
"""执行单个任务,处理取消/异常/收尾。"""
|
| 211 |
+
if task.status != TaskStatus.PENDING:
|
| 212 |
+
return
|
| 213 |
+
if task.cancel_requested:
|
| 214 |
+
task.status = TaskStatus.CANCELLED
|
| 215 |
+
task.finished_at = time.time()
|
| 216 |
+
return
|
| 217 |
+
|
| 218 |
+
task.status = TaskStatus.RUNNING
|
| 219 |
+
self._append_log(task, "info", "task started")
|
| 220 |
+
try:
|
| 221 |
+
coro = self._execute_task(task)
|
| 222 |
+
self._current_asyncio_task = asyncio.create_task(coro)
|
| 223 |
+
await self._current_asyncio_task
|
| 224 |
+
except asyncio.CancelledError:
|
| 225 |
+
# 外部请求取消(或关闭时)会触发
|
| 226 |
+
task.cancel_requested = True
|
| 227 |
+
task.status = TaskStatus.CANCELLED
|
| 228 |
+
task.finished_at = time.time()
|
| 229 |
+
self._append_log(task, "warning", f"task cancelled: {task.cancel_reason or 'cancelled'}")
|
| 230 |
+
except TaskCancelledError:
|
| 231 |
+
task.cancel_requested = True
|
| 232 |
+
task.status = TaskStatus.CANCELLED
|
| 233 |
+
task.finished_at = time.time()
|
| 234 |
+
self._append_log(task, "warning", f"task cancelled: {task.cancel_reason or 'cancelled'}")
|
| 235 |
+
except Exception as exc:
|
| 236 |
+
task.status = TaskStatus.FAILED
|
| 237 |
+
task.error = str(exc)
|
| 238 |
+
task.finished_at = time.time()
|
| 239 |
+
self._append_log(task, "error", f"task error: {type(exc).__name__}: {str(exc)[:200]}")
|
| 240 |
+
finally:
|
| 241 |
+
self._current_asyncio_task = None
|
| 242 |
+
self._clear_cancel_hooks(task.id)
|
| 243 |
+
if task.status in (TaskStatus.SUCCESS, TaskStatus.FAILED, TaskStatus.CANCELLED) and task.finished_at:
|
| 244 |
+
self._save_task_history_best_effort(task)
|
| 245 |
+
self._last_task_id = task.id
|
| 246 |
+
|
| 247 |
+
def _add_cancel_hook(self, task_id: str, hook: Callable[[], None]) -> None:
|
| 248 |
+
"""注册取消回调(线程安全)。"""
|
| 249 |
+
with self._cancel_hooks_lock:
|
| 250 |
+
self._cancel_hooks.setdefault(task_id, []).append(hook)
|
| 251 |
+
|
| 252 |
+
def _fire_cancel_hooks(self, task_id: str) -> None:
|
| 253 |
+
"""触发取消回调(尽力而为)。"""
|
| 254 |
+
with self._cancel_hooks_lock:
|
| 255 |
+
hooks = list(self._cancel_hooks.get(task_id) or [])
|
| 256 |
+
for hook in hooks:
|
| 257 |
+
try:
|
| 258 |
+
hook()
|
| 259 |
+
except Exception as exc:
|
| 260 |
+
logger.warning("[%s] cancel hook error: %s", self._log_prefix, str(exc)[:120])
|
| 261 |
+
|
| 262 |
+
def _clear_cancel_hooks(self, task_id: str) -> None:
|
| 263 |
+
with self._cancel_hooks_lock:
|
| 264 |
+
self._cancel_hooks.pop(task_id, None)
|
| 265 |
+
|
| 266 |
+
# --- 子类需要实现 ---
|
| 267 |
+
def _execute_task(self, task: T) -> Awaitable[None]:
|
| 268 |
+
"""子类实现:执行任务主体(需自行更新 progress/success/fail/finished_at 等)。"""
|
| 269 |
+
raise NotImplementedError
|
| 270 |
+
|
| 271 |
+
def _append_log(self, task: T, level: str, message: str) -> None:
|
| 272 |
+
"""
|
| 273 |
+
添加日志到任务
|
| 274 |
+
|
| 275 |
+
Args:
|
| 276 |
+
task: 任务对象
|
| 277 |
+
level: 日志级别 (info, warning, error)
|
| 278 |
+
message: 日志消息
|
| 279 |
+
"""
|
| 280 |
+
entry = {
|
| 281 |
+
"time": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()),
|
| 282 |
+
"level": level,
|
| 283 |
+
"message": message,
|
| 284 |
+
}
|
| 285 |
+
with self._log_lock:
|
| 286 |
+
task.logs.append(entry)
|
| 287 |
+
if len(task.logs) > 200:
|
| 288 |
+
task.logs = task.logs[-200:]
|
| 289 |
+
|
| 290 |
+
log_message = f"[{self._log_prefix}] {message}"
|
| 291 |
+
if level == "warning":
|
| 292 |
+
logger.warning(log_message)
|
| 293 |
+
elif level == "error":
|
| 294 |
+
logger.error(log_message)
|
| 295 |
+
else:
|
| 296 |
+
logger.info(log_message)
|
| 297 |
+
|
| 298 |
+
# 协作式取消:一旦请求取消,阻断后续通过 log_callback 的执行路径
|
| 299 |
+
# 允许“取消请求/取消完成”相关日志正常写入
|
| 300 |
+
if task.cancel_requested:
|
| 301 |
+
safe_messages = (
|
| 302 |
+
"cancel requested:",
|
| 303 |
+
"task cancelled",
|
| 304 |
+
"task cancelled while pending",
|
| 305 |
+
"login task cancelled:",
|
| 306 |
+
"register task cancelled:",
|
| 307 |
+
)
|
| 308 |
+
if not any(message.startswith(x) for x in safe_messages):
|
| 309 |
+
raise TaskCancelledError(task.cancel_reason or "cancelled")
|
| 310 |
+
|
| 311 |
+
def _save_task_history_best_effort(self, task: T) -> None:
|
| 312 |
+
try:
|
| 313 |
+
from main import save_task_to_history
|
| 314 |
+
task_type = "login" if self._log_prefix == "REFRESH" else "register"
|
| 315 |
+
save_task_to_history(task_type, task.to_dict())
|
| 316 |
+
except Exception:
|
| 317 |
+
pass
|
| 318 |
+
|
| 319 |
+
def _apply_accounts_update(self, accounts_data: list) -> None:
|
| 320 |
+
"""
|
| 321 |
+
应用账户更新
|
| 322 |
+
|
| 323 |
+
Args:
|
| 324 |
+
accounts_data: 账户数据列表
|
| 325 |
+
"""
|
| 326 |
+
global_stats = self.global_stats_provider() or {}
|
| 327 |
+
new_mgr = update_accounts_config(
|
| 328 |
+
accounts_data,
|
| 329 |
+
self.multi_account_mgr,
|
| 330 |
+
self.http_client,
|
| 331 |
+
self.user_agent,
|
| 332 |
+
self.retry_policy,
|
| 333 |
+
self.session_cache_ttl_seconds,
|
| 334 |
+
global_stats,
|
| 335 |
+
)
|
| 336 |
+
self.multi_account_mgr = new_mgr
|
| 337 |
+
if self.set_multi_account_mgr:
|
| 338 |
+
self.set_multi_account_mgr(new_mgr)
|
core/cfmail_client.py
ADDED
|
@@ -0,0 +1,331 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Cloudflare Temp Email 临时邮箱客户端
|
| 3 |
+
|
| 4 |
+
API 文档参考 (基于 Hono 框架,JWT 认证):
|
| 5 |
+
- 获取公开配置: GET /open_api/settings
|
| 6 |
+
- 创建新邮箱: POST /api/new_address body: {name, domain} → {address, jwt}
|
| 7 |
+
- 获取邮件列表: GET /api/mails Authorization: Bearer {jwt}
|
| 8 |
+
- 获取邮件详情: GET /api/mail/:mail_id Authorization: Bearer {jwt}
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
import random
|
| 12 |
+
import string
|
| 13 |
+
import time
|
| 14 |
+
from datetime import datetime
|
| 15 |
+
from typing import Optional
|
| 16 |
+
|
| 17 |
+
import requests
|
| 18 |
+
|
| 19 |
+
from core.mail_utils import extract_verification_code
|
| 20 |
+
from core.proxy_utils import request_with_proxy_fallback
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
class CloudflareMailClient:
|
| 24 |
+
"""Cloudflare Temp Email 临时邮箱客户端"""
|
| 25 |
+
|
| 26 |
+
def __init__(
|
| 27 |
+
self,
|
| 28 |
+
base_url: str = "",
|
| 29 |
+
proxy: str = "",
|
| 30 |
+
api_key: str = "",
|
| 31 |
+
domain: str = "",
|
| 32 |
+
verify_ssl: bool = True,
|
| 33 |
+
log_callback=None,
|
| 34 |
+
) -> None:
|
| 35 |
+
self.base_url = (base_url or "").rstrip("/")
|
| 36 |
+
self.proxy_url = (proxy or "").strip()
|
| 37 |
+
self.api_key = (api_key or "").strip() # x-custom-auth 密码
|
| 38 |
+
self.domain = (domain or "").strip()
|
| 39 |
+
self.verify_ssl = verify_ssl
|
| 40 |
+
self.log_callback = log_callback
|
| 41 |
+
|
| 42 |
+
self.email: Optional[str] = None
|
| 43 |
+
self.password: Optional[str] = None # 兼容接口,存储 JWT token
|
| 44 |
+
self.jwt_token: Optional[str] = None # 创建地址时返回的 JWT
|
| 45 |
+
|
| 46 |
+
self._available_domains: list = []
|
| 47 |
+
|
| 48 |
+
# ------------------------------------------------------------------
|
| 49 |
+
# 内部工具
|
| 50 |
+
# ------------------------------------------------------------------
|
| 51 |
+
|
| 52 |
+
def _log(self, level: str, message: str) -> None:
|
| 53 |
+
if self.log_callback:
|
| 54 |
+
try:
|
| 55 |
+
self.log_callback(level, message)
|
| 56 |
+
except Exception:
|
| 57 |
+
pass
|
| 58 |
+
|
| 59 |
+
def _request(self, method: str, url: str, **kwargs) -> requests.Response:
|
| 60 |
+
headers = kwargs.pop("headers", None) or {}
|
| 61 |
+
|
| 62 |
+
# 实例密码认证(admin 路由使用 x-admin-auth)
|
| 63 |
+
if self.api_key and "x-admin-auth" not in {k.lower() for k in headers}:
|
| 64 |
+
headers["x-admin-auth"] = self.api_key
|
| 65 |
+
|
| 66 |
+
# 邮件操作时使用 JWT Bearer 认证
|
| 67 |
+
if self.jwt_token and "authorization" not in {k.lower() for k in headers}:
|
| 68 |
+
headers["Authorization"] = f"Bearer {self.jwt_token}"
|
| 69 |
+
|
| 70 |
+
kwargs["headers"] = headers
|
| 71 |
+
|
| 72 |
+
self._log("info", f"📤 发送 {method} 请求: {url}")
|
| 73 |
+
if "json" in kwargs and kwargs["json"] is not None:
|
| 74 |
+
self._log("info", f"📦 请求体: {kwargs['json']}")
|
| 75 |
+
|
| 76 |
+
proxies = {"http": self.proxy_url, "https": self.proxy_url} if self.proxy_url else None
|
| 77 |
+
|
| 78 |
+
try:
|
| 79 |
+
res = request_with_proxy_fallback(
|
| 80 |
+
requests.request,
|
| 81 |
+
method,
|
| 82 |
+
url,
|
| 83 |
+
proxies=proxies,
|
| 84 |
+
verify=self.verify_ssl,
|
| 85 |
+
timeout=kwargs.pop("timeout", 30),
|
| 86 |
+
**kwargs,
|
| 87 |
+
)
|
| 88 |
+
self._log("info", f"📥 收到响应: HTTP {res.status_code}")
|
| 89 |
+
if res.content and res.status_code >= 400:
|
| 90 |
+
try:
|
| 91 |
+
self._log("error", f"📄 响应内容: {res.text[:500]}")
|
| 92 |
+
except Exception:
|
| 93 |
+
pass
|
| 94 |
+
return res
|
| 95 |
+
except Exception as e:
|
| 96 |
+
self._log("error", f"❌ 网络请求失败: {e}")
|
| 97 |
+
raise
|
| 98 |
+
|
| 99 |
+
# ------------------------------------------------------------------
|
| 100 |
+
# 公开接口
|
| 101 |
+
# ------------------------------------------------------------------
|
| 102 |
+
|
| 103 |
+
def set_credentials(self, email: str, password: str = "") -> None:
|
| 104 |
+
"""设置凭据(兼容接口)。password 存储 JWT token。"""
|
| 105 |
+
self.email = email
|
| 106 |
+
self.password = password
|
| 107 |
+
if password:
|
| 108 |
+
self.jwt_token = password
|
| 109 |
+
|
| 110 |
+
def _get_available_domains(self) -> list:
|
| 111 |
+
"""GET /open_api/settings 获取可用域名列表"""
|
| 112 |
+
if self._available_domains:
|
| 113 |
+
return self._available_domains
|
| 114 |
+
|
| 115 |
+
try:
|
| 116 |
+
res = self._request("GET", f"{self.base_url}/open_api/settings")
|
| 117 |
+
if res.status_code == 200:
|
| 118 |
+
data = res.json() if res.content else {}
|
| 119 |
+
domains = data.get("domains", [])
|
| 120 |
+
if isinstance(domains, list) and domains:
|
| 121 |
+
self._available_domains = [str(d).strip() for d in domains if d]
|
| 122 |
+
self._log("info", f"🌐 CFMail 可用域名: {self._available_domains}")
|
| 123 |
+
return self._available_domains
|
| 124 |
+
except Exception as e:
|
| 125 |
+
self._log("error", f"❌ 获取可用域名失败: {e}")
|
| 126 |
+
|
| 127 |
+
return self._available_domains
|
| 128 |
+
|
| 129 |
+
def register_account(self, domain: Optional[str] = None) -> bool:
|
| 130 |
+
"""POST /api/new_address 创建新邮箱地址"""
|
| 131 |
+
if not self.base_url:
|
| 132 |
+
self._log("error", "❌ cfmail_base_url 未配置")
|
| 133 |
+
return False
|
| 134 |
+
|
| 135 |
+
# 确定域名
|
| 136 |
+
selected_domain = domain or self.domain
|
| 137 |
+
if not selected_domain:
|
| 138 |
+
available = self._get_available_domains()
|
| 139 |
+
if available:
|
| 140 |
+
selected_domain = random.choice(available)
|
| 141 |
+
|
| 142 |
+
# 生成随机用户名
|
| 143 |
+
rand = "".join(random.choices(string.ascii_lowercase + string.digits, k=10))
|
| 144 |
+
timestamp = str(int(time.time()))[-4:]
|
| 145 |
+
name = f"t{timestamp}{rand}"
|
| 146 |
+
|
| 147 |
+
payload = {"name": name}
|
| 148 |
+
if selected_domain:
|
| 149 |
+
payload["domain"] = selected_domain
|
| 150 |
+
self._log("info", f"📧 使用域名: {selected_domain}")
|
| 151 |
+
|
| 152 |
+
self._log("info", f"🎲 创建邮箱: {name}")
|
| 153 |
+
|
| 154 |
+
try:
|
| 155 |
+
res = self._request("POST", f"{self.base_url}/admin/new_address", json=payload)
|
| 156 |
+
|
| 157 |
+
if res.status_code in (200, 201):
|
| 158 |
+
data = res.json() if res.content else {}
|
| 159 |
+
address = data.get("address", "")
|
| 160 |
+
jwt = data.get("jwt", "")
|
| 161 |
+
|
| 162 |
+
if address:
|
| 163 |
+
self.email = address
|
| 164 |
+
self.jwt_token = jwt
|
| 165 |
+
self.password = jwt # 兼容接口
|
| 166 |
+
self._log("info", f"✅ CFMail 注册成功: {self.email}")
|
| 167 |
+
return True
|
| 168 |
+
|
| 169 |
+
self._log("error", f"❌ CFMail 注册失败: HTTP {res.status_code}")
|
| 170 |
+
return False
|
| 171 |
+
|
| 172 |
+
except Exception as e:
|
| 173 |
+
self._log("error", f"❌ CFMail 注册异常: {e}")
|
| 174 |
+
return False
|
| 175 |
+
|
| 176 |
+
def login(self) -> bool:
|
| 177 |
+
"""无需登录,直接返回 True"""
|
| 178 |
+
return True
|
| 179 |
+
|
| 180 |
+
@staticmethod
|
| 181 |
+
def _extract_body_from_raw(raw: str) -> str:
|
| 182 |
+
"""从原始邮件中提取正文(text/plain + text/html),跳过 header"""
|
| 183 |
+
if not raw:
|
| 184 |
+
return ""
|
| 185 |
+
import email as _email
|
| 186 |
+
try:
|
| 187 |
+
msg = _email.message_from_string(raw)
|
| 188 |
+
parts = []
|
| 189 |
+
if msg.is_multipart():
|
| 190 |
+
for part in msg.walk():
|
| 191 |
+
ct = part.get_content_type()
|
| 192 |
+
if ct in ("text/plain", "text/html"):
|
| 193 |
+
payload = part.get_payload(decode=True)
|
| 194 |
+
if payload:
|
| 195 |
+
charset = part.get_content_charset() or "utf-8"
|
| 196 |
+
parts.append(payload.decode(charset, errors="replace"))
|
| 197 |
+
else:
|
| 198 |
+
payload = msg.get_payload(decode=True)
|
| 199 |
+
if payload:
|
| 200 |
+
charset = msg.get_content_charset() or "utf-8"
|
| 201 |
+
parts.append(payload.decode(charset, errors="replace"))
|
| 202 |
+
return "".join(parts)
|
| 203 |
+
except Exception:
|
| 204 |
+
return ""
|
| 205 |
+
|
| 206 |
+
def fetch_verification_code(self, since_time: Optional[datetime] = None) -> Optional[str]:
|
| 207 |
+
"""GET /api/mails 获取邮件列表,再 GET /api/mail/:id 获取详情,提取验证码"""
|
| 208 |
+
if not self.jwt_token:
|
| 209 |
+
self._log("error", "❌ 缺少 JWT token,无法获取邮件")
|
| 210 |
+
return None
|
| 211 |
+
|
| 212 |
+
try:
|
| 213 |
+
self._log("info", "📬 正在拉取 CFMail 邮件列表...")
|
| 214 |
+
res = self._request("GET", f"{self.base_url}/api/mails", params={"limit": 20, "offset": 0})
|
| 215 |
+
|
| 216 |
+
if res.status_code != 200:
|
| 217 |
+
self._log("error", f"❌ 获取邮件列表失败: HTTP {res.status_code}")
|
| 218 |
+
return None
|
| 219 |
+
|
| 220 |
+
data = res.json() if res.content else {}
|
| 221 |
+
# 响应格式: {"results": [...], "total": N}
|
| 222 |
+
messages = data.get("results", [])
|
| 223 |
+
if not isinstance(messages, list):
|
| 224 |
+
messages = []
|
| 225 |
+
|
| 226 |
+
if not messages:
|
| 227 |
+
self._log("info", "📭 邮箱为空,暂无邮件")
|
| 228 |
+
return None
|
| 229 |
+
|
| 230 |
+
self._log("info", f"📨 收到 {len(messages)} 封邮件,开始检查验证码...")
|
| 231 |
+
|
| 232 |
+
# 按 id 降序(新邮件优先)
|
| 233 |
+
try:
|
| 234 |
+
messages = sorted(messages, key=lambda m: int(m.get("id") or 0), reverse=True)
|
| 235 |
+
except Exception:
|
| 236 |
+
pass
|
| 237 |
+
|
| 238 |
+
for idx, msg in enumerate(messages, 1):
|
| 239 |
+
msg_id = msg.get("id")
|
| 240 |
+
if not msg_id:
|
| 241 |
+
continue
|
| 242 |
+
|
| 243 |
+
# 时间过滤
|
| 244 |
+
if since_time:
|
| 245 |
+
raw_time = msg.get("created_at") or msg.get("createdAt")
|
| 246 |
+
if raw_time:
|
| 247 |
+
try:
|
| 248 |
+
if isinstance(raw_time, (int, float)):
|
| 249 |
+
ts = float(raw_time)
|
| 250 |
+
if ts > 1e12:
|
| 251 |
+
ts /= 1000.0
|
| 252 |
+
msg_time = datetime.fromtimestamp(ts)
|
| 253 |
+
else:
|
| 254 |
+
import re
|
| 255 |
+
raw_time = re.sub(r"(\.\d{6})\d+", r"\1", str(raw_time))
|
| 256 |
+
# cfmail 的 created_at 是 UTC 无时区标记,显式加 +00:00 再转本地时间
|
| 257 |
+
if not raw_time.endswith("Z") and "+" not in raw_time and raw_time.count("-") <= 2:
|
| 258 |
+
raw_time = raw_time + "+00:00"
|
| 259 |
+
msg_time = datetime.fromisoformat(raw_time.replace("Z", "+00:00")).astimezone().replace(tzinfo=None)
|
| 260 |
+
if msg_time < since_time:
|
| 261 |
+
continue
|
| 262 |
+
except Exception:
|
| 263 |
+
pass
|
| 264 |
+
|
| 265 |
+
# 列表响应已包含 raw 字段,直接解析正文提取验证码
|
| 266 |
+
raw_in_list = msg.get("raw") or ""
|
| 267 |
+
if raw_in_list:
|
| 268 |
+
body = self._extract_body_from_raw(raw_in_list)
|
| 269 |
+
code = extract_verification_code(body)
|
| 270 |
+
if code:
|
| 271 |
+
self._log("info", f"✅ 找到验证码: {code}")
|
| 272 |
+
return code
|
| 273 |
+
|
| 274 |
+
# 兜底:尝试从其他摘要字段提取
|
| 275 |
+
summary = (msg.get("subject") or "") + (msg.get("text") or "") + (msg.get("html") or "")
|
| 276 |
+
if summary:
|
| 277 |
+
code = extract_verification_code(summary)
|
| 278 |
+
if code:
|
| 279 |
+
self._log("info", f"✅ 找到验证码: {code}")
|
| 280 |
+
return code
|
| 281 |
+
|
| 282 |
+
# 获取邮件详情
|
| 283 |
+
self._log("info", f"🔍 正在读取邮件 {idx}/{len(messages)} 详情...")
|
| 284 |
+
detail_res = self._request("GET", f"{self.base_url}/api/mail/{msg_id}")
|
| 285 |
+
|
| 286 |
+
if detail_res.status_code != 200:
|
| 287 |
+
self._log("warning", f"⚠️ 读取邮件详情失败: HTTP {detail_res.status_code}")
|
| 288 |
+
continue
|
| 289 |
+
|
| 290 |
+
detail = detail_res.json() if detail_res.content else {}
|
| 291 |
+
content = self._extract_body_from_raw(detail.get("raw") or "")
|
| 292 |
+
if content:
|
| 293 |
+
code = extract_verification_code(content)
|
| 294 |
+
if code:
|
| 295 |
+
self._log("info", f"✅ 找到验证码: {code}")
|
| 296 |
+
return code
|
| 297 |
+
else:
|
| 298 |
+
self._log("info", f"❌ 邮件 {idx} 中未找到验证码")
|
| 299 |
+
|
| 300 |
+
self._log("warning", "⚠️ 所有邮件中均未找到验证码")
|
| 301 |
+
return None
|
| 302 |
+
|
| 303 |
+
except Exception as e:
|
| 304 |
+
self._log("error", f"❌ 获取验证码异常: {e}")
|
| 305 |
+
return None
|
| 306 |
+
|
| 307 |
+
def poll_for_code(
|
| 308 |
+
self,
|
| 309 |
+
timeout: int = 120,
|
| 310 |
+
interval: int = 4,
|
| 311 |
+
since_time: Optional[datetime] = None,
|
| 312 |
+
) -> Optional[str]:
|
| 313 |
+
"""轮询获取验证码"""
|
| 314 |
+
if not self.email:
|
| 315 |
+
return None
|
| 316 |
+
|
| 317 |
+
max_retries = max(1, timeout // interval)
|
| 318 |
+
self._log("info", f"⏱️ 开始轮询验证码 (超时 {timeout}秒, 间隔 {interval}秒, 最多 {max_retries} 次)")
|
| 319 |
+
|
| 320 |
+
for i in range(1, max_retries + 1):
|
| 321 |
+
self._log("info", f"🔄 第 {i}/{max_retries} 次轮询...")
|
| 322 |
+
code = self.fetch_verification_code(since_time=since_time)
|
| 323 |
+
if code:
|
| 324 |
+
self._log("info", f"🎉 验证码获取成功: {code}")
|
| 325 |
+
return code
|
| 326 |
+
if i < max_retries:
|
| 327 |
+
self._log("info", f"⏳ 等待 {interval} 秒后重试...")
|
| 328 |
+
time.sleep(interval)
|
| 329 |
+
|
| 330 |
+
self._log("error", f"⏰ 验证码获取超时 ({timeout}秒)")
|
| 331 |
+
return None
|
core/child_reaper.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Child process reaper (Linux/Unix).
|
| 3 |
+
|
| 4 |
+
When third-party libraries spawn subprocesses and do not `wait()` them properly,
|
| 5 |
+
the exited children become zombie processes (<defunct>) until the parent reaps.
|
| 6 |
+
|
| 7 |
+
This module installs a SIGCHLD handler that reaps all exited children with
|
| 8 |
+
os.waitpid(-1, WNOHANG) to prevent zombie accumulation in long-running services.
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
|
| 13 |
+
import errno
|
| 14 |
+
import os
|
| 15 |
+
import signal
|
| 16 |
+
from typing import Callable, Optional, Union
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
_InstalledHandler = Union[int, Callable[[int, object], None], None]
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def install_child_reaper(log: Optional[Callable[[str], None]] = None) -> bool:
|
| 23 |
+
"""
|
| 24 |
+
Install a SIGCHLD handler to reap zombie processes.
|
| 25 |
+
|
| 26 |
+
Returns True if a handler was installed, otherwise False.
|
| 27 |
+
Safe to call multiple times; it will simply re-install the handler.
|
| 28 |
+
"""
|
| 29 |
+
# Windows has no SIGCHLD; only do this on POSIX.
|
| 30 |
+
if os.name != "posix":
|
| 31 |
+
return False
|
| 32 |
+
|
| 33 |
+
if not hasattr(signal, "SIGCHLD"):
|
| 34 |
+
return False
|
| 35 |
+
|
| 36 |
+
try:
|
| 37 |
+
old_handler: _InstalledHandler = signal.getsignal(signal.SIGCHLD)
|
| 38 |
+
except Exception:
|
| 39 |
+
old_handler = None
|
| 40 |
+
|
| 41 |
+
def _log(msg: str) -> None:
|
| 42 |
+
if log:
|
| 43 |
+
try:
|
| 44 |
+
log(msg)
|
| 45 |
+
except Exception:
|
| 46 |
+
pass
|
| 47 |
+
|
| 48 |
+
def _reap_all_children() -> None:
|
| 49 |
+
# Reap all already-exited child processes (non-blocking).
|
| 50 |
+
while True:
|
| 51 |
+
try:
|
| 52 |
+
pid, _status = os.waitpid(-1, os.WNOHANG)
|
| 53 |
+
except ChildProcessError:
|
| 54 |
+
# No child processes.
|
| 55 |
+
return
|
| 56 |
+
except OSError as e:
|
| 57 |
+
if e.errno == errno.ECHILD:
|
| 58 |
+
return
|
| 59 |
+
# Any other error: stop to avoid spinning.
|
| 60 |
+
_log(f"[CHILD-REAPER] waitpid failed: {e}")
|
| 61 |
+
return
|
| 62 |
+
|
| 63 |
+
if pid == 0:
|
| 64 |
+
return
|
| 65 |
+
|
| 66 |
+
def _handler(signum: int, frame) -> None:
|
| 67 |
+
# 1) First reap everything we can to prevent zombies.
|
| 68 |
+
_reap_all_children()
|
| 69 |
+
|
| 70 |
+
# 2) Chain previous handler (if it was a Python callable).
|
| 71 |
+
try:
|
| 72 |
+
if callable(old_handler):
|
| 73 |
+
old_handler(signum, frame)
|
| 74 |
+
except Exception:
|
| 75 |
+
# Never let exceptions escape a signal handler.
|
| 76 |
+
pass
|
| 77 |
+
|
| 78 |
+
try:
|
| 79 |
+
signal.signal(signal.SIGCHLD, _handler)
|
| 80 |
+
return True
|
| 81 |
+
except Exception as e:
|
| 82 |
+
_log(f"[CHILD-REAPER] failed to install SIGCHLD handler: {e}")
|
| 83 |
+
return False
|
| 84 |
+
|
core/config.py
ADDED
|
@@ -0,0 +1,554 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
统一配置管理系统
|
| 3 |
+
|
| 4 |
+
优先级规则:
|
| 5 |
+
1. 安全配置:仅环境变量(ADMIN_KEY, SESSION_SECRET_KEY)
|
| 6 |
+
2. 业务配置:数据库 > 默认值
|
| 7 |
+
|
| 8 |
+
配置分类:
|
| 9 |
+
- 安全配置:仅从环境变量读取,不可热更新(ADMIN_KEY, SESSION_SECRET_KEY)
|
| 10 |
+
- 业务配置:仅从数据库读取,支持热更新(API_KEY, BASE_URL, PROXY, 重试策略等)
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
import os
|
| 14 |
+
import shutil
|
| 15 |
+
import yaml
|
| 16 |
+
import secrets
|
| 17 |
+
from pathlib import Path
|
| 18 |
+
from typing import Optional, List
|
| 19 |
+
from pydantic import BaseModel, Field, validator
|
| 20 |
+
from dotenv import load_dotenv
|
| 21 |
+
|
| 22 |
+
from core import storage
|
| 23 |
+
|
| 24 |
+
# 加载 .env 文件
|
| 25 |
+
load_dotenv()
|
| 26 |
+
|
| 27 |
+
def _parse_bool(value, default: bool) -> bool:
|
| 28 |
+
if isinstance(value, bool):
|
| 29 |
+
return value
|
| 30 |
+
if value is None:
|
| 31 |
+
return default
|
| 32 |
+
if isinstance(value, (int, float)):
|
| 33 |
+
return value != 0
|
| 34 |
+
if isinstance(value, str):
|
| 35 |
+
lowered = value.strip().lower()
|
| 36 |
+
if lowered in ("1", "true", "yes", "y", "on"):
|
| 37 |
+
return True
|
| 38 |
+
if lowered in ("0", "false", "no", "n", "off"):
|
| 39 |
+
return False
|
| 40 |
+
return default
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
# ==================== 配置模型定义 ====================
|
| 44 |
+
|
| 45 |
+
class BasicConfig(BaseModel):
|
| 46 |
+
"""基础配置"""
|
| 47 |
+
api_key: str = Field(default="", description="API访问密钥(留空则公开访问,多个密钥用逗号分隔)")
|
| 48 |
+
base_url: str = Field(default="", description="服务器URL(留空则自动检测)")
|
| 49 |
+
proxy_for_auth: str = Field(default="", description="账户操作代理地址(注册/登录/刷新,留空则不使用代理)")
|
| 50 |
+
proxy_for_chat: str = Field(default="", description="对话操作代理地址(JWT/会话/消息,留空则不使用代理)")
|
| 51 |
+
duckmail_base_url: str = Field(default="https://api.duckmail.sbs", description="DuckMail API地址")
|
| 52 |
+
duckmail_api_key: str = Field(default="", description="DuckMail API key")
|
| 53 |
+
duckmail_verify_ssl: bool = Field(default=True, description="DuckMail SSL校验")
|
| 54 |
+
temp_mail_provider: str = Field(default="duckmail", description="临时邮箱提供商: duckmail/moemail/freemail/gptmail/cfmail")
|
| 55 |
+
moemail_base_url: str = Field(default="https://moemail.app", description="Moemail API地址")
|
| 56 |
+
moemail_api_key: str = Field(default="", description="Moemail API key")
|
| 57 |
+
moemail_domain: str = Field(default="", description="Moemail 邮箱域名(可选,留空则随机选择)")
|
| 58 |
+
freemail_base_url: str = Field(default="http://your-freemail-server.com", description="Freemail API地址")
|
| 59 |
+
freemail_jwt_token: str = Field(default="", description="Freemail JWT Token")
|
| 60 |
+
freemail_verify_ssl: bool = Field(default=True, description="Freemail SSL校验")
|
| 61 |
+
freemail_domain: str = Field(default="", description="Freemail 邮箱域名(可选,留空则随机选择)")
|
| 62 |
+
mail_proxy_enabled: bool = Field(default=False, description="是否启用临时邮箱代理(使用账户操作代理)")
|
| 63 |
+
gptmail_base_url: str = Field(default="https://mail.chatgpt.org.uk", description="GPTMail API地址")
|
| 64 |
+
gptmail_api_key: str = Field(default="gpt-test", description="GPTMail API key")
|
| 65 |
+
gptmail_verify_ssl: bool = Field(default=True, description="GPTMail SSL校验")
|
| 66 |
+
gptmail_domain: str = Field(default="", description="GPTMail 邮箱域名(可选,留空则随机选择)")
|
| 67 |
+
cfmail_base_url: str = Field(default="", description="Cloudflare Mail API地址")
|
| 68 |
+
cfmail_api_key: str = Field(default="", description="Cloudflare Mail 访问密码(x-custom-auth)")
|
| 69 |
+
cfmail_verify_ssl: bool = Field(default=True, description="Cloudflare Mail SSL校验")
|
| 70 |
+
cfmail_domain: str = Field(default="", description="Cloudflare Mail 邮箱域名(可选,留空随机)")
|
| 71 |
+
browser_engine: str = Field(default="dp", description="浏览器引擎")
|
| 72 |
+
browser_headless: bool = Field(default=False, description="自动化浏览器无头模式")
|
| 73 |
+
refresh_window_hours: int = Field(default=1, ge=0, le=24, description="过期刷新窗口(小时)")
|
| 74 |
+
register_default_count: int = Field(default=1, ge=1, description="默认注册数量")
|
| 75 |
+
register_domain: str = Field(default="", description="DuckMail 域名(推荐)")
|
| 76 |
+
image_expire_hours: int = Field(default=12, ge=-1, le=720, description="图片/视频过期时间(小时),-1为永不删除")
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
class ImageGenerationConfig(BaseModel):
|
| 80 |
+
"""图片生成配置"""
|
| 81 |
+
enabled: bool = Field(default=False, description="是否启用图片生成")
|
| 82 |
+
supported_models: List[str] = Field(
|
| 83 |
+
default=[],
|
| 84 |
+
description="支持图片生成的模型列表"
|
| 85 |
+
)
|
| 86 |
+
output_format: str = Field(default="base64", description="图片输出格式:base64 或 url")
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
class VideoGenerationConfig(BaseModel):
|
| 90 |
+
"""视频生成配置"""
|
| 91 |
+
output_format: str = Field(default="html", description="视频输出格式:html/url/markdown")
|
| 92 |
+
|
| 93 |
+
@validator("output_format")
|
| 94 |
+
def validate_output_format(cls, v):
|
| 95 |
+
allowed = ["html", "url", "markdown"]
|
| 96 |
+
if v not in allowed:
|
| 97 |
+
raise ValueError(f"output_format 必须是 {allowed} 之一")
|
| 98 |
+
return v
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
class RetryConfig(BaseModel):
|
| 102 |
+
"""重试策略配置"""
|
| 103 |
+
max_account_switch_tries: int = Field(default=5, ge=1, le=20, description="账户切换尝试次数")
|
| 104 |
+
rate_limit_cooldown_seconds: int = Field(default=7200, ge=3600, le=43200, description="429冷却时间(秒)")
|
| 105 |
+
text_rate_limit_cooldown_seconds: int = Field(default=7200, ge=3600, le=86400, description="对话配额冷却(秒)")
|
| 106 |
+
images_rate_limit_cooldown_seconds: int = Field(default=14400, ge=3600, le=86400, description="绘图配额冷却(秒)")
|
| 107 |
+
videos_rate_limit_cooldown_seconds: int = Field(default=14400, ge=3600, le=86400, description="视频配额冷却(秒)")
|
| 108 |
+
session_cache_ttl_seconds: int = Field(default=3600, ge=0, le=86400, description="会话缓存时间(秒,0表示禁用缓存)")
|
| 109 |
+
auto_refresh_accounts_seconds: int = Field(default=60, ge=0, le=600, description="自动刷新账号间隔(秒,0禁用)")
|
| 110 |
+
# 定时刷新配置
|
| 111 |
+
scheduled_refresh_enabled: bool = Field(default=False, description="是否启用定时刷新任务")
|
| 112 |
+
scheduled_refresh_cron: str = Field(default="08:00,20:00", description="刷新时间,如 '08:00,20:00' 或 '*/120'(每120分钟)")
|
| 113 |
+
refresh_batch_size: int = Field(default=5, ge=1, le=20, description="每批刷新账号数")
|
| 114 |
+
refresh_batch_interval_minutes: int = Field(default=30, ge=5, le=120, description="批次间等待时间(分钟)")
|
| 115 |
+
refresh_cooldown_hours: float = Field(default=12.0, ge=1, le=48, description="同一账号刷新冷却期(小时)")
|
| 116 |
+
# 向后兼容:旧配置可能只有这个字段,读取时自动转换为 */N cron 格式
|
| 117 |
+
scheduled_refresh_interval_minutes: int = Field(default=0, ge=0, le=720, description="(旧字段,已废弃) 定时刷新检测间隔")
|
| 118 |
+
|
| 119 |
+
class QuotaLimitsConfig(BaseModel):
|
| 120 |
+
"""每日配额上限配置(基于 Google 官方限额,用于主动配额计数)"""
|
| 121 |
+
enabled: bool = Field(default=True, description="是否启用主动配额计数")
|
| 122 |
+
text_daily_limit: int = Field(default=120, ge=0, le=9999, description="对话每日上限(0=不限制)")
|
| 123 |
+
images_daily_limit: int = Field(default=2, ge=0, le=9999, description="绘图每日上限(0=不限制)")
|
| 124 |
+
videos_daily_limit: int = Field(default=1, ge=0, le=9999, description="视频每日上限(0=不限制)")
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
class PublicDisplayConfig(BaseModel):
|
| 128 |
+
"""公开展示配置"""
|
| 129 |
+
logo_url: str = Field(default="", description="Logo URL")
|
| 130 |
+
chat_url: str = Field(default="", description="开始对话链接")
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
class SessionConfig(BaseModel):
|
| 134 |
+
"""Session配置"""
|
| 135 |
+
expire_hours: int = Field(default=24, ge=1, le=168, description="Session过期时间(小时)")
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
class SecurityConfig(BaseModel):
|
| 139 |
+
"""安全配置(仅从环境变量读取,不可热更新)"""
|
| 140 |
+
admin_key: str = Field(default="", description="管理员密钥(必需)")
|
| 141 |
+
session_secret_key: str = Field(..., description="Session密钥")
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
class AppConfig(BaseModel):
|
| 145 |
+
"""应用配置(统一管理)"""
|
| 146 |
+
# 安全配置(仅从环境变量)
|
| 147 |
+
security: SecurityConfig
|
| 148 |
+
|
| 149 |
+
# 业务配置(环境变量 > 数据库 > 默认值)
|
| 150 |
+
basic: BasicConfig
|
| 151 |
+
image_generation: ImageGenerationConfig
|
| 152 |
+
video_generation: VideoGenerationConfig = Field(default_factory=VideoGenerationConfig)
|
| 153 |
+
retry: RetryConfig
|
| 154 |
+
quota_limits: QuotaLimitsConfig = Field(default_factory=QuotaLimitsConfig)
|
| 155 |
+
public_display: PublicDisplayConfig
|
| 156 |
+
session: SessionConfig
|
| 157 |
+
|
| 158 |
+
|
| 159 |
+
# ==================== 配置管理器 ====================
|
| 160 |
+
|
| 161 |
+
class ConfigManager:
|
| 162 |
+
"""配置管理器(单例)"""
|
| 163 |
+
|
| 164 |
+
def __init__(self, yaml_path: str = None):
|
| 165 |
+
# 自动检测环境并设置默认路径
|
| 166 |
+
if yaml_path is None:
|
| 167 |
+
yaml_path = ""
|
| 168 |
+
self.yaml_path = Path(yaml_path)
|
| 169 |
+
self._config: Optional[AppConfig] = None
|
| 170 |
+
self.load()
|
| 171 |
+
|
| 172 |
+
def load(self):
|
| 173 |
+
"""
|
| 174 |
+
加载配置
|
| 175 |
+
|
| 176 |
+
优先级规则:
|
| 177 |
+
1. 安全配置(ADMIN_KEY, SESSION_SECRET_KEY):仅从环境变量读取
|
| 178 |
+
2. 业务配置:数据库 > 默认值
|
| 179 |
+
"""
|
| 180 |
+
# 1. 从数据库加载配置
|
| 181 |
+
yaml_data = self._load_yaml()
|
| 182 |
+
|
| 183 |
+
# 2. 加载安全配置(仅从环境变量,不允许 Web 修改)
|
| 184 |
+
security_config = SecurityConfig(
|
| 185 |
+
admin_key=os.getenv("ADMIN_KEY", ""),
|
| 186 |
+
session_secret_key=os.getenv("SESSION_SECRET_KEY", self._generate_secret())
|
| 187 |
+
)
|
| 188 |
+
|
| 189 |
+
# 3. 加载基础配置(数据库 > 默认值)
|
| 190 |
+
basic_data = yaml_data.get("basic", {})
|
| 191 |
+
refresh_window_raw = basic_data.get("refresh_window_hours", 1)
|
| 192 |
+
register_default_raw = basic_data.get("register_default_count", 1)
|
| 193 |
+
register_domain_raw = basic_data.get("register_domain", "")
|
| 194 |
+
duckmail_api_key_raw = basic_data.get("duckmail_api_key", "")
|
| 195 |
+
|
| 196 |
+
# 兼容旧配置:如果存在旧的 proxy 字段,迁移到新字段
|
| 197 |
+
old_proxy = basic_data.get("proxy", "")
|
| 198 |
+
old_proxy_for_auth_bool = basic_data.get("proxy_for_auth")
|
| 199 |
+
old_proxy_for_chat_bool = basic_data.get("proxy_for_chat")
|
| 200 |
+
|
| 201 |
+
# 新配置优先,如果没有新配置则从旧配置迁移
|
| 202 |
+
proxy_for_auth = basic_data.get("proxy_for_auth", "")
|
| 203 |
+
proxy_for_chat = basic_data.get("proxy_for_chat", "")
|
| 204 |
+
|
| 205 |
+
# 如果新配置为空且存在旧配置,则迁移
|
| 206 |
+
if not proxy_for_auth and old_proxy:
|
| 207 |
+
# 如果旧配置中 proxy_for_auth 是布尔值且为 True,则使用旧的 proxy
|
| 208 |
+
if isinstance(old_proxy_for_auth_bool, bool) and old_proxy_for_auth_bool:
|
| 209 |
+
proxy_for_auth = old_proxy
|
| 210 |
+
|
| 211 |
+
if not proxy_for_chat and old_proxy:
|
| 212 |
+
# 如果旧配置中 proxy_for_chat 是布尔值且为 True,则使用旧的 proxy
|
| 213 |
+
if isinstance(old_proxy_for_chat_bool, bool) and old_proxy_for_chat_bool:
|
| 214 |
+
proxy_for_chat = old_proxy
|
| 215 |
+
|
| 216 |
+
basic_config = BasicConfig(
|
| 217 |
+
api_key=basic_data.get("api_key") or "",
|
| 218 |
+
base_url=basic_data.get("base_url") or "",
|
| 219 |
+
proxy_for_auth=str(proxy_for_auth or "").strip(),
|
| 220 |
+
proxy_for_chat=str(proxy_for_chat or "").strip(),
|
| 221 |
+
duckmail_base_url=basic_data.get("duckmail_base_url") or "https://api.duckmail.sbs",
|
| 222 |
+
duckmail_api_key=str(duckmail_api_key_raw or "").strip(),
|
| 223 |
+
duckmail_verify_ssl=_parse_bool(basic_data.get("duckmail_verify_ssl"), True),
|
| 224 |
+
temp_mail_provider=basic_data.get("temp_mail_provider") or "moemail",
|
| 225 |
+
moemail_base_url=basic_data.get("moemail_base_url") or "https://moemail.nanohajimi.mom",
|
| 226 |
+
moemail_api_key=str(basic_data.get("moemail_api_key") or "").strip(),
|
| 227 |
+
moemail_domain=str(basic_data.get("moemail_domain") or "").strip(),
|
| 228 |
+
freemail_base_url=basic_data.get("freemail_base_url") or "http://your-freemail-server.com",
|
| 229 |
+
freemail_jwt_token=str(basic_data.get("freemail_jwt_token") or "").strip(),
|
| 230 |
+
freemail_verify_ssl=_parse_bool(basic_data.get("freemail_verify_ssl"), True),
|
| 231 |
+
freemail_domain=str(basic_data.get("freemail_domain") or "").strip(),
|
| 232 |
+
mail_proxy_enabled=_parse_bool(basic_data.get("mail_proxy_enabled"), False),
|
| 233 |
+
gptmail_base_url=str(basic_data.get("gptmail_base_url") or "https://mail.chatgpt.org.uk").strip(),
|
| 234 |
+
gptmail_api_key=str(basic_data.get("gptmail_api_key") or "").strip(),
|
| 235 |
+
gptmail_verify_ssl=_parse_bool(basic_data.get("gptmail_verify_ssl"), True),
|
| 236 |
+
gptmail_domain=str(basic_data.get("gptmail_domain") or "").strip(),
|
| 237 |
+
cfmail_base_url=str(basic_data.get("cfmail_base_url") or "").strip(),
|
| 238 |
+
cfmail_api_key=str(basic_data.get("cfmail_api_key") or "").strip(),
|
| 239 |
+
cfmail_verify_ssl=_parse_bool(basic_data.get("cfmail_verify_ssl"), True),
|
| 240 |
+
cfmail_domain=str(basic_data.get("cfmail_domain") or "").strip(),
|
| 241 |
+
browser_engine=basic_data.get("browser_engine") or "dp",
|
| 242 |
+
browser_headless=_parse_bool(basic_data.get("browser_headless"), False),
|
| 243 |
+
refresh_window_hours=int(refresh_window_raw),
|
| 244 |
+
register_default_count=int(register_default_raw),
|
| 245 |
+
register_domain=str(register_domain_raw or "").strip(),
|
| 246 |
+
image_expire_hours=int(basic_data.get("image_expire_hours", 12)),
|
| 247 |
+
)
|
| 248 |
+
|
| 249 |
+
# 4. 加载其他配置(从数据库,带容错处理)
|
| 250 |
+
try:
|
| 251 |
+
image_generation_config = ImageGenerationConfig(
|
| 252 |
+
**yaml_data.get("image_generation", {})
|
| 253 |
+
)
|
| 254 |
+
except Exception as e:
|
| 255 |
+
print(f"[WARN] 图片生成配置加载失败,使用默认值: {e}")
|
| 256 |
+
image_generation_config = ImageGenerationConfig()
|
| 257 |
+
|
| 258 |
+
# 加载视频生成配置
|
| 259 |
+
try:
|
| 260 |
+
video_generation_config = VideoGenerationConfig(
|
| 261 |
+
**yaml_data.get("video_generation", {})
|
| 262 |
+
)
|
| 263 |
+
except Exception as e:
|
| 264 |
+
print(f"[WARN] 视频生成配置加载失败,使用默认值: {e}")
|
| 265 |
+
video_generation_config = VideoGenerationConfig()
|
| 266 |
+
|
| 267 |
+
# 加载重试配置(Pydantic 会自动验证范围)
|
| 268 |
+
try:
|
| 269 |
+
retry_config = RetryConfig(**yaml_data.get("retry", {}))
|
| 270 |
+
except Exception as e:
|
| 271 |
+
print(f"[WARN] 重试配置加载失败,使用默认值: {e}")
|
| 272 |
+
retry_config = RetryConfig()
|
| 273 |
+
|
| 274 |
+
# 加载配额上限配置
|
| 275 |
+
try:
|
| 276 |
+
quota_limits_config = QuotaLimitsConfig(**yaml_data.get("quota_limits", {}))
|
| 277 |
+
except Exception as e:
|
| 278 |
+
print(f"[WARN] 配额上限配置加载失败,使用默认值: {e}")
|
| 279 |
+
quota_limits_config = QuotaLimitsConfig()
|
| 280 |
+
|
| 281 |
+
try:
|
| 282 |
+
public_display_config = PublicDisplayConfig(
|
| 283 |
+
**yaml_data.get("public_display", {})
|
| 284 |
+
)
|
| 285 |
+
except Exception as e:
|
| 286 |
+
print(f"[WARN] 公开展示配置加载失败,使用默认值: {e}")
|
| 287 |
+
public_display_config = PublicDisplayConfig()
|
| 288 |
+
|
| 289 |
+
try:
|
| 290 |
+
session_config = SessionConfig(
|
| 291 |
+
**yaml_data.get("session", {})
|
| 292 |
+
)
|
| 293 |
+
except Exception as e:
|
| 294 |
+
print(f"[WARN] Session配置加载失败,使用默认值: {e}")
|
| 295 |
+
session_config = SessionConfig()
|
| 296 |
+
|
| 297 |
+
# 5. 构建完整配置
|
| 298 |
+
self._config = AppConfig(
|
| 299 |
+
security=security_config,
|
| 300 |
+
basic=basic_config,
|
| 301 |
+
image_generation=image_generation_config,
|
| 302 |
+
video_generation=video_generation_config,
|
| 303 |
+
retry=retry_config,
|
| 304 |
+
quota_limits=quota_limits_config,
|
| 305 |
+
public_display=public_display_config,
|
| 306 |
+
session=session_config
|
| 307 |
+
)
|
| 308 |
+
|
| 309 |
+
def _load_yaml(self) -> dict:
|
| 310 |
+
"""从数据库加载配置(允许空配置)。"""
|
| 311 |
+
if storage.is_database_enabled():
|
| 312 |
+
try:
|
| 313 |
+
data = storage.load_settings_sync()
|
| 314 |
+
|
| 315 |
+
# 允许空库启动:None 可能是空配置或连接异常
|
| 316 |
+
if data is None:
|
| 317 |
+
print("[WARN] 未读取到 settings(可能为空库或连接异常),将使用默认配置启动")
|
| 318 |
+
return {}
|
| 319 |
+
|
| 320 |
+
if isinstance(data, dict):
|
| 321 |
+
return data
|
| 322 |
+
|
| 323 |
+
return {}
|
| 324 |
+
except RuntimeError:
|
| 325 |
+
# 重新抛出 RuntimeError
|
| 326 |
+
raise
|
| 327 |
+
except Exception as e:
|
| 328 |
+
print(f"[ERROR] 数据库加载失败: {e}")
|
| 329 |
+
raise RuntimeError(f"数据库加载失败: {e}")
|
| 330 |
+
|
| 331 |
+
print("[ERROR] 未启用数据库")
|
| 332 |
+
raise RuntimeError("未配置 DATABASE_URL,应用无法启动")
|
| 333 |
+
|
| 334 |
+
def _generate_secret(self) -> str:
|
| 335 |
+
"""生成随机密钥"""
|
| 336 |
+
return secrets.token_urlsafe(32)
|
| 337 |
+
|
| 338 |
+
def save_yaml(self, data: dict):
|
| 339 |
+
"""保存配置到数据库(先验证再保存)"""
|
| 340 |
+
if not storage.is_database_enabled():
|
| 341 |
+
raise RuntimeError("Database is not enabled")
|
| 342 |
+
|
| 343 |
+
# 先验证数据是否符合 Pydantic 模型要求
|
| 344 |
+
try:
|
| 345 |
+
# 构建临时配置进行验证
|
| 346 |
+
security_config = SecurityConfig(
|
| 347 |
+
admin_key=os.getenv("ADMIN_KEY", ""),
|
| 348 |
+
session_secret_key=os.getenv("SESSION_SECRET_KEY", self._generate_secret())
|
| 349 |
+
)
|
| 350 |
+
|
| 351 |
+
basic_data = data.get("basic", {})
|
| 352 |
+
basic_config = BasicConfig(**basic_data)
|
| 353 |
+
|
| 354 |
+
image_generation_config = ImageGenerationConfig(
|
| 355 |
+
**data.get("image_generation", {})
|
| 356 |
+
)
|
| 357 |
+
|
| 358 |
+
video_generation_config = VideoGenerationConfig(
|
| 359 |
+
**data.get("video_generation", {})
|
| 360 |
+
)
|
| 361 |
+
|
| 362 |
+
retry_config = RetryConfig(**data.get("retry", {}))
|
| 363 |
+
|
| 364 |
+
quota_limits_config = QuotaLimitsConfig(**data.get("quota_limits", {}))
|
| 365 |
+
|
| 366 |
+
public_display_config = PublicDisplayConfig(
|
| 367 |
+
**data.get("public_display", {})
|
| 368 |
+
)
|
| 369 |
+
|
| 370 |
+
session_config = SessionConfig(
|
| 371 |
+
**data.get("session", {})
|
| 372 |
+
)
|
| 373 |
+
|
| 374 |
+
# 验证通过,构建完整配置
|
| 375 |
+
test_config = AppConfig(
|
| 376 |
+
security=security_config,
|
| 377 |
+
basic=basic_config,
|
| 378 |
+
image_generation=image_generation_config,
|
| 379 |
+
video_generation=video_generation_config,
|
| 380 |
+
retry=retry_config,
|
| 381 |
+
quota_limits=quota_limits_config,
|
| 382 |
+
public_display=public_display_config,
|
| 383 |
+
session=session_config
|
| 384 |
+
)
|
| 385 |
+
except Exception as e:
|
| 386 |
+
# 验证失败,不保存到数据库
|
| 387 |
+
raise ValueError(f"配置验证失败: {str(e)}")
|
| 388 |
+
|
| 389 |
+
# 验证通过后才保存到数据库
|
| 390 |
+
try:
|
| 391 |
+
saved = storage.save_settings_sync(data)
|
| 392 |
+
if saved:
|
| 393 |
+
return
|
| 394 |
+
except Exception as e:
|
| 395 |
+
print(f"[WARN] 数据库保存失败: {e}")
|
| 396 |
+
raise RuntimeError("Database write failed")
|
| 397 |
+
|
| 398 |
+
def reload(self):
|
| 399 |
+
"""重新加载配置(热更新)"""
|
| 400 |
+
self.load()
|
| 401 |
+
|
| 402 |
+
@property
|
| 403 |
+
def config(self) -> AppConfig:
|
| 404 |
+
"""获取配置"""
|
| 405 |
+
return self._config
|
| 406 |
+
|
| 407 |
+
# ==================== 便捷访问属性 ====================
|
| 408 |
+
|
| 409 |
+
@property
|
| 410 |
+
def api_key(self) -> str:
|
| 411 |
+
"""API访问密钥"""
|
| 412 |
+
return self._config.basic.api_key
|
| 413 |
+
|
| 414 |
+
@property
|
| 415 |
+
def admin_key(self) -> str:
|
| 416 |
+
"""管理员密钥"""
|
| 417 |
+
return self._config.security.admin_key
|
| 418 |
+
|
| 419 |
+
@property
|
| 420 |
+
def session_secret_key(self) -> str:
|
| 421 |
+
"""Session密钥"""
|
| 422 |
+
return self._config.security.session_secret_key
|
| 423 |
+
|
| 424 |
+
@property
|
| 425 |
+
def proxy_for_auth(self) -> str:
|
| 426 |
+
"""账户操作代理地址"""
|
| 427 |
+
return self._config.basic.proxy_for_auth
|
| 428 |
+
|
| 429 |
+
@property
|
| 430 |
+
def proxy_for_chat(self) -> str:
|
| 431 |
+
"""对话操作代理地址"""
|
| 432 |
+
return self._config.basic.proxy_for_chat
|
| 433 |
+
|
| 434 |
+
@property
|
| 435 |
+
def base_url(self) -> str:
|
| 436 |
+
"""服务器URL"""
|
| 437 |
+
return self._config.basic.base_url
|
| 438 |
+
|
| 439 |
+
@property
|
| 440 |
+
def logo_url(self) -> str:
|
| 441 |
+
"""Logo URL"""
|
| 442 |
+
return self._config.public_display.logo_url
|
| 443 |
+
|
| 444 |
+
@property
|
| 445 |
+
def chat_url(self) -> str:
|
| 446 |
+
"""开始对话链接"""
|
| 447 |
+
return self._config.public_display.chat_url
|
| 448 |
+
|
| 449 |
+
@property
|
| 450 |
+
def image_generation_enabled(self) -> bool:
|
| 451 |
+
"""是否启用图片生成"""
|
| 452 |
+
return self._config.image_generation.enabled
|
| 453 |
+
|
| 454 |
+
@property
|
| 455 |
+
def image_generation_models(self) -> List[str]:
|
| 456 |
+
"""支持图片生成的模型列表"""
|
| 457 |
+
return self._config.image_generation.supported_models
|
| 458 |
+
|
| 459 |
+
@property
|
| 460 |
+
def image_output_format(self) -> str:
|
| 461 |
+
"""图片输出格式"""
|
| 462 |
+
return self._config.image_generation.output_format
|
| 463 |
+
|
| 464 |
+
@property
|
| 465 |
+
def video_output_format(self) -> str:
|
| 466 |
+
"""视频输出格式"""
|
| 467 |
+
return self._config.video_generation.output_format
|
| 468 |
+
|
| 469 |
+
@property
|
| 470 |
+
def session_expire_hours(self) -> int:
|
| 471 |
+
"""Session过期时间(小时)"""
|
| 472 |
+
return self._config.session.expire_hours
|
| 473 |
+
|
| 474 |
+
@property
|
| 475 |
+
def max_account_switch_tries(self) -> int:
|
| 476 |
+
"""账户切换尝试次数"""
|
| 477 |
+
return self._config.retry.max_account_switch_tries
|
| 478 |
+
|
| 479 |
+
@property
|
| 480 |
+
def rate_limit_cooldown_seconds(self) -> int:
|
| 481 |
+
# 429 cooldown (seconds)
|
| 482 |
+
if hasattr(self._config.retry, 'text_rate_limit_cooldown_seconds'):
|
| 483 |
+
return self._config.retry.text_rate_limit_cooldown_seconds
|
| 484 |
+
return self._config.retry.rate_limit_cooldown_seconds
|
| 485 |
+
|
| 486 |
+
@property
|
| 487 |
+
def text_rate_limit_cooldown_seconds(self) -> int:
|
| 488 |
+
return self._config.retry.text_rate_limit_cooldown_seconds
|
| 489 |
+
|
| 490 |
+
@property
|
| 491 |
+
def images_rate_limit_cooldown_seconds(self) -> int:
|
| 492 |
+
return self._config.retry.images_rate_limit_cooldown_seconds
|
| 493 |
+
|
| 494 |
+
@property
|
| 495 |
+
def videos_rate_limit_cooldown_seconds(self) -> int:
|
| 496 |
+
return self._config.retry.videos_rate_limit_cooldown_seconds
|
| 497 |
+
|
| 498 |
+
@property
|
| 499 |
+
def session_cache_ttl_seconds(self) -> int:
|
| 500 |
+
# Session cache TTL (seconds)
|
| 501 |
+
return self._config.retry.session_cache_ttl_seconds
|
| 502 |
+
|
| 503 |
+
@property
|
| 504 |
+
def auto_refresh_accounts_seconds(self) -> int:
|
| 505 |
+
# Auto refresh accounts interval (seconds)
|
| 506 |
+
return self._config.retry.auto_refresh_accounts_seconds
|
| 507 |
+
|
| 508 |
+
|
| 509 |
+
# ==================== 全局配置管理器 ====================
|
| 510 |
+
|
| 511 |
+
config_manager = ConfigManager()
|
| 512 |
+
|
| 513 |
+
# 注意:不要直接引用 config_manager.config,因为 reload() 后引用会失效
|
| 514 |
+
# 应该始终通过 config_manager.config 访问配置
|
| 515 |
+
def get_config() -> AppConfig:
|
| 516 |
+
"""获取当前配置(支持热更新)"""
|
| 517 |
+
return config_manager.config
|
| 518 |
+
|
| 519 |
+
# 为了向后兼容,保留 config 变量,但使用属性访问
|
| 520 |
+
class _ConfigProxy:
|
| 521 |
+
"""配置代理,确保始终访问最新配置"""
|
| 522 |
+
@property
|
| 523 |
+
def basic(self):
|
| 524 |
+
return config_manager.config.basic
|
| 525 |
+
|
| 526 |
+
@property
|
| 527 |
+
def security(self):
|
| 528 |
+
return config_manager.config.security
|
| 529 |
+
|
| 530 |
+
@property
|
| 531 |
+
def image_generation(self):
|
| 532 |
+
return config_manager.config.image_generation
|
| 533 |
+
|
| 534 |
+
@property
|
| 535 |
+
def video_generation(self):
|
| 536 |
+
return config_manager.config.video_generation
|
| 537 |
+
|
| 538 |
+
@property
|
| 539 |
+
def retry(self):
|
| 540 |
+
return config_manager.config.retry
|
| 541 |
+
|
| 542 |
+
@property
|
| 543 |
+
def quota_limits(self):
|
| 544 |
+
return config_manager.config.quota_limits
|
| 545 |
+
|
| 546 |
+
@property
|
| 547 |
+
def public_display(self):
|
| 548 |
+
return config_manager.config.public_display
|
| 549 |
+
|
| 550 |
+
@property
|
| 551 |
+
def session(self):
|
| 552 |
+
return config_manager.config.session
|
| 553 |
+
|
| 554 |
+
config = _ConfigProxy()
|
core/database.py
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
统计数据库操作 - 使用 storage.py 的统一数据库连接
|
| 3 |
+
"""
|
| 4 |
+
import time
|
| 5 |
+
from datetime import datetime
|
| 6 |
+
from typing import Dict, Tuple
|
| 7 |
+
import asyncio
|
| 8 |
+
from collections import defaultdict
|
| 9 |
+
from core.storage import _get_sqlite_conn, _sqlite_lock
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class StatsDatabase:
|
| 13 |
+
"""统计数据库管理类 - 使用统一的 data.db"""
|
| 14 |
+
|
| 15 |
+
async def insert_request_log(
|
| 16 |
+
self, timestamp: float, model: str, ttfb_ms: int = None,
|
| 17 |
+
total_ms: int = None, status: str = "success", status_code: int = None
|
| 18 |
+
):
|
| 19 |
+
"""插入请求记录"""
|
| 20 |
+
def _insert():
|
| 21 |
+
conn = _get_sqlite_conn()
|
| 22 |
+
with _sqlite_lock:
|
| 23 |
+
conn.execute(
|
| 24 |
+
"""
|
| 25 |
+
INSERT INTO request_logs
|
| 26 |
+
(timestamp, model, ttfb_ms, total_ms, status, status_code)
|
| 27 |
+
VALUES (?, ?, ?, ?, ?, ?)
|
| 28 |
+
""",
|
| 29 |
+
(int(timestamp), model, ttfb_ms, total_ms, status, status_code)
|
| 30 |
+
)
|
| 31 |
+
conn.commit()
|
| 32 |
+
|
| 33 |
+
await asyncio.to_thread(_insert)
|
| 34 |
+
|
| 35 |
+
async def get_stats_by_time_range(self, time_range: str = "24h") -> Dict:
|
| 36 |
+
"""按时间范围获取统计数据"""
|
| 37 |
+
def _query():
|
| 38 |
+
now = time.time()
|
| 39 |
+
if time_range == "24h":
|
| 40 |
+
start_time = now - 24 * 3600
|
| 41 |
+
bucket_size = 3600
|
| 42 |
+
elif time_range == "7d":
|
| 43 |
+
start_time = now - 7 * 24 * 3600
|
| 44 |
+
bucket_size = 6 * 3600
|
| 45 |
+
elif time_range == "30d":
|
| 46 |
+
start_time = now - 30 * 24 * 3600
|
| 47 |
+
bucket_size = 24 * 3600
|
| 48 |
+
else:
|
| 49 |
+
start_time = now - 24 * 3600
|
| 50 |
+
bucket_size = 3600
|
| 51 |
+
|
| 52 |
+
conn = _get_sqlite_conn()
|
| 53 |
+
with _sqlite_lock:
|
| 54 |
+
rows = conn.execute(
|
| 55 |
+
"""
|
| 56 |
+
SELECT timestamp, model, ttfb_ms, total_ms, status, status_code
|
| 57 |
+
FROM request_logs
|
| 58 |
+
WHERE timestamp >= ?
|
| 59 |
+
ORDER BY timestamp
|
| 60 |
+
""",
|
| 61 |
+
(int(start_time),)
|
| 62 |
+
).fetchall()
|
| 63 |
+
|
| 64 |
+
# 数据分桶
|
| 65 |
+
buckets = defaultdict(lambda: {
|
| 66 |
+
"total": 0, "failed": 0, "rate_limited": 0,
|
| 67 |
+
"models": defaultdict(int),
|
| 68 |
+
"model_ttfb": defaultdict(list),
|
| 69 |
+
"model_total": defaultdict(list)
|
| 70 |
+
})
|
| 71 |
+
|
| 72 |
+
for row in rows:
|
| 73 |
+
ts, model, ttfb, total, status, status_code = row
|
| 74 |
+
bucket_key = int((ts - start_time) // bucket_size)
|
| 75 |
+
bucket = buckets[bucket_key]
|
| 76 |
+
|
| 77 |
+
bucket["total"] += 1
|
| 78 |
+
bucket["models"][model] += 1
|
| 79 |
+
|
| 80 |
+
if status != "success":
|
| 81 |
+
bucket["failed"] += 1
|
| 82 |
+
if status_code == 429:
|
| 83 |
+
bucket["rate_limited"] += 1
|
| 84 |
+
|
| 85 |
+
if status == "success" and ttfb is not None and total is not None:
|
| 86 |
+
bucket["model_ttfb"][model].append(ttfb)
|
| 87 |
+
bucket["model_total"][model].append(total)
|
| 88 |
+
|
| 89 |
+
# 生成结果
|
| 90 |
+
num_buckets = int((now - start_time) // bucket_size) + 1
|
| 91 |
+
labels = []
|
| 92 |
+
total_requests = []
|
| 93 |
+
failed_requests = []
|
| 94 |
+
rate_limited_requests = []
|
| 95 |
+
|
| 96 |
+
# 先收集所有出现过的模型
|
| 97 |
+
all_models = set()
|
| 98 |
+
for bucket in buckets.values():
|
| 99 |
+
all_models.update(bucket["models"].keys())
|
| 100 |
+
all_models.update(bucket["model_ttfb"].keys())
|
| 101 |
+
all_models.update(bucket["model_total"].keys())
|
| 102 |
+
|
| 103 |
+
# 初始化每个模型的数据列表
|
| 104 |
+
model_requests = {model: [] for model in all_models}
|
| 105 |
+
model_ttfb_times = {model: [] for model in all_models}
|
| 106 |
+
model_total_times = {model: [] for model in all_models}
|
| 107 |
+
|
| 108 |
+
# 遍历每个时间桶
|
| 109 |
+
for i in range(num_buckets):
|
| 110 |
+
bucket_time = start_time + i * bucket_size
|
| 111 |
+
dt = datetime.fromtimestamp(bucket_time)
|
| 112 |
+
|
| 113 |
+
if time_range == "24h":
|
| 114 |
+
labels.append(dt.strftime("%H:00"))
|
| 115 |
+
elif time_range == "7d":
|
| 116 |
+
labels.append(dt.strftime("%m-%d %H:00"))
|
| 117 |
+
else:
|
| 118 |
+
labels.append(dt.strftime("%m-%d"))
|
| 119 |
+
|
| 120 |
+
bucket = buckets[i]
|
| 121 |
+
total_requests.append(bucket["total"])
|
| 122 |
+
failed_requests.append(bucket["failed"])
|
| 123 |
+
rate_limited_requests.append(bucket["rate_limited"])
|
| 124 |
+
|
| 125 |
+
# 为每个模型添加数据(存在则添加实际值,不存在则添加0)
|
| 126 |
+
for model in all_models:
|
| 127 |
+
# 请求数
|
| 128 |
+
model_requests[model].append(bucket["models"].get(model, 0))
|
| 129 |
+
|
| 130 |
+
# TTFB平均时间
|
| 131 |
+
if model in bucket["model_ttfb"] and bucket["model_ttfb"][model]:
|
| 132 |
+
avg_ttfb = sum(bucket["model_ttfb"][model]) / len(bucket["model_ttfb"][model])
|
| 133 |
+
model_ttfb_times[model].append(avg_ttfb)
|
| 134 |
+
else:
|
| 135 |
+
model_ttfb_times[model].append(0)
|
| 136 |
+
|
| 137 |
+
# 总响应平均时间
|
| 138 |
+
if model in bucket["model_total"] and bucket["model_total"][model]:
|
| 139 |
+
avg_total = sum(bucket["model_total"][model]) / len(bucket["model_total"][model])
|
| 140 |
+
model_total_times[model].append(avg_total)
|
| 141 |
+
else:
|
| 142 |
+
model_total_times[model].append(0)
|
| 143 |
+
|
| 144 |
+
# 数据已经是按时间顺序(旧→新),不需要反转
|
| 145 |
+
# ECharts 从左到右渲染,所以最旧的在左边,最新的在右边
|
| 146 |
+
|
| 147 |
+
return {
|
| 148 |
+
"labels": labels,
|
| 149 |
+
"total_requests": total_requests,
|
| 150 |
+
"failed_requests": failed_requests,
|
| 151 |
+
"rate_limited_requests": rate_limited_requests,
|
| 152 |
+
"model_requests": dict(model_requests),
|
| 153 |
+
"model_ttfb_times": dict(model_ttfb_times),
|
| 154 |
+
"model_total_times": dict(model_total_times)
|
| 155 |
+
}
|
| 156 |
+
|
| 157 |
+
return await asyncio.to_thread(_query)
|
| 158 |
+
|
| 159 |
+
async def get_total_counts(self) -> Tuple[int, int]:
|
| 160 |
+
"""获取总成功和失败次数"""
|
| 161 |
+
def _query():
|
| 162 |
+
conn = _get_sqlite_conn()
|
| 163 |
+
with _sqlite_lock:
|
| 164 |
+
success = conn.execute(
|
| 165 |
+
"SELECT COUNT(*) FROM request_logs WHERE status = 'success'"
|
| 166 |
+
).fetchone()[0]
|
| 167 |
+
failed = conn.execute(
|
| 168 |
+
"SELECT COUNT(*) FROM request_logs WHERE status != 'success'"
|
| 169 |
+
).fetchone()[0]
|
| 170 |
+
return success, failed
|
| 171 |
+
|
| 172 |
+
return await asyncio.to_thread(_query)
|
| 173 |
+
|
| 174 |
+
async def cleanup_old_data(self, days: int = 30):
|
| 175 |
+
"""清理过期数据 - 默认保留30天"""
|
| 176 |
+
def _cleanup():
|
| 177 |
+
cutoff_time = int(time.time() - days * 24 * 3600)
|
| 178 |
+
conn = _get_sqlite_conn()
|
| 179 |
+
with _sqlite_lock:
|
| 180 |
+
cursor = conn.execute(
|
| 181 |
+
"DELETE FROM request_logs WHERE timestamp < ?",
|
| 182 |
+
(cutoff_time,)
|
| 183 |
+
)
|
| 184 |
+
conn.commit()
|
| 185 |
+
return cursor.rowcount
|
| 186 |
+
|
| 187 |
+
return await asyncio.to_thread(_cleanup)
|
| 188 |
+
|
| 189 |
+
|
| 190 |
+
# 全局实例
|
| 191 |
+
stats_db = StatsDatabase()
|
core/duckmail_client.py
ADDED
|
@@ -0,0 +1,304 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import random
|
| 3 |
+
import string
|
| 4 |
+
import time
|
| 5 |
+
from typing import Optional
|
| 6 |
+
|
| 7 |
+
import requests
|
| 8 |
+
|
| 9 |
+
from core.mail_utils import extract_verification_code
|
| 10 |
+
from core.proxy_utils import request_with_proxy_fallback
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class DuckMailClient:
|
| 14 |
+
"""DuckMail客户端"""
|
| 15 |
+
|
| 16 |
+
def __init__(
|
| 17 |
+
self,
|
| 18 |
+
base_url: str = "https://api.duckmail.sbs",
|
| 19 |
+
proxy: str = "",
|
| 20 |
+
verify_ssl: bool = True,
|
| 21 |
+
api_key: str = "",
|
| 22 |
+
log_callback=None,
|
| 23 |
+
) -> None:
|
| 24 |
+
self.base_url = base_url.rstrip("/")
|
| 25 |
+
self.verify_ssl = verify_ssl
|
| 26 |
+
self.proxies = {"http": proxy, "https": proxy} if proxy else None
|
| 27 |
+
self.api_key = api_key.strip()
|
| 28 |
+
self.log_callback = log_callback
|
| 29 |
+
|
| 30 |
+
self.email: Optional[str] = None
|
| 31 |
+
self.password: Optional[str] = None
|
| 32 |
+
self.account_id: Optional[str] = None
|
| 33 |
+
self.token: Optional[str] = None
|
| 34 |
+
|
| 35 |
+
def set_credentials(self, email: str, password: str) -> None:
|
| 36 |
+
self.email = email
|
| 37 |
+
self.password = password
|
| 38 |
+
|
| 39 |
+
def _request(self, method: str, url: str, **kwargs) -> requests.Response:
|
| 40 |
+
"""发送请求并打印详细日志"""
|
| 41 |
+
headers = kwargs.pop("headers", None) or {}
|
| 42 |
+
if self.api_key and "Authorization" not in headers:
|
| 43 |
+
headers["Authorization"] = f"Bearer {self.api_key}"
|
| 44 |
+
kwargs["headers"] = headers
|
| 45 |
+
self._log("info", f"📤 发送 {method} 请求: {url}")
|
| 46 |
+
if "json" in kwargs:
|
| 47 |
+
self._log("info", f"📦 请求体: {kwargs['json']}")
|
| 48 |
+
|
| 49 |
+
try:
|
| 50 |
+
res = request_with_proxy_fallback(
|
| 51 |
+
requests.request,
|
| 52 |
+
method,
|
| 53 |
+
url,
|
| 54 |
+
proxies=self.proxies,
|
| 55 |
+
verify=self.verify_ssl,
|
| 56 |
+
timeout=kwargs.pop("timeout", 15),
|
| 57 |
+
**kwargs,
|
| 58 |
+
)
|
| 59 |
+
self._log("info", f"📥 收到响应: HTTP {res.status_code}")
|
| 60 |
+
log_body = os.getenv("DUCKMAIL_LOG_BODY", "").strip().lower() in ("1", "true", "yes", "y", "on")
|
| 61 |
+
if res.content and (log_body or res.status_code >= 400):
|
| 62 |
+
try:
|
| 63 |
+
self._log("info", f"📄 响应内容: {res.text[:500]}")
|
| 64 |
+
except Exception:
|
| 65 |
+
pass
|
| 66 |
+
return res
|
| 67 |
+
except Exception as e:
|
| 68 |
+
self._log("error", f"❌ 网络请求失败: {e}")
|
| 69 |
+
raise
|
| 70 |
+
|
| 71 |
+
def register_account(self, domain: Optional[str] = None) -> bool:
|
| 72 |
+
"""注册新邮箱账号"""
|
| 73 |
+
# 获取域名
|
| 74 |
+
if not domain:
|
| 75 |
+
self._log("info", "🔍 正在获取可用域名...")
|
| 76 |
+
domain = self._get_domain()
|
| 77 |
+
self._log("info", f"📧 使用域名: {domain}")
|
| 78 |
+
|
| 79 |
+
# 生成随机邮箱和密码
|
| 80 |
+
rand = "".join(random.choices(string.ascii_lowercase + string.digits, k=10))
|
| 81 |
+
timestamp = str(int(time.time()))[-4:]
|
| 82 |
+
self.email = f"t{timestamp}{rand}@{domain}"
|
| 83 |
+
self.password = f"Pwd{rand}{timestamp}"
|
| 84 |
+
self._log("info", f"🎲 生成邮箱: {self.email}")
|
| 85 |
+
self._log("info", f"🔑 生成密码: {self.password}")
|
| 86 |
+
|
| 87 |
+
try:
|
| 88 |
+
self._log("info", "📤 正在向 DuckMail 发送注册请求...")
|
| 89 |
+
res = self._request(
|
| 90 |
+
"POST",
|
| 91 |
+
f"{self.base_url}/accounts",
|
| 92 |
+
json={"address": self.email, "password": self.password},
|
| 93 |
+
)
|
| 94 |
+
if res.status_code in (200, 201):
|
| 95 |
+
data = res.json() if res.content else {}
|
| 96 |
+
self.account_id = data.get("id")
|
| 97 |
+
self._log("info", f"✅ DuckMail 注册成功,账户ID: {self.account_id}")
|
| 98 |
+
return True
|
| 99 |
+
else:
|
| 100 |
+
self._log("error", f"❌ DuckMail 注册失败: HTTP {res.status_code}")
|
| 101 |
+
except Exception as e:
|
| 102 |
+
self._log("error", f"❌ DuckMail 注册异常: {e}")
|
| 103 |
+
return False
|
| 104 |
+
|
| 105 |
+
self._log("error", "❌ DuckMail 注册失败")
|
| 106 |
+
return False
|
| 107 |
+
|
| 108 |
+
def login(self) -> bool:
|
| 109 |
+
"""登录获取token"""
|
| 110 |
+
if not self.email or not self.password:
|
| 111 |
+
self._log("error", "❌ 邮箱或密码未设置")
|
| 112 |
+
return False
|
| 113 |
+
|
| 114 |
+
try:
|
| 115 |
+
self._log("info", f"🔐 正在登录 DuckMail: {self.email}")
|
| 116 |
+
res = self._request(
|
| 117 |
+
"POST",
|
| 118 |
+
f"{self.base_url}/token",
|
| 119 |
+
json={"address": self.email, "password": self.password},
|
| 120 |
+
)
|
| 121 |
+
if res.status_code == 200:
|
| 122 |
+
data = res.json() if res.content else {}
|
| 123 |
+
token = data.get("token")
|
| 124 |
+
if token:
|
| 125 |
+
self.token = token
|
| 126 |
+
self._log("info", f"✅ DuckMail 登录成功,Token: {token[:20]}...")
|
| 127 |
+
return True
|
| 128 |
+
else:
|
| 129 |
+
self._log("error", "❌ 响应中未找到 Token")
|
| 130 |
+
else:
|
| 131 |
+
self._log("error", f"❌ DuckMail 登录失败: HTTP {res.status_code}")
|
| 132 |
+
except Exception as e:
|
| 133 |
+
self._log("error", f"❌ DuckMail 登录异常: {e}")
|
| 134 |
+
return False
|
| 135 |
+
|
| 136 |
+
self._log("error", "❌ DuckMail 登录失败")
|
| 137 |
+
return False
|
| 138 |
+
|
| 139 |
+
def fetch_verification_code(self, since_time=None) -> Optional[str]:
|
| 140 |
+
"""获取验证码"""
|
| 141 |
+
if not self.token:
|
| 142 |
+
self._log("info", "🔐 Token 不存在,尝试重新登录...")
|
| 143 |
+
if not self.login():
|
| 144 |
+
self._log("error", "❌ 登录失败,无法获取验证码")
|
| 145 |
+
return None
|
| 146 |
+
|
| 147 |
+
try:
|
| 148 |
+
self._log("info", "📬 正在拉取邮件列表...")
|
| 149 |
+
# 获取邮件列表
|
| 150 |
+
res = self._request(
|
| 151 |
+
"GET",
|
| 152 |
+
f"{self.base_url}/messages",
|
| 153 |
+
headers={"Authorization": f"Bearer {self.token}"},
|
| 154 |
+
)
|
| 155 |
+
|
| 156 |
+
if res.status_code != 200:
|
| 157 |
+
self._log("error", f"❌ 获取邮件列表失败: HTTP {res.status_code}")
|
| 158 |
+
return None
|
| 159 |
+
|
| 160 |
+
data = res.json() if res.content else {}
|
| 161 |
+
messages = data.get("hydra:member", [])
|
| 162 |
+
|
| 163 |
+
if not messages:
|
| 164 |
+
self._log("info", "📭 邮箱为空,暂无邮件")
|
| 165 |
+
return None
|
| 166 |
+
|
| 167 |
+
self._log("info", f"📨 收到 {len(messages)} 封邮件,开始检查验证码...")
|
| 168 |
+
|
| 169 |
+
from datetime import datetime
|
| 170 |
+
import re
|
| 171 |
+
|
| 172 |
+
def _parse_message_time(msg_obj) -> Optional[datetime]:
|
| 173 |
+
created_at = msg_obj.get("createdAt")
|
| 174 |
+
if created_at is None:
|
| 175 |
+
return None
|
| 176 |
+
|
| 177 |
+
if isinstance(created_at, (int, float)):
|
| 178 |
+
timestamp = float(created_at)
|
| 179 |
+
if timestamp > 1e12:
|
| 180 |
+
timestamp = timestamp / 1000.0
|
| 181 |
+
return datetime.fromtimestamp(timestamp).astimezone().replace(tzinfo=None)
|
| 182 |
+
|
| 183 |
+
if isinstance(created_at, str):
|
| 184 |
+
raw = created_at.strip()
|
| 185 |
+
if not raw:
|
| 186 |
+
return None
|
| 187 |
+
if raw.isdigit():
|
| 188 |
+
timestamp = float(raw)
|
| 189 |
+
if timestamp > 1e12:
|
| 190 |
+
timestamp = timestamp / 1000.0
|
| 191 |
+
return datetime.fromtimestamp(timestamp).astimezone().replace(tzinfo=None)
|
| 192 |
+
|
| 193 |
+
# 截断纳秒到微秒(fromisoformat 只支持6位小数)
|
| 194 |
+
raw = re.sub(r"(\.\d{6})\d+", r"\1", raw)
|
| 195 |
+
return datetime.fromisoformat(raw.replace("Z", "+00:00")).astimezone().replace(tzinfo=None)
|
| 196 |
+
|
| 197 |
+
return None
|
| 198 |
+
|
| 199 |
+
# 按时间倒序,优先检查最新邮件
|
| 200 |
+
messages_with_time = [(msg, _parse_message_time(msg)) for msg in messages]
|
| 201 |
+
if any(item[1] is not None for item in messages_with_time):
|
| 202 |
+
messages_with_time.sort(key=lambda item: item[1] or datetime.min, reverse=True)
|
| 203 |
+
messages = [item[0] for item in messages_with_time]
|
| 204 |
+
|
| 205 |
+
# 遍历邮件,过滤时间
|
| 206 |
+
for idx, msg in enumerate(messages, 1):
|
| 207 |
+
msg_id = msg.get("id")
|
| 208 |
+
if not msg_id:
|
| 209 |
+
continue
|
| 210 |
+
|
| 211 |
+
# 时间过滤
|
| 212 |
+
if since_time:
|
| 213 |
+
msg_time = _parse_message_time(msg)
|
| 214 |
+
if msg_time and msg_time < since_time:
|
| 215 |
+
continue
|
| 216 |
+
|
| 217 |
+
self._log("info", f"🔍 正在读取邮件 {idx}/{len(messages)} (ID: {msg_id[:10]}...)")
|
| 218 |
+
detail = self._request(
|
| 219 |
+
"GET",
|
| 220 |
+
f"{self.base_url}/messages/{msg_id}",
|
| 221 |
+
headers={"Authorization": f"Bearer {self.token}"},
|
| 222 |
+
)
|
| 223 |
+
|
| 224 |
+
if detail.status_code != 200:
|
| 225 |
+
self._log("warning", f"⚠️ 读取邮件详情失败: HTTP {detail.status_code}")
|
| 226 |
+
continue
|
| 227 |
+
|
| 228 |
+
payload = detail.json() if detail.content else {}
|
| 229 |
+
|
| 230 |
+
# 获取邮件内容
|
| 231 |
+
text_content = payload.get("text") or ""
|
| 232 |
+
html_content = payload.get("html") or ""
|
| 233 |
+
|
| 234 |
+
if isinstance(html_content, list):
|
| 235 |
+
html_content = "".join(str(item) for item in html_content)
|
| 236 |
+
if isinstance(text_content, list):
|
| 237 |
+
text_content = "".join(str(item) for item in text_content)
|
| 238 |
+
|
| 239 |
+
content = text_content + html_content
|
| 240 |
+
self._log("info", f"📄 邮件内容预览: {content[:200]}...")
|
| 241 |
+
|
| 242 |
+
code = extract_verification_code(content)
|
| 243 |
+
if code:
|
| 244 |
+
self._log("info", f"✅ 找到验证码: {code}")
|
| 245 |
+
return code
|
| 246 |
+
else:
|
| 247 |
+
self._log("info", f"❌ 邮件 {idx} 中未找到验证码")
|
| 248 |
+
|
| 249 |
+
self._log("warning", "⚠️ 所有邮件中均未找到验证码")
|
| 250 |
+
return None
|
| 251 |
+
|
| 252 |
+
except Exception as e:
|
| 253 |
+
self._log("error", f"❌ 获取验证码异常: {e}")
|
| 254 |
+
return None
|
| 255 |
+
|
| 256 |
+
def poll_for_code(
|
| 257 |
+
self,
|
| 258 |
+
timeout: int = 120,
|
| 259 |
+
interval: int = 4,
|
| 260 |
+
since_time=None,
|
| 261 |
+
) -> Optional[str]:
|
| 262 |
+
"""轮询获取验证码"""
|
| 263 |
+
if not self.token:
|
| 264 |
+
self._log("info", "🔐 Token 不存在,尝试登录...")
|
| 265 |
+
if not self.login():
|
| 266 |
+
self._log("error", "❌ 登录失败,无法轮询验证码")
|
| 267 |
+
return None
|
| 268 |
+
|
| 269 |
+
max_retries = max(1, timeout // interval)
|
| 270 |
+
self._log("info", f"⏱️ 开始轮询验证码 (超时 {timeout}秒, 间隔 {interval}秒, 最多 {max_retries} 次)")
|
| 271 |
+
|
| 272 |
+
for i in range(1, max_retries + 1):
|
| 273 |
+
self._log("info", f"🔄 第 {i}/{max_retries} 次轮询...")
|
| 274 |
+
code = self.fetch_verification_code(since_time=since_time)
|
| 275 |
+
if code:
|
| 276 |
+
self._log("info", f"🎉 验证码获取成功: {code}")
|
| 277 |
+
return code
|
| 278 |
+
|
| 279 |
+
if i < max_retries:
|
| 280 |
+
self._log("info", f"⏳ 等待 {interval} 秒后重试...")
|
| 281 |
+
time.sleep(interval)
|
| 282 |
+
|
| 283 |
+
self._log("error", f"⏰ 验证码获取超时 ({timeout}秒)")
|
| 284 |
+
return None
|
| 285 |
+
|
| 286 |
+
def _get_domain(self) -> str:
|
| 287 |
+
"""获取可用域名"""
|
| 288 |
+
try:
|
| 289 |
+
res = self._request("GET", f"{self.base_url}/domains")
|
| 290 |
+
if res.status_code == 200:
|
| 291 |
+
data = res.json() if res.content else {}
|
| 292 |
+
members = data.get("hydra:member", [])
|
| 293 |
+
if members:
|
| 294 |
+
return members[0].get("domain") or "duck.com"
|
| 295 |
+
except Exception:
|
| 296 |
+
pass
|
| 297 |
+
return "duck.com"
|
| 298 |
+
|
| 299 |
+
def _log(self, level: str, message: str) -> None:
|
| 300 |
+
if self.log_callback:
|
| 301 |
+
try:
|
| 302 |
+
self.log_callback(level, message)
|
| 303 |
+
except Exception:
|
| 304 |
+
pass
|
core/freemail_client.py
ADDED
|
@@ -0,0 +1,325 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import random
|
| 2 |
+
import string
|
| 3 |
+
import time
|
| 4 |
+
from typing import Optional
|
| 5 |
+
|
| 6 |
+
import requests
|
| 7 |
+
|
| 8 |
+
from core.mail_utils import extract_verification_code
|
| 9 |
+
from core.proxy_utils import request_with_proxy_fallback
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class FreemailClient:
|
| 13 |
+
"""Freemail 临时邮箱客户端"""
|
| 14 |
+
|
| 15 |
+
def __init__(
|
| 16 |
+
self,
|
| 17 |
+
base_url: str = "http://your-freemail-server.com",
|
| 18 |
+
jwt_token: str = "",
|
| 19 |
+
proxy: str = "",
|
| 20 |
+
verify_ssl: bool = True,
|
| 21 |
+
log_callback=None,
|
| 22 |
+
) -> None:
|
| 23 |
+
self.base_url = base_url.rstrip("/")
|
| 24 |
+
self.jwt_token = jwt_token.strip()
|
| 25 |
+
self.verify_ssl = verify_ssl
|
| 26 |
+
self.proxies = {"http": proxy, "https": proxy} if proxy else None
|
| 27 |
+
self.log_callback = log_callback
|
| 28 |
+
|
| 29 |
+
self.email: Optional[str] = None
|
| 30 |
+
|
| 31 |
+
def set_credentials(self, email: str, password: str = None) -> None:
|
| 32 |
+
"""设置邮箱凭证(Freemail 不需要密码)"""
|
| 33 |
+
self.email = email
|
| 34 |
+
|
| 35 |
+
def _request(self, method: str, url: str, **kwargs) -> requests.Response:
|
| 36 |
+
"""发送请求并打印日志"""
|
| 37 |
+
self._log("info", f"📤 发送 {method} 请求: {url}")
|
| 38 |
+
if "params" in kwargs:
|
| 39 |
+
self._log("info", f"🔎 参数: {kwargs['params']}")
|
| 40 |
+
|
| 41 |
+
try:
|
| 42 |
+
res = request_with_proxy_fallback(
|
| 43 |
+
requests.request,
|
| 44 |
+
method,
|
| 45 |
+
url,
|
| 46 |
+
proxies=self.proxies,
|
| 47 |
+
verify=self.verify_ssl,
|
| 48 |
+
timeout=kwargs.pop("timeout", 15),
|
| 49 |
+
**kwargs,
|
| 50 |
+
)
|
| 51 |
+
self._log("info", f"📥 收到响应: HTTP {res.status_code}")
|
| 52 |
+
if res.status_code >= 400:
|
| 53 |
+
try:
|
| 54 |
+
self._log("error", f"📄 响应内容: {res.text[:500]}")
|
| 55 |
+
except Exception:
|
| 56 |
+
pass
|
| 57 |
+
return res
|
| 58 |
+
except Exception as e:
|
| 59 |
+
self._log("error", f"❌ 网络请求失败: {e}")
|
| 60 |
+
raise
|
| 61 |
+
|
| 62 |
+
def register_account(self, domain: Optional[str] = None) -> bool:
|
| 63 |
+
"""创建新的临时邮箱"""
|
| 64 |
+
try:
|
| 65 |
+
params = {"admin_token": self.jwt_token}
|
| 66 |
+
if domain:
|
| 67 |
+
params["domain"] = domain
|
| 68 |
+
self._log("info", f"📧 使用域名: {domain}")
|
| 69 |
+
else:
|
| 70 |
+
self._log("info", "🔍 自动选择域名...")
|
| 71 |
+
|
| 72 |
+
res = self._request(
|
| 73 |
+
"POST",
|
| 74 |
+
f"{self.base_url}/api/generate",
|
| 75 |
+
params=params,
|
| 76 |
+
)
|
| 77 |
+
|
| 78 |
+
if res.status_code in (200, 201):
|
| 79 |
+
data = res.json() if res.content else {}
|
| 80 |
+
# Freemail API 返回的字段是 "email" 或 "mailbox"
|
| 81 |
+
email = data.get("email") or data.get("mailbox")
|
| 82 |
+
if email:
|
| 83 |
+
self.email = email
|
| 84 |
+
self._log("info", f"✅ Freemail 邮箱创建成功: {self.email}")
|
| 85 |
+
return True
|
| 86 |
+
else:
|
| 87 |
+
self._log("error", "❌ 响应中缺少 email 字段")
|
| 88 |
+
return False
|
| 89 |
+
elif res.status_code in (401, 403):
|
| 90 |
+
self._log("error", "❌ Freemail 认证失败 (JWT Token 无效)")
|
| 91 |
+
return False
|
| 92 |
+
else:
|
| 93 |
+
self._log("error", f"❌ Freemail 创建失败: HTTP {res.status_code}")
|
| 94 |
+
return False
|
| 95 |
+
|
| 96 |
+
except Exception as e:
|
| 97 |
+
self._log("error", f"❌ Freemail 注册异常: {e}")
|
| 98 |
+
return False
|
| 99 |
+
|
| 100 |
+
def login(self) -> bool:
|
| 101 |
+
"""登录(Freemail 不需要登录,直接返回 True)"""
|
| 102 |
+
return True
|
| 103 |
+
|
| 104 |
+
def fetch_verification_code(self, since_time=None) -> Optional[str]:
|
| 105 |
+
"""获取验证码"""
|
| 106 |
+
if not self.email:
|
| 107 |
+
self._log("error", "❌ 邮箱地址未设置")
|
| 108 |
+
return None
|
| 109 |
+
|
| 110 |
+
try:
|
| 111 |
+
self._log("info", "📬 正在拉取 Freemail 邮件列表...")
|
| 112 |
+
params = {
|
| 113 |
+
"mailbox": self.email,
|
| 114 |
+
"admin_token": self.jwt_token,
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
res = self._request(
|
| 118 |
+
"GET",
|
| 119 |
+
f"{self.base_url}/api/emails",
|
| 120 |
+
params=params,
|
| 121 |
+
)
|
| 122 |
+
|
| 123 |
+
if res.status_code == 401 or res.status_code == 403:
|
| 124 |
+
self._log("error", "❌ Freemail 认证失败")
|
| 125 |
+
return None
|
| 126 |
+
|
| 127 |
+
if res.status_code != 200:
|
| 128 |
+
self._log("error", f"❌ 获取邮件列表失败: HTTP {res.status_code}")
|
| 129 |
+
return None
|
| 130 |
+
|
| 131 |
+
emails = res.json() if res.content else []
|
| 132 |
+
if not isinstance(emails, list):
|
| 133 |
+
self._log("error", "❌ 响应格式错误(不是列表)")
|
| 134 |
+
return None
|
| 135 |
+
|
| 136 |
+
if not emails:
|
| 137 |
+
self._log("info", "📭 邮箱为空,暂无邮件")
|
| 138 |
+
return None
|
| 139 |
+
|
| 140 |
+
self._log("info", f"📨 收到 {len(emails)} 封邮件,开始检查验证码...")
|
| 141 |
+
|
| 142 |
+
from datetime import datetime, timezone
|
| 143 |
+
import re
|
| 144 |
+
|
| 145 |
+
def _parse_email_time(email_obj) -> Optional[datetime]:
|
| 146 |
+
time_keys = (
|
| 147 |
+
"created_at",
|
| 148 |
+
"createdAt",
|
| 149 |
+
"received_at",
|
| 150 |
+
"receivedAt",
|
| 151 |
+
"sent_at",
|
| 152 |
+
"sentAt",
|
| 153 |
+
)
|
| 154 |
+
|
| 155 |
+
raw_time = None
|
| 156 |
+
for key in time_keys:
|
| 157 |
+
if email_obj.get(key) is not None:
|
| 158 |
+
raw_time = email_obj.get(key)
|
| 159 |
+
break
|
| 160 |
+
|
| 161 |
+
if raw_time is None:
|
| 162 |
+
return None
|
| 163 |
+
|
| 164 |
+
if isinstance(raw_time, (int, float)):
|
| 165 |
+
timestamp = float(raw_time)
|
| 166 |
+
if timestamp > 1e12:
|
| 167 |
+
timestamp = timestamp / 1000.0
|
| 168 |
+
return datetime.fromtimestamp(timestamp).astimezone().replace(tzinfo=None)
|
| 169 |
+
|
| 170 |
+
if isinstance(raw_time, str):
|
| 171 |
+
raw = raw_time.strip()
|
| 172 |
+
if not raw:
|
| 173 |
+
return None
|
| 174 |
+
if raw.isdigit():
|
| 175 |
+
timestamp = float(raw)
|
| 176 |
+
if timestamp > 1e12:
|
| 177 |
+
timestamp = timestamp / 1000.0
|
| 178 |
+
return datetime.fromtimestamp(timestamp).astimezone().replace(tzinfo=None)
|
| 179 |
+
|
| 180 |
+
# 截断纳秒到微秒(fromisoformat 只支持6位小数)
|
| 181 |
+
raw = re.sub(r"(\.\d{6})\d+", r"\1", raw)
|
| 182 |
+
|
| 183 |
+
try:
|
| 184 |
+
parsed = datetime.fromisoformat(raw.replace("Z", "+00:00"))
|
| 185 |
+
if parsed.tzinfo:
|
| 186 |
+
return parsed.astimezone().replace(tzinfo=None)
|
| 187 |
+
return parsed.replace(tzinfo=timezone.utc).astimezone().replace(tzinfo=None)
|
| 188 |
+
except Exception:
|
| 189 |
+
return None
|
| 190 |
+
|
| 191 |
+
return None
|
| 192 |
+
|
| 193 |
+
# 按时间倒序,优先检查最新邮件
|
| 194 |
+
emails_with_time = [(email_item, _parse_email_time(email_item)) for email_item in emails]
|
| 195 |
+
if any(item[1] is not None for item in emails_with_time):
|
| 196 |
+
emails_with_time.sort(key=lambda item: item[1] or datetime.min, reverse=True)
|
| 197 |
+
emails = [item[0] for item in emails_with_time]
|
| 198 |
+
|
| 199 |
+
skipped_no_time_indexes = []
|
| 200 |
+
skipped_expired_indexes = []
|
| 201 |
+
|
| 202 |
+
def _format_indexes(indexes: list[int]) -> str:
|
| 203 |
+
if len(indexes) <= 10:
|
| 204 |
+
return ",".join(str(index) for index in indexes)
|
| 205 |
+
preview = ",".join(str(index) for index in indexes[:10])
|
| 206 |
+
return f"{preview}...(+{len(indexes) - 10})"
|
| 207 |
+
|
| 208 |
+
def _log_skip_summary() -> None:
|
| 209 |
+
if skipped_no_time_indexes:
|
| 210 |
+
self._log(
|
| 211 |
+
"info",
|
| 212 |
+
f"⏭️ 已跳过 {len(skipped_no_time_indexes)} 封缺少可解析时间的邮件"
|
| 213 |
+
f"(序号: {_format_indexes(skipped_no_time_indexes)})",
|
| 214 |
+
)
|
| 215 |
+
if skipped_expired_indexes:
|
| 216 |
+
self._log(
|
| 217 |
+
"info",
|
| 218 |
+
f"⏭️ 已跳过 {len(skipped_expired_indexes)} 封过期邮件"
|
| 219 |
+
f"(序号: {_format_indexes(skipped_expired_indexes)})",
|
| 220 |
+
)
|
| 221 |
+
|
| 222 |
+
# 从最新一封邮件开始查找
|
| 223 |
+
for idx, email_data in enumerate(emails, 1):
|
| 224 |
+
# 时间过滤
|
| 225 |
+
if since_time:
|
| 226 |
+
email_time = _parse_email_time(email_data)
|
| 227 |
+
if email_time is None:
|
| 228 |
+
skipped_no_time_indexes.append(idx)
|
| 229 |
+
continue
|
| 230 |
+
if email_time < since_time:
|
| 231 |
+
skipped_expired_indexes.append(idx)
|
| 232 |
+
continue
|
| 233 |
+
|
| 234 |
+
# 获取邮件完整内容
|
| 235 |
+
email_id = email_data.get("id")
|
| 236 |
+
if email_id:
|
| 237 |
+
# 调用详情接口获取完整内容
|
| 238 |
+
detail_res = self._request(
|
| 239 |
+
"GET",
|
| 240 |
+
f"{self.base_url}/api/email/{email_id}",
|
| 241 |
+
params={"admin_token": self.jwt_token},
|
| 242 |
+
)
|
| 243 |
+
if detail_res.status_code == 200:
|
| 244 |
+
detail_data = detail_res.json()
|
| 245 |
+
content = detail_data.get("content") or ""
|
| 246 |
+
html_content = detail_data.get("html_content") or ""
|
| 247 |
+
else:
|
| 248 |
+
# 降级:如果详情接口失败,使用列表中的字段
|
| 249 |
+
content = email_data.get("content") or ""
|
| 250 |
+
html_content = email_data.get("html_content") or ""
|
| 251 |
+
preview = email_data.get("preview") or ""
|
| 252 |
+
content = content + " " + preview
|
| 253 |
+
else:
|
| 254 |
+
# 降级:没有 ID,使用列表中的字���
|
| 255 |
+
content = email_data.get("content") or ""
|
| 256 |
+
html_content = email_data.get("html_content") or ""
|
| 257 |
+
preview = email_data.get("preview") or ""
|
| 258 |
+
content = content + " " + preview
|
| 259 |
+
|
| 260 |
+
subject = email_data.get("subject") or ""
|
| 261 |
+
full_content = subject + " " + content + " " + html_content
|
| 262 |
+
code = extract_verification_code(full_content)
|
| 263 |
+
if code:
|
| 264 |
+
_log_skip_summary()
|
| 265 |
+
self._log("info", f"✅ 找到验证码: {code}")
|
| 266 |
+
return code
|
| 267 |
+
else:
|
| 268 |
+
self._log("info", f"❌ 邮件 {idx} 中未找到验证码")
|
| 269 |
+
|
| 270 |
+
_log_skip_summary()
|
| 271 |
+
self._log("warning", "⚠️ 所有邮件中均未找到验证码")
|
| 272 |
+
return None
|
| 273 |
+
|
| 274 |
+
except Exception as e:
|
| 275 |
+
self._log("error", f"❌ 获取验证码异常: {e}")
|
| 276 |
+
return None
|
| 277 |
+
|
| 278 |
+
def poll_for_code(
|
| 279 |
+
self,
|
| 280 |
+
timeout: int = 120,
|
| 281 |
+
interval: int = 4,
|
| 282 |
+
since_time=None,
|
| 283 |
+
) -> Optional[str]:
|
| 284 |
+
"""轮询获取验证码"""
|
| 285 |
+
max_retries = max(1, timeout // interval)
|
| 286 |
+
self._log("info", f"⏱️ 开始轮询验证码 (超时 {timeout}秒, 间隔 {interval}秒, 最多 {max_retries} 次)")
|
| 287 |
+
|
| 288 |
+
for i in range(1, max_retries + 1):
|
| 289 |
+
self._log("info", f"🔄 第 {i}/{max_retries} 次轮询...")
|
| 290 |
+
code = self.fetch_verification_code(since_time=since_time)
|
| 291 |
+
if code:
|
| 292 |
+
self._log("info", f"🎉 验证码获取成功: {code}")
|
| 293 |
+
return code
|
| 294 |
+
|
| 295 |
+
if i < max_retries:
|
| 296 |
+
self._log("info", f"⏳ 等待 {interval} 秒后重试...")
|
| 297 |
+
time.sleep(interval)
|
| 298 |
+
|
| 299 |
+
self._log("error", f"⏰ 验证码获取超时 ({timeout}秒)")
|
| 300 |
+
return None
|
| 301 |
+
|
| 302 |
+
def _get_domain(self) -> str:
|
| 303 |
+
"""获取可用域名"""
|
| 304 |
+
try:
|
| 305 |
+
params = {"admin_token": self.jwt_token}
|
| 306 |
+
res = self._request(
|
| 307 |
+
"GET",
|
| 308 |
+
f"{self.base_url}/api/domains",
|
| 309 |
+
params=params,
|
| 310 |
+
)
|
| 311 |
+
if res.status_code == 200:
|
| 312 |
+
domains = res.json() if res.content else []
|
| 313 |
+
if isinstance(domains, list) and domains:
|
| 314 |
+
return domains[0]
|
| 315 |
+
except Exception:
|
| 316 |
+
pass
|
| 317 |
+
return ""
|
| 318 |
+
|
| 319 |
+
def _log(self, level: str, message: str) -> None:
|
| 320 |
+
"""日志回调"""
|
| 321 |
+
if self.log_callback:
|
| 322 |
+
try:
|
| 323 |
+
self.log_callback(level, message)
|
| 324 |
+
except Exception:
|
| 325 |
+
pass
|
core/gemini_automation.py
ADDED
|
@@ -0,0 +1,1103 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Gemini自动化登录模块(用于新账号注册)
|
| 3 |
+
"""
|
| 4 |
+
import os
|
| 5 |
+
import json
|
| 6 |
+
import random
|
| 7 |
+
import re
|
| 8 |
+
import string
|
| 9 |
+
import time
|
| 10 |
+
from datetime import datetime, timedelta, timezone
|
| 11 |
+
from typing import Optional
|
| 12 |
+
from urllib.parse import quote
|
| 13 |
+
|
| 14 |
+
from DrissionPage import ChromiumPage, ChromiumOptions
|
| 15 |
+
from core.base_task_service import TaskCancelledError
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
# 常量
|
| 19 |
+
AUTH_HOME_URL = "https://auth.business.gemini.google/login"
|
| 20 |
+
|
| 21 |
+
# Linux 下常见的 Chromium 路径
|
| 22 |
+
CHROMIUM_PATHS = [
|
| 23 |
+
"/usr/bin/chromium",
|
| 24 |
+
"/usr/bin/chromium-browser",
|
| 25 |
+
"/usr/bin/google-chrome",
|
| 26 |
+
"/usr/bin/google-chrome-stable",
|
| 27 |
+
]
|
| 28 |
+
|
| 29 |
+
# 注册时随机使用的真实英文姓名(避免明显的机器人特征)
|
| 30 |
+
REGISTER_NAMES = [
|
| 31 |
+
"James Smith", "John Johnson", "Robert Williams", "Michael Brown", "William Jones",
|
| 32 |
+
"David Garcia", "Mary Miller", "Patricia Davis", "Jennifer Rodriguez", "Linda Martinez",
|
| 33 |
+
"Barbara Anderson", "Susan Thomas", "Jessica Jackson", "Sarah White", "Karen Harris",
|
| 34 |
+
"Lisa Martin", "Nancy Thompson", "Betty Garcia", "Margaret Martinez", "Sandra Robinson",
|
| 35 |
+
"Ashley Clark", "Dorothy Rodriguez", "Emma Lewis", "Olivia Lee", "Ava Walker",
|
| 36 |
+
"Emily Hall", "Abigail Allen", "Madison Young", "Elizabeth Hernandez", "Charlotte King",
|
| 37 |
+
]
|
| 38 |
+
|
| 39 |
+
# 常见桌面分辨率(避免固定 1280x800 成为指纹)
|
| 40 |
+
COMMON_VIEWPORTS = [
|
| 41 |
+
(1366, 768), (1440, 900), (1536, 864), (1280, 720),
|
| 42 |
+
(1920, 1080), (1600, 900), (1280, 800), (1360, 768),
|
| 43 |
+
]
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def _find_chromium_path() -> Optional[str]:
|
| 47 |
+
"""查找可用的 Chromium/Chrome 浏览器路径"""
|
| 48 |
+
for path in CHROMIUM_PATHS:
|
| 49 |
+
if os.path.isfile(path) and os.access(path, os.X_OK):
|
| 50 |
+
return path
|
| 51 |
+
return None
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
class GeminiAutomation:
|
| 55 |
+
"""Gemini自动化登录"""
|
| 56 |
+
|
| 57 |
+
def __init__(
|
| 58 |
+
self,
|
| 59 |
+
user_agent: str = "",
|
| 60 |
+
proxy: str = "",
|
| 61 |
+
headless: bool = True,
|
| 62 |
+
timeout: int = 60,
|
| 63 |
+
log_callback=None,
|
| 64 |
+
) -> None:
|
| 65 |
+
self.user_agent = user_agent or self._get_ua()
|
| 66 |
+
self.proxy = proxy
|
| 67 |
+
self.headless = headless
|
| 68 |
+
self.timeout = timeout
|
| 69 |
+
self.log_callback = log_callback
|
| 70 |
+
self._page = None
|
| 71 |
+
self._user_data_dir = None
|
| 72 |
+
self._last_send_error = ""
|
| 73 |
+
|
| 74 |
+
def stop(self) -> None:
|
| 75 |
+
"""外部请求停止:尽力关闭浏览器实例。"""
|
| 76 |
+
page = self._page
|
| 77 |
+
if page:
|
| 78 |
+
try:
|
| 79 |
+
page.quit()
|
| 80 |
+
except Exception:
|
| 81 |
+
pass
|
| 82 |
+
|
| 83 |
+
def login_and_extract(self, email: str, mail_client, is_new_account: bool = False) -> dict:
|
| 84 |
+
"""执行登录并提取配置"""
|
| 85 |
+
page = None
|
| 86 |
+
user_data_dir = None
|
| 87 |
+
try:
|
| 88 |
+
page = self._create_page()
|
| 89 |
+
user_data_dir = getattr(page, 'user_data_dir', None)
|
| 90 |
+
self._page = page
|
| 91 |
+
self._user_data_dir = user_data_dir
|
| 92 |
+
return self._run_flow(page, email, mail_client, is_new_account=is_new_account)
|
| 93 |
+
except TaskCancelledError:
|
| 94 |
+
raise
|
| 95 |
+
except Exception as exc:
|
| 96 |
+
self._log("error", f"automation error: {exc}")
|
| 97 |
+
return {"success": False, "error": str(exc)}
|
| 98 |
+
finally:
|
| 99 |
+
if page:
|
| 100 |
+
try:
|
| 101 |
+
page.quit()
|
| 102 |
+
except Exception:
|
| 103 |
+
pass
|
| 104 |
+
self._page = None
|
| 105 |
+
self._cleanup_user_data(user_data_dir)
|
| 106 |
+
self._user_data_dir = None
|
| 107 |
+
|
| 108 |
+
def _create_page(self) -> ChromiumPage:
|
| 109 |
+
"""创建浏览器页面"""
|
| 110 |
+
options = ChromiumOptions()
|
| 111 |
+
|
| 112 |
+
# 自动检测 Chromium 浏览器路径(Linux/Docker 环境)
|
| 113 |
+
chromium_path = _find_chromium_path()
|
| 114 |
+
if chromium_path:
|
| 115 |
+
options.set_browser_path(chromium_path)
|
| 116 |
+
|
| 117 |
+
options.set_argument("--incognito")
|
| 118 |
+
options.set_argument("--no-sandbox")
|
| 119 |
+
options.set_argument("--disable-dev-shm-usage")
|
| 120 |
+
options.set_argument("--disable-setuid-sandbox")
|
| 121 |
+
options.set_argument("--disable-blink-features=AutomationControlled")
|
| 122 |
+
|
| 123 |
+
# 随机窗口尺寸(避免固定分辨率成为指纹)
|
| 124 |
+
vw, vh = random.choice(COMMON_VIEWPORTS)
|
| 125 |
+
options.set_argument(f"--window-size={vw},{vh}")
|
| 126 |
+
options.set_user_agent(self.user_agent)
|
| 127 |
+
|
| 128 |
+
# 防止 WebRTC 泄露真实 IP(即使使用代理也可能暴露)
|
| 129 |
+
options.set_argument("--disable-webrtc")
|
| 130 |
+
options.set_argument("--enforce-webrtc-ip-handling-policy")
|
| 131 |
+
options.set_pref("webrtc.ip_handling_policy", "disable_non_proxied_udp")
|
| 132 |
+
options.set_pref("webrtc.multiple_routes_enabled", False)
|
| 133 |
+
options.set_pref("webrtc.nonproxied_udp_enabled", False)
|
| 134 |
+
|
| 135 |
+
# 语言设置(确保使用中文界面)
|
| 136 |
+
options.set_argument("--lang=zh-CN")
|
| 137 |
+
options.set_pref("intl.accept_languages", "zh-CN,zh")
|
| 138 |
+
|
| 139 |
+
if self.proxy:
|
| 140 |
+
options.set_argument(f"--proxy-server={self.proxy}")
|
| 141 |
+
|
| 142 |
+
if self.headless:
|
| 143 |
+
# 使用新版无头模式,更接近��实浏览器
|
| 144 |
+
options.set_argument("--headless=new")
|
| 145 |
+
options.set_argument("--disable-gpu")
|
| 146 |
+
options.set_argument("--no-first-run")
|
| 147 |
+
options.set_argument("--disable-extensions")
|
| 148 |
+
# 反检测参数
|
| 149 |
+
options.set_argument("--disable-infobars")
|
| 150 |
+
options.set_argument("--enable-features=NetworkService,NetworkServiceInProcess")
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
options.auto_port()
|
| 155 |
+
page = ChromiumPage(options)
|
| 156 |
+
page.set.timeouts(self.timeout)
|
| 157 |
+
|
| 158 |
+
# 最小化 JS 注入:只设置 window.chrome(不使用 Object.defineProperty,避免被 reCAPTCHA 检测)
|
| 159 |
+
# 注意:DrissionPage 不像 Selenium 那样暴露 navigator.webdriver,无需额外隐藏
|
| 160 |
+
try:
|
| 161 |
+
page.run_cdp("Page.addScriptToEvaluateOnNewDocument", source="""
|
| 162 |
+
// 确保 window.chrome 存在(headless 模式下可能缺失)
|
| 163 |
+
if (!window.chrome) {
|
| 164 |
+
window.chrome = {runtime: {}, loadTimes: function(){return {}}, csi: function(){return {}}};
|
| 165 |
+
}
|
| 166 |
+
""")
|
| 167 |
+
except Exception:
|
| 168 |
+
pass
|
| 169 |
+
|
| 170 |
+
return page
|
| 171 |
+
|
| 172 |
+
def _extract_xsrf_token(self, page) -> str:
|
| 173 |
+
"""从页面中提取真实的 XSRF Token(避免硬编码被标黑)"""
|
| 174 |
+
try:
|
| 175 |
+
html = page.html or ""
|
| 176 |
+
# 尝试从 meta 标签提取
|
| 177 |
+
m = re.search(r'name=["\']xsrf-token["\']\s+content=["\']([^"\']+)["\']', html, re.IGNORECASE)
|
| 178 |
+
if m:
|
| 179 |
+
self._log("info", "🔑 从 meta 标签提取到 XSRF token")
|
| 180 |
+
return m.group(1)
|
| 181 |
+
# 尝试从隐藏 input 提取
|
| 182 |
+
m = re.search(r'name=["\']xsrfToken["\'][^>]*value=["\']([A-Za-z0-9_-]{20,})["\']', html)
|
| 183 |
+
if m:
|
| 184 |
+
self._log("info", "🔑 从 input 提取到 XSRF token")
|
| 185 |
+
return m.group(1)
|
| 186 |
+
# 尝试从 JS 变量提取
|
| 187 |
+
m = re.search(r'xsrfToken["\']?\s*[=:]\s*["\']([A-Za-z0-9_-]{20,})["\']', html)
|
| 188 |
+
if m:
|
| 189 |
+
self._log("info", "🔑 从 JS 提取到 XSRF token")
|
| 190 |
+
return m.group(1)
|
| 191 |
+
# 尝试从 URL 参数提取
|
| 192 |
+
m = re.search(r'xsrfToken=([A-Za-z0-9_-]{20,})', html)
|
| 193 |
+
if m:
|
| 194 |
+
self._log("info", "🔑 从 URL 参数提取到 XSRF token")
|
| 195 |
+
return m.group(1)
|
| 196 |
+
except Exception as e:
|
| 197 |
+
self._log("warning", f"⚠️ XSRF token 提取异常: {e}")
|
| 198 |
+
self._log("warning", "⚠️ 未能从页面提取 XSRF token,使用备用值")
|
| 199 |
+
return "GXO_B0wnNhs6UQJZMcrSbTsbEEs"
|
| 200 |
+
|
| 201 |
+
def _run_flow(self, page, email: str, mail_client, is_new_account: bool = False) -> dict:
|
| 202 |
+
"""执行登录流程(is_new_account=True 时启用注册专用的增强用户名处理)"""
|
| 203 |
+
|
| 204 |
+
# 记录任务开始时间,用于邮件时间过滤(全流程固定,不随重发更新)
|
| 205 |
+
from datetime import datetime
|
| 206 |
+
task_start_time = datetime.now()
|
| 207 |
+
|
| 208 |
+
# Step 1: 导航到登录页面
|
| 209 |
+
self._log("info", f"🌐 打开登录页面: {email}")
|
| 210 |
+
page.get(AUTH_HOME_URL, timeout=self.timeout)
|
| 211 |
+
time.sleep(random.uniform(2, 4))
|
| 212 |
+
|
| 213 |
+
# 从页面动态提取 XSRF token(避免硬编码被 Google 标黑)
|
| 214 |
+
xsrf_token = self._extract_xsrf_token(page)
|
| 215 |
+
|
| 216 |
+
# 设置 XSRF Cookie
|
| 217 |
+
try:
|
| 218 |
+
self._log("info", "🍪 设置 XSRF Cookie...")
|
| 219 |
+
page.set.cookies({
|
| 220 |
+
"name": "__Host-AP_SignInXsrf",
|
| 221 |
+
"value": xsrf_token,
|
| 222 |
+
"url": AUTH_HOME_URL,
|
| 223 |
+
"path": "/",
|
| 224 |
+
"secure": True,
|
| 225 |
+
})
|
| 226 |
+
except Exception as e:
|
| 227 |
+
self._log("warning", f"⚠️ Cookie 设置失败: {e}")
|
| 228 |
+
|
| 229 |
+
# Step 1.5: 通过 URL 方式提交邮箱(稳定,不触发风控)
|
| 230 |
+
login_hint = quote(email, safe="")
|
| 231 |
+
login_url = f"https://auth.business.gemini.google/login/email?continueUrl=https%3A%2F%2Fbusiness.gemini.google%2F&loginHint={login_hint}&xsrfToken={xsrf_token}"
|
| 232 |
+
|
| 233 |
+
# 先启动网络监听,再导航(避免漏掉页面加载期间的请求)
|
| 234 |
+
try:
|
| 235 |
+
page.listen.start(
|
| 236 |
+
targets=["batchexecute"],
|
| 237 |
+
is_regex=False,
|
| 238 |
+
method=("POST",),
|
| 239 |
+
res_type=("XHR", "FETCH"),
|
| 240 |
+
)
|
| 241 |
+
except Exception:
|
| 242 |
+
pass
|
| 243 |
+
|
| 244 |
+
self._log("info", "📧 使用 URL 方式提交邮箱...")
|
| 245 |
+
page.get(login_url, timeout=self.timeout)
|
| 246 |
+
time.sleep(random.uniform(3, 5))
|
| 247 |
+
|
| 248 |
+
# 模拟真实用户行为:页面加载后随机滚动
|
| 249 |
+
self._random_scroll(page)
|
| 250 |
+
|
| 251 |
+
# Step 2: 检查当前页面状态
|
| 252 |
+
current_url = page.url
|
| 253 |
+
self._log("info", f"📍 当前 URL: {current_url}")
|
| 254 |
+
|
| 255 |
+
# 检测 signin-error 页面(极端情况,一般 URL 方式不会触发)
|
| 256 |
+
if "signin-error" in current_url:
|
| 257 |
+
self._log("error", "❌ 进入 signin-error 页面,可能是代理���网络问题")
|
| 258 |
+
self._save_screenshot(page, "signin_error")
|
| 259 |
+
return {"success": False, "error": "signin-error: token rejected by Google, try changing proxy"}
|
| 260 |
+
|
| 261 |
+
has_business_params = "business.gemini.google" in current_url and "csesidx=" in current_url and "/cid/" in current_url
|
| 262 |
+
|
| 263 |
+
if has_business_params:
|
| 264 |
+
self._log("info", "✅ 已登录,提取配置")
|
| 265 |
+
return self._extract_config(page, email)
|
| 266 |
+
|
| 267 |
+
# 检测 403 Access Restricted(刷新/登录时账户可能已被封禁)
|
| 268 |
+
access_error = self._check_access_restricted(page, email)
|
| 269 |
+
if access_error:
|
| 270 |
+
return access_error
|
| 271 |
+
|
| 272 |
+
# Step 3: 点击发送验证码按钮(最多5轮,适度退避间隔)
|
| 273 |
+
self._log("info", "📧 发送验证码...")
|
| 274 |
+
max_send_rounds = 5
|
| 275 |
+
send_round_delays = [10, 10, 15, 15, 20]
|
| 276 |
+
send_round = 0
|
| 277 |
+
while True:
|
| 278 |
+
send_round += 1
|
| 279 |
+
if self._click_send_code_button(page):
|
| 280 |
+
break
|
| 281 |
+
if send_round >= max_send_rounds:
|
| 282 |
+
self._log("error", "❌ 验证码发送失败(可能触发风控),建议更换代理IP")
|
| 283 |
+
self._save_screenshot(page, "send_code_button_failed")
|
| 284 |
+
return {"success": False, "error": "send code failed after retries"}
|
| 285 |
+
delay = send_round_delays[min(send_round - 1, len(send_round_delays) - 1)]
|
| 286 |
+
self._log("warning", f"⚠️ 发送失败,{delay}秒后重试 ({send_round}/{max_send_rounds})")
|
| 287 |
+
time.sleep(delay)
|
| 288 |
+
|
| 289 |
+
# Step 4: 等待验证码输入框出现
|
| 290 |
+
code_input = self._wait_for_code_input(page)
|
| 291 |
+
if not code_input:
|
| 292 |
+
self._log("error", "❌ 验证码输入框未出现")
|
| 293 |
+
self._save_screenshot(page, "code_input_missing")
|
| 294 |
+
return {"success": False, "error": "code input not found"}
|
| 295 |
+
|
| 296 |
+
# Step 5: 轮询邮件获取验证码(3次,每次5秒间隔)
|
| 297 |
+
self._log("info", "📬 等待邮箱验证码...")
|
| 298 |
+
code = mail_client.poll_for_code(timeout=15, interval=5, since_time=task_start_time)
|
| 299 |
+
|
| 300 |
+
if not code:
|
| 301 |
+
self._log("warning", "⚠️ 验证码超时,等待后重新发送...")
|
| 302 |
+
time.sleep(random.uniform(12, 18))
|
| 303 |
+
# 尝试点击重新发送按钮
|
| 304 |
+
if self._click_resend_code_button(page):
|
| 305 |
+
# 再次轮询验证码(3次,每次5秒间隔)
|
| 306 |
+
code = mail_client.poll_for_code(timeout=15, interval=5, since_time=task_start_time)
|
| 307 |
+
if not code:
|
| 308 |
+
self._log("error", "❌ 重新发送后仍未收到验证码")
|
| 309 |
+
self._save_screenshot(page, "code_timeout_after_resend")
|
| 310 |
+
return {"success": False, "error": "verification code timeout after resend"}
|
| 311 |
+
else:
|
| 312 |
+
self._log("error", "❌ 验证码超时且未找到重新发送按钮")
|
| 313 |
+
self._save_screenshot(page, "code_timeout")
|
| 314 |
+
return {"success": False, "error": "verification code timeout"}
|
| 315 |
+
|
| 316 |
+
self._log("info", f"✅ 收到验证码: {code}")
|
| 317 |
+
|
| 318 |
+
# Step 6: 输入验证码并提交
|
| 319 |
+
code_input = page.ele("css:input[jsname='ovqh0b']", timeout=3) or \
|
| 320 |
+
page.ele("css:input[type='tel']", timeout=2)
|
| 321 |
+
|
| 322 |
+
if not code_input:
|
| 323 |
+
self._log("error", "❌ 验证码输入框已失效")
|
| 324 |
+
return {"success": False, "error": "code input expired"}
|
| 325 |
+
|
| 326 |
+
# 尝试模拟人类输入,失败则降级到直接注入
|
| 327 |
+
self._log("info", "⌨️ 输入验证码...")
|
| 328 |
+
if not self._simulate_human_input(code_input, code):
|
| 329 |
+
self._log("warning", "⚠️ 模拟输入失败,降级为直接输入")
|
| 330 |
+
code_input.input(code, clear=True)
|
| 331 |
+
time.sleep(random.uniform(0.4, 0.8))
|
| 332 |
+
|
| 333 |
+
# 提交验证码:先回车,再找验证按钮兜底
|
| 334 |
+
self._log("info", "⏎ 提交验证码")
|
| 335 |
+
code_input.input("\n")
|
| 336 |
+
time.sleep(random.uniform(1, 2))
|
| 337 |
+
# 如果回车没触发,找验证按钮点击
|
| 338 |
+
if "verify-oob-code" in page.url:
|
| 339 |
+
verify_btn = self._find_verify_button(page)
|
| 340 |
+
if verify_btn:
|
| 341 |
+
try:
|
| 342 |
+
verify_btn.click()
|
| 343 |
+
self._log("info", "✅ 已点击验证按钮(兜底)")
|
| 344 |
+
except Exception:
|
| 345 |
+
pass
|
| 346 |
+
|
| 347 |
+
# [注册专用] 验证码提交后先等几秒让页面跳转,再检查 403
|
| 348 |
+
if is_new_account:
|
| 349 |
+
time.sleep(3)
|
| 350 |
+
access_error = self._check_access_restricted(page, email)
|
| 351 |
+
if access_error:
|
| 352 |
+
return access_error
|
| 353 |
+
self._log("info", "📝 [注册] 验证码已提交,等待姓名输入页面...")
|
| 354 |
+
if self._handle_username_setup(page, is_new_account=True):
|
| 355 |
+
self._log("info", "✅ 姓名填写完成,等待工作台 URL...")
|
| 356 |
+
if self._wait_for_business_params(page, timeout=45):
|
| 357 |
+
self._log("info", "🎊 注册成功,提取配置...")
|
| 358 |
+
return self._extract_config(page, email)
|
| 359 |
+
# 姓名步骤失败或未出现,继续走通用流程兜底
|
| 360 |
+
self._log("info", "⚠️ 姓名步骤未完成,走通用流程兜底...")
|
| 361 |
+
|
| 362 |
+
# Step 7: 等待页面自动重定向(提交验证码后 Google 会自动跳转)
|
| 363 |
+
self._log("info", "⏳ 等待验证后跳转...")
|
| 364 |
+
time.sleep(random.uniform(10, 15))
|
| 365 |
+
|
| 366 |
+
# 记录当前 URL 状态
|
| 367 |
+
current_url = page.url
|
| 368 |
+
self._log("info", f"📍 验证后 URL: {current_url}")
|
| 369 |
+
|
| 370 |
+
# 检查是否还停留在验证码页面(说明提交失败)
|
| 371 |
+
if "verify-oob-code" in current_url:
|
| 372 |
+
self._log("error", "❌ 验证码提交失败")
|
| 373 |
+
self._save_screenshot(page, "verification_submit_failed")
|
| 374 |
+
return {"success": False, "error": "verification code submission failed"}
|
| 375 |
+
|
| 376 |
+
# Step 8: 处理协议页面(如果有)
|
| 377 |
+
self._handle_agreement_page(page)
|
| 378 |
+
|
| 379 |
+
# Step 8.5: 检测 403 Access Restricted 页面
|
| 380 |
+
access_error = self._check_access_restricted(page, email)
|
| 381 |
+
if access_error:
|
| 382 |
+
return access_error
|
| 383 |
+
|
| 384 |
+
# Step 9: 检查是否已经在正确的页面
|
| 385 |
+
current_url = page.url
|
| 386 |
+
has_business_params = "business.gemini.google" in current_url and "csesidx=" in current_url and "/cid/" in current_url
|
| 387 |
+
|
| 388 |
+
if has_business_params:
|
| 389 |
+
return self._extract_config(page, email)
|
| 390 |
+
|
| 391 |
+
# Step 10: 如果不在正确的页面,尝试导航
|
| 392 |
+
if "business.gemini.google" not in current_url:
|
| 393 |
+
page.get("https://business.gemini.google/", timeout=self.timeout)
|
| 394 |
+
time.sleep(random.uniform(4, 7))
|
| 395 |
+
|
| 396 |
+
# Step 11: 检查是否需要设置用户名(仅登录刷新走此路径,注册已在早期处理)
|
| 397 |
+
if not is_new_account and "cid" not in page.url:
|
| 398 |
+
if self._handle_username_setup(page):
|
| 399 |
+
time.sleep(random.uniform(4, 7))
|
| 400 |
+
|
| 401 |
+
# Step 12: 再次检测 403(导航后可能出现)
|
| 402 |
+
access_error = self._check_access_restricted(page, email)
|
| 403 |
+
if access_error:
|
| 404 |
+
return access_error
|
| 405 |
+
|
| 406 |
+
# Step 13: 等待 URL 参数生成(csesidx 和 cid)
|
| 407 |
+
if not self._wait_for_business_params(page):
|
| 408 |
+
page.refresh()
|
| 409 |
+
time.sleep(random.uniform(4, 7))
|
| 410 |
+
if not self._wait_for_business_params(page):
|
| 411 |
+
self._log("error", "❌ URL 参数生成失败")
|
| 412 |
+
self._save_screenshot(page, "params_missing")
|
| 413 |
+
return {"success": False, "error": "URL parameters not found"}
|
| 414 |
+
|
| 415 |
+
# Step 13: 提取配置
|
| 416 |
+
self._log("info", "🎊 登录成功,提取配置...")
|
| 417 |
+
return self._extract_config(page, email)
|
| 418 |
+
|
| 419 |
+
def _click_send_code_button(self, page) -> bool:
|
| 420 |
+
"""点击发送验证码按钮(如果需要)"""
|
| 421 |
+
time.sleep(random.uniform(1.5, 3))
|
| 422 |
+
max_send_attempts = 5
|
| 423 |
+
# 适度退避延迟序列(秒)
|
| 424 |
+
retry_delays = [10, 10, 15, 15, 20]
|
| 425 |
+
|
| 426 |
+
# 方法1: 直接通过ID查找
|
| 427 |
+
direct_btn = page.ele("#sign-in-with-email", timeout=5)
|
| 428 |
+
if direct_btn:
|
| 429 |
+
for attempt in range(1, max_send_attempts + 1):
|
| 430 |
+
try:
|
| 431 |
+
self._last_send_error = ""
|
| 432 |
+
self._human_click(page, direct_btn)
|
| 433 |
+
if self._verify_code_send_by_network(page) or self._verify_code_send_status(page):
|
| 434 |
+
self._stop_listen(page)
|
| 435 |
+
return True
|
| 436 |
+
delay = retry_delays[min(attempt - 1, len(retry_delays) - 1)]
|
| 437 |
+
if self._last_send_error == "captcha_check_failed":
|
| 438 |
+
self._log("error", f"❌ 触发风控,建议更换代理IP ({attempt}/{max_send_attempts})")
|
| 439 |
+
else:
|
| 440 |
+
self._log("warning", f"⚠️ 发送失败,{delay}秒后重试 ({attempt}/{max_send_attempts})")
|
| 441 |
+
time.sleep(delay)
|
| 442 |
+
except Exception as e:
|
| 443 |
+
self._log("warning", f"⚠️ 点击失败: {e}")
|
| 444 |
+
self._stop_listen(page)
|
| 445 |
+
return False
|
| 446 |
+
|
| 447 |
+
# 方法2: 通过关键词查找
|
| 448 |
+
keywords = ["通过电子邮件发送验证码", "通过电子邮件发送", "email", "Email", "Send code", "Send verification", "Verification code"]
|
| 449 |
+
try:
|
| 450 |
+
buttons = page.eles("tag:button")
|
| 451 |
+
for btn in buttons:
|
| 452 |
+
text = (btn.text or "").strip()
|
| 453 |
+
if text and any(kw in text for kw in keywords):
|
| 454 |
+
for attempt in range(1, max_send_attempts + 1):
|
| 455 |
+
try:
|
| 456 |
+
self._last_send_error = ""
|
| 457 |
+
self._human_click(page, btn)
|
| 458 |
+
if self._verify_code_send_by_network(page) or self._verify_code_send_status(page):
|
| 459 |
+
self._stop_listen(page)
|
| 460 |
+
return True
|
| 461 |
+
delay = retry_delays[min(attempt - 1, len(retry_delays) - 1)]
|
| 462 |
+
if self._last_send_error == "captcha_check_failed":
|
| 463 |
+
self._log("error", f"❌ 触发风控,建议更换代理IP ({attempt}/{max_send_attempts})")
|
| 464 |
+
else:
|
| 465 |
+
self._log("warning", f"⚠️ 发送失败,{delay}秒后重试 ({attempt}/{max_send_attempts})")
|
| 466 |
+
time.sleep(delay)
|
| 467 |
+
except Exception as e:
|
| 468 |
+
self._log("warning", f"⚠️ 点击失败: {e}")
|
| 469 |
+
self._stop_listen(page)
|
| 470 |
+
return False
|
| 471 |
+
except Exception as e:
|
| 472 |
+
self._log("warning", f"⚠️ 搜索按钮异常: {e}")
|
| 473 |
+
|
| 474 |
+
# 检查是否在 signin-error 页面(不应该继续尝试发送)
|
| 475 |
+
if "signin-error" in (page.url or ""):
|
| 476 |
+
self._stop_listen(page)
|
| 477 |
+
self._log("error", "❌ 在 signin-error 页面,无法发送验证码")
|
| 478 |
+
return False
|
| 479 |
+
|
| 480 |
+
# 检查是否已经在验证码输入页面
|
| 481 |
+
code_input = page.ele("css:input[jsname='ovqh0b']", timeout=2) or page.ele("css:input[name='pinInput']", timeout=1)
|
| 482 |
+
if code_input:
|
| 483 |
+
self._stop_listen(page)
|
| 484 |
+
self._log("info", "✅ 已在验证码输入页面")
|
| 485 |
+
|
| 486 |
+
# 直接点击重新发送按钮(不管之前是否发送过)
|
| 487 |
+
if self._click_resend_code_button(page):
|
| 488 |
+
self._log("info", "✅ 已点击重新发送按钮")
|
| 489 |
+
return True
|
| 490 |
+
else:
|
| 491 |
+
self._log("warning", "⚠️ 未找到重新发送按钮,继续流程")
|
| 492 |
+
return True
|
| 493 |
+
|
| 494 |
+
self._stop_listen(page)
|
| 495 |
+
self._log("error", "❌ 未找到发送验证码按钮")
|
| 496 |
+
return False
|
| 497 |
+
|
| 498 |
+
def _stop_listen(self, page) -> None:
|
| 499 |
+
"""安全地停止网络监听"""
|
| 500 |
+
try:
|
| 501 |
+
if hasattr(page, 'listen') and page.listen:
|
| 502 |
+
page.listen.stop()
|
| 503 |
+
except Exception:
|
| 504 |
+
pass
|
| 505 |
+
|
| 506 |
+
def _verify_code_send_by_network(self, page) -> bool:
|
| 507 |
+
"""通过监听网络请求验证验证码是否成功发送"""
|
| 508 |
+
try:
|
| 509 |
+
time.sleep(1)
|
| 510 |
+
|
| 511 |
+
packets = []
|
| 512 |
+
max_wait_seconds = 6
|
| 513 |
+
deadline = time.time() + max_wait_seconds
|
| 514 |
+
try:
|
| 515 |
+
while time.time() < deadline:
|
| 516 |
+
got_any = False
|
| 517 |
+
for packet in page.listen.steps(timeout=1, gap=1):
|
| 518 |
+
packets.append(packet)
|
| 519 |
+
got_any = True
|
| 520 |
+
if got_any:
|
| 521 |
+
time.sleep(0.2)
|
| 522 |
+
else:
|
| 523 |
+
break
|
| 524 |
+
except Exception:
|
| 525 |
+
return False
|
| 526 |
+
|
| 527 |
+
if not packets:
|
| 528 |
+
return False
|
| 529 |
+
|
| 530 |
+
# 保存网络日志(仅用于调试)
|
| 531 |
+
self._save_network_packets(packets)
|
| 532 |
+
|
| 533 |
+
found_batchexecute = False
|
| 534 |
+
found_batchexecute_error = False
|
| 535 |
+
|
| 536 |
+
for packet in packets:
|
| 537 |
+
try:
|
| 538 |
+
url = str(packet.url) if hasattr(packet, 'url') else str(packet)
|
| 539 |
+
|
| 540 |
+
if 'batchexecute' in url:
|
| 541 |
+
found_batchexecute = True
|
| 542 |
+
|
| 543 |
+
try:
|
| 544 |
+
response = packet.response if hasattr(packet, 'response') else None
|
| 545 |
+
if response and hasattr(response, 'raw_body'):
|
| 546 |
+
body = response.raw_body
|
| 547 |
+
raw_body_str = str(body)
|
| 548 |
+
if "CAPTCHA_CHECK_FAILED" in raw_body_str:
|
| 549 |
+
found_batchexecute_error = True
|
| 550 |
+
self._last_send_error = "captcha_check_failed"
|
| 551 |
+
elif "SendEmailOtpError" in raw_body_str:
|
| 552 |
+
found_batchexecute_error = True
|
| 553 |
+
self._last_send_error = "send_email_otp_error"
|
| 554 |
+
except Exception:
|
| 555 |
+
pass
|
| 556 |
+
|
| 557 |
+
except Exception:
|
| 558 |
+
continue
|
| 559 |
+
|
| 560 |
+
if found_batchexecute:
|
| 561 |
+
if found_batchexecute_error:
|
| 562 |
+
return False
|
| 563 |
+
return True
|
| 564 |
+
else:
|
| 565 |
+
return False
|
| 566 |
+
|
| 567 |
+
except Exception:
|
| 568 |
+
return False
|
| 569 |
+
|
| 570 |
+
def _verify_code_send_status(self, page) -> bool:
|
| 571 |
+
"""检测页面提示判断是否发送成功"""
|
| 572 |
+
time.sleep(random.uniform(1.5, 3))
|
| 573 |
+
try:
|
| 574 |
+
success_keywords = ["验证码已发送", "code sent", "email sent", "check your email", "已发送"]
|
| 575 |
+
error_keywords = [
|
| 576 |
+
"出了点问题",
|
| 577 |
+
"something went wrong",
|
| 578 |
+
"error",
|
| 579 |
+
"failed",
|
| 580 |
+
"try again",
|
| 581 |
+
"稍后再试",
|
| 582 |
+
"选择其他登录方法"
|
| 583 |
+
]
|
| 584 |
+
selectors = [
|
| 585 |
+
"css:.zyTWof-gIZMF",
|
| 586 |
+
"css:[role='alert']",
|
| 587 |
+
"css:aside",
|
| 588 |
+
]
|
| 589 |
+
for selector in selectors:
|
| 590 |
+
try:
|
| 591 |
+
elements = page.eles(selector, timeout=1)
|
| 592 |
+
for elem in elements[:20]:
|
| 593 |
+
text = (elem.text or "").strip()
|
| 594 |
+
if not text:
|
| 595 |
+
continue
|
| 596 |
+
if any(kw in text for kw in error_keywords):
|
| 597 |
+
return False
|
| 598 |
+
if any(kw in text for kw in success_keywords):
|
| 599 |
+
return True
|
| 600 |
+
except Exception:
|
| 601 |
+
continue
|
| 602 |
+
return True
|
| 603 |
+
except Exception:
|
| 604 |
+
return True
|
| 605 |
+
|
| 606 |
+
def _truncate_text(self, text: str, max_len: int = 2000) -> str:
|
| 607 |
+
if text is None:
|
| 608 |
+
return ""
|
| 609 |
+
if len(text) <= max_len:
|
| 610 |
+
return text
|
| 611 |
+
return text[:max_len] + f"...(truncated, total={len(text)})"
|
| 612 |
+
|
| 613 |
+
def _save_network_packets(self, packets) -> None:
|
| 614 |
+
"""保存网络日志(仅用于调试)"""
|
| 615 |
+
try:
|
| 616 |
+
from core.storage import _data_file_path
|
| 617 |
+
base_dir = _data_file_path(os.path.join("logs", "network"))
|
| 618 |
+
os.makedirs(base_dir, exist_ok=True)
|
| 619 |
+
ts = datetime.now().strftime("%Y%m%d-%H%M%S")
|
| 620 |
+
file_path = os.path.join(base_dir, f"network-{ts}.jsonl")
|
| 621 |
+
|
| 622 |
+
def safe_str(value):
|
| 623 |
+
try:
|
| 624 |
+
return value if isinstance(value, str) else str(value)
|
| 625 |
+
except Exception:
|
| 626 |
+
return "<unprintable>"
|
| 627 |
+
|
| 628 |
+
with open(file_path, "a", encoding="utf-8") as f:
|
| 629 |
+
for packet in packets:
|
| 630 |
+
try:
|
| 631 |
+
req = packet.request if hasattr(packet, "request") else None
|
| 632 |
+
resp = packet.response if hasattr(packet, "response") else None
|
| 633 |
+
fail = packet.fail_info if hasattr(packet, "fail_info") else None
|
| 634 |
+
|
| 635 |
+
item = {
|
| 636 |
+
"url": safe_str(packet.url) if hasattr(packet, "url") else safe_str(packet),
|
| 637 |
+
"method": safe_str(packet.method) if hasattr(packet, "method") else "UNKNOWN",
|
| 638 |
+
"resourceType": safe_str(packet.resourceType) if hasattr(packet, "resourceType") else "",
|
| 639 |
+
"is_failed": bool(packet.is_failed) if hasattr(packet, "is_failed") else False,
|
| 640 |
+
"fail_info": safe_str(fail) if fail else "",
|
| 641 |
+
"request": {
|
| 642 |
+
"headers": req.headers if req and hasattr(req, "headers") else {},
|
| 643 |
+
"postData": req.postData if req and hasattr(req, "postData") else "",
|
| 644 |
+
},
|
| 645 |
+
"response": {
|
| 646 |
+
"status": resp.status if resp and hasattr(resp, "status") else 0,
|
| 647 |
+
"headers": resp.headers if resp and hasattr(resp, "headers") else {},
|
| 648 |
+
"raw_body": resp.raw_body if resp and hasattr(resp, "raw_body") else "",
|
| 649 |
+
},
|
| 650 |
+
}
|
| 651 |
+
f.write(json.dumps(item, ensure_ascii=False) + "\n")
|
| 652 |
+
except Exception as e:
|
| 653 |
+
f.write(json.dumps({"error": safe_str(e)}, ensure_ascii=False) + "\n")
|
| 654 |
+
except Exception:
|
| 655 |
+
pass
|
| 656 |
+
|
| 657 |
+
def _wait_for_code_input(self, page, timeout: int = 30):
|
| 658 |
+
"""等待验证码输入框出现"""
|
| 659 |
+
selectors = [
|
| 660 |
+
"css:input[jsname='ovqh0b']",
|
| 661 |
+
"css:input[type='tel']",
|
| 662 |
+
"css:input[name='pinInput']",
|
| 663 |
+
"css:input[autocomplete='one-time-code']",
|
| 664 |
+
]
|
| 665 |
+
for _ in range(timeout // 2):
|
| 666 |
+
for selector in selectors:
|
| 667 |
+
try:
|
| 668 |
+
el = page.ele(selector, timeout=1)
|
| 669 |
+
if el:
|
| 670 |
+
return el
|
| 671 |
+
except Exception:
|
| 672 |
+
continue
|
| 673 |
+
time.sleep(2)
|
| 674 |
+
return None
|
| 675 |
+
|
| 676 |
+
def _simulate_human_input(self, element, text: str) -> bool:
|
| 677 |
+
"""模拟人类输入(逐字符输入,带非均匀延迟)
|
| 678 |
+
|
| 679 |
+
Args:
|
| 680 |
+
element: 输入框元素
|
| 681 |
+
text: 要输入的文本
|
| 682 |
+
|
| 683 |
+
Returns:
|
| 684 |
+
bool: 是否成功
|
| 685 |
+
"""
|
| 686 |
+
try:
|
| 687 |
+
# 先点击输入框获取焦点
|
| 688 |
+
element.click()
|
| 689 |
+
time.sleep(random.uniform(0.2, 0.5))
|
| 690 |
+
|
| 691 |
+
# 逐字符输入,模拟真实打字节奏
|
| 692 |
+
for i, char in enumerate(text):
|
| 693 |
+
element.input(char)
|
| 694 |
+
# 基础延迟 80-180ms(正常打字速度)
|
| 695 |
+
delay = random.uniform(0.08, 0.18)
|
| 696 |
+
# 每3-5个字符偶尔有更长的停顿(模拟犹豫/看屏幕)
|
| 697 |
+
if i > 0 and random.random() < 0.2:
|
| 698 |
+
delay += random.uniform(0.2, 0.5)
|
| 699 |
+
time.sleep(delay)
|
| 700 |
+
|
| 701 |
+
# 输入完成后停顿(模拟核对)
|
| 702 |
+
time.sleep(random.uniform(0.3, 0.8))
|
| 703 |
+
return True
|
| 704 |
+
except Exception:
|
| 705 |
+
return False
|
| 706 |
+
|
| 707 |
+
def _human_click(self, page, element) -> None:
|
| 708 |
+
"""模拟人类点击:先移动鼠标到元素附近,再点击"""
|
| 709 |
+
try:
|
| 710 |
+
# 尝试用 actions 链模拟鼠标移动 + 点击
|
| 711 |
+
page.actions.move_to(element)
|
| 712 |
+
time.sleep(random.uniform(0.1, 0.3))
|
| 713 |
+
page.actions.click()
|
| 714 |
+
except Exception:
|
| 715 |
+
# 降级为直接点击
|
| 716 |
+
element.click()
|
| 717 |
+
|
| 718 |
+
def _random_scroll(self, page) -> None:
|
| 719 |
+
"""模拟真实用户的页面滚动行为"""
|
| 720 |
+
try:
|
| 721 |
+
scroll_amount = random.randint(50, 200)
|
| 722 |
+
page.run_js(f"window.scrollBy(0, {scroll_amount})")
|
| 723 |
+
time.sleep(random.uniform(0.3, 0.8))
|
| 724 |
+
# 有时候滚回去一点
|
| 725 |
+
if random.random() < 0.3:
|
| 726 |
+
page.run_js(f"window.scrollBy(0, -{random.randint(20, 80)})")
|
| 727 |
+
time.sleep(random.uniform(0.2, 0.5))
|
| 728 |
+
except Exception:
|
| 729 |
+
pass
|
| 730 |
+
|
| 731 |
+
def _find_verify_button(self, page):
|
| 732 |
+
"""查找验证按钮(排除重新发送按钮)"""
|
| 733 |
+
try:
|
| 734 |
+
buttons = page.eles("tag:button")
|
| 735 |
+
for btn in buttons:
|
| 736 |
+
text = (btn.text or "").strip().lower()
|
| 737 |
+
if text and "重新" not in text and "发送" not in text and "resend" not in text and "send" not in text:
|
| 738 |
+
return btn
|
| 739 |
+
except Exception:
|
| 740 |
+
pass
|
| 741 |
+
return None
|
| 742 |
+
|
| 743 |
+
def _click_resend_code_button(self, page) -> bool:
|
| 744 |
+
"""点击重新发送验证码按钮"""
|
| 745 |
+
time.sleep(random.uniform(1.5, 3))
|
| 746 |
+
|
| 747 |
+
# 查找包含重新发送关键词的按钮(与 _find_verify_button 相反)
|
| 748 |
+
try:
|
| 749 |
+
buttons = page.eles("tag:button")
|
| 750 |
+
for btn in buttons:
|
| 751 |
+
text = (btn.text or "").strip().lower()
|
| 752 |
+
if text and ("重新" in text or "resend" in text):
|
| 753 |
+
try:
|
| 754 |
+
self._log("info", f"🔄 点击重新发送按钮")
|
| 755 |
+
self._human_click(page, btn)
|
| 756 |
+
time.sleep(random.uniform(1.5, 3))
|
| 757 |
+
return True
|
| 758 |
+
except Exception:
|
| 759 |
+
pass
|
| 760 |
+
except Exception:
|
| 761 |
+
pass
|
| 762 |
+
|
| 763 |
+
return False
|
| 764 |
+
|
| 765 |
+
def _check_access_restricted(self, page, email: str = "") -> dict | None:
|
| 766 |
+
"""检测 403 Access Restricted 页面,返回错误 dict 或 None"""
|
| 767 |
+
domain = email.split("@")[1] if "@" in email else "unknown"
|
| 768 |
+
error_msg = f"403 域名封禁 ({domain})"
|
| 769 |
+
|
| 770 |
+
# 方法1: 搜索 h1 标签
|
| 771 |
+
try:
|
| 772 |
+
h1 = page.ele("tag:h1", timeout=2)
|
| 773 |
+
h1_text = h1.text if h1 else ""
|
| 774 |
+
if h1_text and "Access Restricted" in h1_text:
|
| 775 |
+
self._log("error", "⛔ 403 Access Restricted: email banned by Google")
|
| 776 |
+
self._log("error", f"⛔ 403 访问受限,域名 {domain} 可能已被 Google 封禁")
|
| 777 |
+
self._save_screenshot(page, "access_restricted_403")
|
| 778 |
+
return {"success": False, "error": error_msg}
|
| 779 |
+
except Exception:
|
| 780 |
+
pass
|
| 781 |
+
|
| 782 |
+
# 方法2: body 文本
|
| 783 |
+
try:
|
| 784 |
+
body = page.ele("tag:body", timeout=2)
|
| 785 |
+
body_text = (body.text or "")[:500] if body else ""
|
| 786 |
+
if "Access Restricted" in body_text:
|
| 787 |
+
self._log("error", "⛔ 403 Access Restricted: email banned by Google")
|
| 788 |
+
self._log("error", f"⛔ 403 访问受限,域名 {domain} 可能已被 Google 封禁")
|
| 789 |
+
self._save_screenshot(page, "access_restricted_403")
|
| 790 |
+
return {"success": False, "error": error_msg}
|
| 791 |
+
except Exception:
|
| 792 |
+
pass
|
| 793 |
+
|
| 794 |
+
# 方法3: page.html 源码
|
| 795 |
+
try:
|
| 796 |
+
html = (page.html or "")[:2000]
|
| 797 |
+
if "Access Restricted" in html:
|
| 798 |
+
self._log("error", "⛔ 403 Access Restricted: email banned by Google")
|
| 799 |
+
self._log("error", f"⛔ 403 访问受限,域名 {domain} 可能已被 Google 封禁")
|
| 800 |
+
self._save_screenshot(page, "access_restricted_403")
|
| 801 |
+
return {"success": False, "error": error_msg}
|
| 802 |
+
except Exception:
|
| 803 |
+
pass
|
| 804 |
+
|
| 805 |
+
return None
|
| 806 |
+
|
| 807 |
+
def _handle_agreement_page(self, page) -> None:
|
| 808 |
+
"""处理协议页面"""
|
| 809 |
+
if "/admin/create" in page.url:
|
| 810 |
+
agree_btn = page.ele("css:button.agree-button", timeout=5)
|
| 811 |
+
if agree_btn:
|
| 812 |
+
self._human_click(page, agree_btn)
|
| 813 |
+
time.sleep(random.uniform(2, 4))
|
| 814 |
+
|
| 815 |
+
def _wait_for_cid(self, page, timeout: int = 10) -> bool:
|
| 816 |
+
"""等待URL包含cid"""
|
| 817 |
+
for _ in range(timeout):
|
| 818 |
+
if "cid" in page.url:
|
| 819 |
+
return True
|
| 820 |
+
time.sleep(1)
|
| 821 |
+
return False
|
| 822 |
+
|
| 823 |
+
def _wait_for_business_params(self, page, timeout: int = 30) -> bool:
|
| 824 |
+
"""等待业务页面参数生成(csesidx 和 cid)"""
|
| 825 |
+
for _ in range(timeout):
|
| 826 |
+
url = page.url
|
| 827 |
+
if "csesidx=" in url and "/cid/" in url:
|
| 828 |
+
return True
|
| 829 |
+
time.sleep(1)
|
| 830 |
+
return False
|
| 831 |
+
|
| 832 |
+
def _handle_username_setup(self, page, is_new_account: bool = False) -> bool:
|
| 833 |
+
"""处理用户名设置页面(is_new_account=True 时启用按钮兜底和延长超时)"""
|
| 834 |
+
current_url = page.url
|
| 835 |
+
|
| 836 |
+
if "auth.business.gemini.google/login" in current_url:
|
| 837 |
+
return False
|
| 838 |
+
|
| 839 |
+
# 精准选择器(参考实际页面 DOM,优先级从高到低)
|
| 840 |
+
selectors = [
|
| 841 |
+
"css:input[formcontrolname='fullName']",
|
| 842 |
+
"css:input#mat-input-0",
|
| 843 |
+
"css:input[placeholder='全名']",
|
| 844 |
+
"css:input[placeholder='Full name']",
|
| 845 |
+
"css:input[name='displayName']",
|
| 846 |
+
"css:input[aria-label*='用户名' i]",
|
| 847 |
+
"css:input[aria-label*='display name' i]",
|
| 848 |
+
"css:input[type='text']",
|
| 849 |
+
]
|
| 850 |
+
|
| 851 |
+
# 轮询等待输入框出现(最多30秒,每秒检查一次)
|
| 852 |
+
# 与参考代码对齐:页面加载慢时不会过早放弃
|
| 853 |
+
username_input = None
|
| 854 |
+
self._log("info", "⏳ 等待用户名输入框出现(最多30秒)...")
|
| 855 |
+
for i in range(30):
|
| 856 |
+
for selector in selectors:
|
| 857 |
+
try:
|
| 858 |
+
el = page.ele(selector, timeout=1)
|
| 859 |
+
if el:
|
| 860 |
+
username_input = el
|
| 861 |
+
self._log("info", f"✅ 找到用户名输入框: {selector}")
|
| 862 |
+
break
|
| 863 |
+
except Exception:
|
| 864 |
+
continue
|
| 865 |
+
if username_input:
|
| 866 |
+
break
|
| 867 |
+
time.sleep(1)
|
| 868 |
+
|
| 869 |
+
if not username_input:
|
| 870 |
+
self._log("warning", "⚠️ 30秒内未找到用户名输入框,跳过此步骤")
|
| 871 |
+
return False
|
| 872 |
+
|
| 873 |
+
name = random.choice(REGISTER_NAMES)
|
| 874 |
+
self._log("info", f"✏️ 输入姓名: {name}")
|
| 875 |
+
|
| 876 |
+
try:
|
| 877 |
+
# 清空输入框
|
| 878 |
+
username_input.click()
|
| 879 |
+
time.sleep(random.uniform(0.2, 0.5))
|
| 880 |
+
username_input.clear()
|
| 881 |
+
time.sleep(random.uniform(0.1, 0.3))
|
| 882 |
+
|
| 883 |
+
# 尝试模拟人类输入,失败则降级到直接注入
|
| 884 |
+
if not self._simulate_human_input(username_input, name):
|
| 885 |
+
username_input.input(name)
|
| 886 |
+
time.sleep(0.3)
|
| 887 |
+
|
| 888 |
+
# 回车提交
|
| 889 |
+
username_input.input("\n")
|
| 890 |
+
|
| 891 |
+
if is_new_account:
|
| 892 |
+
# 注册专用:回车后等待1.5秒,若未跳转则用按钮兜底
|
| 893 |
+
time.sleep(random.uniform(1.5, 3))
|
| 894 |
+
if "cid" not in page.url:
|
| 895 |
+
self._log("info", "⌨️ 回车未跳转,尝试点击提交按钮...")
|
| 896 |
+
try:
|
| 897 |
+
for btn in page.eles("tag:button"):
|
| 898 |
+
try:
|
| 899 |
+
if btn.is_displayed() and btn.is_enabled():
|
| 900 |
+
btn.click()
|
| 901 |
+
self._log("info", "✅ 已点击提交按钮(兜底)")
|
| 902 |
+
time.sleep(1)
|
| 903 |
+
break
|
| 904 |
+
except Exception:
|
| 905 |
+
continue
|
| 906 |
+
except Exception as e:
|
| 907 |
+
self._log("warning", f"⚠️ 按钮兜底失败: {e}")
|
| 908 |
+
|
| 909 |
+
# 注册专用:等待45秒,失败则刷新再等15秒
|
| 910 |
+
if not self._wait_for_cid(page, timeout=45):
|
| 911 |
+
self._log("warning", "⚠️ 用户名提交后未检测到 cid 参数,尝试刷新...")
|
| 912 |
+
page.refresh()
|
| 913 |
+
time.sleep(random.uniform(2, 4))
|
| 914 |
+
if not self._wait_for_cid(page, timeout=15):
|
| 915 |
+
self._log("error", "❌ 刷新后仍未检测到 cid 参数")
|
| 916 |
+
self._save_screenshot(page, "step7_after_verify")
|
| 917 |
+
return False
|
| 918 |
+
else:
|
| 919 |
+
# 登录刷新:原有30秒逻辑
|
| 920 |
+
if not self._wait_for_cid(page, timeout=30):
|
| 921 |
+
self._log("warning", "⚠️ 用户名提交后未检测到 cid 参数")
|
| 922 |
+
return False
|
| 923 |
+
|
| 924 |
+
return True
|
| 925 |
+
except Exception as e:
|
| 926 |
+
self._log("warning", f"⚠️ 用户名设置异常: {e}")
|
| 927 |
+
return False
|
| 928 |
+
|
| 929 |
+
def _extract_config(self, page, email: str) -> dict:
|
| 930 |
+
"""提取配置(轮询等待 cookie 到位)"""
|
| 931 |
+
try:
|
| 932 |
+
if "cid/" not in page.url:
|
| 933 |
+
page.get("https://business.gemini.google/", timeout=self.timeout)
|
| 934 |
+
time.sleep(random.uniform(2, 4))
|
| 935 |
+
|
| 936 |
+
url = page.url
|
| 937 |
+
if "cid/" not in url:
|
| 938 |
+
return {"success": False, "error": "cid not found"}
|
| 939 |
+
|
| 940 |
+
config_id = url.split("cid/")[1].split("?")[0].split("/")[0]
|
| 941 |
+
csesidx = url.split("csesidx=")[1].split("&")[0] if "csesidx=" in url else ""
|
| 942 |
+
|
| 943 |
+
# 轮询等待关键 cookie 到位(最多10秒)
|
| 944 |
+
ses = None
|
| 945 |
+
host = None
|
| 946 |
+
ses_obj = None
|
| 947 |
+
for _ in range(10):
|
| 948 |
+
cookies = page.cookies()
|
| 949 |
+
ses = next((c["value"] for c in cookies if c["name"] == "__Secure-C_SES"), None)
|
| 950 |
+
host = next((c["value"] for c in cookies if c["name"] == "__Host-C_OSES"), None)
|
| 951 |
+
ses_obj = next((c for c in cookies if c["name"] == "__Secure-C_SES"), None)
|
| 952 |
+
if ses and host:
|
| 953 |
+
break
|
| 954 |
+
time.sleep(1)
|
| 955 |
+
|
| 956 |
+
if not ses or not host:
|
| 957 |
+
self._log("warning", f"⚠️ Cookie 不完整 (ses={'有' if ses else '无'}, host={'有' if host else '无'})")
|
| 958 |
+
|
| 959 |
+
# 使用北京时区,确保时间计算正确(Cookie expiry 是 UTC 时间戳)
|
| 960 |
+
beijing_tz = timezone(timedelta(hours=8))
|
| 961 |
+
if ses_obj and "expiry" in ses_obj:
|
| 962 |
+
cookie_expire_beijing = datetime.fromtimestamp(ses_obj["expiry"], tz=beijing_tz)
|
| 963 |
+
expires_at = (cookie_expire_beijing - timedelta(hours=12)).strftime("%Y-%m-%d %H:%M:%S")
|
| 964 |
+
else:
|
| 965 |
+
expires_at = (datetime.now(beijing_tz) + timedelta(hours=12)).strftime("%Y-%m-%d %H:%M:%S")
|
| 966 |
+
|
| 967 |
+
config = {
|
| 968 |
+
"id": email,
|
| 969 |
+
"csesidx": csesidx,
|
| 970 |
+
"config_id": config_id,
|
| 971 |
+
"secure_c_ses": ses,
|
| 972 |
+
"host_c_oses": host,
|
| 973 |
+
"expires_at": expires_at,
|
| 974 |
+
}
|
| 975 |
+
|
| 976 |
+
# 提取试用期信息
|
| 977 |
+
trial_end = self._extract_trial_end(page, csesidx, config_id)
|
| 978 |
+
if trial_end:
|
| 979 |
+
config["trial_end"] = trial_end
|
| 980 |
+
|
| 981 |
+
return {"success": True, "config": config}
|
| 982 |
+
except Exception as e:
|
| 983 |
+
return {"success": False, "error": str(e)}
|
| 984 |
+
|
| 985 |
+
def _extract_trial_end(self, page, csesidx: str, config_id: str) -> Optional[str]:
|
| 986 |
+
"""从页面中提取试用期到期日期,不跳转到可能 400 的深层路径"""
|
| 987 |
+
# re 已在文件顶部导入
|
| 988 |
+
try:
|
| 989 |
+
self._log("info", "📅 获取试用期信息...")
|
| 990 |
+
|
| 991 |
+
def _days_to_end_date(days: int) -> str:
|
| 992 |
+
end_date = (datetime.now(timezone(timedelta(hours=8))) + timedelta(days=days)).strftime("%Y-%m-%d")
|
| 993 |
+
self._log("info", f"📅 试用期剩余 {days} 天,到期日: {end_date}")
|
| 994 |
+
return end_date
|
| 995 |
+
|
| 996 |
+
def _search_page_source(source: str) -> Optional[str]:
|
| 997 |
+
"""在页面源码中搜索试用期信息"""
|
| 998 |
+
# 格式1: "daysLeft":29 (JSON数据)
|
| 999 |
+
m = re.search(r'"daysLeft"\s*:\s*(\d+)', source)
|
| 1000 |
+
if m:
|
| 1001 |
+
return _days_to_end_date(int(m.group(1)))
|
| 1002 |
+
# 格式2: "trialDaysRemaining":29
|
| 1003 |
+
m = re.search(r'"trialDaysRemaining"\s*:\s*(\d+)', source)
|
| 1004 |
+
if m:
|
| 1005 |
+
return _days_to_end_date(int(m.group(1)))
|
| 1006 |
+
# 格式3: 日期数组 "[2026,3,25]" 形式 (batchexecute格式)
|
| 1007 |
+
m = re.search(r'\[(\d{4}),(\d{1,2}),(\d{1,2})\].*?\[(\d{4}),(\d{1,2}),(\d{1,2})\]', source)
|
| 1008 |
+
if m:
|
| 1009 |
+
# 取第二个日期(结束日期)
|
| 1010 |
+
try:
|
| 1011 |
+
end_date = f"{m.group(4):0>4}-{int(m.group(5)):02d}-{int(m.group(6)):02d}"
|
| 1012 |
+
# 简单校验年份合理
|
| 1013 |
+
if 2025 <= int(m.group(4)) <= 2030:
|
| 1014 |
+
self._log("info", f"📅 试用期到期日: {end_date}")
|
| 1015 |
+
return end_date
|
| 1016 |
+
except Exception:
|
| 1017 |
+
pass
|
| 1018 |
+
# 格式4: "29 days left" 或 "还剩29天"
|
| 1019 |
+
m = re.search(r'(\d+)\s*days?\s*left', source, re.IGNORECASE)
|
| 1020 |
+
if m:
|
| 1021 |
+
return _days_to_end_date(int(m.group(1)))
|
| 1022 |
+
m = re.search(r'还剩\s*(\d+)\s*天', source)
|
| 1023 |
+
if m:
|
| 1024 |
+
return _days_to_end_date(int(m.group(1)))
|
| 1025 |
+
return None
|
| 1026 |
+
|
| 1027 |
+
# ——— 方式1: 当前页面(刚登录完,不需要跳转)———
|
| 1028 |
+
try:
|
| 1029 |
+
source = page.html
|
| 1030 |
+
result = _search_page_source(source or "")
|
| 1031 |
+
if result:
|
| 1032 |
+
return result
|
| 1033 |
+
except Exception:
|
| 1034 |
+
pass
|
| 1035 |
+
|
| 1036 |
+
# ——— 方式2: 跳转到 /settings(不带 billing/plans 后缀,SPA可以处理)———
|
| 1037 |
+
try:
|
| 1038 |
+
settings_url = f"https://business.gemini.google/cid/{config_id}/settings?csesidx={csesidx}"
|
| 1039 |
+
page.get(settings_url, timeout=self.timeout)
|
| 1040 |
+
time.sleep(random.uniform(1.5, 3))
|
| 1041 |
+
source = page.html
|
| 1042 |
+
result = _search_page_source(source or "")
|
| 1043 |
+
if result:
|
| 1044 |
+
return result
|
| 1045 |
+
except Exception:
|
| 1046 |
+
pass
|
| 1047 |
+
|
| 1048 |
+
# ——— 方式3: 跳转到主页(最保险)———
|
| 1049 |
+
try:
|
| 1050 |
+
main_url = f"https://business.gemini.google/cid/{config_id}?csesidx={csesidx}"
|
| 1051 |
+
page.get(main_url, timeout=self.timeout)
|
| 1052 |
+
time.sleep(random.uniform(1.5, 3))
|
| 1053 |
+
source = page.html
|
| 1054 |
+
result = _search_page_source(source or "")
|
| 1055 |
+
if result:
|
| 1056 |
+
return result
|
| 1057 |
+
except Exception:
|
| 1058 |
+
pass
|
| 1059 |
+
|
| 1060 |
+
self._log("warning", "⚠️ 未能获取试用期信息(页面中未找到相关数据)")
|
| 1061 |
+
return None
|
| 1062 |
+
except Exception as e:
|
| 1063 |
+
self._log("warning", f"⚠️ 获取试用期失败: {e}")
|
| 1064 |
+
return None
|
| 1065 |
+
|
| 1066 |
+
def _save_screenshot(self, page, name: str) -> None:
|
| 1067 |
+
"""保存截图"""
|
| 1068 |
+
try:
|
| 1069 |
+
from core.storage import _data_file_path
|
| 1070 |
+
screenshot_dir = _data_file_path("automation")
|
| 1071 |
+
os.makedirs(screenshot_dir, exist_ok=True)
|
| 1072 |
+
path = os.path.join(screenshot_dir, f"{name}_{int(time.time())}.png")
|
| 1073 |
+
page.get_screenshot(path=path)
|
| 1074 |
+
except Exception:
|
| 1075 |
+
pass
|
| 1076 |
+
|
| 1077 |
+
def _log(self, level: str, message: str) -> None:
|
| 1078 |
+
"""记录日志"""
|
| 1079 |
+
if self.log_callback:
|
| 1080 |
+
try:
|
| 1081 |
+
self.log_callback(level, message)
|
| 1082 |
+
except TaskCancelledError:
|
| 1083 |
+
raise
|
| 1084 |
+
except Exception:
|
| 1085 |
+
pass
|
| 1086 |
+
|
| 1087 |
+
def _cleanup_user_data(self, user_data_dir: Optional[str]) -> None:
|
| 1088 |
+
"""清理浏览器用户数据目录"""
|
| 1089 |
+
if not user_data_dir:
|
| 1090 |
+
return
|
| 1091 |
+
try:
|
| 1092 |
+
import shutil
|
| 1093 |
+
if os.path.exists(user_data_dir):
|
| 1094 |
+
shutil.rmtree(user_data_dir, ignore_errors=True)
|
| 1095 |
+
except Exception:
|
| 1096 |
+
pass
|
| 1097 |
+
|
| 1098 |
+
@staticmethod
|
| 1099 |
+
def _get_ua() -> str:
|
| 1100 |
+
"""生成随机User-Agent(使用当前主流 Chrome 版本)"""
|
| 1101 |
+
major = random.choice([132, 133, 134, 135])
|
| 1102 |
+
v = f"{major}.0.{random.randint(6800, 6950)}.{random.randint(50, 150)}"
|
| 1103 |
+
return f"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/{v} Safari/537.36"
|
core/google_api.py
ADDED
|
@@ -0,0 +1,339 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Google API交互模块
|
| 2 |
+
|
| 3 |
+
负责与Google Gemini Business API的所有交互操作
|
| 4 |
+
"""
|
| 5 |
+
import asyncio
|
| 6 |
+
import json
|
| 7 |
+
import logging
|
| 8 |
+
import os
|
| 9 |
+
import re
|
| 10 |
+
import time
|
| 11 |
+
import uuid
|
| 12 |
+
from datetime import datetime, timedelta, timezone
|
| 13 |
+
from typing import TYPE_CHECKING, List
|
| 14 |
+
|
| 15 |
+
import httpx
|
| 16 |
+
from fastapi import HTTPException
|
| 17 |
+
|
| 18 |
+
if TYPE_CHECKING:
|
| 19 |
+
from main import AccountManager
|
| 20 |
+
|
| 21 |
+
logger = logging.getLogger(__name__)
|
| 22 |
+
|
| 23 |
+
# Google API 基础URL
|
| 24 |
+
GEMINI_API_BASE = "https://biz-discoveryengine.googleapis.com/v1alpha"
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def get_common_headers(jwt: str, user_agent: str) -> dict:
|
| 29 |
+
"""生成通用请求头"""
|
| 30 |
+
return {
|
| 31 |
+
"accept": "*/*",
|
| 32 |
+
"accept-encoding": "gzip, deflate, br, zstd",
|
| 33 |
+
"accept-language": "zh-CN,zh;q=0.9,en;q=0.8",
|
| 34 |
+
"authorization": f"Bearer {jwt}",
|
| 35 |
+
"content-type": "application/json",
|
| 36 |
+
"origin": "https://business.gemini.google",
|
| 37 |
+
"referer": "https://business.gemini.google/",
|
| 38 |
+
"user-agent": user_agent,
|
| 39 |
+
"x-server-timeout": "1800",
|
| 40 |
+
"sec-ch-ua": '"Chromium";v="124", "Google Chrome";v="124", "Not-A.Brand";v="99"',
|
| 41 |
+
"sec-ch-ua-mobile": "?0",
|
| 42 |
+
"sec-ch-ua-platform": '"Windows"',
|
| 43 |
+
"sec-fetch-dest": "empty",
|
| 44 |
+
"sec-fetch-mode": "cors",
|
| 45 |
+
"sec-fetch-site": "cross-site",
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
async def make_request_with_jwt_retry(
|
| 50 |
+
account_mgr: "AccountManager",
|
| 51 |
+
method: str,
|
| 52 |
+
url: str,
|
| 53 |
+
http_client: httpx.AsyncClient,
|
| 54 |
+
user_agent: str,
|
| 55 |
+
request_id: str = "",
|
| 56 |
+
**kwargs
|
| 57 |
+
) -> httpx.Response:
|
| 58 |
+
"""通用HTTP请求,自动处理JWT过期重试
|
| 59 |
+
|
| 60 |
+
Args:
|
| 61 |
+
account_mgr: AccountManager实例
|
| 62 |
+
method: HTTP方法 (GET/POST)
|
| 63 |
+
url: 请求URL
|
| 64 |
+
http_client: httpx客户端
|
| 65 |
+
user_agent: User-Agent字符串
|
| 66 |
+
request_id: 请求ID(用于日志)
|
| 67 |
+
**kwargs: 传递给httpx的其他参数(如json, headers等)
|
| 68 |
+
|
| 69 |
+
Returns:
|
| 70 |
+
httpx.Response对象
|
| 71 |
+
"""
|
| 72 |
+
jwt = await account_mgr.get_jwt(request_id)
|
| 73 |
+
headers = get_common_headers(jwt, user_agent)
|
| 74 |
+
|
| 75 |
+
# 合并用户提供的headers(如果有)
|
| 76 |
+
extra_headers = kwargs.pop("headers", None)
|
| 77 |
+
if extra_headers:
|
| 78 |
+
headers.update(extra_headers)
|
| 79 |
+
|
| 80 |
+
# 提取timeout参数(如果有),单独传给httpx
|
| 81 |
+
req_timeout = kwargs.pop("timeout", None)
|
| 82 |
+
|
| 83 |
+
# 发起请求
|
| 84 |
+
req_kwargs = {**kwargs}
|
| 85 |
+
if req_timeout is not None:
|
| 86 |
+
req_kwargs["timeout"] = req_timeout
|
| 87 |
+
|
| 88 |
+
if method.upper() == "GET":
|
| 89 |
+
resp = await http_client.get(url, headers=headers, **req_kwargs)
|
| 90 |
+
elif method.upper() == "POST":
|
| 91 |
+
resp = await http_client.post(url, headers=headers, **req_kwargs)
|
| 92 |
+
else:
|
| 93 |
+
raise ValueError(f"Unsupported HTTP method: {method}")
|
| 94 |
+
|
| 95 |
+
# 如果401,刷新JWT后重试一次
|
| 96 |
+
if resp.status_code == 401:
|
| 97 |
+
jwt = await account_mgr.get_jwt(request_id)
|
| 98 |
+
headers = get_common_headers(jwt, user_agent)
|
| 99 |
+
if extra_headers:
|
| 100 |
+
headers.update(extra_headers)
|
| 101 |
+
|
| 102 |
+
if method.upper() == "GET":
|
| 103 |
+
resp = await http_client.get(url, headers=headers, **req_kwargs)
|
| 104 |
+
elif method.upper() == "POST":
|
| 105 |
+
resp = await http_client.post(url, headers=headers, **req_kwargs)
|
| 106 |
+
|
| 107 |
+
return resp
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
async def create_google_session(
|
| 111 |
+
account_manager: "AccountManager",
|
| 112 |
+
http_client: httpx.AsyncClient,
|
| 113 |
+
user_agent: str,
|
| 114 |
+
request_id: str = ""
|
| 115 |
+
) -> str:
|
| 116 |
+
"""创建Google Session"""
|
| 117 |
+
jwt = await account_manager.get_jwt(request_id)
|
| 118 |
+
headers = get_common_headers(jwt, user_agent)
|
| 119 |
+
body = {
|
| 120 |
+
"configId": account_manager.config.config_id,
|
| 121 |
+
"additionalParams": {"token": "-"},
|
| 122 |
+
"createSessionRequest": {
|
| 123 |
+
"session": {"name": "", "displayName": ""}
|
| 124 |
+
}
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
req_tag = f"[req_{request_id}] " if request_id else ""
|
| 128 |
+
r = await http_client.post(
|
| 129 |
+
f"{GEMINI_API_BASE}/locations/global/widgetCreateSession",
|
| 130 |
+
headers=headers,
|
| 131 |
+
json=body,
|
| 132 |
+
timeout=30.0,
|
| 133 |
+
)
|
| 134 |
+
if r.status_code != 200:
|
| 135 |
+
logger.error(f"[SESSION] [{account_manager.config.account_id}] {req_tag}Session 创建失败: {r.status_code}")
|
| 136 |
+
raise HTTPException(r.status_code, "createSession failed")
|
| 137 |
+
sess_name = r.json()["session"]["name"]
|
| 138 |
+
logger.info(f"[SESSION] [{account_manager.config.account_id}] {req_tag}创建成功: {sess_name[-12:]}")
|
| 139 |
+
return sess_name
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
async def upload_context_file(
|
| 143 |
+
session_name: str,
|
| 144 |
+
mime_type: str,
|
| 145 |
+
base64_content: str,
|
| 146 |
+
account_manager: "AccountManager",
|
| 147 |
+
http_client: httpx.AsyncClient,
|
| 148 |
+
user_agent: str,
|
| 149 |
+
request_id: str = ""
|
| 150 |
+
) -> str:
|
| 151 |
+
"""上传文件到指定 Session,返回 fileId"""
|
| 152 |
+
jwt = await account_manager.get_jwt(request_id)
|
| 153 |
+
headers = get_common_headers(jwt, user_agent)
|
| 154 |
+
|
| 155 |
+
# 生成随机文件名
|
| 156 |
+
ext = mime_type.split('/')[-1] if '/' in mime_type else "bin"
|
| 157 |
+
file_name = f"upload_{int(time.time())}_{uuid.uuid4().hex[:6]}.{ext}"
|
| 158 |
+
|
| 159 |
+
body = {
|
| 160 |
+
"configId": account_manager.config.config_id,
|
| 161 |
+
"additionalParams": {"token": "-"},
|
| 162 |
+
"addContextFileRequest": {
|
| 163 |
+
"name": session_name,
|
| 164 |
+
"fileName": file_name,
|
| 165 |
+
"mimeType": mime_type,
|
| 166 |
+
"fileContents": base64_content
|
| 167 |
+
}
|
| 168 |
+
}
|
| 169 |
+
|
| 170 |
+
r = await http_client.post(
|
| 171 |
+
f"{GEMINI_API_BASE}/locations/global/widgetAddContextFile",
|
| 172 |
+
headers=headers,
|
| 173 |
+
json=body,
|
| 174 |
+
timeout=60.0,
|
| 175 |
+
)
|
| 176 |
+
|
| 177 |
+
req_tag = f"[req_{request_id}] " if request_id else ""
|
| 178 |
+
if r.status_code != 200:
|
| 179 |
+
logger.error(f"[FILE] [{account_manager.config.account_id}] {req_tag}文件上传失败: {r.status_code}")
|
| 180 |
+
error_text = r.text
|
| 181 |
+
if r.status_code == 400:
|
| 182 |
+
try:
|
| 183 |
+
payload = json.loads(r.text or "{}")
|
| 184 |
+
message = payload.get("error", {}).get("message", "")
|
| 185 |
+
except Exception:
|
| 186 |
+
message = ""
|
| 187 |
+
if "Unsupported file type" in message:
|
| 188 |
+
mime_type = message.split("Unsupported file type:", 1)[-1].strip()
|
| 189 |
+
hint = f"不支持的文件类型: {mime_type}。请转换为 PDF、图片或纯文本后再上传。"
|
| 190 |
+
raise HTTPException(400, hint)
|
| 191 |
+
raise HTTPException(r.status_code, f"Upload failed: {error_text}")
|
| 192 |
+
|
| 193 |
+
data = r.json()
|
| 194 |
+
file_id = data.get("addContextFileResponse", {}).get("fileId")
|
| 195 |
+
logger.info(f"[FILE] [{account_manager.config.account_id}] {req_tag}文件上传成功: {mime_type}")
|
| 196 |
+
return file_id
|
| 197 |
+
|
| 198 |
+
|
| 199 |
+
async def get_session_file_metadata(
|
| 200 |
+
account_mgr: "AccountManager",
|
| 201 |
+
session_name: str,
|
| 202 |
+
http_client: httpx.AsyncClient,
|
| 203 |
+
user_agent: str,
|
| 204 |
+
request_id: str = ""
|
| 205 |
+
) -> dict:
|
| 206 |
+
"""获取session中的文件元数据,包括正确的session路径"""
|
| 207 |
+
body = {
|
| 208 |
+
"configId": account_mgr.config.config_id,
|
| 209 |
+
"additionalParams": {"token": "-"},
|
| 210 |
+
"listSessionFileMetadataRequest": {
|
| 211 |
+
"name": session_name,
|
| 212 |
+
"filter": "file_origin_type = AI_GENERATED"
|
| 213 |
+
}
|
| 214 |
+
}
|
| 215 |
+
|
| 216 |
+
resp = await make_request_with_jwt_retry(
|
| 217 |
+
account_mgr,
|
| 218 |
+
"POST",
|
| 219 |
+
f"{GEMINI_API_BASE}/locations/global/widgetListSessionFileMetadata",
|
| 220 |
+
http_client,
|
| 221 |
+
user_agent,
|
| 222 |
+
request_id,
|
| 223 |
+
json=body,
|
| 224 |
+
timeout=30.0
|
| 225 |
+
)
|
| 226 |
+
|
| 227 |
+
if resp.status_code != 200:
|
| 228 |
+
logger.warning(f"[IMAGE] [{account_mgr.config.account_id}] [req_{request_id}] 获取文件元数据失败: {resp.status_code}")
|
| 229 |
+
return {}
|
| 230 |
+
|
| 231 |
+
data = resp.json()
|
| 232 |
+
result = {}
|
| 233 |
+
file_metadata_list = data.get("listSessionFileMetadataResponse", {}).get("fileMetadata", [])
|
| 234 |
+
|
| 235 |
+
for fm in file_metadata_list:
|
| 236 |
+
fid = fm.get("fileId")
|
| 237 |
+
if fid:
|
| 238 |
+
result[fid] = fm
|
| 239 |
+
|
| 240 |
+
return result
|
| 241 |
+
|
| 242 |
+
|
| 243 |
+
def build_image_download_url(session_name: str, file_id: str) -> str:
|
| 244 |
+
"""构造图片下载URL"""
|
| 245 |
+
return f"{GEMINI_API_BASE}/{session_name}:downloadFile?fileId={file_id}&alt=media"
|
| 246 |
+
|
| 247 |
+
|
| 248 |
+
async def download_image_with_jwt(
|
| 249 |
+
account_mgr: "AccountManager",
|
| 250 |
+
session_name: str,
|
| 251 |
+
file_id: str,
|
| 252 |
+
http_client: httpx.AsyncClient,
|
| 253 |
+
user_agent: str,
|
| 254 |
+
request_id: str = "",
|
| 255 |
+
max_retries: int = 3
|
| 256 |
+
) -> bytes:
|
| 257 |
+
"""
|
| 258 |
+
使用JWT认证下载图片(带超时和重试机制)
|
| 259 |
+
|
| 260 |
+
Args:
|
| 261 |
+
account_mgr: 账户管理器
|
| 262 |
+
session_name: Session名称
|
| 263 |
+
file_id: 文件ID
|
| 264 |
+
http_client: httpx客户端
|
| 265 |
+
user_agent: User-Agent字符串
|
| 266 |
+
request_id: 请求ID
|
| 267 |
+
max_retries: 最大重试次数(默认3次)
|
| 268 |
+
|
| 269 |
+
Returns:
|
| 270 |
+
图片字节数据
|
| 271 |
+
|
| 272 |
+
Raises:
|
| 273 |
+
HTTPException: 下载失败
|
| 274 |
+
asyncio.TimeoutError: 超时
|
| 275 |
+
"""
|
| 276 |
+
url = build_image_download_url(session_name, file_id)
|
| 277 |
+
logger.info(f"[IMAGE] [{account_mgr.config.account_id}] [req_{request_id}] 开始下载图片: {file_id[:8]}...")
|
| 278 |
+
|
| 279 |
+
for attempt in range(max_retries):
|
| 280 |
+
try:
|
| 281 |
+
# 3分钟超时(180秒)- 使用 wait_for 兼容 Python 3.10
|
| 282 |
+
resp = await asyncio.wait_for(
|
| 283 |
+
make_request_with_jwt_retry(
|
| 284 |
+
account_mgr,
|
| 285 |
+
"GET",
|
| 286 |
+
url,
|
| 287 |
+
http_client,
|
| 288 |
+
user_agent,
|
| 289 |
+
request_id,
|
| 290 |
+
follow_redirects=True
|
| 291 |
+
),
|
| 292 |
+
timeout=180
|
| 293 |
+
)
|
| 294 |
+
|
| 295 |
+
resp.raise_for_status()
|
| 296 |
+
logger.info(f"[IMAGE] [{account_mgr.config.account_id}] [req_{request_id}] 图片下载成功: {file_id[:8]}... ({len(resp.content)} bytes)")
|
| 297 |
+
return resp.content
|
| 298 |
+
|
| 299 |
+
except asyncio.TimeoutError:
|
| 300 |
+
logger.warning(f"[IMAGE] [{account_mgr.config.account_id}] [req_{request_id}] 图片下载超时 (尝试 {attempt + 1}/{max_retries}): {file_id[:8]}...")
|
| 301 |
+
if attempt == max_retries - 1:
|
| 302 |
+
raise HTTPException(504, f"Image download timeout after {max_retries} attempts")
|
| 303 |
+
await asyncio.sleep(2 ** attempt) # 指数退避:2s, 4s, 8s
|
| 304 |
+
|
| 305 |
+
except httpx.HTTPError as e:
|
| 306 |
+
logger.warning(f"[IMAGE] [{account_mgr.config.account_id}] [req_{request_id}] 图片下载失败 (尝试 {attempt + 1}/{max_retries}): {type(e).__name__}")
|
| 307 |
+
if attempt == max_retries - 1:
|
| 308 |
+
raise HTTPException(500, f"Image download failed: {str(e)[:100]}")
|
| 309 |
+
await asyncio.sleep(2 ** attempt) # 指数退避
|
| 310 |
+
|
| 311 |
+
except Exception as e:
|
| 312 |
+
logger.error(f"[IMAGE] [{account_mgr.config.account_id}] [req_{request_id}] 图片下载异常: {type(e).__name__}: {str(e)[:100]}")
|
| 313 |
+
raise
|
| 314 |
+
|
| 315 |
+
# 不应该到达这里
|
| 316 |
+
raise HTTPException(500, "Image download failed unexpectedly")
|
| 317 |
+
|
| 318 |
+
|
| 319 |
+
def save_image_to_hf(image_data: bytes, chat_id: str, file_id: str, mime_type: str, base_url: str, image_dir: str, url_path: str = "images") -> str:
|
| 320 |
+
"""保存图片到持久化存储,返回完整的公开URL"""
|
| 321 |
+
ext_map = {
|
| 322 |
+
"image/png": ".png",
|
| 323 |
+
"image/jpeg": ".jpg",
|
| 324 |
+
"image/gif": ".gif",
|
| 325 |
+
"image/webp": ".webp",
|
| 326 |
+
"video/mp4": ".mp4",
|
| 327 |
+
"video/webm": ".webm",
|
| 328 |
+
"video/quicktime": ".mov"
|
| 329 |
+
}
|
| 330 |
+
ext = ext_map.get(mime_type, ".png")
|
| 331 |
+
|
| 332 |
+
filename = f"{chat_id}_{file_id}{ext}"
|
| 333 |
+
save_path = os.path.join(image_dir, filename)
|
| 334 |
+
|
| 335 |
+
# 目录已在启动时创建,无需重复创建
|
| 336 |
+
with open(save_path, "wb") as f:
|
| 337 |
+
f.write(image_data)
|
| 338 |
+
|
| 339 |
+
return f"{base_url}/{url_path}/{filename}"
|
core/gptmail_client.py
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import random
|
| 3 |
+
import string
|
| 4 |
+
import time
|
| 5 |
+
from datetime import datetime
|
| 6 |
+
from typing import Any, Dict, List, Optional
|
| 7 |
+
|
| 8 |
+
import requests
|
| 9 |
+
|
| 10 |
+
from core.mail_utils import extract_verification_code
|
| 11 |
+
from core.proxy_utils import request_with_proxy_fallback
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class GPTMailClient:
|
| 15 |
+
"""GPTMail 临时邮箱客户端"""
|
| 16 |
+
|
| 17 |
+
def __init__(
|
| 18 |
+
self,
|
| 19 |
+
base_url: str = "https://mail.chatgpt.org.uk",
|
| 20 |
+
proxy: str = "",
|
| 21 |
+
verify_ssl: bool = True,
|
| 22 |
+
api_key: str = "",
|
| 23 |
+
domain: str = "",
|
| 24 |
+
log_callback=None,
|
| 25 |
+
) -> None:
|
| 26 |
+
self.base_url = (base_url or "").rstrip("/")
|
| 27 |
+
self.verify_ssl = verify_ssl
|
| 28 |
+
self.proxy_url = (proxy or "").strip()
|
| 29 |
+
self.api_key = (api_key or "").strip()
|
| 30 |
+
self.domain = (domain or "").strip()
|
| 31 |
+
self.log_callback = log_callback
|
| 32 |
+
|
| 33 |
+
self.email: Optional[str] = None
|
| 34 |
+
|
| 35 |
+
def set_credentials(self, email: str, password: Optional[str] = None) -> None:
|
| 36 |
+
self.email = email
|
| 37 |
+
|
| 38 |
+
def _log(self, level: str, message: str) -> None:
|
| 39 |
+
if self.log_callback:
|
| 40 |
+
try:
|
| 41 |
+
self.log_callback(level, message)
|
| 42 |
+
except Exception:
|
| 43 |
+
pass
|
| 44 |
+
|
| 45 |
+
def _request(self, method: str, url: str, **kwargs) -> requests.Response:
|
| 46 |
+
headers = kwargs.pop("headers", None) or {}
|
| 47 |
+
if self.api_key and "X-API-Key" not in headers:
|
| 48 |
+
headers["X-API-Key"] = self.api_key
|
| 49 |
+
kwargs["headers"] = headers
|
| 50 |
+
|
| 51 |
+
self._log("info", f"📤 发送 {method} 请求: {url}")
|
| 52 |
+
if "params" in kwargs and kwargs["params"]:
|
| 53 |
+
self._log("info", f"🔎 Query: {kwargs['params']}")
|
| 54 |
+
if "json" in kwargs and kwargs["json"] is not None:
|
| 55 |
+
self._log("info", f"📦 请求体: {kwargs['json']}")
|
| 56 |
+
|
| 57 |
+
proxies = {"http": self.proxy_url, "https": self.proxy_url} if self.proxy_url else None
|
| 58 |
+
|
| 59 |
+
res = request_with_proxy_fallback(
|
| 60 |
+
requests.request,
|
| 61 |
+
method,
|
| 62 |
+
url,
|
| 63 |
+
proxies=proxies,
|
| 64 |
+
verify=self.verify_ssl,
|
| 65 |
+
timeout=kwargs.pop("timeout", 15),
|
| 66 |
+
**kwargs,
|
| 67 |
+
)
|
| 68 |
+
self._log("info", f"📥 收到响应: HTTP {res.status_code}")
|
| 69 |
+
log_body = os.getenv("GPTMAIL_LOG_BODY", "").strip().lower() in ("1", "true", "yes", "y", "on")
|
| 70 |
+
if res.content and (log_body or res.status_code >= 400):
|
| 71 |
+
try:
|
| 72 |
+
self._log("info", f"📄 响应内容: {res.text[:500]}")
|
| 73 |
+
except Exception:
|
| 74 |
+
pass
|
| 75 |
+
return res
|
| 76 |
+
|
| 77 |
+
def generate_email(self, domain: Optional[str] = None) -> Optional[str]:
|
| 78 |
+
"""生成一个新的邮箱地址。"""
|
| 79 |
+
if not self.base_url:
|
| 80 |
+
self._log("error", "❌ GPTMail base_url 为空")
|
| 81 |
+
return None
|
| 82 |
+
|
| 83 |
+
rand = "".join(random.choices(string.ascii_lowercase + string.digits, k=10))
|
| 84 |
+
timestamp = str(int(time.time()))[-4:]
|
| 85 |
+
prefix = f"t{timestamp}{rand}"
|
| 86 |
+
|
| 87 |
+
payload: Dict[str, Any] = {"prefix": prefix}
|
| 88 |
+
# 优先使用传入的 domain,其次使用配置的 domain
|
| 89 |
+
effective_domain = domain or self.domain
|
| 90 |
+
if effective_domain:
|
| 91 |
+
payload["domain"] = effective_domain
|
| 92 |
+
|
| 93 |
+
url = f"{self.base_url}/api/generate-email"
|
| 94 |
+
try:
|
| 95 |
+
res = self._request("POST", url, json=payload)
|
| 96 |
+
if res.status_code != 200:
|
| 97 |
+
self._log("error", f"❌ 生成邮箱失败: HTTP {res.status_code}")
|
| 98 |
+
return None
|
| 99 |
+
body = res.json() if res.content else {}
|
| 100 |
+
if not body.get("success"):
|
| 101 |
+
self._log("error", f"❌ 生成邮箱失败: {body.get('error') or 'unknown error'}")
|
| 102 |
+
return None
|
| 103 |
+
email = ((body.get("data") or {}).get("email") or "").strip()
|
| 104 |
+
if not email:
|
| 105 |
+
self._log("error", "❌ 生成邮箱成功但响应缺少 email")
|
| 106 |
+
return None
|
| 107 |
+
self.email = email
|
| 108 |
+
self._log("info", f"✅ GPTMail 邮箱生成成功: {email}")
|
| 109 |
+
return email
|
| 110 |
+
except Exception as exc:
|
| 111 |
+
self._log("error", f"❌ 生成邮箱异常: {exc}")
|
| 112 |
+
return None
|
| 113 |
+
|
| 114 |
+
def register_account(self, domain: Optional[str] = None) -> bool:
|
| 115 |
+
"""生成一个新的邮箱地址并视为注册成功。"""
|
| 116 |
+
return bool(self.generate_email(domain=domain))
|
| 117 |
+
|
| 118 |
+
def _list_emails(self, email: str) -> List[Dict[str, Any]]:
|
| 119 |
+
url = f"{self.base_url}/api/emails"
|
| 120 |
+
res = self._request("GET", url, params={"email": email})
|
| 121 |
+
if res.status_code != 200:
|
| 122 |
+
self._log("error", f"❌ 获取邮件列表失败: HTTP {res.status_code}")
|
| 123 |
+
return []
|
| 124 |
+
body = res.json() if res.content else {}
|
| 125 |
+
if not body.get("success"):
|
| 126 |
+
self._log("error", f"❌ 获取邮件列表失败: {body.get('error') or 'unknown error'}")
|
| 127 |
+
return []
|
| 128 |
+
return list(((body.get("data") or {}).get("emails") or []))
|
| 129 |
+
|
| 130 |
+
def _get_email(self, mail_id: str) -> Optional[Dict[str, Any]]:
|
| 131 |
+
url = f"{self.base_url}/api/email/{mail_id}"
|
| 132 |
+
res = self._request("GET", url)
|
| 133 |
+
if res.status_code != 200:
|
| 134 |
+
self._log("warning", f"⚠️ 获取邮件详情失败: HTTP {res.status_code}")
|
| 135 |
+
return None
|
| 136 |
+
body = res.json() if res.content else {}
|
| 137 |
+
if not body.get("success"):
|
| 138 |
+
self._log("warning", f"⚠️ 获取邮件详情失败: {body.get('error') or 'unknown error'}")
|
| 139 |
+
return None
|
| 140 |
+
return body.get("data") or None
|
| 141 |
+
|
| 142 |
+
def fetch_verification_code(self, since_time: Optional[datetime] = None) -> Optional[str]:
|
| 143 |
+
"""获取验证码(从邮件内容提取)。"""
|
| 144 |
+
if not self.email:
|
| 145 |
+
return None
|
| 146 |
+
|
| 147 |
+
try:
|
| 148 |
+
self._log("info", "📬 正在拉取 GPTMail 邮件列表...")
|
| 149 |
+
emails = self._list_emails(self.email)
|
| 150 |
+
if not emails:
|
| 151 |
+
self._log("info", "📭 邮箱为空,暂无邮件")
|
| 152 |
+
return None
|
| 153 |
+
|
| 154 |
+
emails = sorted(emails, key=lambda item: int(item.get("timestamp") or 0), reverse=True)
|
| 155 |
+
self._log("info", f"📨 收到 {len(emails)} 封邮件,开始检查验证码...")
|
| 156 |
+
|
| 157 |
+
for msg in emails:
|
| 158 |
+
msg_id = str(msg.get("id") or "").strip()
|
| 159 |
+
if not msg_id:
|
| 160 |
+
continue
|
| 161 |
+
|
| 162 |
+
ts = msg.get("timestamp")
|
| 163 |
+
if since_time and ts:
|
| 164 |
+
try:
|
| 165 |
+
msg_time = datetime.fromtimestamp(int(ts)).astimezone().replace(tzinfo=None)
|
| 166 |
+
if msg_time < since_time:
|
| 167 |
+
continue
|
| 168 |
+
except Exception:
|
| 169 |
+
pass
|
| 170 |
+
|
| 171 |
+
content = (msg.get("content") or "") + (msg.get("html_content") or "")
|
| 172 |
+
code = extract_verification_code(content)
|
| 173 |
+
if code:
|
| 174 |
+
self._log("info", f"✅ 找到验证码: {code}")
|
| 175 |
+
return code
|
| 176 |
+
|
| 177 |
+
detail = self._get_email(msg_id)
|
| 178 |
+
if not detail:
|
| 179 |
+
continue
|
| 180 |
+
|
| 181 |
+
detail_text = (
|
| 182 |
+
(detail.get("content") or "")
|
| 183 |
+
+ (detail.get("html_content") or "")
|
| 184 |
+
+ (detail.get("raw_content") or "")
|
| 185 |
+
)
|
| 186 |
+
code = extract_verification_code(detail_text)
|
| 187 |
+
if code:
|
| 188 |
+
self._log("info", f"✅ 找到验证码: {code}")
|
| 189 |
+
return code
|
| 190 |
+
|
| 191 |
+
self._log("warning", "⚠️ 所有邮件中均未找到验证码")
|
| 192 |
+
return None
|
| 193 |
+
except Exception as exc:
|
| 194 |
+
self._log("error", f"❌ 获取验证码异常: {exc}")
|
| 195 |
+
return None
|
| 196 |
+
|
| 197 |
+
def poll_for_code(
|
| 198 |
+
self,
|
| 199 |
+
timeout: int = 120,
|
| 200 |
+
interval: int = 4,
|
| 201 |
+
since_time: Optional[datetime] = None,
|
| 202 |
+
) -> Optional[str]:
|
| 203 |
+
if not self.email:
|
| 204 |
+
return None
|
| 205 |
+
|
| 206 |
+
max_retries = max(1, timeout // interval)
|
| 207 |
+
self._log("info", f"⏱️ 开始轮询验证码 (超时 {timeout}秒, 间隔 {interval}秒, 最多 {max_retries} 次)")
|
| 208 |
+
|
| 209 |
+
for i in range(1, max_retries + 1):
|
| 210 |
+
self._log("info", f"🔄 第 {i}/{max_retries} 次轮询...")
|
| 211 |
+
code = self.fetch_verification_code(since_time=since_time)
|
| 212 |
+
if code:
|
| 213 |
+
self._log("info", f"🎉 验证码获取成功: {code}")
|
| 214 |
+
return code
|
| 215 |
+
if i < max_retries:
|
| 216 |
+
time.sleep(interval)
|
| 217 |
+
|
| 218 |
+
self._log("error", "❌ 验证码获取超时")
|
| 219 |
+
return None
|
core/jwt.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""JWT管理模块
|
| 2 |
+
|
| 3 |
+
负责JWT token的生成、刷新和管理
|
| 4 |
+
"""
|
| 5 |
+
import asyncio
|
| 6 |
+
import base64
|
| 7 |
+
import hashlib
|
| 8 |
+
import hmac
|
| 9 |
+
import json
|
| 10 |
+
import logging
|
| 11 |
+
import time
|
| 12 |
+
from typing import TYPE_CHECKING
|
| 13 |
+
|
| 14 |
+
import httpx
|
| 15 |
+
from fastapi import HTTPException
|
| 16 |
+
|
| 17 |
+
if TYPE_CHECKING:
|
| 18 |
+
from main import AccountConfig
|
| 19 |
+
|
| 20 |
+
logger = logging.getLogger(__name__)
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def urlsafe_b64encode(data: bytes) -> str:
|
| 24 |
+
return base64.urlsafe_b64encode(data).decode().rstrip("=")
|
| 25 |
+
|
| 26 |
+
def kq_encode(s: str) -> str:
|
| 27 |
+
b = bytearray()
|
| 28 |
+
for ch in s:
|
| 29 |
+
v = ord(ch)
|
| 30 |
+
if v > 255:
|
| 31 |
+
b.append(v & 255)
|
| 32 |
+
b.append(v >> 8)
|
| 33 |
+
else:
|
| 34 |
+
b.append(v)
|
| 35 |
+
return urlsafe_b64encode(bytes(b))
|
| 36 |
+
|
| 37 |
+
def create_jwt(key_bytes: bytes, key_id: str, csesidx: str) -> str:
|
| 38 |
+
now = int(time.time())
|
| 39 |
+
header = {"alg": "HS256", "typ": "JWT", "kid": key_id}
|
| 40 |
+
payload = {
|
| 41 |
+
"iss": "https://business.gemini.google",
|
| 42 |
+
"aud": "https://biz-discoveryengine.googleapis.com",
|
| 43 |
+
"sub": f"csesidx/{csesidx}",
|
| 44 |
+
"iat": now,
|
| 45 |
+
"exp": now + 300,
|
| 46 |
+
"nbf": now,
|
| 47 |
+
}
|
| 48 |
+
header_b64 = kq_encode(json.dumps(header, separators=(",", ":")))
|
| 49 |
+
payload_b64 = kq_encode(json.dumps(payload, separators=(",", ":")))
|
| 50 |
+
message = f"{header_b64}.{payload_b64}"
|
| 51 |
+
sig = hmac.new(key_bytes, message.encode(), hashlib.sha256).digest()
|
| 52 |
+
return f"{message}.{urlsafe_b64encode(sig)}"
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
class JWTManager:
|
| 56 |
+
"""JWT token管理器
|
| 57 |
+
|
| 58 |
+
负责JWT的获取、刷新和缓存
|
| 59 |
+
"""
|
| 60 |
+
def __init__(self, config: "AccountConfig", http_client: httpx.AsyncClient, user_agent: str) -> None:
|
| 61 |
+
self.config = config
|
| 62 |
+
self.http_client = http_client
|
| 63 |
+
self.user_agent = user_agent
|
| 64 |
+
self.jwt: str = ""
|
| 65 |
+
self.expires: float = 0
|
| 66 |
+
self._lock = asyncio.Lock()
|
| 67 |
+
|
| 68 |
+
async def get(self, request_id: str = "") -> str:
|
| 69 |
+
"""获取JWT token(自动刷新)"""
|
| 70 |
+
async with self._lock:
|
| 71 |
+
if time.time() > self.expires:
|
| 72 |
+
await self._refresh(request_id)
|
| 73 |
+
return self.jwt
|
| 74 |
+
|
| 75 |
+
async def _refresh(self, request_id: str = "") -> None:
|
| 76 |
+
"""刷新JWT token"""
|
| 77 |
+
cookie = f"__Secure-C_SES={self.config.secure_c_ses}"
|
| 78 |
+
if self.config.host_c_oses:
|
| 79 |
+
cookie += f"; __Host-C_OSES={self.config.host_c_oses}"
|
| 80 |
+
|
| 81 |
+
req_tag = f"[req_{request_id}] " if request_id else ""
|
| 82 |
+
r = await self.http_client.get(
|
| 83 |
+
"https://business.gemini.google/auth/getoxsrf",
|
| 84 |
+
params={"csesidx": self.config.csesidx},
|
| 85 |
+
headers={
|
| 86 |
+
"cookie": cookie,
|
| 87 |
+
"user-agent": self.user_agent,
|
| 88 |
+
"referer": "https://business.gemini.google/"
|
| 89 |
+
},
|
| 90 |
+
)
|
| 91 |
+
if r.status_code != 200:
|
| 92 |
+
error_body = r.text[:200] if r.text else ""
|
| 93 |
+
logger.error(f"[AUTH] [{self.config.account_id}] {req_tag}JWT 刷新失败: {r.status_code} {error_body}")
|
| 94 |
+
raise HTTPException(r.status_code, f"getoxsrf failed: {error_body}")
|
| 95 |
+
|
| 96 |
+
txt = r.text[4:] if r.text.startswith(")]}'") else r.text
|
| 97 |
+
data = json.loads(txt)
|
| 98 |
+
|
| 99 |
+
key_bytes = base64.urlsafe_b64decode(data["xsrfToken"] + "==")
|
| 100 |
+
self.jwt = create_jwt(key_bytes, data["keyId"], self.config.csesidx)
|
| 101 |
+
self.expires = time.time() + 270
|
| 102 |
+
logger.info(f"[AUTH] [{self.config.account_id}] {req_tag}JWT 刷新成功")
|
core/login_service.py
ADDED
|
@@ -0,0 +1,557 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import asyncio
|
| 2 |
+
import logging
|
| 3 |
+
import os
|
| 4 |
+
import time
|
| 5 |
+
import uuid
|
| 6 |
+
from dataclasses import dataclass, field
|
| 7 |
+
from datetime import datetime, timedelta, timezone
|
| 8 |
+
from typing import Any, Callable, Dict, List, Optional
|
| 9 |
+
|
| 10 |
+
from core.account import load_accounts_from_source
|
| 11 |
+
from core.base_task_service import BaseTask, BaseTaskService, TaskCancelledError, TaskStatus
|
| 12 |
+
from core.config import config
|
| 13 |
+
from core.mail_providers import create_temp_mail_client
|
| 14 |
+
from core.gemini_automation import GeminiAutomation
|
| 15 |
+
from core.microsoft_mail_client import MicrosoftMailClient
|
| 16 |
+
from core.proxy_utils import parse_proxy_setting
|
| 17 |
+
|
| 18 |
+
logger = logging.getLogger("gemini.login")
|
| 19 |
+
|
| 20 |
+
# 常量定义
|
| 21 |
+
CONFIG_CHECK_INTERVAL_SECONDS = 60 # 配置检查间隔(秒)
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
@dataclass
|
| 25 |
+
class LoginTask(BaseTask):
|
| 26 |
+
"""登录任务数据类"""
|
| 27 |
+
account_ids: List[str] = field(default_factory=list)
|
| 28 |
+
|
| 29 |
+
def to_dict(self) -> dict:
|
| 30 |
+
"""转换为字典"""
|
| 31 |
+
base_dict = super().to_dict()
|
| 32 |
+
base_dict["account_ids"] = self.account_ids
|
| 33 |
+
return base_dict
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
class LoginService(BaseTaskService[LoginTask]):
|
| 37 |
+
"""登录服务类 - 统一任务管理"""
|
| 38 |
+
|
| 39 |
+
def __init__(
|
| 40 |
+
self,
|
| 41 |
+
multi_account_mgr,
|
| 42 |
+
http_client,
|
| 43 |
+
user_agent: str,
|
| 44 |
+
retry_policy,
|
| 45 |
+
session_cache_ttl_seconds: int,
|
| 46 |
+
global_stats_provider: Callable[[], dict],
|
| 47 |
+
set_multi_account_mgr: Optional[Callable[[Any], None]] = None,
|
| 48 |
+
) -> None:
|
| 49 |
+
super().__init__(
|
| 50 |
+
multi_account_mgr,
|
| 51 |
+
http_client,
|
| 52 |
+
user_agent,
|
| 53 |
+
retry_policy,
|
| 54 |
+
session_cache_ttl_seconds,
|
| 55 |
+
global_stats_provider,
|
| 56 |
+
set_multi_account_mgr,
|
| 57 |
+
log_prefix="REFRESH",
|
| 58 |
+
)
|
| 59 |
+
self._is_polling = False
|
| 60 |
+
# 防重复:记录每个账号最后一次成功刷新的时间戳
|
| 61 |
+
self._refresh_timestamps: Dict[str, float] = {}
|
| 62 |
+
# cron 触发记录:避免同一时间点当天重复触发
|
| 63 |
+
self._triggered_today: set = set()
|
| 64 |
+
|
| 65 |
+
def _get_running_task(self) -> Optional[LoginTask]:
|
| 66 |
+
"""获取正在运行或等待中的任务"""
|
| 67 |
+
for task in self._tasks.values():
|
| 68 |
+
if isinstance(task, LoginTask) and task.status in (TaskStatus.PENDING, TaskStatus.RUNNING):
|
| 69 |
+
return task
|
| 70 |
+
return None
|
| 71 |
+
|
| 72 |
+
async def start_login(self, account_ids: List[str]) -> LoginTask:
|
| 73 |
+
"""
|
| 74 |
+
启动登录任务 - 统一任务管理
|
| 75 |
+
- 如果有正在运行的任务,将新账户添加到该任务(去重)
|
| 76 |
+
- 如果没有正在运行的任务,创建新任务
|
| 77 |
+
"""
|
| 78 |
+
async with self._lock:
|
| 79 |
+
if not account_ids:
|
| 80 |
+
raise ValueError("账户列表不能为空")
|
| 81 |
+
|
| 82 |
+
# 检查是否有正在运行的任务
|
| 83 |
+
running_task = self._get_running_task()
|
| 84 |
+
|
| 85 |
+
if running_task:
|
| 86 |
+
# 将新账户添加到现有任务(去重)
|
| 87 |
+
new_accounts = [aid for aid in account_ids if aid not in running_task.account_ids]
|
| 88 |
+
|
| 89 |
+
if new_accounts:
|
| 90 |
+
running_task.account_ids.extend(new_accounts)
|
| 91 |
+
self._append_log(
|
| 92 |
+
running_task,
|
| 93 |
+
"info",
|
| 94 |
+
f"📝 添加 {len(new_accounts)} 个账户到现有任务 (总计: {len(running_task.account_ids)})"
|
| 95 |
+
)
|
| 96 |
+
else:
|
| 97 |
+
self._append_log(running_task, "info", "📝 所有账户已在当前任务中")
|
| 98 |
+
|
| 99 |
+
return running_task
|
| 100 |
+
|
| 101 |
+
# 创建新任务
|
| 102 |
+
task = LoginTask(id=str(uuid.uuid4()), account_ids=list(account_ids))
|
| 103 |
+
self._tasks[task.id] = task
|
| 104 |
+
self._append_log(task, "info", f"📝 创建刷新任务 (账号数量: {len(task.account_ids)})")
|
| 105 |
+
|
| 106 |
+
# 直接启动任务
|
| 107 |
+
self._current_task_id = task.id
|
| 108 |
+
asyncio.create_task(self._run_task_directly(task))
|
| 109 |
+
return task
|
| 110 |
+
|
| 111 |
+
async def _run_task_directly(self, task: LoginTask) -> None:
|
| 112 |
+
"""直接执行任务"""
|
| 113 |
+
try:
|
| 114 |
+
await self._run_one_task(task)
|
| 115 |
+
finally:
|
| 116 |
+
# 任务完成后清理
|
| 117 |
+
async with self._lock:
|
| 118 |
+
if self._current_task_id == task.id:
|
| 119 |
+
self._current_task_id = None
|
| 120 |
+
|
| 121 |
+
def _execute_task(self, task: LoginTask):
|
| 122 |
+
return self._run_login_async(task)
|
| 123 |
+
|
| 124 |
+
async def _run_login_async(self, task: LoginTask) -> None:
|
| 125 |
+
"""异步执行登录任务(支持取消)。"""
|
| 126 |
+
loop = asyncio.get_running_loop()
|
| 127 |
+
self._append_log(task, "info", f"🚀 刷新任务已启动 (共 {len(task.account_ids)} 个账号)")
|
| 128 |
+
|
| 129 |
+
for idx, account_id in enumerate(task.account_ids, 1):
|
| 130 |
+
# 检查是否请求取消
|
| 131 |
+
if task.cancel_requested:
|
| 132 |
+
self._append_log(task, "warning", f"login task cancelled: {task.cancel_reason or 'cancelled'}")
|
| 133 |
+
task.status = TaskStatus.CANCELLED
|
| 134 |
+
task.finished_at = time.time()
|
| 135 |
+
return
|
| 136 |
+
|
| 137 |
+
try:
|
| 138 |
+
self._append_log(task, "info", f"📊 进度: {idx}/{len(task.account_ids)}")
|
| 139 |
+
self._append_log(task, "info", "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
|
| 140 |
+
self._append_log(task, "info", f"🔄 开始刷新账号: {account_id}")
|
| 141 |
+
self._append_log(task, "info", "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
|
| 142 |
+
result = await loop.run_in_executor(self._executor, self._refresh_one, account_id, task)
|
| 143 |
+
except TaskCancelledError:
|
| 144 |
+
# 线程侧已触发取消,直接结束任务
|
| 145 |
+
task.status = TaskStatus.CANCELLED
|
| 146 |
+
task.finished_at = time.time()
|
| 147 |
+
return
|
| 148 |
+
except Exception as exc:
|
| 149 |
+
result = {"success": False, "email": account_id, "error": str(exc)}
|
| 150 |
+
task.progress += 1
|
| 151 |
+
task.results.append(result)
|
| 152 |
+
|
| 153 |
+
if result.get("success"):
|
| 154 |
+
task.success_count += 1
|
| 155 |
+
# 记录刷新成功时间(防重复层 1)
|
| 156 |
+
self._refresh_timestamps[account_id] = time.time()
|
| 157 |
+
self._append_log(task, "info", "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
|
| 158 |
+
self._append_log(task, "info", f"🎉 刷新成功: {account_id}")
|
| 159 |
+
self._append_log(task, "info", "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
|
| 160 |
+
else:
|
| 161 |
+
task.fail_count += 1
|
| 162 |
+
error = result.get('error', '未知错误')
|
| 163 |
+
self._append_log(task, "error", "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
|
| 164 |
+
self._append_log(task, "error", f"❌ 刷新失败: {account_id}")
|
| 165 |
+
self._append_log(task, "error", f"❌ 失败原因: {error}")
|
| 166 |
+
self._append_log(task, "error", "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
|
| 167 |
+
|
| 168 |
+
# 403 自动禁用账户
|
| 169 |
+
if "403" in error:
|
| 170 |
+
try:
|
| 171 |
+
accounts = load_accounts_from_source()
|
| 172 |
+
for acc in accounts:
|
| 173 |
+
if acc.get("id") == account_id:
|
| 174 |
+
acc["disabled"] = True
|
| 175 |
+
acc["disabled_reason"] = "403 Access Restricted"
|
| 176 |
+
break
|
| 177 |
+
self._apply_accounts_update(accounts)
|
| 178 |
+
# 同步到内存中的 account manager
|
| 179 |
+
if account_id in self.multi_account_mgr.accounts:
|
| 180 |
+
mgr = self.multi_account_mgr.accounts[account_id]
|
| 181 |
+
mgr.config.disabled = True
|
| 182 |
+
mgr.disabled_reason = "403 Access Restricted"
|
| 183 |
+
self._append_log(task, "error", f"⛔ 已自动禁用账户: {account_id}")
|
| 184 |
+
except Exception as e:
|
| 185 |
+
self._append_log(task, "warning", f"⚠️ 自动禁用失败: {e}")
|
| 186 |
+
|
| 187 |
+
# 账号之间等待 10 秒,避免资源争抢和风控
|
| 188 |
+
if idx < len(task.account_ids) and not task.cancel_requested:
|
| 189 |
+
self._append_log(task, "info", "⏳ 等待 10 秒后处理下一个账号...")
|
| 190 |
+
await asyncio.sleep(10)
|
| 191 |
+
|
| 192 |
+
if task.cancel_requested:
|
| 193 |
+
task.status = TaskStatus.CANCELLED
|
| 194 |
+
else:
|
| 195 |
+
task.status = TaskStatus.SUCCESS if task.fail_count == 0 else TaskStatus.FAILED
|
| 196 |
+
task.finished_at = time.time()
|
| 197 |
+
self._append_log(task, "info", f"login task finished ({task.success_count}/{len(task.account_ids)})")
|
| 198 |
+
self._current_task_id = None
|
| 199 |
+
self._append_log(task, "info", f"🏁 刷新任务完成 (成功: {task.success_count}, 失败: {task.fail_count}, 总计: {len(task.account_ids)})")
|
| 200 |
+
|
| 201 |
+
def _refresh_one(self, account_id: str, task: LoginTask) -> dict:
|
| 202 |
+
"""刷新单个账户"""
|
| 203 |
+
accounts = load_accounts_from_source()
|
| 204 |
+
account = next((acc for acc in accounts if acc.get("id") == account_id), None)
|
| 205 |
+
if not account:
|
| 206 |
+
return {"success": False, "email": account_id, "error": "账号不存在"}
|
| 207 |
+
|
| 208 |
+
if account.get("disabled"):
|
| 209 |
+
return {"success": False, "email": account_id, "error": "账号已禁用"}
|
| 210 |
+
|
| 211 |
+
# 获取邮件提供商
|
| 212 |
+
mail_provider = (account.get("mail_provider") or "").lower()
|
| 213 |
+
if not mail_provider:
|
| 214 |
+
if account.get("mail_client_id") or account.get("mail_refresh_token"):
|
| 215 |
+
mail_provider = "microsoft"
|
| 216 |
+
else:
|
| 217 |
+
mail_provider = "duckmail"
|
| 218 |
+
|
| 219 |
+
# 获取邮件配置
|
| 220 |
+
mail_password = account.get("mail_password") or account.get("email_password")
|
| 221 |
+
mail_client_id = account.get("mail_client_id")
|
| 222 |
+
mail_refresh_token = account.get("mail_refresh_token")
|
| 223 |
+
mail_tenant = account.get("mail_tenant") or "consumers"
|
| 224 |
+
proxy_for_auth, _ = parse_proxy_setting(config.basic.proxy_for_auth)
|
| 225 |
+
|
| 226 |
+
def log_cb(level, message):
|
| 227 |
+
self._append_log(task, level, f"[{account_id}] {message}")
|
| 228 |
+
|
| 229 |
+
log_cb("info", f"📧 邮件提供商: {mail_provider}")
|
| 230 |
+
|
| 231 |
+
# 创建邮件客户端
|
| 232 |
+
if mail_provider == "microsoft":
|
| 233 |
+
if not mail_client_id or not mail_refresh_token:
|
| 234 |
+
return {"success": False, "email": account_id, "error": "Microsoft OAuth 配置缺失"}
|
| 235 |
+
mail_address = account.get("mail_address") or account_id
|
| 236 |
+
client = MicrosoftMailClient(
|
| 237 |
+
client_id=mail_client_id,
|
| 238 |
+
refresh_token=mail_refresh_token,
|
| 239 |
+
tenant=mail_tenant,
|
| 240 |
+
proxy=proxy_for_auth,
|
| 241 |
+
log_callback=log_cb,
|
| 242 |
+
)
|
| 243 |
+
client.set_credentials(mail_address)
|
| 244 |
+
elif mail_provider in ("duckmail", "moemail", "freemail", "gptmail", "cfmail"):
|
| 245 |
+
if mail_provider not in ("freemail", "gptmail", "cfmail") and not mail_password:
|
| 246 |
+
error_message = "邮箱密码缺失" if mail_provider == "duckmail" else "mail password (email_id) missing"
|
| 247 |
+
return {"success": False, "email": account_id, "error": error_message}
|
| 248 |
+
if mail_provider == "freemail" and not account.get("mail_jwt_token") and not config.basic.freemail_jwt_token:
|
| 249 |
+
return {"success": False, "email": account_id, "error": "Freemail JWT Token 未配置"}
|
| 250 |
+
|
| 251 |
+
# 创建邮件客户端,优先使用账户级别配置
|
| 252 |
+
mail_address = account.get("mail_address") or account_id
|
| 253 |
+
|
| 254 |
+
# 构建账户级别的配置参数
|
| 255 |
+
account_config = {}
|
| 256 |
+
if account.get("mail_base_url"):
|
| 257 |
+
account_config["base_url"] = account["mail_base_url"]
|
| 258 |
+
if account.get("mail_api_key"):
|
| 259 |
+
account_config["api_key"] = account["mail_api_key"]
|
| 260 |
+
if account.get("mail_jwt_token"):
|
| 261 |
+
account_config["jwt_token"] = account["mail_jwt_token"]
|
| 262 |
+
if account.get("mail_verify_ssl") is not None:
|
| 263 |
+
account_config["verify_ssl"] = account["mail_verify_ssl"]
|
| 264 |
+
if account.get("mail_domain"):
|
| 265 |
+
account_config["domain"] = account["mail_domain"]
|
| 266 |
+
|
| 267 |
+
# 创建客户端(工厂会优先使用传入的参数,其次使用全局配置)
|
| 268 |
+
client = create_temp_mail_client(
|
| 269 |
+
mail_provider,
|
| 270 |
+
log_cb=log_cb,
|
| 271 |
+
**account_config
|
| 272 |
+
)
|
| 273 |
+
client.set_credentials(mail_address, mail_password)
|
| 274 |
+
if mail_provider == "moemail":
|
| 275 |
+
client.email_id = mail_password # 设置 email_id 用于获取邮件
|
| 276 |
+
else:
|
| 277 |
+
return {"success": False, "email": account_id, "error": f"不支持的邮件提供商: {mail_provider}"}
|
| 278 |
+
|
| 279 |
+
headless = config.basic.browser_headless
|
| 280 |
+
|
| 281 |
+
log_cb("info", f"🌐 启动浏览器 (无头模式={headless})...")
|
| 282 |
+
|
| 283 |
+
automation = GeminiAutomation(
|
| 284 |
+
user_agent=self.user_agent,
|
| 285 |
+
proxy=proxy_for_auth,
|
| 286 |
+
headless=headless,
|
| 287 |
+
log_callback=log_cb,
|
| 288 |
+
)
|
| 289 |
+
# 允许外部取消时立刻关闭浏览器
|
| 290 |
+
self._add_cancel_hook(task.id, lambda: getattr(automation, "stop", lambda: None)())
|
| 291 |
+
try:
|
| 292 |
+
log_cb("info", "🔐 执行 Gemini 自动登录...")
|
| 293 |
+
result = automation.login_and_extract(account_id, client)
|
| 294 |
+
except Exception as exc:
|
| 295 |
+
log_cb("error", f"❌ 自动登录异常: {exc}")
|
| 296 |
+
return {"success": False, "email": account_id, "error": str(exc)}
|
| 297 |
+
if not result.get("success"):
|
| 298 |
+
error = result.get("error", "自动化流程失败")
|
| 299 |
+
log_cb("error", f"❌ 自动登录失败: {error}")
|
| 300 |
+
return {"success": False, "email": account_id, "error": error}
|
| 301 |
+
|
| 302 |
+
log_cb("info", "✅ Gemini 登录成功,正在保存配置...")
|
| 303 |
+
|
| 304 |
+
# 更新账户配置
|
| 305 |
+
config_data = result["config"]
|
| 306 |
+
config_data["mail_provider"] = mail_provider
|
| 307 |
+
if mail_provider in ("freemail", "gptmail"):
|
| 308 |
+
config_data["mail_password"] = ""
|
| 309 |
+
elif mail_provider == "cfmail":
|
| 310 |
+
config_data["mail_password"] = mail_password # 保留 JWT token
|
| 311 |
+
else:
|
| 312 |
+
config_data["mail_password"] = mail_password
|
| 313 |
+
if mail_provider == "microsoft":
|
| 314 |
+
config_data["mail_address"] = account.get("mail_address") or account_id
|
| 315 |
+
config_data["mail_client_id"] = mail_client_id
|
| 316 |
+
config_data["mail_refresh_token"] = mail_refresh_token
|
| 317 |
+
config_data["mail_tenant"] = mail_tenant
|
| 318 |
+
config_data["disabled"] = account.get("disabled", False)
|
| 319 |
+
|
| 320 |
+
for acc in accounts:
|
| 321 |
+
if acc.get("id") == account_id:
|
| 322 |
+
acc.update(config_data)
|
| 323 |
+
break
|
| 324 |
+
|
| 325 |
+
self._apply_accounts_update(accounts)
|
| 326 |
+
|
| 327 |
+
# 清除该账户的所有冷却状态(重新登录后恢复可用)
|
| 328 |
+
if account_id in self.multi_account_mgr.accounts:
|
| 329 |
+
account_mgr = self.multi_account_mgr.accounts[account_id]
|
| 330 |
+
account_mgr.quota_cooldowns.clear() # 清除配额冷却
|
| 331 |
+
account_mgr.is_available = True # 恢复可用状态
|
| 332 |
+
log_cb("info", "✅ 已清除账户冷却状态")
|
| 333 |
+
|
| 334 |
+
log_cb("info", "✅ 配置已保存到数据库")
|
| 335 |
+
return {"success": True, "email": account_id, "config": config_data}
|
| 336 |
+
|
| 337 |
+
|
| 338 |
+
def _get_expiring_accounts(self) -> List[str]:
|
| 339 |
+
"""获取即将过期的账户列表"""
|
| 340 |
+
accounts = load_accounts_from_source()
|
| 341 |
+
expiring = []
|
| 342 |
+
beijing_tz = timezone(timedelta(hours=8))
|
| 343 |
+
now = datetime.now(beijing_tz)
|
| 344 |
+
|
| 345 |
+
for account in accounts:
|
| 346 |
+
account_id = account.get("id")
|
| 347 |
+
if not account_id:
|
| 348 |
+
continue
|
| 349 |
+
|
| 350 |
+
if account.get("disabled"):
|
| 351 |
+
continue
|
| 352 |
+
mail_provider = (account.get("mail_provider") or "").lower()
|
| 353 |
+
if not mail_provider:
|
| 354 |
+
if account.get("mail_client_id") or account.get("mail_refresh_token"):
|
| 355 |
+
mail_provider = "microsoft"
|
| 356 |
+
else:
|
| 357 |
+
mail_provider = "duckmail"
|
| 358 |
+
|
| 359 |
+
mail_password = account.get("mail_password") or account.get("email_password")
|
| 360 |
+
if mail_provider == "microsoft":
|
| 361 |
+
if not account.get("mail_client_id") or not account.get("mail_refresh_token"):
|
| 362 |
+
continue
|
| 363 |
+
elif mail_provider in ("duckmail", "moemail"):
|
| 364 |
+
if not mail_password:
|
| 365 |
+
continue
|
| 366 |
+
elif mail_provider == "freemail":
|
| 367 |
+
if not config.basic.freemail_jwt_token:
|
| 368 |
+
continue
|
| 369 |
+
elif mail_provider == "gptmail":
|
| 370 |
+
# GPTMail 不需要密码,允许直接刷新
|
| 371 |
+
pass
|
| 372 |
+
elif mail_provider == "cfmail":
|
| 373 |
+
# cfmail 需要 JWT token(存在 mail_password 中)或全局配置
|
| 374 |
+
if not mail_password and not config.basic.cfmail_api_key:
|
| 375 |
+
continue
|
| 376 |
+
else:
|
| 377 |
+
continue
|
| 378 |
+
expires_at = account.get("expires_at")
|
| 379 |
+
if not expires_at:
|
| 380 |
+
continue
|
| 381 |
+
|
| 382 |
+
try:
|
| 383 |
+
expire_time = datetime.strptime(expires_at, "%Y-%m-%d %H:%M:%S")
|
| 384 |
+
expire_time = expire_time.replace(tzinfo=beijing_tz)
|
| 385 |
+
remaining = (expire_time - now).total_seconds() / 3600
|
| 386 |
+
except Exception:
|
| 387 |
+
continue
|
| 388 |
+
|
| 389 |
+
if remaining > config.basic.refresh_window_hours:
|
| 390 |
+
continue
|
| 391 |
+
|
| 392 |
+
# 冷却检查(防重复层 1):跳过最近刚刷新过的账号
|
| 393 |
+
cooldown_seconds = config.retry.refresh_cooldown_hours * 3600
|
| 394 |
+
if account_id in self._refresh_timestamps:
|
| 395 |
+
elapsed = time.time() - self._refresh_timestamps[account_id]
|
| 396 |
+
if elapsed < cooldown_seconds:
|
| 397 |
+
logger.debug(f"[LOGIN] skip {account_id}: refreshed {elapsed/3600:.1f}h ago, cooldown {config.retry.refresh_cooldown_hours}h")
|
| 398 |
+
continue
|
| 399 |
+
|
| 400 |
+
if True: # 通过所有检查
|
| 401 |
+
expiring.append(account_id)
|
| 402 |
+
|
| 403 |
+
return expiring
|
| 404 |
+
|
| 405 |
+
async def check_and_refresh(self) -> Optional[LoginTask]:
|
| 406 |
+
if os.environ.get("ACCOUNTS_CONFIG"):
|
| 407 |
+
logger.info("[LOGIN] ACCOUNTS_CONFIG set, skipping refresh")
|
| 408 |
+
return None
|
| 409 |
+
expiring_accounts = self._get_expiring_accounts()
|
| 410 |
+
if not expiring_accounts:
|
| 411 |
+
logger.debug("[LOGIN] no accounts need refresh")
|
| 412 |
+
return None
|
| 413 |
+
|
| 414 |
+
try:
|
| 415 |
+
return await self.start_login(expiring_accounts)
|
| 416 |
+
except Exception as exc:
|
| 417 |
+
logger.warning("[LOGIN] refresh enqueue failed: %s", exc)
|
| 418 |
+
return None
|
| 419 |
+
|
| 420 |
+
@staticmethod
|
| 421 |
+
def _parse_cron(cron_str: str) -> dict:
|
| 422 |
+
"""解析 cron 表达式。
|
| 423 |
+
支持两种格式:
|
| 424 |
+
- '08:00,20:00' → {'mode': 'daily', 'times': ['08:00', '20:00']}
|
| 425 |
+
- '*/120' → {'mode': 'interval', 'minutes': 120}
|
| 426 |
+
"""
|
| 427 |
+
cron_str = cron_str.strip()
|
| 428 |
+
if cron_str.startswith("*/"):
|
| 429 |
+
try:
|
| 430 |
+
minutes = int(cron_str[2:])
|
| 431 |
+
return {"mode": "interval", "minutes": max(minutes, 5)}
|
| 432 |
+
except ValueError:
|
| 433 |
+
return {"mode": "interval", "minutes": 120}
|
| 434 |
+
else:
|
| 435 |
+
times = [t.strip() for t in cron_str.split(",") if t.strip()]
|
| 436 |
+
valid = []
|
| 437 |
+
for t in times:
|
| 438 |
+
parts = t.split(":")
|
| 439 |
+
if len(parts) == 2:
|
| 440 |
+
try:
|
| 441 |
+
h, m = int(parts[0]), int(parts[1])
|
| 442 |
+
if 0 <= h <= 23 and 0 <= m <= 59:
|
| 443 |
+
valid.append(f"{h:02d}:{m:02d}")
|
| 444 |
+
except ValueError:
|
| 445 |
+
pass
|
| 446 |
+
return {"mode": "daily", "times": valid or ["08:00", "20:00"]}
|
| 447 |
+
|
| 448 |
+
async def _wait_for_next_trigger(self) -> None:
|
| 449 |
+
"""等待下一个触发时间点。
|
| 450 |
+
- interval 模式:等 N 分钟
|
| 451 |
+
- daily 模式:等到下一个匹配的 HH:MM,每个时间点每天只触发一次
|
| 452 |
+
"""
|
| 453 |
+
cron_str = config.retry.scheduled_refresh_cron
|
| 454 |
+
# 向后兼容:如果旧字段有值且新字段是默认值,转换为 interval 模式
|
| 455 |
+
if (not cron_str or cron_str == "08:00,20:00") and config.retry.scheduled_refresh_interval_minutes > 0:
|
| 456 |
+
cron_str = f"*/{config.retry.scheduled_refresh_interval_minutes}"
|
| 457 |
+
|
| 458 |
+
cron = self._parse_cron(cron_str)
|
| 459 |
+
|
| 460 |
+
if cron["mode"] == "interval":
|
| 461 |
+
minutes = cron["minutes"]
|
| 462 |
+
logger.info(f"[LOGIN] 间隔模式:{minutes} 分钟后下一次检查")
|
| 463 |
+
await asyncio.sleep(minutes * 60)
|
| 464 |
+
return
|
| 465 |
+
|
| 466 |
+
# daily 模式:每秒检查一次当前时间是否命中
|
| 467 |
+
beijing_tz = timezone(timedelta(hours=8))
|
| 468 |
+
while self._is_polling:
|
| 469 |
+
now = datetime.now(beijing_tz)
|
| 470 |
+
current_time = now.strftime("%H:%M")
|
| 471 |
+
today_str = now.strftime("%Y-%m-%d")
|
| 472 |
+
|
| 473 |
+
# 新的一天,清空触发记录
|
| 474 |
+
old_keys = [k for k in self._triggered_today if not k.startswith(today_str)]
|
| 475 |
+
for k in old_keys:
|
| 476 |
+
self._triggered_today.discard(k)
|
| 477 |
+
|
| 478 |
+
for t in cron["times"]:
|
| 479 |
+
trigger_key = f"{today_str}_{t}"
|
| 480 |
+
if current_time == t and trigger_key not in self._triggered_today:
|
| 481 |
+
self._triggered_today.add(trigger_key)
|
| 482 |
+
logger.info(f"[LOGIN] 定时触发: {t}")
|
| 483 |
+
return
|
| 484 |
+
|
| 485 |
+
await asyncio.sleep(30) # 每 30 秒检查一次
|
| 486 |
+
|
| 487 |
+
async def _wait_task_complete(self, task: LoginTask) -> None:
|
| 488 |
+
"""等待任务完成(防重复层 3:串行等待)"""
|
| 489 |
+
while task.status in (TaskStatus.PENDING, TaskStatus.RUNNING):
|
| 490 |
+
await asyncio.sleep(5)
|
| 491 |
+
|
| 492 |
+
async def start_polling(self) -> None:
|
| 493 |
+
if self._is_polling:
|
| 494 |
+
logger.warning("[LOGIN] polling already running")
|
| 495 |
+
return
|
| 496 |
+
|
| 497 |
+
self._is_polling = True
|
| 498 |
+
logger.info("[LOGIN] 智能刷新调度器已启动")
|
| 499 |
+
try:
|
| 500 |
+
while self._is_polling:
|
| 501 |
+
# 检查是否启用
|
| 502 |
+
if not config.retry.scheduled_refresh_enabled:
|
| 503 |
+
logger.debug("[LOGIN] scheduled refresh disabled")
|
| 504 |
+
await asyncio.sleep(CONFIG_CHECK_INTERVAL_SECONDS)
|
| 505 |
+
continue
|
| 506 |
+
|
| 507 |
+
# 等待下一个触发时间点
|
| 508 |
+
await self._wait_for_next_trigger()
|
| 509 |
+
if not self._is_polling:
|
| 510 |
+
break
|
| 511 |
+
|
| 512 |
+
# 获取所有待刷新账号(已含冷却过滤)
|
| 513 |
+
expiring = self._get_expiring_accounts()
|
| 514 |
+
if not expiring:
|
| 515 |
+
logger.info("[LOGIN] 本轮无需刷新的账号")
|
| 516 |
+
continue
|
| 517 |
+
|
| 518 |
+
batch_size = config.retry.refresh_batch_size
|
| 519 |
+
total_batches = (len(expiring) + batch_size - 1) // batch_size
|
| 520 |
+
logger.info(f"[LOGIN] 待刷新 {len(expiring)} 个账号,分 {total_batches} 批(每批 {batch_size} 个)")
|
| 521 |
+
|
| 522 |
+
# 分批执行
|
| 523 |
+
for i in range(0, len(expiring), batch_size):
|
| 524 |
+
if not self._is_polling:
|
| 525 |
+
break
|
| 526 |
+
|
| 527 |
+
batch = expiring[i:i + batch_size]
|
| 528 |
+
batch_num = i // batch_size + 1
|
| 529 |
+
logger.info(f"[LOGIN] 第 {batch_num}/{total_batches} 批: {batch}")
|
| 530 |
+
|
| 531 |
+
try:
|
| 532 |
+
task = await self.start_login(batch)
|
| 533 |
+
# 等待这批完成(防重复层 3)
|
| 534 |
+
await self._wait_task_complete(task)
|
| 535 |
+
logger.info(f"[LOGIN] 第 {batch_num} 批完成 (成功: {task.success_count}, 失败: {task.fail_count})")
|
| 536 |
+
except Exception as exc:
|
| 537 |
+
logger.warning(f"[LOGIN] 第 {batch_num} 批异常: {exc}")
|
| 538 |
+
|
| 539 |
+
# 批次间等待(最后一批不等)
|
| 540 |
+
remaining = expiring[i + batch_size:]
|
| 541 |
+
if remaining and self._is_polling:
|
| 542 |
+
interval = config.retry.refresh_batch_interval_minutes * 60
|
| 543 |
+
logger.info(f"[LOGIN] 等待 {config.retry.refresh_batch_interval_minutes} 分钟后开始下一批...")
|
| 544 |
+
await asyncio.sleep(interval)
|
| 545 |
+
|
| 546 |
+
logger.info("[LOGIN] 本轮刷新完成")
|
| 547 |
+
|
| 548 |
+
except asyncio.CancelledError:
|
| 549 |
+
logger.info("[LOGIN] polling stopped")
|
| 550 |
+
except Exception as exc:
|
| 551 |
+
logger.error("[LOGIN] polling error: %s", exc)
|
| 552 |
+
finally:
|
| 553 |
+
self._is_polling = False
|
| 554 |
+
|
| 555 |
+
def stop_polling(self) -> None:
|
| 556 |
+
self._is_polling = False
|
| 557 |
+
logger.info("[LOGIN] stopping polling")
|
core/mail_providers/__init__.py
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from .factory import create_temp_mail_client
|
| 2 |
+
|
| 3 |
+
__all__ = ["create_temp_mail_client"]
|
core/mail_providers/factory.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Callable, Optional
|
| 2 |
+
|
| 3 |
+
from core.config import config
|
| 4 |
+
from core.proxy_utils import extract_host, no_proxy_matches, parse_proxy_setting
|
| 5 |
+
from core.cfmail_client import CloudflareMailClient
|
| 6 |
+
from core.duckmail_client import DuckMailClient
|
| 7 |
+
from core.freemail_client import FreemailClient
|
| 8 |
+
from core.gptmail_client import GPTMailClient
|
| 9 |
+
from core.moemail_client import MoemailClient
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def create_temp_mail_client(
|
| 13 |
+
provider: str,
|
| 14 |
+
*,
|
| 15 |
+
domain: Optional[str] = None,
|
| 16 |
+
proxy: Optional[str] = None,
|
| 17 |
+
log_cb: Optional[Callable[[str, str], None]] = None,
|
| 18 |
+
base_url: Optional[str] = None,
|
| 19 |
+
api_key: Optional[str] = None,
|
| 20 |
+
jwt_token: Optional[str] = None,
|
| 21 |
+
verify_ssl: Optional[bool] = None,
|
| 22 |
+
):
|
| 23 |
+
"""
|
| 24 |
+
创建临时邮箱客户端
|
| 25 |
+
|
| 26 |
+
参数优先级:传入参数 > 全局配置
|
| 27 |
+
"""
|
| 28 |
+
provider = (provider or "duckmail").lower()
|
| 29 |
+
if proxy is None:
|
| 30 |
+
proxy_source = config.basic.proxy_for_auth if config.basic.mail_proxy_enabled else ""
|
| 31 |
+
else:
|
| 32 |
+
proxy_source = proxy
|
| 33 |
+
proxy, no_proxy = parse_proxy_setting(proxy_source)
|
| 34 |
+
|
| 35 |
+
if provider == "moemail":
|
| 36 |
+
effective_base_url = base_url or config.basic.moemail_base_url
|
| 37 |
+
if no_proxy_matches(extract_host(effective_base_url), no_proxy):
|
| 38 |
+
proxy = ""
|
| 39 |
+
return MoemailClient(
|
| 40 |
+
base_url=effective_base_url,
|
| 41 |
+
proxy=proxy,
|
| 42 |
+
api_key=api_key or config.basic.moemail_api_key,
|
| 43 |
+
domain=domain or config.basic.moemail_domain,
|
| 44 |
+
log_callback=log_cb,
|
| 45 |
+
)
|
| 46 |
+
|
| 47 |
+
if provider == "freemail":
|
| 48 |
+
effective_base_url = base_url or config.basic.freemail_base_url
|
| 49 |
+
if no_proxy_matches(extract_host(effective_base_url), no_proxy):
|
| 50 |
+
proxy = ""
|
| 51 |
+
return FreemailClient(
|
| 52 |
+
base_url=effective_base_url,
|
| 53 |
+
jwt_token=jwt_token or config.basic.freemail_jwt_token,
|
| 54 |
+
proxy=proxy,
|
| 55 |
+
verify_ssl=verify_ssl if verify_ssl is not None else config.basic.freemail_verify_ssl,
|
| 56 |
+
log_callback=log_cb,
|
| 57 |
+
)
|
| 58 |
+
|
| 59 |
+
if provider == "gptmail":
|
| 60 |
+
effective_base_url = base_url or config.basic.gptmail_base_url
|
| 61 |
+
if no_proxy_matches(extract_host(effective_base_url), no_proxy):
|
| 62 |
+
proxy = ""
|
| 63 |
+
return GPTMailClient(
|
| 64 |
+
base_url=effective_base_url,
|
| 65 |
+
api_key=api_key or config.basic.gptmail_api_key,
|
| 66 |
+
proxy=proxy,
|
| 67 |
+
verify_ssl=verify_ssl if verify_ssl is not None else config.basic.gptmail_verify_ssl,
|
| 68 |
+
domain=domain or config.basic.gptmail_domain,
|
| 69 |
+
log_callback=log_cb,
|
| 70 |
+
)
|
| 71 |
+
|
| 72 |
+
if provider == "cfmail":
|
| 73 |
+
effective_base_url = base_url or config.basic.cfmail_base_url
|
| 74 |
+
if no_proxy_matches(extract_host(effective_base_url), no_proxy):
|
| 75 |
+
proxy = ""
|
| 76 |
+
return CloudflareMailClient(
|
| 77 |
+
base_url=effective_base_url,
|
| 78 |
+
proxy=proxy,
|
| 79 |
+
api_key=api_key or config.basic.cfmail_api_key,
|
| 80 |
+
domain=domain or config.basic.cfmail_domain,
|
| 81 |
+
verify_ssl=verify_ssl if verify_ssl is not None else config.basic.cfmail_verify_ssl,
|
| 82 |
+
log_callback=log_cb,
|
| 83 |
+
)
|
| 84 |
+
|
| 85 |
+
effective_base_url = base_url or config.basic.duckmail_base_url
|
| 86 |
+
if no_proxy_matches(extract_host(effective_base_url), no_proxy):
|
| 87 |
+
proxy = ""
|
| 88 |
+
return DuckMailClient(
|
| 89 |
+
base_url=effective_base_url,
|
| 90 |
+
proxy=proxy,
|
| 91 |
+
verify_ssl=verify_ssl if verify_ssl is not None else config.basic.duckmail_verify_ssl,
|
| 92 |
+
api_key=api_key or config.basic.duckmail_api_key,
|
| 93 |
+
log_callback=log_cb,
|
| 94 |
+
)
|
core/mail_utils.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import re
|
| 2 |
+
from typing import Optional
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
def extract_verification_code(text: str) -> Optional[str]:
|
| 6 |
+
"""提取验证码"""
|
| 7 |
+
if not text:
|
| 8 |
+
return None
|
| 9 |
+
|
| 10 |
+
# 策略1: 上下文关键词匹配(中英文冒号)
|
| 11 |
+
context_pattern = r"(?:验证码|code|verification|passcode|pin).*?[::]\s*([A-Za-z0-9]{4,8})\b"
|
| 12 |
+
match = re.search(context_pattern, text, re.IGNORECASE)
|
| 13 |
+
if match:
|
| 14 |
+
candidate = match.group(1)
|
| 15 |
+
# 排除 CSS 单位值
|
| 16 |
+
if not re.match(r"^\d+(?:px|pt|em|rem|vh|vw|%)$", candidate, re.IGNORECASE):
|
| 17 |
+
return candidate
|
| 18 |
+
|
| 19 |
+
# 策略2: 6位字母数字混合(与测试代码一致,优先级提高)
|
| 20 |
+
match = re.search(r"[A-Z0-9]{6}", text)
|
| 21 |
+
if match:
|
| 22 |
+
return match.group(0)
|
| 23 |
+
|
| 24 |
+
# 策略3: 6位数字(降级为备选)
|
| 25 |
+
digits = re.findall(r"\b\d{6}\b", text)
|
| 26 |
+
if digits:
|
| 27 |
+
return digits[0]
|
| 28 |
+
|
| 29 |
+
return None
|
core/message.py
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""消息处理模块
|
| 2 |
+
|
| 3 |
+
负责消息的解析、文本提取和会话指纹生成
|
| 4 |
+
"""
|
| 5 |
+
import asyncio
|
| 6 |
+
import base64
|
| 7 |
+
import hashlib
|
| 8 |
+
import logging
|
| 9 |
+
import re
|
| 10 |
+
from typing import List, TYPE_CHECKING
|
| 11 |
+
|
| 12 |
+
import httpx
|
| 13 |
+
|
| 14 |
+
if TYPE_CHECKING:
|
| 15 |
+
from main import Message
|
| 16 |
+
|
| 17 |
+
logger = logging.getLogger(__name__)
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def get_conversation_key(messages: List[dict], client_identifier: str = "") -> str:
|
| 21 |
+
"""
|
| 22 |
+
生成对话指纹(使用前3条消息+客户端标识,确保唯一性)
|
| 23 |
+
|
| 24 |
+
策略:
|
| 25 |
+
1. 使用前3条消息生成指纹(而非仅第1条)
|
| 26 |
+
2. 加入客户端标识(IP或request_id)避免不同用户冲突
|
| 27 |
+
3. 保持Session复用能力(同一用户的后续消息仍能找到同一Session)
|
| 28 |
+
|
| 29 |
+
Args:
|
| 30 |
+
messages: 消息列表
|
| 31 |
+
client_identifier: 客户端标识(如IP地址或request_id),用于区分不同用户
|
| 32 |
+
"""
|
| 33 |
+
if not messages:
|
| 34 |
+
return f"{client_identifier}:empty" if client_identifier else "empty"
|
| 35 |
+
|
| 36 |
+
# 提取前3条消息的关键信息(角色+内容)
|
| 37 |
+
message_fingerprints = []
|
| 38 |
+
for msg in messages[:3]: # 只取前3条
|
| 39 |
+
role = msg.get("role", "")
|
| 40 |
+
content = msg.get("content", "")
|
| 41 |
+
|
| 42 |
+
# 统一处理内容格式(字符串或数组)
|
| 43 |
+
if isinstance(content, list):
|
| 44 |
+
# 多模态消息:只提取文本部分
|
| 45 |
+
text = extract_text_from_content(content)
|
| 46 |
+
else:
|
| 47 |
+
text = str(content)
|
| 48 |
+
|
| 49 |
+
# 标准化:去除首尾空白,转小写
|
| 50 |
+
text = text.strip().lower()
|
| 51 |
+
|
| 52 |
+
# 组合角色和内容
|
| 53 |
+
message_fingerprints.append(f"{role}:{text}")
|
| 54 |
+
|
| 55 |
+
# 使用前3条消息+客户端标识生成指纹
|
| 56 |
+
conversation_prefix = "|".join(message_fingerprints)
|
| 57 |
+
if client_identifier:
|
| 58 |
+
conversation_prefix = f"{client_identifier}|{conversation_prefix}"
|
| 59 |
+
|
| 60 |
+
return hashlib.md5(conversation_prefix.encode()).hexdigest()
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def extract_text_from_content(content) -> str:
|
| 64 |
+
"""
|
| 65 |
+
从消息 content 中提取文本内容
|
| 66 |
+
统一处理字符串和多模态数组格式
|
| 67 |
+
"""
|
| 68 |
+
if isinstance(content, str):
|
| 69 |
+
return content
|
| 70 |
+
elif isinstance(content, list):
|
| 71 |
+
# 多模态消息:只提取文本部分
|
| 72 |
+
return "".join([x.get("text", "") for x in content if x.get("type") == "text"])
|
| 73 |
+
else:
|
| 74 |
+
return str(content)
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
async def parse_last_message(messages: List['Message'], http_client: httpx.AsyncClient, request_id: str = ""):
|
| 78 |
+
"""解析最后一条消息,分离文本和文件(支持图片、PDF、文档等,base64 和 URL)"""
|
| 79 |
+
if not messages:
|
| 80 |
+
return "", []
|
| 81 |
+
|
| 82 |
+
last_msg = messages[-1]
|
| 83 |
+
content = last_msg.content
|
| 84 |
+
|
| 85 |
+
text_content = ""
|
| 86 |
+
images = [] # List of {"mime": str, "data": str_base64} - 兼容变量名,实际支持所有文件
|
| 87 |
+
image_urls = [] # 需要下载的 URL - 兼容变量名,实际支持所有文件
|
| 88 |
+
|
| 89 |
+
if isinstance(content, str):
|
| 90 |
+
text_content = content
|
| 91 |
+
elif isinstance(content, list):
|
| 92 |
+
for part in content:
|
| 93 |
+
if part.get("type") == "text":
|
| 94 |
+
text_content += part.get("text", "")
|
| 95 |
+
elif part.get("type") == "image_url":
|
| 96 |
+
url = part.get("image_url", {}).get("url", "")
|
| 97 |
+
# 解析 Data URI: data:mime/type;base64,xxxxxx (支持所有 MIME 类型)
|
| 98 |
+
match = re.match(r"data:([^;]+);base64,(.+)", url)
|
| 99 |
+
if match:
|
| 100 |
+
images.append({"mime": match.group(1), "data": match.group(2)})
|
| 101 |
+
elif url.startswith(("http://", "https://")):
|
| 102 |
+
image_urls.append(url)
|
| 103 |
+
else:
|
| 104 |
+
logger.warning(f"[FILE] [req_{request_id}] 不支持的文件格式: {url[:30]}...")
|
| 105 |
+
|
| 106 |
+
# 并行下载所有 URL 文件(支持图片、PDF、文档等)
|
| 107 |
+
if image_urls:
|
| 108 |
+
async def download_url(url: str):
|
| 109 |
+
try:
|
| 110 |
+
resp = await http_client.get(url, timeout=30, follow_redirects=True)
|
| 111 |
+
if resp.status_code == 404:
|
| 112 |
+
logger.warning(f"[FILE] [req_{request_id}] URL文件已失效(404),已跳过: {url[:50]}...")
|
| 113 |
+
return None
|
| 114 |
+
resp.raise_for_status()
|
| 115 |
+
content_type = resp.headers.get("content-type", "application/octet-stream").split(";")[0]
|
| 116 |
+
# 移除图片类型限制,支持所有文件类型
|
| 117 |
+
b64 = base64.b64encode(resp.content).decode()
|
| 118 |
+
logger.info(f"[FILE] [req_{request_id}] URL文件下载成功: {url[:50]}... ({len(resp.content)} bytes, {content_type})")
|
| 119 |
+
return {"mime": content_type, "data": b64}
|
| 120 |
+
except httpx.HTTPStatusError as e:
|
| 121 |
+
status_code = e.response.status_code if e.response else "unknown"
|
| 122 |
+
logger.warning(f"[FILE] [req_{request_id}] URL文件下载失败({status_code}): {url[:50]}... - {e}")
|
| 123 |
+
return None
|
| 124 |
+
except Exception as e:
|
| 125 |
+
logger.warning(f"[FILE] [req_{request_id}] URL文件下���失败: {url[:50]}... - {e}")
|
| 126 |
+
return None
|
| 127 |
+
|
| 128 |
+
results = await asyncio.gather(*[download_url(u) for u in image_urls], return_exceptions=True)
|
| 129 |
+
safe_results = []
|
| 130 |
+
for result in results:
|
| 131 |
+
if isinstance(result, Exception):
|
| 132 |
+
logger.warning(f"[FILE] [req_{request_id}] URL文件下载异常: {type(result).__name__}: {str(result)[:120]}")
|
| 133 |
+
continue
|
| 134 |
+
safe_results.append(result)
|
| 135 |
+
images.extend([r for r in safe_results if r])
|
| 136 |
+
|
| 137 |
+
return text_content, images
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
def build_full_context_text(messages: List['Message']) -> str:
|
| 141 |
+
"""仅拼接历史文本,图片只处理当次请求的"""
|
| 142 |
+
prompt = ""
|
| 143 |
+
for msg in messages:
|
| 144 |
+
role = "User" if msg.role in ["user", "system"] else "Assistant"
|
| 145 |
+
content_str = extract_text_from_content(msg.content)
|
| 146 |
+
|
| 147 |
+
# 为多模态消息添加图片标记
|
| 148 |
+
if isinstance(msg.content, list):
|
| 149 |
+
image_count = sum(1 for part in msg.content if part.get("type") == "image_url")
|
| 150 |
+
if image_count > 0:
|
| 151 |
+
content_str += "[图片]" * image_count
|
| 152 |
+
|
| 153 |
+
prompt += f"{role}: {content_str}\n\n"
|
| 154 |
+
return prompt
|
core/microsoft_mail_client.py
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import imaplib
|
| 2 |
+
import time
|
| 3 |
+
from datetime import datetime, timedelta
|
| 4 |
+
from email import message_from_bytes
|
| 5 |
+
from email.utils import parsedate_to_datetime
|
| 6 |
+
from typing import Optional
|
| 7 |
+
|
| 8 |
+
import requests
|
| 9 |
+
|
| 10 |
+
from core.mail_utils import extract_verification_code
|
| 11 |
+
|
| 12 |
+
# 常量定义
|
| 13 |
+
CANCELLATION_CHECK_INTERVAL_SECONDS = 5 # 取消检查间隔(秒)
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
class MicrosoftMailClient:
|
| 17 |
+
def __init__(
|
| 18 |
+
self,
|
| 19 |
+
client_id: str,
|
| 20 |
+
refresh_token: str,
|
| 21 |
+
tenant: str = "consumers",
|
| 22 |
+
proxy: str = "",
|
| 23 |
+
log_callback=None,
|
| 24 |
+
) -> None:
|
| 25 |
+
self.client_id = client_id
|
| 26 |
+
self.refresh_token = refresh_token
|
| 27 |
+
self.tenant = tenant or "consumers"
|
| 28 |
+
self.proxies = {"http": proxy, "https": proxy} if proxy else None
|
| 29 |
+
self.log_callback = log_callback
|
| 30 |
+
self.email: Optional[str] = None
|
| 31 |
+
|
| 32 |
+
def set_credentials(self, email: str, password: Optional[str] = None) -> None:
|
| 33 |
+
self.email = email
|
| 34 |
+
|
| 35 |
+
def _get_access_token(self) -> Optional[str]:
|
| 36 |
+
url = f"https://login.microsoftonline.com/{self.tenant}/oauth2/v2.0/token"
|
| 37 |
+
data = {
|
| 38 |
+
"client_id": self.client_id,
|
| 39 |
+
"grant_type": "refresh_token",
|
| 40 |
+
"refresh_token": self.refresh_token,
|
| 41 |
+
}
|
| 42 |
+
try:
|
| 43 |
+
self._log("info", f"🔑 正在获取 Microsoft OAuth 令牌...")
|
| 44 |
+
res = requests.post(url, data=data, proxies=self.proxies, timeout=15)
|
| 45 |
+
if res.status_code != 200:
|
| 46 |
+
self._log("error", f"❌ Microsoft 令牌获取失败: HTTP {res.status_code}")
|
| 47 |
+
return None
|
| 48 |
+
payload = res.json() if res.content else {}
|
| 49 |
+
token = payload.get("access_token")
|
| 50 |
+
if not token:
|
| 51 |
+
self._log("error", "❌ Microsoft 令牌响应中缺少 access_token")
|
| 52 |
+
return None
|
| 53 |
+
self._log("info", "✅ Microsoft OAuth 令牌获取成功")
|
| 54 |
+
return token
|
| 55 |
+
except Exception as exc:
|
| 56 |
+
self._log("error", f"❌ Microsoft 令牌获取异常: {exc}")
|
| 57 |
+
return None
|
| 58 |
+
|
| 59 |
+
def fetch_verification_code(self, since_time: Optional[datetime] = None) -> Optional[str]:
|
| 60 |
+
if not self.email:
|
| 61 |
+
return None
|
| 62 |
+
|
| 63 |
+
self._log("info", "📬 正在获取验证码...")
|
| 64 |
+
token = self._get_access_token()
|
| 65 |
+
if not token:
|
| 66 |
+
self._log("error", "❌ 无法获取访问令牌,跳过邮箱检查")
|
| 67 |
+
return None
|
| 68 |
+
|
| 69 |
+
auth_string = f"user={self.email}\x01auth=Bearer {token}\x01\x01".encode()
|
| 70 |
+
client = imaplib.IMAP4_SSL("outlook.office365.com", 993)
|
| 71 |
+
try:
|
| 72 |
+
self._log("info", f"🔐 正在使用 IMAP XOAUTH2 认证: {self.email}")
|
| 73 |
+
client.authenticate("XOAUTH2", lambda _: auth_string)
|
| 74 |
+
self._log("info", "✅ IMAP 认证成功,已连接到邮箱")
|
| 75 |
+
except Exception as exc:
|
| 76 |
+
self._log("error", f"❌ IMAP 认证失败: {exc}")
|
| 77 |
+
try:
|
| 78 |
+
client.logout()
|
| 79 |
+
except Exception:
|
| 80 |
+
pass
|
| 81 |
+
return None
|
| 82 |
+
|
| 83 |
+
search_since = since_time or (datetime.now() - timedelta(minutes=5))
|
| 84 |
+
self._log("info", f"🔍 搜索 {search_since.strftime('%Y-%m-%d %H:%M:%S')} 之后的邮件")
|
| 85 |
+
|
| 86 |
+
try:
|
| 87 |
+
for mailbox in ("INBOX", "Junk"):
|
| 88 |
+
try:
|
| 89 |
+
status, _ = client.select(mailbox, readonly=True)
|
| 90 |
+
if status != "OK":
|
| 91 |
+
self._log("warning", f"⚠️ 无法选择邮箱: {mailbox}")
|
| 92 |
+
continue
|
| 93 |
+
self._log("info", f"📂 正在检查邮箱: {mailbox}")
|
| 94 |
+
except Exception as e:
|
| 95 |
+
self._log("warning", f"⚠️ 选择邮箱 {mailbox} 时出错: {e}")
|
| 96 |
+
continue
|
| 97 |
+
|
| 98 |
+
# 搜索所有邮件
|
| 99 |
+
status, data = client.search(None, "ALL")
|
| 100 |
+
if status != "OK" or not data or not data[0]:
|
| 101 |
+
self._log("info", f"📭 邮箱 {mailbox} 中没有邮件")
|
| 102 |
+
continue
|
| 103 |
+
|
| 104 |
+
ids = data[0].split()[-5:] # 只检查最近 5 封
|
| 105 |
+
self._log("info", f"📨 在 {mailbox} 中发现 {len(ids)} 封邮件")
|
| 106 |
+
|
| 107 |
+
checked_count = 0
|
| 108 |
+
for msg_id in reversed(ids):
|
| 109 |
+
status, msg_data = client.fetch(msg_id, "(RFC822)")
|
| 110 |
+
if status != "OK" or not msg_data:
|
| 111 |
+
continue
|
| 112 |
+
raw_bytes = None
|
| 113 |
+
for item in msg_data:
|
| 114 |
+
if isinstance(item, tuple) and len(item) > 1:
|
| 115 |
+
raw_bytes = item[1]
|
| 116 |
+
break
|
| 117 |
+
if not raw_bytes:
|
| 118 |
+
continue
|
| 119 |
+
|
| 120 |
+
msg = message_from_bytes(raw_bytes)
|
| 121 |
+
msg_date = self._parse_message_date(msg.get("Date"))
|
| 122 |
+
|
| 123 |
+
# 按时间过滤(静默跳过旧邮件)
|
| 124 |
+
if msg_date and msg_date < search_since:
|
| 125 |
+
continue
|
| 126 |
+
|
| 127 |
+
checked_count += 1
|
| 128 |
+
content = self._message_to_text(msg)
|
| 129 |
+
import re
|
| 130 |
+
match = re.search(r'[A-Z0-9]{6}', content)
|
| 131 |
+
if match:
|
| 132 |
+
code = match.group(0)
|
| 133 |
+
self._log("info", f"🎉 在 {mailbox} 中找到验证码: {code}")
|
| 134 |
+
return code
|
| 135 |
+
|
| 136 |
+
if checked_count > 0:
|
| 137 |
+
self._log("info", f"🔍 已检查 {mailbox} 中 {checked_count} 封近期邮件,未找到验证码")
|
| 138 |
+
|
| 139 |
+
self._log("warning", "⚠️ 所有邮箱中均未找到验证码")
|
| 140 |
+
finally:
|
| 141 |
+
try:
|
| 142 |
+
client.logout()
|
| 143 |
+
except Exception:
|
| 144 |
+
pass
|
| 145 |
+
|
| 146 |
+
return None
|
| 147 |
+
|
| 148 |
+
def poll_for_code(
|
| 149 |
+
self,
|
| 150 |
+
timeout: int = 120,
|
| 151 |
+
interval: int = 4,
|
| 152 |
+
since_time: Optional[datetime] = None,
|
| 153 |
+
) -> Optional[str]:
|
| 154 |
+
if not self.email:
|
| 155 |
+
return None
|
| 156 |
+
|
| 157 |
+
max_retries = max(1, timeout // interval)
|
| 158 |
+
self._log("info", f"⏱️ 开始轮询验证码 (超时 {timeout}秒, 间隔 {interval}秒, 最多 {max_retries} 次)")
|
| 159 |
+
|
| 160 |
+
for i in range(1, max_retries + 1):
|
| 161 |
+
# 检查任务是否被取消(通过 log 触发 TaskCancelledError)
|
| 162 |
+
self._log("info", f"🔄 第 {i}/{max_retries} 次轮询...")
|
| 163 |
+
code = self.fetch_verification_code(since_time=since_time)
|
| 164 |
+
if code:
|
| 165 |
+
self._log("info", f"🎉 验证码获取成功: {code}")
|
| 166 |
+
return code
|
| 167 |
+
if i < max_retries:
|
| 168 |
+
# 分段 sleep,每5秒检查一次取消状态
|
| 169 |
+
for _ in range(interval // CANCELLATION_CHECK_INTERVAL_SECONDS):
|
| 170 |
+
time.sleep(CANCELLATION_CHECK_INTERVAL_SECONDS)
|
| 171 |
+
# 通过 log 检查取消状态(使用有意义的日志)
|
| 172 |
+
self._log("debug", f"等待验证码中... ({(_ + 1) * CANCELLATION_CHECK_INTERVAL_SECONDS}/{interval}秒)")
|
| 173 |
+
# 处理剩余的秒数
|
| 174 |
+
remaining = interval % CANCELLATION_CHECK_INTERVAL_SECONDS
|
| 175 |
+
if remaining > 0:
|
| 176 |
+
time.sleep(remaining)
|
| 177 |
+
|
| 178 |
+
self._log("error", "❌ 验证码获取超时")
|
| 179 |
+
return None
|
| 180 |
+
|
| 181 |
+
@staticmethod
|
| 182 |
+
def _message_to_text(msg) -> str:
|
| 183 |
+
if msg.is_multipart():
|
| 184 |
+
parts = []
|
| 185 |
+
for part in msg.walk():
|
| 186 |
+
content_type = part.get_content_type()
|
| 187 |
+
if content_type not in ("text/plain", "text/html"):
|
| 188 |
+
continue
|
| 189 |
+
payload = part.get_payload(decode=True)
|
| 190 |
+
if not payload:
|
| 191 |
+
continue
|
| 192 |
+
charset = part.get_content_charset() or "utf-8"
|
| 193 |
+
parts.append(payload.decode(charset, errors="ignore"))
|
| 194 |
+
return "".join(parts)
|
| 195 |
+
payload = msg.get_payload(decode=True)
|
| 196 |
+
if isinstance(payload, bytes):
|
| 197 |
+
return payload.decode(msg.get_content_charset() or "utf-8", errors="ignore")
|
| 198 |
+
return str(payload) if payload else ""
|
| 199 |
+
|
| 200 |
+
@staticmethod
|
| 201 |
+
def _parse_message_date(value: Optional[str]) -> Optional[datetime]:
|
| 202 |
+
if not value:
|
| 203 |
+
return None
|
| 204 |
+
try:
|
| 205 |
+
parsed = parsedate_to_datetime(value)
|
| 206 |
+
if parsed is None:
|
| 207 |
+
return None
|
| 208 |
+
if parsed.tzinfo:
|
| 209 |
+
return parsed.astimezone(tz=None).replace(tzinfo=None)
|
| 210 |
+
return parsed
|
| 211 |
+
except Exception:
|
| 212 |
+
return None
|
| 213 |
+
|
| 214 |
+
def _log(self, level: str, message: str) -> None:
|
| 215 |
+
if self.log_callback:
|
| 216 |
+
try:
|
| 217 |
+
self.log_callback(level, message)
|
| 218 |
+
except TaskCancelledError:
|
| 219 |
+
raise
|
| 220 |
+
except Exception:
|
| 221 |
+
pass
|
core/moemail_client.py
ADDED
|
@@ -0,0 +1,355 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Moemail临时邮箱客户端
|
| 3 |
+
|
| 4 |
+
API文档参考:
|
| 5 |
+
- 获取系统配置: GET /api/config
|
| 6 |
+
- 生成临时邮箱: POST /api/emails/generate
|
| 7 |
+
- 获取邮件列表: GET /api/emails/{emailId}
|
| 8 |
+
- 获取单封邮件: GET /api/emails/{emailId}/{messageId}
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
import random
|
| 12 |
+
import string
|
| 13 |
+
import time
|
| 14 |
+
from typing import Optional
|
| 15 |
+
|
| 16 |
+
import requests
|
| 17 |
+
|
| 18 |
+
from core.mail_utils import extract_verification_code
|
| 19 |
+
from core.proxy_utils import request_with_proxy_fallback
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
class MoemailClient:
|
| 23 |
+
"""Moemail临时邮箱客户端"""
|
| 24 |
+
|
| 25 |
+
def __init__(
|
| 26 |
+
self,
|
| 27 |
+
base_url: str = "https://moemail.nanohajimi.mom",
|
| 28 |
+
proxy: str = "",
|
| 29 |
+
api_key: str = "",
|
| 30 |
+
domain: str = "",
|
| 31 |
+
log_callback=None,
|
| 32 |
+
) -> None:
|
| 33 |
+
self.base_url = base_url.rstrip("/")
|
| 34 |
+
self.proxies = {"http": proxy, "https": proxy} if proxy else None
|
| 35 |
+
self.api_key = api_key.strip()
|
| 36 |
+
self.domain = domain.strip() if domain else ""
|
| 37 |
+
self.log_callback = log_callback
|
| 38 |
+
|
| 39 |
+
self.email: Optional[str] = None
|
| 40 |
+
self.email_id: Optional[str] = None
|
| 41 |
+
self.password: Optional[str] = None # 兼容 DuckMailClient 接口
|
| 42 |
+
|
| 43 |
+
# 缓存可用域名列表
|
| 44 |
+
self._available_domains: list = []
|
| 45 |
+
|
| 46 |
+
def set_credentials(self, email: str, password: str = "") -> None:
|
| 47 |
+
"""设置凭据(兼容 DuckMailClient 接口)"""
|
| 48 |
+
self.email = email
|
| 49 |
+
self.password = password
|
| 50 |
+
|
| 51 |
+
def _request(self, method: str, url: str, **kwargs) -> requests.Response:
|
| 52 |
+
"""发送请求并打印详细日志"""
|
| 53 |
+
headers = kwargs.pop("headers", None) or {}
|
| 54 |
+
if self.api_key and "X-API-Key" not in headers:
|
| 55 |
+
headers["X-API-Key"] = self.api_key
|
| 56 |
+
headers.setdefault("Content-Type", "application/json")
|
| 57 |
+
kwargs["headers"] = headers
|
| 58 |
+
|
| 59 |
+
self._log("info", f"📤 发送 {method} 请求: {url}")
|
| 60 |
+
if "json" in kwargs:
|
| 61 |
+
self._log("info", f"📦 请求体: {kwargs['json']}")
|
| 62 |
+
|
| 63 |
+
try:
|
| 64 |
+
res = request_with_proxy_fallback(
|
| 65 |
+
requests.request,
|
| 66 |
+
method,
|
| 67 |
+
url,
|
| 68 |
+
proxies=self.proxies,
|
| 69 |
+
timeout=kwargs.pop("timeout", 30),
|
| 70 |
+
**kwargs,
|
| 71 |
+
)
|
| 72 |
+
self._log("info", f"📥 收到响应: HTTP {res.status_code}")
|
| 73 |
+
if res.content and res.status_code >= 400:
|
| 74 |
+
try:
|
| 75 |
+
self._log("error", f"📄 响应内容: {res.text[:500]}")
|
| 76 |
+
except Exception:
|
| 77 |
+
pass
|
| 78 |
+
return res
|
| 79 |
+
except Exception as e:
|
| 80 |
+
self._log("error", f"❌ 网络请求失败: {e}")
|
| 81 |
+
raise
|
| 82 |
+
|
| 83 |
+
def _get_available_domains(self) -> list:
|
| 84 |
+
"""获取可用的邮箱域名列表"""
|
| 85 |
+
if self._available_domains:
|
| 86 |
+
return self._available_domains
|
| 87 |
+
|
| 88 |
+
try:
|
| 89 |
+
res = self._request("GET", f"{self.base_url}/api/config")
|
| 90 |
+
if res.status_code == 200:
|
| 91 |
+
data = res.json()
|
| 92 |
+
email_domains_str = data.get("emailDomains", "")
|
| 93 |
+
if email_domains_str:
|
| 94 |
+
self._available_domains = [d.strip() for d in email_domains_str.split(",") if d.strip()]
|
| 95 |
+
self._log("info", f"🌐 Moemail 可用域名: {self._available_domains}")
|
| 96 |
+
return self._available_domains
|
| 97 |
+
except Exception as e:
|
| 98 |
+
self._log("error", f"❌ 获取可用域名失败: {e}")
|
| 99 |
+
|
| 100 |
+
# 默认域名
|
| 101 |
+
self._available_domains = ["moemail.app"]
|
| 102 |
+
return self._available_domains
|
| 103 |
+
|
| 104 |
+
def register_account(self, domain: Optional[str] = None) -> bool:
|
| 105 |
+
"""注册新邮箱账号
|
| 106 |
+
|
| 107 |
+
API: POST /api/emails/generate
|
| 108 |
+
"""
|
| 109 |
+
# 确定使用的域名
|
| 110 |
+
selected_domain = domain
|
| 111 |
+
if not selected_domain:
|
| 112 |
+
selected_domain = self.domain
|
| 113 |
+
|
| 114 |
+
if not selected_domain:
|
| 115 |
+
# 从可用域名中随机选择
|
| 116 |
+
available = self._get_available_domains()
|
| 117 |
+
if available:
|
| 118 |
+
selected_domain = random.choice(available)
|
| 119 |
+
else:
|
| 120 |
+
selected_domain = "moemail.app"
|
| 121 |
+
|
| 122 |
+
self._log("info", f"📧 使用域名: {selected_domain}")
|
| 123 |
+
|
| 124 |
+
# 生成随机邮箱名称
|
| 125 |
+
rand = "".join(random.choices(string.ascii_lowercase + string.digits, k=10))
|
| 126 |
+
timestamp = str(int(time.time()))[-4:]
|
| 127 |
+
name = f"t{timestamp}{rand}"
|
| 128 |
+
|
| 129 |
+
self._log("info", f"🎲 生成邮箱: {name}@{selected_domain}")
|
| 130 |
+
|
| 131 |
+
try:
|
| 132 |
+
# 设置为 0 表示永久有效
|
| 133 |
+
self._log("info", f"⏰ 设置过期时间: 永久有效")
|
| 134 |
+
|
| 135 |
+
res = self._request(
|
| 136 |
+
"POST",
|
| 137 |
+
f"{self.base_url}/api/emails/generate",
|
| 138 |
+
json={
|
| 139 |
+
"name": name,
|
| 140 |
+
"expiryTime": 0,
|
| 141 |
+
"domain": selected_domain,
|
| 142 |
+
},
|
| 143 |
+
)
|
| 144 |
+
|
| 145 |
+
if res.status_code in (200, 201):
|
| 146 |
+
data = res.json() if res.content else {}
|
| 147 |
+
self.email = data.get("email", "")
|
| 148 |
+
self.email_id = data.get("id", "")
|
| 149 |
+
self.password = self.email_id # 用 email_id 作为 password 存储
|
| 150 |
+
|
| 151 |
+
if self.email and self.email_id:
|
| 152 |
+
self._log("info", f"✅ Moemail 注册成功: {self.email}")
|
| 153 |
+
self._log("info", f"🔑 Email ID: {self.email_id}")
|
| 154 |
+
return True
|
| 155 |
+
|
| 156 |
+
self._log("error", f"❌ Moemail 注册失败: HTTP {res.status_code}")
|
| 157 |
+
if res.content:
|
| 158 |
+
self._log("error", f"📄 响应内容: {res.text[:500]}")
|
| 159 |
+
return False
|
| 160 |
+
|
| 161 |
+
except Exception as e:
|
| 162 |
+
self._log("error", f"❌ Moemail 注册异常: {e}")
|
| 163 |
+
return False
|
| 164 |
+
|
| 165 |
+
def login(self) -> bool:
|
| 166 |
+
"""登录(Moemail 无需登录,返回 True)"""
|
| 167 |
+
# Moemail 使用 API Key 认证,无需单独登录
|
| 168 |
+
return True
|
| 169 |
+
|
| 170 |
+
def fetch_verification_code(self, since_time=None) -> Optional[str]:
|
| 171 |
+
"""获取验证码
|
| 172 |
+
|
| 173 |
+
API: GET /api/emails/{emailId}
|
| 174 |
+
API: GET /api/emails/{emailId}/{messageId}
|
| 175 |
+
"""
|
| 176 |
+
if not self.email_id:
|
| 177 |
+
self._log("error", "❌ 缺少 email_id,无法获取邮件")
|
| 178 |
+
return None
|
| 179 |
+
|
| 180 |
+
try:
|
| 181 |
+
self._log("info", "📬 正在拉取 Moemail 邮件列表...")
|
| 182 |
+
|
| 183 |
+
# 获取邮件列表
|
| 184 |
+
res = self._request(
|
| 185 |
+
"GET",
|
| 186 |
+
f"{self.base_url}/api/emails/{self.email_id}",
|
| 187 |
+
)
|
| 188 |
+
|
| 189 |
+
if res.status_code != 200:
|
| 190 |
+
self._log("error", f"❌ 获取邮件列表失败: HTTP {res.status_code}")
|
| 191 |
+
return None
|
| 192 |
+
|
| 193 |
+
data = res.json() if res.content else {}
|
| 194 |
+
messages = data.get("messages", [])
|
| 195 |
+
|
| 196 |
+
if not messages:
|
| 197 |
+
self._log("info", "📭 邮箱为空,暂无邮件")
|
| 198 |
+
return None
|
| 199 |
+
|
| 200 |
+
self._log("info", f"📨 收到 {len(messages)} 封邮件,开始检查验证码...")
|
| 201 |
+
|
| 202 |
+
from datetime import datetime
|
| 203 |
+
|
| 204 |
+
def _parse_message_time(msg_obj) -> Optional[datetime]:
|
| 205 |
+
import re
|
| 206 |
+
|
| 207 |
+
time_keys = [
|
| 208 |
+
"createdAt",
|
| 209 |
+
"receivedAt",
|
| 210 |
+
"sentAt",
|
| 211 |
+
"created_at",
|
| 212 |
+
"received_at",
|
| 213 |
+
"sent_at",
|
| 214 |
+
]
|
| 215 |
+
raw_time = None
|
| 216 |
+
for key in time_keys:
|
| 217 |
+
if msg_obj.get(key) is not None:
|
| 218 |
+
raw_time = msg_obj.get(key)
|
| 219 |
+
break
|
| 220 |
+
|
| 221 |
+
if raw_time is None:
|
| 222 |
+
return None
|
| 223 |
+
|
| 224 |
+
if isinstance(raw_time, (int, float)):
|
| 225 |
+
timestamp = float(raw_time)
|
| 226 |
+
if timestamp > 1e12:
|
| 227 |
+
timestamp = timestamp / 1000.0
|
| 228 |
+
return datetime.fromtimestamp(timestamp)
|
| 229 |
+
|
| 230 |
+
if isinstance(raw_time, str):
|
| 231 |
+
raw_time = raw_time.strip()
|
| 232 |
+
if raw_time.isdigit():
|
| 233 |
+
timestamp = float(raw_time)
|
| 234 |
+
if timestamp > 1e12:
|
| 235 |
+
timestamp = timestamp / 1000.0
|
| 236 |
+
return datetime.fromtimestamp(timestamp)
|
| 237 |
+
|
| 238 |
+
# 处理 ISO 时间字符串
|
| 239 |
+
try:
|
| 240 |
+
# 截断纳秒到微秒
|
| 241 |
+
raw_time = re.sub(r"(\.\d{6})\d+", r"\1", raw_time)
|
| 242 |
+
return datetime.fromisoformat(raw_time.replace("Z", "+00:00")).astimezone().replace(tzinfo=None)
|
| 243 |
+
except Exception:
|
| 244 |
+
return None
|
| 245 |
+
|
| 246 |
+
return None
|
| 247 |
+
|
| 248 |
+
def _looks_like_verification(msg_obj) -> bool:
|
| 249 |
+
subject = (msg_obj.get("subject") or "").strip()
|
| 250 |
+
if not subject:
|
| 251 |
+
return False
|
| 252 |
+
import re
|
| 253 |
+
return re.search(r"(验证码|验证|verification|verify|passcode|security\s*code|one[-\s]?time|otp)", subject, re.IGNORECASE) is not None
|
| 254 |
+
|
| 255 |
+
messages_with_time = [(msg, _parse_message_time(msg)) for msg in messages]
|
| 256 |
+
if any(item[1] for item in messages_with_time):
|
| 257 |
+
messages_with_time.sort(key=lambda item: item[1] or datetime.min, reverse=True)
|
| 258 |
+
messages = [item[0] for item in messages_with_time]
|
| 259 |
+
|
| 260 |
+
# 遍历邮件
|
| 261 |
+
for idx, msg in enumerate(messages, 1):
|
| 262 |
+
msg_id = msg.get("id")
|
| 263 |
+
if not msg_id:
|
| 264 |
+
continue
|
| 265 |
+
|
| 266 |
+
# 时间过滤
|
| 267 |
+
if since_time:
|
| 268 |
+
msg_time = _parse_message_time(msg)
|
| 269 |
+
if msg_time:
|
| 270 |
+
if msg_time < since_time:
|
| 271 |
+
continue
|
| 272 |
+
|
| 273 |
+
if not _looks_like_verification(msg):
|
| 274 |
+
continue
|
| 275 |
+
|
| 276 |
+
# 优先从邮件列表的 content 字段提取验证码(更高效)
|
| 277 |
+
list_content = msg.get("content") or ""
|
| 278 |
+
if list_content:
|
| 279 |
+
code = extract_verification_code(list_content)
|
| 280 |
+
if code:
|
| 281 |
+
self._log("info", f"✅ 找到验证码: {code}")
|
| 282 |
+
return code
|
| 283 |
+
|
| 284 |
+
# 如果列表没有 content,则获取邮件详情
|
| 285 |
+
self._log("info", f"🔍 正在读取邮件 {idx}/{len(messages)} 详情...")
|
| 286 |
+
detail_res = self._request(
|
| 287 |
+
"GET",
|
| 288 |
+
f"{self.base_url}/api/emails/{self.email_id}/{msg_id}",
|
| 289 |
+
)
|
| 290 |
+
|
| 291 |
+
if detail_res.status_code != 200:
|
| 292 |
+
self._log("warning", f"⚠️ 读取邮件详情失败: HTTP {detail_res.status_code}")
|
| 293 |
+
continue
|
| 294 |
+
|
| 295 |
+
detail = detail_res.json() if detail_res.content else {}
|
| 296 |
+
|
| 297 |
+
# 处理 {'message': {...}} 格式
|
| 298 |
+
if "message" in detail and isinstance(detail["message"], dict):
|
| 299 |
+
detail = detail["message"]
|
| 300 |
+
|
| 301 |
+
# 获取邮件内容
|
| 302 |
+
text_content = detail.get("text") or detail.get("textContent") or detail.get("content") or ""
|
| 303 |
+
html_content = detail.get("html") or detail.get("htmlContent") or ""
|
| 304 |
+
|
| 305 |
+
if isinstance(html_content, list):
|
| 306 |
+
html_content = "".join(str(item) for item in html_content)
|
| 307 |
+
if isinstance(text_content, list):
|
| 308 |
+
text_content = "".join(str(item) for item in text_content)
|
| 309 |
+
|
| 310 |
+
content = text_content + html_content
|
| 311 |
+
if content:
|
| 312 |
+
code = extract_verification_code(content)
|
| 313 |
+
if code:
|
| 314 |
+
self._log("info", f"✅ 找到验证码: {code}")
|
| 315 |
+
return code
|
| 316 |
+
else:
|
| 317 |
+
self._log("info", f"❌ 邮件 {idx} 中未找到验证码")
|
| 318 |
+
|
| 319 |
+
self._log("warning", "⚠️ 所有邮件中均未找到验证码")
|
| 320 |
+
return None
|
| 321 |
+
|
| 322 |
+
except Exception as e:
|
| 323 |
+
self._log("error", f"❌ 获取验证码异常: {e}")
|
| 324 |
+
return None
|
| 325 |
+
|
| 326 |
+
def poll_for_code(
|
| 327 |
+
self,
|
| 328 |
+
timeout: int = 120,
|
| 329 |
+
interval: int = 4,
|
| 330 |
+
since_time=None,
|
| 331 |
+
) -> Optional[str]:
|
| 332 |
+
"""轮询获取验证码"""
|
| 333 |
+
max_retries = max(1, timeout // interval)
|
| 334 |
+
self._log("info", f"⏱️ 开始轮询验证码 (超时 {timeout}秒, 间隔 {interval}秒, 最多 {max_retries} 次)")
|
| 335 |
+
|
| 336 |
+
for i in range(1, max_retries + 1):
|
| 337 |
+
self._log("info", f"🔄 第 {i}/{max_retries} 次轮询...")
|
| 338 |
+
code = self.fetch_verification_code(since_time=since_time)
|
| 339 |
+
if code:
|
| 340 |
+
self._log("info", f"🎉 验证码获取成功: {code}")
|
| 341 |
+
return code
|
| 342 |
+
|
| 343 |
+
if i < max_retries:
|
| 344 |
+
self._log("info", f"⏳ 等待 {interval} 秒后重试...")
|
| 345 |
+
time.sleep(interval)
|
| 346 |
+
|
| 347 |
+
self._log("error", f"⏰ 验证码获取超时 ({timeout}秒)")
|
| 348 |
+
return None
|
| 349 |
+
|
| 350 |
+
def _log(self, level: str, message: str) -> None:
|
| 351 |
+
if self.log_callback:
|
| 352 |
+
try:
|
| 353 |
+
self.log_callback(level, message)
|
| 354 |
+
except Exception:
|
| 355 |
+
pass
|
core/proxy_utils.py
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
代理设置工具函数
|
| 3 |
+
|
| 4 |
+
支持格式:
|
| 5 |
+
- http://127.0.0.1:7890
|
| 6 |
+
- http://user:pass@127.0.0.1:7890
|
| 7 |
+
- socks5h://127.0.0.1:7890
|
| 8 |
+
- socks5h://user:pass@127.0.0.1:7890 | no_proxy=localhost,127.0.0.1,.local
|
| 9 |
+
|
| 10 |
+
NO_PROXY 格式:
|
| 11 |
+
- 逗号分隔的主机名或域名后缀
|
| 12 |
+
- 支持通配符前缀,如 .local 匹配 *.local
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
import re
|
| 16 |
+
from typing import Tuple, Callable, Any, Optional
|
| 17 |
+
from urllib.parse import urlparse
|
| 18 |
+
import functools
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def parse_proxy_setting(proxy_str: str) -> Tuple[str, str]:
|
| 22 |
+
"""
|
| 23 |
+
解析代理设置字符串,提取代理 URL 和 NO_PROXY 列表
|
| 24 |
+
|
| 25 |
+
Args:
|
| 26 |
+
proxy_str: 代理设置字符串,格式如 "http://127.0.0.1:7890 | no_proxy=localhost,127.0.0.1"
|
| 27 |
+
|
| 28 |
+
Returns:
|
| 29 |
+
Tuple[str, str]: (proxy_url, no_proxy_list)
|
| 30 |
+
- proxy_url: 代理地址,如 "http://127.0.0.1:7890"
|
| 31 |
+
- no_proxy_list: NO_PROXY 列表字符串,如 "localhost,127.0.0.1"
|
| 32 |
+
"""
|
| 33 |
+
if not proxy_str:
|
| 34 |
+
return "", ""
|
| 35 |
+
|
| 36 |
+
proxy_str = proxy_str.strip()
|
| 37 |
+
if not proxy_str:
|
| 38 |
+
return "", ""
|
| 39 |
+
|
| 40 |
+
# 检查是否包含 no_proxy 设置
|
| 41 |
+
# 支持格式: proxy_url | no_proxy=host1,host2
|
| 42 |
+
no_proxy = ""
|
| 43 |
+
proxy_url = proxy_str
|
| 44 |
+
|
| 45 |
+
# 使用 | 分隔代理和 no_proxy
|
| 46 |
+
if "|" in proxy_str:
|
| 47 |
+
parts = proxy_str.split("|", 1)
|
| 48 |
+
proxy_url = parts[0].strip()
|
| 49 |
+
no_proxy_part = parts[1].strip()
|
| 50 |
+
|
| 51 |
+
# 解析 no_proxy=xxx 格式
|
| 52 |
+
no_proxy_match = re.match(r"no_proxy\s*=\s*(.+)", no_proxy_part, re.IGNORECASE)
|
| 53 |
+
if no_proxy_match:
|
| 54 |
+
no_proxy = no_proxy_match.group(1).strip()
|
| 55 |
+
|
| 56 |
+
return normalize_proxy_url(proxy_url), no_proxy
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def extract_host(url: str) -> str:
|
| 60 |
+
"""
|
| 61 |
+
从 URL 中提取主机名
|
| 62 |
+
|
| 63 |
+
Args:
|
| 64 |
+
url: 完整 URL,如 "https://mail.chatgpt.org.uk/api/emails"
|
| 65 |
+
|
| 66 |
+
Returns:
|
| 67 |
+
str: 主机名,如 "mail.chatgpt.org.uk"
|
| 68 |
+
"""
|
| 69 |
+
if not url:
|
| 70 |
+
return ""
|
| 71 |
+
|
| 72 |
+
url = url.strip()
|
| 73 |
+
if not url:
|
| 74 |
+
return ""
|
| 75 |
+
|
| 76 |
+
# 如果没有协议前缀,添加一个以便解析
|
| 77 |
+
if not url.startswith(("http://", "https://", "socks5://", "socks5h://")):
|
| 78 |
+
url = "http://" + url
|
| 79 |
+
|
| 80 |
+
try:
|
| 81 |
+
parsed = urlparse(url)
|
| 82 |
+
return parsed.hostname or ""
|
| 83 |
+
except Exception:
|
| 84 |
+
return ""
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def no_proxy_matches(host: str, no_proxy: str) -> bool:
|
| 88 |
+
"""
|
| 89 |
+
检查主机是否在 NO_PROXY 豁免列表中
|
| 90 |
+
|
| 91 |
+
Args:
|
| 92 |
+
host: 要检查的主机名,如 "mail.chatgpt.org.uk"
|
| 93 |
+
no_proxy: NO_PROXY 列表字符串,如 "localhost,127.0.0.1,.local"
|
| 94 |
+
|
| 95 |
+
Returns:
|
| 96 |
+
bool: 如果主机在豁免列表中返回 True,否则返回 False
|
| 97 |
+
|
| 98 |
+
匹配规则:
|
| 99 |
+
- 精确匹配: "localhost" 匹配 "localhost"
|
| 100 |
+
- 域名后缀匹配: ".local" 匹配 "foo.local", "bar.foo.local"
|
| 101 |
+
- IP 地址匹配: "127.0.0.1" 精确匹配
|
| 102 |
+
"""
|
| 103 |
+
if not host or not no_proxy:
|
| 104 |
+
return False
|
| 105 |
+
|
| 106 |
+
host = host.lower().strip()
|
| 107 |
+
if not host:
|
| 108 |
+
return False
|
| 109 |
+
|
| 110 |
+
# 解析 no_proxy 列表
|
| 111 |
+
no_proxy_list = [item.strip().lower() for item in no_proxy.split(",") if item.strip()]
|
| 112 |
+
|
| 113 |
+
for pattern in no_proxy_list:
|
| 114 |
+
if not pattern:
|
| 115 |
+
continue
|
| 116 |
+
|
| 117 |
+
# 精确匹配
|
| 118 |
+
if host == pattern:
|
| 119 |
+
return True
|
| 120 |
+
|
| 121 |
+
# 域名后缀匹配 (如 .local 匹配 foo.local)
|
| 122 |
+
if pattern.startswith("."):
|
| 123 |
+
if host.endswith(pattern) or host == pattern[1:]:
|
| 124 |
+
return True
|
| 125 |
+
else:
|
| 126 |
+
# 也支持不带点的后缀匹配 (如 local 匹配 foo.local)
|
| 127 |
+
if host.endswith("." + pattern):
|
| 128 |
+
return True
|
| 129 |
+
|
| 130 |
+
return False
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
def normalize_proxy_url(proxy_str: str) -> str:
|
| 134 |
+
"""
|
| 135 |
+
标准化代理 URL 格式
|
| 136 |
+
|
| 137 |
+
支持的输入格式:
|
| 138 |
+
- http://127.0.0.1:7890
|
| 139 |
+
- http://user:pass@127.0.0.1:7890
|
| 140 |
+
- socks5://127.0.0.1:7890
|
| 141 |
+
- socks5h://127.0.0.1:7890
|
| 142 |
+
- 127.0.0.1:7890 (自动添加 http://)
|
| 143 |
+
- host:port:user:pass (旧格式,自动转换)
|
| 144 |
+
|
| 145 |
+
Returns:
|
| 146 |
+
str: 标准化的代理 URL
|
| 147 |
+
"""
|
| 148 |
+
if not proxy_str:
|
| 149 |
+
return ""
|
| 150 |
+
|
| 151 |
+
proxy_str = proxy_str.strip()
|
| 152 |
+
if not proxy_str:
|
| 153 |
+
return ""
|
| 154 |
+
|
| 155 |
+
# 如果已经是标准 URL 格式,直接返回
|
| 156 |
+
if proxy_str.startswith(("http://", "https://", "socks5://", "socks5h://")):
|
| 157 |
+
return proxy_str
|
| 158 |
+
|
| 159 |
+
# 尝试解析旧格式 host:port:user:pass
|
| 160 |
+
parts = proxy_str.split(":")
|
| 161 |
+
if len(parts) == 4:
|
| 162 |
+
host, port, user, password = parts
|
| 163 |
+
return f"http://{user}:{password}@{host}:{port}"
|
| 164 |
+
elif len(parts) == 2:
|
| 165 |
+
# host:port 格式
|
| 166 |
+
return f"http://{proxy_str}"
|
| 167 |
+
|
| 168 |
+
# 无法识别的格式,尝试添加 http:// 前缀
|
| 169 |
+
return f"http://{proxy_str}"
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
def request_with_proxy_fallback(request_func: Callable, *args, **kwargs) -> Any:
|
| 173 |
+
"""
|
| 174 |
+
带代理失败回退的请求包装器
|
| 175 |
+
|
| 176 |
+
如果代理连接失败,自动禁用代理重试一次
|
| 177 |
+
|
| 178 |
+
Args:
|
| 179 |
+
request_func: 原始请求函数
|
| 180 |
+
*args, **kwargs: 传递给请求函数的参数
|
| 181 |
+
|
| 182 |
+
Returns:
|
| 183 |
+
请求响应对象
|
| 184 |
+
|
| 185 |
+
Raises:
|
| 186 |
+
原始异常(如果直连也失败)
|
| 187 |
+
"""
|
| 188 |
+
# 代理相关的错误类型
|
| 189 |
+
PROXY_ERRORS = (
|
| 190 |
+
"ProxyError",
|
| 191 |
+
"ConnectTimeout",
|
| 192 |
+
"ConnectionError",
|
| 193 |
+
"407", # Proxy Authentication Required
|
| 194 |
+
"502", # Bad Gateway (代理问题)
|
| 195 |
+
"503", # Service Unavailable (代理问题)
|
| 196 |
+
)
|
| 197 |
+
|
| 198 |
+
try:
|
| 199 |
+
# 首次尝试(使用代理)
|
| 200 |
+
return request_func(*args, **kwargs)
|
| 201 |
+
except Exception as e:
|
| 202 |
+
error_str = str(e)
|
| 203 |
+
error_type = type(e).__name__
|
| 204 |
+
|
| 205 |
+
# 检查是否是代理相关错误
|
| 206 |
+
is_proxy_error = any(err in error_str or err in error_type for err in PROXY_ERRORS)
|
| 207 |
+
|
| 208 |
+
if is_proxy_error and "proxies" in kwargs:
|
| 209 |
+
# 禁用代理重试
|
| 210 |
+
original_proxies = kwargs.get("proxies")
|
| 211 |
+
kwargs["proxies"] = None
|
| 212 |
+
|
| 213 |
+
try:
|
| 214 |
+
# 直连重试
|
| 215 |
+
return request_func(*args, **kwargs)
|
| 216 |
+
except Exception:
|
| 217 |
+
# 直连也失败,恢复原始代理设置并抛出原始异常
|
| 218 |
+
kwargs["proxies"] = original_proxies
|
| 219 |
+
raise e
|
| 220 |
+
else:
|
| 221 |
+
# 不是代理错误,直接抛出
|
| 222 |
+
raise
|
core/register_service.py
ADDED
|
@@ -0,0 +1,289 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import asyncio
|
| 2 |
+
import logging
|
| 3 |
+
import os
|
| 4 |
+
import time
|
| 5 |
+
import uuid
|
| 6 |
+
from dataclasses import dataclass, field
|
| 7 |
+
from typing import Any, Callable, Dict, List, Optional
|
| 8 |
+
|
| 9 |
+
from core.account import load_accounts_from_source
|
| 10 |
+
from core.base_task_service import BaseTask, BaseTaskService, TaskCancelledError, TaskStatus
|
| 11 |
+
from core.config import config
|
| 12 |
+
from core.mail_providers import create_temp_mail_client
|
| 13 |
+
from core.gemini_automation import GeminiAutomation
|
| 14 |
+
from core.proxy_utils import parse_proxy_setting
|
| 15 |
+
|
| 16 |
+
logger = logging.getLogger("gemini.register")
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
@dataclass
|
| 20 |
+
class RegisterTask(BaseTask):
|
| 21 |
+
"""注册任务数据类"""
|
| 22 |
+
count: int = 0
|
| 23 |
+
domain: Optional[str] = None
|
| 24 |
+
mail_provider: Optional[str] = None
|
| 25 |
+
|
| 26 |
+
def to_dict(self) -> dict:
|
| 27 |
+
"""转换为字典"""
|
| 28 |
+
base_dict = super().to_dict()
|
| 29 |
+
base_dict["count"] = self.count
|
| 30 |
+
base_dict["domain"] = self.domain
|
| 31 |
+
base_dict["mail_provider"] = self.mail_provider
|
| 32 |
+
return base_dict
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
class RegisterService(BaseTaskService[RegisterTask]):
|
| 36 |
+
"""注册服务类"""
|
| 37 |
+
|
| 38 |
+
def __init__(
|
| 39 |
+
self,
|
| 40 |
+
multi_account_mgr,
|
| 41 |
+
http_client,
|
| 42 |
+
user_agent: str,
|
| 43 |
+
retry_policy,
|
| 44 |
+
session_cache_ttl_seconds: int,
|
| 45 |
+
global_stats_provider: Callable[[], dict],
|
| 46 |
+
set_multi_account_mgr: Optional[Callable[[Any], None]] = None,
|
| 47 |
+
) -> None:
|
| 48 |
+
super().__init__(
|
| 49 |
+
multi_account_mgr,
|
| 50 |
+
http_client,
|
| 51 |
+
user_agent,
|
| 52 |
+
retry_policy,
|
| 53 |
+
session_cache_ttl_seconds,
|
| 54 |
+
global_stats_provider,
|
| 55 |
+
set_multi_account_mgr,
|
| 56 |
+
log_prefix="REGISTER",
|
| 57 |
+
)
|
| 58 |
+
|
| 59 |
+
def _get_running_task(self) -> Optional[RegisterTask]:
|
| 60 |
+
"""获取正在运行或等待中的任务"""
|
| 61 |
+
for task in self._tasks.values():
|
| 62 |
+
if isinstance(task, RegisterTask) and task.status in (TaskStatus.PENDING, TaskStatus.RUNNING):
|
| 63 |
+
return task
|
| 64 |
+
return None
|
| 65 |
+
|
| 66 |
+
async def start_register(self, count: Optional[int] = None, domain: Optional[str] = None, mail_provider: Optional[str] = None) -> RegisterTask:
|
| 67 |
+
"""
|
| 68 |
+
启动注册任务 - 统一任务管理
|
| 69 |
+
- 如果有正在运行的任务,将新数量添加到该任务
|
| 70 |
+
- 如果没有正在运行的任务,创建新任务
|
| 71 |
+
"""
|
| 72 |
+
async with self._lock:
|
| 73 |
+
if os.environ.get("ACCOUNTS_CONFIG"):
|
| 74 |
+
raise ValueError("已设置 ACCOUNTS_CONFIG 环境变量,注册功能已禁用")
|
| 75 |
+
|
| 76 |
+
# 先确定使用哪个邮箱服务提供商
|
| 77 |
+
mail_provider_value = (mail_provider or "").strip().lower()
|
| 78 |
+
if not mail_provider_value:
|
| 79 |
+
mail_provider_value = (config.basic.temp_mail_provider or "duckmail").lower()
|
| 80 |
+
|
| 81 |
+
# 再确定使用哪个域名(只有 DuckMail 使用 register_domain 配置)
|
| 82 |
+
domain_value = (domain or "").strip()
|
| 83 |
+
if not domain_value:
|
| 84 |
+
if mail_provider_value == "duckmail":
|
| 85 |
+
domain_value = (config.basic.register_domain or "").strip() or None
|
| 86 |
+
else:
|
| 87 |
+
domain_value = None
|
| 88 |
+
|
| 89 |
+
register_count = count or config.basic.register_default_count
|
| 90 |
+
register_count = max(1, int(register_count))
|
| 91 |
+
|
| 92 |
+
# 检查是否有正在运行的任务
|
| 93 |
+
running_task = self._get_running_task()
|
| 94 |
+
|
| 95 |
+
if running_task:
|
| 96 |
+
# 将新数量添加到现有任务
|
| 97 |
+
running_task.count += register_count
|
| 98 |
+
self._append_log(
|
| 99 |
+
running_task,
|
| 100 |
+
"info",
|
| 101 |
+
f"📝 添加 {register_count} 个账户到现有任务 (总计: {running_task.count})"
|
| 102 |
+
)
|
| 103 |
+
return running_task
|
| 104 |
+
|
| 105 |
+
# 创建新任务
|
| 106 |
+
task = RegisterTask(id=str(uuid.uuid4()), count=register_count, domain=domain_value, mail_provider=mail_provider_value)
|
| 107 |
+
self._tasks[task.id] = task
|
| 108 |
+
self._append_log(task, "info", f"📝 创建注册任务 (数量: {register_count}, 域名: {domain_value or 'default'}, 提供商: {mail_provider_value})")
|
| 109 |
+
|
| 110 |
+
# 直接启动任务
|
| 111 |
+
self._current_task_id = task.id
|
| 112 |
+
asyncio.create_task(self._run_task_directly(task))
|
| 113 |
+
return task
|
| 114 |
+
|
| 115 |
+
async def _run_task_directly(self, task: RegisterTask) -> None:
|
| 116 |
+
"""直接执行任务"""
|
| 117 |
+
try:
|
| 118 |
+
await self._run_one_task(task)
|
| 119 |
+
finally:
|
| 120 |
+
# 任务完成后清理
|
| 121 |
+
async with self._lock:
|
| 122 |
+
if self._current_task_id == task.id:
|
| 123 |
+
self._current_task_id = None
|
| 124 |
+
|
| 125 |
+
def _execute_task(self, task: RegisterTask):
|
| 126 |
+
return self._run_register_async(task, task.domain, task.mail_provider)
|
| 127 |
+
|
| 128 |
+
async def _run_register_async(self, task: RegisterTask, domain: Optional[str], mail_provider: Optional[str]) -> None:
|
| 129 |
+
"""异步执行注册任务(支持取消)。"""
|
| 130 |
+
loop = asyncio.get_running_loop()
|
| 131 |
+
self._append_log(task, "info", f"🚀 注册任务已启动 (共 {task.count} 个账号)")
|
| 132 |
+
|
| 133 |
+
for idx in range(task.count):
|
| 134 |
+
if task.cancel_requested:
|
| 135 |
+
self._append_log(task, "warning", f"register task cancelled: {task.cancel_reason or 'cancelled'}")
|
| 136 |
+
task.status = TaskStatus.CANCELLED
|
| 137 |
+
task.finished_at = time.time()
|
| 138 |
+
return
|
| 139 |
+
|
| 140 |
+
try:
|
| 141 |
+
self._append_log(task, "info", f"📊 进度: {idx + 1}/{task.count}")
|
| 142 |
+
result = await loop.run_in_executor(self._executor, self._register_one, domain, mail_provider, task)
|
| 143 |
+
except TaskCancelledError:
|
| 144 |
+
task.status = TaskStatus.CANCELLED
|
| 145 |
+
task.finished_at = time.time()
|
| 146 |
+
return
|
| 147 |
+
except Exception as exc:
|
| 148 |
+
result = {"success": False, "error": str(exc)}
|
| 149 |
+
task.progress += 1
|
| 150 |
+
task.results.append(result)
|
| 151 |
+
|
| 152 |
+
if result.get("success"):
|
| 153 |
+
task.success_count += 1
|
| 154 |
+
email = result.get('email', '未知')
|
| 155 |
+
self._append_log(task, "info", "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
|
| 156 |
+
self._append_log(task, "info", f"✅ 注册成功: {email}")
|
| 157 |
+
self._append_log(task, "info", "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
|
| 158 |
+
else:
|
| 159 |
+
task.fail_count += 1
|
| 160 |
+
error = result.get('error', '未知错误')
|
| 161 |
+
self._append_log(task, "error", "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
|
| 162 |
+
self._append_log(task, "error", f"❌ 注册失败: {error}")
|
| 163 |
+
self._append_log(task, "error", "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
|
| 164 |
+
|
| 165 |
+
# 账号之间等待 10 秒,避免资源争抢和风控
|
| 166 |
+
if idx < task.count - 1 and not task.cancel_requested:
|
| 167 |
+
self._append_log(task, "info", "⏳ 等待 10 秒后处理下一个账号...")
|
| 168 |
+
await asyncio.sleep(10)
|
| 169 |
+
|
| 170 |
+
if task.cancel_requested:
|
| 171 |
+
task.status = TaskStatus.CANCELLED
|
| 172 |
+
else:
|
| 173 |
+
task.status = TaskStatus.SUCCESS if task.fail_count == 0 else TaskStatus.FAILED
|
| 174 |
+
task.finished_at = time.time()
|
| 175 |
+
self._current_task_id = None
|
| 176 |
+
self._append_log(task, "info", f"🏁 注册任务完成 (成功: {task.success_count}, 失败: {task.fail_count}, 总计: {task.count})")
|
| 177 |
+
|
| 178 |
+
def _register_one(self, domain: Optional[str], mail_provider: Optional[str], task: RegisterTask) -> dict:
|
| 179 |
+
"""注册单个账户"""
|
| 180 |
+
log_cb = lambda level, message: self._append_log(task, level, message)
|
| 181 |
+
|
| 182 |
+
log_cb("info", "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
|
| 183 |
+
log_cb("info", "🆕 开始注册新账户")
|
| 184 |
+
log_cb("info", "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
|
| 185 |
+
|
| 186 |
+
# 使用传递的邮件提供商参数,如果未提供则从配置读取
|
| 187 |
+
temp_mail_provider = (mail_provider or "").strip().lower()
|
| 188 |
+
if not temp_mail_provider:
|
| 189 |
+
temp_mail_provider = (config.basic.temp_mail_provider or "duckmail").lower()
|
| 190 |
+
|
| 191 |
+
log_cb("info", f"📧 步骤 1/3: 注册临时邮箱 (提供商={temp_mail_provider})...")
|
| 192 |
+
|
| 193 |
+
if temp_mail_provider == "freemail" and not config.basic.freemail_jwt_token:
|
| 194 |
+
log_cb("error", "❌ Freemail JWT Token 未配置")
|
| 195 |
+
return {"success": False, "error": "Freemail JWT Token 未配置"}
|
| 196 |
+
|
| 197 |
+
client = create_temp_mail_client(
|
| 198 |
+
temp_mail_provider,
|
| 199 |
+
domain=domain,
|
| 200 |
+
log_cb=log_cb,
|
| 201 |
+
)
|
| 202 |
+
|
| 203 |
+
if not client.register_account(domain=domain):
|
| 204 |
+
log_cb("error", f"❌ {temp_mail_provider} 邮箱注册失败")
|
| 205 |
+
return {"success": False, "error": f"{temp_mail_provider} 注册失败"}
|
| 206 |
+
|
| 207 |
+
log_cb("info", f"✅ 邮箱注册成功: {client.email}")
|
| 208 |
+
|
| 209 |
+
headless = config.basic.browser_headless
|
| 210 |
+
proxy_for_auth, _ = parse_proxy_setting(config.basic.proxy_for_auth)
|
| 211 |
+
|
| 212 |
+
log_cb("info", f"🌐 步骤 2/3: 启动浏览器 (无头模式={headless})...")
|
| 213 |
+
|
| 214 |
+
automation = GeminiAutomation(
|
| 215 |
+
user_agent=self.user_agent,
|
| 216 |
+
proxy=proxy_for_auth,
|
| 217 |
+
headless=headless,
|
| 218 |
+
log_callback=log_cb,
|
| 219 |
+
)
|
| 220 |
+
# 允许外部取消时立刻关闭浏览器
|
| 221 |
+
self._add_cancel_hook(task.id, lambda: getattr(automation, "stop", lambda: None)())
|
| 222 |
+
|
| 223 |
+
try:
|
| 224 |
+
log_cb("info", "🔐 步骤 3/3: 执行 Gemini 自动登录...")
|
| 225 |
+
result = automation.login_and_extract(client.email, client, is_new_account=True)
|
| 226 |
+
except Exception as exc:
|
| 227 |
+
log_cb("error", f"�� 自动登录异常: {exc}")
|
| 228 |
+
return {"success": False, "error": str(exc)}
|
| 229 |
+
|
| 230 |
+
if not result.get("success"):
|
| 231 |
+
error = result.get("error", "自动化流程失败")
|
| 232 |
+
log_cb("error", f"❌ 自动登录失败: {error}")
|
| 233 |
+
return {"success": False, "error": error}
|
| 234 |
+
|
| 235 |
+
log_cb("info", "✅ Gemini 登录成功,正在保存配置...")
|
| 236 |
+
|
| 237 |
+
config_data = result["config"]
|
| 238 |
+
config_data["mail_provider"] = temp_mail_provider
|
| 239 |
+
config_data["mail_address"] = client.email
|
| 240 |
+
|
| 241 |
+
# 保存邮箱自定义配置
|
| 242 |
+
if temp_mail_provider == "freemail":
|
| 243 |
+
config_data["mail_password"] = ""
|
| 244 |
+
config_data["mail_base_url"] = config.basic.freemail_base_url
|
| 245 |
+
config_data["mail_jwt_token"] = config.basic.freemail_jwt_token
|
| 246 |
+
config_data["mail_verify_ssl"] = config.basic.freemail_verify_ssl
|
| 247 |
+
config_data["mail_domain"] = config.basic.freemail_domain
|
| 248 |
+
elif temp_mail_provider == "gptmail":
|
| 249 |
+
config_data["mail_password"] = ""
|
| 250 |
+
config_data["mail_base_url"] = config.basic.gptmail_base_url
|
| 251 |
+
config_data["mail_api_key"] = config.basic.gptmail_api_key
|
| 252 |
+
config_data["mail_verify_ssl"] = config.basic.gptmail_verify_ssl
|
| 253 |
+
config_data["mail_domain"] = config.basic.gptmail_domain
|
| 254 |
+
elif temp_mail_provider == "cfmail":
|
| 255 |
+
config_data["mail_password"] = getattr(client, "jwt_token", "") or getattr(client, "password", "")
|
| 256 |
+
config_data["mail_base_url"] = config.basic.cfmail_base_url
|
| 257 |
+
config_data["mail_api_key"] = config.basic.cfmail_api_key
|
| 258 |
+
config_data["mail_verify_ssl"] = config.basic.cfmail_verify_ssl
|
| 259 |
+
config_data["mail_domain"] = config.basic.cfmail_domain
|
| 260 |
+
elif temp_mail_provider == "moemail":
|
| 261 |
+
config_data["mail_password"] = getattr(client, "email_id", "") or getattr(client, "password", "")
|
| 262 |
+
config_data["mail_base_url"] = config.basic.moemail_base_url
|
| 263 |
+
config_data["mail_api_key"] = config.basic.moemail_api_key
|
| 264 |
+
config_data["mail_domain"] = config.basic.moemail_domain
|
| 265 |
+
elif temp_mail_provider == "duckmail":
|
| 266 |
+
config_data["mail_password"] = getattr(client, "password", "")
|
| 267 |
+
config_data["mail_base_url"] = config.basic.duckmail_base_url
|
| 268 |
+
config_data["mail_api_key"] = config.basic.duckmail_api_key
|
| 269 |
+
else:
|
| 270 |
+
config_data["mail_password"] = getattr(client, "password", "")
|
| 271 |
+
|
| 272 |
+
accounts_data = load_accounts_from_source()
|
| 273 |
+
updated = False
|
| 274 |
+
for acc in accounts_data:
|
| 275 |
+
if acc.get("id") == config_data["id"]:
|
| 276 |
+
acc.update(config_data)
|
| 277 |
+
updated = True
|
| 278 |
+
break
|
| 279 |
+
if not updated:
|
| 280 |
+
accounts_data.append(config_data)
|
| 281 |
+
|
| 282 |
+
self._apply_accounts_update(accounts_data)
|
| 283 |
+
|
| 284 |
+
log_cb("info", "✅ 配置已保存到数据库")
|
| 285 |
+
log_cb("info", "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
|
| 286 |
+
log_cb("info", f"🎉 账户注册完成: {client.email}")
|
| 287 |
+
log_cb("info", "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
|
| 288 |
+
|
| 289 |
+
return {"success": True, "email": client.email, "config": config_data}
|
core/session_auth.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Session认证模块
|
| 3 |
+
提供基于Session的登录认证功能
|
| 4 |
+
"""
|
| 5 |
+
import secrets
|
| 6 |
+
from functools import wraps
|
| 7 |
+
from typing import Optional
|
| 8 |
+
from fastapi import HTTPException, Request, Response
|
| 9 |
+
from fastapi.responses import RedirectResponse
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def generate_session_secret() -> str:
|
| 13 |
+
"""生成随机的session密钥"""
|
| 14 |
+
return secrets.token_hex(32)
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def is_logged_in(request: Request) -> bool:
|
| 18 |
+
"""检查用户是否已登录"""
|
| 19 |
+
return request.session.get("authenticated", False)
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def login_user(request: Request):
|
| 23 |
+
"""标记用户为已登录状态"""
|
| 24 |
+
request.session["authenticated"] = True
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def logout_user(request: Request):
|
| 28 |
+
"""清除用户登录状态"""
|
| 29 |
+
request.session.clear()
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def require_login(redirect_to_login: bool = True):
|
| 33 |
+
"""
|
| 34 |
+
要求用户登录的装饰器
|
| 35 |
+
|
| 36 |
+
Args:
|
| 37 |
+
redirect_to_login: 未登录时是否重定向到登录页面(默认True)
|
| 38 |
+
False时返回404错误
|
| 39 |
+
"""
|
| 40 |
+
def decorator(func):
|
| 41 |
+
@wraps(func)
|
| 42 |
+
async def wrapper(*args, request: Request, **kwargs):
|
| 43 |
+
if not is_logged_in(request):
|
| 44 |
+
if redirect_to_login:
|
| 45 |
+
accept_header = (request.headers.get("accept") or "").lower()
|
| 46 |
+
wants_html = "text/html" in accept_header or request.url.path.endswith("/html")
|
| 47 |
+
|
| 48 |
+
if wants_html:
|
| 49 |
+
# 清理掉 URL 中可能重复的 PATH_PREFIX
|
| 50 |
+
# 避免重定向路径出现多层前缀
|
| 51 |
+
path = request.url.path
|
| 52 |
+
|
| 53 |
+
# 兼容 main 中 PATH_PREFIX 为空的情况
|
| 54 |
+
import main
|
| 55 |
+
prefix = main.PATH_PREFIX
|
| 56 |
+
|
| 57 |
+
if prefix:
|
| 58 |
+
login_url = f"/{prefix}/login"
|
| 59 |
+
else:
|
| 60 |
+
login_url = "/login"
|
| 61 |
+
|
| 62 |
+
return RedirectResponse(url=login_url, status_code=302)
|
| 63 |
+
|
| 64 |
+
raise HTTPException(401, "Unauthorized")
|
| 65 |
+
|
| 66 |
+
return await func(*args, request=request, **kwargs)
|
| 67 |
+
return wrapper
|
| 68 |
+
return decorator
|
core/storage.py
ADDED
|
@@ -0,0 +1,1127 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Storage abstraction supporting SQLite and PostgreSQL backends.
|
| 3 |
+
|
| 4 |
+
Priority:
|
| 5 |
+
1) DATABASE_URL -> PostgreSQL
|
| 6 |
+
2) SQLITE_PATH -> SQLite (defaults to data.db when DATABASE_URL is empty)
|
| 7 |
+
3) No file fallback
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
import asyncio
|
| 11 |
+
import json
|
| 12 |
+
import logging
|
| 13 |
+
import os
|
| 14 |
+
import sqlite3
|
| 15 |
+
import threading
|
| 16 |
+
import time
|
| 17 |
+
from typing import Optional
|
| 18 |
+
|
| 19 |
+
from dotenv import load_dotenv
|
| 20 |
+
|
| 21 |
+
load_dotenv()
|
| 22 |
+
|
| 23 |
+
logger = logging.getLogger(__name__)
|
| 24 |
+
|
| 25 |
+
_db_pool = None
|
| 26 |
+
_db_pool_lock = None
|
| 27 |
+
_db_loop = None
|
| 28 |
+
_db_thread = None
|
| 29 |
+
_db_loop_lock = threading.Lock()
|
| 30 |
+
|
| 31 |
+
_sqlite_conn = None
|
| 32 |
+
_sqlite_lock = threading.Lock()
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def _get_database_url() -> str:
|
| 36 |
+
return os.environ.get("DATABASE_URL", "").strip()
|
| 37 |
+
|
| 38 |
+
def _default_sqlite_path() -> str:
|
| 39 |
+
return os.path.join("data", "data.db")
|
| 40 |
+
|
| 41 |
+
def _get_sqlite_path() -> str:
|
| 42 |
+
env_path = os.environ.get("SQLITE_PATH", "").strip()
|
| 43 |
+
if env_path:
|
| 44 |
+
return env_path
|
| 45 |
+
return _default_sqlite_path()
|
| 46 |
+
|
| 47 |
+
def _get_backend() -> str:
|
| 48 |
+
if _get_database_url():
|
| 49 |
+
return "postgres"
|
| 50 |
+
if _get_sqlite_path():
|
| 51 |
+
return "sqlite"
|
| 52 |
+
return ""
|
| 53 |
+
|
| 54 |
+
def is_database_enabled() -> bool:
|
| 55 |
+
"""Return True when a database backend is configured."""
|
| 56 |
+
return bool(_get_backend())
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def _data_file_path(name: str) -> str:
|
| 60 |
+
return os.path.join("data", name)
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def _ensure_backend_initialized() -> None:
|
| 64 |
+
backend = _get_backend()
|
| 65 |
+
if backend == "postgres":
|
| 66 |
+
_run_in_db_loop(_get_pool())
|
| 67 |
+
return
|
| 68 |
+
if backend == "sqlite":
|
| 69 |
+
_get_sqlite_conn()
|
| 70 |
+
return
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
async def has_accounts() -> Optional[bool]:
|
| 74 |
+
backend = _get_backend()
|
| 75 |
+
if backend == "postgres":
|
| 76 |
+
async with _pg_acquire() as conn:
|
| 77 |
+
row = await conn.fetchrow("SELECT 1 FROM accounts LIMIT 1")
|
| 78 |
+
return bool(row)
|
| 79 |
+
if backend == "sqlite":
|
| 80 |
+
conn = _get_sqlite_conn()
|
| 81 |
+
with _sqlite_lock:
|
| 82 |
+
row = conn.execute("SELECT 1 FROM accounts LIMIT 1").fetchone()
|
| 83 |
+
return bool(row)
|
| 84 |
+
return None
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def has_accounts_sync() -> Optional[bool]:
|
| 88 |
+
return _run_in_db_loop(has_accounts())
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
async def has_settings() -> Optional[bool]:
|
| 92 |
+
backend = _get_backend()
|
| 93 |
+
if backend == "postgres":
|
| 94 |
+
async with _pg_acquire() as conn:
|
| 95 |
+
row = await conn.fetchrow(
|
| 96 |
+
"SELECT 1 FROM kv_settings WHERE key = $1",
|
| 97 |
+
"settings",
|
| 98 |
+
)
|
| 99 |
+
return bool(row)
|
| 100 |
+
if backend == "sqlite":
|
| 101 |
+
conn = _get_sqlite_conn()
|
| 102 |
+
with _sqlite_lock:
|
| 103 |
+
row = conn.execute(
|
| 104 |
+
"SELECT 1 FROM kv_settings WHERE key = ?",
|
| 105 |
+
("settings",),
|
| 106 |
+
).fetchone()
|
| 107 |
+
return bool(row)
|
| 108 |
+
return None
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
def has_settings_sync() -> Optional[bool]:
|
| 112 |
+
return _run_in_db_loop(has_settings())
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
async def has_stats() -> Optional[bool]:
|
| 116 |
+
backend = _get_backend()
|
| 117 |
+
if backend == "postgres":
|
| 118 |
+
async with _pg_acquire() as conn:
|
| 119 |
+
row = await conn.fetchrow(
|
| 120 |
+
"SELECT 1 FROM kv_stats WHERE key = $1",
|
| 121 |
+
"stats",
|
| 122 |
+
)
|
| 123 |
+
return bool(row)
|
| 124 |
+
if backend == "sqlite":
|
| 125 |
+
conn = _get_sqlite_conn()
|
| 126 |
+
with _sqlite_lock:
|
| 127 |
+
row = conn.execute(
|
| 128 |
+
"SELECT 1 FROM kv_stats WHERE key = ?",
|
| 129 |
+
("stats",),
|
| 130 |
+
).fetchone()
|
| 131 |
+
return bool(row)
|
| 132 |
+
return None
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
def has_stats_sync() -> Optional[bool]:
|
| 136 |
+
return _run_in_db_loop(has_stats())
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
def _ensure_db_loop() -> asyncio.AbstractEventLoop:
|
| 140 |
+
global _db_loop, _db_thread
|
| 141 |
+
if _db_loop and _db_thread and _db_thread.is_alive():
|
| 142 |
+
return _db_loop
|
| 143 |
+
with _db_loop_lock:
|
| 144 |
+
if _db_loop and _db_thread and _db_thread.is_alive():
|
| 145 |
+
return _db_loop
|
| 146 |
+
loop = asyncio.new_event_loop()
|
| 147 |
+
|
| 148 |
+
def _runner() -> None:
|
| 149 |
+
asyncio.set_event_loop(loop)
|
| 150 |
+
loop.run_forever()
|
| 151 |
+
|
| 152 |
+
thread = threading.Thread(target=_runner, name="storage-db-loop", daemon=True)
|
| 153 |
+
thread.start()
|
| 154 |
+
_db_loop = loop
|
| 155 |
+
_db_thread = thread
|
| 156 |
+
return _db_loop
|
| 157 |
+
|
| 158 |
+
|
| 159 |
+
def _run_in_db_loop(coro):
|
| 160 |
+
loop = _ensure_db_loop()
|
| 161 |
+
future = asyncio.run_coroutine_threadsafe(coro, loop)
|
| 162 |
+
return future.result()
|
| 163 |
+
|
| 164 |
+
def _get_sqlite_conn():
|
| 165 |
+
"""Get (or create) the SQLite connection."""
|
| 166 |
+
global _sqlite_conn
|
| 167 |
+
if _sqlite_conn is not None:
|
| 168 |
+
return _sqlite_conn
|
| 169 |
+
with _sqlite_lock:
|
| 170 |
+
if _sqlite_conn is not None:
|
| 171 |
+
return _sqlite_conn
|
| 172 |
+
sqlite_path = _get_sqlite_path()
|
| 173 |
+
if not sqlite_path:
|
| 174 |
+
raise ValueError("SQLITE_PATH is not set")
|
| 175 |
+
os.makedirs(os.path.dirname(sqlite_path) or ".", exist_ok=True)
|
| 176 |
+
conn = sqlite3.connect(sqlite_path, check_same_thread=False)
|
| 177 |
+
conn.row_factory = sqlite3.Row
|
| 178 |
+
_init_sqlite_tables(conn)
|
| 179 |
+
_sqlite_conn = conn
|
| 180 |
+
logger.info(f"[STORAGE] SQLite initialized at {sqlite_path}")
|
| 181 |
+
return _sqlite_conn
|
| 182 |
+
|
| 183 |
+
|
| 184 |
+
async def _get_pool():
|
| 185 |
+
"""Get (or create) the asyncpg connection pool."""
|
| 186 |
+
global _db_pool, _db_pool_lock
|
| 187 |
+
if _db_pool is not None:
|
| 188 |
+
return _db_pool
|
| 189 |
+
if _db_pool_lock is None:
|
| 190 |
+
_db_pool_lock = asyncio.Lock()
|
| 191 |
+
async with _db_pool_lock:
|
| 192 |
+
if _db_pool is not None:
|
| 193 |
+
return _db_pool
|
| 194 |
+
db_url = _get_database_url()
|
| 195 |
+
if not db_url:
|
| 196 |
+
raise ValueError("DATABASE_URL is not set")
|
| 197 |
+
try:
|
| 198 |
+
import asyncpg
|
| 199 |
+
_db_pool = await asyncpg.create_pool(
|
| 200 |
+
db_url,
|
| 201 |
+
min_size=0,
|
| 202 |
+
max_size=10,
|
| 203 |
+
command_timeout=30,
|
| 204 |
+
)
|
| 205 |
+
await _init_tables(_db_pool)
|
| 206 |
+
logger.info("[STORAGE] PostgreSQL pool initialized")
|
| 207 |
+
except ImportError:
|
| 208 |
+
logger.error("[STORAGE] asyncpg is required for database storage")
|
| 209 |
+
raise
|
| 210 |
+
except Exception as e:
|
| 211 |
+
logger.error(f"[STORAGE] Database connection failed: {e}")
|
| 212 |
+
raise
|
| 213 |
+
return _db_pool
|
| 214 |
+
|
| 215 |
+
|
| 216 |
+
async def _reset_pool():
|
| 217 |
+
"""Close and recreate the connection pool (called on stale connection errors)."""
|
| 218 |
+
global _db_pool
|
| 219 |
+
if _db_pool is not None:
|
| 220 |
+
try:
|
| 221 |
+
await _db_pool.close()
|
| 222 |
+
except Exception:
|
| 223 |
+
pass
|
| 224 |
+
_db_pool = None
|
| 225 |
+
return await _get_pool()
|
| 226 |
+
|
| 227 |
+
|
| 228 |
+
from contextlib import asynccontextmanager
|
| 229 |
+
|
| 230 |
+
@asynccontextmanager
|
| 231 |
+
async def _pg_acquire():
|
| 232 |
+
"""Acquire a connection with automatic retry on stale connection errors."""
|
| 233 |
+
import asyncpg
|
| 234 |
+
pool = await _get_pool()
|
| 235 |
+
try:
|
| 236 |
+
async with pool.acquire() as conn:
|
| 237 |
+
yield conn
|
| 238 |
+
except (asyncpg.ConnectionDoesNotExistError,
|
| 239 |
+
asyncpg.InterfaceError,
|
| 240 |
+
OSError) as e:
|
| 241 |
+
logger.warning(f"[STORAGE] Connection lost, resetting pool: {e}")
|
| 242 |
+
await _reset_pool()
|
| 243 |
+
raise
|
| 244 |
+
|
| 245 |
+
|
| 246 |
+
async def _init_tables(pool) -> None:
|
| 247 |
+
"""Initialize PostgreSQL tables."""
|
| 248 |
+
async with pool.acquire() as conn:
|
| 249 |
+
await conn.execute(
|
| 250 |
+
"""
|
| 251 |
+
CREATE TABLE IF NOT EXISTS accounts (
|
| 252 |
+
account_id TEXT PRIMARY KEY,
|
| 253 |
+
position INTEGER NOT NULL,
|
| 254 |
+
data JSONB NOT NULL,
|
| 255 |
+
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
| 256 |
+
)
|
| 257 |
+
"""
|
| 258 |
+
)
|
| 259 |
+
await conn.execute(
|
| 260 |
+
"""
|
| 261 |
+
CREATE INDEX IF NOT EXISTS accounts_position_idx
|
| 262 |
+
ON accounts(position)
|
| 263 |
+
"""
|
| 264 |
+
)
|
| 265 |
+
await conn.execute(
|
| 266 |
+
"""
|
| 267 |
+
CREATE TABLE IF NOT EXISTS kv_settings (
|
| 268 |
+
key TEXT PRIMARY KEY,
|
| 269 |
+
value JSONB NOT NULL,
|
| 270 |
+
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
| 271 |
+
)
|
| 272 |
+
"""
|
| 273 |
+
)
|
| 274 |
+
await conn.execute(
|
| 275 |
+
"""
|
| 276 |
+
CREATE TABLE IF NOT EXISTS kv_stats (
|
| 277 |
+
key TEXT PRIMARY KEY,
|
| 278 |
+
value JSONB NOT NULL,
|
| 279 |
+
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
| 280 |
+
)
|
| 281 |
+
"""
|
| 282 |
+
)
|
| 283 |
+
await conn.execute(
|
| 284 |
+
"""
|
| 285 |
+
CREATE TABLE IF NOT EXISTS task_history (
|
| 286 |
+
id TEXT PRIMARY KEY,
|
| 287 |
+
data JSONB NOT NULL,
|
| 288 |
+
created_at DOUBLE PRECISION NOT NULL
|
| 289 |
+
)
|
| 290 |
+
"""
|
| 291 |
+
)
|
| 292 |
+
await conn.execute(
|
| 293 |
+
"""
|
| 294 |
+
CREATE INDEX IF NOT EXISTS task_history_created_at_idx
|
| 295 |
+
ON task_history(created_at DESC)
|
| 296 |
+
"""
|
| 297 |
+
)
|
| 298 |
+
logger.info("[STORAGE] Database tables initialized")
|
| 299 |
+
|
| 300 |
+
def _init_sqlite_tables(conn: sqlite3.Connection) -> None:
|
| 301 |
+
"""Initialize SQLite tables."""
|
| 302 |
+
with conn:
|
| 303 |
+
conn.execute(
|
| 304 |
+
"""
|
| 305 |
+
CREATE TABLE IF NOT EXISTS accounts (
|
| 306 |
+
account_id TEXT PRIMARY KEY,
|
| 307 |
+
position INTEGER NOT NULL,
|
| 308 |
+
data TEXT NOT NULL,
|
| 309 |
+
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
|
| 310 |
+
)
|
| 311 |
+
"""
|
| 312 |
+
)
|
| 313 |
+
conn.execute(
|
| 314 |
+
"""
|
| 315 |
+
CREATE INDEX IF NOT EXISTS accounts_position_idx
|
| 316 |
+
ON accounts(position)
|
| 317 |
+
"""
|
| 318 |
+
)
|
| 319 |
+
conn.execute(
|
| 320 |
+
"""
|
| 321 |
+
CREATE TABLE IF NOT EXISTS kv_settings (
|
| 322 |
+
key TEXT PRIMARY KEY,
|
| 323 |
+
value TEXT NOT NULL,
|
| 324 |
+
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
|
| 325 |
+
)
|
| 326 |
+
"""
|
| 327 |
+
)
|
| 328 |
+
conn.execute(
|
| 329 |
+
"""
|
| 330 |
+
CREATE TABLE IF NOT EXISTS kv_stats (
|
| 331 |
+
key TEXT PRIMARY KEY,
|
| 332 |
+
value TEXT NOT NULL,
|
| 333 |
+
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
|
| 334 |
+
)
|
| 335 |
+
"""
|
| 336 |
+
)
|
| 337 |
+
conn.execute(
|
| 338 |
+
"""
|
| 339 |
+
CREATE TABLE IF NOT EXISTS task_history (
|
| 340 |
+
id TEXT PRIMARY KEY,
|
| 341 |
+
data TEXT NOT NULL,
|
| 342 |
+
created_at REAL NOT NULL
|
| 343 |
+
)
|
| 344 |
+
"""
|
| 345 |
+
)
|
| 346 |
+
conn.execute(
|
| 347 |
+
"""
|
| 348 |
+
CREATE INDEX IF NOT EXISTS task_history_created_at_idx
|
| 349 |
+
ON task_history(created_at)
|
| 350 |
+
"""
|
| 351 |
+
)
|
| 352 |
+
conn.execute(
|
| 353 |
+
"""
|
| 354 |
+
CREATE TABLE IF NOT EXISTS request_logs (
|
| 355 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 356 |
+
timestamp INTEGER NOT NULL,
|
| 357 |
+
model TEXT NOT NULL,
|
| 358 |
+
ttfb_ms INTEGER,
|
| 359 |
+
total_ms INTEGER,
|
| 360 |
+
status TEXT NOT NULL,
|
| 361 |
+
status_code INTEGER,
|
| 362 |
+
created_at TEXT DEFAULT CURRENT_TIMESTAMP
|
| 363 |
+
)
|
| 364 |
+
"""
|
| 365 |
+
)
|
| 366 |
+
conn.execute(
|
| 367 |
+
"""
|
| 368 |
+
CREATE INDEX IF NOT EXISTS request_logs_timestamp_idx
|
| 369 |
+
ON request_logs(timestamp)
|
| 370 |
+
"""
|
| 371 |
+
)
|
| 372 |
+
conn.execute(
|
| 373 |
+
"""
|
| 374 |
+
CREATE INDEX IF NOT EXISTS request_logs_model_idx
|
| 375 |
+
ON request_logs(model)
|
| 376 |
+
"""
|
| 377 |
+
)
|
| 378 |
+
conn.execute(
|
| 379 |
+
"""
|
| 380 |
+
CREATE INDEX IF NOT EXISTS request_logs_status_idx
|
| 381 |
+
ON request_logs(status)
|
| 382 |
+
"""
|
| 383 |
+
)
|
| 384 |
+
|
| 385 |
+
|
| 386 |
+
# ==================== Accounts storage ====================
|
| 387 |
+
|
| 388 |
+
def _normalize_accounts(accounts: list) -> list:
|
| 389 |
+
normalized = []
|
| 390 |
+
for index, acc in enumerate(accounts, 1):
|
| 391 |
+
if not isinstance(acc, dict):
|
| 392 |
+
continue
|
| 393 |
+
account_id = acc.get("id") or f"account_{index}"
|
| 394 |
+
next_acc = dict(acc)
|
| 395 |
+
next_acc.setdefault("id", account_id)
|
| 396 |
+
normalized.append(next_acc)
|
| 397 |
+
return normalized
|
| 398 |
+
|
| 399 |
+
def _parse_account_value(value) -> Optional[dict]:
|
| 400 |
+
if value is None:
|
| 401 |
+
return None
|
| 402 |
+
if isinstance(value, str):
|
| 403 |
+
try:
|
| 404 |
+
value = json.loads(value)
|
| 405 |
+
except Exception:
|
| 406 |
+
return None
|
| 407 |
+
if isinstance(value, dict):
|
| 408 |
+
return value
|
| 409 |
+
return None
|
| 410 |
+
|
| 411 |
+
async def _load_accounts_from_table() -> Optional[list]:
|
| 412 |
+
backend = _get_backend()
|
| 413 |
+
if backend == "postgres":
|
| 414 |
+
async with _pg_acquire() as conn:
|
| 415 |
+
rows = await conn.fetch(
|
| 416 |
+
"SELECT data FROM accounts ORDER BY position ASC"
|
| 417 |
+
)
|
| 418 |
+
if not rows:
|
| 419 |
+
return []
|
| 420 |
+
accounts = []
|
| 421 |
+
for row in rows:
|
| 422 |
+
value = _parse_account_value(row["data"])
|
| 423 |
+
if value is not None:
|
| 424 |
+
accounts.append(value)
|
| 425 |
+
return accounts
|
| 426 |
+
if backend == "sqlite":
|
| 427 |
+
conn = _get_sqlite_conn()
|
| 428 |
+
with _sqlite_lock:
|
| 429 |
+
rows = conn.execute(
|
| 430 |
+
"SELECT data FROM accounts ORDER BY position ASC"
|
| 431 |
+
).fetchall()
|
| 432 |
+
if not rows:
|
| 433 |
+
return []
|
| 434 |
+
accounts = []
|
| 435 |
+
for row in rows:
|
| 436 |
+
value = _parse_account_value(row["data"])
|
| 437 |
+
if value is not None:
|
| 438 |
+
accounts.append(value)
|
| 439 |
+
return accounts
|
| 440 |
+
return None
|
| 441 |
+
|
| 442 |
+
async def _save_accounts_to_table(accounts: list) -> bool:
|
| 443 |
+
backend = _get_backend()
|
| 444 |
+
if backend == "postgres":
|
| 445 |
+
normalized = _normalize_accounts(accounts)
|
| 446 |
+
async with _pg_acquire() as conn:
|
| 447 |
+
async with conn.transaction():
|
| 448 |
+
await conn.execute("DELETE FROM accounts")
|
| 449 |
+
for index, acc in enumerate(normalized, 1):
|
| 450 |
+
await conn.execute(
|
| 451 |
+
"""
|
| 452 |
+
INSERT INTO accounts (account_id, position, data, updated_at)
|
| 453 |
+
VALUES ($1, $2, $3, CURRENT_TIMESTAMP)
|
| 454 |
+
""",
|
| 455 |
+
acc["id"],
|
| 456 |
+
index,
|
| 457 |
+
json.dumps(acc, ensure_ascii=False),
|
| 458 |
+
)
|
| 459 |
+
logger.info(f"[STORAGE] Saved {len(normalized)} accounts to database")
|
| 460 |
+
return True
|
| 461 |
+
if backend == "sqlite":
|
| 462 |
+
conn = _get_sqlite_conn()
|
| 463 |
+
normalized = _normalize_accounts(accounts)
|
| 464 |
+
with _sqlite_lock, conn:
|
| 465 |
+
conn.execute("DELETE FROM accounts")
|
| 466 |
+
for index, acc in enumerate(normalized, 1):
|
| 467 |
+
conn.execute(
|
| 468 |
+
"""
|
| 469 |
+
INSERT INTO accounts (account_id, position, data, updated_at)
|
| 470 |
+
VALUES (?, ?, ?, CURRENT_TIMESTAMP)
|
| 471 |
+
""",
|
| 472 |
+
(acc["id"], index, json.dumps(acc, ensure_ascii=False)),
|
| 473 |
+
)
|
| 474 |
+
logger.info(f"[STORAGE] Saved {len(normalized)} accounts to database")
|
| 475 |
+
return True
|
| 476 |
+
return False
|
| 477 |
+
|
| 478 |
+
async def load_accounts() -> Optional[list]:
|
| 479 |
+
"""
|
| 480 |
+
从数据库加载账户配置(如果启用)
|
| 481 |
+
|
| 482 |
+
注意:不再自动从 kv_store 迁移
|
| 483 |
+
如需迁移,请手动运行:python scripts/migrate_to_database.py
|
| 484 |
+
|
| 485 |
+
返回 None 表示降级到文件存储
|
| 486 |
+
"""
|
| 487 |
+
if not is_database_enabled():
|
| 488 |
+
return None
|
| 489 |
+
try:
|
| 490 |
+
data = await _load_accounts_from_table()
|
| 491 |
+
if data is None:
|
| 492 |
+
return None
|
| 493 |
+
|
| 494 |
+
if data:
|
| 495 |
+
logger.info(f"[STORAGE] 从数据库加载 {len(data)} 个账户")
|
| 496 |
+
else:
|
| 497 |
+
logger.info("[STORAGE] 数据库中未找到账户")
|
| 498 |
+
|
| 499 |
+
return data
|
| 500 |
+
except Exception as e:
|
| 501 |
+
logger.error(f"[STORAGE] 数据库读取失败: {e}")
|
| 502 |
+
return None
|
| 503 |
+
|
| 504 |
+
|
| 505 |
+
async def get_accounts_updated_at() -> Optional[float]:
|
| 506 |
+
"""
|
| 507 |
+
Get the accounts updated_at timestamp (epoch seconds).
|
| 508 |
+
Return None if database is not enabled or failed.
|
| 509 |
+
"""
|
| 510 |
+
if not is_database_enabled():
|
| 511 |
+
return None
|
| 512 |
+
backend = _get_backend()
|
| 513 |
+
try:
|
| 514 |
+
if backend == "postgres":
|
| 515 |
+
async with _pg_acquire() as conn:
|
| 516 |
+
row = await conn.fetchrow(
|
| 517 |
+
"SELECT EXTRACT(EPOCH FROM MAX(updated_at)) AS ts FROM accounts"
|
| 518 |
+
)
|
| 519 |
+
if not row or row["ts"] is None:
|
| 520 |
+
return None
|
| 521 |
+
return float(row["ts"])
|
| 522 |
+
if backend == "sqlite":
|
| 523 |
+
conn = _get_sqlite_conn()
|
| 524 |
+
with _sqlite_lock:
|
| 525 |
+
row = conn.execute(
|
| 526 |
+
"SELECT STRFTIME('%s', MAX(updated_at)) AS ts FROM accounts"
|
| 527 |
+
).fetchone()
|
| 528 |
+
if not row or row["ts"] is None:
|
| 529 |
+
return None
|
| 530 |
+
return float(row["ts"])
|
| 531 |
+
except Exception as e:
|
| 532 |
+
logger.error(f"[STORAGE] Database accounts updated_at failed: {e}")
|
| 533 |
+
return None
|
| 534 |
+
|
| 535 |
+
|
| 536 |
+
def get_accounts_updated_at_sync() -> Optional[float]:
|
| 537 |
+
"""Sync wrapper for get_accounts_updated_at."""
|
| 538 |
+
return _run_in_db_loop(get_accounts_updated_at())
|
| 539 |
+
|
| 540 |
+
|
| 541 |
+
async def save_accounts(accounts: list) -> bool:
|
| 542 |
+
"""Save account configuration to database when enabled."""
|
| 543 |
+
if not is_database_enabled():
|
| 544 |
+
return False
|
| 545 |
+
try:
|
| 546 |
+
return await _save_accounts_to_table(accounts)
|
| 547 |
+
except Exception as e:
|
| 548 |
+
logger.error(f"[STORAGE] Database write failed: {e}")
|
| 549 |
+
return False
|
| 550 |
+
|
| 551 |
+
|
| 552 |
+
def load_accounts_sync() -> Optional[list]:
|
| 553 |
+
"""Sync wrapper for load_accounts (safe in sync/async call sites)."""
|
| 554 |
+
return _run_in_db_loop(load_accounts())
|
| 555 |
+
|
| 556 |
+
|
| 557 |
+
def save_accounts_sync(accounts: list) -> bool:
|
| 558 |
+
"""Sync wrapper for save_accounts (safe in sync/async call sites)."""
|
| 559 |
+
return _run_in_db_loop(save_accounts(accounts))
|
| 560 |
+
|
| 561 |
+
async def _get_account_data(account_id: str) -> Optional[dict]:
|
| 562 |
+
backend = _get_backend()
|
| 563 |
+
if backend == "postgres":
|
| 564 |
+
async with _pg_acquire() as conn:
|
| 565 |
+
row = await conn.fetchrow(
|
| 566 |
+
"SELECT data FROM accounts WHERE account_id = $1",
|
| 567 |
+
account_id,
|
| 568 |
+
)
|
| 569 |
+
if not row:
|
| 570 |
+
return None
|
| 571 |
+
return _parse_account_value(row["data"])
|
| 572 |
+
if backend == "sqlite":
|
| 573 |
+
conn = _get_sqlite_conn()
|
| 574 |
+
with _sqlite_lock:
|
| 575 |
+
row = conn.execute(
|
| 576 |
+
"SELECT data FROM accounts WHERE account_id = ?",
|
| 577 |
+
(account_id,),
|
| 578 |
+
).fetchone()
|
| 579 |
+
if not row:
|
| 580 |
+
return None
|
| 581 |
+
return _parse_account_value(row["data"])
|
| 582 |
+
return None
|
| 583 |
+
|
| 584 |
+
async def _update_account_data(account_id: str, data: dict) -> bool:
|
| 585 |
+
backend = _get_backend()
|
| 586 |
+
payload = json.dumps(data, ensure_ascii=False)
|
| 587 |
+
if backend == "postgres":
|
| 588 |
+
async with _pg_acquire() as conn:
|
| 589 |
+
result = await conn.execute(
|
| 590 |
+
"""
|
| 591 |
+
UPDATE accounts
|
| 592 |
+
SET data = $2, updated_at = CURRENT_TIMESTAMP
|
| 593 |
+
WHERE account_id = $1
|
| 594 |
+
""",
|
| 595 |
+
account_id,
|
| 596 |
+
payload,
|
| 597 |
+
)
|
| 598 |
+
return result.startswith("UPDATE") and not result.endswith("0")
|
| 599 |
+
if backend == "sqlite":
|
| 600 |
+
conn = _get_sqlite_conn()
|
| 601 |
+
with _sqlite_lock, conn:
|
| 602 |
+
cur = conn.execute(
|
| 603 |
+
"""
|
| 604 |
+
UPDATE accounts
|
| 605 |
+
SET data = ?, updated_at = CURRENT_TIMESTAMP
|
| 606 |
+
WHERE account_id = ?
|
| 607 |
+
""",
|
| 608 |
+
(payload, account_id),
|
| 609 |
+
)
|
| 610 |
+
return cur.rowcount > 0
|
| 611 |
+
return False
|
| 612 |
+
|
| 613 |
+
async def update_account_disabled(account_id: str, disabled: bool) -> bool:
|
| 614 |
+
data = await _get_account_data(account_id)
|
| 615 |
+
if data is None:
|
| 616 |
+
return False
|
| 617 |
+
data["disabled"] = disabled
|
| 618 |
+
return await _update_account_data(account_id, data)
|
| 619 |
+
|
| 620 |
+
def _apply_cooldown_data(data: dict, cooldown_data: dict) -> None:
|
| 621 |
+
"""应用冷却数据到账户数据(消除重复代码)"""
|
| 622 |
+
data["quota_cooldowns"] = cooldown_data.get("quota_cooldowns", {})
|
| 623 |
+
data["conversation_count"] = cooldown_data.get("conversation_count", 0)
|
| 624 |
+
data["failure_count"] = cooldown_data.get("failure_count", 0)
|
| 625 |
+
data["daily_usage"] = cooldown_data.get("daily_usage", {"text": 0, "images": 0, "videos": 0})
|
| 626 |
+
data["daily_usage_date"] = cooldown_data.get("daily_usage_date", "")
|
| 627 |
+
|
| 628 |
+
async def update_account_cooldown(account_id: str, cooldown_data: dict) -> bool:
|
| 629 |
+
"""更新单个账户的冷却状态和统计数据"""
|
| 630 |
+
data = await _get_account_data(account_id)
|
| 631 |
+
if data is None:
|
| 632 |
+
return False
|
| 633 |
+
_apply_cooldown_data(data, cooldown_data)
|
| 634 |
+
return await _update_account_data(account_id, data)
|
| 635 |
+
|
| 636 |
+
async def bulk_update_accounts_cooldown(updates: list[tuple[str, dict]]) -> tuple[int, list[str]]:
|
| 637 |
+
"""批量更新账户冷却状态"""
|
| 638 |
+
if not updates:
|
| 639 |
+
return 0, []
|
| 640 |
+
|
| 641 |
+
account_ids = [account_id for account_id, _ in updates]
|
| 642 |
+
cooldown_map = {account_id: cooldown_data for account_id, cooldown_data in updates}
|
| 643 |
+
|
| 644 |
+
backend = _get_backend()
|
| 645 |
+
existing: dict[str, dict] = {}
|
| 646 |
+
updated = 0
|
| 647 |
+
|
| 648 |
+
if backend == "postgres":
|
| 649 |
+
async with _pg_acquire() as conn:
|
| 650 |
+
# SELECT + UPDATE in one connection to avoid contention
|
| 651 |
+
rows = await conn.fetch(
|
| 652 |
+
"SELECT account_id, data FROM accounts WHERE account_id = ANY($1)",
|
| 653 |
+
account_ids,
|
| 654 |
+
)
|
| 655 |
+
for row in rows:
|
| 656 |
+
data = _parse_account_value(row["data"])
|
| 657 |
+
if data is not None:
|
| 658 |
+
existing[row["account_id"]] = data
|
| 659 |
+
|
| 660 |
+
missing = [aid for aid in account_ids if aid not in existing]
|
| 661 |
+
if existing:
|
| 662 |
+
async with conn.transaction():
|
| 663 |
+
for account_id, data in existing.items():
|
| 664 |
+
cooldown_data = cooldown_map[account_id]
|
| 665 |
+
_apply_cooldown_data(data, cooldown_data)
|
| 666 |
+
payload = json.dumps(data, ensure_ascii=False)
|
| 667 |
+
result = await conn.execute(
|
| 668 |
+
"""
|
| 669 |
+
UPDATE accounts
|
| 670 |
+
SET data = $2, updated_at = CURRENT_TIMESTAMP
|
| 671 |
+
WHERE account_id = $1
|
| 672 |
+
""",
|
| 673 |
+
account_id,
|
| 674 |
+
payload,
|
| 675 |
+
)
|
| 676 |
+
if result.startswith("UPDATE") and not result.endswith("0"):
|
| 677 |
+
updated += 1
|
| 678 |
+
return updated, missing if existing else account_ids
|
| 679 |
+
|
| 680 |
+
elif backend == "sqlite":
|
| 681 |
+
conn = _get_sqlite_conn()
|
| 682 |
+
placeholders = ",".join(["?"] * len(account_ids))
|
| 683 |
+
with _sqlite_lock:
|
| 684 |
+
rows = conn.execute(
|
| 685 |
+
f"SELECT account_id, data FROM accounts WHERE account_id IN ({placeholders})",
|
| 686 |
+
tuple(account_ids),
|
| 687 |
+
).fetchall()
|
| 688 |
+
for row in rows:
|
| 689 |
+
data = _parse_account_value(row["data"])
|
| 690 |
+
if data is not None:
|
| 691 |
+
existing[row["account_id"]] = data
|
| 692 |
+
|
| 693 |
+
missing = [aid for aid in account_ids if aid not in existing]
|
| 694 |
+
if not existing:
|
| 695 |
+
return 0, missing
|
| 696 |
+
|
| 697 |
+
with _sqlite_lock, conn:
|
| 698 |
+
for account_id, data in existing.items():
|
| 699 |
+
cooldown_data = cooldown_map[account_id]
|
| 700 |
+
_apply_cooldown_data(data, cooldown_data)
|
| 701 |
+
payload = json.dumps(data, ensure_ascii=False)
|
| 702 |
+
cur = conn.execute(
|
| 703 |
+
"""
|
| 704 |
+
UPDATE accounts
|
| 705 |
+
SET data = ?, updated_at = CURRENT_TIMESTAMP
|
| 706 |
+
WHERE account_id = ?
|
| 707 |
+
""",
|
| 708 |
+
(payload, account_id),
|
| 709 |
+
)
|
| 710 |
+
if cur.rowcount > 0:
|
| 711 |
+
updated += 1
|
| 712 |
+
return updated, missing
|
| 713 |
+
|
| 714 |
+
return 0, account_ids
|
| 715 |
+
|
| 716 |
+
async def bulk_update_accounts_disabled(account_ids: list[str], disabled: bool) -> tuple[int, list[str]]:
|
| 717 |
+
if not account_ids:
|
| 718 |
+
return 0, []
|
| 719 |
+
backend = _get_backend()
|
| 720 |
+
existing: dict[str, dict] = {}
|
| 721 |
+
if backend == "postgres":
|
| 722 |
+
async with _pg_acquire() as conn:
|
| 723 |
+
rows = await conn.fetch(
|
| 724 |
+
"SELECT account_id, data FROM accounts WHERE account_id = ANY($1)",
|
| 725 |
+
account_ids,
|
| 726 |
+
)
|
| 727 |
+
for row in rows:
|
| 728 |
+
data = _parse_account_value(row["data"])
|
| 729 |
+
if data is not None:
|
| 730 |
+
existing[row["account_id"]] = data
|
| 731 |
+
elif backend == "sqlite":
|
| 732 |
+
conn = _get_sqlite_conn()
|
| 733 |
+
placeholders = ",".join(["?"] * len(account_ids))
|
| 734 |
+
with _sqlite_lock:
|
| 735 |
+
rows = conn.execute(
|
| 736 |
+
f"SELECT account_id, data FROM accounts WHERE account_id IN ({placeholders})",
|
| 737 |
+
tuple(account_ids),
|
| 738 |
+
).fetchall()
|
| 739 |
+
for row in rows:
|
| 740 |
+
data = _parse_account_value(row["data"])
|
| 741 |
+
if data is not None:
|
| 742 |
+
existing[row["account_id"]] = data
|
| 743 |
+
else:
|
| 744 |
+
return 0, account_ids
|
| 745 |
+
|
| 746 |
+
missing = [account_id for account_id in account_ids if account_id not in existing]
|
| 747 |
+
if not existing:
|
| 748 |
+
return 0, missing
|
| 749 |
+
|
| 750 |
+
updated = 0
|
| 751 |
+
backend = _get_backend()
|
| 752 |
+
if backend == "postgres":
|
| 753 |
+
async with _pg_acquire() as conn:
|
| 754 |
+
async with conn.transaction():
|
| 755 |
+
for account_id, data in existing.items():
|
| 756 |
+
data["disabled"] = disabled
|
| 757 |
+
payload = json.dumps(data, ensure_ascii=False)
|
| 758 |
+
result = await conn.execute(
|
| 759 |
+
"""
|
| 760 |
+
UPDATE accounts
|
| 761 |
+
SET data = $2, updated_at = CURRENT_TIMESTAMP
|
| 762 |
+
WHERE account_id = $1
|
| 763 |
+
""",
|
| 764 |
+
account_id,
|
| 765 |
+
payload,
|
| 766 |
+
)
|
| 767 |
+
if result.startswith("UPDATE") and not result.endswith("0"):
|
| 768 |
+
updated += 1
|
| 769 |
+
elif backend == "sqlite":
|
| 770 |
+
conn = _get_sqlite_conn()
|
| 771 |
+
with _sqlite_lock, conn:
|
| 772 |
+
for account_id, data in existing.items():
|
| 773 |
+
data["disabled"] = disabled
|
| 774 |
+
payload = json.dumps(data, ensure_ascii=False)
|
| 775 |
+
cur = conn.execute(
|
| 776 |
+
"""
|
| 777 |
+
UPDATE accounts
|
| 778 |
+
SET data = ?, updated_at = CURRENT_TIMESTAMP
|
| 779 |
+
WHERE account_id = ?
|
| 780 |
+
""",
|
| 781 |
+
(payload, account_id),
|
| 782 |
+
)
|
| 783 |
+
if cur.rowcount > 0:
|
| 784 |
+
updated += 1
|
| 785 |
+
return updated, missing
|
| 786 |
+
|
| 787 |
+
async def _renumber_account_positions() -> None:
|
| 788 |
+
backend = _get_backend()
|
| 789 |
+
if backend == "postgres":
|
| 790 |
+
async with _pg_acquire() as conn:
|
| 791 |
+
await conn.execute(
|
| 792 |
+
"""
|
| 793 |
+
WITH ordered AS (
|
| 794 |
+
SELECT account_id, ROW_NUMBER() OVER (ORDER BY position ASC) AS new_pos
|
| 795 |
+
FROM accounts
|
| 796 |
+
)
|
| 797 |
+
UPDATE accounts AS a
|
| 798 |
+
SET position = ordered.new_pos,
|
| 799 |
+
updated_at = CURRENT_TIMESTAMP
|
| 800 |
+
FROM ordered
|
| 801 |
+
WHERE a.account_id = ordered.account_id
|
| 802 |
+
"""
|
| 803 |
+
)
|
| 804 |
+
return
|
| 805 |
+
if backend == "sqlite":
|
| 806 |
+
conn = _get_sqlite_conn()
|
| 807 |
+
with _sqlite_lock, conn:
|
| 808 |
+
rows = conn.execute(
|
| 809 |
+
"SELECT account_id FROM accounts ORDER BY position ASC"
|
| 810 |
+
).fetchall()
|
| 811 |
+
for index, row in enumerate(rows, 1):
|
| 812 |
+
conn.execute(
|
| 813 |
+
"UPDATE accounts SET position = ?, updated_at = CURRENT_TIMESTAMP WHERE account_id = ?",
|
| 814 |
+
(index, row["account_id"]),
|
| 815 |
+
)
|
| 816 |
+
|
| 817 |
+
async def delete_accounts(account_ids: list[str]) -> int:
|
| 818 |
+
if not account_ids:
|
| 819 |
+
return 0
|
| 820 |
+
backend = _get_backend()
|
| 821 |
+
deleted = 0
|
| 822 |
+
if backend == "postgres":
|
| 823 |
+
async with _pg_acquire() as conn:
|
| 824 |
+
result = await conn.execute(
|
| 825 |
+
"DELETE FROM accounts WHERE account_id = ANY($1)",
|
| 826 |
+
account_ids,
|
| 827 |
+
)
|
| 828 |
+
try:
|
| 829 |
+
deleted = int(result.split()[-1])
|
| 830 |
+
except Exception:
|
| 831 |
+
deleted = 0
|
| 832 |
+
elif backend == "sqlite":
|
| 833 |
+
conn = _get_sqlite_conn()
|
| 834 |
+
placeholders = ",".join(["?"] * len(account_ids))
|
| 835 |
+
with _sqlite_lock, conn:
|
| 836 |
+
cur = conn.execute(
|
| 837 |
+
f"DELETE FROM accounts WHERE account_id IN ({placeholders})",
|
| 838 |
+
tuple(account_ids),
|
| 839 |
+
)
|
| 840 |
+
deleted = cur.rowcount or 0
|
| 841 |
+
else:
|
| 842 |
+
return 0
|
| 843 |
+
|
| 844 |
+
if deleted > 0:
|
| 845 |
+
await _renumber_account_positions()
|
| 846 |
+
return deleted
|
| 847 |
+
|
| 848 |
+
def update_account_disabled_sync(account_id: str, disabled: bool) -> bool:
|
| 849 |
+
return _run_in_db_loop(update_account_disabled(account_id, disabled))
|
| 850 |
+
|
| 851 |
+
def update_account_cooldown_sync(account_id: str, cooldown_data: dict) -> bool:
|
| 852 |
+
return _run_in_db_loop(update_account_cooldown(account_id, cooldown_data))
|
| 853 |
+
|
| 854 |
+
def bulk_update_accounts_cooldown_sync(updates: list[tuple[str, dict]]) -> tuple[int, list[str]]:
|
| 855 |
+
return _run_in_db_loop(bulk_update_accounts_cooldown(updates))
|
| 856 |
+
|
| 857 |
+
def bulk_update_accounts_disabled_sync(account_ids: list[str], disabled: bool) -> tuple[int, list[str]]:
|
| 858 |
+
return _run_in_db_loop(bulk_update_accounts_disabled(account_ids, disabled))
|
| 859 |
+
|
| 860 |
+
def delete_accounts_sync(account_ids: list[str]) -> int:
|
| 861 |
+
return _run_in_db_loop(delete_accounts(account_ids))
|
| 862 |
+
|
| 863 |
+
|
| 864 |
+
# ==================== Settings storage ====================
|
| 865 |
+
|
| 866 |
+
async def _load_kv(table_name: str, key: str) -> Optional[dict]:
|
| 867 |
+
"""加载键值数据"""
|
| 868 |
+
backend = _get_backend()
|
| 869 |
+
if backend == "postgres":
|
| 870 |
+
async with _pg_acquire() as conn:
|
| 871 |
+
row = await conn.fetchrow(
|
| 872 |
+
f"SELECT value FROM {table_name} WHERE key = $1",
|
| 873 |
+
key,
|
| 874 |
+
)
|
| 875 |
+
if not row:
|
| 876 |
+
return None
|
| 877 |
+
value = row["value"]
|
| 878 |
+
if isinstance(value, str):
|
| 879 |
+
return json.loads(value)
|
| 880 |
+
return value
|
| 881 |
+
|
| 882 |
+
if backend == "sqlite":
|
| 883 |
+
conn = _get_sqlite_conn()
|
| 884 |
+
with _sqlite_lock:
|
| 885 |
+
row = conn.execute(
|
| 886 |
+
f"SELECT value FROM {table_name} WHERE key = ?",
|
| 887 |
+
(key,),
|
| 888 |
+
).fetchone()
|
| 889 |
+
if not row:
|
| 890 |
+
return None
|
| 891 |
+
value = row["value"]
|
| 892 |
+
if isinstance(value, str):
|
| 893 |
+
return json.loads(value)
|
| 894 |
+
return value
|
| 895 |
+
return None
|
| 896 |
+
|
| 897 |
+
|
| 898 |
+
async def _save_kv(table_name: str, key: str, value: dict) -> bool:
|
| 899 |
+
backend = _get_backend()
|
| 900 |
+
payload = json.dumps(value, ensure_ascii=False)
|
| 901 |
+
if backend == "postgres":
|
| 902 |
+
async with _pg_acquire() as conn:
|
| 903 |
+
await conn.execute(
|
| 904 |
+
f"""
|
| 905 |
+
INSERT INTO {table_name} (key, value, updated_at)
|
| 906 |
+
VALUES ($1, $2, CURRENT_TIMESTAMP)
|
| 907 |
+
ON CONFLICT (key) DO UPDATE SET
|
| 908 |
+
value = EXCLUDED.value,
|
| 909 |
+
updated_at = CURRENT_TIMESTAMP
|
| 910 |
+
""",
|
| 911 |
+
key,
|
| 912 |
+
payload,
|
| 913 |
+
)
|
| 914 |
+
return True
|
| 915 |
+
if backend == "sqlite":
|
| 916 |
+
conn = _get_sqlite_conn()
|
| 917 |
+
with _sqlite_lock, conn:
|
| 918 |
+
conn.execute(
|
| 919 |
+
f"""
|
| 920 |
+
INSERT INTO {table_name} (key, value, updated_at)
|
| 921 |
+
VALUES (?, ?, CURRENT_TIMESTAMP)
|
| 922 |
+
ON CONFLICT(key) DO UPDATE SET
|
| 923 |
+
value = excluded.value,
|
| 924 |
+
updated_at = CURRENT_TIMESTAMP
|
| 925 |
+
""",
|
| 926 |
+
(key, payload),
|
| 927 |
+
)
|
| 928 |
+
return True
|
| 929 |
+
return False
|
| 930 |
+
|
| 931 |
+
async def load_settings() -> Optional[dict]:
|
| 932 |
+
if not is_database_enabled():
|
| 933 |
+
return None
|
| 934 |
+
try:
|
| 935 |
+
return await _load_kv("kv_settings", "settings")
|
| 936 |
+
except Exception as e:
|
| 937 |
+
logger.error(f"[STORAGE] Settings read failed: {e}")
|
| 938 |
+
return None
|
| 939 |
+
|
| 940 |
+
|
| 941 |
+
async def save_settings(settings: dict) -> bool:
|
| 942 |
+
if not is_database_enabled():
|
| 943 |
+
return False
|
| 944 |
+
try:
|
| 945 |
+
saved = await _save_kv("kv_settings", "settings", settings)
|
| 946 |
+
if saved:
|
| 947 |
+
logger.info("[STORAGE] Settings saved to database")
|
| 948 |
+
return saved
|
| 949 |
+
except Exception as e:
|
| 950 |
+
logger.error(f"[STORAGE] Settings write failed: {e}")
|
| 951 |
+
return False
|
| 952 |
+
|
| 953 |
+
|
| 954 |
+
# ==================== Stats storage ====================
|
| 955 |
+
|
| 956 |
+
async def load_stats() -> Optional[dict]:
|
| 957 |
+
if not is_database_enabled():
|
| 958 |
+
return None
|
| 959 |
+
try:
|
| 960 |
+
return await _load_kv("kv_stats", "stats")
|
| 961 |
+
except Exception as e:
|
| 962 |
+
logger.error(f"[STORAGE] Stats read failed: {e}")
|
| 963 |
+
return None
|
| 964 |
+
|
| 965 |
+
|
| 966 |
+
async def save_stats(stats: dict) -> bool:
|
| 967 |
+
if not is_database_enabled():
|
| 968 |
+
return False
|
| 969 |
+
try:
|
| 970 |
+
return await _save_kv("kv_stats", "stats", stats)
|
| 971 |
+
except Exception as e:
|
| 972 |
+
logger.error(f"[STORAGE] Stats write failed: {e}")
|
| 973 |
+
return False
|
| 974 |
+
|
| 975 |
+
|
| 976 |
+
def load_settings_sync() -> Optional[dict]:
|
| 977 |
+
return _run_in_db_loop(load_settings())
|
| 978 |
+
|
| 979 |
+
|
| 980 |
+
def save_settings_sync(settings: dict) -> bool:
|
| 981 |
+
return _run_in_db_loop(save_settings(settings))
|
| 982 |
+
|
| 983 |
+
|
| 984 |
+
def load_stats_sync() -> Optional[dict]:
|
| 985 |
+
return _run_in_db_loop(load_stats())
|
| 986 |
+
|
| 987 |
+
|
| 988 |
+
def save_stats_sync(stats: dict) -> bool:
|
| 989 |
+
return _run_in_db_loop(save_stats(stats))
|
| 990 |
+
|
| 991 |
+
|
| 992 |
+
# ==================== Task history storage ====================
|
| 993 |
+
|
| 994 |
+
async def save_task_history_entry(entry: dict) -> bool:
|
| 995 |
+
if not is_database_enabled():
|
| 996 |
+
return False
|
| 997 |
+
entry_id = entry.get("id")
|
| 998 |
+
if not entry_id:
|
| 999 |
+
return False
|
| 1000 |
+
created_at = float(entry.get("created_at", time.time()))
|
| 1001 |
+
payload = json.dumps(entry, ensure_ascii=False)
|
| 1002 |
+
backend = _get_backend()
|
| 1003 |
+
try:
|
| 1004 |
+
if backend == "postgres":
|
| 1005 |
+
async with _pg_acquire() as conn:
|
| 1006 |
+
await conn.execute(
|
| 1007 |
+
"""
|
| 1008 |
+
INSERT INTO task_history (id, data, created_at)
|
| 1009 |
+
VALUES ($1, $2, $3)
|
| 1010 |
+
ON CONFLICT (id) DO UPDATE SET
|
| 1011 |
+
data = EXCLUDED.data,
|
| 1012 |
+
created_at = EXCLUDED.created_at
|
| 1013 |
+
""",
|
| 1014 |
+
entry_id,
|
| 1015 |
+
payload,
|
| 1016 |
+
created_at,
|
| 1017 |
+
)
|
| 1018 |
+
await conn.execute(
|
| 1019 |
+
"""
|
| 1020 |
+
DELETE FROM task_history
|
| 1021 |
+
WHERE id IN (
|
| 1022 |
+
SELECT id FROM task_history
|
| 1023 |
+
ORDER BY created_at DESC
|
| 1024 |
+
OFFSET 100
|
| 1025 |
+
)
|
| 1026 |
+
"""
|
| 1027 |
+
)
|
| 1028 |
+
return True
|
| 1029 |
+
if backend == "sqlite":
|
| 1030 |
+
conn = _get_sqlite_conn()
|
| 1031 |
+
with _sqlite_lock, conn:
|
| 1032 |
+
conn.execute(
|
| 1033 |
+
"""
|
| 1034 |
+
INSERT INTO task_history (id, data, created_at)
|
| 1035 |
+
VALUES (?, ?, ?)
|
| 1036 |
+
ON CONFLICT(id) DO UPDATE SET
|
| 1037 |
+
data = excluded.data,
|
| 1038 |
+
created_at = excluded.created_at
|
| 1039 |
+
""",
|
| 1040 |
+
(entry_id, payload, created_at),
|
| 1041 |
+
)
|
| 1042 |
+
conn.execute(
|
| 1043 |
+
"""
|
| 1044 |
+
DELETE FROM task_history
|
| 1045 |
+
WHERE id IN (
|
| 1046 |
+
SELECT id FROM task_history
|
| 1047 |
+
ORDER BY created_at DESC
|
| 1048 |
+
LIMIT -1 OFFSET 100
|
| 1049 |
+
)
|
| 1050 |
+
"""
|
| 1051 |
+
)
|
| 1052 |
+
return True
|
| 1053 |
+
except Exception as e:
|
| 1054 |
+
logger.error(f"[STORAGE] Task history write failed: {e}")
|
| 1055 |
+
return False
|
| 1056 |
+
|
| 1057 |
+
|
| 1058 |
+
async def load_task_history(limit: int = 100) -> Optional[list]:
|
| 1059 |
+
if not is_database_enabled():
|
| 1060 |
+
return None
|
| 1061 |
+
backend = _get_backend()
|
| 1062 |
+
try:
|
| 1063 |
+
if backend == "postgres":
|
| 1064 |
+
async with _pg_acquire() as conn:
|
| 1065 |
+
rows = await conn.fetch(
|
| 1066 |
+
"""
|
| 1067 |
+
SELECT data FROM task_history
|
| 1068 |
+
ORDER BY created_at DESC
|
| 1069 |
+
LIMIT $1
|
| 1070 |
+
""",
|
| 1071 |
+
limit,
|
| 1072 |
+
)
|
| 1073 |
+
return [_parse_account_value(row["data"]) for row in rows if row and row["data"] is not None]
|
| 1074 |
+
if backend == "sqlite":
|
| 1075 |
+
conn = _get_sqlite_conn()
|
| 1076 |
+
with _sqlite_lock:
|
| 1077 |
+
rows = conn.execute(
|
| 1078 |
+
"""
|
| 1079 |
+
SELECT data FROM task_history
|
| 1080 |
+
ORDER BY created_at DESC
|
| 1081 |
+
LIMIT ?
|
| 1082 |
+
""",
|
| 1083 |
+
(limit,),
|
| 1084 |
+
).fetchall()
|
| 1085 |
+
results = []
|
| 1086 |
+
for row in rows:
|
| 1087 |
+
value = _parse_account_value(row["data"])
|
| 1088 |
+
if value is not None:
|
| 1089 |
+
results.append(value)
|
| 1090 |
+
return results
|
| 1091 |
+
except Exception as e:
|
| 1092 |
+
logger.error(f"[STORAGE] Task history read failed: {e}")
|
| 1093 |
+
return None
|
| 1094 |
+
|
| 1095 |
+
|
| 1096 |
+
async def clear_task_history() -> int:
|
| 1097 |
+
if not is_database_enabled():
|
| 1098 |
+
return 0
|
| 1099 |
+
backend = _get_backend()
|
| 1100 |
+
try:
|
| 1101 |
+
if backend == "postgres":
|
| 1102 |
+
async with _pg_acquire() as conn:
|
| 1103 |
+
result = await conn.execute("DELETE FROM task_history")
|
| 1104 |
+
if result.startswith("DELETE"):
|
| 1105 |
+
parts = result.split()
|
| 1106 |
+
return int(parts[-1]) if parts else 0
|
| 1107 |
+
return 0
|
| 1108 |
+
if backend == "sqlite":
|
| 1109 |
+
conn = _get_sqlite_conn()
|
| 1110 |
+
with _sqlite_lock, conn:
|
| 1111 |
+
cur = conn.execute("DELETE FROM task_history")
|
| 1112 |
+
return cur.rowcount or 0
|
| 1113 |
+
except Exception as e:
|
| 1114 |
+
logger.error(f"[STORAGE] Task history clear failed: {e}")
|
| 1115 |
+
return 0
|
| 1116 |
+
|
| 1117 |
+
|
| 1118 |
+
def save_task_history_entry_sync(entry: dict) -> bool:
|
| 1119 |
+
return _run_in_db_loop(save_task_history_entry(entry))
|
| 1120 |
+
|
| 1121 |
+
|
| 1122 |
+
def load_task_history_sync(limit: int = 100) -> Optional[list]:
|
| 1123 |
+
return _run_in_db_loop(load_task_history(limit))
|
| 1124 |
+
|
| 1125 |
+
|
| 1126 |
+
def clear_task_history_sync() -> int:
|
| 1127 |
+
return _run_in_db_loop(clear_task_history())
|
core/uptime.py
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Uptime 实时监控与心跳历史持久化。
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
from collections import deque
|
| 6 |
+
from datetime import datetime, timezone, timedelta
|
| 7 |
+
from typing import Dict, List, Optional
|
| 8 |
+
import json
|
| 9 |
+
import os
|
| 10 |
+
from threading import Lock
|
| 11 |
+
|
| 12 |
+
# 北京时区 UTC+8
|
| 13 |
+
BEIJING_TZ = timezone(timedelta(hours=8))
|
| 14 |
+
|
| 15 |
+
# 每个服务保留最近 60 条心跳
|
| 16 |
+
MAX_HEARTBEATS = 60
|
| 17 |
+
SLOW_THRESHOLD_MS = 40000
|
| 18 |
+
WARNING_STATUS_CODES = {429}
|
| 19 |
+
|
| 20 |
+
_storage_path: Optional[str] = None
|
| 21 |
+
_storage_lock = Lock()
|
| 22 |
+
|
| 23 |
+
# 服务注册表
|
| 24 |
+
SERVICES = {
|
| 25 |
+
"api_service": {"name": "API 服务", "heartbeats": deque(maxlen=MAX_HEARTBEATS)},
|
| 26 |
+
"account_pool": {"name": "服务资源", "heartbeats": deque(maxlen=MAX_HEARTBEATS)},
|
| 27 |
+
"gemini-2.5-flash": {"name": "Gemini 2.5 Flash", "heartbeats": deque(maxlen=MAX_HEARTBEATS)},
|
| 28 |
+
"gemini-2.5-pro": {"name": "Gemini 2.5 Pro", "heartbeats": deque(maxlen=MAX_HEARTBEATS)},
|
| 29 |
+
"gemini-3-flash-preview": {"name": "Gemini 3 Flash Preview", "heartbeats": deque(maxlen=MAX_HEARTBEATS)},
|
| 30 |
+
"gemini-3-pro-preview": {"name": "Gemini 3 Pro Preview", "heartbeats": deque(maxlen=MAX_HEARTBEATS)},
|
| 31 |
+
"gemini-3.1-pro-preview": {"name": "Gemini 3.1 Pro Preview", "heartbeats": deque(maxlen=MAX_HEARTBEATS)},
|
| 32 |
+
"gemini-imagen": {"name": "Gemini Imagen", "heartbeats": deque(maxlen=MAX_HEARTBEATS)},
|
| 33 |
+
"gemini-veo": {"name": "Gemini Veo", "heartbeats": deque(maxlen=MAX_HEARTBEATS)},
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
SUPPORTED_MODELS = [
|
| 37 |
+
"gemini-2.5-flash",
|
| 38 |
+
"gemini-2.5-pro",
|
| 39 |
+
"gemini-3-flash-preview",
|
| 40 |
+
"gemini-3-pro-preview",
|
| 41 |
+
"gemini-3.1-pro-preview",
|
| 42 |
+
"gemini-imagen",
|
| 43 |
+
"gemini-veo",
|
| 44 |
+
]
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def configure_storage(path: Optional[str]) -> None:
|
| 48 |
+
"""配置心跳持久化路径。"""
|
| 49 |
+
global _storage_path
|
| 50 |
+
_storage_path = path
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def _classify_level(success: bool, status_code: Optional[int], latency_ms: Optional[int]) -> str:
|
| 54 |
+
if status_code in WARNING_STATUS_CODES:
|
| 55 |
+
return "warn"
|
| 56 |
+
if success and latency_ms is not None and latency_ms >= SLOW_THRESHOLD_MS:
|
| 57 |
+
return "warn"
|
| 58 |
+
return "up" if success else "down"
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def _save_heartbeats() -> None:
|
| 62 |
+
if not _storage_path:
|
| 63 |
+
return
|
| 64 |
+
try:
|
| 65 |
+
payload = {}
|
| 66 |
+
for service_id, service_data in SERVICES.items():
|
| 67 |
+
payload[service_id] = list(service_data["heartbeats"])
|
| 68 |
+
os.makedirs(os.path.dirname(_storage_path), exist_ok=True)
|
| 69 |
+
with _storage_lock, open(_storage_path, "w", encoding="utf-8") as f:
|
| 70 |
+
json.dump(payload, f, ensure_ascii=True, indent=2)
|
| 71 |
+
except Exception:
|
| 72 |
+
return
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def load_heartbeats() -> None:
|
| 76 |
+
if not _storage_path or not os.path.exists(_storage_path):
|
| 77 |
+
return
|
| 78 |
+
try:
|
| 79 |
+
with _storage_lock, open(_storage_path, "r", encoding="utf-8") as f:
|
| 80 |
+
payload = json.load(f)
|
| 81 |
+
for service_id, heartbeats in payload.items():
|
| 82 |
+
if service_id not in SERVICES:
|
| 83 |
+
continue
|
| 84 |
+
SERVICES[service_id]["heartbeats"].clear()
|
| 85 |
+
for beat in heartbeats[-MAX_HEARTBEATS:]:
|
| 86 |
+
SERVICES[service_id]["heartbeats"].append(beat)
|
| 87 |
+
except Exception:
|
| 88 |
+
return
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def record_request(
|
| 92 |
+
service: str,
|
| 93 |
+
success: bool,
|
| 94 |
+
latency_ms: Optional[int] = None,
|
| 95 |
+
status_code: Optional[int] = None
|
| 96 |
+
):
|
| 97 |
+
"""记录一次心跳。"""
|
| 98 |
+
if service not in SERVICES:
|
| 99 |
+
return
|
| 100 |
+
|
| 101 |
+
level = _classify_level(success, status_code, latency_ms)
|
| 102 |
+
heartbeat = {
|
| 103 |
+
"time": datetime.now(BEIJING_TZ).strftime("%H:%M:%S"),
|
| 104 |
+
"success": success,
|
| 105 |
+
"level": level,
|
| 106 |
+
}
|
| 107 |
+
if latency_ms is not None:
|
| 108 |
+
heartbeat["latency_ms"] = latency_ms
|
| 109 |
+
if status_code is not None:
|
| 110 |
+
heartbeat["status_code"] = status_code
|
| 111 |
+
|
| 112 |
+
SERVICES[service]["heartbeats"].append(heartbeat)
|
| 113 |
+
_save_heartbeats()
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
def get_realtime_status() -> Dict:
|
| 117 |
+
"""返回实时监控数据。"""
|
| 118 |
+
result = {"services": {}}
|
| 119 |
+
|
| 120 |
+
for service_id, service_data in SERVICES.items():
|
| 121 |
+
heartbeats = list(service_data["heartbeats"])
|
| 122 |
+
total = len(heartbeats)
|
| 123 |
+
success = sum(1 for h in heartbeats if h.get("success"))
|
| 124 |
+
|
| 125 |
+
uptime = (success / total * 100) if total > 0 else 100.0
|
| 126 |
+
|
| 127 |
+
last_status = "unknown"
|
| 128 |
+
if heartbeats:
|
| 129 |
+
last_level = heartbeats[-1].get("level")
|
| 130 |
+
if last_level in {"up", "down", "warn"}:
|
| 131 |
+
last_status = last_level
|
| 132 |
+
else:
|
| 133 |
+
last_status = "up" if heartbeats[-1].get("success") else "down"
|
| 134 |
+
|
| 135 |
+
result["services"][service_id] = {
|
| 136 |
+
"name": service_data["name"],
|
| 137 |
+
"status": last_status,
|
| 138 |
+
"uptime": round(uptime, 1),
|
| 139 |
+
"total": total,
|
| 140 |
+
"success": success,
|
| 141 |
+
"heartbeats": heartbeats[-MAX_HEARTBEATS:],
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
result["updated_at"] = datetime.now(BEIJING_TZ).strftime("%Y-%m-%d %H:%M:%S")
|
| 145 |
+
return result
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
async def get_uptime_summary(days: int = 90) -> Dict:
|
| 149 |
+
"""兼容旧接口。"""
|
| 150 |
+
return get_realtime_status()
|
docker-compose.yml
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
services:
|
| 2 |
+
gemini-api:
|
| 3 |
+
image: cooooookk/gemini-business2api:latest
|
| 4 |
+
container_name: gemini-business2api
|
| 5 |
+
ports:
|
| 6 |
+
- "7860:7860"
|
| 7 |
+
volumes:
|
| 8 |
+
- ./data:/app/data
|
| 9 |
+
env_file:
|
| 10 |
+
- .env
|
| 11 |
+
restart: unless-stopped
|
| 12 |
+
# 健康检查
|
| 13 |
+
healthcheck:
|
| 14 |
+
test: ["CMD", "curl", "-f", "http://localhost:7860/health"]
|
| 15 |
+
interval: 30s
|
| 16 |
+
timeout: 10s
|
| 17 |
+
retries: 3
|
| 18 |
+
start_period: 10s
|
| 19 |
+
# 日志限制
|
| 20 |
+
logging:
|
| 21 |
+
driver: json-file
|
| 22 |
+
options:
|
| 23 |
+
max-size: "10m"
|
| 24 |
+
max-file: "3"
|
docs/DISCLAIMER.md
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 使用声明与免责条款
|
| 2 |
+
|
| 3 |
+
## ⚠️ 严禁滥用:禁止将本工具用于商业用途或任何形式的滥用(无论规模大小)
|
| 4 |
+
|
| 5 |
+
**本工具严禁用于以下行为:**
|
| 6 |
+
- 商业用途或盈利性使用
|
| 7 |
+
- 任何形式的批量操作或自动化滥用(无论规模大小)
|
| 8 |
+
- 破坏市场秩序或恶意竞争
|
| 9 |
+
- 违反 Google 服务条款的任何行为
|
| 10 |
+
- 违反 Microsoft 服务条款的任何行为
|
| 11 |
+
|
| 12 |
+
**违规后果**:滥用行为可能导致账号永久封禁、法律追责,一切后果由使用者自行承担。
|
| 13 |
+
|
| 14 |
+
## 📖 合法用途
|
| 15 |
+
|
| 16 |
+
本项目仅限于以下场景:
|
| 17 |
+
- 个人学习与技术研究
|
| 18 |
+
- 浏览器自动化技术探索
|
| 19 |
+
- 非商业性技术交流
|
| 20 |
+
|
| 21 |
+
## ⚖️ 法律责任
|
| 22 |
+
|
| 23 |
+
1. **使用者责任**:使用本工具产生的一切后果(包括但不限于账号封禁、数据损失、法律纠纷)由使用者完全承担
|
| 24 |
+
2. **合规义务**:使用者必须遵守所在地法律法规及第三方服务条款(包括但不限于 Google Workspace、Microsoft 365 等服务条款)
|
| 25 |
+
3. **作者免责**:作者不对任何违规使用、滥用行为或由此产生的后果承担责任
|
| 26 |
+
|
| 27 |
+
## 📋 技术声明
|
| 28 |
+
|
| 29 |
+
- **无担保**:本项目按"现状"提供,不提供任何形式的担保
|
| 30 |
+
- **第三方依赖**:依赖的第三方服务(如 DuckMail API、Microsoft Graph API 等)可用性不受作者控制
|
| 31 |
+
- **维护权利**:作者保留随时停止维护、变更功能或关闭项目的权利
|
| 32 |
+
|
| 33 |
+
## 🔗 相关服务条款
|
| 34 |
+
|
| 35 |
+
使用本工具时,您必须同时遵守以下第三方服务的条款:
|
| 36 |
+
- [Google 服务条款](https://policies.google.com/terms)
|
| 37 |
+
- [Google Workspace 附加条款](https://workspace.google.com/terms/service-terms.html)
|
| 38 |
+
- [Microsoft 服务协议](https://www.microsoft.com/servicesagreement)
|
| 39 |
+
- [Microsoft 365 使用条款](https://www.microsoft.com/licensing/terms)
|
| 40 |
+
|
| 41 |
+
---
|
| 42 |
+
|
| 43 |
+
**使用本工具即表示您已阅读、理解并同意遵守以上所有条款。**
|
docs/DISCLAIMER_EN.md
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Terms of Use and Disclaimer
|
| 2 |
+
|
| 3 |
+
## ⚠️ Abuse Prohibited
|
| 4 |
+
|
| 5 |
+
**This tool is strictly prohibited for the following uses:**
|
| 6 |
+
- Commercial use or profit-making activities
|
| 7 |
+
- Any form of batch operations or automated abuse (regardless of scale)
|
| 8 |
+
- Market disruption or malicious competition
|
| 9 |
+
- Any behavior violating Google's Terms of Service
|
| 10 |
+
|
| 11 |
+
**Consequences:** Abuse may result in permanent account bans, legal liability, and all consequences are borne by the user.
|
| 12 |
+
|
| 13 |
+
## 📖 Legitimate Use
|
| 14 |
+
|
| 15 |
+
This project is limited to the following scenarios:
|
| 16 |
+
- Personal learning and technical research
|
| 17 |
+
- Browser automation technology exploration
|
| 18 |
+
- Non-commercial technical exchange
|
| 19 |
+
|
| 20 |
+
## ⚖️ Legal Liability
|
| 21 |
+
|
| 22 |
+
1. **User Responsibility**: All consequences arising from the use of this tool (including but not limited to account bans, data loss, legal disputes) are entirely borne by the user
|
| 23 |
+
2. **Compliance Obligation**: Users must comply with local laws and regulations and third-party service terms
|
| 24 |
+
3. **Author Disclaimer**: The author is not responsible for any violations, abuse, or consequences arising therefrom
|
| 25 |
+
|
| 26 |
+
## 📋 Technical Disclaimer
|
| 27 |
+
|
| 28 |
+
- **No Warranty**: This project is provided "as is" without any form of warranty
|
| 29 |
+
- **Third-Party Dependencies**: Depends on third-party services (such as DuckMail API) whose availability is not controlled by the author
|
| 30 |
+
- **Maintenance Rights**: The author reserves the right to stop maintenance, change functionality, or shut down the project at any time
|
| 31 |
+
|
| 32 |
+
---
|
| 33 |
+
|
| 34 |
+
**By using this tool, you acknowledge that you have read, understood, and agree to comply with all the above terms.**
|
docs/README_EN.md
ADDED
|
@@ -0,0 +1,377 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<p align="center">
|
| 2 |
+
<img src="../docs/logo.svg" width="120" alt="Gemini Business2API logo" />
|
| 3 |
+
</p>
|
| 4 |
+
<h1 align="center">Gemini Business2API</h1>
|
| 5 |
+
<p align="center">Empowering AI with seamless integration</p>
|
| 6 |
+
<p align="center">
|
| 7 |
+
<a href="../README.md">简体中文</a> | <strong>English</strong>
|
| 8 |
+
</p>
|
| 9 |
+
<p align="center"><img src="https://img.shields.io/badge/License-MIT-yellow.svg" /> <img src="https://img.shields.io/badge/Python-3.11-3776AB?logo=python&logoColor=white" /> <img src="https://img.shields.io/badge/FastAPI-0.110-009688?logo=fastapi&logoColor=white" /> <img src="https://img.shields.io/badge/Vue-3-4FC08D?logo=vue.js&logoColor=white" /> <img src="https://img.shields.io/badge/Vite-7-646CFF?logo=vite&logoColor=white" /> <img src="https://img.shields.io/badge/Docker-ready-2496ED?logo=docker&logoColor=white" /></p>
|
| 10 |
+
|
| 11 |
+
<p align="center">Convert Gemini Business to OpenAI-compatible API with multi-account load balancing, image generation, video generation, multimodal capabilities, and built-in admin panel.</p>
|
| 12 |
+
|
| 13 |
+
---
|
| 14 |
+
|
| 15 |
+
## 📜 License & Disclaimer
|
| 16 |
+
|
| 17 |
+
**License**: MIT License - See [LICENSE](../LICENSE) for details
|
| 18 |
+
|
| 19 |
+
### ⚠️ Prohibited Use & Anti-Abuse Policy
|
| 20 |
+
|
| 21 |
+
**This tool is strictly prohibited for:**
|
| 22 |
+
- Commercial use or profit-making activities
|
| 23 |
+
- Batch operations or automated abuse of any scale
|
| 24 |
+
- Market disruption or malicious competition
|
| 25 |
+
- Violations of Google's Terms of Service
|
| 26 |
+
- Violations of Microsoft's Terms of Service
|
| 27 |
+
|
| 28 |
+
**Consequences**: Violations may result in permanent account suspension and legal liability. All consequences are the sole responsibility of the user.
|
| 29 |
+
|
| 30 |
+
**Legitimate Use Only**: Personal learning, technical research, and non-commercial educational purposes only.
|
| 31 |
+
|
| 32 |
+
📖 **Full Disclaimer**: [DISCLAIMER_EN.md](DISCLAIMER_EN.md)
|
| 33 |
+
|
| 34 |
+
---
|
| 35 |
+
|
| 36 |
+
## ✨ Features
|
| 37 |
+
|
| 38 |
+
- ✅ Full OpenAI API compatibility - Seamless integration with existing tools
|
| 39 |
+
- ✅ Multi-account load balancing - Round-robin with automatic failover
|
| 40 |
+
- ✅ Automated account management - Auto registration & login, multiple temp email providers, headless browser mode
|
| 41 |
+
- ✅ Streaming output - Real-time responses
|
| 42 |
+
- ✅ Multimodal input - 100+ file types (images, PDF, Office docs, audio, video, code, etc.)
|
| 43 |
+
- ✅ Image generation & image-to-image - Configurable models, Base64 or URL output
|
| 44 |
+
- ✅ Video generation - Dedicated model with HTML/URL/Markdown output formats
|
| 45 |
+
- ✅ Smart file handling - Auto file type detection, supports URL and Base64
|
| 46 |
+
- ✅ Logging & monitoring - Real-time status and statistics
|
| 47 |
+
- ✅ Proxy support - Configure via admin settings panel
|
| 48 |
+
- ✅ Built-in admin panel - Online configuration and account management
|
| 49 |
+
- ✅ PostgreSQL / SQLite storage - Persistent accounts/settings/stats
|
| 50 |
+
|
| 51 |
+
## 🤖 Model Capabilities
|
| 52 |
+
|
| 53 |
+
| Model ID | Vision | Native Web | File Multimodal | Image Gen | Video Gen |
|
| 54 |
+
| ------------------------ | ------ | ---------- | --------------- | --------- | --------- |
|
| 55 |
+
| `gemini-auto` | ✅ | ✅ | ✅ | Optional | - |
|
| 56 |
+
| `gemini-2.5-flash` | ✅ | ✅ | ✅ | Optional | - |
|
| 57 |
+
| `gemini-2.5-pro` | ✅ | ✅ | ✅ | Optional | - |
|
| 58 |
+
| `gemini-3-flash-preview` | ✅ | ✅ | ✅ | Optional | - |
|
| 59 |
+
| `gemini-3-pro-preview` | ✅ | ✅ | ✅ | Optional | - |
|
| 60 |
+
| `gemini-3.1-pro-preview` | ✅ | ✅ | ✅ | Optional | - |
|
| 61 |
+
| `gemini-imagen` | ✅ | ✅ | ✅ | ✅ | - |
|
| 62 |
+
| `gemini-veo` | ✅ | ✅ | ✅ | - | ✅ |
|
| 63 |
+
|
| 64 |
+
> `gemini-imagen`: Dedicated image generation model · `gemini-veo`: Dedicated video generation model
|
| 65 |
+
|
| 66 |
+
---
|
| 67 |
+
|
| 68 |
+
## 🚀 Quick Start
|
| 69 |
+
|
| 70 |
+
### Method 1: Docker Compose (Recommended)
|
| 71 |
+
|
| 72 |
+
**Supports ARM64 and AMD64 architectures**
|
| 73 |
+
|
| 74 |
+
```bash
|
| 75 |
+
git clone https://github.com/Dreamy-rain/gemini-business2api.git
|
| 76 |
+
cd gemini-business2api
|
| 77 |
+
cp .env.example .env
|
| 78 |
+
# Edit .env to set ADMIN_KEY
|
| 79 |
+
|
| 80 |
+
docker-compose up -d
|
| 81 |
+
|
| 82 |
+
# View logs
|
| 83 |
+
docker-compose logs -f
|
| 84 |
+
|
| 85 |
+
# Update to latest version
|
| 86 |
+
docker-compose pull && docker-compose up -d
|
| 87 |
+
```
|
| 88 |
+
|
| 89 |
+
---
|
| 90 |
+
|
| 91 |
+
### Method 2: Setup Script
|
| 92 |
+
|
| 93 |
+
> **Prerequisites**: Git, Node.js & npm (for frontend build). Script auto-installs Python 3.11 and uv.
|
| 94 |
+
|
| 95 |
+
**Linux / macOS / WSL:**
|
| 96 |
+
```bash
|
| 97 |
+
git clone https://github.com/Dreamy-rain/gemini-business2api.git
|
| 98 |
+
cd gemini-business2api
|
| 99 |
+
bash setup.sh
|
| 100 |
+
# Edit .env to set ADMIN_KEY
|
| 101 |
+
source .venv/bin/activate
|
| 102 |
+
python main.py
|
| 103 |
+
# Background with pm2
|
| 104 |
+
pm2 start main.py --name gemini-api --interpreter ./.venv/bin/python3
|
| 105 |
+
```
|
| 106 |
+
|
| 107 |
+
**Windows:**
|
| 108 |
+
```cmd
|
| 109 |
+
git clone https://github.com/Dreamy-rain/gemini-business2api.git
|
| 110 |
+
cd gemini-business2api
|
| 111 |
+
setup.bat
|
| 112 |
+
# Edit .env to set ADMIN_KEY
|
| 113 |
+
.venv\Scripts\activate.bat
|
| 114 |
+
python main.py
|
| 115 |
+
# Background with pm2
|
| 116 |
+
pm2 start main.py --name gemini-api --interpreter ./.venv/Scripts/python.exe
|
| 117 |
+
```
|
| 118 |
+
|
| 119 |
+
The script handles: uv install, Python 3.11 download, dependency install, frontend build, `.env` creation.
|
| 120 |
+
To update, simply re-run the same script.
|
| 121 |
+
|
| 122 |
+
---
|
| 123 |
+
|
| 124 |
+
### Method 3: Manual Deployment
|
| 125 |
+
|
| 126 |
+
```bash
|
| 127 |
+
git clone https://github.com/Dreamy-rain/gemini-business2api.git
|
| 128 |
+
cd gemini-business2api
|
| 129 |
+
|
| 130 |
+
curl -LsSf https://astral.sh/uv/install.sh | sh
|
| 131 |
+
uv python install 3.11
|
| 132 |
+
|
| 133 |
+
cd frontend && npm install && npm run build && cd ..
|
| 134 |
+
|
| 135 |
+
uv venv --python 3.11 .venv
|
| 136 |
+
source .venv/bin/activate # Windows: .venv\Scripts\activate.bat
|
| 137 |
+
uv pip install -r requirements.txt
|
| 138 |
+
|
| 139 |
+
cp .env.example .env
|
| 140 |
+
# Edit .env to set ADMIN_KEY
|
| 141 |
+
python main.py
|
| 142 |
+
```
|
| 143 |
+
|
| 144 |
+
---
|
| 145 |
+
|
| 146 |
+
### Access
|
| 147 |
+
|
| 148 |
+
- **Admin Panel**: `http://localhost:7860/` (Login with `ADMIN_KEY`)
|
| 149 |
+
- **API Endpoint**: `http://localhost:7860/v1/chat/completions`
|
| 150 |
+
|
| 151 |
+
---
|
| 152 |
+
|
| 153 |
+
## 🗄️ Database Persistence
|
| 154 |
+
|
| 155 |
+
Set `DATABASE_URL` to persist accounts, settings, and stats. Without it, SQLite (`data.db`) is used automatically.
|
| 156 |
+
|
| 157 |
+
**Configuration:**
|
| 158 |
+
- Local deployment → add to `.env`
|
| 159 |
+
- Cloud platforms → set in platform environment variables
|
| 160 |
+
|
| 161 |
+
```
|
| 162 |
+
DATABASE_URL=postgresql://user:password@host/dbname?sslmode=require
|
| 163 |
+
```
|
| 164 |
+
|
| 165 |
+
**Free PostgreSQL Providers:**
|
| 166 |
+
|
| 167 |
+
| Service | Free Tier | How to Get |
|
| 168 |
+
|---------|-----------|-----------|
|
| 169 |
+
| [Neon](https://neon.tech) | 512MB / 100 CPUH/month | Sign up → Create Project → Copy Connection string |
|
| 170 |
+
| [Aiven](https://aiven.io) | More generous | Sign up → Create PostgreSQL service → Copy connection string |
|
| 171 |
+
|
| 172 |
+
> Both `postgres://` and `postgresql://` formats are supported natively.
|
| 173 |
+
|
| 174 |
+
<details>
|
| 175 |
+
<summary>⚠️ FAQ: Periodic save failure / ConnectionDoesNotExistError</summary>
|
| 176 |
+
|
| 177 |
+
If you see errors like:
|
| 178 |
+
|
| 179 |
+
```
|
| 180 |
+
ERROR [COOLDOWN] Save failed: connection was closed in the middle of operation
|
| 181 |
+
asyncpg.exceptions.ConnectionDoesNotExistError: connection was closed in the middle of operation
|
| 182 |
+
```
|
| 183 |
+
|
| 184 |
+
This happens when free PostgreSQL providers (e.g., Aiven free tier) close idle connections. **It does not affect normal usage** — the next operation will auto-reconnect. If frequent, consider switching to [Neon](https://neon.tech) or upgrading your database plan.
|
| 185 |
+
|
| 186 |
+
</details>
|
| 187 |
+
|
| 188 |
+
<details>
|
| 189 |
+
<summary>📦 Database Migration (Upgrading from older versions)</summary>
|
| 190 |
+
|
| 191 |
+
If you have legacy local files (`accounts.json` / `settings.yaml` / `stats.json`), run:
|
| 192 |
+
|
| 193 |
+
```bash
|
| 194 |
+
python scripts/migrate_to_database.py
|
| 195 |
+
```
|
| 196 |
+
|
| 197 |
+
The script auto-detects the environment (PostgreSQL / SQLite) and renames old files after migration.
|
| 198 |
+
|
| 199 |
+
</details>
|
| 200 |
+
|
| 201 |
+
---
|
| 202 |
+
|
| 203 |
+
## 📡 API Endpoints
|
| 204 |
+
|
| 205 |
+
Fully OpenAI API compatible. Works with ChatGPT-Next-Web, LobeChat, OpenCat, and other clients.
|
| 206 |
+
|
| 207 |
+
| Endpoint | Method | Description |
|
| 208 |
+
|----------|--------|-------------|
|
| 209 |
+
| `/v1/chat/completions` | POST | Chat completions (streaming supported) |
|
| 210 |
+
| `/v1/models` | GET | List available models |
|
| 211 |
+
| `/v1/images/generations` | POST | Image generation (text-to-image) |
|
| 212 |
+
| `/v1/images/edits` | POST | Image editing (image-to-image) |
|
| 213 |
+
| `/health` | GET | Health check |
|
| 214 |
+
|
| 215 |
+
**Example:**
|
| 216 |
+
|
| 217 |
+
```bash
|
| 218 |
+
curl http://localhost:7860/v1/chat/completions \
|
| 219 |
+
-H "Authorization: Bearer your-api-key" \
|
| 220 |
+
-H "Content-Type: application/json" \
|
| 221 |
+
-d '{
|
| 222 |
+
"model": "gemini-2.5-flash",
|
| 223 |
+
"messages": [{"role": "user", "content": "Hello"}],
|
| 224 |
+
"stream": true
|
| 225 |
+
}'
|
| 226 |
+
```
|
| 227 |
+
|
| 228 |
+
> `API_KEY` is configured in Admin Panel → System Settings. Leave empty for public access. Multiple keys supported (comma-separated).
|
| 229 |
+
|
| 230 |
+
---
|
| 231 |
+
|
| 232 |
+
## 📧 Email Provider Configuration
|
| 233 |
+
|
| 234 |
+
The project supports 4 temporary email providers for automatic account registration. Switch and configure them in **Admin Panel → System Settings → Temp Email Provider**.
|
| 235 |
+
|
| 236 |
+
### Moemail (Default)
|
| 237 |
+
|
| 238 |
+
Open-source temporary email service, ready to use out of the box.
|
| 239 |
+
|
| 240 |
+
- **Project**: [github.com/beilunyang/moemail](https://github.com/beilunyang/moemail)
|
| 241 |
+
- **Website**: [moemail.app](https://moemail.app)
|
| 242 |
+
- **Config**: API URL + API Key + Domain (optional)
|
| 243 |
+
|
| 244 |
+
### DuckMail
|
| 245 |
+
|
| 246 |
+
Temporary email API service. Custom domain recommended.
|
| 247 |
+
|
| 248 |
+
- **Domain Management**: [domain.duckmail.sbs](https://domain.duckmail.sbs/)
|
| 249 |
+
- **Config**: API URL + API Key + Registration Domain
|
| 250 |
+
|
| 251 |
+
### GPTMail
|
| 252 |
+
|
| 253 |
+
Temporary email API service, no password required.
|
| 254 |
+
|
| 255 |
+
- **Default URL**: `https://mail.chatgpt.org.uk`
|
| 256 |
+
- **Default API Key**: `gpt-test`
|
| 257 |
+
- **Config**: API URL + API Key + Domain (optional)
|
| 258 |
+
|
| 259 |
+
### Freemail
|
| 260 |
+
|
| 261 |
+
Self-hosted temporary email service, for users with their own servers.
|
| 262 |
+
|
| 263 |
+
- **Project**: [github.com/idinging/freemail](https://github.com/idinging/freemail)
|
| 264 |
+
- **Config**: Self-hosted service URL + JWT Token + Domain (optional)
|
| 265 |
+
|
| 266 |
+
> **Tip**: All email settings are configured in the admin panel. Microsoft email login is also handled through the admin panel.
|
| 267 |
+
|
| 268 |
+
---
|
| 269 |
+
|
| 270 |
+
## 🌐 Recommended Deployment Platforms
|
| 271 |
+
|
| 272 |
+
In addition to local Docker Compose, these platforms support Docker image deployment:
|
| 273 |
+
|
| 274 |
+
| Platform | Free Tier | Features |
|
| 275 |
+
|----------|-----------|----------|
|
| 276 |
+
| [Render](https://render.com) | ✅ Yes | Docker support, auto SSL, free PostgreSQL |
|
| 277 |
+
| [Railway](https://railway.app) | $5/month credit | One-click Docker deploy, built-in database |
|
| 278 |
+
| [Fly.io](https://fly.io) | ✅ Yes | Global edge deployment, persistent volumes |
|
| 279 |
+
| [Claw Cloud](https://claw.cloud) | ✅ Yes | Container cloud, simple and easy |
|
| 280 |
+
| Self-hosted VPS (Recommended) | — | Full control with Docker Compose |
|
| 281 |
+
|
| 282 |
+
> Docker image: `cooooookk/gemini-business2api:latest`
|
| 283 |
+
>
|
| 284 |
+
> Set `ADMIN_KEY` and `DATABASE_URL` environment variables when deploying.
|
| 285 |
+
|
| 286 |
+
### Zeabur Deployment Guide
|
| 287 |
+
|
| 288 |
+
1. Fork this repository to your GitHub
|
| 289 |
+
2. Log in to [Zeabur](https://zeabur.com) → **Create Project** → **Shared Cluster** → **Deploy New Service** → **Connect GitHub** → Select your forked repo
|
| 290 |
+
3. Add environment variables:
|
| 291 |
+
|
| 292 |
+
| Variable | Required | Description |
|
| 293 |
+
|----------|----------|-------------|
|
| 294 |
+
| `ADMIN_KEY` | ✅ | Admin panel login key |
|
| 295 |
+
| `DATABASE_URL` | Optional | PostgreSQL connection string (recommended to avoid data loss on restart) |
|
| 296 |
+
|
| 297 |
+
4. **Persistent Storage** (Important):
|
| 298 |
+
|
| 299 |
+
Add persistent storage in service settings:
|
| 300 |
+
|
| 301 |
+
| Disk ID | Mount Path |
|
| 302 |
+
|---------|-----------|
|
| 303 |
+
| `data` | `/app/data` |
|
| 304 |
+
|
| 305 |
+
5. Click **Redeploy** to apply settings
|
| 306 |
+
|
| 307 |
+
**Update**: GitHub repo → **Sync fork** → **Update branch**, Zeabur will auto-redeploy.
|
| 308 |
+
|
| 309 |
+
---
|
| 310 |
+
|
| 311 |
+
## 🔄 Standalone Refresh Service
|
| 312 |
+
|
| 313 |
+
To deploy the account refresh service separately from the main API, use the [`refresh-worker` branch](https://github.com/Dreamy-rain/gemini-business2api/tree/refresh-worker):
|
| 314 |
+
|
| 315 |
+
```bash
|
| 316 |
+
git clone -b refresh-worker https://github.com/Dreamy-rain/gemini-business2api.git gemini-refresh-worker
|
| 317 |
+
cd gemini-refresh-worker
|
| 318 |
+
cp .env.example .env
|
| 319 |
+
# Edit .env to set DATABASE_URL
|
| 320 |
+
docker-compose up -d
|
| 321 |
+
```
|
| 322 |
+
|
| 323 |
+
This service reads accounts from the database and runs scheduled credential refresh independently. Supports cron scheduling, batch processing, and cooldown deduplication.
|
| 324 |
+
|
| 325 |
+
---
|
| 326 |
+
|
| 327 |
+
## 🌐 Socks5 Free Proxy Pool
|
| 328 |
+
|
| 329 |
+
Configure a proxy when auto-registering/refreshing accounts to improve success rates:
|
| 330 |
+
|
| 331 |
+
- **Project**: [github.com/Dreamy-rain/socks5-proxy](https://github.com/Dreamy-rain/socks5-proxy)
|
| 332 |
+
- **Note**: Free proxies are not very stable, but can help improve registration success rates
|
| 333 |
+
- **Usage**: Configure in Admin Panel → System Settings → Proxy Settings
|
| 334 |
+
|
| 335 |
+
---
|
| 336 |
+
|
| 337 |
+
## 📸 Screenshots
|
| 338 |
+
|
| 339 |
+
### Admin System
|
| 340 |
+
|
| 341 |
+
<table>
|
| 342 |
+
<tr>
|
| 343 |
+
<td><img src="img/1.png" alt="Admin System 1" /></td>
|
| 344 |
+
<td><img src="img/2.png" alt="Admin System 2" /></td>
|
| 345 |
+
</tr>
|
| 346 |
+
<tr>
|
| 347 |
+
<td><img src="img/3.png" alt="Admin System 3" /></td>
|
| 348 |
+
<td><img src="img/4.png" alt="Admin System 4" /></td>
|
| 349 |
+
</tr>
|
| 350 |
+
<tr>
|
| 351 |
+
<td><img src="img/5.png" alt="Admin System 5" /></td>
|
| 352 |
+
<td><img src="img/6.png" alt="Admin System 6" /></td>
|
| 353 |
+
</tr>
|
| 354 |
+
</table>
|
| 355 |
+
|
| 356 |
+
### Image Effects
|
| 357 |
+
|
| 358 |
+
<table>
|
| 359 |
+
<tr>
|
| 360 |
+
<td><img src="img/img_1.png" alt="Image Effects 1" /></td>
|
| 361 |
+
<td><img src="img/img_2.png" alt="Image Effects 2" /></td>
|
| 362 |
+
</tr>
|
| 363 |
+
<tr>
|
| 364 |
+
<td><img src="img/img_3.png" alt="Image Effects 3" /></td>
|
| 365 |
+
<td><img src="img/img_4.png" alt="Image Effects 4" /></td>
|
| 366 |
+
</tr>
|
| 367 |
+
</table>
|
| 368 |
+
|
| 369 |
+
### Documentation
|
| 370 |
+
|
| 371 |
+
- Supported file types: [SUPPORTED_FILE_TYPES.md](SUPPORTED_FILE_TYPES.md)
|
| 372 |
+
|
| 373 |
+
## ⭐ Star History
|
| 374 |
+
|
| 375 |
+
[](https://www.star-history.com/#Dreamy-rain/gemini-business2api&type=date&legend=top-left)
|
| 376 |
+
|
| 377 |
+
**If this project helps you, please give it a ⭐ Star!**
|
docs/SUPPORTED_FILE_TYPES.md
ADDED
|
@@ -0,0 +1,383 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 支持的文件类型清单
|
| 2 |
+
|
| 3 |
+
本文档列出了 Gemini 可能支持的所有文件类型(可能支持)。
|
| 4 |
+
|
| 5 |
+
**支持的文件类型**(12 个分类,100+ 种格式):
|
| 6 |
+
|
| 7 |
+
- 🖼️ **图片文件** - 11 种格式(PNG, JPEG, WebP, GIF, BMP, TIFF, SVG, ICO, HEIC, HEIF, AVIF)
|
| 8 |
+
- 📄 **文档文件** - 9 种格式(PDF, TXT, Markdown, HTML, XML, CSV, TSV, RTF, LaTeX)
|
| 9 |
+
- 📊 **Microsoft Office** - 6 种格式(.docx, .doc, .xlsx, .xls, .pptx, .ppt)
|
| 10 |
+
- 📝 **Google Workspace** - 3 种格式(Docs, Sheets, Slides)
|
| 11 |
+
- 💻 **代码文件** - 19 种语言(Python, JavaScript, TypeScript, Java, C/C++, Go, Rust, PHP, Ruby, Swift, Kotlin, Scala, Shell, PowerShell, SQL, R, MATLAB 等)
|
| 12 |
+
- 🎨 **Web 开发** - 8 种格式(CSS, SCSS, LESS, JSON, YAML, TOML, Vue, Svelte)
|
| 13 |
+
- 🎵 **音频文件** - 10 种格式(MP3, WAV, AAC, M4A, OGG, FLAC, AIFF, WMA, OPUS, AMR)
|
| 14 |
+
- 🎬 **视频文件** - 10 种格式(MP4, MOV, AVI, MPEG, WebM, FLV, WMV, MKV, 3GPP, M4V)
|
| 15 |
+
- 📦 **数据文件** - 6 种格式(JSON, JSONL, CSV, TSV, Parquet, Avro)
|
| 16 |
+
- 🗜️ **压缩文件** - 5 种格式(ZIP, RAR, 7Z, TAR, GZ)
|
| 17 |
+
- 🔧 **配置文件** - 5 种格式(YAML, TOML, INI, ENV, Properties)
|
| 18 |
+
- 📚 **电子书** - 2 种格式(EPUB, MOBI)
|
| 19 |
+
|
| 20 |
+
## 🖼️ 图片文件(Image Files)
|
| 21 |
+
|
| 22 |
+
| 格式 | 扩展名 | MIME 类型 | 支持状态 | 说明 |
|
| 23 |
+
| ---- | --------------- | --------------- | ---------- | ------------------ |
|
| 24 |
+
| PNG | `.png` | `image/png` | ✅ 完全支持 | 无损压缩,支持透明 |
|
| 25 |
+
| JPEG | `.jpg`, `.jpeg` | `image/jpeg` | ✅ 完全支持 | 有损压缩,照片常用 |
|
| 26 |
+
| WebP | `.webp` | `image/webp` | ✅ 完全支持 | 现代格式,体积小 |
|
| 27 |
+
| GIF | `.gif` | `image/gif` | ✅ 完全支持 | 支持动画 |
|
| 28 |
+
| BMP | `.bmp` | `image/bmp` | ✅ 支持 | Windows 位图 |
|
| 29 |
+
| TIFF | `.tiff`, `.tif` | `image/tiff` | ✅ 支持 | 高质量图像 |
|
| 30 |
+
| SVG | `.svg` | `image/svg+xml` | ✅ 支持 | 矢量图形 |
|
| 31 |
+
| ICO | `.ico` | `image/x-icon` | ✅ 支持 | 图标文件 |
|
| 32 |
+
| HEIC | `.heic` | `image/heic` | ✅ 支持 | Apple 高效图像格式 |
|
| 33 |
+
| HEIF | `.heif` | `image/heif` | ✅ 支持 | 高效图像格式 |
|
| 34 |
+
| AVIF | `.avif` | `image/avif` | ✅ 支持 | 新一代图像格式 |
|
| 35 |
+
|
| 36 |
+
## 📄 文档文件(Document Files)
|
| 37 |
+
|
| 38 |
+
| 格式 | 扩展名 | MIME 类型 | 支持状态 | 说明 |
|
| 39 |
+
| -------- | --------------- | ------------------------------- | ---------- | ---------------------- |
|
| 40 |
+
| PDF | `.pdf` | `application/pdf` | ✅ 完全支持 | 可提取文本、图片、表格 |
|
| 41 |
+
| 纯文本 | `.txt` | `text/plain` | ✅ 完全支持 | 纯文本文件 |
|
| 42 |
+
| Markdown | `.md` | `text/markdown` | ✅ 完全支持 | 标记语言 |
|
| 43 |
+
| HTML | `.html`, `.htm` | `text/html` | ✅ 完全支持 | 网页文件 |
|
| 44 |
+
| XML | `.xml` | `text/xml` 或 `application/xml` | ✅ 完全支持 | 结构化数据 |
|
| 45 |
+
| CSV | `.csv` | `text/csv` | ✅ 完全支持 | 表格数据 |
|
| 46 |
+
| TSV | `.tsv` | `text/tab-separated-values` | ✅ 支持 | 制表符分隔 |
|
| 47 |
+
| RTF | `.rtf` | `application/rtf` | ✅ 支持 | 富文本格式 |
|
| 48 |
+
| LaTeX | `.tex` | `text/x-tex` | ✅ 支持 | 科学文档 |
|
| 49 |
+
|
| 50 |
+
## 📊 Microsoft Office 文档
|
| 51 |
+
|
| 52 |
+
| 格式 | 扩展名 | MIME 类型 | 支持状态 | 说明 |
|
| 53 |
+
| --------------- | ------- | --------------------------------------------------------------------------- | -------- | ---------------- |
|
| 54 |
+
| Word (新) | `.docx` | `application/vnd.openxmlformats-officedocument.wordprocessingml.document` | ✅ 支持 | 可提取文本和格式 |
|
| 55 |
+
| Word (旧) | `.doc` | `application/msword` | ✅ 支持 | 旧版 Word 文档 |
|
| 56 |
+
| Excel (新) | `.xlsx` | `application/vnd.openxmlformats-officedocument.spreadsheetml.sheet` | ✅ 支持 | 可读取表格数据 |
|
| 57 |
+
| Excel (旧) | `.xls` | `application/vnd.ms-excel` | ✅ 支持 | 旧版 Excel 文档 |
|
| 58 |
+
| PowerPoint (新) | `.pptx` | `application/vnd.openxmlformats-officedocument.presentationml.presentation` | ✅ 支持 | 可提取文本和图片 |
|
| 59 |
+
| PowerPoint (旧) | `.ppt` | `application/vnd.ms-powerpoint` | ✅ 支持 | 旧版 PPT 文档 |
|
| 60 |
+
|
| 61 |
+
## 📝 Google Workspace 文档
|
| 62 |
+
|
| 63 |
+
| 格式 | 扩展名 | MIME 类型 | 支持状态 | 说明 |
|
| 64 |
+
| ------------- | ---------- | ------------------------------------------ | -------- | ------------ |
|
| 65 |
+
| Google Docs | `.gdoc` | `application/vnd.google-apps.document` | ✅ 支持 | 需要导出链接 |
|
| 66 |
+
| Google Sheets | `.gsheet` | `application/vnd.google-apps.spreadsheet` | ✅ 支持 | 需要导出链接 |
|
| 67 |
+
| Google Slides | `.gslides` | `application/vnd.google-apps.presentation` | ✅ 支持 | 需要导出链接 |
|
| 68 |
+
|
| 69 |
+
## 💻 代码文件(Code Files)
|
| 70 |
+
|
| 71 |
+
| 格式 | 扩展名 | MIME 类型 | 支持状态 | 说明 |
|
| 72 |
+
| ---------- | --------------------- | --------------------------------------------- | ---------- | --------------- |
|
| 73 |
+
| Python | `.py` | `text/x-python` 或 `application/x-python` | ✅ 完全支持 | Python 代码 |
|
| 74 |
+
| JavaScript | `.js` | `text/javascript` 或 `application/javascript` | ✅ 完全支持 | JS 代码 |
|
| 75 |
+
| TypeScript | `.ts` | `text/typescript` 或 `application/typescript` | ✅ 完全支持 | TS 代码 |
|
| 76 |
+
| JSX/TSX | `.jsx`, `.tsx` | `text/jsx`, `text/tsx` | ✅ 支持 | React 组件 |
|
| 77 |
+
| Java | `.java` | `text/x-java-source` | ✅ 完全支持 | Java 代码 |
|
| 78 |
+
| C | `.c` | `text/x-c` | ✅ 支持 | C 语言 |
|
| 79 |
+
| C++ | `.cpp`, `.cc`, `.cxx` | `text/x-c++` | ✅ 支持 | C++ 代码 |
|
| 80 |
+
| C# | `.cs` | `text/x-csharp` | ✅ 支持 | C# 代码 |
|
| 81 |
+
| Go | `.go` | `text/x-go` | ✅ 支持 | Go 语言 |
|
| 82 |
+
| Rust | `.rs` | `text/x-rust` | ✅ 支持 | Rust 代码 |
|
| 83 |
+
| PHP | `.php` | `text/x-php` 或 `application/x-php` | ✅ 支持 | PHP 代码 |
|
| 84 |
+
| Ruby | `.rb` | `text/x-ruby` | ✅ 支持 | Ruby 代码 |
|
| 85 |
+
| Swift | `.swift` | `text/x-swift` | ✅ 支持 | Swift 代码 |
|
| 86 |
+
| Kotlin | `.kt` | `text/x-kotlin` | ✅ 支持 | Kotlin 代码 |
|
| 87 |
+
| Scala | `.scala` | `text/x-scala` | ✅ 支持 | Scala 代码 |
|
| 88 |
+
| Shell | `.sh`, `.bash` | `text/x-shellscript` | ✅ 支持 | Shell 脚本 |
|
| 89 |
+
| PowerShell | `.ps1` | `text/x-powershell` | ✅ 支持 | PowerShell 脚本 |
|
| 90 |
+
| SQL | `.sql` | `text/x-sql` 或 `application/sql` | ✅ 支持 | SQL 脚本 |
|
| 91 |
+
| R | `.r`, `.R` | `text/x-r` | ✅ 支持 | R 语言 |
|
| 92 |
+
| MATLAB | `.m` | `text/x-matlab` | ✅ 支持 | MATLAB 代码 |
|
| 93 |
+
|
| 94 |
+
## 🎨 Web 开发文件
|
| 95 |
+
|
| 96 |
+
| 格式 | 扩展名 | MIME 类型 | 支持状态 | 说明 |
|
| 97 |
+
| --------- | ---------------- | ----------------------------------- | ---------- | ------------ |
|
| 98 |
+
| CSS | `.css` | `text/css` | ✅ 完全支持 | 样式表 |
|
| 99 |
+
| SCSS/Sass | `.scss`, `.sass` | `text/x-scss`, `text/x-sass` | ✅ 支持 | CSS 预处理器 |
|
| 100 |
+
| LESS | `.less` | `text/x-less` | ✅ 支持 | CSS 预处理器 |
|
| 101 |
+
| JSON | `.json` | `application/json` | ✅ 完全支持 | 数据交换格式 |
|
| 102 |
+
| YAML | `.yaml`, `.yml` | `text/yaml` 或 `application/x-yaml` | ✅ 支持 | 配置文件 |
|
| 103 |
+
| TOML | `.toml` | `application/toml` | ✅ 支持 | 配置文件 |
|
| 104 |
+
| Vue | `.vue` | `text/x-vue` | ✅ 支持 | Vue 组件 |
|
| 105 |
+
| Svelte | `.svelte` | `text/x-svelte` | ✅ 支持 | Svelte 组件 |
|
| 106 |
+
|
| 107 |
+
## 🎵 音频文件(Audio Files)
|
| 108 |
+
|
| 109 |
+
| 格式 | 扩展名 | MIME 类型 | 支持状态 | 说明 |
|
| 110 |
+
| ---- | --------------- | ---------------------------- | ---------- | ------------ |
|
| 111 |
+
| MP3 | `.mp3` | `audio/mpeg` 或 `audio/mp3` | ✅ 完全支持 | 最常用格式 |
|
| 112 |
+
| WAV | `.wav` | `audio/wav` 或 `audio/x-wav` | ✅ 完全支持 | 无损格式 |
|
| 113 |
+
| AAC | `.aac` | `audio/aac` | ✅ 支持 | 高质量压缩 |
|
| 114 |
+
| M4A | `.m4a` | `audio/m4a` 或 `audio/mp4` | ✅ 支持 | Apple 格式 |
|
| 115 |
+
| OGG | `.ogg` | `audio/ogg` | ✅ 支持 | 开源格式 |
|
| 116 |
+
| FLAC | `.flac` | `audio/flac` | ✅ 支持 | 无损压缩 |
|
| 117 |
+
| AIFF | `.aiff`, `.aif` | `audio/aiff` | ✅ 支持 | Apple 格式 |
|
| 118 |
+
| WMA | `.wma` | `audio/x-ms-wma` | ✅ 支持 | Windows 格式 |
|
| 119 |
+
| OPUS | `.opus` | `audio/opus` | ✅ 支持 | 高效编码 |
|
| 120 |
+
| AMR | `.amr` | `audio/amr` | ✅ 支持 | 语音编码 |
|
| 121 |
+
|
| 122 |
+
**音频功能**:
|
| 123 |
+
- 🎤 语音转文字(转录)
|
| 124 |
+
- 🗣️ 说话人识别
|
| 125 |
+
- 🌍 语言识别
|
| 126 |
+
- 😊 情感分析
|
| 127 |
+
- 🎵 音乐分析
|
| 128 |
+
|
| 129 |
+
## 🎬 视频文件(Video Files)
|
| 130 |
+
|
| 131 |
+
| 格式 | 扩展名 | MIME 类型 | 支持状态 | 说明 |
|
| 132 |
+
| ---- | --------------- | ------------------ | ---------- | ------------ |
|
| 133 |
+
| MP4 | `.mp4` | `video/mp4` | ✅ 完全支持 | 最常用格式 |
|
| 134 |
+
| MOV | `.mov` | `video/quicktime` | ✅ 完全支持 | Apple 格式 |
|
| 135 |
+
| AVI | `.avi` | `video/x-msvideo` | ✅ 支持 | Windows 格式 |
|
| 136 |
+
| MPEG | `.mpeg`, `.mpg` | `video/mpeg` | ✅ 支持 | 标准格式 |
|
| 137 |
+
| WebM | `.webm` | `video/webm` | ✅ 支持 | 网页格式 |
|
| 138 |
+
| FLV | `.flv` | `video/x-flv` | ✅ 支持 | Flash 格式 |
|
| 139 |
+
| WMV | `.wmv` | `video/x-ms-wmv` | ✅ 支持 | Windows 格式 |
|
| 140 |
+
| MKV | `.mkv` | `video/x-matroska` | ✅ 支持 | 开源容器 |
|
| 141 |
+
| 3GPP | `.3gp`, `.3gpp` | `video/3gpp` | ✅ 支持 | 移动格式 |
|
| 142 |
+
| M4V | `.m4v` | `video/x-m4v` | ✅ 支持 | Apple 格式 |
|
| 143 |
+
|
| 144 |
+
**视频功能**:
|
| 145 |
+
- 🎬 场景识别
|
| 146 |
+
- 👤 人物检测
|
| 147 |
+
- 🏷️ 对象识别
|
| 148 |
+
- 📝 字幕生成
|
| 149 |
+
- 🎯 动作识别
|
| 150 |
+
- 📊 内容分析
|
| 151 |
+
|
| 152 |
+
## 📦 数据文件(Data Files)
|
| 153 |
+
|
| 154 |
+
| 格式 | 扩展名 | MIME 类型 | 支持状态 | 说明 |
|
| 155 |
+
| ------- | ---------- | --------------------------- | ---------- | ----------- |
|
| 156 |
+
| JSON | `.json` | `application/json` | ✅ 完全支持 | 数据交换 |
|
| 157 |
+
| JSONL | `.jsonl` | `application/jsonlines` | ✅ 支持 | 行分隔 JSON |
|
| 158 |
+
| CSV | `.csv` | `text/csv` | ✅ 完全支持 | 表格数据 |
|
| 159 |
+
| TSV | `.tsv` | `text/tab-separated-values` | ✅ 支持 | 制表符分隔 |
|
| 160 |
+
| Parquet | `.parquet` | `application/x-parquet` | ⚠️ 可能支持 | 列式存储 |
|
| 161 |
+
| Avro | `.avro` | `application/avro` | ⚠️ 可能支持 | 数据序列化 |
|
| 162 |
+
|
| 163 |
+
## 🗜️ 压缩文件(Archive Files)
|
| 164 |
+
|
| 165 |
+
| 格式 | 扩展名 | MIME 类型 | 支持状态 | 说明 |
|
| 166 |
+
| ---- | ------ | ------------------------------ | ---------- | -------- |
|
| 167 |
+
| ZIP | `.zip` | `application/zip` | ⚠️ 部分支持 | 需要解压 |
|
| 168 |
+
| RAR | `.rar` | `application/x-rar-compressed` | ❌ 不支持 | 需要解压 |
|
| 169 |
+
| 7Z | `.7z` | `application/x-7z-compressed` | ❌ 不支持 | 需要解压 |
|
| 170 |
+
| TAR | `.tar` | `application/x-tar` | ⚠️ 部分支持 | 需要解压 |
|
| 171 |
+
| GZ | `.gz` | `application/gzip` | ⚠️ 部分支持 | 需要解压 |
|
| 172 |
+
|
| 173 |
+
## 🔧 配置文件(Configuration Files)
|
| 174 |
+
|
| 175 |
+
| 格式 | 扩展名 | MIME 类型 | 支持状态 | 说明 |
|
| 176 |
+
| ---------- | --------------- | ------------------ | ---------- | --------- |
|
| 177 |
+
| YAML | `.yaml`, `.yml` | `text/yaml` | ✅ 完全支持 | 配置文件 |
|
| 178 |
+
| TOML | `.toml` | `application/toml` | ✅ 支持 | 配置文件 |
|
| 179 |
+
| INI | `.ini` | `text/plain` | ✅ 支持 | 配置文件 |
|
| 180 |
+
| ENV | `.env` | `text/plain` | ✅ 支持 | 环境变量 |
|
| 181 |
+
| Properties | `.properties` | `text/plain` | ✅ 支持 | Java 配置 |
|
| 182 |
+
|
| 183 |
+
## 📚 电子书格式(E-book Formats)
|
| 184 |
+
|
| 185 |
+
| 格式 | 扩展名 | MIME 类型 | 支持状态 | 说明 |
|
| 186 |
+
| ---- | ------- | -------------------------------- | ---------- | ----------- |
|
| 187 |
+
| EPUB | `.epub` | `application/epub+zip` | ⚠️ 可能支持 | 电子书格式 |
|
| 188 |
+
| MOBI | `.mobi` | `application/x-mobipocket-ebook` | ⚠️ 可能支持 | Kindle 格式 |
|
| 189 |
+
|
| 190 |
+
## 📊 文件大小限制
|
| 191 |
+
|
| 192 |
+
| 文件类型 | 推荐大小 | 最大大小 | 处理时间 |
|
| 193 |
+
| ----------- | -------- | -------- | ---------- |
|
| 194 |
+
| 图片 | < 5 MB | ~20 MB | 秒级 |
|
| 195 |
+
| PDF | < 10 MB | ~100 MB | 秒到分钟 |
|
| 196 |
+
| Office 文档 | < 10 MB | ~50 MB | 秒到分钟 |
|
| 197 |
+
| 文本/代码 | < 1 MB | ~10 MB | 秒级 |
|
| 198 |
+
| 音频 | < 20 MB | ~100 MB | 分钟级 |
|
| 199 |
+
| 视频 | < 100 MB | ~2 GB | 分钟到小时 |
|
| 200 |
+
|
| 201 |
+
## 🎯 使用示例
|
| 202 |
+
|
| 203 |
+
### 1. 图片文件
|
| 204 |
+
|
| 205 |
+
```bash
|
| 206 |
+
curl -X POST http://localhost:7860/v1/v1/chat/completions \
|
| 207 |
+
-H "Content-Type: application/json" \
|
| 208 |
+
-H "Authorization: Bearer your_api_key" \
|
| 209 |
+
-d '{
|
| 210 |
+
"model": "gemini-2.5-pro",
|
| 211 |
+
"messages": [{
|
| 212 |
+
"role": "user",
|
| 213 |
+
"content": [
|
| 214 |
+
{"type": "text", "text": "描述这张图片"},
|
| 215 |
+
{"type": "image_url", "image_url": {"url": "data:image/jpeg;base64,/9j/4AAQSkZJRg..."}}
|
| 216 |
+
]
|
| 217 |
+
}]
|
| 218 |
+
}'
|
| 219 |
+
```
|
| 220 |
+
|
| 221 |
+
### 2. PDF 文档
|
| 222 |
+
|
| 223 |
+
```bash
|
| 224 |
+
curl -X POST http://localhost:7860/v1/v1/chat/completions \
|
| 225 |
+
-H "Content-Type: application/json" \
|
| 226 |
+
-H "Authorization: Bearer your_api_key" \
|
| 227 |
+
-d '{
|
| 228 |
+
"model": "gemini-2.5-pro",
|
| 229 |
+
"messages": [{
|
| 230 |
+
"role": "user",
|
| 231 |
+
"content": [
|
| 232 |
+
{"type": "text", "text": "总结这个PDF的主要内容"},
|
| 233 |
+
{"type": "image_url", "image_url": {"url": "https://example.com/report.pdf"}}
|
| 234 |
+
]
|
| 235 |
+
}]
|
| 236 |
+
}'
|
| 237 |
+
```
|
| 238 |
+
|
| 239 |
+
### 3. Office 文档
|
| 240 |
+
|
| 241 |
+
```bash
|
| 242 |
+
# Word 文档
|
| 243 |
+
curl -X POST http://localhost:7860/v1/v1/chat/completions \
|
| 244 |
+
-H "Content-Type: application/json" \
|
| 245 |
+
-H "Authorization: Bearer your_api_key" \
|
| 246 |
+
-d '{
|
| 247 |
+
"model": "gemini-2.5-pro",
|
| 248 |
+
"messages": [{
|
| 249 |
+
"role": "user",
|
| 250 |
+
"content": [
|
| 251 |
+
{"type": "text", "text": "总结这个Word文档的内容"},
|
| 252 |
+
{"type": "image_url", "image_url": {"url": "https://example.com/document.docx"}}
|
| 253 |
+
]
|
| 254 |
+
}]
|
| 255 |
+
}'
|
| 256 |
+
|
| 257 |
+
# Excel 表格
|
| 258 |
+
curl -X POST http://localhost:7860/v1/v1/chat/completions \
|
| 259 |
+
-H "Content-Type: application/json" \
|
| 260 |
+
-H "Authorization: Bearer your_api_key" \
|
| 261 |
+
-d '{
|
| 262 |
+
"model": "gemini-2.5-pro",
|
| 263 |
+
"messages": [{
|
| 264 |
+
"role": "user",
|
| 265 |
+
"content": [
|
| 266 |
+
{"type": "text", "text": "分析这个Excel表格的数据"},
|
| 267 |
+
{"type": "image_url", "image_url": {"url": "https://example.com/data.xlsx"}}
|
| 268 |
+
]
|
| 269 |
+
}]
|
| 270 |
+
}'
|
| 271 |
+
|
| 272 |
+
# PowerPoint 演示文稿
|
| 273 |
+
curl -X POST http://localhost:7860/v1/v1/chat/completions \
|
| 274 |
+
-H "Content-Type: application/json" \
|
| 275 |
+
-H "Authorization: Bearer your_api_key" \
|
| 276 |
+
-d '{
|
| 277 |
+
"model": "gemini-2.5-pro",
|
| 278 |
+
"messages": [{
|
| 279 |
+
"role": "user",
|
| 280 |
+
"content": [
|
| 281 |
+
{"type": "text", "text": "总结这个PPT的主要内容"},
|
| 282 |
+
{"type": "image_url", "image_url": {"url": "https://example.com/presentation.pptx"}}
|
| 283 |
+
]
|
| 284 |
+
}]
|
| 285 |
+
}'
|
| 286 |
+
```
|
| 287 |
+
|
| 288 |
+
### 4. 音频文件
|
| 289 |
+
|
| 290 |
+
```bash
|
| 291 |
+
curl -X POST http://localhost:7860/v1/v1/chat/completions \
|
| 292 |
+
-H "Content-Type: application/json" \
|
| 293 |
+
-H "Authorization: Bearer your_api_key" \
|
| 294 |
+
-d '{
|
| 295 |
+
"model": "gemini-2.5-pro",
|
| 296 |
+
"messages": [{
|
| 297 |
+
"role": "user",
|
| 298 |
+
"content": [
|
| 299 |
+
{"type": "text", "text": "转录这段音频并总结内容"},
|
| 300 |
+
{"type": "image_url", "image_url": {"url": "https://example.com/audio.mp3"}}
|
| 301 |
+
]
|
| 302 |
+
}]
|
| 303 |
+
}'
|
| 304 |
+
```
|
| 305 |
+
|
| 306 |
+
### 5. 视频文件
|
| 307 |
+
|
| 308 |
+
```bash
|
| 309 |
+
curl -X POST http://localhost:7860/v1/v1/chat/completions \
|
| 310 |
+
-H "Content-Type: application/json" \
|
| 311 |
+
-H "Authorization: Bearer your_api_key" \
|
| 312 |
+
-d '{
|
| 313 |
+
"model": "gemini-2.5-pro",
|
| 314 |
+
"messages": [{
|
| 315 |
+
"role": "user",
|
| 316 |
+
"content": [
|
| 317 |
+
{"type": "text", "text": "描述这个视频的主要场景"},
|
| 318 |
+
{"type": "image_url", "image_url": {"url": "https://example.com/video.mp4"}}
|
| 319 |
+
]
|
| 320 |
+
}]
|
| 321 |
+
}'
|
| 322 |
+
```
|
| 323 |
+
|
| 324 |
+
### 6. 代码文件
|
| 325 |
+
|
| 326 |
+
```bash
|
| 327 |
+
curl -X POST http://localhost:7860/v1/v1/chat/completions \
|
| 328 |
+
-H "Content-Type: application/json" \
|
| 329 |
+
-H "Authorization: Bearer your_api_key" \
|
| 330 |
+
-d '{
|
| 331 |
+
"model": "gemini-2.5-pro",
|
| 332 |
+
"messages": [{
|
| 333 |
+
"role": "user",
|
| 334 |
+
"content": [
|
| 335 |
+
{"type": "text", "text": "审查这段代码并提出改进建议"},
|
| 336 |
+
{"type": "image_url", "image_url": {"url": "data:text/x-python;base64,ZGVmIGhlbGxvKCk6..."}}
|
| 337 |
+
]
|
| 338 |
+
}]
|
| 339 |
+
}'
|
| 340 |
+
```
|
| 341 |
+
|
| 342 |
+
### 7. 混合多种文件
|
| 343 |
+
|
| 344 |
+
```bash
|
| 345 |
+
curl -X POST http://localhost:7860/v1/v1/chat/completions \
|
| 346 |
+
-H "Content-Type: application/json" \
|
| 347 |
+
-H "Authorization: Bearer your_api_key" \
|
| 348 |
+
-d '{
|
| 349 |
+
"model": "gemini-2.5-pro",
|
| 350 |
+
"messages": [{
|
| 351 |
+
"role": "user",
|
| 352 |
+
"content": [
|
| 353 |
+
{"type": "text", "text": "比较这些文件的内容"},
|
| 354 |
+
{"type": "image_url", "image_url": {"url": "https://example.com/image.jpg"}},
|
| 355 |
+
{"type": "image_url", "image_url": {"url": "https://example.com/document.pdf"}},
|
| 356 |
+
{"type": "image_url", "image_url": {"url": "https://example.com/audio.mp3"}}
|
| 357 |
+
]
|
| 358 |
+
}]
|
| 359 |
+
}'
|
| 360 |
+
```
|
| 361 |
+
|
| 362 |
+
## ⚠️ 重要说明
|
| 363 |
+
|
| 364 |
+
1. **实际支持范围**:Google Gemini API 的实际支持范围可能比官方文档更广,建议实际测试
|
| 365 |
+
2. **MIME 类型**:必须正确指定 MIME 类型,否则可能处理失败
|
| 366 |
+
3. **文件大小**:超大文件可能导致超时或处理失败
|
| 367 |
+
4. **处理质量**:不同文件类型的处理质量可能不同
|
| 368 |
+
5. **API 版本**:支持的文件类型可能随 API 版本变化
|
| 369 |
+
6. **字段名称**:虽然支持所有文件类型,但仍使用 `image_url` 字段(OpenAI API 标准)
|
| 370 |
+
|
| 371 |
+
## 📝 支持状态说明
|
| 372 |
+
|
| 373 |
+
- ✅ **完全支持**:经过充分测试,稳定可用
|
| 374 |
+
- ✅ **支持**:可以使用,但可能有限制
|
| 375 |
+
- ⚠️ **可能支持**:理论上支持,需要实际测试
|
| 376 |
+
- ⚠️ **部分支持**:有条件支持,可能需要特殊处理
|
| 377 |
+
- ❌ **不支持**:当前不支持或需要转换
|
| 378 |
+
|
| 379 |
+
## 🔗 相关链接
|
| 380 |
+
|
| 381 |
+
- [项目主页](https://github.com/Dreamy-rain/gemini-business2api)
|
| 382 |
+
- [API 文档](README.md)
|
| 383 |
+
- [问题反馈](https://github.com/Dreamy-rain/gemini-business2api/issues)
|
docs/logo.svg
ADDED
|
|
docs/script/download.js
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// ==UserScript==
|
| 2 |
+
// @name Gemini Business Helper
|
| 3 |
+
// @version 2.0
|
| 4 |
+
// @description 自动下载配置
|
| 5 |
+
// @match https://business.gemini.google/*
|
| 6 |
+
// @grant GM_addStyle
|
| 7 |
+
// @grant GM_cookie
|
| 8 |
+
// ==/UserScript==
|
| 9 |
+
|
| 10 |
+
(function() {
|
| 11 |
+
'use strict';
|
| 12 |
+
|
| 13 |
+
GM_addStyle(`
|
| 14 |
+
#gb-btn {
|
| 15 |
+
position: fixed;
|
| 16 |
+
bottom: 32px;
|
| 17 |
+
right: 32px;
|
| 18 |
+
width: 60px;
|
| 19 |
+
height: 60px;
|
| 20 |
+
background: #1a73e8;
|
| 21 |
+
border-radius: 50%;
|
| 22 |
+
box-shadow: 0 4px 16px rgba(0,0,0,0.2);
|
| 23 |
+
cursor: pointer;
|
| 24 |
+
z-index: 9999;
|
| 25 |
+
display: flex;
|
| 26 |
+
align-items: center;
|
| 27 |
+
justify-content: center;
|
| 28 |
+
color: white;
|
| 29 |
+
font-size: 24px;
|
| 30 |
+
transition: all 0.2s;
|
| 31 |
+
}
|
| 32 |
+
#gb-btn:hover {
|
| 33 |
+
transform: scale(1.1);
|
| 34 |
+
background: #1557b0;
|
| 35 |
+
}
|
| 36 |
+
`);
|
| 37 |
+
|
| 38 |
+
const btn = document.createElement('div');
|
| 39 |
+
btn.id = 'gb-btn';
|
| 40 |
+
btn.textContent = '⬇';
|
| 41 |
+
btn.title = '下载配置';
|
| 42 |
+
document.body.appendChild(btn);
|
| 43 |
+
|
| 44 |
+
const formatTime = (ts) => {
|
| 45 |
+
if (!ts) return null;
|
| 46 |
+
const d = new Date((ts - 43200) * 1000);
|
| 47 |
+
return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')} ${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}:${String(d.getSeconds()).padStart(2,'0')}`;
|
| 48 |
+
};
|
| 49 |
+
|
| 50 |
+
const download = (data, filename) => {
|
| 51 |
+
const blob = new Blob([data], { type: 'application/json' });
|
| 52 |
+
const url = URL.createObjectURL(blob);
|
| 53 |
+
const a = document.createElement('a');
|
| 54 |
+
a.href = url;
|
| 55 |
+
a.download = filename;
|
| 56 |
+
document.body.appendChild(a);
|
| 57 |
+
a.click();
|
| 58 |
+
document.body.removeChild(a);
|
| 59 |
+
URL.revokeObjectURL(url);
|
| 60 |
+
};
|
| 61 |
+
|
| 62 |
+
btn.onclick = () => {
|
| 63 |
+
const pathParts = window.location.pathname.split('/');
|
| 64 |
+
const cidIndex = pathParts.indexOf('cid');
|
| 65 |
+
const config_id = (cidIndex !== -1 && pathParts[cidIndex + 1]) || null;
|
| 66 |
+
const csesidx = new URLSearchParams(window.location.search).get('csesidx');
|
| 67 |
+
|
| 68 |
+
let email = localStorage.getItem('gemini_user_email');
|
| 69 |
+
if (!email) {
|
| 70 |
+
email = prompt('请输入您的邮箱地址:');
|
| 71 |
+
if (email) {
|
| 72 |
+
localStorage.setItem('gemini_user_email', email);
|
| 73 |
+
}
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
GM_cookie('list', {}, (cookies, error) => {
|
| 77 |
+
if (error || !config_id || !csesidx || !email) {
|
| 78 |
+
alert('❌ 数据不完整');
|
| 79 |
+
return;
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
const host_c_oses = (cookies.find(c => c.name === '__Host-C_OSES') || {}).value || null;
|
| 83 |
+
const sesCookie = cookies.find(c => c.name === '__Secure-C_SES') || {};
|
| 84 |
+
const secure_c_ses = sesCookie.value || null;
|
| 85 |
+
|
| 86 |
+
if (!secure_c_ses) {
|
| 87 |
+
alert('❌ Cookie 读取失败');
|
| 88 |
+
return;
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
const data = {
|
| 92 |
+
id: email,
|
| 93 |
+
csesidx,
|
| 94 |
+
config_id,
|
| 95 |
+
secure_c_ses,
|
| 96 |
+
host_c_oses,
|
| 97 |
+
expires_at: formatTime(sesCookie.expirationDate)
|
| 98 |
+
};
|
| 99 |
+
|
| 100 |
+
download(JSON.stringify(data, null, 2), `${email}.json`);
|
| 101 |
+
|
| 102 |
+
btn.textContent = '✓';
|
| 103 |
+
btn.style.background = '#1e8e3e';
|
| 104 |
+
setTimeout(() => {
|
| 105 |
+
btn.textContent = '⬇';
|
| 106 |
+
btn.style.background = '#1a73e8';
|
| 107 |
+
}, 1500);
|
| 108 |
+
});
|
| 109 |
+
};
|
| 110 |
+
})();
|
entrypoint.sh
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/bin/bash
|
| 2 |
+
set -e
|
| 3 |
+
|
| 4 |
+
# 启动 Xvfb 在后台
|
| 5 |
+
Xvfb :99 -screen 0 1280x800x24 -ac &
|
| 6 |
+
|
| 7 |
+
# 等待 Xvfb 启动
|
| 8 |
+
sleep 1
|
| 9 |
+
|
| 10 |
+
# 设置 DISPLAY 环境变量
|
| 11 |
+
export DISPLAY=:99
|
| 12 |
+
|
| 13 |
+
# 启动 Python 应用
|
| 14 |
+
exec python -u main.py
|
frontend/.gitignore
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Logs
|
| 2 |
+
logs
|
| 3 |
+
*.log
|
| 4 |
+
npm-debug.log*
|
| 5 |
+
yarn-debug.log*
|
| 6 |
+
yarn-error.log*
|
| 7 |
+
pnpm-debug.log*
|
| 8 |
+
lerna-debug.log*
|
| 9 |
+
|
| 10 |
+
node_modules
|
| 11 |
+
dist
|
| 12 |
+
dist-ssr
|
| 13 |
+
*.local
|
| 14 |
+
|
| 15 |
+
# Editor directories and files
|
| 16 |
+
.vscode/*
|
| 17 |
+
!.vscode/extensions.json
|
| 18 |
+
.idea
|
| 19 |
+
.DS_Store
|
| 20 |
+
*.suo
|
| 21 |
+
*.ntvs*
|
| 22 |
+
*.njsproj
|
| 23 |
+
*.sln
|
| 24 |
+
*.sw?
|
frontend/README.md
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Frontend - Admin Panel
|
| 2 |
+
|
| 3 |
+
Modern admin panel built with Vue 3 + TypeScript + Tailwind CSS.
|
| 4 |
+
|
| 5 |
+
## Tech Stack
|
| 6 |
+
|
| 7 |
+
- Vue 3 + TypeScript
|
| 8 |
+
- Vite
|
| 9 |
+
- Vue Router + Pinia
|
| 10 |
+
- Tailwind CSS
|
| 11 |
+
- Axios
|
| 12 |
+
- ECharts
|
| 13 |
+
|
| 14 |
+
## Development
|
| 15 |
+
|
| 16 |
+
```bash
|
| 17 |
+
# Install dependencies
|
| 18 |
+
npm install
|
| 19 |
+
|
| 20 |
+
# Start dev server
|
| 21 |
+
npm run dev
|
| 22 |
+
```
|
| 23 |
+
|
| 24 |
+
Visit: http://localhost:5174
|
| 25 |
+
|
| 26 |
+
## Build
|
| 27 |
+
|
| 28 |
+
```bash
|
| 29 |
+
# Build for production
|
| 30 |
+
npm run build
|
| 31 |
+
|
| 32 |
+
# Preview build
|
| 33 |
+
npm run preview
|
| 34 |
+
```
|
| 35 |
+
|
| 36 |
+
Build output: `dist/`
|
| 37 |
+
|
| 38 |
+
## Project Structure
|
| 39 |
+
|
| 40 |
+
```
|
| 41 |
+
src/
|
| 42 |
+
├── api/ # API requests
|
| 43 |
+
├── components/ # UI components
|
| 44 |
+
├── views/ # Page components
|
| 45 |
+
├── stores/ # Pinia stores
|
| 46 |
+
├── router/ # Vue Router
|
| 47 |
+
└── types/ # TypeScript types
|
| 48 |
+
```
|
| 49 |
+
|
| 50 |
+
## Environment Variables
|
| 51 |
+
|
| 52 |
+
Create `.env.local`:
|
| 53 |
+
|
| 54 |
+
```bash
|
| 55 |
+
VITE_API_BASE_URL=http://localhost:7860
|
| 56 |
+
```
|
| 57 |
+
|
| 58 |
+
## Docker Build
|
| 59 |
+
|
| 60 |
+
The root `Dockerfile` automatically builds the frontend:
|
| 61 |
+
|
| 62 |
+
```dockerfile
|
| 63 |
+
FROM node:20-alpine AS frontend-builder
|
| 64 |
+
WORKDIR /frontend
|
| 65 |
+
COPY frontend/package*.json ./
|
| 66 |
+
RUN npm ci
|
| 67 |
+
COPY frontend/ ./
|
| 68 |
+
RUN npm run build
|
| 69 |
+
```
|
| 70 |
+
|
| 71 |
+
Build artifacts are copied to `static/` directory.
|
frontend/index.html
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8" />
|
| 5 |
+
<link rel="icon" type="image/svg+xml" href="/logo.svg" />
|
| 6 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 7 |
+
<title>Gemini Business2API</title>
|
| 8 |
+
</head>
|
| 9 |
+
<body>
|
| 10 |
+
<div id="app"></div>
|
| 11 |
+
<script src="/vendor/echarts/echarts.min.js"></script>
|
| 12 |
+
<script type="module" src="/src/main.ts"></script>
|
| 13 |
+
</body>
|
| 14 |
+
</html>
|
frontend/package-lock.json
ADDED
|
@@ -0,0 +1,1628 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "gemini-business2api",
|
| 3 |
+
"version": "0.0.0",
|
| 4 |
+
"lockfileVersion": 1,
|
| 5 |
+
"requires": true,
|
| 6 |
+
"dependencies": {
|
| 7 |
+
"@alloc/quick-lru": {
|
| 8 |
+
"version": "5.2.0",
|
| 9 |
+
"resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
|
| 10 |
+
"integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==",
|
| 11 |
+
"dev": true
|
| 12 |
+
},
|
| 13 |
+
"@babel/helper-string-parser": {
|
| 14 |
+
"version": "7.27.1",
|
| 15 |
+
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
|
| 16 |
+
"integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="
|
| 17 |
+
},
|
| 18 |
+
"@babel/helper-validator-identifier": {
|
| 19 |
+
"version": "7.28.5",
|
| 20 |
+
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
|
| 21 |
+
"integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="
|
| 22 |
+
},
|
| 23 |
+
"@babel/parser": {
|
| 24 |
+
"version": "7.29.0",
|
| 25 |
+
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz",
|
| 26 |
+
"integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==",
|
| 27 |
+
"requires": {
|
| 28 |
+
"@babel/types": "^7.29.0"
|
| 29 |
+
}
|
| 30 |
+
},
|
| 31 |
+
"@babel/types": {
|
| 32 |
+
"version": "7.29.0",
|
| 33 |
+
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz",
|
| 34 |
+
"integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
|
| 35 |
+
"requires": {
|
| 36 |
+
"@babel/helper-string-parser": "^7.27.1",
|
| 37 |
+
"@babel/helper-validator-identifier": "^7.28.5"
|
| 38 |
+
}
|
| 39 |
+
},
|
| 40 |
+
"@esbuild/aix-ppc64": {
|
| 41 |
+
"version": "0.27.3",
|
| 42 |
+
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz",
|
| 43 |
+
"integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==",
|
| 44 |
+
"dev": true,
|
| 45 |
+
"optional": true
|
| 46 |
+
},
|
| 47 |
+
"@esbuild/android-arm": {
|
| 48 |
+
"version": "0.27.3",
|
| 49 |
+
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz",
|
| 50 |
+
"integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==",
|
| 51 |
+
"dev": true,
|
| 52 |
+
"optional": true
|
| 53 |
+
},
|
| 54 |
+
"@esbuild/android-arm64": {
|
| 55 |
+
"version": "0.27.3",
|
| 56 |
+
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz",
|
| 57 |
+
"integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==",
|
| 58 |
+
"dev": true,
|
| 59 |
+
"optional": true
|
| 60 |
+
},
|
| 61 |
+
"@esbuild/android-x64": {
|
| 62 |
+
"version": "0.27.3",
|
| 63 |
+
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz",
|
| 64 |
+
"integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==",
|
| 65 |
+
"dev": true,
|
| 66 |
+
"optional": true
|
| 67 |
+
},
|
| 68 |
+
"@esbuild/darwin-arm64": {
|
| 69 |
+
"version": "0.27.3",
|
| 70 |
+
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz",
|
| 71 |
+
"integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==",
|
| 72 |
+
"dev": true,
|
| 73 |
+
"optional": true
|
| 74 |
+
},
|
| 75 |
+
"@esbuild/darwin-x64": {
|
| 76 |
+
"version": "0.27.3",
|
| 77 |
+
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz",
|
| 78 |
+
"integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==",
|
| 79 |
+
"dev": true,
|
| 80 |
+
"optional": true
|
| 81 |
+
},
|
| 82 |
+
"@esbuild/freebsd-arm64": {
|
| 83 |
+
"version": "0.27.3",
|
| 84 |
+
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz",
|
| 85 |
+
"integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==",
|
| 86 |
+
"dev": true,
|
| 87 |
+
"optional": true
|
| 88 |
+
},
|
| 89 |
+
"@esbuild/freebsd-x64": {
|
| 90 |
+
"version": "0.27.3",
|
| 91 |
+
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz",
|
| 92 |
+
"integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==",
|
| 93 |
+
"dev": true,
|
| 94 |
+
"optional": true
|
| 95 |
+
},
|
| 96 |
+
"@esbuild/linux-arm": {
|
| 97 |
+
"version": "0.27.3",
|
| 98 |
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz",
|
| 99 |
+
"integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==",
|
| 100 |
+
"dev": true,
|
| 101 |
+
"optional": true
|
| 102 |
+
},
|
| 103 |
+
"@esbuild/linux-arm64": {
|
| 104 |
+
"version": "0.27.3",
|
| 105 |
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz",
|
| 106 |
+
"integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==",
|
| 107 |
+
"dev": true,
|
| 108 |
+
"optional": true
|
| 109 |
+
},
|
| 110 |
+
"@esbuild/linux-ia32": {
|
| 111 |
+
"version": "0.27.3",
|
| 112 |
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz",
|
| 113 |
+
"integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==",
|
| 114 |
+
"dev": true,
|
| 115 |
+
"optional": true
|
| 116 |
+
},
|
| 117 |
+
"@esbuild/linux-loong64": {
|
| 118 |
+
"version": "0.27.3",
|
| 119 |
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz",
|
| 120 |
+
"integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==",
|
| 121 |
+
"dev": true,
|
| 122 |
+
"optional": true
|
| 123 |
+
},
|
| 124 |
+
"@esbuild/linux-mips64el": {
|
| 125 |
+
"version": "0.27.3",
|
| 126 |
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz",
|
| 127 |
+
"integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==",
|
| 128 |
+
"dev": true,
|
| 129 |
+
"optional": true
|
| 130 |
+
},
|
| 131 |
+
"@esbuild/linux-ppc64": {
|
| 132 |
+
"version": "0.27.3",
|
| 133 |
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz",
|
| 134 |
+
"integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==",
|
| 135 |
+
"dev": true,
|
| 136 |
+
"optional": true
|
| 137 |
+
},
|
| 138 |
+
"@esbuild/linux-riscv64": {
|
| 139 |
+
"version": "0.27.3",
|
| 140 |
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz",
|
| 141 |
+
"integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==",
|
| 142 |
+
"dev": true,
|
| 143 |
+
"optional": true
|
| 144 |
+
},
|
| 145 |
+
"@esbuild/linux-s390x": {
|
| 146 |
+
"version": "0.27.3",
|
| 147 |
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz",
|
| 148 |
+
"integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==",
|
| 149 |
+
"dev": true,
|
| 150 |
+
"optional": true
|
| 151 |
+
},
|
| 152 |
+
"@esbuild/linux-x64": {
|
| 153 |
+
"version": "0.27.3",
|
| 154 |
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz",
|
| 155 |
+
"integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==",
|
| 156 |
+
"dev": true,
|
| 157 |
+
"optional": true
|
| 158 |
+
},
|
| 159 |
+
"@esbuild/netbsd-arm64": {
|
| 160 |
+
"version": "0.27.3",
|
| 161 |
+
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz",
|
| 162 |
+
"integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==",
|
| 163 |
+
"dev": true,
|
| 164 |
+
"optional": true
|
| 165 |
+
},
|
| 166 |
+
"@esbuild/netbsd-x64": {
|
| 167 |
+
"version": "0.27.3",
|
| 168 |
+
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz",
|
| 169 |
+
"integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==",
|
| 170 |
+
"dev": true,
|
| 171 |
+
"optional": true
|
| 172 |
+
},
|
| 173 |
+
"@esbuild/openbsd-arm64": {
|
| 174 |
+
"version": "0.27.3",
|
| 175 |
+
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz",
|
| 176 |
+
"integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==",
|
| 177 |
+
"dev": true,
|
| 178 |
+
"optional": true
|
| 179 |
+
},
|
| 180 |
+
"@esbuild/openbsd-x64": {
|
| 181 |
+
"version": "0.27.3",
|
| 182 |
+
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz",
|
| 183 |
+
"integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==",
|
| 184 |
+
"dev": true,
|
| 185 |
+
"optional": true
|
| 186 |
+
},
|
| 187 |
+
"@esbuild/openharmony-arm64": {
|
| 188 |
+
"version": "0.27.3",
|
| 189 |
+
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz",
|
| 190 |
+
"integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==",
|
| 191 |
+
"dev": true,
|
| 192 |
+
"optional": true
|
| 193 |
+
},
|
| 194 |
+
"@esbuild/sunos-x64": {
|
| 195 |
+
"version": "0.27.3",
|
| 196 |
+
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz",
|
| 197 |
+
"integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==",
|
| 198 |
+
"dev": true,
|
| 199 |
+
"optional": true
|
| 200 |
+
},
|
| 201 |
+
"@esbuild/win32-arm64": {
|
| 202 |
+
"version": "0.27.3",
|
| 203 |
+
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz",
|
| 204 |
+
"integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==",
|
| 205 |
+
"dev": true,
|
| 206 |
+
"optional": true
|
| 207 |
+
},
|
| 208 |
+
"@esbuild/win32-ia32": {
|
| 209 |
+
"version": "0.27.3",
|
| 210 |
+
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz",
|
| 211 |
+
"integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==",
|
| 212 |
+
"dev": true,
|
| 213 |
+
"optional": true
|
| 214 |
+
},
|
| 215 |
+
"@esbuild/win32-x64": {
|
| 216 |
+
"version": "0.27.3",
|
| 217 |
+
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz",
|
| 218 |
+
"integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==",
|
| 219 |
+
"dev": true,
|
| 220 |
+
"optional": true
|
| 221 |
+
},
|
| 222 |
+
"@iconify/types": {
|
| 223 |
+
"version": "2.0.0",
|
| 224 |
+
"resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz",
|
| 225 |
+
"integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==",
|
| 226 |
+
"dev": true
|
| 227 |
+
},
|
| 228 |
+
"@iconify/vue": {
|
| 229 |
+
"version": "5.0.0",
|
| 230 |
+
"resolved": "https://registry.npmjs.org/@iconify/vue/-/vue-5.0.0.tgz",
|
| 231 |
+
"integrity": "sha512-C+KuEWIF5nSBrobFJhT//JS87OZ++QDORB6f2q2Wm6fl2mueSTpFBeBsveK0KW9hWiZ4mNiPjsh6Zs4jjdROSg==",
|
| 232 |
+
"dev": true,
|
| 233 |
+
"requires": {
|
| 234 |
+
"@iconify/types": "^2.0.0"
|
| 235 |
+
}
|
| 236 |
+
},
|
| 237 |
+
"@jridgewell/gen-mapping": {
|
| 238 |
+
"version": "0.3.13",
|
| 239 |
+
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
| 240 |
+
"integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
|
| 241 |
+
"dev": true,
|
| 242 |
+
"requires": {
|
| 243 |
+
"@jridgewell/sourcemap-codec": "^1.5.0",
|
| 244 |
+
"@jridgewell/trace-mapping": "^0.3.24"
|
| 245 |
+
}
|
| 246 |
+
},
|
| 247 |
+
"@jridgewell/resolve-uri": {
|
| 248 |
+
"version": "3.1.2",
|
| 249 |
+
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
|
| 250 |
+
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
|
| 251 |
+
"dev": true
|
| 252 |
+
},
|
| 253 |
+
"@jridgewell/sourcemap-codec": {
|
| 254 |
+
"version": "1.5.5",
|
| 255 |
+
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
| 256 |
+
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="
|
| 257 |
+
},
|
| 258 |
+
"@jridgewell/trace-mapping": {
|
| 259 |
+
"version": "0.3.31",
|
| 260 |
+
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
|
| 261 |
+
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
|
| 262 |
+
"dev": true,
|
| 263 |
+
"requires": {
|
| 264 |
+
"@jridgewell/resolve-uri": "^3.1.0",
|
| 265 |
+
"@jridgewell/sourcemap-codec": "^1.4.14"
|
| 266 |
+
}
|
| 267 |
+
},
|
| 268 |
+
"@nodelib/fs.scandir": {
|
| 269 |
+
"version": "2.1.5",
|
| 270 |
+
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
| 271 |
+
"integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
|
| 272 |
+
"dev": true,
|
| 273 |
+
"requires": {
|
| 274 |
+
"@nodelib/fs.stat": "2.0.5",
|
| 275 |
+
"run-parallel": "^1.1.9"
|
| 276 |
+
}
|
| 277 |
+
},
|
| 278 |
+
"@nodelib/fs.stat": {
|
| 279 |
+
"version": "2.0.5",
|
| 280 |
+
"resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
|
| 281 |
+
"integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
|
| 282 |
+
"dev": true
|
| 283 |
+
},
|
| 284 |
+
"@nodelib/fs.walk": {
|
| 285 |
+
"version": "1.2.8",
|
| 286 |
+
"resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
|
| 287 |
+
"integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
|
| 288 |
+
"dev": true,
|
| 289 |
+
"requires": {
|
| 290 |
+
"@nodelib/fs.scandir": "2.1.5",
|
| 291 |
+
"fastq": "^1.6.0"
|
| 292 |
+
}
|
| 293 |
+
},
|
| 294 |
+
"@rolldown/pluginutils": {
|
| 295 |
+
"version": "1.0.0-rc.2",
|
| 296 |
+
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.2.tgz",
|
| 297 |
+
"integrity": "sha512-izyXV/v+cHiRfozX62W9htOAvwMo4/bXKDrQ+vom1L1qRuexPock/7VZDAhnpHCLNejd3NJ6hiab+tO0D44Rgw=="
|
| 298 |
+
},
|
| 299 |
+
"@rollup/rollup-android-arm-eabi": {
|
| 300 |
+
"version": "4.59.0",
|
| 301 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz",
|
| 302 |
+
"integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==",
|
| 303 |
+
"dev": true,
|
| 304 |
+
"optional": true
|
| 305 |
+
},
|
| 306 |
+
"@rollup/rollup-android-arm64": {
|
| 307 |
+
"version": "4.59.0",
|
| 308 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz",
|
| 309 |
+
"integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==",
|
| 310 |
+
"dev": true,
|
| 311 |
+
"optional": true
|
| 312 |
+
},
|
| 313 |
+
"@rollup/rollup-darwin-arm64": {
|
| 314 |
+
"version": "4.59.0",
|
| 315 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz",
|
| 316 |
+
"integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==",
|
| 317 |
+
"dev": true,
|
| 318 |
+
"optional": true
|
| 319 |
+
},
|
| 320 |
+
"@rollup/rollup-darwin-x64": {
|
| 321 |
+
"version": "4.59.0",
|
| 322 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz",
|
| 323 |
+
"integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==",
|
| 324 |
+
"dev": true,
|
| 325 |
+
"optional": true
|
| 326 |
+
},
|
| 327 |
+
"@rollup/rollup-freebsd-arm64": {
|
| 328 |
+
"version": "4.59.0",
|
| 329 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz",
|
| 330 |
+
"integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==",
|
| 331 |
+
"dev": true,
|
| 332 |
+
"optional": true
|
| 333 |
+
},
|
| 334 |
+
"@rollup/rollup-freebsd-x64": {
|
| 335 |
+
"version": "4.59.0",
|
| 336 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz",
|
| 337 |
+
"integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==",
|
| 338 |
+
"dev": true,
|
| 339 |
+
"optional": true
|
| 340 |
+
},
|
| 341 |
+
"@rollup/rollup-linux-arm-gnueabihf": {
|
| 342 |
+
"version": "4.59.0",
|
| 343 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz",
|
| 344 |
+
"integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==",
|
| 345 |
+
"dev": true,
|
| 346 |
+
"optional": true
|
| 347 |
+
},
|
| 348 |
+
"@rollup/rollup-linux-arm-musleabihf": {
|
| 349 |
+
"version": "4.59.0",
|
| 350 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz",
|
| 351 |
+
"integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==",
|
| 352 |
+
"dev": true,
|
| 353 |
+
"optional": true
|
| 354 |
+
},
|
| 355 |
+
"@rollup/rollup-linux-arm64-gnu": {
|
| 356 |
+
"version": "4.59.0",
|
| 357 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz",
|
| 358 |
+
"integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==",
|
| 359 |
+
"dev": true,
|
| 360 |
+
"optional": true
|
| 361 |
+
},
|
| 362 |
+
"@rollup/rollup-linux-arm64-musl": {
|
| 363 |
+
"version": "4.59.0",
|
| 364 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz",
|
| 365 |
+
"integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==",
|
| 366 |
+
"dev": true,
|
| 367 |
+
"optional": true
|
| 368 |
+
},
|
| 369 |
+
"@rollup/rollup-linux-loong64-gnu": {
|
| 370 |
+
"version": "4.59.0",
|
| 371 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz",
|
| 372 |
+
"integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==",
|
| 373 |
+
"dev": true,
|
| 374 |
+
"optional": true
|
| 375 |
+
},
|
| 376 |
+
"@rollup/rollup-linux-loong64-musl": {
|
| 377 |
+
"version": "4.59.0",
|
| 378 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz",
|
| 379 |
+
"integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==",
|
| 380 |
+
"dev": true,
|
| 381 |
+
"optional": true
|
| 382 |
+
},
|
| 383 |
+
"@rollup/rollup-linux-ppc64-gnu": {
|
| 384 |
+
"version": "4.59.0",
|
| 385 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz",
|
| 386 |
+
"integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==",
|
| 387 |
+
"dev": true,
|
| 388 |
+
"optional": true
|
| 389 |
+
},
|
| 390 |
+
"@rollup/rollup-linux-ppc64-musl": {
|
| 391 |
+
"version": "4.59.0",
|
| 392 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz",
|
| 393 |
+
"integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==",
|
| 394 |
+
"dev": true,
|
| 395 |
+
"optional": true
|
| 396 |
+
},
|
| 397 |
+
"@rollup/rollup-linux-riscv64-gnu": {
|
| 398 |
+
"version": "4.59.0",
|
| 399 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz",
|
| 400 |
+
"integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==",
|
| 401 |
+
"dev": true,
|
| 402 |
+
"optional": true
|
| 403 |
+
},
|
| 404 |
+
"@rollup/rollup-linux-riscv64-musl": {
|
| 405 |
+
"version": "4.59.0",
|
| 406 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz",
|
| 407 |
+
"integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==",
|
| 408 |
+
"dev": true,
|
| 409 |
+
"optional": true
|
| 410 |
+
},
|
| 411 |
+
"@rollup/rollup-linux-s390x-gnu": {
|
| 412 |
+
"version": "4.59.0",
|
| 413 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz",
|
| 414 |
+
"integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==",
|
| 415 |
+
"dev": true,
|
| 416 |
+
"optional": true
|
| 417 |
+
},
|
| 418 |
+
"@rollup/rollup-linux-x64-gnu": {
|
| 419 |
+
"version": "4.59.0",
|
| 420 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz",
|
| 421 |
+
"integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==",
|
| 422 |
+
"dev": true,
|
| 423 |
+
"optional": true
|
| 424 |
+
},
|
| 425 |
+
"@rollup/rollup-linux-x64-musl": {
|
| 426 |
+
"version": "4.59.0",
|
| 427 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz",
|
| 428 |
+
"integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==",
|
| 429 |
+
"dev": true,
|
| 430 |
+
"optional": true
|
| 431 |
+
},
|
| 432 |
+
"@rollup/rollup-openbsd-x64": {
|
| 433 |
+
"version": "4.59.0",
|
| 434 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz",
|
| 435 |
+
"integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==",
|
| 436 |
+
"dev": true,
|
| 437 |
+
"optional": true
|
| 438 |
+
},
|
| 439 |
+
"@rollup/rollup-openharmony-arm64": {
|
| 440 |
+
"version": "4.59.0",
|
| 441 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz",
|
| 442 |
+
"integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==",
|
| 443 |
+
"dev": true,
|
| 444 |
+
"optional": true
|
| 445 |
+
},
|
| 446 |
+
"@rollup/rollup-win32-arm64-msvc": {
|
| 447 |
+
"version": "4.59.0",
|
| 448 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz",
|
| 449 |
+
"integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==",
|
| 450 |
+
"dev": true,
|
| 451 |
+
"optional": true
|
| 452 |
+
},
|
| 453 |
+
"@rollup/rollup-win32-ia32-msvc": {
|
| 454 |
+
"version": "4.59.0",
|
| 455 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz",
|
| 456 |
+
"integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==",
|
| 457 |
+
"dev": true,
|
| 458 |
+
"optional": true
|
| 459 |
+
},
|
| 460 |
+
"@rollup/rollup-win32-x64-gnu": {
|
| 461 |
+
"version": "4.59.0",
|
| 462 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz",
|
| 463 |
+
"integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==",
|
| 464 |
+
"dev": true,
|
| 465 |
+
"optional": true
|
| 466 |
+
},
|
| 467 |
+
"@rollup/rollup-win32-x64-msvc": {
|
| 468 |
+
"version": "4.59.0",
|
| 469 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz",
|
| 470 |
+
"integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==",
|
| 471 |
+
"dev": true,
|
| 472 |
+
"optional": true
|
| 473 |
+
},
|
| 474 |
+
"@types/estree": {
|
| 475 |
+
"version": "1.0.8",
|
| 476 |
+
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
| 477 |
+
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
|
| 478 |
+
"dev": true
|
| 479 |
+
},
|
| 480 |
+
"@vitejs/plugin-vue": {
|
| 481 |
+
"version": "6.0.4",
|
| 482 |
+
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.4.tgz",
|
| 483 |
+
"integrity": "sha512-uM5iXipgYIn13UUQCZNdWkYk+sysBeA97d5mHsAoAt1u/wpN3+zxOmsVJWosuzX+IMGRzeYUNytztrYznboIkQ==",
|
| 484 |
+
"requires": {
|
| 485 |
+
"@rolldown/pluginutils": "1.0.0-rc.2"
|
| 486 |
+
}
|
| 487 |
+
},
|
| 488 |
+
"@vue/compiler-core": {
|
| 489 |
+
"version": "3.5.29",
|
| 490 |
+
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.29.tgz",
|
| 491 |
+
"integrity": "sha512-cuzPhD8fwRHk8IGfmYaR4eEe4cAyJEL66Ove/WZL7yWNL134nqLddSLwNRIsFlnnW1kK+p8Ck3viFnC0chXCXw==",
|
| 492 |
+
"requires": {
|
| 493 |
+
"@babel/parser": "^7.29.0",
|
| 494 |
+
"@vue/shared": "3.5.29",
|
| 495 |
+
"entities": "^7.0.1",
|
| 496 |
+
"estree-walker": "^2.0.2",
|
| 497 |
+
"source-map-js": "^1.2.1"
|
| 498 |
+
}
|
| 499 |
+
},
|
| 500 |
+
"@vue/compiler-dom": {
|
| 501 |
+
"version": "3.5.29",
|
| 502 |
+
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.29.tgz",
|
| 503 |
+
"integrity": "sha512-n0G5o7R3uBVmVxjTIYcz7ovr8sy7QObFG8OQJ3xGCDNhbG60biP/P5KnyY8NLd81OuT1WJflG7N4KWYHaeeaIg==",
|
| 504 |
+
"requires": {
|
| 505 |
+
"@vue/compiler-core": "3.5.29",
|
| 506 |
+
"@vue/shared": "3.5.29"
|
| 507 |
+
}
|
| 508 |
+
},
|
| 509 |
+
"@vue/compiler-sfc": {
|
| 510 |
+
"version": "3.5.29",
|
| 511 |
+
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.29.tgz",
|
| 512 |
+
"integrity": "sha512-oJZhN5XJs35Gzr50E82jg2cYdZQ78wEwvRO6Y63TvLVTc+6xICzJHP1UIecdSPPYIbkautNBanDiWYa64QSFIA==",
|
| 513 |
+
"requires": {
|
| 514 |
+
"@babel/parser": "^7.29.0",
|
| 515 |
+
"@vue/compiler-core": "3.5.29",
|
| 516 |
+
"@vue/compiler-dom": "3.5.29",
|
| 517 |
+
"@vue/compiler-ssr": "3.5.29",
|
| 518 |
+
"@vue/shared": "3.5.29",
|
| 519 |
+
"estree-walker": "^2.0.2",
|
| 520 |
+
"magic-string": "^0.30.21",
|
| 521 |
+
"postcss": "^8.5.6",
|
| 522 |
+
"source-map-js": "^1.2.1"
|
| 523 |
+
}
|
| 524 |
+
},
|
| 525 |
+
"@vue/compiler-ssr": {
|
| 526 |
+
"version": "3.5.29",
|
| 527 |
+
"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.29.tgz",
|
| 528 |
+
"integrity": "sha512-Y/ARJZE6fpjzL5GH/phJmsFwx3g6t2KmHKHx5q+MLl2kencADKIrhH5MLF6HHpRMmlRAYBRSvv347Mepf1zVNw==",
|
| 529 |
+
"requires": {
|
| 530 |
+
"@vue/compiler-dom": "3.5.29",
|
| 531 |
+
"@vue/shared": "3.5.29"
|
| 532 |
+
}
|
| 533 |
+
},
|
| 534 |
+
"@vue/devtools-api": {
|
| 535 |
+
"version": "7.7.9",
|
| 536 |
+
"resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-7.7.9.tgz",
|
| 537 |
+
"integrity": "sha512-kIE8wvwlcZ6TJTbNeU2HQNtaxLx3a84aotTITUuL/4bzfPxzajGBOoqjMhwZJ8L9qFYDU/lAYMEEm11dnZOD6g==",
|
| 538 |
+
"requires": {
|
| 539 |
+
"@vue/devtools-kit": "^7.7.9"
|
| 540 |
+
}
|
| 541 |
+
},
|
| 542 |
+
"@vue/devtools-kit": {
|
| 543 |
+
"version": "7.7.9",
|
| 544 |
+
"resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-7.7.9.tgz",
|
| 545 |
+
"integrity": "sha512-PyQ6odHSgiDVd4hnTP+aDk2X4gl2HmLDfiyEnn3/oV+ckFDuswRs4IbBT7vacMuGdwY/XemxBoh302ctbsptuA==",
|
| 546 |
+
"requires": {
|
| 547 |
+
"@vue/devtools-shared": "^7.7.9",
|
| 548 |
+
"birpc": "^2.3.0",
|
| 549 |
+
"hookable": "^5.5.3",
|
| 550 |
+
"mitt": "^3.0.1",
|
| 551 |
+
"perfect-debounce": "^1.0.0",
|
| 552 |
+
"speakingurl": "^14.0.1",
|
| 553 |
+
"superjson": "^2.2.2"
|
| 554 |
+
}
|
| 555 |
+
},
|
| 556 |
+
"@vue/devtools-shared": {
|
| 557 |
+
"version": "7.7.9",
|
| 558 |
+
"resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-7.7.9.tgz",
|
| 559 |
+
"integrity": "sha512-iWAb0v2WYf0QWmxCGy0seZNDPdO3Sp5+u78ORnyeonS6MT4PC7VPrryX2BpMJrwlDeaZ6BD4vP4XKjK0SZqaeA==",
|
| 560 |
+
"requires": {
|
| 561 |
+
"rfdc": "^1.4.1"
|
| 562 |
+
}
|
| 563 |
+
},
|
| 564 |
+
"@vue/reactivity": {
|
| 565 |
+
"version": "3.5.29",
|
| 566 |
+
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.29.tgz",
|
| 567 |
+
"integrity": "sha512-zcrANcrRdcLtmGZETBxWqIkoQei8HaFpZWx/GHKxx79JZsiZ8j1du0VUJtu4eJjgFvU/iKL5lRXFXksVmI+5DA==",
|
| 568 |
+
"requires": {
|
| 569 |
+
"@vue/shared": "3.5.29"
|
| 570 |
+
}
|
| 571 |
+
},
|
| 572 |
+
"@vue/runtime-core": {
|
| 573 |
+
"version": "3.5.29",
|
| 574 |
+
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.29.tgz",
|
| 575 |
+
"integrity": "sha512-8DpW2QfdwIWOLqtsNcds4s+QgwSaHSJY/SUe04LptianUQ/0xi6KVsu/pYVh+HO3NTVvVJjIPL2t6GdeKbS4Lg==",
|
| 576 |
+
"requires": {
|
| 577 |
+
"@vue/reactivity": "3.5.29",
|
| 578 |
+
"@vue/shared": "3.5.29"
|
| 579 |
+
}
|
| 580 |
+
},
|
| 581 |
+
"@vue/runtime-dom": {
|
| 582 |
+
"version": "3.5.29",
|
| 583 |
+
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.29.tgz",
|
| 584 |
+
"integrity": "sha512-AHvvJEtcY9tw/uk+s/YRLSlxxQnqnAkjqvK25ZiM4CllCZWzElRAoQnCM42m9AHRLNJ6oe2kC5DCgD4AUdlvXg==",
|
| 585 |
+
"requires": {
|
| 586 |
+
"@vue/reactivity": "3.5.29",
|
| 587 |
+
"@vue/runtime-core": "3.5.29",
|
| 588 |
+
"@vue/shared": "3.5.29",
|
| 589 |
+
"csstype": "^3.2.3"
|
| 590 |
+
}
|
| 591 |
+
},
|
| 592 |
+
"@vue/server-renderer": {
|
| 593 |
+
"version": "3.5.29",
|
| 594 |
+
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.29.tgz",
|
| 595 |
+
"integrity": "sha512-G/1k6WK5MusLlbxSE2YTcqAAezS+VuwHhOvLx2KnQU7G2zCH6KIb+5Wyt6UjMq7a3qPzNEjJXs1hvAxDclQH+g==",
|
| 596 |
+
"requires": {
|
| 597 |
+
"@vue/compiler-ssr": "3.5.29",
|
| 598 |
+
"@vue/shared": "3.5.29"
|
| 599 |
+
}
|
| 600 |
+
},
|
| 601 |
+
"@vue/shared": {
|
| 602 |
+
"version": "3.5.29",
|
| 603 |
+
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.29.tgz",
|
| 604 |
+
"integrity": "sha512-w7SR0A5zyRByL9XUkCfdLs7t9XOHUyJ67qPGQjOou3p6GvBeBW+AVjUUmlxtZ4PIYaRvE+1LmK44O4uajlZwcg=="
|
| 605 |
+
},
|
| 606 |
+
"any-promise": {
|
| 607 |
+
"version": "1.3.0",
|
| 608 |
+
"resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz",
|
| 609 |
+
"integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==",
|
| 610 |
+
"dev": true
|
| 611 |
+
},
|
| 612 |
+
"anymatch": {
|
| 613 |
+
"version": "3.1.3",
|
| 614 |
+
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
|
| 615 |
+
"integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
|
| 616 |
+
"dev": true,
|
| 617 |
+
"requires": {
|
| 618 |
+
"normalize-path": "^3.0.0",
|
| 619 |
+
"picomatch": "^2.0.4"
|
| 620 |
+
}
|
| 621 |
+
},
|
| 622 |
+
"arg": {
|
| 623 |
+
"version": "5.0.2",
|
| 624 |
+
"resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz",
|
| 625 |
+
"integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==",
|
| 626 |
+
"dev": true
|
| 627 |
+
},
|
| 628 |
+
"asynckit": {
|
| 629 |
+
"version": "0.4.0",
|
| 630 |
+
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
| 631 |
+
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
|
| 632 |
+
},
|
| 633 |
+
"autoprefixer": {
|
| 634 |
+
"version": "10.4.27",
|
| 635 |
+
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.27.tgz",
|
| 636 |
+
"integrity": "sha512-NP9APE+tO+LuJGn7/9+cohklunJsXWiaWEfV3si4Gi/XHDwVNgkwr1J3RQYFIvPy76GmJ9/bW8vyoU1LcxwKHA==",
|
| 637 |
+
"dev": true,
|
| 638 |
+
"requires": {
|
| 639 |
+
"browserslist": "^4.28.1",
|
| 640 |
+
"caniuse-lite": "^1.0.30001774",
|
| 641 |
+
"fraction.js": "^5.3.4",
|
| 642 |
+
"picocolors": "^1.1.1",
|
| 643 |
+
"postcss-value-parser": "^4.2.0"
|
| 644 |
+
}
|
| 645 |
+
},
|
| 646 |
+
"axios": {
|
| 647 |
+
"version": "1.13.6",
|
| 648 |
+
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz",
|
| 649 |
+
"integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==",
|
| 650 |
+
"requires": {
|
| 651 |
+
"follow-redirects": "^1.15.11",
|
| 652 |
+
"form-data": "^4.0.5",
|
| 653 |
+
"proxy-from-env": "^1.1.0"
|
| 654 |
+
}
|
| 655 |
+
},
|
| 656 |
+
"baseline-browser-mapping": {
|
| 657 |
+
"version": "2.10.0",
|
| 658 |
+
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz",
|
| 659 |
+
"integrity": "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==",
|
| 660 |
+
"dev": true
|
| 661 |
+
},
|
| 662 |
+
"binary-extensions": {
|
| 663 |
+
"version": "2.3.0",
|
| 664 |
+
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
|
| 665 |
+
"integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
|
| 666 |
+
"dev": true
|
| 667 |
+
},
|
| 668 |
+
"birpc": {
|
| 669 |
+
"version": "2.9.0",
|
| 670 |
+
"resolved": "https://registry.npmjs.org/birpc/-/birpc-2.9.0.tgz",
|
| 671 |
+
"integrity": "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw=="
|
| 672 |
+
},
|
| 673 |
+
"braces": {
|
| 674 |
+
"version": "3.0.3",
|
| 675 |
+
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
|
| 676 |
+
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
|
| 677 |
+
"dev": true,
|
| 678 |
+
"requires": {
|
| 679 |
+
"fill-range": "^7.1.1"
|
| 680 |
+
}
|
| 681 |
+
},
|
| 682 |
+
"browserslist": {
|
| 683 |
+
"version": "4.28.1",
|
| 684 |
+
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz",
|
| 685 |
+
"integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==",
|
| 686 |
+
"dev": true,
|
| 687 |
+
"requires": {
|
| 688 |
+
"baseline-browser-mapping": "^2.9.0",
|
| 689 |
+
"caniuse-lite": "^1.0.30001759",
|
| 690 |
+
"electron-to-chromium": "^1.5.263",
|
| 691 |
+
"node-releases": "^2.0.27",
|
| 692 |
+
"update-browserslist-db": "^1.2.0"
|
| 693 |
+
}
|
| 694 |
+
},
|
| 695 |
+
"call-bind-apply-helpers": {
|
| 696 |
+
"version": "1.0.2",
|
| 697 |
+
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
| 698 |
+
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
|
| 699 |
+
"requires": {
|
| 700 |
+
"es-errors": "^1.3.0",
|
| 701 |
+
"function-bind": "^1.1.2"
|
| 702 |
+
}
|
| 703 |
+
},
|
| 704 |
+
"camelcase-css": {
|
| 705 |
+
"version": "2.0.1",
|
| 706 |
+
"resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz",
|
| 707 |
+
"integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==",
|
| 708 |
+
"dev": true
|
| 709 |
+
},
|
| 710 |
+
"caniuse-lite": {
|
| 711 |
+
"version": "1.0.30001774",
|
| 712 |
+
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001774.tgz",
|
| 713 |
+
"integrity": "sha512-DDdwPGz99nmIEv216hKSgLD+D4ikHQHjBC/seF98N9CPqRX4M5mSxT9eTV6oyisnJcuzxtZy4n17yKKQYmYQOA==",
|
| 714 |
+
"dev": true
|
| 715 |
+
},
|
| 716 |
+
"chokidar": {
|
| 717 |
+
"version": "3.6.0",
|
| 718 |
+
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
|
| 719 |
+
"integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
|
| 720 |
+
"dev": true,
|
| 721 |
+
"requires": {
|
| 722 |
+
"anymatch": "~3.1.2",
|
| 723 |
+
"braces": "~3.0.2",
|
| 724 |
+
"fsevents": "~2.3.2",
|
| 725 |
+
"glob-parent": "~5.1.2",
|
| 726 |
+
"is-binary-path": "~2.1.0",
|
| 727 |
+
"is-glob": "~4.0.1",
|
| 728 |
+
"normalize-path": "~3.0.0",
|
| 729 |
+
"readdirp": "~3.6.0"
|
| 730 |
+
},
|
| 731 |
+
"dependencies": {
|
| 732 |
+
"glob-parent": {
|
| 733 |
+
"version": "5.1.2",
|
| 734 |
+
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
|
| 735 |
+
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
|
| 736 |
+
"dev": true,
|
| 737 |
+
"requires": {
|
| 738 |
+
"is-glob": "^4.0.1"
|
| 739 |
+
}
|
| 740 |
+
}
|
| 741 |
+
}
|
| 742 |
+
},
|
| 743 |
+
"class-variance-authority": {
|
| 744 |
+
"version": "0.7.1",
|
| 745 |
+
"resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz",
|
| 746 |
+
"integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==",
|
| 747 |
+
"dev": true,
|
| 748 |
+
"requires": {
|
| 749 |
+
"clsx": "^2.1.1"
|
| 750 |
+
}
|
| 751 |
+
},
|
| 752 |
+
"clsx": {
|
| 753 |
+
"version": "2.1.1",
|
| 754 |
+
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
| 755 |
+
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
|
| 756 |
+
"dev": true
|
| 757 |
+
},
|
| 758 |
+
"combined-stream": {
|
| 759 |
+
"version": "1.0.8",
|
| 760 |
+
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
| 761 |
+
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
|
| 762 |
+
"requires": {
|
| 763 |
+
"delayed-stream": "~1.0.0"
|
| 764 |
+
}
|
| 765 |
+
},
|
| 766 |
+
"commander": {
|
| 767 |
+
"version": "4.1.1",
|
| 768 |
+
"resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz",
|
| 769 |
+
"integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==",
|
| 770 |
+
"dev": true
|
| 771 |
+
},
|
| 772 |
+
"copy-anything": {
|
| 773 |
+
"version": "4.0.5",
|
| 774 |
+
"resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-4.0.5.tgz",
|
| 775 |
+
"integrity": "sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==",
|
| 776 |
+
"requires": {
|
| 777 |
+
"is-what": "^5.2.0"
|
| 778 |
+
}
|
| 779 |
+
},
|
| 780 |
+
"cssesc": {
|
| 781 |
+
"version": "3.0.0",
|
| 782 |
+
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
|
| 783 |
+
"integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==",
|
| 784 |
+
"dev": true
|
| 785 |
+
},
|
| 786 |
+
"csstype": {
|
| 787 |
+
"version": "3.2.3",
|
| 788 |
+
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
| 789 |
+
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="
|
| 790 |
+
},
|
| 791 |
+
"delayed-stream": {
|
| 792 |
+
"version": "1.0.0",
|
| 793 |
+
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
| 794 |
+
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="
|
| 795 |
+
},
|
| 796 |
+
"didyoumean": {
|
| 797 |
+
"version": "1.2.2",
|
| 798 |
+
"resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz",
|
| 799 |
+
"integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==",
|
| 800 |
+
"dev": true
|
| 801 |
+
},
|
| 802 |
+
"dlv": {
|
| 803 |
+
"version": "1.1.3",
|
| 804 |
+
"resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz",
|
| 805 |
+
"integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==",
|
| 806 |
+
"dev": true
|
| 807 |
+
},
|
| 808 |
+
"dunder-proto": {
|
| 809 |
+
"version": "1.0.1",
|
| 810 |
+
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
| 811 |
+
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
|
| 812 |
+
"requires": {
|
| 813 |
+
"call-bind-apply-helpers": "^1.0.1",
|
| 814 |
+
"es-errors": "^1.3.0",
|
| 815 |
+
"gopd": "^1.2.0"
|
| 816 |
+
}
|
| 817 |
+
},
|
| 818 |
+
"electron-to-chromium": {
|
| 819 |
+
"version": "1.5.302",
|
| 820 |
+
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.302.tgz",
|
| 821 |
+
"integrity": "sha512-sM6HAN2LyK82IyPBpznDRqlTQAtuSaO+ShzFiWTvoMJLHyZ+Y39r8VMfHzwbU8MVBzQ4Wdn85+wlZl2TLGIlwg==",
|
| 822 |
+
"dev": true
|
| 823 |
+
},
|
| 824 |
+
"entities": {
|
| 825 |
+
"version": "7.0.1",
|
| 826 |
+
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
|
| 827 |
+
"integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="
|
| 828 |
+
},
|
| 829 |
+
"es-define-property": {
|
| 830 |
+
"version": "1.0.1",
|
| 831 |
+
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
| 832 |
+
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="
|
| 833 |
+
},
|
| 834 |
+
"es-errors": {
|
| 835 |
+
"version": "1.3.0",
|
| 836 |
+
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
|
| 837 |
+
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="
|
| 838 |
+
},
|
| 839 |
+
"es-object-atoms": {
|
| 840 |
+
"version": "1.1.1",
|
| 841 |
+
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
|
| 842 |
+
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
|
| 843 |
+
"requires": {
|
| 844 |
+
"es-errors": "^1.3.0"
|
| 845 |
+
}
|
| 846 |
+
},
|
| 847 |
+
"es-set-tostringtag": {
|
| 848 |
+
"version": "2.1.0",
|
| 849 |
+
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
|
| 850 |
+
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
|
| 851 |
+
"requires": {
|
| 852 |
+
"es-errors": "^1.3.0",
|
| 853 |
+
"get-intrinsic": "^1.2.6",
|
| 854 |
+
"has-tostringtag": "^1.0.2",
|
| 855 |
+
"hasown": "^2.0.2"
|
| 856 |
+
}
|
| 857 |
+
},
|
| 858 |
+
"esbuild": {
|
| 859 |
+
"version": "0.27.3",
|
| 860 |
+
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz",
|
| 861 |
+
"integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==",
|
| 862 |
+
"dev": true,
|
| 863 |
+
"requires": {
|
| 864 |
+
"@esbuild/aix-ppc64": "0.27.3",
|
| 865 |
+
"@esbuild/android-arm": "0.27.3",
|
| 866 |
+
"@esbuild/android-arm64": "0.27.3",
|
| 867 |
+
"@esbuild/android-x64": "0.27.3",
|
| 868 |
+
"@esbuild/darwin-arm64": "0.27.3",
|
| 869 |
+
"@esbuild/darwin-x64": "0.27.3",
|
| 870 |
+
"@esbuild/freebsd-arm64": "0.27.3",
|
| 871 |
+
"@esbuild/freebsd-x64": "0.27.3",
|
| 872 |
+
"@esbuild/linux-arm": "0.27.3",
|
| 873 |
+
"@esbuild/linux-arm64": "0.27.3",
|
| 874 |
+
"@esbuild/linux-ia32": "0.27.3",
|
| 875 |
+
"@esbuild/linux-loong64": "0.27.3",
|
| 876 |
+
"@esbuild/linux-mips64el": "0.27.3",
|
| 877 |
+
"@esbuild/linux-ppc64": "0.27.3",
|
| 878 |
+
"@esbuild/linux-riscv64": "0.27.3",
|
| 879 |
+
"@esbuild/linux-s390x": "0.27.3",
|
| 880 |
+
"@esbuild/linux-x64": "0.27.3",
|
| 881 |
+
"@esbuild/netbsd-arm64": "0.27.3",
|
| 882 |
+
"@esbuild/netbsd-x64": "0.27.3",
|
| 883 |
+
"@esbuild/openbsd-arm64": "0.27.3",
|
| 884 |
+
"@esbuild/openbsd-x64": "0.27.3",
|
| 885 |
+
"@esbuild/openharmony-arm64": "0.27.3",
|
| 886 |
+
"@esbuild/sunos-x64": "0.27.3",
|
| 887 |
+
"@esbuild/win32-arm64": "0.27.3",
|
| 888 |
+
"@esbuild/win32-ia32": "0.27.3",
|
| 889 |
+
"@esbuild/win32-x64": "0.27.3"
|
| 890 |
+
}
|
| 891 |
+
},
|
| 892 |
+
"escalade": {
|
| 893 |
+
"version": "3.2.0",
|
| 894 |
+
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
| 895 |
+
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
|
| 896 |
+
"dev": true
|
| 897 |
+
},
|
| 898 |
+
"estree-walker": {
|
| 899 |
+
"version": "2.0.2",
|
| 900 |
+
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
|
| 901 |
+
"integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="
|
| 902 |
+
},
|
| 903 |
+
"fast-glob": {
|
| 904 |
+
"version": "3.3.3",
|
| 905 |
+
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
|
| 906 |
+
"integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==",
|
| 907 |
+
"dev": true,
|
| 908 |
+
"requires": {
|
| 909 |
+
"@nodelib/fs.stat": "^2.0.2",
|
| 910 |
+
"@nodelib/fs.walk": "^1.2.3",
|
| 911 |
+
"glob-parent": "^5.1.2",
|
| 912 |
+
"merge2": "^1.3.0",
|
| 913 |
+
"micromatch": "^4.0.8"
|
| 914 |
+
},
|
| 915 |
+
"dependencies": {
|
| 916 |
+
"glob-parent": {
|
| 917 |
+
"version": "5.1.2",
|
| 918 |
+
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
|
| 919 |
+
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
|
| 920 |
+
"dev": true,
|
| 921 |
+
"requires": {
|
| 922 |
+
"is-glob": "^4.0.1"
|
| 923 |
+
}
|
| 924 |
+
}
|
| 925 |
+
}
|
| 926 |
+
},
|
| 927 |
+
"fastq": {
|
| 928 |
+
"version": "1.20.1",
|
| 929 |
+
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
|
| 930 |
+
"integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==",
|
| 931 |
+
"dev": true,
|
| 932 |
+
"requires": {
|
| 933 |
+
"reusify": "^1.0.4"
|
| 934 |
+
}
|
| 935 |
+
},
|
| 936 |
+
"fdir": {
|
| 937 |
+
"version": "6.5.0",
|
| 938 |
+
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
| 939 |
+
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
|
| 940 |
+
"dev": true
|
| 941 |
+
},
|
| 942 |
+
"fill-range": {
|
| 943 |
+
"version": "7.1.1",
|
| 944 |
+
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
| 945 |
+
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
|
| 946 |
+
"dev": true,
|
| 947 |
+
"requires": {
|
| 948 |
+
"to-regex-range": "^5.0.1"
|
| 949 |
+
}
|
| 950 |
+
},
|
| 951 |
+
"follow-redirects": {
|
| 952 |
+
"version": "1.15.11",
|
| 953 |
+
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
|
| 954 |
+
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ=="
|
| 955 |
+
},
|
| 956 |
+
"form-data": {
|
| 957 |
+
"version": "4.0.5",
|
| 958 |
+
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
|
| 959 |
+
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
|
| 960 |
+
"requires": {
|
| 961 |
+
"asynckit": "^0.4.0",
|
| 962 |
+
"combined-stream": "^1.0.8",
|
| 963 |
+
"es-set-tostringtag": "^2.1.0",
|
| 964 |
+
"hasown": "^2.0.2",
|
| 965 |
+
"mime-types": "^2.1.12"
|
| 966 |
+
}
|
| 967 |
+
},
|
| 968 |
+
"fraction.js": {
|
| 969 |
+
"version": "5.3.4",
|
| 970 |
+
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz",
|
| 971 |
+
"integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==",
|
| 972 |
+
"dev": true
|
| 973 |
+
},
|
| 974 |
+
"fsevents": {
|
| 975 |
+
"version": "2.3.3",
|
| 976 |
+
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
| 977 |
+
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
| 978 |
+
"dev": true,
|
| 979 |
+
"optional": true
|
| 980 |
+
},
|
| 981 |
+
"function-bind": {
|
| 982 |
+
"version": "1.1.2",
|
| 983 |
+
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
| 984 |
+
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="
|
| 985 |
+
},
|
| 986 |
+
"get-intrinsic": {
|
| 987 |
+
"version": "1.3.0",
|
| 988 |
+
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
| 989 |
+
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
|
| 990 |
+
"requires": {
|
| 991 |
+
"call-bind-apply-helpers": "^1.0.2",
|
| 992 |
+
"es-define-property": "^1.0.1",
|
| 993 |
+
"es-errors": "^1.3.0",
|
| 994 |
+
"es-object-atoms": "^1.1.1",
|
| 995 |
+
"function-bind": "^1.1.2",
|
| 996 |
+
"get-proto": "^1.0.1",
|
| 997 |
+
"gopd": "^1.2.0",
|
| 998 |
+
"has-symbols": "^1.1.0",
|
| 999 |
+
"hasown": "^2.0.2",
|
| 1000 |
+
"math-intrinsics": "^1.1.0"
|
| 1001 |
+
}
|
| 1002 |
+
},
|
| 1003 |
+
"get-proto": {
|
| 1004 |
+
"version": "1.0.1",
|
| 1005 |
+
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
|
| 1006 |
+
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
|
| 1007 |
+
"requires": {
|
| 1008 |
+
"dunder-proto": "^1.0.1",
|
| 1009 |
+
"es-object-atoms": "^1.0.0"
|
| 1010 |
+
}
|
| 1011 |
+
},
|
| 1012 |
+
"glob-parent": {
|
| 1013 |
+
"version": "6.0.2",
|
| 1014 |
+
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
|
| 1015 |
+
"integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
|
| 1016 |
+
"dev": true,
|
| 1017 |
+
"requires": {
|
| 1018 |
+
"is-glob": "^4.0.3"
|
| 1019 |
+
}
|
| 1020 |
+
},
|
| 1021 |
+
"gopd": {
|
| 1022 |
+
"version": "1.2.0",
|
| 1023 |
+
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
| 1024 |
+
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="
|
| 1025 |
+
},
|
| 1026 |
+
"has-symbols": {
|
| 1027 |
+
"version": "1.1.0",
|
| 1028 |
+
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
| 1029 |
+
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="
|
| 1030 |
+
},
|
| 1031 |
+
"has-tostringtag": {
|
| 1032 |
+
"version": "1.0.2",
|
| 1033 |
+
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
|
| 1034 |
+
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
|
| 1035 |
+
"requires": {
|
| 1036 |
+
"has-symbols": "^1.0.3"
|
| 1037 |
+
}
|
| 1038 |
+
},
|
| 1039 |
+
"hasown": {
|
| 1040 |
+
"version": "2.0.2",
|
| 1041 |
+
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
| 1042 |
+
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
|
| 1043 |
+
"requires": {
|
| 1044 |
+
"function-bind": "^1.1.2"
|
| 1045 |
+
}
|
| 1046 |
+
},
|
| 1047 |
+
"hookable": {
|
| 1048 |
+
"version": "5.5.3",
|
| 1049 |
+
"resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz",
|
| 1050 |
+
"integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ=="
|
| 1051 |
+
},
|
| 1052 |
+
"is-binary-path": {
|
| 1053 |
+
"version": "2.1.0",
|
| 1054 |
+
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
|
| 1055 |
+
"integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
|
| 1056 |
+
"dev": true,
|
| 1057 |
+
"requires": {
|
| 1058 |
+
"binary-extensions": "^2.0.0"
|
| 1059 |
+
}
|
| 1060 |
+
},
|
| 1061 |
+
"is-core-module": {
|
| 1062 |
+
"version": "2.16.1",
|
| 1063 |
+
"resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz",
|
| 1064 |
+
"integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==",
|
| 1065 |
+
"dev": true,
|
| 1066 |
+
"requires": {
|
| 1067 |
+
"hasown": "^2.0.2"
|
| 1068 |
+
}
|
| 1069 |
+
},
|
| 1070 |
+
"is-extglob": {
|
| 1071 |
+
"version": "2.1.1",
|
| 1072 |
+
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
|
| 1073 |
+
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
|
| 1074 |
+
"dev": true
|
| 1075 |
+
},
|
| 1076 |
+
"is-glob": {
|
| 1077 |
+
"version": "4.0.3",
|
| 1078 |
+
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
|
| 1079 |
+
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
|
| 1080 |
+
"dev": true,
|
| 1081 |
+
"requires": {
|
| 1082 |
+
"is-extglob": "^2.1.1"
|
| 1083 |
+
}
|
| 1084 |
+
},
|
| 1085 |
+
"is-number": {
|
| 1086 |
+
"version": "7.0.0",
|
| 1087 |
+
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
|
| 1088 |
+
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
|
| 1089 |
+
"dev": true
|
| 1090 |
+
},
|
| 1091 |
+
"is-what": {
|
| 1092 |
+
"version": "5.5.0",
|
| 1093 |
+
"resolved": "https://registry.npmjs.org/is-what/-/is-what-5.5.0.tgz",
|
| 1094 |
+
"integrity": "sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw=="
|
| 1095 |
+
},
|
| 1096 |
+
"jiti": {
|
| 1097 |
+
"version": "1.21.7",
|
| 1098 |
+
"resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
|
| 1099 |
+
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
|
| 1100 |
+
"dev": true
|
| 1101 |
+
},
|
| 1102 |
+
"lilconfig": {
|
| 1103 |
+
"version": "3.1.3",
|
| 1104 |
+
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
|
| 1105 |
+
"integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==",
|
| 1106 |
+
"dev": true
|
| 1107 |
+
},
|
| 1108 |
+
"lines-and-columns": {
|
| 1109 |
+
"version": "1.2.4",
|
| 1110 |
+
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
|
| 1111 |
+
"integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
|
| 1112 |
+
"dev": true
|
| 1113 |
+
},
|
| 1114 |
+
"magic-string": {
|
| 1115 |
+
"version": "0.30.21",
|
| 1116 |
+
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
| 1117 |
+
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
|
| 1118 |
+
"requires": {
|
| 1119 |
+
"@jridgewell/sourcemap-codec": "^1.5.5"
|
| 1120 |
+
}
|
| 1121 |
+
},
|
| 1122 |
+
"math-intrinsics": {
|
| 1123 |
+
"version": "1.1.0",
|
| 1124 |
+
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
| 1125 |
+
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="
|
| 1126 |
+
},
|
| 1127 |
+
"merge2": {
|
| 1128 |
+
"version": "1.4.1",
|
| 1129 |
+
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
|
| 1130 |
+
"integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
|
| 1131 |
+
"dev": true
|
| 1132 |
+
},
|
| 1133 |
+
"micromatch": {
|
| 1134 |
+
"version": "4.0.8",
|
| 1135 |
+
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
|
| 1136 |
+
"integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
|
| 1137 |
+
"dev": true,
|
| 1138 |
+
"requires": {
|
| 1139 |
+
"braces": "^3.0.3",
|
| 1140 |
+
"picomatch": "^2.3.1"
|
| 1141 |
+
}
|
| 1142 |
+
},
|
| 1143 |
+
"mime-db": {
|
| 1144 |
+
"version": "1.52.0",
|
| 1145 |
+
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
| 1146 |
+
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="
|
| 1147 |
+
},
|
| 1148 |
+
"mime-types": {
|
| 1149 |
+
"version": "2.1.35",
|
| 1150 |
+
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
| 1151 |
+
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
| 1152 |
+
"requires": {
|
| 1153 |
+
"mime-db": "1.52.0"
|
| 1154 |
+
}
|
| 1155 |
+
},
|
| 1156 |
+
"mitt": {
|
| 1157 |
+
"version": "3.0.1",
|
| 1158 |
+
"resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz",
|
| 1159 |
+
"integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw=="
|
| 1160 |
+
},
|
| 1161 |
+
"mz": {
|
| 1162 |
+
"version": "2.7.0",
|
| 1163 |
+
"resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz",
|
| 1164 |
+
"integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==",
|
| 1165 |
+
"dev": true,
|
| 1166 |
+
"requires": {
|
| 1167 |
+
"any-promise": "^1.0.0",
|
| 1168 |
+
"object-assign": "^4.0.1",
|
| 1169 |
+
"thenify-all": "^1.0.0"
|
| 1170 |
+
}
|
| 1171 |
+
},
|
| 1172 |
+
"nanoid": {
|
| 1173 |
+
"version": "3.3.11",
|
| 1174 |
+
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
| 1175 |
+
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="
|
| 1176 |
+
},
|
| 1177 |
+
"node-releases": {
|
| 1178 |
+
"version": "2.0.27",
|
| 1179 |
+
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
|
| 1180 |
+
"integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==",
|
| 1181 |
+
"dev": true
|
| 1182 |
+
},
|
| 1183 |
+
"normalize-path": {
|
| 1184 |
+
"version": "3.0.0",
|
| 1185 |
+
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
|
| 1186 |
+
"integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
|
| 1187 |
+
"dev": true
|
| 1188 |
+
},
|
| 1189 |
+
"object-assign": {
|
| 1190 |
+
"version": "4.1.1",
|
| 1191 |
+
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
| 1192 |
+
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
|
| 1193 |
+
"dev": true
|
| 1194 |
+
},
|
| 1195 |
+
"object-hash": {
|
| 1196 |
+
"version": "3.0.0",
|
| 1197 |
+
"resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz",
|
| 1198 |
+
"integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==",
|
| 1199 |
+
"dev": true
|
| 1200 |
+
},
|
| 1201 |
+
"path-parse": {
|
| 1202 |
+
"version": "1.0.7",
|
| 1203 |
+
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
|
| 1204 |
+
"integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
|
| 1205 |
+
"dev": true
|
| 1206 |
+
},
|
| 1207 |
+
"perfect-debounce": {
|
| 1208 |
+
"version": "1.0.0",
|
| 1209 |
+
"resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz",
|
| 1210 |
+
"integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA=="
|
| 1211 |
+
},
|
| 1212 |
+
"picocolors": {
|
| 1213 |
+
"version": "1.1.1",
|
| 1214 |
+
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
| 1215 |
+
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="
|
| 1216 |
+
},
|
| 1217 |
+
"picomatch": {
|
| 1218 |
+
"version": "2.3.1",
|
| 1219 |
+
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
|
| 1220 |
+
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
|
| 1221 |
+
"dev": true
|
| 1222 |
+
},
|
| 1223 |
+
"pify": {
|
| 1224 |
+
"version": "2.3.0",
|
| 1225 |
+
"resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz",
|
| 1226 |
+
"integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==",
|
| 1227 |
+
"dev": true
|
| 1228 |
+
},
|
| 1229 |
+
"pinia": {
|
| 1230 |
+
"version": "3.0.4",
|
| 1231 |
+
"resolved": "https://registry.npmjs.org/pinia/-/pinia-3.0.4.tgz",
|
| 1232 |
+
"integrity": "sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==",
|
| 1233 |
+
"requires": {
|
| 1234 |
+
"@vue/devtools-api": "^7.7.7"
|
| 1235 |
+
}
|
| 1236 |
+
},
|
| 1237 |
+
"pirates": {
|
| 1238 |
+
"version": "4.0.7",
|
| 1239 |
+
"resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz",
|
| 1240 |
+
"integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==",
|
| 1241 |
+
"dev": true
|
| 1242 |
+
},
|
| 1243 |
+
"postcss": {
|
| 1244 |
+
"version": "8.5.6",
|
| 1245 |
+
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
| 1246 |
+
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
|
| 1247 |
+
"requires": {
|
| 1248 |
+
"nanoid": "^3.3.11",
|
| 1249 |
+
"picocolors": "^1.1.1",
|
| 1250 |
+
"source-map-js": "^1.2.1"
|
| 1251 |
+
}
|
| 1252 |
+
},
|
| 1253 |
+
"postcss-import": {
|
| 1254 |
+
"version": "15.1.0",
|
| 1255 |
+
"resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz",
|
| 1256 |
+
"integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==",
|
| 1257 |
+
"dev": true,
|
| 1258 |
+
"requires": {
|
| 1259 |
+
"postcss-value-parser": "^4.0.0",
|
| 1260 |
+
"read-cache": "^1.0.0",
|
| 1261 |
+
"resolve": "^1.1.7"
|
| 1262 |
+
}
|
| 1263 |
+
},
|
| 1264 |
+
"postcss-js": {
|
| 1265 |
+
"version": "4.1.0",
|
| 1266 |
+
"resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz",
|
| 1267 |
+
"integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==",
|
| 1268 |
+
"dev": true,
|
| 1269 |
+
"requires": {
|
| 1270 |
+
"camelcase-css": "^2.0.1"
|
| 1271 |
+
}
|
| 1272 |
+
},
|
| 1273 |
+
"postcss-load-config": {
|
| 1274 |
+
"version": "6.0.1",
|
| 1275 |
+
"resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz",
|
| 1276 |
+
"integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==",
|
| 1277 |
+
"dev": true,
|
| 1278 |
+
"requires": {
|
| 1279 |
+
"lilconfig": "^3.1.1"
|
| 1280 |
+
}
|
| 1281 |
+
},
|
| 1282 |
+
"postcss-nested": {
|
| 1283 |
+
"version": "6.2.0",
|
| 1284 |
+
"resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz",
|
| 1285 |
+
"integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==",
|
| 1286 |
+
"dev": true,
|
| 1287 |
+
"requires": {
|
| 1288 |
+
"postcss-selector-parser": "^6.1.1"
|
| 1289 |
+
}
|
| 1290 |
+
},
|
| 1291 |
+
"postcss-selector-parser": {
|
| 1292 |
+
"version": "6.1.2",
|
| 1293 |
+
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz",
|
| 1294 |
+
"integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==",
|
| 1295 |
+
"dev": true,
|
| 1296 |
+
"requires": {
|
| 1297 |
+
"cssesc": "^3.0.0",
|
| 1298 |
+
"util-deprecate": "^1.0.2"
|
| 1299 |
+
}
|
| 1300 |
+
},
|
| 1301 |
+
"postcss-value-parser": {
|
| 1302 |
+
"version": "4.2.0",
|
| 1303 |
+
"resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
|
| 1304 |
+
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
|
| 1305 |
+
"dev": true
|
| 1306 |
+
},
|
| 1307 |
+
"proxy-from-env": {
|
| 1308 |
+
"version": "1.1.0",
|
| 1309 |
+
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
| 1310 |
+
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="
|
| 1311 |
+
},
|
| 1312 |
+
"queue-microtask": {
|
| 1313 |
+
"version": "1.2.3",
|
| 1314 |
+
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
| 1315 |
+
"integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==",
|
| 1316 |
+
"dev": true
|
| 1317 |
+
},
|
| 1318 |
+
"read-cache": {
|
| 1319 |
+
"version": "1.0.0",
|
| 1320 |
+
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
|
| 1321 |
+
"integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==",
|
| 1322 |
+
"dev": true,
|
| 1323 |
+
"requires": {
|
| 1324 |
+
"pify": "^2.3.0"
|
| 1325 |
+
}
|
| 1326 |
+
},
|
| 1327 |
+
"readdirp": {
|
| 1328 |
+
"version": "3.6.0",
|
| 1329 |
+
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
|
| 1330 |
+
"integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
|
| 1331 |
+
"dev": true,
|
| 1332 |
+
"requires": {
|
| 1333 |
+
"picomatch": "^2.2.1"
|
| 1334 |
+
}
|
| 1335 |
+
},
|
| 1336 |
+
"resolve": {
|
| 1337 |
+
"version": "1.22.11",
|
| 1338 |
+
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
|
| 1339 |
+
"integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==",
|
| 1340 |
+
"dev": true,
|
| 1341 |
+
"requires": {
|
| 1342 |
+
"is-core-module": "^2.16.1",
|
| 1343 |
+
"path-parse": "^1.0.7",
|
| 1344 |
+
"supports-preserve-symlinks-flag": "^1.0.0"
|
| 1345 |
+
}
|
| 1346 |
+
},
|
| 1347 |
+
"reusify": {
|
| 1348 |
+
"version": "1.1.0",
|
| 1349 |
+
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
|
| 1350 |
+
"integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==",
|
| 1351 |
+
"dev": true
|
| 1352 |
+
},
|
| 1353 |
+
"rfdc": {
|
| 1354 |
+
"version": "1.4.1",
|
| 1355 |
+
"resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz",
|
| 1356 |
+
"integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="
|
| 1357 |
+
},
|
| 1358 |
+
"rollup": {
|
| 1359 |
+
"version": "4.59.0",
|
| 1360 |
+
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz",
|
| 1361 |
+
"integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==",
|
| 1362 |
+
"dev": true,
|
| 1363 |
+
"requires": {
|
| 1364 |
+
"@rollup/rollup-android-arm-eabi": "4.59.0",
|
| 1365 |
+
"@rollup/rollup-android-arm64": "4.59.0",
|
| 1366 |
+
"@rollup/rollup-darwin-arm64": "4.59.0",
|
| 1367 |
+
"@rollup/rollup-darwin-x64": "4.59.0",
|
| 1368 |
+
"@rollup/rollup-freebsd-arm64": "4.59.0",
|
| 1369 |
+
"@rollup/rollup-freebsd-x64": "4.59.0",
|
| 1370 |
+
"@rollup/rollup-linux-arm-gnueabihf": "4.59.0",
|
| 1371 |
+
"@rollup/rollup-linux-arm-musleabihf": "4.59.0",
|
| 1372 |
+
"@rollup/rollup-linux-arm64-gnu": "4.59.0",
|
| 1373 |
+
"@rollup/rollup-linux-arm64-musl": "4.59.0",
|
| 1374 |
+
"@rollup/rollup-linux-loong64-gnu": "4.59.0",
|
| 1375 |
+
"@rollup/rollup-linux-loong64-musl": "4.59.0",
|
| 1376 |
+
"@rollup/rollup-linux-ppc64-gnu": "4.59.0",
|
| 1377 |
+
"@rollup/rollup-linux-ppc64-musl": "4.59.0",
|
| 1378 |
+
"@rollup/rollup-linux-riscv64-gnu": "4.59.0",
|
| 1379 |
+
"@rollup/rollup-linux-riscv64-musl": "4.59.0",
|
| 1380 |
+
"@rollup/rollup-linux-s390x-gnu": "4.59.0",
|
| 1381 |
+
"@rollup/rollup-linux-x64-gnu": "4.59.0",
|
| 1382 |
+
"@rollup/rollup-linux-x64-musl": "4.59.0",
|
| 1383 |
+
"@rollup/rollup-openbsd-x64": "4.59.0",
|
| 1384 |
+
"@rollup/rollup-openharmony-arm64": "4.59.0",
|
| 1385 |
+
"@rollup/rollup-win32-arm64-msvc": "4.59.0",
|
| 1386 |
+
"@rollup/rollup-win32-ia32-msvc": "4.59.0",
|
| 1387 |
+
"@rollup/rollup-win32-x64-gnu": "4.59.0",
|
| 1388 |
+
"@rollup/rollup-win32-x64-msvc": "4.59.0",
|
| 1389 |
+
"@types/estree": "1.0.8",
|
| 1390 |
+
"fsevents": "~2.3.2"
|
| 1391 |
+
}
|
| 1392 |
+
},
|
| 1393 |
+
"run-parallel": {
|
| 1394 |
+
"version": "1.2.0",
|
| 1395 |
+
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
|
| 1396 |
+
"integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==",
|
| 1397 |
+
"dev": true,
|
| 1398 |
+
"requires": {
|
| 1399 |
+
"queue-microtask": "^1.2.2"
|
| 1400 |
+
}
|
| 1401 |
+
},
|
| 1402 |
+
"source-map-js": {
|
| 1403 |
+
"version": "1.2.1",
|
| 1404 |
+
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
| 1405 |
+
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="
|
| 1406 |
+
},
|
| 1407 |
+
"speakingurl": {
|
| 1408 |
+
"version": "14.0.1",
|
| 1409 |
+
"resolved": "https://registry.npmjs.org/speakingurl/-/speakingurl-14.0.1.tgz",
|
| 1410 |
+
"integrity": "sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ=="
|
| 1411 |
+
},
|
| 1412 |
+
"sucrase": {
|
| 1413 |
+
"version": "3.35.1",
|
| 1414 |
+
"resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz",
|
| 1415 |
+
"integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==",
|
| 1416 |
+
"dev": true,
|
| 1417 |
+
"requires": {
|
| 1418 |
+
"@jridgewell/gen-mapping": "^0.3.2",
|
| 1419 |
+
"commander": "^4.0.0",
|
| 1420 |
+
"lines-and-columns": "^1.1.6",
|
| 1421 |
+
"mz": "^2.7.0",
|
| 1422 |
+
"pirates": "^4.0.1",
|
| 1423 |
+
"tinyglobby": "^0.2.11",
|
| 1424 |
+
"ts-interface-checker": "^0.1.9"
|
| 1425 |
+
}
|
| 1426 |
+
},
|
| 1427 |
+
"superjson": {
|
| 1428 |
+
"version": "2.2.6",
|
| 1429 |
+
"resolved": "https://registry.npmjs.org/superjson/-/superjson-2.2.6.tgz",
|
| 1430 |
+
"integrity": "sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==",
|
| 1431 |
+
"requires": {
|
| 1432 |
+
"copy-anything": "^4"
|
| 1433 |
+
}
|
| 1434 |
+
},
|
| 1435 |
+
"supports-preserve-symlinks-flag": {
|
| 1436 |
+
"version": "1.0.0",
|
| 1437 |
+
"resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
|
| 1438 |
+
"integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==",
|
| 1439 |
+
"dev": true
|
| 1440 |
+
},
|
| 1441 |
+
"tailwind-merge": {
|
| 1442 |
+
"version": "3.5.0",
|
| 1443 |
+
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.5.0.tgz",
|
| 1444 |
+
"integrity": "sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==",
|
| 1445 |
+
"dev": true
|
| 1446 |
+
},
|
| 1447 |
+
"tailwindcss": {
|
| 1448 |
+
"version": "3.4.19",
|
| 1449 |
+
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz",
|
| 1450 |
+
"integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==",
|
| 1451 |
+
"dev": true,
|
| 1452 |
+
"requires": {
|
| 1453 |
+
"@alloc/quick-lru": "^5.2.0",
|
| 1454 |
+
"arg": "^5.0.2",
|
| 1455 |
+
"chokidar": "^3.6.0",
|
| 1456 |
+
"didyoumean": "^1.2.2",
|
| 1457 |
+
"dlv": "^1.1.3",
|
| 1458 |
+
"fast-glob": "^3.3.2",
|
| 1459 |
+
"glob-parent": "^6.0.2",
|
| 1460 |
+
"is-glob": "^4.0.3",
|
| 1461 |
+
"jiti": "^1.21.7",
|
| 1462 |
+
"lilconfig": "^3.1.3",
|
| 1463 |
+
"micromatch": "^4.0.8",
|
| 1464 |
+
"normalize-path": "^3.0.0",
|
| 1465 |
+
"object-hash": "^3.0.0",
|
| 1466 |
+
"picocolors": "^1.1.1",
|
| 1467 |
+
"postcss": "^8.4.47",
|
| 1468 |
+
"postcss-import": "^15.1.0",
|
| 1469 |
+
"postcss-js": "^4.0.1",
|
| 1470 |
+
"postcss-load-config": "^4.0.2 || ^5.0 || ^6.0",
|
| 1471 |
+
"postcss-nested": "^6.2.0",
|
| 1472 |
+
"postcss-selector-parser": "^6.1.2",
|
| 1473 |
+
"resolve": "^1.22.8",
|
| 1474 |
+
"sucrase": "^3.35.0"
|
| 1475 |
+
}
|
| 1476 |
+
},
|
| 1477 |
+
"thenify": {
|
| 1478 |
+
"version": "3.3.1",
|
| 1479 |
+
"resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz",
|
| 1480 |
+
"integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==",
|
| 1481 |
+
"dev": true,
|
| 1482 |
+
"requires": {
|
| 1483 |
+
"any-promise": "^1.0.0"
|
| 1484 |
+
}
|
| 1485 |
+
},
|
| 1486 |
+
"thenify-all": {
|
| 1487 |
+
"version": "1.6.0",
|
| 1488 |
+
"resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz",
|
| 1489 |
+
"integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==",
|
| 1490 |
+
"dev": true,
|
| 1491 |
+
"requires": {
|
| 1492 |
+
"thenify": ">= 3.1.0 < 4"
|
| 1493 |
+
}
|
| 1494 |
+
},
|
| 1495 |
+
"tinyglobby": {
|
| 1496 |
+
"version": "0.2.15",
|
| 1497 |
+
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
| 1498 |
+
"integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
|
| 1499 |
+
"dev": true,
|
| 1500 |
+
"requires": {
|
| 1501 |
+
"fdir": "^6.5.0",
|
| 1502 |
+
"picomatch": "^4.0.3"
|
| 1503 |
+
},
|
| 1504 |
+
"dependencies": {
|
| 1505 |
+
"picomatch": {
|
| 1506 |
+
"version": "4.0.3",
|
| 1507 |
+
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
| 1508 |
+
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
| 1509 |
+
"dev": true
|
| 1510 |
+
}
|
| 1511 |
+
}
|
| 1512 |
+
},
|
| 1513 |
+
"to-regex-range": {
|
| 1514 |
+
"version": "5.0.1",
|
| 1515 |
+
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
| 1516 |
+
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
|
| 1517 |
+
"dev": true,
|
| 1518 |
+
"requires": {
|
| 1519 |
+
"is-number": "^7.0.0"
|
| 1520 |
+
}
|
| 1521 |
+
},
|
| 1522 |
+
"ts-interface-checker": {
|
| 1523 |
+
"version": "0.1.13",
|
| 1524 |
+
"resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz",
|
| 1525 |
+
"integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==",
|
| 1526 |
+
"dev": true
|
| 1527 |
+
},
|
| 1528 |
+
"typescript": {
|
| 1529 |
+
"version": "5.9.3",
|
| 1530 |
+
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
| 1531 |
+
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
| 1532 |
+
"dev": true
|
| 1533 |
+
},
|
| 1534 |
+
"update-browserslist-db": {
|
| 1535 |
+
"version": "1.2.3",
|
| 1536 |
+
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
|
| 1537 |
+
"integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
|
| 1538 |
+
"dev": true,
|
| 1539 |
+
"requires": {
|
| 1540 |
+
"escalade": "^3.2.0",
|
| 1541 |
+
"picocolors": "^1.1.1"
|
| 1542 |
+
}
|
| 1543 |
+
},
|
| 1544 |
+
"util-deprecate": {
|
| 1545 |
+
"version": "1.0.2",
|
| 1546 |
+
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
| 1547 |
+
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
| 1548 |
+
"dev": true
|
| 1549 |
+
},
|
| 1550 |
+
"vite": {
|
| 1551 |
+
"version": "7.3.1",
|
| 1552 |
+
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
|
| 1553 |
+
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
|
| 1554 |
+
"dev": true,
|
| 1555 |
+
"requires": {
|
| 1556 |
+
"esbuild": "^0.27.0",
|
| 1557 |
+
"fdir": "^6.5.0",
|
| 1558 |
+
"fsevents": "~2.3.3",
|
| 1559 |
+
"picomatch": "^4.0.3",
|
| 1560 |
+
"postcss": "^8.5.6",
|
| 1561 |
+
"rollup": "^4.43.0",
|
| 1562 |
+
"tinyglobby": "^0.2.15"
|
| 1563 |
+
},
|
| 1564 |
+
"dependencies": {
|
| 1565 |
+
"picomatch": {
|
| 1566 |
+
"version": "4.0.3",
|
| 1567 |
+
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
| 1568 |
+
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
| 1569 |
+
"dev": true
|
| 1570 |
+
}
|
| 1571 |
+
}
|
| 1572 |
+
},
|
| 1573 |
+
"vue": {
|
| 1574 |
+
"version": "3.5.29",
|
| 1575 |
+
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.29.tgz",
|
| 1576 |
+
"integrity": "sha512-BZqN4Ze6mDQVNAni0IHeMJ5mwr8VAJ3MQC9FmprRhcBYENw+wOAAjRj8jfmN6FLl0j96OXbR+CjWhmAmM+QGnA==",
|
| 1577 |
+
"requires": {
|
| 1578 |
+
"@vue/compiler-dom": "3.5.29",
|
| 1579 |
+
"@vue/compiler-sfc": "3.5.29",
|
| 1580 |
+
"@vue/runtime-dom": "3.5.29",
|
| 1581 |
+
"@vue/server-renderer": "3.5.29",
|
| 1582 |
+
"@vue/shared": "3.5.29"
|
| 1583 |
+
}
|
| 1584 |
+
},
|
| 1585 |
+
"vue-observe-visibility": {
|
| 1586 |
+
"version": "2.0.0-alpha.1",
|
| 1587 |
+
"resolved": "https://registry.npmjs.org/vue-observe-visibility/-/vue-observe-visibility-2.0.0-alpha.1.tgz",
|
| 1588 |
+
"integrity": "sha512-flFbp/gs9pZniXR6fans8smv1kDScJ8RS7rEpMjhVabiKeq7Qz3D9+eGsypncjfIyyU84saU88XZ0zjbD6Gq/g=="
|
| 1589 |
+
},
|
| 1590 |
+
"vue-resize": {
|
| 1591 |
+
"version": "2.0.0-alpha.1",
|
| 1592 |
+
"resolved": "https://registry.npmjs.org/vue-resize/-/vue-resize-2.0.0-alpha.1.tgz",
|
| 1593 |
+
"integrity": "sha512-7+iqOueLU7uc9NrMfrzbG8hwMqchfVfSzpVlCMeJQe4pyibqyoifDNbKTZvwxZKDvGkB+PdFeKvnGZMoEb8esg=="
|
| 1594 |
+
},
|
| 1595 |
+
"vue-router": {
|
| 1596 |
+
"version": "4.6.4",
|
| 1597 |
+
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz",
|
| 1598 |
+
"integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==",
|
| 1599 |
+
"requires": {
|
| 1600 |
+
"@vue/devtools-api": "^6.6.4"
|
| 1601 |
+
},
|
| 1602 |
+
"dependencies": {
|
| 1603 |
+
"@vue/devtools-api": {
|
| 1604 |
+
"version": "6.6.4",
|
| 1605 |
+
"resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz",
|
| 1606 |
+
"integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g=="
|
| 1607 |
+
}
|
| 1608 |
+
}
|
| 1609 |
+
},
|
| 1610 |
+
"vue-virtual-scroller": {
|
| 1611 |
+
"version": "2.0.0-beta.8",
|
| 1612 |
+
"resolved": "https://registry.npmjs.org/vue-virtual-scroller/-/vue-virtual-scroller-2.0.0-beta.8.tgz",
|
| 1613 |
+
"integrity": "sha512-b8/f5NQ5nIEBRTNi6GcPItE4s7kxNHw2AIHLtDp+2QvqdTjVN0FgONwX9cr53jWRgnu+HRLPaWDOR2JPI5MTfQ==",
|
| 1614 |
+
"requires": {
|
| 1615 |
+
"mitt": "^2.1.0",
|
| 1616 |
+
"vue-observe-visibility": "^2.0.0-alpha.1",
|
| 1617 |
+
"vue-resize": "^2.0.0-alpha.1"
|
| 1618 |
+
},
|
| 1619 |
+
"dependencies": {
|
| 1620 |
+
"mitt": {
|
| 1621 |
+
"version": "2.1.0",
|
| 1622 |
+
"resolved": "https://registry.npmjs.org/mitt/-/mitt-2.1.0.tgz",
|
| 1623 |
+
"integrity": "sha512-ILj2TpLiysu2wkBbWjAmww7TkZb65aiQO+DkVdUTBpBXq+MHYiETENkKFMtsJZX1Lf4pe4QOrTSjIfUwN5lRdg=="
|
| 1624 |
+
}
|
| 1625 |
+
}
|
| 1626 |
+
}
|
| 1627 |
+
}
|
| 1628 |
+
}
|
frontend/package.json
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "gemini-business2api",
|
| 3 |
+
"private": true,
|
| 4 |
+
"version": "0.0.0",
|
| 5 |
+
"type": "module",
|
| 6 |
+
"scripts": {
|
| 7 |
+
"dev": "vite",
|
| 8 |
+
"build": "tsc && vite build",
|
| 9 |
+
"preview": "vite preview"
|
| 10 |
+
},
|
| 11 |
+
"devDependencies": {
|
| 12 |
+
"@iconify/vue": "^5.0.0",
|
| 13 |
+
"autoprefixer": "^10.4.23",
|
| 14 |
+
"class-variance-authority": "^0.7.1",
|
| 15 |
+
"clsx": "^2.1.1",
|
| 16 |
+
"postcss": "^8.5.6",
|
| 17 |
+
"tailwind-merge": "^3.4.0",
|
| 18 |
+
"tailwindcss": "^3.4.19",
|
| 19 |
+
"typescript": "~5.9.3",
|
| 20 |
+
"vite": "^7.2.4"
|
| 21 |
+
},
|
| 22 |
+
"dependencies": {
|
| 23 |
+
"@vitejs/plugin-vue": "^6.0.3",
|
| 24 |
+
"axios": "^1.13.2",
|
| 25 |
+
"pinia": "^3.0.4",
|
| 26 |
+
"vue": "^3.5.26",
|
| 27 |
+
"vue-router": "^4.6.4",
|
| 28 |
+
"vue-virtual-scroller": "^2.0.0-beta.8"
|
| 29 |
+
}
|
| 30 |
+
}
|
frontend/postcss.config.js
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export default {
|
| 2 |
+
plugins: {
|
| 3 |
+
tailwindcss: {},
|
| 4 |
+
autoprefixer: {},
|
| 5 |
+
},
|
| 6 |
+
}
|
frontend/public/logo.svg
ADDED
|
|
frontend/public/vendor/echarts/echarts.min.js
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
frontend/src/App.vue
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<template>
|
| 2 |
+
<RouterView />
|
| 3 |
+
<Toast />
|
| 4 |
+
</template>
|
| 5 |
+
|
| 6 |
+
<script setup lang="ts">
|
| 7 |
+
import { RouterView } from 'vue-router'
|
| 8 |
+
import Toast from '@/components/ui/Toast.vue'
|
| 9 |
+
</script>
|