dsh-plugin-image-workbench 1.1.5 → 1.1.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -21
- package/README.en.md +5 -3
- package/README.md +7 -3
- package/client.js +241 -241
- package/cordis.patch.yml +14 -14
- package/host.js +51 -0
- package/install.mjs +333 -387
- package/package.json +3 -2
- package/scripts/fetch-style-images.mjs +104 -0
- package/scripts/vendor-snapshot.py +154 -154
- package/server/access.py +26 -26
- package/server/app.py +1371 -1329
- package/server/catalog.py +147 -147
- package/server/composer.py +391 -391
- package/server/drafts.py +158 -158
- package/server/history.py +575 -575
- package/server/imaging.py +172 -172
- package/server/pool.py +1335 -1335
- package/server/pool_admin.py +921 -921
- package/server/pool_service.py +1073 -1073
- package/server/requirements.txt +6 -6
- package/server/smoke_check.py +402 -402
- package/server/static/access.html +202 -202
- package/server/static/index.html +15 -0
- package/vendor/chatgpt2api/source/.env.example +82 -82
- package/vendor/chatgpt2api/source/.python-version +1 -1
- package/vendor/chatgpt2api/source/LICENSE +21 -21
- package/vendor/chatgpt2api/source/README.md +391 -391
- package/vendor/chatgpt2api/source/VERSION +1 -1
- package/vendor/chatgpt2api/source/api/__init__.py +2 -2
- package/vendor/chatgpt2api/source/api/accounts.py +537 -537
- package/vendor/chatgpt2api/source/api/ai.py +217 -217
- package/vendor/chatgpt2api/source/api/app.py +62 -62
- package/vendor/chatgpt2api/source/api/errors.py +46 -46
- package/vendor/chatgpt2api/source/api/image_inputs.py +309 -309
- package/vendor/chatgpt2api/source/api/image_tasks.py +120 -120
- package/vendor/chatgpt2api/source/api/support.py +134 -134
- package/vendor/chatgpt2api/source/api/system.py +365 -365
- package/vendor/chatgpt2api/source/assets/kunrouai.svg +50 -50
- package/vendor/chatgpt2api/source/config.default.json +108 -108
- package/vendor/chatgpt2api/source/main.py +9 -9
- package/vendor/chatgpt2api/source/pyproject.toml +27 -27
- package/vendor/chatgpt2api/source/scripts/init_proxy_config.py +186 -186
- package/vendor/chatgpt2api/source/scripts/migrate_storage.py +163 -163
- package/vendor/chatgpt2api/source/scripts/privoxy-warp.conf +13 -13
- package/vendor/chatgpt2api/source/scripts/test_outlook_token_mailbox.py +385 -385
- package/vendor/chatgpt2api/source/scripts/test_storage.py +129 -129
- package/vendor/chatgpt2api/source/scripts/verify_oauth_refresh.py +92 -92
- package/vendor/chatgpt2api/source/services/account_service.py +1757 -1757
- package/vendor/chatgpt2api/source/services/auth_service.py +253 -253
- package/vendor/chatgpt2api/source/services/backup_service.py +671 -671
- package/vendor/chatgpt2api/source/services/config.py +667 -667
- package/vendor/chatgpt2api/source/services/content_filter.py +242 -242
- package/vendor/chatgpt2api/source/services/cpa_service.py +315 -315
- package/vendor/chatgpt2api/source/services/editable_file_task_service.py +260 -260
- package/vendor/chatgpt2api/source/services/image_service.py +376 -376
- package/vendor/chatgpt2api/source/services/image_storage_service.py +405 -405
- package/vendor/chatgpt2api/source/services/image_tags_service.py +76 -76
- package/vendor/chatgpt2api/source/services/image_task_service.py +550 -550
- package/vendor/chatgpt2api/source/services/log_service.py +337 -337
- package/vendor/chatgpt2api/source/services/model_service.py +182 -182
- package/vendor/chatgpt2api/source/services/oauth_login_service.py +267 -267
- package/vendor/chatgpt2api/source/services/openai_backend_api.py +2763 -2763
- package/vendor/chatgpt2api/source/services/openai_oauth.py +32 -32
- package/vendor/chatgpt2api/source/services/protocol/__init__.py +2 -2
- package/vendor/chatgpt2api/source/services/protocol/anthropic_v1_messages.py +307 -307
- package/vendor/chatgpt2api/source/services/protocol/chat_completion_cache.py +238 -238
- package/vendor/chatgpt2api/source/services/protocol/conversation.py +1644 -1644
- package/vendor/chatgpt2api/source/services/protocol/error_response.py +124 -124
- package/vendor/chatgpt2api/source/services/protocol/openai_search.py +19 -19
- package/vendor/chatgpt2api/source/services/protocol/openai_v1_chat_complete.py +316 -316
- package/vendor/chatgpt2api/source/services/protocol/openai_v1_image_edit.py +86 -86
- package/vendor/chatgpt2api/source/services/protocol/openai_v1_image_generations.py +42 -42
- package/vendor/chatgpt2api/source/services/protocol/openai_v1_models.py +53 -53
- package/vendor/chatgpt2api/source/services/protocol/openai_v1_response.py +453 -453
- package/vendor/chatgpt2api/source/services/protocol/web_search_tool.py +160 -160
- package/vendor/chatgpt2api/source/services/proxy_service.py +648 -648
- package/vendor/chatgpt2api/source/services/storage/__init__.py +5 -5
- package/vendor/chatgpt2api/source/services/storage/base.py +38 -38
- package/vendor/chatgpt2api/source/services/storage/database_storage.py +194 -194
- package/vendor/chatgpt2api/source/services/storage/factory.py +104 -104
- package/vendor/chatgpt2api/source/services/storage/git_storage.py +183 -183
- package/vendor/chatgpt2api/source/services/storage/json_storage.py +95 -95
- package/vendor/chatgpt2api/source/services/sub2api_service.py +531 -531
- package/vendor/chatgpt2api/source/utils/helper.py +469 -469
- package/vendor/chatgpt2api/source/utils/image_tokens.py +346 -346
- package/vendor/chatgpt2api/source/utils/log.py +110 -110
- package/vendor/chatgpt2api/source/utils/pkce.py +19 -19
- package/vendor/chatgpt2api/source/utils/pow.py +207 -207
- package/vendor/chatgpt2api/source/utils/sentinel.py +159 -159
- package/vendor/chatgpt2api/source/utils/turnstile.py +184 -184
- package/vendor/chatgpt2api/source/uv.lock +904 -904
- package/vendor/chatgpt2api/source/web/bun.lock +1501 -1501
- package/vendor/chatgpt2api/source/web/components.json +24 -24
- package/vendor/chatgpt2api/source/web/eslint.config.mjs +17 -17
- package/vendor/chatgpt2api/source/web/next.config.ts +40 -40
- package/vendor/chatgpt2api/source/web/package.json +58 -58
- package/vendor/chatgpt2api/source/web/postcss.config.mjs +5 -5
- package/vendor/chatgpt2api/source/web/src/app/accounts/components/account-import-dialog.tsx +925 -925
- package/vendor/chatgpt2api/source/web/src/app/accounts/page.tsx +1307 -1307
- package/vendor/chatgpt2api/source/web/src/app/debug/components/chat-panel.tsx +224 -224
- package/vendor/chatgpt2api/source/web/src/app/debug/components/editable-file-panel.tsx +444 -444
- package/vendor/chatgpt2api/source/web/src/app/debug/components/ppt-panel.tsx +9 -9
- package/vendor/chatgpt2api/source/web/src/app/debug/components/psd-panel.tsx +9 -9
- package/vendor/chatgpt2api/source/web/src/app/debug/components/search-panel.tsx +172 -172
- package/vendor/chatgpt2api/source/web/src/app/debug/components/skill-panel.tsx +169 -169
- package/vendor/chatgpt2api/source/web/src/app/debug/components/types.ts +39 -39
- package/vendor/chatgpt2api/source/web/src/app/debug/page.tsx +59 -59
- package/vendor/chatgpt2api/source/web/src/app/globals.css +230 -230
- package/vendor/chatgpt2api/source/web/src/app/image/components/image-composer.tsx +542 -542
- package/vendor/chatgpt2api/source/web/src/app/image/components/image-results.tsx +554 -554
- package/vendor/chatgpt2api/source/web/src/app/image/components/image-sidebar.tsx +176 -176
- package/vendor/chatgpt2api/source/web/src/app/image/page.tsx +1799 -1799
- package/vendor/chatgpt2api/source/web/src/app/image-manager/page.tsx +732 -732
- package/vendor/chatgpt2api/source/web/src/app/layout.tsx +50 -50
- package/vendor/chatgpt2api/source/web/src/app/login/page.tsx +101 -101
- package/vendor/chatgpt2api/source/web/src/app/logs/page.tsx +356 -356
- package/vendor/chatgpt2api/source/web/src/app/page.tsx +30 -30
- package/vendor/chatgpt2api/source/web/src/app/settings/components/api-docs-card.tsx +341 -341
- package/vendor/chatgpt2api/source/web/src/app/settings/components/backup-settings-card.tsx +447 -447
- package/vendor/chatgpt2api/source/web/src/app/settings/components/base-url-card.tsx +71 -71
- package/vendor/chatgpt2api/source/web/src/app/settings/components/config-card.tsx +491 -491
- package/vendor/chatgpt2api/source/web/src/app/settings/components/cpa-pool-dialog.tsx +108 -108
- package/vendor/chatgpt2api/source/web/src/app/settings/components/cpa-pools-card.tsx +169 -169
- package/vendor/chatgpt2api/source/web/src/app/settings/components/import-browser-dialog.tsx +185 -185
- package/vendor/chatgpt2api/source/web/src/app/settings/components/proxy-runtime-card.tsx +379 -379
- package/vendor/chatgpt2api/source/web/src/app/settings/components/proxy-settings-card.tsx +127 -127
- package/vendor/chatgpt2api/source/web/src/app/settings/components/proxy-settings.tsx +200 -200
- package/vendor/chatgpt2api/source/web/src/app/settings/components/settings-header.tsx +12 -12
- package/vendor/chatgpt2api/source/web/src/app/settings/components/sub2api-connections.tsx +886 -886
- package/vendor/chatgpt2api/source/web/src/app/settings/components/third-party-apps-card.tsx +81 -81
- package/vendor/chatgpt2api/source/web/src/app/settings/components/user-keys-card.tsx +422 -422
- package/vendor/chatgpt2api/source/web/src/app/settings/page.tsx +135 -135
- package/vendor/chatgpt2api/source/web/src/app/settings/store.ts +1069 -1069
- package/vendor/chatgpt2api/source/web/src/components/date-range-filter.tsx +49 -49
- package/vendor/chatgpt2api/source/web/src/components/header-actions.tsx +24 -24
- package/vendor/chatgpt2api/source/web/src/components/image-lightbox.tsx +434 -434
- package/vendor/chatgpt2api/source/web/src/components/image-thumbnail.tsx +46 -46
- package/vendor/chatgpt2api/source/web/src/components/theme-script.tsx +18 -18
- package/vendor/chatgpt2api/source/web/src/components/theme-toggle.tsx +14 -14
- package/vendor/chatgpt2api/source/web/src/components/top-nav.tsx +263 -263
- package/vendor/chatgpt2api/source/web/src/components/ui/animated-theme-toggler.tsx +256 -256
- package/vendor/chatgpt2api/source/web/src/components/ui/badge.tsx +41 -41
- package/vendor/chatgpt2api/source/web/src/components/ui/button.tsx +59 -59
- package/vendor/chatgpt2api/source/web/src/components/ui/calendar.tsx +46 -46
- package/vendor/chatgpt2api/source/web/src/components/ui/card.tsx +61 -61
- package/vendor/chatgpt2api/source/web/src/components/ui/checkbox.tsx +27 -27
- package/vendor/chatgpt2api/source/web/src/components/ui/dia-text-reveal.tsx +267 -267
- package/vendor/chatgpt2api/source/web/src/components/ui/dialog.tsx +132 -132
- package/vendor/chatgpt2api/source/web/src/components/ui/field.tsx +13 -13
- package/vendor/chatgpt2api/source/web/src/components/ui/input.tsx +19 -19
- package/vendor/chatgpt2api/source/web/src/components/ui/label.tsx +24 -24
- package/vendor/chatgpt2api/source/web/src/components/ui/morphing-text.tsx +149 -149
- package/vendor/chatgpt2api/source/web/src/components/ui/popover.tsx +38 -38
- package/vendor/chatgpt2api/source/web/src/components/ui/select.tsx +175 -175
- package/vendor/chatgpt2api/source/web/src/components/ui/sheet.tsx +143 -143
- package/vendor/chatgpt2api/source/web/src/components/ui/table.tsx +29 -29
- package/vendor/chatgpt2api/source/web/src/components/ui/tabs.tsx +91 -91
- package/vendor/chatgpt2api/source/web/src/components/ui/text-3d-flip.tsx +269 -269
- package/vendor/chatgpt2api/source/web/src/components/ui/textarea.tsx +18 -18
- package/vendor/chatgpt2api/source/web/src/components/version-release-dialog.tsx +127 -127
- package/vendor/chatgpt2api/source/web/src/constants/common-env.ts +6 -6
- package/vendor/chatgpt2api/source/web/src/hooks/use-version-check.ts +85 -85
- package/vendor/chatgpt2api/source/web/src/lib/api.ts +926 -926
- package/vendor/chatgpt2api/source/web/src/lib/auth-session.ts +26 -26
- package/vendor/chatgpt2api/source/web/src/lib/release.ts +25 -25
- package/vendor/chatgpt2api/source/web/src/lib/request.ts +93 -93
- package/vendor/chatgpt2api/source/web/src/lib/use-auth-guard.ts +89 -89
- package/vendor/chatgpt2api/source/web/src/lib/utils.ts +6 -6
- package/vendor/chatgpt2api/source/web/src/store/auth.ts +112 -112
- package/vendor/chatgpt2api/source/web/src/store/editable-file-history.ts +33 -33
- package/vendor/chatgpt2api/source/web/src/store/image-conversations.ts +318 -318
- package/vendor/chatgpt2api/source/web/tsconfig.json +42 -42
package/server/pool_service.py
CHANGED
|
@@ -1,1073 +1,1073 @@
|
|
|
1
|
-
"""工作台自带的号池进程管理:启动 / 停止 / 重启 / 掉线守护 / 首次初始化。
|
|
2
|
-
|
|
3
|
-
以前生图号池是另一个 DSH 插件在管,工作台面板只能写「去号池插件里点启动」;
|
|
4
|
-
一旦那个插件没装、没启、或守护失效,工作台就只剩一个空壳。现在号池归工作台自己:
|
|
5
|
-
|
|
6
|
-
* 号池进程由本模块 spawn,后台线程每 15 秒探端口,掉了自动拉起;
|
|
7
|
-
* 号池目录、admin-key、用户 Key、账号库全部由本模块按当前 Base URL 定位;
|
|
8
|
-
* 号池服务端代码随工作台插件自带(vendor/chatgpt2api/),首次启动释放到本机
|
|
9
|
-
数据目录并用自带 uv 建独立虚拟环境,**不需要另装任何第三方号池插件**;
|
|
10
|
-
* 不依赖 DSH:浏览器直接打开 8092 也能用,DSH 插件只负责把页面嵌进侧栏。
|
|
11
|
-
|
|
12
|
-
安全边界:只管本机号池。第三方接口、以及 38650 写作台号池都不代管,
|
|
13
|
-
避免「停止」按钮把别人的号池关掉。
|
|
14
|
-
"""
|
|
15
|
-
from __future__ import annotations
|
|
16
|
-
|
|
17
|
-
import json
|
|
18
|
-
import os
|
|
19
|
-
import secrets
|
|
20
|
-
import shutil
|
|
21
|
-
import socket
|
|
22
|
-
import subprocess
|
|
23
|
-
import sys
|
|
24
|
-
import threading
|
|
25
|
-
import time
|
|
26
|
-
from pathlib import Path
|
|
27
|
-
from urllib.parse import urlparse
|
|
28
|
-
|
|
29
|
-
import httpx
|
|
30
|
-
|
|
31
|
-
import pool as pool_mod
|
|
32
|
-
|
|
33
|
-
LOCAL_HOSTS = {"127.0.0.1", "localhost", "::1"}
|
|
34
|
-
DEFAULT_PORT = 38651
|
|
35
|
-
# 38650 是写作台号池,本工作台只读不碰
|
|
36
|
-
FOREIGN_PORTS = {38650}
|
|
37
|
-
STOP_FLAG = ".workbench-pool-stop"
|
|
38
|
-
STATE_FILE = ".workbench-pool-state.json"
|
|
39
|
-
RR_MARK = "# [dsh-patch:rr-persist]"
|
|
40
|
-
|
|
41
|
-
_LOCK = threading.RLock() # 可重入:start() 内部会递归调用自身来完成重启
|
|
42
|
-
_WATCHDOG_STARTED = False
|
|
43
|
-
_LAST = {"action": "", "ok": True, "message": "", "at": 0.0}
|
|
44
|
-
# 号池启动成功后通知工作台刷新账号统计(由 app.py 在 lifespan 里挂上)。
|
|
45
|
-
# 号池是异步后起的,不主动刷一次,页头会先停在「1 号 / 配额 0」最多一分钟。
|
|
46
|
-
ON_STARTED = None
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
def _notify_started() -> None:
|
|
50
|
-
cb = ON_STARTED
|
|
51
|
-
if not callable(cb):
|
|
52
|
-
return
|
|
53
|
-
try:
|
|
54
|
-
cb()
|
|
55
|
-
except Exception as e:
|
|
56
|
-
log("启动后同步回调失败:" + pool_mod.mask(e)[:100])
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
# ---------- 基础工具 ----------
|
|
60
|
-
def log(msg: str) -> None:
|
|
61
|
-
pool_mod.log("[内置号池] " + str(msg))
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
def _dsh_home() -> Path:
|
|
65
|
-
return pool_mod._dsh_home()
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
def vendor_dir() -> Path | None:
|
|
69
|
-
"""工作台插件自带的号池运行时快照。
|
|
70
|
-
|
|
71
|
-
工作台后端可能在两种位置:工作区里的 image-local/,或插件包里的 server/;
|
|
72
|
-
插件也可能通过 profile/node_modules 的目录联接加载。这里全部覆盖,
|
|
73
|
-
唯独不去找本机任何第三方号池插件的源码 —— 那是「能独立安装」的前提。
|
|
74
|
-
"""
|
|
75
|
-
here = Path(__file__).resolve().parent
|
|
76
|
-
candidates = [
|
|
77
|
-
os.environ.get("DSH_IMAGE_POOL_VENDOR"),
|
|
78
|
-
here / "vendor" / "chatgpt2api",
|
|
79
|
-
here.parent / "vendor" / "chatgpt2api",
|
|
80
|
-
here.parent / "dsh-plugin-image-workbench" / "vendor" / "chatgpt2api",
|
|
81
|
-
_dsh_home() / "profiles" / "desktop" / "node_modules"
|
|
82
|
-
/ "dsh-plugin-image-workbench" / "vendor" / "chatgpt2api",
|
|
83
|
-
]
|
|
84
|
-
for c in candidates:
|
|
85
|
-
if c and Path(c, "source", "main.py").is_file():
|
|
86
|
-
return Path(c)
|
|
87
|
-
return None
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
def _read_state(home: Path) -> dict:
|
|
91
|
-
try:
|
|
92
|
-
d = json.loads((home / STATE_FILE).read_text(encoding="utf-8"))
|
|
93
|
-
return d if isinstance(d, dict) else {}
|
|
94
|
-
except Exception:
|
|
95
|
-
return {}
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
def _write_state(home: Path, data: dict) -> None:
|
|
99
|
-
try:
|
|
100
|
-
home.mkdir(parents=True, exist_ok=True)
|
|
101
|
-
tmp = home / (STATE_FILE + ".tmp")
|
|
102
|
-
tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
103
|
-
os.replace(tmp, home / STATE_FILE)
|
|
104
|
-
except Exception as e:
|
|
105
|
-
log("状态写入失败:" + pool_mod.mask(e)[:100])
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
def auto_start(home: Path) -> bool:
|
|
109
|
-
return _read_state(home).get("auto_start") is not False
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
def set_auto_start(home: Path, enabled: bool) -> dict:
|
|
113
|
-
st = _read_state(home)
|
|
114
|
-
st["auto_start"] = bool(enabled)
|
|
115
|
-
_write_state(home, st)
|
|
116
|
-
return {"ok": True, "auto_start": bool(enabled),
|
|
117
|
-
"message": "已开启掉线自动拉起" if enabled else "已关闭掉线自动拉起"}
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
def _record(action: str, ok: bool, message: str) -> None:
|
|
121
|
-
_LAST.update({"action": action, "ok": bool(ok), "message": str(message), "at": time.time()})
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
def view(cfg: dict | None = None) -> dict:
|
|
125
|
-
"""从当前接口配置推出:号池端口、数据目录、是否本机、是否归本工作台管。"""
|
|
126
|
-
cfg = cfg or pool_mod.load_config()
|
|
127
|
-
p = pool_mod.provider_cfg(cfg)
|
|
128
|
-
root = str(p["api_root"] or "")
|
|
129
|
-
u = urlparse(root)
|
|
130
|
-
host = (u.hostname or "").lower()
|
|
131
|
-
try:
|
|
132
|
-
port = int(u.port or 0)
|
|
133
|
-
except Exception:
|
|
134
|
-
port = 0
|
|
135
|
-
if not port:
|
|
136
|
-
port = int(os.environ.get("DSH_IMAGE_POOL_PORT") or DEFAULT_PORT)
|
|
137
|
-
local = host in LOCAL_HOSTS
|
|
138
|
-
home = pool_mod.pool_home(cfg)
|
|
139
|
-
# 自定义端口不该复用 38650/38651 的数据目录,否则会串号
|
|
140
|
-
if local and port not in (38650, 38651) and home.name in ("chatgpt2api-native", "chatgpt2api-image"):
|
|
141
|
-
home = _dsh_home() / "chatgpt2api-workbench"
|
|
142
|
-
# 兼容老安装:如果本机已经有一份带账号的号池目录,继续沿用它的「数据」。
|
|
143
|
-
# 这只是不丢用户已有账号,不是运行时依赖 —— 源码与环境一律来自插件 vendor。
|
|
144
|
-
if local and port == 38651 and home.name != "chatgpt2api-image":
|
|
145
|
-
candidate = _dsh_home() / "chatgpt2api-image"
|
|
146
|
-
if (candidate / "admin-key").exists() or (candidate / "source" / "main.py").exists():
|
|
147
|
-
home = candidate
|
|
148
|
-
return {
|
|
149
|
-
"cfg": cfg, "root": root, "host": host, "port": port, "local": local,
|
|
150
|
-
"home": Path(home), "base_url": str(p["base_url"] or ""),
|
|
151
|
-
"manageable": bool(local) and port not in FOREIGN_PORTS,
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
def ensure_admin_key(home: Path) -> str:
|
|
156
|
-
"""号池后端用 CHATGPT2API_AUTH_KEY 校验 /api/accounts。缺了就生成一份。"""
|
|
157
|
-
p = Path(home) / "admin-key"
|
|
158
|
-
try:
|
|
159
|
-
k = p.read_text(encoding="utf-8").strip()
|
|
160
|
-
if k:
|
|
161
|
-
return k
|
|
162
|
-
except Exception:
|
|
163
|
-
pass
|
|
164
|
-
k = "sk-admin-" + secrets.token_urlsafe(32)
|
|
165
|
-
try:
|
|
166
|
-
Path(home).mkdir(parents=True, exist_ok=True)
|
|
167
|
-
tmp = Path(str(p) + ".tmp")
|
|
168
|
-
tmp.write_text(k, encoding="utf-8")
|
|
169
|
-
os.replace(tmp, p)
|
|
170
|
-
log("已生成 admin-key:" + str(p))
|
|
171
|
-
return k
|
|
172
|
-
except Exception as e:
|
|
173
|
-
log("admin-key 写入失败:" + pool_mod.mask(e)[:100])
|
|
174
|
-
return ""
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
def runtime(home: Path) -> dict:
|
|
178
|
-
"""号池运行时:uv + source/main.py + source/.venv。"""
|
|
179
|
-
home = Path(home)
|
|
180
|
-
vend = vendor_dir()
|
|
181
|
-
uv = ""
|
|
182
|
-
for c in (os.environ.get("DSH_IMAGE_POOL_UV"),
|
|
183
|
-
home / "runtime" / "uv.exe", home / "runtime" / "uv",
|
|
184
|
-
(vend / "runtime" / "uv.exe") if vend else "",
|
|
185
|
-
(vend / "runtime" / "uv") if vend else "",
|
|
186
|
-
shutil.which("uv") or ""):
|
|
187
|
-
if c and Path(c).is_file():
|
|
188
|
-
uv = str(c)
|
|
189
|
-
break
|
|
190
|
-
source = home / "source"
|
|
191
|
-
has_main = (source / "main.py").is_file()
|
|
192
|
-
has_venv = (source / ".venv").is_dir()
|
|
193
|
-
ready = bool(uv) and has_main and has_venv
|
|
194
|
-
if not uv:
|
|
195
|
-
reason = "缺 uv 运行时"
|
|
196
|
-
elif not has_main:
|
|
197
|
-
reason = "缺号池源码 main.py"
|
|
198
|
-
elif not has_venv:
|
|
199
|
-
reason = "缺号池虚拟环境 .venv"
|
|
200
|
-
else:
|
|
201
|
-
reason = ""
|
|
202
|
-
return {"uv": uv, "source": str(source), "ready": ready, "reason": reason}
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
def probe(port: int, timeout: float = 0.5) -> bool:
|
|
206
|
-
try:
|
|
207
|
-
with socket.create_connection(("127.0.0.1", int(port)), timeout=timeout):
|
|
208
|
-
return True
|
|
209
|
-
except Exception:
|
|
210
|
-
return False
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
def health(port: int) -> dict:
|
|
214
|
-
try:
|
|
215
|
-
r = httpx.get(f"http://127.0.0.1:{port}/health?format=json",
|
|
216
|
-
timeout=httpx.Timeout(2.5), trust_env=False)
|
|
217
|
-
if r.status_code != 200:
|
|
218
|
-
return {"online": False, "message": f"号池返回 HTTP {r.status_code}"}
|
|
219
|
-
a = r.json().get("accounts") or {}
|
|
220
|
-
return {"online": True, "message": "",
|
|
221
|
-
"total": int(a.get("total") or 0), "active": int(a.get("active") or 0),
|
|
222
|
-
"limited": int(a.get("limited") or 0), "quota": int(a.get("total_quota") or 0)}
|
|
223
|
-
except Exception as e:
|
|
224
|
-
return {"online": False, "message": "号池端口连不上:" + pool_mod.mask(e)[:80]}
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
# ---------- 出口代理零配置自动探测 ----------
|
|
228
|
-
# 常见本机出口代理候选(端口 + 首选协议),按出现频率排序。
|
|
229
|
-
_LOCAL_PROXY_SPECS = [
|
|
230
|
-
("socks5", 1080), # Shadowsocks / 本机 SOCKS5 最常见
|
|
231
|
-
("http", 7890), # Clash HTTP
|
|
232
|
-
("http", 7891), # Clash HTTP 备选
|
|
233
|
-
("http", 8118), # privoxy
|
|
234
|
-
("socks5", 1087), # 部分 SS 客户端
|
|
235
|
-
("http", 1087),
|
|
236
|
-
]
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
_COMMON_PROXY_PORTS = {1080, 1087, 7890, 7891, 7897, 8118, 8080, 8888,
|
|
240
|
-
10808, 10809, 20171, 33210}
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
def _env_egress_proxy(relaxed: bool = False) -> str:
|
|
244
|
-
"""用户环境显式设了出口代理就直接用,优先级最高。
|
|
245
|
-
|
|
246
|
-
宿主和各类工具链会往环境里塞临时回环代理(随机高端口),那玩意只能通
|
|
247
|
-
它自己的链路,拿去跑号池只会全盘超时。所以回环地址只认常见代理端口。
|
|
248
|
-
"""
|
|
249
|
-
for k in ("HTTPS_PROXY", "https_proxy", "ALL_PROXY", "all_proxy",
|
|
250
|
-
"HTTP_PROXY", "http_proxy"):
|
|
251
|
-
v = (os.environ.get(k) or "").strip()
|
|
252
|
-
if not v:
|
|
253
|
-
continue
|
|
254
|
-
if not relaxed:
|
|
255
|
-
host, port = proxy_host_port(v)
|
|
256
|
-
if host in ("127.0.0.1", "localhost", "::1") and port not in _COMMON_PROXY_PORTS:
|
|
257
|
-
continue
|
|
258
|
-
return v
|
|
259
|
-
return ""
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
def _local_proxy_candidates() -> list[str]:
|
|
263
|
-
"""本机代理端口在监听的,生成候选 URL。
|
|
264
|
-
|
|
265
|
-
同一个端口两种协议都试一遍:1080 既可能是 SOCKS5(Shadowsocks),
|
|
266
|
-
也可能是 HTTP(部分客户端),只按首选协议生成会漏掉一半用户。
|
|
267
|
-
"""
|
|
268
|
-
out: list[str] = []
|
|
269
|
-
seen: set[int] = set()
|
|
270
|
-
for scheme, port in _LOCAL_PROXY_SPECS:
|
|
271
|
-
if port in seen or not probe(port, 0.4):
|
|
272
|
-
continue
|
|
273
|
-
seen.add(port)
|
|
274
|
-
out.append(f"{scheme}://127.0.0.1:{port}")
|
|
275
|
-
out.append(f"{'http' if scheme == 'socks5' else 'socks5'}://127.0.0.1:{port}")
|
|
276
|
-
return out
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
def _pool_proxy_get(home: Path, admin: str, port: int):
|
|
280
|
-
"""读号池当前 proxy_url;读取失败返回 None(不确定状态:不探测也不清除)。"""
|
|
281
|
-
try:
|
|
282
|
-
with httpx.Client(timeout=httpx.Timeout(12), trust_env=False) as c:
|
|
283
|
-
r = c.get(f"http://127.0.0.1:{port}/api/proxy/runtime",
|
|
284
|
-
headers={"Authorization": "Bearer " + admin})
|
|
285
|
-
if r.status_code == 200:
|
|
286
|
-
rt = (r.json() or {}).get("runtime") or {}
|
|
287
|
-
return str(rt.get("proxy_url") or "")
|
|
288
|
-
except Exception:
|
|
289
|
-
pass
|
|
290
|
-
return None
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
def _pool_proxy_set(home: Path, admin: str, port: int, url: str) -> bool:
|
|
294
|
-
body = {"enabled": True, "egress_mode": "single_proxy",
|
|
295
|
-
"proxy_url": url, "resource_proxy_url": url}
|
|
296
|
-
try:
|
|
297
|
-
with httpx.Client(timeout=httpx.Timeout(20), trust_env=False) as c:
|
|
298
|
-
r = c.post(f"http://127.0.0.1:{port}/api/proxy/runtime",
|
|
299
|
-
json=body, headers={"Authorization": "Bearer " + admin})
|
|
300
|
-
return r.status_code in (200, 201, 204)
|
|
301
|
-
except Exception:
|
|
302
|
-
return False
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
def _pool_proxy_test(home: Path, admin: str, port: int) -> bool:
|
|
306
|
-
try:
|
|
307
|
-
with httpx.Client(timeout=httpx.Timeout(30), trust_env=False) as c:
|
|
308
|
-
r = c.post(f"http://127.0.0.1:{port}/api/proxy/test",
|
|
309
|
-
json={"url": ""}, headers={"Authorization": "Bearer " + admin})
|
|
310
|
-
if r.status_code != 200:
|
|
311
|
-
return False
|
|
312
|
-
res = (r.json() or {}).get("result") or {}
|
|
313
|
-
return bool(res.get("ok"))
|
|
314
|
-
except Exception:
|
|
315
|
-
return False
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
_EGRESS_CACHE: dict = {"at": 0.0, "data": {}}
|
|
319
|
-
_EGRESS_LOCK = threading.Lock()
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
def proxy_host_port(url: str) -> tuple[str, int]:
|
|
323
|
-
"""解析代理地址;端口解析不出来返回 0。"""
|
|
324
|
-
try:
|
|
325
|
-
u = urlparse(url if "://" in str(url) else "http://" + str(url))
|
|
326
|
-
host = (u.hostname or "").lower()
|
|
327
|
-
return host, int(u.port or (443 if u.scheme == "https" else 80))
|
|
328
|
-
except Exception:
|
|
329
|
-
return "", 0
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
def proxy_alive(url: str) -> bool:
|
|
333
|
-
"""这个代理在本机还活着吗。生图没有出口代理就出不了图,所以要能判断。
|
|
334
|
-
|
|
335
|
-
只对回环地址做端口探测:远程代理本机判断不了,一律当作可用,不擅自替换。
|
|
336
|
-
"""
|
|
337
|
-
host, port = proxy_host_port(url)
|
|
338
|
-
if not host:
|
|
339
|
-
return False
|
|
340
|
-
if host not in ("127.0.0.1", "localhost", "::1"):
|
|
341
|
-
return True
|
|
342
|
-
return bool(port) and probe(port, 0.35)
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
def detect_egress(cache_seconds: float = 20.0) -> dict:
|
|
346
|
-
"""探测当前设备实际可用的出口代理,只读,不改号池配置。
|
|
347
|
-
|
|
348
|
-
面板里那一栏永远显示本机真实情况,而不是写死某个地址:换一台电脑、
|
|
349
|
-
换了代理端口,显示会跟着变。顺序:环境变量优先,其次扫本机常见端口。
|
|
350
|
-
"""
|
|
351
|
-
now = time.time()
|
|
352
|
-
with _EGRESS_LOCK:
|
|
353
|
-
cached = _EGRESS_CACHE["data"]
|
|
354
|
-
if cached and now - float(_EGRESS_CACHE["at"]) < cache_seconds:
|
|
355
|
-
return dict(cached)
|
|
356
|
-
envp = _env_egress_proxy()
|
|
357
|
-
env_any = _env_egress_proxy(relaxed=True)
|
|
358
|
-
ports: list[dict] = []
|
|
359
|
-
seen: set[int] = set()
|
|
360
|
-
for scheme, port in _LOCAL_PROXY_SPECS:
|
|
361
|
-
if port in seen or not probe(port, 0.3):
|
|
362
|
-
continue
|
|
363
|
-
seen.add(port)
|
|
364
|
-
alt = "http" if scheme == "socks5" else "socks5"
|
|
365
|
-
ports.append({"port": port, "scheme": scheme,
|
|
366
|
-
"url": f"{scheme}://127.0.0.1:{port}",
|
|
367
|
-
"alt": f"{alt}://127.0.0.1:{port}"})
|
|
368
|
-
url = envp or (ports[0]["url"] if ports else "")
|
|
369
|
-
data = {"ok": True, "url": url,
|
|
370
|
-
"source": "env" if envp else ("port" if ports else "none"),
|
|
371
|
-
"env": envp,
|
|
372
|
-
"env_ignored": env_any if env_any and env_any != envp else "",
|
|
373
|
-
"ports": ports, "checked_at": int(now)}
|
|
374
|
-
with _EGRESS_LOCK:
|
|
375
|
-
_EGRESS_CACHE["at"] = now
|
|
376
|
-
_EGRESS_CACHE["data"] = data
|
|
377
|
-
return dict(data)
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
def auto_detect_proxy(home: Path, port: int, recheck: bool = True) -> dict:
|
|
381
|
-
"""号池上线后校准一次出口代理,命中即持久化,否则保持直连。
|
|
382
|
-
|
|
383
|
-
recheck=True 时,如果已保存的地址在本机已经连不上(换了电脑、换了代理
|
|
384
|
-
端口、客户端关了),会重新探测并覆盖;本机还在监听的原值一律保留,
|
|
385
|
-
不会覆盖用户手填的地址。
|
|
386
|
-
"""
|
|
387
|
-
home = Path(home)
|
|
388
|
-
admin = ensure_admin_key(home)
|
|
389
|
-
if not admin:
|
|
390
|
-
return {"ok": False, "reason": "no_admin"}
|
|
391
|
-
cur = _pool_proxy_get(home, admin, port)
|
|
392
|
-
if cur is None:
|
|
393
|
-
return {"ok": True, "skipped": True, "reason": "read_failed"}
|
|
394
|
-
if cur:
|
|
395
|
-
if not recheck or proxy_alive(cur):
|
|
396
|
-
return {"ok": True, "skipped": True, "reason": "already_set", "proxy_url": cur}
|
|
397
|
-
log("已保存的出口代理 " + cur + " 在本机已连不上,重新探测")
|
|
398
|
-
cands: list[str] = []
|
|
399
|
-
envp = _env_egress_proxy()
|
|
400
|
-
if envp:
|
|
401
|
-
cands.append(envp)
|
|
402
|
-
cands += _local_proxy_candidates()
|
|
403
|
-
if not cands:
|
|
404
|
-
log("出口代理自动探测:无候选(无本地代理端口、无 env 代理),保持直连")
|
|
405
|
-
return {"ok": True, "skipped": True, "reason": "no_candidate"}
|
|
406
|
-
for url in cands:
|
|
407
|
-
try:
|
|
408
|
-
if not _pool_proxy_set(home, admin, port, url):
|
|
409
|
-
continue
|
|
410
|
-
time.sleep(0.3)
|
|
411
|
-
if _pool_proxy_test(home, admin, port):
|
|
412
|
-
st = _read_state(home)
|
|
413
|
-
st["proxy_auto_url"] = url
|
|
414
|
-
_write_state(home, st)
|
|
415
|
-
log("出口代理自动探测命中并持久化:" + url)
|
|
416
|
-
return {"ok": True, "proxy_url": url, "auto": True}
|
|
417
|
-
except Exception as e:
|
|
418
|
-
log("出口代理候选 " + url + " 验证失败:" + pool_mod.mask(e)[:120])
|
|
419
|
-
# 候选都不通:确保回到直连,不残留错误代理
|
|
420
|
-
try:
|
|
421
|
-
_pool_proxy_set(home, admin, port, "")
|
|
422
|
-
except Exception:
|
|
423
|
-
pass
|
|
424
|
-
log("出口代理自动探测:候选均不通,保持直连")
|
|
425
|
-
return {"ok": True, "proxy_url": "", "auto": False}
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
def status(cfg: dict | None = None) -> dict:
|
|
429
|
-
"""面板与 DSH 外壳共用的状态快照(不写文件、不启动进程)。"""
|
|
430
|
-
v = view(cfg)
|
|
431
|
-
home = v["home"]
|
|
432
|
-
rt = runtime(home)
|
|
433
|
-
admin = ""
|
|
434
|
-
if v["manageable"]:
|
|
435
|
-
admin = ensure_admin_key(home)
|
|
436
|
-
running = probe(v["port"])
|
|
437
|
-
h = health(v["port"]) if running else {"online": False, "message": "号池端口未监听"}
|
|
438
|
-
stopped = (home / STOP_FLAG).exists()
|
|
439
|
-
msg = h.get("message") or ""
|
|
440
|
-
if v["manageable"] and not running:
|
|
441
|
-
msg = ("已手动停止,点「启动号池」或打开自动拉起" if stopped
|
|
442
|
-
else ("号池没在跑,点「启动号池」由本工作台拉起"
|
|
443
|
-
if rt["ready"] else
|
|
444
|
-
"号池没在跑,首次启动会从插件自带运行时初始化并装依赖(约 1–3 分钟)"))
|
|
445
|
-
if not v["local"]:
|
|
446
|
-
msg = "当前接口不是本机号池,工作台不代管第三方接口"
|
|
447
|
-
elif not v["manageable"]:
|
|
448
|
-
msg = "38650 是写作台号池,本工作台只读,不代管它的进程"
|
|
449
|
-
vend = vendor_dir()
|
|
450
|
-
return {
|
|
451
|
-
"local": v["local"], "manageable": v["manageable"],
|
|
452
|
-
"port": v["port"], "home": str(home), "base_url": v["base_url"],
|
|
453
|
-
"vendor_bundled": bool(vend), "vendor_path": str(vend) if vend else "",
|
|
454
|
-
"running": running, "online": bool(h.get("online")),
|
|
455
|
-
"total": int(h.get("total") or 0), "active": int(h.get("active") or 0),
|
|
456
|
-
"limited": int(h.get("limited") or 0), "quota": int(h.get("quota") or 0),
|
|
457
|
-
"runtime_ready": rt["ready"], "runtime_reason": rt["reason"], "runtime": rt,
|
|
458
|
-
"admin_key_ready": bool(admin), "auto_start": auto_start(home),
|
|
459
|
-
"stopped_by_user": stopped,
|
|
460
|
-
"log": str(home / "pool-service.log"),
|
|
461
|
-
"last": dict(_LAST),
|
|
462
|
-
"message": msg,
|
|
463
|
-
}
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
# ---------- 首次初始化 ----------
|
|
467
|
-
def _rr_patch(source: Path) -> str:
|
|
468
|
-
"""号池 round-robin 游标持久化补丁(幂等),移植自旧号池插件。"""
|
|
469
|
-
f = source / "services" / "account_service.py"
|
|
470
|
-
if not f.is_file():
|
|
471
|
-
return "号池源码缺 account_service.py,跳过轮转补丁"
|
|
472
|
-
try:
|
|
473
|
-
src = f.read_text(encoding="utf-8")
|
|
474
|
-
except Exception:
|
|
475
|
-
return "轮转补丁读取失败,已跳过"
|
|
476
|
-
if RR_MARK in src:
|
|
477
|
-
return ""
|
|
478
|
-
pairs = [
|
|
479
|
-
(" self._index = 0\n self._accounts = self._load_accounts()",
|
|
480
|
-
" self._index = 0\n"
|
|
481
|
-
f" {RR_MARK} 图片/文本各一个游标,持久化到 data/.rr_index.json\n"
|
|
482
|
-
' _rr_state = self._load_rr_state()\n'
|
|
483
|
-
' self._image_index = int(_rr_state.get("image") or 0)\n'
|
|
484
|
-
' self._text_index = int(_rr_state.get("text") or 0)\n'
|
|
485
|
-
" self._accounts = self._load_accounts()"),
|
|
486
|
-
(" def _get_cumulative_file(self) -> Path:",
|
|
487
|
-
f" {RR_MARK} begin 轮转游标读写\n"
|
|
488
|
-
" def _get_rr_state_file(self) -> Path:\n"
|
|
489
|
-
" from services.config import DATA_DIR\n"
|
|
490
|
-
' return DATA_DIR / ".rr_index.json"\n\n'
|
|
491
|
-
" def _load_rr_state(self) -> dict:\n"
|
|
492
|
-
" try:\n"
|
|
493
|
-
" f = self._get_rr_state_file()\n"
|
|
494
|
-
" if f.exists():\n"
|
|
495
|
-
' data = json.loads(f.read_text(encoding="utf-8") or "{}")\n'
|
|
496
|
-
" if isinstance(data, dict):\n"
|
|
497
|
-
' return {"image": int(data.get("image") or 0),\n'
|
|
498
|
-
' "text": int(data.get("text") or 0)}\n'
|
|
499
|
-
" except Exception:\n"
|
|
500
|
-
" pass\n"
|
|
501
|
-
' return {"image": 0, "text": 0}\n\n'
|
|
502
|
-
" def _save_rr_state(self) -> None:\n"
|
|
503
|
-
" try:\n"
|
|
504
|
-
" f = self._get_rr_state_file()\n"
|
|
505
|
-
" f.parent.mkdir(parents=True, exist_ok=True)\n"
|
|
506
|
-
' tmp = f.with_suffix(".json.tmp")\n'
|
|
507
|
-
' tmp.write_text(json.dumps({"image": int(getattr(self, "_image_index", 0)),\n'
|
|
508
|
-
' "text": int(getattr(self, "_text_index", 0))}),\n'
|
|
509
|
-
' encoding="utf-8")\n'
|
|
510
|
-
" tmp.replace(f)\n"
|
|
511
|
-
" except Exception:\n"
|
|
512
|
-
" pass\n"
|
|
513
|
-
f" {RR_MARK} end\n\n"
|
|
514
|
-
" def _get_cumulative_file(self) -> Path:"),
|
|
515
|
-
(" if tokens:\n"
|
|
516
|
-
" access_token = tokens[self._index % len(tokens)]\n"
|
|
517
|
-
" self._index += 1",
|
|
518
|
-
" if tokens:\n"
|
|
519
|
-
" access_token = tokens[self._image_index % len(tokens)]\n"
|
|
520
|
-
" self._image_index += 1\n"
|
|
521
|
-
" self._save_rr_state()"),
|
|
522
|
-
(" access_token = candidates[self._index % len(candidates)]\n"
|
|
523
|
-
" self._index += 1",
|
|
524
|
-
" access_token = candidates[self._text_index % len(candidates)]\n"
|
|
525
|
-
" self._text_index += 1\n"
|
|
526
|
-
" self._save_rr_state()"),
|
|
527
|
-
]
|
|
528
|
-
out = src
|
|
529
|
-
for old, new in pairs:
|
|
530
|
-
if out.count(old) != 1:
|
|
531
|
-
return "轮转补丁锚点不匹配(号池源码版本可能变了),已跳过,不影响出图"
|
|
532
|
-
out = out.replace(old, new)
|
|
533
|
-
try:
|
|
534
|
-
bak = f.with_name(f.name + ".bak_rr_" + time.strftime("%Y%m%d"))
|
|
535
|
-
if not bak.exists():
|
|
536
|
-
shutil.copy2(f, bak)
|
|
537
|
-
f.write_text(out, encoding="utf-8")
|
|
538
|
-
except Exception as e:
|
|
539
|
-
return "轮转补丁写入失败:" + pool_mod.mask(e)[:80]
|
|
540
|
-
return ""
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
PROXY_MARK = "# workbench:upstream-proxy"
|
|
544
|
-
|
|
545
|
-
_PROXY_PATCHES = (
|
|
546
|
-
(
|
|
547
|
-
"services/openai_backend_api.py",
|
|
548
|
-
' impersonate=self.fp["impersonate"],\n'
|
|
549
|
-
' verify=True,\n'
|
|
550
|
-
' ))',
|
|
551
|
-
' impersonate=self.fp["impersonate"],\n'
|
|
552
|
-
' verify=True,\n'
|
|
553
|
-
' upstream=True, ' + PROXY_MARK + '\n'
|
|
554
|
-
' ))',
|
|
555
|
-
),
|
|
556
|
-
(
|
|
557
|
-
"services/account_service.py",
|
|
558
|
-
'build_session_kwargs(account=account, impersonate="chrome110", verify=True))',
|
|
559
|
-
'build_session_kwargs(account=account, impersonate="chrome110", verify=True, '
|
|
560
|
-
'upstream=True)) ' + PROXY_MARK,
|
|
561
|
-
),
|
|
562
|
-
(
|
|
563
|
-
"services/oauth_login_service.py",
|
|
564
|
-
'build_session_kwargs(impersonate="chrome", verify=False)',
|
|
565
|
-
'build_session_kwargs(impersonate="chrome", verify=False, upstream=True) ' + PROXY_MARK,
|
|
566
|
-
),
|
|
567
|
-
)
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
def _proxy_patch(source: Path) -> str:
|
|
571
|
-
"""让界面里配置的出口代理在真实上游请求中生效(幂等,可反复执行)。
|
|
572
|
-
|
|
573
|
-
上游 proxy_service.get_profile() 只有在 upstream=True 时才启用
|
|
574
|
-
proxy_runtime 的 single_proxy;而访问 ChatGPT 的调用点全都没传这个参数,
|
|
575
|
-
于是「接口与号池 → 号池出口代理」填好地址、点测试也通过,真实请求却
|
|
576
|
-
一律直连。受限网络下就是超时,额度永远刷不回来,账号被显示成「已用完」。
|
|
577
|
-
|
|
578
|
-
只补三处真正访问上游的调用:
|
|
579
|
-
openai_backend_api 拉额度与出图的核心客户端
|
|
580
|
-
account_service refresh_token 换 access_token
|
|
581
|
-
oauth_login_service 登录码换 token
|
|
582
|
-
内容审核、CPA、用户图片下载走的是第三方地址,保持原样,
|
|
583
|
-
避免把本来能直连的请求也塞进出网代理。
|
|
584
|
-
|
|
585
|
-
补丁写回前先做语法校验,校验不过就放弃该文件,不影响号池启动。
|
|
586
|
-
返回空串表示无需改动或未应用;返回 applied:N 表示本次真的改了 N 个文件。
|
|
587
|
-
"""
|
|
588
|
-
applied, skipped = [], []
|
|
589
|
-
for rel, old, new in _PROXY_PATCHES:
|
|
590
|
-
f = source / rel
|
|
591
|
-
if not f.is_file():
|
|
592
|
-
skipped.append(rel + " 缺文件")
|
|
593
|
-
continue
|
|
594
|
-
try:
|
|
595
|
-
text = f.read_bytes().decode("utf-8")
|
|
596
|
-
except Exception:
|
|
597
|
-
skipped.append(rel + " 读取失败")
|
|
598
|
-
continue
|
|
599
|
-
# 上游文件混用 LF 与 CRLF,跨行锚点必须按归一化后的文本匹配,
|
|
600
|
-
# 写回时再还原原有行尾,避免整个文件被改写。
|
|
601
|
-
crlf = "\r\n" in text
|
|
602
|
-
norm = text.replace("\r\n", "\n")
|
|
603
|
-
if PROXY_MARK in norm:
|
|
604
|
-
continue
|
|
605
|
-
if norm.count(old) != 1:
|
|
606
|
-
skipped.append(rel + " 锚点不匹配")
|
|
607
|
-
continue
|
|
608
|
-
patched = norm.replace(old, new)
|
|
609
|
-
try:
|
|
610
|
-
compile(patched, str(f), "exec")
|
|
611
|
-
except SyntaxError:
|
|
612
|
-
skipped.append(rel + " 补丁后语法异常")
|
|
613
|
-
continue
|
|
614
|
-
out = patched.replace("\n", "\r\n") if crlf else patched
|
|
615
|
-
try:
|
|
616
|
-
# 留一份原件,需要回滚时直接覆盖回去即可
|
|
617
|
-
bak = f.with_name(f.name + ".bak_proxy")
|
|
618
|
-
if not bak.exists():
|
|
619
|
-
shutil.copy2(f, bak)
|
|
620
|
-
f.write_bytes(out.encode("utf-8"))
|
|
621
|
-
applied.append(rel)
|
|
622
|
-
except Exception:
|
|
623
|
-
skipped.append(rel + " 写入失败")
|
|
624
|
-
if not applied and not skipped:
|
|
625
|
-
return ""
|
|
626
|
-
if applied:
|
|
627
|
-
log("出口代理补丁已应用 %d 处:%s" % (len(applied), "、".join(applied)))
|
|
628
|
-
if skipped:
|
|
629
|
-
log("出口代理补丁跳过 %d 处:%s" % (len(skipped), "、".join(skipped)))
|
|
630
|
-
return ("applied:%d" % len(applied)) if applied else ("skipped:%d" % len(skipped))
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
def _venv_python(src: Path) -> str:
|
|
634
|
-
for p in (src / ".venv" / "Scripts" / "python.exe",
|
|
635
|
-
src / ".venv" / "bin" / "python"):
|
|
636
|
-
if p.is_file():
|
|
637
|
-
return str(p)
|
|
638
|
-
return ""
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
def _find_python313() -> str:
|
|
642
|
-
"""找一台机器上现成的 Python 3.13+;找不到就交给 uv 自己下载。"""
|
|
643
|
-
cands = [sys.executable, shutil.which("python") or ""]
|
|
644
|
-
wb = Path.home() / ".workbuddy" / "binaries" / "python" / "versions"
|
|
645
|
-
if wb.is_dir():
|
|
646
|
-
cands += [str(p / ("python.exe" if os.name == "nt" else "python"))
|
|
647
|
-
for p in sorted(wb.iterdir(), reverse=True)]
|
|
648
|
-
for c in cands:
|
|
649
|
-
if not c or not Path(c).is_file():
|
|
650
|
-
continue
|
|
651
|
-
try:
|
|
652
|
-
r = subprocess.run([c, "-c", "import sys;print(sys.version_info[0],sys.version_info[1])"],
|
|
653
|
-
capture_output=True, text=True, timeout=6,
|
|
654
|
-
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0))
|
|
655
|
-
a = (r.stdout or "").split()
|
|
656
|
-
if len(a) == 2 and (int(a[0]), int(a[1])) >= (3, 13):
|
|
657
|
-
return c
|
|
658
|
-
except Exception:
|
|
659
|
-
continue
|
|
660
|
-
return ""
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
def _venv_ready(src: Path) -> bool:
|
|
664
|
-
"""虚拟环境是否真的能跑号池。
|
|
665
|
-
|
|
666
|
-
只看 python.exe 存在不够:上一轮装依赖失败时目录已经建好、包却没装上,
|
|
667
|
-
之后每次启动都是 "No module named uvicorn",而代码以为环境已就绪。
|
|
668
|
-
"""
|
|
669
|
-
py = _venv_python(src)
|
|
670
|
-
if not py:
|
|
671
|
-
return False
|
|
672
|
-
try:
|
|
673
|
-
r = subprocess.run([py, "-c", "import fastapi, uvicorn"], cwd=str(src),
|
|
674
|
-
capture_output=True, text=True, timeout=90,
|
|
675
|
-
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0))
|
|
676
|
-
return r.returncode == 0
|
|
677
|
-
except Exception:
|
|
678
|
-
return False
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
def _index_candidates(proxy: str) -> list[tuple[str, str, bool]]:
|
|
682
|
-
"""依赖源候选:(标签, 索引地址, 是否走代理)。
|
|
683
|
-
|
|
684
|
-
镜像站必须直连。经代理访问清华镜像会返回 403,uv 只会说
|
|
685
|
-
「fastapi 不在 registry 里」,报错完全指不到真正原因。
|
|
686
|
-
"""
|
|
687
|
-
out: list[tuple[str, str, bool]] = []
|
|
688
|
-
custom = (os.environ.get("DSH_IMAGE_PYPI_INDEX") or "").strip()
|
|
689
|
-
if custom:
|
|
690
|
-
out.append(("自定义源", custom, False))
|
|
691
|
-
out.append(("清华镜像", "https://pypi.tuna.tsinghua.edu.cn/simple", False))
|
|
692
|
-
out.append(("阿里云镜像", "https://mirrors.aliyun.com/pypi/simple", False))
|
|
693
|
-
if proxy:
|
|
694
|
-
out.append(("官方 PyPI", "https://pypi.org/simple", True))
|
|
695
|
-
out.append(("官方 PyPI", "https://pypi.org/simple", False))
|
|
696
|
-
return out
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
def _ensure_venv(home: Path, src: Path, uv: str) -> dict:
|
|
700
|
-
"""用插件自带的 uv 按 lock 建独立虚拟环境,全程不需要第三方插件。"""
|
|
701
|
-
if _venv_ready(src):
|
|
702
|
-
return {"ok": True, "message": ""}
|
|
703
|
-
if not uv:
|
|
704
|
-
return {"ok": False, "message": "号池运行时缺 uv,且系统 PATH 里也找不到 uv。"
|
|
705
|
-
"请重新完整安装工作台插件(vendor/chatgpt2api/runtime/)。"}
|
|
706
|
-
base = os.environ.copy()
|
|
707
|
-
base["UV_CACHE_DIR"] = str(home / "uv-cache")
|
|
708
|
-
base["UV_PYTHON_DOWNLOADS"] = "automatic" # 全新机器允许 uv 自己取 Python 3.13
|
|
709
|
-
base.setdefault("UV_HTTP_TIMEOUT", "120")
|
|
710
|
-
# 允许用户指定 Python 运行时的下载镜像(UV_PYTHON_INSTALL_MIRROR)。
|
|
711
|
-
# 国内直连 GitHub Releases 经常下不动 3.13,这里跟 host.js 用同一个开关。
|
|
712
|
-
mirror = (os.environ.get("DSH_IMAGE_PYTHON_MIRROR") or "").strip()
|
|
713
|
-
if mirror:
|
|
714
|
-
base["UV_PYTHON_INSTALL_MIRROR"] = mirror
|
|
715
|
-
# 系统未设出口代理时,探测本机常见代理(Shadowsocks/Clash/privoxy 等)并注入 uv,
|
|
716
|
-
# 让 uv 能经代理下载 Python 3.13 运行时,覆盖「开了代理但没设环境变量」的典型场景。
|
|
717
|
-
proxy = _env_egress_proxy()
|
|
718
|
-
if not proxy:
|
|
719
|
-
for cand in _local_proxy_candidates():
|
|
720
|
-
proxy = cand
|
|
721
|
-
break
|
|
722
|
-
|
|
723
|
-
cmd = [uv, "sync", "--frozen", "--no-dev"]
|
|
724
|
-
py = _find_python313()
|
|
725
|
-
if py:
|
|
726
|
-
cmd += ["--python", py]
|
|
727
|
-
log("开始建号池虚拟环境:" + " ".join(cmd[1:]))
|
|
728
|
-
proxy_keys = ("HTTPS_PROXY", "https_proxy", "HTTP_PROXY", "http_proxy",
|
|
729
|
-
"ALL_PROXY", "all_proxy")
|
|
730
|
-
no_proxy = ("pypi.tuna.tsinghua.edu.cn,mirrors.aliyun.com,pypi.org,"
|
|
731
|
-
"files.pythonhosted.org,127.0.0.1,localhost")
|
|
732
|
-
last = ""
|
|
733
|
-
for label, index, via_proxy in _index_candidates(proxy):
|
|
734
|
-
env = dict(base)
|
|
735
|
-
env["UV_DEFAULT_INDEX_URL"] = index
|
|
736
|
-
if via_proxy and proxy:
|
|
737
|
-
env["HTTPS_PROXY"] = proxy
|
|
738
|
-
env["HTTP_PROXY"] = proxy
|
|
739
|
-
else:
|
|
740
|
-
for k in proxy_keys:
|
|
741
|
-
env.pop(k, None)
|
|
742
|
-
env["NO_PROXY"] = env["no_proxy"] = no_proxy
|
|
743
|
-
log("号池依赖源:%s(%s)" % (label, ("经代理 " + proxy) if (via_proxy and proxy) else "直连"))
|
|
744
|
-
try:
|
|
745
|
-
r = subprocess.run(cmd, cwd=str(src), env=env, capture_output=True,
|
|
746
|
-
text=True, timeout=600,
|
|
747
|
-
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0))
|
|
748
|
-
except Exception as e:
|
|
749
|
-
last = pool_mod.mask(e)[:160]
|
|
750
|
-
log("号池依赖安装异常:" + last)
|
|
751
|
-
continue
|
|
752
|
-
if _venv_ready(src):
|
|
753
|
-
log("号池虚拟环境已就绪:" + str(_venv_python(src)) + "(" + label + ")")
|
|
754
|
-
return {"ok": True, "message": ""}
|
|
755
|
-
last = (((r.stderr or "") + "\n" + (r.stdout or "")).strip()[-400:]
|
|
756
|
-
or "装完仍缺 fastapi/uvicorn")
|
|
757
|
-
log(" " + label + " 失败:" + last.replace("\n", " ")[:280])
|
|
758
|
-
return {"ok": False,
|
|
759
|
-
"message": "号池虚拟环境没建起来(依赖源被拒或网络不通)。"
|
|
760
|
-
"日志见 pool-service.log。原始输出:" + last}
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
def bootstrap(home: Path) -> dict:
|
|
764
|
-
"""从插件自带 vendor 释放一份独立号池:代码自带、环境自建、账号库不共享。"""
|
|
765
|
-
home = Path(home)
|
|
766
|
-
src = home / "source"
|
|
767
|
-
vend = vendor_dir()
|
|
768
|
-
if not vend:
|
|
769
|
-
return {"ok": False,
|
|
770
|
-
"message": "工作台插件自带的号池运行时缺失(vendor/chatgpt2api)。"
|
|
771
|
-
"安装包不完整,请重新完整安装本工作台插件;"
|
|
772
|
-
"不需要也不应该另装任何第三方号池插件。"}
|
|
773
|
-
template = vend / "source"
|
|
774
|
-
if template.resolve() == src.resolve():
|
|
775
|
-
return {"ok": False, "message": "号池源码目录异常,请检查号池数据目录"}
|
|
776
|
-
log("开始初始化独立号池,代码来自插件自带 " + str(template))
|
|
777
|
-
try:
|
|
778
|
-
home.mkdir(parents=True, exist_ok=True)
|
|
779
|
-
|
|
780
|
-
def _ignore(directory, names):
|
|
781
|
-
skip = {"data", "logs", "__pycache__", ".git", "tmp", "temp", ".venv"}
|
|
782
|
-
return [n for n in names
|
|
783
|
-
if n in skip or n.endswith(".log") or n.endswith(".pyc")]
|
|
784
|
-
|
|
785
|
-
shutil.copytree(template, src, dirs_exist_ok=True, ignore=_ignore)
|
|
786
|
-
# 出厂默认配置:账号、密钥、代理一律不带
|
|
787
|
-
cfg = src / "config.json"
|
|
788
|
-
if not cfg.is_file() and (template / "config.default.json").is_file():
|
|
789
|
-
shutil.copy2(template / "config.default.json", cfg)
|
|
790
|
-
(src / "data").mkdir(parents=True, exist_ok=True)
|
|
791
|
-
(home / "uv-cache").mkdir(parents=True, exist_ok=True)
|
|
792
|
-
# uv 可执行文件随插件分发,复制到数据目录后与源码同生命周期
|
|
793
|
-
uv_src = ""
|
|
794
|
-
for c in (vend / "runtime" / "uv.exe", vend / "runtime" / "uv"):
|
|
795
|
-
if c.is_file():
|
|
796
|
-
uv_src = str(c)
|
|
797
|
-
break
|
|
798
|
-
if uv_src:
|
|
799
|
-
dst_dir = home / "runtime"
|
|
800
|
-
dst_dir.mkdir(parents=True, exist_ok=True)
|
|
801
|
-
dst = dst_dir / Path(uv_src).name
|
|
802
|
-
if not dst.is_file():
|
|
803
|
-
shutil.copy2(uv_src, dst)
|
|
804
|
-
keys = home / "user-api-keys.json"
|
|
805
|
-
if not keys.exists():
|
|
806
|
-
keys.write_text("{}\n", encoding="utf-8")
|
|
807
|
-
note = _rr_patch(src)
|
|
808
|
-
if note:
|
|
809
|
-
log(note)
|
|
810
|
-
proxy_note = _proxy_patch(src)
|
|
811
|
-
if proxy_note:
|
|
812
|
-
log(proxy_note)
|
|
813
|
-
except Exception as e:
|
|
814
|
-
return {"ok": False, "message": "初始化号池失败:" + pool_mod.mask(e)[:140]}
|
|
815
|
-
|
|
816
|
-
rt = runtime(home)
|
|
817
|
-
v = _ensure_venv(home, src, rt["uv"])
|
|
818
|
-
if not v["ok"]:
|
|
819
|
-
return {"ok": False, "message": v["message"]}
|
|
820
|
-
rt = runtime(home)
|
|
821
|
-
if not rt["ready"]:
|
|
822
|
-
return {"ok": False, "message": "初始化完成但运行时仍不完整:" + rt["reason"]}
|
|
823
|
-
log("独立号池初始化完成:" + str(src))
|
|
824
|
-
return {"ok": True, "message": "已从插件自带运行时初始化独立号池(无需另装号池插件,账号库不共享)"}
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
# ---------- 进程控制 ----------
|
|
828
|
-
def _port_pids(port: int) -> list[int]:
|
|
829
|
-
"""只找「本机这个端口正在 LISTENING」的 PID,避免误杀外部连接。"""
|
|
830
|
-
pids: set[int] = set()
|
|
831
|
-
try:
|
|
832
|
-
if os.name == "nt":
|
|
833
|
-
r = subprocess.run(["cmd", "/c", f"netstat -ano | findstr :{port}"],
|
|
834
|
-
capture_output=True, text=True, timeout=8,
|
|
835
|
-
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0))
|
|
836
|
-
for line in (r.stdout or "").splitlines():
|
|
837
|
-
if "LISTENING" not in line.upper():
|
|
838
|
-
continue
|
|
839
|
-
parts = line.split()
|
|
840
|
-
if len(parts) < 5 or not parts[1].endswith(":" + str(port)):
|
|
841
|
-
continue
|
|
842
|
-
if parts[-1].isdigit():
|
|
843
|
-
pids.add(int(parts[-1]))
|
|
844
|
-
else:
|
|
845
|
-
r = subprocess.run(["bash", "-lc", f"lsof -nP -iTCP:{port} -sTCP:LISTEN -t"],
|
|
846
|
-
capture_output=True, text=True, timeout=8)
|
|
847
|
-
for s in (r.stdout or "").split():
|
|
848
|
-
if s.isdigit():
|
|
849
|
-
pids.add(int(s))
|
|
850
|
-
except Exception as e:
|
|
851
|
-
log("查端口占用失败:" + pool_mod.mask(e)[:90])
|
|
852
|
-
return sorted(pids)
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
def stop(mark_user_stop: bool = True) -> dict:
|
|
856
|
-
v = view()
|
|
857
|
-
if not v["manageable"]:
|
|
858
|
-
msg = "当前接口不是本工作台管理的号池,未做任何操作"
|
|
859
|
-
_record("stop", False, msg)
|
|
860
|
-
return {"ok": False, "message": msg}
|
|
861
|
-
home = v["home"]
|
|
862
|
-
if mark_user_stop:
|
|
863
|
-
try:
|
|
864
|
-
(home / STOP_FLAG).write_text(time.strftime("%Y-%m-%d %H:%M:%S"), encoding="utf-8")
|
|
865
|
-
except Exception:
|
|
866
|
-
pass
|
|
867
|
-
pids = _port_pids(v["port"])
|
|
868
|
-
for pid in pids:
|
|
869
|
-
try:
|
|
870
|
-
if os.name == "nt":
|
|
871
|
-
subprocess.run(["taskkill", "/F", "/T", "/PID", str(pid)],
|
|
872
|
-
capture_output=True, timeout=10,
|
|
873
|
-
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0))
|
|
874
|
-
else:
|
|
875
|
-
os.kill(pid, 9)
|
|
876
|
-
except Exception as e:
|
|
877
|
-
log("停止 PID " + str(pid) + " 失败:" + pool_mod.mask(e)[:80])
|
|
878
|
-
for _ in range(20):
|
|
879
|
-
if not probe(v["port"], 0.3):
|
|
880
|
-
break
|
|
881
|
-
time.sleep(0.3)
|
|
882
|
-
ok = not probe(v["port"], 0.3)
|
|
883
|
-
msg = ("号池已停止" if ok else "发送了停止指令,但端口仍被占用(可能是别的进程),请看日志")
|
|
884
|
-
_record("stop", ok, msg)
|
|
885
|
-
log(msg)
|
|
886
|
-
return {"ok": ok, "message": msg, "pids": pids}
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
def start(force: bool = False) -> dict:
|
|
890
|
-
with _LOCK:
|
|
891
|
-
v = view()
|
|
892
|
-
if not v["local"]:
|
|
893
|
-
msg = "当前接口不是本机号池,工作台不代管第三方接口"
|
|
894
|
-
_record("start", False, msg)
|
|
895
|
-
return {"ok": False, "message": msg}
|
|
896
|
-
if not v["manageable"]:
|
|
897
|
-
msg = "38650 是写作台号池,本工作台只读,不代管它的进程"
|
|
898
|
-
_record("start", False, msg)
|
|
899
|
-
return {"ok": False, "message": msg}
|
|
900
|
-
home, port = v["home"], v["port"]
|
|
901
|
-
if probe(port) and not force:
|
|
902
|
-
msg = "号池已在运行,无需重复启动"
|
|
903
|
-
_record("start", True, msg)
|
|
904
|
-
# 号池已在跑(如后端重启但号池没掉):补一次零配置代理探测
|
|
905
|
-
try:
|
|
906
|
-
auto_detect_proxy(home, port)
|
|
907
|
-
except Exception as e:
|
|
908
|
-
log("出口代理自动探测异常:" + pool_mod.mask(e)[:120])
|
|
909
|
-
# 运行中的进程加载的是打补丁之前的代码。刚打完补丁就重启一次,
|
|
910
|
-
# 否则界面里配好的出口代理要等用户手动重启才真正生效。
|
|
911
|
-
if _proxy_patch(home / "source").startswith("applied:"):
|
|
912
|
-
log("出口代理补丁首次应用,重启号池使其生效")
|
|
913
|
-
stop(mark_user_stop=False)
|
|
914
|
-
time.sleep(0.6)
|
|
915
|
-
return start()
|
|
916
|
-
return {"ok": True, "message": msg, "already": True}
|
|
917
|
-
if force and probe(port):
|
|
918
|
-
stop(mark_user_stop=False)
|
|
919
|
-
|
|
920
|
-
rt = runtime(home)
|
|
921
|
-
# 目录存在不等于依赖齐了:半装的 venv 会让号池一起来就报 No module named uvicorn
|
|
922
|
-
if rt["ready"] and not _venv_ready(Path(rt["source"])):
|
|
923
|
-
rt = dict(rt, ready=False, reason="号池虚拟环境缺依赖,正在补齐")
|
|
924
|
-
if not rt["ready"]:
|
|
925
|
-
b = bootstrap(home)
|
|
926
|
-
if not b["ok"]:
|
|
927
|
-
_record("start", False, b["message"])
|
|
928
|
-
return {"ok": False, "message": b["message"]}
|
|
929
|
-
rt = runtime(home)
|
|
930
|
-
# 已装好的号池不会重跑 bootstrap,这里再补一次幂等补丁
|
|
931
|
-
_proxy_patch(Path(rt["source"]))
|
|
932
|
-
admin = ensure_admin_key(home)
|
|
933
|
-
if not admin:
|
|
934
|
-
msg = "admin-key 读不到也写不进,检查号池目录权限:" + str(home)
|
|
935
|
-
_record("start", False, msg)
|
|
936
|
-
return {"ok": False, "message": msg}
|
|
937
|
-
|
|
938
|
-
env = os.environ.copy()
|
|
939
|
-
env.update({
|
|
940
|
-
"CHATGPT2API_AUTH_KEY": admin,
|
|
941
|
-
"STORAGE_BACKEND": "sqlite",
|
|
942
|
-
"UV_CACHE_DIR": str(home / "uv-cache"),
|
|
943
|
-
"UV_PYTHON_DOWNLOADS": "never",
|
|
944
|
-
})
|
|
945
|
-
src = Path(rt["source"])
|
|
946
|
-
bindir = src / ".venv" / ("Scripts" if os.name == "nt" else "bin")
|
|
947
|
-
env["VIRTUAL_ENV"] = str(src / ".venv")
|
|
948
|
-
env["PATH"] = str(bindir) + os.pathsep + env.get("PATH", "")
|
|
949
|
-
# 直接执行号池 venv 里的 uvicorn,不再经 uv.exe 中转。
|
|
950
|
-
# 旧写法 `uv run uvicorn ...` 会形成「uv.exe -> uvicorn.exe -> python.exe」三层,
|
|
951
|
-
# 而 DETACHED_PROCESS 只让直接子进程 uv.exe 脱离控制台,Windows 会给孙进程
|
|
952
|
-
# uvicorn.exe 新建一个可见控制台,屏幕上就一直挂着一个黑框,关掉它还会杀掉号池。
|
|
953
|
-
launcher = ""
|
|
954
|
-
for name in ("uvicorn.exe", "uvicorn"):
|
|
955
|
-
f = bindir / name
|
|
956
|
-
if f.is_file():
|
|
957
|
-
launcher = str(f)
|
|
958
|
-
break
|
|
959
|
-
args = ["main:app", "--host", "127.0.0.1", "--port", str(port), "--no-access-log"]
|
|
960
|
-
if launcher:
|
|
961
|
-
cmd = [launcher] + args
|
|
962
|
-
elif _venv_python(src):
|
|
963
|
-
cmd = [_venv_python(src), "-m", "uvicorn"] + args
|
|
964
|
-
else:
|
|
965
|
-
cmd = [rt["uv"], "run", "--no-sync", "uvicorn"] + args
|
|
966
|
-
log_file = home / "pool-service.log"
|
|
967
|
-
flags = 0
|
|
968
|
-
startupinfo = None
|
|
969
|
-
if os.name == "nt":
|
|
970
|
-
# CREATE_NO_WINDOW 明确禁止为控制台程序创建窗口;STARTF_USESHOWWINDOW
|
|
971
|
-
# 是第二重保险。注意 DETACHED_PROCESS 与 CREATE_NO_WINDOW 不能混用。
|
|
972
|
-
flags = (getattr(subprocess, "CREATE_NO_WINDOW", 0)
|
|
973
|
-
| getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0))
|
|
974
|
-
startupinfo = subprocess.STARTUPINFO()
|
|
975
|
-
startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
|
|
976
|
-
startupinfo.wShowWindow = 0 # SW_HIDE
|
|
977
|
-
try:
|
|
978
|
-
home.mkdir(parents=True, exist_ok=True)
|
|
979
|
-
fd = open(log_file, "a", encoding="utf-8", errors="replace")
|
|
980
|
-
p = subprocess.Popen(cmd, cwd=str(src), env=env,
|
|
981
|
-
stdin=subprocess.DEVNULL, stdout=fd, stderr=fd,
|
|
982
|
-
creationflags=flags, startupinfo=startupinfo,
|
|
983
|
-
close_fds=True)
|
|
984
|
-
fd.close()
|
|
985
|
-
except Exception as e:
|
|
986
|
-
msg = "号池启动失败:" + pool_mod.mask(e)[:140]
|
|
987
|
-
_record("start", False, msg)
|
|
988
|
-
return {"ok": False, "message": msg}
|
|
989
|
-
|
|
990
|
-
for _ in range(80): # 首次启动要装依赖,最多等 40 秒
|
|
991
|
-
time.sleep(0.5)
|
|
992
|
-
if probe(port, 0.4):
|
|
993
|
-
try:
|
|
994
|
-
(home / STOP_FLAG).unlink(missing_ok=True)
|
|
995
|
-
except Exception:
|
|
996
|
-
pass
|
|
997
|
-
msg = f"号池已启动:127.0.0.1:{port}(PID {p.pid})"
|
|
998
|
-
_record("start", True, msg)
|
|
999
|
-
log(msg)
|
|
1000
|
-
# 号池上线后零配置探测出口代理(仅当前为空时),不阻塞前台返回
|
|
1001
|
-
try:
|
|
1002
|
-
auto_detect_proxy(home, port)
|
|
1003
|
-
except Exception as e:
|
|
1004
|
-
log("出口代理自动探测异常(不影响号池运行):" + pool_mod.mask(e)[:120])
|
|
1005
|
-
_notify_started()
|
|
1006
|
-
return {"ok": True, "message": msg, "pid": p.pid}
|
|
1007
|
-
if p.poll() is not None:
|
|
1008
|
-
break
|
|
1009
|
-
msg = "号池启动超时,请看日志:" + str(log_file)
|
|
1010
|
-
_record("start", False, msg)
|
|
1011
|
-
return {"ok": False, "message": msg}
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
def restart() -> dict:
|
|
1015
|
-
# 整个停-等-启放在同一把锁里,避免守护线程在这 0.6 秒的窗口里插一脚
|
|
1016
|
-
with _LOCK:
|
|
1017
|
-
v = view()
|
|
1018
|
-
if not v["manageable"]:
|
|
1019
|
-
msg = "当前接口不是本工作台管理的号池,未做任何操作"
|
|
1020
|
-
_record("restart", False, msg)
|
|
1021
|
-
return {"ok": False, "message": msg}
|
|
1022
|
-
stop(mark_user_stop=False)
|
|
1023
|
-
time.sleep(0.6)
|
|
1024
|
-
return start()
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
# ---------- 掉线守护 ----------
|
|
1028
|
-
def _watchdog() -> None:
|
|
1029
|
-
# 启动后台先立刻补一次,之后每 15 秒探一次
|
|
1030
|
-
first = True
|
|
1031
|
-
while True:
|
|
1032
|
-
if not first:
|
|
1033
|
-
time.sleep(15)
|
|
1034
|
-
first = False
|
|
1035
|
-
try:
|
|
1036
|
-
v = view()
|
|
1037
|
-
if not v["manageable"] or not auto_start(v["home"]):
|
|
1038
|
-
continue
|
|
1039
|
-
if (v["home"] / STOP_FLAG).exists():
|
|
1040
|
-
continue
|
|
1041
|
-
if probe(v["port"], 0.4):
|
|
1042
|
-
continue
|
|
1043
|
-
log("守护发现号池掉线,自动拉起")
|
|
1044
|
-
r = start()
|
|
1045
|
-
if not r.get("ok"):
|
|
1046
|
-
log("守护拉起失败:" + str(r.get("message") or "")[:120])
|
|
1047
|
-
except Exception as e:
|
|
1048
|
-
log("守护异常:" + pool_mod.mask(e)[:100])
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
def start_watchdog() -> None:
|
|
1052
|
-
global _WATCHDOG_STARTED
|
|
1053
|
-
if _WATCHDOG_STARTED:
|
|
1054
|
-
return
|
|
1055
|
-
_WATCHDOG_STARTED = True
|
|
1056
|
-
t = threading.Thread(target=_watchdog, name="builtin-pool-watchdog", daemon=True)
|
|
1057
|
-
t.start()
|
|
1058
|
-
log("内置号池守护已启动(15 秒探一次)")
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
# 命令行手动用:python pool_service.py status|start|stop|restart
|
|
1062
|
-
if __name__ == "__main__":
|
|
1063
|
-
action = (sys.argv[1] if len(sys.argv) > 1 else "status").lower()
|
|
1064
|
-
if action == "status":
|
|
1065
|
-
print(json.dumps(status(), ensure_ascii=False, indent=2))
|
|
1066
|
-
elif action == "start":
|
|
1067
|
-
print(json.dumps(start(), ensure_ascii=False, indent=2))
|
|
1068
|
-
elif action == "stop":
|
|
1069
|
-
print(json.dumps(stop(), ensure_ascii=False, indent=2))
|
|
1070
|
-
elif action == "restart":
|
|
1071
|
-
print(json.dumps(restart(), ensure_ascii=False, indent=2))
|
|
1072
|
-
else:
|
|
1073
|
-
print("用法:python pool_service.py status|start|stop|restart")
|
|
1
|
+
"""工作台自带的号池进程管理:启动 / 停止 / 重启 / 掉线守护 / 首次初始化。
|
|
2
|
+
|
|
3
|
+
以前生图号池是另一个 DSH 插件在管,工作台面板只能写「去号池插件里点启动」;
|
|
4
|
+
一旦那个插件没装、没启、或守护失效,工作台就只剩一个空壳。现在号池归工作台自己:
|
|
5
|
+
|
|
6
|
+
* 号池进程由本模块 spawn,后台线程每 15 秒探端口,掉了自动拉起;
|
|
7
|
+
* 号池目录、admin-key、用户 Key、账号库全部由本模块按当前 Base URL 定位;
|
|
8
|
+
* 号池服务端代码随工作台插件自带(vendor/chatgpt2api/),首次启动释放到本机
|
|
9
|
+
数据目录并用自带 uv 建独立虚拟环境,**不需要另装任何第三方号池插件**;
|
|
10
|
+
* 不依赖 DSH:浏览器直接打开 8092 也能用,DSH 插件只负责把页面嵌进侧栏。
|
|
11
|
+
|
|
12
|
+
安全边界:只管本机号池。第三方接口、以及 38650 写作台号池都不代管,
|
|
13
|
+
避免「停止」按钮把别人的号池关掉。
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import os
|
|
19
|
+
import secrets
|
|
20
|
+
import shutil
|
|
21
|
+
import socket
|
|
22
|
+
import subprocess
|
|
23
|
+
import sys
|
|
24
|
+
import threading
|
|
25
|
+
import time
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from urllib.parse import urlparse
|
|
28
|
+
|
|
29
|
+
import httpx
|
|
30
|
+
|
|
31
|
+
import pool as pool_mod
|
|
32
|
+
|
|
33
|
+
LOCAL_HOSTS = {"127.0.0.1", "localhost", "::1"}
|
|
34
|
+
DEFAULT_PORT = 38651
|
|
35
|
+
# 38650 是写作台号池,本工作台只读不碰
|
|
36
|
+
FOREIGN_PORTS = {38650}
|
|
37
|
+
STOP_FLAG = ".workbench-pool-stop"
|
|
38
|
+
STATE_FILE = ".workbench-pool-state.json"
|
|
39
|
+
RR_MARK = "# [dsh-patch:rr-persist]"
|
|
40
|
+
|
|
41
|
+
_LOCK = threading.RLock() # 可重入:start() 内部会递归调用自身来完成重启
|
|
42
|
+
_WATCHDOG_STARTED = False
|
|
43
|
+
_LAST = {"action": "", "ok": True, "message": "", "at": 0.0}
|
|
44
|
+
# 号池启动成功后通知工作台刷新账号统计(由 app.py 在 lifespan 里挂上)。
|
|
45
|
+
# 号池是异步后起的,不主动刷一次,页头会先停在「1 号 / 配额 0」最多一分钟。
|
|
46
|
+
ON_STARTED = None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _notify_started() -> None:
|
|
50
|
+
cb = ON_STARTED
|
|
51
|
+
if not callable(cb):
|
|
52
|
+
return
|
|
53
|
+
try:
|
|
54
|
+
cb()
|
|
55
|
+
except Exception as e:
|
|
56
|
+
log("启动后同步回调失败:" + pool_mod.mask(e)[:100])
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# ---------- 基础工具 ----------
|
|
60
|
+
def log(msg: str) -> None:
|
|
61
|
+
pool_mod.log("[内置号池] " + str(msg))
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _dsh_home() -> Path:
|
|
65
|
+
return pool_mod._dsh_home()
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def vendor_dir() -> Path | None:
|
|
69
|
+
"""工作台插件自带的号池运行时快照。
|
|
70
|
+
|
|
71
|
+
工作台后端可能在两种位置:工作区里的 image-local/,或插件包里的 server/;
|
|
72
|
+
插件也可能通过 profile/node_modules 的目录联接加载。这里全部覆盖,
|
|
73
|
+
唯独不去找本机任何第三方号池插件的源码 —— 那是「能独立安装」的前提。
|
|
74
|
+
"""
|
|
75
|
+
here = Path(__file__).resolve().parent
|
|
76
|
+
candidates = [
|
|
77
|
+
os.environ.get("DSH_IMAGE_POOL_VENDOR"),
|
|
78
|
+
here / "vendor" / "chatgpt2api",
|
|
79
|
+
here.parent / "vendor" / "chatgpt2api",
|
|
80
|
+
here.parent / "dsh-plugin-image-workbench" / "vendor" / "chatgpt2api",
|
|
81
|
+
_dsh_home() / "profiles" / "desktop" / "node_modules"
|
|
82
|
+
/ "dsh-plugin-image-workbench" / "vendor" / "chatgpt2api",
|
|
83
|
+
]
|
|
84
|
+
for c in candidates:
|
|
85
|
+
if c and Path(c, "source", "main.py").is_file():
|
|
86
|
+
return Path(c)
|
|
87
|
+
return None
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _read_state(home: Path) -> dict:
|
|
91
|
+
try:
|
|
92
|
+
d = json.loads((home / STATE_FILE).read_text(encoding="utf-8"))
|
|
93
|
+
return d if isinstance(d, dict) else {}
|
|
94
|
+
except Exception:
|
|
95
|
+
return {}
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _write_state(home: Path, data: dict) -> None:
|
|
99
|
+
try:
|
|
100
|
+
home.mkdir(parents=True, exist_ok=True)
|
|
101
|
+
tmp = home / (STATE_FILE + ".tmp")
|
|
102
|
+
tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
103
|
+
os.replace(tmp, home / STATE_FILE)
|
|
104
|
+
except Exception as e:
|
|
105
|
+
log("状态写入失败:" + pool_mod.mask(e)[:100])
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def auto_start(home: Path) -> bool:
|
|
109
|
+
return _read_state(home).get("auto_start") is not False
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def set_auto_start(home: Path, enabled: bool) -> dict:
|
|
113
|
+
st = _read_state(home)
|
|
114
|
+
st["auto_start"] = bool(enabled)
|
|
115
|
+
_write_state(home, st)
|
|
116
|
+
return {"ok": True, "auto_start": bool(enabled),
|
|
117
|
+
"message": "已开启掉线自动拉起" if enabled else "已关闭掉线自动拉起"}
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _record(action: str, ok: bool, message: str) -> None:
|
|
121
|
+
_LAST.update({"action": action, "ok": bool(ok), "message": str(message), "at": time.time()})
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def view(cfg: dict | None = None) -> dict:
|
|
125
|
+
"""从当前接口配置推出:号池端口、数据目录、是否本机、是否归本工作台管。"""
|
|
126
|
+
cfg = cfg or pool_mod.load_config()
|
|
127
|
+
p = pool_mod.provider_cfg(cfg)
|
|
128
|
+
root = str(p["api_root"] or "")
|
|
129
|
+
u = urlparse(root)
|
|
130
|
+
host = (u.hostname or "").lower()
|
|
131
|
+
try:
|
|
132
|
+
port = int(u.port or 0)
|
|
133
|
+
except Exception:
|
|
134
|
+
port = 0
|
|
135
|
+
if not port:
|
|
136
|
+
port = int(os.environ.get("DSH_IMAGE_POOL_PORT") or DEFAULT_PORT)
|
|
137
|
+
local = host in LOCAL_HOSTS
|
|
138
|
+
home = pool_mod.pool_home(cfg)
|
|
139
|
+
# 自定义端口不该复用 38650/38651 的数据目录,否则会串号
|
|
140
|
+
if local and port not in (38650, 38651) and home.name in ("chatgpt2api-native", "chatgpt2api-image"):
|
|
141
|
+
home = _dsh_home() / "chatgpt2api-workbench"
|
|
142
|
+
# 兼容老安装:如果本机已经有一份带账号的号池目录,继续沿用它的「数据」。
|
|
143
|
+
# 这只是不丢用户已有账号,不是运行时依赖 —— 源码与环境一律来自插件 vendor。
|
|
144
|
+
if local and port == 38651 and home.name != "chatgpt2api-image":
|
|
145
|
+
candidate = _dsh_home() / "chatgpt2api-image"
|
|
146
|
+
if (candidate / "admin-key").exists() or (candidate / "source" / "main.py").exists():
|
|
147
|
+
home = candidate
|
|
148
|
+
return {
|
|
149
|
+
"cfg": cfg, "root": root, "host": host, "port": port, "local": local,
|
|
150
|
+
"home": Path(home), "base_url": str(p["base_url"] or ""),
|
|
151
|
+
"manageable": bool(local) and port not in FOREIGN_PORTS,
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def ensure_admin_key(home: Path) -> str:
|
|
156
|
+
"""号池后端用 CHATGPT2API_AUTH_KEY 校验 /api/accounts。缺了就生成一份。"""
|
|
157
|
+
p = Path(home) / "admin-key"
|
|
158
|
+
try:
|
|
159
|
+
k = p.read_text(encoding="utf-8").strip()
|
|
160
|
+
if k:
|
|
161
|
+
return k
|
|
162
|
+
except Exception:
|
|
163
|
+
pass
|
|
164
|
+
k = "sk-admin-" + secrets.token_urlsafe(32)
|
|
165
|
+
try:
|
|
166
|
+
Path(home).mkdir(parents=True, exist_ok=True)
|
|
167
|
+
tmp = Path(str(p) + ".tmp")
|
|
168
|
+
tmp.write_text(k, encoding="utf-8")
|
|
169
|
+
os.replace(tmp, p)
|
|
170
|
+
log("已生成 admin-key:" + str(p))
|
|
171
|
+
return k
|
|
172
|
+
except Exception as e:
|
|
173
|
+
log("admin-key 写入失败:" + pool_mod.mask(e)[:100])
|
|
174
|
+
return ""
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def runtime(home: Path) -> dict:
|
|
178
|
+
"""号池运行时:uv + source/main.py + source/.venv。"""
|
|
179
|
+
home = Path(home)
|
|
180
|
+
vend = vendor_dir()
|
|
181
|
+
uv = ""
|
|
182
|
+
for c in (os.environ.get("DSH_IMAGE_POOL_UV"),
|
|
183
|
+
home / "runtime" / "uv.exe", home / "runtime" / "uv",
|
|
184
|
+
(vend / "runtime" / "uv.exe") if vend else "",
|
|
185
|
+
(vend / "runtime" / "uv") if vend else "",
|
|
186
|
+
shutil.which("uv") or ""):
|
|
187
|
+
if c and Path(c).is_file():
|
|
188
|
+
uv = str(c)
|
|
189
|
+
break
|
|
190
|
+
source = home / "source"
|
|
191
|
+
has_main = (source / "main.py").is_file()
|
|
192
|
+
has_venv = (source / ".venv").is_dir()
|
|
193
|
+
ready = bool(uv) and has_main and has_venv
|
|
194
|
+
if not uv:
|
|
195
|
+
reason = "缺 uv 运行时"
|
|
196
|
+
elif not has_main:
|
|
197
|
+
reason = "缺号池源码 main.py"
|
|
198
|
+
elif not has_venv:
|
|
199
|
+
reason = "缺号池虚拟环境 .venv"
|
|
200
|
+
else:
|
|
201
|
+
reason = ""
|
|
202
|
+
return {"uv": uv, "source": str(source), "ready": ready, "reason": reason}
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def probe(port: int, timeout: float = 0.5) -> bool:
|
|
206
|
+
try:
|
|
207
|
+
with socket.create_connection(("127.0.0.1", int(port)), timeout=timeout):
|
|
208
|
+
return True
|
|
209
|
+
except Exception:
|
|
210
|
+
return False
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def health(port: int) -> dict:
|
|
214
|
+
try:
|
|
215
|
+
r = httpx.get(f"http://127.0.0.1:{port}/health?format=json",
|
|
216
|
+
timeout=httpx.Timeout(2.5), trust_env=False)
|
|
217
|
+
if r.status_code != 200:
|
|
218
|
+
return {"online": False, "message": f"号池返回 HTTP {r.status_code}"}
|
|
219
|
+
a = r.json().get("accounts") or {}
|
|
220
|
+
return {"online": True, "message": "",
|
|
221
|
+
"total": int(a.get("total") or 0), "active": int(a.get("active") or 0),
|
|
222
|
+
"limited": int(a.get("limited") or 0), "quota": int(a.get("total_quota") or 0)}
|
|
223
|
+
except Exception as e:
|
|
224
|
+
return {"online": False, "message": "号池端口连不上:" + pool_mod.mask(e)[:80]}
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
# ---------- 出口代理零配置自动探测 ----------
|
|
228
|
+
# 常见本机出口代理候选(端口 + 首选协议),按出现频率排序。
|
|
229
|
+
_LOCAL_PROXY_SPECS = [
|
|
230
|
+
("socks5", 1080), # Shadowsocks / 本机 SOCKS5 最常见
|
|
231
|
+
("http", 7890), # Clash HTTP
|
|
232
|
+
("http", 7891), # Clash HTTP 备选
|
|
233
|
+
("http", 8118), # privoxy
|
|
234
|
+
("socks5", 1087), # 部分 SS 客户端
|
|
235
|
+
("http", 1087),
|
|
236
|
+
]
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
_COMMON_PROXY_PORTS = {1080, 1087, 7890, 7891, 7897, 8118, 8080, 8888,
|
|
240
|
+
10808, 10809, 20171, 33210}
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _env_egress_proxy(relaxed: bool = False) -> str:
|
|
244
|
+
"""用户环境显式设了出口代理就直接用,优先级最高。
|
|
245
|
+
|
|
246
|
+
宿主和各类工具链会往环境里塞临时回环代理(随机高端口),那玩意只能通
|
|
247
|
+
它自己的链路,拿去跑号池只会全盘超时。所以回环地址只认常见代理端口。
|
|
248
|
+
"""
|
|
249
|
+
for k in ("HTTPS_PROXY", "https_proxy", "ALL_PROXY", "all_proxy",
|
|
250
|
+
"HTTP_PROXY", "http_proxy"):
|
|
251
|
+
v = (os.environ.get(k) or "").strip()
|
|
252
|
+
if not v:
|
|
253
|
+
continue
|
|
254
|
+
if not relaxed:
|
|
255
|
+
host, port = proxy_host_port(v)
|
|
256
|
+
if host in ("127.0.0.1", "localhost", "::1") and port not in _COMMON_PROXY_PORTS:
|
|
257
|
+
continue
|
|
258
|
+
return v
|
|
259
|
+
return ""
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _local_proxy_candidates() -> list[str]:
|
|
263
|
+
"""本机代理端口在监听的,生成候选 URL。
|
|
264
|
+
|
|
265
|
+
同一个端口两种协议都试一遍:1080 既可能是 SOCKS5(Shadowsocks),
|
|
266
|
+
也可能是 HTTP(部分客户端),只按首选协议生成会漏掉一半用户。
|
|
267
|
+
"""
|
|
268
|
+
out: list[str] = []
|
|
269
|
+
seen: set[int] = set()
|
|
270
|
+
for scheme, port in _LOCAL_PROXY_SPECS:
|
|
271
|
+
if port in seen or not probe(port, 0.4):
|
|
272
|
+
continue
|
|
273
|
+
seen.add(port)
|
|
274
|
+
out.append(f"{scheme}://127.0.0.1:{port}")
|
|
275
|
+
out.append(f"{'http' if scheme == 'socks5' else 'socks5'}://127.0.0.1:{port}")
|
|
276
|
+
return out
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _pool_proxy_get(home: Path, admin: str, port: int):
|
|
280
|
+
"""读号池当前 proxy_url;读取失败返回 None(不确定状态:不探测也不清除)。"""
|
|
281
|
+
try:
|
|
282
|
+
with httpx.Client(timeout=httpx.Timeout(12), trust_env=False) as c:
|
|
283
|
+
r = c.get(f"http://127.0.0.1:{port}/api/proxy/runtime",
|
|
284
|
+
headers={"Authorization": "Bearer " + admin})
|
|
285
|
+
if r.status_code == 200:
|
|
286
|
+
rt = (r.json() or {}).get("runtime") or {}
|
|
287
|
+
return str(rt.get("proxy_url") or "")
|
|
288
|
+
except Exception:
|
|
289
|
+
pass
|
|
290
|
+
return None
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _pool_proxy_set(home: Path, admin: str, port: int, url: str) -> bool:
|
|
294
|
+
body = {"enabled": True, "egress_mode": "single_proxy",
|
|
295
|
+
"proxy_url": url, "resource_proxy_url": url}
|
|
296
|
+
try:
|
|
297
|
+
with httpx.Client(timeout=httpx.Timeout(20), trust_env=False) as c:
|
|
298
|
+
r = c.post(f"http://127.0.0.1:{port}/api/proxy/runtime",
|
|
299
|
+
json=body, headers={"Authorization": "Bearer " + admin})
|
|
300
|
+
return r.status_code in (200, 201, 204)
|
|
301
|
+
except Exception:
|
|
302
|
+
return False
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _pool_proxy_test(home: Path, admin: str, port: int) -> bool:
|
|
306
|
+
try:
|
|
307
|
+
with httpx.Client(timeout=httpx.Timeout(30), trust_env=False) as c:
|
|
308
|
+
r = c.post(f"http://127.0.0.1:{port}/api/proxy/test",
|
|
309
|
+
json={"url": ""}, headers={"Authorization": "Bearer " + admin})
|
|
310
|
+
if r.status_code != 200:
|
|
311
|
+
return False
|
|
312
|
+
res = (r.json() or {}).get("result") or {}
|
|
313
|
+
return bool(res.get("ok"))
|
|
314
|
+
except Exception:
|
|
315
|
+
return False
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
_EGRESS_CACHE: dict = {"at": 0.0, "data": {}}
|
|
319
|
+
_EGRESS_LOCK = threading.Lock()
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def proxy_host_port(url: str) -> tuple[str, int]:
|
|
323
|
+
"""解析代理地址;端口解析不出来返回 0。"""
|
|
324
|
+
try:
|
|
325
|
+
u = urlparse(url if "://" in str(url) else "http://" + str(url))
|
|
326
|
+
host = (u.hostname or "").lower()
|
|
327
|
+
return host, int(u.port or (443 if u.scheme == "https" else 80))
|
|
328
|
+
except Exception:
|
|
329
|
+
return "", 0
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def proxy_alive(url: str) -> bool:
|
|
333
|
+
"""这个代理在本机还活着吗。生图没有出口代理就出不了图,所以要能判断。
|
|
334
|
+
|
|
335
|
+
只对回环地址做端口探测:远程代理本机判断不了,一律当作可用,不擅自替换。
|
|
336
|
+
"""
|
|
337
|
+
host, port = proxy_host_port(url)
|
|
338
|
+
if not host:
|
|
339
|
+
return False
|
|
340
|
+
if host not in ("127.0.0.1", "localhost", "::1"):
|
|
341
|
+
return True
|
|
342
|
+
return bool(port) and probe(port, 0.35)
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def detect_egress(cache_seconds: float = 20.0) -> dict:
|
|
346
|
+
"""探测当前设备实际可用的出口代理,只读,不改号池配置。
|
|
347
|
+
|
|
348
|
+
面板里那一栏永远显示本机真实情况,而不是写死某个地址:换一台电脑、
|
|
349
|
+
换了代理端口,显示会跟着变。顺序:环境变量优先,其次扫本机常见端口。
|
|
350
|
+
"""
|
|
351
|
+
now = time.time()
|
|
352
|
+
with _EGRESS_LOCK:
|
|
353
|
+
cached = _EGRESS_CACHE["data"]
|
|
354
|
+
if cached and now - float(_EGRESS_CACHE["at"]) < cache_seconds:
|
|
355
|
+
return dict(cached)
|
|
356
|
+
envp = _env_egress_proxy()
|
|
357
|
+
env_any = _env_egress_proxy(relaxed=True)
|
|
358
|
+
ports: list[dict] = []
|
|
359
|
+
seen: set[int] = set()
|
|
360
|
+
for scheme, port in _LOCAL_PROXY_SPECS:
|
|
361
|
+
if port in seen or not probe(port, 0.3):
|
|
362
|
+
continue
|
|
363
|
+
seen.add(port)
|
|
364
|
+
alt = "http" if scheme == "socks5" else "socks5"
|
|
365
|
+
ports.append({"port": port, "scheme": scheme,
|
|
366
|
+
"url": f"{scheme}://127.0.0.1:{port}",
|
|
367
|
+
"alt": f"{alt}://127.0.0.1:{port}"})
|
|
368
|
+
url = envp or (ports[0]["url"] if ports else "")
|
|
369
|
+
data = {"ok": True, "url": url,
|
|
370
|
+
"source": "env" if envp else ("port" if ports else "none"),
|
|
371
|
+
"env": envp,
|
|
372
|
+
"env_ignored": env_any if env_any and env_any != envp else "",
|
|
373
|
+
"ports": ports, "checked_at": int(now)}
|
|
374
|
+
with _EGRESS_LOCK:
|
|
375
|
+
_EGRESS_CACHE["at"] = now
|
|
376
|
+
_EGRESS_CACHE["data"] = data
|
|
377
|
+
return dict(data)
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def auto_detect_proxy(home: Path, port: int, recheck: bool = True) -> dict:
|
|
381
|
+
"""号池上线后校准一次出口代理,命中即持久化,否则保持直连。
|
|
382
|
+
|
|
383
|
+
recheck=True 时,如果已保存的地址在本机已经连不上(换了电脑、换了代理
|
|
384
|
+
端口、客户端关了),会重新探测并覆盖;本机还在监听的原值一律保留,
|
|
385
|
+
不会覆盖用户手填的地址。
|
|
386
|
+
"""
|
|
387
|
+
home = Path(home)
|
|
388
|
+
admin = ensure_admin_key(home)
|
|
389
|
+
if not admin:
|
|
390
|
+
return {"ok": False, "reason": "no_admin"}
|
|
391
|
+
cur = _pool_proxy_get(home, admin, port)
|
|
392
|
+
if cur is None:
|
|
393
|
+
return {"ok": True, "skipped": True, "reason": "read_failed"}
|
|
394
|
+
if cur:
|
|
395
|
+
if not recheck or proxy_alive(cur):
|
|
396
|
+
return {"ok": True, "skipped": True, "reason": "already_set", "proxy_url": cur}
|
|
397
|
+
log("已保存的出口代理 " + cur + " 在本机已连不上,重新探测")
|
|
398
|
+
cands: list[str] = []
|
|
399
|
+
envp = _env_egress_proxy()
|
|
400
|
+
if envp:
|
|
401
|
+
cands.append(envp)
|
|
402
|
+
cands += _local_proxy_candidates()
|
|
403
|
+
if not cands:
|
|
404
|
+
log("出口代理自动探测:无候选(无本地代理端口、无 env 代理),保持直连")
|
|
405
|
+
return {"ok": True, "skipped": True, "reason": "no_candidate"}
|
|
406
|
+
for url in cands:
|
|
407
|
+
try:
|
|
408
|
+
if not _pool_proxy_set(home, admin, port, url):
|
|
409
|
+
continue
|
|
410
|
+
time.sleep(0.3)
|
|
411
|
+
if _pool_proxy_test(home, admin, port):
|
|
412
|
+
st = _read_state(home)
|
|
413
|
+
st["proxy_auto_url"] = url
|
|
414
|
+
_write_state(home, st)
|
|
415
|
+
log("出口代理自动探测命中并持久化:" + url)
|
|
416
|
+
return {"ok": True, "proxy_url": url, "auto": True}
|
|
417
|
+
except Exception as e:
|
|
418
|
+
log("出口代理候选 " + url + " 验证失败:" + pool_mod.mask(e)[:120])
|
|
419
|
+
# 候选都不通:确保回到直连,不残留错误代理
|
|
420
|
+
try:
|
|
421
|
+
_pool_proxy_set(home, admin, port, "")
|
|
422
|
+
except Exception:
|
|
423
|
+
pass
|
|
424
|
+
log("出口代理自动探测:候选均不通,保持直连")
|
|
425
|
+
return {"ok": True, "proxy_url": "", "auto": False}
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def status(cfg: dict | None = None) -> dict:
|
|
429
|
+
"""面板与 DSH 外壳共用的状态快照(不写文件、不启动进程)。"""
|
|
430
|
+
v = view(cfg)
|
|
431
|
+
home = v["home"]
|
|
432
|
+
rt = runtime(home)
|
|
433
|
+
admin = ""
|
|
434
|
+
if v["manageable"]:
|
|
435
|
+
admin = ensure_admin_key(home)
|
|
436
|
+
running = probe(v["port"])
|
|
437
|
+
h = health(v["port"]) if running else {"online": False, "message": "号池端口未监听"}
|
|
438
|
+
stopped = (home / STOP_FLAG).exists()
|
|
439
|
+
msg = h.get("message") or ""
|
|
440
|
+
if v["manageable"] and not running:
|
|
441
|
+
msg = ("已手动停止,点「启动号池」或打开自动拉起" if stopped
|
|
442
|
+
else ("号池没在跑,点「启动号池」由本工作台拉起"
|
|
443
|
+
if rt["ready"] else
|
|
444
|
+
"号池没在跑,首次启动会从插件自带运行时初始化并装依赖(约 1–3 分钟)"))
|
|
445
|
+
if not v["local"]:
|
|
446
|
+
msg = "当前接口不是本机号池,工作台不代管第三方接口"
|
|
447
|
+
elif not v["manageable"]:
|
|
448
|
+
msg = "38650 是写作台号池,本工作台只读,不代管它的进程"
|
|
449
|
+
vend = vendor_dir()
|
|
450
|
+
return {
|
|
451
|
+
"local": v["local"], "manageable": v["manageable"],
|
|
452
|
+
"port": v["port"], "home": str(home), "base_url": v["base_url"],
|
|
453
|
+
"vendor_bundled": bool(vend), "vendor_path": str(vend) if vend else "",
|
|
454
|
+
"running": running, "online": bool(h.get("online")),
|
|
455
|
+
"total": int(h.get("total") or 0), "active": int(h.get("active") or 0),
|
|
456
|
+
"limited": int(h.get("limited") or 0), "quota": int(h.get("quota") or 0),
|
|
457
|
+
"runtime_ready": rt["ready"], "runtime_reason": rt["reason"], "runtime": rt,
|
|
458
|
+
"admin_key_ready": bool(admin), "auto_start": auto_start(home),
|
|
459
|
+
"stopped_by_user": stopped,
|
|
460
|
+
"log": str(home / "pool-service.log"),
|
|
461
|
+
"last": dict(_LAST),
|
|
462
|
+
"message": msg,
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
# ---------- 首次初始化 ----------
|
|
467
|
+
def _rr_patch(source: Path) -> str:
|
|
468
|
+
"""号池 round-robin 游标持久化补丁(幂等),移植自旧号池插件。"""
|
|
469
|
+
f = source / "services" / "account_service.py"
|
|
470
|
+
if not f.is_file():
|
|
471
|
+
return "号池源码缺 account_service.py,跳过轮转补丁"
|
|
472
|
+
try:
|
|
473
|
+
src = f.read_text(encoding="utf-8")
|
|
474
|
+
except Exception:
|
|
475
|
+
return "轮转补丁读取失败,已跳过"
|
|
476
|
+
if RR_MARK in src:
|
|
477
|
+
return ""
|
|
478
|
+
pairs = [
|
|
479
|
+
(" self._index = 0\n self._accounts = self._load_accounts()",
|
|
480
|
+
" self._index = 0\n"
|
|
481
|
+
f" {RR_MARK} 图片/文本各一个游标,持久化到 data/.rr_index.json\n"
|
|
482
|
+
' _rr_state = self._load_rr_state()\n'
|
|
483
|
+
' self._image_index = int(_rr_state.get("image") or 0)\n'
|
|
484
|
+
' self._text_index = int(_rr_state.get("text") or 0)\n'
|
|
485
|
+
" self._accounts = self._load_accounts()"),
|
|
486
|
+
(" def _get_cumulative_file(self) -> Path:",
|
|
487
|
+
f" {RR_MARK} begin 轮转游标读写\n"
|
|
488
|
+
" def _get_rr_state_file(self) -> Path:\n"
|
|
489
|
+
" from services.config import DATA_DIR\n"
|
|
490
|
+
' return DATA_DIR / ".rr_index.json"\n\n'
|
|
491
|
+
" def _load_rr_state(self) -> dict:\n"
|
|
492
|
+
" try:\n"
|
|
493
|
+
" f = self._get_rr_state_file()\n"
|
|
494
|
+
" if f.exists():\n"
|
|
495
|
+
' data = json.loads(f.read_text(encoding="utf-8") or "{}")\n'
|
|
496
|
+
" if isinstance(data, dict):\n"
|
|
497
|
+
' return {"image": int(data.get("image") or 0),\n'
|
|
498
|
+
' "text": int(data.get("text") or 0)}\n'
|
|
499
|
+
" except Exception:\n"
|
|
500
|
+
" pass\n"
|
|
501
|
+
' return {"image": 0, "text": 0}\n\n'
|
|
502
|
+
" def _save_rr_state(self) -> None:\n"
|
|
503
|
+
" try:\n"
|
|
504
|
+
" f = self._get_rr_state_file()\n"
|
|
505
|
+
" f.parent.mkdir(parents=True, exist_ok=True)\n"
|
|
506
|
+
' tmp = f.with_suffix(".json.tmp")\n'
|
|
507
|
+
' tmp.write_text(json.dumps({"image": int(getattr(self, "_image_index", 0)),\n'
|
|
508
|
+
' "text": int(getattr(self, "_text_index", 0))}),\n'
|
|
509
|
+
' encoding="utf-8")\n'
|
|
510
|
+
" tmp.replace(f)\n"
|
|
511
|
+
" except Exception:\n"
|
|
512
|
+
" pass\n"
|
|
513
|
+
f" {RR_MARK} end\n\n"
|
|
514
|
+
" def _get_cumulative_file(self) -> Path:"),
|
|
515
|
+
(" if tokens:\n"
|
|
516
|
+
" access_token = tokens[self._index % len(tokens)]\n"
|
|
517
|
+
" self._index += 1",
|
|
518
|
+
" if tokens:\n"
|
|
519
|
+
" access_token = tokens[self._image_index % len(tokens)]\n"
|
|
520
|
+
" self._image_index += 1\n"
|
|
521
|
+
" self._save_rr_state()"),
|
|
522
|
+
(" access_token = candidates[self._index % len(candidates)]\n"
|
|
523
|
+
" self._index += 1",
|
|
524
|
+
" access_token = candidates[self._text_index % len(candidates)]\n"
|
|
525
|
+
" self._text_index += 1\n"
|
|
526
|
+
" self._save_rr_state()"),
|
|
527
|
+
]
|
|
528
|
+
out = src
|
|
529
|
+
for old, new in pairs:
|
|
530
|
+
if out.count(old) != 1:
|
|
531
|
+
return "轮转补丁锚点不匹配(号池源码版本可能变了),已跳过,不影响出图"
|
|
532
|
+
out = out.replace(old, new)
|
|
533
|
+
try:
|
|
534
|
+
bak = f.with_name(f.name + ".bak_rr_" + time.strftime("%Y%m%d"))
|
|
535
|
+
if not bak.exists():
|
|
536
|
+
shutil.copy2(f, bak)
|
|
537
|
+
f.write_text(out, encoding="utf-8")
|
|
538
|
+
except Exception as e:
|
|
539
|
+
return "轮转补丁写入失败:" + pool_mod.mask(e)[:80]
|
|
540
|
+
return ""
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
PROXY_MARK = "# workbench:upstream-proxy"
|
|
544
|
+
|
|
545
|
+
_PROXY_PATCHES = (
|
|
546
|
+
(
|
|
547
|
+
"services/openai_backend_api.py",
|
|
548
|
+
' impersonate=self.fp["impersonate"],\n'
|
|
549
|
+
' verify=True,\n'
|
|
550
|
+
' ))',
|
|
551
|
+
' impersonate=self.fp["impersonate"],\n'
|
|
552
|
+
' verify=True,\n'
|
|
553
|
+
' upstream=True, ' + PROXY_MARK + '\n'
|
|
554
|
+
' ))',
|
|
555
|
+
),
|
|
556
|
+
(
|
|
557
|
+
"services/account_service.py",
|
|
558
|
+
'build_session_kwargs(account=account, impersonate="chrome110", verify=True))',
|
|
559
|
+
'build_session_kwargs(account=account, impersonate="chrome110", verify=True, '
|
|
560
|
+
'upstream=True)) ' + PROXY_MARK,
|
|
561
|
+
),
|
|
562
|
+
(
|
|
563
|
+
"services/oauth_login_service.py",
|
|
564
|
+
'build_session_kwargs(impersonate="chrome", verify=False)',
|
|
565
|
+
'build_session_kwargs(impersonate="chrome", verify=False, upstream=True) ' + PROXY_MARK,
|
|
566
|
+
),
|
|
567
|
+
)
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
def _proxy_patch(source: Path) -> str:
|
|
571
|
+
"""让界面里配置的出口代理在真实上游请求中生效(幂等,可反复执行)。
|
|
572
|
+
|
|
573
|
+
上游 proxy_service.get_profile() 只有在 upstream=True 时才启用
|
|
574
|
+
proxy_runtime 的 single_proxy;而访问 ChatGPT 的调用点全都没传这个参数,
|
|
575
|
+
于是「接口与号池 → 号池出口代理」填好地址、点测试也通过,真实请求却
|
|
576
|
+
一律直连。受限网络下就是超时,额度永远刷不回来,账号被显示成「已用完」。
|
|
577
|
+
|
|
578
|
+
只补三处真正访问上游的调用:
|
|
579
|
+
openai_backend_api 拉额度与出图的核心客户端
|
|
580
|
+
account_service refresh_token 换 access_token
|
|
581
|
+
oauth_login_service 登录码换 token
|
|
582
|
+
内容审核、CPA、用户图片下载走的是第三方地址,保持原样,
|
|
583
|
+
避免把本来能直连的请求也塞进出网代理。
|
|
584
|
+
|
|
585
|
+
补丁写回前先做语法校验,校验不过就放弃该文件,不影响号池启动。
|
|
586
|
+
返回空串表示无需改动或未应用;返回 applied:N 表示本次真的改了 N 个文件。
|
|
587
|
+
"""
|
|
588
|
+
applied, skipped = [], []
|
|
589
|
+
for rel, old, new in _PROXY_PATCHES:
|
|
590
|
+
f = source / rel
|
|
591
|
+
if not f.is_file():
|
|
592
|
+
skipped.append(rel + " 缺文件")
|
|
593
|
+
continue
|
|
594
|
+
try:
|
|
595
|
+
text = f.read_bytes().decode("utf-8")
|
|
596
|
+
except Exception:
|
|
597
|
+
skipped.append(rel + " 读取失败")
|
|
598
|
+
continue
|
|
599
|
+
# 上游文件混用 LF 与 CRLF,跨行锚点必须按归一化后的文本匹配,
|
|
600
|
+
# 写回时再还原原有行尾,避免整个文件被改写。
|
|
601
|
+
crlf = "\r\n" in text
|
|
602
|
+
norm = text.replace("\r\n", "\n")
|
|
603
|
+
if PROXY_MARK in norm:
|
|
604
|
+
continue
|
|
605
|
+
if norm.count(old) != 1:
|
|
606
|
+
skipped.append(rel + " 锚点不匹配")
|
|
607
|
+
continue
|
|
608
|
+
patched = norm.replace(old, new)
|
|
609
|
+
try:
|
|
610
|
+
compile(patched, str(f), "exec")
|
|
611
|
+
except SyntaxError:
|
|
612
|
+
skipped.append(rel + " 补丁后语法异常")
|
|
613
|
+
continue
|
|
614
|
+
out = patched.replace("\n", "\r\n") if crlf else patched
|
|
615
|
+
try:
|
|
616
|
+
# 留一份原件,需要回滚时直接覆盖回去即可
|
|
617
|
+
bak = f.with_name(f.name + ".bak_proxy")
|
|
618
|
+
if not bak.exists():
|
|
619
|
+
shutil.copy2(f, bak)
|
|
620
|
+
f.write_bytes(out.encode("utf-8"))
|
|
621
|
+
applied.append(rel)
|
|
622
|
+
except Exception:
|
|
623
|
+
skipped.append(rel + " 写入失败")
|
|
624
|
+
if not applied and not skipped:
|
|
625
|
+
return ""
|
|
626
|
+
if applied:
|
|
627
|
+
log("出口代理补丁已应用 %d 处:%s" % (len(applied), "、".join(applied)))
|
|
628
|
+
if skipped:
|
|
629
|
+
log("出口代理补丁跳过 %d 处:%s" % (len(skipped), "、".join(skipped)))
|
|
630
|
+
return ("applied:%d" % len(applied)) if applied else ("skipped:%d" % len(skipped))
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
def _venv_python(src: Path) -> str:
|
|
634
|
+
for p in (src / ".venv" / "Scripts" / "python.exe",
|
|
635
|
+
src / ".venv" / "bin" / "python"):
|
|
636
|
+
if p.is_file():
|
|
637
|
+
return str(p)
|
|
638
|
+
return ""
|
|
639
|
+
|
|
640
|
+
|
|
641
|
+
def _find_python313() -> str:
|
|
642
|
+
"""找一台机器上现成的 Python 3.13+;找不到就交给 uv 自己下载。"""
|
|
643
|
+
cands = [sys.executable, shutil.which("python") or ""]
|
|
644
|
+
wb = Path.home() / ".workbuddy" / "binaries" / "python" / "versions"
|
|
645
|
+
if wb.is_dir():
|
|
646
|
+
cands += [str(p / ("python.exe" if os.name == "nt" else "python"))
|
|
647
|
+
for p in sorted(wb.iterdir(), reverse=True)]
|
|
648
|
+
for c in cands:
|
|
649
|
+
if not c or not Path(c).is_file():
|
|
650
|
+
continue
|
|
651
|
+
try:
|
|
652
|
+
r = subprocess.run([c, "-c", "import sys;print(sys.version_info[0],sys.version_info[1])"],
|
|
653
|
+
capture_output=True, text=True, timeout=6,
|
|
654
|
+
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0))
|
|
655
|
+
a = (r.stdout or "").split()
|
|
656
|
+
if len(a) == 2 and (int(a[0]), int(a[1])) >= (3, 13):
|
|
657
|
+
return c
|
|
658
|
+
except Exception:
|
|
659
|
+
continue
|
|
660
|
+
return ""
|
|
661
|
+
|
|
662
|
+
|
|
663
|
+
def _venv_ready(src: Path) -> bool:
|
|
664
|
+
"""虚拟环境是否真的能跑号池。
|
|
665
|
+
|
|
666
|
+
只看 python.exe 存在不够:上一轮装依赖失败时目录已经建好、包却没装上,
|
|
667
|
+
之后每次启动都是 "No module named uvicorn",而代码以为环境已就绪。
|
|
668
|
+
"""
|
|
669
|
+
py = _venv_python(src)
|
|
670
|
+
if not py:
|
|
671
|
+
return False
|
|
672
|
+
try:
|
|
673
|
+
r = subprocess.run([py, "-c", "import fastapi, uvicorn"], cwd=str(src),
|
|
674
|
+
capture_output=True, text=True, timeout=90,
|
|
675
|
+
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0))
|
|
676
|
+
return r.returncode == 0
|
|
677
|
+
except Exception:
|
|
678
|
+
return False
|
|
679
|
+
|
|
680
|
+
|
|
681
|
+
def _index_candidates(proxy: str) -> list[tuple[str, str, bool]]:
|
|
682
|
+
"""依赖源候选:(标签, 索引地址, 是否走代理)。
|
|
683
|
+
|
|
684
|
+
镜像站必须直连。经代理访问清华镜像会返回 403,uv 只会说
|
|
685
|
+
「fastapi 不在 registry 里」,报错完全指不到真正原因。
|
|
686
|
+
"""
|
|
687
|
+
out: list[tuple[str, str, bool]] = []
|
|
688
|
+
custom = (os.environ.get("DSH_IMAGE_PYPI_INDEX") or "").strip()
|
|
689
|
+
if custom:
|
|
690
|
+
out.append(("自定义源", custom, False))
|
|
691
|
+
out.append(("清华镜像", "https://pypi.tuna.tsinghua.edu.cn/simple", False))
|
|
692
|
+
out.append(("阿里云镜像", "https://mirrors.aliyun.com/pypi/simple", False))
|
|
693
|
+
if proxy:
|
|
694
|
+
out.append(("官方 PyPI", "https://pypi.org/simple", True))
|
|
695
|
+
out.append(("官方 PyPI", "https://pypi.org/simple", False))
|
|
696
|
+
return out
|
|
697
|
+
|
|
698
|
+
|
|
699
|
+
def _ensure_venv(home: Path, src: Path, uv: str) -> dict:
|
|
700
|
+
"""用插件自带的 uv 按 lock 建独立虚拟环境,全程不需要第三方插件。"""
|
|
701
|
+
if _venv_ready(src):
|
|
702
|
+
return {"ok": True, "message": ""}
|
|
703
|
+
if not uv:
|
|
704
|
+
return {"ok": False, "message": "号池运行时缺 uv,且系统 PATH 里也找不到 uv。"
|
|
705
|
+
"请重新完整安装工作台插件(vendor/chatgpt2api/runtime/)。"}
|
|
706
|
+
base = os.environ.copy()
|
|
707
|
+
base["UV_CACHE_DIR"] = str(home / "uv-cache")
|
|
708
|
+
base["UV_PYTHON_DOWNLOADS"] = "automatic" # 全新机器允许 uv 自己取 Python 3.13
|
|
709
|
+
base.setdefault("UV_HTTP_TIMEOUT", "120")
|
|
710
|
+
# 允许用户指定 Python 运行时的下载镜像(UV_PYTHON_INSTALL_MIRROR)。
|
|
711
|
+
# 国内直连 GitHub Releases 经常下不动 3.13,这里跟 host.js 用同一个开关。
|
|
712
|
+
mirror = (os.environ.get("DSH_IMAGE_PYTHON_MIRROR") or "").strip()
|
|
713
|
+
if mirror:
|
|
714
|
+
base["UV_PYTHON_INSTALL_MIRROR"] = mirror
|
|
715
|
+
# 系统未设出口代理时,探测本机常见代理(Shadowsocks/Clash/privoxy 等)并注入 uv,
|
|
716
|
+
# 让 uv 能经代理下载 Python 3.13 运行时,覆盖「开了代理但没设环境变量」的典型场景。
|
|
717
|
+
proxy = _env_egress_proxy()
|
|
718
|
+
if not proxy:
|
|
719
|
+
for cand in _local_proxy_candidates():
|
|
720
|
+
proxy = cand
|
|
721
|
+
break
|
|
722
|
+
|
|
723
|
+
cmd = [uv, "sync", "--frozen", "--no-dev"]
|
|
724
|
+
py = _find_python313()
|
|
725
|
+
if py:
|
|
726
|
+
cmd += ["--python", py]
|
|
727
|
+
log("开始建号池虚拟环境:" + " ".join(cmd[1:]))
|
|
728
|
+
proxy_keys = ("HTTPS_PROXY", "https_proxy", "HTTP_PROXY", "http_proxy",
|
|
729
|
+
"ALL_PROXY", "all_proxy")
|
|
730
|
+
no_proxy = ("pypi.tuna.tsinghua.edu.cn,mirrors.aliyun.com,pypi.org,"
|
|
731
|
+
"files.pythonhosted.org,127.0.0.1,localhost")
|
|
732
|
+
last = ""
|
|
733
|
+
for label, index, via_proxy in _index_candidates(proxy):
|
|
734
|
+
env = dict(base)
|
|
735
|
+
env["UV_DEFAULT_INDEX_URL"] = index
|
|
736
|
+
if via_proxy and proxy:
|
|
737
|
+
env["HTTPS_PROXY"] = proxy
|
|
738
|
+
env["HTTP_PROXY"] = proxy
|
|
739
|
+
else:
|
|
740
|
+
for k in proxy_keys:
|
|
741
|
+
env.pop(k, None)
|
|
742
|
+
env["NO_PROXY"] = env["no_proxy"] = no_proxy
|
|
743
|
+
log("号池依赖源:%s(%s)" % (label, ("经代理 " + proxy) if (via_proxy and proxy) else "直连"))
|
|
744
|
+
try:
|
|
745
|
+
r = subprocess.run(cmd, cwd=str(src), env=env, capture_output=True,
|
|
746
|
+
text=True, timeout=600,
|
|
747
|
+
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0))
|
|
748
|
+
except Exception as e:
|
|
749
|
+
last = pool_mod.mask(e)[:160]
|
|
750
|
+
log("号池依赖安装异常:" + last)
|
|
751
|
+
continue
|
|
752
|
+
if _venv_ready(src):
|
|
753
|
+
log("号池虚拟环境已就绪:" + str(_venv_python(src)) + "(" + label + ")")
|
|
754
|
+
return {"ok": True, "message": ""}
|
|
755
|
+
last = (((r.stderr or "") + "\n" + (r.stdout or "")).strip()[-400:]
|
|
756
|
+
or "装完仍缺 fastapi/uvicorn")
|
|
757
|
+
log(" " + label + " 失败:" + last.replace("\n", " ")[:280])
|
|
758
|
+
return {"ok": False,
|
|
759
|
+
"message": "号池虚拟环境没建起来(依赖源被拒或网络不通)。"
|
|
760
|
+
"日志见 pool-service.log。原始输出:" + last}
|
|
761
|
+
|
|
762
|
+
|
|
763
|
+
def bootstrap(home: Path) -> dict:
|
|
764
|
+
"""从插件自带 vendor 释放一份独立号池:代码自带、环境自建、账号库不共享。"""
|
|
765
|
+
home = Path(home)
|
|
766
|
+
src = home / "source"
|
|
767
|
+
vend = vendor_dir()
|
|
768
|
+
if not vend:
|
|
769
|
+
return {"ok": False,
|
|
770
|
+
"message": "工作台插件自带的号池运行时缺失(vendor/chatgpt2api)。"
|
|
771
|
+
"安装包不完整,请重新完整安装本工作台插件;"
|
|
772
|
+
"不需要也不应该另装任何第三方号池插件。"}
|
|
773
|
+
template = vend / "source"
|
|
774
|
+
if template.resolve() == src.resolve():
|
|
775
|
+
return {"ok": False, "message": "号池源码目录异常,请检查号池数据目录"}
|
|
776
|
+
log("开始初始化独立号池,代码来自插件自带 " + str(template))
|
|
777
|
+
try:
|
|
778
|
+
home.mkdir(parents=True, exist_ok=True)
|
|
779
|
+
|
|
780
|
+
def _ignore(directory, names):
|
|
781
|
+
skip = {"data", "logs", "__pycache__", ".git", "tmp", "temp", ".venv"}
|
|
782
|
+
return [n for n in names
|
|
783
|
+
if n in skip or n.endswith(".log") or n.endswith(".pyc")]
|
|
784
|
+
|
|
785
|
+
shutil.copytree(template, src, dirs_exist_ok=True, ignore=_ignore)
|
|
786
|
+
# 出厂默认配置:账号、密钥、代理一律不带
|
|
787
|
+
cfg = src / "config.json"
|
|
788
|
+
if not cfg.is_file() and (template / "config.default.json").is_file():
|
|
789
|
+
shutil.copy2(template / "config.default.json", cfg)
|
|
790
|
+
(src / "data").mkdir(parents=True, exist_ok=True)
|
|
791
|
+
(home / "uv-cache").mkdir(parents=True, exist_ok=True)
|
|
792
|
+
# uv 可执行文件随插件分发,复制到数据目录后与源码同生命周期
|
|
793
|
+
uv_src = ""
|
|
794
|
+
for c in (vend / "runtime" / "uv.exe", vend / "runtime" / "uv"):
|
|
795
|
+
if c.is_file():
|
|
796
|
+
uv_src = str(c)
|
|
797
|
+
break
|
|
798
|
+
if uv_src:
|
|
799
|
+
dst_dir = home / "runtime"
|
|
800
|
+
dst_dir.mkdir(parents=True, exist_ok=True)
|
|
801
|
+
dst = dst_dir / Path(uv_src).name
|
|
802
|
+
if not dst.is_file():
|
|
803
|
+
shutil.copy2(uv_src, dst)
|
|
804
|
+
keys = home / "user-api-keys.json"
|
|
805
|
+
if not keys.exists():
|
|
806
|
+
keys.write_text("{}\n", encoding="utf-8")
|
|
807
|
+
note = _rr_patch(src)
|
|
808
|
+
if note:
|
|
809
|
+
log(note)
|
|
810
|
+
proxy_note = _proxy_patch(src)
|
|
811
|
+
if proxy_note:
|
|
812
|
+
log(proxy_note)
|
|
813
|
+
except Exception as e:
|
|
814
|
+
return {"ok": False, "message": "初始化号池失败:" + pool_mod.mask(e)[:140]}
|
|
815
|
+
|
|
816
|
+
rt = runtime(home)
|
|
817
|
+
v = _ensure_venv(home, src, rt["uv"])
|
|
818
|
+
if not v["ok"]:
|
|
819
|
+
return {"ok": False, "message": v["message"]}
|
|
820
|
+
rt = runtime(home)
|
|
821
|
+
if not rt["ready"]:
|
|
822
|
+
return {"ok": False, "message": "初始化完成但运行时仍不完整:" + rt["reason"]}
|
|
823
|
+
log("独立号池初始化完成:" + str(src))
|
|
824
|
+
return {"ok": True, "message": "已从插件自带运行时初始化独立号池(无需另装号池插件,账号库不共享)"}
|
|
825
|
+
|
|
826
|
+
|
|
827
|
+
# ---------- 进程控制 ----------
|
|
828
|
+
def _port_pids(port: int) -> list[int]:
|
|
829
|
+
"""只找「本机这个端口正在 LISTENING」的 PID,避免误杀外部连接。"""
|
|
830
|
+
pids: set[int] = set()
|
|
831
|
+
try:
|
|
832
|
+
if os.name == "nt":
|
|
833
|
+
r = subprocess.run(["cmd", "/c", f"netstat -ano | findstr :{port}"],
|
|
834
|
+
capture_output=True, text=True, timeout=8,
|
|
835
|
+
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0))
|
|
836
|
+
for line in (r.stdout or "").splitlines():
|
|
837
|
+
if "LISTENING" not in line.upper():
|
|
838
|
+
continue
|
|
839
|
+
parts = line.split()
|
|
840
|
+
if len(parts) < 5 or not parts[1].endswith(":" + str(port)):
|
|
841
|
+
continue
|
|
842
|
+
if parts[-1].isdigit():
|
|
843
|
+
pids.add(int(parts[-1]))
|
|
844
|
+
else:
|
|
845
|
+
r = subprocess.run(["bash", "-lc", f"lsof -nP -iTCP:{port} -sTCP:LISTEN -t"],
|
|
846
|
+
capture_output=True, text=True, timeout=8)
|
|
847
|
+
for s in (r.stdout or "").split():
|
|
848
|
+
if s.isdigit():
|
|
849
|
+
pids.add(int(s))
|
|
850
|
+
except Exception as e:
|
|
851
|
+
log("查端口占用失败:" + pool_mod.mask(e)[:90])
|
|
852
|
+
return sorted(pids)
|
|
853
|
+
|
|
854
|
+
|
|
855
|
+
def stop(mark_user_stop: bool = True) -> dict:
|
|
856
|
+
v = view()
|
|
857
|
+
if not v["manageable"]:
|
|
858
|
+
msg = "当前接口不是本工作台管理的号池,未做任何操作"
|
|
859
|
+
_record("stop", False, msg)
|
|
860
|
+
return {"ok": False, "message": msg}
|
|
861
|
+
home = v["home"]
|
|
862
|
+
if mark_user_stop:
|
|
863
|
+
try:
|
|
864
|
+
(home / STOP_FLAG).write_text(time.strftime("%Y-%m-%d %H:%M:%S"), encoding="utf-8")
|
|
865
|
+
except Exception:
|
|
866
|
+
pass
|
|
867
|
+
pids = _port_pids(v["port"])
|
|
868
|
+
for pid in pids:
|
|
869
|
+
try:
|
|
870
|
+
if os.name == "nt":
|
|
871
|
+
subprocess.run(["taskkill", "/F", "/T", "/PID", str(pid)],
|
|
872
|
+
capture_output=True, timeout=10,
|
|
873
|
+
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0))
|
|
874
|
+
else:
|
|
875
|
+
os.kill(pid, 9)
|
|
876
|
+
except Exception as e:
|
|
877
|
+
log("停止 PID " + str(pid) + " 失败:" + pool_mod.mask(e)[:80])
|
|
878
|
+
for _ in range(20):
|
|
879
|
+
if not probe(v["port"], 0.3):
|
|
880
|
+
break
|
|
881
|
+
time.sleep(0.3)
|
|
882
|
+
ok = not probe(v["port"], 0.3)
|
|
883
|
+
msg = ("号池已停止" if ok else "发送了停止指令,但端口仍被占用(可能是别的进程),请看日志")
|
|
884
|
+
_record("stop", ok, msg)
|
|
885
|
+
log(msg)
|
|
886
|
+
return {"ok": ok, "message": msg, "pids": pids}
|
|
887
|
+
|
|
888
|
+
|
|
889
|
+
def start(force: bool = False) -> dict:
|
|
890
|
+
with _LOCK:
|
|
891
|
+
v = view()
|
|
892
|
+
if not v["local"]:
|
|
893
|
+
msg = "当前接口不是本机号池,工作台不代管第三方接口"
|
|
894
|
+
_record("start", False, msg)
|
|
895
|
+
return {"ok": False, "message": msg}
|
|
896
|
+
if not v["manageable"]:
|
|
897
|
+
msg = "38650 是写作台号池,本工作台只读,不代管它的进程"
|
|
898
|
+
_record("start", False, msg)
|
|
899
|
+
return {"ok": False, "message": msg}
|
|
900
|
+
home, port = v["home"], v["port"]
|
|
901
|
+
if probe(port) and not force:
|
|
902
|
+
msg = "号池已在运行,无需重复启动"
|
|
903
|
+
_record("start", True, msg)
|
|
904
|
+
# 号池已在跑(如后端重启但号池没掉):补一次零配置代理探测
|
|
905
|
+
try:
|
|
906
|
+
auto_detect_proxy(home, port)
|
|
907
|
+
except Exception as e:
|
|
908
|
+
log("出口代理自动探测异常:" + pool_mod.mask(e)[:120])
|
|
909
|
+
# 运行中的进程加载的是打补丁之前的代码。刚打完补丁就重启一次,
|
|
910
|
+
# 否则界面里配好的出口代理要等用户手动重启才真正生效。
|
|
911
|
+
if _proxy_patch(home / "source").startswith("applied:"):
|
|
912
|
+
log("出口代理补丁首次应用,重启号池使其生效")
|
|
913
|
+
stop(mark_user_stop=False)
|
|
914
|
+
time.sleep(0.6)
|
|
915
|
+
return start()
|
|
916
|
+
return {"ok": True, "message": msg, "already": True}
|
|
917
|
+
if force and probe(port):
|
|
918
|
+
stop(mark_user_stop=False)
|
|
919
|
+
|
|
920
|
+
rt = runtime(home)
|
|
921
|
+
# 目录存在不等于依赖齐了:半装的 venv 会让号池一起来就报 No module named uvicorn
|
|
922
|
+
if rt["ready"] and not _venv_ready(Path(rt["source"])):
|
|
923
|
+
rt = dict(rt, ready=False, reason="号池虚拟环境缺依赖,正在补齐")
|
|
924
|
+
if not rt["ready"]:
|
|
925
|
+
b = bootstrap(home)
|
|
926
|
+
if not b["ok"]:
|
|
927
|
+
_record("start", False, b["message"])
|
|
928
|
+
return {"ok": False, "message": b["message"]}
|
|
929
|
+
rt = runtime(home)
|
|
930
|
+
# 已装好的号池不会重跑 bootstrap,这里再补一次幂等补丁
|
|
931
|
+
_proxy_patch(Path(rt["source"]))
|
|
932
|
+
admin = ensure_admin_key(home)
|
|
933
|
+
if not admin:
|
|
934
|
+
msg = "admin-key 读不到也写不进,检查号池目录权限:" + str(home)
|
|
935
|
+
_record("start", False, msg)
|
|
936
|
+
return {"ok": False, "message": msg}
|
|
937
|
+
|
|
938
|
+
env = os.environ.copy()
|
|
939
|
+
env.update({
|
|
940
|
+
"CHATGPT2API_AUTH_KEY": admin,
|
|
941
|
+
"STORAGE_BACKEND": "sqlite",
|
|
942
|
+
"UV_CACHE_DIR": str(home / "uv-cache"),
|
|
943
|
+
"UV_PYTHON_DOWNLOADS": "never",
|
|
944
|
+
})
|
|
945
|
+
src = Path(rt["source"])
|
|
946
|
+
bindir = src / ".venv" / ("Scripts" if os.name == "nt" else "bin")
|
|
947
|
+
env["VIRTUAL_ENV"] = str(src / ".venv")
|
|
948
|
+
env["PATH"] = str(bindir) + os.pathsep + env.get("PATH", "")
|
|
949
|
+
# 直接执行号池 venv 里的 uvicorn,不再经 uv.exe 中转。
|
|
950
|
+
# 旧写法 `uv run uvicorn ...` 会形成「uv.exe -> uvicorn.exe -> python.exe」三层,
|
|
951
|
+
# 而 DETACHED_PROCESS 只让直接子进程 uv.exe 脱离控制台,Windows 会给孙进程
|
|
952
|
+
# uvicorn.exe 新建一个可见控制台,屏幕上就一直挂着一个黑框,关掉它还会杀掉号池。
|
|
953
|
+
launcher = ""
|
|
954
|
+
for name in ("uvicorn.exe", "uvicorn"):
|
|
955
|
+
f = bindir / name
|
|
956
|
+
if f.is_file():
|
|
957
|
+
launcher = str(f)
|
|
958
|
+
break
|
|
959
|
+
args = ["main:app", "--host", "127.0.0.1", "--port", str(port), "--no-access-log"]
|
|
960
|
+
if launcher:
|
|
961
|
+
cmd = [launcher] + args
|
|
962
|
+
elif _venv_python(src):
|
|
963
|
+
cmd = [_venv_python(src), "-m", "uvicorn"] + args
|
|
964
|
+
else:
|
|
965
|
+
cmd = [rt["uv"], "run", "--no-sync", "uvicorn"] + args
|
|
966
|
+
log_file = home / "pool-service.log"
|
|
967
|
+
flags = 0
|
|
968
|
+
startupinfo = None
|
|
969
|
+
if os.name == "nt":
|
|
970
|
+
# CREATE_NO_WINDOW 明确禁止为控制台程序创建窗口;STARTF_USESHOWWINDOW
|
|
971
|
+
# 是第二重保险。注意 DETACHED_PROCESS 与 CREATE_NO_WINDOW 不能混用。
|
|
972
|
+
flags = (getattr(subprocess, "CREATE_NO_WINDOW", 0)
|
|
973
|
+
| getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0))
|
|
974
|
+
startupinfo = subprocess.STARTUPINFO()
|
|
975
|
+
startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
|
|
976
|
+
startupinfo.wShowWindow = 0 # SW_HIDE
|
|
977
|
+
try:
|
|
978
|
+
home.mkdir(parents=True, exist_ok=True)
|
|
979
|
+
fd = open(log_file, "a", encoding="utf-8", errors="replace")
|
|
980
|
+
p = subprocess.Popen(cmd, cwd=str(src), env=env,
|
|
981
|
+
stdin=subprocess.DEVNULL, stdout=fd, stderr=fd,
|
|
982
|
+
creationflags=flags, startupinfo=startupinfo,
|
|
983
|
+
close_fds=True)
|
|
984
|
+
fd.close()
|
|
985
|
+
except Exception as e:
|
|
986
|
+
msg = "号池启动失败:" + pool_mod.mask(e)[:140]
|
|
987
|
+
_record("start", False, msg)
|
|
988
|
+
return {"ok": False, "message": msg}
|
|
989
|
+
|
|
990
|
+
for _ in range(80): # 首次启动要装依赖,最多等 40 秒
|
|
991
|
+
time.sleep(0.5)
|
|
992
|
+
if probe(port, 0.4):
|
|
993
|
+
try:
|
|
994
|
+
(home / STOP_FLAG).unlink(missing_ok=True)
|
|
995
|
+
except Exception:
|
|
996
|
+
pass
|
|
997
|
+
msg = f"号池已启动:127.0.0.1:{port}(PID {p.pid})"
|
|
998
|
+
_record("start", True, msg)
|
|
999
|
+
log(msg)
|
|
1000
|
+
# 号池上线后零配置探测出口代理(仅当前为空时),不阻塞前台返回
|
|
1001
|
+
try:
|
|
1002
|
+
auto_detect_proxy(home, port)
|
|
1003
|
+
except Exception as e:
|
|
1004
|
+
log("出口代理自动探测异常(不影响号池运行):" + pool_mod.mask(e)[:120])
|
|
1005
|
+
_notify_started()
|
|
1006
|
+
return {"ok": True, "message": msg, "pid": p.pid}
|
|
1007
|
+
if p.poll() is not None:
|
|
1008
|
+
break
|
|
1009
|
+
msg = "号池启动超时,请看日志:" + str(log_file)
|
|
1010
|
+
_record("start", False, msg)
|
|
1011
|
+
return {"ok": False, "message": msg}
|
|
1012
|
+
|
|
1013
|
+
|
|
1014
|
+
def restart() -> dict:
|
|
1015
|
+
# 整个停-等-启放在同一把锁里,避免守护线程在这 0.6 秒的窗口里插一脚
|
|
1016
|
+
with _LOCK:
|
|
1017
|
+
v = view()
|
|
1018
|
+
if not v["manageable"]:
|
|
1019
|
+
msg = "当前接口不是本工作台管理的号池,未做任何操作"
|
|
1020
|
+
_record("restart", False, msg)
|
|
1021
|
+
return {"ok": False, "message": msg}
|
|
1022
|
+
stop(mark_user_stop=False)
|
|
1023
|
+
time.sleep(0.6)
|
|
1024
|
+
return start()
|
|
1025
|
+
|
|
1026
|
+
|
|
1027
|
+
# ---------- 掉线守护 ----------
|
|
1028
|
+
def _watchdog() -> None:
|
|
1029
|
+
# 启动后台先立刻补一次,之后每 15 秒探一次
|
|
1030
|
+
first = True
|
|
1031
|
+
while True:
|
|
1032
|
+
if not first:
|
|
1033
|
+
time.sleep(15)
|
|
1034
|
+
first = False
|
|
1035
|
+
try:
|
|
1036
|
+
v = view()
|
|
1037
|
+
if not v["manageable"] or not auto_start(v["home"]):
|
|
1038
|
+
continue
|
|
1039
|
+
if (v["home"] / STOP_FLAG).exists():
|
|
1040
|
+
continue
|
|
1041
|
+
if probe(v["port"], 0.4):
|
|
1042
|
+
continue
|
|
1043
|
+
log("守护发现号池掉线,自动拉起")
|
|
1044
|
+
r = start()
|
|
1045
|
+
if not r.get("ok"):
|
|
1046
|
+
log("守护拉起失败:" + str(r.get("message") or "")[:120])
|
|
1047
|
+
except Exception as e:
|
|
1048
|
+
log("守护异常:" + pool_mod.mask(e)[:100])
|
|
1049
|
+
|
|
1050
|
+
|
|
1051
|
+
def start_watchdog() -> None:
|
|
1052
|
+
global _WATCHDOG_STARTED
|
|
1053
|
+
if _WATCHDOG_STARTED:
|
|
1054
|
+
return
|
|
1055
|
+
_WATCHDOG_STARTED = True
|
|
1056
|
+
t = threading.Thread(target=_watchdog, name="builtin-pool-watchdog", daemon=True)
|
|
1057
|
+
t.start()
|
|
1058
|
+
log("内置号池守护已启动(15 秒探一次)")
|
|
1059
|
+
|
|
1060
|
+
|
|
1061
|
+
# 命令行手动用:python pool_service.py status|start|stop|restart
|
|
1062
|
+
if __name__ == "__main__":
|
|
1063
|
+
action = (sys.argv[1] if len(sys.argv) > 1 else "status").lower()
|
|
1064
|
+
if action == "status":
|
|
1065
|
+
print(json.dumps(status(), ensure_ascii=False, indent=2))
|
|
1066
|
+
elif action == "start":
|
|
1067
|
+
print(json.dumps(start(), ensure_ascii=False, indent=2))
|
|
1068
|
+
elif action == "stop":
|
|
1069
|
+
print(json.dumps(stop(), ensure_ascii=False, indent=2))
|
|
1070
|
+
elif action == "restart":
|
|
1071
|
+
print(json.dumps(restart(), ensure_ascii=False, indent=2))
|
|
1072
|
+
else:
|
|
1073
|
+
print("用法:python pool_service.py status|start|stop|restart")
|