dsh-plugin-image-workbench 1.1.5 → 1.1.6
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 +1329 -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/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/smoke_check.py
CHANGED
|
@@ -1,402 +1,402 @@
|
|
|
1
|
-
"""本地生图工作台冒烟:符号断言 + 全量读接口 + 一次真实端到端出图。
|
|
2
|
-
|
|
3
|
-
用法:
|
|
4
|
-
.venv/Scripts/python.exe smoke_check.py # 全量(含真实出图,约 1 分钟)
|
|
5
|
-
.venv/Scripts/python.exe smoke_check.py --quick # 只跑符号与读接口
|
|
6
|
-
"""
|
|
7
|
-
from __future__ import annotations
|
|
8
|
-
|
|
9
|
-
import json, pathlib, sys, time
|
|
10
|
-
import httpx
|
|
11
|
-
|
|
12
|
-
BASE = 'http://127.0.0.1:8092'
|
|
13
|
-
QUICK = '--quick' in sys.argv
|
|
14
|
-
ROOT = pathlib.Path(__file__).resolve().parent
|
|
15
|
-
passed = failed = 0
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
def ok(name, cond, detail=''):
|
|
19
|
-
global passed, failed
|
|
20
|
-
if cond:
|
|
21
|
-
passed += 1
|
|
22
|
-
print('PASS ' + name + ((' ' + str(detail)) if detail else ''))
|
|
23
|
-
else:
|
|
24
|
-
failed += 1
|
|
25
|
-
print('FAIL ' + name + ((' ' + str(detail)) if detail else ''))
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
# ---- 1) 离线符号断言:抓「语法合法但符号丢失」 ----
|
|
29
|
-
print('=== 1. 关键符号 ===')
|
|
30
|
-
sys.path.insert(0, str(ROOT))
|
|
31
|
-
import app, pool, pool_admin, pool_service, composer, history, imaging, catalog, access, drafts
|
|
32
|
-
for mod, names in [
|
|
33
|
-
(app, ['app', 'POOL', 'HISTORY', 'DRAFTS', 'CATALOG', 'api_generate',
|
|
34
|
-
'api_compose', '_finish_with_replace']),
|
|
35
|
-
(pool, ['PoolManager', 'load_config', 'pool_api_key', 'raise_for_response',
|
|
36
|
-
'size_for_ratio', 'pool_home', 'pool_admin_key']),
|
|
37
|
-
# 号池面板:导入账号 / 删号 / 签发 Key 全靠这几个入口,缺一个面板就是死的
|
|
38
|
-
(pool_admin, ['panel', 'import_accounts', 'delete_accounts', 'set_status',
|
|
39
|
-
'ensure_key', 'parse_tokens', 'parse_import', '_parse_dash_row',
|
|
40
|
-
'IMPORT_FIELDS', 'target']),
|
|
41
|
-
# 工作台自带号池:进程生命周期全在这里,不再有独立号池插件
|
|
42
|
-
(pool_service, ['status', 'start', 'stop', 'restart', 'bootstrap',
|
|
43
|
-
'ensure_admin_key', 'set_auto_start', 'start_watchdog', 'view']),
|
|
44
|
-
(composer, ['compose']),
|
|
45
|
-
(history, ['History']),
|
|
46
|
-
(imaging, ['ensure', 'ensure_all', 'variant_path']),
|
|
47
|
-
(catalog, ['build']),
|
|
48
|
-
(drafts, ['Drafts']),
|
|
49
|
-
]:
|
|
50
|
-
for n in names:
|
|
51
|
-
ok(f'{mod.__name__}.{n}', hasattr(mod, n))
|
|
52
|
-
ok('PoolManager._execute', callable(getattr(pool.PoolManager, '_execute', None)))
|
|
53
|
-
ok('PoolManager.snapshot', callable(getattr(pool.PoolManager, 'snapshot', None)))
|
|
54
|
-
ok('PoolManager.check_all', callable(getattr(pool.PoolManager, 'check_all', None)))
|
|
55
|
-
ok('PoolManager.cancel_all', callable(getattr(pool.PoolManager, 'cancel_all', None)))
|
|
56
|
-
ok('app.api_generate_stop', hasattr(app, 'api_generate_stop'))
|
|
57
|
-
ok('POST /api/generate/stop 已注册',
|
|
58
|
-
any(getattr(r, 'path', '') == '/api/generate/stop'
|
|
59
|
-
and 'POST' in getattr(r, 'methods', set()) for r in app.app.routes))
|
|
60
|
-
_src_pool = (ROOT / 'pool.py').read_text(encoding='utf-8')
|
|
61
|
-
_src_html = (ROOT / 'static' / 'index.html').read_text(encoding='utf-8')
|
|
62
|
-
ok('已停止状态不落盘', 'not in ("done", "error", "cancelled")' in _src_pool)
|
|
63
|
-
ok('取消后不再换号重试', 'if job.get("status") == "cancelled":' in _src_pool)
|
|
64
|
-
ok('前端两个停止按钮齐全', 'id="stopGen"' in _src_html and 'id="monStop"' in _src_html)
|
|
65
|
-
ok('前端停止链路完整',
|
|
66
|
-
all(x in _src_html for x in ('async function stopAllJobs', '/api/generate/stop',
|
|
67
|
-
'clearBatch();', "d.status === 'cancelled'")))
|
|
68
|
-
ok('access 无 fcntl 依赖', not hasattr(access, 'fcntl'))
|
|
69
|
-
ok('工作台目录可用', (ROOT / 'app.py').is_file())
|
|
70
|
-
ok('AUTH 为空(本地全放行)', app.AUTH is None)
|
|
71
|
-
ok('catalog 三类齐全', all(len(app.CATALOG[k]) > 0 for k in ('styles', 'layouts', 'colors')),
|
|
72
|
-
{k: len(v) for k, v in app.CATALOG.items()})
|
|
73
|
-
ok('配置可缺省(整包首次运行)', isinstance(pool.load_config(), dict))
|
|
74
|
-
cfg = pool.load_config()
|
|
75
|
-
# 接口地址允许换(38651 生图号池 / 38650 写作号池 / 任意 OpenAI 兼容地址),
|
|
76
|
-
# 这里只校验「配置能读出来且是合法 URL」,端口写死会让冒烟在换接口后假失败
|
|
77
|
-
ok('接口地址可读', bool(cfg['pool_base_url']) and cfg['pool_base_url'].startswith('http'),
|
|
78
|
-
cfg['pool_base_url'])
|
|
79
|
-
ok('号池用户 Key 可读', bool(pool.pool_api_key(cfg)))
|
|
80
|
-
ok('出图模型 = gpt-image-2', cfg['model_image'] == 'gpt-image-2')
|
|
81
|
-
|
|
82
|
-
# 公众号首图 2.35:1:上游没有这个尺寸,靠 3:2 出图 + 本地居中裁切;
|
|
83
|
-
# 提示词里必须先说清「上下留可裁背景」,否则裁完砍标题
|
|
84
|
-
ok('2.35:1 按 1536x1024 出图', pool.size_for_ratio('2.35:1') == '1536x1024',
|
|
85
|
-
pool.size_for_ratio('2.35:1'))
|
|
86
|
-
ok('认得出要裁切的比例', imaging.needs_crop('2.35:1') and not imaging.needs_crop('3:4'))
|
|
87
|
-
ok('提示词带横向安全区约束',
|
|
88
|
-
bool(composer.WIDE_CROP_HINT) and composer.WIDE_CROP_HINT[:6] in
|
|
89
|
-
composer.build_prompt({'title': '选题', 'body': ''}, {}, '2.35:1', [],
|
|
90
|
-
False, 'normal', '', False, 'graphic-text', ''))
|
|
91
|
-
try:
|
|
92
|
-
from PIL import Image as _Img
|
|
93
|
-
_src = _Img.new('RGB', (1536, 1024), (250, 245, 255))
|
|
94
|
-
_cut = imaging.crop_to(_src, '2.35:1')
|
|
95
|
-
ok('居中裁切比例正确', _cut.size[0] == 1536 and abs(_cut.size[0] / _cut.size[1] - 2.35) < 0.01,
|
|
96
|
-
_cut.size)
|
|
97
|
-
ok('不用裁的比例原样返回', imaging.crop_to(_src, '3:4').size == (1536, 1024))
|
|
98
|
-
except Exception as e:
|
|
99
|
-
ok('裁切函数可用', False, str(e)[:100])
|
|
100
|
-
|
|
101
|
-
# ---- 2) 全量读接口 ----
|
|
102
|
-
print('\n=== 2. 读接口 ===')
|
|
103
|
-
client = httpx.Client(base_url=BASE, trust_env=False, timeout=30)
|
|
104
|
-
routes = [
|
|
105
|
-
('/', 200), ('/health', 200), ('/api/catalog', 200), ('/api/pool', 200),
|
|
106
|
-
('/api/monitor', 200), ('/api/recent', 200), ('/api/history', 200),
|
|
107
|
-
('/api/tasks', 200), ('/api/drafts', 200), ('/api/pool/check', 200),
|
|
108
|
-
('/api/pool/fails', 200), ('/api/pool/panel', 200),
|
|
109
|
-
('/api/pool/service', 200),
|
|
110
|
-
('/api/pool/proxy', 200),
|
|
111
|
-
('/api/config', 200),
|
|
112
|
-
('/img/individual/FA/FA-001.webp', 200),
|
|
113
|
-
]
|
|
114
|
-
for path, expect in routes:
|
|
115
|
-
try:
|
|
116
|
-
r = client.get(path)
|
|
117
|
-
ok(f'GET {path}', r.status_code == expect, f'HTTP {r.status_code} / {len(r.content)}B')
|
|
118
|
-
except Exception as e:
|
|
119
|
-
ok(f'GET {path}', False, type(e).__name__ + ': ' + str(e)[:100])
|
|
120
|
-
|
|
121
|
-
try:
|
|
122
|
-
p = client.get('/api/pool').json()
|
|
123
|
-
ok('号池 snapshot 有 pool 统计', isinstance(p.get('pool'), dict),
|
|
124
|
-
f"{p.get('pool', {}).get('total')} 号 / 配额 {p.get('pool', {}).get('quota_total')}")
|
|
125
|
-
ok('号池在线', bool(p.get('pool', {}).get('online')))
|
|
126
|
-
# 失败换号:snapshot 要能给出「今天谁失败过几次」
|
|
127
|
-
ok('号池 snapshot 有今日失败统计', isinstance(p.get('fails_today'), int),
|
|
128
|
-
f"今日失败 {p.get('fails_today')} 次 / 涉及 {len(p.get('fail_accounts') or [])} 个号")
|
|
129
|
-
f = client.get('/api/pool/fails').json()
|
|
130
|
-
ok('GET /api/pool/fails 有结构', isinstance(f.get('items'), list) and 'total_today' in f,
|
|
131
|
-
f"{f.get('total_today')} 次")
|
|
132
|
-
except Exception as e:
|
|
133
|
-
ok('号池 snapshot 可解析', False, str(e)[:120])
|
|
134
|
-
|
|
135
|
-
# 出口代理 GET 应回传 proxy_auto_url 字段(零配置自动探测标记)
|
|
136
|
-
try:
|
|
137
|
-
pr = client.get('/api/pool/proxy').json()
|
|
138
|
-
ok('GET /api/pool/proxy 回传 proxy_auto_url 字段',
|
|
139
|
-
'proxy_auto_url' in pr, f"auto={pr.get('proxy_auto_url') or '—'}")
|
|
140
|
-
except Exception as e:
|
|
141
|
-
ok('GET /api/pool/proxy 可解析', False, str(e)[:120])
|
|
142
|
-
|
|
143
|
-
# 出口代理那一栏按本机实时探测结果反显,不写死地址:换机器要能跟着变
|
|
144
|
-
try:
|
|
145
|
-
eg = client.get('/api/pool/egress').json()
|
|
146
|
-
ok('GET /api/pool/egress 给出本机探测结果',
|
|
147
|
-
eg.get('ok') is True and 'url' in eg and isinstance(eg.get('ports'), list),
|
|
148
|
-
f"{eg.get('url') or '未探测到'}(来源 {eg.get('source')})")
|
|
149
|
-
ok('探测结果带候选端口列表', isinstance(eg.get('ports'), list),
|
|
150
|
-
f"{len(eg.get('ports') or [])} 个端口在监听")
|
|
151
|
-
except Exception as e:
|
|
152
|
-
ok('GET /api/pool/egress 可解析', False, str(e)[:120])
|
|
153
|
-
|
|
154
|
-
# 「接口与号池」面板:账号导入 / 删号全靠它,字段缺一个前端就是空白
|
|
155
|
-
try:
|
|
156
|
-
pn = client.get('/api/pool/panel').json()
|
|
157
|
-
ok('panel 有 local / online 标志',
|
|
158
|
-
isinstance(pn.get('local'), bool) and isinstance(pn.get('online'), bool),
|
|
159
|
-
f"local={pn.get('local')} online={pn.get('online')}")
|
|
160
|
-
ok('panel 给出对外接口地址', str(pn.get('base_url', '')).startswith('http'),
|
|
161
|
-
pn.get('base_url'))
|
|
162
|
-
sv = pn.get('service') or {}
|
|
163
|
-
ok('panel 带内置号池服务快照',
|
|
164
|
-
isinstance(sv.get('manageable'), bool) and isinstance(sv.get('runtime_ready'), bool)
|
|
165
|
-
and 'auto_start' in sv, f"port={sv.get('port')} runtime={sv.get('runtime_ready')}")
|
|
166
|
-
ok('内置号池端口可管理(不是写作台 38650)', sv.get('manageable') is True,
|
|
167
|
-
sv.get('message'))
|
|
168
|
-
ok('号池运行时就绪(uv + source + venv)', sv.get('runtime_ready') is True,
|
|
169
|
-
sv.get('runtime_reason'))
|
|
170
|
-
if pn.get('local') and pn.get('online'):
|
|
171
|
-
accs = pn.get('accounts') or []
|
|
172
|
-
ok('panel 账号带掩码邮箱与状态',
|
|
173
|
-
bool(accs) and all(a.get('email') and a.get('status') for a in accs),
|
|
174
|
-
f"{accs[0]['email'] if accs else '—'} / 共 {pn.get('total')} 个")
|
|
175
|
-
# 账号 id 必须是 12 位摘要:access_token 不能出现在前端
|
|
176
|
-
ok('panel 账号 id 是摘要不是 token',
|
|
177
|
-
all(len(str(a.get('id') or '')) == 12 for a in accs),
|
|
178
|
-
accs[0].get('id') if accs else '')
|
|
179
|
-
ok('panel 能拿到用户 Key 明文(供复制)', bool(pn.get('api_key')),
|
|
180
|
-
pn.get('api_key_masked'))
|
|
181
|
-
else:
|
|
182
|
-
print(' 当前接口不是本机号池,跳过账号字段断言')
|
|
183
|
-
except Exception as e:
|
|
184
|
-
ok('GET /api/pool/panel 可用', False, str(e)[:120])
|
|
185
|
-
|
|
186
|
-
# 内置号池服务:状态可读、自动拉起开关可写(只写回原值,不改变用户设置)
|
|
187
|
-
try:
|
|
188
|
-
svc = client.get('/api/pool/service').json()
|
|
189
|
-
ok('服务状态字段齐全',
|
|
190
|
-
all(k in svc for k in ('port', 'home', 'running', 'online', 'auto_start',
|
|
191
|
-
'runtime_ready', 'manageable', 'message')),
|
|
192
|
-
f"port={svc.get('port')} online={svc.get('online')} total={svc.get('total')}")
|
|
193
|
-
r = client.post('/api/pool/service/auto', json={'enabled': bool(svc.get('auto_start'))})
|
|
194
|
-
d = r.json()
|
|
195
|
-
ok('POST /api/pool/service/auto 可写', r.status_code == 200 and d.get('ok') is True
|
|
196
|
-
and d.get('auto_start') == bool(svc.get('auto_start')), f'HTTP {r.status_code}')
|
|
197
|
-
except Exception as e:
|
|
198
|
-
ok('GET /api/pool/service 可用', False, str(e)[:120])
|
|
199
|
-
|
|
200
|
-
# 导入解析:三种常见粘贴格式都要认,认不出时不能瞎编 token
|
|
201
|
-
try:
|
|
202
|
-
_tk = 'eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJ4In0.abc12345'
|
|
203
|
-
_a, _ = pool_admin.parse_tokens(_tk)
|
|
204
|
-
_b, _ = pool_admin.parse_tokens(json.dumps([{'access_token': _tk}]))
|
|
205
|
-
_c, _bad = pool_admin.parse_tokens('a@b.com----pw----' + _tk + '\n乱七八糟')
|
|
206
|
-
ok('导入解析:裸 token / JSON / 老导出格式',
|
|
207
|
-
_a == [_tk] and _b == [_tk] and _c == [_tk] and _bad == 1,
|
|
208
|
-
f'{len(_a)}/{len(_b)}/{len(_c)} bad={_bad}')
|
|
209
|
-
ok('导入解析:没有 token 时不编造', pool_admin.parse_tokens('这不是 token')[0] == [])
|
|
210
|
-
_row = {'access_token': _tk, 'email': 'a@x.com', 'password': 'pw1',
|
|
211
|
-
'source_type': 'web', 'quota': 25, 'status': '正常'}
|
|
212
|
-
_t, _it, _b = pool_admin.parse_import(json.dumps([_row]))
|
|
213
|
-
ok('JSON 导入保留密码与来源类型',
|
|
214
|
-
_t == [] and len(_it) == 1 and _it[0].get('password') == 'pw1'
|
|
215
|
-
and _it[0].get('source_type') == 'web' and _b == 0,
|
|
216
|
-
f'{len(_it)} 条 / {sorted(_it[0]) if _it else []}')
|
|
217
|
-
ok('JSON 导入不灌运行态数据',
|
|
218
|
-
'quota' not in (_it[0] if _it else {}) and 'status' not in (_it[0] if _it else {}))
|
|
219
|
-
_tk2 = 'eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJ5In0.bbb67890'
|
|
220
|
-
_t2, _it2, _ = pool_admin.parse_import(json.dumps({'items': [_row, _tk2]}))
|
|
221
|
-
ok('导入混合格式:对象走 accounts、纯串走 tokens',
|
|
222
|
-
len(_it2) == 1 and _t2 == [_tk2], f'obj={len(_it2)} raw={len(_t2)}')
|
|
223
|
-
ok('导入解析:accessToken 别名归一',
|
|
224
|
-
pool_admin.parse_import(json.dumps({'accessToken': _tk, 'password': 'pw'}))[1][0]['access_token'] == _tk)
|
|
225
|
-
ok('导入解析:带 BOM 的 txt 文件',
|
|
226
|
-
pool_admin.parse_import('\ufeff' + _tk)[0] == [_tk])
|
|
227
|
-
_csv = 'email,password,access_token\r\na@b.com,pw1,' + _tk + '\r\nc@d.com,pw2,' + _tk2
|
|
228
|
-
_ct, _, _cb = pool_admin.parse_import(_csv)
|
|
229
|
-
ok('导入解析:CSV 表头跳过、CRLF 兼容',
|
|
230
|
-
_ct == [_tk, _tk2] and _cb == 0, f'{len(_ct)} 条 / bad={_cb}')
|
|
231
|
-
# 五段式「首登料」:邮箱----密码----邮箱Client ID----邮箱Refresh Token----AT
|
|
232
|
-
# 第 4 段是微软 MSA 邮箱令牌,绝不能被当成号池的 refresh_token 提交。
|
|
233
|
-
_mail_rt = ('M.C535_SN1.0.U.MsaArtifacts.-CgyvaETosvszterVMMhyTAqQdRDx0FMPDuNxRjzP'
|
|
234
|
-
'!mpzzeyPlYSeqOkMN9W0m9aWwvjO5DZtLl6jb3dKlIP7PHuCRV7$')
|
|
235
|
-
_cid = '9e5f94bc-e8a4-4e73-b8be-63364c364c29d753'
|
|
236
|
-
_five = 'a@b.com----pw1----' + _cid + '----' + _mail_rt + '----' + _tk
|
|
237
|
-
_note = '不会用的别买,发货格式:邮箱----邮箱密码----邮箱Client ID----邮箱Refresh Token----AT'
|
|
238
|
-
_t5, _i5, _b5 = pool_admin.parse_import(_five + '\n' + _note)
|
|
239
|
-
_one = _i5[0] if _i5 else {}
|
|
240
|
-
ok('导入解析:五段式发料保留完整资料',
|
|
241
|
-
_t5 == [] and len(_i5) == 1 and _b5 == 0
|
|
242
|
-
and _one.get('email') == 'a@b.com' and _one.get('password') == 'pw1'
|
|
243
|
-
and _one.get('mail_client_id') == _cid
|
|
244
|
-
and _one.get('mail_refresh_token') == _mail_rt
|
|
245
|
-
and _one.get('access_token') == _tk and _one.get('source_type') == 'web',
|
|
246
|
-
f'items={len(_i5)} bad={_b5} keys={sorted(_one)}')
|
|
247
|
-
ok('导入解析:邮箱 Refresh Token 不写成号池 refresh_token',
|
|
248
|
-
'refresh_token' not in _one and _one.get('mail_refresh_token') == _mail_rt)
|
|
249
|
-
ok('导入解析:发料说明行不算坏行', _b5 == 0, f'bad={_b5}')
|
|
250
|
-
_t3, _i3, _ = pool_admin.parse_import('c@d.com----pw2----' + _tk2)
|
|
251
|
-
ok('导入解析:三段式发料也带邮箱密码',
|
|
252
|
-
_t3 == [] and len(_i3) == 1 and _i3[0].get('password') == 'pw2'
|
|
253
|
-
and _i3[0].get('access_token') == _tk2)
|
|
254
|
-
_guarded = pool_admin.parse_import(
|
|
255
|
-
json.dumps([{'access_token': _tk, 'refresh_token': _mail_rt}]))[1][0]
|
|
256
|
-
ok('导入解析:误填到 refresh_token 的邮箱料自动归档',
|
|
257
|
-
'refresh_token' not in _guarded and _guarded.get('mail_refresh_token') == _mail_rt)
|
|
258
|
-
# 真实发料里出现过 JWT 签名含连续横线的行,按分隔符硬切会把资料劈丢
|
|
259
|
-
_tk_dash = 'eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJ6In0.abc----def12345'
|
|
260
|
-
_dash_row = 'e@f.com----pw3----' + _cid + '----' + _mail_rt + '----' + _tk_dash
|
|
261
|
-
_td, _idd, _bd = pool_admin.parse_import(_dash_row)
|
|
262
|
-
ok('导入解析:JWT 内含连续横线也不丢资料',
|
|
263
|
-
_td == [] and len(_idd) == 1 and _bd == 0
|
|
264
|
-
and _idd[0].get('access_token') == _tk_dash
|
|
265
|
-
and _idd[0].get('password') == 'pw3'
|
|
266
|
-
and _idd[0].get('mail_refresh_token') == _mail_rt,
|
|
267
|
-
f'items={len(_idd)} bad={_bd}')
|
|
268
|
-
_mt, _mi, _mb = pool_admin.parse_import(_five + '\n' + _tk2)
|
|
269
|
-
ok('导入解析:五段式与裸 token 混贴不重复',
|
|
270
|
-
_mt == [_tk2] and len(_mi) == 1 and _mb == 0,
|
|
271
|
-
f'raw={len(_mt)} obj={len(_mi)} bad={_mb}')
|
|
272
|
-
except Exception as e:
|
|
273
|
-
ok('导入解析可用', False, str(e)[:120])
|
|
274
|
-
|
|
275
|
-
# 人工剔除名单:只存摘要、重导自动跳过、启用解封
|
|
276
|
-
try:
|
|
277
|
-
import tempfile
|
|
278
|
-
from pathlib import Path as _Path
|
|
279
|
-
with tempfile.TemporaryDirectory() as _td:
|
|
280
|
-
_bid = pool_admin.blacklist_id(_tk)
|
|
281
|
-
ok('剔除名单:只存摘要不存明文',
|
|
282
|
-
_bid != _tk and len(_bid) == 24 and pool_admin.load_blacklist(_td) == set())
|
|
283
|
-
_n = pool_admin.blacklist_add(_td, [_tk, _tk2, _tk])
|
|
284
|
-
ok('剔除名单:停用 / 删除后入名单且去重',
|
|
285
|
-
_n == 2 and len(pool_admin.load_blacklist(_td)) == 2, f'added={_n}')
|
|
286
|
-
_ft, _fi, _fb = pool_admin.filter_blacklisted(
|
|
287
|
-
[_tk, _tk2], [{'access_token': _tk}], pool_admin.load_blacklist(_td))
|
|
288
|
-
ok('剔除名单:重导时自动跳过',
|
|
289
|
-
_ft == [] and _fi == [] and _fb == 3, f'skipped={_fb}')
|
|
290
|
-
_raw = (_Path(_td) / pool_admin.BLACKLIST_FILE).read_text(encoding='utf-8')
|
|
291
|
-
ok('剔除名单:磁盘上无明文 token', _tk not in _raw and _tk2 not in _raw)
|
|
292
|
-
_n2 = pool_admin.blacklist_remove(_td, [_tk])
|
|
293
|
-
ok('剔除名单:启用账号即解封',
|
|
294
|
-
_n2 == 1 and len(pool_admin.load_blacklist(_td)) == 1, f'removed={_n2}')
|
|
295
|
-
except Exception as e:
|
|
296
|
-
ok('剔除名单可用', False, str(e)[:120])
|
|
297
|
-
|
|
298
|
-
# 额度为 0 的号不再算「可用」
|
|
299
|
-
try:
|
|
300
|
-
_rows = [{'status': '正常', 'quota': 0, 'cls': 'ok'},
|
|
301
|
-
{'status': '正常', 'quota': 25, 'cls': 'ok'}]
|
|
302
|
-
_o = {'active': 2, 'limited': 0, 'total': 2, 'quota': 25,
|
|
303
|
-
'message': '2 个账号 · 可用 2 · 配额 25'}
|
|
304
|
-
_z = pool_admin.normalize_zero_quota(_rows, _o)
|
|
305
|
-
ok('面板:额度为 0 的号显示为限流且不计入可用',
|
|
306
|
-
_z == 1 and _rows[0]['status'] == '限流' and _rows[1]['status'] == '正常'
|
|
307
|
-
and _o['active'] == 1 and _o['limited'] == 1 and '可用 1' in _o['message'])
|
|
308
|
-
except Exception as e:
|
|
309
|
-
ok('面板额度口径可用', False, str(e)[:120])
|
|
310
|
-
|
|
311
|
-
# 成品图路由:不裁切的比例直连原图,裁切的给裁好的 PNG
|
|
312
|
-
try:
|
|
313
|
-
_items = (client.get('/api/history').json() or {}).get('items') or []
|
|
314
|
-
if _items:
|
|
315
|
-
_jid = _items[0]['id']
|
|
316
|
-
r = client.get(f'/api/history/{_jid}/final')
|
|
317
|
-
ok('GET /api/history/{id}/final', r.status_code == 200 and
|
|
318
|
-
r.headers.get('content-type', '').startswith('image/'),
|
|
319
|
-
f'HTTP {r.status_code} / {len(r.content)}B')
|
|
320
|
-
else:
|
|
321
|
-
ok('历史里有可用记录', False, 'items 为空,跳过 final 路由')
|
|
322
|
-
except Exception as e:
|
|
323
|
-
ok('GET /api/history/{id}/final', False, str(e)[:120])
|
|
324
|
-
|
|
325
|
-
# ---- 3) 纯本地拼装(不消耗额度)----print('\n=== 3. 本地拼装 ===')
|
|
326
|
-
compose_body = {
|
|
327
|
-
'style_id': app.CATALOG['styles'][0]['id'],
|
|
328
|
-
'layout_id': app.CATALOG['layouts'][0]['id'],
|
|
329
|
-
'color_id': app.CATALOG['colors'][0]['id'],
|
|
330
|
-
'ratio': '3:4', 'mood': '治愈、松弛、幽默', 'fusion': True,
|
|
331
|
-
'topic': '如何停止内耗', 'image_mode': 'graphic-text',
|
|
332
|
-
'items': [{'title': '先把事情写下来', 'body': '写下来,脑子就空了', 'scene': '书桌前'}],
|
|
333
|
-
}
|
|
334
|
-
try:
|
|
335
|
-
r = client.post('/api/compose', json=compose_body)
|
|
336
|
-
d = r.json()
|
|
337
|
-
ok('POST /api/compose', r.status_code == 200 and bool(d), f'HTTP {r.status_code}')
|
|
338
|
-
print(' 返回键:', list(d.keys())[:8])
|
|
339
|
-
except Exception as e:
|
|
340
|
-
ok('POST /api/compose', False, str(e)[:120])
|
|
341
|
-
try:
|
|
342
|
-
r = client.post('/api/preview', json={'mode': 'image', 'topic': '测试',
|
|
343
|
-
'prompt': '一只橘猫在旧书店门口晒太阳'})
|
|
344
|
-
ok('POST /api/preview', r.status_code == 200 and bool(r.json().get('prompt')))
|
|
345
|
-
except Exception as e:
|
|
346
|
-
ok('POST /api/preview', False, str(e)[:120])
|
|
347
|
-
|
|
348
|
-
# ---- 4) 端到端真实出图 ----
|
|
349
|
-
if not QUICK:
|
|
350
|
-
print('\n=== 4. 端到端出图(会消耗 1 次号池配额)===')
|
|
351
|
-
gen_body = {
|
|
352
|
-
'mode': 'image', 'topic': '冒烟测试', 'title': '冒烟测试',
|
|
353
|
-
'body': '一只橘猫蜷在旧书店门槛上打盹', 'ratio': '3:4',
|
|
354
|
-
'image_mode': 'graphic-text', 'seq': 0, 'group': '__smoke__',
|
|
355
|
-
'prompt': ('请生成一张插画:清晨的旧书店门口,木质门框挂着一串黄铜风铃,'
|
|
356
|
-
'一只橘猫蜷在门槛上打盹,阳光斜射进来,空气里浮着细小尘埃。'
|
|
357
|
-
'手绘水彩质感,柔和暖色调,画面干净,不要文字,不要水印,竖版构图。'),
|
|
358
|
-
}
|
|
359
|
-
try:
|
|
360
|
-
r = client.post('/api/generate', json=gen_body, timeout=30)
|
|
361
|
-
jid = r.json().get('job_id') if r.status_code == 200 else ''
|
|
362
|
-
ok('POST /api/generate', bool(jid), f'HTTP {r.status_code} job={jid}')
|
|
363
|
-
except Exception as e:
|
|
364
|
-
jid = ''
|
|
365
|
-
ok('POST /api/generate', False, str(e)[:120])
|
|
366
|
-
if jid:
|
|
367
|
-
deadline = time.time() + 300
|
|
368
|
-
status, job = '', {}
|
|
369
|
-
while time.time() < deadline:
|
|
370
|
-
time.sleep(3)
|
|
371
|
-
try:
|
|
372
|
-
job = client.get('/api/jobs/' + jid, timeout=20).json()
|
|
373
|
-
status = str(job.get('status') or '')
|
|
374
|
-
if status in ('done', 'error'):
|
|
375
|
-
break
|
|
376
|
-
except Exception:
|
|
377
|
-
pass
|
|
378
|
-
ok('任务走到终态', status in ('done', 'error'), f'status={status}')
|
|
379
|
-
if status == 'done':
|
|
380
|
-
img = (job.get('result') or {}).get('image')
|
|
381
|
-
f = ROOT / 'images' / str(img)
|
|
382
|
-
ok('原图已落盘', bool(img) and f.is_file(),
|
|
383
|
-
f'{img} / {f.stat().st_size if f.is_file() else 0} B')
|
|
384
|
-
if f.is_file():
|
|
385
|
-
ok('原图是 PNG', f.read_bytes()[:8] == b'\x89PNG\r\n\x1a\n')
|
|
386
|
-
time.sleep(2)
|
|
387
|
-
ok('card 压缩图已生成', (ROOT / 'thumbs' / f'{jid}_c.webp').is_file())
|
|
388
|
-
ok('preview 压缩图已生成', (ROOT / 'thumbs' / f'{jid}_p.webp').is_file())
|
|
389
|
-
try:
|
|
390
|
-
dl = client.get('/api/history/' + jid + '/download')
|
|
391
|
-
ok('下载返回原图', dl.status_code == 200 and len(dl.content) == f.stat().st_size,
|
|
392
|
-
f'{len(dl.content)} B')
|
|
393
|
-
except Exception as e:
|
|
394
|
-
ok('下载返回原图', False, str(e)[:100])
|
|
395
|
-
ok('历史记录已写入', any(x.get('id') == jid for x in
|
|
396
|
-
client.get('/api/history').json().get('items', [])))
|
|
397
|
-
else:
|
|
398
|
-
print(' 错误信息:', str(job.get('error') or '')[:250])
|
|
399
|
-
|
|
400
|
-
client.close()
|
|
401
|
-
print(f'\n结果:{passed} 通过 / {failed} 失败')
|
|
402
|
-
sys.exit(0 if failed == 0 else 1)
|
|
1
|
+
"""本地生图工作台冒烟:符号断言 + 全量读接口 + 一次真实端到端出图。
|
|
2
|
+
|
|
3
|
+
用法:
|
|
4
|
+
.venv/Scripts/python.exe smoke_check.py # 全量(含真实出图,约 1 分钟)
|
|
5
|
+
.venv/Scripts/python.exe smoke_check.py --quick # 只跑符号与读接口
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json, pathlib, sys, time
|
|
10
|
+
import httpx
|
|
11
|
+
|
|
12
|
+
BASE = 'http://127.0.0.1:8092'
|
|
13
|
+
QUICK = '--quick' in sys.argv
|
|
14
|
+
ROOT = pathlib.Path(__file__).resolve().parent
|
|
15
|
+
passed = failed = 0
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def ok(name, cond, detail=''):
|
|
19
|
+
global passed, failed
|
|
20
|
+
if cond:
|
|
21
|
+
passed += 1
|
|
22
|
+
print('PASS ' + name + ((' ' + str(detail)) if detail else ''))
|
|
23
|
+
else:
|
|
24
|
+
failed += 1
|
|
25
|
+
print('FAIL ' + name + ((' ' + str(detail)) if detail else ''))
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# ---- 1) 离线符号断言:抓「语法合法但符号丢失」 ----
|
|
29
|
+
print('=== 1. 关键符号 ===')
|
|
30
|
+
sys.path.insert(0, str(ROOT))
|
|
31
|
+
import app, pool, pool_admin, pool_service, composer, history, imaging, catalog, access, drafts
|
|
32
|
+
for mod, names in [
|
|
33
|
+
(app, ['app', 'POOL', 'HISTORY', 'DRAFTS', 'CATALOG', 'api_generate',
|
|
34
|
+
'api_compose', '_finish_with_replace']),
|
|
35
|
+
(pool, ['PoolManager', 'load_config', 'pool_api_key', 'raise_for_response',
|
|
36
|
+
'size_for_ratio', 'pool_home', 'pool_admin_key']),
|
|
37
|
+
# 号池面板:导入账号 / 删号 / 签发 Key 全靠这几个入口,缺一个面板就是死的
|
|
38
|
+
(pool_admin, ['panel', 'import_accounts', 'delete_accounts', 'set_status',
|
|
39
|
+
'ensure_key', 'parse_tokens', 'parse_import', '_parse_dash_row',
|
|
40
|
+
'IMPORT_FIELDS', 'target']),
|
|
41
|
+
# 工作台自带号池:进程生命周期全在这里,不再有独立号池插件
|
|
42
|
+
(pool_service, ['status', 'start', 'stop', 'restart', 'bootstrap',
|
|
43
|
+
'ensure_admin_key', 'set_auto_start', 'start_watchdog', 'view']),
|
|
44
|
+
(composer, ['compose']),
|
|
45
|
+
(history, ['History']),
|
|
46
|
+
(imaging, ['ensure', 'ensure_all', 'variant_path']),
|
|
47
|
+
(catalog, ['build']),
|
|
48
|
+
(drafts, ['Drafts']),
|
|
49
|
+
]:
|
|
50
|
+
for n in names:
|
|
51
|
+
ok(f'{mod.__name__}.{n}', hasattr(mod, n))
|
|
52
|
+
ok('PoolManager._execute', callable(getattr(pool.PoolManager, '_execute', None)))
|
|
53
|
+
ok('PoolManager.snapshot', callable(getattr(pool.PoolManager, 'snapshot', None)))
|
|
54
|
+
ok('PoolManager.check_all', callable(getattr(pool.PoolManager, 'check_all', None)))
|
|
55
|
+
ok('PoolManager.cancel_all', callable(getattr(pool.PoolManager, 'cancel_all', None)))
|
|
56
|
+
ok('app.api_generate_stop', hasattr(app, 'api_generate_stop'))
|
|
57
|
+
ok('POST /api/generate/stop 已注册',
|
|
58
|
+
any(getattr(r, 'path', '') == '/api/generate/stop'
|
|
59
|
+
and 'POST' in getattr(r, 'methods', set()) for r in app.app.routes))
|
|
60
|
+
_src_pool = (ROOT / 'pool.py').read_text(encoding='utf-8')
|
|
61
|
+
_src_html = (ROOT / 'static' / 'index.html').read_text(encoding='utf-8')
|
|
62
|
+
ok('已停止状态不落盘', 'not in ("done", "error", "cancelled")' in _src_pool)
|
|
63
|
+
ok('取消后不再换号重试', 'if job.get("status") == "cancelled":' in _src_pool)
|
|
64
|
+
ok('前端两个停止按钮齐全', 'id="stopGen"' in _src_html and 'id="monStop"' in _src_html)
|
|
65
|
+
ok('前端停止链路完整',
|
|
66
|
+
all(x in _src_html for x in ('async function stopAllJobs', '/api/generate/stop',
|
|
67
|
+
'clearBatch();', "d.status === 'cancelled'")))
|
|
68
|
+
ok('access 无 fcntl 依赖', not hasattr(access, 'fcntl'))
|
|
69
|
+
ok('工作台目录可用', (ROOT / 'app.py').is_file())
|
|
70
|
+
ok('AUTH 为空(本地全放行)', app.AUTH is None)
|
|
71
|
+
ok('catalog 三类齐全', all(len(app.CATALOG[k]) > 0 for k in ('styles', 'layouts', 'colors')),
|
|
72
|
+
{k: len(v) for k, v in app.CATALOG.items()})
|
|
73
|
+
ok('配置可缺省(整包首次运行)', isinstance(pool.load_config(), dict))
|
|
74
|
+
cfg = pool.load_config()
|
|
75
|
+
# 接口地址允许换(38651 生图号池 / 38650 写作号池 / 任意 OpenAI 兼容地址),
|
|
76
|
+
# 这里只校验「配置能读出来且是合法 URL」,端口写死会让冒烟在换接口后假失败
|
|
77
|
+
ok('接口地址可读', bool(cfg['pool_base_url']) and cfg['pool_base_url'].startswith('http'),
|
|
78
|
+
cfg['pool_base_url'])
|
|
79
|
+
ok('号池用户 Key 可读', bool(pool.pool_api_key(cfg)))
|
|
80
|
+
ok('出图模型 = gpt-image-2', cfg['model_image'] == 'gpt-image-2')
|
|
81
|
+
|
|
82
|
+
# 公众号首图 2.35:1:上游没有这个尺寸,靠 3:2 出图 + 本地居中裁切;
|
|
83
|
+
# 提示词里必须先说清「上下留可裁背景」,否则裁完砍标题
|
|
84
|
+
ok('2.35:1 按 1536x1024 出图', pool.size_for_ratio('2.35:1') == '1536x1024',
|
|
85
|
+
pool.size_for_ratio('2.35:1'))
|
|
86
|
+
ok('认得出要裁切的比例', imaging.needs_crop('2.35:1') and not imaging.needs_crop('3:4'))
|
|
87
|
+
ok('提示词带横向安全区约束',
|
|
88
|
+
bool(composer.WIDE_CROP_HINT) and composer.WIDE_CROP_HINT[:6] in
|
|
89
|
+
composer.build_prompt({'title': '选题', 'body': ''}, {}, '2.35:1', [],
|
|
90
|
+
False, 'normal', '', False, 'graphic-text', ''))
|
|
91
|
+
try:
|
|
92
|
+
from PIL import Image as _Img
|
|
93
|
+
_src = _Img.new('RGB', (1536, 1024), (250, 245, 255))
|
|
94
|
+
_cut = imaging.crop_to(_src, '2.35:1')
|
|
95
|
+
ok('居中裁切比例正确', _cut.size[0] == 1536 and abs(_cut.size[0] / _cut.size[1] - 2.35) < 0.01,
|
|
96
|
+
_cut.size)
|
|
97
|
+
ok('不用裁的比例原样返回', imaging.crop_to(_src, '3:4').size == (1536, 1024))
|
|
98
|
+
except Exception as e:
|
|
99
|
+
ok('裁切函数可用', False, str(e)[:100])
|
|
100
|
+
|
|
101
|
+
# ---- 2) 全量读接口 ----
|
|
102
|
+
print('\n=== 2. 读接口 ===')
|
|
103
|
+
client = httpx.Client(base_url=BASE, trust_env=False, timeout=30)
|
|
104
|
+
routes = [
|
|
105
|
+
('/', 200), ('/health', 200), ('/api/catalog', 200), ('/api/pool', 200),
|
|
106
|
+
('/api/monitor', 200), ('/api/recent', 200), ('/api/history', 200),
|
|
107
|
+
('/api/tasks', 200), ('/api/drafts', 200), ('/api/pool/check', 200),
|
|
108
|
+
('/api/pool/fails', 200), ('/api/pool/panel', 200),
|
|
109
|
+
('/api/pool/service', 200),
|
|
110
|
+
('/api/pool/proxy', 200),
|
|
111
|
+
('/api/config', 200),
|
|
112
|
+
('/img/individual/FA/FA-001.webp', 200),
|
|
113
|
+
]
|
|
114
|
+
for path, expect in routes:
|
|
115
|
+
try:
|
|
116
|
+
r = client.get(path)
|
|
117
|
+
ok(f'GET {path}', r.status_code == expect, f'HTTP {r.status_code} / {len(r.content)}B')
|
|
118
|
+
except Exception as e:
|
|
119
|
+
ok(f'GET {path}', False, type(e).__name__ + ': ' + str(e)[:100])
|
|
120
|
+
|
|
121
|
+
try:
|
|
122
|
+
p = client.get('/api/pool').json()
|
|
123
|
+
ok('号池 snapshot 有 pool 统计', isinstance(p.get('pool'), dict),
|
|
124
|
+
f"{p.get('pool', {}).get('total')} 号 / 配额 {p.get('pool', {}).get('quota_total')}")
|
|
125
|
+
ok('号池在线', bool(p.get('pool', {}).get('online')))
|
|
126
|
+
# 失败换号:snapshot 要能给出「今天谁失败过几次」
|
|
127
|
+
ok('号池 snapshot 有今日失败统计', isinstance(p.get('fails_today'), int),
|
|
128
|
+
f"今日失败 {p.get('fails_today')} 次 / 涉及 {len(p.get('fail_accounts') or [])} 个号")
|
|
129
|
+
f = client.get('/api/pool/fails').json()
|
|
130
|
+
ok('GET /api/pool/fails 有结构', isinstance(f.get('items'), list) and 'total_today' in f,
|
|
131
|
+
f"{f.get('total_today')} 次")
|
|
132
|
+
except Exception as e:
|
|
133
|
+
ok('号池 snapshot 可解析', False, str(e)[:120])
|
|
134
|
+
|
|
135
|
+
# 出口代理 GET 应回传 proxy_auto_url 字段(零配置自动探测标记)
|
|
136
|
+
try:
|
|
137
|
+
pr = client.get('/api/pool/proxy').json()
|
|
138
|
+
ok('GET /api/pool/proxy 回传 proxy_auto_url 字段',
|
|
139
|
+
'proxy_auto_url' in pr, f"auto={pr.get('proxy_auto_url') or '—'}")
|
|
140
|
+
except Exception as e:
|
|
141
|
+
ok('GET /api/pool/proxy 可解析', False, str(e)[:120])
|
|
142
|
+
|
|
143
|
+
# 出口代理那一栏按本机实时探测结果反显,不写死地址:换机器要能跟着变
|
|
144
|
+
try:
|
|
145
|
+
eg = client.get('/api/pool/egress').json()
|
|
146
|
+
ok('GET /api/pool/egress 给出本机探测结果',
|
|
147
|
+
eg.get('ok') is True and 'url' in eg and isinstance(eg.get('ports'), list),
|
|
148
|
+
f"{eg.get('url') or '未探测到'}(来源 {eg.get('source')})")
|
|
149
|
+
ok('探测结果带候选端口列表', isinstance(eg.get('ports'), list),
|
|
150
|
+
f"{len(eg.get('ports') or [])} 个端口在监听")
|
|
151
|
+
except Exception as e:
|
|
152
|
+
ok('GET /api/pool/egress 可解析', False, str(e)[:120])
|
|
153
|
+
|
|
154
|
+
# 「接口与号池」面板:账号导入 / 删号全靠它,字段缺一个前端就是空白
|
|
155
|
+
try:
|
|
156
|
+
pn = client.get('/api/pool/panel').json()
|
|
157
|
+
ok('panel 有 local / online 标志',
|
|
158
|
+
isinstance(pn.get('local'), bool) and isinstance(pn.get('online'), bool),
|
|
159
|
+
f"local={pn.get('local')} online={pn.get('online')}")
|
|
160
|
+
ok('panel 给出对外接口地址', str(pn.get('base_url', '')).startswith('http'),
|
|
161
|
+
pn.get('base_url'))
|
|
162
|
+
sv = pn.get('service') or {}
|
|
163
|
+
ok('panel 带内置号池服务快照',
|
|
164
|
+
isinstance(sv.get('manageable'), bool) and isinstance(sv.get('runtime_ready'), bool)
|
|
165
|
+
and 'auto_start' in sv, f"port={sv.get('port')} runtime={sv.get('runtime_ready')}")
|
|
166
|
+
ok('内置号池端口可管理(不是写作台 38650)', sv.get('manageable') is True,
|
|
167
|
+
sv.get('message'))
|
|
168
|
+
ok('号池运行时就绪(uv + source + venv)', sv.get('runtime_ready') is True,
|
|
169
|
+
sv.get('runtime_reason'))
|
|
170
|
+
if pn.get('local') and pn.get('online'):
|
|
171
|
+
accs = pn.get('accounts') or []
|
|
172
|
+
ok('panel 账号带掩码邮箱与状态',
|
|
173
|
+
bool(accs) and all(a.get('email') and a.get('status') for a in accs),
|
|
174
|
+
f"{accs[0]['email'] if accs else '—'} / 共 {pn.get('total')} 个")
|
|
175
|
+
# 账号 id 必须是 12 位摘要:access_token 不能出现在前端
|
|
176
|
+
ok('panel 账号 id 是摘要不是 token',
|
|
177
|
+
all(len(str(a.get('id') or '')) == 12 for a in accs),
|
|
178
|
+
accs[0].get('id') if accs else '')
|
|
179
|
+
ok('panel 能拿到用户 Key 明文(供复制)', bool(pn.get('api_key')),
|
|
180
|
+
pn.get('api_key_masked'))
|
|
181
|
+
else:
|
|
182
|
+
print(' 当前接口不是本机号池,跳过账号字段断言')
|
|
183
|
+
except Exception as e:
|
|
184
|
+
ok('GET /api/pool/panel 可用', False, str(e)[:120])
|
|
185
|
+
|
|
186
|
+
# 内置号池服务:状态可读、自动拉起开关可写(只写回原值,不改变用户设置)
|
|
187
|
+
try:
|
|
188
|
+
svc = client.get('/api/pool/service').json()
|
|
189
|
+
ok('服务状态字段齐全',
|
|
190
|
+
all(k in svc for k in ('port', 'home', 'running', 'online', 'auto_start',
|
|
191
|
+
'runtime_ready', 'manageable', 'message')),
|
|
192
|
+
f"port={svc.get('port')} online={svc.get('online')} total={svc.get('total')}")
|
|
193
|
+
r = client.post('/api/pool/service/auto', json={'enabled': bool(svc.get('auto_start'))})
|
|
194
|
+
d = r.json()
|
|
195
|
+
ok('POST /api/pool/service/auto 可写', r.status_code == 200 and d.get('ok') is True
|
|
196
|
+
and d.get('auto_start') == bool(svc.get('auto_start')), f'HTTP {r.status_code}')
|
|
197
|
+
except Exception as e:
|
|
198
|
+
ok('GET /api/pool/service 可用', False, str(e)[:120])
|
|
199
|
+
|
|
200
|
+
# 导入解析:三种常见粘贴格式都要认,认不出时不能瞎编 token
|
|
201
|
+
try:
|
|
202
|
+
_tk = 'eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJ4In0.abc12345'
|
|
203
|
+
_a, _ = pool_admin.parse_tokens(_tk)
|
|
204
|
+
_b, _ = pool_admin.parse_tokens(json.dumps([{'access_token': _tk}]))
|
|
205
|
+
_c, _bad = pool_admin.parse_tokens('a@b.com----pw----' + _tk + '\n乱七八糟')
|
|
206
|
+
ok('导入解析:裸 token / JSON / 老导出格式',
|
|
207
|
+
_a == [_tk] and _b == [_tk] and _c == [_tk] and _bad == 1,
|
|
208
|
+
f'{len(_a)}/{len(_b)}/{len(_c)} bad={_bad}')
|
|
209
|
+
ok('导入解析:没有 token 时不编造', pool_admin.parse_tokens('这不是 token')[0] == [])
|
|
210
|
+
_row = {'access_token': _tk, 'email': 'a@x.com', 'password': 'pw1',
|
|
211
|
+
'source_type': 'web', 'quota': 25, 'status': '正常'}
|
|
212
|
+
_t, _it, _b = pool_admin.parse_import(json.dumps([_row]))
|
|
213
|
+
ok('JSON 导入保留密码与来源类型',
|
|
214
|
+
_t == [] and len(_it) == 1 and _it[0].get('password') == 'pw1'
|
|
215
|
+
and _it[0].get('source_type') == 'web' and _b == 0,
|
|
216
|
+
f'{len(_it)} 条 / {sorted(_it[0]) if _it else []}')
|
|
217
|
+
ok('JSON 导入不灌运行态数据',
|
|
218
|
+
'quota' not in (_it[0] if _it else {}) and 'status' not in (_it[0] if _it else {}))
|
|
219
|
+
_tk2 = 'eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJ5In0.bbb67890'
|
|
220
|
+
_t2, _it2, _ = pool_admin.parse_import(json.dumps({'items': [_row, _tk2]}))
|
|
221
|
+
ok('导入混合格式:对象走 accounts、纯串走 tokens',
|
|
222
|
+
len(_it2) == 1 and _t2 == [_tk2], f'obj={len(_it2)} raw={len(_t2)}')
|
|
223
|
+
ok('导入解析:accessToken 别名归一',
|
|
224
|
+
pool_admin.parse_import(json.dumps({'accessToken': _tk, 'password': 'pw'}))[1][0]['access_token'] == _tk)
|
|
225
|
+
ok('导入解析:带 BOM 的 txt 文件',
|
|
226
|
+
pool_admin.parse_import('\ufeff' + _tk)[0] == [_tk])
|
|
227
|
+
_csv = 'email,password,access_token\r\na@b.com,pw1,' + _tk + '\r\nc@d.com,pw2,' + _tk2
|
|
228
|
+
_ct, _, _cb = pool_admin.parse_import(_csv)
|
|
229
|
+
ok('导入解析:CSV 表头跳过、CRLF 兼容',
|
|
230
|
+
_ct == [_tk, _tk2] and _cb == 0, f'{len(_ct)} 条 / bad={_cb}')
|
|
231
|
+
# 五段式「首登料」:邮箱----密码----邮箱Client ID----邮箱Refresh Token----AT
|
|
232
|
+
# 第 4 段是微软 MSA 邮箱令牌,绝不能被当成号池的 refresh_token 提交。
|
|
233
|
+
_mail_rt = ('M.C535_SN1.0.U.MsaArtifacts.-CgyvaETosvszterVMMhyTAqQdRDx0FMPDuNxRjzP'
|
|
234
|
+
'!mpzzeyPlYSeqOkMN9W0m9aWwvjO5DZtLl6jb3dKlIP7PHuCRV7$')
|
|
235
|
+
_cid = '9e5f94bc-e8a4-4e73-b8be-63364c364c29d753'
|
|
236
|
+
_five = 'a@b.com----pw1----' + _cid + '----' + _mail_rt + '----' + _tk
|
|
237
|
+
_note = '不会用的别买,发货格式:邮箱----邮箱密码----邮箱Client ID----邮箱Refresh Token----AT'
|
|
238
|
+
_t5, _i5, _b5 = pool_admin.parse_import(_five + '\n' + _note)
|
|
239
|
+
_one = _i5[0] if _i5 else {}
|
|
240
|
+
ok('导入解析:五段式发料保留完整资料',
|
|
241
|
+
_t5 == [] and len(_i5) == 1 and _b5 == 0
|
|
242
|
+
and _one.get('email') == 'a@b.com' and _one.get('password') == 'pw1'
|
|
243
|
+
and _one.get('mail_client_id') == _cid
|
|
244
|
+
and _one.get('mail_refresh_token') == _mail_rt
|
|
245
|
+
and _one.get('access_token') == _tk and _one.get('source_type') == 'web',
|
|
246
|
+
f'items={len(_i5)} bad={_b5} keys={sorted(_one)}')
|
|
247
|
+
ok('导入解析:邮箱 Refresh Token 不写成号池 refresh_token',
|
|
248
|
+
'refresh_token' not in _one and _one.get('mail_refresh_token') == _mail_rt)
|
|
249
|
+
ok('导入解析:发料说明行不算坏行', _b5 == 0, f'bad={_b5}')
|
|
250
|
+
_t3, _i3, _ = pool_admin.parse_import('c@d.com----pw2----' + _tk2)
|
|
251
|
+
ok('导入解析:三段式发料也带邮箱密码',
|
|
252
|
+
_t3 == [] and len(_i3) == 1 and _i3[0].get('password') == 'pw2'
|
|
253
|
+
and _i3[0].get('access_token') == _tk2)
|
|
254
|
+
_guarded = pool_admin.parse_import(
|
|
255
|
+
json.dumps([{'access_token': _tk, 'refresh_token': _mail_rt}]))[1][0]
|
|
256
|
+
ok('导入解析:误填到 refresh_token 的邮箱料自动归档',
|
|
257
|
+
'refresh_token' not in _guarded and _guarded.get('mail_refresh_token') == _mail_rt)
|
|
258
|
+
# 真实发料里出现过 JWT 签名含连续横线的行,按分隔符硬切会把资料劈丢
|
|
259
|
+
_tk_dash = 'eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJ6In0.abc----def12345'
|
|
260
|
+
_dash_row = 'e@f.com----pw3----' + _cid + '----' + _mail_rt + '----' + _tk_dash
|
|
261
|
+
_td, _idd, _bd = pool_admin.parse_import(_dash_row)
|
|
262
|
+
ok('导入解析:JWT 内含连续横线也不丢资料',
|
|
263
|
+
_td == [] and len(_idd) == 1 and _bd == 0
|
|
264
|
+
and _idd[0].get('access_token') == _tk_dash
|
|
265
|
+
and _idd[0].get('password') == 'pw3'
|
|
266
|
+
and _idd[0].get('mail_refresh_token') == _mail_rt,
|
|
267
|
+
f'items={len(_idd)} bad={_bd}')
|
|
268
|
+
_mt, _mi, _mb = pool_admin.parse_import(_five + '\n' + _tk2)
|
|
269
|
+
ok('导入解析:五段式与裸 token 混贴不重复',
|
|
270
|
+
_mt == [_tk2] and len(_mi) == 1 and _mb == 0,
|
|
271
|
+
f'raw={len(_mt)} obj={len(_mi)} bad={_mb}')
|
|
272
|
+
except Exception as e:
|
|
273
|
+
ok('导入解析可用', False, str(e)[:120])
|
|
274
|
+
|
|
275
|
+
# 人工剔除名单:只存摘要、重导自动跳过、启用解封
|
|
276
|
+
try:
|
|
277
|
+
import tempfile
|
|
278
|
+
from pathlib import Path as _Path
|
|
279
|
+
with tempfile.TemporaryDirectory() as _td:
|
|
280
|
+
_bid = pool_admin.blacklist_id(_tk)
|
|
281
|
+
ok('剔除名单:只存摘要不存明文',
|
|
282
|
+
_bid != _tk and len(_bid) == 24 and pool_admin.load_blacklist(_td) == set())
|
|
283
|
+
_n = pool_admin.blacklist_add(_td, [_tk, _tk2, _tk])
|
|
284
|
+
ok('剔除名单:停用 / 删除后入名单且去重',
|
|
285
|
+
_n == 2 and len(pool_admin.load_blacklist(_td)) == 2, f'added={_n}')
|
|
286
|
+
_ft, _fi, _fb = pool_admin.filter_blacklisted(
|
|
287
|
+
[_tk, _tk2], [{'access_token': _tk}], pool_admin.load_blacklist(_td))
|
|
288
|
+
ok('剔除名单:重导时自动跳过',
|
|
289
|
+
_ft == [] and _fi == [] and _fb == 3, f'skipped={_fb}')
|
|
290
|
+
_raw = (_Path(_td) / pool_admin.BLACKLIST_FILE).read_text(encoding='utf-8')
|
|
291
|
+
ok('剔除名单:磁盘上无明文 token', _tk not in _raw and _tk2 not in _raw)
|
|
292
|
+
_n2 = pool_admin.blacklist_remove(_td, [_tk])
|
|
293
|
+
ok('剔除名单:启用账号即解封',
|
|
294
|
+
_n2 == 1 and len(pool_admin.load_blacklist(_td)) == 1, f'removed={_n2}')
|
|
295
|
+
except Exception as e:
|
|
296
|
+
ok('剔除名单可用', False, str(e)[:120])
|
|
297
|
+
|
|
298
|
+
# 额度为 0 的号不再算「可用」
|
|
299
|
+
try:
|
|
300
|
+
_rows = [{'status': '正常', 'quota': 0, 'cls': 'ok'},
|
|
301
|
+
{'status': '正常', 'quota': 25, 'cls': 'ok'}]
|
|
302
|
+
_o = {'active': 2, 'limited': 0, 'total': 2, 'quota': 25,
|
|
303
|
+
'message': '2 个账号 · 可用 2 · 配额 25'}
|
|
304
|
+
_z = pool_admin.normalize_zero_quota(_rows, _o)
|
|
305
|
+
ok('面板:额度为 0 的号显示为限流且不计入可用',
|
|
306
|
+
_z == 1 and _rows[0]['status'] == '限流' and _rows[1]['status'] == '正常'
|
|
307
|
+
and _o['active'] == 1 and _o['limited'] == 1 and '可用 1' in _o['message'])
|
|
308
|
+
except Exception as e:
|
|
309
|
+
ok('面板额度口径可用', False, str(e)[:120])
|
|
310
|
+
|
|
311
|
+
# 成品图路由:不裁切的比例直连原图,裁切的给裁好的 PNG
|
|
312
|
+
try:
|
|
313
|
+
_items = (client.get('/api/history').json() or {}).get('items') or []
|
|
314
|
+
if _items:
|
|
315
|
+
_jid = _items[0]['id']
|
|
316
|
+
r = client.get(f'/api/history/{_jid}/final')
|
|
317
|
+
ok('GET /api/history/{id}/final', r.status_code == 200 and
|
|
318
|
+
r.headers.get('content-type', '').startswith('image/'),
|
|
319
|
+
f'HTTP {r.status_code} / {len(r.content)}B')
|
|
320
|
+
else:
|
|
321
|
+
ok('历史里有可用记录', False, 'items 为空,跳过 final 路由')
|
|
322
|
+
except Exception as e:
|
|
323
|
+
ok('GET /api/history/{id}/final', False, str(e)[:120])
|
|
324
|
+
|
|
325
|
+
# ---- 3) 纯本地拼装(不消耗额度)----print('\n=== 3. 本地拼装 ===')
|
|
326
|
+
compose_body = {
|
|
327
|
+
'style_id': app.CATALOG['styles'][0]['id'],
|
|
328
|
+
'layout_id': app.CATALOG['layouts'][0]['id'],
|
|
329
|
+
'color_id': app.CATALOG['colors'][0]['id'],
|
|
330
|
+
'ratio': '3:4', 'mood': '治愈、松弛、幽默', 'fusion': True,
|
|
331
|
+
'topic': '如何停止内耗', 'image_mode': 'graphic-text',
|
|
332
|
+
'items': [{'title': '先把事情写下来', 'body': '写下来,脑子就空了', 'scene': '书桌前'}],
|
|
333
|
+
}
|
|
334
|
+
try:
|
|
335
|
+
r = client.post('/api/compose', json=compose_body)
|
|
336
|
+
d = r.json()
|
|
337
|
+
ok('POST /api/compose', r.status_code == 200 and bool(d), f'HTTP {r.status_code}')
|
|
338
|
+
print(' 返回键:', list(d.keys())[:8])
|
|
339
|
+
except Exception as e:
|
|
340
|
+
ok('POST /api/compose', False, str(e)[:120])
|
|
341
|
+
try:
|
|
342
|
+
r = client.post('/api/preview', json={'mode': 'image', 'topic': '测试',
|
|
343
|
+
'prompt': '一只橘猫在旧书店门口晒太阳'})
|
|
344
|
+
ok('POST /api/preview', r.status_code == 200 and bool(r.json().get('prompt')))
|
|
345
|
+
except Exception as e:
|
|
346
|
+
ok('POST /api/preview', False, str(e)[:120])
|
|
347
|
+
|
|
348
|
+
# ---- 4) 端到端真实出图 ----
|
|
349
|
+
if not QUICK:
|
|
350
|
+
print('\n=== 4. 端到端出图(会消耗 1 次号池配额)===')
|
|
351
|
+
gen_body = {
|
|
352
|
+
'mode': 'image', 'topic': '冒烟测试', 'title': '冒烟测试',
|
|
353
|
+
'body': '一只橘猫蜷在旧书店门槛上打盹', 'ratio': '3:4',
|
|
354
|
+
'image_mode': 'graphic-text', 'seq': 0, 'group': '__smoke__',
|
|
355
|
+
'prompt': ('请生成一张插画:清晨的旧书店门口,木质门框挂着一串黄铜风铃,'
|
|
356
|
+
'一只橘猫蜷在门槛上打盹,阳光斜射进来,空气里浮着细小尘埃。'
|
|
357
|
+
'手绘水彩质感,柔和暖色调,画面干净,不要文字,不要水印,竖版构图。'),
|
|
358
|
+
}
|
|
359
|
+
try:
|
|
360
|
+
r = client.post('/api/generate', json=gen_body, timeout=30)
|
|
361
|
+
jid = r.json().get('job_id') if r.status_code == 200 else ''
|
|
362
|
+
ok('POST /api/generate', bool(jid), f'HTTP {r.status_code} job={jid}')
|
|
363
|
+
except Exception as e:
|
|
364
|
+
jid = ''
|
|
365
|
+
ok('POST /api/generate', False, str(e)[:120])
|
|
366
|
+
if jid:
|
|
367
|
+
deadline = time.time() + 300
|
|
368
|
+
status, job = '', {}
|
|
369
|
+
while time.time() < deadline:
|
|
370
|
+
time.sleep(3)
|
|
371
|
+
try:
|
|
372
|
+
job = client.get('/api/jobs/' + jid, timeout=20).json()
|
|
373
|
+
status = str(job.get('status') or '')
|
|
374
|
+
if status in ('done', 'error'):
|
|
375
|
+
break
|
|
376
|
+
except Exception:
|
|
377
|
+
pass
|
|
378
|
+
ok('任务走到终态', status in ('done', 'error'), f'status={status}')
|
|
379
|
+
if status == 'done':
|
|
380
|
+
img = (job.get('result') or {}).get('image')
|
|
381
|
+
f = ROOT / 'images' / str(img)
|
|
382
|
+
ok('原图已落盘', bool(img) and f.is_file(),
|
|
383
|
+
f'{img} / {f.stat().st_size if f.is_file() else 0} B')
|
|
384
|
+
if f.is_file():
|
|
385
|
+
ok('原图是 PNG', f.read_bytes()[:8] == b'\x89PNG\r\n\x1a\n')
|
|
386
|
+
time.sleep(2)
|
|
387
|
+
ok('card 压缩图已生成', (ROOT / 'thumbs' / f'{jid}_c.webp').is_file())
|
|
388
|
+
ok('preview 压缩图已生成', (ROOT / 'thumbs' / f'{jid}_p.webp').is_file())
|
|
389
|
+
try:
|
|
390
|
+
dl = client.get('/api/history/' + jid + '/download')
|
|
391
|
+
ok('下载返回原图', dl.status_code == 200 and len(dl.content) == f.stat().st_size,
|
|
392
|
+
f'{len(dl.content)} B')
|
|
393
|
+
except Exception as e:
|
|
394
|
+
ok('下载返回原图', False, str(e)[:100])
|
|
395
|
+
ok('历史记录已写入', any(x.get('id') == jid for x in
|
|
396
|
+
client.get('/api/history').json().get('items', [])))
|
|
397
|
+
else:
|
|
398
|
+
print(' 错误信息:', str(job.get('error') or '')[:250])
|
|
399
|
+
|
|
400
|
+
client.close()
|
|
401
|
+
print(f'\n结果:{passed} 通过 / {failed} 失败')
|
|
402
|
+
sys.exit(0 if failed == 0 else 1)
|