lijunke commited on
Commit
18081cf
·
0 Parent(s):

deploy: clean start with hf metadata

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
.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
+ [![Star History Chart](https://api.star-history.com/svg?repos=Dreamy-rain/gemini-business2api&type=date&legend=top-left)](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
+ [![Star History Chart](https://api.star-history.com/svg?repos=Dreamy-rain/gemini-business2api&type=date&legend=top-left)](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>