github-actions[bot] commited on
Commit
f120063
·
1 Parent(s): 8efba24

Sync from GitHub Viciy2023/Qwen2API-A@ae093476e9bc5b0a599620b5925df3a20057038e

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .env.example +90 -0
  2. .env.hf.example +117 -0
  3. .gitattributes +0 -35
  4. .gitignore +12 -0
  5. README.md +851 -7
  6. docker/Dockerfile +45 -0
  7. docker/docker-compose-redis.yml +51 -0
  8. docker/docker-compose.yml +41 -0
  9. docker/entrypoint.sh +19 -0
  10. ecosystem.config.js +53 -0
  11. package.json +40 -0
  12. public/.gitignore +23 -0
  13. public/index.html +13 -0
  14. public/package.json +23 -0
  15. public/postcss.config.js +6 -0
  16. public/src/App.vue +33 -0
  17. public/src/main.js +8 -0
  18. public/src/routes/index.js +74 -0
  19. public/src/style.css +3 -0
  20. public/src/views/auth.vue +66 -0
  21. public/src/views/dashboard.vue +1025 -0
  22. public/src/views/settings.vue +299 -0
  23. public/tailwind.config.js +11 -0
  24. public/vite.config.js +15 -0
  25. scripts/fingerprint-injector.js +20 -0
  26. scripts/hf-bucket-sync.py +151 -0
  27. src/config/index.js +52 -0
  28. src/controllers/chat.image.video.js +357 -0
  29. src/controllers/chat.js +449 -0
  30. src/controllers/cli.chat.js +213 -0
  31. src/controllers/models.js +75 -0
  32. src/middlewares/authorization.js +61 -0
  33. src/middlewares/chat-middleware.js +79 -0
  34. src/models/models-map.js +52 -0
  35. src/routes/accounts.js +259 -0
  36. src/routes/chat.js +34 -0
  37. src/routes/cli.chat.js +39 -0
  38. src/routes/models.js +31 -0
  39. src/routes/settings.js +161 -0
  40. src/routes/verify.js +24 -0
  41. src/server.js +81 -0
  42. src/start.js +113 -0
  43. src/utils/account-rotator.js +247 -0
  44. src/utils/account.js +670 -0
  45. src/utils/chat-helpers.js +376 -0
  46. src/utils/cli.manager.js +279 -0
  47. src/utils/cookie-generator.js +376 -0
  48. src/utils/data-persistence.js +240 -0
  49. src/utils/fingerprint.js +0 -0
  50. src/utils/img-caches.js +88 -0
.env.example ADDED
@@ -0,0 +1,90 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ # 如果需要修改Docker暴露端口,请修改ports中的参数
3
+ # 示例(8080:3000) 则访问 http://localhost:8080
4
+ SERVICE_PORT=3000
5
+
6
+
7
+ # 监听地址(非必填)
8
+ LISTEN_ADDRESS=
9
+
10
+ # PM2 多进程配置
11
+ # PM2进程数量配置
12
+ # max: 使用所有CPU核心
13
+ # 数字: 指定进程数量,如 4
14
+ # 1: 单进程模式
15
+ PM2_INSTANCES=1
16
+
17
+ # PM2内存限制,超过此限制将自动重启进程
18
+ # 支持格式: 100M, 1G, 2G 等
19
+ PM2_MAX_MEMORY=1G
20
+
21
+ # API 密钥配置
22
+ # 支持单个或多个API_KEY,用逗号分隔
23
+ # 第一个API_KEY为管理员密钥,拥有全部权限(可访问前端管理页面、修改设置)
24
+ # 其他API_KEY为普通密钥,仅有调用API的权限,不能访问前端管理页面
25
+ #
26
+ # 单个密钥示例:
27
+ # API_KEY=sk-admin123
28
+ #
29
+ # 多个密钥示例:
30
+ # API_KEY=sk-admin123,sk-user456,sk-user789
31
+ # 其中:
32
+ # - sk-admin123: 管理员密钥(可访问前端管理页面,可修改所有设置)
33
+ # - sk-user456,sk-user789: 普通密钥(仅可调用API,不能访问前端页面)
34
+ API_KEY=sk-123456
35
+
36
+ # 是否输出思考过程
37
+ OUTPUT_THINK=true
38
+
39
+ # 搜索信息显示模式
40
+ SEARCH_INFO_MODE=table
41
+
42
+ # 简化模型映射
43
+ # true: 只返回基础模型,不包含thinking、search、image等变体
44
+ # false: 返回完整模型列表,包含所有变体
45
+ SIMPLE_MODEL_MAP=false
46
+
47
+ # Redis链接(如果使用redis模式,则必填,当redis使用tls时将redis://替换为rediss://)
48
+ REDIS_URL=
49
+
50
+ # 数据保存模式
51
+ # none 不保存数据,仅使用环境变量中的设置
52
+ # file 保存在本地文件中
53
+ # redis 保存到远程/本地redis中
54
+ DATA_SAVE_MODE=none
55
+
56
+ # 账号与密码用:分隔,账号与账号间用,分隔(如果使用redis和file模式,则不需要填写)
57
+ ACCOUNTS=
58
+
59
+ # 日志配置
60
+ # 日志级别 (DEBUG, INFO, WARN, ERROR)
61
+ LOG_LEVEL=INFO
62
+
63
+ # 是否启用文件日志
64
+ ENABLE_FILE_LOG=false
65
+
66
+ # 日志文件目录
67
+ LOG_DIR=./logs
68
+
69
+ # 最大日志文件大小 (MB)
70
+ MAX_LOG_FILE_SIZE=10
71
+
72
+ # 保留的日志文件数量
73
+ MAX_LOG_FILES=5
74
+
75
+ # ========== 代理与反代配置 ==========
76
+
77
+ # 自定义反代URL配置
78
+ # QWEN_CHAT_PROXY_URL: 替代 https://chat.qwen.ai 的反代地址
79
+ # 示例: QWEN_CHAT_PROXY_URL=https://your-proxy.com
80
+ QWEN_CHAT_PROXY_URL=
81
+
82
+ # QWEN_CLI_PROXY_URL: 替代 https://portal.qwen.ai 的反代地址
83
+ # 示例: QWEN_CLI_PROXY_URL=https://your-cli-proxy.com
84
+ QWEN_CLI_PROXY_URL=
85
+
86
+ # HTTP/HTTPS 代理配置
87
+ # 支持 HTTP, HTTPS, SOCKS5 代理
88
+ # 示例: PROXY_URL=http://127.0.0.1:7890
89
+ # 示例: PROXY_URL=socks5://127.0.0.1:1080
90
+ PROXY_URL=
.env.hf.example ADDED
@@ -0,0 +1,117 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Hugging Face Spaces 推荐运行配置
2
+ # 说明:这个文件是示例模板,不要把真实密钥直接写进仓库
3
+ #
4
+ # 使用方式:
5
+ # 1. GitHub Actions 中配置自动同步所需变量
6
+ # - Secret: HF_TOKEN
7
+ # - Variable: HF_SPACE_ID=DanielleNguyen/Qwen2API-A
8
+ # 2. Hugging Face Space 中按下面两类分别配置:
9
+ # - HF Secrets:敏感信息,例如 API_KEY、HF_TOKEN
10
+ # - HF Variables:普通运行配置,例如端口、目录、开关
11
+ # 3. 不建议在仓库中提交真实 .env 文件
12
+
13
+ # ==================================================
14
+ # HF Secrets 建议新建这些
15
+ # ==================================================
16
+ # API_KEY=sk-admin-yourkey,sk-user-yourkey
17
+ # HF_TOKEN=hf_xxx
18
+ # HF_BUCKET_TOKEN=
19
+ # ACCOUNTS=
20
+ # REDIS_URL=
21
+ # PROXY_URL=
22
+ # QWEN_CHAT_PROXY_URL=
23
+ # QWEN_CLI_PROXY_URL=
24
+
25
+ # ==================================================
26
+ # HF Variables 建议新建这些
27
+ # ==================================================
28
+
29
+ # ==============================
30
+ # 服务监听配置
31
+ # Hugging Face Docker Space 建议固定如下:
32
+ # - SERVICE_PORT=7860
33
+ # - LISTEN_ADDRESS=0.0.0.0
34
+ # ==============================
35
+ SERVICE_PORT=7860
36
+ LISTEN_ADDRESS=0.0.0.0
37
+
38
+ # ==============================
39
+ # 运行模式配置
40
+ # Hugging Face 免费空间当前建议单进程运行,更稳定
41
+ # PM2_MAX_MEMORY 是自动重启阈值,不是实际只给 4G 内存
42
+ # ==============================
43
+ PM2_INSTANCES=1
44
+ PM2_MAX_MEMORY=4G
45
+ NODE_ENV=production
46
+
47
+ # ==============================
48
+ # API 访问密钥
49
+ # 第一个密钥是管理员密钥,后面的密钥是普通调用密钥
50
+ # 这是敏感信息,建议放到 HF Secrets
51
+ # ==============================
52
+ API_KEY=sk-admin-demo,sk-user-demo
53
+
54
+ # ==============================
55
+ # 功能配置
56
+ # 这些属于普通配置,建议放到 HF Variables
57
+ # ==============================
58
+ OUTPUT_THINK=true
59
+ SEARCH_INFO_MODE=table
60
+ SIMPLE_MODEL_MAP=false
61
+
62
+ # ==============================
63
+ # 数据存储配置
64
+ # 当前推荐方案:使用 Hugging Face Bucket 做文件持久化
65
+ # - 项目内部使用 DATA_SAVE_MODE=file
66
+ # - 启动时从 Bucket 拉取数据到本地目录
67
+ # - 运行中定时同步本地目录回 Bucket
68
+ # ==============================
69
+ DATA_SAVE_MODE=file
70
+
71
+ # 下面两项通常属于敏感信息,建议放到 HF Secrets
72
+ REDIS_URL=
73
+ ACCOUNTS=
74
+
75
+ # 下面这些路径配置建议放到 HF Variables
76
+ DATA_DIR=/data/qwen2api/data
77
+ CACHE_DIR=/data/qwen2api/caches
78
+ LOG_DIR=/data/qwen2api/logs
79
+
80
+ # ==============================
81
+ # Hugging Face Bucket 持久化配置
82
+ # HF_BUCKET_REPO:你的 Bucket 名称,建议放到 HF Variables
83
+ # HF_TOKEN:Hugging Face 账号 Token,Space 通过它访问 Bucket,建议放到 HF Secrets
84
+ # HF_BUCKET_TOKEN:可选;如果单独给 Bucket 配 token,则优先使用它,也建议放到 HF Secrets
85
+ # ==============================
86
+ HF_BUCKET_REPO=DanielleNguyen/Qwen2API-A-Storage
87
+ HF_TOKEN=
88
+ HF_BUCKET_TOKEN=
89
+ HF_BUCKET_LOCAL_DIR=/data/qwen2api
90
+ HF_BUCKET_REMOTE_DIR=runtime
91
+ HF_BUCKET_SYNC_INTERVAL=300
92
+ HF_BUCKET_SYNC_DEBOUNCE_SECONDS=5
93
+ HF_BUCKET_STARTUP_GRACE_SECONDS=30
94
+
95
+ # ==============================
96
+ # 日志配置
97
+ # Hugging Face 一般不建议开启文件日志,直接看容器日志即可
98
+ # ==============================
99
+ LOG_LEVEL=INFO
100
+ ENABLE_FILE_LOG=false
101
+ MAX_LOG_FILE_SIZE=10
102
+ MAX_LOG_FILES=5
103
+
104
+ # ==============================
105
+ # 代理 / 上游反代配置
106
+ # 按需填写;不需要就留空
107
+ # 如果地址中带认证信息,建议放到 HF Secrets
108
+ # ==============================
109
+ QWEN_CHAT_PROXY_URL=
110
+ QWEN_CLI_PROXY_URL=
111
+ PROXY_URL=
112
+
113
+ # ==============================
114
+ # 图片缓存配置
115
+ # 配合 Bucket 持久化时建议使用 file
116
+ # ==============================
117
+ CACHE_MODE=file
.gitattributes DELETED
@@ -1,35 +0,0 @@
1
- *.7z filter=lfs diff=lfs merge=lfs -text
2
- *.arrow filter=lfs diff=lfs merge=lfs -text
3
- *.bin filter=lfs diff=lfs merge=lfs -text
4
- *.bz2 filter=lfs diff=lfs merge=lfs -text
5
- *.ckpt filter=lfs diff=lfs merge=lfs -text
6
- *.ftz filter=lfs diff=lfs merge=lfs -text
7
- *.gz filter=lfs diff=lfs merge=lfs -text
8
- *.h5 filter=lfs diff=lfs merge=lfs -text
9
- *.joblib filter=lfs diff=lfs merge=lfs -text
10
- *.lfs.* filter=lfs diff=lfs merge=lfs -text
11
- *.mlmodel filter=lfs diff=lfs merge=lfs -text
12
- *.model filter=lfs diff=lfs merge=lfs -text
13
- *.msgpack filter=lfs diff=lfs merge=lfs -text
14
- *.npy filter=lfs diff=lfs merge=lfs -text
15
- *.npz filter=lfs diff=lfs merge=lfs -text
16
- *.onnx filter=lfs diff=lfs merge=lfs -text
17
- *.ot filter=lfs diff=lfs merge=lfs -text
18
- *.parquet filter=lfs diff=lfs merge=lfs -text
19
- *.pb filter=lfs diff=lfs merge=lfs -text
20
- *.pickle filter=lfs diff=lfs merge=lfs -text
21
- *.pkl filter=lfs diff=lfs merge=lfs -text
22
- *.pt filter=lfs diff=lfs merge=lfs -text
23
- *.pth filter=lfs diff=lfs merge=lfs -text
24
- *.rar filter=lfs diff=lfs merge=lfs -text
25
- *.safetensors filter=lfs diff=lfs merge=lfs -text
26
- saved_model/**/* filter=lfs diff=lfs merge=lfs -text
27
- *.tar.* filter=lfs diff=lfs merge=lfs -text
28
- *.tar filter=lfs diff=lfs merge=lfs -text
29
- *.tflite filter=lfs diff=lfs merge=lfs -text
30
- *.tgz filter=lfs diff=lfs merge=lfs -text
31
- *.wasm filter=lfs diff=lfs merge=lfs -text
32
- *.xz filter=lfs diff=lfs merge=lfs -text
33
- *.zip filter=lfs diff=lfs merge=lfs -text
34
- *.zst filter=lfs diff=lfs merge=lfs -text
35
- *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
.gitignore ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ node_modules
2
+ package-lock.json
3
+ .env
4
+ data/data.json
5
+ data
6
+ caches
7
+ logs/
8
+ *.log
9
+ pkg_dist/*
10
+ pkg_dist/
11
+ .idea
12
+ /public/dist
README.md CHANGED
@@ -1,10 +1,854 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
- title: Qwen2API A
3
- emoji: 😻
4
- colorFrom: pink
5
- colorTo: red
6
- sdk: docker
7
- pinned: false
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <div align="center">
2
+
3
+ # 🚀 Qwen-Proxy
4
+
5
+ [![Version](https://img.shields.io/badge/version-2026.03.04.10.58-blue.svg)](https://github.com/Rfym21/Qwen2API)
6
+ [![Node.js](https://img.shields.io/badge/Node.js-18+-green.svg)](https://nodejs.org/)
7
+ [![Docker](https://img.shields.io/badge/Docker-supported-blue.svg)](https://hub.docker.com/r/rfym21/qwen2api)
8
+
9
+ [🔗 加入交流群](https://t.me/nodejs_project) | [📖 文档](#api-文档) | [🐳 Docker 部署](#docker-部署)
10
+
11
+ </div>
12
+
13
+ ## 🛠️ 快速开始
14
+
15
+ ### 项目说明
16
+
17
+ 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 参数支持
18
+
19
+ **主要特性:**
20
+ - 兼容 OpenAI API 格式,无缝对接各类客户端
21
+ - 支持多账户轮询,提高可用性
22
+ - 支持流式/非流式响应
23
+ - 支持多模态(图片识别、图片生成)
24
+ - 支持智能搜索、深度思考等高级功能
25
+ - 支持 CLI 端点,提供 256K 上下文和工具调用能力
26
+ - 提供 Web 管理界面,方便配置和监控
27
+
28
+ ### ⚠️ 高并发说明
29
+
30
+ > **重要提示**: `chat.qwen.ai` 对单 IP 有限速策略,目前已知该限制与 Cookie 无关,仅与 IP 相关。
31
+
32
+ **解决方案:**
33
+
34
+ 如需高并发使用,建议配合代理池实现 IP 轮换:
35
+
36
+ | 方案 | 配置方式 | 说明 |
37
+ |------|----------|------|
38
+ | **方案一** | `PROXY_URL` + [ProxyFlow](https://github.com/Rfym21/ProxyFlow) | 直接配置代理地址,所有请求通过代理池轮换 IP |
39
+ | **方案二** | `QWEN_CHAT_PROXY_URL` + [UrlProxy](https://github.com/Rfym21/UrlProxy) + [ProxyFlow](https://github.com/Rfym21/ProxyFlow) | 通过反代 + 代理池组合,实现更灵活的 IP 轮换 |
40
+
41
+ **配置示例:**
42
+
43
+ ```bash
44
+ # 方案一:直接使用代理池
45
+ PROXY_URL=http://127.0.0.1:8282 # ProxyFlow 代理地址
46
+
47
+ # 方案二:反代 + 代理池组合
48
+ QWEN_CHAT_PROXY_URL=http://127.0.0.1:8000/qwen # UrlProxy 反代地址(UrlProxy 配置 HTTP_PROXY 指向 ProxyFlow)
49
+ ```
50
+
51
+ ### 环境要求
52
+
53
+ - Node.js 18+ (源码部署时需要)
54
+ - Docker (可选)
55
+ - Redis (可选,用于数据持久化)
56
+
57
+ ### ⚙️ 环境配置
58
+
59
+ 创建 `.env` 文件并配置以下参数:
60
+
61
+ ```bash
62
+ # 🌐 服务配置
63
+ LISTEN_ADDRESS=localhost # 监听地址
64
+ SERVICE_PORT=3000 # 服务端口
65
+
66
+ # 🔐 安全配置
67
+ API_KEY=sk-123456,sk-456789 # API 密钥 (必填,支持多密钥)
68
+ ACCOUNTS= # 账户配置 (格式: user1:pass1,user2:pass2)
69
+
70
+ # 🚀 PM2 多进程配置
71
+ PM2_INSTANCES=1 # PM2进程数量 (1/数字/max)
72
+ PM2_MAX_MEMORY=1G # PM2内存限制 (100M/1G/2G等)
73
+ # 注意: PM2集群模式下所有进程共用同一个端口
74
+
75
+ # 🔍 功能配置
76
+ SEARCH_INFO_MODE=table # 搜索信息展示模式 (table/text)
77
+ OUTPUT_THINK=true # 是否输出思考过程 (true/false)
78
+ SIMPLE_MODEL_MAP=false # 简化模型映射 (true/false)
79
+
80
+ # 🌐 代理与反代配置
81
+ QWEN_CHAT_PROXY_URL= # 自定义 Chat API 反代URL (默认: https://chat.qwen.ai)
82
+ QWEN_CLI_PROXY_URL= # 自定义 CLI API 反代URL (默认: https://portal.qwen.ai)
83
+ PROXY_URL= # HTTP/HTTPS/SOCKS5 代理地址 (例如: http://127.0.0.1:7890)
84
+
85
+ # 🗄️ 数据存储
86
+ DATA_SAVE_MODE=none # 数据保存模式 (none/file/redis)
87
+ REDIS_URL= # Redis 连接地址 (可选,使用TLS时为rediss://)
88
+
89
+ # 📸 缓存配置
90
+ CACHE_MODE=default # 图片缓存模式 (default/file)
91
+ ```
92
+
93
+ #### 📋 配置说明
94
+
95
+ | 参数 | 说明 | 示例 |
96
+ |------|------|------|
97
+ | `LISTEN_ADDRESS` | 服务监听地址 | `localhost` 或 `0.0.0.0` |
98
+ | `SERVICE_PORT` | 服务运行端口 | `3000` |
99
+ | `API_KEY` | API 访问密钥,支持多密钥配置。第一个为管理员密钥(可访问前端管理页面),其他为普通密钥(仅可调用API)。多个密钥用逗号分隔 | `sk-admin123,sk-user456,sk-user789` |
100
+ | `PM2_INSTANCES` | PM2进程数量 | `1`/`4`/`max` |
101
+ | `PM2_MAX_MEMORY` | PM2内存限制 | `100M`/`1G`/`2G` |
102
+ | `SEARCH_INFO_MODE` | 搜索结果展示格式 | `table` 或 `text` |
103
+ | `OUTPUT_THINK` | 是否显示 AI 思考过程 | `true` 或 `false` |
104
+ | `SIMPLE_MODEL_MAP` | 简化模型映射,只返回基础模型不包含变体 | `true` 或 `false` |
105
+ | `QWEN_CHAT_PROXY_URL` | 自定义 Chat API 反代地址 | `https://your-proxy.com` |
106
+ | `QWEN_CLI_PROXY_URL` | 自定义 CLI API 反代地址 | `https://your-cli-proxy.com` |
107
+ | `PROXY_URL` | 出站请求代理地址,支持 HTTP/HTTPS/SOCKS5 | `http://127.0.0.1:7890` |
108
+ | `DATA_SAVE_MODE` | 数据持久化方式 | `none`/`file`/`redis` |
109
+ | `REDIS_URL` | Redis 数据库连接地址,使用TLS加密时需使用 `rediss://` 协议 | `redis://localhost:6379` 或 `rediss://xxx.upstash.io` |
110
+ | `CACHE_MODE` | 图片缓存存储方式 | `default`/`file` |
111
+ | `LOG_LEVEL` | 日志级别 | `DEBUG`/`INFO`/`WARN`/`ERROR` |
112
+ | `ENABLE_FILE_LOG` | 是否启用文件日志 | `true` 或 `false` |
113
+ | `LOG_DIR` | 日志文件目录 | `./logs` |
114
+ | `MAX_LOG_FILE_SIZE` | 最大日志文件大小(MB) | `10` |
115
+ | `MAX_LOG_FILES` | 保留的日志文件数量 | `5` |
116
+
117
+ > 💡 **提示**: 可以在 [Upstash](https://upstash.com/) 免费创建 Redis 实例,使用 TLS 协议时地址格式为 `rediss://...`
118
+ <div>
119
+ <img src="./docs/images/upstash.png" alt="Upstash Redis" width="600">
120
+ </div>
121
+
122
+ #### 🔑 多API_KEY配置说明
123
+
124
+ `API_KEY` 环境变量支持配置多个API密钥,用于实现不同权限级别的访问控制:
125
+
126
+ **配置格式:**
127
+ ```bash
128
+ # 单个密钥(管理员权限)
129
+ API_KEY=sk-admin123
130
+
131
+ # 多个密钥(第一个为管理员,其他为普通用户)
132
+ API_KEY=sk-admin123,sk-user456,sk-user789
133
+ ```
134
+
135
+ **权限说明:**
136
+
137
+ | 密钥类型 | 权限范围 | 功能描述 |
138
+ |----------|----------|----------|
139
+ | **管理员密钥** | 完整权限 | • 访问前端管理页面<br>• 修改系统设置<br>• 调用所有API接口<br>• 添加/删除普通密钥 |
140
+ | **普通密钥** | API调用权限 | • 仅可调用API接口<br>• 无法访问前端管理页面<br>• 无法修改系统设置 |
141
+
142
+ **使用场景:**
143
+ - **团队协作**: 为不同团队成员分配不同权限的API密钥
144
+ - **应用集成**: 为第三方应用提供受限的API访问权限
145
+ - **安全隔离**: 将管理权限与普通使用权限分离
146
+
147
+ **注意事项:**
148
+ - 第一个API_KEY自动成为管理员密钥,拥有最高权限
149
+ - 管理员可以通过前端页面动态添加或删除普通密钥
150
+ - 所有密钥都可以正常调用API接口,权限差异仅体现在管理功能上
151
+
152
+ #### 📸 CACHE_MODE 缓存模式说明
153
+
154
+ `CACHE_MODE` 环境变量控制图片缓存的存储方式,用于优化图片上传和处理性能:
155
+
156
+ | 模式 | 说明 | 适用场景 |
157
+ |------|------|----------|
158
+ | `default` | 内存缓存模式 (默认) | 单进程部署,重启后缓存丢失 |
159
+ | `file` | 文件缓存模式 | 多进程部署,缓存持久化到 `./caches/` 目录 |
160
+
161
+ **推荐配置:**
162
+ - **单进程部署**: 使用 `CACHE_MODE=default`,性能最佳
163
+ - **多进程/集群部署**: 使用 `CACHE_MODE=file`,确保进程间缓存共享
164
+ - **Docker 部署**: 建议使用 `CACHE_MODE=file` 并挂载 `./caches` 目录
165
+
166
+ **文件缓存目录结构:**
167
+ ```
168
+ caches/
169
+ ├── [signature1].txt # 缓存文件,包含图片URL
170
+ ├── [signature2].txt
171
+ └── ...
172
+ ```
173
+
174
  ---
175
+
176
+ ## 🚀 部署方式
177
+
178
+ ### 🐳 Docker 部署
179
+
180
+ #### 方式一:直接运行
181
+
182
+ ```bash
183
+ docker run -d \
184
+ -p 3000:3000 \
185
+ -e API_KEY=sk-admin123,sk-user456,sk-user789 \
186
+ -e DATA_SAVE_MODE=none \
187
+ -e CACHE_MODE=file \
188
+ -e ACCOUNTS= \
189
+ -v ./caches:/app/caches \
190
+ --name qwen2api \
191
+ rfym21/qwen2api:latest
192
+ ```
193
+
194
+ #### 方式二:Docker Compose
195
+
196
+ ```bash
197
+ # 下载配置文件
198
+ curl -o docker-compose.yml https://raw.githubusercontent.com/Rfym21/Qwen2API/refs/heads/main/docker/docker-compose.yml
199
+
200
+ # 启动服务
201
+ docker compose pull && docker compose up -d
202
+ ```
203
+
204
+ ### 📦 本地部署
205
+
206
+ ```bash
207
+ # 克隆项目
208
+ git clone https://github.com/Rfym21/Qwen2API.git
209
+ cd Qwen2API
210
+
211
+ # 安装依赖
212
+ npm install
213
+
214
+ # 配置环境变量
215
+ cp .env.example .env
216
+ # 编辑 .env 文件
217
+
218
+ # 智能启动 (推荐 - 自动判断单进程/多进程)
219
+ npm start
220
+
221
+ # 开发模式
222
+ npm run dev
223
+ ```
224
+
225
+ ### 🚀 PM2 多进程部署
226
+
227
+ 使用 PM2 进行生产环境多进程部署,提供更好的性能和稳定性。
228
+
229
+ **重要说明**: PM2 集群模式下,所有进程共用同一个端口,PM2 会自动进行负载均衡。
230
+
231
+ ### 🤖 智能启动模式
232
+
233
+ 使用 `npm start` 可以自动判断启动方式:
234
+
235
+ - 当 `PM2_INSTANCES=1` 时,使用单进程模式
236
+ - 当 `PM2_INSTANCES>1` 时,使用 Node.js 集群模式
237
+ - 自动限制进程数不超过 CPU 核心数
238
+
239
+ ### ☁️ Hugging Face 部署
240
+
241
+ 推荐使用 **Docker Space + GitHub 自动同步** 的方式部署。这个项目本身已经提供 `docker/Dockerfile`,因此在 Hugging Face 上使用 Docker Space 是最稳妥的方案。
242
+
243
+ [![Deploy to Hugging Face](https://img.shields.io/badge/🤗%20Hugging%20Face-Deploy-yellow)](https://huggingface.co/spaces/devme/q2waepnilm)
244
+
245
+ <div>
246
+ <img src="./docs/images/hf.png" alt="Hugging Face Deployment" width="600">
247
+ </div>
248
+
249
+ #### 推荐部署方式
250
+
251
+ 部署链路如下:
252
+
253
+ ```text
254
+ 本地修改代码 -> Push 到 GitHub main -> GitHub Actions 自动同步到 Hugging Face Space -> Hugging Face 自动重建并启动
255
+ ```
256
+
257
+ #### 第一步:创建 Hugging Face Space
258
+
259
+ - 在 Hugging Face 新建 Space
260
+ - **SDK 请选择 `Docker`**
261
+ - Space 名称示例:`DanielleNguyen/Qwen2API-A`
262
+
263
+ #### 第二步:在 GitHub 配置自动同步变量
264
+
265
+ 仓��中已提供自动同步工作流:`.github/workflows/huggingface-sync.yml`
266
+
267
+ 请在 GitHub 仓库中配置以下内容:
268
+
269
+ **GitHub Actions Secrets:**
270
+
271
+ ```bash
272
+ HF_TOKEN=你的_huggingface_token
273
+ ```
274
+
275
+ **GitHub Actions Variables:**
276
+
277
+ ```bash
278
+ HF_SPACE_ID=DanielleNguyen/Qwen2API-A
279
+ ```
280
+
281
+ 配置位置:`GitHub 仓库 -> Settings -> Secrets and variables -> Actions`
282
+
283
+ > ⚠️ 注意:`HF_TOKEN` 必须放在 `Secrets` 中,不要写入仓库文件,也不要提交到 `.env` 中。
284
+
285
+ #### 第三步:在 Hugging Face Space 配置运行时变量
286
+
287
+ 进入:`Hugging Face Space -> Settings -> Variables and secrets`
288
+
289
+ 如果你采用 **HF Bucket 持久化**,建议至少配置以下变量:
290
+
291
+ ```bash
292
+ SERVICE_PORT=7860
293
+ LISTEN_ADDRESS=0.0.0.0
294
+ PM2_INSTANCES=1
295
+ PM2_MAX_MEMORY=4G
296
+ NODE_ENV=production
297
+ OUTPUT_THINK=true
298
+ SEARCH_INFO_MODE=table
299
+ SIMPLE_MODEL_MAP=false
300
+ DATA_SAVE_MODE=file
301
+ DATA_DIR=/data/qwen2api/data
302
+ CACHE_DIR=/data/qwen2api/caches
303
+ LOG_DIR=/data/qwen2api/logs
304
+ HF_BUCKET_REPO=DanielleNguyen/Qwen2API-A-Storage
305
+ HF_BUCKET_LOCAL_DIR=/data/qwen2api
306
+ HF_BUCKET_REMOTE_DIR=runtime
307
+ HF_BUCKET_SYNC_INTERVAL=300
308
+ LOG_LEVEL=INFO
309
+ ENABLE_FILE_LOG=false
310
+ CACHE_MODE=file
311
+ ```
312
+
313
+ 建议作为 Secret 配置的敏感项:
314
+
315
+ ```bash
316
+ API_KEY=sk-admin-yourkey,sk-user-yourkey
317
+ HF_TOKEN=hf_xxx
318
+ HF_BUCKET_TOKEN=
319
+ REDIS_URL=
320
+ ACCOUNTS=
321
+ PROXY_URL=
322
+ QWEN_CHAT_PROXY_URL=
323
+ QWEN_CLI_PROXY_URL=
324
+ ```
325
+
326
+ > 💡 项目中已提供 Hugging Face 示例模板:`.env.hf.example`
327
+
328
+ #### 第四步:HF Bucket 持久化工作方式
329
+
330
+ 当前项目已适配如下持久化链路:
331
+
332
+ ```text
333
+ 容器启动 -> 从 HF Bucket 拉取 /data/qwen2api -> 应用用 file 模式读写本地文件 -> 后台定时同步回 HF Bucket
334
+ ```
335
+
336
+ 默认会持久化这些目录:
337
+
338
+ ```bash
339
+ DATA_DIR=/data/qwen2api/data
340
+ CACHE_DIR=/data/qwen2api/caches
341
+ LOG_DIR=/data/qwen2api/logs
342
+ ```
343
+
344
+ 推荐的 Bucket 名称:
345
+
346
+ ```bash
347
+ HF_BUCKET_REPO=DanielleNguyen/Qwen2API-A-Storage
348
+ ```
349
+
350
+ #### 第五步:端口与监听地址说明
351
+
352
+ 为兼容 Hugging Face Docker Space,推荐固定使用:
353
+
354
+ ```bash
355
+ SERVICE_PORT=7860
356
+ LISTEN_ADDRESS=0.0.0.0
357
+ ```
358
+
359
+ 原因:
360
+
361
+ - `7860` 是 Hugging Face Space 常见服务端口
362
+ - `0.0.0.0` 可确保容器外部可以访问到服务
363
+ - 项目实际启动端口由环境变量 `SERVICE_PORT` 控制
364
+
365
+ #### 第六步:推送代码触发自动部署
366
+
367
+ 当你推送代码到 `main` 分支后:
368
+
369
+ - GitHub Actions 会自动执行 `.github/workflows/huggingface-sync.yml`
370
+ - 自动将仓库代码同步到 Hugging Face Space
371
+ - Hugging Face 收到新代码后会自动重建容器并启动服务
372
+
373
+ #### 推荐的 Hugging Face 配置
374
+
375
+ 如果你只是先跑通服务并启用 Bucket 持久化,建议使用:
376
+
377
+ ```bash
378
+ SERVICE_PORT=7860
379
+ LISTEN_ADDRESS=0.0.0.0
380
+ PM2_INSTANCES=1
381
+ DATA_SAVE_MODE=file
382
+ CACHE_MODE=file
383
+ DATA_DIR=/data/qwen2api/data
384
+ CACHE_DIR=/data/qwen2api/caches
385
+ LOG_DIR=/data/qwen2api/logs
386
+ HF_BUCKET_REPO=DanielleNguyen/Qwen2API-A-Storage
387
+ ENABLE_FILE_LOG=false
388
+ ```
389
+
390
+ 如果你要让运行数据真正可恢复,请再补上 Secret:
391
+
392
+ ```bash
393
+ HF_TOKEN=你的_huggingface_token
394
+ ```
395
+
396
+ > Space 访问 Bucket 时可以直接使用 Hugging Face 账号 Token。若你希望和 GitHub Actions 用途隔离,也可以额外配置 `HF_BUCKET_TOKEN` 专供 Bucket 读写使用。
397
+
398
+ #### 常见问题
399
+
400
+ **1. 为什么不建议在仓库里提交真实 `.env`?**
401
+
402
+ 因为 `API_KEY`、`HF_TOKEN`、`HF_BUCKET_TOKEN`、`REDIS_URL` 等都属于敏感信息,应该放在 GitHub Secrets 或 Hugging Face Secrets 中。
403
+
404
+ **2. 为什么推荐 Docker Space?**
405
+
406
+ 因为本项目是完整的 Node.js 服务,并且已经自带 `docker/Dockerfile`,使用 Docker Space 可以直接复用现有构建和启动流程。
407
+
408
+ **3. 如果 GitHub 已经配置了 `HF_TOKEN` 和 `HF_SPACE_ID`,还需要在 HF 里配置它们吗?**
409
+
410
+ 不需要。
411
+
412
+ - `HF_TOKEN` 和 `HF_SPACE_ID` 只用于 **GitHub -> Hugging Face 同步代码**
413
+ - Hugging Face Space 内只需要配置项目运行时环境变量,例如 `API_KEY`、`SERVICE_PORT`、`LISTEN_ADDRESS`
414
+
415
+ **4. Space 怎么和 Bucket 通信?一定要单独的 `HF_BUCKET_TOKEN` 吗?**
416
+
417
+ 不一定。
418
+
419
+ - 在 Hugging Face Space 里,运行时可以直接使用你的 Hugging Face 账号 Token,即 `HF_TOKEN`
420
+ - 当前项目已兼容:优先读取 `HF_BUCKET_TOKEN`,如果没设置则自动回退到 `HF_TOKEN`
421
+ - 如果你想把“GitHub 同步代码”和“Space 访问 Bucket”分开控制,可以单独再配 `HF_BUCKET_TOKEN`
422
+
423
  ---
424
 
425
+ ## 📁 项目结构
426
+
427
+ ```
428
+ Qwen2API/
429
+ ├── README.md
430
+ ├── ecosystem.config.js # PM2配置文件
431
+ ├── package.json
432
+
433
+ ├── docker/ # Docker配置目录
434
+ │ ├── Dockerfile
435
+ │ ├── docker-compose.yml
436
+ │ └── docker-compose-redis.yml
437
+
438
+ ├── caches/ # 缓存文件目录
439
+ ├── data/ # 数据文件目录
440
+ │ ├── data.json
441
+ │ └── data_template.json
442
+ ├── scripts/ # 脚本目录
443
+ │ └── fingerprint-injector.js # 浏览器指纹注入脚本
444
+
445
+ ├── src/ # 后端源代码目录
446
+ │ ├── server.js # 主服务器文件
447
+ │ ├── start.js # 智能启动脚本 (自动判断单进程/多进程)
448
+ │ ├── config/
449
+ │ │ └── index.js # 配置文件
450
+ │ ├── controllers/ # 控制器目录
451
+ │ │ ├── chat.js # 聊天控制器
452
+ │ │ ├── chat.image.video.js # 图片/视频生成控制器
453
+ │ │ ├── cli.chat.js # CLI聊天控制器
454
+ │ │ └── models.js # 模型控制器
455
+ │ ├── middlewares/ # 中间件目录
456
+ │ │ ├── authorization.js # 授权中间件
457
+ │ │ └── chat-middleware.js # 聊天中间件
458
+ │ ├── models/ # 模型目录
459
+ │ │ └── models-map.js # 模型映射配置
460
+ │ ├── routes/ # 路由目录
461
+ │ │ ├── accounts.js # 账户路由
462
+ │ │ ├── chat.js # 聊天路由
463
+ │ │ ├── cli.chat.js # CLI聊天路由
464
+ │ │ ├── models.js # 模型路由
465
+ │ │ ├── settings.js # 设置路由
466
+ │ │ └── verify.js # 验证路由
467
+ │ └── utils/ # 工具函数目录
468
+ │ ├── account-rotator.js # 账户轮询器
469
+ │ ├── account.js # 账户管理
470
+ │ ├── chat-helpers.js # 聊天辅助函数
471
+ │ ├── cli.manager.js # CLI管理器
472
+ │ ├── cookie-generator.js # Cookie生成器
473
+ │ ├── data-persistence.js # 数据持久化
474
+ │ ├── fingerprint.js # 浏览器指纹生成
475
+ │ ├── img-caches.js # 图片缓存
476
+ │ ├── logger.js # 日志工具
477
+ │ ├── precise-tokenizer.js # 精确分词器
478
+ │ ├── proxy-helper.js # 代理辅助函数
479
+ │ ├── redis.js # Redis连接
480
+ │ ├── request.js # HTTP请求封装
481
+ │ ├── setting.js # 设置管理
482
+ │ ├── ssxmod-manager.js # ssxmod参数管理
483
+ │ ├── token-manager.js # Token管理器
484
+ │ ├── tools.js # 工具调用处理
485
+ │ └── upload.js # 文件上传
486
+
487
+ └── public/ # 前端项目目录
488
+ ├── dist/ # 编译后的前端文件
489
+ │ ├── assets/ # 静态资源
490
+ │ ├── favicon.png
491
+ │ └── index.html
492
+ ├── src/ # 前端源代码
493
+ │ ├── App.vue # 主应用组件
494
+ │ ├── main.js # 入口文件
495
+ │ ├── style.css # 全局样式
496
+ │ ├── assets/ # 静态资源
497
+ │ │ └── background.mp4
498
+ │ ├── routes/ # 路由配置
499
+ │ │ └── index.js
500
+ │ └── views/ # 页面组件
501
+ │ ├── auth.vue # 认证页面
502
+ │ ├── dashboard.vue # 仪表板页面
503
+ │ └── settings.vue # 设置页面
504
+ ├── package.json # 前端依赖配置
505
+ ├── package-lock.json
506
+ ├── index.html # 前端入口HTML
507
+ ├── postcss.config.js # PostCSS配置
508
+ ├── tailwind.config.js # TailwindCSS配置
509
+ ├── vite.config.js # Vite构建配置
510
+ └── public/ # 公共静态资源
511
+ └── favicon.png
512
+ ```
513
+
514
+ ## 📖 API 文档
515
+
516
+ ### 🔐 API 认证说明
517
+
518
+ 本API支持多密钥认证机制,所有API请求都需要在请求头中包含有效的API密钥:
519
+
520
+ ```http
521
+ Authorization: Bearer sk-your-api-key
522
+ ```
523
+
524
+ **支持的密钥类型:**
525
+ - **管理员密钥**: 第一个配置的API_KEY,拥有完整权限
526
+ - **普通密钥**: 其他配置的API_KEY,仅可调用API接口
527
+
528
+ **认证示例:**
529
+ ```bash
530
+ # 使用管理员密钥
531
+ curl -H "Authorization: Bearer sk-admin123" http://localhost:3000/v1/models
532
+
533
+ # 使用普通密钥
534
+ curl -H "Authorization: Bearer sk-user456" http://localhost:3000/v1/chat/completions
535
+ ```
536
+
537
+ ### 🔍 获取模型列表
538
+
539
+ 获取所有可用的 AI 模型列表。
540
+
541
+ ```http
542
+ GET /v1/models
543
+ Authorization: Bearer sk-your-api-key
544
+ ```
545
+
546
+ ```http
547
+ GET /models (免认证)
548
+ ```
549
+
550
+ **响应示例:**
551
+ ```json
552
+ {
553
+ "object": "list",
554
+ "data": [
555
+ {
556
+ "id": "qwen-max-latest",
557
+ "object": "model",
558
+ "created": 1677610602,
559
+ "owned_by": "qwen"
560
+ }
561
+ ]
562
+ }
563
+ ```
564
+
565
+ ### 💬 聊天对话
566
+
567
+ 发送聊天消息并获取 AI 回复。
568
+
569
+ ```http
570
+ POST /v1/chat/completions
571
+ Content-Type: application/json
572
+ Authorization: Bearer sk-your-api-key
573
+ ```
574
+
575
+ **请求体:**
576
+ ```json
577
+ {
578
+ "model": "qwen-max-latest",
579
+ "messages": [
580
+ {
581
+ "role": "system",
582
+ "content": "你是一个有用的助手。"
583
+ },
584
+ {
585
+ "role": "user",
586
+ "content": "你好,请介绍一下自己。"
587
+ }
588
+ ],
589
+ "stream": false,
590
+ "temperature": 0.7,
591
+ "max_tokens": 2000
592
+ }
593
+ ```
594
+
595
+ **响应示例:**
596
+ ```json
597
+ {
598
+ "id": "chatcmpl-123",
599
+ "object": "chat.completion",
600
+ "created": 1677652288,
601
+ "model": "qwen-max-latest",
602
+ "choices": [
603
+ {
604
+ "index": 0,
605
+ "message": {
606
+ "role": "assistant",
607
+ "content": "你好!我是一个AI助手..."
608
+ },
609
+ "finish_reason": "stop"
610
+ }
611
+ ],
612
+ "usage": {
613
+ "prompt_tokens": 20,
614
+ "completion_tokens": 50,
615
+ "total_tokens": 70
616
+ }
617
+ }
618
+ ```
619
+
620
+ ### 🎨 图像生成/编辑
621
+
622
+ 使用 `-image` 模型启用文本到图像生成功能。
623
+ 使用 `-image-edit` 模型启用图像修改功能。
624
+ 当使用 `-image` 模型时你可以通过在请求体中添加 `size` 参数或在消息内容中包含特定关键词 `1:1`, `4:3`, `3:4`, `16:9`, `9:16` 来控制图片尺寸。
625
+
626
+ ```http
627
+ POST /v1/chat/completions
628
+ Content-Type: application/json
629
+ Authorization: Bearer sk-your-api-key
630
+ ```
631
+
632
+ **请求体:**
633
+ ```json
634
+ {
635
+ "model": "qwen-max-latest-image",
636
+ "messages": [
637
+ {
638
+ "role": "user",
639
+ "content": "画一只在花园里玩耍的小猫咪,卡通风格"
640
+ }
641
+ ],
642
+ "size": "1:1",
643
+ "stream": false
644
+ }
645
+ ```
646
+
647
+ **支持的参数:**
648
+ - `size`: 图片尺寸,支持 `"1:1"`、`"4:3"`、`"3:4"`、`"16:9"`、`"9:16"`
649
+ - `stream`: 支持流式和非流式响应
650
+
651
+ **响应示例:**
652
+ ```json
653
+ {
654
+ "created": 1677652288,
655
+ "model": "qwen-max-latest",
656
+ "choices": [
657
+ {
658
+ "index": 0,
659
+ "message": {
660
+ "role": "assistant",
661
+ "content": "![image](https://example.com/generated-image.jpg)"
662
+ },
663
+ "finish_reason": "stop"
664
+ }
665
+ ]
666
+ }
667
+ ```
668
+
669
+ ### 🎯 高级功能
670
+
671
+ #### 🔍 智能搜索模式
672
+
673
+ 在模型名称后添加 `-search` 后缀启用搜索功能:
674
+
675
+ ```json
676
+ {
677
+ "model": "qwen-max-latest-search",
678
+ "messages": [...]
679
+ }
680
+ ```
681
+
682
+ #### 🧠 推理模式
683
+
684
+ 在模型名称后添加 `-thinking` 后缀启用思考过程输出:
685
+
686
+ ```json
687
+ {
688
+ "model": "qwen-max-latest-thinking",
689
+ "messages": [...]
690
+ }
691
+ ```
692
+
693
+ #### 🔍🧠 组合模式
694
+
695
+ 同时启用搜索和推理功能:
696
+
697
+ ```json
698
+ {
699
+ "model": "qwen-max-latest-thinking-search",
700
+ "messages": [...]
701
+ }
702
+ ```
703
+
704
+ #### 🎨 T2I 生图模式
705
+
706
+ 通过设置 `chat_type` 参数为 `t2i` 启用文本到图像生成功能:
707
+
708
+ ```json
709
+ {
710
+ "model": "qwen-max-latest",
711
+ "chat_type": "t2i",
712
+ "messages": [
713
+ {
714
+ "role": "user",
715
+ "content": "画一只可爱的小猫咪"
716
+ }
717
+ ],
718
+ "size": "1:1"
719
+ }
720
+ ```
721
+
722
+ **支持的图片尺寸:** `1:1`、`4:3`、`3:4`、`16:9`、`9:16`
723
+
724
+ **智能尺寸识别:** 系统会自动从提示词中识别尺寸关键词并设置对应尺寸
725
+
726
+ #### 🖼️ 多模态支持
727
+
728
+ API 自动处理图像上传,支持在对话中发送图片:
729
+
730
+ ```json
731
+ {
732
+ "model": "qwen-max-latest",
733
+ "messages": [
734
+ {
735
+ "role": "user",
736
+ "content": [
737
+ {
738
+ "type": "text",
739
+ "text": "这张图片里有什么?"
740
+ },
741
+ {
742
+ "type": "image_url",
743
+ "image_url": {
744
+ "url": "data:image/jpeg;base64,..."
745
+ }
746
+ }
747
+ ]
748
+ }
749
+ ]
750
+ }
751
+ ```
752
+
753
+ ### 🖥️ CLI 端点
754
+
755
+ CLI 端点使用 Qwen Code / Qwen Cli 的 OAuth 令牌访问,支持 256K 上下文和工具调用(Function Calling)。
756
+
757
+ **支持的模型:**
758
+
759
+ | 模型 ID | 说明 |
760
+ |---------|------|
761
+ | `qwen3-coder-plus` | Qwen3 Coder Plus |
762
+ | `qwen3-coder-flash` | Qwen3 Coder Flash(速度更快) |
763
+ | `coder-model` | Qwen 3.5 Plus(带思维链,256K 上下文) |
764
+ | `qwen3.5-plus` | `coder-model` 的别名,自动重定向 |
765
+
766
+ #### 💬 CLI 聊天对话
767
+
768
+ 通过 CLI 端点发送聊天请求,支持流式和非流式响应。
769
+
770
+ ```http
771
+ POST /cli/v1/chat/completions
772
+ Content-Type: application/json
773
+ Authorization: Bearer API_KEY
774
+ ```
775
+
776
+ **请求体:**
777
+ ```json
778
+ {
779
+ "model": "qwen3-coder-plus",
780
+ "messages": [
781
+ {
782
+ "role": "user",
783
+ "content": "你好,请介绍一下自己。"
784
+ }
785
+ ],
786
+ "stream": false,
787
+ "temperature": 0.7,
788
+ "max_tokens": 2000
789
+ }
790
+ ```
791
+
792
+ 使用 `coder-model`(即 Qwen 3.5 Plus)或其别名 `qwen3.5-plus`:
793
+ ```json
794
+ {
795
+ "model": "coder-model",
796
+ "messages": [
797
+ {
798
+ "role": "user",
799
+ "content": "写一个快速排序算法。"
800
+ }
801
+ ],
802
+ "stream": false
803
+ }
804
+ ```
805
+
806
+ **流式请求:**
807
+ ```json
808
+ {
809
+ "model": "qwen3-coder-flash",
810
+ "messages": [
811
+ {
812
+ "role": "user",
813
+ "content": "写一首关于春天的诗。"
814
+ }
815
+ ],
816
+ "stream": true
817
+ }
818
+ ```
819
+
820
+ **响应格式:**
821
+
822
+ 非流式响应与标准 OpenAI API 格式相同:
823
+ ```json
824
+ {
825
+ "id": "chatcmpl-123",
826
+ "object": "chat.completion",
827
+ "created": 1677652288,
828
+ "model": "qwen3-coder-plus",
829
+ "choices": [
830
+ {
831
+ "index": 0,
832
+ "message": {
833
+ "role": "assistant",
834
+ "content": "你好!我是一个AI助手..."
835
+ },
836
+ "finish_reason": "stop"
837
+ }
838
+ ],
839
+ "usage": {
840
+ "prompt_tokens": 20,
841
+ "completion_tokens": 50,
842
+ "total_tokens": 70
843
+ }
844
+ }
845
+ ```
846
+
847
+ 流式响应使用 Server-Sent Events (SSE) 格式:
848
+ ```
849
+ data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1677652288,"model":"qwen3-coder-flash","choices":[{"index":0,"delta":{"content":"你好"},"finish_reason":null}]}
850
+
851
+ data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1677652288,"model":"qwen3-coder-flash","choices":[{"index":0,"delta":{"content":"!"},"finish_reason":null}]}
852
+
853
+ data: [DONE]
854
+ ```
docker/Dockerfile ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM node:lts-alpine
2
+
3
+ ENV NODE_ENV=production
4
+ ENV SERVICE_PORT=7860
5
+ ENV LISTEN_ADDRESS=0.0.0.0
6
+
7
+ RUN apk add --no-cache python3 py3-pip
8
+
9
+ # 全局安装PM2
10
+ RUN npm install -g pm2
11
+
12
+ RUN pip install --no-cache-dir huggingface_hub watchdog
13
+
14
+ WORKDIR /app
15
+
16
+ # 复制package文件
17
+ COPY package*.json ./
18
+
19
+ # 安装依赖
20
+ RUN npm install
21
+
22
+ # 复制应用代码
23
+ COPY . .
24
+
25
+ # 构建前端应用
26
+ RUN cd public && npm install && npm run build
27
+
28
+ # 删除前端不必要文件
29
+ RUN rm -rf public/src public/node_modules public/package*.json
30
+
31
+ # 设置权限
32
+ RUN chmod 777 /app
33
+
34
+ # 创建日志目录
35
+ RUN mkdir -p logs
36
+
37
+ # 允许执行入口脚本
38
+ RUN chmod +x /app/docker/entrypoint.sh
39
+
40
+ # 暴露 Hugging Face Docker Space 默认端口
41
+ # 应用实际监听端口仍由 SERVICE_PORT 控制
42
+ EXPOSE 7860
43
+
44
+ # 启动前自动从 HF Bucket 恢复数据,并在运行中后台同步
45
+ ENTRYPOINT ["/app/docker/entrypoint.sh"]
docker/docker-compose-redis.yml ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ services:
2
+ qwen2api:
3
+ container_name: qwen2api
4
+ image: rfym21/qwen2api:latest
5
+ restart: always
6
+ ports:
7
+ - "3000:3000"
8
+ volumes:
9
+ - ./data:/app/data
10
+ - ./logs:/app/logs
11
+ environment:
12
+ # 如果需要修改Docker暴露端口,请修改ports中的参数
13
+ # 示例(8080:3000) 则访问 http://localhost:8080
14
+ - SERVICE_PORT=3000
15
+ # API 密钥 (非必填)
16
+ # 如果需要使用多账户或使用内置账户,请填写
17
+ - API_KEY=sk-123456
18
+ # 监听地址(非必填)
19
+ - LISTEN_ADDRESS=
20
+ # PM2 多进程配置
21
+ # PM2进程数量: max(使用所有CPU核心), 数字(指定进程数量), 1(单进程)
22
+ # 注意: PM2集群模式下所有进程共用同一个端口
23
+ - PM2_INSTANCES=1
24
+ # PM2内存限制,超过此限制将自动重启进程
25
+ - PM2_MAX_MEMORY=1G
26
+ # 搜索信息展示模式
27
+ # table: 使用折叠块和表格展示
28
+ # text: 使用纯文本
29
+ - SEARCH_INFO_MODE=table
30
+ # 是否输出思考过程
31
+ - OUTPUT_THINK=true
32
+ # 简化模型映射 (true: 只返回基础模型, false: 返回完整模型列表)
33
+ - SIMPLE_MODEL_MAP=false
34
+ # redis 连接地址(必填)
35
+ - REDIS_URL=redis://redis:6379
36
+ # 数据保存模式
37
+ - DATA_SAVE_MODE=redis
38
+ # 图片缓存
39
+ - CACHE_MODE=default
40
+ redis:
41
+ image: redis:7.2-alpine
42
+ container_name: redis_qwen2api
43
+ restart: always
44
+ ports:
45
+ - "6379:6379"
46
+ volumes:
47
+ - redis-data:/data
48
+
49
+ volumes:
50
+ redis-data:
51
+
docker/docker-compose.yml ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ services:
2
+ qwen2api:
3
+ container_name: qwen2api
4
+ image: rfym21/qwen2api:latest
5
+ restart: always
6
+ ports:
7
+ - "3000:3000"
8
+ volumes:
9
+ - ./data:/app/data
10
+ - ./logs:/app/logs
11
+ environment:
12
+ # 如果需要修改Docker暴露端口,请修改ports中的参数
13
+ # 示例(8080:3000) 则访问 http://localhost:8080
14
+ - SERVICE_PORT=3000
15
+ # API 密钥 (非必填)
16
+ # 如果需要使用多账户或使用内置账户,请填写
17
+ - API_KEY=sk-123456
18
+ # 监听地址(非必填)
19
+ - LISTEN_ADDRESS=
20
+ # PM2 多进程配置
21
+ # PM2进程数量: max(使用所有CPU核心), 数字(指定进程数量), 1(单进程)
22
+ # 注意: PM2集群模式下所有进程共用同一个端口
23
+ - PM2_INSTANCES=1
24
+ # PM2内存限制,超过此限制将自动重启进程
25
+ - PM2_MAX_MEMORY=1G
26
+ # 搜索信息展示模式
27
+ # table: 使用折叠块和表格展示
28
+ # text: 使用纯文本
29
+ - SEARCH_INFO_MODE=table
30
+ # 是否输出思考过程
31
+ - OUTPUT_THINK=true
32
+ # 简化模型映射 (true: 只返回基础模型, false: 返回完整模型列表)
33
+ - SIMPLE_MODEL_MAP=false
34
+ # redis 连接地址(如果使用redis模式,则必填,当redis使用tls时将redis://替换为rediss://)
35
+ - REDIS_URL=
36
+ # 数据保存模式
37
+ - DATA_SAVE_MODE=none
38
+ # 账号(如果使用redis和file模式,则不需要填写)
39
+ - ACCOUNTS=
40
+ # 图片缓存
41
+ - CACHE_MODE=default
docker/entrypoint.sh ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/bin/sh
2
+ set -eu
3
+
4
+ APP_DATA_ROOT="${HF_BUCKET_LOCAL_DIR:-/data/qwen2api}"
5
+ export HF_BUCKET_LOCAL_DIR="$APP_DATA_ROOT"
6
+
7
+ mkdir -p "$APP_DATA_ROOT/data" "$APP_DATA_ROOT/caches" "$APP_DATA_ROOT/logs"
8
+
9
+ export DATA_DIR="${DATA_DIR:-$APP_DATA_ROOT/data}"
10
+ export CACHE_DIR="${CACHE_DIR:-$APP_DATA_ROOT/caches}"
11
+ export LOG_DIR="${LOG_DIR:-$APP_DATA_ROOT/logs}"
12
+ export SERVICE_PORT="${SERVICE_PORT:-7860}"
13
+ export LISTEN_ADDRESS="${LISTEN_ADDRESS:-0.0.0.0}"
14
+ export HF_BUCKET_SYNC_INTERVAL="${HF_BUCKET_SYNC_INTERVAL:-300}"
15
+
16
+ python3 /app/scripts/hf-bucket-sync.py restore || true
17
+ python3 /app/scripts/hf-bucket-sync.py daemon &
18
+
19
+ exec npm start
ecosystem.config.js ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const os = require('os')
2
+
3
+ // 获取CPU核心数
4
+ const cpuCores = os.cpus().length
5
+
6
+ // 解析进程数配置
7
+ let instances = process.env.PM2_INSTANCES || 1
8
+ if (instances === 'max') {
9
+ instances = cpuCores
10
+ } else if (!isNaN(instances)) {
11
+ instances = parseInt(instances)
12
+ } else {
13
+ instances = 1
14
+ }
15
+
16
+ // 限制进程数不能超过CPU核心数
17
+ if (instances > cpuCores) {
18
+ console.log(`⚠️ 警告: 配置的进程数(${instances})超过CPU核心数(${cpuCores}),自动调整为${cpuCores}`)
19
+ instances = cpuCores
20
+ }
21
+
22
+ module.exports = {
23
+ apps: [{
24
+ name: 'qwen2api',
25
+ script: './src/server.js',
26
+ instances: instances,
27
+ exec_mode: 'cluster',
28
+
29
+ // 环境变量
30
+ env: {
31
+ PM2_USAGE: 'true'
32
+ },
33
+
34
+ // 日志配置
35
+ log_file: './logs/pm2-combined.log',
36
+ out_file: './logs/pm2-out.log',
37
+ error_file: './logs/pm2-error.log',
38
+ log_date_format: 'YYYY-MM-DD HH:mm:ss Z',
39
+
40
+ // 进程管理配置
41
+ max_memory_restart: process.env.PM2_MAX_MEMORY || '1G',
42
+ min_uptime: '10s',
43
+ max_restarts: 10,
44
+
45
+ // 监听文件变化
46
+ watch: false,
47
+ ignore_watch: ['node_modules', 'logs', 'caches', 'data'],
48
+
49
+ // 其他配置
50
+ merge_logs: true,
51
+ time: true
52
+ }]
53
+ }
package.json ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "qwen2api",
3
+ "version": "2026.03.04.10.58",
4
+ "main": "src/server.js",
5
+ "scripts": {
6
+ "start": "node src/start.js",
7
+ "dev": "nodemon src/server.js",
8
+ "pm2": "pm2 start ecosystem.config.js",
9
+ "pm2:stop": "pm2 stop qwen2api",
10
+ "pm2:restart": "pm2 restart qwen2api",
11
+ "pm2:reload": "pm2 reload qwen2api",
12
+ "pm2:delete": "pm2 delete qwen2api",
13
+ "pm2:logs": "pm2 logs qwen2api",
14
+ "pm2:status": "pm2 status",
15
+ "pm2:monit": "pm2 monit"
16
+ },
17
+ "keywords": [],
18
+ "author": "",
19
+ "license": "ISC",
20
+ "description": "",
21
+ "dependencies": {
22
+ "ali-oss": "^6.22.0",
23
+ "axios": "^1.11.0",
24
+ "body-parser": "^1.20.3",
25
+ "cors": "^2.8.5",
26
+ "dotenv": "^16.4.7",
27
+ "express": "^4.21.2",
28
+ "form-data": "^4.0.2",
29
+ "https-proxy-agent": "^7.0.6",
30
+ "ioredis": "^5.6.1",
31
+ "jwt-decode": "^4.0.0",
32
+ "mime-types": "^3.0.1",
33
+ "multer": "^1.4.5-lts.1",
34
+ "pm2": "^6.0.8",
35
+ "tiktoken": "^1.0.21"
36
+ },
37
+ "devDependencies": {
38
+ "nodemon": "^3.1.7"
39
+ }
40
+ }
public/.gitignore ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Logs
2
+ logs
3
+ *.log
4
+ npm-debug.log*
5
+ yarn-debug.log*
6
+ yarn-error.log*
7
+ pnpm-debug.log*
8
+ lerna-debug.log*
9
+
10
+ node_modules
11
+ dist-ssr
12
+ *.local
13
+
14
+ # Editor directories and files
15
+ .vscode/*
16
+ !.vscode/extensions.json
17
+ .idea
18
+ .DS_Store
19
+ *.suo
20
+ *.ntvs*
21
+ *.njsproj
22
+ *.sln
23
+ *.sw?
public/index.html ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <link rel="icon" type="image/svg+xml" href="/favicon.png" />
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
+ <title>Qwen2 API Dashboard</title>
8
+ </head>
9
+ <body>
10
+ <div id="app"></div>
11
+ <script type="module" src="/src/main.js"></script>
12
+ </body>
13
+ </html>
public/package.json ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "dashboard",
3
+ "private": true,
4
+ "version": "0.0.0",
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "vite --port 6868",
8
+ "build": "vite build",
9
+ "preview": "vite preview"
10
+ },
11
+ "dependencies": {
12
+ "axios": "^1.8.4",
13
+ "vue": "^3.4.29",
14
+ "vue-router": "^4.5.0"
15
+ },
16
+ "devDependencies": {
17
+ "@vitejs/plugin-vue": "^5.0.5",
18
+ "autoprefixer": "^10.4.21",
19
+ "postcss": "^8.5.3",
20
+ "tailwindcss": "^3.4.3",
21
+ "vite": "^5.2.8"
22
+ }
23
+ }
public/postcss.config.js ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ export default {
2
+ plugins: {
3
+ tailwindcss: {},
4
+ autoprefixer: {},
5
+ },
6
+ }
public/src/App.vue ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <template>
2
+ <video id="video-background" autoplay loop muted>
3
+ <source src="./assets/background.mp4" type="video/mp4">
4
+ </video>
5
+
6
+ <router-view></router-view>
7
+
8
+ </template>
9
+
10
+ <script setup>
11
+ </script>
12
+
13
+ <style lang="css" scoped>
14
+ body,
15
+ html {
16
+ margin: 0;
17
+ padding: 0;
18
+ width: 100%;
19
+ height: 100%;
20
+ overflow: hidden;
21
+ position: relative;
22
+ }
23
+
24
+ #video-background {
25
+ position: fixed;
26
+ top: 0;
27
+ left: 0;
28
+ width: 100%;
29
+ height: 100%;
30
+ z-index: -1;
31
+ object-fit: cover;
32
+ }
33
+ </style>
public/src/main.js ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ import { createApp } from 'vue'
2
+ import router from './routes/index.js'
3
+ import App from './App.vue'
4
+ import "./style.css"
5
+
6
+ createApp(App)
7
+ .use(router)
8
+ .mount('#app')
public/src/routes/index.js ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { createRouter, createWebHistory } from 'vue-router'
2
+ import axios from 'axios'
3
+
4
+ const routes = [
5
+ {
6
+ name: 'dashboard',
7
+ path: '/',
8
+ component: () => import('../views/dashboard.vue')
9
+ },
10
+ {
11
+ name: 'auth',
12
+ path: '/auth',
13
+ component: () => import('../views/auth.vue')
14
+ },
15
+ {
16
+ name: 'settings',
17
+ path: '/settings',
18
+ component: () => import('../views/settings.vue')
19
+ }
20
+ ]
21
+
22
+ const router = createRouter({
23
+ history: createWebHistory(),
24
+ routes
25
+ })
26
+
27
+
28
+ // 路由守卫
29
+ router.beforeEach(async (to, from, next) => {
30
+
31
+ if (to.path === '/auth') {
32
+ next()
33
+ } else {
34
+ const apiKey = localStorage.getItem('apiKey')
35
+ if (!apiKey) {
36
+ alert('请先设置身份验证apiKey')
37
+ next({ path: '/auth' })
38
+ } else {
39
+ try {
40
+ const verifyResponse = await axios.post('/verify', {
41
+ apiKey: apiKey
42
+ })
43
+
44
+ if (verifyResponse.data.status === 200) {
45
+ const isAdmin = verifyResponse.data.isAdmin
46
+
47
+ // 存储用户权限信息
48
+ localStorage.setItem('isAdmin', isAdmin.toString())
49
+
50
+ // 检查是否需要管理员权限
51
+ if ((to.path === '/' || to.path === '/settings') && !isAdmin) {
52
+ alert('您没有访问管理页面的权限')
53
+ next({ path: '/auth' })
54
+ return
55
+ }
56
+
57
+ next()
58
+ } else {
59
+ localStorage.removeItem('apiKey')
60
+ localStorage.removeItem('isAdmin')
61
+ next({ path: '/auth' })
62
+ }
63
+ } catch (error) {
64
+ localStorage.removeItem('apiKey')
65
+ localStorage.removeItem('isAdmin')
66
+ next({ path: '/auth' })
67
+ }
68
+ }
69
+ }
70
+
71
+ })
72
+
73
+
74
+ export default router
public/src/style.css ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ @tailwind base;
2
+ @tailwind components;
3
+ @tailwind utilities;
public/src/views/auth.vue ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <template>
2
+ <div class="flex flex-col items-center justify-center w-screen h-screen">
3
+ <transition name="fade-slide">
4
+ <div
5
+ class="flex flex-col items-center w-4/5 h-1/2 bg-opacity-50 bg-white rounded-3xl shadow-xl border-2 border-gray-200 animate-panel"
6
+ v-if="showPanel">
7
+ <h1 class="block mt-24 mb-10 text-2xl font-bold">管理员身份验证</h1>
8
+ <input type="text"
9
+ class="w-4/5 h-16 rounded-2xl bg-opacity-80 bg-white border-2 border-gray-100 pl-10 placeholder:text-gray-500 focus:shadow-lg focus:scale-105 transition-all duration-300"
10
+ placeholder="请输入管理员账号" v-model="apiKey" @keyup.enter="handleLogin">
11
+ <button class="mt-10 w-4/5 h-16 rounded-2xl bg-opacity-65 border-2 border-black bg-black text-white transition-transform duration-200 active:scale-95 hover:scale-105"
12
+ @click="handleLogin">登录</button>
13
+ </div>
14
+ </transition>
15
+ </div>
16
+ </template>
17
+
18
+ <script setup>
19
+ import { ref, onMounted } from 'vue'
20
+ import axios from 'axios'
21
+ import { useRouter } from 'vue-router'
22
+
23
+ const router = useRouter()
24
+ const apiKey = ref('')
25
+ const showPanel = ref(false)
26
+
27
+ const handleLogin = async () => {
28
+ try {
29
+ const res = await axios.post('/verify', {
30
+ apiKey: apiKey.value
31
+ }, {
32
+ headers: {
33
+ 'Content-Type': 'application/json'
34
+ }
35
+ })
36
+ if (res.data.status == 200) {
37
+ localStorage.setItem('apiKey', apiKey.value)
38
+ router.push({ path: '/', replace: true })
39
+ } else {
40
+ alert('apiKey 校验失败,请重新输入!')
41
+ }
42
+ } catch (err) {
43
+ alert('apiKey 校验失败,请重新输入!')
44
+ }
45
+ }
46
+
47
+ onMounted(() => {
48
+ setTimeout(() => {
49
+ showPanel.value = true
50
+ }, 80)
51
+ })
52
+ </script>
53
+
54
+ <style lang="css" scoped>
55
+ .fade-slide-enter-active, .fade-slide-leave-active {
56
+ transition: opacity 0.5s, transform 0.5s;
57
+ }
58
+ .fade-slide-enter-from, .fade-slide-leave-to {
59
+ opacity: 0;
60
+ transform: translateY(40px);
61
+ }
62
+ .fade-slide-enter-to, .fade-slide-leave-from {
63
+ opacity: 1;
64
+ transform: translateY(0);
65
+ }
66
+ </style>
public/src/views/dashboard.vue ADDED
@@ -0,0 +1,1025 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <template>
2
+ <div class="w-100vw h-100vh p-4 overflow-y-auto">
3
+ <div class="container mx-auto">
4
+ <div class="flex flex-col md:flex-row justify-between items-start md:items-center mb-6 px-4 space-y-4 md:space-y-0 pt-5">
5
+ <h1 class="text-4xl font-bold">Token Manager <span class="text-gray-500 text-sm">by 兜豆子</span></h1>
6
+ <div class="grid grid-cols-2 sm:flex sm:flex-row w-full md:w-auto gap-2 sm:gap-0 sm:space-x-2 lg:space-x-4">
7
+ <button @click="showAddModal = true"
8
+ class="action-button font-bold border border-green-200 bg-green-50 text-green-900 px-4 py-2 rounded-xl shadow-sm hover:bg-green-100 hover:border-green-400 transition-all duration-300 transform hover:-translate-y-1 active:translate-y-0 text-center">
9
+ 添加账号
10
+ </button>
11
+ <button @click="refreshAllAccounts"
12
+ :disabled="isRefreshingAll"
13
+ :class="[
14
+ 'action-button font-bold px-4 py-2 rounded-xl shadow-sm transition-all duration-300 transform active:translate-y-0',
15
+ isRefreshingAll
16
+ ? 'bg-purple-400 text-white border-purple-400 refreshing-button-purple cursor-not-allowed transform-none'
17
+ : 'macaron-purple-button text-purple-800 hover:-translate-y-1'
18
+ ]">
19
+ <span v-if="isRefreshingAll" class="flex items-center space-x-2">
20
+ <svg class="animate-spin h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
21
+ <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
22
+ <path class="opacity-75" fill="currentColor" d="m4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
23
+ </svg>
24
+ <span>刷新中...</span>
25
+ </span>
26
+ <span v-else>一键刷新</span>
27
+ </button>
28
+ <button @click="forceRefreshAllAccounts"
29
+ :disabled="isForceRefreshingAll"
30
+ :class="[
31
+ 'action-button font-bold px-4 py-2 rounded-xl shadow-sm transition-all duration-300 transform active:translate-y-0',
32
+ isForceRefreshingAll
33
+ ? 'bg-pink-400 text-white border-pink-400 refreshing-button-pink cursor-not-allowed transform-none'
34
+ : 'macaron-pink-button text-pink-800 hover:-translate-y-1'
35
+ ]">
36
+ <span v-if="isForceRefreshingAll" class="flex items-center space-x-2">
37
+ <svg class="animate-spin h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
38
+ <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
39
+ <path class="opacity-75" fill="currentColor" d="m4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
40
+ </svg>
41
+ <span>强制刷新中...</span>
42
+ </span>
43
+ <span v-else>强制刷新</span>
44
+ </button>
45
+ <button @click="exportAccounts"
46
+ class="action-button font-bold border border-yellow-200 bg-yellow-50 text-yellow-900 px-4 py-2 rounded-xl shadow-sm hover:bg-yellow-100 hover:border-yellow-400 transition-all duration-300 transform hover:-translate-y-1 active:translate-y-0 text-center">
47
+ 导出账号
48
+ </button>
49
+ <router-link to="/settings"
50
+ class="action-button col-span-2 sm:col-span-1 font-bold border border-blue-200 bg-blue-50 text-blue-900 px-4 py-2 rounded-xl shadow-sm hover:bg-blue-100 hover:border-blue-400 transition-all duration-300 transform hover:-translate-y-1 active:translate-y-0 text-center">
51
+ 系统设置
52
+ </router-link>
53
+ </div>
54
+ </div>
55
+
56
+ <!-- 分页控制区 -->
57
+ <div class="flex justify-between items-center px-4 mb-4">
58
+ <div class="flex items-center space-x-2">
59
+ <span class="text-gray-700">每页显示:</span>
60
+ <select v-model="pageSize" @change="changePageSize" class="rounded-lg border-gray-300 bg-white/50 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 transition-all duration-300">
61
+ <option :value="10">10</option>
62
+ <option :value="20">20</option>
63
+ <option :value="50">50</option>
64
+ <option :value="100">100</option>
65
+ <option :value="200">200</option>
66
+ </select>
67
+ </div>
68
+ <div class="flex space-x-2 items-center">
69
+ <span class="text-gray-700">共 {{ totalItems }} 项</span>
70
+ <button
71
+ @click="changePage(currentPage - 1)"
72
+ :disabled="currentPage === 1"
73
+ :class="[
74
+ 'px-3 py-1 rounded-lg transition-all duration-300',
75
+ currentPage === 1 ? 'bg-gray-100 text-gray-400 cursor-not-allowed' : 'bg-blue-50 text-blue-700 hover:bg-blue-100'
76
+ ]"
77
+ >
78
+ 上一页
79
+ </button>
80
+ <span class="text-gray-700">{{ currentPage }}/{{ totalPages }}</span>
81
+ <button
82
+ @click="changePage(currentPage + 1)"
83
+ :disabled="currentPage === totalPages || totalPages === 0"
84
+ :class="[
85
+ 'px-3 py-1 rounded-lg transition-all duration-300',
86
+ currentPage === totalPages || totalPages === 0 ? 'bg-gray-100 text-gray-400 cursor-not-allowed' : 'bg-blue-50 text-blue-700 hover:bg-blue-100'
87
+ ]"
88
+ >
89
+ 下一页
90
+ </button>
91
+ </div>
92
+ </div>
93
+
94
+ <!-- 多选操作区 -->
95
+ <div class="flex justify-between items-center px-4 mb-4">
96
+ <div class="flex items-center space-x-3">
97
+ <label class="inline-flex items-center cursor-pointer group">
98
+ <div class="relative">
99
+ <input type="checkbox"
100
+ v-model="selectAll"
101
+ @change="toggleSelectAll"
102
+ class="sr-only peer">
103
+ <div class="w-6 h-6 bg-white border-2 border-gray-300 rounded-lg peer-checked:bg-indigo-500 peer-checked:border-indigo-500 transition-all duration-300 flex items-center justify-center">
104
+ <svg v-show="selectAll" class="w-4 h-4 text-white" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round">
105
+ <polyline points="20 6 9 17 4 12"></polyline>
106
+ </svg>
107
+ </div>
108
+ </div>
109
+ <span class="ml-2 text-gray-700 group-hover:text-indigo-700 transition-colors duration-200">全选</span>
110
+ </label>
111
+ <button
112
+ @click="deleteSelected"
113
+ :disabled="selectedTokens.length === 0"
114
+ :class="[
115
+ 'px-4 py-1.5 rounded-lg transition-all duration-300 border flex items-center space-x-1',
116
+ selectedTokens.length === 0 ? 'bg-gray-100 text-gray-400 border-gray-200 cursor-not-allowed' : 'bg-red-50 text-red-600 border-red-200 hover:bg-red-100'
117
+ ]"
118
+ >
119
+ <svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
120
+ <path fill-rule="evenodd" d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z" clip-rule="evenodd" />
121
+ </svg>
122
+ <span>删除选中 ({{ selectedTokens.length }})</span>
123
+ </button>
124
+ </div>
125
+ <button
126
+ @click="showDeleteAllConfirm = true"
127
+ class="px-4 py-1.5 rounded-lg border border-red-300 bg-red-50 text-red-700 hover:bg-red-100 transition-all duration-300 flex items-center space-x-1"
128
+ >
129
+ <svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
130
+ <path fill-rule="evenodd" d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z" clip-rule="evenodd" />
131
+ </svg>
132
+ <span>删除全部账号</span>
133
+ </button>
134
+ </div>
135
+
136
+ <!-- Token列表 -->
137
+ <div class="max-h-[calc(75vh)] overflow-y-auto pr-2 scrollbar-hidden">
138
+ <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 p-4">
139
+ <div v-for="token in displayedTokens"
140
+ :key="token.email"
141
+ class="token-card group relative overflow-hidden rounded-2xl transition-all duration-300 hover:shadow-2xl pt-4"
142
+ :class="{'ring-2 ring-indigo-500 ring-opacity-75': isSelected(token.email)}">
143
+ <div class="absolute top-3 left-3 z-10">
144
+ <label class="custom-checkbox cursor-pointer">
145
+ <input type="checkbox"
146
+ :checked="isSelected(token.email)"
147
+ @change="toggleSelect(token.email)"
148
+ class="sr-only peer">
149
+ <div class="checkbox-icon w-6 h-6 bg-white/70 backdrop-blur-sm border-2 border-gray-300 rounded-lg peer-checked:bg-indigo-500 peer-checked:border-indigo-500 transition-all duration-300 flex items-center justify-center shadow-sm hover:shadow">
150
+ <svg v-show="isSelected(token.email)" class="w-4 h-4 text-white transform scale-0 peer-checked:scale-100 transition-transform duration-300" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round">
151
+ <polyline points="20 6 9 17 4 12"></polyline>
152
+ </svg>
153
+ </div>
154
+ </label>
155
+ </div>
156
+ <div class="absolute inset-0 bg-white/30 backdrop-blur-md border border-white/30"></div>
157
+ <div class="relative p-6 flex flex-col gap-4">
158
+ <div class="flex flex-col space-y-3">
159
+ <div class="relative flex items-center bg-blue-50/80 rounded-lg px-2 py-1">
160
+ <div class="overflow-x-auto scrollbar-hide flex-1 flex items-center space-x-2">
161
+ <span class="text-gray-700 min-w-[96px] text-left font-semibold">📧 Email:</span>
162
+ <span class="font-medium whitespace-nowrap text-left">{{ token.email }}</span>
163
+ </div>
164
+ <button @click="copyToClipboard(token.email)" class="absolute right-2 opacity-0 hover:opacity-100 transition-opacity bg-blue-200 hover:bg-blue-300 rounded px-2 py-1 text-base">📋</button>
165
+ </div>
166
+ <div class="relative flex items-center bg-blue-50/80 rounded-lg px-2 py-1">
167
+ <div class="overflow-x-auto scrollbar-hide flex-1 flex items-center space-x-2">
168
+ <span class="text-gray-700 min-w-[96px] text-left font-semibold">🔑 Passwd:</span>
169
+ <span class="font-medium whitespace-nowrap text-left">{{ token.password }}</span>
170
+ </div>
171
+ <button @click="copyToClipboard(token.password)" class="absolute right-2 opacity-0 hover:opacity-100 transition-opacity bg-blue-200 hover:bg-blue-300 rounded px-2 py-1 text-base">📋</button>
172
+ </div>
173
+ <div class="relative flex items-center bg-blue-50/80 rounded-lg px-2 py-1">
174
+ <div class="overflow-x-auto scrollbar-hide flex-1 flex items-center space-x-2">
175
+ <span class="text-gray-700 min-w-[96px] text-left font-semibold">🔐 Token:</span>
176
+ <span class="font-medium whitespace-nowrap text-left text-sm">{{ token.token }}</span>
177
+ </div>
178
+ <button @click="copyToClipboard(token.token)" class="absolute right-2 opacity-0 hover:opacity-100 transition-opacity bg-blue-200 hover:bg-blue-300 rounded px-2 py-1 text-base">📋</button>
179
+ </div>
180
+ <div class="relative flex items-center bg-blue-50/80 rounded-lg px-2 py-1">
181
+ <div class="overflow-x-auto scrollbar-hide flex-1 flex items-center space-x-2">
182
+ <span class="text-gray-700 min-w-[96px] text-left font-semibold">⏰ Expire:</span>
183
+ <span class="font-medium whitespace-nowrap text-left">{{ new Date(token.expires * 1000).toLocaleString() }}</span>
184
+ </div>
185
+ <button @click="copyToClipboard(new Date(token.expires * 1000).toLocaleString())" class="absolute right-2 opacity-0 hover:opacity-100 transition-opacity bg-blue-200 hover:bg-blue-300 rounded px-2 py-1 text-base">📋</button>
186
+ </div>
187
+ </div>
188
+
189
+ <div class="pt-4 mt-auto border-t border-gray-200/50 space-y-2">
190
+ <button @click="refreshToken(token.email)"
191
+ :disabled="refreshingTokens.includes(token.email)"
192
+ :class="[
193
+ 'w-full py-2 rounded-lg transition-all duration-300 flex items-center justify-center space-x-2',
194
+ refreshingTokens.includes(token.email)
195
+ ? 'bg-green-400 text-white refreshing-button-green cursor-not-allowed'
196
+ : 'macaron-green-button text-green-600 hover:bg-green-100 border border-green-200'
197
+ ]">
198
+ <span v-if="refreshingTokens.includes(token.email)" class="flex items-center space-x-2">
199
+ <svg class="animate-spin h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
200
+ <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
201
+ <path class="opacity-75" fill="currentColor" d="m4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
202
+ </svg>
203
+ <span>刷新中...</span>
204
+ </span>
205
+ <span v-else>刷新令牌</span>
206
+ </button>
207
+ <button @click="deleteToken(token.email)"
208
+ class="w-full group-hover:bg-red-50 text-red-600 py-2 rounded-lg transition-all duration-300 hover:bg-red-100">
209
+ 删除账号
210
+ </button>
211
+ </div>
212
+ </div>
213
+ </div>
214
+ </div>
215
+ </div>
216
+ </div>
217
+
218
+ <!-- 删除全部确认对话框 -->
219
+ <div v-if="showDeleteAllConfirm"
220
+ class="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50"
221
+ @click.self="showDeleteAllConfirm = false">
222
+ <div class="relative bg-white/90 backdrop-blur-lg rounded-2xl p-6 w-11/12 max-w-md transform transition-all duration-300 scale-100 opacity-100">
223
+ <h2 class="text-2xl font-bold text-red-600 mb-4">⚠️ 危险操作</h2>
224
+ <p class="text-gray-700 mb-6">您确定要删除<span class="font-bold">全部 {{ totalItems }} 个</span>账号吗?此操作不可恢复!</p>
225
+ <div class="flex justify-end space-x-4">
226
+ <button @click="showDeleteAllConfirm = false"
227
+ class="px-4 py-2 rounded-xl bg-gray-100 hover:bg-gray-200 transition-all duration-300">
228
+ 取消
229
+ </button>
230
+ <button @click="deleteAllAccounts"
231
+ class="px-4 py-2 rounded-xl bg-red-600 text-white hover:bg-red-700 transition-all duration-300">
232
+ 确认删除
233
+ </button>
234
+ </div>
235
+ </div>
236
+ </div>
237
+
238
+ <!-- 添加账号模态框 -->
239
+ <div v-if="showAddModal"
240
+ class="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50"
241
+ @click.self="showAddModal = false">
242
+ <div class="relative bg-white/80 backdrop-blur-lg rounded-2xl p-6 w-11/12 max-w-md transform transition-all duration-300 scale-100 opacity-100">
243
+ <div class="flex mb-6 border-b border-gray-200">
244
+ <button :class="['flex-1 py-2 font-bold transition-all rounded-t-xl duration-300', addMode==='single' ? 'text-gray-600 border-b-2 border-gray-500 bg-gray-50/60' : 'text-gray-500 bg-transparent']" @click="addMode='single'">单账号添加</button>
245
+ <button :class="['flex-1 py-2 font-bold transition-all rounded-t-xl duration-300', addMode==='batch' ? 'text-gray-600 border-b-2 border-gray-500 bg-gray-50/60' : 'text-gray-500 bg-transparent']" @click="addMode='batch'">批量添加</button>
246
+ </div>
247
+ <transition name="fade" mode="out-in">
248
+ <div v-if="addMode==='single'" key="single">
249
+ <h2 class="text-xl font-bold mb-4">添加账号</h2>
250
+ <div class="space-y-4">
251
+ <div>
252
+ <label class="block text-sm font-medium text-gray-700">Email</label>
253
+ <input v-model="newAccount.email" type="email"
254
+ class="mt-1 block w-full rounded-xl border-gray-300 bg-white/50 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 transition-all duration-300 h-12 text-base px-4">
255
+ </div>
256
+ <div>
257
+ <label class="block text-sm font-medium text-gray-700">Password</label>
258
+ <input v-model="newAccount.password" type="password"
259
+ class="mt-1 block w-full rounded-xl border-gray-300 bg-white/50 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 transition-all duration-300 h-12 text-base px-4">
260
+ </div>
261
+ <div class="flex justify-end space-x-4 pt-4">
262
+ <button @click="showAddModal = false"
263
+ class="px-4 py-2 rounded-xl bg-gray-100 hover:bg-gray-200 transition-all duration-300">
264
+ 取消
265
+ </button>
266
+ <button @click="addToken"
267
+ class="px-4 py-2 rounded-xl bg-black text-white hover:bg-white hover:text-black transition-all duration-300">
268
+ 添加
269
+ </button>
270
+ </div>
271
+ </div>
272
+ </div>
273
+ <div v-else key="batch">
274
+ <h2 class="text-xl font-bold mb-4 px-4">批量添加账号</h2>
275
+ <div class="space-y-4">
276
+ <div>
277
+ <label class="block text-sm font-medium text-gray-700 px-4 pb-2">账号列表(每行一个,格式:email:password)</label>
278
+ <textarea v-model="batchAccounts" rows="6" class="mt-1 block w-full rounded-xl border-gray-300 bg-white/50 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 transition-all duration-300 h-36 text-base px-4 py-3 resize-none"></textarea>
279
+ </div>
280
+ <div class="flex justify-end space-x-4 pt-4">
281
+ <button @click="showAddModal = false"
282
+ class="px-4 py-2 rounded-xl bg-gray-100 hover:bg-gray-200 transition-all duration-300">
283
+ 取消
284
+ </button>
285
+ <button @click="addBatchTokens"
286
+ class="px-4 py-2 rounded-xl bg-black text-white hover:bg-white hover:text-black transition-all duration-300">
287
+ 批量添加
288
+ </button>
289
+ </div>
290
+ </div>
291
+ </div>
292
+ </transition>
293
+ </div>
294
+ </div>
295
+
296
+ <!-- Toast 通知 -->
297
+ <div v-if="toast.show"
298
+ :class="[
299
+ 'fixed top-4 right-4 z-50 px-6 py-4 rounded-xl shadow-lg transform transition-all duration-300',
300
+ toast.type === 'success' ? 'bg-emerald-500 text-white' : 'bg-red-500 text-white'
301
+ ]">
302
+ <div class="flex items-center space-x-2">
303
+ <svg v-if="toast.type === 'success'" class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
304
+ <path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
305
+ </svg>
306
+ <svg v-else class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
307
+ <path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd" />
308
+ </svg>
309
+ <span>{{ toast.message }}</span>
310
+ </div>
311
+ </div>
312
+ </div>
313
+ </template>
314
+
315
+ <script setup>
316
+ import { ref, onMounted, computed } from 'vue'
317
+ import axios from 'axios'
318
+
319
+ const tokens = ref([])
320
+ const showAddModal = ref(false)
321
+ const addMode = ref('single')
322
+ const newAccount = ref({
323
+ email: '',
324
+ password: ''
325
+ })
326
+ const batchAccounts = ref('')
327
+
328
+ // 分页相关
329
+ const displayedTokens = ref([])
330
+ const currentPage = ref(1)
331
+ const pageSize = ref(10)
332
+ const totalItems = ref(0)
333
+ const totalPages = computed(() => Math.max(1, Math.ceil(totalItems.value / pageSize.value)))
334
+ const isLoading = ref(false)
335
+
336
+ // 多选相关
337
+ const selectedTokens = ref([])
338
+ const selectAll = ref(false)
339
+ const showDeleteAllConfirm = ref(false)
340
+
341
+ // 刷新相关
342
+ const isRefreshingAll = ref(false)
343
+ const isForceRefreshingAll = ref(false)
344
+ const refreshingTokens = ref([])
345
+
346
+ // Toast 通知
347
+ const toast = ref({
348
+ show: false,
349
+ message: '',
350
+ type: 'success'
351
+ })
352
+
353
+ const isSelected = (email) => {
354
+ return selectedTokens.value.includes(email)
355
+ }
356
+
357
+ const toggleSelect = (email) => {
358
+ const index = selectedTokens.value.indexOf(email)
359
+ if (index === -1) {
360
+ selectedTokens.value.push(email)
361
+ } else {
362
+ selectedTokens.value.splice(index, 1)
363
+ }
364
+ // 更新全选状态
365
+ selectAll.value = selectedTokens.value.length === displayedTokens.value.length
366
+ }
367
+
368
+ const toggleSelectAll = () => {
369
+ if (selectAll.value) {
370
+ // 全选当前页
371
+ selectedTokens.value = displayedTokens.value.map(token => token.email)
372
+ } else {
373
+ // 取消全选
374
+ selectedTokens.value = []
375
+ }
376
+ }
377
+
378
+ const deleteSelected = async () => {
379
+ if (selectedTokens.value.length === 0) return
380
+
381
+ if (!confirm(`确定要删除选中的 ${selectedTokens.value.length} 个账号吗?`)) return
382
+
383
+ try {
384
+ // 批量删除,这里假设后端支持批量删除,如果不支持,需要循环调用单个删除
385
+ const deletePromises = selectedTokens.value.map(email =>
386
+ axios.delete('/api/deleteAccount', {
387
+ data: { email },
388
+ headers: {
389
+ 'Authorization': localStorage.getItem('apiKey') || ''
390
+ }
391
+ })
392
+ )
393
+
394
+ await Promise.all(deletePromises)
395
+ await getTokens()
396
+ selectedTokens.value = []
397
+ selectAll.value = false
398
+ showToast('删除成功')
399
+ } catch (error) {
400
+ console.error('批量删除失败:', error)
401
+ showToast('批量删除失败: ' + error.message, 'error')
402
+ }
403
+ }
404
+
405
+ const deleteAllAccounts = async () => {
406
+ try {
407
+ // 先获取全部账号数据
408
+ const res = await axios.get('/api/getAllAccounts', {
409
+ params: { page: 1, pageSize: 10000 },
410
+ headers: { 'Authorization': localStorage.getItem('apiKey') || '' }
411
+ })
412
+ const allAccounts = res.data.data
413
+
414
+ const deletePromises = allAccounts.map(token =>
415
+ axios.delete('/api/deleteAccount', {
416
+ data: { email: token.email },
417
+ headers: {
418
+ 'Authorization': localStorage.getItem('apiKey') || ''
419
+ }
420
+ })
421
+ )
422
+
423
+ await Promise.all(deletePromises)
424
+ showDeleteAllConfirm.value = false
425
+ currentPage.value = 1
426
+ await getTokens()
427
+ selectedTokens.value = []
428
+ selectAll.value = false
429
+ showToast('所有账号已删除')
430
+ } catch (error) {
431
+ console.error('删除所有账号失败:', error)
432
+ showToast('删除所有账号失败: ' + error.message, 'error')
433
+ }
434
+ }
435
+
436
+ const changePage = async (page) => {
437
+ if (page >= 1 && page <= totalPages.value) {
438
+ currentPage.value = page
439
+ // 重置选择状态
440
+ selectedTokens.value = []
441
+ selectAll.value = false
442
+ await getTokens()
443
+ }
444
+ }
445
+
446
+ const changePageSize = async () => {
447
+ currentPage.value = 1
448
+ // 重置选择状态
449
+ selectedTokens.value = []
450
+ selectAll.value = false
451
+ await getTokens()
452
+ }
453
+
454
+ const showToast = (message, type = 'success') => {
455
+ toast.value.message = message
456
+ toast.value.type = type
457
+ toast.value.show = true
458
+
459
+ setTimeout(() => {
460
+ toast.value.show = false
461
+ }, 3000)
462
+ }
463
+
464
+ const copyToClipboard = async (text) => {
465
+ try {
466
+ await navigator.clipboard.writeText(text)
467
+ showToast('已复制到剪贴板')
468
+ } catch (err) {
469
+ console.error('复制失败:', err)
470
+ showToast('复制失败', 'error')
471
+ }
472
+ }
473
+
474
+ const getTokens = async () => {
475
+ isLoading.value = true
476
+ try {
477
+ const res = await axios.get('/api/getAllAccounts', {
478
+ params: {
479
+ page: currentPage.value,
480
+ pageSize: pageSize.value
481
+ },
482
+ headers: {
483
+ 'Authorization': localStorage.getItem('apiKey') || ''
484
+ }
485
+ })
486
+
487
+ displayedTokens.value = res.data.data
488
+ totalItems.value = res.data.total
489
+
490
+ // 如果当前页超出了总页数,重置到第一页并重新获取
491
+ if (currentPage.value > totalPages.value && totalPages.value > 0) {
492
+ currentPage.value = 1
493
+ await getTokens()
494
+ return
495
+ }
496
+
497
+ // 重置选择状态
498
+ selectedTokens.value = []
499
+ selectAll.value = false
500
+
501
+ } catch (error) {
502
+ console.error('获取Token列表失败:', error)
503
+ showToast('获取Token列表失败: ' + error.message, 'error')
504
+ } finally {
505
+ isLoading.value = false
506
+ }
507
+ }
508
+
509
+ const addToken = async () => {
510
+ try {
511
+ await axios.post('/api/setAccount', newAccount.value, {
512
+ headers: {
513
+ 'Authorization': localStorage.getItem('apiKey') || ''
514
+ }
515
+ })
516
+ showAddModal.value = false
517
+ newAccount.value = { email: '', password: '' }
518
+ await getTokens()
519
+ showToast('添加账号成功')
520
+ } catch (error) {
521
+ console.error('添加账号失败:', error)
522
+ showToast('添加账号失败: ' + error.message, 'error')
523
+ }
524
+ }
525
+
526
+ const addBatchTokens = async () => {
527
+ try {
528
+ await axios.post('/api/setAccounts', { accounts: batchAccounts.value }, {
529
+ headers: {
530
+ 'Authorization': localStorage.getItem('apiKey') || ''
531
+ }
532
+ })
533
+ showAddModal.value = false
534
+ batchAccounts.value = ''
535
+ await getTokens()
536
+ showToast('批量添加任务已提交')
537
+ } catch (error) {
538
+ console.error('批量添加失败:', error)
539
+ showToast('批量添加失败: ' + error.message, 'error')
540
+ }
541
+ }
542
+
543
+ const refreshToken = async (email) => {
544
+ if (refreshingTokens.value.includes(email)) return
545
+
546
+ refreshingTokens.value.push(email)
547
+
548
+ try {
549
+ await axios.post('/api/refreshAccount', { email }, {
550
+ headers: {
551
+ 'Authorization': localStorage.getItem('apiKey') || ''
552
+ }
553
+ })
554
+
555
+ // 刷新成功后重新获取账号列表
556
+ await getTokens()
557
+ showToast(`账号 ${email} 令牌刷新成功`)
558
+ } catch (error) {
559
+ console.error('刷新账号令牌失败:', error)
560
+ showToast('刷新账号令牌失败: ' + error.message, 'error')
561
+ } finally {
562
+ // 移除刷新状态
563
+ const index = refreshingTokens.value.indexOf(email)
564
+ if (index > -1) {
565
+ refreshingTokens.value.splice(index, 1)
566
+ }
567
+ }
568
+ }
569
+
570
+ const refreshAllAccounts = async () => {
571
+ if (isRefreshingAll.value) return
572
+
573
+ if (!confirm('确定要刷新所有账号的令牌吗?这可能需要一些时间。')) return
574
+
575
+ isRefreshingAll.value = true
576
+
577
+ try {
578
+ const response = await axios.post('/api/refreshAllAccounts', {
579
+ thresholdHours: 24
580
+ }, {
581
+ headers: {
582
+ 'Authorization': localStorage.getItem('apiKey') || ''
583
+ }
584
+ })
585
+
586
+ // 刷新成功后重新获取账号列表
587
+ await getTokens()
588
+ showToast(`批量刷新完成,成功刷新了 ${response.data.refreshedCount} 个账号`)
589
+ } catch (error) {
590
+ console.error('批量刷新失败:', error)
591
+ showToast('批量刷新失败: ' + error.message, 'error')
592
+ } finally {
593
+ isRefreshingAll.value = false
594
+ }
595
+ }
596
+
597
+ const forceRefreshAllAccounts = async () => {
598
+ if (isForceRefreshingAll.value) return
599
+
600
+ if (!confirm('确定要强制刷新所有账号的令牌吗?这将刷新所有账号,不管它们是否即将过期,可能需要较长时间。')) return
601
+
602
+ isForceRefreshingAll.value = true
603
+
604
+ try {
605
+ const response = await axios.post('/api/forceRefreshAllAccounts', {}, {
606
+ headers: {
607
+ 'Authorization': localStorage.getItem('apiKey') || ''
608
+ }
609
+ })
610
+
611
+ // 刷新成功后重新获取账号列表
612
+ await getTokens()
613
+ showToast(`强制刷新完成,成功刷新了 ${response.data.refreshedCount} 个账号`)
614
+ } catch (error) {
615
+ console.error('强制刷新失败:', error)
616
+ showToast('强制刷新失败: ' + error.message, 'error')
617
+ } finally {
618
+ isForceRefreshingAll.value = false
619
+ }
620
+ }
621
+
622
+ const deleteToken = async (email) => {
623
+ if (!confirm('确定要删除此账号吗?')) return
624
+
625
+ try {
626
+ await axios.delete('/api/deleteAccount', {
627
+ data: { email },
628
+ headers: {
629
+ 'Authorization': localStorage.getItem('apiKey') || ''
630
+ }
631
+ })
632
+ await getTokens()
633
+ showToast('删除账号成功')
634
+ } catch (error) {
635
+ console.error('删除账号失败:', error)
636
+ showToast('删除账号失败: ' + error.message, 'error')
637
+ }
638
+ }
639
+
640
+ const exportAccounts = async () => {
641
+ try {
642
+ // 获取全部账号用于导出
643
+ const res = await axios.get('/api/getAllAccounts', {
644
+ params: { page: 1, pageSize: 10000 },
645
+ headers: { 'Authorization': localStorage.getItem('apiKey') || '' }
646
+ })
647
+ const allAccounts = res.data.data
648
+
649
+ if (allAccounts.length === 0) {
650
+ showToast('没有可导出的账号', 'error')
651
+ return
652
+ }
653
+
654
+ // 构建导出内容,格式为"账号:密码",每行一个
655
+ const content = allAccounts.map(token => `${token.email}:${token.password}`).join('\n')
656
+
657
+ // 创建Blob对象
658
+ const blob = new Blob([content], { type: 'text/plain;charset=utf-8' })
659
+
660
+ // 创建下载链接并触发下载
661
+ const url = URL.createObjectURL(blob)
662
+ const link = document.createElement('a')
663
+ link.href = url
664
+ link.download = 'qwen_accounts.txt'
665
+ document.body.appendChild(link)
666
+ link.click()
667
+
668
+ // 清理
669
+ setTimeout(() => {
670
+ document.body.removeChild(link)
671
+ URL.revokeObjectURL(url)
672
+ }, 100)
673
+
674
+ showToast('导出完成')
675
+ } catch (error) {
676
+ console.error('导出失败:', error)
677
+ showToast('导出失败: ' + error.message, 'error')
678
+ }
679
+ }
680
+
681
+ onMounted(() => {
682
+ getTokens()
683
+ })
684
+ </script>
685
+
686
+ <style lang="css" scoped>
687
+ @media (max-width: 640px) {
688
+ .container {
689
+ padding: 0;
690
+ }
691
+ }
692
+
693
+ .fade-enter-active, .fade-leave-active {
694
+ transition: opacity 0.3s, transform 0.3s;
695
+ }
696
+ .fade-enter-from, .fade-leave-to {
697
+ opacity: 0;
698
+ transform: translateY(10px);
699
+ }
700
+ .fade-enter-to, .fade-leave-from {
701
+ opacity: 1;
702
+ transform: translateY(0);
703
+ }
704
+
705
+ .token-card {
706
+ background: linear-gradient(135deg, rgba(255, 255, 255, 0.7), rgba(255, 255, 255, 0.3));
707
+ box-shadow: 0 8px 32px 0 rgba(31, 38, 135, 0.15);
708
+ transform: translateY(0);
709
+ transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
710
+ }
711
+
712
+ .token-card:hover {
713
+ transform: translateY(-5px);
714
+ }
715
+
716
+ .scrollbar-hide {
717
+ -ms-overflow-style: none;
718
+ scrollbar-width: none;
719
+ }
720
+
721
+ .scrollbar-hide::-webkit-scrollbar {
722
+ display: none;
723
+ }
724
+
725
+ @keyframes slideIn {
726
+ from {
727
+ transform: translateY(20px);
728
+ opacity: 0;
729
+ }
730
+ to {
731
+ transform: translateY(0);
732
+ opacity: 1;
733
+ }
734
+ }
735
+
736
+ .token-card {
737
+ animation: slideIn 0.5s ease-out;
738
+ animation-fill-mode: both;
739
+ }
740
+
741
+ .token-card:nth-child(3n+1) { animation-delay: 0.1s; }
742
+ .token-card:nth-child(3n+2) { animation-delay: 0.2s; }
743
+ .token-card:nth-child(3n+3) { animation-delay: 0.3s; }
744
+
745
+ .overflow-x-auto {
746
+ position: relative;
747
+ cursor: pointer;
748
+ }
749
+
750
+ .overflow-x-auto::after {
751
+ content: '';
752
+ position: absolute;
753
+ right: 0;
754
+ top: 0;
755
+ bottom: 0;
756
+ width: 24px;
757
+ background: linear-gradient(to right, transparent, rgba(255, 255, 255, 0.8));
758
+ pointer-events: none;
759
+ opacity: 0;
760
+ transition: opacity 0.3s;
761
+ }
762
+
763
+ .overflow-x-auto:hover::after {
764
+ opacity: 1;
765
+ }
766
+
767
+ /* 隐藏滚动条样式 */
768
+ .scrollbar-hidden {
769
+ -ms-overflow-style: none; /* IE and Edge */
770
+ scrollbar-width: none; /* Firefox */
771
+ }
772
+
773
+ .scrollbar-hidden::-webkit-scrollbar {
774
+ display: none; /* Chrome, Safari and Opera */
775
+ }
776
+
777
+ /* 自定义滚动条样式(备用) */
778
+ .max-h-\[calc\(100vh-200px\)\]::-webkit-scrollbar {
779
+ width: 6px;
780
+ }
781
+
782
+ .max-h-\[calc\(100vh-200px\)\]::-webkit-scrollbar-track {
783
+ background: rgba(0, 0, 0, 0.05);
784
+ border-radius: 8px;
785
+ }
786
+
787
+ .max-h-\[calc\(100vh-200px\)\]::-webkit-scrollbar-thumb {
788
+ background-color: rgba(0, 0, 0, 0.1);
789
+ border-radius: 8px;
790
+ }
791
+
792
+ .max-h-\[calc\(100vh-200px\)\]::-webkit-scrollbar-thumb:hover {
793
+ background-color: rgba(0, 0, 0, 0.2);
794
+ }
795
+
796
+ /* 自定义复选框样式 */
797
+ .custom-checkbox .checkbox-icon {
798
+ position: relative;
799
+ overflow: hidden;
800
+ }
801
+
802
+ .custom-checkbox .checkbox-icon:before {
803
+ content: '';
804
+ position: absolute;
805
+ top: 0;
806
+ left: 0;
807
+ width: 0;
808
+ height: 100%;
809
+ background: rgba(99, 102, 241, 0.1);
810
+ transition: width 0.3s ease;
811
+ }
812
+
813
+ .custom-checkbox:hover .checkbox-icon:before {
814
+ width: 100%;
815
+ }
816
+
817
+ .custom-checkbox input:checked + .checkbox-icon svg {
818
+ animation: check-animation 0.5s cubic-bezier(0.17, 0.67, 0.83, 0.67);
819
+ transform: scale(1);
820
+ }
821
+
822
+ @keyframes check-animation {
823
+ 0% {
824
+ transform: scale(0);
825
+ }
826
+ 50% {
827
+ transform: scale(1.2);
828
+ }
829
+ 100% {
830
+ transform: scale(1);
831
+ }
832
+ }
833
+
834
+ /* 给选中的卡片添加动画效果 */
835
+ .token-card.ring-2 {
836
+ animation: selected-pulse 2s infinite;
837
+ }
838
+
839
+ @keyframes selected-pulse {
840
+ 0% {
841
+ box-shadow: 0 0 0 0 rgba(99, 102, 241, 0.4);
842
+ }
843
+ 70% {
844
+ box-shadow: 0 0 0 6px rgba(99, 102, 241, 0);
845
+ }
846
+ 100% {
847
+ box-shadow: 0 0 0 0 rgba(99, 102, 241, 0);
848
+ }
849
+ }
850
+
851
+ /* 马卡龙紫色刷新按钮动画 */
852
+ @keyframes refresh-pulse-purple {
853
+ 0% {
854
+ box-shadow: 0 0 0 0 rgba(168, 85, 247, 0.4);
855
+ }
856
+ 70% {
857
+ box-shadow: 0 0 0 6px rgba(168, 85, 247, 0);
858
+ }
859
+ 100% {
860
+ box-shadow: 0 0 0 0 rgba(168, 85, 247, 0);
861
+ }
862
+ }
863
+
864
+ /* 马卡龙绿色刷新按钮动画 */
865
+ @keyframes refresh-pulse-green {
866
+ 0% {
867
+ box-shadow: 0 0 0 0 rgba(74, 222, 128, 0.4);
868
+ }
869
+ 70% {
870
+ box-shadow: 0 0 0 6px rgba(74, 222, 128, 0);
871
+ }
872
+ 100% {
873
+ box-shadow: 0 0 0 0 rgba(74, 222, 128, 0);
874
+ }
875
+ }
876
+
877
+ /* 马卡龙粉色刷新按钮动画 */
878
+ @keyframes refresh-pulse-pink {
879
+ 0% {
880
+ box-shadow: 0 0 0 0 rgba(236, 72, 153, 0.4);
881
+ }
882
+ 70% {
883
+ box-shadow: 0 0 0 6px rgba(236, 72, 153, 0);
884
+ }
885
+ 100% {
886
+ box-shadow: 0 0 0 0 rgba(236, 72, 153, 0);
887
+ }
888
+ }
889
+
890
+ .action-button:hover {
891
+ animation: refresh-pulse-purple 1.5s infinite;
892
+ }
893
+
894
+ /* 刷新中的按钮样式 - 马卡龙紫色 */
895
+ .refreshing-button-purple {
896
+ background: linear-gradient(45deg, #c084fc, #a855f7);
897
+ color: white;
898
+ animation: refresh-pulse-purple 1.5s infinite;
899
+ box-shadow: 0 4px 15px rgba(168, 85, 247, 0.3);
900
+ }
901
+
902
+ /* 刷新中的按钮样式 - 马卡龙绿色 */
903
+ .refreshing-button-green {
904
+ background: linear-gradient(45deg, #86efac, #4ade80);
905
+ color: white;
906
+ animation: refresh-pulse-green 1.5s infinite;
907
+ box-shadow: 0 4px 15px rgba(74, 222, 128, 0.3);
908
+ }
909
+
910
+ /* 刷新中的按钮样式 - 马卡龙粉色 */
911
+ .refreshing-button-pink {
912
+ background: linear-gradient(45deg, #f472b6, #ec4899);
913
+ color: white;
914
+ animation: refresh-pulse-pink 1.5s infinite;
915
+ box-shadow: 0 4px 15px rgba(236, 72, 153, 0.3);
916
+ }
917
+
918
+ /* 马卡龙色系按钮增强效果 */
919
+ .action-button {
920
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
921
+ backdrop-filter: blur(10px);
922
+ }
923
+
924
+ .action-button:hover {
925
+ transform: translateY(-2px);
926
+ box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
927
+ }
928
+
929
+ /* 单个刷新按钮的马卡龙绿色样式增强 */
930
+ .text-green-600:hover {
931
+ background: linear-gradient(135deg, #dcfce7, #bbf7d0) !important;
932
+ border-color: #86efac !important;
933
+ transform: translateY(-1px);
934
+ box-shadow: 0 4px 12px rgba(74, 222, 128, 0.2);
935
+ }
936
+
937
+ /* 绿色刷新按钮的基础样式 */
938
+ .bg-green-50 {
939
+ background: linear-gradient(135deg, #f0fdf4, #dcfce7);
940
+ border: 1px solid #bbf7d0;
941
+ }
942
+
943
+ .bg-green-50:hover {
944
+ background: linear-gradient(135deg, #dcfce7, #bbf7d0);
945
+ border-color: #86efac;
946
+ transform: translateY(-1px);
947
+ box-shadow: 0 4px 12px rgba(74, 222, 128, 0.2);
948
+ }
949
+
950
+ /* 马卡龙绿色按钮样式 */
951
+ .macaron-green-button {
952
+ background: linear-gradient(135deg, #f0fdf4, #dcfce7);
953
+ border: 1px solid #bbf7d0;
954
+ transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
955
+ }
956
+
957
+ .macaron-green-button:hover {
958
+ background: linear-gradient(135deg, #dcfce7, #bbf7d0);
959
+ border-color: #86efac;
960
+ transform: translateY(-1px);
961
+ box-shadow: 0 4px 12px rgba(74, 222, 128, 0.2);
962
+ }
963
+
964
+ /* 马卡龙紫色按钮样式 */
965
+ .macaron-purple-button {
966
+ background: linear-gradient(135deg, #faf5ff, #f3e8ff);
967
+ border: 1px solid #e9d5ff;
968
+ transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
969
+ }
970
+
971
+ .macaron-purple-button:hover {
972
+ background: linear-gradient(135deg, #f3e8ff, #e9d5ff);
973
+ border-color: #c4b5fd;
974
+ transform: translateY(-2px);
975
+ box-shadow: 0 4px 15px rgba(168, 85, 247, 0.2);
976
+ }
977
+
978
+ /* 马卡龙粉色按钮样式 */
979
+ .macaron-pink-button {
980
+ background: linear-gradient(135deg, #fdf2f8, #fce7f3);
981
+ border: 1px solid #f9a8d4;
982
+ transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
983
+ }
984
+
985
+ .macaron-pink-button:hover {
986
+ background: linear-gradient(135deg, #fce7f3, #fbcfe8);
987
+ border-color: #f472b6;
988
+ transform: translateY(-2px);
989
+ box-shadow: 0 4px 15px rgba(236, 72, 153, 0.2);
990
+ }
991
+
992
+ /* 响应式优化 */
993
+ @media (max-width: 640px) {
994
+ .action-button {
995
+ min-height: 44px;
996
+ font-size: 0.875rem;
997
+ padding: 0.6rem 1rem;
998
+ display: flex;
999
+ align-items: center;
1000
+ justify-content: center;
1001
+ }
1002
+
1003
+ .container {
1004
+ padding: 0 0.5rem;
1005
+ }
1006
+
1007
+ /* 分页按钮 */
1008
+ .flex.space-x-2.items-center button {
1009
+ min-height: 40px;
1010
+ min-width: 72px;
1011
+ font-size: 0.875rem;
1012
+ }
1013
+
1014
+ /* 多选操作按钮 */
1015
+ .flex.justify-between.items-center button {
1016
+ min-height: 40px;
1017
+ padding: 0.5rem 0.875rem;
1018
+ }
1019
+
1020
+ /* 卡片内按钮 */
1021
+ .token-card button {
1022
+ min-height: 44px;
1023
+ }
1024
+ }
1025
+ </style>
public/src/views/settings.vue ADDED
@@ -0,0 +1,299 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <template>
2
+ <div class="w-full min-h-screen p-4">
3
+ <div class="container mx-auto">
4
+ <div class="flex flex-col md:flex-row justify-between items-center mb-6 px-4 space-y-4 md:space-y-0 pt-5">
5
+ <h1 class="text-3xl font-bold">系统设置</h1>
6
+ <router-link to="/"
7
+ class="action-button font-bold border border-blue-200 bg-blue-50 text-blue-900 px-4 py-2 rounded-xl shadow-sm hover:bg-blue-100 hover:border-blue-400 transition-all duration-300 transform hover:-translate-y-1 active:translate-y-0 text-center">
8
+ 返回Token管理
9
+ </router-link>
10
+ </div>
11
+ <div class="grid grid-cols-1 gap-6 p-4">
12
+ <!-- API Key 管理 -->
13
+ <div class="setting-card relative overflow-hidden rounded-2xl p-6 flex flex-col gap-4">
14
+ <div class="absolute inset-0 bg-white/30 backdrop-blur-md border border-white/30 rounded-2xl"></div>
15
+ <div class="relative flex flex-col gap-4">
16
+ <label class="text-gray-700 font-semibold text-lg">🔑 API Key 管理</label>
17
+
18
+ <!-- 管理员密钥 -->
19
+ <div class="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
20
+ <div class="flex items-center gap-2 mb-2">
21
+ <span class="text-yellow-600 font-semibold">👑 管理员密钥</span>
22
+ <span class="text-xs bg-yellow-200 text-yellow-800 px-2 py-1 rounded">不可修改</span>
23
+ </div>
24
+ <input :value="settings.adminKey" type="text" readonly
25
+ class="w-full rounded-lg border-gray-300 bg-gray-100 shadow-sm h-10 text-sm px-3 cursor-not-allowed">
26
+ </div>
27
+
28
+ <!-- 普通密钥列表 -->
29
+ <div class="space-y-2">
30
+ <div class="flex items-center justify-between">
31
+ <span class="text-gray-700 font-semibold">🔐 普通密钥</span>
32
+ <button @click="showAddKeyModal = true"
33
+ class="bg-green-500 text-white px-3 py-1 rounded-lg text-sm hover:bg-green-600 transition-all">
34
+ + 添加密钥
35
+ </button>
36
+ </div>
37
+
38
+ <div v-if="settings.regularKeys.length === 0" class="text-gray-500 text-center py-4">
39
+ 暂无普通密钥
40
+ </div>
41
+
42
+ <div v-for="(key, index) in settings.regularKeys" :key="index"
43
+ class="flex items-center gap-2 bg-gray-50 border border-gray-200 rounded-lg p-3">
44
+ <input :value="key" type="text" readonly
45
+ class="flex-1 rounded-lg border-gray-300 bg-white shadow-sm h-8 text-sm px-3">
46
+ <button @click="deleteRegularKey(index)"
47
+ class="bg-red-500 text-white px-3 py-1 rounded-lg text-sm hover:bg-red-600 transition-all">
48
+ 删除
49
+ </button>
50
+ </div>
51
+ </div>
52
+ </div>
53
+ </div>
54
+
55
+ <!-- 其他设置项 -->
56
+ <div class="grid grid-cols-1 md:grid-cols-2 gap-6">
57
+ <!-- 自动刷新 -->
58
+ <div class="setting-card relative overflow-hidden rounded-2xl p-6 flex flex-col gap-4">
59
+ <div class="absolute inset-0 bg-white/30 backdrop-blur-md border border-white/30 rounded-2xl">
60
+ </div>
61
+ <div class="relative flex flex-col gap-2">
62
+ <label class="text-gray-700 font-semibold">🔄 自动刷新</label>
63
+ <div class="flex items-center gap-2">
64
+ <input v-model="settings.autoRefresh" type="checkbox"
65
+ class="h-5 w-5 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500">
66
+ <span>启用自动刷新</span>
67
+ </div>
68
+ <label class="text-gray-700">刷新间隔(秒)</label>
69
+ <input v-model.number="settings.autoRefreshInterval" type="number"
70
+ class="mt-1 block w-full rounded-xl border-gray-300 bg-white/60 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 transition-all duration-300 h-12 text-base px-4">
71
+ <button @click="saveAutoRefresh"
72
+ class="w-full mt-2 bg-black text-white rounded-lg py-2 hover:bg-white hover:text-black border border-black transition-all duration-300">��存</button>
73
+ </div>
74
+ </div>
75
+ <!-- 思考输出 -->
76
+ <div class="setting-card relative overflow-hidden rounded-2xl p-6 flex flex-col gap-4">
77
+ <div class="absolute inset-0 bg-white/30 backdrop-blur-md border border-white/30 rounded-2xl">
78
+ </div>
79
+ <div class="relative flex flex-col gap-2">
80
+ <label class="text-gray-700 font-semibold">💡 思考输出</label>
81
+ <div class="flex items-center gap-2">
82
+ <input v-model="settings.outThink" type="checkbox"
83
+ class="h-5 w-5 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500">
84
+ <span>启用思考输出</span>
85
+ </div>
86
+ <button @click="saveOutThink"
87
+ class="w-full mt-2 bg-black text-white rounded-lg py-2 hover:bg-white hover:text-black border border-black transition-all duration-300">保存</button>
88
+ </div>
89
+ </div>
90
+ <!-- 搜索信息模式 -->
91
+ <div class="setting-card relative overflow-hidden rounded-2xl p-6 flex flex-col gap-4">
92
+ <div class="absolute inset-0 bg-white/30 backdrop-blur-md border border-white/30 rounded-2xl">
93
+ </div>
94
+ <div class="relative flex flex-col gap-2">
95
+ <label class="text-gray-700 font-semibold">🔍 搜索信息显示模式</label>
96
+ <select v-model="settings.searchInfoMode"
97
+ class="mt-1 block w-full rounded-xl border-gray-300 bg-white/60 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 transition-all duration-300 h-12 text-base px-4">
98
+ <option value="table">表格模式</option>
99
+ <option value="text">文本模式</option>
100
+ </select>
101
+ <button @click="saveSearchInfoMode"
102
+ class="w-full mt-2 bg-black text-white rounded-lg py-2 hover:bg-white hover:text-black border border-black transition-all duration-300">保存</button>
103
+ </div>
104
+ </div>
105
+ <!-- 简化模型映射 -->
106
+ <div class="setting-card relative overflow-hidden rounded-2xl p-6 flex flex-col gap-4">
107
+ <div class="absolute inset-0 bg-white/30 backdrop-blur-md border border-white/30 rounded-2xl">
108
+ </div>
109
+ <div class="relative flex flex-col gap-2">
110
+ <label class="text-gray-700 font-semibold">🎯 简化模型映射</label>
111
+ <div class="flex items-center gap-2">
112
+ <input v-model="settings.simpleModelMap" type="checkbox"
113
+ class="h-5 w-5 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500">
114
+ <span>只返回基础模型,不包含thinking、search、image等变体</span>
115
+ </div>
116
+ <button @click="saveSimpleModelMap"
117
+ class="w-full mt-2 bg-black text-white rounded-lg py-2 hover:bg-white hover:text-black border border-black transition-all duration-300">保存</button>
118
+ </div>
119
+ </div>
120
+ </div>
121
+ </div>
122
+
123
+ <!-- 添加API Key模态框 -->
124
+ <div v-if="showAddKeyModal"
125
+ class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
126
+ <div class="bg-white rounded-lg p-6 w-96 max-w-90vw">
127
+ <h3 class="text-lg font-semibold mb-4">添加普通API Key</h3>
128
+ <input v-model="newApiKey" type="text" placeholder="请输入API Key"
129
+ class="w-full rounded-lg border-gray-300 shadow-sm h-10 text-sm px-3 mb-4">
130
+ <div class="flex gap-2 justify-end">
131
+ <button @click="showAddKeyModal = false"
132
+ class="px-4 py-2 bg-gray-300 text-gray-700 rounded-lg hover:bg-gray-400 transition-all">
133
+ 取消
134
+ </button>
135
+ <button @click="addRegularKey"
136
+ class="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-all">
137
+ 添加
138
+ </button>
139
+ </div>
140
+ </div>
141
+ </div>
142
+ </div>
143
+ </div>
144
+ </template>
145
+
146
+ <script setup>
147
+ import { ref, onMounted } from 'vue'
148
+ import axios from 'axios'
149
+
150
+ const settings = ref({
151
+ apiKey: localStorage.getItem('apiKey'),
152
+ adminKey: '',
153
+ regularKeys: [],
154
+ defaultHeaders: '',
155
+ defaultCookie: '',
156
+ autoRefresh: false,
157
+ autoRefreshInterval: 21600,
158
+ outThink: false,
159
+ searchInfoMode: 'table',
160
+ simpleModelMap: false
161
+ })
162
+
163
+ const showAddKeyModal = ref(false)
164
+ const newApiKey = ref('')
165
+
166
+ const loadSettings = async () => {
167
+ try {
168
+ const res = await axios.get('/api/settings', {
169
+ headers: {
170
+ 'Authorization': localStorage.getItem('apiKey')
171
+ }
172
+ })
173
+ settings.value.apiKey = res.data.apiKey
174
+ settings.value.adminKey = res.data.adminKey || ''
175
+ settings.value.regularKeys = res.data.regularKeys || []
176
+ settings.value.defaultHeaders = JSON.stringify(res.data.defaultHeaders)
177
+ settings.value.defaultCookie = res.data.defaultCookie
178
+ settings.value.autoRefresh = res.data.autoRefresh
179
+ settings.value.autoRefreshInterval = res.data.autoRefreshInterval
180
+ settings.value.outThink = res.data.outThink
181
+ settings.value.searchInfoMode = res.data.searchInfoMode
182
+ settings.value.simpleModelMap = res.data.simpleModelMap
183
+ } catch (error) {
184
+ console.error('加载设置失败:', error)
185
+ }
186
+ }
187
+
188
+ const saveApiKey = async () => {
189
+ try {
190
+ await axios.post('/api/setApiKey', { apiKey: settings.value.apiKey }, {
191
+ headers: { 'Authorization': localStorage.getItem('apiKey') || '' }
192
+ })
193
+ alert('API Key保存成功')
194
+ } catch (error) {
195
+ alert('API Key保存失败: ' + error.message)
196
+ }
197
+ }
198
+ const saveAutoRefresh = async () => {
199
+ try {
200
+ await axios.post('/api/setAutoRefresh', {
201
+ autoRefresh: settings.value.autoRefresh,
202
+ autoRefreshInterval: settings.value.autoRefreshInterval
203
+ }, {
204
+ headers: { 'Authorization': localStorage.getItem('apiKey') || '' }
205
+ })
206
+ alert('自动刷新设置保存成功')
207
+ } catch (error) {
208
+ alert('自动刷新设置保存失败: ' + error.message)
209
+ }
210
+ }
211
+ const saveOutThink = async () => {
212
+ try {
213
+ await axios.post('/api/setOutThink', { outThink: settings.value.outThink }, {
214
+ headers: { 'Authorization': localStorage.getItem('apiKey') || '' }
215
+ })
216
+ alert('思考输出设置保存成功')
217
+ } catch (error) {
218
+ alert('思考输出设置保存失败: ' + error.message)
219
+ }
220
+ }
221
+ const saveSearchInfoMode = async () => {
222
+ try {
223
+ await axios.post('/api/search-info-mode', { searchInfoMode: settings.value.searchInfoMode }, {
224
+ headers: { 'Authorization': localStorage.getItem('apiKey') || '' }
225
+ })
226
+ alert('搜索信息模式保存成功')
227
+ } catch (error) {
228
+ alert('搜索信息模式保存失败: ' + error.message)
229
+ }
230
+ }
231
+ const saveSimpleModelMap = async () => {
232
+ try {
233
+ await axios.post('/api/simple-model-map', { simpleModelMap: settings.value.simpleModelMap }, {
234
+ headers: { 'Authorization': localStorage.getItem('apiKey') || '' }
235
+ })
236
+ alert('简化模型映射设置保存成功')
237
+ } catch (error) {
238
+ alert('简化模型映射设置保存失败: ' + error.message)
239
+ }
240
+ }
241
+
242
+ // API Key 管理相关函数
243
+ const addRegularKey = async () => {
244
+ if (!newApiKey.value.trim()) {
245
+ alert('请输入API Key')
246
+ return
247
+ }
248
+
249
+ try {
250
+ await axios.post('/api/addRegularKey', { apiKey: newApiKey.value.trim() }, {
251
+ headers: { 'Authorization': localStorage.getItem('apiKey') || '' }
252
+ })
253
+ alert('API Key添加成功')
254
+ newApiKey.value = ''
255
+ showAddKeyModal.value = false
256
+ await loadSettings()
257
+ } catch (error) {
258
+ alert('API Key添加失败: ' + error.message)
259
+ }
260
+ }
261
+
262
+ const deleteRegularKey = async (index) => {
263
+ if (!confirm('确定要删除此API Key吗?')) return
264
+
265
+ const keyToDelete = settings.value.regularKeys[index]
266
+ try {
267
+ await axios.post('/api/deleteRegularKey', { apiKey: keyToDelete }, {
268
+ headers: { 'Authorization': localStorage.getItem('apiKey') || '' }
269
+ })
270
+ alert('API Key删除成功')
271
+ await loadSettings()
272
+ } catch (error) {
273
+ alert('API Key删除失败: ' + error.message)
274
+ }
275
+ }
276
+
277
+ onMounted(() => {
278
+ loadSettings()
279
+ })
280
+ </script>
281
+
282
+ <style lang="css" scoped>
283
+ .setting-card {
284
+ background: linear-gradient(135deg, rgba(255, 255, 255, 0.7), rgba(255, 255, 255, 0.3));
285
+ box-shadow: 0 8px 32px 0 rgba(31, 38, 135, 0.10);
286
+ transition: box-shadow 0.3s, transform 0.3s;
287
+ position: relative;
288
+ }
289
+
290
+ .setting-card:hover {
291
+ box-shadow: 0 12px 36px 0 rgba(31, 38, 135, 0.18);
292
+ transform: translateY(-2px) scale(1.01);
293
+ }
294
+
295
+ .action-button {
296
+ backdrop-filter: blur(4px);
297
+ -webkit-backdrop-filter: blur(4px);
298
+ }
299
+ </style>
public/tailwind.config.js ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /** @type {import('tailwindcss').Config} */
2
+ export default {
3
+ content: [
4
+ "./index.html",
5
+ "./src/**/*.{vue,js,ts,jsx,tsx}",
6
+ ],
7
+ theme: {
8
+ extend: {},
9
+ },
10
+ plugins: [],
11
+ }
public/vite.config.js ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { defineConfig } from 'vite'
2
+ import vue from '@vitejs/plugin-vue'
3
+
4
+ // https://vitejs.dev/config/
5
+ export default defineConfig({
6
+ plugins: [vue()],
7
+ server: {
8
+ proxy: {
9
+ '/': {
10
+ target: 'http://localhost:4000', // 实际后端地址
11
+ changeOrigin: true,
12
+ }
13
+ }
14
+ }
15
+ })
scripts/fingerprint-injector.js ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ (function() {
2
+ // 拦截 String.prototype.charAt
3
+ const originalCharAt = String.prototype.charAt;
4
+ let capturedData = null;
5
+
6
+ String.prototype.charAt = function(index) {
7
+ if (this.length > 200 && this.includes('^') && !capturedData) {
8
+ const fields = this.split('^');
9
+ if (fields.length === 37) {
10
+ capturedData = this.toString();
11
+ console.log('\n=== 检测到浏览器指纹 ===');
12
+ console.log(capturedData);
13
+
14
+ // 恢复原方法
15
+ String.prototype.charAt = originalCharAt;
16
+ }
17
+ }
18
+ return originalCharAt.call(this, index);
19
+ };
20
+ })();
scripts/hf-bucket-sync.py ADDED
@@ -0,0 +1,151 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import sys
3
+ import time
4
+ import threading
5
+ from pathlib import Path
6
+
7
+ from huggingface_hub import sync_bucket
8
+ from watchdog.events import FileSystemEventHandler
9
+ from watchdog.observers import Observer
10
+
11
+
12
+ BUCKET_REPO = os.environ.get("HF_BUCKET_REPO", "").strip()
13
+ BUCKET_TOKEN = (
14
+ os.environ.get("HF_BUCKET_TOKEN", "").strip()
15
+ or os.environ.get("HF_TOKEN", "").strip()
16
+ )
17
+ LOCAL_ROOT = Path(os.environ.get("HF_BUCKET_LOCAL_DIR", "/data/qwen2api")).resolve()
18
+ REMOTE_ROOT = os.environ.get("HF_BUCKET_REMOTE_DIR", "runtime").strip("/")
19
+ SYNC_INTERVAL = int(os.environ.get("HF_BUCKET_SYNC_INTERVAL", "300"))
20
+ STARTUP_GRACE_SECONDS = int(os.environ.get("HF_BUCKET_STARTUP_GRACE_SECONDS", "30"))
21
+ SYNC_DEBOUNCE_SECONDS = int(os.environ.get("HF_BUCKET_SYNC_DEBOUNCE_SECONDS", "5"))
22
+
23
+
24
+ def log(message: str) -> None:
25
+ print(f"[hf-bucket] {message}", flush=True)
26
+
27
+
28
+ def bucket_path() -> str:
29
+ if REMOTE_ROOT:
30
+ return f"hf://buckets/{BUCKET_REPO}/{REMOTE_ROOT}"
31
+ return f"hf://buckets/{BUCKET_REPO}"
32
+
33
+
34
+ def ensure_local_root() -> None:
35
+ LOCAL_ROOT.mkdir(parents=True, exist_ok=True)
36
+
37
+
38
+ def can_use_bucket() -> bool:
39
+ if not BUCKET_REPO:
40
+ log("skip: HF_BUCKET_REPO 未设置")
41
+ return False
42
+ if not BUCKET_TOKEN:
43
+ log("skip: HF_TOKEN 或 HF_BUCKET_TOKEN 未设置")
44
+ return False
45
+ return True
46
+
47
+
48
+ def restore() -> None:
49
+ if not can_use_bucket():
50
+ return
51
+
52
+ ensure_local_root()
53
+ try:
54
+ sync_bucket(bucket_path(), str(LOCAL_ROOT), token=BUCKET_TOKEN)
55
+ log(f"restore 完成: {bucket_path()} -> {LOCAL_ROOT}")
56
+ except Exception as exc:
57
+ log(f"restore 失败: {exc}")
58
+
59
+
60
+ def push() -> None:
61
+ if not can_use_bucket():
62
+ return
63
+
64
+ ensure_local_root()
65
+ try:
66
+ sync_bucket(str(LOCAL_ROOT), bucket_path(), token=BUCKET_TOKEN, delete=False)
67
+ log(f"sync 完成: {LOCAL_ROOT} -> {bucket_path()}")
68
+ except Exception as exc:
69
+ log(f"sync 失败: {exc}")
70
+
71
+
72
+ def daemon() -> None:
73
+ if not can_use_bucket():
74
+ return
75
+
76
+ class ChangeHandler(FileSystemEventHandler):
77
+ def __init__(self) -> None:
78
+ self._timer = None
79
+ self._lock = threading.Lock()
80
+ self._startup_time = time.time()
81
+
82
+ def _schedule(self) -> None:
83
+ if time.time() - self._startup_time < STARTUP_GRACE_SECONDS:
84
+ return
85
+
86
+ with self._lock:
87
+ if self._timer is not None:
88
+ self._timer.cancel()
89
+ self._timer = threading.Timer(SYNC_DEBOUNCE_SECONDS, push)
90
+ self._timer.daemon = True
91
+ self._timer.start()
92
+
93
+ def on_created(self, event):
94
+ if not event.is_directory:
95
+ self._schedule()
96
+
97
+ def on_modified(self, event):
98
+ if not event.is_directory:
99
+ self._schedule()
100
+
101
+ def on_deleted(self, event):
102
+ if not event.is_directory:
103
+ self._schedule()
104
+
105
+ def on_moved(self, event):
106
+ if not event.is_directory:
107
+ self._schedule()
108
+
109
+ observer = Observer()
110
+ observer.schedule(ChangeHandler(), str(LOCAL_ROOT), recursive=True)
111
+ observer.start()
112
+
113
+ log(
114
+ f"后台同步已启动,监听目录 {LOCAL_ROOT},即时同步防抖 {SYNC_DEBOUNCE_SECONDS} 秒,定时兜底 {SYNC_INTERVAL} 秒"
115
+ )
116
+
117
+ def periodic_sync() -> None:
118
+ while True:
119
+ time.sleep(SYNC_INTERVAL)
120
+ push()
121
+
122
+ periodic_thread = threading.Thread(target=periodic_sync, daemon=True)
123
+ periodic_thread.start()
124
+
125
+ try:
126
+ while True:
127
+ time.sleep(1)
128
+ except KeyboardInterrupt:
129
+ observer.stop()
130
+ observer.join()
131
+
132
+
133
+ def main() -> int:
134
+ command = sys.argv[1] if len(sys.argv) > 1 else "daemon"
135
+
136
+ if command == "restore":
137
+ restore()
138
+ return 0
139
+ if command == "push":
140
+ push()
141
+ return 0
142
+ if command == "daemon":
143
+ daemon()
144
+ return 0
145
+
146
+ log(f"未知命令: {command}")
147
+ return 1
148
+
149
+
150
+ if __name__ == "__main__":
151
+ raise SystemExit(main())
src/config/index.js ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const dotenv = require('dotenv')
2
+ dotenv.config()
3
+ const paths = require('../utils/paths')
4
+
5
+ /**
6
+ * 解析API_KEY环境变量,支持逗号分隔的多个key
7
+ * @returns {Object} 包含apiKeys数组和adminKey的对象
8
+ */
9
+ const parseApiKeys = () => {
10
+ const apiKeyEnv = process.env.API_KEY
11
+ if (!apiKeyEnv) {
12
+ return { apiKeys: [], adminKey: null }
13
+ }
14
+
15
+ const keys = apiKeyEnv.split(',').map(key => key.trim()).filter(key => key.length > 0)
16
+ return {
17
+ apiKeys: keys,
18
+ adminKey: keys.length > 0 ? keys[0] : null
19
+ }
20
+ }
21
+
22
+ const { apiKeys, adminKey } = parseApiKeys()
23
+
24
+ const config = {
25
+ dataSaveMode: process.env.DATA_SAVE_MODE || "none",
26
+ apiKeys: apiKeys,
27
+ adminKey: adminKey,
28
+ simpleModelMap: process.env.SIMPLE_MODEL_MAP === 'true' ? true : false,
29
+ listenAddress: process.env.LISTEN_ADDRESS || null,
30
+ listenPort: process.env.SERVICE_PORT || 3000,
31
+ searchInfoMode: process.env.SEARCH_INFO_MODE === 'table' ? "table" : "text",
32
+ outThink: process.env.OUTPUT_THINK === 'true' ? true : false,
33
+ redisURL: process.env.REDIS_URL || null,
34
+ autoRefresh: true,
35
+ autoRefreshInterval: 6 * 60 * 60,
36
+ cacheMode: process.env.CACHE_MODE || "default",
37
+ logLevel: process.env.LOG_LEVEL || "INFO",
38
+ enableFileLog: process.env.ENABLE_FILE_LOG === 'true',
39
+ logDir: paths.logDir,
40
+ maxLogFileSize: parseInt(process.env.MAX_LOG_FILE_SIZE) || 10,
41
+ maxLogFiles: parseInt(process.env.MAX_LOG_FILES) || 5,
42
+ dataDir: paths.dataDir,
43
+ cacheDir: paths.cacheDir,
44
+ dataFilePath: paths.dataFilePath,
45
+ // 自定义反代URL配置
46
+ qwenChatProxyUrl: process.env.QWEN_CHAT_PROXY_URL || "https://chat.qwen.ai",
47
+ qwenCliProxyUrl: process.env.QWEN_CLI_PROXY_URL || "https://portal.qwen.ai",
48
+ // 代理配置
49
+ proxyUrl: process.env.PROXY_URL || null
50
+ }
51
+
52
+ module.exports = config
src/controllers/chat.image.video.js ADDED
@@ -0,0 +1,357 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const axios = require('axios')
2
+ const { logger } = require('../utils/logger.js')
3
+ const { setResponseHeaders } = require('./chat.js')
4
+ const accountManager = require('../utils/account.js')
5
+ const { sleep } = require('../utils/tools.js')
6
+ const { generateChatID } = require('../utils/request.js')
7
+ const { getSsxmodItna, getSsxmodItna2 } = require('../utils/ssxmod-manager')
8
+ const { getProxyAgent, getChatBaseUrl, applyProxyToAxiosConfig } = require('../utils/proxy-helper')
9
+
10
+ /**
11
+ * 主要的聊天完成处理函数
12
+ * @param {object} req - Express 请求对象
13
+ * @param {object} res - Express 响应对象
14
+ */
15
+ const handleImageVideoCompletion = async (req, res) => {
16
+ const { model, messages, size, chat_type } = req.body
17
+ // console.log(JSON.stringify(req.body.messages.filter(item => item.role == "user" || item.role == "assistant")))
18
+ const token = accountManager.getAccountToken()
19
+
20
+ try {
21
+
22
+ // 请求体模板
23
+ const reqBody = {
24
+ "stream": false,
25
+ "chat_id": null,
26
+ "model": model,
27
+ "messages": [
28
+ {
29
+ "role": "user",
30
+ "content": "",
31
+ "files": [],
32
+ "chat_type": chat_type,
33
+ "feature_config": {
34
+ "output_schema": "phase"
35
+ }
36
+ }
37
+ ]
38
+ }
39
+
40
+ const chat_id = await generateChatID(token, model)
41
+
42
+ if (!chat_id) {
43
+ // 如果生成chat_id失败,则返回错误
44
+ throw new Error()
45
+ } else {
46
+ reqBody.chat_id = chat_id
47
+ }
48
+
49
+ // 拿到用户最后一句消息
50
+ const _userPrompt = messages[messages.length - 1].content
51
+ if (!_userPrompt) {
52
+ throw new Error()
53
+ }
54
+
55
+ // 提取历史消息
56
+ const messagesHistory = messages.filter(item => item.role == "user" || item.role == "assistant")
57
+ // 聊天消息中所有图片url
58
+ const select_image_list = []
59
+
60
+ // 遍历模型回复消息,拿到所有图片
61
+ if (chat_type == "image_edit") {
62
+ for (const item of messagesHistory) {
63
+ if (item.role == "assistant") {
64
+ // 使用matchAll提取所有图片链接
65
+ const matches = [...item.content.matchAll(/!\[image\]\((.*?)\)/g)]
66
+ // 将所有匹配到的图片url添加到图片列表
67
+ for (const match of matches) {
68
+ select_image_list.push(match[1])
69
+ }
70
+ } else {
71
+ if (Array.isArray(item.content) && item.content.length > 0) {
72
+ for (const content of item.content) {
73
+ if (content.type == "image") {
74
+ select_image_list.push(content.image)
75
+ }
76
+ }
77
+ }
78
+ }
79
+ }
80
+ }
81
+
82
+ //分情况处理
83
+ if (chat_type == 't2i' || chat_type == 't2v') {
84
+ if (Array.isArray(_userPrompt)) {
85
+ reqBody.messages[0].content = _userPrompt.map(item => item.type == "text" ? item.text : "").join("\n\n")
86
+ } else {
87
+ reqBody.messages[0].content = _userPrompt
88
+ }
89
+ } else if (chat_type == 'image_edit') {
90
+ if (!Array.isArray(_userPrompt)) {
91
+
92
+ if (messagesHistory.length === 1) {
93
+ reqBody.messages[0].chat_type = "t2i"
94
+ } else if (select_image_list.length >= 1) {
95
+ reqBody.messages[0].files.push({
96
+ "type": "image",
97
+ "url": select_image_list[select_image_list.length - 1]
98
+ })
99
+ }
100
+ reqBody.messages[0].content += _userPrompt
101
+ } else {
102
+ const texts = _userPrompt.filter(item => item.type == "text")
103
+ if (texts.length === 0) {
104
+ throw new Error()
105
+ }
106
+ // 拼接提示词
107
+ for (const item of texts) {
108
+ reqBody.messages[0].content += item.text
109
+ }
110
+
111
+ const files = _userPrompt.filter(item => item.type == "image")
112
+ // 如果图片为空,则设置为t2i
113
+ if (files.length === 0) {
114
+ reqBody.messages[0].chat_type = "t2i"
115
+ }
116
+ // 遍历图片
117
+ for (const item of files) {
118
+ reqBody.messages[0].files.push({
119
+ "type": "image",
120
+ "url": item.image
121
+ })
122
+ }
123
+
124
+ }
125
+ }
126
+
127
+
128
+ // 处理图片视频尺寸
129
+ if (chat_type == 't2i' || chat_type == 't2v') {
130
+ // 获取图片尺寸,优先级 参数 > 提示词 > 默认
131
+ if (size != undefined && size != null) {
132
+ reqBody.size = "1:1"
133
+ } else if (_userPrompt.indexOf("@4:3") != -1) {
134
+ reqBody.size = "4:3"//"1024*768"
135
+ } else if (_userPrompt.indexOf("@3:4") != -1) {
136
+ reqBody.size = "3:4"//"768*1024"
137
+ } else if (_userPrompt.indexOf("@16:9") != -1) {
138
+ reqBody.size = "16:9"//"1280*720"
139
+ } else if (_userPrompt.indexOf("@9:16") != -1) {
140
+ reqBody.size = "9:16"//"720*1280"
141
+ }
142
+ }
143
+
144
+ const chatBaseUrl = getChatBaseUrl()
145
+ const proxyAgent = getProxyAgent()
146
+
147
+ logger.info('发送图片视频请求', 'CHAT')
148
+ logger.info(`选择图片: ${select_image_list[select_image_list.length - 1] || "未选择图片,切换生成图/视频模式"}`, 'CHAT')
149
+ logger.info(`使用提示: ${reqBody.messages[0].content}`, 'CHAT')
150
+ // console.log(JSON.stringify(reqBody))
151
+ const newChatType = reqBody.messages[0].chat_type
152
+
153
+ const requestConfig = {
154
+ headers: {
155
+ 'Authorization': `Bearer ${token}`,
156
+ "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",
157
+ "Connection": "keep-alive",
158
+ "Accept": "application/json",
159
+ "Accept-Encoding": "gzip, deflate, br, zstd",
160
+ "Content-Type": "application/json",
161
+ "Timezone": "Mon Dec 08 2025 17:28:55 GMT+0800",
162
+ "sec-ch-ua": "\"Microsoft Edge\";v=\"143\", \"Chromium\";v=\"143\", \"Not A(Brand\";v=\"24\"",
163
+ "source": "web",
164
+ "Version": "0.1.13",
165
+ "bx-v": "2.5.31",
166
+ "Origin": chatBaseUrl,
167
+ "Sec-Fetch-Site": "same-origin",
168
+ "Sec-Fetch-Mode": "cors",
169
+ "Sec-Fetch-Dest": "empty",
170
+ "Referer": `${chatBaseUrl}/c/guest`,
171
+ "Accept-Language": "zh-CN,zh;q=0.9,en-US;q=0.8,en;q=0.7",
172
+ "Cookie": `ssxmod_itna=${getSsxmodItna()};ssxmod_itna2=${getSsxmodItna2()}`,
173
+ },
174
+ responseType: newChatType == 't2i' ? 'stream' : 'json',
175
+ timeout: 1000 * 60 * 5
176
+ }
177
+
178
+ // 添加代理配置
179
+ if (proxyAgent) {
180
+ requestConfig.httpsAgent = proxyAgent
181
+ requestConfig.proxy = false
182
+ }
183
+
184
+ const response_data = await axios.post(`${chatBaseUrl}/api/v2/chat/completions?chat_id=${chat_id}`, reqBody, requestConfig)
185
+
186
+ try {
187
+ let contentUrl = null
188
+ if (newChatType == 't2i') {
189
+ const decoder = new TextDecoder('utf-8')
190
+ response_data.data.on('data', async (chunk) => {
191
+ const data = decoder.decode(chunk, { stream: true }).split('\n').filter(item => item.trim() != "")
192
+ console.log(data)
193
+ for (const item of data) {
194
+ const jsonObj = JSON.parse(item.replace("data:", '').trim())
195
+ if (jsonObj && jsonObj.choices && jsonObj.choices[0] && jsonObj.choices[0].delta && jsonObj.choices[0].delta.content.trim() != "" && contentUrl == null) {
196
+ contentUrl = jsonObj.choices[0].delta.content
197
+ }
198
+ }
199
+ })
200
+
201
+ response_data.data.on('end', () => {
202
+ return returnResponse(res, model, contentUrl, req.body.stream)
203
+ })
204
+ } else if (newChatType == 'image_edit') {
205
+ console.log(response_data.data)
206
+ contentUrl = response_data.data?.data?.choices[0]?.message?.content[0]?.image
207
+ return returnResponse(res, model, contentUrl, req.body.stream)
208
+ } else if (newChatType == 't2v') {
209
+ return handleVideoCompletion(req, res, response_data.data, token)
210
+ }
211
+
212
+ } catch (error) {
213
+ logger.error('图片处理错误', 'CHAT', error)
214
+ res.status(500).json({ error: "服务错误!!!" })
215
+ }
216
+
217
+ } catch (error) {
218
+ res.status(500).json({
219
+ error: "服务错误,请稍后再试"
220
+ })
221
+ }
222
+ }
223
+
224
+ /**
225
+ * 返回响应
226
+ * @param {*} res
227
+ * @param {*} model
228
+ * @param {*} contentUrl
229
+ */
230
+ const returnResponse = (res, model, contentUrl, stream) => {
231
+ setResponseHeaders(res, stream)
232
+ logger.info(`返回响应: ${contentUrl}`, 'CHAT')
233
+
234
+ const returnBody = {
235
+ "created": new Date().getTime(),
236
+ "model": model,
237
+ "choices": [
238
+ {
239
+ "index": 0,
240
+ "message": {
241
+ "role": "assistant",
242
+ "content": `![image](${contentUrl})`
243
+ }
244
+ }
245
+ ]
246
+ }
247
+
248
+ if (stream) {
249
+ res.write(`data: ${JSON.stringify(returnBody)}\n\n`)
250
+ res.write(`data: [DONE]\n\n`)
251
+ res.end()
252
+ } else {
253
+ res.json(returnBody)
254
+ }
255
+ }
256
+
257
+ const handleVideoCompletion = async (req, res, response_data, token) => {
258
+ try {
259
+ const videoTaskID = response_data?.data?.messages[0]?.extra?.wanx?.task_id
260
+ if (!response_data || !response_data.success || !videoTaskID) {
261
+ throw new Error()
262
+ }
263
+
264
+ logger.info(`视频任务ID: ${videoTaskID}`, 'CHAT')
265
+ const returnBody = {
266
+ "id": `chatcmpl-${new Date().getTime()}`,
267
+ "object": "chat.completion.chunk",
268
+ "created": new Date().getTime(),
269
+ "model": response_data.data.model,
270
+ "choices": [
271
+ {
272
+ "index": 0,
273
+ "message": {
274
+ "role": "assistant",
275
+ "content": ""
276
+ },
277
+ "finish_reason": null
278
+ }
279
+ ]
280
+ }
281
+
282
+ // 设置尝试次数
283
+ const maxAttempts = 60
284
+ // 设置每次请求的间隔时间
285
+ const delay = 20 * 1000
286
+ // 循环尝试获取任务状态
287
+ for (let i = 0; i < maxAttempts; i++) {
288
+ const content = await getVideoTaskStatus(videoTaskID, token)
289
+ if (content) {
290
+ returnBody.choices[0].message.content = `
291
+ <video controls = "controls">
292
+ ${content}
293
+ </video>
294
+
295
+ [Download Video](${content})
296
+ `
297
+ // 设置响应头
298
+ setResponseHeaders(res, req.body.stream)
299
+
300
+ if (req.body.stream) {
301
+ res.write(`data: ${JSON.stringify(returnBody)}\n\n`)
302
+ res.write(`data: [DONE]\n\n`)
303
+ res.end()
304
+ } else {
305
+ res.json(returnBody)
306
+ }
307
+ return
308
+ } else if (content == null && req.body.stream) {
309
+ // 发送空数据保活
310
+ res.write(`data: ${JSON.stringify(returnBody)}\n\n`)
311
+ }
312
+
313
+ await sleep(delay)
314
+ }
315
+ } catch (error) {
316
+ logger.error('获取视频任务状态失败', 'CHAT', error)
317
+ res.status(500).json({ error: error.response_data?.data?.code || "可能该帐号今日生成次数已用完" })
318
+ }
319
+ }
320
+
321
+ const getVideoTaskStatus = async (videoTaskID, token) => {
322
+ try {
323
+ const chatBaseUrl = getChatBaseUrl()
324
+ const proxyAgent = getProxyAgent()
325
+
326
+ const requestConfig = {
327
+ headers: {
328
+ "Authorization": `Bearer ${token}`,
329
+ 'Content-Type': 'application/json',
330
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
331
+ ...(getSsxmodItna() && { 'Cookie': `ssxmod_itna=${getSsxmodItna()};ssxmod_itna2=${getSsxmodItna2()}` })
332
+ }
333
+ }
334
+
335
+ // 添加代理配置
336
+ if (proxyAgent) {
337
+ requestConfig.httpsAgent = proxyAgent
338
+ requestConfig.proxy = false
339
+ }
340
+
341
+ const response_data = await axios.get(`${chatBaseUrl}/api/v1/tasks/status/${videoTaskID}`, requestConfig)
342
+
343
+ if (response_data.data?.task_status == "success") {
344
+ logger.info('获取视频任务状态成功', 'CHAT', response_data.data?.content)
345
+ return response_data.data?.content
346
+ }
347
+ logger.info(`获取视频任务 ${videoTaskID} 状态: ${response_data.data?.task_status}`, 'CHAT')
348
+ return null
349
+ } catch (error) {
350
+ console.log(error.response.data)
351
+ return null
352
+ }
353
+ }
354
+
355
+ module.exports = {
356
+ handleImageVideoCompletion
357
+ }
src/controllers/chat.js ADDED
@@ -0,0 +1,449 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const { isJson, generateUUID } = require('../utils/tools.js')
2
+ const { createUsageObject } = require('../utils/precise-tokenizer.js')
3
+ const { sendChatRequest } = require('../utils/request.js')
4
+ const accountManager = require('../utils/account.js')
5
+ const config = require('../config/index.js')
6
+ const axios = require('axios')
7
+ const { logger } = require('../utils/logger')
8
+
9
+ /**
10
+ * 设置响应头
11
+ * @param {object} res - Express 响应对象
12
+ * @param {boolean} stream - 是否流式响应
13
+ */
14
+ const setResponseHeaders = (res, stream) => {
15
+ try {
16
+ if (stream) {
17
+ res.set({
18
+ 'Content-Type': 'text/event-stream',
19
+ 'Cache-Control': 'no-cache',
20
+ 'Connection': 'keep-alive',
21
+ })
22
+ } else {
23
+ res.set({
24
+ 'Content-Type': 'application/json',
25
+ })
26
+ }
27
+ } catch (e) {
28
+ logger.error('处理聊天请求时发生错误', 'CHAT', '', e)
29
+ }
30
+ }
31
+
32
+ /**
33
+ * 处理流式响应
34
+ * @param {object} res - Express 响应对象
35
+ * @param {object} response - 上游响应流
36
+ * @param {boolean} enable_thinking - 是否启用思考模式
37
+ * @param {boolean} enable_web_search - 是否启用网络搜索
38
+ * @param {object} requestBody - 原始请求体,用于提取prompt信息
39
+ */
40
+ const handleStreamResponse = async (res, response, enable_thinking, enable_web_search, requestBody = null) => {
41
+ try {
42
+ const message_id = generateUUID()
43
+ const decoder = new TextDecoder('utf-8')
44
+ let web_search_info = null
45
+ let thinking_start = false
46
+ let thinking_end = false
47
+ let buffer = ''
48
+
49
+ // Token消耗量统计
50
+ let totalTokens = {
51
+ prompt_tokens: 0,
52
+ completion_tokens: 0,
53
+ total_tokens: 0
54
+ }
55
+ let completionContent = '' // 收集完整的回复内容用于token估算
56
+
57
+ // 提取prompt文本用于token估算
58
+ let promptText = ''
59
+ if (requestBody && requestBody.messages) {
60
+ promptText = requestBody.messages.map(msg => {
61
+ if (typeof msg.content === 'string') {
62
+ return msg.content
63
+ } else if (Array.isArray(msg.content)) {
64
+ return msg.content.map(item => item.text || '').join('')
65
+ }
66
+ return ''
67
+ }).join('\n')
68
+ }
69
+
70
+ response.on('data', async (chunk) => {
71
+ const decodeText = decoder.decode(chunk, { stream: true })
72
+ // console.log(decodeText)
73
+ buffer += decodeText
74
+
75
+ const chunks = []
76
+ let startIndex = 0
77
+
78
+ while (true) {
79
+ const dataStart = buffer.indexOf('data: ', startIndex)
80
+ if (dataStart === -1) break
81
+
82
+ const dataEnd = buffer.indexOf('\n\n', dataStart)
83
+ if (dataEnd === -1) break
84
+
85
+ const dataChunk = buffer.substring(dataStart, dataEnd).trim()
86
+ chunks.push(dataChunk)
87
+
88
+ startIndex = dataEnd + 2
89
+ }
90
+
91
+ if (startIndex > 0) {
92
+ buffer = buffer.substring(startIndex)
93
+ }
94
+
95
+ for (const item of chunks) {
96
+ try {
97
+ let dataContent = item.replace("data: ", '')
98
+ let decodeJson = isJson(dataContent) ? JSON.parse(dataContent) : null
99
+ if (decodeJson === null || !decodeJson.choices || decodeJson.choices.length === 0) {
100
+ continue
101
+ }
102
+
103
+ // 提取真实的usage信息(如果上游API提供)
104
+ if (decodeJson.usage) {
105
+ totalTokens = {
106
+ prompt_tokens: decodeJson.usage.prompt_tokens || totalTokens.prompt_tokens,
107
+ completion_tokens: decodeJson.usage.completion_tokens || totalTokens.completion_tokens,
108
+ total_tokens: decodeJson.usage.total_tokens || totalTokens.total_tokens
109
+ }
110
+ }
111
+
112
+ // 处理 web_search 信息
113
+ if (decodeJson.choices[0].delta && decodeJson.choices[0].delta.name === 'web_search') {
114
+ web_search_info = decodeJson.choices[0].delta.extra.web_search_info
115
+ }
116
+
117
+ if (!decodeJson.choices[0].delta || !decodeJson.choices[0].delta.content ||
118
+ (decodeJson.choices[0].delta.phase !== 'think' && decodeJson.choices[0].delta.phase !== 'answer')) {
119
+ continue
120
+ }
121
+
122
+ let content = decodeJson.choices[0].delta.content
123
+ completionContent += content // 累计完整内容用于token估算
124
+
125
+ if (decodeJson.choices[0].delta.phase === 'think' && !thinking_start) {
126
+ thinking_start = true
127
+ if (web_search_info) {
128
+ content = `<think>\n\n${await accountManager.generateMarkdownTable(web_search_info, config.searchInfoMode)}\n\n${content}`
129
+ } else {
130
+ content = `<think>\n\n${content}`
131
+ }
132
+ }
133
+ if (decodeJson.choices[0].delta.phase === 'answer' && !thinking_end && thinking_start) {
134
+ thinking_end = true
135
+ content = `\n\n</think>\n${content}`
136
+ }
137
+
138
+ const StreamTemplate = {
139
+ "id": `chatcmpl-${message_id}`,
140
+ "object": "chat.completion.chunk",
141
+ "created": new Date().getTime(),
142
+ "choices": [
143
+ {
144
+ "index": 0,
145
+ "delta": {
146
+ "content": content
147
+ },
148
+ "finish_reason": null
149
+ }
150
+ ]
151
+ }
152
+
153
+ res.write(`data: ${JSON.stringify(StreamTemplate)}\n\n`)
154
+ } catch (error) {
155
+ logger.error('流式数据处理错误', 'CHAT', '', error)
156
+ res.status(500).json({ error: "服务错误!!!" })
157
+ }
158
+ }
159
+ })
160
+
161
+ response.on('end', async () => {
162
+ try {
163
+ // 处理最终的搜索信息
164
+ if ((config.outThink === false || !enable_thinking) && web_search_info && config.searchInfoMode === "text") {
165
+ const webSearchTable = await accountManager.generateMarkdownTable(web_search_info, "text")
166
+ res.write(`data: ${JSON.stringify({
167
+ "id": `chatcmpl-${message_id}`,
168
+ "object": "chat.completion.chunk",
169
+ "created": new Date().getTime(),
170
+ "choices": [
171
+ {
172
+ "index": 0,
173
+ "delta": {
174
+ "content": `\n\n---\n${webSearchTable}`
175
+ },
176
+ "finish_reason": null
177
+ }
178
+ ]
179
+ })}\n\n`)
180
+ }
181
+
182
+ // 计算最终的token使用量
183
+ if (totalTokens.prompt_tokens === 0 && totalTokens.completion_tokens === 0) {
184
+ totalTokens = createUsageObject(requestBody?.messages || promptText, completionContent, null)
185
+ logger.info(`流式使用tiktoken计算 - Prompt: ${totalTokens.prompt_tokens}, Completion: ${totalTokens.completion_tokens}, Total: ${totalTokens.total_tokens}`, 'CHAT')
186
+ } else {
187
+ logger.info(`流式使用上游真实Token - Prompt: ${totalTokens.prompt_tokens}, Completion: ${totalTokens.completion_tokens}, Total: ${totalTokens.total_tokens}`, 'CHAT')
188
+ }
189
+
190
+ // 确保token数量的有效性
191
+ totalTokens.prompt_tokens = Math.max(0, totalTokens.prompt_tokens || 0)
192
+ totalTokens.completion_tokens = Math.max(0, totalTokens.completion_tokens || 0)
193
+ totalTokens.total_tokens = totalTokens.prompt_tokens + totalTokens.completion_tokens
194
+
195
+ // 发送最终的finish chunk,包含finish_reason
196
+ res.write(`data: ${JSON.stringify({
197
+ "id": `chatcmpl-${message_id}`,
198
+ "object": "chat.completion.chunk",
199
+ "created": new Date().getTime(),
200
+ "choices": [
201
+ {
202
+ "index": 0,
203
+ "delta": {},
204
+ "finish_reason": "stop"
205
+ }
206
+ ]
207
+ })}\n\n`)
208
+
209
+ // 发送usage信息chunk(符合OpenAI API标准)
210
+ res.write(`data: ${JSON.stringify({
211
+ "id": `chatcmpl-${message_id}`,
212
+ "object": "chat.completion.chunk",
213
+ "created": new Date().getTime(),
214
+ "choices": [],
215
+ "usage": totalTokens
216
+ })}\n\n`)
217
+
218
+ // 发送结束标记
219
+ res.write(`data: [DONE]\n\n`)
220
+ res.end()
221
+ } catch (e) {
222
+ logger.error('流式响应处理错误', 'CHAT', '', e)
223
+ res.status(500).json({ error: "服务错误!!!" })
224
+ }
225
+ })
226
+ } catch (error) {
227
+ logger.error('聊天处理错误', 'CHAT', '', error)
228
+ res.status(500).json({ error: "服务错误!!!" })
229
+ }
230
+ }
231
+
232
+ /**
233
+ * 处理非流式响应(从流式数据累积完整响应)
234
+ * @param {object} res - Express 响应对象
235
+ * @param {object} response - 上游响应流
236
+ * @param {boolean} enable_thinking - 是否启用思考模式
237
+ * @param {boolean} enable_web_search - 是否启用网络搜索
238
+ * @param {string} model - 模型名称
239
+ * @param {object} requestBody - 原始请求体,用于提取prompt信息
240
+ */
241
+ const handleNonStreamResponse = async (res, response, enable_thinking, enable_web_search, model, requestBody = null) => {
242
+ try {
243
+ const decoder = new TextDecoder('utf-8')
244
+ let buffer = ''
245
+ let fullContent = ''
246
+ let web_search_info = null
247
+ let thinking_start = false
248
+ let thinking_end = false
249
+
250
+ // Token消耗量统计
251
+ let totalTokens = {
252
+ prompt_tokens: 0,
253
+ completion_tokens: 0,
254
+ total_tokens: 0
255
+ }
256
+
257
+ // 提取prompt文本用于token估算
258
+ let promptText = ''
259
+ if (requestBody && requestBody.messages) {
260
+ promptText = requestBody.messages.map(msg => {
261
+ if (typeof msg.content === 'string') {
262
+ return msg.content
263
+ } else if (Array.isArray(msg.content)) {
264
+ return msg.content.map(item => item.text || '').join('')
265
+ }
266
+ return ''
267
+ }).join('\n')
268
+ }
269
+
270
+ // 处理流式响应并累积内容
271
+ await new Promise((resolve, reject) => {
272
+ response.on('data', async (chunk) => {
273
+ const decodeText = decoder.decode(chunk, { stream: true })
274
+ buffer += decodeText
275
+
276
+ const chunks = []
277
+ let startIndex = 0
278
+
279
+ while (true) {
280
+ const dataStart = buffer.indexOf('data: ', startIndex)
281
+ if (dataStart === -1) break
282
+
283
+ const dataEnd = buffer.indexOf('\n\n', dataStart)
284
+ if (dataEnd === -1) break
285
+
286
+ const dataChunk = buffer.substring(dataStart, dataEnd).trim()
287
+ chunks.push(dataChunk)
288
+
289
+ startIndex = dataEnd + 2
290
+ }
291
+
292
+ if (startIndex > 0) {
293
+ buffer = buffer.substring(startIndex)
294
+ }
295
+
296
+ for (const item of chunks) {
297
+ try {
298
+ let dataContent = item.replace("data: ", '')
299
+ let decodeJson = isJson(dataContent) ? JSON.parse(dataContent) : null
300
+ if (decodeJson === null || !decodeJson.choices || decodeJson.choices.length === 0) {
301
+ continue
302
+ }
303
+
304
+ // 提取真实的usage信息(如果上游API提供)
305
+ if (decodeJson.usage) {
306
+ totalTokens = {
307
+ prompt_tokens: decodeJson.usage.prompt_tokens || totalTokens.prompt_tokens,
308
+ completion_tokens: decodeJson.usage.completion_tokens || totalTokens.completion_tokens,
309
+ total_tokens: decodeJson.usage.total_tokens || totalTokens.total_tokens
310
+ }
311
+ }
312
+
313
+ // 处理 web_search 信息
314
+ if (decodeJson.choices[0].delta && decodeJson.choices[0].delta.name === 'web_search') {
315
+ web_search_info = decodeJson.choices[0].delta.extra.web_search_info
316
+ }
317
+
318
+ if (!decodeJson.choices[0].delta || !decodeJson.choices[0].delta.content ||
319
+ (decodeJson.choices[0].delta.phase !== 'think' && decodeJson.choices[0].delta.phase !== 'answer')) {
320
+ continue
321
+ }
322
+
323
+ let content = decodeJson.choices[0].delta.content
324
+
325
+ // 处理thinking模式
326
+ if (decodeJson.choices[0].delta.phase === 'think' && !thinking_start) {
327
+ thinking_start = true
328
+ if (web_search_info) {
329
+ const webSearchTable = await accountManager.generateMarkdownTable(web_search_info, config.searchInfoMode)
330
+ content = `<think>\n\n${webSearchTable}\n\n${content}`
331
+ } else {
332
+ content = `<think>\n\n${content}`
333
+ }
334
+ }
335
+ if (decodeJson.choices[0].delta.phase === 'answer' && !thinking_end && thinking_start) {
336
+ thinking_end = true
337
+ content = `\n\n</think>\n${content}`
338
+ }
339
+
340
+ fullContent += content
341
+ } catch (error) {
342
+ logger.error('非流式数据处理错误', 'CHAT', '', error)
343
+ }
344
+ }
345
+ })
346
+
347
+ response.on('end', () => {
348
+ resolve()
349
+ })
350
+
351
+ response.on('error', (error) => {
352
+ logger.error('非流式响应流读取错误', 'CHAT', '', error)
353
+ reject(error)
354
+ })
355
+ })
356
+
357
+ // 处理最终的搜索信息
358
+ if ((config.outThink === false || !enable_thinking) && web_search_info && config.searchInfoMode === "text") {
359
+ const webSearchTable = await accountManager.generateMarkdownTable(web_search_info, "text")
360
+ fullContent += `\n\n---\n${webSearchTable}`
361
+ }
362
+
363
+ // 计算最终的token使用量
364
+ if (totalTokens.prompt_tokens === 0 && totalTokens.completion_tokens === 0) {
365
+ totalTokens = createUsageObject(requestBody?.messages || promptText, fullContent, null)
366
+ logger.info(`非流式使用tiktoken计算 - Prompt: ${totalTokens.prompt_tokens}, Completion: ${totalTokens.completion_tokens}, Total: ${totalTokens.total_tokens}`, 'CHAT')
367
+ } else {
368
+ logger.info(`非流式使用上游真实Token - Prompt: ${totalTokens.prompt_tokens}, Completion: ${totalTokens.completion_tokens}, Total: ${totalTokens.total_tokens}`, 'CHAT')
369
+ }
370
+
371
+ // 确保token数量的有效性
372
+ totalTokens.prompt_tokens = Math.max(0, totalTokens.prompt_tokens || 0)
373
+ totalTokens.completion_tokens = Math.max(0, totalTokens.completion_tokens || 0)
374
+ totalTokens.total_tokens = totalTokens.prompt_tokens + totalTokens.completion_tokens
375
+
376
+ // 返回完整的JSON响应
377
+ const bodyTemplate = {
378
+ "id": `chatcmpl-${generateUUID()}`,
379
+ "object": "chat.completion",
380
+ "created": new Date().getTime(),
381
+ "model": model,
382
+ "choices": [
383
+ {
384
+ "index": 0,
385
+ "message": {
386
+ "role": "assistant",
387
+ "content": fullContent
388
+ },
389
+ "finish_reason": "stop"
390
+ }
391
+ ],
392
+ "usage": totalTokens
393
+ }
394
+ res.json(bodyTemplate)
395
+ } catch (error) {
396
+ logger.error('非流式聊天处理错误', 'CHAT', '', error)
397
+ res.status(500)
398
+ .json({
399
+ error: "服务错误!!!"
400
+ })
401
+ }
402
+ }
403
+
404
+
405
+ /**
406
+ * 主要的聊天完成处理函数
407
+ * @param {object} req - Express 请求对象
408
+ * @param {object} res - Express 响应对象
409
+ */
410
+ const handleChatCompletion = async (req, res) => {
411
+ const { stream, model } = req.body
412
+
413
+ const enable_thinking = req.enable_thinking
414
+ const enable_web_search = req.enable_web_search
415
+
416
+ try {
417
+ const response_data = await sendChatRequest(req.body)
418
+
419
+ if (!response_data.status || !response_data.response) {
420
+ res.status(500)
421
+ .json({
422
+ error: "请求发送失败!!!"
423
+ })
424
+ return
425
+ }
426
+
427
+ if (stream) {
428
+ setResponseHeaders(res, true)
429
+ await handleStreamResponse(res, response_data.response, enable_thinking, enable_web_search, req.body)
430
+ } else {
431
+ setResponseHeaders(res, false)
432
+ await handleNonStreamResponse(res, response_data.response, enable_thinking, enable_web_search, model, req.body)
433
+ }
434
+
435
+ } catch (error) {
436
+ logger.error('聊天处理错误', 'CHAT', '', error)
437
+ res.status(500)
438
+ .json({
439
+ error: "token无效,请求发送失败!!!"
440
+ })
441
+ }
442
+ }
443
+
444
+ module.exports = {
445
+ handleChatCompletion,
446
+ handleStreamResponse,
447
+ handleNonStreamResponse,
448
+ setResponseHeaders
449
+ }
src/controllers/cli.chat.js ADDED
@@ -0,0 +1,213 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const axios = require('axios')
2
+ const { logger } = require('../utils/logger')
3
+ const { getProxyAgent, getCliBaseUrl, applyProxyToAxiosConfig } = require('../utils/proxy-helper')
4
+
5
+ const MODEL_REDIRECT = {
6
+ 'qwen3.5-plus': 'coder-model',
7
+ }
8
+
9
+ function preprocessCliRequestBody(rawBody) {
10
+ const body = rawBody && typeof rawBody === 'object' ? JSON.parse(JSON.stringify(rawBody)) : {}
11
+
12
+ if (body.model && MODEL_REDIRECT[body.model]) {
13
+ body.model = MODEL_REDIRECT[body.model]
14
+ }
15
+ const isStream = body.stream === true
16
+
17
+ if (isStream) {
18
+ const hasToolsArray = Array.isArray(body.tools)
19
+ if (!hasToolsArray || body.tools.length === 0) {
20
+ body.tools = [{
21
+ type: 'function',
22
+ function: {
23
+ name: 'do_not_call_me',
24
+ description: 'Do not call this tool.',
25
+ parameters: {
26
+ type: 'object',
27
+ properties: {
28
+ operation: { type: 'number', description: 'placeholder' }
29
+ },
30
+ required: ['operation']
31
+ }
32
+ }
33
+ }]
34
+ }
35
+
36
+ if (!body.stream_options || typeof body.stream_options !== 'object') {
37
+ body.stream_options = {}
38
+ }
39
+ body.stream_options.include_usage = true
40
+ }
41
+
42
+ return body
43
+ }
44
+
45
+ function formatCliJsonResponse(data, fallbackModel) {
46
+ if (!data || typeof data !== 'object') {
47
+ return data
48
+ }
49
+ if (!data.object) {
50
+ data.object = 'chat.completion'
51
+ }
52
+ if (!data.model && fallbackModel) {
53
+ data.model = fallbackModel
54
+ }
55
+ if (!Array.isArray(data.choices)) {
56
+ data.choices = []
57
+ }
58
+ return data
59
+ }
60
+
61
+ /**
62
+ * 处理CLI聊天完成请求(支持OpenAI格式的流式和JSON响应)
63
+ * @param {Object} req - Express请求对象
64
+ * @param {Object} res - Express响应对象
65
+ */
66
+ const handleCliChatCompletion = async (req, res) => {
67
+ try {
68
+ const access_token = req.account.cli_info.access_token
69
+ const body = preprocessCliRequestBody(req.body)
70
+ const isStream = body.stream === true
71
+
72
+ // 打印当前使用的账号邮箱
73
+ logger.info(`CLI请求使用账号[${req.account.email}]开始处理`, 'CLI', '🚀')
74
+
75
+ // 无论成功失败都增加请求计数
76
+ req.account.cli_info.request_number++
77
+
78
+ const cliBaseUrl = getCliBaseUrl()
79
+ const proxyAgent = getProxyAgent()
80
+
81
+ // 设置请求配置
82
+ const axiosConfig = {
83
+ method: 'POST',
84
+ url: `${cliBaseUrl}/v1/chat/completions`,
85
+ headers: {
86
+ 'Authorization': `Bearer ${access_token}`,
87
+ 'Content-Type': 'application/json',
88
+ 'Accept': isStream ? 'text/event-stream' : 'application/json',
89
+ 'User-Agent': 'QwenCode/0.10.3 (darwin; arm64)',
90
+ 'X-Dashscope-Useragent': 'QwenCode/0.10.3 (darwin; arm64)',
91
+ 'X-Stainless-Runtime-Version': 'v22.17.0',
92
+ 'Sec-Fetch-Mode': 'cors',
93
+ 'X-Stainless-Lang': 'js',
94
+ 'X-Stainless-Arch': 'arm64',
95
+ 'X-Stainless-Package-Version': '5.11.0',
96
+ 'X-Dashscope-Cachecontrol': 'enable',
97
+ 'X-Stainless-Retry-Count': '0',
98
+ 'X-Stainless-Os': 'MacOS',
99
+ 'X-Dashscope-Authtype': 'qwen-oauth',
100
+ 'X-Stainless-Runtime': 'node'
101
+ },
102
+ data: body,
103
+ timeout: 5 * 60 * 1000,
104
+ validateStatus: function () {
105
+ return true
106
+ }
107
+ }
108
+
109
+ // 添加代理配置
110
+ if (proxyAgent) {
111
+ axiosConfig.httpsAgent = proxyAgent
112
+ axiosConfig.proxy = false
113
+ }
114
+
115
+ // 如果是流式请求,设置响应类型为流
116
+ if (isStream) {
117
+ axiosConfig.responseType = 'stream'
118
+
119
+ // 设置响应头为流式
120
+ res.setHeader('Content-Type', 'text/event-stream')
121
+ res.setHeader('Cache-Control', 'no-cache')
122
+ res.setHeader('Connection', 'keep-alive')
123
+ res.setHeader('Access-Control-Allow-Origin', '*')
124
+ res.setHeader('Access-Control-Allow-Headers', '*')
125
+ }
126
+
127
+ const response = await axios(axiosConfig)
128
+
129
+ // 检查响应状态
130
+ if (response.status !== 200) {
131
+ logger.error(`CLI请求使用账号[${req.account.email}]转发失败 - 状态码: ${response.status} - 当前请求数: ${req.account.cli_info.request_number}`, 'CLI', '❌')
132
+ return res.status(response.status).json({
133
+ error: {
134
+ message: `api_error`,
135
+ type: 'api_error',
136
+ code: response.status,
137
+ details: response.data
138
+ }
139
+ })
140
+ }
141
+
142
+ // 处理流式响应
143
+ if (isStream) {
144
+ // 逐���转发,确保始终输出标准 SSE 片段
145
+ response.data.on('data', (chunk) => {
146
+ const text = chunk.toString('utf8')
147
+ const lines = text.split('\n')
148
+ for (const line of lines) {
149
+ if (!line || !line.startsWith('data:')) continue
150
+ res.write(`${line}\n\n`)
151
+ }
152
+ })
153
+
154
+ // 处理流错误
155
+ response.data.on('error', (streamError) => {
156
+ logger.error(`CLI请求使用账号[${req.account.email}]流式传输失败 - 当前请求数: ${req.account.cli_info.request_number}`, 'CLI', '❌')
157
+ if (!res.headersSent) {
158
+ res.status(500).json({
159
+ error: {
160
+ message: 'stream_error',
161
+ type: 'stream_error',
162
+ code: 500
163
+ }
164
+ })
165
+ }
166
+ })
167
+
168
+ // 处理流结束
169
+ response.data.on('end', () => {
170
+ logger.success(`CLI请求使用账号[${req.account.email}]转发成功 (流式) - 当前请求数: ${req.account.cli_info.request_number}`, 'CLI')
171
+ res.end()
172
+ })
173
+ } else {
174
+ // 处理JSON响应
175
+ res.json(formatCliJsonResponse(response.data, body.model))
176
+ logger.success(`CLI请求使用账号[${req.account.email}]转发成功 (JSON) - 当前请求数: ${req.account.cli_info.request_number}`, 'CLI')
177
+ }
178
+ } catch (error) {
179
+ logger.error(`CLI请求使用账号[${req.account.email}]处理异常 - 当前请求数: ${req.account.cli_info.request_number}`, 'CLI', '💥', error.message)
180
+
181
+ // 如果是axios错误,提供更详细的错误信息
182
+ if (error.response) {
183
+ return res.status(error.response.status).json({
184
+ error: {
185
+ message: "api_error",
186
+ type: 'api_error',
187
+ code: error.response.status,
188
+ details: error.response.data
189
+ }
190
+ })
191
+ } else if (error.request) {
192
+ return res.status(503).json({
193
+ error: {
194
+ message: 'connection_error',
195
+ type: 'connection_error',
196
+ code: 503
197
+ }
198
+ })
199
+ } else {
200
+ return res.status(500).json({
201
+ error: {
202
+ message: 'internal_error',
203
+ type: 'internal_error',
204
+ code: 500
205
+ }
206
+ })
207
+ }
208
+ }
209
+ }
210
+
211
+ module.exports = {
212
+ handleCliChatCompletion
213
+ }
src/controllers/models.js ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const { getLatestModels } = require('../models/models-map.js')
2
+ const config = require('../config/index.js')
3
+
4
+ const handleGetModels = async (req, res) => {
5
+ const models = []
6
+
7
+ const ModelsMap = await getLatestModels()
8
+
9
+ for (const model of ModelsMap) {
10
+ delete model.name
11
+ models.push(model)
12
+
13
+ if (config.simpleModelMap) {
14
+ continue
15
+ }
16
+
17
+ const isThinking = model?.info?.meta?.abilities?.thinking
18
+ const isSearch = model?.info?.meta?.chat_type?.includes('search')
19
+ const isImage = model?.info?.meta?.chat_type?.includes('t2i')
20
+ const isVideo = model?.info?.meta?.chat_type?.includes('t2v')
21
+ const isImageEdit = model?.info?.meta?.chat_type?.includes('image_edit')
22
+ const isDeepResearch = model?.info?.meta?.chat_type?.includes('deep_research')
23
+
24
+ if (isThinking) {
25
+ const newModelData = JSON.parse(JSON.stringify(model))
26
+ newModelData.id = `${model.id}-thinking`
27
+
28
+ models.push(newModelData)
29
+ }
30
+
31
+ if (isSearch) {
32
+ const newModelData = JSON.parse(JSON.stringify(model))
33
+ newModelData.id = `${model.id}-search`
34
+ models.push(newModelData)
35
+ }
36
+
37
+ if (isThinking && isSearch) {
38
+ const newModelData = JSON.parse(JSON.stringify(model))
39
+ newModelData.id = `${model.id}-thinking-search`
40
+ models.push(newModelData)
41
+ }
42
+
43
+ if (isImage) {
44
+ const newModelData = JSON.parse(JSON.stringify(model))
45
+ newModelData.id = `${model.id}-image`
46
+ models.push(newModelData)
47
+ }
48
+
49
+ if (isVideo) {
50
+ const newModelData = JSON.parse(JSON.stringify(model))
51
+ newModelData.id = `${model.id}-video`
52
+ models.push(newModelData)
53
+ }
54
+
55
+ if (isImageEdit) {
56
+ const newModelData = JSON.parse(JSON.stringify(model))
57
+ newModelData.id = `${model.id}-image-edit`
58
+ models.push(newModelData)
59
+ }
60
+
61
+ // if (isDeepResearch) {
62
+ // const newModelData = JSON.parse(JSON.stringify(model))
63
+ // newModelData.id = `${model.id}-deep-research`
64
+ // models.push(newModelData)
65
+ // }
66
+ }
67
+ res.json({
68
+ "object": "list",
69
+ "data": models
70
+ })
71
+ }
72
+
73
+ module.exports = {
74
+ handleGetModels
75
+ }
src/middlewares/authorization.js ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const config = require('../config')
2
+
3
+ /**
4
+ * 验证API Key是否有效
5
+ * @param {string} providedKey - 提供的API Key
6
+ * @returns {Object} 验证结果 { isValid: boolean, isAdmin: boolean }
7
+ */
8
+ const validateApiKey = (providedKey) => {
9
+ if (!providedKey) {
10
+ return { isValid: false, isAdmin: false }
11
+ }
12
+
13
+ // 移除Bearer前缀
14
+ const cleanKey = providedKey.startsWith('Bearer ') ? providedKey.slice(7) : providedKey
15
+
16
+ // 检查是否在有效的API keys列表中
17
+ const isValid = config.apiKeys.includes(cleanKey)
18
+ const isAdmin = cleanKey === config.adminKey
19
+
20
+ return { isValid, isAdmin }
21
+ }
22
+
23
+ /**
24
+ * API Key验证中间件 - 验证任何有效的API Key
25
+ */
26
+ const apiKeyVerify = (req, res, next) => {
27
+ const apiKey = req.headers['authorization'] || req.headers['Authorization'] || req.headers['x-api-key']
28
+ const { isValid, isAdmin } = validateApiKey(apiKey)
29
+
30
+ if (!isValid) {
31
+ return res.status(401).json({ error: 'Unauthorized' })
32
+ }
33
+
34
+ // 将权限信息附加到请求对象
35
+ req.isAdmin = isAdmin
36
+ req.apiKey = apiKey
37
+ next()
38
+ }
39
+
40
+ /**
41
+ * 管理员权限验证中间件 - 只允许管理员API Key
42
+ */
43
+ const adminKeyVerify = (req, res, next) => {
44
+ const apiKey = req.headers['authorization'] || req.headers['Authorization'] || req.headers['x-api-key']
45
+ const { isValid, isAdmin } = validateApiKey(apiKey)
46
+
47
+ if (!isValid || !isAdmin) {
48
+ return res.status(403).json({ error: 'Admin access required' })
49
+ }
50
+
51
+ req.isAdmin = isAdmin
52
+ req.apiKey = apiKey
53
+ next()
54
+ }
55
+
56
+ module.exports = {
57
+ apiKeyVerify,
58
+ adminKeyVerify,
59
+ validateApiKey
60
+ }
61
+
src/middlewares/chat-middleware.js ADDED
@@ -0,0 +1,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const { generateUUID } = require('../utils/tools.js')
2
+ const { isChatType, isThinkingEnabled, parserModel, parserMessages } = require('../utils/chat-helpers.js')
3
+ const { logger } = require('../utils/logger')
4
+
5
+ /**
6
+ * 处理聊天请求体的中间件
7
+ * 解析和转换请求参数为内部格式
8
+ */
9
+ const processRequestBody = async (req, res, next) => {
10
+ try {
11
+ // 构建请求体
12
+ const body = {
13
+ "stream": true,
14
+ "incremental_output": true,
15
+ "chat_type": "t2t",
16
+ "model": "qwen3-235b-a22b",
17
+ "messages": [],
18
+ "session_id": generateUUID(),
19
+ "id": generateUUID(),
20
+ "sub_chat_type": "t2t",
21
+ "chat_mode": "normal"
22
+ }
23
+
24
+ // 获取请求体原始数据
25
+ let {
26
+ messages, // 消息历史
27
+ model, // 模型
28
+ stream, // 流式输出
29
+ enable_thinking, // 是否启用思考
30
+ thinking_budget, // 思考预算
31
+ size //图片尺寸
32
+ } = req.body
33
+
34
+ // 处理 stream 参数
35
+ if (stream === true || stream === 'true') {
36
+ body.stream = true
37
+ } else {
38
+ body.stream = false
39
+ }
40
+
41
+ // 处理 chat_type 参数 : 聊天类型
42
+ body.chat_type = isChatType(model)
43
+
44
+ req.enable_web_search = body.chat_type === 'search' ? true : false
45
+
46
+ // 处理 model 参数 : 模型
47
+ body.model = parserModel(model)
48
+
49
+ // 处理 messages 参数 : 消息历史
50
+ body.messages = await parserMessages(messages, isThinkingEnabled(model, enable_thinking, thinking_budget), body.chat_type)
51
+
52
+ // 处理 enable_thinking 参数 : 是否启用思考
53
+ req.enable_thinking = isThinkingEnabled(model, enable_thinking, thinking_budget).thinking_enabled
54
+
55
+ // 处理 sub_chat_type 参数 : 子聊天类型
56
+ body.sub_chat_type = body.chat_type
57
+
58
+ // 处理图片尺寸
59
+ if (size) {
60
+ body.size = size
61
+ }
62
+
63
+ // 处理请求体,将body赋值给req.body
64
+ req.body = body
65
+
66
+ next()
67
+ } catch (e) {
68
+ logger.error('处理请求体时发生错误', 'MIDDLEWARE', '', e)
69
+ res.status(500)
70
+ .json({
71
+ status: 500,
72
+ message: "在处理请求体时发生错误 ~ ~ ~"
73
+ })
74
+ }
75
+ }
76
+
77
+ module.exports = {
78
+ processRequestBody
79
+ }
src/models/models-map.js ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const axios = require('axios')
2
+ const accountManager = require('../utils/account.js')
3
+ const { getSsxmodItna, getSsxmodItna2 } = require('../utils/ssxmod-manager')
4
+ const { getProxyAgent, getChatBaseUrl, applyProxyToAxiosConfig } = require('../utils/proxy-helper')
5
+
6
+ let cachedModels = null
7
+ let fetchPromise = null
8
+
9
+ const getLatestModels = async (force = false) => {
10
+ // 如果有缓存且不强制刷新,直接返回
11
+ if (cachedModels && !force) {
12
+ return cachedModels
13
+ }
14
+
15
+ // 如果正在获取,返回当前的 Promise
16
+ if (fetchPromise) {
17
+ return fetchPromise
18
+ }
19
+
20
+ const chatBaseUrl = getChatBaseUrl()
21
+ const proxyAgent = getProxyAgent()
22
+
23
+ const requestConfig = {
24
+ headers: {
25
+ 'Authorization': `Bearer ${accountManager.getAccountToken()}`,
26
+ 'Content-Type': 'application/json',
27
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
28
+ ...(getSsxmodItna() && { 'Cookie': `ssxmod_itna=${getSsxmodItna()};ssxmod_itna2=${getSsxmodItna2()}` })
29
+ }
30
+ }
31
+
32
+ // 添加代理配置
33
+ if (proxyAgent) {
34
+ requestConfig.httpsAgent = proxyAgent
35
+ requestConfig.proxy = false
36
+ }
37
+
38
+ fetchPromise = axios.get(`${chatBaseUrl}/api/models`, requestConfig).then(response => {
39
+ // console.log(response)
40
+ cachedModels = response.data.data
41
+ fetchPromise = null
42
+ return cachedModels
43
+ }).catch(error => {
44
+ console.error('Error fetching latest models:', error)
45
+ fetchPromise = null
46
+ return []
47
+ })
48
+
49
+ return fetchPromise
50
+ }
51
+
52
+ module.exports = { getLatestModels }
src/routes/accounts.js ADDED
@@ -0,0 +1,259 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const express = require('express')
2
+ const router = express.Router()
3
+ const accountManager = require('../utils/account')
4
+ const { logger } = require('../utils/logger')
5
+ const { JwtDecode } = require('../utils/tools')
6
+ const { adminKeyVerify } = require('../middlewares/authorization')
7
+ const { deleteAccount, saveAccounts, refreshAccountToken } = require('../utils/setting')
8
+
9
+ /**
10
+ * 获取所有账号(分页)
11
+ *
12
+ * @param {number} page 页码
13
+ * @param {number} pageSize 每页数量
14
+ * @returns {Object} 账号列表
15
+ */
16
+ router.get('/getAllAccounts', adminKeyVerify, async (req, res) => {
17
+ try {
18
+ const page = parseInt(req.query.page) || 1
19
+ const pageSize = parseInt(req.query.pageSize) || 1000
20
+ const start = (page - 1) * pageSize
21
+
22
+ // 获取所有账号键
23
+ const allAccounts = accountManager.getAllAccountKeys()
24
+ const total = allAccounts.length
25
+
26
+ // 分页处理
27
+ const paginatedAccounts = allAccounts.slice(start, start + pageSize)
28
+
29
+ // 获取每个账号的详细信息
30
+ const accounts = paginatedAccounts.map(account => {
31
+ return {
32
+ email: account.email,
33
+ password: account.password,
34
+ token: account.token,
35
+ expires: account.expires
36
+ }
37
+ })
38
+
39
+ res.json({
40
+ total,
41
+ page,
42
+ pageSize,
43
+ data: accounts
44
+ })
45
+ } catch (error) {
46
+ logger.error('获取账号列表失败', 'ACCOUNT', '', error)
47
+ res.status(500).json({ error: error.message })
48
+ }
49
+ })
50
+
51
+ /**
52
+ * POST /setAccount
53
+ * 添加账号
54
+ *
55
+ * @param {string} email 邮箱
56
+ * @param {string} password 密码
57
+ * @returns {Object} 账号信息
58
+ */
59
+ router.post('/setAccount', adminKeyVerify, async (req, res) => {
60
+ try {
61
+ const { email, password } = req.body
62
+
63
+ if (!email || !password) {
64
+ return res.status(400).json({ error: '邮箱和密码不能为空' })
65
+ }
66
+
67
+ // 检查账号是否已存在
68
+ const exists = accountManager.accountTokens.find(item => item.email === email)
69
+ if (exists) {
70
+ return res.status(409).json({ error: '账号已存在' })
71
+ }
72
+
73
+ const authToken = await accountManager.login(email, password)
74
+ if (!authToken) {
75
+ return res.status(401).json({ error: '登录失败' })
76
+ }
77
+ // 解析JWT
78
+ const decoded = JwtDecode(authToken)
79
+ const expires = decoded.exp
80
+
81
+ const success = await saveAccounts(email, password, authToken, expires)
82
+
83
+ if (success) {
84
+ res.status(200).json({
85
+ email,
86
+ message: '账号创建成功'
87
+ })
88
+ } else {
89
+ res.status(500).json({ error: '账号创建失败' })
90
+ }
91
+ } catch (error) {
92
+ logger.error('创建账号失败', 'ACCOUNT', '', error)
93
+ res.status(500).json({ error: error.message })
94
+ }
95
+ })
96
+
97
+ /**
98
+ * DELETE /deleteAccount
99
+ * 删除账号
100
+ *
101
+ * @param {string} email 邮箱
102
+ * @returns {Object} 账号信息
103
+ */
104
+ router.delete('/deleteAccount', adminKeyVerify, async (req, res) => {
105
+ try {
106
+ const { email } = req.body
107
+
108
+ // 检查账号是否存在
109
+ const exists = await accountManager.accountTokens.find(item => item.email === email)
110
+ if (!exists) {
111
+ return res.status(404).json({ error: '账号不存在' })
112
+ }
113
+
114
+ // 删除账号
115
+ const success = await deleteAccount(email)
116
+
117
+ if (success) {
118
+ res.json({ message: '账号删除成功' })
119
+ } else {
120
+ res.status(500).json({ error: '账号删除失败' })
121
+ }
122
+ } catch (error) {
123
+ logger.error('删除账号失败', 'ACCOUNT', '', error)
124
+ res.status(500).json({ error: error.message })
125
+ }
126
+ })
127
+
128
+
129
+ /**
130
+ * POST /setAccounts
131
+ * 批量添加账号
132
+ *
133
+ * @param {string} accounts 账号列表
134
+ * @returns {Object} 账号信息
135
+ */
136
+ router.post('/setAccounts', adminKeyVerify, async (req, res) => {
137
+ try {
138
+ let { accounts } = req.body
139
+ if (!accounts) {
140
+ return res.status(400).json({ error: '账号列表不能为空' })
141
+ }
142
+
143
+ accounts = accounts.replace(/[\r]/g, '\n')
144
+ accounts = accounts.split('\n').filter(item => item.trim() !== '')
145
+
146
+ for (const account of accounts) {
147
+ const [email, password] = account.split(':')
148
+ if (!email || !password) {
149
+ continue
150
+ }
151
+
152
+ const authToken = await accountManager.login(email, password)
153
+ if (!authToken) {
154
+ continue
155
+ }
156
+ // 解析JWT
157
+ const decoded = JwtDecode(authToken)
158
+ const expires = decoded.exp
159
+
160
+ const success = await saveAccounts(email, password, authToken, expires)
161
+ if (!success) {
162
+ continue
163
+ }
164
+ }
165
+
166
+ res.json({ message: '账号批量添加任务提交成功' })
167
+ } catch (error) {
168
+ logger.error('批量创建账号失败', 'ACCOUNT', '', error)
169
+ res.status(500).json({ error: error.message })
170
+ }
171
+ })
172
+
173
+ /**
174
+ * POST /refreshAccount
175
+ * 刷新单个账号的令牌
176
+ *
177
+ * @param {string} email 邮箱
178
+ * @returns {Object} 刷新结果
179
+ */
180
+ router.post('/refreshAccount', adminKeyVerify, async (req, res) => {
181
+ try {
182
+ const { email } = req.body
183
+
184
+ if (!email) {
185
+ return res.status(400).json({ error: '邮箱不能为空' })
186
+ }
187
+
188
+ // 检查账号是否存在
189
+ const exists = accountManager.accountTokens.find(item => item.email === email)
190
+ if (!exists) {
191
+ return res.status(404).json({ error: '账号不存在' })
192
+ }
193
+
194
+ // 刷新账号令牌
195
+ const success = await accountManager.refreshAccountToken(email)
196
+
197
+ if (success) {
198
+ res.json({
199
+ message: '账号令牌刷新成功',
200
+ email: email
201
+ })
202
+ } else {
203
+ res.status(500).json({ error: '账号令牌刷新失败' })
204
+ }
205
+ } catch (error) {
206
+ logger.error('刷新账号令牌失败', 'ACCOUNT', '', error)
207
+ res.status(500).json({ error: error.message })
208
+ }
209
+ })
210
+
211
+ /**
212
+ * POST /refreshAllAccounts
213
+ * 刷新所有账号的令牌
214
+ *
215
+ * @param {number} thresholdHours 过期阈值(小时),默认24小时
216
+ * @returns {Object} 刷新结果
217
+ */
218
+ router.post('/refreshAllAccounts', adminKeyVerify, async (req, res) => {
219
+ try {
220
+ const { thresholdHours = 24 } = req.body
221
+
222
+ // 执行批量刷新
223
+ const refreshedCount = await accountManager.autoRefreshTokens(thresholdHours)
224
+
225
+ res.json({
226
+ message: '批量刷新完成',
227
+ refreshedCount: refreshedCount,
228
+ thresholdHours: thresholdHours
229
+ })
230
+ } catch (error) {
231
+ logger.error('批量刷新账号令牌失败', 'ACCOUNT', '', error)
232
+ res.status(500).json({ error: error.message })
233
+ }
234
+ })
235
+
236
+ /**
237
+ * POST /forceRefreshAllAccounts
238
+ * 强制刷新所有账号的令牌(不管是否即将过期)
239
+ *
240
+ * @returns {Object} 刷新结果
241
+ */
242
+ router.post('/forceRefreshAllAccounts', adminKeyVerify, async (req, res) => {
243
+ try {
244
+ // 强制刷新所有账号(设置阈值为很大的值,确保所有账号都会被刷新)
245
+ const refreshedCount = await accountManager.autoRefreshTokens(8760) // 365天
246
+
247
+ res.json({
248
+ message: '强制刷新完成',
249
+ refreshedCount: refreshedCount,
250
+ totalAccounts: accountManager.getAllAccountKeys().length
251
+ })
252
+ } catch (error) {
253
+ logger.error('强制刷新账号令牌失败', 'ACCOUNT', '', error)
254
+ res.status(500).json({ error: error.message })
255
+ }
256
+ })
257
+
258
+
259
+ module.exports = router
src/routes/chat.js ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const express = require('express')
2
+ const router = express.Router()
3
+ const { apiKeyVerify } = require('../middlewares/authorization.js')
4
+ const { processRequestBody } = require('../middlewares/chat-middleware.js')
5
+ const { handleChatCompletion } = require('../controllers/chat.js')
6
+ const { handleImageVideoCompletion } = require('../controllers/chat.image.video.js')
7
+
8
+ const selectChatCompletion = (req, res, next) => {
9
+ const ChatCompletionMap = {
10
+ 't2t': handleChatCompletion,
11
+ 'search': handleChatCompletion,
12
+ 't2i': handleImageVideoCompletion,
13
+ 't2v': handleImageVideoCompletion,
14
+ 'image_edit': handleImageVideoCompletion,
15
+ // 'deep_research': handleDeepResearchCompletion
16
+ }
17
+
18
+ const chatType = req.body.chat_type
19
+ const chatCompletion = ChatCompletionMap[chatType]
20
+ if (chatCompletion) {
21
+ chatCompletion(req, res, next)
22
+ } else {
23
+ handleImageCompletion(req, res, next)
24
+ }
25
+ }
26
+
27
+ router.post('/v1/chat/completions',
28
+ apiKeyVerify,
29
+ processRequestBody,
30
+ selectChatCompletion
31
+ )
32
+
33
+
34
+ module.exports = router
src/routes/cli.chat.js ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const express = require('express')
2
+ const router = express.Router()
3
+ const { apiKeyVerify } = require('../middlewares/authorization.js')
4
+ const { handleCliChatCompletion } = require('../controllers/cli.chat.js')
5
+ const accountManager = require('../utils/account.js')
6
+
7
+ router.post('/cli/v1/chat/completions',
8
+ apiKeyVerify,
9
+ async (req, res, next) => {
10
+ // 异步初始化新账号(不阻塞当前请求)
11
+ const noCliAccount = accountManager.accountTokens.filter(account => !account.cli_info)
12
+ if (noCliAccount.length > 0) {
13
+ const randomNewAccount = noCliAccount[Math.floor(Math.random() * noCliAccount.length)]
14
+ // 异步初始化,不等待结果
15
+ accountManager.initializeCliForAccount(randomNewAccount).catch(error => {
16
+ console.error(`异步初始化CLI账户失败 (${randomNewAccount.email}):`, error)
17
+ })
18
+ }
19
+
20
+ // 获取当前可用的CLI账户用于本次请求
21
+ const availableAccounts = accountManager.accountTokens.filter(account =>
22
+ account.cli_info && account.cli_info.request_number < 2000
23
+ )
24
+
25
+ if (availableAccounts.length === 0) {
26
+ return res.status(503).json({
27
+ error: '没有可用的CLI账户,请稍后重试'
28
+ })
29
+ }
30
+
31
+ // 随机选择一个可用账户用于本次请求
32
+ const randomAccount = availableAccounts[Math.floor(Math.random() * availableAccounts.length)]
33
+ req.account = randomAccount
34
+ next()
35
+ },
36
+ handleCliChatCompletion
37
+ )
38
+
39
+ module.exports = router
src/routes/models.js ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const express = require('express')
2
+ const router = express.Router()
3
+ const { apiKeyVerify } = require('../middlewares/authorization')
4
+ const { handleGetModels } = require('../controllers/models.js')
5
+
6
+ router.get('/v1/models', apiKeyVerify, handleGetModels)
7
+
8
+ router.get('/models', handleGetModels)
9
+
10
+ router.post('/cli/v1/models', async (req, res) => {
11
+ res.json({
12
+ object: 'list',
13
+ data: [
14
+ {
15
+ id: 'qwen3-coder-plus',
16
+ object: 'model',
17
+ created: 1719878112,
18
+ owned_by: 'qwen-code'
19
+ },
20
+ {
21
+ id: 'qwen3-coder-flash',
22
+ object: 'model',
23
+ created: 1719878112,
24
+ owned_by: 'qwen-code'
25
+ },
26
+ ]
27
+ })
28
+ })
29
+
30
+
31
+ module.exports = router
src/routes/settings.js ADDED
@@ -0,0 +1,161 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const express = require('express')
2
+ const router = express.Router()
3
+ const config = require('../config')
4
+ const { apiKeyVerify, adminKeyVerify } = require('../middlewares/authorization')
5
+ const { logger } = require('../utils/logger')
6
+
7
+
8
+ router.get('/settings', adminKeyVerify, async (req, res) => {
9
+ // 分离管理员密钥和普通密钥
10
+ const regularKeys = config.apiKeys.filter(key => key !== config.adminKey)
11
+
12
+ res.json({
13
+ apiKey: config.apiKey, // 保持向后兼容
14
+ adminKey: config.adminKey,
15
+ regularKeys: regularKeys,
16
+ defaultHeaders: config.defaultHeaders,
17
+ defaultCookie: config.defaultCookie,
18
+ autoRefresh: config.autoRefresh,
19
+ autoRefreshInterval: config.autoRefreshInterval,
20
+ outThink: config.outThink,
21
+ searchInfoMode: config.searchInfoMode,
22
+ simpleModelMap: config.simpleModelMap
23
+ })
24
+ })
25
+
26
+ // 添加普通API Key
27
+ router.post('/addRegularKey', adminKeyVerify, async (req, res) => {
28
+ try {
29
+ const { apiKey } = req.body
30
+ if (!apiKey) {
31
+ return res.status(400).json({ error: 'API Key不能为空' })
32
+ }
33
+
34
+ // 检查是否已存在
35
+ if (config.apiKeys.includes(apiKey)) {
36
+ return res.status(409).json({ error: 'API Key已存在' })
37
+ }
38
+
39
+ // 添加到配置中
40
+ config.apiKeys.push(apiKey)
41
+
42
+ res.json({ message: 'API Key添加成功' })
43
+ } catch (error) {
44
+ logger.error('添加API Key失败', 'CONFIG', '', error)
45
+ res.status(500).json({ error: error.message })
46
+ }
47
+ })
48
+
49
+ // 删除普通API Key
50
+ router.post('/deleteRegularKey', adminKeyVerify, async (req, res) => {
51
+ try {
52
+ const { apiKey } = req.body
53
+ if (!apiKey) {
54
+ return res.status(400).json({ error: 'API Key不能为空' })
55
+ }
56
+
57
+ // 不能删除管理员密钥
58
+ if (apiKey === config.adminKey) {
59
+ return res.status(403).json({ error: '不能删除管理员密钥' })
60
+ }
61
+
62
+ // 从配置中移除
63
+ const index = config.apiKeys.indexOf(apiKey)
64
+ if (index === -1) {
65
+ return res.status(404).json({ error: 'API Key不存在' })
66
+ }
67
+
68
+ config.apiKeys.splice(index, 1)
69
+
70
+ res.json({ message: 'API Key删除成功' })
71
+ } catch (error) {
72
+ logger.error('删除API Key失败', 'CONFIG', '', error)
73
+ res.status(500).json({ error: error.message })
74
+ }
75
+ })
76
+
77
+ // 更新自动刷新设置
78
+ router.post('/setAutoRefresh', adminKeyVerify, async (req, res) => {
79
+ try {
80
+ const { autoRefresh, autoRefreshInterval } = req.body
81
+
82
+ if (typeof autoRefresh !== 'boolean') {
83
+ return res.status(400).json({ error: '无效的自动刷新设置' })
84
+ }
85
+
86
+ if (autoRefreshInterval !== undefined) {
87
+ const interval = parseInt(autoRefreshInterval)
88
+ if (isNaN(interval) || interval < 0) {
89
+ return res.status(400).json({ error: '无效的自动刷新间隔' })
90
+ }
91
+ }
92
+ config.autoRefresh = autoRefresh
93
+ config.autoRefreshInterval = autoRefreshInterval || 6 * 60 * 60
94
+ res.json({
95
+ status: true,
96
+ message: '自动刷新设置更新成功'
97
+ })
98
+ } catch (error) {
99
+ logger.error('更新自动刷新设置失败', 'CONFIG', '', error)
100
+ res.status(500).json({ error: error.message })
101
+ }
102
+ })
103
+
104
+ // 更新思考输出设置
105
+ router.post('/setOutThink', adminKeyVerify, async (req, res) => {
106
+ try {
107
+ const { outThink } = req.body;
108
+ if (typeof outThink !== 'boolean') {
109
+ return res.status(400).json({ error: '无效的思考输出设置' })
110
+ }
111
+
112
+ config.outThink = outThink
113
+ res.json({
114
+ status: true,
115
+ message: '思考输出设置更新成功'
116
+ })
117
+ } catch (error) {
118
+ logger.error('更新思考输出设置失败', 'CONFIG', '', error)
119
+ res.status(500).json({ error: error.message })
120
+ }
121
+ })
122
+
123
+ // 更新搜索信息模式
124
+ router.post('/search-info-mode', adminKeyVerify, async (req, res) => {
125
+ try {
126
+ const { searchInfoMode } = req.body
127
+ if (!['table', 'text'].includes(searchInfoMode)) {
128
+ return res.status(400).json({ error: '无效的搜索信息模式' })
129
+ }
130
+
131
+ config.searchInfoMode = searchInfoMode
132
+ res.json({
133
+ status: true,
134
+ message: '搜索信息模式更新成功'
135
+ })
136
+ } catch (error) {
137
+ logger.error('更新搜索信息模式失败', 'CONFIG', '', error)
138
+ res.status(500).json({ error: error.message })
139
+ }
140
+ })
141
+
142
+ // 更新简化模型映射设置
143
+ router.post('/simple-model-map', adminKeyVerify, async (req, res) => {
144
+ try {
145
+ const { simpleModelMap } = req.body
146
+ if (typeof simpleModelMap !== 'boolean') {
147
+ return res.status(400).json({ error: '无效的简化模型映射设置' })
148
+ }
149
+
150
+ config.simpleModelMap = simpleModelMap
151
+ res.json({
152
+ status: true,
153
+ message: '简化模型映射设置更新成功'
154
+ })
155
+ } catch (error) {
156
+ logger.error('更新简化模型映射设置失败', 'CONFIG', '', error)
157
+ res.status(500).json({ error: error.message })
158
+ }
159
+ })
160
+
161
+ module.exports = router
src/routes/verify.js ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const express = require('express')
2
+ const router = express.Router()
3
+ const config = require('../config/index.js')
4
+ const { validateApiKey } = require('../middlewares/authorization')
5
+
6
+ router.post('/verify', (req, res) => {
7
+ const apiKey = req.body.apiKey
8
+ const { isValid, isAdmin } = validateApiKey(apiKey)
9
+
10
+ if (!isValid) {
11
+ return res.status(401).json({
12
+ status: 401,
13
+ message: 'Unauthorized'
14
+ })
15
+ }
16
+
17
+ res.status(200).json({
18
+ status: 200,
19
+ message: 'success',
20
+ isAdmin: isAdmin
21
+ })
22
+ })
23
+
24
+ module.exports = router
src/server.js ADDED
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const express = require('express')
2
+ const bodyParser = require('body-parser')
3
+ const config = require('./config/index.js')
4
+ const cors = require('cors')
5
+ const { logger } = require('./utils/logger')
6
+ const { initSsxmodManager } = require('./utils/ssxmod-manager')
7
+ const app = express()
8
+ const path = require('path')
9
+ const fs = require('fs')
10
+ const paths = require('./utils/paths')
11
+ const modelsRouter = require('./routes/models.js')
12
+ const chatRouter = require('./routes/chat.js')
13
+ const cliChatRouter = require('./routes/cli.chat.js')
14
+ const verifyRouter = require('./routes/verify.js')
15
+ const accountsRouter = require('./routes/accounts.js')
16
+ const settingsRouter = require('./routes/settings.js')
17
+
18
+ if (config.dataSaveMode === 'file') {
19
+ if (!fs.existsSync(paths.dataFilePath)) {
20
+ fs.mkdirSync(paths.dataDir, { recursive: true })
21
+ fs.writeFileSync(paths.dataFilePath, JSON.stringify({"accounts": [] }, null, 2))
22
+ }
23
+ }
24
+
25
+ // 初始化 SSXMOD Cookie 管理器
26
+ initSsxmodManager()
27
+
28
+ app.use(bodyParser.json({ limit: '128mb' }))
29
+ app.use(bodyParser.urlencoded({ limit: '128mb', extended: true }))
30
+ app.use(cors())
31
+
32
+ // API路由
33
+ app.use(modelsRouter)
34
+ app.use(chatRouter)
35
+ app.use(cliChatRouter)
36
+ app.use(verifyRouter)
37
+ app.use('/api', accountsRouter)
38
+ app.use('/api', settingsRouter)
39
+
40
+ app.use(express.static(path.join(__dirname, '../public/dist')))
41
+
42
+ app.get('*', (req, res) => {
43
+ res.sendFile(path.join(__dirname, '../public/dist/index.html'), (err) => {
44
+ if (err) {
45
+ logger.error('管理页面加载失败', 'SERVER', '', err)
46
+ res.status(500).send('服务器内部错误')
47
+ }
48
+ })
49
+ })
50
+
51
+ // 处理错误中间件(必须放在所有路由之后)
52
+ app.use((err, req, res, next) => {
53
+ logger.error('服务器内部错误', 'SERVER', '', err)
54
+ res.status(500).send('服务器内部错误')
55
+ })
56
+
57
+
58
+ // 服务器启动信息
59
+ const serverInfo = {
60
+ address: config.listenAddress || 'localhost',
61
+ port: config.listenPort,
62
+ outThink: config.outThink ? '开启' : '关闭',
63
+ searchInfoMode: config.searchInfoMode === 'table' ? '表格' : '文本',
64
+ dataSaveMode: config.dataSaveMode,
65
+ logLevel: config.logLevel,
66
+ enableFileLog: config.enableFileLog
67
+ }
68
+
69
+ if (config.listenAddress) {
70
+ app.listen(config.listenPort, config.listenAddress, () => {
71
+ logger.server('服务器启动成功', 'SERVER', serverInfo)
72
+ logger.info('开源地址: https://github.com/Rfym21/Qwen2API', 'INFO')
73
+ logger.info('电报群聊: https://t.me/nodejs_project', 'INFO')
74
+ })
75
+ } else {
76
+ app.listen(config.listenPort, () => {
77
+ logger.server('服务器启动成功', 'SERVER', serverInfo)
78
+ logger.info('开源地址: https://github.com/Rfym21/Qwen2API', 'INFO')
79
+ logger.info('电报群聊: https://t.me/nodejs_project', 'INFO')
80
+ })
81
+ }
src/start.js ADDED
@@ -0,0 +1,113 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const cluster = require('cluster')
2
+ const os = require('os')
3
+ const { logger } = require('./utils/logger')
4
+
5
+ // 加载环境变量
6
+ require('dotenv').config()
7
+
8
+ // 获取CPU核心数
9
+ const cpuCores = os.cpus().length
10
+
11
+ // 获取环境变量配置
12
+ const PM2_INSTANCES = process.env.PM2_INSTANCES || '1'
13
+ const SERVICE_PORT = process.env.SERVICE_PORT || 3000
14
+ const NODE_ENV = process.env.NODE_ENV || 'production'
15
+
16
+ // 解析进程数
17
+ let instances
18
+ if (PM2_INSTANCES === 'max') {
19
+ instances = cpuCores
20
+ } else if (!isNaN(PM2_INSTANCES)) {
21
+ instances = parseInt(PM2_INSTANCES)
22
+ } else {
23
+ instances = 1
24
+ }
25
+
26
+ // 限制进程数不能超过CPU核心数
27
+ if (instances > cpuCores) {
28
+ logger.warn(`配置的进程数(${instances})超过CPU核心数(${cpuCores}),自动调整为${cpuCores}`, 'AUTO')
29
+ instances = cpuCores
30
+ }
31
+
32
+ logger.info('🚀 Qwen2API 智能启动', 'AUTO')
33
+ logger.info(`CPU核心数: ${cpuCores}`, 'AUTO')
34
+ logger.info(`配置的进程数: ${PM2_INSTANCES}`, 'AUTO')
35
+ logger.info(`实际启动进程数: ${instances}`, 'AUTO')
36
+ logger.info(`服务端口: ${SERVICE_PORT}`, 'AUTO')
37
+
38
+ // 智能判断启动方式
39
+ if (instances === 1) {
40
+ logger.info('📦 使用单进程模式启动', 'AUTO')
41
+ // 直接启动服务器
42
+ require('./server.js')
43
+ } else {
44
+ // 检查是否通过PM2启动
45
+ if (process.env.PM2_USAGE || process.env.pm_id !== undefined) {
46
+ logger.info(`PM2进程启动 - 进程ID: ${process.pid}, 工作进程ID: ${process.env.pm_id || 'unknown'}`, 'PM2')
47
+ require('./server.js')
48
+ } else if (cluster.isMaster) {
49
+ logger.info(`🔥 使用Node.js集群模式启动 (${instances}个进程)`, 'AUTO')
50
+
51
+ logger.info(`启动主进程 - PID: ${process.pid}`, 'CLUSTER')
52
+ logger.info(`运行环境: ${NODE_ENV}`, 'CLUSTER')
53
+
54
+ // 创建工作进程
55
+ for (let i = 0; i < instances; i++) {
56
+ const worker = cluster.fork()
57
+ logger.info(`启动工作进程 ${i + 1}/${instances} - PID: ${worker.process.pid}`, 'CLUSTER')
58
+ }
59
+
60
+ // 监听工作进程退出
61
+ cluster.on('exit', (worker, code, signal) => {
62
+ logger.error(`工作进程 ${worker.process.pid} 已退出 - 退出码: ${code}, 信号: ${signal}`, 'CLUSTER')
63
+
64
+ // 自动重启工作进程
65
+ if (!worker.exitedAfterDisconnect) {
66
+ logger.info('正在重启工作进程...', 'CLUSTER')
67
+ const newWorker = cluster.fork()
68
+ logger.info(`新工作进程已启动 - PID: ${newWorker.process.pid}`, 'CLUSTER')
69
+ }
70
+ })
71
+
72
+ // 监听工作进程在线
73
+ cluster.on('online', (worker) => {
74
+ logger.info(`工作进程 ${worker.process.pid} 已上线`, 'CLUSTER')
75
+ })
76
+
77
+ // 监听工作进程断开连接
78
+ cluster.on('disconnect', (worker) => {
79
+ logger.warn(`工作进程 ${worker.process.pid} 已断开连接`, 'CLUSTER')
80
+ })
81
+
82
+ // 优雅关闭处理
83
+ process.on('SIGTERM', () => {
84
+ logger.info('收到SIGTERM信号,正在优雅关闭...', 'CLUSTER')
85
+ cluster.disconnect(() => {
86
+ process.exit(0)
87
+ })
88
+ })
89
+
90
+ process.on('SIGINT', () => {
91
+ logger.info('收到SIGINT信号,正在优雅关闭...', 'CLUSTER')
92
+ cluster.disconnect(() => {
93
+ process.exit(0)
94
+ })
95
+ })
96
+
97
+ } else {
98
+ // 工作进程逻辑
99
+ logger.info(`工作进程启动 - PID: ${process.pid}`, 'WORKER')
100
+ require('./server.js')
101
+
102
+ // 工作进程优雅关闭处理
103
+ process.on('SIGTERM', () => {
104
+ logger.info(`工作进程 ${process.pid} 收到SIGTERM信号,正在关闭...`, 'WORKER')
105
+ process.exit(0)
106
+ })
107
+
108
+ process.on('SIGINT', () => {
109
+ logger.info(`工作进程 ${process.pid} 收到SIGINT信号,正在关闭...`, 'WORKER')
110
+ process.exit(0)
111
+ })
112
+ }
113
+ }
src/utils/account-rotator.js ADDED
@@ -0,0 +1,247 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const { logger } = require('./logger')
2
+
3
+ /**
4
+ * 账户轮询管理器
5
+ * 负责账户的轮询选择和负载均衡
6
+ */
7
+ class AccountRotator {
8
+ constructor() {
9
+ this.accounts = []
10
+ this.currentIndex = 0
11
+ this.lastUsedTimes = new Map() // 记录每个账户的最后使用时间
12
+ this.failureCounts = new Map() // 记录每个账户的失败次数
13
+ this.maxFailures = 3 // 最大失败次数
14
+ this.cooldownPeriod = 5 * 60 * 1000 // 5分钟冷却期
15
+ }
16
+
17
+ /**
18
+ * 设置账户列表
19
+ * @param {Array} accounts - 账户列表
20
+ */
21
+ setAccounts(accounts) {
22
+ if (!Array.isArray(accounts)) {
23
+ logger.error('账户列表必须是数组', 'ACCOUNT')
24
+ throw new Error('账户列表必须是数组')
25
+ }
26
+
27
+ this.accounts = [...accounts]
28
+ this.currentIndex = 0
29
+
30
+ // 清理不存在账户的记录
31
+ this._cleanupRecords()
32
+ }
33
+
34
+ /**
35
+ * 获取下一个可用的账户令牌
36
+ * @returns {string|null} 账户令牌或null
37
+ */
38
+ getNextToken() {
39
+ if (this.accounts.length === 0) {
40
+ logger.error('没有可用的账户', 'ACCOUNT')
41
+ return null
42
+ }
43
+
44
+ const availableAccounts = this._getAvailableAccounts()
45
+ if (availableAccounts.length === 0) {
46
+ logger.warn('所有账户都不可用,使用轮询策略', 'ACCOUNT')
47
+ return this._getTokenByRoundRobin()
48
+ }
49
+
50
+ // 从可用账户中选择最少使用的
51
+ const selectedAccount = this._selectLeastUsedAccount(availableAccounts)
52
+ this._recordUsage(selectedAccount.email)
53
+
54
+ return selectedAccount.token
55
+ }
56
+
57
+ /**
58
+ * 获取指定邮箱的账户令牌
59
+ * @param {string} email - 邮箱地址
60
+ * @returns {string|null} 账户令牌或null
61
+ */
62
+ getTokenByEmail(email) {
63
+ const account = this.accounts.find(acc => acc.email === email)
64
+ if (!account) {
65
+ logger.error(`未找到邮箱为 ${email} 的账户`, 'ACCOUNT')
66
+ return null
67
+ }
68
+
69
+ if (!this._isAccountAvailable(account)) {
70
+ logger.warn(`账户 ${email} 当前不可用`, 'ACCOUNT')
71
+ return null
72
+ }
73
+
74
+ this._recordUsage(email)
75
+ return account.token
76
+ }
77
+
78
+ /**
79
+ * 记录账户使用失败
80
+ * @param {string} email - 邮箱地址
81
+ */
82
+ recordFailure(email) {
83
+ const currentFailures = this.failureCounts.get(email) || 0
84
+ this.failureCounts.set(email, currentFailures + 1)
85
+
86
+ if (currentFailures + 1 >= this.maxFailures) {
87
+ logger.warn(`账户 ${email} 失败次数达到上限,将进入冷却期`, 'ACCOUNT')
88
+ }
89
+ }
90
+
91
+ /**
92
+ * 重置账户失败计数
93
+ * @param {string} email - 邮箱地址
94
+ */
95
+ resetFailures(email) {
96
+ this.failureCounts.delete(email)
97
+ }
98
+
99
+ /**
100
+ * 获取账户统计信息
101
+ * @returns {Object} 统计信息
102
+ */
103
+ getStats() {
104
+ const total = this.accounts.length
105
+ const available = this._getAvailableAccounts().length
106
+ const inCooldown = total - available
107
+
108
+ const usageStats = {}
109
+ this.accounts.forEach(account => {
110
+ const email = account.email
111
+ usageStats[email] = {
112
+ failures: this.failureCounts.get(email) || 0,
113
+ lastUsed: this.lastUsedTimes.get(email) || null,
114
+ available: this._isAccountAvailable(account)
115
+ }
116
+ })
117
+
118
+ return {
119
+ total,
120
+ available,
121
+ inCooldown,
122
+ currentIndex: this.currentIndex,
123
+ usageStats
124
+ }
125
+ }
126
+
127
+ /**
128
+ * 获取可用账户列表
129
+ * @private
130
+ */
131
+ _getAvailableAccounts() {
132
+ return this.accounts.filter(account => this._isAccountAvailable(account))
133
+ }
134
+
135
+ /**
136
+ * 检查账户是否可用
137
+ * @param {Object} account - 账户对象
138
+ * @returns {boolean} 是否可用
139
+ * @private
140
+ */
141
+ _isAccountAvailable(account) {
142
+ if (!account.token) {
143
+ return false
144
+ }
145
+
146
+ const failures = this.failureCounts.get(account.email) || 0
147
+ if (failures >= this.maxFailures) {
148
+ const lastUsed = this.lastUsedTimes.get(account.email)
149
+ if (lastUsed && Date.now() - lastUsed < this.cooldownPeriod) {
150
+ return false // 仍在冷却期
151
+ } else {
152
+ // 冷却期结束,重置失败计数
153
+ this.failureCounts.delete(account.email)
154
+ }
155
+ }
156
+
157
+ return true
158
+ }
159
+
160
+ /**
161
+ * 选择最少使用的账户
162
+ * @param {Array} accounts - 可用账户列表
163
+ * @returns {Object} 选中的账户
164
+ * @private
165
+ */
166
+ _selectLeastUsedAccount(accounts) {
167
+ if (accounts.length === 1) {
168
+ return accounts[0]
169
+ }
170
+
171
+ // 按最后使用时间排序,选择最久未使用的
172
+ return accounts.reduce((least, current) => {
173
+ const leastLastUsed = this.lastUsedTimes.get(least.email) || 0
174
+ const currentLastUsed = this.lastUsedTimes.get(current.email) || 0
175
+
176
+ return currentLastUsed < leastLastUsed ? current : least
177
+ })
178
+ }
179
+
180
+ /**
181
+ * 轮询策略获取令牌
182
+ * @returns {string|null} 账户令牌或null
183
+ * @private
184
+ */
185
+ _getTokenByRoundRobin() {
186
+ if (this.currentIndex >= this.accounts.length) {
187
+ this.currentIndex = 0
188
+ }
189
+
190
+ const account = this.accounts[this.currentIndex]
191
+ this.currentIndex++
192
+
193
+ if (account && account.token) {
194
+ this._recordUsage(account.email)
195
+ return account.token
196
+ }
197
+
198
+ // 如果当前账户无效,尝试下一个
199
+ if (this.currentIndex < this.accounts.length) {
200
+ return this._getTokenByRoundRobin()
201
+ }
202
+
203
+ return null
204
+ }
205
+
206
+ /**
207
+ * 记录账户使用
208
+ * @param {string} email - 邮箱地址
209
+ * @private
210
+ */
211
+ _recordUsage(email) {
212
+ this.lastUsedTimes.set(email, Date.now())
213
+ }
214
+
215
+ /**
216
+ * 清理不存在账户的记录
217
+ * @private
218
+ */
219
+ _cleanupRecords() {
220
+ const currentEmails = new Set(this.accounts.map(acc => acc.email))
221
+
222
+ // 清理失败计数记录
223
+ for (const email of this.failureCounts.keys()) {
224
+ if (!currentEmails.has(email)) {
225
+ this.failureCounts.delete(email)
226
+ }
227
+ }
228
+
229
+ // 清理使用时间记录
230
+ for (const email of this.lastUsedTimes.keys()) {
231
+ if (!currentEmails.has(email)) {
232
+ this.lastUsedTimes.delete(email)
233
+ }
234
+ }
235
+ }
236
+
237
+ /**
238
+ * 重置所有统计数据
239
+ */
240
+ reset() {
241
+ this.currentIndex = 0
242
+ this.lastUsedTimes.clear()
243
+ this.failureCounts.clear()
244
+ }
245
+ }
246
+
247
+ module.exports = AccountRotator
src/utils/account.js ADDED
@@ -0,0 +1,670 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const config = require('../config/index.js')
2
+ const DataPersistence = require('./data-persistence')
3
+ const TokenManager = require('./token-manager')
4
+ const AccountRotator = require('./account-rotator')
5
+ const { logger } = require('./logger')
6
+ /**
7
+ * 账户管理器
8
+ * 统一管理账户、令牌、模型等功能
9
+ */
10
+ class Account {
11
+ constructor() {
12
+ // 初始化各个管理器
13
+ this.dataPersistence = new DataPersistence()
14
+ this.tokenManager = new TokenManager()
15
+ this.accountRotator = new AccountRotator()
16
+
17
+ // 账户数据
18
+ this.accountTokens = []
19
+ this.isInitialized = false
20
+
21
+ // 配置信息
22
+ this.defaultHeaders = config.defaultHeaders || {}
23
+
24
+ // cli请求次数定时刷新器
25
+ this.cliRequestNumberInterval = null
26
+ this.cliDailyResetInterval = null
27
+
28
+ // 初始化
29
+ this._initialize()
30
+ }
31
+
32
+ /**
33
+ * 异步初始化
34
+ * @private
35
+ */
36
+ async _initialize() {
37
+ try {
38
+ // 加载账户信息
39
+ await this.loadAccountTokens()
40
+
41
+ // 设置定期刷新令牌
42
+ if (config.autoRefresh) {
43
+ this.refreshInterval = setInterval(
44
+ () => this.autoRefreshTokens(),
45
+ (config.autoRefreshInterval || 21600) * 1000 // 默认6小时
46
+ )
47
+ }
48
+
49
+ this.isInitialized = true
50
+ logger.success(`账户管理器初始化完成,共加载 ${this.accountTokens.length} 个账户`, 'ACCOUNT')
51
+ } catch (error) {
52
+ logger.error('账户管理器初始化失败', 'ACCOUNT', '', error)
53
+ }
54
+ }
55
+
56
+ /**
57
+ * 加载账户令牌数据
58
+ * @returns {Promise<void>}
59
+ */
60
+ async loadAccountTokens() {
61
+ try {
62
+ this.accountTokens = await this.dataPersistence.loadAccounts()
63
+
64
+ // 如果是环境变量模式,需要进行登录获取令牌
65
+ if (config.dataSaveMode === 'none' && this.accountTokens.length > 0) {
66
+ await this._loginEnvironmentAccounts()
67
+ }
68
+
69
+ // 验证和清理无效令牌
70
+ await this._validateAndCleanTokens()
71
+
72
+ // 更新账户轮询器
73
+ this.accountRotator.setAccounts(this.accountTokens)
74
+
75
+ // 初始化 CLI 账户,随机初始化一个账号
76
+ if (this.accountTokens.length > 0) {
77
+ const randomIndex = Math.floor(Math.random() * this.accountTokens.length)
78
+ const randomAccount = this.accountTokens[randomIndex]
79
+ logger.info(`初始化 CLI 账户, 随机初始化账号: ${randomAccount.email}`, 'ACCOUNT')
80
+ await this._initializeCliAccount(randomAccount)
81
+ }
82
+
83
+ // 设置cli定时器 每天00:00:00刷新请求次数
84
+ this._setupDailyResetTimer()
85
+
86
+ logger.success(`成功加载 ${this.accountTokens.length} 个账户`, 'ACCOUNT')
87
+ } catch (error) {
88
+ logger.error('加载账户令牌失败', 'ACCOUNT', '', error)
89
+ this.accountTokens = []
90
+ }
91
+ }
92
+
93
+ /**
94
+ * 为环境变量模式的账户进行登录
95
+ * @private
96
+ */
97
+ async _loginEnvironmentAccounts() {
98
+ const loginPromises = this.accountTokens.map(async (account) => {
99
+ if (!account.token && account.email && account.password) {
100
+ const token = await this.tokenManager.login(account.email, account.password)
101
+ if (token) {
102
+ const decoded = this.tokenManager.validateToken(token)
103
+ if (decoded) {
104
+ account.token = token
105
+ account.expires = decoded.exp
106
+ }
107
+ }
108
+ }
109
+ return account
110
+ })
111
+
112
+ this.accountTokens = await Promise.all(loginPromises)
113
+ }
114
+
115
+ /**
116
+ * 初始化CLI账户
117
+ * @param {Object} account - 账户对象
118
+ * @private
119
+ */
120
+ async _initializeCliAccount(account) {
121
+ try {
122
+ const cliManager = require('./cli.manager')
123
+ const cliAccount = await cliManager.initCliAccount(account.token)
124
+
125
+ if (cliAccount.access_token && cliAccount.refresh_token && cliAccount.expiry_date) {
126
+ account.cli_info = {
127
+ access_token: cliAccount.access_token,
128
+ refresh_token: cliAccount.refresh_token,
129
+ expiry_date: cliAccount.expiry_date,
130
+ refresh_token_interval: setInterval(async () => {
131
+ try {
132
+ const refreshToken = await cliManager.refreshAccessToken({
133
+ access_token: account.cli_info.access_token,
134
+ refresh_token: account.cli_info.refresh_token,
135
+ expiry_date: account.cli_info.expiry_date
136
+ })
137
+ if (refreshToken.access_token && refreshToken.refresh_token && refreshToken.expiry_date) {
138
+ account.cli_info.access_token = refreshToken.access_token
139
+ account.cli_info.refresh_token = refreshToken.refresh_token
140
+ account.cli_info.expiry_date = refreshToken.expiry_date
141
+ logger.info(`CLI账户 ${account.email} 令牌刷新成功`, 'CLI')
142
+ }
143
+ } catch (error) {
144
+ logger.error(`CLI账户 ${account.email} 令牌刷新失败`, 'CLI', '', error)
145
+ }
146
+ // 每2小时刷新一次
147
+ }, 1000 * 60 * 60 * 2),
148
+ request_number: 0
149
+ }
150
+ logger.success(`CLI账户 ${account.email} 初始化成功`, 'CLI')
151
+ } else {
152
+ logger.error(`CLI账户 ${account.email} 初始化失败:无效的响应数据`, 'CLI')
153
+ }
154
+ } catch (error) {
155
+ logger.error(`CLI账户 ${account.email} 初始化失败`, 'CLI', '', error)
156
+ }
157
+ }
158
+
159
+ /**
160
+ * 设置每日重置定时器
161
+ * @private
162
+ */
163
+ _setupDailyResetTimer() {
164
+ logger.info('设置CLI请求次数每日重置定时器', 'CLI')
165
+
166
+ // 计算到下一天00:00:00的毫秒数
167
+ const now = new Date()
168
+ const tomorrow = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1, 0, 0, 0)
169
+ const timeDiff = tomorrow.getTime() - now.getTime()
170
+
171
+ logger.info(`距离下次重置还有 ${Math.round(timeDiff / 1000 / 60)} 分钟`, 'CLI')
172
+
173
+ // 首次执行使用setTimeout
174
+ this.cliRequestNumberInterval = setTimeout(() => {
175
+ // 重置所有CLI账户的请求次数
176
+ this._resetCliRequestNumbers()
177
+
178
+ // 设置每24小时执行一次的定时器
179
+ this.cliDailyResetInterval = setInterval(() => {
180
+ this._resetCliRequestNumbers()
181
+ }, 24 * 60 * 60 * 1000)
182
+ }, timeDiff)
183
+ }
184
+
185
+ /**
186
+ * 重置CLI请求次数
187
+ * @private
188
+ */
189
+ _resetCliRequestNumbers() {
190
+ const cliAccounts = this.accountTokens.filter(account => account.cli_info)
191
+ cliAccounts.forEach(account => {
192
+ account.cli_info.request_number = 0
193
+ })
194
+ logger.info(`已重置 ${cliAccounts.length} 个CLI账户的请求次数`, 'CLI')
195
+ }
196
+
197
+ /**
198
+ * 验证和清理无效令牌
199
+ * @private
200
+ */
201
+ async _validateAndCleanTokens() {
202
+ const validAccounts = []
203
+
204
+ for (const account of this.accountTokens) {
205
+ if (account.token && this.tokenManager.validateToken(account.token)) {
206
+ validAccounts.push(account)
207
+ } else if (account.email && account.password) {
208
+ // 尝试重新登录
209
+ logger.info(`令牌无效,尝试重新登录: ${account.email}`, 'TOKEN', '🔄')
210
+ const newToken = await this.tokenManager.login(account.email, account.password)
211
+ if (newToken) {
212
+ const decoded = this.tokenManager.validateToken(newToken)
213
+ if (decoded) {
214
+ account.token = newToken
215
+ account.expires = decoded.exp
216
+ validAccounts.push(account)
217
+ }
218
+ }
219
+ }
220
+ }
221
+
222
+ this.accountTokens = validAccounts
223
+ }
224
+
225
+
226
+ /**
227
+ * 自动刷新即将过期的令牌
228
+ * @param {number} thresholdHours - 过期阈值(小时)
229
+ * @returns {Promise<number>} 成功刷新的令牌数量
230
+ */
231
+ async autoRefreshTokens(thresholdHours = 24) {
232
+ if (!this.isInitialized) {
233
+ logger.warn('账户管理器尚未初始化,跳过自动刷新', 'TOKEN')
234
+ return 0
235
+ }
236
+
237
+ logger.info('开始自动刷新令牌...', 'TOKEN', '🔄')
238
+
239
+ // 获取需要刷新的账户
240
+ const needsRefresh = this.accountTokens.filter(account =>
241
+ this.tokenManager.isTokenExpiringSoon(account.token, thresholdHours)
242
+ )
243
+
244
+ if (needsRefresh.length === 0) {
245
+ logger.info('没有需要刷新的令牌', 'TOKEN')
246
+ return 0
247
+ }
248
+
249
+ logger.info(`发现 ${needsRefresh.length} 个令牌需要刷新`, 'TOKEN')
250
+
251
+ let successCount = 0
252
+ let failedCount = 0
253
+
254
+ // 逐个刷新账户,每次成功后立即保存
255
+ for (const account of needsRefresh) {
256
+ try {
257
+ const updatedAccount = await this.tokenManager.refreshToken(account)
258
+ if (updatedAccount) {
259
+ // 立即更新内存中的账户数据
260
+ const index = this.accountTokens.findIndex(acc => acc.email === account.email)
261
+ if (index !== -1) {
262
+ this.accountTokens[index] = updatedAccount
263
+ }
264
+
265
+ // 立即保存���持久化存储
266
+ await this.dataPersistence.saveAccount(account.email, {
267
+ password: updatedAccount.password,
268
+ token: updatedAccount.token,
269
+ expires: updatedAccount.expires
270
+ })
271
+
272
+ // 重置失败计数
273
+ this.accountRotator.resetFailures(account.email)
274
+ successCount++
275
+
276
+ logger.info(`账户 ${account.email} 令牌刷新并保存成功 (${successCount}/${needsRefresh.length})`, 'TOKEN', '✅')
277
+ } else {
278
+ // 记录失败的账户
279
+ this.accountRotator.recordFailure(account.email)
280
+ failedCount++
281
+ logger.error(`账户 ${account.email} 令牌刷新失败 (${failedCount} 个失败)`, 'TOKEN', '❌')
282
+ }
283
+ } catch (error) {
284
+ this.accountRotator.recordFailure(account.email)
285
+ failedCount++
286
+ logger.error(`账户 ${account.email} 刷新过程中出错`, 'TOKEN', '', error)
287
+ }
288
+
289
+ // 添加延迟避免请求过于频繁
290
+ await this._delay(1000)
291
+ }
292
+
293
+ // 更新轮询器
294
+ this.accountRotator.setAccounts(this.accountTokens)
295
+
296
+ logger.success(`令牌刷新完成: 成功 ${successCount} 个,失败 ${failedCount} 个`, 'TOKEN')
297
+ return successCount
298
+ }
299
+
300
+ /**
301
+ * 获取可用的账户令牌
302
+ * @returns {string|null} 账户令牌或null
303
+ */
304
+ getAccountToken() {
305
+ if (!this.isInitialized) {
306
+ logger.warn('账户管理器尚未初始化完成', 'ACCOUNT')
307
+ return null
308
+ }
309
+
310
+ if (this.accountTokens.length === 0) {
311
+ logger.error('没有可用的账户令牌', 'ACCOUNT')
312
+ return null
313
+ }
314
+
315
+ const token = this.accountRotator.getNextToken()
316
+ if (!token) {
317
+ logger.error('所有账户令牌都不可用', 'ACCOUNT')
318
+ }
319
+
320
+ return token
321
+ }
322
+
323
+ /**
324
+ * 根据邮箱获取特定账户的令牌
325
+ * @param {string} email - 邮箱地址
326
+ * @returns {string|null} 账户令牌或null
327
+ */
328
+ getTokenByEmail(email) {
329
+ return this.accountRotator.getTokenByEmail(email)
330
+ }
331
+
332
+ /**
333
+ * 保存更新后的账户数据
334
+ * @param {Array} updatedAccounts - 更新后的账户列表
335
+ * @private
336
+ */
337
+ async _saveUpdatedAccounts(updatedAccounts) {
338
+ try {
339
+ for (const account of updatedAccounts) {
340
+ await this.dataPersistence.saveAccount(account.email, {
341
+ password: account.password,
342
+ token: account.token,
343
+ expires: account.expires
344
+ })
345
+ }
346
+ } catch (error) {
347
+ logger.error('保存更新后的账户数据失败', 'ACCOUNT', '', error)
348
+ }
349
+ }
350
+
351
+ /**
352
+ * 手动刷新指定账户的令牌
353
+ * @param {string} email - 邮箱地址
354
+ * @returns {Promise<boolean>} 刷新是否成功
355
+ */
356
+ async refreshAccountToken(email) {
357
+ const account = this.accountTokens.find(acc => acc.email === email)
358
+ if (!account) {
359
+ logger.error(`未找到邮箱为 ${email} 的账户`, 'ACCOUNT')
360
+ return false
361
+ }
362
+
363
+ const updatedAccount = await this.tokenManager.refreshToken(account)
364
+ if (updatedAccount) {
365
+ // 更新内存中的数据
366
+ const index = this.accountTokens.findIndex(acc => acc.email === email)
367
+ if (index !== -1) {
368
+ this.accountTokens[index] = updatedAccount
369
+ }
370
+
371
+ // 保存到持久化存储
372
+ await this.dataPersistence.saveAccount(email, {
373
+ password: updatedAccount.password,
374
+ token: updatedAccount.token,
375
+ expires: updatedAccount.expires
376
+ })
377
+
378
+ // 重置失败计数
379
+ this.accountRotator.resetFailures(email)
380
+
381
+ return true
382
+ }
383
+
384
+ return false
385
+ }
386
+
387
+ // 更新销毁方法,清除定时器
388
+ destroy() {
389
+ if (this.saveInterval) {
390
+ clearInterval(this.saveInterval)
391
+ }
392
+ if (this.refreshInterval) {
393
+ clearInterval(this.refreshInterval)
394
+ }
395
+ }
396
+
397
+
398
+
399
+ /**
400
+ * 生成 Markdown 表格
401
+ * @param {Array} websites - 网站信息数组
402
+ * @param {string} mode - 模式 ('table' 或 'text')
403
+ * @returns {Promise<string>} Markdown 字符串
404
+ */
405
+ async generateMarkdownTable(websites, mode) {
406
+ // 输入校验
407
+ if (!Array.isArray(websites) || websites.length === 0) {
408
+ return ''
409
+ }
410
+
411
+ let markdown = ''
412
+ if (mode === 'table') {
413
+ markdown += '| **序号** | **网站URL** | **来源** |\n'
414
+ markdown += '|:---|:---|:---|\n'
415
+ }
416
+
417
+ // 默认值
418
+ const DEFAULT_TITLE = '未知标题'
419
+ const DEFAULT_URL = 'https://www.baidu.com'
420
+ const DEFAULT_HOSTNAME = '未知来源'
421
+
422
+ // 表格内容
423
+ websites.forEach((site, index) => {
424
+ const { title, url, hostname } = site
425
+ // 处理字段值,若为空则使用默认值
426
+ const urlCell = `[${title || DEFAULT_TITLE}](${url || DEFAULT_URL})`
427
+ const hostnameCell = hostname || DEFAULT_HOSTNAME
428
+ if (mode === 'table') {
429
+ markdown += `| ${index + 1} | ${urlCell} | ${hostnameCell} |\n`
430
+ } else {
431
+ markdown += `[${index + 1}] ${urlCell} | 来源: ${hostnameCell}\n`
432
+ }
433
+ })
434
+
435
+ return markdown
436
+ }
437
+
438
+
439
+
440
+ /**
441
+ * 获取所有账户信息
442
+ * @returns {Array} 账户列表
443
+ */
444
+ getAllAccountKeys() {
445
+ return this.accountTokens
446
+ }
447
+
448
+ /**
449
+ * 用户登录(委托给 TokenManager)
450
+ * @param {string} email - 邮箱
451
+ * @param {string} password - 密码
452
+ * @returns {Promise<string|null>} 令牌或null
453
+ */
454
+ async login(email, password) {
455
+ return await this.tokenManager.login(email, password)
456
+ }
457
+
458
+ /**
459
+ * 获取账户健康状态统计
460
+ * @returns {Object} 健康状态统计
461
+ */
462
+ getHealthStats() {
463
+ const tokenStats = this.tokenManager.getTokenHealthStats(this.accountTokens)
464
+ const rotatorStats = this.accountRotator.getStats()
465
+
466
+ return {
467
+ accounts: tokenStats,
468
+ rotation: rotatorStats,
469
+ initialized: this.isInitialized
470
+ }
471
+ }
472
+
473
+ /**
474
+ * 记录账户使用失败
475
+ * @param {string} email - 邮箱地址
476
+ */
477
+ recordAccountFailure(email) {
478
+ this.accountRotator.recordFailure(email)
479
+ }
480
+
481
+ /**
482
+ * 重置账户失败计数
483
+ * @param {string} email - 邮箱地址
484
+ */
485
+ resetAccountFailures(email) {
486
+ this.accountRotator.resetFailures(email)
487
+ }
488
+
489
+ /**
490
+ * 添加新账户
491
+ * @param {string} email - 邮箱
492
+ * @param {string} password - 密码
493
+ * @returns {Promise<boolean>} 添加是否成功
494
+ */
495
+ async addAccount(email, password) {
496
+ try {
497
+ // 检查账户是否已存在
498
+ const existingAccount = this.accountTokens.find(acc => acc.email === email)
499
+ if (existingAccount) {
500
+ logger.warn(`账户 ${email} 已存在`, 'ACCOUNT')
501
+ return false
502
+ }
503
+
504
+ // 尝试登录获取令牌
505
+ const token = await this.tokenManager.login(email, password)
506
+ if (!token) {
507
+ logger.error(`账户 ${email} 登录失败,无法添加`, 'ACCOUNT')
508
+ return false
509
+ }
510
+
511
+ const decoded = this.tokenManager.validateToken(token)
512
+ if (!decoded) {
513
+ logger.error(`账户 ${email} 令牌无效,无法添加`, 'ACCOUNT')
514
+ return false
515
+ }
516
+
517
+ const newAccount = {
518
+ email,
519
+ password,
520
+ token,
521
+ expires: decoded.exp
522
+ }
523
+
524
+ // 添加到内存
525
+ this.accountTokens.push(newAccount)
526
+
527
+ // 保存到持久化存储
528
+ await this.dataPersistence.saveAccount(email, newAccount)
529
+
530
+ // 更新轮询器
531
+ this.accountRotator.setAccounts(this.accountTokens)
532
+
533
+ logger.success(`成功添加账户: ${email}`, 'ACCOUNT')
534
+ return true
535
+ } catch (error) {
536
+ logger.error(`添加账户失败 (${email})`, 'ACCOUNT', '', error)
537
+ return false
538
+ }
539
+ }
540
+
541
+ /**
542
+ * 移除账户
543
+ * @param {string} email - 邮箱地址
544
+ * @returns {Promise<boolean>} 移除是否成功
545
+ */
546
+ async removeAccount(email) {
547
+ try {
548
+ const index = this.accountTokens.findIndex(acc => acc.email === email)
549
+ if (index === -1) {
550
+ logger.warn(`账户 ${email} 不存在`, 'ACCOUNT')
551
+ return false
552
+ }
553
+
554
+ // 从内存中移除
555
+ this.accountTokens.splice(index, 1)
556
+
557
+ // 更新轮询器
558
+ this.accountRotator.setAccounts(this.accountTokens)
559
+
560
+ logger.success(`成功移除账户: ${email}`, 'ACCOUNT')
561
+ return true
562
+ } catch (error) {
563
+ logger.error(`移除账户失败 (${email})`, 'ACCOUNT', '', error)
564
+ return false
565
+ }
566
+ }
567
+
568
+ /**
569
+ * 删除账户(向后兼容)
570
+ * @param {string} email - 邮箱地址
571
+ * @returns {boolean} 删除是否成功
572
+ */
573
+ deleteAccount(email) {
574
+ const index = this.accountTokens.findIndex(t => t.email === email)
575
+ if (index !== -1) {
576
+ this.accountTokens.splice(index, 1)
577
+ this.accountRotator.setAccounts(this.accountTokens)
578
+ return true
579
+ }
580
+ return false
581
+ }
582
+
583
+ /**
584
+ * 为指定账户初始化CLI信��(公共方法)
585
+ * @param {Object} account - 账户对象
586
+ * @returns {Promise<boolean>} 初始化是否成功
587
+ */
588
+ async initializeCliForAccount(account) {
589
+ if (!account) {
590
+ logger.error('账户对象不能为空', 'CLI')
591
+ return false
592
+ }
593
+
594
+ try {
595
+ await this._initializeCliAccount(account)
596
+ return true
597
+ } catch (error) {
598
+ logger.error(`为账户 ${account.email} 初始化CLI失败`, 'CLI', '', error)
599
+ return false
600
+ }
601
+ }
602
+
603
+ /**
604
+ * 延迟函数
605
+ * @param {number} ms - 延迟毫秒数
606
+ * @private
607
+ */
608
+ async _delay(ms) {
609
+ return new Promise(resolve => setTimeout(resolve, ms))
610
+ }
611
+
612
+ /**
613
+ * 清理资源
614
+ */
615
+ destroy() {
616
+ // 清理自动刷新定时器
617
+ if (this.refreshInterval) {
618
+ clearInterval(this.refreshInterval)
619
+ this.refreshInterval = null
620
+ }
621
+
622
+ // 清理CLI请求次数重置定时器
623
+ if (this.cliRequestNumberInterval) {
624
+ clearTimeout(this.cliRequestNumberInterval)
625
+ this.cliRequestNumberInterval = null
626
+ }
627
+
628
+ if (this.cliDailyResetInterval) {
629
+ clearInterval(this.cliDailyResetInterval)
630
+ this.cliDailyResetInterval = null
631
+ }
632
+
633
+ // 清理所有CLI账户的刷新定时器
634
+ this.accountTokens.forEach(account => {
635
+ if (account.cli_info && account.cli_info.refresh_token_interval) {
636
+ clearInterval(account.cli_info.refresh_token_interval)
637
+ account.cli_info.refresh_token_interval = null
638
+ }
639
+ })
640
+
641
+ this.accountRotator.reset()
642
+ logger.info('账户管理器已清理资源', 'ACCOUNT', '🧹')
643
+ }
644
+
645
+ }
646
+
647
+ if (!(process.env.API_KEY || config.apiKey)) {
648
+ logger.error('请务必设置 API_KEY 环境变量', 'CONFIG', '⚙️')
649
+ process.exit(1)
650
+ }
651
+
652
+ const accountManager = new Account()
653
+
654
+ // 添加进程退出时的清理
655
+ process.on('exit', () => {
656
+ if (accountManager) {
657
+ accountManager.destroy()
658
+ }
659
+ })
660
+
661
+ // 处理意外退出
662
+ process.on('SIGINT', () => {
663
+ if (accountManager) {
664
+ accountManager.destroy()
665
+ }
666
+ process.exit(0)
667
+ })
668
+
669
+
670
+ module.exports = accountManager
src/utils/chat-helpers.js ADDED
@@ -0,0 +1,376 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const { logger } = require('./logger');
2
+ const { sha256Encrypt, generateUUID } = require('./tools.js');
3
+ const { uploadFileToQwenOss } = require('./upload.js');
4
+ const accountManager = require('./account.js');
5
+ const CacheManager = require('./img-caches.js');
6
+
7
+ /**
8
+ * 判断聊天类型
9
+ * @param {string} model - 模型名称
10
+ * @param {boolean} search - 是否搜索模式
11
+ * @returns {string} 聊天类型 ('search' 或 't2t')
12
+ */
13
+ const isChatType = (model) => {
14
+ if (!model) return 't2t';
15
+ if (model.includes('-search')) {
16
+ return 'search';
17
+ } else if (model.includes('-image-edit')) {
18
+ return 'image_edit';
19
+ } else if (model.includes('-image')) {
20
+ return 't2i';
21
+ } else if (model.includes('-video')) {
22
+ return 't2v';
23
+ } else if (model.includes('-deep-research')) {
24
+ return 'deep_research';
25
+ } else {
26
+ return 't2t';
27
+ }
28
+ }
29
+
30
+ /**
31
+ * 判断是否启用思考模式
32
+ * @param {string} model - 模型名称
33
+ * @param {boolean} enable_thinking - 是否启用思考
34
+ * @param {number} thinking_budget - 思考预算
35
+ * @returns {object} 思考配置对象
36
+ */
37
+ const isThinkingEnabled = (model, enable_thinking, thinking_budget) => {
38
+ const thinking_config = {
39
+ "output_schema": "phase",
40
+ "thinking_enabled": false,
41
+ "thinking_budget": 81920
42
+ }
43
+
44
+ if (!model) return thinking_config;
45
+
46
+ if (model.includes('-thinking') || enable_thinking) {
47
+ thinking_config.thinking_enabled = true;
48
+ }
49
+
50
+ if (thinking_budget && Number(thinking_budget) !== Number.NaN && Number(thinking_budget) > 0 && Number(thinking_budget) < 38912) {
51
+ thinking_config.budget = Number(thinking_budget);
52
+ }
53
+
54
+ return thinking_config;
55
+ }
56
+
57
+ /**
58
+ * 解析模型名称,移除特殊后缀
59
+ * @param {string} model - 原始模型名称
60
+ * @returns {string} 解析后的模型名称
61
+ */
62
+ const parserModel = (model) => {
63
+ if (!model) return 'qwen3-coder-plus';
64
+
65
+ try {
66
+ model = String(model);
67
+ model = model.replace('-search', '');
68
+ model = model.replace('-thinking', '');
69
+ model = model.replace('-edit', '');
70
+ model = model.replace('-video', '');
71
+ model = model.replace('-deep-research', '');
72
+ model = model.replace('-image', '');
73
+ return model;
74
+ } catch (e) {
75
+ return 'qwen3-coder-plus';
76
+ }
77
+ }
78
+
79
+ /**
80
+ * 从消息中提取文本内容
81
+ * @param {string|Array} content - 消息内容
82
+ * @returns {string} 提取的文本
83
+ */
84
+ const extractTextFromContent = (content) => {
85
+ if (typeof content === 'string') {
86
+ return content;
87
+ } else if (Array.isArray(content)) {
88
+ const textParts = content
89
+ .filter(item => item.type === 'text')
90
+ .map(item => item.text || '');
91
+ return textParts.join(' ');
92
+ }
93
+ return '';
94
+ }
95
+
96
+ /**
97
+ * 格式化消息为文本(包含角色标注)
98
+ * @param {object} message - 单条消息
99
+ * @returns {string} 格式化后的消息文本
100
+ */
101
+ const formatSingleMessage = (message) => {
102
+ const role = message.role;
103
+ const content = extractTextFromContent(message.content);
104
+ return content.trim() ? `${role}:${content}` : '';
105
+ }
106
+
107
+ /**
108
+ * 格式化历史消息为文本前缀
109
+ * @param {Array} messages - 消息数组(不包含最后一条)
110
+ * @returns {string} 格式化后的历史消息
111
+ */
112
+ const formatHistoryMessages = (messages) => {
113
+ const formattedParts = [];
114
+
115
+ for (let message of messages) {
116
+ const formatted = formatSingleMessage(message);
117
+ if (formatted) {
118
+ formattedParts.push(formatted);
119
+ }
120
+ }
121
+
122
+ return formattedParts.length > 0 ? formattedParts.join(';') : '';
123
+ }
124
+
125
+ /**
126
+ * 解析消息格式,处理图片上传和消息结构
127
+ * @param {Array} messages - 原始消息数组
128
+ * @param {object} thinking_config - 思考配置
129
+ * @param {string} chat_type - 聊天类型
130
+ * @returns {Promise<Array>} 解析后的消息数组
131
+ */
132
+ const parserMessages = async (messages, thinking_config, chat_type) => {
133
+ try {
134
+ const feature_config = thinking_config;
135
+ const imgCacheManager = new CacheManager();
136
+
137
+ // 如果只有一条消息,使用原有逻辑处理(不标注角色)
138
+ if (messages.length <= 1) {
139
+ logger.network('单条消息,使用原格式处理', 'PARSER');
140
+ return await processOriginalLogic(messages, thinking_config, chat_type, imgCacheManager);
141
+ }
142
+
143
+ // 多条消息的情况:分离历史消息和最后一条消息
144
+ logger.network('多条消息,格式化处理并标注角色', 'PARSER');
145
+ const historyMessages = messages.slice(0, -1);
146
+ const lastMessage = messages[messages.length - 1];
147
+
148
+ // 格式化历史消息为文本前缀
149
+ const historyText = formatHistoryMessages(historyMessages);
150
+
151
+ // 处理最后一条消息
152
+ let finalContent = [];
153
+ let lastMessageText = '';
154
+ const lastMessageRole = lastMessage.role;
155
+
156
+ if (typeof lastMessage.content === 'string') {
157
+ lastMessageText = lastMessage.content;
158
+ } else if (Array.isArray(lastMessage.content)) {
159
+ // 处理最后一条消息中的内容
160
+ for (let item of lastMessage.content) {
161
+ if (item.type === 'text') {
162
+ lastMessageText += item.text || '';
163
+ } else if (item.type === 'image' || item.type === 'image_url') {
164
+ // 处理图片上传
165
+ let base64 = null;
166
+ if (item.type === 'image_url') {
167
+ base64 = item.image_url.url;
168
+ }
169
+
170
+ if (base64) {
171
+ const regex = /data:(.+);base64,/;
172
+ const fileType = base64.match(regex);
173
+ const fileExtension = fileType && fileType[1] ? fileType[1].split('/')[1] || 'png' : 'png';
174
+ const filename = `${generateUUID()}.${fileExtension}`;
175
+ base64 = base64.replace(regex, '');
176
+ const signature = sha256Encrypt(base64);
177
+
178
+ try {
179
+ const buffer = Buffer.from(base64, 'base64');
180
+ const cacheIsExist = imgCacheManager.cacheIsExist(signature);
181
+
182
+ if (cacheIsExist) {
183
+ finalContent.push({
184
+ type: 'image',
185
+ image: imgCacheManager.getCache(signature).url
186
+ });
187
+ } else {
188
+ const uploadResult = await uploadFileToQwenOss(buffer, filename, accountManager.getAccountToken());
189
+ if (uploadResult && uploadResult.status === 200) {
190
+ finalContent.push({
191
+ type: 'image',
192
+ image: uploadResult.file_url
193
+ });
194
+ imgCacheManager.addCache(signature, uploadResult.file_url);
195
+ }
196
+ }
197
+ } catch (error) {
198
+ logger.error('图片上传失败', 'UPLOAD', '', error);
199
+ }
200
+ }
201
+ }
202
+ }
203
+ }
204
+
205
+ // 组合最终内容:历史文本 + 当前消息(带角色标注)
206
+ let combinedText = '';
207
+ if (historyText) {
208
+ combinedText = historyText + ';';
209
+ }
210
+ // 添加最后一条消息,带角色标注
211
+ if (lastMessageText.trim()) {
212
+ combinedText += `${lastMessageRole}:${lastMessageText}`;
213
+ }
214
+
215
+ // 如果有图片,创建包含文本和图片的content数组
216
+ if (finalContent.length > 0) {
217
+ finalContent.unshift({
218
+ type: 'text',
219
+ text: combinedText,
220
+ chat_type: 't2t',
221
+ feature_config: {
222
+ "output_schema": "phase",
223
+ "thinking_enabled": false,
224
+ }
225
+ });
226
+
227
+ return [
228
+ {
229
+ "role": "user",
230
+ "content": finalContent,
231
+ "chat_type": chat_type,
232
+ "extra": {},
233
+ "feature_config": feature_config
234
+ }
235
+ ];
236
+ } else {
237
+ // 纯文本情况
238
+ return [
239
+ {
240
+ "role": "user",
241
+ "content": combinedText,
242
+ "chat_type": chat_type,
243
+ "extra": {},
244
+ "feature_config": feature_config
245
+ }
246
+ ];
247
+ }
248
+
249
+ } catch (e) {
250
+ logger.error('消息解析失败', 'PARSER', '', e);
251
+ return [
252
+ {
253
+ "role": "user",
254
+ "content": "直接返回字符串: '聊天历史处理有误...'",
255
+ "chat_type": "t2t",
256
+ "extra": {},
257
+ "feature_config": {
258
+ "output_schema": "phase",
259
+ "enabled": false,
260
+ }
261
+ }
262
+ ];
263
+ }
264
+ }
265
+
266
+ /**
267
+ * 原有的单条消息处理逻辑
268
+ * @param {Array} messages - 消息数组
269
+ * @param {object} thinking_config - 思考配置
270
+ * @param {string} chat_type - 聊天类型
271
+ * @param {object} imgCacheManager - 图片缓存管理器
272
+ * @returns {Promise<Array>} 处理后的消息数组
273
+ */
274
+ const processOriginalLogic = async (messages, thinking_config, chat_type, imgCacheManager) => {
275
+ const feature_config = thinking_config;
276
+
277
+ for (let message of messages) {
278
+ if (message.role === 'user' || message.role === 'assistant') {
279
+ message.chat_type = "t2t";
280
+ message.extra = {};
281
+ message.feature_config = {
282
+ "output_schema": "phase",
283
+ "thinking_enabled": false,
284
+ };
285
+
286
+ if (!Array.isArray(message.content)) continue;
287
+
288
+ const newContent = [];
289
+
290
+ for (let item of message.content) {
291
+ if (item.type === 'image' || item.type === 'image_url') {
292
+ let base64 = null;
293
+ if (item.type === 'image_url') {
294
+ base64 = item.image_url.url;
295
+ }
296
+ if (base64) {
297
+ const regex = /data:(.+);base64,/;
298
+ const fileType = base64.match(regex);
299
+ const fileExtension = fileType && fileType[1] ? fileType[1].split('/')[1] || 'png' : 'png';
300
+ const filename = `${generateUUID()}.${fileExtension}`;
301
+ base64 = base64.replace(regex, '');
302
+ const signature = sha256Encrypt(base64);
303
+
304
+ try {
305
+ const buffer = Buffer.from(base64, 'base64');
306
+ const cacheIsExist = imgCacheManager.cacheIsExist(signature);
307
+ if (cacheIsExist) {
308
+ delete item.image_url;
309
+ item.type = 'image';
310
+ item.image = imgCacheManager.getCache(signature).url;
311
+ newContent.push(item);
312
+ } else {
313
+ const uploadResult = await uploadFileToQwenOss(buffer, filename, accountManager.getAccountToken());
314
+ if (uploadResult && uploadResult.status === 200) {
315
+ delete item.image_url;
316
+ item.type = 'image';
317
+ item.image = uploadResult.file_url;
318
+ imgCacheManager.addCache(signature, uploadResult.file_url);
319
+ newContent.push(item);
320
+ }
321
+ }
322
+
323
+ } catch (error) {
324
+ logger.error('图片上传失败', 'UPLOAD', '', error);
325
+ }
326
+ }
327
+ } else if (item.type === 'text') {
328
+ item.chat_type = 't2t';
329
+ item.feature_config = {
330
+ "output_schema": "phase",
331
+ "thinking_enabled": false,
332
+ };
333
+
334
+ if (newContent.length >= 2) {
335
+ messages.push({
336
+ "role": "user",
337
+ "content": item.text,
338
+ "chat_type": "t2t",
339
+ "extra": {},
340
+ "feature_config": {
341
+ "output_schema": "phase",
342
+ "thinking_enabled": false,
343
+ }
344
+ });
345
+ } else {
346
+ newContent.push(item);
347
+ }
348
+ }
349
+ }
350
+ } else {
351
+ if (Array.isArray(message.content)) {
352
+ let system_prompt = '';
353
+ for (let item of message.content) {
354
+ if (item.type === 'text') {
355
+ system_prompt += item.text;
356
+ }
357
+ }
358
+ if (system_prompt) {
359
+ message.content = system_prompt;
360
+ }
361
+ }
362
+ }
363
+ }
364
+
365
+ messages[messages.length - 1].feature_config = feature_config;
366
+ messages[messages.length - 1].chat_type = chat_type;
367
+
368
+ return messages;
369
+ }
370
+
371
+ module.exports = {
372
+ isChatType,
373
+ isThinkingEnabled,
374
+ parserModel,
375
+ parserMessages
376
+ }
src/utils/cli.manager.js ADDED
@@ -0,0 +1,279 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const crypto = require('crypto')
2
+ const { getProxyAgent, getChatBaseUrl, applyProxyToFetchOptions } = require('./proxy-helper')
3
+
4
+ /**
5
+ * 为 PKCE 生成随机代码验证器
6
+ * @returns {string} 43-128个字符的随机字符串
7
+ */
8
+ function generateCodeVerifier() {
9
+ return crypto.randomBytes(32).toString('base64url')
10
+ }
11
+
12
+ /**
13
+ * 使用 SHA-256 从代码验证器生成代码挑战
14
+ * @param {string} codeVerifier - 代码验证器字符串
15
+ * @returns {string} 代码挑战字符串
16
+ */
17
+ function generateCodeChallenge(codeVerifier) {
18
+ const hash = crypto.createHash('sha256')
19
+ hash.update(codeVerifier)
20
+ return hash.digest('base64url')
21
+ }
22
+
23
+ /**
24
+ * 生成 PKCE 代码验证器和挑战对
25
+ * @returns {Object} 包含 code_verifier 和 code_challenge 的对象
26
+ */
27
+ function generatePKCEPair() {
28
+ const codeVerifier = generateCodeVerifier()
29
+ const codeChallenge = generateCodeChallenge(codeVerifier)
30
+ return {
31
+ code_verifier: codeVerifier,
32
+ code_challenge: codeChallenge
33
+ }
34
+ }
35
+
36
+ class CliAuthManager {
37
+ /**
38
+ * 启动 OAuth 设备授权流程
39
+ * @returns {Promise<Object>} 包含设备代码、验证URL和代码验证器的对象
40
+ */
41
+ async initiateDeviceFlow() {
42
+ // 生成 PKCE 代码验证器和挑战
43
+ const { code_verifier, code_challenge } = generatePKCEPair()
44
+
45
+ const bodyData = new URLSearchParams({
46
+ client_id: "f0304373b74a44d2b584a3fb70ca9e56",
47
+ scope: "openid profile email model.completion",
48
+ code_challenge: code_challenge,
49
+ code_challenge_method: 'S256',
50
+ })
51
+
52
+ const chatBaseUrl = getChatBaseUrl()
53
+ const proxyAgent = getProxyAgent()
54
+
55
+ const fetchOptions = {
56
+ method: 'POST',
57
+ headers: {
58
+ 'Content-Type': 'application/x-www-form-urlencoded',
59
+ Accept: 'application/json',
60
+ },
61
+ body: bodyData,
62
+ }
63
+
64
+ // 添加代理配置
65
+ if (proxyAgent) {
66
+ fetchOptions.agent = proxyAgent
67
+ }
68
+
69
+ try {
70
+ const response = await fetch(`${chatBaseUrl}/api/v1/oauth2/device/code`, fetchOptions)
71
+
72
+ if (response.ok) {
73
+ const result = await response.json()
74
+ return {
75
+ status: true,
76
+ ...result,
77
+ code_verifier: code_verifier
78
+ }
79
+ } else {
80
+ throw new Error()
81
+ }
82
+ } catch (error) {
83
+ return {
84
+ status: false,
85
+ device_code: null,
86
+ user_code: null,
87
+ verification_uri: null,
88
+ verification_uri_complete: null,
89
+ expires_in: null,
90
+ code_verifier: null
91
+ }
92
+ }
93
+ }
94
+
95
+ /**
96
+ * 授权登录
97
+ * @param {string} user_code - 用户代码
98
+ * @param {string} access_token - 访问令牌
99
+ * @returns {Promise<boolean>} 是否授权成功
100
+ */
101
+ async authorizeLogin(user_code, access_token) {
102
+ try {
103
+ const chatBaseUrl = getChatBaseUrl()
104
+ const proxyAgent = getProxyAgent()
105
+
106
+ const fetchOptions = {
107
+ method: 'POST',
108
+ headers: {
109
+ 'Content-Type': 'application/json',
110
+ "authorization": `Bearer ${access_token}`,
111
+ },
112
+ body: JSON.stringify({
113
+ "approved": true,
114
+ "user_code": user_code
115
+ })
116
+ }
117
+
118
+ if (proxyAgent) {
119
+ fetchOptions.agent = proxyAgent
120
+ }
121
+
122
+ const response = await fetch(`${chatBaseUrl}/api/v2/oauth2/authorize`, fetchOptions)
123
+
124
+ if (response.ok) {
125
+ return true
126
+ } else {
127
+ throw new Error()
128
+ }
129
+ } catch (error) {
130
+ return false
131
+ }
132
+ }
133
+
134
+ /**
135
+ * 轮询获取访问令牌
136
+ * @param {string} device_code - 设备代码
137
+ * @param {string} code_verifier - 代码验证器
138
+ * @returns {Promise<Object>} 访问令牌信息
139
+ */
140
+ async pollForToken(device_code, code_verifier) {
141
+ let pollInterval = 5000
142
+ const maxAttempts = 60
143
+ const chatBaseUrl = getChatBaseUrl()
144
+ const proxyAgent = getProxyAgent()
145
+
146
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
147
+ const bodyData = new URLSearchParams({
148
+ grant_type: "urn:ietf:params:oauth:grant-type:device_code",
149
+ client_id: "f0304373b74a44d2b584a3fb70ca9e56",
150
+ device_code: device_code,
151
+ code_verifier: code_verifier,
152
+ })
153
+
154
+ const fetchOptions = {
155
+ method: 'POST',
156
+ headers: {
157
+ 'Content-Type': 'application/x-www-form-urlencoded',
158
+ Accept: 'application/json',
159
+ },
160
+ body: bodyData,
161
+ }
162
+
163
+ if (proxyAgent) {
164
+ fetchOptions.agent = proxyAgent
165
+ }
166
+
167
+ try {
168
+ const response = await fetch(`${chatBaseUrl}/api/v1/oauth2/token`, fetchOptions)
169
+
170
+ if (response.ok) {
171
+
172
+
173
+ const tokenData = await response.json()
174
+
175
+ // 转换为凭据格式
176
+ const credentials = {
177
+ access_token: tokenData.access_token,
178
+ refresh_token: tokenData.refresh_token || undefined,
179
+ expiry_date: tokenData.expires_in ? Date.now() + tokenData.expires_in * 1000 : undefined,
180
+ }
181
+
182
+ return credentials
183
+ }
184
+
185
+ // 等待5秒, 然后继续轮询
186
+ await new Promise(resolve => setTimeout(resolve, pollInterval))
187
+ } catch (error) {
188
+ // 等待5秒, 然后继续轮询
189
+ await new Promise(resolve => setTimeout(resolve, pollInterval))
190
+ console.log(`轮询尝试 ${attempt + 1}/${maxAttempts} 失败:`, error)
191
+ continue
192
+ }
193
+ }
194
+
195
+ return {
196
+ status: false,
197
+ access_token: null,
198
+ refresh_token: null,
199
+ expiry_date: null
200
+ }
201
+ }
202
+
203
+ /**
204
+ * 初始化 CLI 账户
205
+ * @param {string} access_token - 访问令牌
206
+ * @returns {Promise<Object>} 账户信息
207
+ */
208
+ async initCliAccount(access_token) {
209
+ const deviceFlow = await this.initiateDeviceFlow()
210
+ if (!deviceFlow.status || !await this.authorizeLogin(deviceFlow.user_code, access_token)) {
211
+ return {
212
+ status: false,
213
+ access_token: null,
214
+ refresh_token: null,
215
+ expiry_date: null
216
+ }
217
+ }
218
+
219
+ return await this.pollForToken(deviceFlow.device_code, deviceFlow.code_verifier)
220
+ }
221
+
222
+ /**
223
+ * 刷新访问令牌
224
+ * @param {Object} CliAccount - 账户信息
225
+ * @returns {Promise<Object>} 账户信息
226
+ */
227
+ async refreshAccessToken(CliAccount) {
228
+ try {
229
+
230
+ if (!CliAccount || !CliAccount.refresh_token) {
231
+ throw new Error()
232
+ }
233
+
234
+ const chatBaseUrl = getChatBaseUrl()
235
+ const proxyAgent = getProxyAgent()
236
+
237
+ const bodyData = new URLSearchParams({
238
+ grant_type: 'refresh_token',
239
+ refresh_token: CliAccount.refresh_token,
240
+ client_id: "f0304373b74a44d2b584a3fb70ca9e56",
241
+ })
242
+
243
+ const fetchOptions = {
244
+ method: 'POST',
245
+ headers: {
246
+ 'Content-Type': 'application/x-www-form-urlencoded',
247
+ Accept: 'application/json',
248
+ },
249
+ body: bodyData
250
+ }
251
+
252
+ if (proxyAgent) {
253
+ fetchOptions.agent = proxyAgent
254
+ }
255
+
256
+ const response = await fetch(`${chatBaseUrl}/api/v1/oauth2/token`, fetchOptions)
257
+
258
+ if (response.ok) {
259
+ const tokenData = await response.json()
260
+
261
+ return {
262
+ access_token: tokenData.access_token,
263
+ refresh_token: tokenData.refresh_token || CliAccount.refresh_token,
264
+ expiry_date: Date.now() + tokenData.expires_in * 1000,
265
+ }
266
+ }
267
+ } catch (error) {
268
+ return {
269
+ status: false,
270
+ access_token: null,
271
+ refresh_token: null,
272
+ expiry_date: null
273
+ }
274
+ }
275
+ }
276
+
277
+ }
278
+
279
+ module.exports = new CliAuthManager()
src/utils/cookie-generator.js ADDED
@@ -0,0 +1,376 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // 导入指纹生成器
2
+ const { generateFingerprint } = require('./fingerprint');
3
+
4
+ // 自定义Base64字符表
5
+ const CUSTOM_BASE64_CHARS = "DGi0YA7BemWnQjCl4_bR3f8SKIF9tUz/xhr2oEOgPpac=61ZqwTudLkM5vHyNXsVJ";
6
+
7
+ // 哈希字段位置(这些字段需要随机生成)
8
+ const HASH_FIELDS = {
9
+ 16: 'split', // 插件哈希(格式: count|hash,只替换hash部分)
10
+ 17: 'full', // Canvas指纹哈希
11
+ 18: 'full', // UserAgent哈希
12
+ 31: 'full', // UserAgent哈希2
13
+ 34: 'full', // 文档URL哈希
14
+ 36: 'full' // 文档属性哈希
15
+ };
16
+
17
+ // ==================== LZW压缩算法 ====================
18
+
19
+ function lzwCompress(data, bits, charFunc) {
20
+ if (data == null) return '';
21
+
22
+ let dict = {};
23
+ let dictToCreate = {};
24
+ let c = '';
25
+ let wc = '';
26
+ let w = '';
27
+ let enlargeIn = 2;
28
+ let dictSize = 3;
29
+ let numBits = 2;
30
+ let result = [];
31
+ let value = 0;
32
+ let position = 0;
33
+
34
+ for (let i = 0; i < data.length; i++) {
35
+ c = data.charAt(i);
36
+
37
+ if (!Object.prototype.hasOwnProperty.call(dict, c)) {
38
+ dict[c] = dictSize++;
39
+ dictToCreate[c] = true;
40
+ }
41
+
42
+ wc = w + c;
43
+
44
+ if (Object.prototype.hasOwnProperty.call(dict, wc)) {
45
+ w = wc;
46
+ } else {
47
+ if (Object.prototype.hasOwnProperty.call(dictToCreate, w)) {
48
+ if (w.charCodeAt(0) < 256) {
49
+ for (let j = 0; j < numBits; j++) {
50
+ value = (value << 1);
51
+ if (position === bits - 1) {
52
+ position = 0;
53
+ result.push(charFunc(value));
54
+ value = 0;
55
+ } else {
56
+ position++;
57
+ }
58
+ }
59
+
60
+ let charCode = w.charCodeAt(0);
61
+ for (let j = 0; j < 8; j++) {
62
+ value = (value << 1) | (charCode & 1);
63
+ if (position === bits - 1) {
64
+ position = 0;
65
+ result.push(charFunc(value));
66
+ value = 0;
67
+ } else {
68
+ position++;
69
+ }
70
+ charCode >>= 1;
71
+ }
72
+ } else {
73
+ let charCode = 1;
74
+ for (let j = 0; j < numBits; j++) {
75
+ value = (value << 1) | charCode;
76
+ if (position === bits - 1) {
77
+ position = 0;
78
+ result.push(charFunc(value));
79
+ value = 0;
80
+ } else {
81
+ position++;
82
+ }
83
+ charCode = 0;
84
+ }
85
+
86
+ charCode = w.charCodeAt(0);
87
+ for (let j = 0; j < 16; j++) {
88
+ value = (value << 1) | (charCode & 1);
89
+ if (position === bits - 1) {
90
+ position = 0;
91
+ result.push(charFunc(value));
92
+ value = 0;
93
+ } else {
94
+ position++;
95
+ }
96
+ charCode >>= 1;
97
+ }
98
+ }
99
+
100
+ enlargeIn--;
101
+ if (enlargeIn === 0) {
102
+ enlargeIn = Math.pow(2, numBits);
103
+ numBits++;
104
+ }
105
+ delete dictToCreate[w];
106
+ } else {
107
+ let charCode = dict[w];
108
+ for (let j = 0; j < numBits; j++) {
109
+ value = (value << 1) | (charCode & 1);
110
+ if (position === bits - 1) {
111
+ position = 0;
112
+ result.push(charFunc(value));
113
+ value = 0;
114
+ } else {
115
+ position++;
116
+ }
117
+ charCode >>= 1;
118
+ }
119
+ }
120
+
121
+ enlargeIn--;
122
+ if (enlargeIn === 0) {
123
+ enlargeIn = Math.pow(2, numBits);
124
+ numBits++;
125
+ }
126
+
127
+ dict[wc] = dictSize++;
128
+ w = String(c);
129
+ }
130
+ }
131
+
132
+ if (w !== '') {
133
+ if (Object.prototype.hasOwnProperty.call(dictToCreate, w)) {
134
+ if (w.charCodeAt(0) < 256) {
135
+ for (let j = 0; j < numBits; j++) {
136
+ value = (value << 1);
137
+ if (position === bits - 1) {
138
+ position = 0;
139
+ result.push(charFunc(value));
140
+ value = 0;
141
+ } else {
142
+ position++;
143
+ }
144
+ }
145
+
146
+ let charCode = w.charCodeAt(0);
147
+ for (let j = 0; j < 8; j++) {
148
+ value = (value << 1) | (charCode & 1);
149
+ if (position === bits - 1) {
150
+ position = 0;
151
+ result.push(charFunc(value));
152
+ value = 0;
153
+ } else {
154
+ position++;
155
+ }
156
+ charCode >>= 1;
157
+ }
158
+ } else {
159
+ let charCode = 1;
160
+ for (let j = 0; j < numBits; j++) {
161
+ value = (value << 1) | charCode;
162
+ if (position === bits - 1) {
163
+ position = 0;
164
+ result.push(charFunc(value));
165
+ value = 0;
166
+ } else {
167
+ position++;
168
+ }
169
+ charCode = 0;
170
+ }
171
+
172
+ charCode = w.charCodeAt(0);
173
+ for (let j = 0; j < 16; j++) {
174
+ value = (value << 1) | (charCode & 1);
175
+ if (position === bits - 1) {
176
+ position = 0;
177
+ result.push(charFunc(value));
178
+ value = 0;
179
+ } else {
180
+ position++;
181
+ }
182
+ charCode >>= 1;
183
+ }
184
+ }
185
+
186
+ enlargeIn--;
187
+ if (enlargeIn === 0) {
188
+ enlargeIn = Math.pow(2, numBits);
189
+ numBits++;
190
+ }
191
+ delete dictToCreate[w];
192
+ } else {
193
+ let charCode = dict[w];
194
+ for (let j = 0; j < numBits; j++) {
195
+ value = (value << 1) | (charCode & 1);
196
+ if (position === bits - 1) {
197
+ position = 0;
198
+ result.push(charFunc(value));
199
+ value = 0;
200
+ } else {
201
+ position++;
202
+ }
203
+ charCode >>= 1;
204
+ }
205
+ }
206
+
207
+ enlargeIn--;
208
+ if (enlargeIn === 0) {
209
+ enlargeIn = Math.pow(2, numBits);
210
+ numBits++;
211
+ }
212
+ }
213
+
214
+ let charCode = 2;
215
+ for (let j = 0; j < numBits; j++) {
216
+ value = (value << 1) | (charCode & 1);
217
+ if (position === bits - 1) {
218
+ position = 0;
219
+ result.push(charFunc(value));
220
+ value = 0;
221
+ } else {
222
+ position++;
223
+ }
224
+ charCode >>= 1;
225
+ }
226
+
227
+ while (true) {
228
+ value = (value << 1);
229
+ if (position === bits - 1) {
230
+ result.push(charFunc(value));
231
+ break;
232
+ }
233
+ position++;
234
+ }
235
+
236
+ return result.join('');
237
+ }
238
+
239
+ // ==================== 编码函数 ====================
240
+
241
+ function customEncode(data, urlSafe) {
242
+ if (data == null) return '';
243
+
244
+ const base64Chars = CUSTOM_BASE64_CHARS;
245
+
246
+ let compressed = lzwCompress(data, 6, function(index) {
247
+ return base64Chars.charAt(index);
248
+ });
249
+
250
+ if (!urlSafe) {
251
+ switch (compressed.length % 4) {
252
+ case 1: return compressed + '===';
253
+ case 2: return compressed + '==';
254
+ case 3: return compressed + '=';
255
+ default: return compressed;
256
+ }
257
+ }
258
+
259
+ return compressed;
260
+ }
261
+
262
+ // ==================== 辅助函数 ====================
263
+
264
+ function randomHash() {
265
+ return Math.floor(Math.random() * 4294967296);
266
+ }
267
+
268
+ function generateDeviceId() {
269
+ return Array.from({ length: 20 }, () =>
270
+ Math.floor(Math.random() * 16).toString(16)
271
+ ).join('');
272
+ }
273
+
274
+ // ==================== 数据解析和处理 ====================
275
+
276
+ function parseRealData(realData) {
277
+ const fields = realData.split('^');
278
+ return fields;
279
+ }
280
+
281
+ function processFields(fields) {
282
+ const processed = [...fields];
283
+ const currentTimestamp = Date.now();
284
+
285
+ // 替换哈希字段
286
+ for (const [index, type] of Object.entries(HASH_FIELDS)) {
287
+ const idx = parseInt(index);
288
+
289
+ if (type === 'split') {
290
+ // 字段16: 格式为 "count|hash",只替换hash部分
291
+ const parts = processed[idx].split('|');
292
+ if (parts.length === 2) {
293
+ processed[idx] = `${parts[0]}|${randomHash()}`;
294
+ }
295
+ } else if (type === 'full') {
296
+ // 完全替换为随机哈希
297
+ if (idx === 36) {
298
+ // 字段36: 文档属性哈希(10-100的随机整数)
299
+ processed[idx] = Math.floor(Math.random() * 91) + 10;
300
+ } else {
301
+ processed[idx] = randomHash();
302
+ }
303
+ }
304
+ }
305
+
306
+ processed[33] = currentTimestamp; // 字段33: 当前时间戳
307
+
308
+ return processed;
309
+ }
310
+
311
+ // ==================== Cookie生成 ====================
312
+
313
+ function generateCookies(realData = null, fingerprintOptions = {}) {
314
+ // 使用传入的指纹或生成新的随机指纹
315
+ const fingerprint = realData || generateFingerprint(fingerprintOptions);
316
+
317
+ // 解析指纹数据
318
+ const fields = parseRealData(fingerprint);
319
+
320
+ // 处理字段(���机化哈希,更新时间戳)
321
+ const processedFields = processFields(fields);
322
+
323
+ // 生成 ssxmod_itna (37字段)
324
+ const ssxmod_itna_data = processedFields.join('^');
325
+ const ssxmod_itna = '1-' + customEncode(ssxmod_itna_data, true);
326
+
327
+ // 生成 ssxmod_itna2 (18字段)
328
+ // 只使用: 字段0, 字段1, 字段23, 字段32, 字段33
329
+ const ssxmod_itna2_data = [
330
+ processedFields[0], // 设备ID
331
+ processedFields[1], // SDK版本
332
+ processedFields[23], // 模式 (P/M)
333
+ 0, '', 0, '', '', 0, // 事件相关(P模式下为空)
334
+ 0, 0,
335
+ processedFields[32], // 常量 (11)
336
+ processedFields[33], // 当前时间戳
337
+ 0, 0, 0, 0, 0
338
+ ].join('^');
339
+ const ssxmod_itna2 = '1-' + customEncode(ssxmod_itna2_data, true);
340
+
341
+ return {
342
+ ssxmod_itna,
343
+ ssxmod_itna2,
344
+ timestamp: parseInt(processedFields[33]),
345
+ rawData: ssxmod_itna_data,
346
+ rawData2: ssxmod_itna2_data
347
+ };
348
+ }
349
+
350
+ function generateBatch(count = 10, realData = null, fingerprintOptions = {}) {
351
+ const results = [];
352
+ for (let i = 0; i < count; i++) {
353
+ results.push(generateCookies(realData, fingerprintOptions));
354
+ }
355
+ return results;
356
+ }
357
+
358
+ // ==================== 主程序 ====================
359
+
360
+ if (require.main === module) {
361
+ const result = generateCookies();
362
+ console.log('ssxmod_itna:', result.ssxmod_itna);
363
+ console.log('ssxmod_itna2:', result.ssxmod_itna2);
364
+ }
365
+
366
+ // ==================== 导出 ====================
367
+
368
+ module.exports = {
369
+ generateCookies,
370
+ generateBatch,
371
+ customEncode,
372
+ randomHash,
373
+ generateDeviceId,
374
+ parseRealData,
375
+ generateFingerprint
376
+ };
src/utils/data-persistence.js ADDED
@@ -0,0 +1,240 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const fs = require('fs').promises
2
+ const path = require('path')
3
+ const config = require('../config/index.js')
4
+ const redisClient = require('./redis')
5
+ const { logger } = require('./logger')
6
+
7
+ /**
8
+ * 数据持久化管理器
9
+ * 统一处理账户数据的存储和读取
10
+ */
11
+ class DataPersistence {
12
+ constructor() {
13
+ this.dataFilePath = config.dataFilePath
14
+ }
15
+
16
+ /**
17
+ * 加载所有账户数据
18
+ * @returns {Promise<Array>} 账户列表
19
+ */
20
+ async loadAccounts() {
21
+ try {
22
+ switch (config.dataSaveMode) {
23
+ case 'redis':
24
+ return await this._loadFromRedis()
25
+ case 'file':
26
+ return await this._loadFromFile()
27
+ case 'none':
28
+ return await this._loadFromEnv()
29
+ default:
30
+ logger.error(`不支持的数据保存模式: ${config.dataSaveMode}`, 'DATA')
31
+ throw new Error(`不支持的数据保存模式: ${config.dataSaveMode}`)
32
+ }
33
+ } catch (error) {
34
+ logger.error('加载账户数据失败', 'DATA', '', error)
35
+ return []
36
+ }
37
+ }
38
+
39
+ /**
40
+ * 保存单个账户数据
41
+ * @param {string} email - 邮箱
42
+ * @param {Object} accountData - 账户数据
43
+ * @returns {Promise<boolean>} 保存是否成功
44
+ */
45
+ async saveAccount(email, accountData) {
46
+ try {
47
+ switch (config.dataSaveMode) {
48
+ case 'redis':
49
+ return await this._saveToRedis(email, accountData)
50
+ case 'file':
51
+ return await this._saveToFile(email, accountData)
52
+ case 'none':
53
+ logger.warn('环境变量模式不支持保存账户数据', 'DATA')
54
+ return false
55
+ default:
56
+ logger.error(`不支持的数据保存模式: ${config.dataSaveMode}`, 'DATA')
57
+ throw new Error(`不支持的数据保存模式: ${config.dataSaveMode}`)
58
+ }
59
+ } catch (error) {
60
+ logger.error(`保存账户数据失败 (${email})`, 'DATA', '', error)
61
+ return false
62
+ }
63
+ }
64
+
65
+ /**
66
+ * 批量保存账户数据
67
+ * @param {Array} accounts - 账户列表
68
+ * @returns {Promise<boolean>} 保存是否成功
69
+ */
70
+ async saveAllAccounts(accounts) {
71
+ try {
72
+ switch (config.dataSaveMode) {
73
+ case 'redis':
74
+ return await this._saveAllToRedis(accounts)
75
+ case 'file':
76
+ return await this._saveAllToFile(accounts)
77
+ case 'none':
78
+ logger.warn('环境变量模式不支持保存账户数据', 'DATA')
79
+ return false
80
+ default:
81
+ logger.error(`不支持的数据保存模式: ${config.dataSaveMode}`, 'DATA')
82
+ throw new Error(`不支持的数据保存模式: ${config.dataSaveMode}`)
83
+ }
84
+ } catch (error) {
85
+ logger.error('批量保存账户数据失败', 'DATA', '', error)
86
+ return false
87
+ }
88
+ }
89
+
90
+ /**
91
+ * 从 Redis 加载账户数据
92
+ * @private
93
+ */
94
+ async _loadFromRedis() {
95
+ const accounts = await redisClient.getAllAccounts()
96
+ return accounts.length > 0 ? accounts : []
97
+ }
98
+
99
+ /**
100
+ * 从文件加载账户数据
101
+ * @private
102
+ */
103
+ async _loadFromFile() {
104
+ // 确保文件存在
105
+ await this._ensureDataFileExists()
106
+
107
+ const fileContent = await fs.readFile(this.dataFilePath, 'utf-8')
108
+ const data = JSON.parse(fileContent)
109
+
110
+ return data.accounts || []
111
+ }
112
+
113
+ /**
114
+ * 从环境变量加载账户数据
115
+ * @private
116
+ */
117
+ async _loadFromEnv() {
118
+ if (!process.env.ACCOUNTS) {
119
+ return []
120
+ }
121
+
122
+ const { JwtDecode } = require('./tools')
123
+ const accountTokens = process.env.ACCOUNTS.split(',')
124
+ const accounts = []
125
+
126
+ for (const item of accountTokens) {
127
+ const [email, password] = item.split(':')
128
+ if (email && password) {
129
+ // 注意:这里需要登录获取token,但在加载阶段不应该进行网络请求
130
+ // 这个逻辑需要在Account类中处理
131
+ accounts.push({ email, password, token: null, expires: null })
132
+ }
133
+ }
134
+
135
+ return accounts
136
+ }
137
+
138
+ /**
139
+ * 保存到 Redis
140
+ * @private
141
+ */
142
+ async _saveToRedis(email, accountData) {
143
+ return await redisClient.setAccount(email, accountData)
144
+ }
145
+
146
+ /**
147
+ * 保存到文件
148
+ * @private
149
+ */
150
+ async _saveToFile(email, accountData) {
151
+ await this._ensureDataFileExists()
152
+
153
+ const fileContent = await fs.readFile(this.dataFilePath, 'utf-8')
154
+ const data = JSON.parse(fileContent)
155
+
156
+ if (!data.accounts) {
157
+ data.accounts = []
158
+ }
159
+
160
+ // 查找现有账户或添加新账户
161
+ const existingIndex = data.accounts.findIndex(account => account.email === email)
162
+ const updatedAccount = {
163
+ email,
164
+ password: accountData.password,
165
+ token: accountData.token,
166
+ expires: accountData.expires
167
+ }
168
+
169
+ if (existingIndex !== -1) {
170
+ data.accounts[existingIndex] = updatedAccount
171
+ } else {
172
+ data.accounts.push(updatedAccount)
173
+ }
174
+
175
+ await fs.writeFile(this.dataFilePath, JSON.stringify(data, null, 2), 'utf-8')
176
+ return true
177
+ }
178
+
179
+ /**
180
+ * 批量保存到 Redis
181
+ * @private
182
+ */
183
+ async _saveAllToRedis(accounts) {
184
+ let successCount = 0
185
+ for (const account of accounts) {
186
+ const success = await this._saveToRedis(account.email, account)
187
+ if (success) successCount++
188
+ }
189
+ return successCount === accounts.length
190
+ }
191
+
192
+ /**
193
+ * 批量保存到文件
194
+ * @private
195
+ */
196
+ async _saveAllToFile(accounts) {
197
+ await this._ensureDataFileExists()
198
+
199
+ const fileContent = await fs.readFile(this.dataFilePath, 'utf-8')
200
+ const data = JSON.parse(fileContent)
201
+
202
+ data.accounts = accounts.map(account => ({
203
+ email: account.email,
204
+ password: account.password,
205
+ token: account.token,
206
+ expires: account.expires
207
+ }))
208
+
209
+ await fs.writeFile(this.dataFilePath, JSON.stringify(data, null, 2), 'utf-8')
210
+ return true
211
+ }
212
+
213
+ /**
214
+ * 确保数据文件存在
215
+ * @private
216
+ */
217
+ async _ensureDataFileExists() {
218
+ try {
219
+ await fs.access(this.dataFilePath)
220
+ } catch (error) {
221
+ logger.info('数据文件不存在,正在创建默认文件...', 'FILE', '📁')
222
+
223
+ // 确保目录存在
224
+ const dirPath = path.dirname(this.dataFilePath)
225
+ await fs.mkdir(dirPath, { recursive: true })
226
+
227
+ // 创建默认数据结构
228
+ const defaultData = {
229
+ defaultHeaders: null,
230
+ defaultCookie: null,
231
+ accounts: []
232
+ }
233
+
234
+ await fs.writeFile(this.dataFilePath, JSON.stringify(defaultData, null, 2), 'utf-8')
235
+ logger.success('默认数据文件创建成功', 'FILE')
236
+ }
237
+ }
238
+ }
239
+
240
+ module.exports = DataPersistence
src/utils/fingerprint.js ADDED
Binary file (7.56 kB). View file
 
src/utils/img-caches.js ADDED
@@ -0,0 +1,88 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const fs = require('fs')
2
+ const path = require('path')
3
+ const config = require('../config')
4
+ const { logger } = require('./logger')
5
+
6
+ class imgCacheManager {
7
+ constructor() {
8
+ this.cacheMap = new Map()
9
+ }
10
+
11
+ getCachePath(signature) {
12
+ return path.join(config.cacheDir, `${signature}.txt`)
13
+ }
14
+
15
+ cacheIsExist(signature) {
16
+ try {
17
+ if (config.cacheMode === 'default') {
18
+ return this.cacheMap.has(signature)
19
+ } else {
20
+ const cachePath = this.getCachePath(signature)
21
+ return fs.existsSync(cachePath)
22
+ }
23
+ } catch (e) {
24
+ logger.error('缓存检查失败', 'CACHE', '', e)
25
+ return false
26
+ }
27
+ }
28
+
29
+ addCache(signature, url) {
30
+ try {
31
+ const isExist = this.cacheIsExist(signature)
32
+
33
+ if (isExist) {
34
+ return false
35
+ } else {
36
+
37
+ if (config.cacheMode === 'default') {
38
+ this.cacheMap.set(signature, url)
39
+ } else {
40
+ const cachePath = this.getCachePath(signature)
41
+ fs.mkdirSync(config.cacheDir, { recursive: true })
42
+ fs.writeFileSync(cachePath, url)
43
+ }
44
+
45
+ return true
46
+
47
+ }
48
+ } catch (e) {
49
+ logger.error('添加缓存失败', 'CACHE', '', e)
50
+ return false
51
+ }
52
+ }
53
+
54
+ getCache(signature) {
55
+ try {
56
+ const cachePath = this.getCachePath(signature)
57
+ const isExist = this.cacheIsExist(signature)
58
+
59
+ if (isExist) {
60
+ if (config.cacheMode === 'default') {
61
+ return {
62
+ status: 200,
63
+ url: this.cacheMap.get(signature)
64
+ }
65
+ } else {
66
+ const data = fs.readFileSync(cachePath, 'utf-8')
67
+ return {
68
+ status: 200,
69
+ url: data
70
+ }
71
+ }
72
+ } else {
73
+ return {
74
+ status: 404,
75
+ url: null
76
+ }
77
+ }
78
+ } catch (e) {
79
+ logger.error('获取缓存失败', 'CACHE', '', e)
80
+ return {
81
+ status: 500,
82
+ url: null
83
+ }
84
+ }
85
+ }
86
+ }
87
+
88
+ module.exports = imgCacheManager