diff --git a/.env.example b/.env.example new file mode 100644 index 0000000000000000000000000000000000000000..72667cd93949b13864248bc1302fc24f142fdfbc --- /dev/null +++ b/.env.example @@ -0,0 +1,90 @@ + +# 如果需要修改Docker暴露端口,请修改ports中的参数 +# 示例(8080:3000) 则访问 http://localhost:8080 +SERVICE_PORT=3000 + + +# 监听地址(非必填) +LISTEN_ADDRESS= + +# PM2 多进程配置 +# PM2进程数量配置 +# max: 使用所有CPU核心 +# 数字: 指定进程数量,如 4 +# 1: 单进程模式 +PM2_INSTANCES=1 + +# PM2内存限制,超过此限制将自动重启进程 +# 支持格式: 100M, 1G, 2G 等 +PM2_MAX_MEMORY=1G + +# API 密钥配置 +# 支持单个或多个API_KEY,用逗号分隔 +# 第一个API_KEY为管理员密钥,拥有全部权限(可访问前端管理页面、修改设置) +# 其他API_KEY为普通密钥,仅有调用API的权限,不能访问前端管理页面 +# +# 单个密钥示例: +# API_KEY=sk-admin123 +# +# 多个密钥示例: +# API_KEY=sk-admin123,sk-user456,sk-user789 +# 其中: +# - sk-admin123: 管理员密钥(可访问前端管理页面,可修改所有设置) +# - sk-user456,sk-user789: 普通密钥(仅可调用API,不能访问前端页面) +API_KEY=sk-123456 + +# 是否输出思考过程 +OUTPUT_THINK=true + +# 搜索信息显示模式 +SEARCH_INFO_MODE=table + +# 简化模型映射 +# true: 只返回基础模型,不包含thinking、search、image等变体 +# false: 返回完整模型列表,包含所有变体 +SIMPLE_MODEL_MAP=false + +# Redis链接(如果使用redis模式,则必填,当redis使用tls时将redis://替换为rediss://) +REDIS_URL= + +# 数据保存模式 +# none 不保存数据,仅使用环境变量中的设置 +# file 保存在本地文件中 +# redis 保存到远程/本地redis中 +DATA_SAVE_MODE=none + +# 账号与密码用:分隔,账号与账号间用,分隔(如果使用redis和file模式,则不需要填写) +ACCOUNTS= + +# 日志配置 +# 日志级别 (DEBUG, INFO, WARN, ERROR) +LOG_LEVEL=INFO + +# 是否启用文件日志 +ENABLE_FILE_LOG=false + +# 日志文件目录 +LOG_DIR=./logs + +# 最大日志文件大小 (MB) +MAX_LOG_FILE_SIZE=10 + +# 保留的日志文件数量 +MAX_LOG_FILES=5 + +# ========== 代理与反代配置 ========== + +# 自定义反代URL配置 +# QWEN_CHAT_PROXY_URL: 替代 https://chat.qwen.ai 的反代地址 +# 示例: QWEN_CHAT_PROXY_URL=https://your-proxy.com +QWEN_CHAT_PROXY_URL= + +# QWEN_CLI_PROXY_URL: 替代 https://portal.qwen.ai 的反代地址 +# 示例: QWEN_CLI_PROXY_URL=https://your-cli-proxy.com +QWEN_CLI_PROXY_URL= + +# HTTP/HTTPS 代理配置 +# 支持 HTTP, HTTPS, SOCKS5 代理 +# 示例: PROXY_URL=http://127.0.0.1:7890 +# 示例: PROXY_URL=socks5://127.0.0.1:1080 +PROXY_URL= \ No newline at end of file diff --git a/.env.hf.example b/.env.hf.example new file mode 100644 index 0000000000000000000000000000000000000000..12f70ab6f4382e169e15f59bfef3852897bdca6b --- /dev/null +++ b/.env.hf.example @@ -0,0 +1,117 @@ +# Hugging Face Spaces 推荐运行配置 +# 说明:这个文件是示例模板,不要把真实密钥直接写进仓库 +# +# 使用方式: +# 1. GitHub Actions 中配置自动同步所需变量 +# - Secret: HF_TOKEN +# - Variable: HF_SPACE_ID=DanielleNguyen/Qwen2API-A +# 2. Hugging Face Space 中按下面两类分别配置: +# - HF Secrets:敏感信息,例如 API_KEY、HF_TOKEN +# - HF Variables:普通运行配置,例如端口、目录、开关 +# 3. 不建议在仓库中提交真实 .env 文件 + +# ================================================== +# HF Secrets 建议新建这些 +# ================================================== +# API_KEY=sk-admin-yourkey,sk-user-yourkey +# HF_TOKEN=hf_xxx +# HF_BUCKET_TOKEN= +# ACCOUNTS= +# REDIS_URL= +# PROXY_URL= +# QWEN_CHAT_PROXY_URL= +# QWEN_CLI_PROXY_URL= + +# ================================================== +# HF Variables 建议新建这些 +# ================================================== + +# ============================== +# 服务监听配置 +# Hugging Face Docker Space 建议固定如下: +# - SERVICE_PORT=7860 +# - LISTEN_ADDRESS=0.0.0.0 +# ============================== +SERVICE_PORT=7860 +LISTEN_ADDRESS=0.0.0.0 + +# ============================== +# 运行模式配置 +# Hugging Face 免费空间当前建议单进程运行,更稳定 +# PM2_MAX_MEMORY 是自动重启阈值,不是实际只给 4G 内存 +# ============================== +PM2_INSTANCES=1 +PM2_MAX_MEMORY=4G +NODE_ENV=production + +# ============================== +# API 访问密钥 +# 第一个密钥是管理员密钥,后面的密钥是普通调用密钥 +# 这是敏感信息,建议放到 HF Secrets +# ============================== +API_KEY=sk-admin-demo,sk-user-demo + +# ============================== +# 功能配置 +# 这些属于普通配置,建议放到 HF Variables +# ============================== +OUTPUT_THINK=true +SEARCH_INFO_MODE=table +SIMPLE_MODEL_MAP=false + +# ============================== +# 数据存储配置 +# 当前推荐方案:使用 Hugging Face Bucket 做文件持久化 +# - 项目内部使用 DATA_SAVE_MODE=file +# - 启动时从 Bucket 拉取数据到本地目录 +# - 运行中定时同步本地目录回 Bucket +# ============================== +DATA_SAVE_MODE=file + +# 下面两项通常属于敏感信息,建议放到 HF Secrets +REDIS_URL= +ACCOUNTS= + +# 下面这些路径配置建议放到 HF Variables +DATA_DIR=/data/qwen2api/data +CACHE_DIR=/data/qwen2api/caches +LOG_DIR=/data/qwen2api/logs + +# ============================== +# Hugging Face Bucket 持久化配置 +# HF_BUCKET_REPO:你的 Bucket 名称,建议放到 HF Variables +# HF_TOKEN:Hugging Face 账号 Token,Space 通过它访问 Bucket,建议放到 HF Secrets +# HF_BUCKET_TOKEN:可选;如果单独给 Bucket 配 token,则优先使用它,也建议放到 HF Secrets +# ============================== +HF_BUCKET_REPO=DanielleNguyen/Qwen2API-A-Storage +HF_TOKEN= +HF_BUCKET_TOKEN= +HF_BUCKET_LOCAL_DIR=/data/qwen2api +HF_BUCKET_REMOTE_DIR=runtime +HF_BUCKET_SYNC_INTERVAL=300 +HF_BUCKET_SYNC_DEBOUNCE_SECONDS=5 +HF_BUCKET_STARTUP_GRACE_SECONDS=30 + +# ============================== +# 日志配置 +# Hugging Face 一般不建议开启文件日志,直接看容器日志即可 +# ============================== +LOG_LEVEL=INFO +ENABLE_FILE_LOG=false +MAX_LOG_FILE_SIZE=10 +MAX_LOG_FILES=5 + +# ============================== +# 代理 / 上游反代配置 +# 按需填写;不需要就留空 +# 如果地址中带认证信息,建议放到 HF Secrets +# ============================== +QWEN_CHAT_PROXY_URL= +QWEN_CLI_PROXY_URL= +PROXY_URL= + +# ============================== +# 图片缓存配置 +# 配合 Bucket 持久化时建议使用 file +# ============================== +CACHE_MODE=file diff --git a/.gitattributes b/.gitattributes deleted file mode 100644 index a6344aac8c09253b3b630fb776ae94478aa0275b..0000000000000000000000000000000000000000 --- a/.gitattributes +++ /dev/null @@ -1,35 +0,0 @@ -*.7z filter=lfs diff=lfs merge=lfs -text -*.arrow filter=lfs diff=lfs merge=lfs -text -*.bin filter=lfs diff=lfs merge=lfs -text -*.bz2 filter=lfs diff=lfs merge=lfs -text -*.ckpt filter=lfs diff=lfs merge=lfs -text -*.ftz filter=lfs diff=lfs merge=lfs -text -*.gz filter=lfs diff=lfs merge=lfs -text -*.h5 filter=lfs diff=lfs merge=lfs -text -*.joblib filter=lfs diff=lfs merge=lfs -text -*.lfs.* filter=lfs diff=lfs merge=lfs -text -*.mlmodel filter=lfs diff=lfs merge=lfs -text -*.model filter=lfs diff=lfs merge=lfs -text -*.msgpack filter=lfs diff=lfs merge=lfs -text -*.npy filter=lfs diff=lfs merge=lfs -text -*.npz filter=lfs diff=lfs merge=lfs -text -*.onnx filter=lfs diff=lfs merge=lfs -text -*.ot filter=lfs diff=lfs merge=lfs -text -*.parquet filter=lfs diff=lfs merge=lfs -text -*.pb filter=lfs diff=lfs merge=lfs -text -*.pickle filter=lfs diff=lfs merge=lfs -text -*.pkl filter=lfs diff=lfs merge=lfs -text -*.pt filter=lfs diff=lfs merge=lfs -text -*.pth filter=lfs diff=lfs merge=lfs -text -*.rar filter=lfs diff=lfs merge=lfs -text -*.safetensors filter=lfs diff=lfs merge=lfs -text -saved_model/**/* filter=lfs diff=lfs merge=lfs -text -*.tar.* filter=lfs diff=lfs merge=lfs -text -*.tar filter=lfs diff=lfs merge=lfs -text -*.tflite filter=lfs diff=lfs merge=lfs -text -*.tgz filter=lfs diff=lfs merge=lfs -text -*.wasm filter=lfs diff=lfs merge=lfs -text -*.xz filter=lfs diff=lfs merge=lfs -text -*.zip filter=lfs diff=lfs merge=lfs -text -*.zst filter=lfs diff=lfs merge=lfs -text -*tfevents* filter=lfs diff=lfs merge=lfs -text diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..099a04c8103801766628b2109e523e76c498e05b --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +node_modules +package-lock.json +.env +data/data.json +data +caches +logs/ +*.log +pkg_dist/* +pkg_dist/ +.idea +/public/dist diff --git a/README.md b/README.md index b3e3351b3e2f551ef4d65c411fc8965cdb7f1a1f..e2a360ecf6b33df529c405c961b93c6477d9d893 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,854 @@ +
+ +# 🚀 Qwen-Proxy + +[![Version](https://img.shields.io/badge/version-2026.03.04.10.58-blue.svg)](https://github.com/Rfym21/Qwen2API) +[![Node.js](https://img.shields.io/badge/Node.js-18+-green.svg)](https://nodejs.org/) +[![Docker](https://img.shields.io/badge/Docker-supported-blue.svg)](https://hub.docker.com/r/rfym21/qwen2api) + +[🔗 加入交流群](https://t.me/nodejs_project) | [📖 文档](#api-文档) | [🐳 Docker 部署](#docker-部署) + +
+ +## 🛠️ 快速开始 + +### 项目说明 + +Qwen-Proxy 是一个将 `https://chat.qwen.ai` 和 `Qwen Code / Qwen Cli` 转换为 OpenAI 兼容 API 的代理服务。通过本项目,您只需要一个账户,即可以使用任何支持 OpenAI API 的客户端(如 ChatGPT-Next-Web、LobeChat 等)来调用 `https://chat.qwen.ai` 和 `Qwen Code / Qwen Cli`的各种模型。其中 `/cli` 端点下的模型由 `Qwen Code / Qwen Cli` 提供,支持256k上下文,原生 tools 参数支持 + +**主要特性:** +- 兼容 OpenAI API 格式,无缝对接各类客户端 +- 支持多账户轮询,提高可用性 +- 支持流式/非流式响应 +- 支持多模态(图片识别、图片生成) +- 支持智能搜索、深度思考等高级功能 +- 支持 CLI 端点,提供 256K 上下文和工具调用能力 +- 提供 Web 管理界面,方便配置和监控 + +### ⚠️ 高并发说明 + +> **重要提示**: `chat.qwen.ai` 对单 IP 有限速策略,目前已知该限制与 Cookie 无关,仅与 IP 相关。 + +**解决方案:** + +如需高并发使用,建议配合代理池实现 IP 轮换: + +| 方案 | 配置方式 | 说明 | +|------|----------|------| +| **方案一** | `PROXY_URL` + [ProxyFlow](https://github.com/Rfym21/ProxyFlow) | 直接配置代理地址,所有请求通过代理池轮换 IP | +| **方案二** | `QWEN_CHAT_PROXY_URL` + [UrlProxy](https://github.com/Rfym21/UrlProxy) + [ProxyFlow](https://github.com/Rfym21/ProxyFlow) | 通过反代 + 代理池组合,实现更灵活的 IP 轮换 | + +**配置示例:** + +```bash +# 方案一:直接使用代理池 +PROXY_URL=http://127.0.0.1:8282 # ProxyFlow 代理地址 + +# 方案二:反代 + 代理池组合 +QWEN_CHAT_PROXY_URL=http://127.0.0.1:8000/qwen # UrlProxy 反代地址(UrlProxy 配置 HTTP_PROXY 指向 ProxyFlow) +``` + +### 环境要求 + +- Node.js 18+ (源码部署时需要) +- Docker (可选) +- Redis (可选,用于数据持久化) + +### ⚙️ 环境配置 + +创建 `.env` 文件并配置以下参数: + +```bash +# 🌐 服务配置 +LISTEN_ADDRESS=localhost # 监听地址 +SERVICE_PORT=3000 # 服务端口 + +# 🔐 安全配置 +API_KEY=sk-123456,sk-456789 # API 密钥 (必填,支持多密钥) +ACCOUNTS= # 账户配置 (格式: user1:pass1,user2:pass2) + +# 🚀 PM2 多进程配置 +PM2_INSTANCES=1 # PM2进程数量 (1/数字/max) +PM2_MAX_MEMORY=1G # PM2内存限制 (100M/1G/2G等) + # 注意: PM2集群模式下所有进程共用同一个端口 + +# 🔍 功能配置 +SEARCH_INFO_MODE=table # 搜索信息展示模式 (table/text) +OUTPUT_THINK=true # 是否输出思考过程 (true/false) +SIMPLE_MODEL_MAP=false # 简化模型映射 (true/false) + +# 🌐 代理与反代配置 +QWEN_CHAT_PROXY_URL= # 自定义 Chat API 反代URL (默认: https://chat.qwen.ai) +QWEN_CLI_PROXY_URL= # 自定义 CLI API 反代URL (默认: https://portal.qwen.ai) +PROXY_URL= # HTTP/HTTPS/SOCKS5 代理地址 (例如: http://127.0.0.1:7890) + +# 🗄️ 数据存储 +DATA_SAVE_MODE=none # 数据保存模式 (none/file/redis) +REDIS_URL= # Redis 连接地址 (可选,使用TLS时为rediss://) + +# 📸 缓存配置 +CACHE_MODE=default # 图片缓存模式 (default/file) +``` + +#### 📋 配置说明 + +| 参数 | 说明 | 示例 | +|------|------|------| +| `LISTEN_ADDRESS` | 服务监听地址 | `localhost` 或 `0.0.0.0` | +| `SERVICE_PORT` | 服务运行端口 | `3000` | +| `API_KEY` | API 访问密钥,支持多密钥配置。第一个为管理员密钥(可访问前端管理页面),其他为普通密钥(仅可调用API)。多个密钥用逗号分隔 | `sk-admin123,sk-user456,sk-user789` | +| `PM2_INSTANCES` | PM2进程数量 | `1`/`4`/`max` | +| `PM2_MAX_MEMORY` | PM2内存限制 | `100M`/`1G`/`2G` | +| `SEARCH_INFO_MODE` | 搜索结果展示格式 | `table` 或 `text` | +| `OUTPUT_THINK` | 是否显示 AI 思考过程 | `true` 或 `false` | +| `SIMPLE_MODEL_MAP` | 简化模型映射,只返回基础模型不包含变体 | `true` 或 `false` | +| `QWEN_CHAT_PROXY_URL` | 自定义 Chat API 反代地址 | `https://your-proxy.com` | +| `QWEN_CLI_PROXY_URL` | 自定义 CLI API 反代地址 | `https://your-cli-proxy.com` | +| `PROXY_URL` | 出站请求代理地址,支持 HTTP/HTTPS/SOCKS5 | `http://127.0.0.1:7890` | +| `DATA_SAVE_MODE` | 数据持久化方式 | `none`/`file`/`redis` | +| `REDIS_URL` | Redis 数据库连接地址,使用TLS加密时需使用 `rediss://` 协议 | `redis://localhost:6379` 或 `rediss://xxx.upstash.io` | +| `CACHE_MODE` | 图片缓存存储方式 | `default`/`file` | +| `LOG_LEVEL` | 日志级别 | `DEBUG`/`INFO`/`WARN`/`ERROR` | +| `ENABLE_FILE_LOG` | 是否启用文件日志 | `true` 或 `false` | +| `LOG_DIR` | 日志文件目录 | `./logs` | +| `MAX_LOG_FILE_SIZE` | 最大日志文件大小(MB) | `10` | +| `MAX_LOG_FILES` | 保留的日志文件数量 | `5` | + +> 💡 **提示**: 可以在 [Upstash](https://upstash.com/) 免费创建 Redis 实例,使用 TLS 协议时地址格式为 `rediss://...` +
+Upstash Redis +
+ +#### 🔑 多API_KEY配置说明 + +`API_KEY` 环境变量支持配置多个API密钥,用于实现不同权限级别的访问控制: + +**配置格式:** +```bash +# 单个密钥(管理员权限) +API_KEY=sk-admin123 + +# 多个密钥(第一个为管理员,其他为普通用户) +API_KEY=sk-admin123,sk-user456,sk-user789 +``` + +**权限说明:** + +| 密钥类型 | 权限范围 | 功能描述 | +|----------|----------|----------| +| **管理员密钥** | 完整权限 | • 访问前端管理页面
• 修改系统设置
• 调用所有API接口
• 添加/删除普通密钥 | +| **普通密钥** | API调用权限 | • 仅可调用API接口
• 无法访问前端管理页面
• 无法修改系统设置 | + +**使用场景:** +- **团队协作**: 为不同团队成员分配不同权限的API密钥 +- **应用集成**: 为第三方应用提供受限的API访问权限 +- **安全隔离**: 将管理权限与普通使用权限分离 + +**注意事项:** +- 第一个API_KEY自动成为管理员密钥,拥有最高权限 +- 管理员可以通过前端页面动态添加或删除普通密钥 +- 所有密钥都可以正常调用API接口,权限差异仅体现在管理功能上 + +#### 📸 CACHE_MODE 缓存模式说明 + +`CACHE_MODE` 环境变量控制图片缓存的存储方式,用于优化图片上传和处理性能: + +| 模式 | 说明 | 适用场景 | +|------|------|----------| +| `default` | 内存缓存模式 (默认) | 单进程部署,重启后缓存丢失 | +| `file` | 文件缓存模式 | 多进程部署,缓存持久化到 `./caches/` 目录 | + +**推荐配置:** +- **单进程部署**: 使用 `CACHE_MODE=default`,性能最佳 +- **多进程/集群部署**: 使用 `CACHE_MODE=file`,确保进程间缓存共享 +- **Docker 部署**: 建议使用 `CACHE_MODE=file` 并挂载 `./caches` 目录 + +**文件缓存目录结构:** +``` +caches/ +├── [signature1].txt # 缓存文件,包含图片URL +├── [signature2].txt +└── ... +``` + --- -title: Qwen2API A -emoji: 😻 -colorFrom: pink -colorTo: red -sdk: docker -pinned: false + +## 🚀 部署方式 + +### 🐳 Docker 部署 + +#### 方式一:直接运行 + +```bash +docker run -d \ + -p 3000:3000 \ + -e API_KEY=sk-admin123,sk-user456,sk-user789 \ + -e DATA_SAVE_MODE=none \ + -e CACHE_MODE=file \ + -e ACCOUNTS= \ + -v ./caches:/app/caches \ + --name qwen2api \ + rfym21/qwen2api:latest +``` + +#### 方式二:Docker Compose + +```bash +# 下载配置文件 +curl -o docker-compose.yml https://raw.githubusercontent.com/Rfym21/Qwen2API/refs/heads/main/docker/docker-compose.yml + +# 启动服务 +docker compose pull && docker compose up -d +``` + +### 📦 本地部署 + +```bash +# 克隆项目 +git clone https://github.com/Rfym21/Qwen2API.git +cd Qwen2API + +# 安装依赖 +npm install + +# 配置环境变量 +cp .env.example .env +# 编辑 .env 文件 + +# 智能启动 (推荐 - 自动判断单进程/多进程) +npm start + +# 开发模式 +npm run dev +``` + +### 🚀 PM2 多进程部署 + +使用 PM2 进行生产环境多进程部署,提供更好的性能和稳定性。 + +**重要说明**: PM2 集群模式下,所有进程共用同一个端口,PM2 会自动进行负载均衡。 + +### 🤖 智能启动模式 + +使用 `npm start` 可以自动判断启动方式: + +- 当 `PM2_INSTANCES=1` 时,使用单进程模式 +- 当 `PM2_INSTANCES>1` 时,使用 Node.js 集群模式 +- 自动限制进程数不超过 CPU 核心数 + +### ☁️ Hugging Face 部署 + +推荐使用 **Docker Space + GitHub 自动同步** 的方式部署。这个项目本身已经提供 `docker/Dockerfile`,因此在 Hugging Face 上使用 Docker Space 是最稳妥的方案。 + +[![Deploy to Hugging Face](https://img.shields.io/badge/🤗%20Hugging%20Face-Deploy-yellow)](https://huggingface.co/spaces/devme/q2waepnilm) + +
+Hugging Face Deployment +
+ +#### 推荐部署方式 + +部署链路如下: + +```text +本地修改代码 -> Push 到 GitHub main -> GitHub Actions 自动同步到 Hugging Face Space -> Hugging Face 自动重建并启动 +``` + +#### 第一步:创建 Hugging Face Space + +- 在 Hugging Face 新建 Space +- **SDK 请选择 `Docker`** +- Space 名称示例:`DanielleNguyen/Qwen2API-A` + +#### 第二步:在 GitHub 配置自动同步变量 + +仓库中已提供自动同步工作流:`.github/workflows/huggingface-sync.yml` + +请在 GitHub 仓库中配置以下内容: + +**GitHub Actions Secrets:** + +```bash +HF_TOKEN=你的_huggingface_token +``` + +**GitHub Actions Variables:** + +```bash +HF_SPACE_ID=DanielleNguyen/Qwen2API-A +``` + +配置位置:`GitHub 仓库 -> Settings -> Secrets and variables -> Actions` + +> ⚠️ 注意:`HF_TOKEN` 必须放在 `Secrets` 中,不要写入仓库文件,也不要提交到 `.env` 中。 + +#### 第三步:在 Hugging Face Space 配置运行时变量 + +进入:`Hugging Face Space -> Settings -> Variables and secrets` + +如果你采用 **HF Bucket 持久化**,建议至少配置以下变量: + +```bash +SERVICE_PORT=7860 +LISTEN_ADDRESS=0.0.0.0 +PM2_INSTANCES=1 +PM2_MAX_MEMORY=4G +NODE_ENV=production +OUTPUT_THINK=true +SEARCH_INFO_MODE=table +SIMPLE_MODEL_MAP=false +DATA_SAVE_MODE=file +DATA_DIR=/data/qwen2api/data +CACHE_DIR=/data/qwen2api/caches +LOG_DIR=/data/qwen2api/logs +HF_BUCKET_REPO=DanielleNguyen/Qwen2API-A-Storage +HF_BUCKET_LOCAL_DIR=/data/qwen2api +HF_BUCKET_REMOTE_DIR=runtime +HF_BUCKET_SYNC_INTERVAL=300 +LOG_LEVEL=INFO +ENABLE_FILE_LOG=false +CACHE_MODE=file +``` + +建议作为 Secret 配置的敏感项: + +```bash +API_KEY=sk-admin-yourkey,sk-user-yourkey +HF_TOKEN=hf_xxx +HF_BUCKET_TOKEN= +REDIS_URL= +ACCOUNTS= +PROXY_URL= +QWEN_CHAT_PROXY_URL= +QWEN_CLI_PROXY_URL= +``` + +> 💡 项目中已提供 Hugging Face 示例模板:`.env.hf.example` + +#### 第四步:HF Bucket 持久化工作方式 + +当前项目已适配如下持久化链路: + +```text +容器启动 -> 从 HF Bucket 拉取 /data/qwen2api -> 应用用 file 模式读写本地文件 -> 后台定时同步回 HF Bucket +``` + +默认会持久化这些目录: + +```bash +DATA_DIR=/data/qwen2api/data +CACHE_DIR=/data/qwen2api/caches +LOG_DIR=/data/qwen2api/logs +``` + +推荐的 Bucket 名称: + +```bash +HF_BUCKET_REPO=DanielleNguyen/Qwen2API-A-Storage +``` + +#### 第五步:端口与监听地址说明 + +为兼容 Hugging Face Docker Space,推荐固定使用: + +```bash +SERVICE_PORT=7860 +LISTEN_ADDRESS=0.0.0.0 +``` + +原因: + +- `7860` 是 Hugging Face Space 常见服务端口 +- `0.0.0.0` 可确保容器外部可以访问到服务 +- 项目实际启动端口由环境变量 `SERVICE_PORT` 控制 + +#### 第六步:推送代码触发自动部署 + +当你推送代码到 `main` 分支后: + +- GitHub Actions 会自动执行 `.github/workflows/huggingface-sync.yml` +- 自动将仓库代码同步到 Hugging Face Space +- Hugging Face 收到新代码后会自动重建容器并启动服务 + +#### 推荐的 Hugging Face 配置 + +如果你只是先跑通服务并启用 Bucket 持久化,建议使用: + +```bash +SERVICE_PORT=7860 +LISTEN_ADDRESS=0.0.0.0 +PM2_INSTANCES=1 +DATA_SAVE_MODE=file +CACHE_MODE=file +DATA_DIR=/data/qwen2api/data +CACHE_DIR=/data/qwen2api/caches +LOG_DIR=/data/qwen2api/logs +HF_BUCKET_REPO=DanielleNguyen/Qwen2API-A-Storage +ENABLE_FILE_LOG=false +``` + +如果你要让运行数据真正可恢复,请再补上 Secret: + +```bash +HF_TOKEN=你的_huggingface_token +``` + +> Space 访问 Bucket 时可以直接使用 Hugging Face 账号 Token。若你希望和 GitHub Actions 用途隔离,也可以额外配置 `HF_BUCKET_TOKEN` 专供 Bucket 读写使用。 + +#### 常见问题 + +**1. 为什么不建议在仓库里提交真实 `.env`?** + +因为 `API_KEY`、`HF_TOKEN`、`HF_BUCKET_TOKEN`、`REDIS_URL` 等都属于敏感信息,应该放在 GitHub Secrets 或 Hugging Face Secrets 中。 + +**2. 为什么推荐 Docker Space?** + +因为本项目是完整的 Node.js 服务,并且已经自带 `docker/Dockerfile`,使用 Docker Space 可以直接复用现有构建和启动流程。 + +**3. 如果 GitHub 已经配置了 `HF_TOKEN` 和 `HF_SPACE_ID`,还需要在 HF 里配置它们吗?** + +不需要。 + +- `HF_TOKEN` 和 `HF_SPACE_ID` 只用于 **GitHub -> Hugging Face 同步代码** +- Hugging Face Space 内只需要配置项目运行时环境变量,例如 `API_KEY`、`SERVICE_PORT`、`LISTEN_ADDRESS` + +**4. Space 怎么和 Bucket 通信?一定要单独的 `HF_BUCKET_TOKEN` 吗?** + +不一定。 + +- 在 Hugging Face Space 里,运行时可以直接使用你的 Hugging Face 账号 Token,即 `HF_TOKEN` +- 当前项目已兼容:优先读取 `HF_BUCKET_TOKEN`,如果没设置则自动回退到 `HF_TOKEN` +- 如果你想把“GitHub 同步代码”和“Space 访问 Bucket”分开控制,可以单独再配 `HF_BUCKET_TOKEN` + --- -Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference +## 📁 项目结构 + +``` +Qwen2API/ +├── README.md +├── ecosystem.config.js # PM2配置文件 +├── package.json +│ +├── docker/ # Docker配置目录 +│ ├── Dockerfile +│ ├── docker-compose.yml +│ └── docker-compose-redis.yml +│ +├── caches/ # 缓存文件目录 +├── data/ # 数据文件目录 +│ ├── data.json +│ └── data_template.json +├── scripts/ # 脚本目录 +│ └── fingerprint-injector.js # 浏览器指纹注入脚本 +│ +├── src/ # 后端源代码目录 +│ ├── server.js # 主服务器文件 +│ ├── start.js # 智能启动脚本 (自动判断单进程/多进程) +│ ├── config/ +│ │ └── index.js # 配置文件 +│ ├── controllers/ # 控制器目录 +│ │ ├── chat.js # 聊天控制器 +│ │ ├── chat.image.video.js # 图片/视频生成控制器 +│ │ ├── cli.chat.js # CLI聊天控制器 +│ │ └── models.js # 模型控制器 +│ ├── middlewares/ # 中间件目录 +│ │ ├── authorization.js # 授权中间件 +│ │ └── chat-middleware.js # 聊天中间件 +│ ├── models/ # 模型目录 +│ │ └── models-map.js # 模型映射配置 +│ ├── routes/ # 路由目录 +│ │ ├── accounts.js # 账户路由 +│ │ ├── chat.js # 聊天路由 +│ │ ├── cli.chat.js # CLI聊天路由 +│ │ ├── models.js # 模型路由 +│ │ ├── settings.js # 设置路由 +│ │ └── verify.js # 验证路由 +│ └── utils/ # 工具函数目录 +│ ├── account-rotator.js # 账户轮询器 +│ ├── account.js # 账户管理 +│ ├── chat-helpers.js # 聊天辅助函数 +│ ├── cli.manager.js # CLI管理器 +│ ├── cookie-generator.js # Cookie生成器 +│ ├── data-persistence.js # 数据持久化 +│ ├── fingerprint.js # 浏览器指纹生成 +│ ├── img-caches.js # 图片缓存 +│ ├── logger.js # 日志工具 +│ ├── precise-tokenizer.js # 精确分词器 +│ ├── proxy-helper.js # 代理辅助函数 +│ ├── redis.js # Redis连接 +│ ├── request.js # HTTP请求封装 +│ ├── setting.js # 设置管理 +│ ├── ssxmod-manager.js # ssxmod参数管理 +│ ├── token-manager.js # Token管理器 +│ ├── tools.js # 工具调用处理 +│ └── upload.js # 文件上传 +│ +└── public/ # 前端项目目录 + ├── dist/ # 编译后的前端文件 + │ ├── assets/ # 静态资源 + │ ├── favicon.png + │ └── index.html + ├── src/ # 前端源代码 + │ ├── App.vue # 主应用组件 + │ ├── main.js # 入口文件 + │ ├── style.css # 全局样式 + │ ├── assets/ # 静态资源 + │ │ └── background.mp4 + │ ├── routes/ # 路由配置 + │ │ └── index.js + │ └── views/ # 页面组件 + │ ├── auth.vue # 认证页面 + │ ├── dashboard.vue # 仪表板页面 + │ └── settings.vue # 设置页面 + ├── package.json # 前端依赖配置 + ├── package-lock.json + ├── index.html # 前端入口HTML + ├── postcss.config.js # PostCSS配置 + ├── tailwind.config.js # TailwindCSS配置 + ├── vite.config.js # Vite构建配置 + └── public/ # 公共静态资源 + └── favicon.png +``` + +## 📖 API 文档 + +### 🔐 API 认证说明 + +本API支持多密钥认证机制,所有API请求都需要在请求头中包含有效的API密钥: + +```http +Authorization: Bearer sk-your-api-key +``` + +**支持的密钥类型:** +- **管理员密钥**: 第一个配置的API_KEY,拥有完整权限 +- **普通密钥**: 其他配置的API_KEY,仅可调用API接口 + +**认证示例:** +```bash +# 使用管理员密钥 +curl -H "Authorization: Bearer sk-admin123" http://localhost:3000/v1/models + +# 使用普通密钥 +curl -H "Authorization: Bearer sk-user456" http://localhost:3000/v1/chat/completions +``` + +### 🔍 获取模型列表 + +获取所有可用的 AI 模型列表。 + +```http +GET /v1/models +Authorization: Bearer sk-your-api-key +``` + +```http +GET /models (免认证) +``` + +**响应示例:** +```json +{ + "object": "list", + "data": [ + { + "id": "qwen-max-latest", + "object": "model", + "created": 1677610602, + "owned_by": "qwen" + } + ] +} +``` + +### 💬 聊天对话 + +发送聊天消息并获取 AI 回复。 + +```http +POST /v1/chat/completions +Content-Type: application/json +Authorization: Bearer sk-your-api-key +``` + +**请求体:** +```json +{ + "model": "qwen-max-latest", + "messages": [ + { + "role": "system", + "content": "你是一个有用的助手。" + }, + { + "role": "user", + "content": "你好,请介绍一下自己。" + } + ], + "stream": false, + "temperature": 0.7, + "max_tokens": 2000 +} +``` + +**响应示例:** +```json +{ + "id": "chatcmpl-123", + "object": "chat.completion", + "created": 1677652288, + "model": "qwen-max-latest", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "你好!我是一个AI助手..." + }, + "finish_reason": "stop" + } + ], + "usage": { + "prompt_tokens": 20, + "completion_tokens": 50, + "total_tokens": 70 + } +} +``` + +### 🎨 图像生成/编辑 + +使用 `-image` 模型启用文本到图像生成功能。 +使用 `-image-edit` 模型启用图像修改功能。 +当使用 `-image` 模型时你可以通过在请求体中添加 `size` 参数或在消息内容中包含特定关键词 `1:1`, `4:3`, `3:4`, `16:9`, `9:16` 来控制图片尺寸。 + +```http +POST /v1/chat/completions +Content-Type: application/json +Authorization: Bearer sk-your-api-key +``` + +**请求体:** +```json +{ + "model": "qwen-max-latest-image", + "messages": [ + { + "role": "user", + "content": "画一只在花园里玩耍的小猫咪,卡通风格" + } + ], + "size": "1:1", + "stream": false +} +``` + +**支持的参数:** +- `size`: 图片尺寸,支持 `"1:1"`、`"4:3"`、`"3:4"`、`"16:9"`、`"9:16"` +- `stream`: 支持流式和非流式响应 + +**响应示例:** +```json +{ + "created": 1677652288, + "model": "qwen-max-latest", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "![image](https://example.com/generated-image.jpg)" + }, + "finish_reason": "stop" + } + ] +} +``` + +### 🎯 高级功能 + +#### 🔍 智能搜索模式 + +在模型名称后添加 `-search` 后缀启用搜索功能: + +```json +{ + "model": "qwen-max-latest-search", + "messages": [...] +} +``` + +#### 🧠 推理模式 + +在模型名称后添加 `-thinking` 后缀启用思考过程输出: + +```json +{ + "model": "qwen-max-latest-thinking", + "messages": [...] +} +``` + +#### 🔍🧠 组合模式 + +同时启用搜索和推理功能: + +```json +{ + "model": "qwen-max-latest-thinking-search", + "messages": [...] +} +``` + +#### 🎨 T2I 生图模式 + +通过设置 `chat_type` 参数为 `t2i` 启用文本到图像生成功能: + +```json +{ + "model": "qwen-max-latest", + "chat_type": "t2i", + "messages": [ + { + "role": "user", + "content": "画一只可爱的小猫咪" + } + ], + "size": "1:1" +} +``` + +**支持的图片尺寸:** `1:1`、`4:3`、`3:4`、`16:9`、`9:16` + +**智能尺寸识别:** 系统会自动从提示词中识别尺寸关键词并设置对应尺寸 + +#### 🖼️ 多模态支持 + +API 自动处理图像上传,支持在对话中发送图片: + +```json +{ + "model": "qwen-max-latest", + "messages": [ + { + "role": "user", + "content": [ + { + "type": "text", + "text": "这张图片里有什么?" + }, + { + "type": "image_url", + "image_url": { + "url": "data:image/jpeg;base64,..." + } + } + ] + } + ] +} +``` + +### 🖥️ CLI 端点 + +CLI 端点使用 Qwen Code / Qwen Cli 的 OAuth 令牌访问,支持 256K 上下文和工具调用(Function Calling)。 + +**支持的模型:** + +| 模型 ID | 说明 | +|---------|------| +| `qwen3-coder-plus` | Qwen3 Coder Plus | +| `qwen3-coder-flash` | Qwen3 Coder Flash(速度更快) | +| `coder-model` | Qwen 3.5 Plus(带思维链,256K 上下文) | +| `qwen3.5-plus` | `coder-model` 的别名,自动重定向 | + +#### 💬 CLI 聊天对话 + +通过 CLI 端点发送聊天请求,支持流式和非流式响应。 + +```http +POST /cli/v1/chat/completions +Content-Type: application/json +Authorization: Bearer API_KEY +``` + +**请求体:** +```json +{ + "model": "qwen3-coder-plus", + "messages": [ + { + "role": "user", + "content": "你好,请介绍一下自己。" + } + ], + "stream": false, + "temperature": 0.7, + "max_tokens": 2000 +} +``` + +使用 `coder-model`(即 Qwen 3.5 Plus)或其别名 `qwen3.5-plus`: +```json +{ + "model": "coder-model", + "messages": [ + { + "role": "user", + "content": "写一个快速排序算法。" + } + ], + "stream": false +} +``` + +**流式请求:** +```json +{ + "model": "qwen3-coder-flash", + "messages": [ + { + "role": "user", + "content": "写一首关于春天的诗。" + } + ], + "stream": true +} +``` + +**响应格式:** + +非流式响应与标准 OpenAI API 格式相同: +```json +{ + "id": "chatcmpl-123", + "object": "chat.completion", + "created": 1677652288, + "model": "qwen3-coder-plus", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "你好!我是一个AI助手..." + }, + "finish_reason": "stop" + } + ], + "usage": { + "prompt_tokens": 20, + "completion_tokens": 50, + "total_tokens": 70 + } +} +``` + +流式响应使用 Server-Sent Events (SSE) 格式: +``` +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1677652288,"model":"qwen3-coder-flash","choices":[{"index":0,"delta":{"content":"你好"},"finish_reason":null}]} + +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1677652288,"model":"qwen3-coder-flash","choices":[{"index":0,"delta":{"content":"!"},"finish_reason":null}]} + +data: [DONE] +``` diff --git a/docker/Dockerfile b/docker/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..646430448905f4b7ccac5c7906540f949bf9fd08 --- /dev/null +++ b/docker/Dockerfile @@ -0,0 +1,45 @@ +FROM node:lts-alpine + +ENV NODE_ENV=production +ENV SERVICE_PORT=7860 +ENV LISTEN_ADDRESS=0.0.0.0 + +RUN apk add --no-cache python3 py3-pip + +# 全局安装PM2 +RUN npm install -g pm2 + +RUN pip install --no-cache-dir huggingface_hub watchdog + +WORKDIR /app + +# 复制package文件 +COPY package*.json ./ + +# 安装依赖 +RUN npm install + +# 复制应用代码 +COPY . . + +# 构建前端应用 +RUN cd public && npm install && npm run build + +# 删除前端不必要文件 +RUN rm -rf public/src public/node_modules public/package*.json + +# 设置权限 +RUN chmod 777 /app + +# 创建日志目录 +RUN mkdir -p logs + +# 允许执行入口脚本 +RUN chmod +x /app/docker/entrypoint.sh + +# 暴露 Hugging Face Docker Space 默认端口 +# 应用实际监听端口仍由 SERVICE_PORT 控制 +EXPOSE 7860 + +# 启动前自动从 HF Bucket 恢复数据,并在运行中后台同步 +ENTRYPOINT ["/app/docker/entrypoint.sh"] diff --git a/docker/docker-compose-redis.yml b/docker/docker-compose-redis.yml new file mode 100644 index 0000000000000000000000000000000000000000..b3082d628c7a9290c2de20a1174ddacc10b89de2 --- /dev/null +++ b/docker/docker-compose-redis.yml @@ -0,0 +1,51 @@ +services: + qwen2api: + container_name: qwen2api + image: rfym21/qwen2api:latest + restart: always + ports: + - "3000:3000" + volumes: + - ./data:/app/data + - ./logs:/app/logs + environment: + # 如果需要修改Docker暴露端口,请修改ports中的参数 + # 示例(8080:3000) 则访问 http://localhost:8080 + - SERVICE_PORT=3000 + # API 密钥 (非必填) + # 如果需要使用多账户或使用内置账户,请填写 + - API_KEY=sk-123456 + # 监听地址(非必填) + - LISTEN_ADDRESS= + # PM2 多进程配置 + # PM2进程数量: max(使用所有CPU核心), 数字(指定进程数量), 1(单进程) + # 注意: PM2集群模式下所有进程共用同一个端口 + - PM2_INSTANCES=1 + # PM2内存限制,超过此限制将自动重启进程 + - PM2_MAX_MEMORY=1G + # 搜索信息展示模式 + # table: 使用折叠块和表格展示 + # text: 使用纯文本 + - SEARCH_INFO_MODE=table + # 是否输出思考过程 + - OUTPUT_THINK=true + # 简化模型映射 (true: 只返回基础模型, false: 返回完整模型列表) + - SIMPLE_MODEL_MAP=false + # redis 连接地址(必填) + - REDIS_URL=redis://redis:6379 + # 数据保存模式 + - DATA_SAVE_MODE=redis + # 图片缓存 + - CACHE_MODE=default + redis: + image: redis:7.2-alpine + container_name: redis_qwen2api + restart: always + ports: + - "6379:6379" + volumes: + - redis-data:/data + +volumes: + redis-data: + diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml new file mode 100644 index 0000000000000000000000000000000000000000..09c6d2bcccb4d9da30f3db9c450d3ff9c2121b0c --- /dev/null +++ b/docker/docker-compose.yml @@ -0,0 +1,41 @@ +services: + qwen2api: + container_name: qwen2api + image: rfym21/qwen2api:latest + restart: always + ports: + - "3000:3000" + volumes: + - ./data:/app/data + - ./logs:/app/logs + environment: + # 如果需要修改Docker暴露端口,请修改ports中的参数 + # 示例(8080:3000) 则访问 http://localhost:8080 + - SERVICE_PORT=3000 + # API 密钥 (非必填) + # 如果需要使用多账户或使用内置账户,请填写 + - API_KEY=sk-123456 + # 监听地址(非必填) + - LISTEN_ADDRESS= + # PM2 多进程配置 + # PM2进程数量: max(使用所有CPU核心), 数字(指定进程数量), 1(单进程) + # 注意: PM2集群模式下所有进程共用同一个端口 + - PM2_INSTANCES=1 + # PM2内存限制,超过此限制将自动重启进程 + - PM2_MAX_MEMORY=1G + # 搜索信息展示模式 + # table: 使用折叠块和表格展示 + # text: 使用纯文本 + - SEARCH_INFO_MODE=table + # 是否输出思考过程 + - OUTPUT_THINK=true + # 简化模型映射 (true: 只返回基础模型, false: 返回完整模型列表) + - SIMPLE_MODEL_MAP=false + # redis 连接地址(如果使用redis模式,则必填,当redis使用tls时将redis://替换为rediss://) + - REDIS_URL= + # 数据保存模式 + - DATA_SAVE_MODE=none + # 账号(如果使用redis和file模式,则不需要填写) + - ACCOUNTS= + # 图片缓存 + - CACHE_MODE=default diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh new file mode 100644 index 0000000000000000000000000000000000000000..cef94ee6645f5b540231f39e6f9ca517af1f6cb0 --- /dev/null +++ b/docker/entrypoint.sh @@ -0,0 +1,19 @@ +#!/bin/sh +set -eu + +APP_DATA_ROOT="${HF_BUCKET_LOCAL_DIR:-/data/qwen2api}" +export HF_BUCKET_LOCAL_DIR="$APP_DATA_ROOT" + +mkdir -p "$APP_DATA_ROOT/data" "$APP_DATA_ROOT/caches" "$APP_DATA_ROOT/logs" + +export DATA_DIR="${DATA_DIR:-$APP_DATA_ROOT/data}" +export CACHE_DIR="${CACHE_DIR:-$APP_DATA_ROOT/caches}" +export LOG_DIR="${LOG_DIR:-$APP_DATA_ROOT/logs}" +export SERVICE_PORT="${SERVICE_PORT:-7860}" +export LISTEN_ADDRESS="${LISTEN_ADDRESS:-0.0.0.0}" +export HF_BUCKET_SYNC_INTERVAL="${HF_BUCKET_SYNC_INTERVAL:-300}" + +python3 /app/scripts/hf-bucket-sync.py restore || true +python3 /app/scripts/hf-bucket-sync.py daemon & + +exec npm start diff --git a/ecosystem.config.js b/ecosystem.config.js new file mode 100644 index 0000000000000000000000000000000000000000..51f4f8bac03830f14fecf830d6cfab98ccc333c6 --- /dev/null +++ b/ecosystem.config.js @@ -0,0 +1,53 @@ +const os = require('os') + +// 获取CPU核心数 +const cpuCores = os.cpus().length + +// 解析进程数配置 +let instances = process.env.PM2_INSTANCES || 1 +if (instances === 'max') { + instances = cpuCores +} else if (!isNaN(instances)) { + instances = parseInt(instances) +} else { + instances = 1 +} + +// 限制进程数不能超过CPU核心数 +if (instances > cpuCores) { + console.log(`⚠️ 警告: 配置的进程数(${instances})超过CPU核心数(${cpuCores}),自动调整为${cpuCores}`) + instances = cpuCores +} + +module.exports = { + apps: [{ + name: 'qwen2api', + script: './src/server.js', + instances: instances, + exec_mode: 'cluster', + + // 环境变量 + env: { + PM2_USAGE: 'true' + }, + + // 日志配置 + log_file: './logs/pm2-combined.log', + out_file: './logs/pm2-out.log', + error_file: './logs/pm2-error.log', + log_date_format: 'YYYY-MM-DD HH:mm:ss Z', + + // 进程管理配置 + max_memory_restart: process.env.PM2_MAX_MEMORY || '1G', + min_uptime: '10s', + max_restarts: 10, + + // 监听文件变化 + watch: false, + ignore_watch: ['node_modules', 'logs', 'caches', 'data'], + + // 其他配置 + merge_logs: true, + time: true + }] +} diff --git a/package.json b/package.json new file mode 100644 index 0000000000000000000000000000000000000000..5ef0a26cce6f3367258f921641f1181d3e7174dc --- /dev/null +++ b/package.json @@ -0,0 +1,40 @@ +{ + "name": "qwen2api", + "version": "2026.03.04.10.58", + "main": "src/server.js", + "scripts": { + "start": "node src/start.js", + "dev": "nodemon src/server.js", + "pm2": "pm2 start ecosystem.config.js", + "pm2:stop": "pm2 stop qwen2api", + "pm2:restart": "pm2 restart qwen2api", + "pm2:reload": "pm2 reload qwen2api", + "pm2:delete": "pm2 delete qwen2api", + "pm2:logs": "pm2 logs qwen2api", + "pm2:status": "pm2 status", + "pm2:monit": "pm2 monit" + }, + "keywords": [], + "author": "", + "license": "ISC", + "description": "", + "dependencies": { + "ali-oss": "^6.22.0", + "axios": "^1.11.0", + "body-parser": "^1.20.3", + "cors": "^2.8.5", + "dotenv": "^16.4.7", + "express": "^4.21.2", + "form-data": "^4.0.2", + "https-proxy-agent": "^7.0.6", + "ioredis": "^5.6.1", + "jwt-decode": "^4.0.0", + "mime-types": "^3.0.1", + "multer": "^1.4.5-lts.1", + "pm2": "^6.0.8", + "tiktoken": "^1.0.21" + }, + "devDependencies": { + "nodemon": "^3.1.7" + } +} diff --git a/public/.gitignore b/public/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..251ce6d2bd9308e5975611a1ad18308ba6da8117 --- /dev/null +++ b/public/.gitignore @@ -0,0 +1,23 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000000000000000000000000000000000000..11e703ecfa4a85d0171761b53c608b21aef195b1 --- /dev/null +++ b/public/index.html @@ -0,0 +1,13 @@ + + + + + + + Qwen2 API Dashboard + + +
+ + + diff --git a/public/package.json b/public/package.json new file mode 100644 index 0000000000000000000000000000000000000000..65221fa84ad62ca45007cfd2c32db6d327157c1e --- /dev/null +++ b/public/package.json @@ -0,0 +1,23 @@ +{ + "name": "dashboard", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite --port 6868", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "axios": "^1.8.4", + "vue": "^3.4.29", + "vue-router": "^4.5.0" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.0.5", + "autoprefixer": "^10.4.21", + "postcss": "^8.5.3", + "tailwindcss": "^3.4.3", + "vite": "^5.2.8" + } +} diff --git a/public/postcss.config.js b/public/postcss.config.js new file mode 100644 index 0000000000000000000000000000000000000000..2e7af2b7f1a6f391da1631d93968a9d487ba977d --- /dev/null +++ b/public/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/public/src/App.vue b/public/src/App.vue new file mode 100644 index 0000000000000000000000000000000000000000..a8198c722f7c90c321a85abf5b9f629748bc5600 --- /dev/null +++ b/public/src/App.vue @@ -0,0 +1,33 @@ + + + + + diff --git a/public/src/main.js b/public/src/main.js new file mode 100644 index 0000000000000000000000000000000000000000..f637b031b63bb6a3f7b75236a4fad7ffb3d6262b --- /dev/null +++ b/public/src/main.js @@ -0,0 +1,8 @@ +import { createApp } from 'vue' +import router from './routes/index.js' +import App from './App.vue' +import "./style.css" + +createApp(App) + .use(router) + .mount('#app') diff --git a/public/src/routes/index.js b/public/src/routes/index.js new file mode 100644 index 0000000000000000000000000000000000000000..92d110952c3be169ec57027bf82da3139d821692 --- /dev/null +++ b/public/src/routes/index.js @@ -0,0 +1,74 @@ +import { createRouter, createWebHistory } from 'vue-router' +import axios from 'axios' + +const routes = [ + { + name: 'dashboard', + path: '/', + component: () => import('../views/dashboard.vue') + }, + { + name: 'auth', + path: '/auth', + component: () => import('../views/auth.vue') + }, + { + name: 'settings', + path: '/settings', + component: () => import('../views/settings.vue') + } +] + +const router = createRouter({ + history: createWebHistory(), + routes +}) + + +// 路由守卫 +router.beforeEach(async (to, from, next) => { + + if (to.path === '/auth') { + next() + } else { + const apiKey = localStorage.getItem('apiKey') + if (!apiKey) { + alert('请先设置身份验证apiKey') + next({ path: '/auth' }) + } else { + try { + const verifyResponse = await axios.post('/verify', { + apiKey: apiKey + }) + + if (verifyResponse.data.status === 200) { + const isAdmin = verifyResponse.data.isAdmin + + // 存储用户权限信息 + localStorage.setItem('isAdmin', isAdmin.toString()) + + // 检查是否需要管理员权限 + if ((to.path === '/' || to.path === '/settings') && !isAdmin) { + alert('您没有访问管理页面的权限') + next({ path: '/auth' }) + return + } + + next() + } else { + localStorage.removeItem('apiKey') + localStorage.removeItem('isAdmin') + next({ path: '/auth' }) + } + } catch (error) { + localStorage.removeItem('apiKey') + localStorage.removeItem('isAdmin') + next({ path: '/auth' }) + } + } + } + +}) + + +export default router \ No newline at end of file diff --git a/public/src/style.css b/public/src/style.css new file mode 100644 index 0000000000000000000000000000000000000000..bd6213e1dfe6b0a79ce7d8b37d0d2dc70f0250bb --- /dev/null +++ b/public/src/style.css @@ -0,0 +1,3 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; \ No newline at end of file diff --git a/public/src/views/auth.vue b/public/src/views/auth.vue new file mode 100644 index 0000000000000000000000000000000000000000..e745b3bebaa19ee6575c77eed2cd72c15e8279d2 --- /dev/null +++ b/public/src/views/auth.vue @@ -0,0 +1,66 @@ + + + + + \ No newline at end of file diff --git a/public/src/views/dashboard.vue b/public/src/views/dashboard.vue new file mode 100644 index 0000000000000000000000000000000000000000..bc0ff59448a510f60dc12f3bfe52b817df238721 --- /dev/null +++ b/public/src/views/dashboard.vue @@ -0,0 +1,1025 @@ + + + + + \ No newline at end of file diff --git a/public/src/views/settings.vue b/public/src/views/settings.vue new file mode 100644 index 0000000000000000000000000000000000000000..bf5b128ab354d37b8a2ecd697669f3b23e17bfa5 --- /dev/null +++ b/public/src/views/settings.vue @@ -0,0 +1,299 @@ + + + + + \ No newline at end of file diff --git a/public/tailwind.config.js b/public/tailwind.config.js new file mode 100644 index 0000000000000000000000000000000000000000..ff48818cbe8639b10e036feefb2a11415a2d50ed --- /dev/null +++ b/public/tailwind.config.js @@ -0,0 +1,11 @@ +/** @type {import('tailwindcss').Config} */ +export default { + content: [ + "./index.html", + "./src/**/*.{vue,js,ts,jsx,tsx}", + ], + theme: { + extend: {}, + }, + plugins: [], +} \ No newline at end of file diff --git a/public/vite.config.js b/public/vite.config.js new file mode 100644 index 0000000000000000000000000000000000000000..2715d90f2afc8dc037e81918e68e73c36d812aea --- /dev/null +++ b/public/vite.config.js @@ -0,0 +1,15 @@ +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' + +// https://vitejs.dev/config/ +export default defineConfig({ + plugins: [vue()], + server: { + proxy: { + '/': { + target: 'http://localhost:4000', // 实际后端地址 + changeOrigin: true, + } + } + } +}) diff --git a/scripts/fingerprint-injector.js b/scripts/fingerprint-injector.js new file mode 100644 index 0000000000000000000000000000000000000000..d8a09639938a4278f3878663dc48b161839a6fc6 --- /dev/null +++ b/scripts/fingerprint-injector.js @@ -0,0 +1,20 @@ +(function() { + // 拦截 String.prototype.charAt + const originalCharAt = String.prototype.charAt; + let capturedData = null; + + String.prototype.charAt = function(index) { + if (this.length > 200 && this.includes('^') && !capturedData) { + const fields = this.split('^'); + if (fields.length === 37) { + capturedData = this.toString(); + console.log('\n=== 检测到浏览器指纹 ==='); + console.log(capturedData); + + // 恢复原方法 + String.prototype.charAt = originalCharAt; + } + } + return originalCharAt.call(this, index); + }; +})(); diff --git a/scripts/hf-bucket-sync.py b/scripts/hf-bucket-sync.py new file mode 100644 index 0000000000000000000000000000000000000000..d880761b22801a3cdfabc132bdf858954608d11d --- /dev/null +++ b/scripts/hf-bucket-sync.py @@ -0,0 +1,151 @@ +import os +import sys +import time +import threading +from pathlib import Path + +from huggingface_hub import sync_bucket +from watchdog.events import FileSystemEventHandler +from watchdog.observers import Observer + + +BUCKET_REPO = os.environ.get("HF_BUCKET_REPO", "").strip() +BUCKET_TOKEN = ( + os.environ.get("HF_BUCKET_TOKEN", "").strip() + or os.environ.get("HF_TOKEN", "").strip() +) +LOCAL_ROOT = Path(os.environ.get("HF_BUCKET_LOCAL_DIR", "/data/qwen2api")).resolve() +REMOTE_ROOT = os.environ.get("HF_BUCKET_REMOTE_DIR", "runtime").strip("/") +SYNC_INTERVAL = int(os.environ.get("HF_BUCKET_SYNC_INTERVAL", "300")) +STARTUP_GRACE_SECONDS = int(os.environ.get("HF_BUCKET_STARTUP_GRACE_SECONDS", "30")) +SYNC_DEBOUNCE_SECONDS = int(os.environ.get("HF_BUCKET_SYNC_DEBOUNCE_SECONDS", "5")) + + +def log(message: str) -> None: + print(f"[hf-bucket] {message}", flush=True) + + +def bucket_path() -> str: + if REMOTE_ROOT: + return f"hf://buckets/{BUCKET_REPO}/{REMOTE_ROOT}" + return f"hf://buckets/{BUCKET_REPO}" + + +def ensure_local_root() -> None: + LOCAL_ROOT.mkdir(parents=True, exist_ok=True) + + +def can_use_bucket() -> bool: + if not BUCKET_REPO: + log("skip: HF_BUCKET_REPO 未设置") + return False + if not BUCKET_TOKEN: + log("skip: HF_TOKEN 或 HF_BUCKET_TOKEN 未设置") + return False + return True + + +def restore() -> None: + if not can_use_bucket(): + return + + ensure_local_root() + try: + sync_bucket(bucket_path(), str(LOCAL_ROOT), token=BUCKET_TOKEN) + log(f"restore 完成: {bucket_path()} -> {LOCAL_ROOT}") + except Exception as exc: + log(f"restore 失败: {exc}") + + +def push() -> None: + if not can_use_bucket(): + return + + ensure_local_root() + try: + sync_bucket(str(LOCAL_ROOT), bucket_path(), token=BUCKET_TOKEN, delete=False) + log(f"sync 完成: {LOCAL_ROOT} -> {bucket_path()}") + except Exception as exc: + log(f"sync 失败: {exc}") + + +def daemon() -> None: + if not can_use_bucket(): + return + + class ChangeHandler(FileSystemEventHandler): + def __init__(self) -> None: + self._timer = None + self._lock = threading.Lock() + self._startup_time = time.time() + + def _schedule(self) -> None: + if time.time() - self._startup_time < STARTUP_GRACE_SECONDS: + return + + with self._lock: + if self._timer is not None: + self._timer.cancel() + self._timer = threading.Timer(SYNC_DEBOUNCE_SECONDS, push) + self._timer.daemon = True + self._timer.start() + + def on_created(self, event): + if not event.is_directory: + self._schedule() + + def on_modified(self, event): + if not event.is_directory: + self._schedule() + + def on_deleted(self, event): + if not event.is_directory: + self._schedule() + + def on_moved(self, event): + if not event.is_directory: + self._schedule() + + observer = Observer() + observer.schedule(ChangeHandler(), str(LOCAL_ROOT), recursive=True) + observer.start() + + log( + f"后台同步已启动,监听目录 {LOCAL_ROOT},即时同步防抖 {SYNC_DEBOUNCE_SECONDS} 秒,定时兜底 {SYNC_INTERVAL} 秒" + ) + + def periodic_sync() -> None: + while True: + time.sleep(SYNC_INTERVAL) + push() + + periodic_thread = threading.Thread(target=periodic_sync, daemon=True) + periodic_thread.start() + + try: + while True: + time.sleep(1) + except KeyboardInterrupt: + observer.stop() + observer.join() + + +def main() -> int: + command = sys.argv[1] if len(sys.argv) > 1 else "daemon" + + if command == "restore": + restore() + return 0 + if command == "push": + push() + return 0 + if command == "daemon": + daemon() + return 0 + + log(f"未知命令: {command}") + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/config/index.js b/src/config/index.js new file mode 100644 index 0000000000000000000000000000000000000000..8497cb1efdd6bfdc1a0f9a65d6a9ddde4c423fff --- /dev/null +++ b/src/config/index.js @@ -0,0 +1,52 @@ +const dotenv = require('dotenv') +dotenv.config() +const paths = require('../utils/paths') + +/** + * 解析API_KEY环境变量,支持逗号分隔的多个key + * @returns {Object} 包含apiKeys数组和adminKey的对象 + */ +const parseApiKeys = () => { + const apiKeyEnv = process.env.API_KEY + if (!apiKeyEnv) { + return { apiKeys: [], adminKey: null } + } + + const keys = apiKeyEnv.split(',').map(key => key.trim()).filter(key => key.length > 0) + return { + apiKeys: keys, + adminKey: keys.length > 0 ? keys[0] : null + } +} + +const { apiKeys, adminKey } = parseApiKeys() + +const config = { + dataSaveMode: process.env.DATA_SAVE_MODE || "none", + apiKeys: apiKeys, + adminKey: adminKey, + simpleModelMap: process.env.SIMPLE_MODEL_MAP === 'true' ? true : false, + listenAddress: process.env.LISTEN_ADDRESS || null, + listenPort: process.env.SERVICE_PORT || 3000, + searchInfoMode: process.env.SEARCH_INFO_MODE === 'table' ? "table" : "text", + outThink: process.env.OUTPUT_THINK === 'true' ? true : false, + redisURL: process.env.REDIS_URL || null, + autoRefresh: true, + autoRefreshInterval: 6 * 60 * 60, + cacheMode: process.env.CACHE_MODE || "default", + logLevel: process.env.LOG_LEVEL || "INFO", + enableFileLog: process.env.ENABLE_FILE_LOG === 'true', + logDir: paths.logDir, + maxLogFileSize: parseInt(process.env.MAX_LOG_FILE_SIZE) || 10, + maxLogFiles: parseInt(process.env.MAX_LOG_FILES) || 5, + dataDir: paths.dataDir, + cacheDir: paths.cacheDir, + dataFilePath: paths.dataFilePath, + // 自定义反代URL配置 + qwenChatProxyUrl: process.env.QWEN_CHAT_PROXY_URL || "https://chat.qwen.ai", + qwenCliProxyUrl: process.env.QWEN_CLI_PROXY_URL || "https://portal.qwen.ai", + // 代理配置 + proxyUrl: process.env.PROXY_URL || null +} + +module.exports = config diff --git a/src/controllers/chat.image.video.js b/src/controllers/chat.image.video.js new file mode 100644 index 0000000000000000000000000000000000000000..9b636b9782b3ac9ad55ad3043c6e13823d6c5818 --- /dev/null +++ b/src/controllers/chat.image.video.js @@ -0,0 +1,357 @@ +const axios = require('axios') +const { logger } = require('../utils/logger.js') +const { setResponseHeaders } = require('./chat.js') +const accountManager = require('../utils/account.js') +const { sleep } = require('../utils/tools.js') +const { generateChatID } = require('../utils/request.js') +const { getSsxmodItna, getSsxmodItna2 } = require('../utils/ssxmod-manager') +const { getProxyAgent, getChatBaseUrl, applyProxyToAxiosConfig } = require('../utils/proxy-helper') + +/** + * 主要的聊天完成处理函数 + * @param {object} req - Express 请求对象 + * @param {object} res - Express 响应对象 + */ +const handleImageVideoCompletion = async (req, res) => { + const { model, messages, size, chat_type } = req.body + // console.log(JSON.stringify(req.body.messages.filter(item => item.role == "user" || item.role == "assistant"))) + const token = accountManager.getAccountToken() + + try { + + // 请求体模板 + const reqBody = { + "stream": false, + "chat_id": null, + "model": model, + "messages": [ + { + "role": "user", + "content": "", + "files": [], + "chat_type": chat_type, + "feature_config": { + "output_schema": "phase" + } + } + ] + } + + const chat_id = await generateChatID(token, model) + + if (!chat_id) { + // 如果生成chat_id失败,则返回错误 + throw new Error() + } else { + reqBody.chat_id = chat_id + } + + // 拿到用户最后一句消息 + const _userPrompt = messages[messages.length - 1].content + if (!_userPrompt) { + throw new Error() + } + + // 提取历史消息 + const messagesHistory = messages.filter(item => item.role == "user" || item.role == "assistant") + // 聊天消息中所有图片url + const select_image_list = [] + + // 遍历模型回复消息,拿到所有图片 + if (chat_type == "image_edit") { + for (const item of messagesHistory) { + if (item.role == "assistant") { + // 使用matchAll提取所有图片链接 + const matches = [...item.content.matchAll(/!\[image\]\((.*?)\)/g)] + // 将所有匹配到的图片url添加到图片列表 + for (const match of matches) { + select_image_list.push(match[1]) + } + } else { + if (Array.isArray(item.content) && item.content.length > 0) { + for (const content of item.content) { + if (content.type == "image") { + select_image_list.push(content.image) + } + } + } + } + } + } + + //分情况处理 + if (chat_type == 't2i' || chat_type == 't2v') { + if (Array.isArray(_userPrompt)) { + reqBody.messages[0].content = _userPrompt.map(item => item.type == "text" ? item.text : "").join("\n\n") + } else { + reqBody.messages[0].content = _userPrompt + } + } else if (chat_type == 'image_edit') { + if (!Array.isArray(_userPrompt)) { + + if (messagesHistory.length === 1) { + reqBody.messages[0].chat_type = "t2i" + } else if (select_image_list.length >= 1) { + reqBody.messages[0].files.push({ + "type": "image", + "url": select_image_list[select_image_list.length - 1] + }) + } + reqBody.messages[0].content += _userPrompt + } else { + const texts = _userPrompt.filter(item => item.type == "text") + if (texts.length === 0) { + throw new Error() + } + // 拼接提示词 + for (const item of texts) { + reqBody.messages[0].content += item.text + } + + const files = _userPrompt.filter(item => item.type == "image") + // 如果图片为空,则设置为t2i + if (files.length === 0) { + reqBody.messages[0].chat_type = "t2i" + } + // 遍历图片 + for (const item of files) { + reqBody.messages[0].files.push({ + "type": "image", + "url": item.image + }) + } + + } + } + + + // 处理图片视频尺寸 + if (chat_type == 't2i' || chat_type == 't2v') { + // 获取图片尺寸,优先级 参数 > 提示词 > 默认 + if (size != undefined && size != null) { + reqBody.size = "1:1" + } else if (_userPrompt.indexOf("@4:3") != -1) { + reqBody.size = "4:3"//"1024*768" + } else if (_userPrompt.indexOf("@3:4") != -1) { + reqBody.size = "3:4"//"768*1024" + } else if (_userPrompt.indexOf("@16:9") != -1) { + reqBody.size = "16:9"//"1280*720" + } else if (_userPrompt.indexOf("@9:16") != -1) { + reqBody.size = "9:16"//"720*1280" + } + } + + const chatBaseUrl = getChatBaseUrl() + const proxyAgent = getProxyAgent() + + logger.info('发送图片视频请求', 'CHAT') + logger.info(`选择图片: ${select_image_list[select_image_list.length - 1] || "未选择图片,切换生成图/视频模式"}`, 'CHAT') + logger.info(`使用提示: ${reqBody.messages[0].content}`, 'CHAT') + // console.log(JSON.stringify(reqBody)) + const newChatType = reqBody.messages[0].chat_type + + const requestConfig = { + headers: { + 'Authorization': `Bearer ${token}`, + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36 Edg/143.0.0.0", + "Connection": "keep-alive", + "Accept": "application/json", + "Accept-Encoding": "gzip, deflate, br, zstd", + "Content-Type": "application/json", + "Timezone": "Mon Dec 08 2025 17:28:55 GMT+0800", + "sec-ch-ua": "\"Microsoft Edge\";v=\"143\", \"Chromium\";v=\"143\", \"Not A(Brand\";v=\"24\"", + "source": "web", + "Version": "0.1.13", + "bx-v": "2.5.31", + "Origin": chatBaseUrl, + "Sec-Fetch-Site": "same-origin", + "Sec-Fetch-Mode": "cors", + "Sec-Fetch-Dest": "empty", + "Referer": `${chatBaseUrl}/c/guest`, + "Accept-Language": "zh-CN,zh;q=0.9,en-US;q=0.8,en;q=0.7", + "Cookie": `ssxmod_itna=${getSsxmodItna()};ssxmod_itna2=${getSsxmodItna2()}`, + }, + responseType: newChatType == 't2i' ? 'stream' : 'json', + timeout: 1000 * 60 * 5 + } + + // 添加代理配置 + if (proxyAgent) { + requestConfig.httpsAgent = proxyAgent + requestConfig.proxy = false + } + + const response_data = await axios.post(`${chatBaseUrl}/api/v2/chat/completions?chat_id=${chat_id}`, reqBody, requestConfig) + + try { + let contentUrl = null + if (newChatType == 't2i') { + const decoder = new TextDecoder('utf-8') + response_data.data.on('data', async (chunk) => { + const data = decoder.decode(chunk, { stream: true }).split('\n').filter(item => item.trim() != "") + console.log(data) + for (const item of data) { + const jsonObj = JSON.parse(item.replace("data:", '').trim()) + if (jsonObj && jsonObj.choices && jsonObj.choices[0] && jsonObj.choices[0].delta && jsonObj.choices[0].delta.content.trim() != "" && contentUrl == null) { + contentUrl = jsonObj.choices[0].delta.content + } + } + }) + + response_data.data.on('end', () => { + return returnResponse(res, model, contentUrl, req.body.stream) + }) + } else if (newChatType == 'image_edit') { + console.log(response_data.data) + contentUrl = response_data.data?.data?.choices[0]?.message?.content[0]?.image + return returnResponse(res, model, contentUrl, req.body.stream) + } else if (newChatType == 't2v') { + return handleVideoCompletion(req, res, response_data.data, token) + } + + } catch (error) { + logger.error('图片处理错误', 'CHAT', error) + res.status(500).json({ error: "服务错误!!!" }) + } + + } catch (error) { + res.status(500).json({ + error: "服务错误,请稍后再试" + }) + } +} + +/** + * 返回响应 + * @param {*} res + * @param {*} model + * @param {*} contentUrl + */ +const returnResponse = (res, model, contentUrl, stream) => { + setResponseHeaders(res, stream) + logger.info(`返回响应: ${contentUrl}`, 'CHAT') + + const returnBody = { + "created": new Date().getTime(), + "model": model, + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": `![image](${contentUrl})` + } + } + ] + } + + if (stream) { + res.write(`data: ${JSON.stringify(returnBody)}\n\n`) + res.write(`data: [DONE]\n\n`) + res.end() + } else { + res.json(returnBody) + } +} + +const handleVideoCompletion = async (req, res, response_data, token) => { + try { + const videoTaskID = response_data?.data?.messages[0]?.extra?.wanx?.task_id + if (!response_data || !response_data.success || !videoTaskID) { + throw new Error() + } + + logger.info(`视频任务ID: ${videoTaskID}`, 'CHAT') + const returnBody = { + "id": `chatcmpl-${new Date().getTime()}`, + "object": "chat.completion.chunk", + "created": new Date().getTime(), + "model": response_data.data.model, + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "" + }, + "finish_reason": null + } + ] + } + + // 设置尝试次数 + const maxAttempts = 60 + // 设置每次请求的间隔时间 + const delay = 20 * 1000 + // 循环尝试获取任务状态 + for (let i = 0; i < maxAttempts; i++) { + const content = await getVideoTaskStatus(videoTaskID, token) + if (content) { + returnBody.choices[0].message.content = ` + + +[Download Video](${content}) +` + // 设置响应头 + setResponseHeaders(res, req.body.stream) + + if (req.body.stream) { + res.write(`data: ${JSON.stringify(returnBody)}\n\n`) + res.write(`data: [DONE]\n\n`) + res.end() + } else { + res.json(returnBody) + } + return + } else if (content == null && req.body.stream) { + // 发送空数据保活 + res.write(`data: ${JSON.stringify(returnBody)}\n\n`) + } + + await sleep(delay) + } + } catch (error) { + logger.error('获取视频任务状态失败', 'CHAT', error) + res.status(500).json({ error: error.response_data?.data?.code || "可能该帐号今日生成次数已用完" }) + } +} + +const getVideoTaskStatus = async (videoTaskID, token) => { + try { + const chatBaseUrl = getChatBaseUrl() + const proxyAgent = getProxyAgent() + + const requestConfig = { + headers: { + "Authorization": `Bearer ${token}`, + 'Content-Type': 'application/json', + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', + ...(getSsxmodItna() && { 'Cookie': `ssxmod_itna=${getSsxmodItna()};ssxmod_itna2=${getSsxmodItna2()}` }) + } + } + + // 添加代理配置 + if (proxyAgent) { + requestConfig.httpsAgent = proxyAgent + requestConfig.proxy = false + } + + const response_data = await axios.get(`${chatBaseUrl}/api/v1/tasks/status/${videoTaskID}`, requestConfig) + + if (response_data.data?.task_status == "success") { + logger.info('获取视频任务状态成功', 'CHAT', response_data.data?.content) + return response_data.data?.content + } + logger.info(`获取视频任务 ${videoTaskID} 状态: ${response_data.data?.task_status}`, 'CHAT') + return null + } catch (error) { + console.log(error.response.data) + return null + } +} + +module.exports = { + handleImageVideoCompletion +} \ No newline at end of file diff --git a/src/controllers/chat.js b/src/controllers/chat.js new file mode 100644 index 0000000000000000000000000000000000000000..7ba803c5c6c645014fbfbd07950d3547cbe17d24 --- /dev/null +++ b/src/controllers/chat.js @@ -0,0 +1,449 @@ +const { isJson, generateUUID } = require('../utils/tools.js') +const { createUsageObject } = require('../utils/precise-tokenizer.js') +const { sendChatRequest } = require('../utils/request.js') +const accountManager = require('../utils/account.js') +const config = require('../config/index.js') +const axios = require('axios') +const { logger } = require('../utils/logger') + +/** + * 设置响应头 + * @param {object} res - Express 响应对象 + * @param {boolean} stream - 是否流式响应 + */ +const setResponseHeaders = (res, stream) => { + try { + if (stream) { + res.set({ + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive', + }) + } else { + res.set({ + 'Content-Type': 'application/json', + }) + } + } catch (e) { + logger.error('处理聊天请求时发生错误', 'CHAT', '', e) + } +} + +/** + * 处理流式响应 + * @param {object} res - Express 响应对象 + * @param {object} response - 上游响应流 + * @param {boolean} enable_thinking - 是否启用思考模式 + * @param {boolean} enable_web_search - 是否启用网络搜索 + * @param {object} requestBody - 原始请求体,用于提取prompt信息 + */ +const handleStreamResponse = async (res, response, enable_thinking, enable_web_search, requestBody = null) => { + try { + const message_id = generateUUID() + const decoder = new TextDecoder('utf-8') + let web_search_info = null + let thinking_start = false + let thinking_end = false + let buffer = '' + + // Token消耗量统计 + let totalTokens = { + prompt_tokens: 0, + completion_tokens: 0, + total_tokens: 0 + } + let completionContent = '' // 收集完整的回复内容用于token估算 + + // 提取prompt文本用于token估算 + let promptText = '' + if (requestBody && requestBody.messages) { + promptText = requestBody.messages.map(msg => { + if (typeof msg.content === 'string') { + return msg.content + } else if (Array.isArray(msg.content)) { + return msg.content.map(item => item.text || '').join('') + } + return '' + }).join('\n') + } + + response.on('data', async (chunk) => { + const decodeText = decoder.decode(chunk, { stream: true }) + // console.log(decodeText) + buffer += decodeText + + const chunks = [] + let startIndex = 0 + + while (true) { + const dataStart = buffer.indexOf('data: ', startIndex) + if (dataStart === -1) break + + const dataEnd = buffer.indexOf('\n\n', dataStart) + if (dataEnd === -1) break + + const dataChunk = buffer.substring(dataStart, dataEnd).trim() + chunks.push(dataChunk) + + startIndex = dataEnd + 2 + } + + if (startIndex > 0) { + buffer = buffer.substring(startIndex) + } + + for (const item of chunks) { + try { + let dataContent = item.replace("data: ", '') + let decodeJson = isJson(dataContent) ? JSON.parse(dataContent) : null + if (decodeJson === null || !decodeJson.choices || decodeJson.choices.length === 0) { + continue + } + + // 提取真实的usage信息(如果上游API提供) + if (decodeJson.usage) { + totalTokens = { + prompt_tokens: decodeJson.usage.prompt_tokens || totalTokens.prompt_tokens, + completion_tokens: decodeJson.usage.completion_tokens || totalTokens.completion_tokens, + total_tokens: decodeJson.usage.total_tokens || totalTokens.total_tokens + } + } + + // 处理 web_search 信息 + if (decodeJson.choices[0].delta && decodeJson.choices[0].delta.name === 'web_search') { + web_search_info = decodeJson.choices[0].delta.extra.web_search_info + } + + if (!decodeJson.choices[0].delta || !decodeJson.choices[0].delta.content || + (decodeJson.choices[0].delta.phase !== 'think' && decodeJson.choices[0].delta.phase !== 'answer')) { + continue + } + + let content = decodeJson.choices[0].delta.content + completionContent += content // 累计完整内容用于token估算 + + if (decodeJson.choices[0].delta.phase === 'think' && !thinking_start) { + thinking_start = true + if (web_search_info) { + content = `\n\n${await accountManager.generateMarkdownTable(web_search_info, config.searchInfoMode)}\n\n${content}` + } else { + content = `\n\n${content}` + } + } + if (decodeJson.choices[0].delta.phase === 'answer' && !thinking_end && thinking_start) { + thinking_end = true + content = `\n\n\n${content}` + } + + const StreamTemplate = { + "id": `chatcmpl-${message_id}`, + "object": "chat.completion.chunk", + "created": new Date().getTime(), + "choices": [ + { + "index": 0, + "delta": { + "content": content + }, + "finish_reason": null + } + ] + } + + res.write(`data: ${JSON.stringify(StreamTemplate)}\n\n`) + } catch (error) { + logger.error('流式数据处理错误', 'CHAT', '', error) + res.status(500).json({ error: "服务错误!!!" }) + } + } + }) + + response.on('end', async () => { + try { + // 处理最终的搜索信息 + if ((config.outThink === false || !enable_thinking) && web_search_info && config.searchInfoMode === "text") { + const webSearchTable = await accountManager.generateMarkdownTable(web_search_info, "text") + res.write(`data: ${JSON.stringify({ + "id": `chatcmpl-${message_id}`, + "object": "chat.completion.chunk", + "created": new Date().getTime(), + "choices": [ + { + "index": 0, + "delta": { + "content": `\n\n---\n${webSearchTable}` + }, + "finish_reason": null + } + ] + })}\n\n`) + } + + // 计算最终的token使用量 + if (totalTokens.prompt_tokens === 0 && totalTokens.completion_tokens === 0) { + totalTokens = createUsageObject(requestBody?.messages || promptText, completionContent, null) + logger.info(`流式使用tiktoken计算 - Prompt: ${totalTokens.prompt_tokens}, Completion: ${totalTokens.completion_tokens}, Total: ${totalTokens.total_tokens}`, 'CHAT') + } else { + logger.info(`流式使用上游真实Token - Prompt: ${totalTokens.prompt_tokens}, Completion: ${totalTokens.completion_tokens}, Total: ${totalTokens.total_tokens}`, 'CHAT') + } + + // 确保token数量的有效性 + totalTokens.prompt_tokens = Math.max(0, totalTokens.prompt_tokens || 0) + totalTokens.completion_tokens = Math.max(0, totalTokens.completion_tokens || 0) + totalTokens.total_tokens = totalTokens.prompt_tokens + totalTokens.completion_tokens + + // 发送最终的finish chunk,包含finish_reason + res.write(`data: ${JSON.stringify({ + "id": `chatcmpl-${message_id}`, + "object": "chat.completion.chunk", + "created": new Date().getTime(), + "choices": [ + { + "index": 0, + "delta": {}, + "finish_reason": "stop" + } + ] + })}\n\n`) + + // 发送usage信息chunk(符合OpenAI API标准) + res.write(`data: ${JSON.stringify({ + "id": `chatcmpl-${message_id}`, + "object": "chat.completion.chunk", + "created": new Date().getTime(), + "choices": [], + "usage": totalTokens + })}\n\n`) + + // 发送结束标记 + res.write(`data: [DONE]\n\n`) + res.end() + } catch (e) { + logger.error('流式响应处理错误', 'CHAT', '', e) + res.status(500).json({ error: "服务错误!!!" }) + } + }) + } catch (error) { + logger.error('聊天处理错误', 'CHAT', '', error) + res.status(500).json({ error: "服务错误!!!" }) + } +} + +/** + * 处理非流式响应(从流式数据累积完整响应) + * @param {object} res - Express 响应对象 + * @param {object} response - 上游响应流 + * @param {boolean} enable_thinking - 是否启用思考模式 + * @param {boolean} enable_web_search - 是否启用网络搜索 + * @param {string} model - 模型名称 + * @param {object} requestBody - 原始请求体,用于提取prompt信息 + */ +const handleNonStreamResponse = async (res, response, enable_thinking, enable_web_search, model, requestBody = null) => { + try { + const decoder = new TextDecoder('utf-8') + let buffer = '' + let fullContent = '' + let web_search_info = null + let thinking_start = false + let thinking_end = false + + // Token消耗量统计 + let totalTokens = { + prompt_tokens: 0, + completion_tokens: 0, + total_tokens: 0 + } + + // 提取prompt文本用于token估算 + let promptText = '' + if (requestBody && requestBody.messages) { + promptText = requestBody.messages.map(msg => { + if (typeof msg.content === 'string') { + return msg.content + } else if (Array.isArray(msg.content)) { + return msg.content.map(item => item.text || '').join('') + } + return '' + }).join('\n') + } + + // 处理流式响应并累积内容 + await new Promise((resolve, reject) => { + response.on('data', async (chunk) => { + const decodeText = decoder.decode(chunk, { stream: true }) + buffer += decodeText + + const chunks = [] + let startIndex = 0 + + while (true) { + const dataStart = buffer.indexOf('data: ', startIndex) + if (dataStart === -1) break + + const dataEnd = buffer.indexOf('\n\n', dataStart) + if (dataEnd === -1) break + + const dataChunk = buffer.substring(dataStart, dataEnd).trim() + chunks.push(dataChunk) + + startIndex = dataEnd + 2 + } + + if (startIndex > 0) { + buffer = buffer.substring(startIndex) + } + + for (const item of chunks) { + try { + let dataContent = item.replace("data: ", '') + let decodeJson = isJson(dataContent) ? JSON.parse(dataContent) : null + if (decodeJson === null || !decodeJson.choices || decodeJson.choices.length === 0) { + continue + } + + // 提取真实的usage信息(如果上游API提供) + if (decodeJson.usage) { + totalTokens = { + prompt_tokens: decodeJson.usage.prompt_tokens || totalTokens.prompt_tokens, + completion_tokens: decodeJson.usage.completion_tokens || totalTokens.completion_tokens, + total_tokens: decodeJson.usage.total_tokens || totalTokens.total_tokens + } + } + + // 处理 web_search 信息 + if (decodeJson.choices[0].delta && decodeJson.choices[0].delta.name === 'web_search') { + web_search_info = decodeJson.choices[0].delta.extra.web_search_info + } + + if (!decodeJson.choices[0].delta || !decodeJson.choices[0].delta.content || + (decodeJson.choices[0].delta.phase !== 'think' && decodeJson.choices[0].delta.phase !== 'answer')) { + continue + } + + let content = decodeJson.choices[0].delta.content + + // 处理thinking模式 + if (decodeJson.choices[0].delta.phase === 'think' && !thinking_start) { + thinking_start = true + if (web_search_info) { + const webSearchTable = await accountManager.generateMarkdownTable(web_search_info, config.searchInfoMode) + content = `\n\n${webSearchTable}\n\n${content}` + } else { + content = `\n\n${content}` + } + } + if (decodeJson.choices[0].delta.phase === 'answer' && !thinking_end && thinking_start) { + thinking_end = true + content = `\n\n\n${content}` + } + + fullContent += content + } catch (error) { + logger.error('非流式数据处理错误', 'CHAT', '', error) + } + } + }) + + response.on('end', () => { + resolve() + }) + + response.on('error', (error) => { + logger.error('非流式响应流读取错误', 'CHAT', '', error) + reject(error) + }) + }) + + // 处理最终的搜索信息 + if ((config.outThink === false || !enable_thinking) && web_search_info && config.searchInfoMode === "text") { + const webSearchTable = await accountManager.generateMarkdownTable(web_search_info, "text") + fullContent += `\n\n---\n${webSearchTable}` + } + + // 计算最终的token使用量 + if (totalTokens.prompt_tokens === 0 && totalTokens.completion_tokens === 0) { + totalTokens = createUsageObject(requestBody?.messages || promptText, fullContent, null) + logger.info(`非流式使用tiktoken计算 - Prompt: ${totalTokens.prompt_tokens}, Completion: ${totalTokens.completion_tokens}, Total: ${totalTokens.total_tokens}`, 'CHAT') + } else { + logger.info(`非流式使用上游真实Token - Prompt: ${totalTokens.prompt_tokens}, Completion: ${totalTokens.completion_tokens}, Total: ${totalTokens.total_tokens}`, 'CHAT') + } + + // 确保token数量的有效性 + totalTokens.prompt_tokens = Math.max(0, totalTokens.prompt_tokens || 0) + totalTokens.completion_tokens = Math.max(0, totalTokens.completion_tokens || 0) + totalTokens.total_tokens = totalTokens.prompt_tokens + totalTokens.completion_tokens + + // 返回完整的JSON响应 + const bodyTemplate = { + "id": `chatcmpl-${generateUUID()}`, + "object": "chat.completion", + "created": new Date().getTime(), + "model": model, + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": fullContent + }, + "finish_reason": "stop" + } + ], + "usage": totalTokens + } + res.json(bodyTemplate) + } catch (error) { + logger.error('非流式聊天处理错误', 'CHAT', '', error) + res.status(500) + .json({ + error: "服务错误!!!" + }) + } +} + + +/** + * 主要的聊天完成处理函数 + * @param {object} req - Express 请求对象 + * @param {object} res - Express 响应对象 + */ +const handleChatCompletion = async (req, res) => { + const { stream, model } = req.body + + const enable_thinking = req.enable_thinking + const enable_web_search = req.enable_web_search + + try { + const response_data = await sendChatRequest(req.body) + + if (!response_data.status || !response_data.response) { + res.status(500) + .json({ + error: "请求发送失败!!!" + }) + return + } + + if (stream) { + setResponseHeaders(res, true) + await handleStreamResponse(res, response_data.response, enable_thinking, enable_web_search, req.body) + } else { + setResponseHeaders(res, false) + await handleNonStreamResponse(res, response_data.response, enable_thinking, enable_web_search, model, req.body) + } + + } catch (error) { + logger.error('聊天处理错误', 'CHAT', '', error) + res.status(500) + .json({ + error: "token无效,请求发送失败!!!" + }) + } +} + +module.exports = { + handleChatCompletion, + handleStreamResponse, + handleNonStreamResponse, + setResponseHeaders +} diff --git a/src/controllers/cli.chat.js b/src/controllers/cli.chat.js new file mode 100644 index 0000000000000000000000000000000000000000..02b5260466c7ab4c1ea8038a308a50b2b9fd4207 --- /dev/null +++ b/src/controllers/cli.chat.js @@ -0,0 +1,213 @@ +const axios = require('axios') +const { logger } = require('../utils/logger') +const { getProxyAgent, getCliBaseUrl, applyProxyToAxiosConfig } = require('../utils/proxy-helper') + +const MODEL_REDIRECT = { + 'qwen3.5-plus': 'coder-model', +} + +function preprocessCliRequestBody(rawBody) { + const body = rawBody && typeof rawBody === 'object' ? JSON.parse(JSON.stringify(rawBody)) : {} + + if (body.model && MODEL_REDIRECT[body.model]) { + body.model = MODEL_REDIRECT[body.model] + } + const isStream = body.stream === true + + if (isStream) { + const hasToolsArray = Array.isArray(body.tools) + if (!hasToolsArray || body.tools.length === 0) { + body.tools = [{ + type: 'function', + function: { + name: 'do_not_call_me', + description: 'Do not call this tool.', + parameters: { + type: 'object', + properties: { + operation: { type: 'number', description: 'placeholder' } + }, + required: ['operation'] + } + } + }] + } + + if (!body.stream_options || typeof body.stream_options !== 'object') { + body.stream_options = {} + } + body.stream_options.include_usage = true + } + + return body +} + +function formatCliJsonResponse(data, fallbackModel) { + if (!data || typeof data !== 'object') { + return data + } + if (!data.object) { + data.object = 'chat.completion' + } + if (!data.model && fallbackModel) { + data.model = fallbackModel + } + if (!Array.isArray(data.choices)) { + data.choices = [] + } + return data +} + +/** + * 处理CLI聊天完成请求(支持OpenAI格式的流式和JSON响应) + * @param {Object} req - Express请求对象 + * @param {Object} res - Express响应对象 + */ +const handleCliChatCompletion = async (req, res) => { + try { + const access_token = req.account.cli_info.access_token + const body = preprocessCliRequestBody(req.body) + const isStream = body.stream === true + + // 打印当前使用的账号邮箱 + logger.info(`CLI请求使用账号[${req.account.email}]开始处理`, 'CLI', '🚀') + + // 无论成功失败都增加请求计数 + req.account.cli_info.request_number++ + + const cliBaseUrl = getCliBaseUrl() + const proxyAgent = getProxyAgent() + + // 设置请求配置 + const axiosConfig = { + method: 'POST', + url: `${cliBaseUrl}/v1/chat/completions`, + headers: { + 'Authorization': `Bearer ${access_token}`, + 'Content-Type': 'application/json', + 'Accept': isStream ? 'text/event-stream' : 'application/json', + 'User-Agent': 'QwenCode/0.10.3 (darwin; arm64)', + 'X-Dashscope-Useragent': 'QwenCode/0.10.3 (darwin; arm64)', + 'X-Stainless-Runtime-Version': 'v22.17.0', + 'Sec-Fetch-Mode': 'cors', + 'X-Stainless-Lang': 'js', + 'X-Stainless-Arch': 'arm64', + 'X-Stainless-Package-Version': '5.11.0', + 'X-Dashscope-Cachecontrol': 'enable', + 'X-Stainless-Retry-Count': '0', + 'X-Stainless-Os': 'MacOS', + 'X-Dashscope-Authtype': 'qwen-oauth', + 'X-Stainless-Runtime': 'node' + }, + data: body, + timeout: 5 * 60 * 1000, + validateStatus: function () { + return true + } + } + + // 添加代理配置 + if (proxyAgent) { + axiosConfig.httpsAgent = proxyAgent + axiosConfig.proxy = false + } + + // 如果是流式请求,设置响应类型为流 + if (isStream) { + axiosConfig.responseType = 'stream' + + // 设置响应头为流式 + res.setHeader('Content-Type', 'text/event-stream') + res.setHeader('Cache-Control', 'no-cache') + res.setHeader('Connection', 'keep-alive') + res.setHeader('Access-Control-Allow-Origin', '*') + res.setHeader('Access-Control-Allow-Headers', '*') + } + + const response = await axios(axiosConfig) + + // 检查响应状态 + if (response.status !== 200) { + logger.error(`CLI请求使用账号[${req.account.email}]转发失败 - 状态码: ${response.status} - 当前请求数: ${req.account.cli_info.request_number}`, 'CLI', '❌') + return res.status(response.status).json({ + error: { + message: `api_error`, + type: 'api_error', + code: response.status, + details: response.data + } + }) + } + + // 处理流式响应 + if (isStream) { + // 逐行转发,确保始终输出标准 SSE 片段 + response.data.on('data', (chunk) => { + const text = chunk.toString('utf8') + const lines = text.split('\n') + for (const line of lines) { + if (!line || !line.startsWith('data:')) continue + res.write(`${line}\n\n`) + } + }) + + // 处理流错误 + response.data.on('error', (streamError) => { + logger.error(`CLI请求使用账号[${req.account.email}]流式传输失败 - 当前请求数: ${req.account.cli_info.request_number}`, 'CLI', '❌') + if (!res.headersSent) { + res.status(500).json({ + error: { + message: 'stream_error', + type: 'stream_error', + code: 500 + } + }) + } + }) + + // 处理流结束 + response.data.on('end', () => { + logger.success(`CLI请求使用账号[${req.account.email}]转发成功 (流式) - 当前请求数: ${req.account.cli_info.request_number}`, 'CLI') + res.end() + }) + } else { + // 处理JSON响应 + res.json(formatCliJsonResponse(response.data, body.model)) + logger.success(`CLI请求使用账号[${req.account.email}]转发成功 (JSON) - 当前请求数: ${req.account.cli_info.request_number}`, 'CLI') + } + } catch (error) { + logger.error(`CLI请求使用账号[${req.account.email}]处理异常 - 当前请求数: ${req.account.cli_info.request_number}`, 'CLI', '💥', error.message) + + // 如果是axios错误,提供更详细的错误信息 + if (error.response) { + return res.status(error.response.status).json({ + error: { + message: "api_error", + type: 'api_error', + code: error.response.status, + details: error.response.data + } + }) + } else if (error.request) { + return res.status(503).json({ + error: { + message: 'connection_error', + type: 'connection_error', + code: 503 + } + }) + } else { + return res.status(500).json({ + error: { + message: 'internal_error', + type: 'internal_error', + code: 500 + } + }) + } + } +} + +module.exports = { + handleCliChatCompletion +} diff --git a/src/controllers/models.js b/src/controllers/models.js new file mode 100644 index 0000000000000000000000000000000000000000..038a8016a6795935f0ae0ea2a837385633ac166d --- /dev/null +++ b/src/controllers/models.js @@ -0,0 +1,75 @@ +const { getLatestModels } = require('../models/models-map.js') +const config = require('../config/index.js') + +const handleGetModels = async (req, res) => { + const models = [] + + const ModelsMap = await getLatestModels() + + for (const model of ModelsMap) { + delete model.name + models.push(model) + + if (config.simpleModelMap) { + continue + } + + const isThinking = model?.info?.meta?.abilities?.thinking + const isSearch = model?.info?.meta?.chat_type?.includes('search') + const isImage = model?.info?.meta?.chat_type?.includes('t2i') + const isVideo = model?.info?.meta?.chat_type?.includes('t2v') + const isImageEdit = model?.info?.meta?.chat_type?.includes('image_edit') + const isDeepResearch = model?.info?.meta?.chat_type?.includes('deep_research') + + if (isThinking) { + const newModelData = JSON.parse(JSON.stringify(model)) + newModelData.id = `${model.id}-thinking` + + models.push(newModelData) + } + + if (isSearch) { + const newModelData = JSON.parse(JSON.stringify(model)) + newModelData.id = `${model.id}-search` + models.push(newModelData) + } + + if (isThinking && isSearch) { + const newModelData = JSON.parse(JSON.stringify(model)) + newModelData.id = `${model.id}-thinking-search` + models.push(newModelData) + } + + if (isImage) { + const newModelData = JSON.parse(JSON.stringify(model)) + newModelData.id = `${model.id}-image` + models.push(newModelData) + } + + if (isVideo) { + const newModelData = JSON.parse(JSON.stringify(model)) + newModelData.id = `${model.id}-video` + models.push(newModelData) + } + + if (isImageEdit) { + const newModelData = JSON.parse(JSON.stringify(model)) + newModelData.id = `${model.id}-image-edit` + models.push(newModelData) + } + + // if (isDeepResearch) { + // const newModelData = JSON.parse(JSON.stringify(model)) + // newModelData.id = `${model.id}-deep-research` + // models.push(newModelData) + // } + } + res.json({ + "object": "list", + "data": models + }) +} + +module.exports = { + handleGetModels +} \ No newline at end of file diff --git a/src/middlewares/authorization.js b/src/middlewares/authorization.js new file mode 100644 index 0000000000000000000000000000000000000000..359dd8b79dc6d4f5862a5dd0cf68bfbab9daad44 --- /dev/null +++ b/src/middlewares/authorization.js @@ -0,0 +1,61 @@ +const config = require('../config') + +/** + * 验证API Key是否有效 + * @param {string} providedKey - 提供的API Key + * @returns {Object} 验证结果 { isValid: boolean, isAdmin: boolean } + */ +const validateApiKey = (providedKey) => { + if (!providedKey) { + return { isValid: false, isAdmin: false } + } + + // 移除Bearer前缀 + const cleanKey = providedKey.startsWith('Bearer ') ? providedKey.slice(7) : providedKey + + // 检查是否在有效的API keys列表中 + const isValid = config.apiKeys.includes(cleanKey) + const isAdmin = cleanKey === config.adminKey + + return { isValid, isAdmin } +} + +/** + * API Key验证中间件 - 验证任何有效的API Key + */ +const apiKeyVerify = (req, res, next) => { + const apiKey = req.headers['authorization'] || req.headers['Authorization'] || req.headers['x-api-key'] + const { isValid, isAdmin } = validateApiKey(apiKey) + + if (!isValid) { + return res.status(401).json({ error: 'Unauthorized' }) + } + + // 将权限信息附加到请求对象 + req.isAdmin = isAdmin + req.apiKey = apiKey + next() +} + +/** + * 管理员权限验证中间件 - 只允许管理员API Key + */ +const adminKeyVerify = (req, res, next) => { + const apiKey = req.headers['authorization'] || req.headers['Authorization'] || req.headers['x-api-key'] + const { isValid, isAdmin } = validateApiKey(apiKey) + + if (!isValid || !isAdmin) { + return res.status(403).json({ error: 'Admin access required' }) + } + + req.isAdmin = isAdmin + req.apiKey = apiKey + next() +} + +module.exports = { + apiKeyVerify, + adminKeyVerify, + validateApiKey +} + diff --git a/src/middlewares/chat-middleware.js b/src/middlewares/chat-middleware.js new file mode 100644 index 0000000000000000000000000000000000000000..92dfe4882a2df567a26a2ec3a3cf020be69a8f3a --- /dev/null +++ b/src/middlewares/chat-middleware.js @@ -0,0 +1,79 @@ +const { generateUUID } = require('../utils/tools.js') +const { isChatType, isThinkingEnabled, parserModel, parserMessages } = require('../utils/chat-helpers.js') +const { logger } = require('../utils/logger') + +/** + * 处理聊天请求体的中间件 + * 解析和转换请求参数为内部格式 + */ +const processRequestBody = async (req, res, next) => { + try { + // 构建请求体 + const body = { + "stream": true, + "incremental_output": true, + "chat_type": "t2t", + "model": "qwen3-235b-a22b", + "messages": [], + "session_id": generateUUID(), + "id": generateUUID(), + "sub_chat_type": "t2t", + "chat_mode": "normal" + } + + // 获取请求体原始数据 + let { + messages, // 消息历史 + model, // 模型 + stream, // 流式输出 + enable_thinking, // 是否启用思考 + thinking_budget, // 思考预算 + size //图片尺寸 + } = req.body + + // 处理 stream 参数 + if (stream === true || stream === 'true') { + body.stream = true + } else { + body.stream = false + } + + // 处理 chat_type 参数 : 聊天类型 + body.chat_type = isChatType(model) + + req.enable_web_search = body.chat_type === 'search' ? true : false + + // 处理 model 参数 : 模型 + body.model = parserModel(model) + + // 处理 messages 参数 : 消息历史 + body.messages = await parserMessages(messages, isThinkingEnabled(model, enable_thinking, thinking_budget), body.chat_type) + + // 处理 enable_thinking 参数 : 是否启用思考 + req.enable_thinking = isThinkingEnabled(model, enable_thinking, thinking_budget).thinking_enabled + + // 处理 sub_chat_type 参数 : 子聊天类型 + body.sub_chat_type = body.chat_type + + // 处理图片尺寸 + if (size) { + body.size = size + } + + // 处理请求体,将body赋值给req.body + req.body = body + + next() + } catch (e) { + logger.error('处理请求体时发生错误', 'MIDDLEWARE', '', e) + res.status(500) + .json({ + status: 500, + message: "在处理请求体时发生错误 ~ ~ ~" + }) + } +} + +module.exports = { + processRequestBody +} diff --git a/src/models/models-map.js b/src/models/models-map.js new file mode 100644 index 0000000000000000000000000000000000000000..4e9b2d3c780f5b67b1c15f65574341cc091d4b99 --- /dev/null +++ b/src/models/models-map.js @@ -0,0 +1,52 @@ +const axios = require('axios') +const accountManager = require('../utils/account.js') +const { getSsxmodItna, getSsxmodItna2 } = require('../utils/ssxmod-manager') +const { getProxyAgent, getChatBaseUrl, applyProxyToAxiosConfig } = require('../utils/proxy-helper') + +let cachedModels = null +let fetchPromise = null + +const getLatestModels = async (force = false) => { + // 如果有缓存且不强制刷新,直接返回 + if (cachedModels && !force) { + return cachedModels + } + + // 如果正在获取,返回当前的 Promise + if (fetchPromise) { + return fetchPromise + } + + const chatBaseUrl = getChatBaseUrl() + const proxyAgent = getProxyAgent() + + const requestConfig = { + headers: { + 'Authorization': `Bearer ${accountManager.getAccountToken()}`, + 'Content-Type': 'application/json', + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', + ...(getSsxmodItna() && { 'Cookie': `ssxmod_itna=${getSsxmodItna()};ssxmod_itna2=${getSsxmodItna2()}` }) + } + } + + // 添加代理配置 + if (proxyAgent) { + requestConfig.httpsAgent = proxyAgent + requestConfig.proxy = false + } + + fetchPromise = axios.get(`${chatBaseUrl}/api/models`, requestConfig).then(response => { + // console.log(response) + cachedModels = response.data.data + fetchPromise = null + return cachedModels + }).catch(error => { + console.error('Error fetching latest models:', error) + fetchPromise = null + return [] + }) + + return fetchPromise +} + +module.exports = { getLatestModels } \ No newline at end of file diff --git a/src/routes/accounts.js b/src/routes/accounts.js new file mode 100644 index 0000000000000000000000000000000000000000..b54fc86042abc77ae0299d4256620ba509914292 --- /dev/null +++ b/src/routes/accounts.js @@ -0,0 +1,259 @@ +const express = require('express') +const router = express.Router() +const accountManager = require('../utils/account') +const { logger } = require('../utils/logger') +const { JwtDecode } = require('../utils/tools') +const { adminKeyVerify } = require('../middlewares/authorization') +const { deleteAccount, saveAccounts, refreshAccountToken } = require('../utils/setting') + +/** + * 获取所有账号(分页) + * + * @param {number} page 页码 + * @param {number} pageSize 每页数量 + * @returns {Object} 账号列表 + */ +router.get('/getAllAccounts', adminKeyVerify, async (req, res) => { + try { + const page = parseInt(req.query.page) || 1 + const pageSize = parseInt(req.query.pageSize) || 1000 + const start = (page - 1) * pageSize + + // 获取所有账号键 + const allAccounts = accountManager.getAllAccountKeys() + const total = allAccounts.length + + // 分页处理 + const paginatedAccounts = allAccounts.slice(start, start + pageSize) + + // 获取每个账号的详细信息 + const accounts = paginatedAccounts.map(account => { + return { + email: account.email, + password: account.password, + token: account.token, + expires: account.expires + } + }) + + res.json({ + total, + page, + pageSize, + data: accounts + }) + } catch (error) { + logger.error('获取账号列表失败', 'ACCOUNT', '', error) + res.status(500).json({ error: error.message }) + } +}) + +/** + * POST /setAccount + * 添加账号 + * + * @param {string} email 邮箱 + * @param {string} password 密码 + * @returns {Object} 账号信息 + */ +router.post('/setAccount', adminKeyVerify, async (req, res) => { + try { + const { email, password } = req.body + + if (!email || !password) { + return res.status(400).json({ error: '邮箱和密码不能为空' }) + } + + // 检查账号是否已存在 + const exists = accountManager.accountTokens.find(item => item.email === email) + if (exists) { + return res.status(409).json({ error: '账号已存在' }) + } + + const authToken = await accountManager.login(email, password) + if (!authToken) { + return res.status(401).json({ error: '登录失败' }) + } + // 解析JWT + const decoded = JwtDecode(authToken) + const expires = decoded.exp + + const success = await saveAccounts(email, password, authToken, expires) + + if (success) { + res.status(200).json({ + email, + message: '账号创建成功' + }) + } else { + res.status(500).json({ error: '账号创建失败' }) + } + } catch (error) { + logger.error('创建账号失败', 'ACCOUNT', '', error) + res.status(500).json({ error: error.message }) + } +}) + +/** + * DELETE /deleteAccount + * 删除账号 + * + * @param {string} email 邮箱 + * @returns {Object} 账号信息 + */ +router.delete('/deleteAccount', adminKeyVerify, async (req, res) => { + try { + const { email } = req.body + + // 检查账号是否存在 + const exists = await accountManager.accountTokens.find(item => item.email === email) + if (!exists) { + return res.status(404).json({ error: '账号不存在' }) + } + + // 删除账号 + const success = await deleteAccount(email) + + if (success) { + res.json({ message: '账号删除成功' }) + } else { + res.status(500).json({ error: '账号删除失败' }) + } + } catch (error) { + logger.error('删除账号失败', 'ACCOUNT', '', error) + res.status(500).json({ error: error.message }) + } +}) + + +/** + * POST /setAccounts + * 批量添加账号 + * + * @param {string} accounts 账号列表 + * @returns {Object} 账号信息 + */ +router.post('/setAccounts', adminKeyVerify, async (req, res) => { + try { + let { accounts } = req.body + if (!accounts) { + return res.status(400).json({ error: '账号列表不能为空' }) + } + + accounts = accounts.replace(/[\r]/g, '\n') + accounts = accounts.split('\n').filter(item => item.trim() !== '') + + for (const account of accounts) { + const [email, password] = account.split(':') + if (!email || !password) { + continue + } + + const authToken = await accountManager.login(email, password) + if (!authToken) { + continue + } + // 解析JWT + const decoded = JwtDecode(authToken) + const expires = decoded.exp + + const success = await saveAccounts(email, password, authToken, expires) + if (!success) { + continue + } + } + + res.json({ message: '账号批量添加任务提交成功' }) + } catch (error) { + logger.error('批量创建账号失败', 'ACCOUNT', '', error) + res.status(500).json({ error: error.message }) + } +}) + +/** + * POST /refreshAccount + * 刷新单个账号的令牌 + * + * @param {string} email 邮箱 + * @returns {Object} 刷新结果 + */ +router.post('/refreshAccount', adminKeyVerify, async (req, res) => { + try { + const { email } = req.body + + if (!email) { + return res.status(400).json({ error: '邮箱不能为空' }) + } + + // 检查账号是否存在 + const exists = accountManager.accountTokens.find(item => item.email === email) + if (!exists) { + return res.status(404).json({ error: '账号不存在' }) + } + + // 刷新账号令牌 + const success = await accountManager.refreshAccountToken(email) + + if (success) { + res.json({ + message: '账号令牌刷新成功', + email: email + }) + } else { + res.status(500).json({ error: '账号令牌刷新失败' }) + } + } catch (error) { + logger.error('刷新账号令牌失败', 'ACCOUNT', '', error) + res.status(500).json({ error: error.message }) + } +}) + +/** + * POST /refreshAllAccounts + * 刷新所有账号的令牌 + * + * @param {number} thresholdHours 过期阈值(小时),默认24小时 + * @returns {Object} 刷新结果 + */ +router.post('/refreshAllAccounts', adminKeyVerify, async (req, res) => { + try { + const { thresholdHours = 24 } = req.body + + // 执行批量刷新 + const refreshedCount = await accountManager.autoRefreshTokens(thresholdHours) + + res.json({ + message: '批量刷新完成', + refreshedCount: refreshedCount, + thresholdHours: thresholdHours + }) + } catch (error) { + logger.error('批量刷新账号令牌失败', 'ACCOUNT', '', error) + res.status(500).json({ error: error.message }) + } +}) + +/** + * POST /forceRefreshAllAccounts + * 强制刷新所有账号的令牌(不管是否即将过期) + * + * @returns {Object} 刷新结果 + */ +router.post('/forceRefreshAllAccounts', adminKeyVerify, async (req, res) => { + try { + // 强制刷新所有账号(设置阈值为很大的值,确保所有账号都会被刷新) + const refreshedCount = await accountManager.autoRefreshTokens(8760) // 365天 + + res.json({ + message: '强制刷新完成', + refreshedCount: refreshedCount, + totalAccounts: accountManager.getAllAccountKeys().length + }) + } catch (error) { + logger.error('强制刷新账号令牌失败', 'ACCOUNT', '', error) + res.status(500).json({ error: error.message }) + } +}) + + +module.exports = router \ No newline at end of file diff --git a/src/routes/chat.js b/src/routes/chat.js new file mode 100644 index 0000000000000000000000000000000000000000..16c78f07510b6d22ddbc1afdd67e7aee2a79de45 --- /dev/null +++ b/src/routes/chat.js @@ -0,0 +1,34 @@ +const express = require('express') +const router = express.Router() +const { apiKeyVerify } = require('../middlewares/authorization.js') +const { processRequestBody } = require('../middlewares/chat-middleware.js') +const { handleChatCompletion } = require('../controllers/chat.js') +const { handleImageVideoCompletion } = require('../controllers/chat.image.video.js') + +const selectChatCompletion = (req, res, next) => { + const ChatCompletionMap = { + 't2t': handleChatCompletion, + 'search': handleChatCompletion, + 't2i': handleImageVideoCompletion, + 't2v': handleImageVideoCompletion, + 'image_edit': handleImageVideoCompletion, + // 'deep_research': handleDeepResearchCompletion + } + + const chatType = req.body.chat_type + const chatCompletion = ChatCompletionMap[chatType] + if (chatCompletion) { + chatCompletion(req, res, next) + } else { + handleImageCompletion(req, res, next) + } +} + +router.post('/v1/chat/completions', + apiKeyVerify, + processRequestBody, + selectChatCompletion +) + + +module.exports = router \ No newline at end of file diff --git a/src/routes/cli.chat.js b/src/routes/cli.chat.js new file mode 100644 index 0000000000000000000000000000000000000000..c46457db5b973f7d229d51c3dd86704af3d9329e --- /dev/null +++ b/src/routes/cli.chat.js @@ -0,0 +1,39 @@ +const express = require('express') +const router = express.Router() +const { apiKeyVerify } = require('../middlewares/authorization.js') +const { handleCliChatCompletion } = require('../controllers/cli.chat.js') +const accountManager = require('../utils/account.js') + +router.post('/cli/v1/chat/completions', + apiKeyVerify, + async (req, res, next) => { + // 异步初始化新账号(不阻塞当前请求) + const noCliAccount = accountManager.accountTokens.filter(account => !account.cli_info) + if (noCliAccount.length > 0) { + const randomNewAccount = noCliAccount[Math.floor(Math.random() * noCliAccount.length)] + // 异步初始化,不等待结果 + accountManager.initializeCliForAccount(randomNewAccount).catch(error => { + console.error(`异步初始化CLI账户失败 (${randomNewAccount.email}):`, error) + }) + } + + // 获取当前可用的CLI账户用于本次请求 + const availableAccounts = accountManager.accountTokens.filter(account => + account.cli_info && account.cli_info.request_number < 2000 + ) + + if (availableAccounts.length === 0) { + return res.status(503).json({ + error: '没有可用的CLI账户,请稍后重试' + }) + } + + // 随机选择一个可用账户用于本次请求 + const randomAccount = availableAccounts[Math.floor(Math.random() * availableAccounts.length)] + req.account = randomAccount + next() + }, + handleCliChatCompletion +) + +module.exports = router \ No newline at end of file diff --git a/src/routes/models.js b/src/routes/models.js new file mode 100644 index 0000000000000000000000000000000000000000..ef5ad8377f8a367d1801a118795c2ee844f22f4c --- /dev/null +++ b/src/routes/models.js @@ -0,0 +1,31 @@ +const express = require('express') +const router = express.Router() +const { apiKeyVerify } = require('../middlewares/authorization') +const { handleGetModels } = require('../controllers/models.js') + +router.get('/v1/models', apiKeyVerify, handleGetModels) + +router.get('/models', handleGetModels) + +router.post('/cli/v1/models', async (req, res) => { + res.json({ + object: 'list', + data: [ + { + id: 'qwen3-coder-plus', + object: 'model', + created: 1719878112, + owned_by: 'qwen-code' + }, + { + id: 'qwen3-coder-flash', + object: 'model', + created: 1719878112, + owned_by: 'qwen-code' + }, + ] + }) +}) + + +module.exports = router diff --git a/src/routes/settings.js b/src/routes/settings.js new file mode 100644 index 0000000000000000000000000000000000000000..8ab4f7e8f73491728f0ec805aa497bdfaaa79536 --- /dev/null +++ b/src/routes/settings.js @@ -0,0 +1,161 @@ +const express = require('express') +const router = express.Router() +const config = require('../config') +const { apiKeyVerify, adminKeyVerify } = require('../middlewares/authorization') +const { logger } = require('../utils/logger') + + +router.get('/settings', adminKeyVerify, async (req, res) => { + // 分离管理员密钥和普通密钥 + const regularKeys = config.apiKeys.filter(key => key !== config.adminKey) + + res.json({ + apiKey: config.apiKey, // 保持向后兼容 + adminKey: config.adminKey, + regularKeys: regularKeys, + defaultHeaders: config.defaultHeaders, + defaultCookie: config.defaultCookie, + autoRefresh: config.autoRefresh, + autoRefreshInterval: config.autoRefreshInterval, + outThink: config.outThink, + searchInfoMode: config.searchInfoMode, + simpleModelMap: config.simpleModelMap + }) +}) + +// 添加普通API Key +router.post('/addRegularKey', adminKeyVerify, async (req, res) => { + try { + const { apiKey } = req.body + if (!apiKey) { + return res.status(400).json({ error: 'API Key不能为空' }) + } + + // 检查是否已存在 + if (config.apiKeys.includes(apiKey)) { + return res.status(409).json({ error: 'API Key已存在' }) + } + + // 添加到配置中 + config.apiKeys.push(apiKey) + + res.json({ message: 'API Key添加成功' }) + } catch (error) { + logger.error('添加API Key失败', 'CONFIG', '', error) + res.status(500).json({ error: error.message }) + } +}) + +// 删除普通API Key +router.post('/deleteRegularKey', adminKeyVerify, async (req, res) => { + try { + const { apiKey } = req.body + if (!apiKey) { + return res.status(400).json({ error: 'API Key不能为空' }) + } + + // 不能删除管理员密钥 + if (apiKey === config.adminKey) { + return res.status(403).json({ error: '不能删除管理员密钥' }) + } + + // 从配置中移除 + const index = config.apiKeys.indexOf(apiKey) + if (index === -1) { + return res.status(404).json({ error: 'API Key不存在' }) + } + + config.apiKeys.splice(index, 1) + + res.json({ message: 'API Key删除成功' }) + } catch (error) { + logger.error('删除API Key失败', 'CONFIG', '', error) + res.status(500).json({ error: error.message }) + } +}) + +// 更新自动刷新设置 +router.post('/setAutoRefresh', adminKeyVerify, async (req, res) => { + try { + const { autoRefresh, autoRefreshInterval } = req.body + + if (typeof autoRefresh !== 'boolean') { + return res.status(400).json({ error: '无效的自动刷新设置' }) + } + + if (autoRefreshInterval !== undefined) { + const interval = parseInt(autoRefreshInterval) + if (isNaN(interval) || interval < 0) { + return res.status(400).json({ error: '无效的自动刷新间隔' }) + } + } + config.autoRefresh = autoRefresh + config.autoRefreshInterval = autoRefreshInterval || 6 * 60 * 60 + res.json({ + status: true, + message: '自动刷新设置更新成功' + }) + } catch (error) { + logger.error('更新自动刷新设置失败', 'CONFIG', '', error) + res.status(500).json({ error: error.message }) + } +}) + +// 更新思考输出设置 +router.post('/setOutThink', adminKeyVerify, async (req, res) => { + try { + const { outThink } = req.body; + if (typeof outThink !== 'boolean') { + return res.status(400).json({ error: '无效的思考输出设置' }) + } + + config.outThink = outThink + res.json({ + status: true, + message: '思考输出设置更新成功' + }) + } catch (error) { + logger.error('更新思考输出设置失败', 'CONFIG', '', error) + res.status(500).json({ error: error.message }) + } +}) + +// 更新搜索信息模式 +router.post('/search-info-mode', adminKeyVerify, async (req, res) => { + try { + const { searchInfoMode } = req.body + if (!['table', 'text'].includes(searchInfoMode)) { + return res.status(400).json({ error: '无效的搜索信息模式' }) + } + + config.searchInfoMode = searchInfoMode + res.json({ + status: true, + message: '搜索信息模式更新成功' + }) + } catch (error) { + logger.error('更新搜索信息模式失败', 'CONFIG', '', error) + res.status(500).json({ error: error.message }) + } +}) + +// 更新简化模型映射设置 +router.post('/simple-model-map', adminKeyVerify, async (req, res) => { + try { + const { simpleModelMap } = req.body + if (typeof simpleModelMap !== 'boolean') { + return res.status(400).json({ error: '无效的简化模型映射设置' }) + } + + config.simpleModelMap = simpleModelMap + res.json({ + status: true, + message: '简化模型映射设置更新成功' + }) + } catch (error) { + logger.error('更新简化模型映射设置失败', 'CONFIG', '', error) + res.status(500).json({ error: error.message }) + } +}) + +module.exports = router \ No newline at end of file diff --git a/src/routes/verify.js b/src/routes/verify.js new file mode 100644 index 0000000000000000000000000000000000000000..cf55a868b63002ac2f8347a35c080190c13d5a82 --- /dev/null +++ b/src/routes/verify.js @@ -0,0 +1,24 @@ +const express = require('express') +const router = express.Router() +const config = require('../config/index.js') +const { validateApiKey } = require('../middlewares/authorization') + +router.post('/verify', (req, res) => { + const apiKey = req.body.apiKey + const { isValid, isAdmin } = validateApiKey(apiKey) + + if (!isValid) { + return res.status(401).json({ + status: 401, + message: 'Unauthorized' + }) + } + + res.status(200).json({ + status: 200, + message: 'success', + isAdmin: isAdmin + }) +}) + +module.exports = router diff --git a/src/server.js b/src/server.js new file mode 100644 index 0000000000000000000000000000000000000000..1578e3ad27721c607cb14a1f62bc970194ca763c --- /dev/null +++ b/src/server.js @@ -0,0 +1,81 @@ +const express = require('express') +const bodyParser = require('body-parser') +const config = require('./config/index.js') +const cors = require('cors') +const { logger } = require('./utils/logger') +const { initSsxmodManager } = require('./utils/ssxmod-manager') +const app = express() +const path = require('path') +const fs = require('fs') +const paths = require('./utils/paths') +const modelsRouter = require('./routes/models.js') +const chatRouter = require('./routes/chat.js') +const cliChatRouter = require('./routes/cli.chat.js') +const verifyRouter = require('./routes/verify.js') +const accountsRouter = require('./routes/accounts.js') +const settingsRouter = require('./routes/settings.js') + +if (config.dataSaveMode === 'file') { + if (!fs.existsSync(paths.dataFilePath)) { + fs.mkdirSync(paths.dataDir, { recursive: true }) + fs.writeFileSync(paths.dataFilePath, JSON.stringify({"accounts": [] }, null, 2)) + } +} + +// 初始化 SSXMOD Cookie 管理器 +initSsxmodManager() + +app.use(bodyParser.json({ limit: '128mb' })) +app.use(bodyParser.urlencoded({ limit: '128mb', extended: true })) +app.use(cors()) + +// API路由 +app.use(modelsRouter) +app.use(chatRouter) +app.use(cliChatRouter) +app.use(verifyRouter) +app.use('/api', accountsRouter) +app.use('/api', settingsRouter) + +app.use(express.static(path.join(__dirname, '../public/dist'))) + +app.get('*', (req, res) => { + res.sendFile(path.join(__dirname, '../public/dist/index.html'), (err) => { + if (err) { + logger.error('管理页面加载失败', 'SERVER', '', err) + res.status(500).send('服务器内部错误') + } + }) +}) + +// 处理错误中间件(必须放在所有路由之后) +app.use((err, req, res, next) => { + logger.error('服务器内部错误', 'SERVER', '', err) + res.status(500).send('服务器内部错误') +}) + + +// 服务器启动信息 +const serverInfo = { + address: config.listenAddress || 'localhost', + port: config.listenPort, + outThink: config.outThink ? '开启' : '关闭', + searchInfoMode: config.searchInfoMode === 'table' ? '表格' : '文本', + dataSaveMode: config.dataSaveMode, + logLevel: config.logLevel, + enableFileLog: config.enableFileLog +} + +if (config.listenAddress) { + app.listen(config.listenPort, config.listenAddress, () => { + logger.server('服务器启动成功', 'SERVER', serverInfo) + logger.info('开源地址: https://github.com/Rfym21/Qwen2API', 'INFO') + logger.info('电报群聊: https://t.me/nodejs_project', 'INFO') + }) +} else { + app.listen(config.listenPort, () => { + logger.server('服务器启动成功', 'SERVER', serverInfo) + logger.info('开源地址: https://github.com/Rfym21/Qwen2API', 'INFO') + logger.info('电报群聊: https://t.me/nodejs_project', 'INFO') + }) +} diff --git a/src/start.js b/src/start.js new file mode 100644 index 0000000000000000000000000000000000000000..518af3add2475894855767d226dddff6dd32d9d3 --- /dev/null +++ b/src/start.js @@ -0,0 +1,113 @@ +const cluster = require('cluster') +const os = require('os') +const { logger } = require('./utils/logger') + +// 加载环境变量 +require('dotenv').config() + +// 获取CPU核心数 +const cpuCores = os.cpus().length + +// 获取环境变量配置 +const PM2_INSTANCES = process.env.PM2_INSTANCES || '1' +const SERVICE_PORT = process.env.SERVICE_PORT || 3000 +const NODE_ENV = process.env.NODE_ENV || 'production' + +// 解析进程数 +let instances +if (PM2_INSTANCES === 'max') { + instances = cpuCores +} else if (!isNaN(PM2_INSTANCES)) { + instances = parseInt(PM2_INSTANCES) +} else { + instances = 1 +} + +// 限制进程数不能超过CPU核心数 +if (instances > cpuCores) { + logger.warn(`配置的进程数(${instances})超过CPU核心数(${cpuCores}),自动调整为${cpuCores}`, 'AUTO') + instances = cpuCores +} + +logger.info('🚀 Qwen2API 智能启动', 'AUTO') +logger.info(`CPU核心数: ${cpuCores}`, 'AUTO') +logger.info(`配置的进程数: ${PM2_INSTANCES}`, 'AUTO') +logger.info(`实际启动进程数: ${instances}`, 'AUTO') +logger.info(`服务端口: ${SERVICE_PORT}`, 'AUTO') + +// 智能判断启动方式 +if (instances === 1) { + logger.info('📦 使用单进程模式启动', 'AUTO') + // 直接启动服务器 + require('./server.js') +} else { + // 检查是否通过PM2启动 + if (process.env.PM2_USAGE || process.env.pm_id !== undefined) { + logger.info(`PM2进程启动 - 进程ID: ${process.pid}, 工作进程ID: ${process.env.pm_id || 'unknown'}`, 'PM2') + require('./server.js') + } else if (cluster.isMaster) { + logger.info(`🔥 使用Node.js集群模式启动 (${instances}个进程)`, 'AUTO') + + logger.info(`启动主进程 - PID: ${process.pid}`, 'CLUSTER') + logger.info(`运行环境: ${NODE_ENV}`, 'CLUSTER') + + // 创建工作进程 + for (let i = 0; i < instances; i++) { + const worker = cluster.fork() + logger.info(`启动工作进程 ${i + 1}/${instances} - PID: ${worker.process.pid}`, 'CLUSTER') + } + + // 监听工作进程退出 + cluster.on('exit', (worker, code, signal) => { + logger.error(`工作进程 ${worker.process.pid} 已退出 - 退出码: ${code}, 信号: ${signal}`, 'CLUSTER') + + // 自动重启工作进程 + if (!worker.exitedAfterDisconnect) { + logger.info('正在重启工作进程...', 'CLUSTER') + const newWorker = cluster.fork() + logger.info(`新工作进程已启动 - PID: ${newWorker.process.pid}`, 'CLUSTER') + } + }) + + // 监听工作进程在线 + cluster.on('online', (worker) => { + logger.info(`工作进程 ${worker.process.pid} 已上线`, 'CLUSTER') + }) + + // 监听工作进程断开连接 + cluster.on('disconnect', (worker) => { + logger.warn(`工作进程 ${worker.process.pid} 已断开连接`, 'CLUSTER') + }) + + // 优雅关闭处理 + process.on('SIGTERM', () => { + logger.info('收到SIGTERM信号,正在优雅关闭...', 'CLUSTER') + cluster.disconnect(() => { + process.exit(0) + }) + }) + + process.on('SIGINT', () => { + logger.info('收到SIGINT信号,正在优雅关闭...', 'CLUSTER') + cluster.disconnect(() => { + process.exit(0) + }) + }) + + } else { + // 工作进程逻辑 + logger.info(`工作进程启动 - PID: ${process.pid}`, 'WORKER') + require('./server.js') + + // 工作进程优雅关闭处理 + process.on('SIGTERM', () => { + logger.info(`工作进程 ${process.pid} 收到SIGTERM信号,正在关闭...`, 'WORKER') + process.exit(0) + }) + + process.on('SIGINT', () => { + logger.info(`工作进程 ${process.pid} 收到SIGINT信号,正在关闭...`, 'WORKER') + process.exit(0) + }) + } +} diff --git a/src/utils/account-rotator.js b/src/utils/account-rotator.js new file mode 100644 index 0000000000000000000000000000000000000000..9f2d7fc88f8caccff1feed0629d5796bc0ffaedf --- /dev/null +++ b/src/utils/account-rotator.js @@ -0,0 +1,247 @@ +const { logger } = require('./logger') + +/** + * 账户轮询管理器 + * 负责账户的轮询选择和负载均衡 + */ +class AccountRotator { + constructor() { + this.accounts = [] + this.currentIndex = 0 + this.lastUsedTimes = new Map() // 记录每个账户的最后使用时间 + this.failureCounts = new Map() // 记录每个账户的失败次数 + this.maxFailures = 3 // 最大失败次数 + this.cooldownPeriod = 5 * 60 * 1000 // 5分钟冷却期 + } + + /** + * 设置账户列表 + * @param {Array} accounts - 账户列表 + */ + setAccounts(accounts) { + if (!Array.isArray(accounts)) { + logger.error('账户列表必须是数组', 'ACCOUNT') + throw new Error('账户列表必须是数组') + } + + this.accounts = [...accounts] + this.currentIndex = 0 + + // 清理不存在账户的记录 + this._cleanupRecords() + } + + /** + * 获取下一个可用的账户令牌 + * @returns {string|null} 账户令牌或null + */ + getNextToken() { + if (this.accounts.length === 0) { + logger.error('没有可用的账户', 'ACCOUNT') + return null + } + + const availableAccounts = this._getAvailableAccounts() + if (availableAccounts.length === 0) { + logger.warn('所有账户都不可用,使用轮询策略', 'ACCOUNT') + return this._getTokenByRoundRobin() + } + + // 从可用账户中选择最少使用的 + const selectedAccount = this._selectLeastUsedAccount(availableAccounts) + this._recordUsage(selectedAccount.email) + + return selectedAccount.token + } + + /** + * 获取指定邮箱的账户令牌 + * @param {string} email - 邮箱地址 + * @returns {string|null} 账户令牌或null + */ + getTokenByEmail(email) { + const account = this.accounts.find(acc => acc.email === email) + if (!account) { + logger.error(`未找到邮箱为 ${email} 的账户`, 'ACCOUNT') + return null + } + + if (!this._isAccountAvailable(account)) { + logger.warn(`账户 ${email} 当前不可用`, 'ACCOUNT') + return null + } + + this._recordUsage(email) + return account.token + } + + /** + * 记录账户使用失败 + * @param {string} email - 邮箱地址 + */ + recordFailure(email) { + const currentFailures = this.failureCounts.get(email) || 0 + this.failureCounts.set(email, currentFailures + 1) + + if (currentFailures + 1 >= this.maxFailures) { + logger.warn(`账户 ${email} 失败次数达到上限,将进入冷却期`, 'ACCOUNT') + } + } + + /** + * 重置账户失败计数 + * @param {string} email - 邮箱地址 + */ + resetFailures(email) { + this.failureCounts.delete(email) + } + + /** + * 获取账户统计信息 + * @returns {Object} 统计信息 + */ + getStats() { + const total = this.accounts.length + const available = this._getAvailableAccounts().length + const inCooldown = total - available + + const usageStats = {} + this.accounts.forEach(account => { + const email = account.email + usageStats[email] = { + failures: this.failureCounts.get(email) || 0, + lastUsed: this.lastUsedTimes.get(email) || null, + available: this._isAccountAvailable(account) + } + }) + + return { + total, + available, + inCooldown, + currentIndex: this.currentIndex, + usageStats + } + } + + /** + * 获取可用账户列表 + * @private + */ + _getAvailableAccounts() { + return this.accounts.filter(account => this._isAccountAvailable(account)) + } + + /** + * 检查账户是否可用 + * @param {Object} account - 账户对象 + * @returns {boolean} 是否可用 + * @private + */ + _isAccountAvailable(account) { + if (!account.token) { + return false + } + + const failures = this.failureCounts.get(account.email) || 0 + if (failures >= this.maxFailures) { + const lastUsed = this.lastUsedTimes.get(account.email) + if (lastUsed && Date.now() - lastUsed < this.cooldownPeriod) { + return false // 仍在冷却期 + } else { + // 冷却期结束,重置失败计数 + this.failureCounts.delete(account.email) + } + } + + return true + } + + /** + * 选择最少使用的账户 + * @param {Array} accounts - 可用账户列表 + * @returns {Object} 选中的账户 + * @private + */ + _selectLeastUsedAccount(accounts) { + if (accounts.length === 1) { + return accounts[0] + } + + // 按最后使用时间排序,选择最久未使用的 + return accounts.reduce((least, current) => { + const leastLastUsed = this.lastUsedTimes.get(least.email) || 0 + const currentLastUsed = this.lastUsedTimes.get(current.email) || 0 + + return currentLastUsed < leastLastUsed ? current : least + }) + } + + /** + * 轮询策略获取令牌 + * @returns {string|null} 账户令牌或null + * @private + */ + _getTokenByRoundRobin() { + if (this.currentIndex >= this.accounts.length) { + this.currentIndex = 0 + } + + const account = this.accounts[this.currentIndex] + this.currentIndex++ + + if (account && account.token) { + this._recordUsage(account.email) + return account.token + } + + // 如果当前账户无效,尝试下一个 + if (this.currentIndex < this.accounts.length) { + return this._getTokenByRoundRobin() + } + + return null + } + + /** + * 记录账户使用 + * @param {string} email - 邮箱地址 + * @private + */ + _recordUsage(email) { + this.lastUsedTimes.set(email, Date.now()) + } + + /** + * 清理不存在账户的记录 + * @private + */ + _cleanupRecords() { + const currentEmails = new Set(this.accounts.map(acc => acc.email)) + + // 清理失败计数记录 + for (const email of this.failureCounts.keys()) { + if (!currentEmails.has(email)) { + this.failureCounts.delete(email) + } + } + + // 清理使用时间记录 + for (const email of this.lastUsedTimes.keys()) { + if (!currentEmails.has(email)) { + this.lastUsedTimes.delete(email) + } + } + } + + /** + * 重置所有统计数据 + */ + reset() { + this.currentIndex = 0 + this.lastUsedTimes.clear() + this.failureCounts.clear() + } +} + +module.exports = AccountRotator diff --git a/src/utils/account.js b/src/utils/account.js new file mode 100644 index 0000000000000000000000000000000000000000..125d79aa8345f13519d127591dd2b46ac75c9063 --- /dev/null +++ b/src/utils/account.js @@ -0,0 +1,670 @@ +const config = require('../config/index.js') +const DataPersistence = require('./data-persistence') +const TokenManager = require('./token-manager') +const AccountRotator = require('./account-rotator') +const { logger } = require('./logger') +/** + * 账户管理器 + * 统一管理账户、令牌、模型等功能 + */ +class Account { + constructor() { + // 初始化各个管理器 + this.dataPersistence = new DataPersistence() + this.tokenManager = new TokenManager() + this.accountRotator = new AccountRotator() + + // 账户数据 + this.accountTokens = [] + this.isInitialized = false + + // 配置信息 + this.defaultHeaders = config.defaultHeaders || {} + + // cli请求次数定时刷新器 + this.cliRequestNumberInterval = null + this.cliDailyResetInterval = null + + // 初始化 + this._initialize() + } + + /** + * 异步初始化 + * @private + */ + async _initialize() { + try { + // 加载账户信息 + await this.loadAccountTokens() + + // 设置定期刷新令牌 + if (config.autoRefresh) { + this.refreshInterval = setInterval( + () => this.autoRefreshTokens(), + (config.autoRefreshInterval || 21600) * 1000 // 默认6小时 + ) + } + + this.isInitialized = true + logger.success(`账户管理器初始化完成,共加载 ${this.accountTokens.length} 个账户`, 'ACCOUNT') + } catch (error) { + logger.error('账户管理器初始化失败', 'ACCOUNT', '', error) + } + } + + /** + * 加载账户令牌数据 + * @returns {Promise} + */ + async loadAccountTokens() { + try { + this.accountTokens = await this.dataPersistence.loadAccounts() + + // 如果是环境变量模式,需要进行登录获取令牌 + if (config.dataSaveMode === 'none' && this.accountTokens.length > 0) { + await this._loginEnvironmentAccounts() + } + + // 验证和清理无效令牌 + await this._validateAndCleanTokens() + + // 更新账户轮询器 + this.accountRotator.setAccounts(this.accountTokens) + + // 初始化 CLI 账户,随机初始化一个账号 + if (this.accountTokens.length > 0) { + const randomIndex = Math.floor(Math.random() * this.accountTokens.length) + const randomAccount = this.accountTokens[randomIndex] + logger.info(`初始化 CLI 账户, 随机初始化账号: ${randomAccount.email}`, 'ACCOUNT') + await this._initializeCliAccount(randomAccount) + } + + // 设置cli定时器 每天00:00:00刷新请求次数 + this._setupDailyResetTimer() + + logger.success(`成功加载 ${this.accountTokens.length} 个账户`, 'ACCOUNT') + } catch (error) { + logger.error('加载账户令牌失败', 'ACCOUNT', '', error) + this.accountTokens = [] + } + } + + /** + * 为环境变量模式的账户进行登录 + * @private + */ + async _loginEnvironmentAccounts() { + const loginPromises = this.accountTokens.map(async (account) => { + if (!account.token && account.email && account.password) { + const token = await this.tokenManager.login(account.email, account.password) + if (token) { + const decoded = this.tokenManager.validateToken(token) + if (decoded) { + account.token = token + account.expires = decoded.exp + } + } + } + return account + }) + + this.accountTokens = await Promise.all(loginPromises) + } + + /** + * 初始化CLI账户 + * @param {Object} account - 账户对象 + * @private + */ + async _initializeCliAccount(account) { + try { + const cliManager = require('./cli.manager') + const cliAccount = await cliManager.initCliAccount(account.token) + + if (cliAccount.access_token && cliAccount.refresh_token && cliAccount.expiry_date) { + account.cli_info = { + access_token: cliAccount.access_token, + refresh_token: cliAccount.refresh_token, + expiry_date: cliAccount.expiry_date, + refresh_token_interval: setInterval(async () => { + try { + const refreshToken = await cliManager.refreshAccessToken({ + access_token: account.cli_info.access_token, + refresh_token: account.cli_info.refresh_token, + expiry_date: account.cli_info.expiry_date + }) + if (refreshToken.access_token && refreshToken.refresh_token && refreshToken.expiry_date) { + account.cli_info.access_token = refreshToken.access_token + account.cli_info.refresh_token = refreshToken.refresh_token + account.cli_info.expiry_date = refreshToken.expiry_date + logger.info(`CLI账户 ${account.email} 令牌刷新成功`, 'CLI') + } + } catch (error) { + logger.error(`CLI账户 ${account.email} 令牌刷新失败`, 'CLI', '', error) + } + // 每2小时刷新一次 + }, 1000 * 60 * 60 * 2), + request_number: 0 + } + logger.success(`CLI账户 ${account.email} 初始化成功`, 'CLI') + } else { + logger.error(`CLI账户 ${account.email} 初始化失败:无效的响应数据`, 'CLI') + } + } catch (error) { + logger.error(`CLI账户 ${account.email} 初始化失败`, 'CLI', '', error) + } + } + + /** + * 设置每日重置定时器 + * @private + */ + _setupDailyResetTimer() { + logger.info('设置CLI请求次数每日重置定时器', 'CLI') + + // 计算到下一天00:00:00的毫秒数 + const now = new Date() + const tomorrow = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1, 0, 0, 0) + const timeDiff = tomorrow.getTime() - now.getTime() + + logger.info(`距离下次重置还有 ${Math.round(timeDiff / 1000 / 60)} 分钟`, 'CLI') + + // 首次执行使用setTimeout + this.cliRequestNumberInterval = setTimeout(() => { + // 重置所有CLI账户的请求次数 + this._resetCliRequestNumbers() + + // 设置每24小时执行一次的定时器 + this.cliDailyResetInterval = setInterval(() => { + this._resetCliRequestNumbers() + }, 24 * 60 * 60 * 1000) + }, timeDiff) + } + + /** + * 重置CLI请求次数 + * @private + */ + _resetCliRequestNumbers() { + const cliAccounts = this.accountTokens.filter(account => account.cli_info) + cliAccounts.forEach(account => { + account.cli_info.request_number = 0 + }) + logger.info(`已重置 ${cliAccounts.length} 个CLI账户的请求次数`, 'CLI') + } + + /** + * 验证和清理无效令牌 + * @private + */ + async _validateAndCleanTokens() { + const validAccounts = [] + + for (const account of this.accountTokens) { + if (account.token && this.tokenManager.validateToken(account.token)) { + validAccounts.push(account) + } else if (account.email && account.password) { + // 尝试重新登录 + logger.info(`令牌无效,尝试重新登录: ${account.email}`, 'TOKEN', '🔄') + const newToken = await this.tokenManager.login(account.email, account.password) + if (newToken) { + const decoded = this.tokenManager.validateToken(newToken) + if (decoded) { + account.token = newToken + account.expires = decoded.exp + validAccounts.push(account) + } + } + } + } + + this.accountTokens = validAccounts + } + + + /** + * 自动刷新即将过期的令牌 + * @param {number} thresholdHours - 过期阈值(小时) + * @returns {Promise} 成功刷新的令牌数量 + */ + async autoRefreshTokens(thresholdHours = 24) { + if (!this.isInitialized) { + logger.warn('账户管理器尚未初始化,跳过自动刷新', 'TOKEN') + return 0 + } + + logger.info('开始自动刷新令牌...', 'TOKEN', '🔄') + + // 获取需要刷新的账户 + const needsRefresh = this.accountTokens.filter(account => + this.tokenManager.isTokenExpiringSoon(account.token, thresholdHours) + ) + + if (needsRefresh.length === 0) { + logger.info('没有需要刷新的令牌', 'TOKEN') + return 0 + } + + logger.info(`发现 ${needsRefresh.length} 个令牌需要刷新`, 'TOKEN') + + let successCount = 0 + let failedCount = 0 + + // 逐个刷新账户,每次成功后立即保存 + for (const account of needsRefresh) { + try { + const updatedAccount = await this.tokenManager.refreshToken(account) + if (updatedAccount) { + // 立即更新内存中的账户数据 + const index = this.accountTokens.findIndex(acc => acc.email === account.email) + if (index !== -1) { + this.accountTokens[index] = updatedAccount + } + + // 立即保存到持久化存储 + await this.dataPersistence.saveAccount(account.email, { + password: updatedAccount.password, + token: updatedAccount.token, + expires: updatedAccount.expires + }) + + // 重置失败计数 + this.accountRotator.resetFailures(account.email) + successCount++ + + logger.info(`账户 ${account.email} 令牌刷新并保存成功 (${successCount}/${needsRefresh.length})`, 'TOKEN', '✅') + } else { + // 记录失败的账户 + this.accountRotator.recordFailure(account.email) + failedCount++ + logger.error(`账户 ${account.email} 令牌刷新失败 (${failedCount} 个失败)`, 'TOKEN', '❌') + } + } catch (error) { + this.accountRotator.recordFailure(account.email) + failedCount++ + logger.error(`账户 ${account.email} 刷新过程中出错`, 'TOKEN', '', error) + } + + // 添加延迟避免请求过于频繁 + await this._delay(1000) + } + + // 更新轮询器 + this.accountRotator.setAccounts(this.accountTokens) + + logger.success(`令牌刷新完成: 成功 ${successCount} 个,失败 ${failedCount} 个`, 'TOKEN') + return successCount + } + + /** + * 获取可用的账户令牌 + * @returns {string|null} 账户令牌或null + */ + getAccountToken() { + if (!this.isInitialized) { + logger.warn('账户管理器尚未初始化完成', 'ACCOUNT') + return null + } + + if (this.accountTokens.length === 0) { + logger.error('没有可用的账户令牌', 'ACCOUNT') + return null + } + + const token = this.accountRotator.getNextToken() + if (!token) { + logger.error('所有账户令牌都不可用', 'ACCOUNT') + } + + return token + } + + /** + * 根据邮箱获取特定账户的令牌 + * @param {string} email - 邮箱地址 + * @returns {string|null} 账户令牌或null + */ + getTokenByEmail(email) { + return this.accountRotator.getTokenByEmail(email) + } + + /** + * 保存更新后的账户数据 + * @param {Array} updatedAccounts - 更新后的账户列表 + * @private + */ + async _saveUpdatedAccounts(updatedAccounts) { + try { + for (const account of updatedAccounts) { + await this.dataPersistence.saveAccount(account.email, { + password: account.password, + token: account.token, + expires: account.expires + }) + } + } catch (error) { + logger.error('保存更新后的账户数据失败', 'ACCOUNT', '', error) + } + } + + /** + * 手动刷新指定账户的令牌 + * @param {string} email - 邮箱地址 + * @returns {Promise} 刷新是否成功 + */ + async refreshAccountToken(email) { + const account = this.accountTokens.find(acc => acc.email === email) + if (!account) { + logger.error(`未找到邮箱为 ${email} 的账户`, 'ACCOUNT') + return false + } + + const updatedAccount = await this.tokenManager.refreshToken(account) + if (updatedAccount) { + // 更新内存中的数据 + const index = this.accountTokens.findIndex(acc => acc.email === email) + if (index !== -1) { + this.accountTokens[index] = updatedAccount + } + + // 保存到持久化存储 + await this.dataPersistence.saveAccount(email, { + password: updatedAccount.password, + token: updatedAccount.token, + expires: updatedAccount.expires + }) + + // 重置失败计数 + this.accountRotator.resetFailures(email) + + return true + } + + return false + } + + // 更新销毁方法,清除定时器 + destroy() { + if (this.saveInterval) { + clearInterval(this.saveInterval) + } + if (this.refreshInterval) { + clearInterval(this.refreshInterval) + } + } + + + + /** + * 生成 Markdown 表格 + * @param {Array} websites - 网站信息数组 + * @param {string} mode - 模式 ('table' 或 'text') + * @returns {Promise} Markdown 字符串 + */ + async generateMarkdownTable(websites, mode) { + // 输入校验 + if (!Array.isArray(websites) || websites.length === 0) { + return '' + } + + let markdown = '' + if (mode === 'table') { + markdown += '| **序号** | **网站URL** | **来源** |\n' + markdown += '|:---|:---|:---|\n' + } + + // 默认值 + const DEFAULT_TITLE = '未知标题' + const DEFAULT_URL = 'https://www.baidu.com' + const DEFAULT_HOSTNAME = '未知来源' + + // 表格内容 + websites.forEach((site, index) => { + const { title, url, hostname } = site + // 处理字段值,若为空则使用默认值 + const urlCell = `[${title || DEFAULT_TITLE}](${url || DEFAULT_URL})` + const hostnameCell = hostname || DEFAULT_HOSTNAME + if (mode === 'table') { + markdown += `| ${index + 1} | ${urlCell} | ${hostnameCell} |\n` + } else { + markdown += `[${index + 1}] ${urlCell} | 来源: ${hostnameCell}\n` + } + }) + + return markdown + } + + + + /** + * 获取所有账户信息 + * @returns {Array} 账户列表 + */ + getAllAccountKeys() { + return this.accountTokens + } + + /** + * 用户登录(委托给 TokenManager) + * @param {string} email - 邮箱 + * @param {string} password - 密码 + * @returns {Promise} 令牌或null + */ + async login(email, password) { + return await this.tokenManager.login(email, password) + } + + /** + * 获取账户健康状态统计 + * @returns {Object} 健康状态统计 + */ + getHealthStats() { + const tokenStats = this.tokenManager.getTokenHealthStats(this.accountTokens) + const rotatorStats = this.accountRotator.getStats() + + return { + accounts: tokenStats, + rotation: rotatorStats, + initialized: this.isInitialized + } + } + + /** + * 记录账户使用失败 + * @param {string} email - 邮箱地址 + */ + recordAccountFailure(email) { + this.accountRotator.recordFailure(email) + } + + /** + * 重置账户失败计数 + * @param {string} email - 邮箱地址 + */ + resetAccountFailures(email) { + this.accountRotator.resetFailures(email) + } + + /** + * 添加新账户 + * @param {string} email - 邮箱 + * @param {string} password - 密码 + * @returns {Promise} 添加是否成功 + */ + async addAccount(email, password) { + try { + // 检查账户是否已存在 + const existingAccount = this.accountTokens.find(acc => acc.email === email) + if (existingAccount) { + logger.warn(`账户 ${email} 已存在`, 'ACCOUNT') + return false + } + + // 尝试登录获取令牌 + const token = await this.tokenManager.login(email, password) + if (!token) { + logger.error(`账户 ${email} 登录失败,无法添加`, 'ACCOUNT') + return false + } + + const decoded = this.tokenManager.validateToken(token) + if (!decoded) { + logger.error(`账户 ${email} 令牌无效,无法添加`, 'ACCOUNT') + return false + } + + const newAccount = { + email, + password, + token, + expires: decoded.exp + } + + // 添加到内存 + this.accountTokens.push(newAccount) + + // 保存到持久化存储 + await this.dataPersistence.saveAccount(email, newAccount) + + // 更新轮询器 + this.accountRotator.setAccounts(this.accountTokens) + + logger.success(`成功添加账户: ${email}`, 'ACCOUNT') + return true + } catch (error) { + logger.error(`添加账户失败 (${email})`, 'ACCOUNT', '', error) + return false + } + } + + /** + * 移除账户 + * @param {string} email - 邮箱地址 + * @returns {Promise} 移除是否成功 + */ + async removeAccount(email) { + try { + const index = this.accountTokens.findIndex(acc => acc.email === email) + if (index === -1) { + logger.warn(`账户 ${email} 不存在`, 'ACCOUNT') + return false + } + + // 从内存中移除 + this.accountTokens.splice(index, 1) + + // 更新轮询器 + this.accountRotator.setAccounts(this.accountTokens) + + logger.success(`成功移除账户: ${email}`, 'ACCOUNT') + return true + } catch (error) { + logger.error(`移除账户失败 (${email})`, 'ACCOUNT', '', error) + return false + } + } + + /** + * 删除账户(向后兼容) + * @param {string} email - 邮箱地址 + * @returns {boolean} 删除是否成功 + */ + deleteAccount(email) { + const index = this.accountTokens.findIndex(t => t.email === email) + if (index !== -1) { + this.accountTokens.splice(index, 1) + this.accountRotator.setAccounts(this.accountTokens) + return true + } + return false + } + + /** + * 为指定账户初始化CLI信息(公共方法) + * @param {Object} account - 账户对象 + * @returns {Promise} 初始化是否成功 + */ + async initializeCliForAccount(account) { + if (!account) { + logger.error('账户对象不能为空', 'CLI') + return false + } + + try { + await this._initializeCliAccount(account) + return true + } catch (error) { + logger.error(`为账户 ${account.email} 初始化CLI失败`, 'CLI', '', error) + return false + } + } + + /** + * 延迟函数 + * @param {number} ms - 延迟毫秒数 + * @private + */ + async _delay(ms) { + return new Promise(resolve => setTimeout(resolve, ms)) + } + + /** + * 清理资源 + */ + destroy() { + // 清理自动刷新定时器 + if (this.refreshInterval) { + clearInterval(this.refreshInterval) + this.refreshInterval = null + } + + // 清理CLI请求次数重置定时器 + if (this.cliRequestNumberInterval) { + clearTimeout(this.cliRequestNumberInterval) + this.cliRequestNumberInterval = null + } + + if (this.cliDailyResetInterval) { + clearInterval(this.cliDailyResetInterval) + this.cliDailyResetInterval = null + } + + // 清理所有CLI账户的刷新定时器 + this.accountTokens.forEach(account => { + if (account.cli_info && account.cli_info.refresh_token_interval) { + clearInterval(account.cli_info.refresh_token_interval) + account.cli_info.refresh_token_interval = null + } + }) + + this.accountRotator.reset() + logger.info('账户管理器已清理资源', 'ACCOUNT', '🧹') + } + +} + +if (!(process.env.API_KEY || config.apiKey)) { + logger.error('请务必设置 API_KEY 环境变量', 'CONFIG', '⚙️') + process.exit(1) +} + +const accountManager = new Account() + +// 添加进程退出时的清理 +process.on('exit', () => { + if (accountManager) { + accountManager.destroy() + } +}) + +// 处理意外退出 +process.on('SIGINT', () => { + if (accountManager) { + accountManager.destroy() + } + process.exit(0) +}) + + +module.exports = accountManager \ No newline at end of file diff --git a/src/utils/chat-helpers.js b/src/utils/chat-helpers.js new file mode 100644 index 0000000000000000000000000000000000000000..59f64bfc7bf302809b452190ecdf5822caa83a6b --- /dev/null +++ b/src/utils/chat-helpers.js @@ -0,0 +1,376 @@ +const { logger } = require('./logger'); +const { sha256Encrypt, generateUUID } = require('./tools.js'); +const { uploadFileToQwenOss } = require('./upload.js'); +const accountManager = require('./account.js'); +const CacheManager = require('./img-caches.js'); + +/** + * 判断聊天类型 + * @param {string} model - 模型名称 + * @param {boolean} search - 是否搜索模式 + * @returns {string} 聊天类型 ('search' 或 't2t') + */ +const isChatType = (model) => { + if (!model) return 't2t'; + if (model.includes('-search')) { + return 'search'; + } else if (model.includes('-image-edit')) { + return 'image_edit'; + } else if (model.includes('-image')) { + return 't2i'; + } else if (model.includes('-video')) { + return 't2v'; + } else if (model.includes('-deep-research')) { + return 'deep_research'; + } else { + return 't2t'; + } +} + +/** + * 判断是否启用思考模式 + * @param {string} model - 模型名称 + * @param {boolean} enable_thinking - 是否启用思考 + * @param {number} thinking_budget - 思考预算 + * @returns {object} 思考配置对象 + */ +const isThinkingEnabled = (model, enable_thinking, thinking_budget) => { + const thinking_config = { + "output_schema": "phase", + "thinking_enabled": false, + "thinking_budget": 81920 + } + + if (!model) return thinking_config; + + if (model.includes('-thinking') || enable_thinking) { + thinking_config.thinking_enabled = true; + } + + if (thinking_budget && Number(thinking_budget) !== Number.NaN && Number(thinking_budget) > 0 && Number(thinking_budget) < 38912) { + thinking_config.budget = Number(thinking_budget); + } + + return thinking_config; +} + +/** + * 解析模型名称,移除特殊后缀 + * @param {string} model - 原始模型名称 + * @returns {string} 解析后的模型名称 + */ +const parserModel = (model) => { + if (!model) return 'qwen3-coder-plus'; + + try { + model = String(model); + model = model.replace('-search', ''); + model = model.replace('-thinking', ''); + model = model.replace('-edit', ''); + model = model.replace('-video', ''); + model = model.replace('-deep-research', ''); + model = model.replace('-image', ''); + return model; + } catch (e) { + return 'qwen3-coder-plus'; + } +} + +/** + * 从消息中提取文本内容 + * @param {string|Array} content - 消息内容 + * @returns {string} 提取的文本 + */ +const extractTextFromContent = (content) => { + if (typeof content === 'string') { + return content; + } else if (Array.isArray(content)) { + const textParts = content + .filter(item => item.type === 'text') + .map(item => item.text || ''); + return textParts.join(' '); + } + return ''; +} + +/** + * 格式化消息为文本(包含角色标注) + * @param {object} message - 单条消息 + * @returns {string} 格式化后的消息文本 + */ +const formatSingleMessage = (message) => { + const role = message.role; + const content = extractTextFromContent(message.content); + return content.trim() ? `${role}:${content}` : ''; +} + +/** + * 格式化历史消息为文本前缀 + * @param {Array} messages - 消息数组(不包含最后一条) + * @returns {string} 格式化后的历史消息 + */ +const formatHistoryMessages = (messages) => { + const formattedParts = []; + + for (let message of messages) { + const formatted = formatSingleMessage(message); + if (formatted) { + formattedParts.push(formatted); + } + } + + return formattedParts.length > 0 ? formattedParts.join(';') : ''; +} + +/** + * 解析消息格式,处理图片上传和消息结构 + * @param {Array} messages - 原始消息数组 + * @param {object} thinking_config - 思考配置 + * @param {string} chat_type - 聊天类型 + * @returns {Promise} 解析后的消息数组 + */ +const parserMessages = async (messages, thinking_config, chat_type) => { + try { + const feature_config = thinking_config; + const imgCacheManager = new CacheManager(); + + // 如果只有一条消息,使用原有逻辑处理(不标注角色) + if (messages.length <= 1) { + logger.network('单条消息,使用原格式处理', 'PARSER'); + return await processOriginalLogic(messages, thinking_config, chat_type, imgCacheManager); + } + + // 多条消息的情况:分离历史消息和最后一条消息 + logger.network('多条消息,格式化处理并标注角色', 'PARSER'); + const historyMessages = messages.slice(0, -1); + const lastMessage = messages[messages.length - 1]; + + // 格式化历史消息为文本前缀 + const historyText = formatHistoryMessages(historyMessages); + + // 处理最后一条消息 + let finalContent = []; + let lastMessageText = ''; + const lastMessageRole = lastMessage.role; + + if (typeof lastMessage.content === 'string') { + lastMessageText = lastMessage.content; + } else if (Array.isArray(lastMessage.content)) { + // 处理最后一条消息中的内容 + for (let item of lastMessage.content) { + if (item.type === 'text') { + lastMessageText += item.text || ''; + } else if (item.type === 'image' || item.type === 'image_url') { + // 处理图片上传 + let base64 = null; + if (item.type === 'image_url') { + base64 = item.image_url.url; + } + + if (base64) { + const regex = /data:(.+);base64,/; + const fileType = base64.match(regex); + const fileExtension = fileType && fileType[1] ? fileType[1].split('/')[1] || 'png' : 'png'; + const filename = `${generateUUID()}.${fileExtension}`; + base64 = base64.replace(regex, ''); + const signature = sha256Encrypt(base64); + + try { + const buffer = Buffer.from(base64, 'base64'); + const cacheIsExist = imgCacheManager.cacheIsExist(signature); + + if (cacheIsExist) { + finalContent.push({ + type: 'image', + image: imgCacheManager.getCache(signature).url + }); + } else { + const uploadResult = await uploadFileToQwenOss(buffer, filename, accountManager.getAccountToken()); + if (uploadResult && uploadResult.status === 200) { + finalContent.push({ + type: 'image', + image: uploadResult.file_url + }); + imgCacheManager.addCache(signature, uploadResult.file_url); + } + } + } catch (error) { + logger.error('图片上传失败', 'UPLOAD', '', error); + } + } + } + } + } + + // 组合最终内容:历史文本 + 当前消息(带角色标注) + let combinedText = ''; + if (historyText) { + combinedText = historyText + ';'; + } + // 添加最后一条消息,带角色标注 + if (lastMessageText.trim()) { + combinedText += `${lastMessageRole}:${lastMessageText}`; + } + + // 如果有图片,创建包含文本和图片的content数组 + if (finalContent.length > 0) { + finalContent.unshift({ + type: 'text', + text: combinedText, + chat_type: 't2t', + feature_config: { + "output_schema": "phase", + "thinking_enabled": false, + } + }); + + return [ + { + "role": "user", + "content": finalContent, + "chat_type": chat_type, + "extra": {}, + "feature_config": feature_config + } + ]; + } else { + // 纯文本情况 + return [ + { + "role": "user", + "content": combinedText, + "chat_type": chat_type, + "extra": {}, + "feature_config": feature_config + } + ]; + } + + } catch (e) { + logger.error('消息解析失败', 'PARSER', '', e); + return [ + { + "role": "user", + "content": "直接返回字符串: '聊天历史处理有误...'", + "chat_type": "t2t", + "extra": {}, + "feature_config": { + "output_schema": "phase", + "enabled": false, + } + } + ]; + } +} + +/** + * 原有的单条消息处理逻辑 + * @param {Array} messages - 消息数组 + * @param {object} thinking_config - 思考配置 + * @param {string} chat_type - 聊天类型 + * @param {object} imgCacheManager - 图片缓存管理器 + * @returns {Promise} 处理后的消息数组 + */ +const processOriginalLogic = async (messages, thinking_config, chat_type, imgCacheManager) => { + const feature_config = thinking_config; + + for (let message of messages) { + if (message.role === 'user' || message.role === 'assistant') { + message.chat_type = "t2t"; + message.extra = {}; + message.feature_config = { + "output_schema": "phase", + "thinking_enabled": false, + }; + + if (!Array.isArray(message.content)) continue; + + const newContent = []; + + for (let item of message.content) { + if (item.type === 'image' || item.type === 'image_url') { + let base64 = null; + if (item.type === 'image_url') { + base64 = item.image_url.url; + } + if (base64) { + const regex = /data:(.+);base64,/; + const fileType = base64.match(regex); + const fileExtension = fileType && fileType[1] ? fileType[1].split('/')[1] || 'png' : 'png'; + const filename = `${generateUUID()}.${fileExtension}`; + base64 = base64.replace(regex, ''); + const signature = sha256Encrypt(base64); + + try { + const buffer = Buffer.from(base64, 'base64'); + const cacheIsExist = imgCacheManager.cacheIsExist(signature); + if (cacheIsExist) { + delete item.image_url; + item.type = 'image'; + item.image = imgCacheManager.getCache(signature).url; + newContent.push(item); + } else { + const uploadResult = await uploadFileToQwenOss(buffer, filename, accountManager.getAccountToken()); + if (uploadResult && uploadResult.status === 200) { + delete item.image_url; + item.type = 'image'; + item.image = uploadResult.file_url; + imgCacheManager.addCache(signature, uploadResult.file_url); + newContent.push(item); + } + } + + } catch (error) { + logger.error('图片上传失败', 'UPLOAD', '', error); + } + } + } else if (item.type === 'text') { + item.chat_type = 't2t'; + item.feature_config = { + "output_schema": "phase", + "thinking_enabled": false, + }; + + if (newContent.length >= 2) { + messages.push({ + "role": "user", + "content": item.text, + "chat_type": "t2t", + "extra": {}, + "feature_config": { + "output_schema": "phase", + "thinking_enabled": false, + } + }); + } else { + newContent.push(item); + } + } + } + } else { + if (Array.isArray(message.content)) { + let system_prompt = ''; + for (let item of message.content) { + if (item.type === 'text') { + system_prompt += item.text; + } + } + if (system_prompt) { + message.content = system_prompt; + } + } + } + } + + messages[messages.length - 1].feature_config = feature_config; + messages[messages.length - 1].chat_type = chat_type; + + return messages; +} + +module.exports = { + isChatType, + isThinkingEnabled, + parserModel, + parserMessages +} diff --git a/src/utils/cli.manager.js b/src/utils/cli.manager.js new file mode 100644 index 0000000000000000000000000000000000000000..4178d2b852b4c6d8aabdb86c97a2d73c29f6c3a9 --- /dev/null +++ b/src/utils/cli.manager.js @@ -0,0 +1,279 @@ +const crypto = require('crypto') +const { getProxyAgent, getChatBaseUrl, applyProxyToFetchOptions } = require('./proxy-helper') + +/** + * 为 PKCE 生成随机代码验证器 + * @returns {string} 43-128个字符的随机字符串 + */ +function generateCodeVerifier() { + return crypto.randomBytes(32).toString('base64url') +} + +/** + * 使用 SHA-256 从代码验证器生成代码挑战 + * @param {string} codeVerifier - 代码验证器字符串 + * @returns {string} 代码挑战字符串 + */ +function generateCodeChallenge(codeVerifier) { + const hash = crypto.createHash('sha256') + hash.update(codeVerifier) + return hash.digest('base64url') +} + +/** + * 生成 PKCE 代码验证器和挑战对 + * @returns {Object} 包含 code_verifier 和 code_challenge 的对象 + */ +function generatePKCEPair() { + const codeVerifier = generateCodeVerifier() + const codeChallenge = generateCodeChallenge(codeVerifier) + return { + code_verifier: codeVerifier, + code_challenge: codeChallenge + } +} + +class CliAuthManager { + /** + * 启动 OAuth 设备授权流程 + * @returns {Promise} 包含设备代码、验证URL和代码验证器的对象 + */ + async initiateDeviceFlow() { + // 生成 PKCE 代码验证器和挑战 + const { code_verifier, code_challenge } = generatePKCEPair() + + const bodyData = new URLSearchParams({ + client_id: "f0304373b74a44d2b584a3fb70ca9e56", + scope: "openid profile email model.completion", + code_challenge: code_challenge, + code_challenge_method: 'S256', + }) + + const chatBaseUrl = getChatBaseUrl() + const proxyAgent = getProxyAgent() + + const fetchOptions = { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + Accept: 'application/json', + }, + body: bodyData, + } + + // 添加代理配置 + if (proxyAgent) { + fetchOptions.agent = proxyAgent + } + + try { + const response = await fetch(`${chatBaseUrl}/api/v1/oauth2/device/code`, fetchOptions) + + if (response.ok) { + const result = await response.json() + return { + status: true, + ...result, + code_verifier: code_verifier + } + } else { + throw new Error() + } + } catch (error) { + return { + status: false, + device_code: null, + user_code: null, + verification_uri: null, + verification_uri_complete: null, + expires_in: null, + code_verifier: null + } + } + } + + /** + * 授权登录 + * @param {string} user_code - 用户代码 + * @param {string} access_token - 访问令牌 + * @returns {Promise} 是否授权成功 + */ + async authorizeLogin(user_code, access_token) { + try { + const chatBaseUrl = getChatBaseUrl() + const proxyAgent = getProxyAgent() + + const fetchOptions = { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + "authorization": `Bearer ${access_token}`, + }, + body: JSON.stringify({ + "approved": true, + "user_code": user_code + }) + } + + if (proxyAgent) { + fetchOptions.agent = proxyAgent + } + + const response = await fetch(`${chatBaseUrl}/api/v2/oauth2/authorize`, fetchOptions) + + if (response.ok) { + return true + } else { + throw new Error() + } + } catch (error) { + return false + } + } + + /** + * 轮询获取访问令牌 + * @param {string} device_code - 设备代码 + * @param {string} code_verifier - 代码验证器 + * @returns {Promise} 访问令牌信息 + */ + async pollForToken(device_code, code_verifier) { + let pollInterval = 5000 + const maxAttempts = 60 + const chatBaseUrl = getChatBaseUrl() + const proxyAgent = getProxyAgent() + + for (let attempt = 0; attempt < maxAttempts; attempt++) { + const bodyData = new URLSearchParams({ + grant_type: "urn:ietf:params:oauth:grant-type:device_code", + client_id: "f0304373b74a44d2b584a3fb70ca9e56", + device_code: device_code, + code_verifier: code_verifier, + }) + + const fetchOptions = { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + Accept: 'application/json', + }, + body: bodyData, + } + + if (proxyAgent) { + fetchOptions.agent = proxyAgent + } + + try { + const response = await fetch(`${chatBaseUrl}/api/v1/oauth2/token`, fetchOptions) + + if (response.ok) { + + + const tokenData = await response.json() + + // 转换为凭据格式 + const credentials = { + access_token: tokenData.access_token, + refresh_token: tokenData.refresh_token || undefined, + expiry_date: tokenData.expires_in ? Date.now() + tokenData.expires_in * 1000 : undefined, + } + + return credentials + } + + // 等待5秒, 然后继续轮询 + await new Promise(resolve => setTimeout(resolve, pollInterval)) + } catch (error) { + // 等待5秒, 然后继续轮询 + await new Promise(resolve => setTimeout(resolve, pollInterval)) + console.log(`轮询尝试 ${attempt + 1}/${maxAttempts} 失败:`, error) + continue + } + } + + return { + status: false, + access_token: null, + refresh_token: null, + expiry_date: null + } + } + + /** + * 初始化 CLI 账户 + * @param {string} access_token - 访问令牌 + * @returns {Promise} 账户信息 + */ + async initCliAccount(access_token) { + const deviceFlow = await this.initiateDeviceFlow() + if (!deviceFlow.status || !await this.authorizeLogin(deviceFlow.user_code, access_token)) { + return { + status: false, + access_token: null, + refresh_token: null, + expiry_date: null + } + } + + return await this.pollForToken(deviceFlow.device_code, deviceFlow.code_verifier) + } + + /** + * 刷新访问令牌 + * @param {Object} CliAccount - 账户信息 + * @returns {Promise} 账户信息 + */ + async refreshAccessToken(CliAccount) { + try { + + if (!CliAccount || !CliAccount.refresh_token) { + throw new Error() + } + + const chatBaseUrl = getChatBaseUrl() + const proxyAgent = getProxyAgent() + + const bodyData = new URLSearchParams({ + grant_type: 'refresh_token', + refresh_token: CliAccount.refresh_token, + client_id: "f0304373b74a44d2b584a3fb70ca9e56", + }) + + const fetchOptions = { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + Accept: 'application/json', + }, + body: bodyData + } + + if (proxyAgent) { + fetchOptions.agent = proxyAgent + } + + const response = await fetch(`${chatBaseUrl}/api/v1/oauth2/token`, fetchOptions) + + if (response.ok) { + const tokenData = await response.json() + + return { + access_token: tokenData.access_token, + refresh_token: tokenData.refresh_token || CliAccount.refresh_token, + expiry_date: Date.now() + tokenData.expires_in * 1000, + } + } + } catch (error) { + return { + status: false, + access_token: null, + refresh_token: null, + expiry_date: null + } + } + } + +} + +module.exports = new CliAuthManager() \ No newline at end of file diff --git a/src/utils/cookie-generator.js b/src/utils/cookie-generator.js new file mode 100644 index 0000000000000000000000000000000000000000..fb78b345e6dd01f0dbb37dac2a7dd154e05b06c9 --- /dev/null +++ b/src/utils/cookie-generator.js @@ -0,0 +1,376 @@ +// 导入指纹生成器 +const { generateFingerprint } = require('./fingerprint'); + +// 自定义Base64字符表 +const CUSTOM_BASE64_CHARS = "DGi0YA7BemWnQjCl4_bR3f8SKIF9tUz/xhr2oEOgPpac=61ZqwTudLkM5vHyNXsVJ"; + +// 哈希字段位置(这些字段需要随机生成) +const HASH_FIELDS = { + 16: 'split', // 插件哈希(格式: count|hash,只替换hash部分) + 17: 'full', // Canvas指纹哈希 + 18: 'full', // UserAgent哈希 + 31: 'full', // UserAgent哈希2 + 34: 'full', // 文档URL哈希 + 36: 'full' // 文档属性哈希 +}; + +// ==================== LZW压缩算法 ==================== + +function lzwCompress(data, bits, charFunc) { + if (data == null) return ''; + + let dict = {}; + let dictToCreate = {}; + let c = ''; + let wc = ''; + let w = ''; + let enlargeIn = 2; + let dictSize = 3; + let numBits = 2; + let result = []; + let value = 0; + let position = 0; + + for (let i = 0; i < data.length; i++) { + c = data.charAt(i); + + if (!Object.prototype.hasOwnProperty.call(dict, c)) { + dict[c] = dictSize++; + dictToCreate[c] = true; + } + + wc = w + c; + + if (Object.prototype.hasOwnProperty.call(dict, wc)) { + w = wc; + } else { + if (Object.prototype.hasOwnProperty.call(dictToCreate, w)) { + if (w.charCodeAt(0) < 256) { + for (let j = 0; j < numBits; j++) { + value = (value << 1); + if (position === bits - 1) { + position = 0; + result.push(charFunc(value)); + value = 0; + } else { + position++; + } + } + + let charCode = w.charCodeAt(0); + for (let j = 0; j < 8; j++) { + value = (value << 1) | (charCode & 1); + if (position === bits - 1) { + position = 0; + result.push(charFunc(value)); + value = 0; + } else { + position++; + } + charCode >>= 1; + } + } else { + let charCode = 1; + for (let j = 0; j < numBits; j++) { + value = (value << 1) | charCode; + if (position === bits - 1) { + position = 0; + result.push(charFunc(value)); + value = 0; + } else { + position++; + } + charCode = 0; + } + + charCode = w.charCodeAt(0); + for (let j = 0; j < 16; j++) { + value = (value << 1) | (charCode & 1); + if (position === bits - 1) { + position = 0; + result.push(charFunc(value)); + value = 0; + } else { + position++; + } + charCode >>= 1; + } + } + + enlargeIn--; + if (enlargeIn === 0) { + enlargeIn = Math.pow(2, numBits); + numBits++; + } + delete dictToCreate[w]; + } else { + let charCode = dict[w]; + for (let j = 0; j < numBits; j++) { + value = (value << 1) | (charCode & 1); + if (position === bits - 1) { + position = 0; + result.push(charFunc(value)); + value = 0; + } else { + position++; + } + charCode >>= 1; + } + } + + enlargeIn--; + if (enlargeIn === 0) { + enlargeIn = Math.pow(2, numBits); + numBits++; + } + + dict[wc] = dictSize++; + w = String(c); + } + } + + if (w !== '') { + if (Object.prototype.hasOwnProperty.call(dictToCreate, w)) { + if (w.charCodeAt(0) < 256) { + for (let j = 0; j < numBits; j++) { + value = (value << 1); + if (position === bits - 1) { + position = 0; + result.push(charFunc(value)); + value = 0; + } else { + position++; + } + } + + let charCode = w.charCodeAt(0); + for (let j = 0; j < 8; j++) { + value = (value << 1) | (charCode & 1); + if (position === bits - 1) { + position = 0; + result.push(charFunc(value)); + value = 0; + } else { + position++; + } + charCode >>= 1; + } + } else { + let charCode = 1; + for (let j = 0; j < numBits; j++) { + value = (value << 1) | charCode; + if (position === bits - 1) { + position = 0; + result.push(charFunc(value)); + value = 0; + } else { + position++; + } + charCode = 0; + } + + charCode = w.charCodeAt(0); + for (let j = 0; j < 16; j++) { + value = (value << 1) | (charCode & 1); + if (position === bits - 1) { + position = 0; + result.push(charFunc(value)); + value = 0; + } else { + position++; + } + charCode >>= 1; + } + } + + enlargeIn--; + if (enlargeIn === 0) { + enlargeIn = Math.pow(2, numBits); + numBits++; + } + delete dictToCreate[w]; + } else { + let charCode = dict[w]; + for (let j = 0; j < numBits; j++) { + value = (value << 1) | (charCode & 1); + if (position === bits - 1) { + position = 0; + result.push(charFunc(value)); + value = 0; + } else { + position++; + } + charCode >>= 1; + } + } + + enlargeIn--; + if (enlargeIn === 0) { + enlargeIn = Math.pow(2, numBits); + numBits++; + } + } + + let charCode = 2; + for (let j = 0; j < numBits; j++) { + value = (value << 1) | (charCode & 1); + if (position === bits - 1) { + position = 0; + result.push(charFunc(value)); + value = 0; + } else { + position++; + } + charCode >>= 1; + } + + while (true) { + value = (value << 1); + if (position === bits - 1) { + result.push(charFunc(value)); + break; + } + position++; + } + + return result.join(''); +} + +// ==================== 编码函数 ==================== + +function customEncode(data, urlSafe) { + if (data == null) return ''; + + const base64Chars = CUSTOM_BASE64_CHARS; + + let compressed = lzwCompress(data, 6, function(index) { + return base64Chars.charAt(index); + }); + + if (!urlSafe) { + switch (compressed.length % 4) { + case 1: return compressed + '==='; + case 2: return compressed + '=='; + case 3: return compressed + '='; + default: return compressed; + } + } + + return compressed; +} + +// ==================== 辅助函数 ==================== + +function randomHash() { + return Math.floor(Math.random() * 4294967296); +} + +function generateDeviceId() { + return Array.from({ length: 20 }, () => + Math.floor(Math.random() * 16).toString(16) + ).join(''); +} + +// ==================== 数据解析和处理 ==================== + +function parseRealData(realData) { + const fields = realData.split('^'); + return fields; +} + +function processFields(fields) { + const processed = [...fields]; + const currentTimestamp = Date.now(); + + // 替换哈希字段 + for (const [index, type] of Object.entries(HASH_FIELDS)) { + const idx = parseInt(index); + + if (type === 'split') { + // 字段16: 格式为 "count|hash",只替换hash部分 + const parts = processed[idx].split('|'); + if (parts.length === 2) { + processed[idx] = `${parts[0]}|${randomHash()}`; + } + } else if (type === 'full') { + // 完全替换为随机哈希 + if (idx === 36) { + // 字段36: 文档属性哈希(10-100的随机整数) + processed[idx] = Math.floor(Math.random() * 91) + 10; + } else { + processed[idx] = randomHash(); + } + } + } + + processed[33] = currentTimestamp; // 字段33: 当前时间戳 + + return processed; +} + +// ==================== Cookie生成 ==================== + +function generateCookies(realData = null, fingerprintOptions = {}) { + // 使用传入的指纹或生成新的随机指纹 + const fingerprint = realData || generateFingerprint(fingerprintOptions); + + // 解析指纹数据 + const fields = parseRealData(fingerprint); + + // 处理字段(随机化哈希,更新时间戳) + const processedFields = processFields(fields); + + // 生成 ssxmod_itna (37字段) + const ssxmod_itna_data = processedFields.join('^'); + const ssxmod_itna = '1-' + customEncode(ssxmod_itna_data, true); + + // 生成 ssxmod_itna2 (18字段) + // 只使用: 字段0, 字段1, 字段23, 字段32, 字段33 + const ssxmod_itna2_data = [ + processedFields[0], // 设备ID + processedFields[1], // SDK版本 + processedFields[23], // 模式 (P/M) + 0, '', 0, '', '', 0, // 事件相关(P模式下为空) + 0, 0, + processedFields[32], // 常量 (11) + processedFields[33], // 当前时间戳 + 0, 0, 0, 0, 0 + ].join('^'); + const ssxmod_itna2 = '1-' + customEncode(ssxmod_itna2_data, true); + + return { + ssxmod_itna, + ssxmod_itna2, + timestamp: parseInt(processedFields[33]), + rawData: ssxmod_itna_data, + rawData2: ssxmod_itna2_data + }; +} + +function generateBatch(count = 10, realData = null, fingerprintOptions = {}) { + const results = []; + for (let i = 0; i < count; i++) { + results.push(generateCookies(realData, fingerprintOptions)); + } + return results; +} + +// ==================== 主程序 ==================== + +if (require.main === module) { + const result = generateCookies(); + console.log('ssxmod_itna:', result.ssxmod_itna); + console.log('ssxmod_itna2:', result.ssxmod_itna2); +} + +// ==================== 导出 ==================== + +module.exports = { + generateCookies, + generateBatch, + customEncode, + randomHash, + generateDeviceId, + parseRealData, + generateFingerprint +}; diff --git a/src/utils/data-persistence.js b/src/utils/data-persistence.js new file mode 100644 index 0000000000000000000000000000000000000000..240851990277f383155ee05f69c04cae4e1b35dc --- /dev/null +++ b/src/utils/data-persistence.js @@ -0,0 +1,240 @@ +const fs = require('fs').promises +const path = require('path') +const config = require('../config/index.js') +const redisClient = require('./redis') +const { logger } = require('./logger') + +/** + * 数据持久化管理器 + * 统一处理账户数据的存储和读取 + */ +class DataPersistence { + constructor() { + this.dataFilePath = config.dataFilePath + } + + /** + * 加载所有账户数据 + * @returns {Promise} 账户列表 + */ + async loadAccounts() { + try { + switch (config.dataSaveMode) { + case 'redis': + return await this._loadFromRedis() + case 'file': + return await this._loadFromFile() + case 'none': + return await this._loadFromEnv() + default: + logger.error(`不支持的数据保存模式: ${config.dataSaveMode}`, 'DATA') + throw new Error(`不支持的数据保存模式: ${config.dataSaveMode}`) + } + } catch (error) { + logger.error('加载账户数据失败', 'DATA', '', error) + return [] + } + } + + /** + * 保存单个账户数据 + * @param {string} email - 邮箱 + * @param {Object} accountData - 账户数据 + * @returns {Promise} 保存是否成功 + */ + async saveAccount(email, accountData) { + try { + switch (config.dataSaveMode) { + case 'redis': + return await this._saveToRedis(email, accountData) + case 'file': + return await this._saveToFile(email, accountData) + case 'none': + logger.warn('环境变量模式不支持保存账户数据', 'DATA') + return false + default: + logger.error(`不支持的数据保存模式: ${config.dataSaveMode}`, 'DATA') + throw new Error(`不支持的数据保存模式: ${config.dataSaveMode}`) + } + } catch (error) { + logger.error(`保存账户数据失败 (${email})`, 'DATA', '', error) + return false + } + } + + /** + * 批量保存账户数据 + * @param {Array} accounts - 账户列表 + * @returns {Promise} 保存是否成功 + */ + async saveAllAccounts(accounts) { + try { + switch (config.dataSaveMode) { + case 'redis': + return await this._saveAllToRedis(accounts) + case 'file': + return await this._saveAllToFile(accounts) + case 'none': + logger.warn('环境变量模式不支持保存账户数据', 'DATA') + return false + default: + logger.error(`不支持的数据保存模式: ${config.dataSaveMode}`, 'DATA') + throw new Error(`不支持的数据保存模式: ${config.dataSaveMode}`) + } + } catch (error) { + logger.error('批量保存账户数据失败', 'DATA', '', error) + return false + } + } + + /** + * 从 Redis 加载账户数据 + * @private + */ + async _loadFromRedis() { + const accounts = await redisClient.getAllAccounts() + return accounts.length > 0 ? accounts : [] + } + + /** + * 从文件加载账户数据 + * @private + */ + async _loadFromFile() { + // 确保文件存在 + await this._ensureDataFileExists() + + const fileContent = await fs.readFile(this.dataFilePath, 'utf-8') + const data = JSON.parse(fileContent) + + return data.accounts || [] + } + + /** + * 从环境变量加载账户数据 + * @private + */ + async _loadFromEnv() { + if (!process.env.ACCOUNTS) { + return [] + } + + const { JwtDecode } = require('./tools') + const accountTokens = process.env.ACCOUNTS.split(',') + const accounts = [] + + for (const item of accountTokens) { + const [email, password] = item.split(':') + if (email && password) { + // 注意:这里需要登录获取token,但在加载阶段不应该进行网络请求 + // 这个逻辑需要在Account类中处理 + accounts.push({ email, password, token: null, expires: null }) + } + } + + return accounts + } + + /** + * 保存到 Redis + * @private + */ + async _saveToRedis(email, accountData) { + return await redisClient.setAccount(email, accountData) + } + + /** + * 保存到文件 + * @private + */ + async _saveToFile(email, accountData) { + await this._ensureDataFileExists() + + const fileContent = await fs.readFile(this.dataFilePath, 'utf-8') + const data = JSON.parse(fileContent) + + if (!data.accounts) { + data.accounts = [] + } + + // 查找现有账户或添加新账户 + const existingIndex = data.accounts.findIndex(account => account.email === email) + const updatedAccount = { + email, + password: accountData.password, + token: accountData.token, + expires: accountData.expires + } + + if (existingIndex !== -1) { + data.accounts[existingIndex] = updatedAccount + } else { + data.accounts.push(updatedAccount) + } + + await fs.writeFile(this.dataFilePath, JSON.stringify(data, null, 2), 'utf-8') + return true + } + + /** + * 批量保存到 Redis + * @private + */ + async _saveAllToRedis(accounts) { + let successCount = 0 + for (const account of accounts) { + const success = await this._saveToRedis(account.email, account) + if (success) successCount++ + } + return successCount === accounts.length + } + + /** + * 批量保存到文件 + * @private + */ + async _saveAllToFile(accounts) { + await this._ensureDataFileExists() + + const fileContent = await fs.readFile(this.dataFilePath, 'utf-8') + const data = JSON.parse(fileContent) + + data.accounts = accounts.map(account => ({ + email: account.email, + password: account.password, + token: account.token, + expires: account.expires + })) + + await fs.writeFile(this.dataFilePath, JSON.stringify(data, null, 2), 'utf-8') + return true + } + + /** + * 确保数据文件存在 + * @private + */ + async _ensureDataFileExists() { + try { + await fs.access(this.dataFilePath) + } catch (error) { + logger.info('数据文件不存在,正在创建默认文件...', 'FILE', '📁') + + // 确保目录存在 + const dirPath = path.dirname(this.dataFilePath) + await fs.mkdir(dirPath, { recursive: true }) + + // 创建默认数据结构 + const defaultData = { + defaultHeaders: null, + defaultCookie: null, + accounts: [] + } + + await fs.writeFile(this.dataFilePath, JSON.stringify(defaultData, null, 2), 'utf-8') + logger.success('默认数据文件创建成功', 'FILE') + } + } +} + +module.exports = DataPersistence diff --git a/src/utils/fingerprint.js b/src/utils/fingerprint.js new file mode 100644 index 0000000000000000000000000000000000000000..4b1715e978e1df94d41e2900ecae00e212b22dcd Binary files /dev/null and b/src/utils/fingerprint.js differ diff --git a/src/utils/img-caches.js b/src/utils/img-caches.js new file mode 100644 index 0000000000000000000000000000000000000000..25335fabd8852b8a5cd245302df29545d3f34c8e --- /dev/null +++ b/src/utils/img-caches.js @@ -0,0 +1,88 @@ +const fs = require('fs') +const path = require('path') +const config = require('../config') +const { logger } = require('./logger') + +class imgCacheManager { + constructor() { + this.cacheMap = new Map() + } + + getCachePath(signature) { + return path.join(config.cacheDir, `${signature}.txt`) + } + + cacheIsExist(signature) { + try { + if (config.cacheMode === 'default') { + return this.cacheMap.has(signature) + } else { + const cachePath = this.getCachePath(signature) + return fs.existsSync(cachePath) + } + } catch (e) { + logger.error('缓存检查失败', 'CACHE', '', e) + return false + } + } + + addCache(signature, url) { + try { + const isExist = this.cacheIsExist(signature) + + if (isExist) { + return false + } else { + + if (config.cacheMode === 'default') { + this.cacheMap.set(signature, url) + } else { + const cachePath = this.getCachePath(signature) + fs.mkdirSync(config.cacheDir, { recursive: true }) + fs.writeFileSync(cachePath, url) + } + + return true + + } + } catch (e) { + logger.error('添加缓存失败', 'CACHE', '', e) + return false + } + } + + getCache(signature) { + try { + const cachePath = this.getCachePath(signature) + const isExist = this.cacheIsExist(signature) + + if (isExist) { + if (config.cacheMode === 'default') { + return { + status: 200, + url: this.cacheMap.get(signature) + } + } else { + const data = fs.readFileSync(cachePath, 'utf-8') + return { + status: 200, + url: data + } + } + } else { + return { + status: 404, + url: null + } + } + } catch (e) { + logger.error('获取缓存失败', 'CACHE', '', e) + return { + status: 500, + url: null + } + } + } +} + +module.exports = imgCacheManager diff --git a/src/utils/logger.js b/src/utils/logger.js new file mode 100644 index 0000000000000000000000000000000000000000..430aa8ad7a1e49ac175bb18a213766d7393e4851 --- /dev/null +++ b/src/utils/logger.js @@ -0,0 +1,321 @@ +const fs = require('fs') +const path = require('path') +const paths = require('./paths') + +/** + * 日志管理器 + * 统一管理项目中的日志输出,支持分级打印、时间戳、Emoji标签等功能 + */ +class Logger { + constructor(options = {}) { + this.options = { + // 日志级别: DEBUG < INFO < WARN < ERROR + level: options.level || 'INFO', + // 是否启用文件日志 + enableFileLog: options.enableFileLog || false, + // 日志文件路径 + logDir: options.logDir || paths.logDir, + // 日志文件名格式 + logFileName: options.logFileName || 'app.log', + // 是否显示时间戳 + showTimestamp: options.showTimestamp !== false, + // 是否显示日志级别 + showLevel: options.showLevel !== false, + // 是否显示模块名 + showModule: options.showModule !== false, + // 时间格式 + timeFormat: options.timeFormat || 'YYYY-MM-DD HH:mm:ss', + // 最大日志文件大小 (MB) + maxFileSize: options.maxFileSize || 10, + // 保留的日志文件数量 + maxFiles: options.maxFiles || 5 + } + + // 日志级别映射 + this.levels = { + DEBUG: 0, + INFO: 1, + WARN: 2, + ERROR: 3 + } + + // Emoji 标签映射 + this.emojis = { + DEBUG: '🔍', + INFO: '📝', + WARN: '⚠️', + ERROR: '❌', + SUCCESS: '✅', + NETWORK: '🌐', + DATABASE: '🗄️', + AUTH: '🔐', + UPLOAD: '📤', + DOWNLOAD: '📥', + CACHE: '💾', + CONFIG: '⚙️', + SERVER: '🚀', + CLIENT: '👤', + REDIS: '🔴', + TOKEN: '🎫', + SEARCH: '🔍', + CHAT: '💬', + MODEL: '🤖', + FILE: '📁', + TIME: '⏰', + MEMORY: '🧠', + PROCESS: '⚡' + } + + // 颜色代码 + this.colors = { + DEBUG: '\x1b[36m', // 青色 + INFO: '\x1b[32m', // 绿色 + WARN: '\x1b[33m', // 黄色 + ERROR: '\x1b[31m', // 红色 + RESET: '\x1b[0m', // 重置 + BRIGHT: '\x1b[1m', // 加粗 + DIM: '\x1b[2m' // 暗淡 + } + + // 初始化日志目录 + if (this.options.enableFileLog) { + this.initLogDirectory() + } + } + + /** + * 初始化日志目录 + */ + initLogDirectory() { + try { + if (!fs.existsSync(this.options.logDir)) { + fs.mkdirSync(this.options.logDir, { recursive: true }) + } + } catch (error) { + console.error('创建日志目录失败:', error.message) + } + } + + /** + * 检查日志级别是否应该输出 + * @param {string} level - 日志级别 + * @returns {boolean} + */ + shouldLog(level) { + return this.levels[level] >= this.levels[this.options.level] + } + + /** + * 格式化时间戳 + * @returns {string} + */ + formatTimestamp() { + const now = new Date() + const year = now.getFullYear() + const month = String(now.getMonth() + 1).padStart(2, '0') + const day = String(now.getDate()).padStart(2, '0') + const hours = String(now.getHours()).padStart(2, '0') + const minutes = String(now.getMinutes()).padStart(2, '0') + const seconds = String(now.getSeconds()).padStart(2, '0') + + return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}` + } + + /** + * 格式化日志消息 + * @param {string} level - 日志级别 + * @param {string} message - 日志消息 + * @param {string} module - 模块名 + * @param {string} emoji - Emoji标签 + * @returns {Object} 格式化后的消息对象 + */ + formatMessage(level, message, module = '', emoji = '') { + const timestamp = this.options.showTimestamp ? this.formatTimestamp() : '' + const levelStr = this.options.showLevel ? `[${level}]` : '' + const moduleStr = this.options.showModule && module ? `[${module}]` : '' + const emojiStr = emoji || this.emojis[level] || '' + + // 控制台输出格式(带颜色) + const consoleMessage = [ + this.colors.DIM + timestamp + this.colors.RESET, + this.colors[level] + levelStr + this.colors.RESET, + this.colors.BRIGHT + moduleStr + this.colors.RESET, + emojiStr, + message + ].filter(Boolean).join(' ') + + // 文件输出格式(无颜色) + const fileMessage = [ + timestamp, + levelStr, + moduleStr, + emojiStr, + message + ].filter(Boolean).join(' ') + + return { consoleMessage, fileMessage } + } + + /** + * 写入日志文件 + * @param {string} message - 日志消息 + */ + writeToFile(message) { + if (!this.options.enableFileLog) return + + try { + const logFile = path.join(this.options.logDir, this.options.logFileName) + const logEntry = `${message}\n` + + // 检查文件大小并轮转 + this.rotateLogFile(logFile) + + fs.appendFileSync(logFile, logEntry, 'utf8') + } catch (error) { + console.error('写入日志文件失败:', error.message) + } + } + + /** + * 日志文件轮转 + * @param {string} logFile - 日志文件路径 + */ + rotateLogFile(logFile) { + try { + if (!fs.existsSync(logFile)) return + + const stats = fs.statSync(logFile) + const fileSizeMB = stats.size / (1024 * 1024) + + if (fileSizeMB > this.options.maxFileSize) { + const timestamp = new Date().toISOString().replace(/[:.]/g, '-') + const backupFile = logFile.replace('.log', `_${timestamp}.log`) + + fs.renameSync(logFile, backupFile) + + // 清理旧的日志文件 + this.cleanOldLogFiles() + } + } catch (error) { + console.error('日志文件轮转失败:', error.message) + } + } + + /** + * 清理旧的日志文件 + */ + cleanOldLogFiles() { + try { + const files = fs.readdirSync(this.options.logDir) + const logFiles = files + .filter(file => file.endsWith('.log') && file !== this.options.logFileName) + .map(file => ({ + name: file, + path: path.join(this.options.logDir, file), + mtime: fs.statSync(path.join(this.options.logDir, file)).mtime + })) + .sort((a, b) => b.mtime - a.mtime) + + // 保留最新的几个文件,删除其余的 + if (logFiles.length > this.options.maxFiles) { + const filesToDelete = logFiles.slice(this.options.maxFiles) + filesToDelete.forEach(file => { + fs.unlinkSync(file.path) + }) + } + } catch (error) { + console.error('清理旧日志文件失败:', error.message) + } + } + + /** + * 通用日志方法 + * @param {string} level - 日志级别 + * @param {string} message - 日志消息 + * @param {string} module - 模块名 + * @param {string} emoji - Emoji标签 + * @param {any} data - 附加数据 + */ + log(level, message, module = '', emoji = '', data = null) { + if (!this.shouldLog(level)) return + + const { consoleMessage, fileMessage } = this.formatMessage(level, message, module, emoji) + + // 控制台输出 + if (level === 'ERROR') { + console.error(consoleMessage) + } else if (level === 'WARN') { + console.warn(consoleMessage) + } else { + console.log(consoleMessage) + } + + // 输出附加数据 + if (data !== null) { + console.log(data) + } + + // 文件输出 + this.writeToFile(fileMessage + (data ? `\n${JSON.stringify(data, null, 2)}` : '')) + } + + // 便捷方法 + debug(message, module = '', emoji = '', data = null) { + this.log('DEBUG', message, module, emoji || this.emojis.DEBUG, data) + } + + info(message, module = '', emoji = '', data = null) { + this.log('INFO', message, module, emoji || this.emojis.INFO, data) + } + + warn(message, module = '', emoji = '', data = null) { + this.log('WARN', message, module, emoji || this.emojis.WARN, data) + } + + error(message, module = '', emoji = '', data = null) { + this.log('ERROR', message, module, emoji || this.emojis.ERROR, data) + } + + // 特定场景的便捷方法 + success(message, module = '', data = null) { + this.info(message, module, this.emojis.SUCCESS, data) + } + + network(message, module = '', data = null) { + this.info(message, module, this.emojis.NETWORK, data) + } + + database(message, module = '', data = null) { + this.info(message, module, this.emojis.DATABASE, data) + } + + auth(message, module = '', data = null) { + this.info(message, module, this.emojis.AUTH, data) + } + + redis(message, module = '', data = null) { + this.info(message, module, this.emojis.REDIS, data) + } + + chat(message, module = '', data = null) { + this.info(message, module, this.emojis.CHAT, data) + } + + server(message, module = '', data = null) { + this.info(message, module, this.emojis.SERVER, data) + } +} + +// 创建默认实例 +const defaultLogger = new Logger({ + level: process.env.LOG_LEVEL || 'INFO', + enableFileLog: process.env.ENABLE_FILE_LOG === 'true', + showModule: true, + showTimestamp: true, + showLevel: true +}) + +module.exports = { + Logger, + logger: defaultLogger +} diff --git a/src/utils/paths.js b/src/utils/paths.js new file mode 100644 index 0000000000000000000000000000000000000000..99b3b4080fde14900f8ab925f1027ced4a72b5ab --- /dev/null +++ b/src/utils/paths.js @@ -0,0 +1,20 @@ +const path = require('path') + +const resolvePath = (targetPath, fallbackPath) => { + if (targetPath && targetPath.trim()) { + return path.resolve(targetPath) + } + + return path.resolve(fallbackPath) +} + +const dataDir = resolvePath(process.env.DATA_DIR, path.join(__dirname, '../../data')) +const cacheDir = resolvePath(process.env.CACHE_DIR, path.join(__dirname, '../../caches')) +const logDir = resolvePath(process.env.LOG_DIR, path.join(__dirname, '../../logs')) + +module.exports = { + dataDir, + cacheDir, + logDir, + dataFilePath: path.join(dataDir, 'data.json') +} diff --git a/src/utils/precise-tokenizer.js b/src/utils/precise-tokenizer.js new file mode 100644 index 0000000000000000000000000000000000000000..e039e99070bc73a579faf425055eab9bf234748b --- /dev/null +++ b/src/utils/precise-tokenizer.js @@ -0,0 +1,110 @@ +/** + * 精准Token统计工具 + * 使用tiktoken进行准确的token计数 + */ + +const tiktoken = require('tiktoken') + +/** + * 使用tiktoken进行精准token计数 + * @param {string} text - 要计数的文本 + * @param {string} model - 模型名称,默认为gpt-3.5-turbo + * @returns {number} 精确的token数量 + */ +function countTokens(text, model = 'gpt-3.5-turbo') { + if (!text || typeof text !== 'string') return 0 + + const encoding = tiktoken.encoding_for_model(model) + const tokens = encoding.encode(text) + encoding.free() // 释放内存 + return tokens.length +} + + + +/** + * 计算消息数组的token数量 + * @param {Array} messages - 消息数组 + * @param {string} model - 模型名称 + * @returns {number} 总token数量 + */ +function countMessagesTokens(messages, model = 'gpt-3.5-turbo') { + if (!Array.isArray(messages)) return 0 + + let totalTokens = 0 + + // 每条消息的基础开销(根据OpenAI文档) + const messageOverhead = 4 // 每条消息约4个token的格式开销 + + for (const message of messages) { + totalTokens += messageOverhead + + // 角色token + if (message.role) { + totalTokens += countTokens(message.role, model) + } + + // 内容token + if (typeof message.content === 'string') { + totalTokens += countTokens(message.content, model) + } else if (Array.isArray(message.content)) { + for (const item of message.content) { + if (item.text) { + totalTokens += countTokens(item.text, model) + } + } + } + + // 函数调用等其他字段的token计算 + if (message.function_call) { + totalTokens += countTokens(JSON.stringify(message.function_call), model) + } + } + + // 对话的额外开销 + totalTokens += 2 // 对话开始和结束的token + + return totalTokens +} + +/** + * 创建精准的usage对象 + * @param {Array|string} promptMessages - 提示消息或文本 + * @param {string} completionText - 完成文本 + * @param {object} realUsage - 真实的usage数据(如果有) + * @param {string} model - 模型名称 + * @returns {object} usage对象 + */ +function createUsageObject(promptMessages, completionText = '', realUsage = null, model = 'gpt-3.5-turbo') { + // 如果有真实的usage数据,优先使用 + if (realUsage && realUsage.prompt_tokens && realUsage.completion_tokens) { + return { + prompt_tokens: realUsage.prompt_tokens, + completion_tokens: realUsage.completion_tokens, + total_tokens: realUsage.total_tokens || (realUsage.prompt_tokens + realUsage.completion_tokens) + } + } + + // 计算prompt tokens + let promptTokens = 0 + if (Array.isArray(promptMessages)) { + promptTokens = countMessagesTokens(promptMessages, model) + } else if (typeof promptMessages === 'string') { + promptTokens = countTokens(promptMessages, model) + } + + // 计算completion tokens + const completionTokens = countTokens(completionText, model) + + return { + prompt_tokens: promptTokens, + completion_tokens: completionTokens, + total_tokens: promptTokens + completionTokens + } +} + +module.exports = { + countTokens, + countMessagesTokens, + createUsageObject +} diff --git a/src/utils/proxy-helper.js b/src/utils/proxy-helper.js new file mode 100644 index 0000000000000000000000000000000000000000..e12f445b081fbe62a5b6de4b0a5a101c72adbb16 --- /dev/null +++ b/src/utils/proxy-helper.js @@ -0,0 +1,66 @@ +const config = require('../config/index.js') +const { HttpsProxyAgent } = require('https-proxy-agent') + +// 缓存代理 agent 实例 +let proxyAgentInstance = null + +/** + * 获取代理 Agent + * @returns {HttpsProxyAgent|undefined} + */ +const getProxyAgent = () => { + if (config.proxyUrl) { + if (!proxyAgentInstance) { + proxyAgentInstance = new HttpsProxyAgent(config.proxyUrl) + } + return proxyAgentInstance + } + return undefined +} + +/** + * 获取 Chat API 基础 URL + * @returns {string} + */ +const getChatBaseUrl = () => config.qwenChatProxyUrl + +/** + * 获取 CLI API 基础 URL + * @returns {string} + */ +const getCliBaseUrl = () => config.qwenCliProxyUrl + +/** + * 为 axios 请求配置添加代理设置 + * @param {Object} requestConfig - axios 请求配置对象 + * @returns {Object} 添加了代理配置的请求对象 + */ +const applyProxyToAxiosConfig = (requestConfig = {}) => { + const proxyAgent = getProxyAgent() + if (proxyAgent) { + requestConfig.httpsAgent = proxyAgent + requestConfig.proxy = false + } + return requestConfig +} + +/** + * 为 fetch 请求配置添加代理设置 + * @param {Object} fetchOptions - fetch 请求配置对象 + * @returns {Object} 添加了代理配置的请求对象 + */ +const applyProxyToFetchOptions = (fetchOptions = {}) => { + const proxyAgent = getProxyAgent() + if (proxyAgent) { + fetchOptions.agent = proxyAgent + } + return fetchOptions +} + +module.exports = { + getProxyAgent, + getChatBaseUrl, + getCliBaseUrl, + applyProxyToAxiosConfig, + applyProxyToFetchOptions +} diff --git a/src/utils/redis.js b/src/utils/redis.js new file mode 100644 index 0000000000000000000000000000000000000000..73c111c863aa27ed3f5007ce0da67f2b4c03e3fe --- /dev/null +++ b/src/utils/redis.js @@ -0,0 +1,401 @@ +const Redis = require('ioredis') +const config = require('../config/index.js') +const { logger } = require('./logger') + +/** + * Redis 连接管理器 + * 实现按需连接机制,仅在读写操作时建立连接 + */ + +// 连接配置 +const REDIS_CONFIG = { + maxRetries: 3, + connectTimeout: 10000, + commandTimeout: 5000, + retryDelayOnFailover: 200, + maxRetriesPerRequest: 3, + enableOfflineQueue: false, + lazyConnect: true, + keepAlive: 30000, + connectionName: 'qwen2api_on_demand' +} + +// 连接状态 +let redis = null +let isConnecting = false +let connectionPromise = null +let lastActivity = 0 +let idleTimer = null + +// 空闲超时时间 (5分钟) +const IDLE_TIMEOUT = 5 * 60 * 1000 + +/** + * 判断是否需要TLS + */ +const isTLS = config.redisURL && (config.redisURL.startsWith('rediss://') || config.redisURL.includes('--tls')) + +/** + * 创建Redis连接配置 + */ +const createRedisConfig = () => ({ + ...REDIS_CONFIG, + // TLS配置 + ...(isTLS ? { + tls: { + rejectUnauthorized: true + } + } : {}), + + // 重试策略 + retryStrategy(times) { + if (times > REDIS_CONFIG.maxRetries) { + logger.error(`Redis连接重试次数超限: ${times}`, 'REDIS') + return null + } + + const delay = Math.min(100 * Math.pow(2, times), 3000) + logger.info(`Redis重试连接: ${times}, 延迟: ${delay}ms`, 'REDIS', '🔄') + return delay + }, + + // 错误重连策略 + reconnectOnError(err) { + const targetErrors = ['READONLY', 'ETIMEDOUT', 'ECONNRESET', 'EPIPE'] + return targetErrors.some(e => err.message.includes(e)) + } +}) + +/** + * 更新活动时间并重置空闲定时器 + */ +const updateActivity = () => { + lastActivity = Date.now() + + // 清除现有定时器 + if (idleTimer) { + clearTimeout(idleTimer) + } + + // 设置新的空闲定时器 + idleTimer = setTimeout(() => { + if (redis && Date.now() - lastActivity > IDLE_TIMEOUT) { + logger.info('Redis连接空闲超时,断开连接', 'REDIS', '🔌') + disconnectRedis() + } + }, IDLE_TIMEOUT) +} + +/** + * 建立Redis连接 + */ +const connectRedis = async () => { + if (redis && redis.status === 'ready') { + updateActivity() + return redis + } + + if (isConnecting && connectionPromise) { + return connectionPromise + } + + isConnecting = true + connectionPromise = new Promise(async (resolve, reject) => { + try { + logger.info('建立Redis连接...', 'REDIS', '🔌') + + const newRedis = new Redis(config.redisURL, createRedisConfig()) + + // 设置事件监听器 + newRedis.on('connect', () => { + logger.success('Redis连接建立', 'REDIS') + }) + + newRedis.on('ready', () => { + logger.success('Redis准备就绪', 'REDIS') + redis = newRedis + isConnecting = false + updateActivity() + resolve(redis) + }) + + newRedis.on('error', (err) => { + logger.error('Redis连接错误', 'REDIS', '', err) + if (isConnecting) { + isConnecting = false + reject(err) + } + }) + + newRedis.on('close', () => { + logger.info('Redis连接关闭', 'REDIS', '🔌') + redis = null + }) + + newRedis.on('reconnecting', (delay) => { + logger.info(`Redis重新连接中...延迟: ${delay}ms`, 'REDIS', '🔄') + }) + + // 等待连接就绪 + await newRedis.connect() + + } catch (error) { + isConnecting = false + logger.error('Redis连接失败', 'REDIS', '', error) + reject(error) + } + }) + + return connectionPromise +} + +/** + * 断开Redis连接 + */ +const disconnectRedis = async () => { + if (idleTimer) { + clearTimeout(idleTimer) + idleTimer = null + } + + if (redis) { + try { + await redis.disconnect() + logger.info('Redis连接已断开', 'REDIS', '🔌') + } catch (error) { + logger.error('断开Redis连接时出错', 'REDIS', '', error) + } finally { + redis = null + isConnecting = false + connectionPromise = null + } + } +} + +/** + * 确保Redis连接可用 + */ +const ensureConnection = async () => { + if (config.dataSaveMode !== 'redis') { + logger.error('当前数据保存模式不是Redis', 'REDIS') + throw new Error('当前数据保存模式不是Redis') + } + + if (!redis || redis.status !== 'ready') { + return await connectRedis() + } + + updateActivity() + return redis +} + +/** + * 获取所有账户 + * @returns {Promise} 所有账户信息数组 + */ +const getAllAccounts = async () => { + try { + const client = await ensureConnection() + + // 使用SCAN命令替代KEYS命令,避免阻塞Redis服务器 + const keys = [] + let cursor = '0' + + do { + const result = await client.scan(cursor, 'MATCH', 'user:*', 'COUNT', 100) + cursor = result[0] + keys.push(...result[1]) + } while (cursor !== '0') + + if (!keys.length) { + logger.info('没有找到任何账户', 'REDIS', '✅') + return [] + } + + // 使用pipeline一次性获取所有账户数据 + const pipeline = client.pipeline() + keys.forEach(key => { + pipeline.hgetall(key) + }) + + const results = await pipeline.exec() + if (!results) { + logger.error('获取账户数据失败', 'REDIS') + return [] + } + + const accounts = results.map((result, index) => { + // result格式为[err, value] + const [err, accountData] = result + if (err) { + logger.error(`获取账户 ${keys[index]} 数据失败`, 'REDIS', '', err) + return null + } + if (!accountData || Object.keys(accountData).length === 0) { + logger.error(`账户 ${keys[index]} 数据为空`, 'REDIS') + return null + } + return { + email: keys[index].replace('user:', ''), + password: accountData.password || '', + token: accountData.token || '', + expires: accountData.expires || '' + } + }).filter(Boolean) // 过滤掉null值 + + logger.success(`获取所有账户成功,共 ${accounts.length} 个账户`, 'REDIS') + return accounts + } catch (err) { + logger.error('获取账户时出错', 'REDIS', '', err) + return [] + } +} + +/** + * 设置账户 + * @param {string} key - 键名(邮箱) + * @param {Object} value - 账户信息 + * @returns {Promise} 设置是否成功 + */ +const setAccount = async (key, value) => { + try { + const client = await ensureConnection() + + const { password, token, expires } = value + await client.hset(`user:${key}`, { + password: password || '', + token: token || '', + expires: expires || '' + }) + + logger.success(`账户 ${key} 设置成功`, 'REDIS') + return true + } catch (err) { + logger.error(`设置账户 ${key} 失败`, 'REDIS', '', err) + return false + } +} + +/** + * 删除账户 + * @param {string} key - 键名(邮箱) + * @returns {Promise} 删除是否成功 + */ +const deleteAccount = async (key) => { + try { + const client = await ensureConnection() + + const result = await client.del(`user:${key}`) + if (result > 0) { + logger.success(`账户 ${key} 删除成功`, 'REDIS') + return true + } else { + logger.warn(`账户 ${key} 不存在`, 'REDIS') + return false + } + } catch (err) { + logger.error(`删除账户 ${key} 失败`, 'REDIS', '', err) + return false + } +} + +/** + * 检查键是否存在 + * @param {string} key - 键名 + * @returns {Promise} 键是否存在 + */ +const checkKeyExists = async (key = 'headers') => { + try { + const client = await ensureConnection() + + const exists = await client.exists(key) + const result = exists === 1 + + logger.info(`键 "${key}" ${result ? '存在' : '不存在'}`, 'REDIS', result ? '✅' : '❌') + return result + } catch (err) { + logger.error(`检查键 "${key}" 时出错`, 'REDIS', '', err) + return false + } +} + +/** + * 获取连接状态 + * @returns {Object} 连接状态信息 + */ +const getConnectionStatus = () => { + return { + connected: redis && redis.status === 'ready', + status: redis ? redis.status : 'disconnected', + lastActivity: lastActivity, + idleTimeout: IDLE_TIMEOUT, + config: REDIS_CONFIG + } +} + +/** + * 手动断开连接(用于应用关闭时清理) + */ +const cleanup = async () => { + logger.info('清理Redis连接...', 'REDIS', '🧹') + await disconnectRedis() +} + +// 创建兼容的Redis客户端对象 +const redisClient = { + getAllAccounts, + setAccount, + deleteAccount, + checkKeyExists, + getConnectionStatus, + cleanup, + + // 直接Redis命令的代理方法(按需连接) + async hset(key, ...args) { + const client = await ensureConnection() + return client.hset(key, ...args) + }, + + async hget(key, field) { + const client = await ensureConnection() + return client.hget(key, field) + }, + + async hgetall(key) { + const client = await ensureConnection() + return client.hgetall(key) + }, + + async exists(key) { + const client = await ensureConnection() + return client.exists(key) + }, + + async keys(pattern) { + const client = await ensureConnection() + // 使用SCAN命令替代KEYS命令,避免阻塞Redis服务器 + const keys = [] + let cursor = '0' + + do { + const result = await client.scan(cursor, 'MATCH', pattern, 'COUNT', 100) + cursor = result[0] + keys.push(...result[1]) + } while (cursor !== '0') + + return keys + }, + + async del(key) { + const client = await ensureConnection() + return client.del(key) + } +} + +// 进程退出时清理连接 +process.on('exit', cleanup) +process.on('SIGINT', cleanup) +process.on('SIGTERM', cleanup) + +// 根据配置决定是否导出Redis客户端 +module.exports = config.dataSaveMode === 'redis' ? redisClient : null diff --git a/src/utils/request.js b/src/utils/request.js new file mode 100644 index 0000000000000000000000000000000000000000..46f1da6d9852d7da4ae9a8eafb376abd37b73052 --- /dev/null +++ b/src/utils/request.js @@ -0,0 +1,157 @@ +const axios = require('axios') +const accountManager = require('./account.js') +const { logger } = require('./logger') +const { getSsxmodItna, getSsxmodItna2 } = require('./ssxmod-manager') +const { getProxyAgent, getChatBaseUrl, applyProxyToAxiosConfig } = require('./proxy-helper') + + +/** + * 发送聊天请求 + * @param {Object} body - 请求体 + * @param {number} retryCount - 当前重试次数 + * @param {string} lastUsedEmail - 上次使用的邮箱(用于错误记录) + * @returns {Promise} 响应结果 + */ +const sendChatRequest = async (body) => { + try { + // 获取可用的令牌 + const currentToken = accountManager.getAccountToken() + + if (!currentToken) { + logger.error('无法获取有效的访问令牌', 'TOKEN') + return { + status: false, + response: null + } + } + + const chatBaseUrl = getChatBaseUrl() + const proxyAgent = getProxyAgent() + + // 构建请求配置 + const requestConfig = { + headers: { + 'Authorization': `Bearer ${currentToken}`, + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36 Edg/143.0.0.0", + "Connection": "keep-alive", + "Accept": "application/json", + "Accept-Encoding": "gzip, deflate, br, zstd", + "Content-Type": "application/json", + "Timezone": "Mon Dec 08 2025 17:28:55 GMT+0800", + "sec-ch-ua": "\"Microsoft Edge\";v=\"143\", \"Chromium\";v=\"143\", \"Not A(Brand\";v=\"24\"", + "source": "web", + "Version": "0.1.13", + "bx-v": "2.5.31", + "Origin": chatBaseUrl, + "Sec-Fetch-Site": "same-origin", + "Sec-Fetch-Mode": "cors", + "Sec-Fetch-Dest": "empty", + "Referer": `${chatBaseUrl}/c/guest`, + "Accept-Language": "zh-CN,zh;q=0.9,en-US;q=0.8,en;q=0.7", + "Cookie": `ssxmod_itna=${getSsxmodItna()};ssxmod_itna2=${getSsxmodItna2()}`, + }, + responseType: 'stream', // Always use streaming (upstream doesn't support stream=false) + timeout: 60 * 1000, + } + + // 添加代理配置 + if (proxyAgent) { + requestConfig.httpsAgent = proxyAgent + requestConfig.proxy = false // 禁用axios默认代理,使用httpsAgent + } + + // console.log(body) + // console.log(requestConfig) + + const chat_id = await generateChatID(currentToken, body.model) + + logger.network(`发送聊天请求`, 'REQUEST') + const response = await axios.post(`${chatBaseUrl}/api/v2/chat/completions?chat_id=` + chat_id, { + ...body, + stream: true, // Always request streaming (upstream doesn't support stream=false) + chat_id: chat_id + }, requestConfig) + + // 请求成功 + if (response.status === 200) { + // console.log(response.data) + return { + currentToken: currentToken, + status: true, + response: response.data + } + } + + } catch (error) { + console.log(error) + logger.error('发送聊天请求失败', 'REQUEST', '', error.message) + return { + status: false, + response: null + } + } +} + +/** + * 生成chat_id + * @param {*} currentToken + * @returns {Promise} 返回生成的chat_id,如果失败则返回null + */ +const generateChatID = async (currentToken, model) => { + try { + const chatBaseUrl = getChatBaseUrl() + const proxyAgent = getProxyAgent() + + const requestConfig = { + headers: { + 'Authorization': `Bearer ${currentToken}`, + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36 Edg/143.0.0.0", + "Connection": "keep-alive", + "Accept": "application/json", + "Accept-Encoding": "gzip, deflate, br, zstd", + "Content-Type": "application/json", + "Timezone": "Mon Dec 08 2025 17:28:55 GMT+0800", + "sec-ch-ua": "\"Microsoft Edge\";v=\"143\", \"Chromium\";v=\"143\", \"Not A(Brand\";v=\"24\"", + "source": "web", + "Version": "0.1.13", + "bx-v": "2.5.31", + "Origin": chatBaseUrl, + "Sec-Fetch-Site": "same-origin", + "Sec-Fetch-Mode": "cors", + "Sec-Fetch-Dest": "empty", + "Referer": `${chatBaseUrl}/c/guest`, + "Accept-Language": "zh-CN,zh;q=0.9,en-US;q=0.8,en;q=0.7", + "Cookie": `ssxmod_itna=${getSsxmodItna()};ssxmod_itna2=${getSsxmodItna2()}`, + } + } + + // 添加代理配置 + if (proxyAgent) { + requestConfig.httpsAgent = proxyAgent + requestConfig.proxy = false + } + + const response_data = await axios.post(`${chatBaseUrl}/api/v2/chats/new`, { + "title": "New Chat", + "models": [ + model + ], + "chat_mode": "local", + "chat_type": "t2i", + "timestamp": new Date().getTime() + }, requestConfig) + + // console.log(response_data.data) + + return response_data.data?.data?.id || null + + } catch (error) { + logger.error('生成chat_id失败', 'CHAT', '', error.message) + return null + } +} + +module.exports = { + sendChatRequest, + generateChatID +} \ No newline at end of file diff --git a/src/utils/setting.js b/src/utils/setting.js new file mode 100644 index 0000000000000000000000000000000000000000..2def38739e2e893880bf5fe5b5d7d8a85807198e --- /dev/null +++ b/src/utils/setting.js @@ -0,0 +1,133 @@ +const accountManager = require('./account') +const { logger } = require('./logger') + +/** + * 账户设置工具 + * 提供账户的保存和删除功能,使用统一的账户管理器 + */ + +/** + * 保存账户信息 + * @param {string} email - 邮箱地址 + * @param {string} password - 密码 + * @param {string} token - 访问令牌 + * @param {number} expires - 过期时间戳 + * @returns {Promise} 保存是否成功 + */ +const saveAccounts = async (email, password, token, expires) => { + try { + // 参数验证 + if (!email || !password) { + logger.error('保存账户失败: 邮箱和密码不能为空', 'SETTING') + return false + } + + // 使用账户管理器的统一方法 + const success = await accountManager.addAccount(email, password) + + if (success) { + logger.success(`账户 ${email} 保存成功`, 'SETTING') + return true + } else { + logger.error(`账户 ${email} 保存失败`, 'SETTING') + return false + } + } catch (error) { + logger.error(`保存账户 ${email} 时发生错误`, 'SETTING', '', error) + return false + } +} + +/** + * 删除账户 + * @param {string} email - 邮箱地址 + * @returns {Promise} 删除是否成功 + */ +const deleteAccount = async (email) => { + try { + // 参数验证 + if (!email) { + logger.error('删除账户失败: 邮箱不能为空', 'SETTING') + return false + } + + // 使用账户管理器的统一方法 + const success = await accountManager.removeAccount(email) + + if (success) { + logger.success(`账户 ${email} 删除成功`, 'SETTING') + return true + } else { + logger.error(`账户 ${email} 删除失败`, 'SETTING') + return false + } + } catch (error) { + logger.error(`删除账户 ${email} 时发生错误`, 'SETTING', '', error) + return false + } +} + +/** + * 获取所有账户信息 + * @returns {Array} 账户列表 + */ +const getAllAccounts = () => { + try { + return accountManager.getAllAccountKeys() + } catch (error) { + logger.error('获取账户列表时发生错误', 'SETTING', '', error) + return [] + } +} + +/** + * 获取账户健康状态 + * @returns {Object} 健康状态统计 + */ +const getAccountHealth = () => { + try { + return accountManager.getHealthStats() + } catch (error) { + logger.error('获取账户健康状态时发生错误', 'SETTING', '', error) + return { + accounts: { total: 0, valid: 0, expired: 0, expiringSoon: 0, invalid: 0 }, + rotation: { total: 0, available: 0, inCooldown: 0 }, + initialized: false + } + } +} + +/** + * 手动刷新账户令牌 + * @param {string} email - 邮箱地址 + * @returns {Promise} 刷新是否成功 + */ +const refreshAccountToken = async (email) => { + try { + if (!email) { + logger.error('刷新令牌失败: 邮箱不能为空', 'SETTING') + return false + } + + const success = await accountManager.refreshAccountToken(email) + + if (success) { + logger.success(`账户 ${email} 令牌刷新成功`, 'SETTING') + return true + } else { + logger.error(`账户 ${email} 令牌刷新失败`, 'SETTING') + return false + } + } catch (error) { + logger.error(`刷新账户 ${email} 令牌时发生错误`, 'SETTING', '', error) + return false + } +} + +module.exports = { + saveAccounts, + deleteAccount, + getAllAccounts, + getAccountHealth, + refreshAccountToken +} \ No newline at end of file diff --git a/src/utils/ssxmod-manager.js b/src/utils/ssxmod-manager.js new file mode 100644 index 0000000000000000000000000000000000000000..06326aa5228d9d5833e4b1ebebc64a64efdacd3d --- /dev/null +++ b/src/utils/ssxmod-manager.js @@ -0,0 +1,98 @@ +/** + * SSXMOD Cookie 管理器 + * 负责生成和定时刷新 ssxmod_itna 和 ssxmod_itna2 Cookie + */ + +const { generateCookies } = require('./cookie-generator'); +const { logger } = require('./logger'); + +// 全局 Cookie 存储 +let currentCookies = { + ssxmod_itna: '', + ssxmod_itna2: '', + timestamp: 0 +}; + +// 刷新间隔 (15分钟) +const REFRESH_INTERVAL = 15 * 60 * 1000; + +// 定时器引用 +let refreshTimer = null; + +/** + * 刷新 SSXMOD Cookie + */ +function refreshCookies() { + try { + const result = generateCookies(); + currentCookies = { + ssxmod_itna: result.ssxmod_itna, + ssxmod_itna2: result.ssxmod_itna2, + timestamp: result.timestamp + }; + logger.info(`SSXMOD Cookie 已刷新`, 'SSXMOD'); + } catch (error) { + logger.error('SSXMOD Cookie 刷新失败', 'SSXMOD', '', error.message); + } +} + +/** + * 初始化 SSXMOD 管理器 + * 启动时生成一次 Cookie,并设置定时刷新 + */ +function initSsxmodManager() { + // 立即生成一次 + refreshCookies(); + + // 设置定时刷新 (每15分钟) + if (refreshTimer) { + clearInterval(refreshTimer); + } + refreshTimer = setInterval(refreshCookies, REFRESH_INTERVAL); + + logger.info(`SSXMOD 管理器已启动,刷新间隔: ${REFRESH_INTERVAL / 1000 / 60} 分钟`, 'SSXMOD'); +} + +/** + * 获取当前 ssxmod_itna + * @returns {string} ssxmod_itna 值 + */ +function getSsxmodItna() { + return currentCookies.ssxmod_itna; +} + +/** + * 获取当前 ssxmod_itna2 + * @returns {string} ssxmod_itna2 值 + */ +function getSsxmodItna2() { + return currentCookies.ssxmod_itna2; +} + +/** + * 获取完整的 Cookie 对象 + * @returns {Object} 包含 ssxmod_itna 和 ssxmod_itna2 的对象 + */ +function getCookies() { + return { ...currentCookies }; +} + +/** + * 停止定时刷新 + */ +function stopRefresh() { + if (refreshTimer) { + clearInterval(refreshTimer); + refreshTimer = null; + logger.info('SSXMOD 定时刷新已停止', 'SSXMOD'); + } +} + +module.exports = { + initSsxmodManager, + getSsxmodItna, + getSsxmodItna2, + getCookies, + refreshCookies, + stopRefresh +}; diff --git a/src/utils/token-manager.js b/src/utils/token-manager.js new file mode 100644 index 0000000000000000000000000000000000000000..9178ddbb507f035d7950b7deb284be22b50a0bd6 --- /dev/null +++ b/src/utils/token-manager.js @@ -0,0 +1,256 @@ +const axios = require('axios') +const { sha256Encrypt, JwtDecode } = require('./tools') +const { logger } = require('./logger') +const { getProxyAgent, getChatBaseUrl, applyProxyToAxiosConfig } = require('./proxy-helper') + +/** + * 令牌管理器 + * 负责令牌的获取、验证、刷新等操作 + */ +class TokenManager { + constructor() { + this.defaultHeaders = { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36 Edg/134.0.0.0' + } + } + + /** + * 获取登录端点 + * @returns {string} 登录端点URL + */ + get loginEndpoint() { + return `${getChatBaseUrl()}/api/v1/auths/signin` + } + + /** + * 用户登录获取令牌 + * @param {string} email - 邮箱 + * @param {string} password - 密码 + * @returns {Promise} 令牌或null + */ + async login(email, password) { + try { + const proxyAgent = getProxyAgent() + const requestConfig = { + headers: this.defaultHeaders, + timeout: 10000 // 10秒超时 + } + + // 添加代理配置 + if (proxyAgent) { + requestConfig.httpsAgent = proxyAgent + requestConfig.proxy = false + } + + const response = await axios.post(this.loginEndpoint, { + email: email, + password: sha256Encrypt(password) + }, requestConfig) + + if (response.data && response.data.token) { + logger.success(`${email} 登录成功:${response.data.token}`, 'AUTH') + return response.data.token + } else { + logger.error(`${email} 登录响应缺少令牌`, 'AUTH') + return null + } + } catch (error) { + if (error.response) { + logger.error(`${email} 登录失败 (${error.response.status})`, 'AUTH', '', error) + } else if (error.request) { + logger.error(`${email} 登录失败: 网络请求超时或无响应`, 'AUTH') + } else { + logger.error(`${email} 登录失败`, 'AUTH', '', error) + } + return null + } + } + + /** + * 验证令牌是否有效 + * @param {string} token - JWT令牌 + * @returns {Object|null} 解码后的令牌信息或null + */ + validateToken(token) { + try { + if (!token) return null + + const decoded = JwtDecode(token) + if (!decoded || !decoded.exp) { + return null + } + + const now = Math.floor(Date.now() / 1000) + if (decoded.exp <= now) { + return null // 令牌已过期 + } + + return decoded + } catch (error) { + logger.error('令牌验证失败', 'TOKEN', '', error) + return null + } + } + + /** + * 检查令牌是否即将过期 + * @param {string} token - JWT令牌 + * @param {number} thresholdHours - 过期阈值(小时) + * @returns {boolean} 是否即将过期 + */ + isTokenExpiringSoon(token, thresholdHours = 6) { + const decoded = this.validateToken(token) + if (!decoded) return true // 无效令牌视为即将过期 + + const now = Math.floor(Date.now() / 1000) + const thresholdSeconds = thresholdHours * 60 * 60 + return decoded.exp - now < thresholdSeconds + } + + /** + * 获取令牌剩余有效时间(小时) + * @param {string} token - JWT令牌 + * @returns {number} 剩余小时数,-1表示无效令牌 + */ + getTokenRemainingHours(token) { + const decoded = this.validateToken(token) + if (!decoded) return -1 + + const now = Math.floor(Date.now() / 1000) + const remainingSeconds = decoded.exp - now + return Math.max(0, Math.round(remainingSeconds / 3600)) + } + + /** + * 刷新单个账户的令牌 + * @param {Object} account - 账户对象 {email, password, token, expires} + * @returns {Promise} 更新后的账户对象或null + */ + async refreshToken(account) { + try { + const newToken = await this.login(account.email, account.password) + if (!newToken) { + return null + } + + const decoded = this.validateToken(newToken) + if (!decoded) { + logger.error(`刷新后的令牌无效: ${account.email}`, 'TOKEN') + return null + } + + const updatedAccount = { + ...account, + token: newToken, + expires: decoded.exp + } + + const remainingHours = this.getTokenRemainingHours(newToken) + logger.success(`令牌刷新成功: ${account.email} (有效期: ${remainingHours}小时)`, 'TOKEN') + + return updatedAccount + } catch (error) { + logger.error(`刷新令牌失败 (${account.email})`, 'TOKEN', '', error) + return null + } + } + + /** + * 批量刷新即将过期的令牌 + * @param {Array} accounts - 账户列表 + * @param {number} thresholdHours - 过期阈值(小时) + * @param {Function} onEachRefresh - 每次刷新成功后的回调函数 (updatedAccount, index, total) => void + * @returns {Promise} 刷新结果 {refreshed: Array, failed: Array} + */ + async batchRefreshTokens(accounts, thresholdHours = 24, onEachRefresh = null) { + const needsRefresh = accounts.filter(account => + this.isTokenExpiringSoon(account.token, thresholdHours) + ) + + if (needsRefresh.length === 0) { + logger.info('没有需要刷新的令牌', 'TOKEN') + return { refreshed: [], failed: [] } + } + + logger.info(`发现 ${needsRefresh.length} 个令牌需要刷新`, 'TOKEN') + + const refreshed = [] + const failed = [] + + for (let i = 0; i < needsRefresh.length; i++) { + const account = needsRefresh[i] + const updatedAccount = await this.refreshToken(account) + + if (updatedAccount) { + refreshed.push(updatedAccount) + + // 如果提供了回调函数,立即调用 + if (onEachRefresh && typeof onEachRefresh === 'function') { + try { + await onEachRefresh(updatedAccount, i + 1, needsRefresh.length) + } catch (error) { + logger.error(`刷新回调函数执行失败 (${account.email})`, 'TOKEN', '', error) + } + } + } else { + failed.push(account) + } + + // 添加延迟避免请求过于频繁 + await this._delay(1000) + } + + logger.success(`令牌刷新完成: 成功 ${refreshed.length} 个,失败 ${failed.length} 个`, 'TOKEN') + return { refreshed, failed } + } + + /** + * 获取健康的令牌统计信息 + * @param {Array} accounts - 账户列表 + * @returns {Object} 统计信息 + */ + getTokenHealthStats(accounts) { + const stats = { + total: accounts.length, + valid: 0, + expired: 0, + expiringSoon: 0, + invalid: 0 + } + + accounts.forEach(account => { + if (!account.token) { + stats.invalid++ + return + } + + const decoded = this.validateToken(account.token) + if (!decoded) { + stats.invalid++ + return + } + + const now = Math.floor(Date.now() / 1000) + if (decoded.exp <= now) { + stats.expired++ + } else if (this.isTokenExpiringSoon(account.token, 6)) { + stats.expiringSoon++ + } else { + stats.valid++ + } + }) + + return stats + } + + /** + * 延迟函数 + * @param {number} ms - 延迟毫秒数 + * @private + */ + async _delay(ms) { + return new Promise(resolve => setTimeout(resolve, ms)) + } +} + +module.exports = TokenManager diff --git a/src/utils/tools.js b/src/utils/tools.js new file mode 100644 index 0000000000000000000000000000000000000000..496f6ae2235911c30d27b31621040191017b2743 --- /dev/null +++ b/src/utils/tools.js @@ -0,0 +1,54 @@ +const crypto = require('crypto') +const { jwtDecode } = require('jwt-decode') +const { logger } = require('./logger') + + +const isJson = (str) => { + try { + JSON.parse(str) + return true + } catch (error) { + return false + } +} + +const sleep = async (ms) => { + return await new Promise(resolve => setTimeout(resolve, ms)) +} + +const sha256Encrypt = (text) => { + if (typeof text !== 'string') { + logger.error('输入必须是字符串类型', 'TOOLS') + throw new Error('输入必须是字符串类型') + } + const hash = crypto.createHash('sha256') + hash.update(text, 'utf-8') + return hash.digest('hex') +} + +const JwtDecode = (token) => { + try { + const decoded = jwtDecode(token, { complete: true }) + return decoded + } catch (error) { + logger.error('解析JWT失败', 'JWT', '', error) + return null + } +} + +/** + * 生成UUID v4 + * 使用Node.js内置的crypto.randomUUID() + * @returns {string} UUID v4字符串 + */ +const generateUUID = () => { + return crypto.randomUUID() +} + +module.exports = { + isJson, + sleep, + sha256Encrypt, + JwtDecode, + generateUUID +} diff --git a/src/utils/upload.js b/src/utils/upload.js new file mode 100644 index 0000000000000000000000000000000000000000..1318bcd13c085604d361c20db1dc9782fa94e7bf --- /dev/null +++ b/src/utils/upload.js @@ -0,0 +1,293 @@ +const axios = require('axios') +const OSS = require('ali-oss') +const mimetypes = require('mime-types') +const { logger } = require('./logger') +const { generateUUID } = require('./tools.js') +const { getProxyAgent, getChatBaseUrl, applyProxyToAxiosConfig } = require('./proxy-helper') + +// 配置常量 +const UPLOAD_CONFIG = { + get stsTokenUrl() { + return `${getChatBaseUrl()}/api/v1/files/getstsToken` + }, + maxRetries: 3, + timeout: 30000, + maxFileSize: 100 * 1024 * 1024, // 100MB + retryDelay: 1000 +} + +// 支持的文件类型 +const SUPPORTED_TYPES = { + image: ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/bmp'], + video: ['video/mp4', 'video/avi', 'video/mov', 'video/wmv', 'video/flv'], + audio: ['audio/mp3', 'audio/wav', 'audio/aac', 'audio/ogg'], + document: ['application/pdf', 'text/plain', 'application/msword'] +} + +/** + * 验证文件大小 + * @param {number} fileSize - 文件大小(字节) + * @returns {boolean} 是否符合大小限制 + */ +const validateFileSize = (fileSize) => { + return fileSize > 0 && fileSize <= UPLOAD_CONFIG.maxFileSize +} + + + +/** + * 从完整MIME类型获取简化的文件类型 + * @param {string} mimeType - 完整的MIME类型 + * @returns {string} 简化文件类型 + */ +const getSimpleFileType = (mimeType) => { + if (!mimeType) return 'file' + + const mainType = mimeType.split('/')[0].toLowerCase() + + // 检查是否为支持的主要类型 + if (Object.keys(SUPPORTED_TYPES).includes(mainType)) { + return mainType + } + + return 'file' +} + +/** + * 延迟函数 + * @param {number} ms - 延迟毫秒数 + */ +const delay = (ms) => new Promise(resolve => setTimeout(resolve, ms)) + +/** + * 请求STS Token(带重试机制) + * @param {string} filename - 文件名 + * @param {number} filesize - 文件大小(字节) + * @param {string} filetypeSimple - 简化文件类型 + * @param {string} authToken - 认证Token + * @param {number} retryCount - 重试次数 + * @returns {Promise} STS Token响应数据 + */ +const requestStsToken = async (filename, filesize, filetypeSimple, authToken, retryCount = 0) => { + try { + // 参数验证 + if (!filename || !authToken) { + logger.error('文件名和认证Token不能为空', 'UPLOAD') + throw new Error('文件名和认证Token不能为空') + } + + if (!validateFileSize(filesize)) { + logger.error(`文件大小超出限制,最大允许 ${UPLOAD_CONFIG.maxFileSize / 1024 / 1024}MB`, 'UPLOAD') + throw new Error(`文件大小超出限制,最大允许 ${UPLOAD_CONFIG.maxFileSize / 1024 / 1024}MB`) + } + + const requestId = generateUUID() + const bearerToken = authToken.startsWith('Bearer ') ? authToken : `Bearer ${authToken}` + const proxyAgent = getProxyAgent() + + const headers = { + 'Authorization': bearerToken, + 'Content-Type': 'application/json', + 'x-request-id': requestId, + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36' + } + + const payload = { + filename, + filesize, + filetype: filetypeSimple + } + + const requestConfig = { + headers, + timeout: UPLOAD_CONFIG.timeout + } + + // 添加代理配置 + if (proxyAgent) { + requestConfig.httpsAgent = proxyAgent + requestConfig.proxy = false + } + + logger.info(`请求STS Token: ${filename} (${filesize} bytes, ${filetypeSimple})`, 'UPLOAD', '🎫') + + const response = await axios.post(UPLOAD_CONFIG.stsTokenUrl, payload, requestConfig) + + if (response.status === 200 && response.data) { + const stsData = response.data + + // 验证响应数据完整性 + const credentials = { + access_key_id: stsData.access_key_id, + access_key_secret: stsData.access_key_secret, + security_token: stsData.security_token + } + + const fileInfo = { + url: stsData.file_url, + path: stsData.file_path, + bucket: stsData.bucketname, + endpoint: stsData.region + '.aliyuncs.com', + id: stsData.file_id + } + + // 检查必要字段 + const requiredCredentials = ['access_key_id', 'access_key_secret', 'security_token'] + const requiredFileInfo = ['url', 'path', 'bucket', 'endpoint', 'id'] + + const missingCredentials = requiredCredentials.filter(key => !credentials[key]) + const missingFileInfo = requiredFileInfo.filter(key => !fileInfo[key]) + + if (missingCredentials.length > 0 || missingFileInfo.length > 0) { + logger.error(`STS响应数据不完整: 缺少 ${[...missingCredentials, ...missingFileInfo].join(', ')}`, 'UPLOAD') + throw new Error(`STS响应数据不完整: 缺少 ${[...missingCredentials, ...missingFileInfo].join(', ')}`) + } + + logger.success('STS Token获取成功', 'UPLOAD') + return { credentials, file_info: fileInfo } + } else { + logger.error(`获取STS Token失败,状态码: ${response.status}`, 'UPLOAD') + throw new Error(`获取STS Token失败,状态码: ${response.status}`) + } + } catch (error) { + logger.error(`请求STS Token失败 (重试: ${retryCount})`, 'UPLOAD', '', error) + + // 403错误特殊处理 + if (error.response?.status === 403) { + logger.error('403 Forbidden错误,可能是Token权限问题', 'UPLOAD') + logger.error('认证失败,请检查Token权限', 'UPLOAD') + throw new Error('认证失败,请检查Token权限') + } + + // 重试逻辑 + if (retryCount < UPLOAD_CONFIG.maxRetries && + (error.code === 'ECONNABORTED' || error.code === 'ETIMEDOUT' || + error.response?.status >= 500)) { + + const delayMs = UPLOAD_CONFIG.retryDelay * Math.pow(2, retryCount) + logger.warn(`等待 ${delayMs}ms 后重试...`, 'UPLOAD', '⏳') + await delay(delayMs) + + return requestStsToken(filename, filesize, filetypeSimple, authToken, retryCount + 1) + } + + throw error + } +} + +/** + * 使用STS凭证将文件Buffer上传到阿里云OSS(带重试机制) + * @param {Buffer} fileBuffer - 文件内容的Buffer + * @param {Object} stsCredentials - STS凭证 + * @param {Object} ossInfo - OSS信息 + * @param {string} fileContentTypeFull - 文件的完整MIME类型 + * @param {number} retryCount - 重试次数 + * @returns {Promise} 上传结果 + */ +const uploadToOssWithSts = async (fileBuffer, stsCredentials, ossInfo, fileContentTypeFull, retryCount = 0) => { + try { + // 参数验证 + if (!fileBuffer || !stsCredentials || !ossInfo) { + logger.error('缺少必要的上传参数', 'UPLOAD') + throw new Error('缺少必要的上传参数') + } + + const client = new OSS({ + accessKeyId: stsCredentials.access_key_id, + accessKeySecret: stsCredentials.access_key_secret, + stsToken: stsCredentials.security_token, + bucket: ossInfo.bucket, + endpoint: ossInfo.endpoint, + secure: true, + timeout: UPLOAD_CONFIG.timeout + }) + + logger.info(`上传文件到OSS: ${ossInfo.path} (${fileBuffer.length} bytes)`, 'UPLOAD', '📤') + + const result = await client.put(ossInfo.path, fileBuffer, { + headers: { + 'Content-Type': fileContentTypeFull || 'application/octet-stream' + } + }) + + if (result.res && result.res.status === 200) { + logger.success('文件上传到OSS成功', 'UPLOAD') + return { success: true, result } + } else { + logger.error(`OSS上传失败,状态码: ${result.res?.status || 'unknown'}`, 'UPLOAD') + throw new Error(`OSS上传失败,状态码: ${result.res?.status || 'unknown'}`) + } + } catch (error) { + logger.error(`OSS上传失败 (重试: ${retryCount})`, 'UPLOAD', '', error) + + // 重试逻辑 + if (retryCount < UPLOAD_CONFIG.maxRetries) { + const delayMs = UPLOAD_CONFIG.retryDelay * Math.pow(2, retryCount) + logger.warn(`等待 ${delayMs}ms 后重试OSS上传...`, 'UPLOAD', '⏳') + await delay(delayMs) + + return uploadToOssWithSts(fileBuffer, stsCredentials, ossInfo, fileContentTypeFull, retryCount + 1) + } + + throw error + } +} + +/** + * 完整的文件上传流程:获取STS Token -> 上传到OSS。 + * @param {Buffer} fileBuffer - 图片文件的Buffer。 + * @param {string} originalFilename - 原始文件名 (例如 "image.png")。 + * @param {string} qwenAuthToken - 通义千问认证Token (纯token,不含Bearer)。 + * @returns {Promise<{file_url: string, file_id: string, message: string}>} 包含上传后的URL、文件ID和成功消息。 + * @throws {Error} 如果任何步骤失败。 + */ +const uploadFileToQwenOss = async (fileBuffer, originalFilename, authToken) => { + try { + // 参数验证 + if (!fileBuffer || !originalFilename || !authToken) { + logger.error('缺少必要的上传参数', 'UPLOAD') + throw new Error('缺少必要的上传参数') + } + + const filesize = fileBuffer.length + const mimeType = mimetypes.lookup(originalFilename) || 'application/octet-stream' + const filetypeSimple = getSimpleFileType(mimeType) + + // 文件大小验证 + if (!validateFileSize(filesize)) { + logger.error(`文件大小超出限制,最大允许 ${UPLOAD_CONFIG.maxFileSize / 1024 / 1024}MB`, 'UPLOAD') + throw new Error(`文件大小超出限制,最大允许 ${UPLOAD_CONFIG.maxFileSize / 1024 / 1024}MB`) + } + + logger.info(`开始上传文件: ${originalFilename} (${filesize} bytes, ${mimeType})`, 'UPLOAD', '📤') + + // 第一步:获取STS Token + const { credentials, file_info } = await requestStsToken( + originalFilename, + filesize, + filetypeSimple, + authToken + ) + + // 第二步:上传到OSS + await uploadToOssWithSts(fileBuffer, credentials, file_info, mimeType) + + logger.success('文件上传流程完成', 'UPLOAD') + + return { + status: 200, + file_url: file_info.url, + file_id: file_info.id, + message: '文件上传成功' + } + } catch (error) { + logger.error('文件上传流程失败', 'UPLOAD', '', error) + throw error + } +} + + + +module.exports = { + uploadFileToQwenOss +}