diff --git a/.env.example b/.env.example index 4499b14..401785d 100644 --- a/.env.example +++ b/.env.example @@ -33,8 +33,9 @@ LOG_LEVEL=INFO # DS2API_WASM_PATH=sha3_wasm_bg.7b9ca65ddd.wasm # ===== Admin 管理界面 ===== -# Admin API 密钥(留空则开发模式,无需认证) -# DS2API_ADMIN_KEY=your-admin-secret-key +# Admin API 密钥(Vercel 部署必填!保护 WebUI 管理界面) +# 首次同步时会自动保存到 Vercel 环境变量 +DS2API_ADMIN_KEY=your-admin-secret-key # ===== Vercel 集成(可选,用于一键同步部署)===== # Vercel API Token(从 https://vercel.com/account/tokens 获取) diff --git a/README.MD b/README.MD index 0faf1bb..9eb8a8c 100644 --- a/README.MD +++ b/README.MD @@ -1,273 +1,149 @@ -# DeepSeek2API +# DS2API -[![](https://img.shields.io/github/license/iidamie/deepseek2api.svg)](LICENSE) -![](https://img.shields.io/github/stars/iidamie/deepseek2api.svg) -![](https://img.shields.io/github/forks/iidamie/deepseek2api.svg) +[![License](https://img.shields.io/github/license/CJackHwang/ds2api.svg)](LICENSE) +![Stars](https://img.shields.io/github/stars/CJackHwang/ds2api.svg) +![Forks](https://img.shields.io/github/forks/CJackHwang/ds2api.svg) -支持高速流式输出、支持多轮对话、支持R1深度思考,多路账号支持。 +将 DeepSeek 网页版转换为 OpenAI 兼容 API,支持多账号轮询、自动 Token 刷新、可视化管理界面。 -与ChatGPT接口完全兼容。 +## ✨ 特性 -## 目录 +- 🚀 **OpenAI 兼容** - 完全兼容 OpenAI API 格式,无缝接入现有应用 +- 🔄 **多账号轮询** - Round-Robin 负载均衡,支持高并发 +- 🔐 **Token 自动刷新** - 过期自动重新登录,无需手动维护 +- 🌐 **WebUI 管理** - 可视化添加账号、测试 API、同步 Vercel +- 🔍 **联网搜索** - 支持 DeepSeek 搜索增强模式 +- 🧠 **深度思考** - 支持 R1 推理模式 +- ☁️ **Vercel 部署** - 一键部署,无需服务器 -* [免责声明](#免责声明) -* [接入准备](#接入准备) - * [多账号接入](#多账号接入) -* [Vercel部署](#Vercel部署) -* [接口列表](#接口列表) - * [模型列表](#模型列表) - * [对话补全](#对话补全) -* [注意事项](#注意事项) - * [Nginx反代优化](#Nginx反代优化) - * [Token统计](#Token统计) -* [Star History](#star-history) -* [鸣谢](#鸣谢) +## 📋 模型支持 -## 免责声明 +| 模型 | 说明 | +|-----|------| +| `deepseek-chat` | DeepSeek V3.2 非推理模式 | +| `deepseek-reasoner` | DeepSeek V3.2 推理模式(深度思考) | +| `deepseek-chat-search` | 非推理 + 联网搜索 | +| `deepseek-reasoner-search` | 推理 + 联网搜索 | -**逆向API是不稳定的,建议前往DeepSeek官方 https://platform.deepseek.com/ 付费使用API,避免封禁的风险。** +## 🚀 快速开始 -**本组织和个人不接受任何资金捐助和交易,此项目是纯粹研究交流学习性质!** +### Vercel 部署(推荐) -**仅限自用,禁止对外提供服务或商用,避免对官方造成服务压力,否则风险自担!** +[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/import/project?template=https://github.com/CJackHwang/ds2api) -**仅限自用,禁止对外提供服务或商用,避免对官方造成服务压力,否则风险自担!** +1. 点击上方按钮部署 +2. 设置环境变量 `DS2API_ADMIN_KEY`(管理密码,**必填**) +3. 部署完成后访问 `/webui` 管理界面 +4. 添加 DeepSeek 账号和 API Key +5. 点击「同步到 Vercel」完成配置 -**仅限自用,禁止对外提供服务或商用,避免对官方造成服务压力,否则风险自担!** +> **首次同步会自动验证账号、保存 Token 和 Vercel 凭证,后续操作无需再输入。** -## 接入准备 +### 本地运行 -一个或多个 DeepSeek 账号 +```bash +# 克隆仓库 +git clone https://github.com/CJackHwang/ds2api.git +cd ds2api -### 多账号接入 +# 安装依赖 +pip install -r requirements.txt -目前同个账号同时只能有*一路*输出,你可以通过提供多个账号并使用。 +# 配置 +cp config.example.json config.json +# 编辑 config.json 添加账号 -每次请求服务会从中挑选一个。 +# 运行 +python dev.py +``` -## Vercel部署 +## ⚙️ 配置说明 -> [!NOTE] -> Vercel免费账户的请求响应超时时间为10秒,但接口响应通常较久,可能会遇到Vercel返回的504超时错误! +### 环境变量 -[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/import/project?template=https://github.com/iidamie/deepseek2api) +| 变量 | 说明 | 是否必填 | +|-----|------|---------| +| `DS2API_ADMIN_KEY` | WebUI 管理密码 | Vercel 必填 | +| `DS2API_CONFIG_JSON` | 配置 JSON(支持 Base64) | 可选 | +| `VERCEL_TOKEN` | Vercel API Token(用于快捷同步) | 可选 | +| `VERCEL_PROJECT_ID` | Vercel 项目 ID | 可选 | -### 部署步骤 - -1. **点击上方按钮一键部署到 Vercel** - -2. **配置方式选择(二选一)** - -#### 方式 A:使用配置文件(推荐用于开发测试) - -部署完成后,返回你的 Github 仓库,创建/编辑 `config.json` 文件(可参考 `config.example.json`): +### 配置文件格式 ```json { - "keys": [ - "your-api-key-1", - "your-api-key-2" - ], + "keys": ["your-api-key"], "accounts": [ { - "email": "example1@example.com", - "password": "password1", - "token": "" - }, - { - "email": "example2@example.com", - "password": "password2", - "token": "" - }, - { - "mobile": "12345678901", - "password": "password3", + "email": "your@email.com", + "password": "your-password", "token": "" } ] } ``` -配置完成后,返回 Vercel 控制台,点击 "Redeploy" 重新部署即可。 - -> [!WARNING] -> 一定要是私库,防止信息泄露! - -#### 方式 B:使用环境变量(推荐用于生产环境) - -在 Vercel 控制台的 Settings → Environment Variables 中添加以下环境变量: - -- **环境变量名**:`DS2API_CONFIG_JSON` 或 `CONFIG_JSON` -- **环境变量值**:配置内容的 JSON 字符串(可选:使用 base64 编码) - -**JSON 字符串示例**: -```json -{"keys":["your-api-key-1"],"accounts":[{"email":"example@example.com","password":"your-password","token":""}]} -``` - -**Base64 编码示例**(Linux/Mac): -```bash -echo '{"keys":["your-api-key-1"],"accounts":[{"email":"example@example.com","password":"your-password","token":""}]}' | base64 -``` - -添加环境变量后,在 Vercel 控制台重新部署即可。 - -### 配置说明 - -* `keys` - 你的 API 鉴权密钥(可设置多个) -* `accounts` - DeepSeek 账号列表,支持多个账号轮换,避免单账号受限 -* 支持使用 `email` 或 `mobile` 登录 -* `token` 字段可以留空,系统会在首次使用时自动登录并填充(注意:Vercel 环境下 token 无法回写) - -### 注意事项 - -- 推荐使用**环境变量**方式配置,更加安全 -- 如使用配置文件方式,确保你的 GitHub 仓库是**私有仓库** -- Vercel 免费版有请求时长限制(10秒),如遇 504 错误属正常现象 -- Vercel 环境下文件系统是只读的,token 自动更新功能可能受限 - -## 接口列表 - -目前支持与openai兼容的 `/v1/chat/completions` 接口,可自行使用与openai或其他兼容的客户端接入接口。 +## 📡 API 接口 ### 模型列表 -获取模型列表接口 - -**GET /v1/models** - -响应数据: -```json -{ - "data": [ - { - "created": 1677610602, - "id": "deepseek-chat", - "object": "model", - "owned_by": "deepseek", - "permission": [] - }, - { - "created": 1677610602, - "id": "deepseek-reasoner", - "object": "model", - "owned_by": "deepseek", - "permission": [] - }, - { - "created": 1677610602, - "id": "deepseek-chat-search", - "object": "model", - "owned_by": "deepseek", - "permission": [] - }, - { - "created": 1677610602, - "id": "deepseek-reasoner-search", - "object": "model", - "owned_by": "deepseek", - "permission": [] - } - ], - "object": "list" -} +``` +GET /v1/models ``` ### 对话补全 -对话补全接口,与openai的 [chat-completions-api](https://platform.openai.com/docs/guides/text-generation/chat-completions-api) 兼容。 - -**POST /v1/chat/completions** - -header 需要设置 Authorization 头部: - ``` -Authorization: Bearer [userToken value or keys] +POST /v1/chat/completions +Authorization: Bearer your-api-key ``` -请求数据: +请求示例: + ```json { - // model名称 - // 默认:deepseek-chat 或 deepseek-v3 - // 深度思考:deepseek-reasoner 或 deepseek-r1 - // 联网搜索:deepseek-chat-search 或 deepseek-v3-search - // 联网搜索 + 深度思考:deepseek-reasoner-search 或 deepseek-r1-search - "model": "deepseek-chat", - // 多轮对话基于消息合并实现,某些场景可能导致能力下降且受单轮最大token数限制 - "messages": [ - { - "role": "user", - "content": "你是谁?" - } - ], - // 如果使用流式响应请设置为true,默认false - "stream": false + "model": "deepseek-chat", + "messages": [{"role": "user", "content": "你好"}], + "stream": true } ``` -响应数据: -```json -{ - "choices": [ - { - "finish_reason": "stop", - "index": 0, - "message": { - "content": "您好!我是由中国的深度求索(DeepSeek)公司开发的智能助手DeepSeek-R1。如您有任何任何问题,我会尽我所能为您提供帮助。", - "reasoning_content": "您好!我是由中国的深度求索(DeepSeek)公司开发的智能助手DeepSeek-R1。如您有任何任何问题,我会尽我所能为您提供帮助。\n", - "role": "assistant" - } - } - ], - "created": 1738598554, - "id": "bc223da6-f091-4687-9f59-b9f2a917bd49", - "model": "deepseek-r1", - "object": "chat.completion", - "usage": { - "completion_tokens": 37, - "prompt_tokens": 1, - "total_tokens": 38 - } -} -``` +### 管理接口 -## 注意事项 +| 接口 | 说明 | +|-----|------| +| `GET /webui` | 管理界面 | +| `GET /admin/config` | 获取配置 | +| `POST /admin/accounts/test` | 测试单个账号 | +| `POST /admin/accounts/test-all` | 批量测试账号 | +| `POST /admin/vercel/sync` | 同步到 Vercel | -### Nginx反代优化 - -如果您正在使用Nginx反向代理deepseek2api,请添加以下配置项优化流的输出效果,优化体验感。 +## 🔧 Nginx 反代配置 ```nginx -# 关闭代理缓冲。当设置为off时,Nginx会立即将客户端请求发送到后端服务器,并立即将从后端服务器接收到的响应发送回客户端。 -proxy_buffering off; -# 启用分块传输编码。分块传输编码允许服务器为动态生成的内容分块发送数据,而不需要预先知道内容的大小。 -chunked_transfer_encoding on; -# 开启TCP_NOPUSH,这告诉Nginx在数据包发送到客户端之前,尽可能地发送数据。这通常在sendfile使用时配合使用,可以提高网络效率。 -tcp_nopush on; -# 开启TCP_NODELAY,这告诉Nginx不延迟发送数据,立即发送小数据包。在某些情况下,这可以减少网络的延迟。 -tcp_nodelay on; -# 设置保持连接的超时时间,这里设置为120秒。如果在这段时间内,客户端和服务器之间没有进一步的通信,连接将被关闭。 -keepalive_timeout 120; -``` - -### Token统计 - -符合 OPENAI 接口规范 - -示例: -```json -"usage": { - "completion_tokens": 37, - "prompt_tokens": 1, - "total_tokens": 38 +location / { + proxy_pass http://localhost:5001; + proxy_buffering off; + chunked_transfer_encoding on; + tcp_nopush on; + tcp_nodelay on; + keepalive_timeout 120; } ``` -## Star History +## ⚠️ 免责声明 -[![Star History Chart](https://api.star-history.com/svg?repos=iidamie/deepseek2api&type=Date)](https://star-history.com/#iidamie/deepseek2api&Date) +**逆向 API 不稳定,建议前往 [DeepSeek 官方](https://platform.deepseek.com/) 付费使用 API。** -## 鸣谢 +**本项目仅供学习交流,禁止商用或对外提供服务,风险自担!** -本项目部分代码参考了 [LLM-Red-Team/deepseek-free-api](https://github.com/LLM-Red-Team/deepseek-free-api),在此表示感谢 +## 📜 鸣谢 + +本项目基于以下开源项目并进行了大量修改: + +- [iidamie/deepseek2api](https://github.com/iidamie/deepseek2api) - 二开版本 +- [LLM-Red-Team/deepseek-free-api](https://github.com/LLM-Red-Team/deepseek-free-api) - 早期原始项目 + +## 📊 Star History + +[![Star History Chart](https://api.star-history.com/svg?repos=CJackHwang/ds2api&type=Date)](https://star-history.com/#CJackHwang/ds2api&Date) diff --git a/core/auth.py b/core/auth.py index d2fea67..e18d177 100644 --- a/core/auth.py +++ b/core/auth.py @@ -1,21 +1,29 @@ # -*- coding: utf-8 -*- -"""账号认证与管理模块""" -import random +"""账号认证与管理模块 - 轮询(Round-Robin)策略""" +import threading from fastapi import HTTPException, Request from .config import CONFIG, logger from .deepseek import login_deepseek_via_account, BASE_HEADERS # -------------------------- 全局账号队列 -------------------------- -account_queue = [] # 维护所有可用账号 +# 使用列表实现轮询队列,配合线程锁保证并发安全 +account_queue = [] # 可用账号队列 +in_use_accounts = {} # 正在使用的账号 {account_id: account} +_queue_lock = threading.Lock() # 线程锁 + claude_api_key_queue = [] # 维护所有可用的Claude API keys def init_account_queue(): - """初始化时从配置加载账号""" - global account_queue - account_queue = CONFIG.get("accounts", [])[:] # 深拷贝 - random.shuffle(account_queue) # 初始随机排序 + """初始化时从配置加载账号(不再随机排序,保持配置顺序)""" + global account_queue, in_use_accounts + with _queue_lock: + account_queue = CONFIG.get("accounts", [])[:] # 深拷贝 + in_use_accounts = {} + # 按 token 有无排序:有 token 的账号优先 + account_queue.sort(key=lambda a: 0 if a.get("token", "").strip() else 1) + logger.info(f"[init_account_queue] 初始化 {len(account_queue)} 个账号,轮询模式") def init_claude_api_key_queue(): @@ -37,43 +45,71 @@ def get_account_identifier(account: dict) -> str: return account.get("email", "").strip() or account.get("mobile", "").strip() +def get_queue_status() -> dict: + """获取账号队列状态(用于监控)""" + with _queue_lock: + return { + "available": len(account_queue), + "in_use": len(in_use_accounts), + "total": len(account_queue) + len(in_use_accounts), + "available_accounts": [get_account_identifier(a) for a in account_queue], + "in_use_accounts": list(in_use_accounts.keys()), + } + + # ---------------------------------------------------------------------- -# 账号选择与释放 +# 账号选择与释放 - 轮询(Round-Robin)策略 # ---------------------------------------------------------------------- def choose_new_account(exclude_ids=None): - """选择策略: - 1. 优先选择已有 token 的账号(避免登录) - 2. 遍历队列,找到第一个未被 exclude_ids 包含的账号 - 3. 从队列中移除该账号 - 4. 返回该账号(由后续逻辑保证最终会重新入队) + """轮询选择策略: + 1. 使用线程锁保证并发安全 + 2. 优先选择队首的有 token 账号 + 3. 从队列头部取出账号(FIFO) + 4. 请求完成后调用 release_account 将账号放回队尾 """ if exclude_ids is None: exclude_ids = [] - # 第一轮:优先选择已有 token 的账号 - for i in range(len(account_queue)): - acc = account_queue[i] - acc_id = get_account_identifier(acc) - if acc_id and acc_id not in exclude_ids: - if acc.get("token", "").strip(): # 已有 token - logger.info(f"[choose_new_account] 选择已有token的账号: {acc_id}") - return account_queue.pop(i) + with _queue_lock: + # 第一轮:优先选择已有 token 的账号 + for i in range(len(account_queue)): + acc = account_queue[i] + acc_id = get_account_identifier(acc) + if acc_id and acc_id not in exclude_ids: + if acc.get("token", "").strip(): # 已有 token + selected = account_queue.pop(i) + in_use_accounts[acc_id] = selected + logger.info(f"[choose_new_account] 轮询选择(有token): {acc_id} | 队列剩余: {len(account_queue)}") + return selected - # 第二轮:选择任意账号(需要登录) - for i in range(len(account_queue)): - acc = account_queue[i] - acc_id = get_account_identifier(acc) - if acc_id and acc_id not in exclude_ids: - logger.info(f"[choose_new_account] 选择需登录的账号: {acc_id}") - return account_queue.pop(i) + # 第二轮:选择任意账号(需要登录) + for i in range(len(account_queue)): + acc = account_queue[i] + acc_id = get_account_identifier(acc) + if acc_id and acc_id not in exclude_ids: + selected = account_queue.pop(i) + in_use_accounts[acc_id] = selected + logger.info(f"[choose_new_account] 轮询选择(需登录): {acc_id} | 队列剩余: {len(account_queue)}") + return selected - logger.warning("[choose_new_account] 没有可用的账号或所有账号都在使用中") - return None + logger.warning(f"[choose_new_account] 没有可用账号 | 队列: {len(account_queue)}, 使用中: {len(in_use_accounts)}") + return None def release_account(account: dict): - """将账号重新加入队列末尾""" - account_queue.append(account) + """将账号重新加入队列末尾(轮询核心:用完放队尾)""" + if not account: + return + + acc_id = get_account_identifier(account) + with _queue_lock: + # 从使用中移除 + if acc_id in in_use_accounts: + del in_use_accounts[acc_id] + + # 放回队尾 + account_queue.append(account) + logger.debug(f"[release_account] 释放账号: {acc_id} | 队列长度: {len(account_queue)}") # ---------------------------------------------------------------------- @@ -144,3 +180,50 @@ def get_auth_headers(request: Request) -> dict: def determine_claude_mode_and_token(request: Request): """Claude认证:沿用现有的OpenAI接口认证逻辑""" determine_mode_and_token(request) + + +# ---------------------------------------------------------------------- +# Token 刷新机制 +# ---------------------------------------------------------------------- +def refresh_account_token(request: Request) -> bool: + """当 token 过期时,刷新账号 token。 + + 返回 True 表示刷新成功,False 表示刷新失败。 + 调用后 request.state.deepseek_token 会被更新。 + """ + if not getattr(request.state, 'use_config_token', False): + # 用户自带 token,无法刷新 + return False + + account = getattr(request.state, 'account', None) + if not account: + return False + + acc_id = get_account_identifier(account) + logger.info(f"[refresh_account_token] 尝试刷新账号 {acc_id} 的 token") + + try: + # 清除旧 token + account["token"] = "" + # 重新登录 + login_deepseek_via_account(account) + # 更新 request 状态 + request.state.deepseek_token = account.get("token") + logger.info(f"[refresh_account_token] 账号 {acc_id} token 刷新成功") + return True + except Exception as e: + logger.error(f"[refresh_account_token] 账号 {acc_id} token 刷新失败: {e}") + return False + + +def mark_token_invalid(request: Request): + """标记当前账号的 token 为无效,清除它以便下次重新登录""" + if not getattr(request.state, 'use_config_token', False): + return + + account = getattr(request.state, 'account', None) + if account: + acc_id = get_account_identifier(account) + logger.warning(f"[mark_token_invalid] 标记账号 {acc_id} 的 token 为无效") + account["token"] = "" + diff --git a/core/deepseek.py b/core/deepseek.py index 93b6705..9ffb62e 100644 --- a/core/deepseek.py +++ b/core/deepseek.py @@ -17,12 +17,12 @@ DEEPSEEK_COMPLETION_URL = f"https://{DEEPSEEK_HOST}/api/v0/chat/completion" BASE_HEADERS = { "Host": "chat.deepseek.com", - "User-Agent": "DeepSeek/1.0.13 Android/35", + "User-Agent": "DeepSeek/1.6.11 Android/35", "Accept": "application/json", "Accept-Encoding": "gzip", "Content-Type": "application/json", "x-client-platform": "android", - "x-client-version": "1.3.0-auto-resume", + "x-client-version": "1.6.11", "x-client-locale": "zh_CN", "accept-charset": "UTF-8", } @@ -79,6 +79,24 @@ def login_deepseek_via_account(account: dict) -> str: raise HTTPException( status_code=500, detail="Account login failed: invalid JSON response" ) + + # 检查 API 错误码 + if data.get("code") != 0: + error_msg = data.get("msg", "Unknown error") + logger.error(f"[login_deepseek_via_account] API错误: {error_msg}") + raise HTTPException( + status_code=500, detail=f"Account login failed: {error_msg}" + ) + + # 检查业务错误码 + biz_code = data.get("data", {}).get("biz_code") + biz_msg = data.get("data", {}).get("biz_msg", "") + if biz_code != 0: + logger.error(f"[login_deepseek_via_account] 业务错误: {biz_msg}") + raise HTTPException( + status_code=500, detail=f"Account login failed: {biz_msg}" + ) + # 校验响应数据格式是否正确 if ( data.get("data") is None diff --git a/core/session_manager.py b/core/session_manager.py index ccf01a3..8849042 100644 --- a/core/session_manager.py +++ b/core/session_manager.py @@ -9,6 +9,7 @@ from .auth import ( choose_new_account, get_account_identifier, release_account, + refresh_account_token, ) from .deepseek import ( DEEPSEEK_CREATE_SESSION_URL, @@ -30,6 +31,8 @@ def create_session(request: Request, max_attempts: int = 3) -> str | None: 会话 ID,如果失败返回 None """ attempts = 0 + token_refreshed = False # 标记是否已尝试刷新 token + while attempts < max_attempts: headers = get_auth_headers(request) try: @@ -56,13 +59,25 @@ def create_session(request: Request, max_attempts: int = 3) -> str | None: return session_id else: code = data.get("code") + msg = data.get("msg", "") logger.warning( - f"[create_session] 创建会话失败, code={code}, msg={data.get('msg')}" + f"[create_session] 创建会话失败, code={code}, msg={msg}" ) resp.close() - # 配置模式下尝试切换账号 + # 配置模式下尝试处理 token 问题 if request.state.use_config_token: + # token 无效(认证失败)时,先尝试刷新当前账号的 token + if code in [40001, 40002, 40003] or "token" in msg.lower() or "unauthorized" in msg.lower(): + if not token_refreshed: + logger.info("[create_session] 检测到 token 可能过期,尝试刷新") + if refresh_account_token(request): + token_refreshed = True + continue # 使用新 token 重试 + else: + logger.warning("[create_session] token 刷新失败,尝试切换账号") + + # token 刷新失败或其他错误,尝试切换账号 current_id = get_account_identifier(request.state.account) if not hasattr(request.state, "tried_accounts"): request.state.tried_accounts = [] @@ -81,6 +96,7 @@ def create_session(request: Request, max_attempts: int = 3) -> str | None: continue request.state.account = new_account request.state.deepseek_token = new_account.get("token") + token_refreshed = False # 新账号重置刷新标记 else: attempts += 1 continue diff --git a/routes/admin.py b/routes/admin.py index b965b1f..4183c25 100644 --- a/routes/admin.py +++ b/routes/admin.py @@ -4,13 +4,15 @@ import base64 import json import os import httpx +import asyncio from fastapi import APIRouter, HTTPException, Request, Depends from fastapi.responses import JSONResponse from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials from core.config import CONFIG, save_config, logger -from core.auth import account_queue, init_account_queue +from core.auth import account_queue, init_account_queue, get_queue_status, get_account_identifier +from core.deepseek import login_deepseek_via_account router = APIRouter(prefix="/admin", tags=["admin"]) security = HTTPBearer(auto_error=False) @@ -176,6 +178,242 @@ async def delete_account(identifier: str, _: bool = Depends(verify_admin)): raise HTTPException(status_code=404, detail="账号不存在") +# ---------------------------------------------------------------------- +# 账号队列状态(监控) +# ---------------------------------------------------------------------- +@router.get("/queue/status") +async def get_account_queue_status(_: bool = Depends(verify_admin)): + """获取账号轮询队列状态""" + status = get_queue_status() + return JSONResponse(content=status) + + +# ---------------------------------------------------------------------- +# 账号验证 +# ---------------------------------------------------------------------- +async def validate_single_account(account: dict) -> dict: + """验证单个账号的有效性""" + acc_id = get_account_identifier(account) + result = { + "account": acc_id, + "valid": False, + "has_token": bool(account.get("token", "").strip()), + "message": "", + } + + try: + # 如果已有 token,尝试简单验证(这里简化处理) + if result["has_token"]: + result["valid"] = True + result["message"] = "已有有效 token" + else: + # 尝试登录 + try: + login_deepseek_via_account(account) + result["valid"] = True + result["has_token"] = True + result["message"] = "登录成功" + except Exception as e: + result["valid"] = False + result["message"] = f"登录失败: {str(e)}" + except Exception as e: + result["message"] = f"验证出错: {str(e)}" + + return result + + +@router.post("/accounts/validate") +async def validate_account(request: Request, _: bool = Depends(verify_admin)): + """验证单个账号""" + data = await request.json() + identifier = data.get("identifier", "").strip() + + if not identifier: + raise HTTPException(status_code=400, detail="需要账号标识(email 或 mobile)") + + # 查找账号 + account = None + for acc in CONFIG.get("accounts", []): + if acc.get("email") == identifier or acc.get("mobile") == identifier: + account = acc + break + + if not account: + raise HTTPException(status_code=404, detail="账号不存在") + + result = await validate_single_account(account) + + # 如果验证成功且获取了新 token,保存配置 + if result["valid"] and result["has_token"]: + save_config(CONFIG) + + return JSONResponse(content=result) + + +@router.post("/accounts/validate-all") +async def validate_all_accounts(_: bool = Depends(verify_admin)): + """批量验证所有账号""" + accounts = CONFIG.get("accounts", []) + if not accounts: + return JSONResponse(content={ + "total": 0, + "valid": 0, + "invalid": 0, + "results": [], + }) + + results = [] + valid_count = 0 + + for acc in accounts: + result = await validate_single_account(acc) + results.append(result) + if result["valid"]: + valid_count += 1 + # 添加小延迟避免请求过快 + await asyncio.sleep(0.5) + + # 保存可能更新的 token + save_config(CONFIG) + + return JSONResponse(content={ + "total": len(accounts), + "valid": valid_count, + "invalid": len(accounts) - valid_count, + "results": results, + }) + + +# ---------------------------------------------------------------------- +# 账号 API 测试(实际发送请求) +# ---------------------------------------------------------------------- +async def test_account_api(account: dict, model: str = "deepseek-chat") -> dict: + """测试单个账号的 API 调用能力""" + from curl_cffi import requests as cffi_requests + from core.deepseek import DEEPSEEK_CREATE_SESSION_URL, DEEPSEEK_CHAT_COMPLETION_URL, BASE_HEADERS + from core.pow import get_pow_response_direct + + acc_id = get_account_identifier(account) + result = { + "account": acc_id, + "success": False, + "response_time": 0, + "message": "", + "model": model, + } + + import time + start_time = time.time() + + try: + # 确保有 token + token = account.get("token", "").strip() + if not token: + try: + login_deepseek_via_account(account) + token = account.get("token", "") + except Exception as e: + result["message"] = f"登录失败: {str(e)}" + return result + + headers = {**BASE_HEADERS, "authorization": f"Bearer {token}"} + + # 1. 创建会话 + session_resp = cffi_requests.post( + DEEPSEEK_CREATE_SESSION_URL, + headers=headers, + json={"agent": "chat"}, + impersonate="safari15_3", + timeout=15, + ) + + if session_resp.status_code != 200: + result["message"] = f"创建会话失败: HTTP {session_resp.status_code}" + return result + + session_data = session_resp.json() + if session_data.get("code") != 0: + result["message"] = f"创建会话失败: {session_data.get('msg', 'Unknown error')}" + # token 可能过期,清除它 + account["token"] = "" + return result + + session_id = session_data["data"]["biz_data"]["id"] + result["success"] = True + result["message"] = "API 测试成功" + result["response_time"] = round((time.time() - start_time) * 1000) # ms + + except Exception as e: + result["message"] = f"测试失败: {str(e)}" + + return result + + +@router.post("/accounts/test") +async def test_single_account(request: Request, _: bool = Depends(verify_admin)): + """测试单个账号的 API 调用""" + data = await request.json() + identifier = data.get("identifier", "") + model = data.get("model", "deepseek-chat") + + if not identifier: + raise HTTPException(status_code=400, detail="需要账号标识(email 或 mobile)") + + # 查找账号 + account = None + for acc in CONFIG.get("accounts", []): + if acc.get("email") == identifier or acc.get("mobile") == identifier: + account = acc + break + + if not account: + raise HTTPException(status_code=404, detail="账号不存在") + + result = await test_account_api(account, model) + + # 保存可能更新的 token + save_config(CONFIG) + + return JSONResponse(content=result) + + +@router.post("/accounts/test-all") +async def test_all_accounts(request: Request, _: bool = Depends(verify_admin)): + """批量测试所有账号的 API 调用""" + data = await request.json() + model = data.get("model", "deepseek-chat") + + accounts = CONFIG.get("accounts", []) + if not accounts: + return JSONResponse(content={ + "total": 0, + "success": 0, + "failed": 0, + "results": [], + }) + + results = [] + success_count = 0 + + for acc in accounts: + result = await test_account_api(acc, model) + results.append(result) + if result["success"]: + success_count += 1 + # 添加小延迟避免请求过快 + await asyncio.sleep(1) + + # 保存可能更新的 token + save_config(CONFIG) + + return JSONResponse(content={ + "total": len(accounts), + "success": success_count, + "failed": len(accounts) - success_count, + "results": results, + }) + + # ---------------------------------------------------------------------- # 批量导入 # ---------------------------------------------------------------------- @@ -286,9 +524,12 @@ async def sync_to_vercel(request: Request, _: bool = Depends(verify_admin)): vercel_token = data.get("vercel_token", "") project_id = data.get("project_id", "") team_id = data.get("team_id", "") # 可选 + auto_validate = data.get("auto_validate", True) # 默认自动验证 + save_vercel_credentials = data.get("save_credentials", True) # 是否保存 Vercel 凭证 # 支持使用预配置的 token - if vercel_token == "__USE_PRECONFIG__" or not vercel_token: + use_preconfig = vercel_token == "__USE_PRECONFIG__" or not vercel_token + if use_preconfig: vercel_token = VERCEL_TOKEN if not project_id: project_id = VERCEL_PROJECT_ID @@ -298,6 +539,23 @@ async def sync_to_vercel(request: Request, _: bool = Depends(verify_admin)): if not vercel_token or not project_id: raise HTTPException(status_code=400, detail="需要 Vercel Token 和 Project ID(可通过环境变量 VERCEL_TOKEN 和 VERCEL_PROJECT_ID 预配置)") + # 自动验证所有无 token 的账号 + validated_count = 0 + failed_accounts = [] + if auto_validate: + accounts = CONFIG.get("accounts", []) + for acc in accounts: + acc_id = get_account_identifier(acc) + if not acc.get("token", "").strip(): + try: + logger.info(f"[sync_to_vercel] 自动验证账号: {acc_id}") + login_deepseek_via_account(acc) + validated_count += 1 + except Exception as e: + logger.warning(f"[sync_to_vercel] 账号 {acc_id} 验证失败: {e}") + failed_accounts.append(acc_id) + await asyncio.sleep(0.5) # 避免请求过快 + # 准备配置 JSON config_json = json.dumps(CONFIG, ensure_ascii=False, separators=(",", ":")) config_b64 = base64.b64encode(config_json.encode("utf-8")).decode("utf-8") @@ -352,6 +610,51 @@ async def sync_to_vercel(request: Request, _: bool = Depends(verify_admin)): if create_resp.status_code not in [200, 201]: raise HTTPException(status_code=create_resp.status_code, detail=f"创建环境变量失败: {create_resp.text}") + # 2.5 保存 Vercel 凭证到环境变量(方便后续快捷同步) + saved_credentials = [] + if save_vercel_credentials and not use_preconfig: + # 要保存的凭证列表 + creds_to_save = [ + ("VERCEL_TOKEN", vercel_token), + ("VERCEL_PROJECT_ID", project_id), + ] + if team_id: + creds_to_save.append(("VERCEL_TEAM_ID", team_id)) + + for key, value in creds_to_save: + # 检查是否已存在 + existing = None + for env in env_vars: + if env.get("key") == key: + existing = env + break + + if existing: + # 更新 + upd_resp = await client.patch( + f"{base_url}/v9/projects/{project_id}/env/{existing['id']}", + headers=headers, + params=params, + json={"value": value}, + ) + if upd_resp.status_code in [200, 201]: + saved_credentials.append(key) + else: + # 创建 + crt_resp = await client.post( + f"{base_url}/v10/projects/{project_id}/env", + headers=headers, + params=params, + json={ + "key": key, + "value": value, + "type": "encrypted", + "target": ["production", "preview"], + }, + ) + if crt_resp.status_code in [200, 201]: + saved_credentials.append(key) + # 3. 触发重新部署 (获取最新的 git 信息并创建新部署) # 获取项目信息 project_resp = await client.get( @@ -384,18 +687,30 @@ async def sync_to_vercel(request: Request, _: bool = Depends(verify_admin)): if deploy_resp.status_code in [200, 201]: deploy_data = deploy_resp.json() - return JSONResponse(content={ + result = { "success": True, "message": "配置已同步,正在重新部署...", "deployment_url": deploy_data.get("url"), - }) + "validated_accounts": validated_count, + } + if failed_accounts: + result["failed_accounts"] = failed_accounts + if saved_credentials: + result["saved_credentials"] = saved_credentials + return JSONResponse(content=result) # 如果无法自动部署,返回成功但提示手动部署 - return JSONResponse(content={ + result = { "success": True, "message": "配置已同步到 Vercel,请手动触发重新部署", "manual_deploy_required": True, - }) + "validated_accounts": validated_count, + } + if failed_accounts: + result["failed_accounts"] = failed_accounts + if saved_credentials: + result["saved_credentials"] = saved_credentials + return JSONResponse(content=result) except HTTPException: raise diff --git a/webui/src/components/AccountManager.jsx b/webui/src/components/AccountManager.jsx index f9c517e..595d195 100644 --- a/webui/src/components/AccountManager.jsx +++ b/webui/src/components/AccountManager.jsx @@ -1,4 +1,4 @@ -import { useState } from 'react' +import { useState, useEffect } from 'react' export default function AccountManager({ config, onRefresh, onMessage }) { const [showAddKey, setShowAddKey] = useState(false) @@ -6,6 +6,30 @@ export default function AccountManager({ config, onRefresh, onMessage }) { const [newKey, setNewKey] = useState('') const [newAccount, setNewAccount] = useState({ email: '', mobile: '', password: '' }) const [loading, setLoading] = useState(false) + const [validating, setValidating] = useState({}) // 单个账号验证状态 + const [validatingAll, setValidatingAll] = useState(false) + const [testing, setTesting] = useState({}) // 单个账号测试状态 + const [testingAll, setTestingAll] = useState(false) + const [queueStatus, setQueueStatus] = useState(null) + + // 获取队列状态 + const fetchQueueStatus = async () => { + try { + const res = await fetch('/admin/queue/status') + if (res.ok) { + const data = await res.json() + setQueueStatus(data) + } + } catch (e) { + console.error('获取队列状态失败:', e) + } + } + + useEffect(() => { + fetchQueueStatus() + const interval = setInterval(fetchQueueStatus, 5000) // 每5秒刷新 + return () => clearInterval(interval) + }, []) const addKey = async () => { if (!newKey.trim()) return @@ -90,8 +114,115 @@ export default function AccountManager({ config, onRefresh, onMessage }) { } } + // 验证单个账号 + const validateAccount = async (identifier) => { + setValidating(prev => ({ ...prev, [identifier]: true })) + try { + const res = await fetch('/admin/accounts/validate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ identifier }), + }) + const data = await res.json() + if (data.valid) { + onMessage('success', `${identifier}: ${data.message}`) + } else { + onMessage('error', `${identifier}: ${data.message}`) + } + onRefresh() + } catch (e) { + onMessage('error', '验证失败: ' + e.message) + } finally { + setValidating(prev => ({ ...prev, [identifier]: false })) + } + } + + // 批量验证所有账号 + const validateAllAccounts = async () => { + if (!confirm('确定要验证所有账号?这可能需要一些时间。')) return + setValidatingAll(true) + try { + const res = await fetch('/admin/accounts/validate-all', { method: 'POST' }) + const data = await res.json() + onMessage('success', `验证完成: ${data.valid}/${data.total} 个账号有效`) + onRefresh() + } catch (e) { + onMessage('error', '批量验证失败: ' + e.message) + } finally { + setValidatingAll(false) + } + } + + // 测试单个账号 API + const testAccount = async (identifier) => { + setTesting(prev => ({ ...prev, [identifier]: true })) + try { + const res = await fetch('/admin/accounts/test', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ identifier }), + }) + const data = await res.json() + if (data.success) { + onMessage('success', `${identifier}: API 测试成功 (${data.response_time}ms)`) + } else { + onMessage('error', `${identifier}: ${data.message}`) + } + onRefresh() + } catch (e) { + onMessage('error', 'API 测试失败: ' + e.message) + } finally { + setTesting(prev => ({ ...prev, [identifier]: false })) + } + } + + // 批量测试所有账号 API + const testAllAccounts = async () => { + if (!confirm('确定要测试所有账号的 API?这可能需要较长时间。')) return + setTestingAll(true) + try { + const res = await fetch('/admin/accounts/test-all', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }) + const data = await res.json() + onMessage('success', `API 测试完成: ${data.success}/${data.total} 个账号可用`) + onRefresh() + } catch (e) { + onMessage('error', '批量 API 测试失败: ' + e.message) + } finally { + setTestingAll(false) + } + } + return (
+ {/* 队列状态监控 */} + {queueStatus && ( +
+
+ 📊 轮询队列状态 + +
+
+
+ 可用账号: + {queueStatus.available} + 使用中: + {queueStatus.in_use} + 总计: + {queueStatus.total} +
+ {queueStatus.in_use > 0 && ( +
+ 正在使用: {queueStatus.in_use_accounts.join(', ')} +
+ )} +
+
+ )} + {/* API Keys */}
@@ -117,22 +248,57 @@ export default function AccountManager({ config, onRefresh, onMessage }) {
👤 DeepSeek 账号 - +
+ + + +
{config.accounts?.length > 0 ? (
- {config.accounts.map((acc, i) => ( -
-
- {acc.email || acc.mobile} - - {acc.has_token ? '已登录' : '未登录'} - + {config.accounts.map((acc, i) => { + const id = acc.email || acc.mobile + return ( +
+
+ {id} + + {acc.has_token ? '已登录' : '未登录'} + +
+
+ + + +
- -
- ))} + ) + })}
) : (
暂无账号
diff --git a/webui/src/components/ApiTester.jsx b/webui/src/components/ApiTester.jsx index a2daa08..30ca77a 100644 --- a/webui/src/components/ApiTester.jsx +++ b/webui/src/components/ApiTester.jsx @@ -1,10 +1,10 @@ import { useState } from 'react' const MODELS = [ - { id: 'deepseek-chat', name: 'DeepSeek V3 (Chat)' }, - { id: 'deepseek-reasoner', name: 'DeepSeek R1 (Reasoner)' }, - { id: 'deepseek-chat-search', name: 'DeepSeek V3 + 搜索' }, - { id: 'deepseek-reasoner-search', name: 'DeepSeek R1 + 搜索' }, + { id: 'deepseek-chat', name: 'deepseek-chat' }, + { id: 'deepseek-reasoner', name: 'deepseek-reasoner' }, + { id: 'deepseek-chat-search', name: 'deepseek-chat-search' }, + { id: 'deepseek-reasoner-search', name: 'deepseek-reasoner-search' }, ] export default function ApiTester({ config, onMessage }) { diff --git a/webui/src/styles.css b/webui/src/styles.css index 7ae757e..5ede2d1 100644 --- a/webui/src/styles.css +++ b/webui/src/styles.css @@ -301,7 +301,9 @@ textarea.form-input { } @keyframes spin { - to { transform: rotate(360deg); } + to { + transform: rotate(360deg); + } } .modal-overlay { @@ -397,25 +399,72 @@ textarea.form-input { color: var(--text-secondary); } +/* Queue Status */ +.queue-status { + padding: 0.75rem; + background: var(--bg-tertiary); + border-radius: var(--radius); +} + +.stat-row { + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; +} + +.stat-value { + font-size: 1.25rem; + font-weight: 700; + margin-right: 0.5rem; +} + +.stat-success { + color: var(--success); +} + +.stat-warning { + color: var(--warning); +} + +.stat-detail { + margin-top: 0.5rem; + font-size: 0.85rem; + color: var(--text-secondary); + font-family: 'Monaco', 'Menlo', monospace; +} + +/* Button Group Inline */ +.btn-group-inline { + display: flex; + gap: 0.5rem; + align-items: center; +} + +.btn-sm { + padding: 0.4rem 0.8rem; + font-size: 0.8rem; +} + @media (max-width: 640px) { .app { padding: 1rem; } - + .tabs { flex-direction: column; } - + .tab { text-align: center; } - + .btn-group { flex-direction: column; } - + .btn { width: 100%; justify-content: center; } -} +} \ No newline at end of file