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_admin.py
CHANGED
|
@@ -1,921 +1,921 @@
|
|
|
1
|
-
"""本地号池管理:账号导入 / 删除、用户 Key 签发、对外连接信息。
|
|
2
|
-
|
|
3
|
-
号池本身是独立服务(38650 写作 / 38651 生图),以前想往里加号,要么改
|
|
4
|
-
accounts.json,要么去号池自带的前端。这个模块把「加号 / 删号 / 停用 / 拿 Key」
|
|
5
|
-
做进工作台的「接口与号池」面板,好处是用户不用碰代码、不用开别的窗口。
|
|
6
|
-
|
|
7
|
-
三条硬约束:
|
|
8
|
-
|
|
9
|
-
1. 只碰本机号池。Base URL 不是 127.0.0.1 / localhost,或者找不到 admin-key,
|
|
10
|
-
一律拒绝写操作——把第三方接口当成号池去改账号,是真会出事的。
|
|
11
|
-
2. 浏览器拿不到 access_token。列表只回 sha1 摘要当 id,删除 / 停用时服务端用
|
|
12
|
-
内存表换回真 token。access_token 等于账号全部权限,不该进前端。
|
|
13
|
-
3. 报错说人话。号池回的是英文 JSON,这里翻成「这个 token 校验没过」这类话,
|
|
14
|
-
同时把号池自己返回的 errors 原样带上,方便对账。
|
|
15
|
-
|
|
16
|
-
号池对账号的约束:access_token 是 JWT(eyJ 开头三段),导入后号池会立刻拿它
|
|
17
|
-
去 OpenAI 换一次额度,所以「导入成功」= 号池认了、上游也没拒;上游连不上
|
|
18
|
-
(比如本机代理没开)会进 errors,账号仍然留在池子里,等下次刷新再试。
|
|
19
|
-
"""
|
|
20
|
-
from __future__ import annotations
|
|
21
|
-
|
|
22
|
-
import asyncio
|
|
23
|
-
import hashlib
|
|
24
|
-
import json
|
|
25
|
-
import os
|
|
26
|
-
import re
|
|
27
|
-
import time
|
|
28
|
-
from pathlib import Path
|
|
29
|
-
from urllib.parse import urlparse
|
|
30
|
-
|
|
31
|
-
import httpx
|
|
32
|
-
|
|
33
|
-
import pool as pool_mod
|
|
34
|
-
import pool_service
|
|
35
|
-
|
|
36
|
-
# ChatGPT 的 access_token 一定是 JWT:三段 base64url,第一段解出来是 {"alg":...}
|
|
37
|
-
TOKEN_RE = re.compile(r"eyJ[A-Za-z0-9_\-]{6,}\.[A-Za-z0-9_\-]{6,}\.[A-Za-z0-9_\-]+")
|
|
38
|
-
MAX_IMPORT = 50 # 一次最多导入 50 个:每加一个都要上游校验,再多就该分批发
|
|
39
|
-
LOCAL_HOSTS = {"127.0.0.1", "localhost", "::1", "[::1]"}
|
|
40
|
-
|
|
41
|
-
# id -> access_token。账号 id 是 token 的摘要,浏览器只认这个,真 token 留在这
|
|
42
|
-
_TOKENS: dict[str, str] = {}
|
|
43
|
-
# 复用的连接池(见 _client())
|
|
44
|
-
_CLIENT: httpx.AsyncClient | None = None
|
|
45
|
-
_CLIENT_LOOP = None
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
# ---------- 小工具 ----------
|
|
49
|
-
def _id_of(token: str) -> str:
|
|
50
|
-
return hashlib.sha1(token.encode("utf-8")).hexdigest()[:12]
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
def _remember(tokens) -> None:
|
|
54
|
-
for t in tokens:
|
|
55
|
-
if t:
|
|
56
|
-
_TOKENS[_id_of(t)] = t
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
def _is_local(url: str) -> bool:
|
|
60
|
-
try:
|
|
61
|
-
return (urlparse(url).hostname or "").lower() in {"127.0.0.1", "localhost", "::1"}
|
|
62
|
-
except Exception:
|
|
63
|
-
return False
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
def _fmt_ts(ts) -> str:
|
|
67
|
-
if not ts:
|
|
68
|
-
return ""
|
|
69
|
-
try:
|
|
70
|
-
f = float(ts)
|
|
71
|
-
except (TypeError, ValueError):
|
|
72
|
-
return ""
|
|
73
|
-
if f <= 0:
|
|
74
|
-
return ""
|
|
75
|
-
return time.strftime("%Y-%m-%d %H:%M", time.localtime(f))
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
def mask_key(key: str) -> str:
|
|
79
|
-
key = str(key or "")
|
|
80
|
-
if len(key) <= 12:
|
|
81
|
-
return "***" if key else ""
|
|
82
|
-
return key[:6] + "***" + key[-4:]
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
def _err_text(r: httpx.Response) -> str:
|
|
86
|
-
try:
|
|
87
|
-
j = r.json()
|
|
88
|
-
except Exception:
|
|
89
|
-
return (r.text or "")[:200] or f"HTTP {r.status_code}"
|
|
90
|
-
if isinstance(j, dict):
|
|
91
|
-
e = j.get("detail") or j.get("error") or j
|
|
92
|
-
if isinstance(e, dict):
|
|
93
|
-
return str(e.get("error") or e.get("message") or e)[:240]
|
|
94
|
-
return str(e)[:240]
|
|
95
|
-
return str(j)[:240]
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
def target(cfg: dict | None = None) -> dict:
|
|
99
|
-
"""当前接口对应的号池:地址、数据目录、admin-key。
|
|
100
|
-
|
|
101
|
-
local=False 表示「当前接口不是本机号池」,面板据此把写操作全部关掉。
|
|
102
|
-
"""
|
|
103
|
-
cfg = cfg or pool_mod.load_config()
|
|
104
|
-
p = pool_mod.provider_cfg(cfg)
|
|
105
|
-
home = pool_mod.pool_home(cfg)
|
|
106
|
-
admin = pool_mod.pool_admin_key(cfg)
|
|
107
|
-
# 全新安装时号池目录里还没有 admin-key:工作台自己签一份,别让面板
|
|
108
|
-
# 误判成「不是本机号池」而把导入 / 建 Key 全禁掉。
|
|
109
|
-
if not admin and _is_local(p["api_root"]) and pool_service.view(cfg)["manageable"]:
|
|
110
|
-
admin = pool_service.ensure_admin_key(pool_service.view(cfg)["home"])
|
|
111
|
-
local = bool(p["api_root"]) and _is_local(p["api_root"]) and bool(admin)
|
|
112
|
-
return {
|
|
113
|
-
"local": local,
|
|
114
|
-
"root": p["api_root"],
|
|
115
|
-
"base_url": p["base_url"],
|
|
116
|
-
"home": str(home),
|
|
117
|
-
"admin_key": admin,
|
|
118
|
-
"name": p["name"],
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
async def _client() -> httpx.AsyncClient:
|
|
123
|
-
"""复用同一个 httpx 连接池。
|
|
124
|
-
|
|
125
|
-
Windows 上新建一个 AsyncClient 要 0.7~0.8 秒(SSL/线程池初始化),原来每个请求
|
|
126
|
-
都新建一个,面板串起来要 2 秒多才出数。池子绑在事件循环上,换循环就重建。
|
|
127
|
-
"""
|
|
128
|
-
global _CLIENT, _CLIENT_LOOP
|
|
129
|
-
loop = asyncio.get_running_loop()
|
|
130
|
-
if _CLIENT is None or _CLIENT_LOOP is not loop or _CLIENT.is_closed:
|
|
131
|
-
_CLIENT = httpx.AsyncClient(trust_env=False, timeout=httpx.Timeout(60.0, connect=8.0))
|
|
132
|
-
_CLIENT_LOOP = loop
|
|
133
|
-
return _CLIENT
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
async def _req(method: str, tgt: dict, path: str, body=None, timeout: float = 30.0,
|
|
137
|
-
client: httpx.AsyncClient | None = None):
|
|
138
|
-
url = str(tgt["root"]).rstrip("/") + "/" + path.lstrip("/")
|
|
139
|
-
hdr = {"Authorization": "Bearer " + tgt["admin_key"]}
|
|
140
|
-
c = client or await _client()
|
|
141
|
-
return await c.request(method, url, json=body, headers=hdr, timeout=timeout)
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
# ---------- 号池出口代理(受限网络必填,默认直连) ----------
|
|
145
|
-
async def get_pool_proxy() -> dict:
|
|
146
|
-
"""读取本机号池的出口代理配置。admin 接口,浏览器不碰 key。
|
|
147
|
-
|
|
148
|
-
号池 agent 需要访问 OpenAI / ChatGPT;不受限网络可留空直连,受限网络
|
|
149
|
-
由用户在这里填自己的出口代理。默认不写死任何地址,公网用户各填各的。
|
|
150
|
-
"""
|
|
151
|
-
tgt = target()
|
|
152
|
-
if not tgt["local"]:
|
|
153
|
-
return {"ok": False, "reason": "non_local",
|
|
154
|
-
"msg": "当前接口不是本机号池,无法读取代理配置"}
|
|
155
|
-
try:
|
|
156
|
-
r = await _req("GET", tgt, "api/proxy/runtime", timeout=20)
|
|
157
|
-
except Exception as e:
|
|
158
|
-
return {"ok": False, "reason": "net", "msg": str(e)[:160]}
|
|
159
|
-
if r.status_code != 200:
|
|
160
|
-
return {"ok": False, "reason": "http", "status": r.status_code, "msg": _err_text(r)}
|
|
161
|
-
try:
|
|
162
|
-
j = r.json()
|
|
163
|
-
except Exception:
|
|
164
|
-
return {"ok": False, "reason": "json", "msg": "号池返回非 JSON"}
|
|
165
|
-
return {"ok": True, "runtime": j.get("runtime", {}), "status": j.get("status", {})}
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
def _proxy_error_hint(text: str) -> str:
|
|
169
|
-
"""把 curl 的原始报错压成一句能指导操作的话。
|
|
170
|
-
|
|
171
|
-
用户在受限网络里最先踩的坑就是「端口填错 / 代理没开」,
|
|
172
|
-
原始报错是一整段英文 curl 说明,说了等于没说。
|
|
173
|
-
"""
|
|
174
|
-
t = str(text or "")
|
|
175
|
-
m = re.search(r"connect to ([^\s]+) port (\d+)", t, re.I)
|
|
176
|
-
if m:
|
|
177
|
-
return "连不上 %s:%s,代理没启动或端口填错了" % (m.group(1), m.group(2))
|
|
178
|
-
low = t.lower()
|
|
179
|
-
if "timed out" in low or "timeout" in low:
|
|
180
|
-
return "连接超时,代理没启动或端口填错了"
|
|
181
|
-
if "resolve" in low:
|
|
182
|
-
return "代理地址解析不了,检查填写的地址"
|
|
183
|
-
return t[:120] or "未知原因"
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
async def set_pool_proxy(payload: dict) -> dict:
|
|
187
|
-
"""把用户填的出口代理写进本机号池(默认直连;受限网络填自己的代理)。
|
|
188
|
-
|
|
189
|
-
payload: {enabled, egress_mode, proxy_url, resource_proxy_url}
|
|
190
|
-
号池接口要求扁平字段,会自动合并进 proxy_runtime,不会覆盖 clearance 等其它键。
|
|
191
|
-
"""
|
|
192
|
-
tgt = target()
|
|
193
|
-
if not tgt["local"]:
|
|
194
|
-
return {"ok": False, "reason": "non_local",
|
|
195
|
-
"msg": "当前接口不是本机号池,无法修改代理配置"}
|
|
196
|
-
body = {
|
|
197
|
-
"enabled": bool(payload.get("enabled", True)),
|
|
198
|
-
"egress_mode": str(payload.get("egress_mode") or "single_proxy").strip().lower() or "single_proxy",
|
|
199
|
-
"proxy_url": str(payload.get("proxy_url") or "").strip(),
|
|
200
|
-
"resource_proxy_url": str(payload.get("resource_proxy_url") or payload.get("proxy_url") or "").strip(),
|
|
201
|
-
}
|
|
202
|
-
try:
|
|
203
|
-
r = await _req("POST", tgt, "api/proxy/runtime", body=body, timeout=20)
|
|
204
|
-
except Exception as e:
|
|
205
|
-
return {"ok": False, "reason": "net", "msg": str(e)[:160]}
|
|
206
|
-
if r.status_code not in (200, 201, 204):
|
|
207
|
-
return {"ok": False, "reason": "http", "status": r.status_code, "msg": _err_text(r)}
|
|
208
|
-
try:
|
|
209
|
-
j = r.json()
|
|
210
|
-
except Exception:
|
|
211
|
-
j = {}
|
|
212
|
-
# 保存后立刻用号池自己的测试接口验一次:端口填错、代理没启动是最常见的
|
|
213
|
-
# 失败原因,当场说清楚,比等用户点出图再超时好。
|
|
214
|
-
check: dict = {}
|
|
215
|
-
proxy_ok = True
|
|
216
|
-
if body["proxy_url"]:
|
|
217
|
-
check = await test_pool_proxy("")
|
|
218
|
-
proxy_ok = bool(check.get("ok"))
|
|
219
|
-
if not body["proxy_url"]:
|
|
220
|
-
msg = "已改为直连(不使用出口代理)"
|
|
221
|
-
elif proxy_ok:
|
|
222
|
-
msg = "代理已保存,连通性正常"
|
|
223
|
-
else:
|
|
224
|
-
msg = "代理已保存,但" + _proxy_error_hint(str(check.get("error") or check.get("msg") or ""))
|
|
225
|
-
return {"ok": True, "runtime": (j.get("runtime") if isinstance(j, dict) else None),
|
|
226
|
-
"check": check, "proxy_ok": proxy_ok, "msg": msg}
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
async def test_pool_proxy(url: str = "") -> dict:
|
|
230
|
-
"""用号池自己已配置的代理测连通性(测的是已保存配置,不是临时填的)。
|
|
231
|
-
|
|
232
|
-
返回号池 test_proxy 的原始结果:{ok, status, latency_ms, error, ...}
|
|
233
|
-
"""
|
|
234
|
-
tgt = target()
|
|
235
|
-
if not tgt["local"]:
|
|
236
|
-
return {"ok": False, "reason": "non_local",
|
|
237
|
-
"msg": "当前接口不是本机号池,无法测试代理"}
|
|
238
|
-
try:
|
|
239
|
-
r = await _req("POST", tgt, "api/proxy/test", body={"url": str(url or "")},
|
|
240
|
-
timeout=30)
|
|
241
|
-
except Exception as e:
|
|
242
|
-
return {"ok": False, "reason": "net", "msg": str(e)[:160]}
|
|
243
|
-
if r.status_code != 200:
|
|
244
|
-
return {"ok": False, "reason": "http", "status": r.status_code, "msg": _err_text(r)}
|
|
245
|
-
try:
|
|
246
|
-
j = r.json()
|
|
247
|
-
except Exception:
|
|
248
|
-
return {"ok": False, "reason": "json", "msg": "号池返回非 JSON"}
|
|
249
|
-
return {"ok": True, **(j.get("result") if isinstance(j.get("result"), dict) else {})}
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
# ---------- 重新拉取账号额度(换代理后额度要重算) ----------
|
|
253
|
-
async def refresh_accounts() -> dict:
|
|
254
|
-
"""让号池重新向 OpenAI 拉一遍每个账号的额度。
|
|
255
|
-
|
|
256
|
-
号池侧是异步任务,立即返回 progress_id;调用方轮询进度即可。
|
|
257
|
-
换出口代理后额度必然要重算,否则界面会一直停在旧的 0。
|
|
258
|
-
"""
|
|
259
|
-
tgt = target()
|
|
260
|
-
if not tgt["local"]:
|
|
261
|
-
return {"ok": False, "reason": "non_local", "msg": "当前接口不是本机号池"}
|
|
262
|
-
try:
|
|
263
|
-
# 空列表 = 刷新全部账号(号池侧会取 list_tokens)
|
|
264
|
-
r = await _req("POST", tgt, "api/accounts/refresh", {"access_tokens": []}, timeout=30)
|
|
265
|
-
except Exception as e:
|
|
266
|
-
return {"ok": False, "reason": "net", "msg": str(e)[:160]}
|
|
267
|
-
if r.status_code != 200:
|
|
268
|
-
return {"ok": False, "reason": "http", "status": r.status_code, "msg": _err_text(r)}
|
|
269
|
-
try:
|
|
270
|
-
j = r.json()
|
|
271
|
-
except Exception:
|
|
272
|
-
j = {}
|
|
273
|
-
return {"ok": True, "progress_id": str((j or {}).get("progress_id") or "")}
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
async def refresh_progress(progress_id: str) -> dict:
|
|
277
|
-
"""查询刷新进度:{total, processed, done, error, status_counts, total_quota}。"""
|
|
278
|
-
tgt = target()
|
|
279
|
-
if not tgt["local"]:
|
|
280
|
-
return {"ok": False, "reason": "non_local", "msg": "当前接口不是本机号池"}
|
|
281
|
-
pid = str(progress_id or "").strip()
|
|
282
|
-
if not pid:
|
|
283
|
-
return {"ok": False, "reason": "bad", "msg": "缺少 progress_id"}
|
|
284
|
-
try:
|
|
285
|
-
r = await _req("GET", tgt, f"api/accounts/refresh/progress/{pid}", timeout=15)
|
|
286
|
-
except Exception as e:
|
|
287
|
-
return {"ok": False, "reason": "net", "msg": str(e)[:160]}
|
|
288
|
-
if r.status_code == 404:
|
|
289
|
-
return {"ok": False, "reason": "gone", "msg": "刷新任务已结束"}
|
|
290
|
-
if r.status_code != 200:
|
|
291
|
-
return {"ok": False, "reason": "http", "status": r.status_code, "msg": _err_text(r)}
|
|
292
|
-
try:
|
|
293
|
-
j = r.json()
|
|
294
|
-
except Exception:
|
|
295
|
-
return {"ok": False, "reason": "json", "msg": "号池返回非 JSON"}
|
|
296
|
-
return {"ok": True, **(j if isinstance(j, dict) else {})}
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
# ---------- 用户 Key(对外提供 API 就靠它) ----------
|
|
300
|
-
def _key_file(home: str | Path) -> Path:
|
|
301
|
-
return Path(home) / "user-api-keys.json"
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
def read_plain_keys(home: str | Path) -> dict[str, str]:
|
|
305
|
-
"""读号池目录里的「keyId -> sk-明文」表。
|
|
306
|
-
|
|
307
|
-
号池后端只存哈希,明文只落在本机这个文件里(其它插件也写它,所以只改自己那条)。
|
|
308
|
-
"""
|
|
309
|
-
p = _key_file(home)
|
|
310
|
-
try:
|
|
311
|
-
d = json.loads(p.read_text(encoding="utf-8"))
|
|
312
|
-
except Exception:
|
|
313
|
-
return {}
|
|
314
|
-
if isinstance(d, dict):
|
|
315
|
-
return {str(k): str(v) for k, v in d.items() if isinstance(v, str) and v.strip()}
|
|
316
|
-
if isinstance(d, list):
|
|
317
|
-
return {str(i): str(v) for i, v in enumerate(d) if isinstance(v, str) and v.strip()}
|
|
318
|
-
return {}
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
def _save_plain_key(home: str | Path, key_id: str, key: str) -> None:
|
|
322
|
-
p = _key_file(home)
|
|
323
|
-
data = read_plain_keys(home)
|
|
324
|
-
data[str(key_id)] = key
|
|
325
|
-
try:
|
|
326
|
-
p.parent.mkdir(parents=True, exist_ok=True)
|
|
327
|
-
tmp = p.with_suffix(".json.tmp")
|
|
328
|
-
tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
329
|
-
os.replace(tmp, p)
|
|
330
|
-
except Exception as e:
|
|
331
|
-
pool_mod.log(f"用户 Key 明文写入失败:{pool_mod.mask(e)[:120]}")
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
async def _list_keys(tgt: dict, client=None) -> list[dict]:
|
|
335
|
-
try:
|
|
336
|
-
r = await _req("GET", tgt, "api/auth/users", timeout=10, client=client)
|
|
337
|
-
except Exception:
|
|
338
|
-
return []
|
|
339
|
-
if r.status_code != 200:
|
|
340
|
-
return []
|
|
341
|
-
try:
|
|
342
|
-
items = r.json().get("items") or []
|
|
343
|
-
except Exception:
|
|
344
|
-
return []
|
|
345
|
-
out = []
|
|
346
|
-
for it in items:
|
|
347
|
-
if not isinstance(it, dict):
|
|
348
|
-
continue
|
|
349
|
-
out.append({
|
|
350
|
-
"id": str(it.get("id") or ""),
|
|
351
|
-
"name": str(it.get("name") or "默认"),
|
|
352
|
-
"enabled": it.get("enabled") is not False,
|
|
353
|
-
"last_used_at": _fmt_ts(pool_mod._parse_dt(it.get("last_used_at"))),
|
|
354
|
-
})
|
|
355
|
-
return out
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
async def ensure_key(force_new: bool = False, name: str = "生图工作台") -> dict:
|
|
359
|
-
"""保证号池里有一把可用的用户 Key,返回明文供面板展示 / 复制。
|
|
360
|
-
|
|
361
|
-
force_new=True 时另起一把新的(老 Key 不删,避免正在跑的脚本突然断掉)。
|
|
362
|
-
"""
|
|
363
|
-
tgt = target()
|
|
364
|
-
if not tgt["local"]:
|
|
365
|
-
return {"ok": False, "message": "当前接口不是本机号池"}
|
|
366
|
-
keys = await _list_keys(tgt)
|
|
367
|
-
plain = read_plain_keys(tgt["home"])
|
|
368
|
-
if not force_new:
|
|
369
|
-
for k in keys:
|
|
370
|
-
if k["enabled"] and plain.get(k["id"]):
|
|
371
|
-
return {"ok": True, "created": False, "key": plain[k["id"]],
|
|
372
|
-
"id": k["id"], "home": tgt["home"]}
|
|
373
|
-
# 后端列表里没有、但本地文件有明文:仍然可用,别硬造新 Key
|
|
374
|
-
for kid, val in plain.items():
|
|
375
|
-
if val:
|
|
376
|
-
return {"ok": True, "created": False, "key": val, "id": kid,
|
|
377
|
-
"home": tgt["home"]}
|
|
378
|
-
try:
|
|
379
|
-
r = await _req("POST", tgt, "api/auth/users", {"name": name}, timeout=20)
|
|
380
|
-
except Exception as e:
|
|
381
|
-
return {"ok": False, "message": f"号池连不上:{pool_mod.mask(e)[:120]}"}
|
|
382
|
-
if r.status_code != 200:
|
|
383
|
-
return {"ok": False, "message": f"号池拒绝建 Key:{_err_text(r)}"}
|
|
384
|
-
j = r.json()
|
|
385
|
-
kid = str((j.get("item") or {}).get("id") or "")
|
|
386
|
-
key = str(j.get("key") or "")
|
|
387
|
-
if not key:
|
|
388
|
-
return {"ok": False, "message": "号池没返回密钥,稍后重试"}
|
|
389
|
-
if kid:
|
|
390
|
-
_save_plain_key(tgt["home"], kid, key)
|
|
391
|
-
return {"ok": True, "created": True, "key": key, "id": kid, "home": tgt["home"]}
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
# ---------- 面板数据 ----------
|
|
395
|
-
_STATUS_CLS = {"正常": "ok", "限流": "warn", "禁用": "off", "异常": "err"}
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
async def panel(cfg: dict | None = None) -> dict:
|
|
399
|
-
tgt = target(cfg)
|
|
400
|
-
out = {
|
|
401
|
-
"local": tgt["local"],
|
|
402
|
-
"online": False,
|
|
403
|
-
"root": tgt["root"],
|
|
404
|
-
"base_url": tgt["base_url"],
|
|
405
|
-
"home": tgt["home"],
|
|
406
|
-
"message": "",
|
|
407
|
-
"total": 0, "active": 0, "limited": 0, "disabled": 0, "quota": 0,
|
|
408
|
-
"api_key": "", "api_key_masked": "", "api_key_id": "", "keys": [],
|
|
409
|
-
"accounts": [], "accounts_error": "", "blacklist_total": 0,
|
|
410
|
-
# 工作台自带号池:进程是否在线、能否启动 / 停止、运行时是否就绪
|
|
411
|
-
"service": pool_service.status(cfg),
|
|
412
|
-
}
|
|
413
|
-
out["blacklist_total"] = len(load_blacklist(tgt["home"]))
|
|
414
|
-
if not tgt["local"]:
|
|
415
|
-
out["message"] = "当前接口不是本机号池(Base URL 不是 127.0.0.1,或找不到 admin-key)"
|
|
416
|
-
return out
|
|
417
|
-
|
|
418
|
-
# 三个请求互不依赖,一起发出去。串行在 Windows 上要 2 秒多,面板会先闪一下「读取中」
|
|
419
|
-
c = await _client()
|
|
420
|
-
|
|
421
|
-
async def grab(path, timeout):
|
|
422
|
-
return await _req("GET", tgt, path, timeout=timeout, client=c)
|
|
423
|
-
|
|
424
|
-
h_res, a_res, k_res = await asyncio.gather(
|
|
425
|
-
grab("health?format=json", 6), grab("api/accounts", 12),
|
|
426
|
-
_list_keys(tgt, client=c), return_exceptions=True)
|
|
427
|
-
|
|
428
|
-
# 1) 号池自报状态:有 /health 才算真在线
|
|
429
|
-
if isinstance(h_res, Exception):
|
|
430
|
-
if isinstance(h_res, (httpx.ConnectError, httpx.ConnectTimeout)):
|
|
431
|
-
out["message"] = out["service"]["message"] or "号池没在跑:端口上连不上"
|
|
432
|
-
else:
|
|
433
|
-
out["message"] = f"号池连不上:{pool_mod.mask(h_res)[:110]}"
|
|
434
|
-
# 号池停着的时候,本地明文 Key 依然在 user-api-keys.json 里。
|
|
435
|
-
# 以前这里直接 return,面板会把当前密钥显示成「还没有用户 Key」,
|
|
436
|
-
# 用户以为密钥丢了又去点「重新生成」,白白多出一把。
|
|
437
|
-
plain = read_plain_keys(tgt["home"])
|
|
438
|
-
if plain:
|
|
439
|
-
kid, val = next(iter(plain.items()))
|
|
440
|
-
out.update({"api_key": val, "api_key_masked": mask_key(val), "api_key_id": kid,
|
|
441
|
-
"keys": [{"id": kid, "name": "本地保存", "enabled": True,
|
|
442
|
-
"last_used_at": "", "has_plain": True, "current": True}]})
|
|
443
|
-
# 刚启动的那一瞬间进程状态可能还没翻过来,回给前端最新的服务快照
|
|
444
|
-
out["service"] = pool_service.status(cfg)
|
|
445
|
-
return out
|
|
446
|
-
if h_res.status_code == 200:
|
|
447
|
-
j = h_res.json()
|
|
448
|
-
a = j.get("accounts") or {}
|
|
449
|
-
out.update({
|
|
450
|
-
"online": True,
|
|
451
|
-
"total": int(a.get("total") or 0),
|
|
452
|
-
"active": int(a.get("active") or 0),
|
|
453
|
-
"limited": int(a.get("limited") or 0),
|
|
454
|
-
"quota": int(a.get("total_quota") or 0),
|
|
455
|
-
"message": f"{int(a.get('total') or 0)} 个账号 · 可用 {int(a.get('active') or 0)}"
|
|
456
|
-
f" · 配额 {int(a.get('total_quota') or 0)}",
|
|
457
|
-
})
|
|
458
|
-
else:
|
|
459
|
-
out["message"] = f"号池返回 HTTP {h_res.status_code}"
|
|
460
|
-
return out
|
|
461
|
-
|
|
462
|
-
# 2) 账号明细:顺手把 id -> token 表填上,后面删号/停用才换得回来
|
|
463
|
-
if isinstance(a_res, Exception):
|
|
464
|
-
out["accounts_error"] = f"账号明细拉取失败:{pool_mod.mask(a_res)[:90]}"
|
|
465
|
-
elif a_res.status_code != 200:
|
|
466
|
-
out["accounts_error"] = f"账号明细拉取失败(HTTP {a_res.status_code})"
|
|
467
|
-
else:
|
|
468
|
-
try:
|
|
469
|
-
items = a_res.json().get("items") or []
|
|
470
|
-
except Exception:
|
|
471
|
-
items = []
|
|
472
|
-
rows = []
|
|
473
|
-
disabled = 0
|
|
474
|
-
for a in items:
|
|
475
|
-
if not isinstance(a, dict):
|
|
476
|
-
continue
|
|
477
|
-
tok = str(a.get("access_token") or "").strip()
|
|
478
|
-
_remember([tok])
|
|
479
|
-
st = str(a.get("status") or "正常").strip()
|
|
480
|
-
if st == "禁用":
|
|
481
|
-
disabled += 1
|
|
482
|
-
rows.append({
|
|
483
|
-
"id": _id_of(tok) if tok else "",
|
|
484
|
-
"email": pool_mod.mask_email(str(a.get("email") or "")),
|
|
485
|
-
"status": st,
|
|
486
|
-
"cls": _STATUS_CLS.get(st, "warn"),
|
|
487
|
-
"quota": int(a.get("quota") or 0),
|
|
488
|
-
"success": int(a.get("success") or 0),
|
|
489
|
-
"fail": int(a.get("fail") or 0),
|
|
490
|
-
"type": str(a.get("type") or ""),
|
|
491
|
-
"last_used": _fmt_ts(pool_mod._parse_dt(a.get("last_used_at"))),
|
|
492
|
-
"error": str(a.get("last_refresh_error") or "")[:120],
|
|
493
|
-
})
|
|
494
|
-
# quota=0 却挂「正常」的号不算可用:显示与页头统计一起纠正
|
|
495
|
-
normalize_zero_quota(rows, out)
|
|
496
|
-
rows.sort(key=lambda x: (x["cls"] != "ok", -x["quota"], x["email"]))
|
|
497
|
-
out["accounts"] = rows
|
|
498
|
-
out["disabled"] = disabled
|
|
499
|
-
|
|
500
|
-
# 3) 用户 Key:面板上要能直接复制出去接别的工具
|
|
501
|
-
# 「当前」优先认接口里正在用的那把,其次才是第一个有明文的启用 Key
|
|
502
|
-
keys = [] if isinstance(k_res, Exception) else k_res
|
|
503
|
-
plain = read_plain_keys(tgt["home"])
|
|
504
|
-
active = str(pool_mod.provider_cfg(cfg)["api_key"] or "")
|
|
505
|
-
cur = active if active else ""
|
|
506
|
-
for k in keys:
|
|
507
|
-
k["has_plain"] = bool(plain.get(k["id"]))
|
|
508
|
-
if active and plain.get(k["id"]) == active:
|
|
509
|
-
out["api_key_id"] = k["id"]
|
|
510
|
-
if cur:
|
|
511
|
-
for kid, val in plain.items():
|
|
512
|
-
if val == cur:
|
|
513
|
-
out["api_key_id"] = kid
|
|
514
|
-
break
|
|
515
|
-
if not cur:
|
|
516
|
-
for k in keys:
|
|
517
|
-
if k["enabled"] and k["has_plain"]:
|
|
518
|
-
cur = plain[k["id"]]
|
|
519
|
-
out["api_key_id"] = k["id"]
|
|
520
|
-
break
|
|
521
|
-
if not cur and plain:
|
|
522
|
-
# 后端列表读不到(老号池没这个接口)就退一步用本地明文
|
|
523
|
-
kid, cur = next(iter(plain.items()))
|
|
524
|
-
out["api_key_id"] = kid
|
|
525
|
-
for k in keys:
|
|
526
|
-
k["current"] = k["id"] == out["api_key_id"]
|
|
527
|
-
out["keys"] = keys
|
|
528
|
-
out["api_key"] = cur
|
|
529
|
-
out["api_key_masked"] = mask_key(cur)
|
|
530
|
-
return out
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
# ---------- 人工剔除名单 ----------
|
|
534
|
-
# 删除 / 停用过的 token 记一份摘要,再导入同一份 txt 时自动跳过。
|
|
535
|
-
# 只存 sha256 前 24 位,明文 token 不落盘;文件随号池数据目录走,换池互不干扰。
|
|
536
|
-
BLACKLIST_FILE = "import-blacklist.json"
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
def blacklist_id(token: str) -> str:
|
|
540
|
-
return hashlib.sha256(str(token or "").encode("utf-8")).hexdigest()[:24]
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
def _blacklist_path(home: str | Path) -> Path:
|
|
544
|
-
return Path(home) / BLACKLIST_FILE
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
def load_blacklist(home: str | Path) -> set[str]:
|
|
548
|
-
try:
|
|
549
|
-
d = json.loads(_blacklist_path(home).read_text(encoding="utf-8"))
|
|
550
|
-
ids = d.get("ids") if isinstance(d, dict) else d
|
|
551
|
-
return {str(x) for x in (ids or []) if isinstance(x, str) and x}
|
|
552
|
-
except Exception:
|
|
553
|
-
return set()
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
def _save_blacklist(home: str | Path, ids: set[str]) -> None:
|
|
557
|
-
p = _blacklist_path(home)
|
|
558
|
-
try:
|
|
559
|
-
p.parent.mkdir(parents=True, exist_ok=True)
|
|
560
|
-
tmp = p.with_suffix(".json.tmp")
|
|
561
|
-
tmp.write_text(json.dumps({"version": 1, "ids": sorted(ids)},
|
|
562
|
-
ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
563
|
-
os.replace(tmp, p)
|
|
564
|
-
except Exception as e:
|
|
565
|
-
pool_mod.log(f"剔除名单写入失败:{pool_mod.mask(e)[:120]}")
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
def blacklist_add(home, tokens) -> int:
|
|
569
|
-
ids = load_blacklist(home)
|
|
570
|
-
before = len(ids)
|
|
571
|
-
ids.update(blacklist_id(t) for t in tokens if t)
|
|
572
|
-
if len(ids) != before:
|
|
573
|
-
_save_blacklist(home, ids)
|
|
574
|
-
return len(ids) - before
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
def blacklist_remove(home, tokens) -> int:
|
|
578
|
-
ids = load_blacklist(home)
|
|
579
|
-
before = len(ids)
|
|
580
|
-
ids.difference_update(blacklist_id(t) for t in tokens if t)
|
|
581
|
-
if len(ids) != before:
|
|
582
|
-
_save_blacklist(home, ids)
|
|
583
|
-
return before - len(ids)
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
def filter_blacklisted(tokens: list[str], items: list[dict], blacklist: set[str]):
|
|
587
|
-
"""导入前过滤人工剔除过的 token,返回 (tokens, items, 跳过数)。"""
|
|
588
|
-
if not blacklist:
|
|
589
|
-
return tokens, items, 0
|
|
590
|
-
kept_t = [t for t in tokens if blacklist_id(t) not in blacklist]
|
|
591
|
-
kept_i = [d for d in items
|
|
592
|
-
if blacklist_id(d.get("access_token") or "") not in blacklist]
|
|
593
|
-
return kept_t, kept_i, (len(tokens) - len(kept_t)) + (len(items) - len(kept_i))
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
def normalize_zero_quota(rows: list[dict], out: dict) -> int:
|
|
597
|
-
"""号池把 quota=0 的新号写成「正常」,这里纠正显示与统计,不误导成可用。"""
|
|
598
|
-
n = 0
|
|
599
|
-
for r in rows:
|
|
600
|
-
if r.get("status") == "正常" and int(r.get("quota") or 0) == 0:
|
|
601
|
-
r["status"] = "限流"
|
|
602
|
-
r["cls"] = _STATUS_CLS.get("限流", "warn")
|
|
603
|
-
n += 1
|
|
604
|
-
if n:
|
|
605
|
-
out["active"] = max(0, int(out.get("active") or 0) - n)
|
|
606
|
-
out["limited"] = int(out.get("limited") or 0) + n
|
|
607
|
-
out["message"] = (f'{int(out.get("total") or 0)} 个账号 · 可用 {out["active"]}'
|
|
608
|
-
f' · 配额 {int(out.get("quota") or 0)}')
|
|
609
|
-
return n
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
async def _mark_zero_quota(tgt: dict, submitted: list[str], client) -> int:
|
|
613
|
-
"""把本次新增里额度为 0 的号从「正常」改成「限流」。
|
|
614
|
-
|
|
615
|
-
只在号池明确完成额度校验后调用;quota>0 或已有刷新错误的号一律不动,
|
|
616
|
-
避免把还能用的号锁死。改失败不影响导入结果。
|
|
617
|
-
"""
|
|
618
|
-
if not submitted:
|
|
619
|
-
return 0
|
|
620
|
-
try:
|
|
621
|
-
r = await _req("GET", tgt, "api/accounts", timeout=15, client=client)
|
|
622
|
-
if r.status_code != 200:
|
|
623
|
-
return 0
|
|
624
|
-
items = (r.json() or {}).get("items") or []
|
|
625
|
-
except Exception:
|
|
626
|
-
return 0
|
|
627
|
-
want = set(submitted)
|
|
628
|
-
todo = [str(a.get("access_token") or "") for a in items
|
|
629
|
-
if isinstance(a, dict)
|
|
630
|
-
and str(a.get("access_token") or "") in want
|
|
631
|
-
and str(a.get("status") or "正常") == "正常"
|
|
632
|
-
and int(a.get("quota") or 0) == 0
|
|
633
|
-
and not str(a.get("last_refresh_error") or "").strip()]
|
|
634
|
-
if not todo:
|
|
635
|
-
return 0
|
|
636
|
-
|
|
637
|
-
async def one(tok: str) -> bool:
|
|
638
|
-
try:
|
|
639
|
-
rr = await _req("POST", tgt, "api/accounts/update",
|
|
640
|
-
{"access_token": tok, "status": "限流"},
|
|
641
|
-
timeout=15, client=client)
|
|
642
|
-
return rr.status_code < 400
|
|
643
|
-
except Exception:
|
|
644
|
-
return False
|
|
645
|
-
|
|
646
|
-
res = await asyncio.gather(*(one(t) for t in todo[:MAX_IMPORT]))
|
|
647
|
-
return sum(1 for x in res if x)
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
# ---------- 导入 ----------
|
|
651
|
-
# 账号资料要原样带过去。web 类型的号没有 OpenAI refresh_token,
|
|
652
|
-
# access_token 过期后靠 password 重新登录;以前只把 token 抠出来提交,
|
|
653
|
-
# 号池里就会多一批「密码为空」的号,几天后 token 一过期就全变死号。
|
|
654
|
-
#
|
|
655
|
-
# 特别小心 mail_refresh_token:市面上「首登料」的发料格式是
|
|
656
|
-
# 邮箱----邮箱密码----邮箱Client ID----邮箱Refresh Token----AT
|
|
657
|
-
# 第 4 段是微软 MSA 的邮箱刷新令牌(M.C5xx...0.U.MsaArtifacts...),给接码用的,
|
|
658
|
-
# 不是 OpenAI 的 OAuth refresh_token。号池的 refresh_token 只会被 POST 到
|
|
659
|
-
# auth.openai.com/oauth/token,把邮箱令牌填进去必然刷新失败,还会触发
|
|
660
|
-
# 自动密码重登和 keepalive 定时刷新。所以邮箱料一律用 mail_* 独立字段留档,
|
|
661
|
-
# 号池会原样保存未知字段(account_service._normalize_account 是 dict(item)),
|
|
662
|
-
# 重启 / 导出时资料都还在,同时不会被当成 OpenAI 令牌使用。
|
|
663
|
-
IMPORT_FIELDS = (
|
|
664
|
-
"access_token", "accessToken", "email", "password", "totp_secret",
|
|
665
|
-
"source_type", "type", "user_id", "proxy", "default_model_slug",
|
|
666
|
-
"refresh_token", "id_token", "session_token",
|
|
667
|
-
"mail_client_id", "mail_refresh_token",
|
|
668
|
-
)
|
|
669
|
-
# 微软 MSA 邮箱刷新令牌,形如 M.C535_SN1.0.U.MsaArtifacts.xxxxx
|
|
670
|
-
MAIL_TOKEN_RE = re.compile(r"^M\.[A-Za-z0-9_-]+\.[0-9]+\.U\.", re.I)
|
|
671
|
-
# 发料文件末尾常带一行中文说明(含「发货格式」),不能算成没认出来的账号
|
|
672
|
-
MATERIAL_NOTE_RE = re.compile(r"发货格式|质保|售后|不会用|适用于", re.I)
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
def _clean_import_item(it: dict) -> dict | None:
|
|
676
|
-
"""校验一条账号 JSON,只保留号池重登需要的字段,别把运行态数据也灌进去。"""
|
|
677
|
-
tok = str(it.get("access_token") or it.get("accessToken") or "").strip()
|
|
678
|
-
if not TOKEN_RE.fullmatch(tok):
|
|
679
|
-
return None
|
|
680
|
-
out: dict = {"access_token": tok}
|
|
681
|
-
for k in IMPORT_FIELDS:
|
|
682
|
-
v = it.get(k)
|
|
683
|
-
if v is None or v == "" or k in ("access_token", "accessToken"):
|
|
684
|
-
continue
|
|
685
|
-
# 有人把邮箱 Refresh Token 填到 refresh_token 字段时,改归档别误伤号池
|
|
686
|
-
if k == "refresh_token" and isinstance(v, str) and MAIL_TOKEN_RE.match(v.strip()):
|
|
687
|
-
out.setdefault("mail_refresh_token", v)
|
|
688
|
-
continue
|
|
689
|
-
out[k] = v if isinstance(v, (str, int, float, bool)) else str(v)
|
|
690
|
-
return out
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
def _parse_dash_row(line: str) -> dict | None:
|
|
694
|
-
"""解析发料行:邮箱----密码----[邮箱Client ID----邮箱Refresh Token]----AT。
|
|
695
|
-
|
|
696
|
-
先整行锁定 JWT,再切前面的资料:JWT 签名本身可能含连续横线,按
|
|
697
|
-
分隔符直接 split 整行会把一个 token 劈成两半(真实发料里就有一行这样),
|
|
698
|
-
那条账号就会退化成「只有 token、没有邮箱密码」。
|
|
699
|
-
只认第一段是邮箱的行;中间资料按位置留档,段数不明时不硬猜字段语义。
|
|
700
|
-
"""
|
|
701
|
-
m = TOKEN_RE.search(line)
|
|
702
|
-
if m is None:
|
|
703
|
-
return None
|
|
704
|
-
head = line[:m.start()].strip()
|
|
705
|
-
parts = [p.strip() for p in re.split(r"-{4,}", head) if p.strip()]
|
|
706
|
-
if len(parts) < 2 or "@" not in parts[0]:
|
|
707
|
-
return None
|
|
708
|
-
out: dict = {"access_token": m.group(0), "email": parts[0],
|
|
709
|
-
"password": parts[1], "source_type": "web"}
|
|
710
|
-
if len(parts) == 4:
|
|
711
|
-
out["mail_client_id"] = parts[2]
|
|
712
|
-
out["mail_refresh_token"] = parts[3]
|
|
713
|
-
return out
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
def parse_import(text: str) -> tuple[list[str], list[dict], int]:
|
|
717
|
-
"""解析粘贴内容,返回 (裸 token 列表, 完整账号对象, 没认出来的行数)。
|
|
718
|
-
|
|
719
|
-
支持四种常见格式(txt / json / csv 上传后走同一条解析):
|
|
720
|
-
- 每行一个 token(最常见)
|
|
721
|
-
- 号池 / 第三方工具导出的 JSON:数组、{accounts:[...]}、或单个对象
|
|
722
|
-
- 五段式首登料(邮箱----密码----Client ID----邮箱RT----AT):整条资料一起入池
|
|
723
|
-
- 老式导出行(邮箱,密码,token / 邮箱----密码----token):按行扫 JWT
|
|
724
|
-
"""
|
|
725
|
-
# Windows 记事本存的 UTF-8 txt 常带 BOM,不清掉会把整段 JSON 判成纯文本
|
|
726
|
-
raw = str(text or "").lstrip("\ufeff")
|
|
727
|
-
tokens: list[str] = []
|
|
728
|
-
items: list[dict] = []
|
|
729
|
-
bad = 0
|
|
730
|
-
s = raw.strip()
|
|
731
|
-
data = None
|
|
732
|
-
if s.startswith("[") or s.startswith("{"):
|
|
733
|
-
try:
|
|
734
|
-
data = json.loads(s)
|
|
735
|
-
except Exception:
|
|
736
|
-
data = None
|
|
737
|
-
if data is not None:
|
|
738
|
-
entries = data
|
|
739
|
-
if isinstance(data, dict):
|
|
740
|
-
for key in ("accounts", "items", "tokens"):
|
|
741
|
-
if isinstance(data.get(key), list):
|
|
742
|
-
entries = data[key]
|
|
743
|
-
break
|
|
744
|
-
else:
|
|
745
|
-
entries = [data]
|
|
746
|
-
if isinstance(entries, list):
|
|
747
|
-
for it in entries:
|
|
748
|
-
if isinstance(it, dict):
|
|
749
|
-
one = _clean_import_item(it)
|
|
750
|
-
if one:
|
|
751
|
-
items.append(one)
|
|
752
|
-
else:
|
|
753
|
-
bad += 1
|
|
754
|
-
elif isinstance(it, str) and TOKEN_RE.fullmatch(it.strip()):
|
|
755
|
-
tokens.append(it.strip())
|
|
756
|
-
else:
|
|
757
|
-
bad += 1
|
|
758
|
-
items = list({d["access_token"]: d for d in items}.values())
|
|
759
|
-
have = {d["access_token"] for d in items}
|
|
760
|
-
tokens = [t for t in dict.fromkeys(tokens) if t not in have]
|
|
761
|
-
return tokens, items, bad
|
|
762
|
-
|
|
763
|
-
for line in raw.splitlines():
|
|
764
|
-
line = line.strip()
|
|
765
|
-
if not line:
|
|
766
|
-
continue
|
|
767
|
-
one = _parse_dash_row(line)
|
|
768
|
-
if one is not None:
|
|
769
|
-
items.append(one)
|
|
770
|
-
continue
|
|
771
|
-
found = TOKEN_RE.findall(line)
|
|
772
|
-
if found:
|
|
773
|
-
tokens.extend(found)
|
|
774
|
-
elif MATERIAL_NOTE_RE.search(line) and not TOKEN_RE.search(line):
|
|
775
|
-
continue # 发料文件末尾的中文说明 / 格式示例行
|
|
776
|
-
elif re.match(r"^\s*(email|user|account|access_token|token)\b", line, re.I) \
|
|
777
|
-
and re.search(r"[,;\t|]", line):
|
|
778
|
-
continue # CSV / TSV 的表头行,不算没认出的账号
|
|
779
|
-
else:
|
|
780
|
-
bad += 1
|
|
781
|
-
items = list({d["access_token"]: d for d in items}.values())
|
|
782
|
-
have = {d["access_token"] for d in items}
|
|
783
|
-
tokens = [t for t in dict.fromkeys(tokens) if t not in have]
|
|
784
|
-
if bad and not tokens and not items:
|
|
785
|
-
bad = max(1, bad)
|
|
786
|
-
return tokens, items, bad
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
def parse_tokens(text: str) -> tuple[list[str], int]:
|
|
790
|
-
"""兼容旧调用:只关心 token 列表(面板/冒烟用它做格式校验)。"""
|
|
791
|
-
tokens, items, bad = parse_import(text)
|
|
792
|
-
merged = list(dict.fromkeys([*tokens, *[d["access_token"] for d in items]]))
|
|
793
|
-
return merged, bad
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
async def import_accounts(text: str) -> dict:
|
|
797
|
-
tgt = target()
|
|
798
|
-
if not tgt["local"]:
|
|
799
|
-
return {"ok": False, "message": "当前接口不是本机号池,无法导入账号"}
|
|
800
|
-
tokens, items, bad = parse_import(text)
|
|
801
|
-
# 人工停用 / 删除过的号不允许靠重导同一份文件复活
|
|
802
|
-
blacklisted = 0
|
|
803
|
-
if tokens or items:
|
|
804
|
-
tokens, items, blacklisted = filter_blacklisted(
|
|
805
|
-
tokens, items, load_blacklist(tgt["home"]))
|
|
806
|
-
all_tokens = list(dict.fromkeys([*tokens, *[d["access_token"] for d in items]]))
|
|
807
|
-
if not all_tokens:
|
|
808
|
-
if blacklisted:
|
|
809
|
-
return {"ok": False, "blacklisted": blacklisted,
|
|
810
|
-
"message": f"{blacklisted} 个账号都在剔除名单里;先在列表里启用即可解封"}
|
|
811
|
-
# 邮箱 / 密码 / 2FA 料(email----password----secret)里没有 token,
|
|
812
|
-
# 本号池版本不会用它现场登录换 token,别让用户以为导入成功了。
|
|
813
|
-
material = bool(re.search(r"[\w.+-]+@[\w.-]+\s*[-]{3,4}\s*\S", text))
|
|
814
|
-
return {"ok": False,
|
|
815
|
-
"message": ("文件里是邮箱 / 密码料,没有 access_token"
|
|
816
|
-
if material else
|
|
817
|
-
"没读到 access_token:每行贴一个,或粘贴导出的账号 JSON")}
|
|
818
|
-
note = ""
|
|
819
|
-
if len(all_tokens) > MAX_IMPORT:
|
|
820
|
-
items = items[:MAX_IMPORT]
|
|
821
|
-
tokens = tokens[:max(0, MAX_IMPORT - len(items))]
|
|
822
|
-
note = f"一次最多导 {MAX_IMPORT} 个,已取前 {len(items) + len(tokens)} 个"
|
|
823
|
-
# 号池的 POST /api/accounts 同时收 accounts(完整对象)和 tokens(裸 token)。
|
|
824
|
-
# 五段式发料 / JSON 粘贴走前者,邮箱密码、2FA、来源类型和邮箱料都能留下;
|
|
825
|
-
# 裸 token 只能走后者。号池对已存在的账号是「合并更新」,重导同一份文件
|
|
826
|
-
# 还能把以前漏掉的密码补回去,不必删库重来。
|
|
827
|
-
body: dict = {}
|
|
828
|
-
if items:
|
|
829
|
-
body["accounts"] = items
|
|
830
|
-
if tokens:
|
|
831
|
-
body["tokens"] = tokens
|
|
832
|
-
try:
|
|
833
|
-
r = await _req("POST", tgt, "api/accounts", body, timeout=240)
|
|
834
|
-
except Exception as e:
|
|
835
|
-
return {"ok": False, "message": f"导入请求失败:{pool_mod.mask(e)[:120]}"}
|
|
836
|
-
if r.status_code >= 400:
|
|
837
|
-
return {"ok": False, "message": f"号池拒绝导入:{_err_text(r)}"}
|
|
838
|
-
try:
|
|
839
|
-
j = r.json()
|
|
840
|
-
except Exception:
|
|
841
|
-
j = {}
|
|
842
|
-
_remember(all_tokens)
|
|
843
|
-
if items:
|
|
844
|
-
note = (note + " · " if note else "") + f"{len(items)} 个账号连邮箱密码和来源一起导入"
|
|
845
|
-
added = int(j.get("added") or 0)
|
|
846
|
-
skipped = int(j.get("skipped") or 0)
|
|
847
|
-
refreshed = int(j.get("refreshed") or 0)
|
|
848
|
-
errors = [str(e)[:160] for e in (j.get("errors") or [])][:6]
|
|
849
|
-
# 号池报告「已完成校验」且确实有新增,才把额度为 0 的新号从「正常」改成「限流」
|
|
850
|
-
limited = 0
|
|
851
|
-
if added > 0 and refreshed > 0:
|
|
852
|
-
try:
|
|
853
|
-
limited = await _mark_zero_quota(tgt, all_tokens, await _client())
|
|
854
|
-
except Exception:
|
|
855
|
-
limited = 0
|
|
856
|
-
return {"ok": True, "count": len(all_tokens), "added": added, "skipped": skipped,
|
|
857
|
-
"refreshed": refreshed, "limited": limited, "errors": errors,
|
|
858
|
-
"blacklisted": blacklisted,
|
|
859
|
-
"blacklist_total": len(load_blacklist(tgt["home"])),
|
|
860
|
-
"bad": bad, "note": note}
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
# ---------- 删除 / 停用 ----------
|
|
864
|
-
async def _ids_to_tokens(ids: list[str]) -> tuple[list[str], int]:
|
|
865
|
-
ids = [str(i) for i in ids if i]
|
|
866
|
-
tokens = [_TOKENS[i] for i in ids if i in _TOKENS]
|
|
867
|
-
if len(tokens) == len(ids):
|
|
868
|
-
return tokens, 0
|
|
869
|
-
# id 表可能是上一轮进程留下的,先刷一次账号列表再换
|
|
870
|
-
await panel()
|
|
871
|
-
tokens = [_TOKENS[i] for i in ids if i in _TOKENS]
|
|
872
|
-
return tokens, len(ids) - len(tokens)
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
async def delete_accounts(ids: list[str]) -> dict:
|
|
876
|
-
tgt = target()
|
|
877
|
-
if not tgt["local"]:
|
|
878
|
-
return {"ok": False, "message": "当前接口不是本机号池,无法删除账号"}
|
|
879
|
-
tokens, missing = await _ids_to_tokens(ids)
|
|
880
|
-
if not tokens:
|
|
881
|
-
return {"ok": False, "message": "账号已变化,刷新面板后重试"}
|
|
882
|
-
try:
|
|
883
|
-
r = await _req("DELETE", tgt, "api/accounts", {"tokens": tokens}, timeout=30)
|
|
884
|
-
except Exception as e:
|
|
885
|
-
return {"ok": False, "message": f"删除请求失败:{pool_mod.mask(e)[:120]}"}
|
|
886
|
-
if r.status_code >= 400:
|
|
887
|
-
return {"ok": False, "message": f"号池拒绝删除:{_err_text(r)}"}
|
|
888
|
-
try:
|
|
889
|
-
removed = int((r.json() or {}).get("removed") or 0)
|
|
890
|
-
except Exception:
|
|
891
|
-
removed = 0
|
|
892
|
-
for i in ids:
|
|
893
|
-
_TOKENS.pop(str(i), None)
|
|
894
|
-
bl = blacklist_add(tgt["home"], tokens)
|
|
895
|
-
pool_mod.log(f"号池删除账号 {removed} 个,剔除名单 +{bl}")
|
|
896
|
-
return {"ok": True, "removed": removed, "missing": missing, "blacklisted": bl}
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
async def set_status(ids: list[str], disabled: bool) -> dict:
|
|
900
|
-
tgt = target()
|
|
901
|
-
if not tgt["local"]:
|
|
902
|
-
return {"ok": False, "message": "当前接口不是本机号池,无法修改账号"}
|
|
903
|
-
tokens, missing = await _ids_to_tokens(ids)
|
|
904
|
-
if not tokens:
|
|
905
|
-
return {"ok": False, "message": "账号已变化,刷新面板后重试"}
|
|
906
|
-
status = "禁用" if disabled else "正常"
|
|
907
|
-
done = 0
|
|
908
|
-
for tok in tokens:
|
|
909
|
-
try:
|
|
910
|
-
r = await _req("POST", tgt, "api/accounts/update",
|
|
911
|
-
{"access_token": tok, "status": status}, timeout=20)
|
|
912
|
-
except Exception as e:
|
|
913
|
-
return {"ok": False, "message": f"请求失败:{pool_mod.mask(e)[:120]}"}
|
|
914
|
-
if r.status_code >= 400:
|
|
915
|
-
return {"ok": False, "message": f"号池拒绝修改:{_err_text(r)}"}
|
|
916
|
-
done += 1
|
|
917
|
-
# 停用即入剔除名单(以后重导也回不来),启用即解封
|
|
918
|
-
bl = (blacklist_add(tgt["home"], tokens) if disabled
|
|
919
|
-
else blacklist_remove(tgt["home"], tokens))
|
|
920
|
-
return {"ok": True, "changed": done, "status": status, "missing": missing,
|
|
921
|
-
"blacklisted": bl}
|
|
1
|
+
"""本地号池管理:账号导入 / 删除、用户 Key 签发、对外连接信息。
|
|
2
|
+
|
|
3
|
+
号池本身是独立服务(38650 写作 / 38651 生图),以前想往里加号,要么改
|
|
4
|
+
accounts.json,要么去号池自带的前端。这个模块把「加号 / 删号 / 停用 / 拿 Key」
|
|
5
|
+
做进工作台的「接口与号池」面板,好处是用户不用碰代码、不用开别的窗口。
|
|
6
|
+
|
|
7
|
+
三条硬约束:
|
|
8
|
+
|
|
9
|
+
1. 只碰本机号池。Base URL 不是 127.0.0.1 / localhost,或者找不到 admin-key,
|
|
10
|
+
一律拒绝写操作——把第三方接口当成号池去改账号,是真会出事的。
|
|
11
|
+
2. 浏览器拿不到 access_token。列表只回 sha1 摘要当 id,删除 / 停用时服务端用
|
|
12
|
+
内存表换回真 token。access_token 等于账号全部权限,不该进前端。
|
|
13
|
+
3. 报错说人话。号池回的是英文 JSON,这里翻成「这个 token 校验没过」这类话,
|
|
14
|
+
同时把号池自己返回的 errors 原样带上,方便对账。
|
|
15
|
+
|
|
16
|
+
号池对账号的约束:access_token 是 JWT(eyJ 开头三段),导入后号池会立刻拿它
|
|
17
|
+
去 OpenAI 换一次额度,所以「导入成功」= 号池认了、上游也没拒;上游连不上
|
|
18
|
+
(比如本机代理没开)会进 errors,账号仍然留在池子里,等下次刷新再试。
|
|
19
|
+
"""
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import asyncio
|
|
23
|
+
import hashlib
|
|
24
|
+
import json
|
|
25
|
+
import os
|
|
26
|
+
import re
|
|
27
|
+
import time
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
from urllib.parse import urlparse
|
|
30
|
+
|
|
31
|
+
import httpx
|
|
32
|
+
|
|
33
|
+
import pool as pool_mod
|
|
34
|
+
import pool_service
|
|
35
|
+
|
|
36
|
+
# ChatGPT 的 access_token 一定是 JWT:三段 base64url,第一段解出来是 {"alg":...}
|
|
37
|
+
TOKEN_RE = re.compile(r"eyJ[A-Za-z0-9_\-]{6,}\.[A-Za-z0-9_\-]{6,}\.[A-Za-z0-9_\-]+")
|
|
38
|
+
MAX_IMPORT = 50 # 一次最多导入 50 个:每加一个都要上游校验,再多就该分批发
|
|
39
|
+
LOCAL_HOSTS = {"127.0.0.1", "localhost", "::1", "[::1]"}
|
|
40
|
+
|
|
41
|
+
# id -> access_token。账号 id 是 token 的摘要,浏览器只认这个,真 token 留在这
|
|
42
|
+
_TOKENS: dict[str, str] = {}
|
|
43
|
+
# 复用的连接池(见 _client())
|
|
44
|
+
_CLIENT: httpx.AsyncClient | None = None
|
|
45
|
+
_CLIENT_LOOP = None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# ---------- 小工具 ----------
|
|
49
|
+
def _id_of(token: str) -> str:
|
|
50
|
+
return hashlib.sha1(token.encode("utf-8")).hexdigest()[:12]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _remember(tokens) -> None:
|
|
54
|
+
for t in tokens:
|
|
55
|
+
if t:
|
|
56
|
+
_TOKENS[_id_of(t)] = t
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _is_local(url: str) -> bool:
|
|
60
|
+
try:
|
|
61
|
+
return (urlparse(url).hostname or "").lower() in {"127.0.0.1", "localhost", "::1"}
|
|
62
|
+
except Exception:
|
|
63
|
+
return False
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _fmt_ts(ts) -> str:
|
|
67
|
+
if not ts:
|
|
68
|
+
return ""
|
|
69
|
+
try:
|
|
70
|
+
f = float(ts)
|
|
71
|
+
except (TypeError, ValueError):
|
|
72
|
+
return ""
|
|
73
|
+
if f <= 0:
|
|
74
|
+
return ""
|
|
75
|
+
return time.strftime("%Y-%m-%d %H:%M", time.localtime(f))
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def mask_key(key: str) -> str:
|
|
79
|
+
key = str(key or "")
|
|
80
|
+
if len(key) <= 12:
|
|
81
|
+
return "***" if key else ""
|
|
82
|
+
return key[:6] + "***" + key[-4:]
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _err_text(r: httpx.Response) -> str:
|
|
86
|
+
try:
|
|
87
|
+
j = r.json()
|
|
88
|
+
except Exception:
|
|
89
|
+
return (r.text or "")[:200] or f"HTTP {r.status_code}"
|
|
90
|
+
if isinstance(j, dict):
|
|
91
|
+
e = j.get("detail") or j.get("error") or j
|
|
92
|
+
if isinstance(e, dict):
|
|
93
|
+
return str(e.get("error") or e.get("message") or e)[:240]
|
|
94
|
+
return str(e)[:240]
|
|
95
|
+
return str(j)[:240]
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def target(cfg: dict | None = None) -> dict:
|
|
99
|
+
"""当前接口对应的号池:地址、数据目录、admin-key。
|
|
100
|
+
|
|
101
|
+
local=False 表示「当前接口不是本机号池」,面板据此把写操作全部关掉。
|
|
102
|
+
"""
|
|
103
|
+
cfg = cfg or pool_mod.load_config()
|
|
104
|
+
p = pool_mod.provider_cfg(cfg)
|
|
105
|
+
home = pool_mod.pool_home(cfg)
|
|
106
|
+
admin = pool_mod.pool_admin_key(cfg)
|
|
107
|
+
# 全新安装时号池目录里还没有 admin-key:工作台自己签一份,别让面板
|
|
108
|
+
# 误判成「不是本机号池」而把导入 / 建 Key 全禁掉。
|
|
109
|
+
if not admin and _is_local(p["api_root"]) and pool_service.view(cfg)["manageable"]:
|
|
110
|
+
admin = pool_service.ensure_admin_key(pool_service.view(cfg)["home"])
|
|
111
|
+
local = bool(p["api_root"]) and _is_local(p["api_root"]) and bool(admin)
|
|
112
|
+
return {
|
|
113
|
+
"local": local,
|
|
114
|
+
"root": p["api_root"],
|
|
115
|
+
"base_url": p["base_url"],
|
|
116
|
+
"home": str(home),
|
|
117
|
+
"admin_key": admin,
|
|
118
|
+
"name": p["name"],
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
async def _client() -> httpx.AsyncClient:
|
|
123
|
+
"""复用同一个 httpx 连接池。
|
|
124
|
+
|
|
125
|
+
Windows 上新建一个 AsyncClient 要 0.7~0.8 秒(SSL/线程池初始化),原来每个请求
|
|
126
|
+
都新建一个,面板串起来要 2 秒多才出数。池子绑在事件循环上,换循环就重建。
|
|
127
|
+
"""
|
|
128
|
+
global _CLIENT, _CLIENT_LOOP
|
|
129
|
+
loop = asyncio.get_running_loop()
|
|
130
|
+
if _CLIENT is None or _CLIENT_LOOP is not loop or _CLIENT.is_closed:
|
|
131
|
+
_CLIENT = httpx.AsyncClient(trust_env=False, timeout=httpx.Timeout(60.0, connect=8.0))
|
|
132
|
+
_CLIENT_LOOP = loop
|
|
133
|
+
return _CLIENT
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
async def _req(method: str, tgt: dict, path: str, body=None, timeout: float = 30.0,
|
|
137
|
+
client: httpx.AsyncClient | None = None):
|
|
138
|
+
url = str(tgt["root"]).rstrip("/") + "/" + path.lstrip("/")
|
|
139
|
+
hdr = {"Authorization": "Bearer " + tgt["admin_key"]}
|
|
140
|
+
c = client or await _client()
|
|
141
|
+
return await c.request(method, url, json=body, headers=hdr, timeout=timeout)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
# ---------- 号池出口代理(受限网络必填,默认直连) ----------
|
|
145
|
+
async def get_pool_proxy() -> dict:
|
|
146
|
+
"""读取本机号池的出口代理配置。admin 接口,浏览器不碰 key。
|
|
147
|
+
|
|
148
|
+
号池 agent 需要访问 OpenAI / ChatGPT;不受限网络可留空直连,受限网络
|
|
149
|
+
由用户在这里填自己的出口代理。默认不写死任何地址,公网用户各填各的。
|
|
150
|
+
"""
|
|
151
|
+
tgt = target()
|
|
152
|
+
if not tgt["local"]:
|
|
153
|
+
return {"ok": False, "reason": "non_local",
|
|
154
|
+
"msg": "当前接口不是本机号池,无法读取代理配置"}
|
|
155
|
+
try:
|
|
156
|
+
r = await _req("GET", tgt, "api/proxy/runtime", timeout=20)
|
|
157
|
+
except Exception as e:
|
|
158
|
+
return {"ok": False, "reason": "net", "msg": str(e)[:160]}
|
|
159
|
+
if r.status_code != 200:
|
|
160
|
+
return {"ok": False, "reason": "http", "status": r.status_code, "msg": _err_text(r)}
|
|
161
|
+
try:
|
|
162
|
+
j = r.json()
|
|
163
|
+
except Exception:
|
|
164
|
+
return {"ok": False, "reason": "json", "msg": "号池返回非 JSON"}
|
|
165
|
+
return {"ok": True, "runtime": j.get("runtime", {}), "status": j.get("status", {})}
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _proxy_error_hint(text: str) -> str:
|
|
169
|
+
"""把 curl 的原始报错压成一句能指导操作的话。
|
|
170
|
+
|
|
171
|
+
用户在受限网络里最先踩的坑就是「端口填错 / 代理没开」,
|
|
172
|
+
原始报错是一整段英文 curl 说明,说了等于没说。
|
|
173
|
+
"""
|
|
174
|
+
t = str(text or "")
|
|
175
|
+
m = re.search(r"connect to ([^\s]+) port (\d+)", t, re.I)
|
|
176
|
+
if m:
|
|
177
|
+
return "连不上 %s:%s,代理没启动或端口填错了" % (m.group(1), m.group(2))
|
|
178
|
+
low = t.lower()
|
|
179
|
+
if "timed out" in low or "timeout" in low:
|
|
180
|
+
return "连接超时,代理没启动或端口填错了"
|
|
181
|
+
if "resolve" in low:
|
|
182
|
+
return "代理地址解析不了,检查填写的地址"
|
|
183
|
+
return t[:120] or "未知原因"
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
async def set_pool_proxy(payload: dict) -> dict:
|
|
187
|
+
"""把用户填的出口代理写进本机号池(默认直连;受限网络填自己的代理)。
|
|
188
|
+
|
|
189
|
+
payload: {enabled, egress_mode, proxy_url, resource_proxy_url}
|
|
190
|
+
号池接口要求扁平字段,会自动合并进 proxy_runtime,不会覆盖 clearance 等其它键。
|
|
191
|
+
"""
|
|
192
|
+
tgt = target()
|
|
193
|
+
if not tgt["local"]:
|
|
194
|
+
return {"ok": False, "reason": "non_local",
|
|
195
|
+
"msg": "当前接口不是本机号池,无法修改代理配置"}
|
|
196
|
+
body = {
|
|
197
|
+
"enabled": bool(payload.get("enabled", True)),
|
|
198
|
+
"egress_mode": str(payload.get("egress_mode") or "single_proxy").strip().lower() or "single_proxy",
|
|
199
|
+
"proxy_url": str(payload.get("proxy_url") or "").strip(),
|
|
200
|
+
"resource_proxy_url": str(payload.get("resource_proxy_url") or payload.get("proxy_url") or "").strip(),
|
|
201
|
+
}
|
|
202
|
+
try:
|
|
203
|
+
r = await _req("POST", tgt, "api/proxy/runtime", body=body, timeout=20)
|
|
204
|
+
except Exception as e:
|
|
205
|
+
return {"ok": False, "reason": "net", "msg": str(e)[:160]}
|
|
206
|
+
if r.status_code not in (200, 201, 204):
|
|
207
|
+
return {"ok": False, "reason": "http", "status": r.status_code, "msg": _err_text(r)}
|
|
208
|
+
try:
|
|
209
|
+
j = r.json()
|
|
210
|
+
except Exception:
|
|
211
|
+
j = {}
|
|
212
|
+
# 保存后立刻用号池自己的测试接口验一次:端口填错、代理没启动是最常见的
|
|
213
|
+
# 失败原因,当场说清楚,比等用户点出图再超时好。
|
|
214
|
+
check: dict = {}
|
|
215
|
+
proxy_ok = True
|
|
216
|
+
if body["proxy_url"]:
|
|
217
|
+
check = await test_pool_proxy("")
|
|
218
|
+
proxy_ok = bool(check.get("ok"))
|
|
219
|
+
if not body["proxy_url"]:
|
|
220
|
+
msg = "已改为直连(不使用出口代理)"
|
|
221
|
+
elif proxy_ok:
|
|
222
|
+
msg = "代理已保存,连通性正常"
|
|
223
|
+
else:
|
|
224
|
+
msg = "代理已保存,但" + _proxy_error_hint(str(check.get("error") or check.get("msg") or ""))
|
|
225
|
+
return {"ok": True, "runtime": (j.get("runtime") if isinstance(j, dict) else None),
|
|
226
|
+
"check": check, "proxy_ok": proxy_ok, "msg": msg}
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
async def test_pool_proxy(url: str = "") -> dict:
|
|
230
|
+
"""用号池自己已配置的代理测连通性(测的是已保存配置,不是临时填的)。
|
|
231
|
+
|
|
232
|
+
返回号池 test_proxy 的原始结果:{ok, status, latency_ms, error, ...}
|
|
233
|
+
"""
|
|
234
|
+
tgt = target()
|
|
235
|
+
if not tgt["local"]:
|
|
236
|
+
return {"ok": False, "reason": "non_local",
|
|
237
|
+
"msg": "当前接口不是本机号池,无法测试代理"}
|
|
238
|
+
try:
|
|
239
|
+
r = await _req("POST", tgt, "api/proxy/test", body={"url": str(url or "")},
|
|
240
|
+
timeout=30)
|
|
241
|
+
except Exception as e:
|
|
242
|
+
return {"ok": False, "reason": "net", "msg": str(e)[:160]}
|
|
243
|
+
if r.status_code != 200:
|
|
244
|
+
return {"ok": False, "reason": "http", "status": r.status_code, "msg": _err_text(r)}
|
|
245
|
+
try:
|
|
246
|
+
j = r.json()
|
|
247
|
+
except Exception:
|
|
248
|
+
return {"ok": False, "reason": "json", "msg": "号池返回非 JSON"}
|
|
249
|
+
return {"ok": True, **(j.get("result") if isinstance(j.get("result"), dict) else {})}
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
# ---------- 重新拉取账号额度(换代理后额度要重算) ----------
|
|
253
|
+
async def refresh_accounts() -> dict:
|
|
254
|
+
"""让号池重新向 OpenAI 拉一遍每个账号的额度。
|
|
255
|
+
|
|
256
|
+
号池侧是异步任务,立即返回 progress_id;调用方轮询进度即可。
|
|
257
|
+
换出口代理后额度必然要重算,否则界面会一直停在旧的 0。
|
|
258
|
+
"""
|
|
259
|
+
tgt = target()
|
|
260
|
+
if not tgt["local"]:
|
|
261
|
+
return {"ok": False, "reason": "non_local", "msg": "当前接口不是本机号池"}
|
|
262
|
+
try:
|
|
263
|
+
# 空列表 = 刷新全部账号(号池侧会取 list_tokens)
|
|
264
|
+
r = await _req("POST", tgt, "api/accounts/refresh", {"access_tokens": []}, timeout=30)
|
|
265
|
+
except Exception as e:
|
|
266
|
+
return {"ok": False, "reason": "net", "msg": str(e)[:160]}
|
|
267
|
+
if r.status_code != 200:
|
|
268
|
+
return {"ok": False, "reason": "http", "status": r.status_code, "msg": _err_text(r)}
|
|
269
|
+
try:
|
|
270
|
+
j = r.json()
|
|
271
|
+
except Exception:
|
|
272
|
+
j = {}
|
|
273
|
+
return {"ok": True, "progress_id": str((j or {}).get("progress_id") or "")}
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
async def refresh_progress(progress_id: str) -> dict:
|
|
277
|
+
"""查询刷新进度:{total, processed, done, error, status_counts, total_quota}。"""
|
|
278
|
+
tgt = target()
|
|
279
|
+
if not tgt["local"]:
|
|
280
|
+
return {"ok": False, "reason": "non_local", "msg": "当前接口不是本机号池"}
|
|
281
|
+
pid = str(progress_id or "").strip()
|
|
282
|
+
if not pid:
|
|
283
|
+
return {"ok": False, "reason": "bad", "msg": "缺少 progress_id"}
|
|
284
|
+
try:
|
|
285
|
+
r = await _req("GET", tgt, f"api/accounts/refresh/progress/{pid}", timeout=15)
|
|
286
|
+
except Exception as e:
|
|
287
|
+
return {"ok": False, "reason": "net", "msg": str(e)[:160]}
|
|
288
|
+
if r.status_code == 404:
|
|
289
|
+
return {"ok": False, "reason": "gone", "msg": "刷新任务已结束"}
|
|
290
|
+
if r.status_code != 200:
|
|
291
|
+
return {"ok": False, "reason": "http", "status": r.status_code, "msg": _err_text(r)}
|
|
292
|
+
try:
|
|
293
|
+
j = r.json()
|
|
294
|
+
except Exception:
|
|
295
|
+
return {"ok": False, "reason": "json", "msg": "号池返回非 JSON"}
|
|
296
|
+
return {"ok": True, **(j if isinstance(j, dict) else {})}
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
# ---------- 用户 Key(对外提供 API 就靠它) ----------
|
|
300
|
+
def _key_file(home: str | Path) -> Path:
|
|
301
|
+
return Path(home) / "user-api-keys.json"
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def read_plain_keys(home: str | Path) -> dict[str, str]:
|
|
305
|
+
"""读号池目录里的「keyId -> sk-明文」表。
|
|
306
|
+
|
|
307
|
+
号池后端只存哈希,明文只落在本机这个文件里(其它插件也写它,所以只改自己那条)。
|
|
308
|
+
"""
|
|
309
|
+
p = _key_file(home)
|
|
310
|
+
try:
|
|
311
|
+
d = json.loads(p.read_text(encoding="utf-8"))
|
|
312
|
+
except Exception:
|
|
313
|
+
return {}
|
|
314
|
+
if isinstance(d, dict):
|
|
315
|
+
return {str(k): str(v) for k, v in d.items() if isinstance(v, str) and v.strip()}
|
|
316
|
+
if isinstance(d, list):
|
|
317
|
+
return {str(i): str(v) for i, v in enumerate(d) if isinstance(v, str) and v.strip()}
|
|
318
|
+
return {}
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _save_plain_key(home: str | Path, key_id: str, key: str) -> None:
|
|
322
|
+
p = _key_file(home)
|
|
323
|
+
data = read_plain_keys(home)
|
|
324
|
+
data[str(key_id)] = key
|
|
325
|
+
try:
|
|
326
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
327
|
+
tmp = p.with_suffix(".json.tmp")
|
|
328
|
+
tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
329
|
+
os.replace(tmp, p)
|
|
330
|
+
except Exception as e:
|
|
331
|
+
pool_mod.log(f"用户 Key 明文写入失败:{pool_mod.mask(e)[:120]}")
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
async def _list_keys(tgt: dict, client=None) -> list[dict]:
|
|
335
|
+
try:
|
|
336
|
+
r = await _req("GET", tgt, "api/auth/users", timeout=10, client=client)
|
|
337
|
+
except Exception:
|
|
338
|
+
return []
|
|
339
|
+
if r.status_code != 200:
|
|
340
|
+
return []
|
|
341
|
+
try:
|
|
342
|
+
items = r.json().get("items") or []
|
|
343
|
+
except Exception:
|
|
344
|
+
return []
|
|
345
|
+
out = []
|
|
346
|
+
for it in items:
|
|
347
|
+
if not isinstance(it, dict):
|
|
348
|
+
continue
|
|
349
|
+
out.append({
|
|
350
|
+
"id": str(it.get("id") or ""),
|
|
351
|
+
"name": str(it.get("name") or "默认"),
|
|
352
|
+
"enabled": it.get("enabled") is not False,
|
|
353
|
+
"last_used_at": _fmt_ts(pool_mod._parse_dt(it.get("last_used_at"))),
|
|
354
|
+
})
|
|
355
|
+
return out
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
async def ensure_key(force_new: bool = False, name: str = "生图工作台") -> dict:
|
|
359
|
+
"""保证号池里有一把可用的用户 Key,返回明文供面板展示 / 复制。
|
|
360
|
+
|
|
361
|
+
force_new=True 时另起一把新的(老 Key 不删,避免正在跑的脚本突然断掉)。
|
|
362
|
+
"""
|
|
363
|
+
tgt = target()
|
|
364
|
+
if not tgt["local"]:
|
|
365
|
+
return {"ok": False, "message": "当前接口不是本机号池"}
|
|
366
|
+
keys = await _list_keys(tgt)
|
|
367
|
+
plain = read_plain_keys(tgt["home"])
|
|
368
|
+
if not force_new:
|
|
369
|
+
for k in keys:
|
|
370
|
+
if k["enabled"] and plain.get(k["id"]):
|
|
371
|
+
return {"ok": True, "created": False, "key": plain[k["id"]],
|
|
372
|
+
"id": k["id"], "home": tgt["home"]}
|
|
373
|
+
# 后端列表里没有、但本地文件有明文:仍然可用,别硬造新 Key
|
|
374
|
+
for kid, val in plain.items():
|
|
375
|
+
if val:
|
|
376
|
+
return {"ok": True, "created": False, "key": val, "id": kid,
|
|
377
|
+
"home": tgt["home"]}
|
|
378
|
+
try:
|
|
379
|
+
r = await _req("POST", tgt, "api/auth/users", {"name": name}, timeout=20)
|
|
380
|
+
except Exception as e:
|
|
381
|
+
return {"ok": False, "message": f"号池连不上:{pool_mod.mask(e)[:120]}"}
|
|
382
|
+
if r.status_code != 200:
|
|
383
|
+
return {"ok": False, "message": f"号池拒绝建 Key:{_err_text(r)}"}
|
|
384
|
+
j = r.json()
|
|
385
|
+
kid = str((j.get("item") or {}).get("id") or "")
|
|
386
|
+
key = str(j.get("key") or "")
|
|
387
|
+
if not key:
|
|
388
|
+
return {"ok": False, "message": "号池没返回密钥,稍后重试"}
|
|
389
|
+
if kid:
|
|
390
|
+
_save_plain_key(tgt["home"], kid, key)
|
|
391
|
+
return {"ok": True, "created": True, "key": key, "id": kid, "home": tgt["home"]}
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
# ---------- 面板数据 ----------
|
|
395
|
+
_STATUS_CLS = {"正常": "ok", "限流": "warn", "禁用": "off", "异常": "err"}
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
async def panel(cfg: dict | None = None) -> dict:
|
|
399
|
+
tgt = target(cfg)
|
|
400
|
+
out = {
|
|
401
|
+
"local": tgt["local"],
|
|
402
|
+
"online": False,
|
|
403
|
+
"root": tgt["root"],
|
|
404
|
+
"base_url": tgt["base_url"],
|
|
405
|
+
"home": tgt["home"],
|
|
406
|
+
"message": "",
|
|
407
|
+
"total": 0, "active": 0, "limited": 0, "disabled": 0, "quota": 0,
|
|
408
|
+
"api_key": "", "api_key_masked": "", "api_key_id": "", "keys": [],
|
|
409
|
+
"accounts": [], "accounts_error": "", "blacklist_total": 0,
|
|
410
|
+
# 工作台自带号池:进程是否在线、能否启动 / 停止、运行时是否就绪
|
|
411
|
+
"service": pool_service.status(cfg),
|
|
412
|
+
}
|
|
413
|
+
out["blacklist_total"] = len(load_blacklist(tgt["home"]))
|
|
414
|
+
if not tgt["local"]:
|
|
415
|
+
out["message"] = "当前接口不是本机号池(Base URL 不是 127.0.0.1,或找不到 admin-key)"
|
|
416
|
+
return out
|
|
417
|
+
|
|
418
|
+
# 三个请求互不依赖,一起发出去。串行在 Windows 上要 2 秒多,面板会先闪一下「读取中」
|
|
419
|
+
c = await _client()
|
|
420
|
+
|
|
421
|
+
async def grab(path, timeout):
|
|
422
|
+
return await _req("GET", tgt, path, timeout=timeout, client=c)
|
|
423
|
+
|
|
424
|
+
h_res, a_res, k_res = await asyncio.gather(
|
|
425
|
+
grab("health?format=json", 6), grab("api/accounts", 12),
|
|
426
|
+
_list_keys(tgt, client=c), return_exceptions=True)
|
|
427
|
+
|
|
428
|
+
# 1) 号池自报状态:有 /health 才算真在线
|
|
429
|
+
if isinstance(h_res, Exception):
|
|
430
|
+
if isinstance(h_res, (httpx.ConnectError, httpx.ConnectTimeout)):
|
|
431
|
+
out["message"] = out["service"]["message"] or "号池没在跑:端口上连不上"
|
|
432
|
+
else:
|
|
433
|
+
out["message"] = f"号池连不上:{pool_mod.mask(h_res)[:110]}"
|
|
434
|
+
# 号池停着的时候,本地明文 Key 依然在 user-api-keys.json 里。
|
|
435
|
+
# 以前这里直接 return,面板会把当前密钥显示成「还没有用户 Key」,
|
|
436
|
+
# 用户以为密钥丢了又去点「重新生成」,白白多出一把。
|
|
437
|
+
plain = read_plain_keys(tgt["home"])
|
|
438
|
+
if plain:
|
|
439
|
+
kid, val = next(iter(plain.items()))
|
|
440
|
+
out.update({"api_key": val, "api_key_masked": mask_key(val), "api_key_id": kid,
|
|
441
|
+
"keys": [{"id": kid, "name": "本地保存", "enabled": True,
|
|
442
|
+
"last_used_at": "", "has_plain": True, "current": True}]})
|
|
443
|
+
# 刚启动的那一瞬间进程状态可能还没翻过来,回给前端最新的服务快照
|
|
444
|
+
out["service"] = pool_service.status(cfg)
|
|
445
|
+
return out
|
|
446
|
+
if h_res.status_code == 200:
|
|
447
|
+
j = h_res.json()
|
|
448
|
+
a = j.get("accounts") or {}
|
|
449
|
+
out.update({
|
|
450
|
+
"online": True,
|
|
451
|
+
"total": int(a.get("total") or 0),
|
|
452
|
+
"active": int(a.get("active") or 0),
|
|
453
|
+
"limited": int(a.get("limited") or 0),
|
|
454
|
+
"quota": int(a.get("total_quota") or 0),
|
|
455
|
+
"message": f"{int(a.get('total') or 0)} 个账号 · 可用 {int(a.get('active') or 0)}"
|
|
456
|
+
f" · 配额 {int(a.get('total_quota') or 0)}",
|
|
457
|
+
})
|
|
458
|
+
else:
|
|
459
|
+
out["message"] = f"号池返回 HTTP {h_res.status_code}"
|
|
460
|
+
return out
|
|
461
|
+
|
|
462
|
+
# 2) 账号明细:顺手把 id -> token 表填上,后面删号/停用才换得回来
|
|
463
|
+
if isinstance(a_res, Exception):
|
|
464
|
+
out["accounts_error"] = f"账号明细拉取失败:{pool_mod.mask(a_res)[:90]}"
|
|
465
|
+
elif a_res.status_code != 200:
|
|
466
|
+
out["accounts_error"] = f"账号明细拉取失败(HTTP {a_res.status_code})"
|
|
467
|
+
else:
|
|
468
|
+
try:
|
|
469
|
+
items = a_res.json().get("items") or []
|
|
470
|
+
except Exception:
|
|
471
|
+
items = []
|
|
472
|
+
rows = []
|
|
473
|
+
disabled = 0
|
|
474
|
+
for a in items:
|
|
475
|
+
if not isinstance(a, dict):
|
|
476
|
+
continue
|
|
477
|
+
tok = str(a.get("access_token") or "").strip()
|
|
478
|
+
_remember([tok])
|
|
479
|
+
st = str(a.get("status") or "正常").strip()
|
|
480
|
+
if st == "禁用":
|
|
481
|
+
disabled += 1
|
|
482
|
+
rows.append({
|
|
483
|
+
"id": _id_of(tok) if tok else "",
|
|
484
|
+
"email": pool_mod.mask_email(str(a.get("email") or "")),
|
|
485
|
+
"status": st,
|
|
486
|
+
"cls": _STATUS_CLS.get(st, "warn"),
|
|
487
|
+
"quota": int(a.get("quota") or 0),
|
|
488
|
+
"success": int(a.get("success") or 0),
|
|
489
|
+
"fail": int(a.get("fail") or 0),
|
|
490
|
+
"type": str(a.get("type") or ""),
|
|
491
|
+
"last_used": _fmt_ts(pool_mod._parse_dt(a.get("last_used_at"))),
|
|
492
|
+
"error": str(a.get("last_refresh_error") or "")[:120],
|
|
493
|
+
})
|
|
494
|
+
# quota=0 却挂「正常」的号不算可用:显示与页头统计一起纠正
|
|
495
|
+
normalize_zero_quota(rows, out)
|
|
496
|
+
rows.sort(key=lambda x: (x["cls"] != "ok", -x["quota"], x["email"]))
|
|
497
|
+
out["accounts"] = rows
|
|
498
|
+
out["disabled"] = disabled
|
|
499
|
+
|
|
500
|
+
# 3) 用户 Key:面板上要能直接复制出去接别的工具
|
|
501
|
+
# 「当前」优先认接口里正在用的那把,其次才是第一个有明文的启用 Key
|
|
502
|
+
keys = [] if isinstance(k_res, Exception) else k_res
|
|
503
|
+
plain = read_plain_keys(tgt["home"])
|
|
504
|
+
active = str(pool_mod.provider_cfg(cfg)["api_key"] or "")
|
|
505
|
+
cur = active if active else ""
|
|
506
|
+
for k in keys:
|
|
507
|
+
k["has_plain"] = bool(plain.get(k["id"]))
|
|
508
|
+
if active and plain.get(k["id"]) == active:
|
|
509
|
+
out["api_key_id"] = k["id"]
|
|
510
|
+
if cur:
|
|
511
|
+
for kid, val in plain.items():
|
|
512
|
+
if val == cur:
|
|
513
|
+
out["api_key_id"] = kid
|
|
514
|
+
break
|
|
515
|
+
if not cur:
|
|
516
|
+
for k in keys:
|
|
517
|
+
if k["enabled"] and k["has_plain"]:
|
|
518
|
+
cur = plain[k["id"]]
|
|
519
|
+
out["api_key_id"] = k["id"]
|
|
520
|
+
break
|
|
521
|
+
if not cur and plain:
|
|
522
|
+
# 后端列表读不到(老号池没这个接口)就退一步用本地明文
|
|
523
|
+
kid, cur = next(iter(plain.items()))
|
|
524
|
+
out["api_key_id"] = kid
|
|
525
|
+
for k in keys:
|
|
526
|
+
k["current"] = k["id"] == out["api_key_id"]
|
|
527
|
+
out["keys"] = keys
|
|
528
|
+
out["api_key"] = cur
|
|
529
|
+
out["api_key_masked"] = mask_key(cur)
|
|
530
|
+
return out
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
# ---------- 人工剔除名单 ----------
|
|
534
|
+
# 删除 / 停用过的 token 记一份摘要,再导入同一份 txt 时自动跳过。
|
|
535
|
+
# 只存 sha256 前 24 位,明文 token 不落盘;文件随号池数据目录走,换池互不干扰。
|
|
536
|
+
BLACKLIST_FILE = "import-blacklist.json"
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
def blacklist_id(token: str) -> str:
|
|
540
|
+
return hashlib.sha256(str(token or "").encode("utf-8")).hexdigest()[:24]
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
def _blacklist_path(home: str | Path) -> Path:
|
|
544
|
+
return Path(home) / BLACKLIST_FILE
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
def load_blacklist(home: str | Path) -> set[str]:
|
|
548
|
+
try:
|
|
549
|
+
d = json.loads(_blacklist_path(home).read_text(encoding="utf-8"))
|
|
550
|
+
ids = d.get("ids") if isinstance(d, dict) else d
|
|
551
|
+
return {str(x) for x in (ids or []) if isinstance(x, str) and x}
|
|
552
|
+
except Exception:
|
|
553
|
+
return set()
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
def _save_blacklist(home: str | Path, ids: set[str]) -> None:
|
|
557
|
+
p = _blacklist_path(home)
|
|
558
|
+
try:
|
|
559
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
560
|
+
tmp = p.with_suffix(".json.tmp")
|
|
561
|
+
tmp.write_text(json.dumps({"version": 1, "ids": sorted(ids)},
|
|
562
|
+
ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
563
|
+
os.replace(tmp, p)
|
|
564
|
+
except Exception as e:
|
|
565
|
+
pool_mod.log(f"剔除名单写入失败:{pool_mod.mask(e)[:120]}")
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
def blacklist_add(home, tokens) -> int:
|
|
569
|
+
ids = load_blacklist(home)
|
|
570
|
+
before = len(ids)
|
|
571
|
+
ids.update(blacklist_id(t) for t in tokens if t)
|
|
572
|
+
if len(ids) != before:
|
|
573
|
+
_save_blacklist(home, ids)
|
|
574
|
+
return len(ids) - before
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
def blacklist_remove(home, tokens) -> int:
|
|
578
|
+
ids = load_blacklist(home)
|
|
579
|
+
before = len(ids)
|
|
580
|
+
ids.difference_update(blacklist_id(t) for t in tokens if t)
|
|
581
|
+
if len(ids) != before:
|
|
582
|
+
_save_blacklist(home, ids)
|
|
583
|
+
return before - len(ids)
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
def filter_blacklisted(tokens: list[str], items: list[dict], blacklist: set[str]):
|
|
587
|
+
"""导入前过滤人工剔除过的 token,返回 (tokens, items, 跳过数)。"""
|
|
588
|
+
if not blacklist:
|
|
589
|
+
return tokens, items, 0
|
|
590
|
+
kept_t = [t for t in tokens if blacklist_id(t) not in blacklist]
|
|
591
|
+
kept_i = [d for d in items
|
|
592
|
+
if blacklist_id(d.get("access_token") or "") not in blacklist]
|
|
593
|
+
return kept_t, kept_i, (len(tokens) - len(kept_t)) + (len(items) - len(kept_i))
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
def normalize_zero_quota(rows: list[dict], out: dict) -> int:
|
|
597
|
+
"""号池把 quota=0 的新号写成「正常」,这里纠正显示与统计,不误导成可用。"""
|
|
598
|
+
n = 0
|
|
599
|
+
for r in rows:
|
|
600
|
+
if r.get("status") == "正常" and int(r.get("quota") or 0) == 0:
|
|
601
|
+
r["status"] = "限流"
|
|
602
|
+
r["cls"] = _STATUS_CLS.get("限流", "warn")
|
|
603
|
+
n += 1
|
|
604
|
+
if n:
|
|
605
|
+
out["active"] = max(0, int(out.get("active") or 0) - n)
|
|
606
|
+
out["limited"] = int(out.get("limited") or 0) + n
|
|
607
|
+
out["message"] = (f'{int(out.get("total") or 0)} 个账号 · 可用 {out["active"]}'
|
|
608
|
+
f' · 配额 {int(out.get("quota") or 0)}')
|
|
609
|
+
return n
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
async def _mark_zero_quota(tgt: dict, submitted: list[str], client) -> int:
|
|
613
|
+
"""把本次新增里额度为 0 的号从「正常」改成「限流」。
|
|
614
|
+
|
|
615
|
+
只在号池明确完成额度校验后调用;quota>0 或已有刷新错误的号一律不动,
|
|
616
|
+
避免把还能用的号锁死。改失败不影响导入结果。
|
|
617
|
+
"""
|
|
618
|
+
if not submitted:
|
|
619
|
+
return 0
|
|
620
|
+
try:
|
|
621
|
+
r = await _req("GET", tgt, "api/accounts", timeout=15, client=client)
|
|
622
|
+
if r.status_code != 200:
|
|
623
|
+
return 0
|
|
624
|
+
items = (r.json() or {}).get("items") or []
|
|
625
|
+
except Exception:
|
|
626
|
+
return 0
|
|
627
|
+
want = set(submitted)
|
|
628
|
+
todo = [str(a.get("access_token") or "") for a in items
|
|
629
|
+
if isinstance(a, dict)
|
|
630
|
+
and str(a.get("access_token") or "") in want
|
|
631
|
+
and str(a.get("status") or "正常") == "正常"
|
|
632
|
+
and int(a.get("quota") or 0) == 0
|
|
633
|
+
and not str(a.get("last_refresh_error") or "").strip()]
|
|
634
|
+
if not todo:
|
|
635
|
+
return 0
|
|
636
|
+
|
|
637
|
+
async def one(tok: str) -> bool:
|
|
638
|
+
try:
|
|
639
|
+
rr = await _req("POST", tgt, "api/accounts/update",
|
|
640
|
+
{"access_token": tok, "status": "限流"},
|
|
641
|
+
timeout=15, client=client)
|
|
642
|
+
return rr.status_code < 400
|
|
643
|
+
except Exception:
|
|
644
|
+
return False
|
|
645
|
+
|
|
646
|
+
res = await asyncio.gather(*(one(t) for t in todo[:MAX_IMPORT]))
|
|
647
|
+
return sum(1 for x in res if x)
|
|
648
|
+
|
|
649
|
+
|
|
650
|
+
# ---------- 导入 ----------
|
|
651
|
+
# 账号资料要原样带过去。web 类型的号没有 OpenAI refresh_token,
|
|
652
|
+
# access_token 过期后靠 password 重新登录;以前只把 token 抠出来提交,
|
|
653
|
+
# 号池里就会多一批「密码为空」的号,几天后 token 一过期就全变死号。
|
|
654
|
+
#
|
|
655
|
+
# 特别小心 mail_refresh_token:市面上「首登料」的发料格式是
|
|
656
|
+
# 邮箱----邮箱密码----邮箱Client ID----邮箱Refresh Token----AT
|
|
657
|
+
# 第 4 段是微软 MSA 的邮箱刷新令牌(M.C5xx...0.U.MsaArtifacts...),给接码用的,
|
|
658
|
+
# 不是 OpenAI 的 OAuth refresh_token。号池的 refresh_token 只会被 POST 到
|
|
659
|
+
# auth.openai.com/oauth/token,把邮箱令牌填进去必然刷新失败,还会触发
|
|
660
|
+
# 自动密码重登和 keepalive 定时刷新。所以邮箱料一律用 mail_* 独立字段留档,
|
|
661
|
+
# 号池会原样保存未知字段(account_service._normalize_account 是 dict(item)),
|
|
662
|
+
# 重启 / 导出时资料都还在,同时不会被当成 OpenAI 令牌使用。
|
|
663
|
+
IMPORT_FIELDS = (
|
|
664
|
+
"access_token", "accessToken", "email", "password", "totp_secret",
|
|
665
|
+
"source_type", "type", "user_id", "proxy", "default_model_slug",
|
|
666
|
+
"refresh_token", "id_token", "session_token",
|
|
667
|
+
"mail_client_id", "mail_refresh_token",
|
|
668
|
+
)
|
|
669
|
+
# 微软 MSA 邮箱刷新令牌,形如 M.C535_SN1.0.U.MsaArtifacts.xxxxx
|
|
670
|
+
MAIL_TOKEN_RE = re.compile(r"^M\.[A-Za-z0-9_-]+\.[0-9]+\.U\.", re.I)
|
|
671
|
+
# 发料文件末尾常带一行中文说明(含「发货格式」),不能算成没认出来的账号
|
|
672
|
+
MATERIAL_NOTE_RE = re.compile(r"发货格式|质保|售后|不会用|适用于", re.I)
|
|
673
|
+
|
|
674
|
+
|
|
675
|
+
def _clean_import_item(it: dict) -> dict | None:
|
|
676
|
+
"""校验一条账号 JSON,只保留号池重登需要的字段,别把运行态数据也灌进去。"""
|
|
677
|
+
tok = str(it.get("access_token") or it.get("accessToken") or "").strip()
|
|
678
|
+
if not TOKEN_RE.fullmatch(tok):
|
|
679
|
+
return None
|
|
680
|
+
out: dict = {"access_token": tok}
|
|
681
|
+
for k in IMPORT_FIELDS:
|
|
682
|
+
v = it.get(k)
|
|
683
|
+
if v is None or v == "" or k in ("access_token", "accessToken"):
|
|
684
|
+
continue
|
|
685
|
+
# 有人把邮箱 Refresh Token 填到 refresh_token 字段时,改归档别误伤号池
|
|
686
|
+
if k == "refresh_token" and isinstance(v, str) and MAIL_TOKEN_RE.match(v.strip()):
|
|
687
|
+
out.setdefault("mail_refresh_token", v)
|
|
688
|
+
continue
|
|
689
|
+
out[k] = v if isinstance(v, (str, int, float, bool)) else str(v)
|
|
690
|
+
return out
|
|
691
|
+
|
|
692
|
+
|
|
693
|
+
def _parse_dash_row(line: str) -> dict | None:
|
|
694
|
+
"""解析发料行:邮箱----密码----[邮箱Client ID----邮箱Refresh Token]----AT。
|
|
695
|
+
|
|
696
|
+
先整行锁定 JWT,再切前面的资料:JWT 签名本身可能含连续横线,按
|
|
697
|
+
分隔符直接 split 整行会把一个 token 劈成两半(真实发料里就有一行这样),
|
|
698
|
+
那条账号就会退化成「只有 token、没有邮箱密码」。
|
|
699
|
+
只认第一段是邮箱的行;中间资料按位置留档,段数不明时不硬猜字段语义。
|
|
700
|
+
"""
|
|
701
|
+
m = TOKEN_RE.search(line)
|
|
702
|
+
if m is None:
|
|
703
|
+
return None
|
|
704
|
+
head = line[:m.start()].strip()
|
|
705
|
+
parts = [p.strip() for p in re.split(r"-{4,}", head) if p.strip()]
|
|
706
|
+
if len(parts) < 2 or "@" not in parts[0]:
|
|
707
|
+
return None
|
|
708
|
+
out: dict = {"access_token": m.group(0), "email": parts[0],
|
|
709
|
+
"password": parts[1], "source_type": "web"}
|
|
710
|
+
if len(parts) == 4:
|
|
711
|
+
out["mail_client_id"] = parts[2]
|
|
712
|
+
out["mail_refresh_token"] = parts[3]
|
|
713
|
+
return out
|
|
714
|
+
|
|
715
|
+
|
|
716
|
+
def parse_import(text: str) -> tuple[list[str], list[dict], int]:
|
|
717
|
+
"""解析粘贴内容,返回 (裸 token 列表, 完整账号对象, 没认出来的行数)。
|
|
718
|
+
|
|
719
|
+
支持四种常见格式(txt / json / csv 上传后走同一条解析):
|
|
720
|
+
- 每行一个 token(最常见)
|
|
721
|
+
- 号池 / 第三方工具导出的 JSON:数组、{accounts:[...]}、或单个对象
|
|
722
|
+
- 五段式首登料(邮箱----密码----Client ID----邮箱RT----AT):整条资料一起入池
|
|
723
|
+
- 老式导出行(邮箱,密码,token / 邮箱----密码----token):按行扫 JWT
|
|
724
|
+
"""
|
|
725
|
+
# Windows 记事本存的 UTF-8 txt 常带 BOM,不清掉会把整段 JSON 判成纯文本
|
|
726
|
+
raw = str(text or "").lstrip("\ufeff")
|
|
727
|
+
tokens: list[str] = []
|
|
728
|
+
items: list[dict] = []
|
|
729
|
+
bad = 0
|
|
730
|
+
s = raw.strip()
|
|
731
|
+
data = None
|
|
732
|
+
if s.startswith("[") or s.startswith("{"):
|
|
733
|
+
try:
|
|
734
|
+
data = json.loads(s)
|
|
735
|
+
except Exception:
|
|
736
|
+
data = None
|
|
737
|
+
if data is not None:
|
|
738
|
+
entries = data
|
|
739
|
+
if isinstance(data, dict):
|
|
740
|
+
for key in ("accounts", "items", "tokens"):
|
|
741
|
+
if isinstance(data.get(key), list):
|
|
742
|
+
entries = data[key]
|
|
743
|
+
break
|
|
744
|
+
else:
|
|
745
|
+
entries = [data]
|
|
746
|
+
if isinstance(entries, list):
|
|
747
|
+
for it in entries:
|
|
748
|
+
if isinstance(it, dict):
|
|
749
|
+
one = _clean_import_item(it)
|
|
750
|
+
if one:
|
|
751
|
+
items.append(one)
|
|
752
|
+
else:
|
|
753
|
+
bad += 1
|
|
754
|
+
elif isinstance(it, str) and TOKEN_RE.fullmatch(it.strip()):
|
|
755
|
+
tokens.append(it.strip())
|
|
756
|
+
else:
|
|
757
|
+
bad += 1
|
|
758
|
+
items = list({d["access_token"]: d for d in items}.values())
|
|
759
|
+
have = {d["access_token"] for d in items}
|
|
760
|
+
tokens = [t for t in dict.fromkeys(tokens) if t not in have]
|
|
761
|
+
return tokens, items, bad
|
|
762
|
+
|
|
763
|
+
for line in raw.splitlines():
|
|
764
|
+
line = line.strip()
|
|
765
|
+
if not line:
|
|
766
|
+
continue
|
|
767
|
+
one = _parse_dash_row(line)
|
|
768
|
+
if one is not None:
|
|
769
|
+
items.append(one)
|
|
770
|
+
continue
|
|
771
|
+
found = TOKEN_RE.findall(line)
|
|
772
|
+
if found:
|
|
773
|
+
tokens.extend(found)
|
|
774
|
+
elif MATERIAL_NOTE_RE.search(line) and not TOKEN_RE.search(line):
|
|
775
|
+
continue # 发料文件末尾的中文说明 / 格式示例行
|
|
776
|
+
elif re.match(r"^\s*(email|user|account|access_token|token)\b", line, re.I) \
|
|
777
|
+
and re.search(r"[,;\t|]", line):
|
|
778
|
+
continue # CSV / TSV 的表头行,不算没认出的账号
|
|
779
|
+
else:
|
|
780
|
+
bad += 1
|
|
781
|
+
items = list({d["access_token"]: d for d in items}.values())
|
|
782
|
+
have = {d["access_token"] for d in items}
|
|
783
|
+
tokens = [t for t in dict.fromkeys(tokens) if t not in have]
|
|
784
|
+
if bad and not tokens and not items:
|
|
785
|
+
bad = max(1, bad)
|
|
786
|
+
return tokens, items, bad
|
|
787
|
+
|
|
788
|
+
|
|
789
|
+
def parse_tokens(text: str) -> tuple[list[str], int]:
|
|
790
|
+
"""兼容旧调用:只关心 token 列表(面板/冒烟用它做格式校验)。"""
|
|
791
|
+
tokens, items, bad = parse_import(text)
|
|
792
|
+
merged = list(dict.fromkeys([*tokens, *[d["access_token"] for d in items]]))
|
|
793
|
+
return merged, bad
|
|
794
|
+
|
|
795
|
+
|
|
796
|
+
async def import_accounts(text: str) -> dict:
|
|
797
|
+
tgt = target()
|
|
798
|
+
if not tgt["local"]:
|
|
799
|
+
return {"ok": False, "message": "当前接口不是本机号池,无法导入账号"}
|
|
800
|
+
tokens, items, bad = parse_import(text)
|
|
801
|
+
# 人工停用 / 删除过的号不允许靠重导同一份文件复活
|
|
802
|
+
blacklisted = 0
|
|
803
|
+
if tokens or items:
|
|
804
|
+
tokens, items, blacklisted = filter_blacklisted(
|
|
805
|
+
tokens, items, load_blacklist(tgt["home"]))
|
|
806
|
+
all_tokens = list(dict.fromkeys([*tokens, *[d["access_token"] for d in items]]))
|
|
807
|
+
if not all_tokens:
|
|
808
|
+
if blacklisted:
|
|
809
|
+
return {"ok": False, "blacklisted": blacklisted,
|
|
810
|
+
"message": f"{blacklisted} 个账号都在剔除名单里;先在列表里启用即可解封"}
|
|
811
|
+
# 邮箱 / 密码 / 2FA 料(email----password----secret)里没有 token,
|
|
812
|
+
# 本号池版本不会用它现场登录换 token,别让用户以为导入成功了。
|
|
813
|
+
material = bool(re.search(r"[\w.+-]+@[\w.-]+\s*[-]{3,4}\s*\S", text))
|
|
814
|
+
return {"ok": False,
|
|
815
|
+
"message": ("文件里是邮箱 / 密码料,没有 access_token"
|
|
816
|
+
if material else
|
|
817
|
+
"没读到 access_token:每行贴一个,或粘贴导出的账号 JSON")}
|
|
818
|
+
note = ""
|
|
819
|
+
if len(all_tokens) > MAX_IMPORT:
|
|
820
|
+
items = items[:MAX_IMPORT]
|
|
821
|
+
tokens = tokens[:max(0, MAX_IMPORT - len(items))]
|
|
822
|
+
note = f"一次最多导 {MAX_IMPORT} 个,已取前 {len(items) + len(tokens)} 个"
|
|
823
|
+
# 号池的 POST /api/accounts 同时收 accounts(完整对象)和 tokens(裸 token)。
|
|
824
|
+
# 五段式发料 / JSON 粘贴走前者,邮箱密码、2FA、来源类型和邮箱料都能留下;
|
|
825
|
+
# 裸 token 只能走后者。号池对已存在的账号是「合并更新」,重导同一份文件
|
|
826
|
+
# 还能把以前漏掉的密码补回去,不必删库重来。
|
|
827
|
+
body: dict = {}
|
|
828
|
+
if items:
|
|
829
|
+
body["accounts"] = items
|
|
830
|
+
if tokens:
|
|
831
|
+
body["tokens"] = tokens
|
|
832
|
+
try:
|
|
833
|
+
r = await _req("POST", tgt, "api/accounts", body, timeout=240)
|
|
834
|
+
except Exception as e:
|
|
835
|
+
return {"ok": False, "message": f"导入请求失败:{pool_mod.mask(e)[:120]}"}
|
|
836
|
+
if r.status_code >= 400:
|
|
837
|
+
return {"ok": False, "message": f"号池拒绝导入:{_err_text(r)}"}
|
|
838
|
+
try:
|
|
839
|
+
j = r.json()
|
|
840
|
+
except Exception:
|
|
841
|
+
j = {}
|
|
842
|
+
_remember(all_tokens)
|
|
843
|
+
if items:
|
|
844
|
+
note = (note + " · " if note else "") + f"{len(items)} 个账号连邮箱密码和来源一起导入"
|
|
845
|
+
added = int(j.get("added") or 0)
|
|
846
|
+
skipped = int(j.get("skipped") or 0)
|
|
847
|
+
refreshed = int(j.get("refreshed") or 0)
|
|
848
|
+
errors = [str(e)[:160] for e in (j.get("errors") or [])][:6]
|
|
849
|
+
# 号池报告「已完成校验」且确实有新增,才把额度为 0 的新号从「正常」改成「限流」
|
|
850
|
+
limited = 0
|
|
851
|
+
if added > 0 and refreshed > 0:
|
|
852
|
+
try:
|
|
853
|
+
limited = await _mark_zero_quota(tgt, all_tokens, await _client())
|
|
854
|
+
except Exception:
|
|
855
|
+
limited = 0
|
|
856
|
+
return {"ok": True, "count": len(all_tokens), "added": added, "skipped": skipped,
|
|
857
|
+
"refreshed": refreshed, "limited": limited, "errors": errors,
|
|
858
|
+
"blacklisted": blacklisted,
|
|
859
|
+
"blacklist_total": len(load_blacklist(tgt["home"])),
|
|
860
|
+
"bad": bad, "note": note}
|
|
861
|
+
|
|
862
|
+
|
|
863
|
+
# ---------- 删除 / 停用 ----------
|
|
864
|
+
async def _ids_to_tokens(ids: list[str]) -> tuple[list[str], int]:
|
|
865
|
+
ids = [str(i) for i in ids if i]
|
|
866
|
+
tokens = [_TOKENS[i] for i in ids if i in _TOKENS]
|
|
867
|
+
if len(tokens) == len(ids):
|
|
868
|
+
return tokens, 0
|
|
869
|
+
# id 表可能是上一轮进程留下的,先刷一次账号列表再换
|
|
870
|
+
await panel()
|
|
871
|
+
tokens = [_TOKENS[i] for i in ids if i in _TOKENS]
|
|
872
|
+
return tokens, len(ids) - len(tokens)
|
|
873
|
+
|
|
874
|
+
|
|
875
|
+
async def delete_accounts(ids: list[str]) -> dict:
|
|
876
|
+
tgt = target()
|
|
877
|
+
if not tgt["local"]:
|
|
878
|
+
return {"ok": False, "message": "当前接口不是本机号池,无法删除账号"}
|
|
879
|
+
tokens, missing = await _ids_to_tokens(ids)
|
|
880
|
+
if not tokens:
|
|
881
|
+
return {"ok": False, "message": "账号已变化,刷新面板后重试"}
|
|
882
|
+
try:
|
|
883
|
+
r = await _req("DELETE", tgt, "api/accounts", {"tokens": tokens}, timeout=30)
|
|
884
|
+
except Exception as e:
|
|
885
|
+
return {"ok": False, "message": f"删除请求失败:{pool_mod.mask(e)[:120]}"}
|
|
886
|
+
if r.status_code >= 400:
|
|
887
|
+
return {"ok": False, "message": f"号池拒绝删除:{_err_text(r)}"}
|
|
888
|
+
try:
|
|
889
|
+
removed = int((r.json() or {}).get("removed") or 0)
|
|
890
|
+
except Exception:
|
|
891
|
+
removed = 0
|
|
892
|
+
for i in ids:
|
|
893
|
+
_TOKENS.pop(str(i), None)
|
|
894
|
+
bl = blacklist_add(tgt["home"], tokens)
|
|
895
|
+
pool_mod.log(f"号池删除账号 {removed} 个,剔除名单 +{bl}")
|
|
896
|
+
return {"ok": True, "removed": removed, "missing": missing, "blacklisted": bl}
|
|
897
|
+
|
|
898
|
+
|
|
899
|
+
async def set_status(ids: list[str], disabled: bool) -> dict:
|
|
900
|
+
tgt = target()
|
|
901
|
+
if not tgt["local"]:
|
|
902
|
+
return {"ok": False, "message": "当前接口不是本机号池,无法修改账号"}
|
|
903
|
+
tokens, missing = await _ids_to_tokens(ids)
|
|
904
|
+
if not tokens:
|
|
905
|
+
return {"ok": False, "message": "账号已变化,刷新面板后重试"}
|
|
906
|
+
status = "禁用" if disabled else "正常"
|
|
907
|
+
done = 0
|
|
908
|
+
for tok in tokens:
|
|
909
|
+
try:
|
|
910
|
+
r = await _req("POST", tgt, "api/accounts/update",
|
|
911
|
+
{"access_token": tok, "status": status}, timeout=20)
|
|
912
|
+
except Exception as e:
|
|
913
|
+
return {"ok": False, "message": f"请求失败:{pool_mod.mask(e)[:120]}"}
|
|
914
|
+
if r.status_code >= 400:
|
|
915
|
+
return {"ok": False, "message": f"号池拒绝修改:{_err_text(r)}"}
|
|
916
|
+
done += 1
|
|
917
|
+
# 停用即入剔除名单(以后重导也回不来),启用即解封
|
|
918
|
+
bl = (blacklist_add(tgt["home"], tokens) if disabled
|
|
919
|
+
else blacklist_remove(tgt["home"], tokens))
|
|
920
|
+
return {"ok": True, "changed": done, "status": status, "missing": missing,
|
|
921
|
+
"blacklisted": bl}
|