Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
df9da63841 | ||
|
|
593c6f77cb | ||
|
|
4d949751d1 | ||
|
|
82444412ab | ||
|
|
a18391dae7 | ||
|
|
38d7285a0e | ||
|
|
0d1bbab8f8 | ||
|
|
b08a6fed73 | ||
|
|
7dabeb9971 | ||
|
|
a0862dacbd | ||
|
|
c02866e610 | ||
|
|
b05c370250 | ||
|
|
19b7f78947 | ||
|
|
608a79d48a | ||
|
|
c734e3fe79 | ||
|
|
fd82d3b047 | ||
|
|
08f0bff1fa | ||
|
|
e079a65909 | ||
|
|
594592c9b2 | ||
|
|
a2b57db715 | ||
|
|
3d4976913e | ||
|
|
f7c9d761ee | ||
|
|
7d39ab5c90 | ||
|
|
e6fdb661fc | ||
|
|
8706151e23 | ||
|
|
03b9bbab78 | ||
|
|
1d2bb4489a | ||
|
|
de8dbc7754 | ||
|
|
05e12d8f61 | ||
|
|
7d31578e8b | ||
|
|
01b4a05c26 | ||
|
|
189b5855e9 | ||
|
|
4550369bc1 | ||
|
|
70c4323c52 | ||
|
|
0425771598 | ||
|
|
3e91360f88 | ||
|
|
2f05974a61 | ||
|
|
a887186280 | ||
|
|
9163f6b371 | ||
|
|
953d98314b | ||
|
|
9ee48dc2aa | ||
|
|
ca46c34dfe | ||
|
|
fde6967941 | ||
|
|
5b57a6c769 | ||
|
|
3cd71b5285 | ||
|
|
b324e5d332 | ||
|
|
9c0007ca4c | ||
|
|
1c93c1b08f | ||
|
|
02793fb108 | ||
|
|
d76a1de864 | ||
|
|
0da2d8a3c0 | ||
|
|
415efe91a8 | ||
|
|
4217f855ac | ||
|
|
b803a2a15a | ||
|
|
2e6b93d7e0 | ||
|
|
aa000ff5e6 | ||
|
|
2a2cbad7bd | ||
|
|
61db0f4ed6 | ||
|
|
34c7a2e148 | ||
|
|
7734b20434 | ||
|
|
cd2dfe97ef | ||
|
|
049d4b6bbc | ||
|
|
6a0cd1c763 | ||
|
|
ed1d8ee764 | ||
|
|
e65a1595fe | ||
|
|
a04eb911e9 | ||
|
|
5b3b65ea16 | ||
|
|
79f51d9b91 | ||
|
|
dfcca03850 | ||
|
|
7b0990443b | ||
|
|
4c7c02a1e2 | ||
|
|
7dbfcb5a24 | ||
|
|
9f43e5547a | ||
|
|
6abe4decff | ||
|
|
9c4224286a | ||
|
|
b3d9857b3e | ||
|
|
ffc4cd8ca2 | ||
|
|
fa101c16ce | ||
|
|
9852b1c9c1 | ||
|
|
a92f1c86f4 | ||
|
|
c81a24d173 | ||
|
|
391a50a982 | ||
|
|
5e897c0565 | ||
|
|
c80b4e1c17 | ||
|
|
38e91b4747 | ||
|
|
d687c833e0 | ||
|
|
00c695d035 | ||
|
|
ba36b9fd8e | ||
|
|
3e9052e1b6 | ||
|
|
15bd62597a | ||
|
|
bcab01d408 | ||
|
|
1d703f1c94 | ||
|
|
b0ef62c21a | ||
|
|
7538009f94 | ||
|
|
89f1b085dc | ||
|
|
d64e3c7404 | ||
|
|
3a92119187 | ||
|
|
4658a1ca61 | ||
|
|
0cf23db403 | ||
|
|
b4250f36f8 | ||
|
|
21c72d0d93 | ||
|
|
3777f84ce0 | ||
|
|
7ddf3a7262 | ||
|
|
d00a204916 | ||
|
|
d853db1c8a | ||
|
|
d1cd50f5c5 | ||
|
|
da0a42090c | ||
|
|
e77b16ae2e | ||
|
|
e4f0733333 | ||
|
|
311616f2d5 | ||
|
|
def05c9ae8 | ||
|
|
f64dfffe40 | ||
|
|
1fabd8afcf | ||
|
|
d11dc752b5 | ||
|
|
611bfde233 | ||
|
|
cba13593d3 | ||
|
|
8725ff8380 | ||
|
|
3c68dd1ef2 | ||
|
|
f0be2a291f | ||
|
|
03d1fa74be | ||
|
|
43d50ca629 | ||
|
|
ff0ad8bb73 | ||
|
|
782f1dcdd9 | ||
|
|
35dc010d44 | ||
|
|
f6a1ad9ffc | ||
|
|
7426c181a7 | ||
|
|
4e10cce794 | ||
|
|
01697759fc | ||
|
|
d5d991e844 | ||
|
|
1ef5721a20 | ||
|
|
a06a02ddcf | ||
|
|
2ad11625a8 | ||
|
|
29782b1924 | ||
|
|
098dc667b3 | ||
|
|
9f55dcd4f0 | ||
|
|
9166cb7010 | ||
|
|
335c4b6206 | ||
|
|
fa97fa6d3c | ||
|
|
e232097ba5 | ||
|
|
f1dc7f28dc | ||
|
|
40a9f5fbf2 | ||
|
|
56d6b59a00 | ||
|
|
cd150ef6c7 | ||
|
|
f3defdeb95 | ||
|
|
c146c0b41d | ||
|
|
75af87d441 | ||
|
|
c9afaad24b | ||
|
|
717ed2e35f | ||
|
|
becfed81cd | ||
|
|
25a08decdc | ||
|
|
b68e22c094 | ||
|
|
2675f85598 | ||
|
|
f5892bb310 | ||
|
|
a9acfee460 | ||
|
|
eea3fdd0a9 | ||
|
|
613950c9f3 | ||
|
|
8f57d6d016 | ||
|
|
9de006808e | ||
|
|
ec2307b507 | ||
|
|
86d1016e85 | ||
|
|
0ab110d255 | ||
|
|
2abde42862 | ||
|
|
51d5fb9370 | ||
|
|
ecd2ecb758 | ||
|
|
ec62e34807 | ||
|
|
cd61a14856 | ||
|
|
c80c6e67e9 | ||
|
|
dbbf3ce8f7 | ||
|
|
4cf0f46e90 | ||
|
|
e3ac8f8701 | ||
|
|
699625ea0e | ||
|
|
a4ac3f8ee5 | ||
|
|
250c7e27eb | ||
|
|
2f6b9f7af4 | ||
|
|
e86b9189ab | ||
|
|
21d2dd5253 | ||
|
|
17bb7521fd | ||
|
|
f99cde8e05 | ||
|
|
cbfd36af56 | ||
|
|
4c6f7582bd | ||
|
|
5bcd935895 | ||
|
|
0abd7ffdff | ||
|
|
da1da13fdf | ||
|
|
4fa3ad521c | ||
|
|
bc115ba0d7 | ||
|
|
fb873aae90 | ||
|
|
fb4403228d | ||
|
|
3217d4b11a | ||
|
|
4927da5e1c | ||
|
|
e79ec00bf6 | ||
|
|
0d7a4c1b56 | ||
|
|
74b98cd123 | ||
|
|
9a458ee195 | ||
|
|
55daf3648d | ||
|
|
6e485a7d88 | ||
|
|
d42a9c8084 | ||
|
|
e2fbae2ee1 | ||
|
|
a86e2bb162 | ||
|
|
0a3a6b99a1 | ||
|
|
a7ac8522bf | ||
|
|
f6db4f0d62 | ||
|
|
04124060fc | ||
|
|
bab2609a85 | ||
|
|
3ad9ffd239 | ||
|
|
567d4e7b68 | ||
|
|
d4a3df4df0 | ||
|
|
cc910d1e56 | ||
|
|
d3036c99b7 | ||
|
|
b1c050f633 | ||
|
|
42fe233ed9 | ||
|
|
14282306f6 | ||
|
|
6979710088 | ||
|
|
9ac44bdce2 | ||
|
|
4662634651 | ||
|
|
c46fe0e543 | ||
|
|
dd88d12cf4 | ||
|
|
879a3fe5cc | ||
|
|
4e552ea19b | ||
|
|
e4292f8a0e | ||
|
|
82e7e1bf14 | ||
|
|
f28d197c58 | ||
|
|
656f86632d | ||
|
|
5b4b9af678 | ||
|
|
150d6ce22d | ||
|
|
2dd90580ea | ||
|
|
6b45a85c86 | ||
|
|
03916dea55 | ||
|
|
2c6ed87201 | ||
|
|
fe03c65f07 | ||
|
|
dcaf68fd07 | ||
|
|
cddadf6364 | ||
|
|
5754e4831a | ||
|
|
5451579073 | ||
|
|
ab99737e03 | ||
|
|
3ebefab91e | ||
|
|
1267e0a727 | ||
|
|
10a28c1e77 | ||
|
|
5ada8af2b5 | ||
|
|
5c01377173 | ||
|
|
b71fa60b9e | ||
|
|
30f39d6255 | ||
|
|
b835633c67 | ||
|
|
2139b0dcb3 | ||
|
|
0a34e2f0a9 | ||
|
|
9e4a6def92 | ||
|
|
1a2e010997 | ||
|
|
3995d50dd6 | ||
|
|
7e491ed10e | ||
|
|
8daaf7f4af | ||
|
|
668ae1d6fe | ||
|
|
23f942440a | ||
|
|
da68f92aa6 | ||
|
|
bbd5427748 | ||
|
|
a71843928c | ||
|
|
bd56a6cbf7 | ||
|
|
b2eec937ac | ||
|
|
f8c3597f47 | ||
|
|
5fa3b921bb | ||
|
|
8ebabe1a12 | ||
|
|
9f089c1463 | ||
|
|
d0dd11f2cb | ||
|
|
351bbdfbd8 | ||
|
|
5ac18ccc1c | ||
|
|
87fcda47e7 | ||
|
|
68afe34a8d | ||
|
|
b92024ad4a | ||
|
|
e70e831afa | ||
|
|
c078e68e98 | ||
|
|
81247b54e3 | ||
|
|
e5b7fc4afa | ||
|
|
9742448750 | ||
|
|
65a01c35e8 | ||
|
|
7c606e860e | ||
|
|
81b5788436 | ||
|
|
90a054a14d | ||
|
|
d8472c988a | ||
|
|
ab87be8223 | ||
|
|
9827c8c6d8 | ||
|
|
7a69371315 | ||
|
|
cbb14bb678 | ||
|
|
87648e89d5 | ||
|
|
aac5c5d177 | ||
|
|
36d017953d | ||
|
|
c3c79b63d4 | ||
|
|
065ad349de | ||
|
|
b88f170b8b | ||
|
|
77e9bf0f5d | ||
|
|
5400145147 | ||
|
|
d595e7455c | ||
|
|
78870aea90 | ||
|
|
bd99a0d5b7 | ||
|
|
4e98a86fb3 | ||
|
|
ae4daacbbf | ||
|
|
a391d2db99 | ||
|
|
71e51d0608 | ||
|
|
fa27134f7c | ||
|
|
a57254da9e | ||
|
|
c435456977 | ||
|
|
487408ba0f | ||
|
|
5f5bf73949 | ||
|
|
45600c137b | ||
|
|
2ff7ef3678 | ||
|
|
521d363ab2 | ||
|
|
8a8d656a58 | ||
|
|
e25d695ce7 | ||
|
|
6171b7cd92 | ||
|
|
95170ec4aa | ||
|
|
574074ebfb |
@@ -26,10 +26,22 @@
|
|||||||
## 工作规则
|
## 工作规则
|
||||||
|
|
||||||
- 正式代码统一放入 `app/` 包;迁移完成后复用 `app/cdp.py`,遵守架构第七节的选择器、就绪判断、上传/拖拽方式,不另起一套 CDP 交互。
|
- 正式代码统一放入 `app/` 包;迁移完成后复用 `app/cdp.py`,遵守架构第七节的选择器、就绪判断、上传/拖拽方式,不另起一套 CDP 交互。
|
||||||
|
- ①「导入采集」点击采集后可以为本轮匹配到的账号自动确保 Chrome 就绪:已打开则复用,未打开才启动;只检测登录态,不自动登录、不填密码、不绕过验证码。③「更新蝦皮」仍保持高风险边界:更新前只检测账号 Chrome/CDP/登录态,不自动启动缺失账号 Chrome。
|
||||||
- 只做当前任务范围内的事;T-504 已接入的多账号并行、dry-run、运行日志属于当前范围,其他 V2 / V3 功能只记录不实现。
|
- 只做当前任务范围内的事;T-504 已接入的多账号并行、dry-run、运行日志属于当前范围,其他 V2 / V3 功能只记录不实现。
|
||||||
- CDP 选择器 / 流程 / 配置 schema 变化,必须同步更新 `docs/04-architecture.md` 与相关任务。
|
- CDP 选择器 / 流程 / 配置 schema 变化,必须同步更新 `docs/04-architecture.md` 与相关任务。
|
||||||
- **新任务一任务一文件写进 `docs/tasks/T-<编号>.md`,不再手改 `docs/06-tasks.md`**(已冻结为 T-000~T-549 历史归档);命名/frontmatter/领取规则见 `docs/tasks/README.md`。为避免多 agent 并发抢改同一文件、ID 撞号。
|
- **新任务一任务一文件写进 `docs/tasks/T-<编号>.md`,不再手改 `docs/06-tasks.md`**(已冻结为 T-000~T-549 历史归档);命名/frontmatter/领取规则见 `docs/tasks/README.md`。为避免多 agent 并发抢改同一文件、ID 撞号。
|
||||||
- 改完后**只更新对应任务文件**:把状态改 `DONE`、执行记录写进该任务文件的 `## 执行记录` 一节。**不再逐任务追加 `progress.md`、不再逐任务覆盖 `docs/current-state.md`**(这两个是每任务共享写入点,多 agent 并发会互相抢/覆盖)。`progress.md` 冻结为历史归档;`docs/current-state.md` 不再手动逐任务维护,当前状态以 `docs/tasks/` 各任务 frontmatter 为准,后续可由脚本汇总生成。
|
- 改完后**只更新对应任务文件**:把状态改 `DONE`、执行记录写进该任务文件的 `## 执行记录` 一节。**不再逐任务追加 `progress.md`、不再逐任务覆盖 `docs/current-state.md`**(这两个是每任务共享写入点,多 agent 并发会互相抢/覆盖)。`progress.md` 冻结为历史归档;`docs/current-state.md` 不再手动逐任务维护,当前状态以 `docs/tasks/` 各任务 frontmatter 为准,后续可由脚本汇总生成。
|
||||||
|
- **用户指令暗语**(用户输入以下触发词时按约定执行,默认全栈工程师视角、只提交本次相关文件):
|
||||||
|
- `bug: <现象>` / `需求: <描述>` → 先查代码再给分析和方案,**只讨论不改代码**;
|
||||||
|
- `grill: <方案>` → 反方评审,逐点挑战该方案;
|
||||||
|
- `落task` → 把已讨论定案落成 `docs/tasks/T-<编号>.md`(按上述规则查号防撞),**只写文档不写代码,写完自动提交 git**;
|
||||||
|
- `审 T-<编号>` → **以 git 历史为准**(`git log -p docs/tasks/T-<编号>.md` 找出最近改动),先核代码事实,再审核该改动是否合理、给缺口;
|
||||||
|
- `补` → 把讨论新增的结论补进当前任务文件并提交 git;
|
||||||
|
- `做 T-<编号>` → 实现该任务 + 跑任务内验证命令;**验证全绿才提交**(执行记录写进任务文件、状态改 DONE、提交 git);验证失败 → 报告结果、**不提交**、状态留 DOING 或标 BLOCKED 记原因;
|
||||||
|
- `记backlog: <一行>` → 追加进 `docs/06-tasks.md` 待办池并提交(只记一行,不建任务文件)。
|
||||||
|
- 上下文规则:`落task`/`补` 可带参数(如 `落task <主题>`、`补 T-<编号>`);新会话或无对话上下文时**必须先问清指代对象,不得猜**。
|
||||||
|
- 本条目是这套暗语的**唯一权威源**;agent 记忆和模板库中的副本仅为指针/种子,以此处为准。
|
||||||
|
- **SVG 效果图统一存 `docs/ui/`**(不放 scratchpad/tmp):新 tab 或界面改版的线框/效果图命名 `tabN-<名>.svg`,改版加后缀(如 `-T584`、`-v2`),画完随更新的文档一起提交 git;新增文件在 `docs/ui/README.md` 登记。
|
||||||
|
|
||||||
## 安全红线
|
## 安全红线
|
||||||
|
|
||||||
|
|||||||
+163
-22
@@ -3,6 +3,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import time
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
@@ -12,6 +13,15 @@ from . import config as account_config
|
|||||||
|
|
||||||
|
|
||||||
DEFAULT_REGION_HOST = editor.DEFAULT_REGION_HOST
|
DEFAULT_REGION_HOST = editor.DEFAULT_REGION_HOST
|
||||||
|
INITIAL_LOGIN_TAB_WAIT_SECONDS = 2.0
|
||||||
|
INITIAL_LOGIN_TAB_POLL_SECONDS = 0.1
|
||||||
|
SAFE_STARTUP_PAGE_PREFIXES = (
|
||||||
|
"about:blank",
|
||||||
|
"chrome://newtab",
|
||||||
|
"chrome://new-tab-page",
|
||||||
|
"chrome://welcome",
|
||||||
|
"chrome://intro",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class AccountError(RuntimeError):
|
class AccountError(RuntimeError):
|
||||||
@@ -222,13 +232,19 @@ def _seller_portal_url(account) -> str:
|
|||||||
return f"https://{normalize_region_host(_account_value(account, 'region_host'))}/portal/"
|
return f"https://{normalize_region_host(_account_value(account, 'region_host'))}/portal/"
|
||||||
|
|
||||||
|
|
||||||
def _find_login_tab(account, host):
|
def _page_tabs(host):
|
||||||
|
return [
|
||||||
|
tab
|
||||||
|
for tab in cdp.http_get("/json", host=host)
|
||||||
|
if tab.get("type") == "page"
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _find_login_tab_in_tabs(account, tabs):
|
||||||
region_host = normalize_region_host(_account_value(account, 'region_host')).lower()
|
region_host = normalize_region_host(_account_value(account, 'region_host')).lower()
|
||||||
seller_tabs = []
|
seller_tabs = []
|
||||||
login_tabs = []
|
login_tabs = []
|
||||||
for tab in cdp.http_get("/json", host=host):
|
for tab in tabs:
|
||||||
if tab.get("type") != "page":
|
|
||||||
continue
|
|
||||||
url = str(tab.get("url") or "")
|
url = str(tab.get("url") or "")
|
||||||
lower_url = url.lower()
|
lower_url = url.lower()
|
||||||
if region_host in lower_url:
|
if region_host in lower_url:
|
||||||
@@ -238,38 +254,157 @@ def _find_login_tab(account, host):
|
|||||||
return (seller_tabs or login_tabs or [None])[0]
|
return (seller_tabs or login_tabs or [None])[0]
|
||||||
|
|
||||||
|
|
||||||
def _open_or_activate_login_tab(account):
|
def _find_login_tab(account, host):
|
||||||
host = _debug_host(account)
|
return _find_login_tab_in_tabs(account, _page_tabs(host))
|
||||||
portal_url = _seller_portal_url(account)
|
|
||||||
tab = _find_login_tab(account, host)
|
|
||||||
if tab:
|
|
||||||
target_id = tab.get("id")
|
|
||||||
if target_id:
|
|
||||||
cdp.activate_tab(target_id, host=host)
|
|
||||||
return {
|
|
||||||
"target_id": target_id,
|
|
||||||
"url": tab.get("url") or portal_url,
|
|
||||||
"created_tab": False,
|
|
||||||
}
|
|
||||||
|
|
||||||
tab = cdp.create_tab_info(portal_url, host=host)
|
|
||||||
|
def _wait_for_initial_login_tab(account, host, timeout=INITIAL_LOGIN_TAB_WAIT_SECONDS):
|
||||||
|
timeout = max(0.0, float(timeout))
|
||||||
|
deadline = time.monotonic() + timeout
|
||||||
|
last_tabs = []
|
||||||
|
probe_error = None
|
||||||
|
first_probe = True
|
||||||
|
while first_probe or time.monotonic() < deadline:
|
||||||
|
first_probe = False
|
||||||
|
try:
|
||||||
|
last_tabs = _page_tabs(host)
|
||||||
|
probe_error = None
|
||||||
|
except Exception as exc:
|
||||||
|
probe_error = exc.__class__.__name__
|
||||||
|
tab = _find_login_tab_in_tabs(account, last_tabs)
|
||||||
|
if tab:
|
||||||
|
return tab, last_tabs, probe_error
|
||||||
|
remaining = deadline - time.monotonic()
|
||||||
|
if remaining <= 0:
|
||||||
|
break
|
||||||
|
time.sleep(min(INITIAL_LOGIN_TAB_POLL_SECONDS, remaining))
|
||||||
|
return None, last_tabs, probe_error
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_startup_page(tabs):
|
||||||
|
if len(tabs) != 1:
|
||||||
|
return None
|
||||||
|
tab = tabs[0]
|
||||||
|
url = str(tab.get("url") or "").strip().lower()
|
||||||
|
if not url or any(url.startswith(prefix) for prefix in SAFE_STARTUP_PAGE_PREFIXES):
|
||||||
|
return tab
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _navigate_page_target(tab, url):
|
||||||
|
websocket_url = tab.get("webSocketDebuggerUrl")
|
||||||
|
if not websocket_url:
|
||||||
|
raise AccountError("Chrome 初始页面缺少 CDP 地址,无法复用")
|
||||||
|
client = cdp.CDP(websocket_url)
|
||||||
|
try:
|
||||||
|
result = client.send("Page.navigate", {"url": url})
|
||||||
|
finally:
|
||||||
|
client.close()
|
||||||
|
if result.get("errorText"):
|
||||||
|
raise AccountError("Chrome 初始页面导航到蝦皮卖家中心失败")
|
||||||
|
|
||||||
|
|
||||||
|
def _activate_login_tab_result(
|
||||||
|
tab,
|
||||||
|
host,
|
||||||
|
portal_url,
|
||||||
|
*,
|
||||||
|
created_tab=False,
|
||||||
|
startup_target_reused=False,
|
||||||
|
startup_page_navigated=False,
|
||||||
|
page_target_count=0,
|
||||||
|
fallback_reason=None,
|
||||||
|
target_probe_error=None,
|
||||||
|
):
|
||||||
target_id = tab.get("id")
|
target_id = tab.get("id")
|
||||||
if target_id:
|
if target_id:
|
||||||
cdp.activate_tab(target_id, host=host)
|
cdp.activate_tab(target_id, host=host)
|
||||||
return {
|
return {
|
||||||
"target_id": target_id,
|
"target_id": target_id,
|
||||||
"url": tab.get("url") or portal_url,
|
"url": tab.get("url") or portal_url,
|
||||||
"created_tab": True,
|
"created_tab": bool(created_tab),
|
||||||
|
"startup_target_reused": bool(startup_target_reused),
|
||||||
|
"startup_page_navigated": bool(startup_page_navigated),
|
||||||
|
"page_target_count": int(page_target_count),
|
||||||
|
"fallback_reason": fallback_reason,
|
||||||
|
"target_probe_error": target_probe_error,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _open_or_activate_login_tab(
|
||||||
|
account,
|
||||||
|
*,
|
||||||
|
newly_launched=False,
|
||||||
|
startup_wait_timeout=INITIAL_LOGIN_TAB_WAIT_SECONDS,
|
||||||
|
):
|
||||||
|
host = _debug_host(account)
|
||||||
|
portal_url = _seller_portal_url(account)
|
||||||
|
if newly_launched:
|
||||||
|
tab, tabs, probe_error = _wait_for_initial_login_tab(
|
||||||
|
account,
|
||||||
|
host,
|
||||||
|
timeout=startup_wait_timeout,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
tabs = _page_tabs(host)
|
||||||
|
tab = _find_login_tab_in_tabs(account, tabs)
|
||||||
|
probe_error = None
|
||||||
|
if tab:
|
||||||
|
return _activate_login_tab_result(
|
||||||
|
tab,
|
||||||
|
host,
|
||||||
|
portal_url,
|
||||||
|
startup_target_reused=newly_launched,
|
||||||
|
page_target_count=len(tabs),
|
||||||
|
target_probe_error=probe_error,
|
||||||
|
)
|
||||||
|
|
||||||
|
fallback_reason = "existing_chrome_no_login_tab"
|
||||||
|
if newly_launched:
|
||||||
|
startup_tab = _safe_startup_page(tabs)
|
||||||
|
if startup_tab:
|
||||||
|
try:
|
||||||
|
_navigate_page_target(startup_tab, portal_url)
|
||||||
|
except Exception as exc:
|
||||||
|
fallback_reason = "startup_navigation_failed"
|
||||||
|
probe_error = probe_error or exc.__class__.__name__
|
||||||
|
else:
|
||||||
|
navigated_tab = dict(startup_tab)
|
||||||
|
navigated_tab["url"] = portal_url
|
||||||
|
return _activate_login_tab_result(
|
||||||
|
navigated_tab,
|
||||||
|
host,
|
||||||
|
portal_url,
|
||||||
|
startup_target_reused=True,
|
||||||
|
startup_page_navigated=True,
|
||||||
|
page_target_count=len(tabs),
|
||||||
|
target_probe_error=probe_error,
|
||||||
|
)
|
||||||
|
elif tabs:
|
||||||
|
fallback_reason = "startup_page_not_safe"
|
||||||
|
else:
|
||||||
|
fallback_reason = "startup_page_missing"
|
||||||
|
|
||||||
|
tab = cdp.create_tab_info(portal_url, host=host)
|
||||||
|
return _activate_login_tab_result(
|
||||||
|
tab,
|
||||||
|
host,
|
||||||
|
portal_url,
|
||||||
|
created_tab=True,
|
||||||
|
page_target_count=len(tabs) + 1,
|
||||||
|
fallback_reason=fallback_reason,
|
||||||
|
target_probe_error=probe_error,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def launch_for_login(account_or_alias, path=None, config=None):
|
def launch_for_login(account_or_alias, path=None, config=None):
|
||||||
account = resolve_account(account_or_alias, path=path, config=config)
|
account = resolve_account(account_or_alias, path=path, config=config)
|
||||||
port = normalize_debug_port(_account_value(account, "debug_port"))
|
port = normalize_debug_port(_account_value(account, "debug_port"))
|
||||||
alias = _account_value(account, "alias")
|
alias = _account_value(account, "alias")
|
||||||
|
portal_url = _seller_portal_url(account)
|
||||||
|
|
||||||
if chrome.is_running(port):
|
if chrome.is_running(port):
|
||||||
tab = _open_or_activate_login_tab(account)
|
tab = _open_or_activate_login_tab(account, newly_launched=False)
|
||||||
return {
|
return {
|
||||||
"ok": True,
|
"ok": True,
|
||||||
"action": "reused",
|
"action": "reused",
|
||||||
@@ -278,14 +413,15 @@ def launch_for_login(account_or_alias, path=None, config=None):
|
|||||||
"alias": alias,
|
"alias": alias,
|
||||||
"debug_port": port,
|
"debug_port": port,
|
||||||
"pid": None,
|
"pid": None,
|
||||||
|
"initial_url": portal_url,
|
||||||
**tab,
|
**tab,
|
||||||
}
|
}
|
||||||
|
|
||||||
process = chrome.launch_chrome(account, config=config)
|
process = chrome.launch_chrome(account, config=config, initial_url=portal_url)
|
||||||
timeout = appconfig.cdp_ready_timeout(config)
|
timeout = appconfig.cdp_ready_timeout(config)
|
||||||
if not chrome.wait_debug_ready(port, timeout=timeout):
|
if not chrome.wait_debug_ready(port, timeout=timeout):
|
||||||
raise AccountError(f"Chrome 已启动,但 CDP 端口 {port} 未在 {timeout} 秒内就绪")
|
raise AccountError(f"Chrome 已启动,但 CDP 端口 {port} 未在 {timeout} 秒内就绪")
|
||||||
tab = _open_or_activate_login_tab(account)
|
tab = _open_or_activate_login_tab(account, newly_launched=True)
|
||||||
return {
|
return {
|
||||||
"ok": True,
|
"ok": True,
|
||||||
"action": "launched",
|
"action": "launched",
|
||||||
@@ -294,6 +430,7 @@ def launch_for_login(account_or_alias, path=None, config=None):
|
|||||||
"alias": alias,
|
"alias": alias,
|
||||||
"debug_port": port,
|
"debug_port": port,
|
||||||
"pid": getattr(process, "pid", None),
|
"pid": getattr(process, "pid", None),
|
||||||
|
"initial_url": portal_url,
|
||||||
**tab,
|
**tab,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -327,6 +464,10 @@ def login_status_text(status) -> str:
|
|||||||
return "未登录"
|
return "未登录"
|
||||||
if reason == "NO_SESSION_COOKIE":
|
if reason == "NO_SESSION_COOKIE":
|
||||||
return "未登录"
|
return "未登录"
|
||||||
|
if reason == "LOGIN_CHECK_TARGET_UNAVAILABLE" or str(reason).startswith(
|
||||||
|
"LOGIN_CHECK_FAILED"
|
||||||
|
):
|
||||||
|
return "登录状态暂不可确认"
|
||||||
if reason:
|
if reason:
|
||||||
return f"未登录({reason})"
|
return f"未登录({reason})"
|
||||||
return "未登录"
|
return "未登录"
|
||||||
|
|||||||
+306
-11
@@ -40,12 +40,19 @@ CMHUB_CONFIG_PATH = os.path.join(default_data_dir(), "config", "cmhub.json")
|
|||||||
CATEGORIES = {"text", "image"}
|
CATEGORIES = {"text", "image"}
|
||||||
API_TYPES = {"chat", "images_edits", "auto"}
|
API_TYPES = {"chat", "images_edits", "auto"}
|
||||||
AI_BACKENDS = {"direct", "cmhub"}
|
AI_BACKENDS = {"direct", "cmhub"}
|
||||||
|
AI_GENERATE_MODES = {"title", "cover", "title_cover"}
|
||||||
|
SHOPEE_UPDATE_MODES = {"title", "cover", "title_cover"}
|
||||||
AI_CONCURRENCY_MIN = 1
|
AI_CONCURRENCY_MIN = 1
|
||||||
AI_CONCURRENCY_MAX = 5
|
AI_CONCURRENCY_MAX = 5
|
||||||
AI_RETRY_MIN = 0
|
AI_RETRY_MIN = 0
|
||||||
AI_RETRY_MAX = 10
|
AI_RETRY_MAX = 10
|
||||||
|
SHOPEE_PARALLEL_ACCOUNTS_MIN = 1
|
||||||
|
SHOPEE_PARALLEL_ACCOUNTS_MAX = 5
|
||||||
CMHUB_CONNECT_TIMEOUT_DEFAULT = 66
|
CMHUB_CONNECT_TIMEOUT_DEFAULT = 66
|
||||||
CMHUB_CONNECT_TIMEOUT_OLD_DEFAULT = 10
|
CMHUB_CONNECT_TIMEOUT_OLD_DEFAULT = 10
|
||||||
|
DEFAULT_CMHUB_BASE_URL = "https://cm.833729.com"
|
||||||
|
DEFAULT_MEMBER_CENTER_URL = "https://cm.833729.com"
|
||||||
|
FIRST_USE_GUIDE_STATES = {"", "pending", "completed", "dismissed"}
|
||||||
RUNTIME_CONFIG_KEYS = {
|
RUNTIME_CONFIG_KEYS = {
|
||||||
"config_path",
|
"config_path",
|
||||||
"ai_models_path",
|
"ai_models_path",
|
||||||
@@ -67,7 +74,7 @@ LEGACY_USER_DATA_PATHS = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
DEFAULT_CONFIG = {
|
DEFAULT_CONFIG = {
|
||||||
"chrome_path": r"C:\Program Files\Google\Chrome\Application\chrome.exe",
|
"chrome_path": "",
|
||||||
"user_data_root": "chrome_user_data_dir",
|
"user_data_root": "chrome_user_data_dir",
|
||||||
"image_dir": "images",
|
"image_dir": "images",
|
||||||
"db_path": "cmshopee.db",
|
"db_path": "cmshopee.db",
|
||||||
@@ -78,11 +85,13 @@ DEFAULT_CONFIG = {
|
|||||||
"default_text_model": "GPT-5.5 文本",
|
"default_text_model": "GPT-5.5 文本",
|
||||||
"default_image_model": "Nano Banana 2",
|
"default_image_model": "Nano Banana 2",
|
||||||
"generate_cover": False,
|
"generate_cover": False,
|
||||||
|
"generate_mode": "",
|
||||||
"backend": "cmhub",
|
"backend": "cmhub",
|
||||||
"cmhub": {
|
"cmhub": {
|
||||||
"base_url": "",
|
"base_url": DEFAULT_CMHUB_BASE_URL,
|
||||||
"title_alias": "",
|
"title_alias": "",
|
||||||
"image_alias": "",
|
"image_alias": "",
|
||||||
|
"vision_alias": "vision-standard",
|
||||||
"connect_timeout": CMHUB_CONNECT_TIMEOUT_DEFAULT,
|
"connect_timeout": CMHUB_CONNECT_TIMEOUT_DEFAULT,
|
||||||
"use_system_proxy": False,
|
"use_system_proxy": False,
|
||||||
"download_with_curl": "auto",
|
"download_with_curl": "auto",
|
||||||
@@ -102,13 +111,25 @@ DEFAULT_CONFIG = {
|
|||||||
},
|
},
|
||||||
"shopee_update": {
|
"shopee_update": {
|
||||||
"test_item_id": "51100639510",
|
"test_item_id": "51100639510",
|
||||||
"allow_real_submit": False,
|
"update_mode": "",
|
||||||
"allow_cover_update": False,
|
|
||||||
"max_items_per_run": 1,
|
"max_items_per_run": 1,
|
||||||
"close_success_tab": False,
|
|
||||||
"dry_run": False,
|
"dry_run": False,
|
||||||
"parallel_accounts": False,
|
"max_parallel_accounts": 1,
|
||||||
"max_parallel_accounts": 2,
|
},
|
||||||
|
"product_suite": {
|
||||||
|
"last_account_alias": "",
|
||||||
|
"last_settings": {
|
||||||
|
"platform": "Shopee",
|
||||||
|
"country": "中国台湾",
|
||||||
|
"language": "繁体中文",
|
||||||
|
"ratio": "1:1",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"subscription": {
|
||||||
|
"last_notice_id": "",
|
||||||
|
},
|
||||||
|
"onboarding": {
|
||||||
|
"first_use_guide_state": "",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,10 +151,10 @@ DEFAULT_AI_MODELS_CONFIG = {
|
|||||||
"name": "Nano Banana 2",
|
"name": "Nano Banana 2",
|
||||||
"category": "image",
|
"category": "image",
|
||||||
"enabled": True,
|
"enabled": True,
|
||||||
"url": "https://api.vectorengine.ai/v1/chat/completions",
|
"url": "https://api.vectorengine.ai/v1",
|
||||||
"model": "gemini-3.1-flash-image-preview",
|
"model": "gemini-3.1-flash-image-preview",
|
||||||
"api_key": "",
|
"api_key": "",
|
||||||
"api_type": "auto",
|
"api_type": "images_edits",
|
||||||
"connect_timeout_seconds": 30,
|
"connect_timeout_seconds": 30,
|
||||||
"timeout_seconds": 0,
|
"timeout_seconds": 0,
|
||||||
"extra_body": {},
|
"extra_body": {},
|
||||||
@@ -243,10 +264,22 @@ def title_prompt_path(config=None) -> str:
|
|||||||
return data_path("title_prompt.txt", config=config)
|
return data_path("title_prompt.txt", config=config)
|
||||||
|
|
||||||
|
|
||||||
|
def title_templates_dir(config=None) -> str:
|
||||||
|
return data_path("prompts", "title", config=config)
|
||||||
|
|
||||||
|
|
||||||
def cover_prompts_dir(config=None) -> str:
|
def cover_prompts_dir(config=None) -> str:
|
||||||
return data_path("prompts", "cover", config=config)
|
return data_path("prompts", "cover", config=config)
|
||||||
|
|
||||||
|
|
||||||
|
def image_studio_prompts_dir(config=None) -> str:
|
||||||
|
return data_path("prompts", "image_studio", config=config)
|
||||||
|
|
||||||
|
|
||||||
|
def product_suite_prompt_path(config=None) -> str:
|
||||||
|
return data_path("prompts", "product_suite", "base.txt", config=config)
|
||||||
|
|
||||||
|
|
||||||
def diagnostic_log_dir(config=None) -> str:
|
def diagnostic_log_dir(config=None) -> str:
|
||||||
return data_path("logs", config=config)
|
return data_path("logs", config=config)
|
||||||
|
|
||||||
@@ -357,6 +390,11 @@ def _normalize_config_values(config, migrate_old_cmhub_connect_timeout=False):
|
|||||||
return config
|
return config
|
||||||
ai = config.get("ai")
|
ai = config.get("ai")
|
||||||
if isinstance(ai, dict):
|
if isinstance(ai, dict):
|
||||||
|
ai["generate_mode"] = normalize_generate_mode(
|
||||||
|
ai.get("generate_mode"),
|
||||||
|
generate_cover=ai.get("generate_cover", False),
|
||||||
|
)
|
||||||
|
ai["generate_cover"] = generate_mode_includes_cover(ai["generate_mode"])
|
||||||
ai["title_concurrency"] = _clamp_int(
|
ai["title_concurrency"] = _clamp_int(
|
||||||
ai.get("title_concurrency"),
|
ai.get("title_concurrency"),
|
||||||
AI_CONCURRENCY_MIN,
|
AI_CONCURRENCY_MIN,
|
||||||
@@ -377,7 +415,13 @@ def _normalize_config_values(config, migrate_old_cmhub_connect_timeout=False):
|
|||||||
)
|
)
|
||||||
cmhub = ai.get("cmhub")
|
cmhub = ai.get("cmhub")
|
||||||
if isinstance(cmhub, dict):
|
if isinstance(cmhub, dict):
|
||||||
cmhub["base_url"] = normalize_cmhub_base_url(cmhub.get("base_url", ""))
|
cmhub["base_url"] = (
|
||||||
|
normalize_cmhub_base_url(cmhub.get("base_url", ""))
|
||||||
|
or DEFAULT_CMHUB_BASE_URL
|
||||||
|
)
|
||||||
|
cmhub["title_alias"] = str(cmhub.get("title_alias", "") or "").strip()
|
||||||
|
cmhub["image_alias"] = str(cmhub.get("image_alias", "") or "").strip()
|
||||||
|
cmhub["vision_alias"] = str(cmhub.get("vision_alias", "") or "").strip()
|
||||||
cmhub["connect_timeout"] = _normalize_cmhub_connect_timeout(
|
cmhub["connect_timeout"] = _normalize_cmhub_connect_timeout(
|
||||||
cmhub.get("connect_timeout"),
|
cmhub.get("connect_timeout"),
|
||||||
migrate_old_default=migrate_old_cmhub_connect_timeout,
|
migrate_old_default=migrate_old_cmhub_connect_timeout,
|
||||||
@@ -385,9 +429,63 @@ def _normalize_config_values(config, migrate_old_cmhub_connect_timeout=False):
|
|||||||
cmhub["download_with_curl"] = _normalize_cmhub_download_with_curl(
|
cmhub["download_with_curl"] = _normalize_cmhub_download_with_curl(
|
||||||
cmhub.get("download_with_curl", "auto")
|
cmhub.get("download_with_curl", "auto")
|
||||||
)
|
)
|
||||||
|
update = config.get("shopee_update")
|
||||||
|
if isinstance(update, dict):
|
||||||
|
old_parallel_accounts = update.pop("parallel_accounts", None)
|
||||||
|
old_allow_cover_update = update.get("allow_cover_update", False)
|
||||||
|
update.pop("allow_real_submit", None)
|
||||||
|
update.pop("allow_cover_update", None)
|
||||||
|
update.pop("close_success_tab", None)
|
||||||
|
update["update_mode"] = normalize_update_mode(
|
||||||
|
update.get("update_mode"),
|
||||||
|
allow_cover_update=old_allow_cover_update,
|
||||||
|
)
|
||||||
|
if old_parallel_accounts is False:
|
||||||
|
update["max_parallel_accounts"] = SHOPEE_PARALLEL_ACCOUNTS_MIN
|
||||||
|
else:
|
||||||
|
update["max_parallel_accounts"] = _clamp_int(
|
||||||
|
update.get("max_parallel_accounts"),
|
||||||
|
SHOPEE_PARALLEL_ACCOUNTS_MIN,
|
||||||
|
SHOPEE_PARALLEL_ACCOUNTS_MAX,
|
||||||
|
DEFAULT_CONFIG["shopee_update"]["max_parallel_accounts"],
|
||||||
|
)
|
||||||
|
update["max_items_per_run"] = _clamp_int(
|
||||||
|
update.get("max_items_per_run"),
|
||||||
|
1,
|
||||||
|
9999,
|
||||||
|
DEFAULT_CONFIG["shopee_update"]["max_items_per_run"],
|
||||||
|
)
|
||||||
|
suite = config.get("product_suite")
|
||||||
|
if not isinstance(suite, dict):
|
||||||
|
suite = {}
|
||||||
|
config["product_suite"] = suite
|
||||||
|
suite["last_account_alias"] = _normalize_product_suite_last_account_alias(
|
||||||
|
suite.get("last_account_alias")
|
||||||
|
)
|
||||||
|
suite["last_settings"] = _normalize_product_suite_last_settings(
|
||||||
|
suite.get("last_settings")
|
||||||
|
)
|
||||||
|
onboarding = config.get("onboarding")
|
||||||
|
if not isinstance(onboarding, dict):
|
||||||
|
onboarding = {}
|
||||||
|
config["onboarding"] = onboarding
|
||||||
|
guide_state = str(onboarding.get("first_use_guide_state") or "").strip().lower()
|
||||||
|
onboarding["first_use_guide_state"] = (
|
||||||
|
guide_state if guide_state in FIRST_USE_GUIDE_STATES else ""
|
||||||
|
)
|
||||||
return config
|
return config
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_product_suite_last_settings(value):
|
||||||
|
from . import product_suite
|
||||||
|
|
||||||
|
return product_suite.last_suite_settings(value)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_product_suite_last_account_alias(value):
|
||||||
|
return str(value or "").strip() if isinstance(value, str) else ""
|
||||||
|
|
||||||
|
|
||||||
def _clamp_int(value, minimum, maximum, default):
|
def _clamp_int(value, minimum, maximum, default):
|
||||||
try:
|
try:
|
||||||
number = int(value)
|
number = int(value)
|
||||||
@@ -628,10 +726,134 @@ def cdp_ready_timeout(config=None) -> int:
|
|||||||
return int(_config_or_load(config).get("cdp_ready_timeout", 60))
|
return int(_config_or_load(config).get("cdp_ready_timeout", 60))
|
||||||
|
|
||||||
|
|
||||||
|
def subscription_notice_id(config=None) -> str:
|
||||||
|
section = _config_or_load(config).get("subscription", {})
|
||||||
|
if not isinstance(section, dict):
|
||||||
|
return ""
|
||||||
|
return str(section.get("last_notice_id") or "").strip()
|
||||||
|
|
||||||
|
|
||||||
|
def save_subscription_notice_id(notice_id, path=CONFIG_PATH) -> dict:
|
||||||
|
"""Persist the last server notification version without storing credentials."""
|
||||||
|
|
||||||
|
config = load_config(path)
|
||||||
|
section = config.get("subscription", {})
|
||||||
|
if not isinstance(section, dict):
|
||||||
|
section = {}
|
||||||
|
section["last_notice_id"] = str(notice_id or "").strip()
|
||||||
|
config["subscription"] = section
|
||||||
|
return save_config(config, path=path)
|
||||||
|
|
||||||
|
|
||||||
|
def first_use_guide_state(config=None) -> str:
|
||||||
|
section = _config_or_load(config).get("onboarding", {})
|
||||||
|
if not isinstance(section, dict):
|
||||||
|
return ""
|
||||||
|
state = str(section.get("first_use_guide_state") or "").strip().lower()
|
||||||
|
return state if state in FIRST_USE_GUIDE_STATES else ""
|
||||||
|
|
||||||
|
|
||||||
|
def save_first_use_guide_state(state, path=CONFIG_PATH) -> dict:
|
||||||
|
"""Persist the local first-use guide state without storing credentials."""
|
||||||
|
|
||||||
|
normalized = str(state or "").strip().lower()
|
||||||
|
if normalized not in FIRST_USE_GUIDE_STATES:
|
||||||
|
raise ConfigError("首次使用引导状态无效")
|
||||||
|
config = load_config(path)
|
||||||
|
section = config.get("onboarding", {})
|
||||||
|
if not isinstance(section, dict):
|
||||||
|
section = {}
|
||||||
|
section["first_use_guide_state"] = normalized
|
||||||
|
config["onboarding"] = section
|
||||||
|
return save_config(config, path=path)
|
||||||
|
|
||||||
|
|
||||||
def ai_config(config=None) -> dict:
|
def ai_config(config=None) -> dict:
|
||||||
return copy.deepcopy(_config_or_load(config).get("ai", DEFAULT_CONFIG["ai"]))
|
return copy.deepcopy(_config_or_load(config).get("ai", DEFAULT_CONFIG["ai"]))
|
||||||
|
|
||||||
|
|
||||||
|
def product_suite_last_settings(config=None) -> dict:
|
||||||
|
cfg = _config_or_load(config)
|
||||||
|
suite = cfg.get("product_suite", {})
|
||||||
|
if not isinstance(suite, dict):
|
||||||
|
suite = {}
|
||||||
|
return _normalize_product_suite_last_settings(suite.get("last_settings"))
|
||||||
|
|
||||||
|
|
||||||
|
def product_suite_last_account_alias(config=None) -> str:
|
||||||
|
cfg = _config_or_load(config)
|
||||||
|
suite = cfg.get("product_suite", {})
|
||||||
|
if not isinstance(suite, dict):
|
||||||
|
suite = {}
|
||||||
|
return _normalize_product_suite_last_account_alias(
|
||||||
|
suite.get("last_account_alias")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_generate_mode(value=None, generate_cover=None) -> str:
|
||||||
|
text = str(value or "").strip().lower()
|
||||||
|
if text in AI_GENERATE_MODES:
|
||||||
|
return text
|
||||||
|
return "title_cover" if bool(generate_cover) else "title"
|
||||||
|
|
||||||
|
|
||||||
|
def generate_mode_includes_title(mode) -> bool:
|
||||||
|
return normalize_generate_mode(mode) in {"title", "title_cover"}
|
||||||
|
|
||||||
|
|
||||||
|
def generate_mode_includes_cover(mode) -> bool:
|
||||||
|
return normalize_generate_mode(mode) in {"cover", "title_cover"}
|
||||||
|
|
||||||
|
|
||||||
|
def ai_generate_mode(config=None) -> str:
|
||||||
|
ai = ai_config(config)
|
||||||
|
return normalize_generate_mode(ai.get("generate_mode"), generate_cover=ai.get("generate_cover", False))
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_update_mode(value=None, allow_cover_update=None) -> str:
|
||||||
|
text = str(value or "").strip().lower()
|
||||||
|
if text in SHOPEE_UPDATE_MODES:
|
||||||
|
return text
|
||||||
|
return "title_cover" if bool(allow_cover_update) else "title"
|
||||||
|
|
||||||
|
|
||||||
|
def update_mode_includes_title(mode) -> bool:
|
||||||
|
return normalize_update_mode(mode) in {"title", "title_cover"}
|
||||||
|
|
||||||
|
|
||||||
|
def update_mode_includes_cover(mode) -> bool:
|
||||||
|
return normalize_update_mode(mode) in {"cover", "title_cover"}
|
||||||
|
|
||||||
|
|
||||||
|
def shopee_update_config(config=None) -> dict:
|
||||||
|
cfg = _config_or_load(config)
|
||||||
|
loaded = cfg.get("shopee_update", {})
|
||||||
|
if not isinstance(loaded, dict):
|
||||||
|
loaded = {}
|
||||||
|
merged = _deep_merge(DEFAULT_CONFIG["shopee_update"], loaded)
|
||||||
|
merged["update_mode"] = normalize_update_mode(
|
||||||
|
merged.get("update_mode"),
|
||||||
|
allow_cover_update=merged.get("allow_cover_update", False),
|
||||||
|
)
|
||||||
|
if merged.get("parallel_accounts") is False:
|
||||||
|
merged["max_parallel_accounts"] = SHOPEE_PARALLEL_ACCOUNTS_MIN
|
||||||
|
else:
|
||||||
|
merged["max_parallel_accounts"] = _clamp_int(
|
||||||
|
merged.get("max_parallel_accounts"),
|
||||||
|
SHOPEE_PARALLEL_ACCOUNTS_MIN,
|
||||||
|
SHOPEE_PARALLEL_ACCOUNTS_MAX,
|
||||||
|
DEFAULT_CONFIG["shopee_update"]["max_parallel_accounts"],
|
||||||
|
)
|
||||||
|
for removed_key in (
|
||||||
|
"allow_real_submit",
|
||||||
|
"allow_cover_update",
|
||||||
|
"close_success_tab",
|
||||||
|
"parallel_accounts",
|
||||||
|
):
|
||||||
|
merged.pop(removed_key, None)
|
||||||
|
return merged
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def ai_backend(config=None) -> str:
|
def ai_backend(config=None) -> str:
|
||||||
ai = ai_config(config)
|
ai = ai_config(config)
|
||||||
@@ -648,9 +870,13 @@ def cmhub_config(config=None) -> dict:
|
|||||||
if not isinstance(value, dict):
|
if not isinstance(value, dict):
|
||||||
raise ConfigError("ai.cmhub 必须是对象")
|
raise ConfigError("ai.cmhub 必须是对象")
|
||||||
merged = _deep_merge(DEFAULT_CONFIG["ai"]["cmhub"], value)
|
merged = _deep_merge(DEFAULT_CONFIG["ai"]["cmhub"], value)
|
||||||
merged["base_url"] = normalize_cmhub_base_url(merged.get("base_url", ""))
|
merged["base_url"] = (
|
||||||
|
normalize_cmhub_base_url(merged.get("base_url", ""))
|
||||||
|
or DEFAULT_CMHUB_BASE_URL
|
||||||
|
)
|
||||||
merged["title_alias"] = str(merged.get("title_alias", "") or "").strip()
|
merged["title_alias"] = str(merged.get("title_alias", "") or "").strip()
|
||||||
merged["image_alias"] = str(merged.get("image_alias", "") or "").strip()
|
merged["image_alias"] = str(merged.get("image_alias", "") or "").strip()
|
||||||
|
merged["vision_alias"] = str(merged.get("vision_alias", "") or "").strip()
|
||||||
merged["connect_timeout"] = _normalize_cmhub_connect_timeout(
|
merged["connect_timeout"] = _normalize_cmhub_connect_timeout(
|
||||||
merged.get("connect_timeout"),
|
merged.get("connect_timeout"),
|
||||||
migrate_old_default=False,
|
migrate_old_default=False,
|
||||||
@@ -842,6 +1068,25 @@ def update_ai_model(model_name, path=AI_MODELS_PATH, **fields) -> None:
|
|||||||
save_ai_models_config(config, path=path)
|
save_ai_models_config(config, path=path)
|
||||||
|
|
||||||
|
|
||||||
|
def rename_ai_model(model_name, new_name, path=AI_MODELS_PATH) -> dict:
|
||||||
|
"""Rename one AI model while preserving every other stored field."""
|
||||||
|
|
||||||
|
config = load_ai_models_config(path)
|
||||||
|
index = _model_index(config["models"], model_name)
|
||||||
|
renamed = copy.deepcopy(config["models"][index])
|
||||||
|
renamed["name"] = str(new_name or "").strip()
|
||||||
|
if not renamed["name"]:
|
||||||
|
raise ConfigError("AI 模型名称不能为空")
|
||||||
|
if renamed["name"] != model_name and any(
|
||||||
|
model["name"] == renamed["name"] for model in config["models"]
|
||||||
|
):
|
||||||
|
raise ConfigError(f"AI 模型名称已存在:{renamed['name']}")
|
||||||
|
normalized = _normalize_ai_model(renamed)
|
||||||
|
config["models"][index] = normalized
|
||||||
|
save_ai_models_config(config, path=path)
|
||||||
|
return copy.deepcopy(normalized)
|
||||||
|
|
||||||
|
|
||||||
def delete_ai_model(name, path=AI_MODELS_PATH) -> None:
|
def delete_ai_model(name, path=AI_MODELS_PATH) -> None:
|
||||||
config = load_ai_models_config(path)
|
config = load_ai_models_config(path)
|
||||||
index = _model_index(config["models"], name)
|
index = _model_index(config["models"], name)
|
||||||
@@ -857,6 +1102,56 @@ def get_model(name, path=AI_MODELS_PATH) -> dict:
|
|||||||
return copy.deepcopy(models[_model_index(models, name)])
|
return copy.deepcopy(models[_model_index(models, name)])
|
||||||
|
|
||||||
|
|
||||||
|
def is_image_edit_model(model) -> bool:
|
||||||
|
"""Return whether a model satisfies the direct image-edit contract."""
|
||||||
|
|
||||||
|
return (
|
||||||
|
isinstance(model, dict)
|
||||||
|
and model.get("category") == "image"
|
||||||
|
and model.get("api_type") == "images_edits"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def image_model_config_error(model) -> str:
|
||||||
|
"""Return a Chinese actionable error for a direct image model, if any."""
|
||||||
|
|
||||||
|
if not isinstance(model, dict) or model.get("category") != "image":
|
||||||
|
return "当前模型不是图片模型"
|
||||||
|
if model.get("api_type") != "images_edits":
|
||||||
|
return "图片模型仅支持 OpenAI 图片编辑接口,请在设置中选择该接口类型"
|
||||||
|
missing = [
|
||||||
|
field
|
||||||
|
for field in ("url", "model", "api_key")
|
||||||
|
if not str(model.get(field, "") or "").strip()
|
||||||
|
]
|
||||||
|
if missing:
|
||||||
|
return "图片模型缺少必要配置:" + "、".join(missing)
|
||||||
|
url = str(model.get("url") or "").strip()
|
||||||
|
parts = urllib.parse.urlsplit(url)
|
||||||
|
if parts.scheme not in {"http", "https"} or not parts.netloc:
|
||||||
|
return "图片模型网址必须使用 http 或 https"
|
||||||
|
try:
|
||||||
|
connect_timeout = int(model.get("connect_timeout_seconds", 0) or 0)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
connect_timeout = 0
|
||||||
|
if connect_timeout <= 0:
|
||||||
|
return "图片模型连接超时必须大于 0 秒"
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def check_image_model_config(name, path=AI_MODELS_PATH) -> dict:
|
||||||
|
"""Validate one image model locally without making a billable request."""
|
||||||
|
|
||||||
|
try:
|
||||||
|
model = get_model(name, path=path)
|
||||||
|
error = image_model_config_error(model)
|
||||||
|
if error:
|
||||||
|
return {"ok": False, "check_only": True, "error": error}
|
||||||
|
return {"ok": True, "check_only": True}
|
||||||
|
except Exception as exc:
|
||||||
|
return {"ok": False, "check_only": True, "error": str(exc)}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def model_request_url(model) -> str:
|
def model_request_url(model) -> str:
|
||||||
"""Return the HTTP endpoint used for a configured AI model."""
|
"""Return the HTTP endpoint used for a configured AI model."""
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
"""Bundled static assets (application icon).
|
||||||
|
|
||||||
|
The icon ships inside the package so the same file backs the window icon at
|
||||||
|
runtime and the executable icon at build time. Keep this module free of Qt
|
||||||
|
imports: the packaging spec imports it while collecting data files.
|
||||||
|
|
||||||
|
Regenerate the files with ``py -3.10 scripts/gen_logo.py``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
ICON_NAME = "cmshopee.ico"
|
||||||
|
ICON_PNG_NAME = "cmshopee-256.png"
|
||||||
|
|
||||||
|
_PACKAGE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
|
||||||
|
|
||||||
|
def _candidate_paths(name):
|
||||||
|
"""Locations to probe, covering source tree and PyInstaller onedir."""
|
||||||
|
|
||||||
|
yield os.path.join(_PACKAGE_DIR, name)
|
||||||
|
|
||||||
|
bundle_dir = getattr(sys, "_MEIPASS", None)
|
||||||
|
if bundle_dir:
|
||||||
|
yield os.path.join(bundle_dir, "app", "assets", name)
|
||||||
|
|
||||||
|
if getattr(sys, "frozen", False):
|
||||||
|
yield os.path.join(
|
||||||
|
os.path.dirname(os.path.abspath(sys.executable)),
|
||||||
|
"app",
|
||||||
|
"assets",
|
||||||
|
name,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def asset_path(name):
|
||||||
|
"""Return an existing asset path, or None when it is not bundled."""
|
||||||
|
|
||||||
|
for candidate in _candidate_paths(name):
|
||||||
|
if candidate and os.path.isfile(candidate):
|
||||||
|
return candidate
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def icon_path():
|
||||||
|
"""Path to the multi-size Windows icon, or None when unavailable."""
|
||||||
|
|
||||||
|
return asset_path(ICON_NAME)
|
||||||
|
|
||||||
|
|
||||||
|
def icon_png_path():
|
||||||
|
"""Path to the 256px PNG fallback, or None when unavailable."""
|
||||||
|
|
||||||
|
return asset_path(ICON_PNG_NAME)
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1,14 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" width="256" height="256" role="img" aria-label="蝦皮圈優化助手">
|
||||||
|
<title>蝦皮圈優化助手</title>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="badge" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0" stop-color="#F5793F"/>
|
||||||
|
<stop offset="1" stop-color="#D6371C"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect x="10" y="10" width="236" height="236" rx="54" fill="url(#badge)"/>
|
||||||
|
<path d="M 112 58 L 56 116 L 168 116 Z" fill="#FFFFFF"/>
|
||||||
|
<rect x="90" y="110" width="44" height="52" rx="10" fill="#FFFFFF"/>
|
||||||
|
<rect x="56" y="178" width="112" height="26" rx="13" fill="#FFFFFF"/>
|
||||||
|
<polygon points="194.0,40.0 201.8,66.2 228.0,74.0 201.8,81.8 194.0,108.0 186.2,81.8 160.0,74.0 186.2,66.2" fill="#FFFFFF"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 777 B |
Binary file not shown.
|
After Width: | Height: | Size: 36 KiB |
+51
-4
@@ -22,12 +22,12 @@ def _base(host=None):
|
|||||||
return f"http://{host or CDP_HOST}"
|
return f"http://{host or CDP_HOST}"
|
||||||
|
|
||||||
|
|
||||||
def http_get(path, host=None):
|
def http_get(path, host=None, timeout=10):
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
s = requests.Session()
|
s = requests.Session()
|
||||||
s.trust_env = False # 忽略环境代理
|
s.trust_env = False # 忽略环境代理
|
||||||
return s.get(f"{_base(host)}{path}", timeout=10).json()
|
return s.get(f"{_base(host)}{path}", timeout=timeout).json()
|
||||||
|
|
||||||
|
|
||||||
def close_tab(target_id, host=None):
|
def close_tab(target_id, host=None):
|
||||||
@@ -43,6 +43,45 @@ def close_tab(target_id, host=None):
|
|||||||
return response.ok
|
return response.ok
|
||||||
|
|
||||||
|
|
||||||
|
def wait_target_closed(target_id, host=None, timeout=2.0, poll_interval=0.1):
|
||||||
|
"""Wait briefly for a closed target to disappear from Chrome's target list."""
|
||||||
|
|
||||||
|
if not target_id:
|
||||||
|
return True
|
||||||
|
timeout = max(0.0, float(timeout))
|
||||||
|
poll_interval = max(0.01, float(poll_interval))
|
||||||
|
deadline = time.monotonic() + timeout
|
||||||
|
while True:
|
||||||
|
remaining = max(0.0, deadline - time.monotonic())
|
||||||
|
request_timeout = max(0.05, min(1.0, remaining))
|
||||||
|
try:
|
||||||
|
targets = http_get("/json", host=host, timeout=request_timeout)
|
||||||
|
except Exception:
|
||||||
|
targets = None
|
||||||
|
if targets is not None and not any(
|
||||||
|
str(target.get("id") or "") == str(target_id)
|
||||||
|
for target in targets
|
||||||
|
):
|
||||||
|
return True
|
||||||
|
remaining = deadline - time.monotonic()
|
||||||
|
if remaining <= 0:
|
||||||
|
return False
|
||||||
|
time.sleep(min(poll_interval, remaining))
|
||||||
|
|
||||||
|
|
||||||
|
def close_tab_and_wait(target_id, host=None, timeout=2.0, poll_interval=0.1):
|
||||||
|
"""Request target closure and confirm disappearance within a bounded budget."""
|
||||||
|
|
||||||
|
if not close_tab(target_id, host=host):
|
||||||
|
return False
|
||||||
|
return wait_target_closed(
|
||||||
|
target_id,
|
||||||
|
host=host,
|
||||||
|
timeout=timeout,
|
||||||
|
poll_interval=poll_interval,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def activate_tab(target_id, host=None):
|
def activate_tab(target_id, host=None):
|
||||||
"""Bring an existing Chrome target/page to the foreground."""
|
"""Bring an existing Chrome target/page to the foreground."""
|
||||||
|
|
||||||
@@ -160,12 +199,20 @@ def find_product_tab(item_id, host=None):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def create_tab_info(url, host=None):
|
def create_tab_info(url, host=None, background=False):
|
||||||
"""用 browser 级 Target.createTarget 新建 tab,返回其 page target 信息。"""
|
"""用 browser 级 Target.createTarget 新建 tab,返回其 page target 信息。"""
|
||||||
ver = http_get("/json/version", host=host)
|
ver = http_get("/json/version", host=host)
|
||||||
b = CDP(ver["webSocketDebuggerUrl"])
|
b = CDP(ver["webSocketDebuggerUrl"])
|
||||||
try:
|
try:
|
||||||
tid = b.send("Target.createTarget", {"url": url})["targetId"]
|
params = {"url": url}
|
||||||
|
if background:
|
||||||
|
params["background"] = True
|
||||||
|
try:
|
||||||
|
tid = b.send("Target.createTarget", params)["targetId"]
|
||||||
|
except RuntimeError:
|
||||||
|
if not background:
|
||||||
|
raise
|
||||||
|
tid = b.send("Target.createTarget", {"url": url})["targetId"]
|
||||||
finally:
|
finally:
|
||||||
b.close()
|
b.close()
|
||||||
end = time.time() + 15
|
end = time.time() + 15
|
||||||
|
|||||||
+185
-4
@@ -5,22 +5,178 @@ from __future__ import annotations
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import time
|
import time
|
||||||
import urllib.error
|
import urllib.error
|
||||||
import urllib.request
|
import urllib.request
|
||||||
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
from . import appconfig
|
from . import appconfig
|
||||||
from . import config as account_config
|
from . import config as account_config
|
||||||
|
|
||||||
|
|
||||||
CDP_HOST = "127.0.0.1"
|
CDP_HOST = "127.0.0.1"
|
||||||
|
CHROME_APP_PATHS_KEY = r"SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths\chrome.exe"
|
||||||
|
|
||||||
|
|
||||||
class ChromeLaunchError(RuntimeError):
|
class ChromeLaunchError(RuntimeError):
|
||||||
"""Raised when Chrome launch configuration is invalid."""
|
"""Raised when Chrome launch configuration is invalid."""
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_chrome_path(value) -> str:
|
||||||
|
"""Return a filesystem-ready Chrome path, or an empty string."""
|
||||||
|
|
||||||
|
text = str(value or "").strip()
|
||||||
|
if len(text) >= 2 and text[0] == text[-1] and text[0] in {"\"", "'"}:
|
||||||
|
text = text[1:-1].strip()
|
||||||
|
if not text:
|
||||||
|
return ""
|
||||||
|
text = os.path.expandvars(os.path.expanduser(text))
|
||||||
|
return os.path.abspath(text)
|
||||||
|
|
||||||
|
|
||||||
|
def _load_winreg():
|
||||||
|
"""Return winreg only on Windows so non-Windows callers stay harmless."""
|
||||||
|
|
||||||
|
if os.name != "nt":
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
import winreg
|
||||||
|
except ImportError:
|
||||||
|
return None
|
||||||
|
return winreg
|
||||||
|
|
||||||
|
|
||||||
|
def _registry_view_flags(winreg):
|
||||||
|
flags = [0]
|
||||||
|
for name in ("KEY_WOW64_64KEY", "KEY_WOW64_32KEY"):
|
||||||
|
value = getattr(winreg, name, 0)
|
||||||
|
if value and value not in flags:
|
||||||
|
flags.append(value)
|
||||||
|
return flags
|
||||||
|
|
||||||
|
|
||||||
|
def _close_registry_key(key):
|
||||||
|
close = getattr(key, "Close", None) or getattr(key, "close", None)
|
||||||
|
if callable(close):
|
||||||
|
try:
|
||||||
|
close()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _registry_chrome_candidates():
|
||||||
|
"""Yield Chrome App Paths values without exposing registry failures."""
|
||||||
|
|
||||||
|
winreg = _load_winreg()
|
||||||
|
if winreg is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
key_read = getattr(winreg, "KEY_READ", 0)
|
||||||
|
hives = (
|
||||||
|
getattr(winreg, "HKEY_CURRENT_USER", None),
|
||||||
|
getattr(winreg, "HKEY_LOCAL_MACHINE", None),
|
||||||
|
)
|
||||||
|
seen = set()
|
||||||
|
for hive in hives:
|
||||||
|
if hive is None:
|
||||||
|
continue
|
||||||
|
for view_flag in _registry_view_flags(winreg):
|
||||||
|
marker = (hive, view_flag)
|
||||||
|
if marker in seen:
|
||||||
|
continue
|
||||||
|
seen.add(marker)
|
||||||
|
key = None
|
||||||
|
try:
|
||||||
|
key = winreg.OpenKey(
|
||||||
|
hive,
|
||||||
|
CHROME_APP_PATHS_KEY,
|
||||||
|
0,
|
||||||
|
key_read | view_flag,
|
||||||
|
)
|
||||||
|
value, _value_type = winreg.QueryValueEx(key, "")
|
||||||
|
except (AttributeError, OSError):
|
||||||
|
continue
|
||||||
|
finally:
|
||||||
|
if key is not None:
|
||||||
|
_close_registry_key(key)
|
||||||
|
if value:
|
||||||
|
yield value
|
||||||
|
|
||||||
|
|
||||||
|
def _standard_chrome_candidates():
|
||||||
|
"""Yield the supported Windows Chrome install locations."""
|
||||||
|
|
||||||
|
suffix = ("Google", "Chrome", "Application", "chrome.exe")
|
||||||
|
for variable in ("ProgramFiles", "ProgramFiles(x86)", "LOCALAPPDATA"):
|
||||||
|
base = os.environ.get(variable, "").strip()
|
||||||
|
if base:
|
||||||
|
yield os.path.join(base, *suffix)
|
||||||
|
|
||||||
|
|
||||||
|
def is_configured_chrome_path_valid(value) -> bool:
|
||||||
|
"""Return whether a configured Chrome executable can be launched safely."""
|
||||||
|
|
||||||
|
normalized = _normalize_chrome_path(value)
|
||||||
|
if normalized and os.path.isfile(normalized):
|
||||||
|
return True
|
||||||
|
text = str(value or "").strip().strip("\"")
|
||||||
|
return text.lower() == "chrome.exe" and bool(shutil.which(text))
|
||||||
|
|
||||||
|
|
||||||
|
def detect_chrome_path() -> str:
|
||||||
|
"""Find an installed Google Chrome executable without probing other browsers."""
|
||||||
|
|
||||||
|
for candidate in _registry_chrome_candidates():
|
||||||
|
normalized = _normalize_chrome_path(candidate)
|
||||||
|
if normalized and os.path.isfile(normalized):
|
||||||
|
return normalized
|
||||||
|
for candidate in _standard_chrome_candidates():
|
||||||
|
normalized = _normalize_chrome_path(candidate)
|
||||||
|
if normalized and os.path.isfile(normalized):
|
||||||
|
return normalized
|
||||||
|
path_candidate = shutil.which("chrome.exe")
|
||||||
|
normalized = _normalize_chrome_path(path_candidate)
|
||||||
|
if normalized and os.path.isfile(normalized):
|
||||||
|
return normalized
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_configured_chrome_path(config=None, config_path=None) -> dict:
|
||||||
|
"""Persist an auto-detected path only when the configured path is unusable."""
|
||||||
|
|
||||||
|
path = config_path or (config or {}).get("config_path") or appconfig.CONFIG_PATH
|
||||||
|
current = appconfig.load_config(path) if config is None else config
|
||||||
|
configured_path = appconfig.chrome_path(current)
|
||||||
|
if is_configured_chrome_path_valid(configured_path):
|
||||||
|
return {
|
||||||
|
"changed": False,
|
||||||
|
"config": current,
|
||||||
|
"path": configured_path,
|
||||||
|
"message": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
detected_path = detect_chrome_path()
|
||||||
|
if not detected_path:
|
||||||
|
return {
|
||||||
|
"changed": False,
|
||||||
|
"config": current,
|
||||||
|
"path": configured_path,
|
||||||
|
"message": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
updated = dict(current)
|
||||||
|
updated["chrome_path"] = detected_path
|
||||||
|
saved = appconfig.save_config(updated, path=path)
|
||||||
|
return {
|
||||||
|
"changed": True,
|
||||||
|
"config": saved,
|
||||||
|
"path": detected_path,
|
||||||
|
"message": f"已自动定位 Chrome:{detected_path}",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _account_value(account, field, default=None):
|
def _account_value(account, field, default=None):
|
||||||
if isinstance(account, dict):
|
if isinstance(account, dict):
|
||||||
return account.get(field, default)
|
return account.get(field, default)
|
||||||
@@ -54,7 +210,27 @@ def _user_data_dir(account, config=None) -> str:
|
|||||||
return account_config.ensure_user_data_dir(slug, config=config)
|
return account_config.ensure_user_data_dir(slug, config=config)
|
||||||
|
|
||||||
|
|
||||||
def build_launch_args(account, config=None) -> list:
|
def _validated_initial_url(value):
|
||||||
|
text = str(value or "").strip()
|
||||||
|
if not text:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
parsed = urlsplit(text)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise ChromeLaunchError("Chrome 初始页面必须是有效的蝦皮 http/https 地址") from exc
|
||||||
|
hostname = str(parsed.hostname or "").lower()
|
||||||
|
is_shopee_host = hostname.startswith("shopee.") or ".shopee." in hostname
|
||||||
|
if (
|
||||||
|
parsed.scheme.lower() not in {"http", "https"}
|
||||||
|
or not is_shopee_host
|
||||||
|
or parsed.username
|
||||||
|
or parsed.password
|
||||||
|
):
|
||||||
|
raise ChromeLaunchError("Chrome 初始页面必须是有效的蝦皮 http/https 地址")
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def build_launch_args(account, config=None, initial_url=None) -> list:
|
||||||
"""Build Chrome command arguments for one account."""
|
"""Build Chrome command arguments for one account."""
|
||||||
|
|
||||||
chrome_path = appconfig.chrome_path(config)
|
chrome_path = appconfig.chrome_path(config)
|
||||||
@@ -62,12 +238,17 @@ def build_launch_args(account, config=None) -> list:
|
|||||||
raise ChromeLaunchError("chrome_path 不能为空")
|
raise ChromeLaunchError("chrome_path 不能为空")
|
||||||
port = _debug_port(account)
|
port = _debug_port(account)
|
||||||
user_data_dir = _user_data_dir(account, config=config)
|
user_data_dir = _user_data_dir(account, config=config)
|
||||||
return [
|
args = [
|
||||||
chrome_path,
|
chrome_path,
|
||||||
f"--remote-debugging-port={port}",
|
f"--remote-debugging-port={port}",
|
||||||
"--remote-allow-origins=*",
|
"--remote-allow-origins=*",
|
||||||
f"--user-data-dir={user_data_dir}",
|
f"--user-data-dir={user_data_dir}",
|
||||||
]
|
]
|
||||||
|
startup_url = _validated_initial_url(initial_url)
|
||||||
|
if startup_url:
|
||||||
|
args.extend(("--no-first-run", "--no-default-browser-check"))
|
||||||
|
args.append(startup_url)
|
||||||
|
return args
|
||||||
|
|
||||||
|
|
||||||
def _safe_shortcut_name(value) -> str:
|
def _safe_shortcut_name(value) -> str:
|
||||||
@@ -145,10 +326,10 @@ def create_shortcut(account, shortcut_path=None, desktop_dir=None, name=None, co
|
|||||||
return shortcut_path
|
return shortcut_path
|
||||||
|
|
||||||
|
|
||||||
def launch_chrome(account, config=None) -> subprocess.Popen:
|
def launch_chrome(account, config=None, initial_url=None) -> subprocess.Popen:
|
||||||
"""Launch Chrome for one account and return the process handle."""
|
"""Launch Chrome for one account and return the process handle."""
|
||||||
|
|
||||||
args = build_launch_args(account, config=config)
|
args = build_launch_args(account, config=config, initial_url=initial_url)
|
||||||
try:
|
try:
|
||||||
return subprocess.Popen(args)
|
return subprocess.Popen(args)
|
||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
|
|||||||
@@ -0,0 +1,205 @@
|
|||||||
|
"""Remote client-policy parsing and non-sensitive local caching."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
from dataclasses import dataclass, replace
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from . import appconfig
|
||||||
|
|
||||||
|
|
||||||
|
POLICY_VERSION = 1
|
||||||
|
CACHE_VERSION = 1
|
||||||
|
CACHE_FILENAME = "client_policy.json"
|
||||||
|
|
||||||
|
|
||||||
|
class ClientPolicyError(ValueError):
|
||||||
|
"""Raised when a remote or cached client policy is malformed."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ClientPolicy:
|
||||||
|
policy_version: int
|
||||||
|
subscription_check_enabled: bool
|
||||||
|
subscription_enforcement_enabled: bool
|
||||||
|
updated_at: str
|
||||||
|
source: str = "remote"
|
||||||
|
warning: str = ""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def mode(self) -> str:
|
||||||
|
if not self.subscription_check_enabled:
|
||||||
|
return "off"
|
||||||
|
if self.subscription_enforcement_enabled:
|
||||||
|
return "enforce"
|
||||||
|
return "observe"
|
||||||
|
|
||||||
|
def to_contract_dict(self) -> dict:
|
||||||
|
return {
|
||||||
|
"policy_version": self.policy_version,
|
||||||
|
"subscription_check_enabled": self.subscription_check_enabled,
|
||||||
|
"subscription_enforcement_enabled": (
|
||||||
|
self.subscription_enforcement_enabled
|
||||||
|
),
|
||||||
|
"updated_at": self.updated_at,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def observation_policy(*, source="fallback", warning="") -> ClientPolicy:
|
||||||
|
"""Return the fail-open client fallback that still observes entitlement."""
|
||||||
|
|
||||||
|
return ClientPolicy(
|
||||||
|
policy_version=POLICY_VERSION,
|
||||||
|
subscription_check_enabled=True,
|
||||||
|
subscription_enforcement_enabled=False,
|
||||||
|
updated_at="",
|
||||||
|
source=source,
|
||||||
|
warning=warning,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_client_policy(value, *, source="remote") -> ClientPolicy:
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
raise ClientPolicyError("客户端策略不是 JSON 对象")
|
||||||
|
|
||||||
|
policy_version = value.get("policy_version")
|
||||||
|
if type(policy_version) is not int or policy_version != POLICY_VERSION:
|
||||||
|
raise ClientPolicyError("客户端策略版本不受支持")
|
||||||
|
|
||||||
|
check_enabled = value.get("subscription_check_enabled")
|
||||||
|
enforcement_enabled = value.get("subscription_enforcement_enabled")
|
||||||
|
if type(check_enabled) is not bool or type(enforcement_enabled) is not bool:
|
||||||
|
raise ClientPolicyError("客户端订阅策略开关必须是布尔值")
|
||||||
|
|
||||||
|
updated_at = _parse_timezone_timestamp(
|
||||||
|
value.get("updated_at"),
|
||||||
|
field_name="客户端策略更新时间",
|
||||||
|
)
|
||||||
|
warning = ""
|
||||||
|
if not check_enabled and enforcement_enabled:
|
||||||
|
enforcement_enabled = False
|
||||||
|
warning = "客户端订阅策略组合非法,已按关闭模式处理"
|
||||||
|
|
||||||
|
return ClientPolicy(
|
||||||
|
policy_version=policy_version,
|
||||||
|
subscription_check_enabled=check_enabled,
|
||||||
|
subscription_enforcement_enabled=enforcement_enabled,
|
||||||
|
updated_at=updated_at,
|
||||||
|
source=source,
|
||||||
|
warning=warning,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def policy_cache_path(config=None) -> str:
|
||||||
|
return appconfig.data_path("config", CACHE_FILENAME, config=config)
|
||||||
|
|
||||||
|
|
||||||
|
def save_cached_policy(
|
||||||
|
policy: ClientPolicy,
|
||||||
|
*,
|
||||||
|
config=None,
|
||||||
|
path=None,
|
||||||
|
cached_at=None,
|
||||||
|
) -> str:
|
||||||
|
destination = os.path.abspath(path or policy_cache_path(config))
|
||||||
|
os.makedirs(os.path.dirname(destination), exist_ok=True)
|
||||||
|
acquired_at = _parse_timezone_timestamp(
|
||||||
|
cached_at or _now_iso(),
|
||||||
|
field_name="客户端策略缓存时间",
|
||||||
|
)
|
||||||
|
payload = {
|
||||||
|
"cache_version": CACHE_VERSION,
|
||||||
|
"cached_at": acquired_at,
|
||||||
|
"client_policy": policy.to_contract_dict(),
|
||||||
|
}
|
||||||
|
temporary_path = ""
|
||||||
|
try:
|
||||||
|
with tempfile.NamedTemporaryFile(
|
||||||
|
mode="w",
|
||||||
|
encoding="utf-8",
|
||||||
|
dir=os.path.dirname(destination),
|
||||||
|
prefix="client-policy-",
|
||||||
|
suffix=".tmp",
|
||||||
|
delete=False,
|
||||||
|
) as handle:
|
||||||
|
temporary_path = handle.name
|
||||||
|
json.dump(payload, handle, ensure_ascii=False, indent=2)
|
||||||
|
handle.write("\n")
|
||||||
|
handle.flush()
|
||||||
|
os.fsync(handle.fileno())
|
||||||
|
os.replace(temporary_path, destination)
|
||||||
|
finally:
|
||||||
|
if temporary_path and os.path.exists(temporary_path):
|
||||||
|
try:
|
||||||
|
os.remove(temporary_path)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return destination
|
||||||
|
|
||||||
|
|
||||||
|
def load_cached_policy(*, config=None, path=None) -> ClientPolicy:
|
||||||
|
source_path = os.path.abspath(path or policy_cache_path(config))
|
||||||
|
try:
|
||||||
|
with open(source_path, "r", encoding="utf-8") as handle:
|
||||||
|
payload = json.load(handle)
|
||||||
|
except (OSError, ValueError) as exc:
|
||||||
|
raise ClientPolicyError("客户端策略缓存不可用") from exc
|
||||||
|
cache_version = payload.get("cache_version") if isinstance(payload, dict) else None
|
||||||
|
if type(cache_version) is not int or cache_version != CACHE_VERSION:
|
||||||
|
raise ClientPolicyError("客户端策略缓存版本不受支持")
|
||||||
|
_parse_timezone_timestamp(
|
||||||
|
payload.get("cached_at"),
|
||||||
|
field_name="客户端策略缓存时间",
|
||||||
|
)
|
||||||
|
return parse_client_policy(payload.get("client_policy"), source="cache")
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_client_policy(
|
||||||
|
remote_policy,
|
||||||
|
*,
|
||||||
|
config=None,
|
||||||
|
path=None,
|
||||||
|
) -> ClientPolicy:
|
||||||
|
"""Prefer a valid live policy, then cache, then observation mode."""
|
||||||
|
|
||||||
|
if isinstance(remote_policy, ClientPolicy):
|
||||||
|
try:
|
||||||
|
save_cached_policy(remote_policy, config=config, path=path)
|
||||||
|
except (OSError, ClientPolicyError) as exc:
|
||||||
|
warning = _join_warning(
|
||||||
|
remote_policy.warning,
|
||||||
|
"客户端策略缓存写入失败:%s" % exc,
|
||||||
|
)
|
||||||
|
return replace(remote_policy, source="remote", warning=warning)
|
||||||
|
return replace(remote_policy, source="remote")
|
||||||
|
|
||||||
|
try:
|
||||||
|
return load_cached_policy(config=config, path=path)
|
||||||
|
except ClientPolicyError as exc:
|
||||||
|
return observation_policy(
|
||||||
|
warning="未取得有效远程策略或本地缓存,已使用观察模式:%s" % exc
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_timezone_timestamp(value, *, field_name) -> str:
|
||||||
|
text = str(value or "").strip()
|
||||||
|
if not text:
|
||||||
|
raise ClientPolicyError("%s不能为空" % field_name)
|
||||||
|
try:
|
||||||
|
parsed = datetime.fromisoformat(text.replace("Z", "+00:00"))
|
||||||
|
except ValueError as exc:
|
||||||
|
raise ClientPolicyError("%s格式不正确" % field_name) from exc
|
||||||
|
if parsed.tzinfo is None or parsed.utcoffset() is None:
|
||||||
|
raise ClientPolicyError("%s必须包含时区" % field_name)
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def _now_iso() -> str:
|
||||||
|
return datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds")
|
||||||
|
|
||||||
|
|
||||||
|
def _join_warning(*messages) -> str:
|
||||||
|
return ";".join(str(message).strip() for message in messages if str(message).strip())
|
||||||
@@ -0,0 +1,330 @@
|
|||||||
|
"""User-facing helpers for cmhub hosted model aliases."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import copy
|
||||||
|
from decimal import Decimal, InvalidOperation
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
|
||||||
|
|
||||||
|
TIER_DEFAULT = "default"
|
||||||
|
TIER_HIGH_QUALITY = "high_quality"
|
||||||
|
TIER_ECONOMICAL = "economical"
|
||||||
|
|
||||||
|
TIER_LABELS = {
|
||||||
|
TIER_DEFAULT: "默认档",
|
||||||
|
TIER_HIGH_QUALITY: "高质量档",
|
||||||
|
TIER_ECONOMICAL: "省点档",
|
||||||
|
}
|
||||||
|
|
||||||
|
TIER_DESCRIPTIONS = {
|
||||||
|
TIER_DEFAULT: "日常批量,质量、速度和成本平衡",
|
||||||
|
TIER_HIGH_QUALITY: "重点商品或失败重试,质量优先,成本可能更高",
|
||||||
|
TIER_ECONOMICAL: "低价值 SKU 或初稿,省点优先,上线前需人工复核",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# The catalog is deliberately process-local. It only caches public model metadata,
|
||||||
|
# never the API key or a price estimate chosen by a user.
|
||||||
|
MODEL_CATALOG_CACHE_TTL_SECONDS = 300
|
||||||
|
_MODEL_CATALOG_CACHE = {}
|
||||||
|
_MODEL_CATALOG_CACHE_LOCK = threading.RLock()
|
||||||
|
_CATALOG_ALL_ALIASES_KEY = "*"
|
||||||
|
_PRICE_CONDITION_KEYS = (
|
||||||
|
"resolution",
|
||||||
|
"name",
|
||||||
|
"quality",
|
||||||
|
"size",
|
||||||
|
"ratio",
|
||||||
|
"aspect_ratio",
|
||||||
|
"tier",
|
||||||
|
"condition",
|
||||||
|
"conditions",
|
||||||
|
"min_images",
|
||||||
|
"max_images",
|
||||||
|
"image_count",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def cache_model_catalog(base_url, models, *, now=None):
|
||||||
|
"""Cache one fetched catalog in memory for all aliases it contains."""
|
||||||
|
|
||||||
|
normalized_base_url = _catalog_base_url(base_url)
|
||||||
|
if not normalized_base_url:
|
||||||
|
return
|
||||||
|
copied_models = [copy.deepcopy(model) for model in list(models or []) if isinstance(model, dict)]
|
||||||
|
fetched_at = float(time.monotonic() if now is None else now)
|
||||||
|
entry = (fetched_at, copied_models)
|
||||||
|
aliases = {
|
||||||
|
str(model.get("alias") or "").strip()
|
||||||
|
for model in copied_models
|
||||||
|
if str(model.get("alias") or "").strip()
|
||||||
|
}
|
||||||
|
with _MODEL_CATALOG_CACHE_LOCK:
|
||||||
|
_MODEL_CATALOG_CACHE[(normalized_base_url, _CATALOG_ALL_ALIASES_KEY)] = entry
|
||||||
|
for alias in aliases:
|
||||||
|
_MODEL_CATALOG_CACHE[(normalized_base_url, alias)] = entry
|
||||||
|
|
||||||
|
|
||||||
|
def cached_model_catalog(base_url, alias="", *, max_age_seconds=None, now=None):
|
||||||
|
"""Return a fresh catalog copy for a normalized gateway/alias pair, if available."""
|
||||||
|
|
||||||
|
normalized_base_url = _catalog_base_url(base_url)
|
||||||
|
normalized_alias = str(alias or "").strip()
|
||||||
|
if not normalized_base_url:
|
||||||
|
return None
|
||||||
|
max_age = MODEL_CATALOG_CACHE_TTL_SECONDS if max_age_seconds is None else max_age_seconds
|
||||||
|
try:
|
||||||
|
max_age = float(max_age)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
current = float(time.monotonic() if now is None else now)
|
||||||
|
keys = [(normalized_base_url, normalized_alias)] if normalized_alias else []
|
||||||
|
keys.append((normalized_base_url, _CATALOG_ALL_ALIASES_KEY))
|
||||||
|
with _MODEL_CATALOG_CACHE_LOCK:
|
||||||
|
for key in keys:
|
||||||
|
entry = _MODEL_CATALOG_CACHE.get(key)
|
||||||
|
if entry is None:
|
||||||
|
continue
|
||||||
|
fetched_at, models = entry
|
||||||
|
if current - fetched_at > max_age:
|
||||||
|
continue
|
||||||
|
return [copy.deepcopy(model) for model in models]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def clear_model_catalog_cache():
|
||||||
|
"""Clear the transient catalog cache. Intended for tests and gateway changes."""
|
||||||
|
|
||||||
|
with _MODEL_CATALOG_CACHE_LOCK:
|
||||||
|
_MODEL_CATALOG_CACHE.clear()
|
||||||
|
|
||||||
|
|
||||||
|
def unambiguous_points_cost(models, alias, operation_type, *, requires_image=None):
|
||||||
|
"""Return the unique unconditional price for one configured alias, otherwise ``None``."""
|
||||||
|
|
||||||
|
normalized_alias = str(alias or "").strip()
|
||||||
|
normalized_operation = str(operation_type or "").strip().lower()
|
||||||
|
matches = [
|
||||||
|
model
|
||||||
|
for model in list(models or [])
|
||||||
|
if isinstance(model, dict) and str(model.get("alias") or "").strip() == normalized_alias
|
||||||
|
]
|
||||||
|
if len(matches) != 1:
|
||||||
|
return None
|
||||||
|
model = matches[0]
|
||||||
|
if str(model.get("operation_type") or "").strip().lower() != normalized_operation:
|
||||||
|
return None
|
||||||
|
if str(model.get("pricing_status") or "").strip().lower() != "priced":
|
||||||
|
return None
|
||||||
|
if requires_image is True and model.get("requires_image") is not True:
|
||||||
|
return None
|
||||||
|
if requires_image is False and model.get("requires_image") is not False:
|
||||||
|
return None
|
||||||
|
prices = model.get("prices")
|
||||||
|
if not isinstance(prices, list) or len(prices) != 1 or not isinstance(prices[0], dict):
|
||||||
|
return None
|
||||||
|
price = prices[0]
|
||||||
|
if any(_has_value(price.get(key)) for key in _PRICE_CONDITION_KEYS):
|
||||||
|
return None
|
||||||
|
return _points_cost_value(price)
|
||||||
|
|
||||||
|
|
||||||
|
def format_points_cost(value):
|
||||||
|
"""Format a catalog points value without inventing precision."""
|
||||||
|
|
||||||
|
if value is None:
|
||||||
|
return ""
|
||||||
|
decimal_value = _points_cost_decimal(value)
|
||||||
|
if decimal_value is None:
|
||||||
|
return ""
|
||||||
|
text = format(decimal_value.normalize(), "f")
|
||||||
|
if "." in text:
|
||||||
|
text = text.rstrip("0").rstrip(".")
|
||||||
|
return text or "0"
|
||||||
|
|
||||||
|
|
||||||
|
def _catalog_base_url(base_url):
|
||||||
|
return str(base_url or "").strip().rstrip("/").lower()
|
||||||
|
|
||||||
|
|
||||||
|
def _has_value(value):
|
||||||
|
return value not in (None, "", [], {}, ())
|
||||||
|
|
||||||
|
|
||||||
|
def _points_cost_value(price):
|
||||||
|
if not isinstance(price, dict):
|
||||||
|
return None
|
||||||
|
if "points_cost" not in price:
|
||||||
|
return None
|
||||||
|
return _points_cost_decimal(price.get("points_cost"))
|
||||||
|
|
||||||
|
|
||||||
|
def _points_cost_decimal(value):
|
||||||
|
if isinstance(value, bool) or value is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
decimal_value = Decimal(str(value))
|
||||||
|
except (InvalidOperation, ValueError):
|
||||||
|
return None
|
||||||
|
if not decimal_value.is_finite() or decimal_value < 0:
|
||||||
|
return None
|
||||||
|
return decimal_value
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_tier(value):
|
||||||
|
text = str(value or "").strip().lower().replace("-", "_").replace(" ", "_")
|
||||||
|
if text in {"high", "quality", "high_quality", "premium", "pro", "sol"}:
|
||||||
|
return TIER_HIGH_QUALITY
|
||||||
|
if text in {"low", "cheap", "economy", "economical", "saving", "lite", "luna"}:
|
||||||
|
return TIER_ECONOMICAL
|
||||||
|
if text in {"default", "standard", "balanced", "balance", "terra"}:
|
||||||
|
return TIER_DEFAULT
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def model_tier(model):
|
||||||
|
model = model if isinstance(model, dict) else {}
|
||||||
|
for key in ("tier", "model_tier", "quality_tier", "cmhub_tier", "recommended_tier"):
|
||||||
|
tier = normalize_tier(model.get(key))
|
||||||
|
if tier:
|
||||||
|
return tier
|
||||||
|
haystack = " ".join(_model_text_values(model)).lower()
|
||||||
|
if any(token in haystack for token in ("省点", "低成本", "高频", "high-volume", "luna", "economy", "cheap", "lite")):
|
||||||
|
return TIER_ECONOMICAL
|
||||||
|
if any(token in haystack for token in ("高质量", "旗舰", "重点", "失败重试", "premium", "high-quality", "sol")):
|
||||||
|
return TIER_HIGH_QUALITY
|
||||||
|
if any(token in haystack for token in ("平衡", "默认", "日常", "standard", "balanced", "terra")):
|
||||||
|
return TIER_DEFAULT
|
||||||
|
alias = str(model.get("alias") or "").lower()
|
||||||
|
if alias.endswith("-hd") or alias.endswith("_hd") or alias.endswith(".hd") or alias == "hd":
|
||||||
|
return TIER_HIGH_QUALITY
|
||||||
|
return TIER_DEFAULT
|
||||||
|
|
||||||
|
|
||||||
|
def tier_label(tier):
|
||||||
|
return TIER_LABELS.get(normalize_tier(tier) or tier, TIER_LABELS[TIER_DEFAULT])
|
||||||
|
|
||||||
|
|
||||||
|
def tier_description(tier):
|
||||||
|
return TIER_DESCRIPTIONS.get(normalize_tier(tier) or tier, TIER_DESCRIPTIONS[TIER_DEFAULT])
|
||||||
|
|
||||||
|
|
||||||
|
def model_display_name(model):
|
||||||
|
model = model if isinstance(model, dict) else {}
|
||||||
|
for key in ("display_name", "name", "label"):
|
||||||
|
value = str(model.get(key) or "").strip()
|
||||||
|
if value:
|
||||||
|
return value
|
||||||
|
return str(model.get("alias") or "").strip()
|
||||||
|
|
||||||
|
|
||||||
|
def price_text(prices):
|
||||||
|
if not isinstance(prices, list):
|
||||||
|
return ""
|
||||||
|
parts = []
|
||||||
|
for item in prices[:3]:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
cost = item.get("points_cost")
|
||||||
|
if cost is None:
|
||||||
|
cost = item.get("cost")
|
||||||
|
if cost is None:
|
||||||
|
continue
|
||||||
|
resolution = item.get("resolution") or item.get("name") or ""
|
||||||
|
parts.append(f"{resolution}:{cost}点" if resolution else f"{cost}点")
|
||||||
|
return "/".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def alias_label(model):
|
||||||
|
model = model if isinstance(model, dict) else {}
|
||||||
|
alias = str(model.get("alias") or "").strip()
|
||||||
|
display_name = model_display_name(model)
|
||||||
|
tier = model_tier(model)
|
||||||
|
parts = [tier_label(tier)]
|
||||||
|
if display_name and display_name != alias:
|
||||||
|
parts.append(display_name)
|
||||||
|
elif alias:
|
||||||
|
parts.append(alias)
|
||||||
|
prices = price_text(model.get("prices"))
|
||||||
|
if prices:
|
||||||
|
parts.append(prices)
|
||||||
|
if model.get("requires_image"):
|
||||||
|
parts.append("需参考图")
|
||||||
|
return " · ".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def alias_tooltip(model):
|
||||||
|
model = model if isinstance(model, dict) else {}
|
||||||
|
alias = str(model.get("alias") or "").strip()
|
||||||
|
tier = model_tier(model)
|
||||||
|
usage = _recommended_for_text(model)
|
||||||
|
lines = [f"{tier_label(tier)}:{usage or tier_description(tier)}"]
|
||||||
|
if alias:
|
||||||
|
lines.append(f"cmhub 别名:{alias}")
|
||||||
|
prices = price_text(model.get("prices"))
|
||||||
|
if prices:
|
||||||
|
lines.append(f"扣点:{prices}")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def configured_alias_summary(alias, *, model=None):
|
||||||
|
alias = str(alias or "").strip()
|
||||||
|
if model is None:
|
||||||
|
model = {"alias": alias}
|
||||||
|
else:
|
||||||
|
model = dict(model)
|
||||||
|
model.setdefault("alias", alias)
|
||||||
|
tier = model_tier(model)
|
||||||
|
if alias:
|
||||||
|
return f"cmhub 托管{tier_label(tier)},生图别名 {alias},扣点以返回结果为准"
|
||||||
|
return "cmhub 托管默认档,未配置生图别名;请先到设置选择生图别名"
|
||||||
|
|
||||||
|
|
||||||
|
def _recommended_for_text(model):
|
||||||
|
for key in ("recommended_for", "usage", "description"):
|
||||||
|
value = model.get(key)
|
||||||
|
if isinstance(value, (list, tuple)):
|
||||||
|
text = "、".join(str(item).strip() for item in value if str(item).strip())
|
||||||
|
else:
|
||||||
|
text = str(value or "").strip()
|
||||||
|
if text:
|
||||||
|
return text
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _model_text_values(model):
|
||||||
|
for key in (
|
||||||
|
"alias",
|
||||||
|
"display_name",
|
||||||
|
"name",
|
||||||
|
"label",
|
||||||
|
"tier",
|
||||||
|
"model_tier",
|
||||||
|
"quality_tier",
|
||||||
|
"recommended_tier",
|
||||||
|
"recommended_for",
|
||||||
|
"description",
|
||||||
|
):
|
||||||
|
value = model.get(key)
|
||||||
|
if isinstance(value, (list, tuple, set)):
|
||||||
|
for item in value:
|
||||||
|
text = str(item or "").strip()
|
||||||
|
if text:
|
||||||
|
yield text
|
||||||
|
elif isinstance(value, dict):
|
||||||
|
for item in value.values():
|
||||||
|
text = str(item or "").strip()
|
||||||
|
if text:
|
||||||
|
yield text
|
||||||
|
else:
|
||||||
|
text = str(value or "").strip()
|
||||||
|
if text:
|
||||||
|
yield text
|
||||||
|
tags = model.get("tags")
|
||||||
|
if isinstance(tags, (list, tuple, set)):
|
||||||
|
for tag in tags:
|
||||||
|
text = str(tag or "").strip()
|
||||||
|
if text:
|
||||||
|
yield text
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
"""Pure helpers for classifying and presenting collection skip reasons."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
ALIAS_UNMATCHED = "alias_unmatched"
|
||||||
|
LOGIN_REQUIRED = "login_required"
|
||||||
|
OTHER = "other"
|
||||||
|
|
||||||
|
SKIP_REASON_ORDER = (ALIAS_UNMATCHED, LOGIN_REQUIRED, OTHER)
|
||||||
|
SKIP_REASON_LABELS = {
|
||||||
|
ALIAS_UNMATCHED: "别名未匹配",
|
||||||
|
LOGIN_REQUIRED: "账号未登录",
|
||||||
|
OTHER: "其他原因",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def empty_skip_reason_counts():
|
||||||
|
return {reason: 0 for reason in SKIP_REASON_ORDER}
|
||||||
|
|
||||||
|
|
||||||
|
def classify_skip_reason(reason="", *, alias_matched=True):
|
||||||
|
"""Classify historical skip text without treating uncertain login checks as logout."""
|
||||||
|
if not alias_matched:
|
||||||
|
return ALIAS_UNMATCHED
|
||||||
|
|
||||||
|
text = str(reason or "").strip().lower()
|
||||||
|
if "别名未匹配" in text or "alias_unmatched" in text:
|
||||||
|
return ALIAS_UNMATCHED
|
||||||
|
if (
|
||||||
|
"账号未登录" in text
|
||||||
|
or "采集中途掉登录" in text
|
||||||
|
or "login_page" in text
|
||||||
|
or ("accounts.shopee." in text and "/seller/login" in text)
|
||||||
|
):
|
||||||
|
return LOGIN_REQUIRED
|
||||||
|
return OTHER
|
||||||
|
|
||||||
|
|
||||||
|
def skipped_stage_text(reason="", *, alias_matched=True):
|
||||||
|
reason_code = classify_skip_reason(reason, alias_matched=alias_matched)
|
||||||
|
return f"略过 · {SKIP_REASON_LABELS[reason_code]}"
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_skip_reason_counts(counts=None, *, skipped_total=0):
|
||||||
|
normalized = empty_skip_reason_counts()
|
||||||
|
source = counts if isinstance(counts, dict) else {}
|
||||||
|
for reason in SKIP_REASON_ORDER:
|
||||||
|
try:
|
||||||
|
normalized[reason] = max(0, int(source.get(reason, 0) or 0))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
normalized[reason] = 0
|
||||||
|
|
||||||
|
total = max(0, int(skipped_total or 0))
|
||||||
|
accounted = sum(normalized.values())
|
||||||
|
if accounted < total:
|
||||||
|
normalized[OTHER] += total - accounted
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
def format_skip_reason_summary(counts=None, *, skipped_total=0):
|
||||||
|
total = max(0, int(skipped_total or 0))
|
||||||
|
if total == 0:
|
||||||
|
return ""
|
||||||
|
normalized = normalize_skip_reason_counts(counts, skipped_total=total)
|
||||||
|
parts = [
|
||||||
|
f"{SKIP_REASON_LABELS[reason]}{normalized[reason]}"
|
||||||
|
for reason in SKIP_REASON_ORDER
|
||||||
|
if normalized[reason] > 0
|
||||||
|
]
|
||||||
|
return f"略过{total}:" + ",".join(parts)
|
||||||
@@ -11,11 +11,15 @@ from dataclasses import dataclass
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Iterable, Optional
|
from typing import Iterable, Optional
|
||||||
|
|
||||||
from . import appconfig
|
from . import appconfig, product_status
|
||||||
from .config import make_slug
|
from .config import make_slug
|
||||||
|
|
||||||
|
|
||||||
DEFAULT_BUSY_TIMEOUT_MS = 5000
|
DEFAULT_BUSY_TIMEOUT_MS = 5000
|
||||||
|
PRODUCT_STATUS_FEATURE_INTRODUCED_AT = "2026-07-18T16:34:37"
|
||||||
|
LEGACY_PRODUCT_STATUS_DEFAULT_NOTE = "历史批次默认按架上商品处理(未实时检测)"
|
||||||
|
LEGACY_PROMOTION_STATUS_REPAIR_NOTE = "历史商品状态修正:促销编辑提示不属于商品状态异常"
|
||||||
|
LEGACY_STATUS_BACKUP_TASK_ID = "T704"
|
||||||
VALID_BATCH_FIELDS = {
|
VALID_BATCH_FIELDS = {
|
||||||
"source_files_json",
|
"source_files_json",
|
||||||
"status",
|
"status",
|
||||||
@@ -30,6 +34,7 @@ FAILURE_STEP_LABELS = {
|
|||||||
"open_product": "打开商品页",
|
"open_product": "打开商品页",
|
||||||
"wait_ready": "页面就绪",
|
"wait_ready": "页面就绪",
|
||||||
"read_title": "读标题",
|
"read_title": "读标题",
|
||||||
|
"read_product_status": "读取商品状态",
|
||||||
"read_cover": "读封面",
|
"read_cover": "读封面",
|
||||||
"download_cover": "下载封面",
|
"download_cover": "下载封面",
|
||||||
"db_write": "写库",
|
"db_write": "写库",
|
||||||
@@ -46,6 +51,7 @@ FAILURE_STEP_LABELS = {
|
|||||||
"cover_submit": "提交封面任务",
|
"cover_submit": "提交封面任务",
|
||||||
"cover_build_request": "构建封面请求",
|
"cover_build_request": "构建封面请求",
|
||||||
"cover_request": "请求生成封面",
|
"cover_request": "请求生成封面",
|
||||||
|
"cover_poll": "等待封面结果",
|
||||||
"cover_download": "下载新封面",
|
"cover_download": "下载新封面",
|
||||||
"cover_save": "保存新封面",
|
"cover_save": "保存新封面",
|
||||||
"cover_parse_response": "解析封面响应",
|
"cover_parse_response": "解析封面响应",
|
||||||
@@ -134,10 +140,17 @@ class Task:
|
|||||||
account_name: Optional[str]
|
account_name: Optional[str]
|
||||||
alias: str
|
alias: str
|
||||||
item_id: str
|
item_id: str
|
||||||
|
product_status: Optional[str]
|
||||||
|
product_status_note: Optional[str]
|
||||||
|
product_status_at: Optional[str]
|
||||||
old_title: Optional[str]
|
old_title: Optional[str]
|
||||||
old_cover_path: Optional[str]
|
old_cover_path: Optional[str]
|
||||||
new_title: Optional[str]
|
new_title: Optional[str]
|
||||||
new_cover_path: Optional[str]
|
new_cover_path: Optional[str]
|
||||||
|
image_task_id: Optional[str]
|
||||||
|
image_task_key: Optional[str]
|
||||||
|
cover_reset_count: int
|
||||||
|
cover_reset_at: Optional[str]
|
||||||
committed: int
|
committed: int
|
||||||
stage: str
|
stage: str
|
||||||
status: str
|
status: str
|
||||||
@@ -150,6 +163,8 @@ class Task:
|
|||||||
generated_at: Optional[str]
|
generated_at: Optional[str]
|
||||||
applied_at: Optional[str]
|
applied_at: Optional[str]
|
||||||
updated_at: str
|
updated_at: str
|
||||||
|
deleted_at: Optional[str]
|
||||||
|
deleted_reason: Optional[str]
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -227,10 +242,17 @@ CREATE TABLE IF NOT EXISTS tasks (
|
|||||||
account_name TEXT,
|
account_name TEXT,
|
||||||
alias TEXT NOT NULL,
|
alias TEXT NOT NULL,
|
||||||
item_id TEXT NOT NULL,
|
item_id TEXT NOT NULL,
|
||||||
|
product_status TEXT,
|
||||||
|
product_status_note TEXT,
|
||||||
|
product_status_at TEXT,
|
||||||
old_title TEXT,
|
old_title TEXT,
|
||||||
old_cover_path TEXT,
|
old_cover_path TEXT,
|
||||||
new_title TEXT,
|
new_title TEXT,
|
||||||
new_cover_path TEXT,
|
new_cover_path TEXT,
|
||||||
|
image_task_id TEXT,
|
||||||
|
image_task_key TEXT,
|
||||||
|
cover_reset_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
cover_reset_at TEXT,
|
||||||
committed INTEGER NOT NULL DEFAULT 0,
|
committed INTEGER NOT NULL DEFAULT 0,
|
||||||
stage TEXT NOT NULL DEFAULT 'imported',
|
stage TEXT NOT NULL DEFAULT 'imported',
|
||||||
status TEXT NOT NULL DEFAULT 'pending',
|
status TEXT NOT NULL DEFAULT 'pending',
|
||||||
@@ -243,6 +265,8 @@ CREATE TABLE IF NOT EXISTS tasks (
|
|||||||
generated_at TEXT,
|
generated_at TEXT,
|
||||||
applied_at TEXT,
|
applied_at TEXT,
|
||||||
updated_at TEXT NOT NULL,
|
updated_at TEXT NOT NULL,
|
||||||
|
deleted_at TEXT,
|
||||||
|
deleted_reason TEXT,
|
||||||
UNIQUE(batch_id, source_file_abs, source_sheet, source_row)
|
UNIQUE(batch_id, source_file_abs, source_sheet, source_row)
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -280,6 +304,101 @@ CREATE TABLE IF NOT EXISTS run_log_events (
|
|||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_run_logs_started ON run_logs(started_at DESC, id DESC);
|
CREATE INDEX IF NOT EXISTS idx_run_logs_started ON run_logs(started_at DESC, id DESC);
|
||||||
CREATE INDEX IF NOT EXISTS idx_run_log_events_run ON run_log_events(run_id, id);
|
CREATE INDEX IF NOT EXISTS idx_run_log_events_run ON run_log_events(run_id, id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS image_studio_projects (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
account_alias TEXT NOT NULL,
|
||||||
|
account_slug TEXT NOT NULL,
|
||||||
|
account_name TEXT,
|
||||||
|
item_id TEXT NOT NULL,
|
||||||
|
storage_key TEXT NOT NULL,
|
||||||
|
binding_state TEXT NOT NULL DEFAULT 'bound',
|
||||||
|
target_main_count INTEGER NOT NULL DEFAULT 9,
|
||||||
|
target_detail_count INTEGER NOT NULL DEFAULT 12,
|
||||||
|
draft_prompt TEXT,
|
||||||
|
suite_settings_json TEXT NOT NULL DEFAULT '{}',
|
||||||
|
current_generation_round_key TEXT,
|
||||||
|
status TEXT NOT NULL DEFAULT 'active',
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
deleted_at TEXT,
|
||||||
|
deleted_reason TEXT,
|
||||||
|
UNIQUE(account_alias, item_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_image_studio_projects_status
|
||||||
|
ON image_studio_projects(status, updated_at DESC);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS image_studio_assets (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
project_id INTEGER NOT NULL REFERENCES image_studio_projects(id) ON DELETE CASCADE,
|
||||||
|
kind TEXT NOT NULL,
|
||||||
|
remote_url TEXT,
|
||||||
|
local_path TEXT,
|
||||||
|
aspect_ratio TEXT,
|
||||||
|
requested_output_size TEXT,
|
||||||
|
rendered_width INTEGER,
|
||||||
|
rendered_height INTEGER,
|
||||||
|
parent_asset_id INTEGER REFERENCES image_studio_assets(id) ON DELETE SET NULL,
|
||||||
|
prompt TEXT,
|
||||||
|
status TEXT NOT NULL DEFAULT 'available',
|
||||||
|
source_order INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_image_studio_assets_project_kind
|
||||||
|
ON image_studio_assets(project_id, kind, source_order, id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_image_studio_assets_parent
|
||||||
|
ON image_studio_assets(parent_asset_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS image_studio_jobs (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
project_id INTEGER NOT NULL REFERENCES image_studio_projects(id) ON DELETE CASCADE,
|
||||||
|
source_asset_id INTEGER REFERENCES image_studio_assets(id) ON DELETE SET NULL,
|
||||||
|
reference_asset_ids TEXT,
|
||||||
|
output_asset_id INTEGER REFERENCES image_studio_assets(id) ON DELETE SET NULL,
|
||||||
|
generation_source TEXT NOT NULL DEFAULT 'cmhub',
|
||||||
|
provider TEXT NOT NULL DEFAULT 'cmhub',
|
||||||
|
job_type TEXT NOT NULL,
|
||||||
|
task_key TEXT NOT NULL UNIQUE,
|
||||||
|
task_id TEXT,
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending',
|
||||||
|
prompt TEXT,
|
||||||
|
error TEXT,
|
||||||
|
recovery_action TEXT NOT NULL DEFAULT 'regenerate',
|
||||||
|
attempts INTEGER NOT NULL DEFAULT 0,
|
||||||
|
points_cost INTEGER,
|
||||||
|
points_balance INTEGER,
|
||||||
|
call_id TEXT,
|
||||||
|
generation_round_key TEXT,
|
||||||
|
generation_slot_index INTEGER,
|
||||||
|
run_session_id TEXT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
submitted_at TEXT,
|
||||||
|
finished_at TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_image_studio_jobs_project_status
|
||||||
|
ON image_studio_jobs(project_id, status, id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_image_studio_jobs_task_id
|
||||||
|
ON image_studio_jobs(task_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS image_studio_selections (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
project_id INTEGER NOT NULL REFERENCES image_studio_projects(id) ON DELETE CASCADE,
|
||||||
|
selection_type TEXT NOT NULL,
|
||||||
|
position INTEGER NOT NULL,
|
||||||
|
asset_id INTEGER NOT NULL REFERENCES image_studio_assets(id) ON DELETE CASCADE,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
UNIQUE(project_id, selection_type, position),
|
||||||
|
UNIQUE(project_id, selection_type, asset_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_image_studio_selections_project
|
||||||
|
ON image_studio_selections(project_id, selection_type, position);
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
@@ -287,6 +406,34 @@ def _now() -> str:
|
|||||||
return datetime.now().isoformat(timespec="seconds")
|
return datetime.now().isoformat(timespec="seconds")
|
||||||
|
|
||||||
|
|
||||||
|
def _cover_archive_timestamp() -> str:
|
||||||
|
return datetime.now().strftime("%Y%m%d%H%M%S")
|
||||||
|
|
||||||
|
|
||||||
|
def _archive_generated_cover_file(file_path) -> Optional[str]:
|
||||||
|
path_text = str(file_path or "").strip()
|
||||||
|
if not path_text or not os.path.isfile(path_text):
|
||||||
|
return None
|
||||||
|
directory = os.path.dirname(path_text)
|
||||||
|
filename = os.path.basename(path_text)
|
||||||
|
stem, ext = os.path.splitext(filename)
|
||||||
|
timestamp = _cover_archive_timestamp()
|
||||||
|
suffix = 1
|
||||||
|
while True:
|
||||||
|
archive_name = f"{stem}_{timestamp}{ext}" if suffix == 1 else f"{stem}_{timestamp}_{suffix}{ext}"
|
||||||
|
archive_path = os.path.join(directory, archive_name)
|
||||||
|
if not os.path.exists(archive_path):
|
||||||
|
break
|
||||||
|
suffix += 1
|
||||||
|
try:
|
||||||
|
os.rename(path_text, archive_path)
|
||||||
|
except PermissionError as exc:
|
||||||
|
raise DbError("请先关闭正在查看的封面图片再重置") from exc
|
||||||
|
except OSError as exc:
|
||||||
|
raise DbError(f"归档当前封面图片失败: {exc}") from exc
|
||||||
|
return archive_path
|
||||||
|
|
||||||
|
|
||||||
def _db_path(path=None) -> str:
|
def _db_path(path=None) -> str:
|
||||||
return path or appconfig.db_path()
|
return path or appconfig.db_path()
|
||||||
|
|
||||||
@@ -355,6 +502,23 @@ def init_db(path=None, conn=None) -> None:
|
|||||||
with database:
|
with database:
|
||||||
database.executescript(SCHEMA_SQL)
|
database.executescript(SCHEMA_SQL)
|
||||||
_ensure_batch_delete_columns(database)
|
_ensure_batch_delete_columns(database)
|
||||||
|
_ensure_task_image_task_columns(database)
|
||||||
|
_ensure_task_cover_reset_columns(database)
|
||||||
|
_ensure_task_product_status_columns(database)
|
||||||
|
_ensure_task_delete_columns(database)
|
||||||
|
_migrate_legacy_product_status_defaults(database)
|
||||||
|
_repair_legacy_promotion_status_defaults(database)
|
||||||
|
_ensure_image_studio_project_suite_columns(database)
|
||||||
|
_ensure_image_studio_project_draft_columns(database)
|
||||||
|
_ensure_image_studio_job_recovery_columns(database)
|
||||||
|
_ensure_image_studio_generation_round_columns(database)
|
||||||
|
_ensure_image_studio_job_reference_asset_ids_column(database)
|
||||||
|
_ensure_image_studio_asset_output_columns(database)
|
||||||
|
_ensure_image_studio_job_run_session_column(database)
|
||||||
|
if _has_processed_tasks_missing_product_status(database):
|
||||||
|
_backup_before_processed_status_migration(database)
|
||||||
|
with database:
|
||||||
|
_migrate_processed_task_product_status_defaults(database)
|
||||||
|
|
||||||
|
|
||||||
def _ensure_batch_delete_columns(database):
|
def _ensure_batch_delete_columns(database):
|
||||||
@@ -364,6 +528,336 @@ def _ensure_batch_delete_columns(database):
|
|||||||
if "deleted_reason" not in columns:
|
if "deleted_reason" not in columns:
|
||||||
database.execute("ALTER TABLE batches ADD COLUMN deleted_reason TEXT")
|
database.execute("ALTER TABLE batches ADD COLUMN deleted_reason TEXT")
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_task_image_task_columns(database):
|
||||||
|
columns = {row["name"] for row in database.execute("PRAGMA table_info(tasks)").fetchall()}
|
||||||
|
if "image_task_id" not in columns:
|
||||||
|
database.execute("ALTER TABLE tasks ADD COLUMN image_task_id TEXT")
|
||||||
|
if "image_task_key" not in columns:
|
||||||
|
database.execute("ALTER TABLE tasks ADD COLUMN image_task_key TEXT")
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_task_cover_reset_columns(database):
|
||||||
|
columns = {row["name"] for row in database.execute("PRAGMA table_info(tasks)").fetchall()}
|
||||||
|
if "cover_reset_count" not in columns:
|
||||||
|
database.execute("ALTER TABLE tasks ADD COLUMN cover_reset_count INTEGER NOT NULL DEFAULT 0")
|
||||||
|
if "cover_reset_at" not in columns:
|
||||||
|
database.execute("ALTER TABLE tasks ADD COLUMN cover_reset_at TEXT")
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_task_product_status_columns(database):
|
||||||
|
columns = {row["name"] for row in database.execute("PRAGMA table_info(tasks)").fetchall()}
|
||||||
|
if "product_status" not in columns:
|
||||||
|
database.execute("ALTER TABLE tasks ADD COLUMN product_status TEXT")
|
||||||
|
if "product_status_note" not in columns:
|
||||||
|
database.execute("ALTER TABLE tasks ADD COLUMN product_status_note TEXT")
|
||||||
|
if "product_status_at" not in columns:
|
||||||
|
database.execute("ALTER TABLE tasks ADD COLUMN product_status_at TEXT")
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_task_delete_columns(database):
|
||||||
|
columns = {row["name"] for row in database.execute("PRAGMA table_info(tasks)").fetchall()}
|
||||||
|
if "deleted_at" not in columns:
|
||||||
|
database.execute("ALTER TABLE tasks ADD COLUMN deleted_at TEXT")
|
||||||
|
if "deleted_reason" not in columns:
|
||||||
|
database.execute("ALTER TABLE tasks ADD COLUMN deleted_reason TEXT")
|
||||||
|
database.execute("CREATE INDEX IF NOT EXISTS idx_tasks_deleted_at ON tasks(deleted_at)")
|
||||||
|
|
||||||
|
|
||||||
|
def _migrate_legacy_product_status_defaults(database):
|
||||||
|
"""Treat pre-status-feature active batches as historically on-shelf once."""
|
||||||
|
|
||||||
|
database.execute(
|
||||||
|
"""
|
||||||
|
UPDATE tasks
|
||||||
|
SET product_status = 'normal',
|
||||||
|
product_status_note = ?,
|
||||||
|
product_status_at = NULL,
|
||||||
|
updated_at = ?
|
||||||
|
WHERE (product_status IS NULL OR TRIM(product_status) = '')
|
||||||
|
AND deleted_at IS NULL
|
||||||
|
AND batch_id IN (
|
||||||
|
SELECT id
|
||||||
|
FROM batches
|
||||||
|
WHERE deleted_at IS NULL
|
||||||
|
AND created_at < ?
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
LEGACY_PRODUCT_STATUS_DEFAULT_NOTE,
|
||||||
|
_now(),
|
||||||
|
PRODUCT_STATUS_FEATURE_INTRODUCED_AT,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _processed_task_status_condition() -> str:
|
||||||
|
return """
|
||||||
|
(tasks.stage IN ('collected', 'generated', 'applied')
|
||||||
|
OR tasks.collected_at IS NOT NULL
|
||||||
|
OR tasks.generated_at IS NOT NULL
|
||||||
|
OR tasks.applied_at IS NOT NULL
|
||||||
|
OR NULLIF(TRIM(COALESCE(tasks.old_title, '')), '') IS NOT NULL
|
||||||
|
OR NULLIF(TRIM(COALESCE(tasks.old_cover_path, '')), '') IS NOT NULL
|
||||||
|
OR NULLIF(TRIM(COALESCE(tasks.new_title, '')), '') IS NOT NULL
|
||||||
|
OR NULLIF(TRIM(COALESCE(tasks.new_cover_path, '')), '') IS NOT NULL)
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _has_processed_tasks_missing_product_status(database) -> bool:
|
||||||
|
row = database.execute(
|
||||||
|
f"""
|
||||||
|
SELECT 1
|
||||||
|
FROM tasks
|
||||||
|
INNER JOIN batches ON batches.id = tasks.batch_id
|
||||||
|
WHERE (tasks.product_status IS NULL OR TRIM(tasks.product_status) = '')
|
||||||
|
AND tasks.deleted_at IS NULL
|
||||||
|
AND batches.deleted_at IS NULL
|
||||||
|
AND {_processed_task_status_condition()}
|
||||||
|
LIMIT 1
|
||||||
|
"""
|
||||||
|
).fetchone()
|
||||||
|
return row is not None
|
||||||
|
|
||||||
|
|
||||||
|
def _main_database_path(database) -> str:
|
||||||
|
for row in database.execute("PRAGMA database_list").fetchall():
|
||||||
|
if row["name"] == "main":
|
||||||
|
return str(row["file"] or "").strip()
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _backup_before_processed_status_migration(database) -> Optional[str]:
|
||||||
|
"""Create a consistent file backup before T-704 changes user task rows."""
|
||||||
|
|
||||||
|
database_path = _main_database_path(database)
|
||||||
|
if not database_path or database_path == ":memory:":
|
||||||
|
return None
|
||||||
|
|
||||||
|
backup_dir = os.path.join(os.path.dirname(os.path.abspath(database_path)), "backups")
|
||||||
|
stem, ext = os.path.splitext(os.path.basename(database_path))
|
||||||
|
ext = ext or ".db"
|
||||||
|
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||||
|
backup_path = os.path.join(
|
||||||
|
backup_dir,
|
||||||
|
f"{stem}-before-{LEGACY_STATUS_BACKUP_TASK_ID}-{timestamp}-{uuid.uuid4().hex[:8]}{ext}",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
os.makedirs(backup_dir, exist_ok=True)
|
||||||
|
backup_database = sqlite3.connect(backup_path)
|
||||||
|
try:
|
||||||
|
database.backup(backup_database)
|
||||||
|
finally:
|
||||||
|
backup_database.close()
|
||||||
|
except (OSError, sqlite3.Error) as exc:
|
||||||
|
try:
|
||||||
|
if os.path.exists(backup_path):
|
||||||
|
os.remove(backup_path)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
raise DbError(f"兼容历史商品状态前备份数据库失败: {exc}") from exc
|
||||||
|
return backup_path
|
||||||
|
|
||||||
|
|
||||||
|
def _migrate_processed_task_product_status_defaults(database):
|
||||||
|
"""Default only already-processed missing snapshots, regardless of batch date."""
|
||||||
|
|
||||||
|
database.execute(
|
||||||
|
f"""
|
||||||
|
UPDATE tasks
|
||||||
|
SET product_status = 'normal',
|
||||||
|
product_status_note = ?,
|
||||||
|
product_status_at = NULL,
|
||||||
|
updated_at = ?
|
||||||
|
WHERE id IN (
|
||||||
|
SELECT tasks.id
|
||||||
|
FROM tasks
|
||||||
|
INNER JOIN batches ON batches.id = tasks.batch_id
|
||||||
|
WHERE (tasks.product_status IS NULL OR TRIM(tasks.product_status) = '')
|
||||||
|
AND tasks.deleted_at IS NULL
|
||||||
|
AND batches.deleted_at IS NULL
|
||||||
|
AND {_processed_task_status_condition()}
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
LEGACY_PRODUCT_STATUS_DEFAULT_NOTE,
|
||||||
|
_now(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _repair_legacy_promotion_status_defaults(database):
|
||||||
|
"""Repair only legacy unknown snapshots caused by a known benign warning."""
|
||||||
|
|
||||||
|
rows = database.execute(
|
||||||
|
"""
|
||||||
|
SELECT tasks.id, tasks.product_status_note
|
||||||
|
FROM tasks
|
||||||
|
INNER JOIN batches ON batches.id = tasks.batch_id
|
||||||
|
WHERE tasks.product_status = 'unknown'
|
||||||
|
AND tasks.deleted_at IS NULL
|
||||||
|
AND batches.deleted_at IS NULL
|
||||||
|
AND batches.created_at < ?
|
||||||
|
""",
|
||||||
|
(PRODUCT_STATUS_FEATURE_INTRODUCED_AT,),
|
||||||
|
).fetchall()
|
||||||
|
task_ids = [
|
||||||
|
int(row["id"])
|
||||||
|
for row in rows
|
||||||
|
if product_status.is_promotion_edit_restriction_note(row["product_status_note"])
|
||||||
|
]
|
||||||
|
if not task_ids:
|
||||||
|
return
|
||||||
|
|
||||||
|
placeholders = ", ".join("?" for _task_id in task_ids)
|
||||||
|
database.execute(
|
||||||
|
f"""
|
||||||
|
UPDATE tasks
|
||||||
|
SET product_status = 'normal',
|
||||||
|
product_status_note = ?,
|
||||||
|
updated_at = ?
|
||||||
|
WHERE id IN ({placeholders})
|
||||||
|
""",
|
||||||
|
(LEGACY_PROMOTION_STATUS_REPAIR_NOTE, _now(), *task_ids),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_image_studio_project_suite_columns(database):
|
||||||
|
columns = {
|
||||||
|
row["name"]
|
||||||
|
for row in database.execute("PRAGMA table_info(image_studio_projects)").fetchall()
|
||||||
|
}
|
||||||
|
if "suite_settings_json" not in columns:
|
||||||
|
database.execute(
|
||||||
|
"ALTER TABLE image_studio_projects "
|
||||||
|
"ADD COLUMN suite_settings_json TEXT NOT NULL DEFAULT '{}'"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_image_studio_project_draft_columns(database):
|
||||||
|
columns = {
|
||||||
|
row["name"]
|
||||||
|
for row in database.execute("PRAGMA table_info(image_studio_projects)").fetchall()
|
||||||
|
}
|
||||||
|
if "storage_key" not in columns:
|
||||||
|
database.execute("ALTER TABLE image_studio_projects ADD COLUMN storage_key TEXT")
|
||||||
|
if "binding_state" not in columns:
|
||||||
|
database.execute(
|
||||||
|
"ALTER TABLE image_studio_projects "
|
||||||
|
"ADD COLUMN binding_state TEXT NOT NULL DEFAULT 'bound'"
|
||||||
|
)
|
||||||
|
database.execute(
|
||||||
|
"UPDATE image_studio_projects SET storage_key = item_id "
|
||||||
|
"WHERE storage_key IS NULL OR TRIM(storage_key) = ''"
|
||||||
|
)
|
||||||
|
database.execute(
|
||||||
|
"UPDATE image_studio_projects SET binding_state = 'bound' "
|
||||||
|
"WHERE binding_state IS NULL OR binding_state NOT IN ('draft', 'bound')"
|
||||||
|
)
|
||||||
|
database.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_image_studio_projects_binding "
|
||||||
|
"ON image_studio_projects(binding_state, updated_at DESC)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_image_studio_job_recovery_columns(database):
|
||||||
|
columns = {
|
||||||
|
row["name"] for row in database.execute("PRAGMA table_info(image_studio_jobs)").fetchall()
|
||||||
|
}
|
||||||
|
if "recovery_action" not in columns:
|
||||||
|
database.execute(
|
||||||
|
"ALTER TABLE image_studio_jobs ADD COLUMN recovery_action TEXT NOT NULL DEFAULT 'regenerate'"
|
||||||
|
)
|
||||||
|
database.execute(
|
||||||
|
"""
|
||||||
|
UPDATE image_studio_jobs
|
||||||
|
SET recovery_action = CASE
|
||||||
|
WHEN status = 'succeeded' THEN 'none'
|
||||||
|
WHEN task_id IS NOT NULL AND status IN ('submitted', 'running') THEN 'resume'
|
||||||
|
ELSE 'regenerate'
|
||||||
|
END
|
||||||
|
WHERE recovery_action IS NULL
|
||||||
|
OR recovery_action NOT IN ('none', 'resume', 'regenerate')
|
||||||
|
OR (
|
||||||
|
recovery_action = 'regenerate'
|
||||||
|
AND task_id IS NOT NULL
|
||||||
|
AND status IN ('submitted', 'running')
|
||||||
|
)
|
||||||
|
OR (
|
||||||
|
recovery_action = 'regenerate'
|
||||||
|
AND status = 'succeeded'
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_image_studio_generation_round_columns(database):
|
||||||
|
project_columns = {
|
||||||
|
row["name"]
|
||||||
|
for row in database.execute("PRAGMA table_info(image_studio_projects)").fetchall()
|
||||||
|
}
|
||||||
|
if "current_generation_round_key" not in project_columns:
|
||||||
|
database.execute(
|
||||||
|
"ALTER TABLE image_studio_projects "
|
||||||
|
"ADD COLUMN current_generation_round_key TEXT"
|
||||||
|
)
|
||||||
|
|
||||||
|
job_columns = {
|
||||||
|
row["name"]
|
||||||
|
for row in database.execute("PRAGMA table_info(image_studio_jobs)").fetchall()
|
||||||
|
}
|
||||||
|
if "generation_round_key" not in job_columns:
|
||||||
|
database.execute(
|
||||||
|
"ALTER TABLE image_studio_jobs ADD COLUMN generation_round_key TEXT"
|
||||||
|
)
|
||||||
|
if "generation_slot_index" not in job_columns:
|
||||||
|
database.execute(
|
||||||
|
"ALTER TABLE image_studio_jobs ADD COLUMN generation_slot_index INTEGER"
|
||||||
|
)
|
||||||
|
database.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_image_studio_jobs_generation_round "
|
||||||
|
"ON image_studio_jobs("
|
||||||
|
"project_id, generation_round_key, generation_slot_index, id)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_image_studio_job_reference_asset_ids_column(database):
|
||||||
|
columns = {
|
||||||
|
row["name"] for row in database.execute("PRAGMA table_info(image_studio_jobs)").fetchall()
|
||||||
|
}
|
||||||
|
if "reference_asset_ids" not in columns:
|
||||||
|
database.execute(
|
||||||
|
"ALTER TABLE image_studio_jobs ADD COLUMN reference_asset_ids TEXT"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_image_studio_asset_output_columns(database):
|
||||||
|
columns = {
|
||||||
|
row["name"] for row in database.execute("PRAGMA table_info(image_studio_assets)").fetchall()
|
||||||
|
}
|
||||||
|
if "requested_output_size" not in columns:
|
||||||
|
database.execute(
|
||||||
|
"ALTER TABLE image_studio_assets ADD COLUMN requested_output_size TEXT"
|
||||||
|
)
|
||||||
|
if "rendered_width" not in columns:
|
||||||
|
database.execute(
|
||||||
|
"ALTER TABLE image_studio_assets ADD COLUMN rendered_width INTEGER"
|
||||||
|
)
|
||||||
|
if "rendered_height" not in columns:
|
||||||
|
database.execute(
|
||||||
|
"ALTER TABLE image_studio_assets ADD COLUMN rendered_height INTEGER"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_image_studio_job_run_session_column(database):
|
||||||
|
columns = {
|
||||||
|
row["name"] for row in database.execute("PRAGMA table_info(image_studio_jobs)").fetchall()
|
||||||
|
}
|
||||||
|
if "run_session_id" not in columns:
|
||||||
|
database.execute(
|
||||||
|
"ALTER TABLE image_studio_jobs ADD COLUMN run_session_id TEXT"
|
||||||
|
)
|
||||||
|
|
||||||
def create_batch(file_paths: Iterable[str], note=None, path=None, conn=None) -> str:
|
def create_batch(file_paths: Iterable[str], note=None, path=None, conn=None) -> str:
|
||||||
batch_id = datetime.now().strftime("%Y%m%d_%H%M%S_") + uuid.uuid4().hex[:8]
|
batch_id = datetime.now().strftime("%Y%m%d_%H%M%S_") + uuid.uuid4().hex[:8]
|
||||||
files = [os.path.abspath(file_path) for file_path in file_paths]
|
files = [os.path.abspath(file_path) for file_path in file_paths]
|
||||||
@@ -573,6 +1067,7 @@ def list_tasks(
|
|||||||
params.append(value)
|
params.append(value)
|
||||||
if not include_deleted:
|
if not include_deleted:
|
||||||
clauses.append("b.deleted_at IS NULL")
|
clauses.append("b.deleted_at IS NULL")
|
||||||
|
clauses.append("t.deleted_at IS NULL")
|
||||||
sql = "SELECT t.* FROM tasks t JOIN batches b ON b.id = t.batch_id"
|
sql = "SELECT t.* FROM tasks t JOIN batches b ON b.id = t.batch_id"
|
||||||
if clauses:
|
if clauses:
|
||||||
sql += " WHERE " + " AND ".join(clauses)
|
sql += " WHERE " + " AND ".join(clauses)
|
||||||
@@ -613,11 +1108,44 @@ def delete_batch(batch_id, reason=None, path=None, conn=None) -> dict:
|
|||||||
"image_paths": image_paths,
|
"image_paths": image_paths,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def delete_task(task_id, reason=None, path=None, conn=None) -> dict:
|
||||||
|
"""Soft delete one inactive task without touching its source files or images."""
|
||||||
|
|
||||||
|
task_id = int(task_id)
|
||||||
|
with _connection(conn, path) as database:
|
||||||
|
task = get_task(task_id, conn=database)
|
||||||
|
if task is None:
|
||||||
|
raise DbError(f"任务不存在或已删除: {task_id}")
|
||||||
|
if task.status == "running":
|
||||||
|
raise DbError("任务正在处理,不能删除")
|
||||||
|
now = _now()
|
||||||
|
with database:
|
||||||
|
cursor = database.execute(
|
||||||
|
"""
|
||||||
|
UPDATE tasks
|
||||||
|
SET deleted_at = ?, deleted_reason = ?, updated_at = ?
|
||||||
|
WHERE id = ? AND deleted_at IS NULL AND status <> 'running'
|
||||||
|
""",
|
||||||
|
(now, str(reason or ""), now, task_id),
|
||||||
|
)
|
||||||
|
if cursor.rowcount != 1:
|
||||||
|
raise DbError("任务已删除或正在处理,不能删除")
|
||||||
|
return {
|
||||||
|
"task_id": task_id,
|
||||||
|
"batch_id": task.batch_id,
|
||||||
|
"item_id": task.item_id,
|
||||||
|
"committed": int(task.committed or 0),
|
||||||
|
"deleted_at": now,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def get_task(task_id, path=None, conn=None, include_deleted=False):
|
def get_task(task_id, path=None, conn=None, include_deleted=False):
|
||||||
clauses = ["t.id = ?"]
|
clauses = ["t.id = ?"]
|
||||||
params = [int(task_id)]
|
params = [int(task_id)]
|
||||||
if not include_deleted:
|
if not include_deleted:
|
||||||
clauses.append("b.deleted_at IS NULL")
|
clauses.append("b.deleted_at IS NULL")
|
||||||
|
clauses.append("t.deleted_at IS NULL")
|
||||||
sql = "SELECT t.* FROM tasks t JOIN batches b ON b.id = t.batch_id"
|
sql = "SELECT t.* FROM tasks t JOIN batches b ON b.id = t.batch_id"
|
||||||
sql += " WHERE " + " AND ".join(clauses)
|
sql += " WHERE " + " AND ".join(clauses)
|
||||||
with _connection(conn, path) as database:
|
with _connection(conn, path) as database:
|
||||||
@@ -688,8 +1216,126 @@ def mark_skipped(task_id, reason, path=None, conn=None) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def set_collected(task_id, old_title, old_cover_path, path=None, conn=None) -> None:
|
def ensure_image_task_key(task_id, path=None, conn=None) -> str:
|
||||||
|
"""Return a stable cmhub image idempotency key for the current cover attempt."""
|
||||||
|
|
||||||
|
task_id = int(task_id)
|
||||||
|
with _connection(conn, path) as database:
|
||||||
|
with database:
|
||||||
|
row = database.execute(
|
||||||
|
"SELECT image_task_key FROM tasks WHERE id = ?",
|
||||||
|
(task_id,),
|
||||||
|
).fetchone()
|
||||||
|
if row is None:
|
||||||
|
raise DbError(f"任务不存在: {task_id}")
|
||||||
|
key = str(row["image_task_key"] or "").strip()
|
||||||
|
if not key:
|
||||||
|
key = f"cmshopee-task-{task_id}-{uuid.uuid4().hex}"
|
||||||
|
database.execute(
|
||||||
|
"""
|
||||||
|
UPDATE tasks
|
||||||
|
SET image_task_key = ?, updated_at = ?
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(key, _now(), task_id),
|
||||||
|
)
|
||||||
|
return key
|
||||||
|
|
||||||
|
|
||||||
|
def set_image_task_submitted(task_id, image_task_id, image_task_key=None, path=None, conn=None) -> None:
|
||||||
|
"""Persist the cmhub image task id immediately after submit succeeds."""
|
||||||
|
|
||||||
|
task_id = int(task_id)
|
||||||
|
image_task_id = str(image_task_id or "").strip()
|
||||||
|
if not image_task_id:
|
||||||
|
raise DbError("缺少 cmhub 生图任务ID")
|
||||||
|
with _connection(conn, path) as database:
|
||||||
|
with database:
|
||||||
|
row = database.execute(
|
||||||
|
"SELECT image_task_key FROM tasks WHERE id = ?",
|
||||||
|
(task_id,),
|
||||||
|
).fetchone()
|
||||||
|
if row is None:
|
||||||
|
raise DbError(f"任务不存在: {task_id}")
|
||||||
|
key = str(image_task_key or row["image_task_key"] or "").strip()
|
||||||
|
if not key:
|
||||||
|
key = f"cmshopee-task-{task_id}-{uuid.uuid4().hex}"
|
||||||
|
database.execute(
|
||||||
|
"""
|
||||||
|
UPDATE tasks
|
||||||
|
SET image_task_id = ?,
|
||||||
|
image_task_key = ?,
|
||||||
|
updated_at = ?
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(image_task_id, key, _now(), task_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def clear_image_task(task_id, path=None, conn=None) -> None:
|
||||||
|
"""Clear persisted cmhub image task state so the next cover run can submit anew."""
|
||||||
|
|
||||||
|
with _connection(conn, path) as database:
|
||||||
|
with database:
|
||||||
|
database.execute(
|
||||||
|
"""
|
||||||
|
UPDATE tasks
|
||||||
|
SET image_task_id = NULL,
|
||||||
|
image_task_key = NULL,
|
||||||
|
updated_at = ?
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(_now(), int(task_id)),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def set_product_status(
|
||||||
|
task_id,
|
||||||
|
product_status_value,
|
||||||
|
product_status_note=None,
|
||||||
|
product_status_at=None,
|
||||||
|
path=None,
|
||||||
|
conn=None,
|
||||||
|
) -> None:
|
||||||
|
"""Persist a status snapshot without changing the collection lifecycle."""
|
||||||
|
|
||||||
now = _now()
|
now = _now()
|
||||||
|
detected_at = product_status_at or now
|
||||||
|
with _connection(conn, path) as database:
|
||||||
|
with database:
|
||||||
|
database.execute(
|
||||||
|
"""
|
||||||
|
UPDATE tasks
|
||||||
|
SET product_status = ?,
|
||||||
|
product_status_note = ?,
|
||||||
|
product_status_at = ?,
|
||||||
|
updated_at = ?
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
product_status.normalize_status(product_status_value),
|
||||||
|
str(product_status_note or "")[:2000] or None,
|
||||||
|
detected_at,
|
||||||
|
now,
|
||||||
|
int(task_id),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def set_collected(
|
||||||
|
task_id,
|
||||||
|
old_title,
|
||||||
|
old_cover_path,
|
||||||
|
path=None,
|
||||||
|
conn=None,
|
||||||
|
*,
|
||||||
|
product_status_value=None,
|
||||||
|
product_status_note=None,
|
||||||
|
product_status_at=None,
|
||||||
|
) -> None:
|
||||||
|
now = _now()
|
||||||
|
has_status_snapshot = product_status_value is not None
|
||||||
|
detected_at = product_status_at or now
|
||||||
with _connection(conn, path) as database:
|
with _connection(conn, path) as database:
|
||||||
with database:
|
with database:
|
||||||
database.execute(
|
database.execute(
|
||||||
@@ -702,10 +1348,25 @@ def set_collected(task_id, old_title, old_cover_path, path=None, conn=None) -> N
|
|||||||
last_error = NULL,
|
last_error = NULL,
|
||||||
collect_attempts = collect_attempts + 1,
|
collect_attempts = collect_attempts + 1,
|
||||||
collected_at = ?,
|
collected_at = ?,
|
||||||
|
product_status = CASE WHEN ? THEN ? ELSE product_status END,
|
||||||
|
product_status_note = CASE WHEN ? THEN ? ELSE product_status_note END,
|
||||||
|
product_status_at = CASE WHEN ? THEN ? ELSE product_status_at END,
|
||||||
updated_at = ?
|
updated_at = ?
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
""",
|
""",
|
||||||
(old_title, old_cover_path, now, now, int(task_id)),
|
(
|
||||||
|
old_title,
|
||||||
|
old_cover_path,
|
||||||
|
now,
|
||||||
|
int(has_status_snapshot),
|
||||||
|
product_status.normalize_status(product_status_value),
|
||||||
|
int(has_status_snapshot),
|
||||||
|
str(product_status_note or "")[:2000] or None,
|
||||||
|
int(has_status_snapshot),
|
||||||
|
detected_at,
|
||||||
|
now,
|
||||||
|
int(task_id),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -729,6 +1390,29 @@ def set_generated(task_id, new_title, new_cover_path, path=None, conn=None) -> N
|
|||||||
(new_title, new_cover_path, now, now, int(task_id)),
|
(new_title, new_cover_path, now, now, int(task_id)),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def set_generated_cover(task_id, new_cover_path, path=None, conn=None) -> None:
|
||||||
|
"""Persist an AI-generated cover without changing the generated title."""
|
||||||
|
|
||||||
|
now = _now()
|
||||||
|
with _connection(conn, path) as database:
|
||||||
|
with database:
|
||||||
|
database.execute(
|
||||||
|
"""
|
||||||
|
UPDATE tasks
|
||||||
|
SET new_cover_path = ?,
|
||||||
|
stage = 'generated',
|
||||||
|
status = 'success',
|
||||||
|
last_error = NULL,
|
||||||
|
generate_attempts = generate_attempts + 1,
|
||||||
|
generated_at = ?,
|
||||||
|
updated_at = ?
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(new_cover_path, now, now, int(task_id)),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def update_generated_title(task_id, new_title, path=None, conn=None) -> None:
|
def update_generated_title(task_id, new_title, path=None, conn=None) -> None:
|
||||||
"""Update a generated title manually without touching Shopee or local cover files."""
|
"""Update a generated title manually without touching Shopee or local cover files."""
|
||||||
|
|
||||||
@@ -759,6 +1443,38 @@ def update_generated_title(task_id, new_title, path=None, conn=None) -> None:
|
|||||||
(title, now, int(task_id)),
|
(title, now, int(task_id)),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def update_generated_cover(task_id, new_cover_path, path=None, conn=None) -> None:
|
||||||
|
"""Point a task at an existing local generated cover without copying the file."""
|
||||||
|
|
||||||
|
cover_path = str(new_cover_path or "").strip()
|
||||||
|
if not cover_path:
|
||||||
|
raise DbError("新封面路径不能为空")
|
||||||
|
abs_cover_path = os.path.abspath(cover_path)
|
||||||
|
if not os.path.isfile(abs_cover_path):
|
||||||
|
raise DbError("新封面图片不存在")
|
||||||
|
with _connection(conn, path) as database:
|
||||||
|
task = get_task(task_id, conn=database)
|
||||||
|
if task is None:
|
||||||
|
raise DbError(f"任务不存在: {task_id}")
|
||||||
|
if task.status == "running":
|
||||||
|
raise DbError("任务正在运行,不能修改新封面")
|
||||||
|
now = _now()
|
||||||
|
with database:
|
||||||
|
database.execute(
|
||||||
|
"""
|
||||||
|
UPDATE tasks
|
||||||
|
SET new_cover_path = ?,
|
||||||
|
stage = 'generated',
|
||||||
|
status = 'pending',
|
||||||
|
last_error = NULL,
|
||||||
|
updated_at = ?
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(abs_cover_path, now, int(task_id)),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def set_applied(task_id, committed, error=None, path=None, conn=None, step=None) -> None:
|
def set_applied(task_id, committed, error=None, path=None, conn=None, step=None) -> None:
|
||||||
now = _now()
|
now = _now()
|
||||||
success = bool(committed) and error is None
|
success = bool(committed) and error is None
|
||||||
@@ -814,26 +1530,46 @@ def reset_generated(
|
|||||||
if before is None:
|
if before is None:
|
||||||
raise DbError(f"任务不存在: {task_id}")
|
raise DbError(f"任务不存在: {task_id}")
|
||||||
new_cover_path = before.new_cover_path
|
new_cover_path = before.new_cover_path
|
||||||
deleted_file = None
|
archived_file = None
|
||||||
if delete_file and reset_cover and new_cover_path and os.path.isfile(str(new_cover_path)):
|
if reset_cover:
|
||||||
os.remove(str(new_cover_path))
|
archived_file = _archive_generated_cover_file(new_cover_path)
|
||||||
deleted_file = new_cover_path
|
|
||||||
now = _now()
|
now = _now()
|
||||||
new_title = None if reset_title else before.new_title
|
new_title = None if reset_title else before.new_title
|
||||||
new_cover = None if reset_cover else before.new_cover_path
|
new_cover = None if reset_cover else before.new_cover_path
|
||||||
|
image_task_id = None if reset_cover else before.image_task_id
|
||||||
|
image_task_key = None if reset_cover else before.image_task_key
|
||||||
|
should_record_cover_reset = bool(reset_cover and str(before.new_cover_path or "").strip())
|
||||||
|
cover_reset_count = int(before.cover_reset_count or 0)
|
||||||
|
cover_reset_at = before.cover_reset_at
|
||||||
|
if should_record_cover_reset:
|
||||||
|
cover_reset_count += 1
|
||||||
|
cover_reset_at = now
|
||||||
with database:
|
with database:
|
||||||
database.execute(
|
database.execute(
|
||||||
"""
|
"""
|
||||||
UPDATE tasks
|
UPDATE tasks
|
||||||
SET new_title = ?,
|
SET new_title = ?,
|
||||||
new_cover_path = ?,
|
new_cover_path = ?,
|
||||||
|
image_task_id = ?,
|
||||||
|
image_task_key = ?,
|
||||||
|
cover_reset_count = ?,
|
||||||
|
cover_reset_at = ?,
|
||||||
stage = 'generated',
|
stage = 'generated',
|
||||||
status = 'success',
|
status = 'success',
|
||||||
last_error = NULL,
|
last_error = NULL,
|
||||||
updated_at = ?
|
updated_at = ?
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
""",
|
""",
|
||||||
(new_title, new_cover, now, int(task_id)),
|
(
|
||||||
|
new_title,
|
||||||
|
new_cover,
|
||||||
|
image_task_id,
|
||||||
|
image_task_key,
|
||||||
|
cover_reset_count,
|
||||||
|
cover_reset_at,
|
||||||
|
now,
|
||||||
|
int(task_id),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
after = get_task(task_id, conn=database)
|
after = get_task(task_id, conn=database)
|
||||||
return {
|
return {
|
||||||
@@ -841,7 +1577,8 @@ def reset_generated(
|
|||||||
"before": before,
|
"before": before,
|
||||||
"after": after,
|
"after": after,
|
||||||
"new_cover_path": new_cover_path,
|
"new_cover_path": new_cover_path,
|
||||||
"deleted_file": deleted_file,
|
"archived_file": archived_file,
|
||||||
|
"deleted_file": None,
|
||||||
"reset_title": bool(reset_title),
|
"reset_title": bool(reset_title),
|
||||||
"reset_cover": bool(reset_cover),
|
"reset_cover": bool(reset_cover),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Bundled default prompt resources."""
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Bundled default cover prompt templates."""
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
商品标题:{新标题}
|
||||||
|
你是一名专业电商主图视觉设计师,请根据“商品标题”和“随请求一起上传的原产品主图”,直接生成一张新的电商商品主图。
|
||||||
|
这张图片主要用于 Shopee / 虾皮手机端商品展示。生成目标是:在严格还原商品本体的基础上,重新设计背景、构图、模特、姿势、光线、道具和排版,生成一张与原图明显不同、但商品一致的全新电商主图,让商品在手机端浏览时清晰、醒目、真实、有购买欲。
|
||||||
|
|
||||||
|
一、【用户可配置字段】
|
||||||
|
商品分类:【可选填写;不填则根据商品标题和原图判断,例如:女装 / 3C配件 / 家居用品 / 母婴用品 / 宠物用品 / 美妆个护 / 小百货;不用可以删除本行】
|
||||||
|
|
||||||
|
目标平台:【默认 Shopee;如不是 Shopee,请改成你的目标平台,例如:Shopee / 淘宝 / 拼多多 / Amazon / 独立站;不用可以删除本行】
|
||||||
|
|
||||||
|
目标地区与语言:【默认台湾地区,繁体中文;如不是台湾地区,请改成你的目标地区和语言,例如:中国台湾,繁体中文 / 马来西亚,中文和英文 / 新加坡,英文 / 泰国,泰文;不用可以删除本行】
|
||||||
|
|
||||||
|
图片比例:【可选填写;不填则根据目标平台和商品类目判断,Shopee 默认优先 1:1,例如:1:1 / 3:4 / 4:5;不用可以删除本行】
|
||||||
|
|
||||||
|
目标人群:【可选填写;不填则根据商品标题、商品分类和原图判断,例如:台湾年轻女生 / 上班族 / 宝妈 / 宠物主人 / 租房人群 / 3C用户;不用可以删除本行】
|
||||||
|
|
||||||
|
候选风格词库:【可选填写;只能从中选择适合当前商品的 1-3 个风格方向,不要全部硬塞,例如:干净质感 / 韩系极简 / 生活场景 / 轻营销 / 高级棚拍 / 科技感 / 居家感 / 可爱风 / 自然光 / 清爽收纳 / 轻奢质感;不用可以删除本行】
|
||||||
|
|
||||||
|
必须保留的信息:【可选填写;填写图片中必须保留的商品特征,例如:保留原图商品颜色、保留包装盒、保留套装数量、保留透明材质、保留拉链设计、保留原图商品角度;不用可以删除本行】
|
||||||
|
|
||||||
|
禁止出现的信息:【可选填写;填写图片中不希望出现的内容,例如:不要出现价格、不要出现折扣、不要出现品牌 Logo、不要出现医疗功效、不要出现模特、不要出现英文、不要改变商品颜色;不用可以删除本行】
|
||||||
|
|
||||||
|
二、【商品输入信息】
|
||||||
|
商品标题:{新标题}
|
||||||
|
商品原图:请参考随请求一起上传的原产品主图。当前上传图片是商品本体的最高优先级参考。
|
||||||
|
三、【生成目标】
|
||||||
|
请直接生成一张新的电商商品主图。
|
||||||
|
生成前先根据商品标题和原产品主图判断商品类目、商品本体、颜色、结构、材质、数量、包装、配件、可见卖点、适合的主图风格、是否需要人物或模特、是否需要少量文字标签、适合的背景和构图。
|
||||||
|
四、【商品还原与画面重构规则】
|
||||||
|
原产品主图只用于识别和还原商品本体,不要复刻原图整体画面。
|
||||||
|
必须保留的是商品本体:商品颜色、款式、版型、结构、领口、袖型、长度、图案、材质质感、包装、数量、配件和其他可见核心特征。
|
||||||
|
可以并且应该重新设计的是非商品元素:背景、场景、光线、构图、拍摄角度、人物长相、人物发型、人物表情、人物姿势、鞋子、包包、手持道具、陈列道具、文字排版和整体氛围。
|
||||||
|
如果商品是服装、鞋包、饰品等模特展示类商品,只参考原图中模特身上的商品本体,不参考原图模特的脸、发型、身材、姿势、表情、鞋子、包包、背景和拍摄构图。请重新生成一个自然真实、适合目标人群的新模特,使用新的姿势、新的表情、新的场景和新的构图来展示同一件商品。
|
||||||
|
如果商品标题和原图发生冲突,商品外观、颜色、结构、数量、款式、包装等视觉信息优先以原图为准,标题只作为类目、用途和卖点理解的辅助信息。
|
||||||
|
不得虚构商品原图和标题中没有的信息。不要编造材质、功能、规格、尺寸、颜色、品牌、认证、功效、适用人群、价格、折扣、销量、排名、平台活动或官方授权。
|
||||||
|
五、【主图风格与构图规则】
|
||||||
|
这不是原图微调,也不是原图局部修复,而是基于原图商品本体重新设计一张新的电商主图。
|
||||||
|
新图必须与原图形成明显差异,至少在以下方面重新设计:背景场景、光线氛围、构图角度、人物姿势、人物表情、画面排版、道具搭配。
|
||||||
|
禁止生成与原图高度相似的图片。不要照搬原图背景,不要照搬原图模特脸,不要照搬原图姿势,不要照搬原图表情,不要照搬原图鞋子和道具,不要只做轻微换背景或局部美化。
|
||||||
|
允许保留的只有商品本体本身,例如服装的款式、颜色、版型、材质、图案和核心设计。其他画面元素都应重新设计,让新主图看起来像同一件商品的全新拍摄图,而不是原图的复制版。
|
||||||
|
如果商品是女装、男装、童装、鞋包、饰品等需要模特展示的商品,可以使用模特展示,但必须重新生成新的模特和新的拍摄画面。
|
||||||
|
模特要求:符合目标地区和目标人群审美,真实自然,不网红化,不夸张摆拍。模特脸部、发型、表情、姿势、鞋子、包包和背景都不要参考原图。只需要让模特穿着与原图一致的商品,展示商品的版型、长度、垂坠感、穿着效果和核心卖点。
|
||||||
|
姿势要求:重新设计自然站姿、微侧身、轻松行走、手拿咖啡、整理衣摆、自然插袋、轻扶包包等适合商品的动作。不要复刻原图姿势。
|
||||||
|
鞋子和配饰:除非鞋子、包包、配饰本身就是商品的一部分,否则不要照搬原图鞋子和配饰,可根据新场景重新搭配简洁、低干扰的鞋包配饰。
|
||||||
|
主图必须适合手机端电商浏览,尤其适合 Shopee 手机端。商品主体要清晰、醒目、居中或位于视觉焦点,缩略图状态下也要让用户一眼看清商品是什么。
|
||||||
|
商品主体永远是画面第一重点,背景、人物、道具、文字都只能服务商品,不能抢商品。根据商品类型自动决定商品占比,但必须保证手机端清晰识别。
|
||||||
|
默认不要使用纯白底,除非目标平台、用户要求或商品特性明显适合白底展示。优先选择干净、低干扰、有质感的背景,例如浅色棚拍背景、柔和渐变背景、极简生活化背景、桌面/台面轻场景、居家轻场景、低饱和场景背景。背景要提升商品质感和点击率,但不能复杂、杂乱或喧宾夺主。
|
||||||
|
如果商品适合人物或模特展示,例如服装、鞋包、饰品、美妆、母婴用品等,可以加入人物或模特,但人物只能用于展示商品效果,不能抢商品主体,不能让脸部、姿势或背景成为主要视觉焦点。如果商品不适合人物展示,不要强行添加人物,改用质感棚拍、轻场景、组合展示或商品特写。
|
||||||
|
整体画面要偏真实电商摄影质感,高清、干净、自然光感、商业感强,避免过度艺术化、过度杂志化、过度夸张营销、低质拼贴感和廉价广告感。
|
||||||
|
六、【图中文字规则】
|
||||||
|
根据商品标题、原图、目标平台和目标地区判断是否需要图中文字。不是所有商品都必须加文字。如果文字不能明显提升点击率,保持干净无文字。
|
||||||
|
如果需要添加文字,只允许加入 1-2 个短卖点或克制的情绪价值词,文字必须真实、简短、清晰,适合手机端阅读。文字区域不超过画面面积的 15%,不得遮挡商品主体和关键细节。
|
||||||
|
图中文字语言必须根据“目标地区与语言”决定;如果用户没有填写,默认使用台湾地区繁体中文。
|
||||||
|
禁止自动生成价格、折扣、销量、排名、限时活动、官方认证、授权标识、医疗疗效、绝对化承诺等不可验证内容,除非这些信息由用户明确提供。
|
||||||
|
七、【品牌、Logo 与平台标识规则】
|
||||||
|
原图中已经存在的真实商品 Logo、包装文字、品牌细节或产品标识,默认作为商品细节保留,除非用户在“禁止出现的信息”中明确要求去掉。
|
||||||
|
禁止新增或虚构品牌 Logo、平台 Logo、官方认证、授权标识、正品标识、奖章图标、销量图标、排行榜图标或任何可能造成侵权和误导的视觉元素。
|
||||||
|
八、【负面约束】
|
||||||
|
不要改变商品本体,不要改变商品颜色,不要改变商品结构,不要增加原图没有的核心商品配件,不要减少原图中的套装数量。
|
||||||
|
不要复刻原图背景,不要复刻原图模特脸,不要复刻原图发型,不要复刻原图姿势,不要复刻原图表情,不要复刻原图鞋子,不要复刻原图包包,不要复刻原图道具,不要复刻原图构图,不要生成与原图几乎一样的画面。
|
||||||
|
不要虚构功能,不要虚构材质,不要虚构品牌,不要生成价格折扣,不要生成销量排名,不要生成夸大功效,不要生成医疗疗效,不要生成虚假认证,不要让背景抢商品,不要让文字遮挡商品,不要让人物抢主体,不要生成低清晰度图片,不要生成畸形人体、畸形手指、错误透视、商品变形、文字乱码、杂乱排版或廉价广告感。
|
||||||
|
九、【最终生成要求】
|
||||||
|
直接生成一张新的电商商品主图,不要生成提示词,不要输出分析过程,不要输出多方案,不要输出解释说明,不要输出文字描述。
|
||||||
|
图片应符合目标平台和图片比例要求,商品真实清晰,主图点击吸引力强,手机端浏览体验好,商品本体还原准确,背景干净有质感,整体适合电商销售使用。
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Bundled product-suite prompt resources."""
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
生成目标:{套图名称},{生成目标}
|
||||||
|
平台:{平台};国家地区:{国家地区};输出语言:{输出语言}。
|
||||||
|
{尺寸与长图规则}
|
||||||
|
{禁用内容规则}
|
||||||
|
{价格信息规则}
|
||||||
|
{尺码信息规则}
|
||||||
|
{参考图规则}
|
||||||
|
商品卖点与要求:
|
||||||
|
{商品卖点与要求}
|
||||||
|
本次生成比例:{图片比例}。请严格按此比例输出,不能改成其他长宽比。
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
旧标题:{旧标题}
|
||||||
|
蝦皮台灣站 商品標題優化標準(最終梳理版)
|
||||||
|
版本:2026.05 最終優化版
|
||||||
|
適用平台:蝦皮台灣站(Shopee TW)
|
||||||
|
|
||||||
|
================================================================================
|
||||||
|
一、核心結構(強制執行)
|
||||||
|
================================================================================
|
||||||
|
• 標題開頭固定為:【台灣出貨】
|
||||||
|
• 開頭第二個詞必須是該商品「最核心商品賣點的搜索關鍵詞」
|
||||||
|
• 關鍵詞需符合台灣用戶搜尋習慣,並自然融入商品賣點與風格,痛點解決,受眾與場景,實時熱銷利益詞
|
||||||
|
• 總字數嚴格控制在 54~60 個字符(含中英文、空格、【】、標點),不可超過 60 字符
|
||||||
|
•标题中包含有大码。大尺码等大码女装相关的关键词,增加标题中大码女装的相关关键词密度。
|
||||||
|
•标题中包含有小個子。矮個子等小個子女装相关的关键词,增加标题中小個子穿搭的相关关键词密度。
|
||||||
|
|
||||||
|
================================================================================
|
||||||
|
二、絕對禁止事項
|
||||||
|
================================================================================
|
||||||
|
• 禁止使用任何時效承諾詞(現貨、快速出貨、秒發、24H出貨、隔日達等),除非原標題已出現
|
||||||
|
• 禁止出現店名或店鋪相關詞,所有的品牌標籤都是全面禁止的,禁止出現任何品牌名稱
|
||||||
|
• 原標題未出現材質時,嚴禁加入任何材質名詞
|
||||||
|
• 原標題未出現大碼/plus size相關詞時,嚴禁添加大尺碼描述
|
||||||
|
• 禁止醫療、治療、療效相關詞(詳見下方特殊處理)
|
||||||
|
• 請去掉如新港棉,雲朵棉,40支純棉等不會有用戶搜素的材質詞,增加核心指令請確保標題中的所有關鍵詞都是符合用戶搜素習慣,搜素需求的關鍵詞。
|
||||||
|
================================================================================
|
||||||
|
三、高價值詞保留與加入原則
|
||||||
|
================================================================================
|
||||||
|
• 必須保留原標題中的風格詞、版型詞(如:重工刺繡、文藝復古、古典復古、A字裙、大擺裙、寬鬆顯瘦、辣妹風、純欲風等)
|
||||||
|
• 優先加入季節與轉換詞(服飾類):冰絲涼感、抗熱消暑、透氣涼感、親膚舒適、輕薄無負擔
|
||||||
|
• 其他品類依商品特性加入對應利益詞與熱銷類目詞
|
||||||
|
• 服飾類風格需動態匹配原標題:
|
||||||
|
- 原標題偏辣妹/純欲 → 強調設計感、性感氣質、顯瘦曲線
|
||||||
|
- 原標題偏法式/名媛 → 強調優雅、高質感、知性氣質
|
||||||
|
- 原標題偏文藝/復古/媽媽裝 → 強調復古知性、寬鬆顯瘦、實用舒適
|
||||||
|
|
||||||
|
================================================================================
|
||||||
|
四、輸出格式 (不要展示思考過程,直接出結果)
|
||||||
|
================================================================================
|
||||||
|
單一標題:
|
||||||
|
直接輸出新標題(只輸出優化後標題)
|
||||||
|
|
||||||
|
================================================================================
|
||||||
|
輸出範例
|
||||||
|
================================================================================
|
||||||
|
單一標題範例:
|
||||||
|
【台灣出貨】新中式掛鐘 古典復古 簡約現代 繁體漢字 客廳餐廳 個性藝術創意 靜音時鐘 客廳裝飾 藝術時鐘
|
||||||
|
|
||||||
|
================================================================================
|
||||||
|
================================================================================
|
||||||
|
五、其他注意事項
|
||||||
|
================================================================================
|
||||||
|
• 相似商品批量生成時,修飾詞重複度需低於 50%(建議輪替同義詞)
|
||||||
|
• 所有標題必須使用繁體中文
|
||||||
|
• 嚴格遵守字數限制(建議目標控制在 55~58 字符,留緩衝空間)
|
||||||
|
|
||||||
|
================================================================================
|
||||||
|
六、時效詞處理原則(重要補充)
|
||||||
|
================================================================================
|
||||||
|
• 原標題「有」時效詞 → 新標題必須精準保留
|
||||||
|
• 原標題「沒有」時效詞 → 新標題嚴禁添加任何時效承諾詞
|
||||||
|
|
||||||
|
================================================================================
|
||||||
|
七、醫療與銀髮族商品特殊處理
|
||||||
|
================================================================================
|
||||||
|
嚴禁使用詞彙:
|
||||||
|
醫療、醫用、醫生推薦、臨床、治療、療效、復健、關節炎、靜脈曲張、腰椎間盤突出、止痛、消炎、殺菌、防褥瘡、降血壓、醫療級、復健車、血氧機、血壓計、助聽器、矯正帶
|
||||||
|
|
||||||
|
銀髮族/樂齡商品安全替代詞建議:
|
||||||
|
• 復健購物車 → 家用休具、休閒買菜車、輕便購物車
|
||||||
|
• 矯正坐墊 → 減壓坐墊、人體工學靠枕、久坐舒適款
|
||||||
|
• 按摩治療儀 → 居家舒壓按摩、放鬆神器、日常舒適好物
|
||||||
|
|
||||||
|
================================================================================
|
||||||
|
八、符合台灣用戶搜索習慣的實戰優化建議
|
||||||
|
================================================================================
|
||||||
|
1. 核心關鍵詞選擇優先使用平台即時建議
|
||||||
|
在蝦皮搜尋欄輸入商品類型,觀察自動跳出的熱門詞,優先選用搜尋量高的詞放在「【台灣出貨】」之後第二位。
|
||||||
|
|
||||||
|
2. 利益導向優先
|
||||||
|
台灣用戶特別愛搜:顯瘦、遮肉、寬鬆舒適、親膚、透氣涼感、百搭、日常穿搭、高CP值、輕薄無負擔。
|
||||||
|
建議在核心關鍵詞後盡量放這些「為什麼要買」的利益詞。
|
||||||
|
|
||||||
|
3. 自然流暢可讀(手機優先)
|
||||||
|
標題要像自然中文一樣好讀,手機上掃一眼就能懂。避免硬塞關鍵詞造成斷裂感。
|
||||||
|
|
||||||
|
4. 同義詞輪替策略(批量專用)
|
||||||
|
透氣涼感 / 親膚舒適 / 輕薄透氣 / 抗熱消暑 / 舒適無負擔
|
||||||
|
顯瘦 / 遮肉顯瘦 / 寬鬆顯瘦 / 版型優良
|
||||||
|
這樣可大幅降低重複度,同時維持高相關性。
|
||||||
|
|
||||||
|
5. 強化使用場景詞
|
||||||
|
台灣用戶常搜「日常穿」「居家穿」「出門穿」「媽媽實用」「通勤穿搭」「夏季必備」。適度加入能提升點擊率。
|
||||||
|
|
||||||
|
6. 信任感已由「台灣出貨」建立
|
||||||
|
不需再堆疊「現貨」「快速到貨」。可視情況輕加「品質嚴選」「台灣設計風格」(若原標題有相關元素)。
|
||||||
|
7. 字數精準控制技巧
|
||||||
|
建議目標控制在 55~58 字符,留一點緩衝空間。計算時務必包含空格與【】。
|
||||||
|
|
||||||
|
8. 服飾類風格精準動態匹配
|
||||||
|
- 辣妹/純欲風:設計感、性感氣質、顯瘦曲線
|
||||||
|
- 文藝復古/媽媽裝:復古知性、寬鬆顯瘦、實用舒適
|
||||||
|
- 法式/名媛風:優雅高質感、知性氣質
|
||||||
|
|
||||||
|
9. 定期迭代與數據驗證
|
||||||
|
上架後觀察賣家後台「曝光次數」與「點擊率」,把表現好的熱門關鍵詞保留或加強,表現差的替換。
|
||||||
|
|
||||||
|
10. 避免平台風險
|
||||||
|
嚴格遵守「原標題沒有就不要加」的原則(材質、尺寸、醫療詞),這是長期穩定排名的關鍵。
|
||||||
|
|
||||||
|
================================================================================
|
||||||
|
輸出範例
|
||||||
|
================================================================================
|
||||||
|
單一標題範例:
|
||||||
|
【台灣出貨】新中式掛鐘 古典復古 簡約現代 繁體漢字 客廳餐廳 個性藝術創意 靜音時鐘 客廳裝飾 藝術時鐘
|
||||||
|
|
||||||
|
================================================================================
|
||||||
|
注意事項總結
|
||||||
|
================================================================================
|
||||||
|
• 嚴格遵守字數 52-60 字符
|
||||||
|
• 繁體中文輸出
|
||||||
|
• 保留原標題風格詞與版型詞
|
||||||
|
• 時效詞「有就留、沒有就絕不加」
|
||||||
|
• 材質與尺寸「原標題沒有就絕不加」
|
||||||
|
• 醫療相關詞一律禁用,銀髮族改用安全替代詞
|
||||||
|
• 批量時修飾詞重複度低於 50%
|
||||||
|
• 建議定期根據賣家後台數據迭代熱門關鍵詞
|
||||||
+855
-154
File diff suppressed because it is too large
Load Diff
+138
-39
@@ -4,10 +4,19 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
import webbrowser
|
from dataclasses import replace
|
||||||
|
|
||||||
from .. import appconfig, diagnostics, update_check
|
from .. import (
|
||||||
from ..version import APP_NAME, display_name
|
appconfig,
|
||||||
|
chrome,
|
||||||
|
client_policy,
|
||||||
|
db,
|
||||||
|
diagnostics,
|
||||||
|
image_studio_generation,
|
||||||
|
update_check,
|
||||||
|
update_health,
|
||||||
|
)
|
||||||
|
from ..version import APP_NAME, APP_VERSION, display_name
|
||||||
from . import widgets as _widgets
|
from . import widgets as _widgets
|
||||||
from .widgets import *
|
from .widgets import *
|
||||||
|
|
||||||
@@ -16,22 +25,38 @@ QMessageBox = _widgets._RawQMessageBox
|
|||||||
run_worker = _widgets._raw_run_worker if QT_IMPORT_ERROR is None else _widgets.run_worker
|
run_worker = _widgets._raw_run_worker if QT_IMPORT_ERROR is None else _widgets.run_worker
|
||||||
|
|
||||||
if QT_IMPORT_ERROR is None:
|
if QT_IMPORT_ERROR is None:
|
||||||
|
from .activation_dialog import MembershipActivationDialog
|
||||||
from .models import ApplyTaskTableModel, GenerateTaskTableModel, TaskTableModel
|
from .models import ApplyTaskTableModel, GenerateTaskTableModel, TaskTableModel
|
||||||
from .workers import (
|
from .workers import (
|
||||||
AccountLoginCheckWorker,
|
AccountLoginCheckWorker,
|
||||||
AIModelTestWorker,
|
AIModelTestWorker,
|
||||||
|
CMHubModelCatalogWorker,
|
||||||
CMHubSettingsWorker,
|
CMHubSettingsWorker,
|
||||||
ApplyWorker,
|
ApplyWorker,
|
||||||
CollectWorker,
|
CollectWorker,
|
||||||
GenerateWorker,
|
GenerateWorker,
|
||||||
|
ImageStudioDownloadOriginalWorker,
|
||||||
|
ImageStudioExportWorker,
|
||||||
|
ImageStudioGenerateJobsWorker,
|
||||||
|
ImageStudioPullImagesWorker,
|
||||||
|
ImageStudioResumeJobsWorker,
|
||||||
|
ProductSuiteAiWriteWorker,
|
||||||
|
ProductSuiteGenerateWorker,
|
||||||
|
ProductSuiteHistoryExportWorker,
|
||||||
|
ProductSuiteImportImagesWorker,
|
||||||
|
ProductTabOpenWorker,
|
||||||
WriteBackWorker,
|
WriteBackWorker,
|
||||||
)
|
)
|
||||||
from .tabs.accounts import AccountDialog, AccountsTab
|
from .tabs.accounts import AccountDialog, AccountsTab
|
||||||
from .tabs.apply import ApplyTab
|
from .tabs.apply import ApplyTab
|
||||||
from .tabs.collect import CollectTab
|
from .tabs.collect import CollectTab
|
||||||
from .tabs.generate import GenerateTab
|
from .tabs.generate import GenerateTab
|
||||||
|
from .tabs.image_studio import ImageStudioPreviewDialog, ImageStudioTab
|
||||||
|
from .product_suite_prompt_dialog import ProductSuitePromptDialog
|
||||||
|
from .tabs.product_suite import ProductSuitePreviewDialog, ProductSuiteTab
|
||||||
from .tabs.settings import SettingsTab
|
from .tabs.settings import SettingsTab
|
||||||
from .main_window import MainWindow
|
from .main_window import MainWindow
|
||||||
|
from .update_dialog import ForcedUpdateDialog, UpdatePreparationWorker
|
||||||
else:
|
else:
|
||||||
class MainWindow(QMainWindow):
|
class MainWindow(QMainWindow):
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
@@ -54,6 +79,10 @@ def _write_update_check_diagnostic(message, *, result=None, exc=None):
|
|||||||
"min_supported_version": result.min_supported_version,
|
"min_supported_version": result.min_supported_version,
|
||||||
"download_url": result.download_url,
|
"download_url": result.download_url,
|
||||||
"error": result.error,
|
"error": result.error,
|
||||||
|
"client_policy_mode": (
|
||||||
|
result.client_policy.mode if result.client_policy is not None else ""
|
||||||
|
),
|
||||||
|
"client_policy_error": result.client_policy_error,
|
||||||
}
|
}
|
||||||
try:
|
try:
|
||||||
diagnostics.write_diagnostic_log(
|
diagnostics.write_diagnostic_log(
|
||||||
@@ -68,52 +97,45 @@ def _write_update_check_diagnostic(message, *, result=None, exc=None):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
def _forced_update_details(result):
|
def _show_forced_update_dialog(result, *, parent=None, dialog_factory=None) -> bool:
|
||||||
online_version = result.latest_version or result.min_supported_version or "未知"
|
dialog_class = dialog_factory or ForcedUpdateDialog
|
||||||
lines = [
|
dialog = dialog_class(result, parent=parent)
|
||||||
f"当前版本:{result.current_version}",
|
dialog.exec()
|
||||||
f"线上版本:{online_version}",
|
|
||||||
]
|
|
||||||
if result.min_supported_version:
|
|
||||||
lines.append(f"最低支持版本:{result.min_supported_version}")
|
|
||||||
if result.message:
|
|
||||||
lines.append(f"升级说明:{result.message}")
|
|
||||||
if result.download_url:
|
|
||||||
lines.append("请下载新版,关闭程序后覆盖程序文件和 _internal/,保留 data/ 目录。")
|
|
||||||
else:
|
|
||||||
lines.append("版本接口未提供下载地址,请联系管理员获取新版后再使用。")
|
|
||||||
return "\n".join(lines)
|
|
||||||
|
|
||||||
|
|
||||||
def _show_forced_update_dialog(result, *, parent=None, opener=None) -> bool:
|
|
||||||
opener = opener or webbrowser.open
|
|
||||||
box = QMessageBox(parent)
|
|
||||||
box.setIcon(QMessageBox.Warning)
|
|
||||||
box.setWindowTitle("必须升级")
|
|
||||||
box.setText("当前版本已不能继续使用,请先升级到新版。")
|
|
||||||
box.setInformativeText(_forced_update_details(result))
|
|
||||||
download_button = box.addButton("下载新版", QMessageBox.AcceptRole)
|
|
||||||
exit_button = box.addButton("退出程序", QMessageBox.RejectRole)
|
|
||||||
if not result.download_url and hasattr(download_button, "setEnabled"):
|
|
||||||
download_button.setEnabled(False)
|
|
||||||
box.setDefaultButton(download_button if result.download_url else exit_button)
|
|
||||||
box.exec()
|
|
||||||
if box.clickedButton() is download_button and result.download_url:
|
|
||||||
opener(result.download_url)
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def _run_startup_update_gate(*, checker=None, opener=None) -> bool:
|
def _run_startup_update_gate(
|
||||||
|
*,
|
||||||
|
checker=None,
|
||||||
|
dialog_factory=None,
|
||||||
|
result_callback=None,
|
||||||
|
) -> bool:
|
||||||
try:
|
try:
|
||||||
result = (checker or update_check.check_for_update)()
|
result = (checker or update_check.check_for_update)()
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
_write_update_check_diagnostic("启动版本检查异常,已允许继续使用", exc=exc)
|
_write_update_check_diagnostic("启动版本检查异常,已允许继续使用", exc=exc)
|
||||||
return True
|
return True
|
||||||
|
if result_callback is not None:
|
||||||
|
result_callback(result)
|
||||||
|
|
||||||
if result.error:
|
if result.error:
|
||||||
_write_update_check_diagnostic("启动版本检查失败,已允许继续使用", result=result)
|
_write_update_check_diagnostic("启动版本检查失败,已允许继续使用", result=result)
|
||||||
|
elif result.client_policy_error:
|
||||||
|
_write_update_check_diagnostic("客户端订阅策略已按安全兼容规则处理", result=result)
|
||||||
if result.forced:
|
if result.forced:
|
||||||
return _show_forced_update_dialog(result, opener=opener)
|
failed = update_health.get_failed_release(
|
||||||
|
appconfig.app_base_dir(),
|
||||||
|
result.latest_version,
|
||||||
|
result.sha256,
|
||||||
|
)
|
||||||
|
if failed:
|
||||||
|
result = replace(
|
||||||
|
result,
|
||||||
|
automatic_update_error=(
|
||||||
|
"该版本自动升级曾失败,已停止重复安装。请等待管理员发布修复版本,或手动安装新版。"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return _show_forced_update_dialog(result, dialog_factory=dialog_factory)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
@@ -123,19 +145,96 @@ def main() -> int:
|
|||||||
return 1
|
return 1
|
||||||
_ensure_offscreen_for_headless_tests()
|
_ensure_offscreen_for_headless_tests()
|
||||||
app = QApplication.instance() or QApplication(sys.argv)
|
app = QApplication.instance() or QApplication(sys.argv)
|
||||||
|
# Set on the application so the taskbar button, dialogs and message boxes
|
||||||
|
# all inherit it, not just the main window.
|
||||||
|
startup_icon = app_icon()
|
||||||
|
if startup_icon is not None:
|
||||||
|
app.setWindowIcon(startup_icon)
|
||||||
|
health_context = update_health.context_from_argv(
|
||||||
|
sys.argv,
|
||||||
|
appconfig.app_base_dir(),
|
||||||
|
APP_VERSION,
|
||||||
|
)
|
||||||
|
update_health.write_health(health_context, "process_started")
|
||||||
try:
|
try:
|
||||||
appconfig.prepare_data_dir()
|
appconfig.prepare_data_dir()
|
||||||
except appconfig.DataMigrationConflictError as exc:
|
except appconfig.DataMigrationConflictError as exc:
|
||||||
|
update_health.write_health(health_context, "environment_blocked", str(exc))
|
||||||
QMessageBox.critical(None, "数据迁移冲突", str(exc))
|
QMessageBox.critical(None, "数据迁移冲突", str(exc))
|
||||||
return 1
|
return 1
|
||||||
except appconfig.DataDirectoryWriteError as exc:
|
except appconfig.DataDirectoryWriteError as exc:
|
||||||
|
update_health.write_health(health_context, "environment_blocked", str(exc))
|
||||||
QMessageBox.critical(None, "数据目录不可写", str(exc))
|
QMessageBox.critical(None, "数据目录不可写", str(exc))
|
||||||
return 1
|
return 1
|
||||||
except appconfig.ConfigError as exc:
|
except appconfig.ConfigError as exc:
|
||||||
|
update_health.write_health(health_context, "environment_blocked", str(exc))
|
||||||
QMessageBox.critical(None, "启动配置错误", str(exc))
|
QMessageBox.critical(None, "启动配置错误", str(exc))
|
||||||
return 1
|
return 1
|
||||||
if not _run_startup_update_gate():
|
startup_update_results = []
|
||||||
|
if not _run_startup_update_gate(result_callback=startup_update_results.append):
|
||||||
return 1
|
return 1
|
||||||
window = MainWindow()
|
try:
|
||||||
|
startup = chrome.ensure_configured_chrome_path()
|
||||||
|
except (OSError, appconfig.ConfigError) as exc:
|
||||||
|
update_health.write_health(health_context, "environment_blocked", str(exc))
|
||||||
|
QMessageBox.critical(None, "启动配置错误", str(exc))
|
||||||
|
return 1
|
||||||
|
runtime_lease = None
|
||||||
|
try:
|
||||||
|
database_path = appconfig.db_path(startup["config"])
|
||||||
|
db.init_db(database_path)
|
||||||
|
runtime_lease = image_studio_generation.acquire_startup_recovery_lease(
|
||||||
|
appconfig.data_dir(startup["config"])
|
||||||
|
)
|
||||||
|
recovery = image_studio_generation.recover_stale_direct_jobs_at_startup(
|
||||||
|
lease=runtime_lease,
|
||||||
|
path=database_path,
|
||||||
|
)
|
||||||
|
recovered_count = len(recovery.get("recovered") or [])
|
||||||
|
if recovered_count:
|
||||||
|
diagnostics.write_diagnostic_log(
|
||||||
|
"启动时标记未确认的自定义网关套图任务",
|
||||||
|
level="WARNING",
|
||||||
|
step="image_studio_direct_recovery",
|
||||||
|
payload={"recovered_count": recovered_count},
|
||||||
|
)
|
||||||
|
elif recovery.get("skipped"):
|
||||||
|
diagnostics.write_diagnostic_log(
|
||||||
|
"检测到另一个程序实例,跳过自定义网关套图任务恢复",
|
||||||
|
level="INFO",
|
||||||
|
step="image_studio_direct_recovery",
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
_write_update_check_diagnostic("自定义网关套图任务恢复检查失败", exc=exc)
|
||||||
|
remote_policy = (
|
||||||
|
startup_update_results[-1].client_policy
|
||||||
|
if startup_update_results
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
resolved_policy = client_policy.resolve_client_policy(
|
||||||
|
remote_policy,
|
||||||
|
config=startup["config"],
|
||||||
|
)
|
||||||
|
if resolved_policy.warning:
|
||||||
|
_write_update_check_diagnostic(
|
||||||
|
"客户端订阅策略使用兼容模式",
|
||||||
|
result=(
|
||||||
|
startup_update_results[-1]
|
||||||
|
if startup_update_results
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
)
|
||||||
|
window = MainWindow(
|
||||||
|
config=startup["config"],
|
||||||
|
startup_status=startup["message"],
|
||||||
|
subscription_policy=resolved_policy,
|
||||||
|
)
|
||||||
|
if runtime_lease is not None:
|
||||||
|
app.aboutToQuit.connect(runtime_lease.release)
|
||||||
window.show()
|
window.show()
|
||||||
|
QTimer.singleShot(0, window.begin_subscription_check)
|
||||||
|
QTimer.singleShot(
|
||||||
|
0,
|
||||||
|
lambda: update_health.write_health(health_context, "main_window_ready"),
|
||||||
|
)
|
||||||
return app.exec()
|
return app.exec()
|
||||||
|
|||||||
@@ -0,0 +1,161 @@
|
|||||||
|
"""First-run membership activation dialog."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from PySide6.QtCore import Qt, Signal
|
||||||
|
from PySide6.QtWidgets import (
|
||||||
|
QCheckBox,
|
||||||
|
QDialog,
|
||||||
|
QHBoxLayout,
|
||||||
|
QLabel,
|
||||||
|
QLineEdit,
|
||||||
|
QPushButton,
|
||||||
|
QVBoxLayout,
|
||||||
|
)
|
||||||
|
|
||||||
|
from .widgets import (
|
||||||
|
BUTTON_BASE_STYLE,
|
||||||
|
COLOR_DANGER,
|
||||||
|
COLOR_INFO,
|
||||||
|
COLOR_MUTED,
|
||||||
|
COLOR_SUCCESS,
|
||||||
|
_primary_button_style,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class MembershipActivationDialog(QDialog):
|
||||||
|
"""Collect one membership API key and report asynchronous validation state."""
|
||||||
|
|
||||||
|
validationRequested = Signal(str)
|
||||||
|
memberCenterRequested = Signal()
|
||||||
|
|
||||||
|
def __init__(self, parent=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
self._validating = False
|
||||||
|
self.setObjectName("membershipActivationDialog")
|
||||||
|
self.setWindowTitle("激活蝦皮圈优化助手")
|
||||||
|
self.setModal(True)
|
||||||
|
self.setWindowModality(Qt.WindowModal)
|
||||||
|
self.setMinimumWidth(520)
|
||||||
|
self.setStyleSheet(BUTTON_BASE_STYLE)
|
||||||
|
|
||||||
|
layout = QVBoxLayout(self)
|
||||||
|
layout.setContentsMargins(24, 22, 24, 22)
|
||||||
|
layout.setSpacing(12)
|
||||||
|
|
||||||
|
heading = QLabel("首次使用需要绑定会员账号")
|
||||||
|
heading.setObjectName("membershipActivationHeading")
|
||||||
|
heading.setStyleSheet("font-size: 16px; font-weight: 600; color: #24292f;")
|
||||||
|
layout.addWidget(heading)
|
||||||
|
|
||||||
|
description = QLabel(
|
||||||
|
"请从线上会员中心复制会员 API Key。程序会先验证账号和套餐,"
|
||||||
|
"验证通过后再开放业务功能。"
|
||||||
|
)
|
||||||
|
description.setObjectName("membershipActivationDescription")
|
||||||
|
description.setWordWrap(True)
|
||||||
|
description.setStyleSheet(f"color: {COLOR_MUTED};")
|
||||||
|
layout.addWidget(description)
|
||||||
|
|
||||||
|
key_label = QLabel("会员 API Key")
|
||||||
|
key_label.setObjectName("membershipApiKeyLabel")
|
||||||
|
layout.addWidget(key_label)
|
||||||
|
|
||||||
|
self.api_key_edit = QLineEdit()
|
||||||
|
self.api_key_edit.setObjectName("membershipApiKeyEdit")
|
||||||
|
self.api_key_edit.setEchoMode(QLineEdit.Password)
|
||||||
|
self.api_key_edit.setPlaceholderText("粘贴会员 API Key")
|
||||||
|
self.api_key_edit.setClearButtonEnabled(True)
|
||||||
|
self.api_key_edit.returnPressed.connect(self._request_validation)
|
||||||
|
key_label.setBuddy(self.api_key_edit)
|
||||||
|
layout.addWidget(self.api_key_edit)
|
||||||
|
|
||||||
|
self.show_key_checkbox = QCheckBox("显示 API Key")
|
||||||
|
self.show_key_checkbox.setObjectName("showMembershipApiKeyCheckbox")
|
||||||
|
self.show_key_checkbox.toggled.connect(self._toggle_key_visibility)
|
||||||
|
layout.addWidget(self.show_key_checkbox)
|
||||||
|
|
||||||
|
storage_hint = QLabel(
|
||||||
|
"API Key 在输入框和设置页中打码显示,但会以本机明文配置保存,"
|
||||||
|
"仅用于会员验证和默认网关请求。"
|
||||||
|
)
|
||||||
|
storage_hint.setObjectName("membershipApiKeyStorageHint")
|
||||||
|
storage_hint.setWordWrap(True)
|
||||||
|
storage_hint.setStyleSheet(f"color: {COLOR_MUTED};")
|
||||||
|
layout.addWidget(storage_hint)
|
||||||
|
|
||||||
|
self.status_label = QLabel("")
|
||||||
|
self.status_label.setObjectName("membershipActivationStatusLabel")
|
||||||
|
self.status_label.setWordWrap(True)
|
||||||
|
self.status_label.setMinimumHeight(36)
|
||||||
|
layout.addWidget(self.status_label)
|
||||||
|
|
||||||
|
button_layout = QHBoxLayout()
|
||||||
|
button_layout.setSpacing(8)
|
||||||
|
self.member_center_button = QPushButton("前往会员中心获取 API Key")
|
||||||
|
self.member_center_button.setObjectName("membershipCenterButton")
|
||||||
|
self.member_center_button.clicked.connect(self.memberCenterRequested.emit)
|
||||||
|
button_layout.addWidget(self.member_center_button)
|
||||||
|
button_layout.addStretch(1)
|
||||||
|
|
||||||
|
self.exit_button = QPushButton("退出程序")
|
||||||
|
self.exit_button.setObjectName("membershipActivationExitButton")
|
||||||
|
self.exit_button.clicked.connect(self.reject)
|
||||||
|
button_layout.addWidget(self.exit_button)
|
||||||
|
|
||||||
|
self.validate_button = QPushButton("验证并开始使用")
|
||||||
|
self.validate_button.setObjectName("membershipActivationValidateButton")
|
||||||
|
self.validate_button.setDefault(True)
|
||||||
|
self.validate_button.setStyleSheet(
|
||||||
|
_primary_button_style("membershipActivationValidateButton")
|
||||||
|
)
|
||||||
|
self.validate_button.clicked.connect(self._request_validation)
|
||||||
|
button_layout.addWidget(self.validate_button)
|
||||||
|
layout.addLayout(button_layout)
|
||||||
|
|
||||||
|
self.api_key_edit.setFocus()
|
||||||
|
|
||||||
|
def api_key(self) -> str:
|
||||||
|
return self.api_key_edit.text().strip()
|
||||||
|
|
||||||
|
def set_validating(self, validating: bool) -> None:
|
||||||
|
self._validating = bool(validating)
|
||||||
|
self.api_key_edit.setEnabled(not self._validating)
|
||||||
|
self.show_key_checkbox.setEnabled(not self._validating)
|
||||||
|
self.validate_button.setEnabled(not self._validating)
|
||||||
|
self.validate_button.setText(
|
||||||
|
"正在验证会员账号..." if self._validating else "验证并开始使用"
|
||||||
|
)
|
||||||
|
if self._validating:
|
||||||
|
self.set_status("正在验证会员账号,请稍候。", level="info")
|
||||||
|
|
||||||
|
def set_status(self, message: str, *, level: str = "muted") -> None:
|
||||||
|
color = {
|
||||||
|
"danger": COLOR_DANGER,
|
||||||
|
"info": COLOR_INFO,
|
||||||
|
"success": COLOR_SUCCESS,
|
||||||
|
}.get(str(level), COLOR_MUTED)
|
||||||
|
self.status_label.setStyleSheet(f"color: {color};")
|
||||||
|
self.status_label.setText(str(message or ""))
|
||||||
|
|
||||||
|
def focus_api_key(self) -> None:
|
||||||
|
self.api_key_edit.setFocus()
|
||||||
|
self.api_key_edit.selectAll()
|
||||||
|
|
||||||
|
def accept_activation(self) -> None:
|
||||||
|
self.set_validating(False)
|
||||||
|
self.accept()
|
||||||
|
|
||||||
|
def _request_validation(self) -> None:
|
||||||
|
if self._validating:
|
||||||
|
return
|
||||||
|
key = self.api_key()
|
||||||
|
if not key:
|
||||||
|
self.set_status("请输入会员 API Key。", level="danger")
|
||||||
|
self.focus_api_key()
|
||||||
|
return
|
||||||
|
self.validationRequested.emit(key)
|
||||||
|
|
||||||
|
def _toggle_key_visibility(self, checked: bool) -> None:
|
||||||
|
mode = QLineEdit.Normal if checked else QLineEdit.Password
|
||||||
|
self.api_key_edit.setEchoMode(mode)
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
"""Helpers for opening local folders from the GUI."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
def open_in_file_manager(path: str) -> str:
|
||||||
|
"""Open an existing directory with the platform file manager.
|
||||||
|
|
||||||
|
Returns the absolute path that was opened. The helper never creates
|
||||||
|
directories; callers should present FileNotFoundError as a user-facing
|
||||||
|
warning.
|
||||||
|
"""
|
||||||
|
|
||||||
|
target = os.path.abspath(str(path or ""))
|
||||||
|
if not os.path.isdir(target):
|
||||||
|
raise FileNotFoundError(f"目录不存在:{target}")
|
||||||
|
|
||||||
|
if sys.platform.startswith("win"):
|
||||||
|
startfile = getattr(os, "startfile", None)
|
||||||
|
if startfile is None:
|
||||||
|
raise OSError("当前系统不支持打开文件夹")
|
||||||
|
startfile(target)
|
||||||
|
return target
|
||||||
|
|
||||||
|
command = ["open", target] if sys.platform == "darwin" else ["xdg-open", target]
|
||||||
|
subprocess.Popen(
|
||||||
|
command,
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
shell=False,
|
||||||
|
)
|
||||||
|
return target
|
||||||
@@ -0,0 +1,239 @@
|
|||||||
|
"""Reusable full-image preview dialog for desktop image workflows."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
import os
|
||||||
|
|
||||||
|
from PySide6.QtCore import QSize, Qt, QTimer
|
||||||
|
from PySide6.QtGui import QImageReader, QPixmap
|
||||||
|
from PySide6.QtWidgets import (
|
||||||
|
QApplication,
|
||||||
|
QDialog,
|
||||||
|
QHBoxLayout,
|
||||||
|
QLabel,
|
||||||
|
QPushButton,
|
||||||
|
QScrollArea,
|
||||||
|
QToolButton,
|
||||||
|
QVBoxLayout,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ImagePreviewDialog(QDialog):
|
||||||
|
"""Preview a local image with bounded zoom and fit-to-window behavior."""
|
||||||
|
|
||||||
|
MIN_ZOOM = 0.25
|
||||||
|
MAX_ZOOM = 4.0
|
||||||
|
ZOOM_STEP = 0.25
|
||||||
|
|
||||||
|
def __init__(self, path, title="图片预览", parent=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
self._base_title = str(title or "图片预览")
|
||||||
|
self._source = self._load_source(path)
|
||||||
|
self.fit_to_window = True
|
||||||
|
self.zoom_factor = 1.0
|
||||||
|
self._effective_zoom = 1.0
|
||||||
|
self._display_size = QSize()
|
||||||
|
self._resize_timer = QTimer(self)
|
||||||
|
self._resize_timer.setSingleShot(True)
|
||||||
|
self._resize_timer.setInterval(50)
|
||||||
|
self._resize_timer.timeout.connect(self._render)
|
||||||
|
|
||||||
|
self.setWindowTitle(self._window_title())
|
||||||
|
layout = QVBoxLayout(self)
|
||||||
|
self.scroll = QScrollArea()
|
||||||
|
self.scroll.setObjectName("imagePreviewScrollArea")
|
||||||
|
self.scroll.setWidgetResizable(False)
|
||||||
|
self.scroll.setAlignment(Qt.AlignCenter)
|
||||||
|
self.image_label = QLabel()
|
||||||
|
self.image_label.setObjectName("imagePreviewLabel")
|
||||||
|
self.image_label.setAlignment(Qt.AlignCenter)
|
||||||
|
self.scroll.setWidget(self.image_label)
|
||||||
|
layout.addWidget(self.scroll, 1)
|
||||||
|
|
||||||
|
controls = QHBoxLayout()
|
||||||
|
self.zoom_out_button = QToolButton()
|
||||||
|
self.zoom_out_button.setObjectName("imagePreviewZoomOutButton")
|
||||||
|
self.zoom_out_button.setText("-")
|
||||||
|
self.zoom_out_button.setToolTip("缩小图片")
|
||||||
|
self.zoom_out_button.setAccessibleName("缩小图片")
|
||||||
|
self.zoom_out_button.setMinimumSize(32, 32)
|
||||||
|
self.zoom_out_button.clicked.connect(self.zoom_out)
|
||||||
|
controls.addWidget(self.zoom_out_button)
|
||||||
|
|
||||||
|
self.zoom_label = QLabel("适应窗口")
|
||||||
|
self.zoom_label.setObjectName("imagePreviewZoomLabel")
|
||||||
|
self.zoom_label.setAlignment(Qt.AlignCenter)
|
||||||
|
self.zoom_label.setMinimumWidth(72)
|
||||||
|
controls.addWidget(self.zoom_label)
|
||||||
|
|
||||||
|
self.zoom_in_button = QToolButton()
|
||||||
|
self.zoom_in_button.setObjectName("imagePreviewZoomInButton")
|
||||||
|
self.zoom_in_button.setText("+")
|
||||||
|
self.zoom_in_button.setToolTip("放大图片")
|
||||||
|
self.zoom_in_button.setAccessibleName("放大图片")
|
||||||
|
self.zoom_in_button.setMinimumSize(32, 32)
|
||||||
|
self.zoom_in_button.clicked.connect(self.zoom_in)
|
||||||
|
controls.addWidget(self.zoom_in_button)
|
||||||
|
|
||||||
|
self.fit_button = QPushButton("适应窗口")
|
||||||
|
self.fit_button.setObjectName("imagePreviewFitButton")
|
||||||
|
self.fit_button.setCheckable(True)
|
||||||
|
self.fit_button.setChecked(True)
|
||||||
|
self.fit_button.setToolTip("完整显示图片且不放大超过原始尺寸")
|
||||||
|
self.fit_button.clicked.connect(self.show_fit)
|
||||||
|
controls.addWidget(self.fit_button)
|
||||||
|
|
||||||
|
self.actual_size_button = QPushButton("100%")
|
||||||
|
self.actual_size_button.setObjectName("imagePreviewActualSizeButton")
|
||||||
|
self.actual_size_button.setToolTip("按图片原始像素查看")
|
||||||
|
self.actual_size_button.clicked.connect(self.show_actual_size)
|
||||||
|
controls.addWidget(self.actual_size_button)
|
||||||
|
|
||||||
|
controls.addStretch(1)
|
||||||
|
close_button = QPushButton("关闭")
|
||||||
|
close_button.clicked.connect(self.reject)
|
||||||
|
controls.addWidget(close_button)
|
||||||
|
layout.addLayout(controls)
|
||||||
|
|
||||||
|
self._fit_to_screen()
|
||||||
|
self._render()
|
||||||
|
QTimer.singleShot(0, self._render)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _load_source(path):
|
||||||
|
normalized = str(path or "")
|
||||||
|
if not normalized or not os.path.isfile(normalized):
|
||||||
|
return QPixmap()
|
||||||
|
reader = QImageReader(normalized)
|
||||||
|
reader.setAutoTransform(True)
|
||||||
|
image = reader.read()
|
||||||
|
return QPixmap.fromImage(image) if not image.isNull() else QPixmap()
|
||||||
|
|
||||||
|
def _window_title(self):
|
||||||
|
if self._source.isNull():
|
||||||
|
return self._base_title
|
||||||
|
return "%s · %dx%d" % (
|
||||||
|
self._base_title,
|
||||||
|
self._source.width(),
|
||||||
|
self._source.height(),
|
||||||
|
)
|
||||||
|
|
||||||
|
def resizeEvent(self, event):
|
||||||
|
super().resizeEvent(event)
|
||||||
|
if self.fit_to_window:
|
||||||
|
self._resize_timer.start()
|
||||||
|
|
||||||
|
def showEvent(self, event):
|
||||||
|
super().showEvent(event)
|
||||||
|
QTimer.singleShot(0, self._render)
|
||||||
|
|
||||||
|
def show_fit(self, checked=False):
|
||||||
|
self.fit_to_window = True
|
||||||
|
self._display_size = QSize()
|
||||||
|
self._render()
|
||||||
|
|
||||||
|
def show_actual_size(self, checked=False):
|
||||||
|
self.fit_to_window = False
|
||||||
|
self.zoom_factor = 1.0
|
||||||
|
self._display_size = QSize()
|
||||||
|
self._render()
|
||||||
|
|
||||||
|
def zoom_in(self, checked=False):
|
||||||
|
self._step_zoom(1)
|
||||||
|
|
||||||
|
def zoom_out(self, checked=False):
|
||||||
|
self._step_zoom(-1)
|
||||||
|
|
||||||
|
def _step_zoom(self, direction):
|
||||||
|
if self._source.isNull():
|
||||||
|
return
|
||||||
|
current = self._effective_zoom if self.fit_to_window else self.zoom_factor
|
||||||
|
if direction > 0:
|
||||||
|
stepped = (math.floor(current / self.ZOOM_STEP + 1e-9) + 1) * self.ZOOM_STEP
|
||||||
|
else:
|
||||||
|
stepped = (math.ceil(current / self.ZOOM_STEP - 1e-9) - 1) * self.ZOOM_STEP
|
||||||
|
self.fit_to_window = False
|
||||||
|
self.zoom_factor = max(self.MIN_ZOOM, min(self.MAX_ZOOM, stepped))
|
||||||
|
self._display_size = QSize()
|
||||||
|
self._render()
|
||||||
|
|
||||||
|
def _fit_scale(self):
|
||||||
|
viewport = self.scroll.viewport().size()
|
||||||
|
if self._source.isNull() or viewport.width() <= 0 or viewport.height() <= 0:
|
||||||
|
return 1.0
|
||||||
|
return min(
|
||||||
|
1.0,
|
||||||
|
viewport.width() / self._source.width(),
|
||||||
|
viewport.height() / self._source.height(),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _render(self):
|
||||||
|
viewport = self.scroll.viewport().size()
|
||||||
|
viewport_width = max(1, viewport.width())
|
||||||
|
viewport_height = max(1, viewport.height())
|
||||||
|
if self._source.isNull():
|
||||||
|
self.image_label.clear()
|
||||||
|
self.image_label.setText("图片文件不存在或无法读取")
|
||||||
|
self.image_label.resize(max(320, viewport_width), max(240, viewport_height))
|
||||||
|
self.zoom_label.setText("无法预览")
|
||||||
|
self._set_controls_enabled(False)
|
||||||
|
return
|
||||||
|
|
||||||
|
if self.fit_to_window:
|
||||||
|
scale = max(1e-6, min(1.0, self._fit_scale()))
|
||||||
|
else:
|
||||||
|
scale = max(self.MIN_ZOOM, min(self.MAX_ZOOM, self.zoom_factor))
|
||||||
|
self._effective_zoom = scale
|
||||||
|
display_size = QSize(
|
||||||
|
max(1, int(round(self._source.width() * scale))),
|
||||||
|
max(1, int(round(self._source.height() * scale))),
|
||||||
|
)
|
||||||
|
if display_size == self._source.size():
|
||||||
|
pixmap = self._source
|
||||||
|
else:
|
||||||
|
pixmap = self._source.scaled(
|
||||||
|
display_size,
|
||||||
|
Qt.KeepAspectRatio,
|
||||||
|
Qt.SmoothTransformation,
|
||||||
|
)
|
||||||
|
if display_size != self._display_size or self.image_label.pixmap() is None:
|
||||||
|
self.image_label.setPixmap(pixmap)
|
||||||
|
self.image_label.resize(pixmap.size())
|
||||||
|
self._display_size = QSize(pixmap.size())
|
||||||
|
self.zoom_label.setText(
|
||||||
|
"适应窗口" if self.fit_to_window else "%d%%" % int(round(scale * 100))
|
||||||
|
)
|
||||||
|
self.fit_button.setChecked(self.fit_to_window)
|
||||||
|
self._set_controls_enabled(True)
|
||||||
|
|
||||||
|
def _set_controls_enabled(self, available):
|
||||||
|
self.fit_button.setEnabled(available)
|
||||||
|
self.actual_size_button.setEnabled(available)
|
||||||
|
self.zoom_out_button.setEnabled(
|
||||||
|
available and self._effective_zoom > self.MIN_ZOOM + 1e-9
|
||||||
|
)
|
||||||
|
self.zoom_in_button.setEnabled(
|
||||||
|
available and self._effective_zoom < self.MAX_ZOOM - 1e-9
|
||||||
|
)
|
||||||
|
|
||||||
|
def _fit_to_screen(self):
|
||||||
|
screen = self.parentWidget().screen() if self.parentWidget() is not None else None
|
||||||
|
screen = screen or QApplication.primaryScreen()
|
||||||
|
if screen is None:
|
||||||
|
self.resize(720, 520)
|
||||||
|
return
|
||||||
|
available = screen.availableGeometry()
|
||||||
|
max_width = max(1, int(available.width() * 0.9))
|
||||||
|
max_height = max(1, int(available.height() * 0.9))
|
||||||
|
min_width = min(560, max_width)
|
||||||
|
min_height = min(420, max_height)
|
||||||
|
source_width = self._source.width() if not self._source.isNull() else 640
|
||||||
|
source_height = self._source.height() if not self._source.isNull() else 480
|
||||||
|
self.resize(
|
||||||
|
min(max_width, max(min_width, source_width + 48)),
|
||||||
|
min(max_height, max(min_height, source_height + 104)),
|
||||||
|
)
|
||||||
|
frame = self.frameGeometry()
|
||||||
|
frame.moveCenter(available.center())
|
||||||
|
self.move(frame.topLeft())
|
||||||
+619
-15
@@ -2,18 +2,28 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from PySide6.QtCore import QUrl
|
||||||
|
from PySide6.QtGui import QDesktopServices
|
||||||
|
|
||||||
|
from .. import client_policy, subscription
|
||||||
from ..version import display_name
|
from ..version import display_name
|
||||||
|
from .activation_dialog import MembershipActivationDialog
|
||||||
from .tabs.accounts import AccountsTab
|
from .tabs.accounts import AccountsTab
|
||||||
from .tabs.apply import ApplyTab
|
from .tabs.apply import ApplyTab
|
||||||
from .tabs.collect import CollectTab
|
from .tabs.collect import CollectTab
|
||||||
from .tabs.generate import GenerateTab
|
from .tabs.generate import GenerateTab
|
||||||
|
from .tabs.product_suite import ProductSuiteTab
|
||||||
from .tabs.settings import SettingsTab
|
from .tabs.settings import SettingsTab
|
||||||
from .widgets import *
|
from .widgets import *
|
||||||
|
from .workers import SubscriptionCheckWorker
|
||||||
|
|
||||||
|
|
||||||
PREFERRED_WINDOW_SIZE = (1180, 760)
|
PREFERRED_WINDOW_SIZE = (1180, 760)
|
||||||
MIN_WINDOW_SIZE = (960, 640)
|
MIN_WINDOW_SIZE = (960, 640)
|
||||||
WINDOW_SCREEN_MARGIN = 40
|
WINDOW_SCREEN_MARGIN = 40
|
||||||
|
# 仅供直接构造 MainWindow 的兼容入口使用;正式启动以远程策略为准。
|
||||||
|
SUBSCRIPTION_CHECK_ENABLED = True
|
||||||
|
SUBSCRIPTION_ENFORCEMENT_ENABLED = True
|
||||||
|
|
||||||
|
|
||||||
def _screen_available_geometry():
|
def _screen_available_geometry():
|
||||||
@@ -72,9 +82,17 @@ def _fit_and_center_window(
|
|||||||
|
|
||||||
|
|
||||||
class MainWindow(QMainWindow):
|
class MainWindow(QMainWindow):
|
||||||
"""Main application window with the fixed five-tab workflow."""
|
"""Main application window with the fixed workflow tabs."""
|
||||||
|
|
||||||
def __init__(self, db_path=None, config=None, config_path=None, ai_models_path=None):
|
def __init__(
|
||||||
|
self,
|
||||||
|
db_path=None,
|
||||||
|
config=None,
|
||||||
|
config_path=None,
|
||||||
|
ai_models_path=None,
|
||||||
|
startup_status="",
|
||||||
|
subscription_policy=None,
|
||||||
|
):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self._initial_window_fit_applied_after_show = False
|
self._initial_window_fit_applied_after_show = False
|
||||||
self.config = appconfig.load_config(config_path or appconfig.CONFIG_PATH) if config is None else config
|
self.config = appconfig.load_config(config_path or appconfig.CONFIG_PATH) if config is None else config
|
||||||
@@ -89,21 +107,60 @@ class MainWindow(QMainWindow):
|
|||||||
or self.config.get("ai_models_path")
|
or self.config.get("ai_models_path")
|
||||||
or appconfig.ai_models_config_path(self.config)
|
or appconfig.ai_models_config_path(self.config)
|
||||||
)
|
)
|
||||||
|
self._client_policy = (
|
||||||
|
subscription_policy
|
||||||
|
if isinstance(subscription_policy, client_policy.ClientPolicy)
|
||||||
|
else client_policy.ClientPolicy(
|
||||||
|
policy_version=client_policy.POLICY_VERSION,
|
||||||
|
subscription_check_enabled=SUBSCRIPTION_CHECK_ENABLED,
|
||||||
|
subscription_enforcement_enabled=SUBSCRIPTION_ENFORCEMENT_ENABLED,
|
||||||
|
updated_at="",
|
||||||
|
source="compatibility",
|
||||||
|
)
|
||||||
|
)
|
||||||
self.setWindowTitle(display_name())
|
self.setWindowTitle(display_name())
|
||||||
|
window_icon = app_icon()
|
||||||
|
if window_icon is not None:
|
||||||
|
self.setWindowIcon(window_icon)
|
||||||
_fit_and_center_window(self)
|
_fit_and_center_window(self)
|
||||||
self.setStyleSheet(BUTTON_BASE_STYLE)
|
self.setStyleSheet(BUTTON_BASE_STYLE)
|
||||||
self._settings_tab_index = TAB_TITLES.index("⑤ 设置")
|
self._settings_tab_index = TAB_TITLES.index("设置")
|
||||||
self._last_tab_index = 0
|
self._last_tab_index = 0
|
||||||
self._reverting_tab_change = False
|
self._reverting_tab_change = False
|
||||||
|
self._subscription_status = subscription.SubscriptionStatus(
|
||||||
|
subscription.STATUS_UNAVAILABLE
|
||||||
|
)
|
||||||
|
self._subscription_worker = None
|
||||||
|
self._subscription_thread = None
|
||||||
|
self._subscription_request_token = 0
|
||||||
|
self._subscription_closed = False
|
||||||
|
self._expired_subscription_notice_shown = False
|
||||||
|
self._subscription_notice_state = None
|
||||||
|
self._subscription_notice_states_seen = set()
|
||||||
|
self._activation_dialog = None
|
||||||
|
self._first_use_guide_shown = False
|
||||||
self.tabs = QTabWidget()
|
self.tabs = QTabWidget()
|
||||||
self.tabs.setObjectName("mainTabs")
|
self.tabs.setObjectName("mainTabs")
|
||||||
self.tabs.setStyleSheet(TAB_STYLE)
|
self.tabs.setStyleSheet(TAB_STYLE)
|
||||||
self.tabs.currentChanged.connect(self._on_tab_changed)
|
self.tabs.currentChanged.connect(self._on_tab_changed)
|
||||||
for title in TAB_TITLES:
|
for title in TAB_TITLES:
|
||||||
self.tabs.addTab(self._build_tab(title), title)
|
self.tabs.addTab(self._build_tab(title), title)
|
||||||
self.tabs.setTabIcon(TAB_TITLES.index("③ 更新蝦皮"), _warning_dot_icon())
|
settings_tab = self._settings_tab()
|
||||||
self.setCentralWidget(self.tabs)
|
if hasattr(settings_tab, "settingsSaved"):
|
||||||
|
settings_tab.settingsSaved.connect(self._on_settings_saved)
|
||||||
|
if hasattr(settings_tab, "subscriptionCheckRequested"):
|
||||||
|
settings_tab.subscriptionCheckRequested.connect(
|
||||||
|
self.begin_subscription_check
|
||||||
|
)
|
||||||
|
central = QWidget()
|
||||||
|
central_layout = QVBoxLayout(central)
|
||||||
|
central_layout.setContentsMargins(0, 0, 0, 0)
|
||||||
|
central_layout.setSpacing(0)
|
||||||
|
central_layout.addWidget(self.tabs, 1)
|
||||||
|
self.setCentralWidget(central)
|
||||||
self.show_status("就绪", level="muted")
|
self.show_status("就绪", level="muted")
|
||||||
|
if startup_status:
|
||||||
|
self.show_status(startup_status, level="success")
|
||||||
|
|
||||||
def showEvent(self, event):
|
def showEvent(self, event):
|
||||||
super().showEvent(event)
|
super().showEvent(event)
|
||||||
@@ -134,6 +191,8 @@ class MainWindow(QMainWindow):
|
|||||||
title_prompt_path=appconfig.title_prompt_path(self.config),
|
title_prompt_path=appconfig.title_prompt_path(self.config),
|
||||||
cover_prompts_dir=appconfig.cover_prompts_dir(self.config),
|
cover_prompts_dir=appconfig.cover_prompts_dir(self.config),
|
||||||
open_accounts_callback=lambda: self.open_accounts_tab(),
|
open_accounts_callback=lambda: self.open_accounts_tab(),
|
||||||
|
refresh_workflow_callback=lambda: self.refresh_task_tabs(),
|
||||||
|
subscription_preflight_callback=self.ensure_subscription_for_new_submit,
|
||||||
)
|
)
|
||||||
if title == "③ 更新蝦皮":
|
if title == "③ 更新蝦皮":
|
||||||
return ApplyTab(
|
return ApplyTab(
|
||||||
@@ -142,19 +201,35 @@ class MainWindow(QMainWindow):
|
|||||||
status_callback=self.show_status,
|
status_callback=self.show_status,
|
||||||
open_accounts_callback=lambda: self.open_accounts_tab(),
|
open_accounts_callback=lambda: self.open_accounts_tab(),
|
||||||
open_settings_callback=lambda: self.open_settings_tab(),
|
open_settings_callback=lambda: self.open_settings_tab(),
|
||||||
|
refresh_workflow_callback=lambda: self.refresh_task_tabs(),
|
||||||
)
|
)
|
||||||
if title == "④ 账号管理":
|
if title == "账号管理":
|
||||||
return AccountsTab(
|
return AccountsTab(
|
||||||
db_path=self.db_path,
|
db_path=self.db_path,
|
||||||
config=self.config,
|
config=self.config,
|
||||||
status_callback=self.show_status,
|
status_callback=self.show_status,
|
||||||
)
|
)
|
||||||
return SettingsTab(
|
if title == "设置":
|
||||||
config=self.config,
|
tab = SettingsTab(
|
||||||
config_path=self.config_path,
|
config=self.config,
|
||||||
ai_models_path=self.ai_models_path,
|
config_path=self.config_path,
|
||||||
status_callback=self.show_status,
|
ai_models_path=self.ai_models_path,
|
||||||
)
|
status_callback=self.show_status,
|
||||||
|
)
|
||||||
|
if hasattr(tab, "set_subscription_policy_enabled"):
|
||||||
|
tab.set_subscription_policy_enabled(
|
||||||
|
self._client_policy.subscription_check_enabled
|
||||||
|
)
|
||||||
|
return tab
|
||||||
|
if title == "商品套图":
|
||||||
|
return ProductSuiteTab(
|
||||||
|
db_path=self.db_path,
|
||||||
|
config=self.config,
|
||||||
|
config_path=self.config_path,
|
||||||
|
status_callback=self.show_status,
|
||||||
|
subscription_preflight_callback=self.ensure_subscription_for_new_submit,
|
||||||
|
)
|
||||||
|
raise ValueError(f"未知主界面模块:{title}")
|
||||||
|
|
||||||
def refresh_task_tabs(self):
|
def refresh_task_tabs(self):
|
||||||
for index in range(self.tabs.count()):
|
for index in range(self.tabs.count()):
|
||||||
@@ -162,6 +237,532 @@ class MainWindow(QMainWindow):
|
|||||||
if hasattr(widget, "refresh_tasks"):
|
if hasattr(widget, "refresh_tasks"):
|
||||||
widget.refresh_tasks()
|
widget.refresh_tasks()
|
||||||
|
|
||||||
|
def _on_settings_saved(self, backend):
|
||||||
|
for index in range(self.tabs.count()):
|
||||||
|
widget = self.tabs.widget(index)
|
||||||
|
if hasattr(widget, "refresh_gateway_state"):
|
||||||
|
widget.refresh_gateway_state()
|
||||||
|
label = "自定义网关" if str(backend) == "direct" else "默认网关"
|
||||||
|
self.show_status("设置已保存,当前使用%s" % label, level="success")
|
||||||
|
self.begin_subscription_check()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def subscription_status(self):
|
||||||
|
return self._subscription_status
|
||||||
|
|
||||||
|
@property
|
||||||
|
def subscription_policy(self):
|
||||||
|
return self._client_policy
|
||||||
|
|
||||||
|
def begin_subscription_check(self):
|
||||||
|
"""Refresh membership state without blocking the Qt GUI thread."""
|
||||||
|
|
||||||
|
self._set_membership_window_title()
|
||||||
|
if not self._client_policy.subscription_check_enabled:
|
||||||
|
self._set_product_access(True)
|
||||||
|
self._set_subscription_check_running(False)
|
||||||
|
self.show_status("服务端暂未启用会员检测", level="muted")
|
||||||
|
return
|
||||||
|
self._subscription_request_token += 1
|
||||||
|
token = self._subscription_request_token
|
||||||
|
if self._subscription_worker is not None:
|
||||||
|
self._subscription_worker.cancel()
|
||||||
|
self._set_product_access(
|
||||||
|
not self._client_policy.subscription_enforcement_enabled
|
||||||
|
)
|
||||||
|
self._set_subscription_check_running(True)
|
||||||
|
self.show_status("正在验证会员状态", level="info")
|
||||||
|
worker = SubscriptionCheckWorker(
|
||||||
|
config=self.config,
|
||||||
|
cmhub_config_path=self.config.get("cmhub_config_path"),
|
||||||
|
)
|
||||||
|
worker.finished.connect(
|
||||||
|
lambda payload, token=token: self._on_subscription_checked(
|
||||||
|
token,
|
||||||
|
payload,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
worker.cancelled.connect(
|
||||||
|
lambda payload, token=token: self._on_subscription_checked(
|
||||||
|
token,
|
||||||
|
payload,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
thread = run_worker(worker, thread_name="SubscriptionCheckWorker", start=False)
|
||||||
|
thread.finished.connect(lambda: self._forget_subscription_thread(thread))
|
||||||
|
self._subscription_worker = worker
|
||||||
|
self._subscription_thread = thread
|
||||||
|
thread.start()
|
||||||
|
|
||||||
|
def ensure_subscription_for_new_submit(self, action_name="生成"):
|
||||||
|
"""Return whether a new product request may start from the current UI."""
|
||||||
|
|
||||||
|
if (
|
||||||
|
not self._client_policy.subscription_check_enabled
|
||||||
|
or not self._client_policy.subscription_enforcement_enabled
|
||||||
|
):
|
||||||
|
return True
|
||||||
|
status = self._subscription_status
|
||||||
|
if status.allows_product_workflows:
|
||||||
|
return True
|
||||||
|
if self._subscription_thread is not None:
|
||||||
|
self.show_status("正在验证会员状态,请稍后再%s" % action_name, level="info")
|
||||||
|
return False
|
||||||
|
self.show_status("%s:%s" % (action_name, status.user_message), level="warning")
|
||||||
|
self.open_settings_tab()
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _on_subscription_checked(self, token, payload):
|
||||||
|
if self._subscription_closed or token != self._subscription_request_token:
|
||||||
|
return
|
||||||
|
result = payload.get("subscription") if isinstance(payload, dict) else None
|
||||||
|
if not isinstance(result, subscription.SubscriptionStatus):
|
||||||
|
result = subscription.SubscriptionStatus(subscription.STATUS_UNAVAILABLE)
|
||||||
|
self._subscription_status = result
|
||||||
|
self._apply_subscription_status(result)
|
||||||
|
|
||||||
|
def _forget_subscription_thread(self, thread):
|
||||||
|
if self._subscription_thread is thread:
|
||||||
|
self._subscription_thread = None
|
||||||
|
self._subscription_worker = None
|
||||||
|
self._set_subscription_check_running(False)
|
||||||
|
|
||||||
|
def _apply_subscription_status(
|
||||||
|
self,
|
||||||
|
status,
|
||||||
|
*,
|
||||||
|
show_notice=True,
|
||||||
|
show_first_use_guide=True,
|
||||||
|
):
|
||||||
|
self._subscription_status = status
|
||||||
|
if status.allows_product_workflows:
|
||||||
|
self._expired_subscription_notice_shown = False
|
||||||
|
self._subscription_notice_state = None
|
||||||
|
self._subscription_notice_states_seen.clear()
|
||||||
|
if status.state == subscription.STATUS_ACTIVE:
|
||||||
|
expiry = subscription.format_expiry(status.expires_at)
|
||||||
|
self._set_membership_window_title(
|
||||||
|
status.account_name,
|
||||||
|
status.plan_name,
|
||||||
|
"有效至%s" % expiry,
|
||||||
|
)
|
||||||
|
elif status.state == subscription.STATUS_GRACE:
|
||||||
|
expiry = subscription.format_expiry(
|
||||||
|
status.grace_expires_at
|
||||||
|
) or subscription.format_expiry(status.expires_at)
|
||||||
|
self._set_membership_window_title(
|
||||||
|
status.account_name,
|
||||||
|
status.plan_name,
|
||||||
|
"宽限至%s" % expiry,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self._set_membership_window_title()
|
||||||
|
|
||||||
|
access_allowed = (
|
||||||
|
status.allows_product_workflows
|
||||||
|
or not self._client_policy.subscription_enforcement_enabled
|
||||||
|
)
|
||||||
|
self._set_product_access(access_allowed)
|
||||||
|
if status.allows_product_workflows:
|
||||||
|
message = (
|
||||||
|
"会员状态已验证"
|
||||||
|
if self._client_policy.subscription_enforcement_enabled
|
||||||
|
else "会员状态已验证,当前处于观察模式"
|
||||||
|
)
|
||||||
|
self.show_status(message, level="success")
|
||||||
|
if (
|
||||||
|
self._client_policy.subscription_enforcement_enabled
|
||||||
|
and show_notice
|
||||||
|
):
|
||||||
|
self._show_subscription_notice_once(status)
|
||||||
|
if show_first_use_guide:
|
||||||
|
QTimer.singleShot(0, self._show_first_use_guide_if_pending)
|
||||||
|
return
|
||||||
|
if not self._client_policy.subscription_enforcement_enabled:
|
||||||
|
self.show_status(
|
||||||
|
"订阅观察:%s,当前不影响使用" % status.user_message,
|
||||||
|
level=self._subscription_level(status),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
self.show_status(status.user_message, level=self._subscription_level(status))
|
||||||
|
if status.state == subscription.STATUS_NOT_CONFIGURED:
|
||||||
|
self.show_status("请先绑定会员账号", level="warning")
|
||||||
|
self._show_membership_activation()
|
||||||
|
return
|
||||||
|
if status.state == subscription.STATUS_KEY_INVALID:
|
||||||
|
self.open_settings_tab()
|
||||||
|
self._show_membership_activation()
|
||||||
|
if self._activation_dialog is not None:
|
||||||
|
self._activation_dialog.focus_api_key()
|
||||||
|
return
|
||||||
|
self.open_settings_tab()
|
||||||
|
if status.state == subscription.STATUS_EXPIRED:
|
||||||
|
self._show_expired_subscription_notice_once(status)
|
||||||
|
return
|
||||||
|
self._show_subscription_state_notice_once(status)
|
||||||
|
|
||||||
|
def _show_membership_activation(self):
|
||||||
|
if self._activation_dialog is not None:
|
||||||
|
self._activation_dialog.raise_()
|
||||||
|
self._activation_dialog.activateWindow()
|
||||||
|
return
|
||||||
|
dialog = MembershipActivationDialog(self)
|
||||||
|
self._activation_dialog = dialog
|
||||||
|
dialog.validationRequested.connect(self._begin_activation_validation)
|
||||||
|
dialog.memberCenterRequested.connect(self._open_activation_member_center)
|
||||||
|
dialog.finished.connect(
|
||||||
|
lambda result, current=dialog: self._on_activation_dialog_finished(
|
||||||
|
current,
|
||||||
|
result,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
dialog.open()
|
||||||
|
|
||||||
|
def _begin_activation_validation(self, api_key):
|
||||||
|
dialog = self._activation_dialog
|
||||||
|
key = str(api_key or "").strip()
|
||||||
|
if dialog is None or not key:
|
||||||
|
return
|
||||||
|
self._subscription_request_token += 1
|
||||||
|
token = self._subscription_request_token
|
||||||
|
if self._subscription_worker is not None:
|
||||||
|
self._subscription_worker.cancel()
|
||||||
|
self._set_product_access(False)
|
||||||
|
self._set_subscription_check_running(True)
|
||||||
|
dialog.set_validating(True)
|
||||||
|
self.show_status("正在验证会员账号", level="info")
|
||||||
|
worker = SubscriptionCheckWorker(
|
||||||
|
config=self.config,
|
||||||
|
cmhub_config_path=self.config.get("cmhub_config_path"),
|
||||||
|
base_url=appconfig.cmhub_config(self.config).get("base_url"),
|
||||||
|
api_key=key,
|
||||||
|
)
|
||||||
|
worker.finished.connect(
|
||||||
|
lambda payload, token=token, key=key: self._on_activation_checked(
|
||||||
|
token,
|
||||||
|
key,
|
||||||
|
payload,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
worker.cancelled.connect(
|
||||||
|
lambda payload, token=token, key=key: self._on_activation_checked(
|
||||||
|
token,
|
||||||
|
key,
|
||||||
|
payload,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
thread = run_worker(worker, thread_name="MembershipActivationWorker", start=False)
|
||||||
|
thread.finished.connect(lambda: self._forget_subscription_thread(thread))
|
||||||
|
self._subscription_worker = worker
|
||||||
|
self._subscription_thread = thread
|
||||||
|
thread.start()
|
||||||
|
|
||||||
|
def _on_activation_checked(self, token, api_key, payload):
|
||||||
|
if self._subscription_closed or token != self._subscription_request_token:
|
||||||
|
return
|
||||||
|
dialog = self._activation_dialog
|
||||||
|
if dialog is None:
|
||||||
|
return
|
||||||
|
dialog.set_validating(False)
|
||||||
|
status = payload.get("subscription") if isinstance(payload, dict) else None
|
||||||
|
if not isinstance(status, subscription.SubscriptionStatus):
|
||||||
|
status = subscription.SubscriptionStatus(subscription.STATUS_UNAVAILABLE)
|
||||||
|
self._subscription_status = status
|
||||||
|
|
||||||
|
if not status.credentials_accepted:
|
||||||
|
self._set_product_access(False)
|
||||||
|
self._set_membership_window_title()
|
||||||
|
if status.state == subscription.STATUS_KEY_INVALID:
|
||||||
|
message = "会员 API Key 无效,请检查后重新输入。"
|
||||||
|
dialog.focus_api_key()
|
||||||
|
elif status.state == subscription.STATUS_NOT_CONFIGURED:
|
||||||
|
message = "请输入有效的会员 API Key。"
|
||||||
|
dialog.focus_api_key()
|
||||||
|
else:
|
||||||
|
message = "暂时无法验证会员账号,请检查网络后重试。"
|
||||||
|
dialog.set_status(message, level="danger")
|
||||||
|
self.show_status(message, level="warning")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
appconfig.save_cmhub_config(
|
||||||
|
{"api_key": str(api_key or "").strip()},
|
||||||
|
path=self.config.get("cmhub_config_path")
|
||||||
|
or appconfig.cmhub_config_file_path(self.config),
|
||||||
|
)
|
||||||
|
except (OSError, appconfig.ConfigError):
|
||||||
|
message = "会员账号已验证,但 API Key 保存失败,请检查数据目录后重试。"
|
||||||
|
dialog.set_status(message, level="danger")
|
||||||
|
self.show_status(message, level="danger")
|
||||||
|
return
|
||||||
|
|
||||||
|
settings_tab = self._settings_tab()
|
||||||
|
if hasattr(settings_tab, "refresh_default_gateway_credentials"):
|
||||||
|
settings_tab.refresh_default_gateway_credentials()
|
||||||
|
|
||||||
|
if not status.allows_product_workflows:
|
||||||
|
self._set_product_access(False)
|
||||||
|
self._set_membership_window_title()
|
||||||
|
message = "%s。请前往会员中心处理后重新验证。" % status.user_message
|
||||||
|
dialog.set_status(message, level="danger")
|
||||||
|
self.show_status(status.user_message, level=self._subscription_level(status))
|
||||||
|
return
|
||||||
|
|
||||||
|
self._save_first_use_state("pending")
|
||||||
|
if status.notice_id:
|
||||||
|
self._save_subscription_notice_id(status.notice_id)
|
||||||
|
self._apply_subscription_status(
|
||||||
|
status,
|
||||||
|
show_notice=False,
|
||||||
|
show_first_use_guide=False,
|
||||||
|
)
|
||||||
|
dialog.set_status("会员账号验证成功,正在进入工作区。", level="success")
|
||||||
|
dialog.accept_activation()
|
||||||
|
QTimer.singleShot(0, self._show_first_use_guide_if_pending)
|
||||||
|
|
||||||
|
def _on_activation_dialog_finished(self, dialog, result):
|
||||||
|
if self._activation_dialog is dialog:
|
||||||
|
self._activation_dialog = None
|
||||||
|
dialog.deleteLater()
|
||||||
|
if (
|
||||||
|
result != QDialog.Accepted
|
||||||
|
and not self._subscription_closed
|
||||||
|
and self._client_policy.subscription_enforcement_enabled
|
||||||
|
and not self._subscription_status.allows_product_workflows
|
||||||
|
):
|
||||||
|
self.close()
|
||||||
|
|
||||||
|
def _open_activation_member_center(self):
|
||||||
|
manage_url = str(self._subscription_status.manage_url or "").strip()
|
||||||
|
if not manage_url:
|
||||||
|
manage_url = subscription.safe_manage_url(
|
||||||
|
appconfig.DEFAULT_MEMBER_CENTER_URL,
|
||||||
|
appconfig.DEFAULT_CMHUB_BASE_URL,
|
||||||
|
)
|
||||||
|
if not manage_url:
|
||||||
|
message = "会员中心地址当前不可用,请稍后重试。"
|
||||||
|
if self._activation_dialog is not None:
|
||||||
|
self._activation_dialog.set_status(message, level="danger")
|
||||||
|
self.show_status(message, level="warning")
|
||||||
|
return
|
||||||
|
opened = QDesktopServices.openUrl(QUrl(manage_url))
|
||||||
|
if opened is False:
|
||||||
|
message = "无法打开会员中心,请检查系统默认浏览器后重试。"
|
||||||
|
if self._activation_dialog is not None:
|
||||||
|
self._activation_dialog.set_status(message, level="danger")
|
||||||
|
self.show_status(message, level="warning")
|
||||||
|
|
||||||
|
def _show_first_use_guide_if_pending(self):
|
||||||
|
if self._first_use_guide_shown:
|
||||||
|
return
|
||||||
|
if appconfig.first_use_guide_state(self.config) != "pending":
|
||||||
|
return
|
||||||
|
if not self._subscription_status.allows_product_workflows:
|
||||||
|
return
|
||||||
|
self._first_use_guide_shown = True
|
||||||
|
|
||||||
|
box = QMessageBox(self)
|
||||||
|
box.setIcon(QMessageBox.Information)
|
||||||
|
box.setWindowTitle("首次使用指引")
|
||||||
|
box.setText(
|
||||||
|
"接下来按以下顺序开始使用:\n\n"
|
||||||
|
"1. 添加蝦皮店铺账号\n"
|
||||||
|
"2. 启动 Chrome 并人工完成登录\n"
|
||||||
|
"3. 导入商品 Excel\n"
|
||||||
|
"4. 开始采集、AI 生成和更新蝦皮"
|
||||||
|
)
|
||||||
|
start_button = box.addButton("开始配置店铺", QMessageBox.AcceptRole)
|
||||||
|
later_button = box.addButton("稍后提醒", QMessageBox.RejectRole)
|
||||||
|
dismiss_button = box.addButton("不再提示", QMessageBox.ActionRole)
|
||||||
|
box.setDefaultButton(start_button)
|
||||||
|
box.exec()
|
||||||
|
clicked = box.clickedButton()
|
||||||
|
if clicked is start_button:
|
||||||
|
self._save_first_use_state("completed")
|
||||||
|
self.open_accounts_tab()
|
||||||
|
elif clicked is dismiss_button:
|
||||||
|
self._save_first_use_state("dismissed")
|
||||||
|
elif clicked is later_button:
|
||||||
|
self.show_status("可稍后从账号管理开始配置店铺", level="muted")
|
||||||
|
|
||||||
|
def _save_first_use_state(self, state):
|
||||||
|
try:
|
||||||
|
saved = appconfig.save_first_use_guide_state(
|
||||||
|
state,
|
||||||
|
path=self.config_path,
|
||||||
|
)
|
||||||
|
except (OSError, appconfig.ConfigError):
|
||||||
|
self.show_status("首次使用引导状态保存失败", level="warning")
|
||||||
|
return False
|
||||||
|
self._replace_runtime_config(saved)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _save_subscription_notice_id(self, notice_id):
|
||||||
|
try:
|
||||||
|
saved = appconfig.save_subscription_notice_id(
|
||||||
|
notice_id,
|
||||||
|
path=self.config_path,
|
||||||
|
)
|
||||||
|
except (OSError, appconfig.ConfigError):
|
||||||
|
return False
|
||||||
|
self._replace_runtime_config(saved)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _replace_runtime_config(self, saved):
|
||||||
|
self.config.clear()
|
||||||
|
self.config.update(saved)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _subscription_level(status):
|
||||||
|
if status.state in {subscription.STATUS_UNAVAILABLE, subscription.STATUS_KEY_INVALID}:
|
||||||
|
return "warning"
|
||||||
|
return "danger"
|
||||||
|
|
||||||
|
def _set_membership_window_title(
|
||||||
|
self,
|
||||||
|
account_name="",
|
||||||
|
plan_name="",
|
||||||
|
expiry_text="",
|
||||||
|
):
|
||||||
|
account = " ".join(str(account_name or "").split())
|
||||||
|
plan = " ".join(str(plan_name or "").split())
|
||||||
|
expiry = " ".join(str(expiry_text or "").split())
|
||||||
|
if account and plan and expiry:
|
||||||
|
self.setWindowTitle(
|
||||||
|
"%s | %s · %s · %s" % (display_name(), account, plan, expiry)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
self.setWindowTitle(display_name())
|
||||||
|
|
||||||
|
def _set_product_access(self, enabled):
|
||||||
|
for index, title in enumerate(TAB_TITLES):
|
||||||
|
self.tabs.setTabEnabled(index, bool(enabled) or title == "设置")
|
||||||
|
|
||||||
|
def _set_subscription_check_running(self, running):
|
||||||
|
settings_tab = self._settings_tab()
|
||||||
|
if hasattr(settings_tab, "set_subscription_check_running"):
|
||||||
|
settings_tab.set_subscription_check_running(running)
|
||||||
|
|
||||||
|
def _show_expired_subscription_notice_once(self, status):
|
||||||
|
self._show_subscription_state_notice_once(status)
|
||||||
|
|
||||||
|
def _show_subscription_state_notice_once(self, status):
|
||||||
|
state = status.state
|
||||||
|
notice_state = (
|
||||||
|
subscription.STATUS_UNAVAILABLE
|
||||||
|
if state == subscription.STATUS_LEGACY
|
||||||
|
else state
|
||||||
|
)
|
||||||
|
if notice_state in self._subscription_notice_states_seen:
|
||||||
|
return
|
||||||
|
self._subscription_notice_states_seen.add(notice_state)
|
||||||
|
self._subscription_notice_state = notice_state
|
||||||
|
self._expired_subscription_notice_shown = (
|
||||||
|
notice_state == subscription.STATUS_EXPIRED
|
||||||
|
)
|
||||||
|
|
||||||
|
titles = {
|
||||||
|
subscription.STATUS_REQUIRED: "尚未开通会员套餐",
|
||||||
|
subscription.STATUS_EXPIRED: "会员套餐已过期",
|
||||||
|
subscription.STATUS_REVOKED: "会员套餐已失效",
|
||||||
|
subscription.STATUS_ACCOUNT_DISABLED: "会员账号当前不可用",
|
||||||
|
subscription.STATUS_UNAVAILABLE: "暂时无法验证会员状态",
|
||||||
|
}
|
||||||
|
messages = {
|
||||||
|
subscription.STATUS_REQUIRED: (
|
||||||
|
"当前账号尚未开通蝦皮圈会员套餐,业务功能已暂停。"
|
||||||
|
"请前往会员中心选择套餐,完成后重新检测会员状态。"
|
||||||
|
),
|
||||||
|
subscription.STATUS_EXPIRED: (
|
||||||
|
"当前账号的蝦皮圈会员已到期,业务功能已暂停。"
|
||||||
|
"请前往会员中心续费或更换套餐,完成后重新检测会员状态。"
|
||||||
|
),
|
||||||
|
subscription.STATUS_REVOKED: (
|
||||||
|
"当前账号的蝦皮圈会员套餐已失效,业务功能已暂停。"
|
||||||
|
"请前往会员中心查看套餐状态,处理后重新检测。"
|
||||||
|
),
|
||||||
|
subscription.STATUS_ACCOUNT_DISABLED: (
|
||||||
|
"当前会员账号暂时不可用,业务功能已暂停。"
|
||||||
|
"请前往会员中心查看账号状态或联系管理员处理。"
|
||||||
|
),
|
||||||
|
subscription.STATUS_UNAVAILABLE: (
|
||||||
|
"当前暂时无法验证会员状态,业务功能已暂停。"
|
||||||
|
"这不代表账号未付费,请检查网络或服务状态后重新检测。"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
box = QMessageBox(self)
|
||||||
|
box.setIcon(QMessageBox.Warning)
|
||||||
|
box.setWindowTitle(titles.get(notice_state, "暂时无法验证会员状态"))
|
||||||
|
text = messages.get(
|
||||||
|
notice_state,
|
||||||
|
messages[subscription.STATUS_UNAVAILABLE],
|
||||||
|
)
|
||||||
|
|
||||||
|
manage_button = None
|
||||||
|
settings_button = None
|
||||||
|
manage_url = str(status.manage_url or "").strip()
|
||||||
|
if notice_state != subscription.STATUS_UNAVAILABLE:
|
||||||
|
manage_button = box.addButton(
|
||||||
|
"前往会员中心",
|
||||||
|
QMessageBox.ActionRole,
|
||||||
|
)
|
||||||
|
manage_button.setEnabled(bool(manage_url))
|
||||||
|
if manage_url:
|
||||||
|
box.setDefaultButton(manage_button)
|
||||||
|
else:
|
||||||
|
manage_button.setToolTip("会员中心地址当前不可用")
|
||||||
|
text += "\n\n会员中心地址当前不可用,请稍后重试。"
|
||||||
|
retry_button = box.addButton("重新检测", QMessageBox.AcceptRole)
|
||||||
|
if notice_state == subscription.STATUS_UNAVAILABLE:
|
||||||
|
settings_button = box.addButton("打开设置", QMessageBox.ActionRole)
|
||||||
|
box.setDefaultButton(retry_button)
|
||||||
|
exit_button = box.addButton("退出程序", QMessageBox.DestructiveRole)
|
||||||
|
box.setText(text)
|
||||||
|
box.exec()
|
||||||
|
|
||||||
|
clicked = box.clickedButton()
|
||||||
|
if manage_button is not None and clicked is manage_button and manage_url:
|
||||||
|
opened = QDesktopServices.openUrl(QUrl(manage_url))
|
||||||
|
if opened is False:
|
||||||
|
self.show_status(
|
||||||
|
"无法打开会员中心,请检查系统默认浏览器后重试",
|
||||||
|
level="warning",
|
||||||
|
)
|
||||||
|
elif clicked is retry_button:
|
||||||
|
self.begin_subscription_check()
|
||||||
|
elif settings_button is not None and clicked is settings_button:
|
||||||
|
self.open_settings_tab()
|
||||||
|
elif clicked is exit_button:
|
||||||
|
self.close()
|
||||||
|
|
||||||
|
def _show_subscription_notice_once(self, status):
|
||||||
|
notice_id = str(status.notice_id or "").strip()
|
||||||
|
if not notice_id or notice_id == appconfig.subscription_notice_id(self.config):
|
||||||
|
return
|
||||||
|
box = QMessageBox(self)
|
||||||
|
box.setIcon(QMessageBox.Information)
|
||||||
|
box.setWindowTitle("已启用会员订阅")
|
||||||
|
expiry = subscription.format_expiry(
|
||||||
|
status.grace_expires_at if status.state == subscription.STATUS_GRACE else status.expires_at
|
||||||
|
)
|
||||||
|
text = "已启用蝦皮圈会员订阅服务。当前账号:%s;套餐:%s。" % (
|
||||||
|
status.account_name,
|
||||||
|
status.plan_name,
|
||||||
|
)
|
||||||
|
if expiry:
|
||||||
|
text += "当前有效至%s。" % expiry
|
||||||
|
text += "请在到期前前往线上会员中心选择或续费套餐。"
|
||||||
|
box.setText(text)
|
||||||
|
manage_button = None
|
||||||
|
if status.manage_url:
|
||||||
|
manage_button = box.addButton("前往会员中心", QMessageBox.ActionRole)
|
||||||
|
close_button = box.addButton("关闭", QMessageBox.AcceptRole)
|
||||||
|
box.setDefaultButton(close_button)
|
||||||
|
box.exec()
|
||||||
|
if manage_button is not None and box.clickedButton() is manage_button:
|
||||||
|
QDesktopServices.openUrl(QUrl(status.manage_url))
|
||||||
|
self._save_subscription_notice_id(notice_id)
|
||||||
|
|
||||||
def _on_tab_changed(self, index):
|
def _on_tab_changed(self, index):
|
||||||
if self._reverting_tab_change:
|
if self._reverting_tab_change:
|
||||||
self._last_tab_index = index
|
self._last_tab_index = index
|
||||||
@@ -190,7 +791,7 @@ class MainWindow(QMainWindow):
|
|||||||
return True
|
return True
|
||||||
box = QMessageBox(self)
|
box = QMessageBox(self)
|
||||||
box.setWindowTitle("未保存更改")
|
box.setWindowTitle("未保存更改")
|
||||||
box.setText("⑤ 设置有未保存更改。要先保存再离开吗?")
|
box.setText("设置有未保存更改。要先保存再离开吗?")
|
||||||
save_button = box.addButton("保存", QMessageBox.AcceptRole)
|
save_button = box.addButton("保存", QMessageBox.AcceptRole)
|
||||||
discard_button = box.addButton("放弃", QMessageBox.DestructiveRole)
|
discard_button = box.addButton("放弃", QMessageBox.DestructiveRole)
|
||||||
box.addButton("取消", QMessageBox.RejectRole)
|
box.addButton("取消", QMessageBox.RejectRole)
|
||||||
@@ -205,12 +806,15 @@ class MainWindow(QMainWindow):
|
|||||||
|
|
||||||
def closeEvent(self, event):
|
def closeEvent(self, event):
|
||||||
if self._confirm_leave_settings_tab():
|
if self._confirm_leave_settings_tab():
|
||||||
|
self._subscription_closed = True
|
||||||
|
if self._subscription_worker is not None:
|
||||||
|
self._subscription_worker.cancel()
|
||||||
event.accept()
|
event.accept()
|
||||||
return
|
return
|
||||||
event.ignore()
|
event.ignore()
|
||||||
|
|
||||||
def open_accounts_tab(self):
|
def open_accounts_tab(self):
|
||||||
self.tabs.setCurrentIndex(TAB_TITLES.index("④ 账号管理"))
|
self.tabs.setCurrentIndex(TAB_TITLES.index("账号管理"))
|
||||||
|
|
||||||
def open_settings_tab(self):
|
def open_settings_tab(self):
|
||||||
self.tabs.setCurrentIndex(TAB_TITLES.index("⑤ 设置"))
|
self.tabs.setCurrentIndex(TAB_TITLES.index("设置"))
|
||||||
|
|||||||
+71
-10
@@ -2,11 +2,15 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from .. import ai, appconfig, diagnostics, product_status
|
||||||
|
from ..collect_skip import skipped_stage_text
|
||||||
from .widgets import *
|
from .widgets import *
|
||||||
|
|
||||||
|
|
||||||
class TaskTableModel(QAbstractTableModel):
|
class TaskTableModel(QAbstractTableModel):
|
||||||
"""Table model for task rows shared by workflow tabs."""
|
"""Table model for task rows shared by workflow tabs."""
|
||||||
|
|
||||||
HEADERS = ["账号", "别名", "商品ID", "阶段"]
|
HEADERS = ["账号", "别名", "商品ID", "阶段", "商品状态"]
|
||||||
|
|
||||||
STAGE_TEXT = {
|
STAGE_TEXT = {
|
||||||
"imported": "待采集",
|
"imported": "待采集",
|
||||||
@@ -85,14 +89,23 @@ class TaskTableModel(QAbstractTableModel):
|
|||||||
task = self.tasks[index.row()]
|
task = self.tasks[index.row()]
|
||||||
if role == Qt.DisplayRole:
|
if role == Qt.DisplayRole:
|
||||||
return self._display_value(task, index.column())
|
return self._display_value(task, index.column())
|
||||||
if role == Qt.ForegroundRole and index.column() == 3:
|
if role == Qt.ForegroundRole:
|
||||||
return self._stage_color(task)
|
if index.column() == 3:
|
||||||
|
return self._stage_color(task)
|
||||||
|
if index.column() == 4:
|
||||||
|
return self._product_status_color(task)
|
||||||
if role == Qt.ToolTipRole:
|
if role == Qt.ToolTipRole:
|
||||||
if self.is_unmatched(task):
|
if index.column() == 4:
|
||||||
return "别名未匹配账号,采集时将略过"
|
return self._product_status_tooltip(task)
|
||||||
error = getattr(task, "last_error", "") or ""
|
error = getattr(task, "last_error", "") or ""
|
||||||
|
if self.is_unmatched(task) or getattr(task, "status", "") == "skipped":
|
||||||
|
if error:
|
||||||
|
return diagnostics.redact_log_text(str(error))
|
||||||
|
if self.is_unmatched(task):
|
||||||
|
return "别名未匹配账号,采集时将略过"
|
||||||
|
return "未记录具体略过原因"
|
||||||
if error:
|
if error:
|
||||||
return str(error)
|
return diagnostics.redact_log_text(str(error))
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def flags(self, index):
|
def flags(self, index):
|
||||||
@@ -124,8 +137,12 @@ class TaskTableModel(QAbstractTableModel):
|
|||||||
return bool(error) and any(marker.lower() in error for marker in self.PRODUCT_UNAVAILABLE_MARKERS)
|
return bool(error) and any(marker.lower() in error for marker in self.PRODUCT_UNAVAILABLE_MARKERS)
|
||||||
|
|
||||||
def _stage_text(self, task) -> str:
|
def _stage_text(self, task) -> str:
|
||||||
if self.is_unmatched(task):
|
unmatched = self.is_unmatched(task)
|
||||||
return "略过"
|
if unmatched or getattr(task, "status", "") == "skipped":
|
||||||
|
return skipped_stage_text(
|
||||||
|
getattr(task, "last_error", ""),
|
||||||
|
alias_matched=not unmatched,
|
||||||
|
)
|
||||||
if self._is_product_unavailable(task):
|
if self._is_product_unavailable(task):
|
||||||
return "商品失效"
|
return "商品失效"
|
||||||
if task.status in self.STATUS_TEXT and task.status != "pending":
|
if task.status in self.STATUS_TEXT and task.status != "pending":
|
||||||
@@ -142,12 +159,33 @@ class TaskTableModel(QAbstractTableModel):
|
|||||||
return _qcolor(COLOR_SUCCESS)
|
return _qcolor(COLOR_SUCCESS)
|
||||||
return _qcolor(COLOR_PENDING)
|
return _qcolor(COLOR_PENDING)
|
||||||
|
|
||||||
|
def _product_status_color(self, task):
|
||||||
|
status = product_status.raw_status(getattr(task, "product_status", None))
|
||||||
|
if status == product_status.STATUS_NORMAL:
|
||||||
|
return _qcolor(COLOR_SUCCESS)
|
||||||
|
if status == product_status.STATUS_UNLISTED:
|
||||||
|
return _qcolor(COLOR_DANGER)
|
||||||
|
if status == product_status.STATUS_REVIEWING:
|
||||||
|
return _qcolor(COLOR_WARNING)
|
||||||
|
return _qcolor(COLOR_MUTED)
|
||||||
|
|
||||||
|
def _product_status_tooltip(self, task):
|
||||||
|
note = str(getattr(task, "product_status_note", "") or "").strip()
|
||||||
|
detected_at = str(getattr(task, "product_status_at", "") or "").strip()
|
||||||
|
lines = [note] if note else []
|
||||||
|
if detected_at:
|
||||||
|
lines.append(f"检测时间:{detected_at}")
|
||||||
|
if not lines and product_status.raw_status(getattr(task, "product_status", None)) == product_status.STATUS_UNCHECKED:
|
||||||
|
lines.append("尚未检测商品状态")
|
||||||
|
return "\n".join(lines) or None
|
||||||
|
|
||||||
def _display_value(self, task, column):
|
def _display_value(self, task, column):
|
||||||
values = [
|
values = [
|
||||||
self._account_name(task),
|
self._account_name(task),
|
||||||
task.alias,
|
task.alias,
|
||||||
task.item_id,
|
task.item_id,
|
||||||
self._stage_text(task),
|
self._stage_text(task),
|
||||||
|
product_status.display_status(getattr(task, "product_status", None)),
|
||||||
]
|
]
|
||||||
return values[column] if 0 <= column < len(values) else None
|
return values[column] if 0 <= column < len(values) else None
|
||||||
|
|
||||||
@@ -178,10 +216,15 @@ class GenerateTaskTableModel(QAbstractTableModel):
|
|||||||
self.db_path = db_path
|
self.db_path = db_path
|
||||||
self.status_callback = status_callback
|
self.status_callback = status_callback
|
||||||
self.last_edit_error = None
|
self.last_edit_error = None
|
||||||
|
self.generate_mode = "title"
|
||||||
self.generate_cover_enabled = False
|
self.generate_cover_enabled = False
|
||||||
|
|
||||||
def set_generate_cover_enabled(self, enabled):
|
def set_generate_cover_enabled(self, enabled):
|
||||||
self.generate_cover_enabled = bool(enabled)
|
self.set_generate_mode("title_cover" if enabled else "title")
|
||||||
|
|
||||||
|
def set_generate_mode(self, mode):
|
||||||
|
self.generate_mode = appconfig.normalize_generate_mode(mode)
|
||||||
|
self.generate_cover_enabled = appconfig.generate_mode_includes_cover(self.generate_mode)
|
||||||
if not self.tasks:
|
if not self.tasks:
|
||||||
return
|
return
|
||||||
top_left = self.index(0, 4)
|
top_left = self.index(0, 4)
|
||||||
@@ -217,9 +260,14 @@ class GenerateTaskTableModel(QAbstractTableModel):
|
|||||||
task = self.tasks[index.row()]
|
task = self.tasks[index.row()]
|
||||||
if role in (Qt.DisplayRole, Qt.EditRole):
|
if role in (Qt.DisplayRole, Qt.EditRole):
|
||||||
return self._display_value(task, index.column())
|
return self._display_value(task, index.column())
|
||||||
|
if role == Qt.ForegroundRole and index.column() == 1:
|
||||||
|
if self._cover_reset_count(task) > 0:
|
||||||
|
return _qcolor(COLOR_WARNING)
|
||||||
if role == Qt.ForegroundRole and index.column() in {4, 5}:
|
if role == Qt.ForegroundRole and index.column() in {4, 5}:
|
||||||
return self._component_status_color(self._display_value(task, index.column()))
|
return self._component_status_color(self._display_value(task, index.column()))
|
||||||
if role == Qt.ToolTipRole:
|
if role == Qt.ToolTipRole:
|
||||||
|
if index.column() == 1 and self._cover_reset_count(task) > 0:
|
||||||
|
return self._cover_reset_tooltip(task)
|
||||||
if index.column() == 3 and self._can_edit_title(task):
|
if index.column() == 3 and self._can_edit_title(task):
|
||||||
return "双击可微调新标题,只修改本地待更新内容"
|
return "双击可微调新标题,只修改本地待更新内容"
|
||||||
if task.last_error:
|
if task.last_error:
|
||||||
@@ -300,6 +348,16 @@ class GenerateTaskTableModel(QAbstractTableModel):
|
|||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
def _cover_reset_count(self, task):
|
||||||
|
try:
|
||||||
|
return int(getattr(task, "cover_reset_count", 0) or 0)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def _cover_reset_tooltip(self, task):
|
||||||
|
count = self._cover_reset_count(task)
|
||||||
|
return f"该商品封面已重置 {count} 次,可能有多张候选图,左键双击可查看封面画廊"
|
||||||
|
|
||||||
def _has_new_title(self, task):
|
def _has_new_title(self, task):
|
||||||
return bool(str(getattr(task, "new_title", "") or "").strip())
|
return bool(str(getattr(task, "new_title", "") or "").strip())
|
||||||
|
|
||||||
@@ -376,7 +434,10 @@ class GenerateTaskTableModel(QAbstractTableModel):
|
|||||||
return "失败"
|
return "失败"
|
||||||
if not self.generate_cover_enabled:
|
if not self.generate_cover_enabled:
|
||||||
return "未启用"
|
return "未启用"
|
||||||
if self._is_title_failure(task) or not self._has_new_title(task):
|
if self.generate_mode == "cover":
|
||||||
|
if not ai.cover_title_context(task):
|
||||||
|
return "未开始"
|
||||||
|
elif self._is_title_failure(task) or not self._has_new_title(task):
|
||||||
return "未开始"
|
return "未开始"
|
||||||
if getattr(task, "status", None) == "running":
|
if getattr(task, "status", None) == "running":
|
||||||
return "处理中"
|
return "处理中"
|
||||||
|
|||||||
@@ -0,0 +1,324 @@
|
|||||||
|
"""Product-suite prompt template editor and final prompt preview."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from PySide6.QtCore import Qt, QTimer
|
||||||
|
from PySide6.QtGui import QFontDatabase
|
||||||
|
from PySide6.QtWidgets import (
|
||||||
|
QComboBox,
|
||||||
|
QDialog,
|
||||||
|
QHBoxLayout,
|
||||||
|
QLabel,
|
||||||
|
QMenu,
|
||||||
|
QMessageBox,
|
||||||
|
QPlainTextEdit,
|
||||||
|
QPushButton,
|
||||||
|
QSizePolicy,
|
||||||
|
QSplitter,
|
||||||
|
QToolButton,
|
||||||
|
QVBoxLayout,
|
||||||
|
QWidget,
|
||||||
|
)
|
||||||
|
|
||||||
|
from .. import product_suite, prompts
|
||||||
|
|
||||||
|
|
||||||
|
class ProductSuitePromptDialog(QDialog):
|
||||||
|
"""Edit one global product-suite template and preview the final request."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
prompt_path,
|
||||||
|
base_prompt,
|
||||||
|
settings,
|
||||||
|
item_id,
|
||||||
|
source_image_count=0,
|
||||||
|
parent=None,
|
||||||
|
):
|
||||||
|
super().__init__(parent)
|
||||||
|
self.setObjectName("productSuitePromptDialog")
|
||||||
|
self.setWindowTitle("套图提示词设置")
|
||||||
|
self.setMinimumSize(760, 480)
|
||||||
|
self.resize(920, 600)
|
||||||
|
self.prompt_path = str(prompt_path)
|
||||||
|
self.base_prompt = str(base_prompt or "")
|
||||||
|
self.settings = product_suite.normalize_suite_settings(settings)
|
||||||
|
self.item_id = str(item_id or "")
|
||||||
|
self.reference_asset_count = product_suite.reference_asset_count(
|
||||||
|
self.settings,
|
||||||
|
source_image_count,
|
||||||
|
)
|
||||||
|
self._saved_text = ""
|
||||||
|
self._allow_close = False
|
||||||
|
|
||||||
|
self._build_ui()
|
||||||
|
self._connect_signals()
|
||||||
|
self._load_current_template()
|
||||||
|
|
||||||
|
def _build_ui(self):
|
||||||
|
root = QVBoxLayout(self)
|
||||||
|
root.setContentsMargins(12, 12, 12, 12)
|
||||||
|
root.setSpacing(10)
|
||||||
|
|
||||||
|
toolbar = QHBoxLayout()
|
||||||
|
toolbar.setSpacing(8)
|
||||||
|
self.save_button = QPushButton("保存")
|
||||||
|
self.save_button.setObjectName("suitePromptSaveButton")
|
||||||
|
self.save_button.setMinimumHeight(32)
|
||||||
|
toolbar.addWidget(self.save_button)
|
||||||
|
self.restore_button = QPushButton("恢复默认")
|
||||||
|
self.restore_button.setObjectName("suitePromptRestoreButton")
|
||||||
|
self.restore_button.setMinimumHeight(32)
|
||||||
|
toolbar.addWidget(self.restore_button)
|
||||||
|
self.feedback_label = QLabel("")
|
||||||
|
self.feedback_label.setObjectName("suitePromptFeedbackLabel")
|
||||||
|
self.feedback_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred)
|
||||||
|
toolbar.addWidget(self.feedback_label, 1)
|
||||||
|
toolbar.addWidget(QLabel("预览分类"))
|
||||||
|
self.category_combo = QComboBox()
|
||||||
|
self.category_combo.setObjectName("suitePromptCategoryCombo")
|
||||||
|
self.category_combo.setMinimumWidth(128)
|
||||||
|
for category in product_suite.category_order(self.settings):
|
||||||
|
self.category_combo.addItem(category, category)
|
||||||
|
white_index = self.category_combo.findData("白底图")
|
||||||
|
if white_index >= 0:
|
||||||
|
self.category_combo.setCurrentIndex(white_index)
|
||||||
|
toolbar.addWidget(self.category_combo)
|
||||||
|
root.addLayout(toolbar)
|
||||||
|
|
||||||
|
self.splitter = QSplitter(Qt.Horizontal)
|
||||||
|
self.splitter.setObjectName("suitePromptSplitter")
|
||||||
|
self.splitter.addWidget(self._build_editor_panel())
|
||||||
|
self.splitter.addWidget(self._build_preview_panel())
|
||||||
|
self.splitter.setStretchFactor(0, 1)
|
||||||
|
self.splitter.setStretchFactor(1, 1)
|
||||||
|
self.splitter.setSizes([440, 440])
|
||||||
|
root.addWidget(self.splitter, 1)
|
||||||
|
|
||||||
|
def _build_editor_panel(self):
|
||||||
|
panel = QWidget()
|
||||||
|
layout = QVBoxLayout(panel)
|
||||||
|
layout.setContentsMargins(0, 0, 4, 0)
|
||||||
|
layout.setSpacing(6)
|
||||||
|
title_row = QHBoxLayout()
|
||||||
|
title = QLabel("基础模板")
|
||||||
|
title.setStyleSheet("font-weight: 600;")
|
||||||
|
title_row.addWidget(title)
|
||||||
|
title_row.addStretch(1)
|
||||||
|
self.variable_button = QToolButton()
|
||||||
|
self.variable_button.setObjectName("suitePromptVariableButton")
|
||||||
|
self.variable_button.setText("插入变量")
|
||||||
|
self.variable_button.setToolTip("在当前光标位置插入提示词变量")
|
||||||
|
self.variable_button.setAccessibleName("插入提示词变量")
|
||||||
|
self.variable_button.setPopupMode(QToolButton.InstantPopup)
|
||||||
|
menu = QMenu(self.variable_button)
|
||||||
|
for name in product_suite.PRODUCT_SUITE_PLACEHOLDERS:
|
||||||
|
action = menu.addAction("{%s}" % name)
|
||||||
|
action.triggered.connect(
|
||||||
|
lambda checked=False, variable=name: self.insert_variable(variable)
|
||||||
|
)
|
||||||
|
self.variable_button.setMenu(menu)
|
||||||
|
title_row.addWidget(self.variable_button)
|
||||||
|
layout.addLayout(title_row)
|
||||||
|
|
||||||
|
self.template_edit = QPlainTextEdit()
|
||||||
|
self.template_edit.setObjectName("suitePromptTemplateEdit")
|
||||||
|
self.template_edit.setPlaceholderText("输入套图提示词模板")
|
||||||
|
self.template_edit.setFont(QFontDatabase.systemFont(QFontDatabase.FixedFont))
|
||||||
|
layout.addWidget(self.template_edit, 1)
|
||||||
|
|
||||||
|
self.validation_label = QLabel("")
|
||||||
|
self.validation_label.setObjectName("suitePromptValidationLabel")
|
||||||
|
self.validation_label.setWordWrap(True)
|
||||||
|
self.validation_label.setStyleSheet("color: #cf222e;")
|
||||||
|
layout.addWidget(self.validation_label)
|
||||||
|
return panel
|
||||||
|
|
||||||
|
def _build_preview_panel(self):
|
||||||
|
panel = QWidget()
|
||||||
|
layout = QVBoxLayout(panel)
|
||||||
|
layout.setContentsMargins(4, 0, 0, 0)
|
||||||
|
layout.setSpacing(6)
|
||||||
|
title = QLabel("最终提示词预览")
|
||||||
|
title.setStyleSheet("font-weight: 600;")
|
||||||
|
layout.addWidget(title)
|
||||||
|
self.preview_edit = QPlainTextEdit()
|
||||||
|
self.preview_edit.setObjectName("suitePromptPreviewEdit")
|
||||||
|
self.preview_edit.setReadOnly(True)
|
||||||
|
self.preview_edit.setFont(QFontDatabase.systemFont(QFontDatabase.FixedFont))
|
||||||
|
self.preview_edit.setStyleSheet(
|
||||||
|
"QPlainTextEdit#suitePromptPreviewEdit { "
|
||||||
|
"background: #f6f8fa; color: #24292f; border: 1px solid #d0d7de; "
|
||||||
|
"}"
|
||||||
|
)
|
||||||
|
layout.addWidget(self.preview_edit, 1)
|
||||||
|
return panel
|
||||||
|
|
||||||
|
def _connect_signals(self):
|
||||||
|
self.save_button.clicked.connect(self.save_template)
|
||||||
|
self.restore_button.clicked.connect(self.restore_default)
|
||||||
|
self.category_combo.currentIndexChanged.connect(self.schedule_preview)
|
||||||
|
self.template_edit.textChanged.connect(self.schedule_preview)
|
||||||
|
self.preview_timer = QTimer(self)
|
||||||
|
self.preview_timer.setSingleShot(True)
|
||||||
|
self.preview_timer.setInterval(200)
|
||||||
|
self.preview_timer.timeout.connect(self.refresh_preview)
|
||||||
|
self.feedback_timer = QTimer(self)
|
||||||
|
self.feedback_timer.setSingleShot(True)
|
||||||
|
self.feedback_timer.setInterval(3000)
|
||||||
|
self.feedback_timer.timeout.connect(lambda: self.feedback_label.setText(""))
|
||||||
|
|
||||||
|
def _load_current_template(self):
|
||||||
|
try:
|
||||||
|
text = prompts.read_product_suite_prompt(self.prompt_path)
|
||||||
|
except prompts.PromptError as exc:
|
||||||
|
text = ""
|
||||||
|
self._set_feedback(str(exc), "danger")
|
||||||
|
self._saved_text = text
|
||||||
|
self.template_edit.setPlainText(text)
|
||||||
|
self.refresh_preview()
|
||||||
|
|
||||||
|
def insert_variable(self, name):
|
||||||
|
if name not in product_suite.PRODUCT_SUITE_PLACEHOLDERS:
|
||||||
|
return
|
||||||
|
cursor = self.template_edit.textCursor()
|
||||||
|
cursor.insertText("{%s}" % name)
|
||||||
|
self.template_edit.setTextCursor(cursor)
|
||||||
|
self.template_edit.setFocus()
|
||||||
|
|
||||||
|
def schedule_preview(self, value=None):
|
||||||
|
self.preview_timer.start()
|
||||||
|
|
||||||
|
def preview_context(self):
|
||||||
|
category = str(self.category_combo.currentData() or "白底图")
|
||||||
|
return product_suite.product_suite_prompt_context(
|
||||||
|
self.base_prompt,
|
||||||
|
self.settings,
|
||||||
|
category,
|
||||||
|
self.item_id or "未绑定商品",
|
||||||
|
source_index=1,
|
||||||
|
reference_asset_count=self.reference_asset_count,
|
||||||
|
)
|
||||||
|
|
||||||
|
def refresh_preview(self):
|
||||||
|
text = self.template_edit.toPlainText()
|
||||||
|
errors = product_suite.product_suite_prompt_errors(text)
|
||||||
|
self.save_button.setEnabled(not errors)
|
||||||
|
self.validation_label.setText(";".join(errors))
|
||||||
|
if errors:
|
||||||
|
self.preview_edit.setPlainText(
|
||||||
|
"套图提示词模板无效:\n%s"
|
||||||
|
% "\n".join("- %s" % error for error in errors)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
rendered = product_suite.render_product_suite_prompt(
|
||||||
|
text,
|
||||||
|
self.preview_context(),
|
||||||
|
)
|
||||||
|
except product_suite.ProductSuitePromptError as exc:
|
||||||
|
self.save_button.setEnabled(False)
|
||||||
|
self.validation_label.setText(str(exc))
|
||||||
|
self.preview_edit.setPlainText("套图提示词模板无效:\n%s" % exc)
|
||||||
|
return
|
||||||
|
self.preview_edit.setPlainText(rendered)
|
||||||
|
|
||||||
|
def save_template(self):
|
||||||
|
text = self.template_edit.toPlainText()
|
||||||
|
try:
|
||||||
|
prompts.save_product_suite_prompt(text, self.prompt_path)
|
||||||
|
except prompts.PromptError as exc:
|
||||||
|
self._message("提示词保存失败", str(exc))
|
||||||
|
return False
|
||||||
|
self._saved_text = text
|
||||||
|
self.refresh_preview()
|
||||||
|
self._set_feedback("套图提示词模板已保存", "success")
|
||||||
|
return True
|
||||||
|
|
||||||
|
def restore_default(self):
|
||||||
|
try:
|
||||||
|
prompts.load_default_product_suite_prompt()
|
||||||
|
except prompts.PromptError as exc:
|
||||||
|
self._message("恢复默认失败", str(exc))
|
||||||
|
return False
|
||||||
|
if not self._confirm_restore():
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
text = prompts.restore_default_product_suite_prompt(self.prompt_path)
|
||||||
|
except prompts.PromptError as exc:
|
||||||
|
self._message("恢复默认失败", str(exc))
|
||||||
|
return False
|
||||||
|
self._saved_text = text
|
||||||
|
previous = self.template_edit.blockSignals(True)
|
||||||
|
try:
|
||||||
|
self.template_edit.setPlainText(text)
|
||||||
|
finally:
|
||||||
|
self.template_edit.blockSignals(previous)
|
||||||
|
self.refresh_preview()
|
||||||
|
self._set_feedback("已恢复默认套图提示词", "success")
|
||||||
|
return True
|
||||||
|
|
||||||
|
def is_dirty(self):
|
||||||
|
return self.template_edit.toPlainText() != self._saved_text
|
||||||
|
|
||||||
|
def _confirm_restore(self):
|
||||||
|
box = QMessageBox(self)
|
||||||
|
box.setIcon(QMessageBox.Warning)
|
||||||
|
box.setWindowTitle("恢复默认提示词")
|
||||||
|
box.setText("恢复默认会丢弃当前未保存的模板修改。确认继续吗?")
|
||||||
|
restore_button = box.addButton("恢复默认", QMessageBox.AcceptRole)
|
||||||
|
box.addButton("取消", QMessageBox.RejectRole)
|
||||||
|
box.exec()
|
||||||
|
return box.clickedButton() is restore_button
|
||||||
|
|
||||||
|
def _unsaved_action(self):
|
||||||
|
box = QMessageBox(self)
|
||||||
|
box.setIcon(QMessageBox.Question)
|
||||||
|
box.setWindowTitle("提示词尚未保存")
|
||||||
|
box.setText("当前套图提示词模板有未保存修改。")
|
||||||
|
save_button = box.addButton("保存", QMessageBox.AcceptRole)
|
||||||
|
discard_button = box.addButton("不保存", QMessageBox.DestructiveRole)
|
||||||
|
cancel_button = box.addButton("取消", QMessageBox.RejectRole)
|
||||||
|
box.setDefaultButton(save_button)
|
||||||
|
box.exec()
|
||||||
|
clicked = box.clickedButton()
|
||||||
|
if clicked is save_button:
|
||||||
|
return "save"
|
||||||
|
if clicked is discard_button:
|
||||||
|
return "discard"
|
||||||
|
if clicked is cancel_button:
|
||||||
|
return "cancel"
|
||||||
|
return "cancel"
|
||||||
|
|
||||||
|
def _can_close(self):
|
||||||
|
if not self.is_dirty():
|
||||||
|
return True
|
||||||
|
action = self._unsaved_action()
|
||||||
|
if action == "save":
|
||||||
|
return self.save_template()
|
||||||
|
return action == "discard"
|
||||||
|
|
||||||
|
def reject(self):
|
||||||
|
if self._can_close():
|
||||||
|
self._allow_close = True
|
||||||
|
super().reject()
|
||||||
|
|
||||||
|
def closeEvent(self, event):
|
||||||
|
if self._allow_close or self._can_close():
|
||||||
|
event.accept()
|
||||||
|
else:
|
||||||
|
event.ignore()
|
||||||
|
|
||||||
|
def _set_feedback(self, message, level):
|
||||||
|
color = "#1a7f37" if level == "success" else "#cf222e"
|
||||||
|
self.feedback_label.setStyleSheet("color: %s; font-weight: 600;" % color)
|
||||||
|
self.feedback_label.setText(str(message))
|
||||||
|
self.feedback_timer.start()
|
||||||
|
|
||||||
|
def _message(self, title, message):
|
||||||
|
box = QMessageBox(self)
|
||||||
|
box.setIcon(QMessageBox.Warning)
|
||||||
|
box.setWindowTitle(str(title))
|
||||||
|
box.setText(str(message))
|
||||||
|
box.exec()
|
||||||
@@ -110,6 +110,9 @@ class AccountsTab(QWidget):
|
|||||||
self.account_rows = []
|
self.account_rows = []
|
||||||
self.login_statuses = {}
|
self.login_statuses = {}
|
||||||
self.threads = []
|
self.threads = []
|
||||||
|
self._launching_accounts = set()
|
||||||
|
self._launch_guard_until = {}
|
||||||
|
self._launch_run_ids = {}
|
||||||
|
|
||||||
self.table = QTableWidget(0, len(self.COLUMNS))
|
self.table = QTableWidget(0, len(self.COLUMNS))
|
||||||
self.table.setHorizontalHeaderLabels(self.COLUMNS)
|
self.table.setHorizontalHeaderLabels(self.COLUMNS)
|
||||||
@@ -173,6 +176,9 @@ class AccountsTab(QWidget):
|
|||||||
|
|
||||||
def _update_button_state(self):
|
def _update_button_state(self):
|
||||||
has_selection = self._selected_account() is not None
|
has_selection = self._selected_account() is not None
|
||||||
|
launch_in_progress = bool(self._launching_accounts)
|
||||||
|
self.table.setEnabled(not launch_in_progress)
|
||||||
|
self.add_button.setEnabled(not launch_in_progress)
|
||||||
for button in (
|
for button in (
|
||||||
self.edit_button,
|
self.edit_button,
|
||||||
self.delete_button,
|
self.delete_button,
|
||||||
@@ -180,7 +186,33 @@ class AccountsTab(QWidget):
|
|||||||
self.check_button,
|
self.check_button,
|
||||||
self.shortcut_button,
|
self.shortcut_button,
|
||||||
):
|
):
|
||||||
button.setEnabled(has_selection)
|
button.setEnabled(has_selection and not launch_in_progress)
|
||||||
|
|
||||||
|
def _launch_guard_seconds(self):
|
||||||
|
app = QApplication.instance()
|
||||||
|
if app is None:
|
||||||
|
return 0.5
|
||||||
|
return max(0.25, app.doubleClickInterval() / 1000.0)
|
||||||
|
|
||||||
|
def _ignore_reentrant_launch(self, account):
|
||||||
|
alias = account.alias
|
||||||
|
now = time.monotonic()
|
||||||
|
if (
|
||||||
|
alias not in self._launching_accounts
|
||||||
|
and now >= self._launch_guard_until.get(alias, 0.0)
|
||||||
|
):
|
||||||
|
return False
|
||||||
|
_safe_add_run_log_event(
|
||||||
|
self._launch_run_ids.get(alias),
|
||||||
|
(
|
||||||
|
f"step=launch_chrome result=ignored detail=账号 {alias} "
|
||||||
|
"reason=launch_in_progress"
|
||||||
|
),
|
||||||
|
db_path=self.db_path,
|
||||||
|
account=account,
|
||||||
|
)
|
||||||
|
self._set_status("该账号 Chrome 正在启动,请稍候", level="info")
|
||||||
|
return True
|
||||||
|
|
||||||
def refresh_accounts(self):
|
def refresh_accounts(self):
|
||||||
try:
|
try:
|
||||||
@@ -297,12 +329,27 @@ class AccountsTab(QWidget):
|
|||||||
account = self._selected_account()
|
account = self._selected_account()
|
||||||
if account is None:
|
if account is None:
|
||||||
return
|
return
|
||||||
|
if self._ignore_reentrant_launch(account):
|
||||||
|
return
|
||||||
run_id = _safe_create_run_log(
|
run_id = _safe_create_run_log(
|
||||||
"chrome_launch",
|
"chrome_launch",
|
||||||
db_path=self.db_path,
|
db_path=self.db_path,
|
||||||
total=1,
|
total=1,
|
||||||
options={"alias": account.alias, "debug_port": account.debug_port},
|
options={"alias": account.alias, "debug_port": account.debug_port},
|
||||||
)
|
)
|
||||||
|
self._launching_accounts.add(account.alias)
|
||||||
|
self._launch_run_ids[account.alias] = run_id
|
||||||
|
self._update_button_state()
|
||||||
|
try:
|
||||||
|
self._launch_login_once(account, run_id)
|
||||||
|
finally:
|
||||||
|
self._launching_accounts.discard(account.alias)
|
||||||
|
self._launch_guard_until[account.alias] = (
|
||||||
|
time.monotonic() + self._launch_guard_seconds()
|
||||||
|
)
|
||||||
|
self._update_button_state()
|
||||||
|
|
||||||
|
def _launch_login_once(self, account, run_id):
|
||||||
started = time.monotonic()
|
started = time.monotonic()
|
||||||
_safe_add_run_log_event(
|
_safe_add_run_log_event(
|
||||||
run_id,
|
run_id,
|
||||||
@@ -346,13 +393,27 @@ class AccountsTab(QWidget):
|
|||||||
action = (launch_result or {}).get("action") or "launched"
|
action = (launch_result or {}).get("action") or "launched"
|
||||||
pid = (launch_result or {}).get("pid")
|
pid = (launch_result or {}).get("pid")
|
||||||
target_id = (launch_result or {}).get("target_id") or ""
|
target_id = (launch_result or {}).get("target_id") or ""
|
||||||
url = (launch_result or {}).get("url") or ""
|
url = diagnostics.redact_log_text((launch_result or {}).get("url") or "")
|
||||||
|
created_tab = bool((launch_result or {}).get("created_tab"))
|
||||||
|
startup_target_reused = bool(
|
||||||
|
(launch_result or {}).get("startup_target_reused")
|
||||||
|
)
|
||||||
|
startup_page_navigated = bool(
|
||||||
|
(launch_result or {}).get("startup_page_navigated")
|
||||||
|
)
|
||||||
|
page_target_count = int((launch_result or {}).get("page_target_count") or 0)
|
||||||
|
fallback_reason = (launch_result or {}).get("fallback_reason") or ""
|
||||||
event_result = "reused" if action == "reused" else "launched"
|
event_result = "reused" if action == "reused" else "launched"
|
||||||
_safe_add_run_log_event(
|
_safe_add_run_log_event(
|
||||||
run_id,
|
run_id,
|
||||||
(
|
(
|
||||||
f"step=launch_chrome result={event_result} detail=账号 {account.alias} "
|
f"step=launch_chrome result={event_result} detail=账号 {account.alias} "
|
||||||
f"pid={pid or ''} target_id={target_id} url={url} elapsed_ms={elapsed_ms}"
|
f"pid={pid or ''} target_id={target_id} url={url} "
|
||||||
|
f"created_tab={int(created_tab)} "
|
||||||
|
f"startup_target_reused={int(startup_target_reused)} "
|
||||||
|
f"startup_page_navigated={int(startup_page_navigated)} "
|
||||||
|
f"page_target_count={page_target_count} "
|
||||||
|
f"fallback_reason={fallback_reason} elapsed_ms={elapsed_ms}"
|
||||||
),
|
),
|
||||||
db_path=self.db_path,
|
db_path=self.db_path,
|
||||||
account=account,
|
account=account,
|
||||||
@@ -372,6 +433,11 @@ class AccountsTab(QWidget):
|
|||||||
"pid": pid,
|
"pid": pid,
|
||||||
"target_id": target_id,
|
"target_id": target_id,
|
||||||
"url": url,
|
"url": url,
|
||||||
|
"created_tab": created_tab,
|
||||||
|
"startup_target_reused": startup_target_reused,
|
||||||
|
"startup_page_navigated": startup_page_navigated,
|
||||||
|
"page_target_count": page_target_count,
|
||||||
|
"fallback_reason": fallback_reason or None,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
self.login_statuses[account.alias] = "已启动"
|
self.login_statuses[account.alias] = "已启动"
|
||||||
@@ -447,4 +513,3 @@ class AccountsTab(QWidget):
|
|||||||
PLAINTEXT_SECRET_TITLE,
|
PLAINTEXT_SECRET_TITLE,
|
||||||
PLAINTEXT_PASSWORD_WARNING,
|
PLAINTEXT_PASSWORD_WARNING,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
+485
-82
@@ -2,9 +2,16 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from PySide6.QtCore import QModelIndex, Signal
|
||||||
|
|
||||||
|
from ... import product_status
|
||||||
from ..models import ApplyTaskTableModel
|
from ..models import ApplyTaskTableModel
|
||||||
from ..widgets import *
|
from ..widgets import *
|
||||||
from ..workers import ApplyWorker as _RealApplyWorker, WriteBackWorker as _RealWriteBackWorker
|
from ..workers import (
|
||||||
|
ApplyWorker as _RealApplyWorker,
|
||||||
|
ProductTabOpenWorker as _RealProductTabOpenWorker,
|
||||||
|
WriteBackWorker as _RealWriteBackWorker,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def ApplyWorker(*args, **kwargs):
|
def ApplyWorker(*args, **kwargs):
|
||||||
@@ -14,9 +21,35 @@ def ApplyWorker(*args, **kwargs):
|
|||||||
def WriteBackWorker(*args, **kwargs):
|
def WriteBackWorker(*args, **kwargs):
|
||||||
return _call_package_attr("WriteBackWorker", _RealWriteBackWorker, *args, **kwargs)
|
return _call_package_attr("WriteBackWorker", _RealWriteBackWorker, *args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
def ProductTabOpenWorker(*args, **kwargs):
|
||||||
|
return _call_package_attr(
|
||||||
|
"ProductTabOpenWorker", _RealProductTabOpenWorker, *args, **kwargs
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class _ApplyTaskTableView(QTableView):
|
||||||
|
"""任务表仅向外发出左键双击事件,供人工查看商品页。"""
|
||||||
|
|
||||||
|
leftDoubleClicked = Signal(QModelIndex)
|
||||||
|
|
||||||
|
def mouseDoubleClickEvent(self, event):
|
||||||
|
super().mouseDoubleClickEvent(event)
|
||||||
|
if event.button() != Qt.LeftButton:
|
||||||
|
return
|
||||||
|
index = self.indexAt(event.position().toPoint())
|
||||||
|
if index.isValid():
|
||||||
|
self.leftDoubleClicked.emit(index)
|
||||||
|
|
||||||
|
|
||||||
class ApplyTab(QWidget):
|
class ApplyTab(QWidget):
|
||||||
"""Tab 3: list generated tasks and confirm the update scope."""
|
"""Tab 3: list generated tasks and confirm the update scope."""
|
||||||
|
|
||||||
|
UPDATE_MODE_ITEMS = [
|
||||||
|
("只更新标题", "title"),
|
||||||
|
("只更新封面", "cover"),
|
||||||
|
("更新标题和封面", "title_cover"),
|
||||||
|
]
|
||||||
STATUS_FILTERS = [
|
STATUS_FILTERS = [
|
||||||
("已生成", "generated"),
|
("已生成", "generated"),
|
||||||
("失败", "failed"),
|
("失败", "failed"),
|
||||||
@@ -33,17 +66,22 @@ class ApplyTab(QWidget):
|
|||||||
status_callback=None,
|
status_callback=None,
|
||||||
open_accounts_callback=None,
|
open_accounts_callback=None,
|
||||||
open_settings_callback=None,
|
open_settings_callback=None,
|
||||||
|
refresh_workflow_callback=None,
|
||||||
):
|
):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self.config = appconfig.load_config() if config is None else config
|
self.config = appconfig.load_config() if config is None else config
|
||||||
|
self.config_path = self.config.get("config_path") or appconfig.CONFIG_PATH
|
||||||
self.db_path = _database_path(db_path, self.config)
|
self.db_path = _database_path(db_path, self.config)
|
||||||
self.status_callback = status_callback
|
self.status_callback = status_callback
|
||||||
self.open_accounts_callback = open_accounts_callback
|
self.open_accounts_callback = open_accounts_callback
|
||||||
self.open_settings_callback = open_settings_callback
|
self.open_settings_callback = open_settings_callback
|
||||||
|
self.refresh_workflow_callback = refresh_workflow_callback
|
||||||
self.apply_worker = None
|
self.apply_worker = None
|
||||||
self.apply_thread = None
|
self.apply_thread = None
|
||||||
self.result_write_back_worker = None
|
self.result_write_back_worker = None
|
||||||
self.result_write_back_thread = None
|
self.result_write_back_thread = None
|
||||||
|
self.product_tab_open_worker = None
|
||||||
|
self.product_tab_open_thread = None
|
||||||
self.last_apply_summary = None
|
self.last_apply_summary = None
|
||||||
|
|
||||||
self.batch_filter = QComboBox()
|
self.batch_filter = QComboBox()
|
||||||
@@ -57,6 +95,13 @@ class ApplyTab(QWidget):
|
|||||||
self.status_filter.setObjectName("applyStatusFilter")
|
self.status_filter.setObjectName("applyStatusFilter")
|
||||||
for label, value in self.STATUS_FILTERS:
|
for label, value in self.STATUS_FILTERS:
|
||||||
self.status_filter.addItem(label, value)
|
self.status_filter.addItem(label, value)
|
||||||
|
self.product_status_filter = QComboBox()
|
||||||
|
self.product_status_filter.setObjectName("applyProductStatusFilter")
|
||||||
|
for label, value in product_status.PRODUCT_STATUS_FILTER_ITEMS:
|
||||||
|
self.product_status_filter.addItem(label, value)
|
||||||
|
self.product_status_filter.setCurrentIndex(
|
||||||
|
self.product_status_filter.findData(product_status.STATUS_NORMAL)
|
||||||
|
)
|
||||||
self.refresh_button = QPushButton("刷新")
|
self.refresh_button = QPushButton("刷新")
|
||||||
|
|
||||||
filter_layout = QHBoxLayout()
|
filter_layout = QHBoxLayout()
|
||||||
@@ -66,7 +111,9 @@ class ApplyTab(QWidget):
|
|||||||
filter_layout.addWidget(self.shop_filter, 1)
|
filter_layout.addWidget(self.shop_filter, 1)
|
||||||
filter_layout.addWidget(QLabel("商品ID"))
|
filter_layout.addWidget(QLabel("商品ID"))
|
||||||
filter_layout.addWidget(self.item_filter, 1)
|
filter_layout.addWidget(self.item_filter, 1)
|
||||||
filter_layout.addWidget(QLabel("状态"))
|
filter_layout.addWidget(QLabel("商品状态"))
|
||||||
|
filter_layout.addWidget(self.product_status_filter, 1)
|
||||||
|
filter_layout.addWidget(QLabel("更新状态"))
|
||||||
filter_layout.addWidget(self.status_filter, 1)
|
filter_layout.addWidget(self.status_filter, 1)
|
||||||
filter_layout.addWidget(self.refresh_button)
|
filter_layout.addWidget(self.refresh_button)
|
||||||
|
|
||||||
@@ -80,7 +127,7 @@ class ApplyTab(QWidget):
|
|||||||
) = _build_empty_state_card("applyEmptyStateCard")
|
) = _build_empty_state_card("applyEmptyStateCard")
|
||||||
if self.open_accounts_callback is not None:
|
if self.open_accounts_callback is not None:
|
||||||
self.empty_state_button.clicked.connect(self.open_accounts_callback)
|
self.empty_state_button.clicked.connect(self.open_accounts_callback)
|
||||||
self.task_table = QTableView()
|
self.task_table = _ApplyTaskTableView()
|
||||||
self.model = ApplyTaskTableModel(self.task_table)
|
self.model = ApplyTaskTableModel(self.task_table)
|
||||||
self.task_table.setModel(self.model)
|
self.task_table.setModel(self.model)
|
||||||
self.task_table.setSelectionBehavior(QAbstractItemView.SelectRows)
|
self.task_table.setSelectionBehavior(QAbstractItemView.SelectRows)
|
||||||
@@ -110,8 +157,20 @@ class ApplyTab(QWidget):
|
|||||||
self.write_back_button = QPushButton("回写结果到 Excel")
|
self.write_back_button = QPushButton("回写结果到 Excel")
|
||||||
self.stop_update_button.setEnabled(False)
|
self.stop_update_button.setEnabled(False)
|
||||||
self.write_back_button.setEnabled(False)
|
self.write_back_button.setEnabled(False)
|
||||||
|
self.update_mode_combo = QComboBox()
|
||||||
|
self.update_mode_combo.setObjectName("applyUpdateModeCombo")
|
||||||
|
self.update_mode_combo.setToolTip("选择本轮要更新标题、封面,或同时更新标题和封面")
|
||||||
|
for label, value in self.UPDATE_MODE_ITEMS:
|
||||||
|
self.update_mode_combo.addItem(label, value)
|
||||||
|
self._set_combo_by_data(
|
||||||
|
self.update_mode_combo,
|
||||||
|
self._shopee_update_config().get("update_mode", "title"),
|
||||||
|
)
|
||||||
|
|
||||||
action_layout = QHBoxLayout()
|
action_layout = QHBoxLayout()
|
||||||
|
action_layout.addWidget(QLabel("更新内容"))
|
||||||
|
action_layout.addWidget(self.update_mode_combo)
|
||||||
|
action_layout.addSpacing(16)
|
||||||
action_layout.addWidget(self.preview_update_button)
|
action_layout.addWidget(self.preview_update_button)
|
||||||
action_layout.addWidget(self.start_update_button)
|
action_layout.addWidget(self.start_update_button)
|
||||||
action_layout.addWidget(self.stop_update_button)
|
action_layout.addWidget(self.stop_update_button)
|
||||||
@@ -133,12 +192,15 @@ class ApplyTab(QWidget):
|
|||||||
self.batch_filter.currentIndexChanged.connect(self.refresh_tasks)
|
self.batch_filter.currentIndexChanged.connect(self.refresh_tasks)
|
||||||
self.shop_filter.currentIndexChanged.connect(self.refresh_tasks)
|
self.shop_filter.currentIndexChanged.connect(self.refresh_tasks)
|
||||||
self.item_filter.textChanged.connect(self.refresh_tasks)
|
self.item_filter.textChanged.connect(self.refresh_tasks)
|
||||||
|
self.product_status_filter.currentIndexChanged.connect(self.refresh_tasks)
|
||||||
self.status_filter.currentIndexChanged.connect(self.refresh_tasks)
|
self.status_filter.currentIndexChanged.connect(self.refresh_tasks)
|
||||||
self.refresh_button.clicked.connect(self.refresh_tasks)
|
self.refresh_button.clicked.connect(self.refresh_tasks)
|
||||||
self.preview_update_button.clicked.connect(self.preview_update)
|
self.preview_update_button.clicked.connect(self.preview_update)
|
||||||
self.start_update_button.clicked.connect(self.start_update)
|
self.start_update_button.clicked.connect(self.start_update)
|
||||||
|
self.update_mode_combo.currentIndexChanged.connect(self._on_update_mode_changed)
|
||||||
self.stop_update_button.clicked.connect(self.stop_update)
|
self.stop_update_button.clicked.connect(self.stop_update)
|
||||||
self.reset_update_button.clicked.connect(self.reset_apply_status)
|
self.reset_update_button.clicked.connect(self.reset_apply_status)
|
||||||
|
self.task_table.leftDoubleClicked.connect(self.open_or_focus_selected_product)
|
||||||
self.task_table.customContextMenuRequested.connect(self.show_task_context_menu)
|
self.task_table.customContextMenuRequested.connect(self.show_task_context_menu)
|
||||||
self.write_back_button.clicked.connect(self.write_back_results)
|
self.write_back_button.clicked.connect(self.write_back_results)
|
||||||
|
|
||||||
@@ -156,6 +218,7 @@ class ApplyTab(QWidget):
|
|||||||
selected_batch = self.batch_filter.currentData()
|
selected_batch = self.batch_filter.currentData()
|
||||||
selected_shop = self.shop_filter.currentData()
|
selected_shop = self.shop_filter.currentData()
|
||||||
selected_status = self.status_filter.currentData() or "generated"
|
selected_status = self.status_filter.currentData() or "generated"
|
||||||
|
selected_product_status = self.product_status_filter.currentData() or "all"
|
||||||
item_query = self.item_filter.text().strip()
|
item_query = self.item_filter.text().strip()
|
||||||
self._populate_batch_filter(batches, selected_batch)
|
self._populate_batch_filter(batches, selected_batch)
|
||||||
selected_batch = self.batch_filter.currentData()
|
selected_batch = self.batch_filter.currentData()
|
||||||
@@ -170,6 +233,7 @@ class ApplyTab(QWidget):
|
|||||||
task for task in batch_tasks
|
task for task in batch_tasks
|
||||||
if self._matches_shop(task, selected_shop)
|
if self._matches_shop(task, selected_shop)
|
||||||
and self._matches_item(task, item_query)
|
and self._matches_item(task, item_query)
|
||||||
|
and self._matches_product_status(task, selected_product_status)
|
||||||
and self._matches_status(task, selected_status)
|
and self._matches_status(task, selected_status)
|
||||||
]
|
]
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
@@ -193,7 +257,7 @@ class ApplyTab(QWidget):
|
|||||||
self.empty_state_card,
|
self.empty_state_card,
|
||||||
self.empty_state_label,
|
self.empty_state_label,
|
||||||
self.empty_state_button,
|
self.empty_state_button,
|
||||||
"第一步:前往『④账号管理』配置并登录账号,再回到③更新蝦皮。",
|
"第一步:前往『账号管理』配置并登录账号,再回到③更新蝦皮。",
|
||||||
self.open_accounts_callback is not None,
|
self.open_accounts_callback is not None,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
@@ -225,44 +289,68 @@ class ApplyTab(QWidget):
|
|||||||
if self.apply_thread is not None:
|
if self.apply_thread is not None:
|
||||||
self._set_status("更新正在进行...")
|
self._set_status("更新正在进行...")
|
||||||
return
|
return
|
||||||
tasks = [
|
base_candidates = self._update_candidates()
|
||||||
task for task in self.model.tasks
|
if not base_candidates:
|
||||||
if self._is_actionable_task(task)
|
|
||||||
]
|
|
||||||
if not tasks:
|
|
||||||
self._set_status("当前筛选结果没有可更新任务")
|
self._set_status("当前筛选结果没有可更新任务")
|
||||||
return
|
return
|
||||||
update_cfg = self._shopee_update_config()
|
update_cfg = self._shopee_update_config()
|
||||||
dry_run = bool(dry_run)
|
update_mode = self._current_update_mode()
|
||||||
safety_error = self._update_safety_error(tasks, dry_run=dry_run)
|
update_plan = product_status.build_apply_plan(base_candidates, update_mode)
|
||||||
if safety_error:
|
executable_tasks = update_plan["executable"]
|
||||||
self._show_update_safety_error(safety_error)
|
if not executable_tasks:
|
||||||
self._set_status(safety_error.replace("\n", " "))
|
preflight_error = self._update_preflight_error(update_plan, update_mode)
|
||||||
|
title = (
|
||||||
|
"商品状态不允许更新"
|
||||||
|
if update_plan["status_scope_excluded"]
|
||||||
|
else "更新内容未生成"
|
||||||
|
)
|
||||||
|
QMessageBox.warning(self, title, preflight_error)
|
||||||
|
self._set_status(preflight_error.replace("\n", " "))
|
||||||
return
|
return
|
||||||
|
dry_run = bool(dry_run)
|
||||||
answer = QMessageBox.question(
|
answer = QMessageBox.question(
|
||||||
self,
|
self,
|
||||||
"确认检查本轮更新" if dry_run else "确认开始更新",
|
"确认检查本轮更新" if dry_run else "确认开始更新",
|
||||||
self._confirmation_message(tasks, dry_run=dry_run),
|
self._confirmation_message(
|
||||||
|
executable_tasks,
|
||||||
|
dry_run=dry_run,
|
||||||
|
content_plan=update_plan,
|
||||||
|
),
|
||||||
QMessageBox.Yes | QMessageBox.No,
|
QMessageBox.Yes | QMessageBox.No,
|
||||||
QMessageBox.No,
|
QMessageBox.No,
|
||||||
)
|
)
|
||||||
if answer != QMessageBox.Yes:
|
if answer != QMessageBox.Yes:
|
||||||
self._set_status("已取消检查本轮更新" if dry_run else "已取消开始更新")
|
self._set_status("已取消检查本轮更新" if dry_run else "已取消开始更新")
|
||||||
return
|
return
|
||||||
|
current_plan = product_status.build_apply_plan(
|
||||||
|
self._update_candidates(),
|
||||||
|
update_mode,
|
||||||
|
)
|
||||||
|
if current_plan["fingerprint"] != update_plan["fingerprint"]:
|
||||||
|
self._set_status("当前任务数据已变化,请重新开始更新")
|
||||||
|
return
|
||||||
|
executable_tasks = current_plan["executable"]
|
||||||
|
if not executable_tasks:
|
||||||
|
preflight_error = self._update_preflight_error(current_plan, update_mode)
|
||||||
|
self._set_status(preflight_error.replace("\n", " "))
|
||||||
|
return
|
||||||
batch_size = max(1, int(update_cfg.get("max_items_per_run", 1) or 1))
|
batch_size = max(1, int(update_cfg.get("max_items_per_run", 1) or 1))
|
||||||
worker = ApplyWorker(
|
worker = ApplyWorker(
|
||||||
tasks,
|
executable_tasks,
|
||||||
db_path=self.db_path,
|
db_path=self.db_path,
|
||||||
config=self.config,
|
config=self.config,
|
||||||
close_success_tab=bool(update_cfg.get("close_success_tab", False)),
|
|
||||||
dry_run=dry_run,
|
dry_run=dry_run,
|
||||||
parallel_accounts=bool(update_cfg.get("parallel_accounts", False)),
|
update_mode=update_mode,
|
||||||
max_parallel_accounts=max(
|
max_parallel_accounts=max(
|
||||||
1,
|
1,
|
||||||
int(update_cfg.get("max_parallel_accounts", 1) or 1),
|
int(update_cfg.get("max_parallel_accounts", 1) or 1),
|
||||||
),
|
),
|
||||||
batch_size=batch_size,
|
batch_size=batch_size,
|
||||||
diagnostic_log_dir=diagnostics.DEFAULT_LOG_DIR,
|
diagnostic_log_dir=diagnostics.DEFAULT_LOG_DIR,
|
||||||
|
product_status_counts=current_plan["status_counts"],
|
||||||
|
status_scope_excluded=current_plan["status_scope_excluded"],
|
||||||
|
content_scope_excluded=current_plan["content_scope_excluded"],
|
||||||
|
apply_plan_fingerprint=current_plan["fingerprint"],
|
||||||
)
|
)
|
||||||
worker.progress.connect(self._on_apply_progress)
|
worker.progress.connect(self._on_apply_progress)
|
||||||
worker.row_updated.connect(self._on_apply_row_updated)
|
worker.row_updated.connect(self._on_apply_row_updated)
|
||||||
@@ -275,11 +363,19 @@ class ApplyTab(QWidget):
|
|||||||
self.apply_worker = worker
|
self.apply_worker = worker
|
||||||
self.apply_thread = thread
|
self.apply_thread = thread
|
||||||
self._set_apply_running(True)
|
self._set_apply_running(True)
|
||||||
self._reset_run_log(tasks, dry_run=dry_run, batch_size=batch_size)
|
self._reset_run_log(
|
||||||
|
executable_tasks,
|
||||||
|
dry_run=dry_run,
|
||||||
|
batch_size=batch_size,
|
||||||
|
content_plan=current_plan,
|
||||||
|
)
|
||||||
|
skip_status = self._content_skip_status(current_plan)
|
||||||
if dry_run:
|
if dry_run:
|
||||||
self._set_status(f"开始检查本轮更新:{len(tasks)} 条")
|
self._set_status(f"开始检查本轮更新:{len(executable_tasks)} 条{skip_status}")
|
||||||
else:
|
else:
|
||||||
self._set_status(f"开始更新:{len(tasks)} 条,按每批最多 {batch_size} 条执行")
|
self._set_status(
|
||||||
|
f"开始更新:{len(executable_tasks)} 条,按每批最多 {batch_size} 条执行{skip_status}"
|
||||||
|
)
|
||||||
thread.start()
|
thread.start()
|
||||||
|
|
||||||
def stop_update(self, checked=False):
|
def stop_update(self, checked=False):
|
||||||
@@ -296,18 +392,144 @@ class ApplyTab(QWidget):
|
|||||||
|
|
||||||
def show_task_context_menu(self, position):
|
def show_task_context_menu(self, position):
|
||||||
index = self.task_table.indexAt(position)
|
index = self.task_table.indexAt(position)
|
||||||
if index.isValid():
|
if not index.isValid():
|
||||||
self.task_table.setCurrentIndex(index)
|
return
|
||||||
menu = QMenu(self)
|
self.task_table.selectRow(index.row())
|
||||||
|
self.task_table.setCurrentIndex(index)
|
||||||
|
task = self.model.task_at(index.row())
|
||||||
|
if task is None:
|
||||||
|
return
|
||||||
|
menu = QMenu(self.task_table)
|
||||||
reset_action = menu.addAction("重置更新状态")
|
reset_action = menu.addAction("重置更新状态")
|
||||||
reset_action.setEnabled(
|
reset_action.setEnabled(
|
||||||
self.apply_thread is None
|
self.apply_thread is None
|
||||||
and self.result_write_back_thread is None
|
and self.result_write_back_thread is None
|
||||||
and self._selected_task() is not None
|
and getattr(task, "status", "") != "running"
|
||||||
)
|
)
|
||||||
reset_action.triggered.connect(self.reset_apply_status)
|
reset_action.triggered.connect(self.reset_apply_status)
|
||||||
|
menu.addSeparator()
|
||||||
|
delete_action = menu.addAction("删除本条记录")
|
||||||
|
delete_action.setEnabled(
|
||||||
|
self.apply_thread is None
|
||||||
|
and self.result_write_back_thread is None
|
||||||
|
and getattr(task, "status", "") != "running"
|
||||||
|
)
|
||||||
|
delete_action.triggered.connect(self.delete_selected_task)
|
||||||
menu.exec(self.task_table.viewport().mapToGlobal(position))
|
menu.exec(self.task_table.viewport().mapToGlobal(position))
|
||||||
|
|
||||||
|
def delete_selected_task(self, checked=False):
|
||||||
|
if self.apply_thread is not None or self.result_write_back_thread is not None:
|
||||||
|
self._set_status("更新或回写正在进行,不能删除记录", level="warning")
|
||||||
|
return
|
||||||
|
task = self._selected_task()
|
||||||
|
if task is None:
|
||||||
|
self._set_status("请选择要删除的记录", level="warning")
|
||||||
|
return
|
||||||
|
if getattr(task, "status", "") == "running":
|
||||||
|
self._set_status("任务正在处理,不能删除", level="warning")
|
||||||
|
return
|
||||||
|
batch = db.get_batch(task.batch_id, path=self.db_path)
|
||||||
|
batch_text = self._batch_label(batch) if batch is not None else task.batch_id
|
||||||
|
lines = [
|
||||||
|
"确定要删除本条本地记录吗?",
|
||||||
|
f"商品ID:{task.item_id}",
|
||||||
|
f"店铺:{self.model.account_name_for(task)}({task.alias})",
|
||||||
|
f"所属批次:{batch_text}",
|
||||||
|
]
|
||||||
|
if int(getattr(task, "committed", 0) or 0) == 1:
|
||||||
|
lines.extend(
|
||||||
|
[
|
||||||
|
"",
|
||||||
|
"该商品已提交过蝦皮。本次仅删除本地记录,不会回滚蝦皮线上商品。",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
lines.extend(
|
||||||
|
[
|
||||||
|
"",
|
||||||
|
"删除后,该记录不会出现在①、②、③的列表、筛选、采集、生成、更新或回写范围。",
|
||||||
|
"不会删除原 Excel、本地图片或账号 Chrome 数据。",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
answer = QMessageBox.question(
|
||||||
|
self,
|
||||||
|
"删除本条记录",
|
||||||
|
"\n".join(lines),
|
||||||
|
QMessageBox.Yes | QMessageBox.No,
|
||||||
|
QMessageBox.No,
|
||||||
|
)
|
||||||
|
if answer != QMessageBox.Yes:
|
||||||
|
self._set_status("已取消删除本条记录")
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
result = db.delete_task(
|
||||||
|
task.id,
|
||||||
|
reason="用户在更新蝦皮页删除单条记录",
|
||||||
|
path=self.db_path,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
QMessageBox.warning(self, "删除本条记录", str(exc))
|
||||||
|
self._set_status(f"删除本条记录失败:{exc}", level="danger")
|
||||||
|
return
|
||||||
|
self.refresh_tasks()
|
||||||
|
if self.refresh_workflow_callback is not None:
|
||||||
|
self.refresh_workflow_callback()
|
||||||
|
message = f"已删除本地记录:商品 {result['item_id']}"
|
||||||
|
self._set_status(message, level="success")
|
||||||
|
QMessageBox.information(self, "删除本条记录", message)
|
||||||
|
|
||||||
|
def open_or_focus_selected_product(self, index):
|
||||||
|
if self.apply_thread is not None or self.result_write_back_thread is not None:
|
||||||
|
self._set_status("更新或回写正在进行,暂不能打开商品详情页", level="warning")
|
||||||
|
return
|
||||||
|
if self.product_tab_open_thread is not None:
|
||||||
|
self._set_status("正在打开商品详情页,请稍候", level="info")
|
||||||
|
return
|
||||||
|
if not index.isValid():
|
||||||
|
self._set_status("请选择要查看的商品记录", level="warning")
|
||||||
|
return
|
||||||
|
self.task_table.selectRow(index.row())
|
||||||
|
self.task_table.setCurrentIndex(index)
|
||||||
|
task = self.model.task_at(index.row())
|
||||||
|
if task is None:
|
||||||
|
self._set_status("未找到要查看的商品记录", level="warning")
|
||||||
|
return
|
||||||
|
worker = ProductTabOpenWorker(
|
||||||
|
task.alias,
|
||||||
|
task.item_id,
|
||||||
|
db_path=self.db_path,
|
||||||
|
config=self.config,
|
||||||
|
)
|
||||||
|
worker.finished.connect(self._on_product_tab_open_finished)
|
||||||
|
worker.failed.connect(self._on_product_tab_open_failed)
|
||||||
|
thread = run_worker(worker, thread_name="ProductTabOpenWorker", start=False)
|
||||||
|
thread.finished.connect(lambda: self._forget_product_tab_open_thread(thread))
|
||||||
|
self.product_tab_open_worker = worker
|
||||||
|
self.product_tab_open_thread = thread
|
||||||
|
self._set_status(f"正在打开商品 {task.item_id} 的详情页...", level="info")
|
||||||
|
thread.start()
|
||||||
|
|
||||||
|
def _on_product_tab_open_finished(self, payload):
|
||||||
|
if not payload.get("ok"):
|
||||||
|
self._set_status(
|
||||||
|
payload.get("message") or "打开商品详情页失败,请检查账号登录状态。",
|
||||||
|
level="warning",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
action = "已打开新页面" if payload.get("created") else "已聚焦现有页面"
|
||||||
|
account_name = payload.get("account_name") or payload.get("alias") or "账号"
|
||||||
|
self._set_status(
|
||||||
|
f"商品 {payload.get('item_id')}:{account_name}{action}",
|
||||||
|
level="success",
|
||||||
|
)
|
||||||
|
|
||||||
|
def _on_product_tab_open_failed(self, task_id, error):
|
||||||
|
self._set_status("打开商品详情页失败,请检查账号 Chrome 和登录状态。", level="danger")
|
||||||
|
|
||||||
|
def _forget_product_tab_open_thread(self, thread):
|
||||||
|
if self.product_tab_open_thread is thread:
|
||||||
|
self.product_tab_open_thread = None
|
||||||
|
self.product_tab_open_worker = None
|
||||||
|
|
||||||
def reset_apply_status(self, checked=False):
|
def reset_apply_status(self, checked=False):
|
||||||
if self.apply_thread is not None or self.result_write_back_thread is not None:
|
if self.apply_thread is not None or self.result_write_back_thread is not None:
|
||||||
self._set_status("更新或回写正在进行,不能重置")
|
self._set_status("更新或回写正在进行,不能重置")
|
||||||
@@ -368,13 +590,19 @@ class ApplyTab(QWidget):
|
|||||||
return self.model.task_at(0)
|
return self.model.task_at(0)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def _is_actionable_task(self, task):
|
def _is_update_candidate_task(self, task):
|
||||||
return (
|
return (
|
||||||
getattr(task, "stage", None) == "generated"
|
getattr(task, "stage", None) == "generated"
|
||||||
and getattr(task, "status", None) in {"success", "pending", "failed"}
|
and getattr(task, "status", None) in {"success", "pending", "failed"}
|
||||||
and bool(getattr(task, "new_title", None) or getattr(task, "new_cover_path", None))
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _update_candidates(self):
|
||||||
|
return [
|
||||||
|
task
|
||||||
|
for task in self.model.tasks
|
||||||
|
if self._is_update_candidate_task(task)
|
||||||
|
]
|
||||||
|
|
||||||
def _populate_batch_filter(self, batches, selected_batch):
|
def _populate_batch_filter(self, batches, selected_batch):
|
||||||
had_previous_items = self.batch_filter.count() > 0
|
had_previous_items = self.batch_filter.count() > 0
|
||||||
self.batch_filter.blockSignals(True)
|
self.batch_filter.blockSignals(True)
|
||||||
@@ -436,6 +664,9 @@ class ApplyTab(QWidget):
|
|||||||
def _item_filter_label(self):
|
def _item_filter_label(self):
|
||||||
return self.item_filter.text().strip() or "全部商品"
|
return self.item_filter.text().strip() or "全部商品"
|
||||||
|
|
||||||
|
def _product_status_filter_label(self):
|
||||||
|
return self.product_status_filter.currentText() or "全部商品状态"
|
||||||
|
|
||||||
def _is_update_task(self, task):
|
def _is_update_task(self, task):
|
||||||
if task.stage in {"generated", "applied"}:
|
if task.stage in {"generated", "applied"}:
|
||||||
return True
|
return True
|
||||||
@@ -449,6 +680,11 @@ class ApplyTab(QWidget):
|
|||||||
return True
|
return True
|
||||||
return item_query in str(getattr(task, "item_id", ""))
|
return item_query in str(getattr(task, "item_id", ""))
|
||||||
|
|
||||||
|
def _matches_product_status(self, task, selected_product_status):
|
||||||
|
if selected_product_status in (None, "all"):
|
||||||
|
return True
|
||||||
|
return product_status.raw_status(getattr(task, "product_status", None)) == selected_product_status
|
||||||
|
|
||||||
def _matches_status(self, task, selected_status):
|
def _matches_status(self, task, selected_status):
|
||||||
if selected_status in (None, "all"):
|
if selected_status in (None, "all"):
|
||||||
return True
|
return True
|
||||||
@@ -462,15 +698,20 @@ class ApplyTab(QWidget):
|
|||||||
return task.status == "skipped"
|
return task.status == "skipped"
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def _confirmation_message(self, tasks, dry_run=False):
|
def _confirmation_message(self, tasks, dry_run=False, content_plan=None):
|
||||||
update_cfg = self._shopee_update_config()
|
update_cfg = self._shopee_update_config()
|
||||||
cover_text = "允许" if update_cfg.get("allow_cover_update") else "不允许"
|
update_mode = self._current_update_mode()
|
||||||
close_text = "是" if update_cfg.get("close_success_tab") else "否"
|
update_mode_text = self._update_mode_label(update_mode)
|
||||||
|
cover_warning = "\n本轮将更新线上封面,请确认封面内容无误。" if appconfig.update_mode_includes_cover(update_mode) else ""
|
||||||
batch_size = max(1, int(update_cfg.get("max_items_per_run", 1) or 1))
|
batch_size = max(1, int(update_cfg.get("max_items_per_run", 1) or 1))
|
||||||
batch_count = (len(tasks) + batch_size - 1) // batch_size if tasks else 0
|
batch_count = (len(tasks) + batch_size - 1) // batch_size if tasks else 0
|
||||||
|
max_parallel_accounts = max(
|
||||||
|
1,
|
||||||
|
int(update_cfg.get("max_parallel_accounts", 1) or 1),
|
||||||
|
)
|
||||||
parallel_text = (
|
parallel_text = (
|
||||||
f"开启,最多 {update_cfg.get('max_parallel_accounts', 1)} 个账号"
|
f"最多 {max_parallel_accounts} 个账号"
|
||||||
if update_cfg.get("parallel_accounts")
|
if max_parallel_accounts > 1
|
||||||
else "关闭"
|
else "关闭"
|
||||||
)
|
)
|
||||||
intro = (
|
intro = (
|
||||||
@@ -478,19 +719,26 @@ class ApplyTab(QWidget):
|
|||||||
if dry_run
|
if dry_run
|
||||||
else "即将按当前筛选结果分批更新蝦皮线上商品。\n\n"
|
else "即将按当前筛选结果分批更新蝦皮线上商品。\n\n"
|
||||||
)
|
)
|
||||||
|
skipped_text = self._content_skip_text(content_plan)
|
||||||
|
skipped_block = f"\n{skipped_text}\n" if skipped_text else ""
|
||||||
return (
|
return (
|
||||||
intro
|
intro
|
||||||
+ f"批次:{self._batch_filter_label()}\n"
|
+ f"批次:{self._batch_filter_label()}\n"
|
||||||
+ f"店铺:{self._shop_filter_label()}\n"
|
+ f"店铺:{self._shop_filter_label()}\n"
|
||||||
+ f"商品ID:{self._item_filter_label()}\n"
|
+ f"商品ID:{self._item_filter_label()}\n"
|
||||||
+ f"状态:{self._status_label()}\n"
|
+ f"商品状态:{self._product_status_filter_label()}\n"
|
||||||
|
+ f"更新状态:{self._status_label()}\n"
|
||||||
|
+ f"更新内容:{update_mode_text}\n"
|
||||||
+ f"任务数:{len(tasks)}\n"
|
+ f"任务数:{len(tasks)}\n"
|
||||||
+ f"预计批次:{batch_count}\n\n"
|
+ f"预计批次:{batch_count}\n"
|
||||||
+ "安全设置:"
|
+ skipped_block
|
||||||
+ f"封面更新={cover_text},"
|
+ "\n"
|
||||||
|
+ "执行设置:"
|
||||||
+ f"每批最大更新条数={batch_size},"
|
+ f"每批最大更新条数={batch_size},"
|
||||||
+ f"成功后关闭新页={close_text},"
|
+ "成功后自动关闭程序新开编辑页,"
|
||||||
+ f"多账号并行={parallel_text}\n\n"
|
+ f"多账号并行={parallel_text}\n\n"
|
||||||
|
+ cover_warning
|
||||||
|
+ ("\n\n" if cover_warning else "")
|
||||||
+ (
|
+ (
|
||||||
"检查只写运行日志,不打开蝦皮、不点击「更新」、不改任务状态。"
|
"检查只写运行日志,不打开蝦皮、不点击「更新」、不改任务状态。"
|
||||||
if dry_run
|
if dry_run
|
||||||
@@ -498,49 +746,189 @@ class ApplyTab(QWidget):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
def _update_safety_error(self, tasks, dry_run=False):
|
|
||||||
update_cfg = self._shopee_update_config()
|
|
||||||
if dry_run:
|
|
||||||
return None
|
|
||||||
if not update_cfg.get("allow_real_submit", False):
|
|
||||||
return (
|
|
||||||
"设置未开启「允许真实提交线上商品」,已阻止本次更新。\n"
|
|
||||||
"请到⑤设置 > 蝦皮更新安全开启该开关后再开始更新。"
|
|
||||||
)
|
|
||||||
if not update_cfg.get("allow_cover_update", False):
|
|
||||||
cover_tasks = [
|
|
||||||
str(getattr(task, "item_id", ""))
|
|
||||||
for task in tasks
|
|
||||||
if getattr(task, "new_cover_path", None)
|
|
||||||
]
|
|
||||||
if cover_tasks:
|
|
||||||
return (
|
|
||||||
"设置未开启「允许更新封面」,当前任务包含新封面路径,已阻止本次更新。\n"
|
|
||||||
"请到⑤设置 > 蝦皮更新安全开启该开关,或先筛掉含新封面的任务。"
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
|
|
||||||
def _show_update_safety_error(self, message):
|
|
||||||
box = QMessageBox(self)
|
|
||||||
box.setIcon(QMessageBox.Warning)
|
|
||||||
box.setWindowTitle("更新安全开关")
|
|
||||||
box.setText(str(message))
|
|
||||||
settings_button = None
|
|
||||||
if self.open_settings_callback is not None:
|
|
||||||
settings_button = box.addButton("前往设置", QMessageBox.ActionRole)
|
|
||||||
box.addButton(QMessageBox.Ok)
|
|
||||||
box.exec()
|
|
||||||
if settings_button is not None and box.clickedButton() is settings_button:
|
|
||||||
self.open_settings_callback()
|
|
||||||
|
|
||||||
def _shopee_update_config(self):
|
def _shopee_update_config(self):
|
||||||
defaults = appconfig.default_config().get("shopee_update", {})
|
return appconfig.shopee_update_config(self.config)
|
||||||
loaded = self.config.get("shopee_update", {})
|
|
||||||
if not isinstance(loaded, dict):
|
def _current_update_mode(self):
|
||||||
loaded = {}
|
return appconfig.normalize_update_mode(
|
||||||
merged = dict(defaults)
|
self.update_mode_combo.currentData(),
|
||||||
merged.update(loaded)
|
allow_cover_update=False,
|
||||||
return merged
|
)
|
||||||
|
|
||||||
|
def _update_mode_label(self, mode):
|
||||||
|
mode = appconfig.normalize_update_mode(mode)
|
||||||
|
return {
|
||||||
|
"title": "只更新标题",
|
||||||
|
"cover": "只更新封面",
|
||||||
|
"title_cover": "更新标题和封面",
|
||||||
|
}.get(mode, "只更新标题")
|
||||||
|
|
||||||
|
def _set_combo_by_data(self, combo, value):
|
||||||
|
index = combo.findData(value)
|
||||||
|
combo.setCurrentIndex(index if index >= 0 else 0)
|
||||||
|
|
||||||
|
def _on_update_mode_changed(self, index=None):
|
||||||
|
previous = self._shopee_update_config().get("update_mode", "title")
|
||||||
|
if self._save_update_mode_setting(show_status=True):
|
||||||
|
return
|
||||||
|
self.update_mode_combo.blockSignals(True)
|
||||||
|
self._set_combo_by_data(self.update_mode_combo, previous)
|
||||||
|
self.update_mode_combo.blockSignals(False)
|
||||||
|
|
||||||
|
def _save_update_mode_setting(self, show_status=True):
|
||||||
|
update_mode = self._current_update_mode()
|
||||||
|
update_cfg = self._shopee_update_config()
|
||||||
|
update_cfg["update_mode"] = update_mode
|
||||||
|
payload = {
|
||||||
|
key: value
|
||||||
|
for key, value in self.config.items()
|
||||||
|
if key not in {"config_path", "ai_models_path", "cmhub_config_path", "data_dir"}
|
||||||
|
}
|
||||||
|
payload["shopee_update"] = update_cfg
|
||||||
|
try:
|
||||||
|
saved = appconfig.save_config(payload, path=self.config_path)
|
||||||
|
except Exception as exc:
|
||||||
|
self._set_status(f"更新内容设置保存失败:{exc}")
|
||||||
|
return False
|
||||||
|
internal = {
|
||||||
|
key: value
|
||||||
|
for key, value in self.config.items()
|
||||||
|
if key in {"config_path", "ai_models_path", "cmhub_config_path", "data_dir"}
|
||||||
|
}
|
||||||
|
self.config.clear()
|
||||||
|
self.config.update(saved)
|
||||||
|
self.config.update(internal)
|
||||||
|
if self.config_path != appconfig.CONFIG_PATH:
|
||||||
|
self.config["config_path"] = self.config_path
|
||||||
|
if show_status:
|
||||||
|
self._set_status(f"更新内容已设置为:{self._update_mode_label(update_mode)}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _partition_update_content_tasks(self, tasks, update_mode):
|
||||||
|
missing_title = [
|
||||||
|
task for task in tasks
|
||||||
|
if appconfig.update_mode_includes_title(update_mode)
|
||||||
|
and not str(getattr(task, "new_title", "") or "").strip()
|
||||||
|
]
|
||||||
|
missing_cover = [
|
||||||
|
task for task in tasks
|
||||||
|
if appconfig.update_mode_includes_cover(update_mode)
|
||||||
|
and not str(getattr(task, "new_cover_path", "") or "").strip()
|
||||||
|
]
|
||||||
|
skipped = {id(task) for task in missing_title + missing_cover}
|
||||||
|
return {
|
||||||
|
"executable": [task for task in tasks if id(task) not in skipped],
|
||||||
|
"missing_title": missing_title,
|
||||||
|
"missing_cover": missing_cover,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _update_preflight_error(self, update_plan, update_mode):
|
||||||
|
if update_plan["executable"]:
|
||||||
|
return None
|
||||||
|
lines = []
|
||||||
|
status_text = self._status_skip_text(update_plan)
|
||||||
|
if status_text:
|
||||||
|
lines.append("当前筛选结果没有状态正常且可更新的商品。")
|
||||||
|
lines.append(status_text)
|
||||||
|
lines.append("未上架、审核中或状态未知的商品为安全起见不会更新。")
|
||||||
|
content_error = self._update_content_error(update_plan, update_mode)
|
||||||
|
if content_error:
|
||||||
|
if lines:
|
||||||
|
lines.append("")
|
||||||
|
lines.append(content_error)
|
||||||
|
return "\n".join(lines) or "当前筛选结果没有可更新任务。"
|
||||||
|
|
||||||
|
def _update_content_error(self, tasks_or_plan, update_mode):
|
||||||
|
if isinstance(tasks_or_plan, dict):
|
||||||
|
content_plan = tasks_or_plan
|
||||||
|
else:
|
||||||
|
content_plan = self._partition_update_content_tasks(tasks_or_plan, update_mode)
|
||||||
|
if content_plan["executable"]:
|
||||||
|
return None
|
||||||
|
lines = []
|
||||||
|
mode_text = self._update_mode_label(update_mode)
|
||||||
|
missing_title = content_plan["missing_title"]
|
||||||
|
missing_cover = content_plan["missing_cover"]
|
||||||
|
if missing_title:
|
||||||
|
lines.append(f"当前筛选结果中有 {len(missing_title)} 条缺少新标题,不能执行“{mode_text}”。")
|
||||||
|
lines.append("请先回到②AI生成选择“只生成标题”或“生成标题和封面”。")
|
||||||
|
lines.append(f"示例商品ID:{self._sample_item_ids(missing_title)}")
|
||||||
|
if missing_cover:
|
||||||
|
if lines:
|
||||||
|
lines.append("")
|
||||||
|
lines.append(f"当前筛选结果中有 {len(missing_cover)} 条缺少新封面,不能执行“{mode_text}”。")
|
||||||
|
lines.append("请先回到②AI生成选择“只生成封面”或“生成标题和封面”。")
|
||||||
|
lines.append(f"示例商品ID:{self._sample_item_ids(missing_cover)}")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
def _content_skip_text(self, content_plan):
|
||||||
|
if not content_plan:
|
||||||
|
return ""
|
||||||
|
status_text = self._status_skip_text(content_plan)
|
||||||
|
missing_title = content_plan.get("missing_title", [])
|
||||||
|
missing_cover = content_plan.get("missing_cover", [])
|
||||||
|
skipped_count = len({id(task) for task in missing_title + missing_cover})
|
||||||
|
lines = [status_text] if status_text else []
|
||||||
|
if skipped_count:
|
||||||
|
lines.append(f"本轮将跳过 {skipped_count} 条缺少所选更新内容的记录:")
|
||||||
|
if missing_title:
|
||||||
|
lines.append(
|
||||||
|
f"缺少新标题:{len(missing_title)} 条(示例商品ID:{self._sample_item_ids(missing_title)})"
|
||||||
|
)
|
||||||
|
if missing_cover:
|
||||||
|
lines.append(
|
||||||
|
f"缺少新封面:{len(missing_cover)} 条(示例商品ID:{self._sample_item_ids(missing_cover)})"
|
||||||
|
)
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
def _content_skip_status(self, content_plan):
|
||||||
|
if not content_plan:
|
||||||
|
return ""
|
||||||
|
status_skipped = int(content_plan.get("status_scope_excluded", 0) or 0)
|
||||||
|
missing_title = content_plan.get("missing_title", [])
|
||||||
|
missing_cover = content_plan.get("missing_cover", [])
|
||||||
|
skipped_count = len({id(task) for task in missing_title + missing_cover})
|
||||||
|
if not skipped_count and not status_skipped:
|
||||||
|
return ""
|
||||||
|
parts = []
|
||||||
|
if status_skipped:
|
||||||
|
parts.append(f"状态异常 {status_skipped}")
|
||||||
|
if skipped_count:
|
||||||
|
parts.append(
|
||||||
|
f"缺标题 {len(missing_title)} / 缺封面 {len(missing_cover)}"
|
||||||
|
)
|
||||||
|
return ",跳过 {total} 条({details})".format(
|
||||||
|
total=status_skipped + skipped_count,
|
||||||
|
details=";".join(parts),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _status_skip_text(self, update_plan):
|
||||||
|
if not update_plan:
|
||||||
|
return ""
|
||||||
|
rows = [
|
||||||
|
("未上架", update_plan.get("unlisted", [])),
|
||||||
|
("审核中", update_plan.get("reviewing", [])),
|
||||||
|
("状态未知", update_plan.get("unknown", [])),
|
||||||
|
]
|
||||||
|
skipped = sum(len(tasks) for _label, tasks in rows)
|
||||||
|
if not skipped:
|
||||||
|
return ""
|
||||||
|
lines = [f"本轮将排除 {skipped} 条商品状态异常记录:"]
|
||||||
|
for label, tasks in rows:
|
||||||
|
if tasks:
|
||||||
|
lines.append(
|
||||||
|
f"{label}:{len(tasks)} 条(示例商品ID:{self._sample_item_ids(tasks)})"
|
||||||
|
)
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
def _sample_item_ids(self, tasks):
|
||||||
|
values = [
|
||||||
|
str(getattr(task, "item_id", "") or "").strip()
|
||||||
|
for task in tasks[:5]
|
||||||
|
]
|
||||||
|
values = [value for value in values if value]
|
||||||
|
suffix = f" 等 {len(tasks)} 条" if len(tasks) > 5 else ""
|
||||||
|
return "、".join(values) + suffix if values else "无"
|
||||||
|
|
||||||
def _set_apply_running(self, running):
|
def _set_apply_running(self, running):
|
||||||
self.preview_update_button.setEnabled(not running)
|
self.preview_update_button.setEnabled(not running)
|
||||||
@@ -551,7 +939,9 @@ class ApplyTab(QWidget):
|
|||||||
self.batch_filter.setEnabled(not running)
|
self.batch_filter.setEnabled(not running)
|
||||||
self.shop_filter.setEnabled(not running)
|
self.shop_filter.setEnabled(not running)
|
||||||
self.item_filter.setEnabled(not running)
|
self.item_filter.setEnabled(not running)
|
||||||
|
self.product_status_filter.setEnabled(not running)
|
||||||
self.status_filter.setEnabled(not running)
|
self.status_filter.setEnabled(not running)
|
||||||
|
self.update_mode_combo.setEnabled(not running)
|
||||||
self._update_write_back_button()
|
self._update_write_back_button()
|
||||||
|
|
||||||
def _set_result_write_back_running(self, running):
|
def _set_result_write_back_running(self, running):
|
||||||
@@ -562,7 +952,9 @@ class ApplyTab(QWidget):
|
|||||||
self.batch_filter.setEnabled(not running)
|
self.batch_filter.setEnabled(not running)
|
||||||
self.shop_filter.setEnabled(not running)
|
self.shop_filter.setEnabled(not running)
|
||||||
self.item_filter.setEnabled(not running)
|
self.item_filter.setEnabled(not running)
|
||||||
|
self.product_status_filter.setEnabled(not running)
|
||||||
self.status_filter.setEnabled(not running)
|
self.status_filter.setEnabled(not running)
|
||||||
|
self.update_mode_combo.setEnabled(not running)
|
||||||
self.write_back_button.setEnabled(False if running else bool(self._active_batch_ids()))
|
self.write_back_button.setEnabled(False if running else bool(self._active_batch_ids()))
|
||||||
|
|
||||||
def _forget_apply_thread(self, thread):
|
def _forget_apply_thread(self, thread):
|
||||||
@@ -634,12 +1026,23 @@ class ApplyTab(QWidget):
|
|||||||
def _show_current_run_log_empty(self):
|
def _show_current_run_log_empty(self):
|
||||||
self.run_log_view.setPlainText("本轮日志会在开始运行后显示")
|
self.run_log_view.setPlainText("本轮日志会在开始运行后显示")
|
||||||
|
|
||||||
def _reset_run_log(self, tasks, dry_run=False, batch_size=1):
|
def _reset_run_log(self, tasks, dry_run=False, batch_size=1, content_plan=None):
|
||||||
self.run_log_view.clear()
|
self.run_log_view.clear()
|
||||||
action = "检查" if dry_run else "更新"
|
action = "检查" if dry_run else "更新"
|
||||||
|
mode_text = self._update_mode_label(self._current_update_mode())
|
||||||
self._append_run_log(
|
self._append_run_log(
|
||||||
f"本轮{action}开始:任务 {len(tasks)} 条,每批 {batch_size} 条"
|
f"本轮{action}开始:任务 {len(tasks)} 条,更新内容:{mode_text},每批 {batch_size} 条"
|
||||||
)
|
)
|
||||||
|
skip_status = self._content_skip_status(content_plan)
|
||||||
|
if skip_status:
|
||||||
|
excluded_label = (
|
||||||
|
"预检排除记录"
|
||||||
|
if content_plan and content_plan.get("status_scope_excluded")
|
||||||
|
else "缺失记录"
|
||||||
|
)
|
||||||
|
self._append_run_log(
|
||||||
|
f"本轮预检:可执行 {len(tasks)} 条{skip_status};{excluded_label}未进入本轮执行。"
|
||||||
|
)
|
||||||
|
|
||||||
def _load_latest_run_log(self):
|
def _load_latest_run_log(self):
|
||||||
try:
|
try:
|
||||||
@@ -688,7 +1091,7 @@ class ApplyTab(QWidget):
|
|||||||
full_message = (
|
full_message = (
|
||||||
f"{message}\n\n"
|
f"{message}\n\n"
|
||||||
"本轮更新已中止,不会自动打开账号 Chrome,也不会提交任何商品。\n"
|
"本轮更新已中止,不会自动打开账号 Chrome,也不会提交任何商品。\n"
|
||||||
"请先到「④ 账号管理」配置账号、启动对应账号 Chrome,并确认已人工登录蝦皮。"
|
"请先到「账号管理」配置账号、启动对应账号 Chrome,并确认已人工登录蝦皮。"
|
||||||
)
|
)
|
||||||
QMessageBox.warning(self, "账号未就绪", full_message)
|
QMessageBox.warning(self, "账号未就绪", full_message)
|
||||||
self._set_status(full_message.replace("\n", " "))
|
self._set_status(full_message.replace("\n", " "))
|
||||||
|
|||||||
+427
-7
@@ -2,6 +2,13 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from ...collect_skip import (
|
||||||
|
ALIAS_UNMATCHED,
|
||||||
|
LOGIN_REQUIRED,
|
||||||
|
format_skip_reason_summary,
|
||||||
|
normalize_skip_reason_counts,
|
||||||
|
)
|
||||||
|
from ... import product_status
|
||||||
from ..models import TaskTableModel
|
from ..models import TaskTableModel
|
||||||
from ..widgets import *
|
from ..widgets import *
|
||||||
from ..workers import CollectWorker as _RealCollectWorker, WriteBackWorker as _RealWriteBackWorker
|
from ..workers import CollectWorker as _RealCollectWorker, WriteBackWorker as _RealWriteBackWorker
|
||||||
@@ -14,6 +21,36 @@ def CollectWorker(*args, **kwargs):
|
|||||||
def WriteBackWorker(*args, **kwargs):
|
def WriteBackWorker(*args, **kwargs):
|
||||||
return _call_package_attr("WriteBackWorker", _RealWriteBackWorker, *args, **kwargs)
|
return _call_package_attr("WriteBackWorker", _RealWriteBackWorker, *args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
COLLECT_ACTIVITY_STEP_LABELS = {
|
||||||
|
"preflight": "检查账号",
|
||||||
|
"match_account": "匹配账号",
|
||||||
|
"check_login": "检查账号登录",
|
||||||
|
"prepare_task": "准备采集",
|
||||||
|
"open_product": "打开商品页",
|
||||||
|
"wait_ready": "等待商品页加载",
|
||||||
|
"read_product_status": "读取商品状态",
|
||||||
|
"read_title": "读取标题",
|
||||||
|
"read_cover": "读取封面",
|
||||||
|
"download_cover": "下载封面",
|
||||||
|
"save_result": "保存采集结果",
|
||||||
|
"db_write": "保存采集结果",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _collect_activity_step_label(step):
|
||||||
|
return COLLECT_ACTIVITY_STEP_LABELS.get(str(step or ""), "处理当前商品")
|
||||||
|
|
||||||
|
|
||||||
|
def _format_collect_elapsed(seconds):
|
||||||
|
total = max(0, int(seconds or 0))
|
||||||
|
hours, remainder = divmod(total, 3600)
|
||||||
|
minutes, seconds = divmod(remainder, 60)
|
||||||
|
if hours:
|
||||||
|
return f"{hours}:{minutes:02d}:{seconds:02d}"
|
||||||
|
return f"{minutes:02d}:{seconds:02d}"
|
||||||
|
|
||||||
|
|
||||||
class CollectTab(QWidget):
|
class CollectTab(QWidget):
|
||||||
"""Tab 1: import Excel files and list imported tasks."""
|
"""Tab 1: import Excel files and list imported tasks."""
|
||||||
|
|
||||||
@@ -50,6 +87,15 @@ class CollectTab(QWidget):
|
|||||||
self.write_back_worker = None
|
self.write_back_worker = None
|
||||||
self.write_back_thread = None
|
self.write_back_thread = None
|
||||||
self.last_collect_run_id = None
|
self.last_collect_run_id = None
|
||||||
|
self._collect_run_started_at = None
|
||||||
|
self._collect_task_started_at = None
|
||||||
|
self._collect_task_elapsed_seconds = 0
|
||||||
|
self._collect_activity_payload = {}
|
||||||
|
self._collect_terminal_text = ""
|
||||||
|
self._collect_stop_requested = False
|
||||||
|
self._collect_elapsed_timer = QTimer(self)
|
||||||
|
self._collect_elapsed_timer.setInterval(1000)
|
||||||
|
self._collect_elapsed_timer.timeout.connect(self._refresh_collect_activity)
|
||||||
|
|
||||||
self.import_button = QPushButton("导入 Excel...")
|
self.import_button = QPushButton("导入 Excel...")
|
||||||
self.refresh_button = QPushButton("刷新")
|
self.refresh_button = QPushButton("刷新")
|
||||||
@@ -68,6 +114,10 @@ class CollectTab(QWidget):
|
|||||||
self.status_filter.setObjectName("collectStatusFilter")
|
self.status_filter.setObjectName("collectStatusFilter")
|
||||||
for label, value in self.STATUS_FILTERS:
|
for label, value in self.STATUS_FILTERS:
|
||||||
self.status_filter.addItem(label, value)
|
self.status_filter.addItem(label, value)
|
||||||
|
self.product_status_filter = QComboBox()
|
||||||
|
self.product_status_filter.setObjectName("collectProductStatusFilter")
|
||||||
|
for label, value in product_status.PRODUCT_STATUS_FILTER_ITEMS:
|
||||||
|
self.product_status_filter.addItem(label, value)
|
||||||
self.delete_batch_button = QPushButton("删除批次")
|
self.delete_batch_button = QPushButton("删除批次")
|
||||||
self.delete_batch_button.setObjectName("deleteBatchButton")
|
self.delete_batch_button.setObjectName("deleteBatchButton")
|
||||||
self.delete_batch_button.setStyleSheet(_danger_outline_button_style("deleteBatchButton"))
|
self.delete_batch_button.setStyleSheet(_danger_outline_button_style("deleteBatchButton"))
|
||||||
@@ -88,13 +138,23 @@ class CollectTab(QWidget):
|
|||||||
filter_layout.addWidget(self.shop_filter, 1)
|
filter_layout.addWidget(self.shop_filter, 1)
|
||||||
filter_layout.addWidget(QLabel("商品ID"))
|
filter_layout.addWidget(QLabel("商品ID"))
|
||||||
filter_layout.addWidget(self.item_filter, 1)
|
filter_layout.addWidget(self.item_filter, 1)
|
||||||
filter_layout.addWidget(QLabel("状态"))
|
filter_layout.addWidget(QLabel("商品状态"))
|
||||||
|
filter_layout.addWidget(self.product_status_filter, 1)
|
||||||
|
filter_layout.addWidget(QLabel("处理状态"))
|
||||||
filter_layout.addWidget(self.status_filter, 1)
|
filter_layout.addWidget(self.status_filter, 1)
|
||||||
filter_layout.addWidget(self.delete_batch_button)
|
filter_layout.addWidget(self.delete_batch_button)
|
||||||
|
|
||||||
self.summary_label = QLabel("未导入任务")
|
self.summary_label = QLabel("未导入任务")
|
||||||
self.summary_label.setTextFormat(Qt.RichText)
|
self.summary_label.setTextFormat(Qt.RichText)
|
||||||
self.batch_progress_label = _build_batch_progress_overview("collectBatchProgressOverview")
|
self.batch_progress_label = _build_batch_progress_overview("collectBatchProgressOverview")
|
||||||
|
self.collect_activity_label = QLabel("")
|
||||||
|
self.collect_activity_label.setObjectName("collectActivityLabel")
|
||||||
|
self.collect_activity_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
|
||||||
|
activity_sample = "正在采集 999/999 · 等待商品页加载 · 本条 99:59"
|
||||||
|
activity_width = self.collect_activity_label.fontMetrics().horizontalAdvance(activity_sample) + 24
|
||||||
|
self.collect_activity_label.setFixedWidth(activity_width)
|
||||||
|
self.collect_activity_label.setVisible(False)
|
||||||
|
self._set_collect_activity_style("muted")
|
||||||
self.match_detail_label = QLabel("")
|
self.match_detail_label = QLabel("")
|
||||||
self.show_all_button = QPushButton("全部")
|
self.show_all_button = QPushButton("全部")
|
||||||
self.show_unmatched_button = QPushButton("未匹配(0)")
|
self.show_unmatched_button = QPushButton("未匹配(0)")
|
||||||
@@ -106,13 +166,22 @@ class CollectTab(QWidget):
|
|||||||
summary_layout.addWidget(self.show_all_button)
|
summary_layout.addWidget(self.show_all_button)
|
||||||
summary_layout.addWidget(self.show_unmatched_button)
|
summary_layout.addWidget(self.show_unmatched_button)
|
||||||
|
|
||||||
|
batch_progress_layout = QHBoxLayout()
|
||||||
|
batch_progress_layout.setContentsMargins(0, 0, 0, 0)
|
||||||
|
batch_progress_layout.setSpacing(8)
|
||||||
|
batch_progress_layout.addWidget(self.batch_progress_label, 1)
|
||||||
|
batch_progress_layout.addWidget(self.collect_activity_label)
|
||||||
|
|
||||||
self.table = QTableView()
|
self.table = QTableView()
|
||||||
self.model = TaskTableModel(self.table)
|
self.model = TaskTableModel(self.table)
|
||||||
self.table.setModel(self.model)
|
self.table.setModel(self.model)
|
||||||
self.table.setSelectionBehavior(QAbstractItemView.SelectRows)
|
self.table.setSelectionBehavior(QAbstractItemView.SelectRows)
|
||||||
self.table.setSelectionMode(QAbstractItemView.SingleSelection)
|
self.table.setSelectionMode(QAbstractItemView.SingleSelection)
|
||||||
self.table.setEditTriggers(QAbstractItemView.NoEditTriggers)
|
self.table.setEditTriggers(QAbstractItemView.NoEditTriggers)
|
||||||
|
self.table.setContextMenuPolicy(Qt.CustomContextMenu)
|
||||||
self.table.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch)
|
self.table.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch)
|
||||||
|
for column in (3, 4):
|
||||||
|
self.table.horizontalHeader().setSectionResizeMode(column, QHeaderView.ResizeToContents)
|
||||||
self.table.verticalHeader().setVisible(False)
|
self.table.verticalHeader().setVisible(False)
|
||||||
|
|
||||||
self.run_log_view = QPlainTextEdit()
|
self.run_log_view = QPlainTextEdit()
|
||||||
@@ -136,7 +205,7 @@ class CollectTab(QWidget):
|
|||||||
layout.addLayout(filter_layout)
|
layout.addLayout(filter_layout)
|
||||||
layout.addLayout(summary_layout)
|
layout.addLayout(summary_layout)
|
||||||
layout.addWidget(self.match_detail_label)
|
layout.addWidget(self.match_detail_label)
|
||||||
layout.addWidget(self.batch_progress_label)
|
layout.addLayout(batch_progress_layout)
|
||||||
layout.addWidget(self.empty_state_card)
|
layout.addWidget(self.empty_state_card)
|
||||||
layout.addWidget(self.table, 1)
|
layout.addWidget(self.table, 1)
|
||||||
layout.addWidget(QLabel("采集运行日志"))
|
layout.addWidget(QLabel("采集运行日志"))
|
||||||
@@ -148,8 +217,10 @@ class CollectTab(QWidget):
|
|||||||
self.batch_filter.currentIndexChanged.connect(self.refresh_tasks)
|
self.batch_filter.currentIndexChanged.connect(self.refresh_tasks)
|
||||||
self.shop_filter.currentIndexChanged.connect(self.refresh_tasks)
|
self.shop_filter.currentIndexChanged.connect(self.refresh_tasks)
|
||||||
self.item_filter.textChanged.connect(self.refresh_tasks)
|
self.item_filter.textChanged.connect(self.refresh_tasks)
|
||||||
|
self.product_status_filter.currentIndexChanged.connect(self.refresh_tasks)
|
||||||
self.status_filter.currentIndexChanged.connect(self.refresh_tasks)
|
self.status_filter.currentIndexChanged.connect(self.refresh_tasks)
|
||||||
self.delete_batch_button.clicked.connect(self.delete_current_batch)
|
self.delete_batch_button.clicked.connect(self.delete_current_batch)
|
||||||
|
self.table.customContextMenuRequested.connect(self._show_task_context_menu)
|
||||||
self.collect_button.clicked.connect(self.collect_old_data)
|
self.collect_button.clicked.connect(self.collect_old_data)
|
||||||
self.stop_collect_button.clicked.connect(self.stop_collect)
|
self.stop_collect_button.clicked.connect(self.stop_collect)
|
||||||
self.write_back_button.clicked.connect(self.write_back_old_data)
|
self.write_back_button.clicked.connect(self.write_back_old_data)
|
||||||
@@ -162,6 +233,156 @@ class CollectTab(QWidget):
|
|||||||
def _set_status(self, message, level=None):
|
def _set_status(self, message, level=None):
|
||||||
_emit_status(self.status_callback, message, level=level)
|
_emit_status(self.status_callback, message, level=level)
|
||||||
|
|
||||||
|
def _set_collect_activity_style(self, level):
|
||||||
|
color = _status_level_color(level)
|
||||||
|
self.collect_activity_label.setStyleSheet(
|
||||||
|
"QLabel#collectActivityLabel { "
|
||||||
|
"background: #f6f8fa; border: 1px solid #d0d7de; "
|
||||||
|
"border-radius: 6px; padding: 8px 10px; "
|
||||||
|
f"color: {color}; font-weight: 600; "
|
||||||
|
"}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _start_collect_activity(self):
|
||||||
|
now = time.monotonic()
|
||||||
|
self._collect_run_started_at = now
|
||||||
|
self._collect_task_started_at = None
|
||||||
|
self._collect_task_elapsed_seconds = 0
|
||||||
|
self._collect_activity_payload = {
|
||||||
|
"state": "preflight_started",
|
||||||
|
"total": 0,
|
||||||
|
"step": "preflight",
|
||||||
|
}
|
||||||
|
self._collect_terminal_text = ""
|
||||||
|
self._collect_stop_requested = False
|
||||||
|
self._refresh_collect_activity(now=now)
|
||||||
|
if not self._collect_elapsed_timer.isActive():
|
||||||
|
self._collect_elapsed_timer.start()
|
||||||
|
|
||||||
|
def _on_collect_activity(self, payload):
|
||||||
|
event = dict(payload or {})
|
||||||
|
state = str(event.get("state") or "")
|
||||||
|
now = time.monotonic()
|
||||||
|
if self._collect_run_started_at is None:
|
||||||
|
self._collect_run_started_at = now
|
||||||
|
self._collect_terminal_text = ""
|
||||||
|
if not self._collect_elapsed_timer.isActive():
|
||||||
|
self._collect_elapsed_timer.start()
|
||||||
|
|
||||||
|
if state == "preflight_started":
|
||||||
|
self._collect_activity_payload = event
|
||||||
|
elif state == "task_started":
|
||||||
|
self._collect_task_started_at = now
|
||||||
|
self._collect_task_elapsed_seconds = 0
|
||||||
|
self._collect_activity_payload = event
|
||||||
|
elif state in {"task_step", "task_finished"}:
|
||||||
|
current = dict(self._collect_activity_payload)
|
||||||
|
current.update(event)
|
||||||
|
self._collect_activity_payload = current
|
||||||
|
if state == "task_finished" and self._collect_task_started_at is not None:
|
||||||
|
self._collect_task_elapsed_seconds = max(
|
||||||
|
0,
|
||||||
|
int(now - self._collect_task_started_at),
|
||||||
|
)
|
||||||
|
self._collect_task_started_at = None
|
||||||
|
self._refresh_collect_activity(now=now)
|
||||||
|
|
||||||
|
def _refresh_collect_activity(self, now=None):
|
||||||
|
if self._collect_terminal_text:
|
||||||
|
self.collect_activity_label.setVisible(True)
|
||||||
|
self.collect_activity_label.setText(self._collect_terminal_text)
|
||||||
|
return
|
||||||
|
if self._collect_run_started_at is None:
|
||||||
|
self.collect_activity_label.setVisible(False)
|
||||||
|
return
|
||||||
|
|
||||||
|
current_time = time.monotonic() if now is None else now
|
||||||
|
run_elapsed = max(0, int(current_time - self._collect_run_started_at))
|
||||||
|
if self._collect_task_started_at is not None:
|
||||||
|
task_elapsed = max(0, int(current_time - self._collect_task_started_at))
|
||||||
|
else:
|
||||||
|
task_elapsed = self._collect_task_elapsed_seconds
|
||||||
|
event = self._collect_activity_payload
|
||||||
|
state = str(event.get("state") or "")
|
||||||
|
index = int(event.get("index") or 0)
|
||||||
|
total = int(event.get("total") or 0)
|
||||||
|
progress = f"{index}/{total}" if index and total else str(index or total or "")
|
||||||
|
step_label = _collect_activity_step_label(event.get("step"))
|
||||||
|
|
||||||
|
if self._collect_stop_requested:
|
||||||
|
if index:
|
||||||
|
text = f"正在停止 · 本条 {_format_collect_elapsed(task_elapsed)}"
|
||||||
|
else:
|
||||||
|
text = f"正在停止 · 已等待 {_format_collect_elapsed(run_elapsed)}"
|
||||||
|
level = "warning"
|
||||||
|
elif state in {"task_started", "task_step"}:
|
||||||
|
text = (
|
||||||
|
f"正在采集 {progress} · {step_label} · "
|
||||||
|
f"本条 {_format_collect_elapsed(task_elapsed)}"
|
||||||
|
)
|
||||||
|
level = "info"
|
||||||
|
elif state == "task_finished":
|
||||||
|
result_label = {
|
||||||
|
"success": "已完成",
|
||||||
|
"failed": "失败",
|
||||||
|
"skipped": "略过",
|
||||||
|
}.get(str(event.get("result") or ""), "已结束")
|
||||||
|
text = (
|
||||||
|
f"第 {progress} 条 · {result_label} · "
|
||||||
|
f"本条 {_format_collect_elapsed(task_elapsed)}"
|
||||||
|
)
|
||||||
|
level = "danger" if event.get("result") == "failed" else "muted"
|
||||||
|
else:
|
||||||
|
text = f"正在检查账号 · {_format_collect_elapsed(run_elapsed)}"
|
||||||
|
level = "info"
|
||||||
|
|
||||||
|
tooltip_parts = []
|
||||||
|
if event.get("item_id"):
|
||||||
|
tooltip_parts.append(f"商品ID:{event.get('item_id')}")
|
||||||
|
if event.get("alias"):
|
||||||
|
tooltip_parts.append(f"账号别名:{event.get('alias')}")
|
||||||
|
if state in {"task_started", "task_step"}:
|
||||||
|
tooltip_parts.append(f"当前阶段:{step_label}")
|
||||||
|
self.collect_activity_label.setToolTip("\n".join(tooltip_parts))
|
||||||
|
self._set_collect_activity_style(level)
|
||||||
|
self.collect_activity_label.setText(text)
|
||||||
|
self.collect_activity_label.setVisible(True)
|
||||||
|
|
||||||
|
def _finish_collect_activity(self, outcome, payload=None):
|
||||||
|
now = time.monotonic()
|
||||||
|
if self._collect_run_started_at is None:
|
||||||
|
total_elapsed = 0
|
||||||
|
else:
|
||||||
|
total_elapsed = max(0, int(now - self._collect_run_started_at))
|
||||||
|
self._collect_elapsed_timer.stop()
|
||||||
|
self._collect_run_started_at = None
|
||||||
|
self._collect_task_started_at = None
|
||||||
|
self._collect_task_elapsed_seconds = 0
|
||||||
|
self._collect_activity_payload = {}
|
||||||
|
self._collect_stop_requested = False
|
||||||
|
|
||||||
|
if outcome == "blocked":
|
||||||
|
text = "采集未开始 · 检查未通过"
|
||||||
|
level = "warning"
|
||||||
|
tooltip = "采集前检查未通过,请按弹窗提示处理"
|
||||||
|
elif outcome == "cancelled":
|
||||||
|
text = f"采集已停止 · 总用时 {_format_collect_elapsed(total_elapsed)}"
|
||||||
|
level = "warning"
|
||||||
|
tooltip = "本轮采集已停止"
|
||||||
|
elif outcome == "error":
|
||||||
|
text = "采集已结束 · 请查看运行日志"
|
||||||
|
level = "danger"
|
||||||
|
tooltip = "采集异常结束,请查看下方采集运行日志"
|
||||||
|
else:
|
||||||
|
text = f"采集完成 · 总用时 {_format_collect_elapsed(total_elapsed)}"
|
||||||
|
level = "success"
|
||||||
|
tooltip = "本轮采集已经完成"
|
||||||
|
self._collect_terminal_text = text
|
||||||
|
self.collect_activity_label.setToolTip(tooltip)
|
||||||
|
self._set_collect_activity_style(level)
|
||||||
|
self.collect_activity_label.setText(text)
|
||||||
|
self.collect_activity_label.setVisible(True)
|
||||||
|
|
||||||
def _on_collect_log(self, message):
|
def _on_collect_log(self, message):
|
||||||
self._append_collect_log(message)
|
self._append_collect_log(message)
|
||||||
self._set_status(message)
|
self._set_status(message)
|
||||||
@@ -194,14 +415,15 @@ class CollectTab(QWidget):
|
|||||||
self._append_collect_log(safe_message)
|
self._append_collect_log(safe_message)
|
||||||
|
|
||||||
def _show_error(self, message):
|
def _show_error(self, message):
|
||||||
QMessageBox.warning(self, "导入采集", str(message))
|
text = str(message)
|
||||||
self._set_status(str(message))
|
self._set_status(text, level="danger")
|
||||||
|
QTimer.singleShot(0, lambda: QMessageBox.warning(self, "导入采集", text))
|
||||||
|
|
||||||
def _show_account_guide(self, message):
|
def _show_account_guide(self, message):
|
||||||
full_message = (
|
full_message = (
|
||||||
f"{message}\n\n"
|
f"{message}\n\n"
|
||||||
"本轮采集已中止,不会自动打开账号 Chrome。\n"
|
"本轮采集已中止。\n"
|
||||||
"请先到「④ 账号管理」配置账号、启动对应账号 Chrome,并确认已人工登录蝦皮。"
|
"请先到「账号管理」检查账号配置、Chrome 路径和登录状态。"
|
||||||
)
|
)
|
||||||
QMessageBox.warning(self, "账号未就绪", full_message)
|
QMessageBox.warning(self, "账号未就绪", full_message)
|
||||||
self._set_status(full_message.replace("\n", " "))
|
self._set_status(full_message.replace("\n", " "))
|
||||||
@@ -345,6 +567,7 @@ class CollectTab(QWidget):
|
|||||||
selected_batch = self.batch_filter.currentData()
|
selected_batch = self.batch_filter.currentData()
|
||||||
selected_shop = self.shop_filter.currentData()
|
selected_shop = self.shop_filter.currentData()
|
||||||
selected_status = self.status_filter.currentData() or "all"
|
selected_status = self.status_filter.currentData() or "all"
|
||||||
|
selected_product_status = self.product_status_filter.currentData() or "all"
|
||||||
item_query = self.item_filter.text().strip()
|
item_query = self.item_filter.text().strip()
|
||||||
if self.current_batch_id and self.batch_filter.findData(self.current_batch_id) < 0:
|
if self.current_batch_id and self.batch_filter.findData(self.current_batch_id) < 0:
|
||||||
selected_batch = self.current_batch_id
|
selected_batch = self.current_batch_id
|
||||||
@@ -359,6 +582,7 @@ class CollectTab(QWidget):
|
|||||||
task for task in task_rows
|
task for task in task_rows
|
||||||
if self._matches_shop(task, selected_shop)
|
if self._matches_shop(task, selected_shop)
|
||||||
and self._matches_item(task, item_query)
|
and self._matches_item(task, item_query)
|
||||||
|
and self._matches_product_status(task, selected_product_status)
|
||||||
and self._matches_status(task, selected_status, account_rows)
|
and self._matches_status(task, selected_status, account_rows)
|
||||||
]
|
]
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
@@ -427,6 +651,11 @@ class CollectTab(QWidget):
|
|||||||
return True
|
return True
|
||||||
return item_query in str(getattr(task, "item_id", ""))
|
return item_query in str(getattr(task, "item_id", ""))
|
||||||
|
|
||||||
|
def _matches_product_status(self, task, selected_product_status):
|
||||||
|
if selected_product_status in (None, "all"):
|
||||||
|
return True
|
||||||
|
return product_status.raw_status(getattr(task, "product_status", None)) == selected_product_status
|
||||||
|
|
||||||
def _matches_status(self, task, selected_status, account_rows):
|
def _matches_status(self, task, selected_status, account_rows):
|
||||||
if selected_status in (None, "all"):
|
if selected_status in (None, "all"):
|
||||||
return True
|
return True
|
||||||
@@ -455,6 +684,85 @@ class CollectTab(QWidget):
|
|||||||
running = bool(self.collect_thread or self.write_back_thread)
|
running = bool(self.collect_thread or self.write_back_thread)
|
||||||
self.delete_batch_button.setEnabled((not running) and bool(self._selected_batch_id()))
|
self.delete_batch_button.setEnabled((not running) and bool(self._selected_batch_id()))
|
||||||
|
|
||||||
|
def _show_task_context_menu(self, position):
|
||||||
|
index = self.table.indexAt(position)
|
||||||
|
if not index.isValid():
|
||||||
|
return
|
||||||
|
self.table.selectRow(index.row())
|
||||||
|
self.table.setCurrentIndex(index)
|
||||||
|
task = self.model.task_at(index.row())
|
||||||
|
if task is None:
|
||||||
|
return
|
||||||
|
menu = QMenu(self.table)
|
||||||
|
delete_action = menu.addAction("删除本条记录")
|
||||||
|
delete_action.setEnabled(
|
||||||
|
not bool(self.collect_thread or self.write_back_thread)
|
||||||
|
and getattr(task, "status", "") != "running"
|
||||||
|
)
|
||||||
|
delete_action.triggered.connect(self.delete_selected_task)
|
||||||
|
menu.exec(self.table.viewport().mapToGlobal(position))
|
||||||
|
|
||||||
|
def delete_selected_task(self, checked=False):
|
||||||
|
if self.collect_thread or self.write_back_thread:
|
||||||
|
self._set_status("采集或回写正在进行,不能删除记录", level="warning")
|
||||||
|
return
|
||||||
|
index = self.table.currentIndex()
|
||||||
|
task = self.model.task_at(index.row()) if index.isValid() else None
|
||||||
|
if task is None:
|
||||||
|
self._set_status("请选择要删除的记录", level="warning")
|
||||||
|
return
|
||||||
|
if getattr(task, "status", "") == "running":
|
||||||
|
self._set_status("任务正在处理,不能删除", level="warning")
|
||||||
|
return
|
||||||
|
batch = db.get_batch(task.batch_id, path=self.db_path)
|
||||||
|
batch_text = self._batch_label(batch) if batch is not None else task.batch_id
|
||||||
|
lines = [
|
||||||
|
"确定要删除本条本地记录吗?",
|
||||||
|
f"商品ID:{task.item_id}",
|
||||||
|
f"店铺:{task.account_name or task.alias}({task.alias})",
|
||||||
|
f"所属批次:{batch_text}",
|
||||||
|
]
|
||||||
|
if int(getattr(task, "committed", 0) or 0) == 1:
|
||||||
|
lines.extend(
|
||||||
|
[
|
||||||
|
"",
|
||||||
|
"该商品已提交过蝦皮。本次仅删除本地记录,不会回滚蝦皮线上商品。",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
lines.extend(
|
||||||
|
[
|
||||||
|
"",
|
||||||
|
"删除后,该记录不会出现在①、②、③的列表、筛选、采集、生成、更新或回写范围。",
|
||||||
|
"不会删除原 Excel、本地图片或账号 Chrome 数据。",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
answer = QMessageBox.question(
|
||||||
|
self,
|
||||||
|
"删除本条记录",
|
||||||
|
"\n".join(lines),
|
||||||
|
QMessageBox.Yes | QMessageBox.No,
|
||||||
|
QMessageBox.No,
|
||||||
|
)
|
||||||
|
if answer != QMessageBox.Yes:
|
||||||
|
self._set_status("已取消删除本条记录")
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
result = db.delete_task(
|
||||||
|
task.id,
|
||||||
|
reason="用户在导入采集页删除单条记录",
|
||||||
|
path=self.db_path,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
QMessageBox.warning(self, "删除本条记录", str(exc))
|
||||||
|
self._set_status(f"删除本条记录失败:{exc}", level="danger")
|
||||||
|
return
|
||||||
|
self.refresh_tasks()
|
||||||
|
if self.refresh_workflow_callback is not None:
|
||||||
|
self.refresh_workflow_callback()
|
||||||
|
message = f"已删除本地记录:商品 {result['item_id']}"
|
||||||
|
self._set_status(message, level="success")
|
||||||
|
QMessageBox.information(self, "删除本条记录", message)
|
||||||
|
|
||||||
def delete_current_batch(self, checked=False):
|
def delete_current_batch(self, checked=False):
|
||||||
batch_id = self._selected_batch_id()
|
batch_id = self._selected_batch_id()
|
||||||
if not batch_id:
|
if not batch_id:
|
||||||
@@ -509,12 +817,20 @@ class CollectTab(QWidget):
|
|||||||
if not tasks:
|
if not tasks:
|
||||||
self._set_status("没有可采集任务")
|
self._set_status("没有可采集任务")
|
||||||
return
|
return
|
||||||
|
collect_scope = self._choose_collect_scope()
|
||||||
|
if collect_scope is None:
|
||||||
|
self._set_status("已取消采集")
|
||||||
|
return
|
||||||
worker = CollectWorker(
|
worker = CollectWorker(
|
||||||
tasks,
|
tasks,
|
||||||
db_path=self.db_path,
|
db_path=self.db_path,
|
||||||
config=self.config,
|
config=self.config,
|
||||||
diagnostic_log_dir=diagnostics.DEFAULT_LOG_DIR,
|
diagnostic_log_dir=diagnostics.DEFAULT_LOG_DIR,
|
||||||
|
collect_scope=collect_scope,
|
||||||
)
|
)
|
||||||
|
activity_signal = getattr(worker, "activity", None)
|
||||||
|
if activity_signal is not None:
|
||||||
|
activity_signal.connect(self._on_collect_activity)
|
||||||
worker.progress.connect(self._on_collect_progress)
|
worker.progress.connect(self._on_collect_progress)
|
||||||
worker.row_updated.connect(self._on_collect_row_updated)
|
worker.row_updated.connect(self._on_collect_row_updated)
|
||||||
worker.log.connect(self._on_collect_log)
|
worker.log.connect(self._on_collect_log)
|
||||||
@@ -527,11 +843,36 @@ class CollectTab(QWidget):
|
|||||||
self.collect_worker = worker
|
self.collect_worker = worker
|
||||||
self.collect_thread = thread
|
self.collect_thread = thread
|
||||||
self._set_collect_running(True)
|
self._set_collect_running(True)
|
||||||
|
self._start_collect_activity()
|
||||||
thread.start()
|
thread.start()
|
||||||
|
|
||||||
|
def _choose_collect_scope(self):
|
||||||
|
box = ProductStatusScopeDialog(
|
||||||
|
title="选择采集范围",
|
||||||
|
text="请选择本轮要采集的商品范围。",
|
||||||
|
informative_text=(
|
||||||
|
"程序会逐个打开商品详情页检测状态并保存结果。"
|
||||||
|
"采集架上商品仅继续采集检测结果为正常的商品;"
|
||||||
|
"采集全部商品还包括未上架、审核中和状态未知商品,"
|
||||||
|
"可能增加采集时间,但本步骤不消耗 AI 点数。"
|
||||||
|
),
|
||||||
|
normal_text="采集架上商品",
|
||||||
|
all_text="采集全部商品",
|
||||||
|
normal_value=product_status.COLLECT_SCOPE_NORMAL_ONLY,
|
||||||
|
all_value=product_status.COLLECT_SCOPE_ALL,
|
||||||
|
normal_object_name="collectNormalOnlyButton",
|
||||||
|
all_object_name="collectAllStatusesButton",
|
||||||
|
cancel_object_name="collectScopeCancelButton",
|
||||||
|
parent=self,
|
||||||
|
)
|
||||||
|
box.exec()
|
||||||
|
return box.choice()
|
||||||
|
|
||||||
def stop_collect(self, checked=False):
|
def stop_collect(self, checked=False):
|
||||||
if self.collect_worker is not None:
|
if self.collect_worker is not None:
|
||||||
self.collect_worker.cancel()
|
self.collect_worker.cancel()
|
||||||
|
self._collect_stop_requested = True
|
||||||
|
self._refresh_collect_activity()
|
||||||
self._set_status("正在停止采集...")
|
self._set_status("正在停止采集...")
|
||||||
|
|
||||||
def write_back_old_data(self, checked=False):
|
def write_back_old_data(self, checked=False):
|
||||||
@@ -593,6 +934,7 @@ class CollectTab(QWidget):
|
|||||||
self.batch_filter.setEnabled(not running)
|
self.batch_filter.setEnabled(not running)
|
||||||
self.shop_filter.setEnabled(not running)
|
self.shop_filter.setEnabled(not running)
|
||||||
self.item_filter.setEnabled(not running)
|
self.item_filter.setEnabled(not running)
|
||||||
|
self.product_status_filter.setEnabled(not running)
|
||||||
self.status_filter.setEnabled(not running)
|
self.status_filter.setEnabled(not running)
|
||||||
self._update_delete_batch_button()
|
self._update_delete_batch_button()
|
||||||
|
|
||||||
@@ -604,11 +946,14 @@ class CollectTab(QWidget):
|
|||||||
self.batch_filter.setEnabled(not running)
|
self.batch_filter.setEnabled(not running)
|
||||||
self.shop_filter.setEnabled(not running)
|
self.shop_filter.setEnabled(not running)
|
||||||
self.item_filter.setEnabled(not running)
|
self.item_filter.setEnabled(not running)
|
||||||
|
self.product_status_filter.setEnabled(not running)
|
||||||
self.status_filter.setEnabled(not running)
|
self.status_filter.setEnabled(not running)
|
||||||
self._update_delete_batch_button()
|
self._update_delete_batch_button()
|
||||||
|
|
||||||
def _forget_collect_thread(self, thread):
|
def _forget_collect_thread(self, thread):
|
||||||
if self.collect_thread is thread:
|
if self.collect_thread is thread:
|
||||||
|
if self._collect_elapsed_timer.isActive():
|
||||||
|
self._finish_collect_activity("error")
|
||||||
self.collect_thread = None
|
self.collect_thread = None
|
||||||
self.collect_worker = None
|
self.collect_worker = None
|
||||||
|
|
||||||
@@ -636,6 +981,12 @@ class CollectTab(QWidget):
|
|||||||
|
|
||||||
def _on_collect_finished(self, payload):
|
def _on_collect_finished(self, payload):
|
||||||
self._set_collect_running(False)
|
self._set_collect_running(False)
|
||||||
|
if payload.get("blocked"):
|
||||||
|
self._finish_collect_activity("blocked", payload)
|
||||||
|
elif payload.get("error"):
|
||||||
|
self._finish_collect_activity("error", payload)
|
||||||
|
else:
|
||||||
|
self._finish_collect_activity("finished", payload)
|
||||||
self.last_collect_run_id = payload.get("run_id") or self.last_collect_run_id
|
self.last_collect_run_id = payload.get("run_id") or self.last_collect_run_id
|
||||||
self.refresh_tasks()
|
self.refresh_tasks()
|
||||||
self._load_latest_collect_run_log()
|
self._load_latest_collect_run_log()
|
||||||
@@ -647,6 +998,18 @@ class CollectTab(QWidget):
|
|||||||
skipped=payload.get("skipped", 0),
|
skipped=payload.get("skipped", 0),
|
||||||
failed=payload.get("failed", 0),
|
failed=payload.get("failed", 0),
|
||||||
)
|
)
|
||||||
|
status_counts = payload.get("product_status_counts") or {}
|
||||||
|
status_skipped = int(payload.get("status_scope_skipped", 0) or 0)
|
||||||
|
if status_counts:
|
||||||
|
message += ";状态正常{normal},未上架{unlisted},审核中{reviewing},状态未知{unknown}".format(
|
||||||
|
normal=status_counts.get("normal", 0),
|
||||||
|
unlisted=status_counts.get("unlisted", 0),
|
||||||
|
reviewing=status_counts.get("reviewing", 0),
|
||||||
|
unknown=status_counts.get("unknown", 0),
|
||||||
|
)
|
||||||
|
if status_skipped:
|
||||||
|
message += f";按范围略过{status_skipped}"
|
||||||
|
self._show_collect_account_summary(payload, message)
|
||||||
if payload.get("collected", 0) > 0:
|
if payload.get("collected", 0) > 0:
|
||||||
batch_id = self._active_batch_id()
|
batch_id = self._active_batch_id()
|
||||||
if batch_id and self._start_write_back(batch_id, auto=True):
|
if batch_id and self._start_write_back(batch_id, auto=True):
|
||||||
@@ -674,6 +1037,12 @@ class CollectTab(QWidget):
|
|||||||
"以下账号 Chrome 未启动或调试端口不可访问:"
|
"以下账号 Chrome 未启动或调试端口不可访问:"
|
||||||
+ "、".join(self._account_label(item) for item in not_running)
|
+ "、".join(self._account_label(item) for item in not_running)
|
||||||
)
|
)
|
||||||
|
launch_failed = payload.get("launch_failed") or []
|
||||||
|
if launch_failed:
|
||||||
|
lines.append(
|
||||||
|
"以下账号 Chrome 启动失败:"
|
||||||
|
+ "、".join(self._account_label(item) for item in launch_failed)
|
||||||
|
)
|
||||||
logged_out = payload.get("logged_out") or []
|
logged_out = payload.get("logged_out") or []
|
||||||
if logged_out:
|
if logged_out:
|
||||||
lines.append(
|
lines.append(
|
||||||
@@ -682,6 +1051,56 @@ class CollectTab(QWidget):
|
|||||||
)
|
)
|
||||||
self._show_account_guide("\n".join(lines))
|
self._show_account_guide("\n".join(lines))
|
||||||
|
|
||||||
|
def _show_collect_account_summary(self, payload, message):
|
||||||
|
launched = payload.get("launched_accounts") or []
|
||||||
|
reused = payload.get("reused_accounts") or []
|
||||||
|
login_required = payload.get("login_required_accounts") or []
|
||||||
|
skipped = max(0, int(payload.get("skipped", 0) or 0))
|
||||||
|
status_scope_skipped = int(payload.get("status_scope_skipped", 0) or 0)
|
||||||
|
account_skipped = max(0, skipped - status_scope_skipped)
|
||||||
|
skip_counts = normalize_skip_reason_counts(
|
||||||
|
payload.get("skip_reason_counts"),
|
||||||
|
skipped_total=account_skipped,
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
not launched
|
||||||
|
and not reused
|
||||||
|
and not login_required
|
||||||
|
and account_skipped == 0
|
||||||
|
and status_scope_skipped == 0
|
||||||
|
):
|
||||||
|
return
|
||||||
|
lines = [message]
|
||||||
|
skip_summary = format_skip_reason_summary(
|
||||||
|
skip_counts,
|
||||||
|
skipped_total=account_skipped,
|
||||||
|
)
|
||||||
|
if skip_summary:
|
||||||
|
lines.append(skip_summary)
|
||||||
|
if skip_counts[ALIAS_UNMATCHED] > 0:
|
||||||
|
lines.append("请检查 Excel 别名是否与账号管理中的账号别名一致。")
|
||||||
|
if launched:
|
||||||
|
lines.append(
|
||||||
|
"本轮已自动启动账号 Chrome:"
|
||||||
|
+ "、".join(self._account_label(item) for item in launched)
|
||||||
|
)
|
||||||
|
if login_required:
|
||||||
|
lines.append(
|
||||||
|
"以下账号需要补登录:"
|
||||||
|
+ "、".join(self._account_label(item) for item in login_required)
|
||||||
|
)
|
||||||
|
if skip_counts[LOGIN_REQUIRED] > 0:
|
||||||
|
lines.append("请到账号管理完成对应账号登录后,再重新采集略过任务。")
|
||||||
|
if status_scope_skipped:
|
||||||
|
lines.append(f"本轮按范围略过{status_scope_skipped}个非正常状态商品,未下载标题和封面。")
|
||||||
|
if launched or reused or login_required:
|
||||||
|
lines.append("采集结束后不会自动关闭账号 Chrome,请按需自行关闭。")
|
||||||
|
text = "\n".join(lines)
|
||||||
|
if login_required or skip_counts[LOGIN_REQUIRED] > 0:
|
||||||
|
QMessageBox.warning(self, "采集完成", text)
|
||||||
|
else:
|
||||||
|
QMessageBox.information(self, "采集完成", text)
|
||||||
|
|
||||||
def _account_label(self, item):
|
def _account_label(self, item):
|
||||||
if isinstance(item, dict):
|
if isinstance(item, dict):
|
||||||
name = item.get("account_name") or item.get("alias") or ""
|
name = item.get("account_name") or item.get("alias") or ""
|
||||||
@@ -696,6 +1115,7 @@ class CollectTab(QWidget):
|
|||||||
|
|
||||||
def _on_collect_cancelled(self, payload):
|
def _on_collect_cancelled(self, payload):
|
||||||
self._set_collect_running(False)
|
self._set_collect_running(False)
|
||||||
|
self._finish_collect_activity("cancelled", payload)
|
||||||
self.refresh_tasks()
|
self.refresh_tasks()
|
||||||
self._set_status(
|
self._set_status(
|
||||||
"采集已停止:完成{done}/{total}".format(
|
"采集已停止:完成{done}/{total}".format(
|
||||||
@@ -810,7 +1230,7 @@ class CollectTab(QWidget):
|
|||||||
self.empty_state_card,
|
self.empty_state_card,
|
||||||
self.empty_state_label,
|
self.empty_state_label,
|
||||||
self.empty_state_button,
|
self.empty_state_button,
|
||||||
"第一步:前往『④账号管理』配置并登录账号,再回到①导入 Excel。",
|
"第一步:前往『账号管理』配置并登录账号,再回到①导入 Excel。",
|
||||||
self.open_accounts_callback is not None,
|
self.open_accounts_callback is not None,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|||||||
+1554
-100
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+458
-139
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,248 @@
|
|||||||
|
"""启动阶段的强制升级进度窗口。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from PySide6.QtCore import QObject, QThread, Signal, Slot
|
||||||
|
from PySide6.QtWidgets import (
|
||||||
|
QDialog,
|
||||||
|
QHBoxLayout,
|
||||||
|
QLabel,
|
||||||
|
QProgressBar,
|
||||||
|
QPushButton,
|
||||||
|
QVBoxLayout,
|
||||||
|
)
|
||||||
|
|
||||||
|
from .. import appconfig, diagnostics, update_installer, updater_entry
|
||||||
|
|
||||||
|
|
||||||
|
def _format_bytes(value):
|
||||||
|
value = max(0, int(value or 0))
|
||||||
|
if value >= 1024 * 1024:
|
||||||
|
return "%.1f MB" % (value / (1024 * 1024))
|
||||||
|
if value >= 1024:
|
||||||
|
return "%.1f KB" % (value / 1024)
|
||||||
|
return "%d B" % value
|
||||||
|
|
||||||
|
|
||||||
|
class UpdatePreparationWorker(QObject):
|
||||||
|
stage_changed = Signal(str)
|
||||||
|
progress_changed = Signal(int, int)
|
||||||
|
succeeded = Signal(object)
|
||||||
|
failed = Signal(str)
|
||||||
|
finished = Signal()
|
||||||
|
|
||||||
|
def __init__(self, result, install_root, prepare=None):
|
||||||
|
super().__init__()
|
||||||
|
self.result = result
|
||||||
|
self.install_root = install_root
|
||||||
|
self.prepare = prepare or update_installer.prepare_update
|
||||||
|
self._cancelled = False
|
||||||
|
|
||||||
|
@Slot()
|
||||||
|
def run(self):
|
||||||
|
try:
|
||||||
|
metadata = update_installer.metadata_from_update_info(self.result)
|
||||||
|
staged = self.prepare(
|
||||||
|
metadata,
|
||||||
|
self.install_root,
|
||||||
|
cancelled=lambda: self._cancelled,
|
||||||
|
progress=self.progress_changed.emit,
|
||||||
|
stage_callback=self.stage_changed.emit,
|
||||||
|
)
|
||||||
|
if self._cancelled:
|
||||||
|
raise update_installer.UpdateCancelled("已取消下载新版")
|
||||||
|
self.succeeded.emit(staged)
|
||||||
|
except update_installer.UpdateCancelled:
|
||||||
|
self.failed.emit("已取消下载新版")
|
||||||
|
except update_installer.UpdateInstallError as exc:
|
||||||
|
self.failed.emit(str(exc))
|
||||||
|
except Exception as exc:
|
||||||
|
try:
|
||||||
|
diagnostics.write_diagnostic_log(
|
||||||
|
"准备自动更新失败",
|
||||||
|
level="ERROR",
|
||||||
|
step="startup_update_prepare",
|
||||||
|
exc=exc,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self.failed.emit("准备新版失败,请稍后重试")
|
||||||
|
finally:
|
||||||
|
self.finished.emit()
|
||||||
|
|
||||||
|
def cancel(self):
|
||||||
|
self._cancelled = True
|
||||||
|
|
||||||
|
|
||||||
|
class ForcedUpdateDialog(QDialog):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
result,
|
||||||
|
*,
|
||||||
|
parent=None,
|
||||||
|
install_root=None,
|
||||||
|
worker_factory=None,
|
||||||
|
updater_launcher=None,
|
||||||
|
):
|
||||||
|
super().__init__(parent)
|
||||||
|
self.result = result
|
||||||
|
self.install_root = Path(install_root or appconfig.app_base_dir()).resolve()
|
||||||
|
self.worker_factory = worker_factory or UpdatePreparationWorker
|
||||||
|
self.updater_launcher = updater_launcher or updater_entry.copy_and_launch_updater
|
||||||
|
self.thread = None
|
||||||
|
self.worker = None
|
||||||
|
self.exit_requested = False
|
||||||
|
self.update_started = False
|
||||||
|
self._build_ui()
|
||||||
|
self._validate_available_update()
|
||||||
|
|
||||||
|
def _build_ui(self):
|
||||||
|
self.setWindowTitle("必须升级")
|
||||||
|
self.setModal(True)
|
||||||
|
self.setMinimumWidth(460)
|
||||||
|
layout = QVBoxLayout(self)
|
||||||
|
title = QLabel("当前版本已不能继续使用,请先升级到新版。")
|
||||||
|
title.setWordWrap(True)
|
||||||
|
layout.addWidget(title)
|
||||||
|
online_version = self.result.latest_version or self.result.min_supported_version or "未知"
|
||||||
|
details = "当前版本:%s\n线上版本:%s" % (
|
||||||
|
self.result.current_version,
|
||||||
|
online_version,
|
||||||
|
)
|
||||||
|
if self.result.message:
|
||||||
|
details += "\n升级说明:%s" % self.result.message
|
||||||
|
self.details_label = QLabel(details)
|
||||||
|
self.details_label.setWordWrap(True)
|
||||||
|
layout.addWidget(self.details_label)
|
||||||
|
self.http_warning_label = QLabel("当前使用临时 HTTP 通道下载,安装前仍会校验完整性")
|
||||||
|
self.http_warning_label.setWordWrap(True)
|
||||||
|
self.http_warning_label.setStyleSheet("color: #b45309;")
|
||||||
|
self.http_warning_label.setHidden(
|
||||||
|
not update_installer.uses_plain_http(self.result.download_url)
|
||||||
|
)
|
||||||
|
layout.addWidget(self.http_warning_label)
|
||||||
|
self.stage_label = QLabel("发现必须升级的新版本")
|
||||||
|
layout.addWidget(self.stage_label)
|
||||||
|
self.progress_bar = QProgressBar()
|
||||||
|
self.progress_bar.setRange(0, 100)
|
||||||
|
self.progress_bar.setValue(0)
|
||||||
|
layout.addWidget(self.progress_bar)
|
||||||
|
self.bytes_label = QLabel("等待开始")
|
||||||
|
layout.addWidget(self.bytes_label)
|
||||||
|
buttons = QHBoxLayout()
|
||||||
|
buttons.addStretch(1)
|
||||||
|
self.action_button = QPushButton("立即升级")
|
||||||
|
self.exit_button = QPushButton("退出程序")
|
||||||
|
self.action_button.clicked.connect(self.start_update)
|
||||||
|
self.exit_button.clicked.connect(self.request_exit)
|
||||||
|
buttons.addWidget(self.action_button)
|
||||||
|
buttons.addWidget(self.exit_button)
|
||||||
|
layout.addLayout(buttons)
|
||||||
|
|
||||||
|
def _validate_available_update(self):
|
||||||
|
if self.result.automatic_update_error:
|
||||||
|
self.stage_label.setText(self.result.automatic_update_error)
|
||||||
|
self.action_button.setText("等待修复版本")
|
||||||
|
self.action_button.setEnabled(False)
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
update_installer.metadata_from_update_info(self.result)
|
||||||
|
except update_installer.UpdateInstallError as exc:
|
||||||
|
self._show_failure(str(exc))
|
||||||
|
|
||||||
|
@Slot()
|
||||||
|
def start_update(self):
|
||||||
|
if self.thread is not None:
|
||||||
|
return
|
||||||
|
self.exit_requested = False
|
||||||
|
self.action_button.setEnabled(False)
|
||||||
|
self.exit_button.setText("取消并退出")
|
||||||
|
self.stage_label.setText("正在准备新版")
|
||||||
|
self.worker = self.worker_factory(self.result, self.install_root)
|
||||||
|
self.thread = QThread(self)
|
||||||
|
self.worker.moveToThread(self.thread)
|
||||||
|
self.thread.started.connect(self.worker.run)
|
||||||
|
self.worker.stage_changed.connect(self.stage_label.setText)
|
||||||
|
self.worker.progress_changed.connect(self._update_progress)
|
||||||
|
self.worker.succeeded.connect(self._on_prepared)
|
||||||
|
self.worker.failed.connect(self._on_failed)
|
||||||
|
self.worker.finished.connect(self.thread.quit)
|
||||||
|
self.worker.finished.connect(self.worker.deleteLater)
|
||||||
|
self.thread.finished.connect(self._on_thread_finished)
|
||||||
|
self.thread.start()
|
||||||
|
|
||||||
|
@Slot(int, int)
|
||||||
|
def _update_progress(self, downloaded, total):
|
||||||
|
percent = int(downloaded * 100 / total) if total else 0
|
||||||
|
self.progress_bar.setValue(max(0, min(100, percent)))
|
||||||
|
self.bytes_label.setText("已下载 %s / %s" % (_format_bytes(downloaded), _format_bytes(total)))
|
||||||
|
|
||||||
|
@Slot(object)
|
||||||
|
def _on_prepared(self, staged):
|
||||||
|
self.stage_label.setText("新版准备完成,正在重启")
|
||||||
|
self.progress_bar.setValue(100)
|
||||||
|
try:
|
||||||
|
plan_path = updater_entry.create_plan(
|
||||||
|
self.install_root,
|
||||||
|
staged.staging_dir,
|
||||||
|
staged.version,
|
||||||
|
os.getpid(),
|
||||||
|
staged.sha256,
|
||||||
|
)
|
||||||
|
updater_path = self.install_root / "cmshopee-updater.exe"
|
||||||
|
self.updater_launcher(updater_path, plan_path)
|
||||||
|
self.update_started = True
|
||||||
|
except Exception as exc:
|
||||||
|
try:
|
||||||
|
diagnostics.write_diagnostic_log(
|
||||||
|
"启动独立更新器失败",
|
||||||
|
level="ERROR",
|
||||||
|
step="startup_update_launch",
|
||||||
|
exc=exc,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self._show_failure("启动更新程序失败,请重试或联系管理员")
|
||||||
|
|
||||||
|
@Slot(str)
|
||||||
|
def _on_failed(self, message):
|
||||||
|
if self.exit_requested:
|
||||||
|
self.reject()
|
||||||
|
return
|
||||||
|
self._show_failure(message)
|
||||||
|
|
||||||
|
@Slot()
|
||||||
|
def _on_thread_finished(self):
|
||||||
|
thread = self.thread
|
||||||
|
self.thread = None
|
||||||
|
self.worker = None
|
||||||
|
if thread is not None:
|
||||||
|
thread.deleteLater()
|
||||||
|
if self.update_started:
|
||||||
|
self.accept()
|
||||||
|
|
||||||
|
def _show_failure(self, message):
|
||||||
|
self.stage_label.setText("升级失败:%s" % message)
|
||||||
|
self.action_button.setText("重试")
|
||||||
|
self.action_button.setEnabled(True)
|
||||||
|
self.exit_button.setText("退出程序")
|
||||||
|
|
||||||
|
@Slot()
|
||||||
|
def request_exit(self):
|
||||||
|
if self.worker is not None:
|
||||||
|
self.exit_requested = True
|
||||||
|
self.action_button.setEnabled(False)
|
||||||
|
self.exit_button.setEnabled(False)
|
||||||
|
self.stage_label.setText("正在取消下载并清理临时文件")
|
||||||
|
self.worker.cancel()
|
||||||
|
return
|
||||||
|
self.reject()
|
||||||
|
|
||||||
|
def reject(self):
|
||||||
|
if self.worker is not None:
|
||||||
|
self.request_exit()
|
||||||
|
return
|
||||||
|
super().reject()
|
||||||
+128
-16
@@ -8,12 +8,15 @@ import threading
|
|||||||
import time
|
import time
|
||||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||||
|
|
||||||
|
from .. import assets
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from PySide6.QtCore import QAbstractTableModel, QModelIndex, Qt, QTimer
|
from PySide6.QtCore import QAbstractTableModel, QModelIndex, Qt, QTimer
|
||||||
from PySide6.QtGui import QColor, QIcon, QPainter, QPixmap
|
from PySide6.QtGui import QColor, QIcon, QImage, QPainter, QPixmap
|
||||||
from PySide6.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
QAbstractItemView,
|
QAbstractItemView,
|
||||||
QApplication,
|
QApplication,
|
||||||
|
QButtonGroup,
|
||||||
QCheckBox,
|
QCheckBox,
|
||||||
QComboBox,
|
QComboBox,
|
||||||
QDialog,
|
QDialog,
|
||||||
@@ -32,6 +35,7 @@ try:
|
|||||||
QPlainTextEdit,
|
QPlainTextEdit,
|
||||||
QProgressBar,
|
QProgressBar,
|
||||||
QPushButton,
|
QPushButton,
|
||||||
|
QRadioButton,
|
||||||
QScrollArea,
|
QScrollArea,
|
||||||
QSplitter,
|
QSplitter,
|
||||||
QTableView,
|
QTableView,
|
||||||
@@ -56,8 +60,9 @@ TAB_TITLES = [
|
|||||||
"① 导入采集",
|
"① 导入采集",
|
||||||
"② AI生成",
|
"② AI生成",
|
||||||
"③ 更新蝦皮",
|
"③ 更新蝦皮",
|
||||||
"④ 账号管理",
|
"账号管理",
|
||||||
"⑤ 设置",
|
"设置",
|
||||||
|
"商品套图",
|
||||||
]
|
]
|
||||||
|
|
||||||
TAB_STYLE = """
|
TAB_STYLE = """
|
||||||
@@ -133,6 +138,22 @@ QPushButton:disabled {{
|
|||||||
_RawQMessageBox = QMessageBox if QT_IMPORT_ERROR is None else None
|
_RawQMessageBox = QMessageBox if QT_IMPORT_ERROR is None else None
|
||||||
|
|
||||||
|
|
||||||
|
def app_icon():
|
||||||
|
"""Application icon, or None when Qt is unavailable or the file is missing.
|
||||||
|
|
||||||
|
Callers must tolerate None: the icon is a packaged asset, and a source
|
||||||
|
checkout that has not run scripts/gen_logo.py still has to start.
|
||||||
|
"""
|
||||||
|
|
||||||
|
if QT_IMPORT_ERROR is not None:
|
||||||
|
return None
|
||||||
|
path = assets.icon_path() or assets.icon_png_path()
|
||||||
|
if not path:
|
||||||
|
return None
|
||||||
|
icon = QIcon(path)
|
||||||
|
return None if icon.isNull() else icon
|
||||||
|
|
||||||
|
|
||||||
class _GuiAttributeProxy:
|
class _GuiAttributeProxy:
|
||||||
def __init__(self, name, fallback):
|
def __init__(self, name, fallback):
|
||||||
self._name = name
|
self._name = name
|
||||||
@@ -289,6 +310,110 @@ def _danger_outline_button_style(object_name):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _primary_button_style(object_name):
|
||||||
|
return (
|
||||||
|
f"QPushButton#{object_name} {{ "
|
||||||
|
f"background-color: {COLOR_INFO}; border-color: {COLOR_INFO}; "
|
||||||
|
"color: white; font-weight: 600; "
|
||||||
|
"}"
|
||||||
|
f"QPushButton#{object_name}:hover {{ "
|
||||||
|
"background-color: #0550ae; border-color: #0550ae; "
|
||||||
|
"}"
|
||||||
|
f"QPushButton#{object_name}:pressed {{ "
|
||||||
|
"background-color: #033d8b; border-color: #033d8b; "
|
||||||
|
"}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ProductStatusScopeDialog(QDialog):
|
||||||
|
"""供采集和生成复用的稳定中文范围选择框。"""
|
||||||
|
|
||||||
|
MINIMUM_WIDTH = 520
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
title,
|
||||||
|
text,
|
||||||
|
informative_text,
|
||||||
|
normal_text,
|
||||||
|
all_text,
|
||||||
|
normal_value,
|
||||||
|
all_value,
|
||||||
|
normal_object_name,
|
||||||
|
all_object_name,
|
||||||
|
cancel_object_name,
|
||||||
|
all_enabled=True,
|
||||||
|
parent=None,
|
||||||
|
):
|
||||||
|
super().__init__(parent)
|
||||||
|
self._choice = None
|
||||||
|
self._informative_text = str(informative_text or "")
|
||||||
|
self.setWindowTitle(str(title or "选择范围"))
|
||||||
|
self.setModal(True)
|
||||||
|
self.setMinimumWidth(self.MINIMUM_WIDTH)
|
||||||
|
self.setStyleSheet(BUTTON_BASE_STYLE)
|
||||||
|
|
||||||
|
layout = QVBoxLayout(self)
|
||||||
|
layout.setContentsMargins(20, 18, 20, 18)
|
||||||
|
layout.setSpacing(10)
|
||||||
|
|
||||||
|
title_label = QLabel(str(text or ""))
|
||||||
|
title_label.setObjectName("productStatusScopeTitleLabel")
|
||||||
|
title_label.setWordWrap(True)
|
||||||
|
title_label.setStyleSheet("font-weight: 600; color: #24292f;")
|
||||||
|
layout.addWidget(title_label)
|
||||||
|
|
||||||
|
detail_label = QLabel(self._informative_text)
|
||||||
|
detail_label.setObjectName("productStatusScopeDetailLabel")
|
||||||
|
detail_label.setWordWrap(True)
|
||||||
|
detail_label.setStyleSheet("color: #57606a;")
|
||||||
|
layout.addWidget(detail_label)
|
||||||
|
|
||||||
|
layout.addSpacing(2)
|
||||||
|
self.normal_button = QPushButton(str(normal_text or ""))
|
||||||
|
self.normal_button.setObjectName(str(normal_object_name))
|
||||||
|
self.normal_button.setMinimumWidth(self.MINIMUM_WIDTH - 40)
|
||||||
|
self.normal_button.setMinimumHeight(34)
|
||||||
|
self.normal_button.setDefault(True)
|
||||||
|
self.normal_button.setFocus()
|
||||||
|
self.normal_button.setStyleSheet(_primary_button_style(normal_object_name))
|
||||||
|
self.normal_button.clicked.connect(lambda: self._accept_choice(normal_value))
|
||||||
|
layout.addWidget(self.normal_button)
|
||||||
|
|
||||||
|
self.all_button = QPushButton(str(all_text or ""))
|
||||||
|
self.all_button.setObjectName(str(all_object_name))
|
||||||
|
self.all_button.setMinimumWidth(self.MINIMUM_WIDTH - 40)
|
||||||
|
self.all_button.setMinimumHeight(34)
|
||||||
|
self.all_button.setEnabled(bool(all_enabled))
|
||||||
|
self.all_button.setStyleSheet(_warning_outline_button_style(all_object_name))
|
||||||
|
self.all_button.clicked.connect(lambda: self._accept_choice(all_value))
|
||||||
|
layout.addWidget(self.all_button)
|
||||||
|
|
||||||
|
self.cancel_button = QPushButton("取消")
|
||||||
|
self.cancel_button.setObjectName(str(cancel_object_name))
|
||||||
|
self.cancel_button.setMinimumWidth(self.MINIMUM_WIDTH - 40)
|
||||||
|
self.cancel_button.setMinimumHeight(32)
|
||||||
|
self.cancel_button.clicked.connect(self.reject)
|
||||||
|
layout.addWidget(self.cancel_button)
|
||||||
|
|
||||||
|
def defaultButton(self):
|
||||||
|
"""供调用方和 GUI 测试读取明确的默认按钮。"""
|
||||||
|
|
||||||
|
return self.normal_button
|
||||||
|
|
||||||
|
def informativeText(self):
|
||||||
|
"""按 QMessageBox 兼容接口返回说明文字。"""
|
||||||
|
|
||||||
|
return self._informative_text
|
||||||
|
|
||||||
|
def choice(self):
|
||||||
|
return self._choice
|
||||||
|
|
||||||
|
def _accept_choice(self, value):
|
||||||
|
self._choice = value
|
||||||
|
self.accept()
|
||||||
|
|
||||||
|
|
||||||
def _login_status_display(status):
|
def _login_status_display(status):
|
||||||
return f"● {status or '未知'}"
|
return f"● {status or '未知'}"
|
||||||
|
|
||||||
@@ -304,19 +429,6 @@ def _login_status_color(status):
|
|||||||
return _qcolor(COLOR_MUTED)
|
return _qcolor(COLOR_MUTED)
|
||||||
|
|
||||||
|
|
||||||
def _warning_dot_icon(size=12):
|
|
||||||
pixmap = QPixmap(size, size)
|
|
||||||
pixmap.fill(Qt.transparent)
|
|
||||||
painter = QPainter(pixmap)
|
|
||||||
painter.setRenderHint(QPainter.Antialiasing)
|
|
||||||
painter.setPen(Qt.NoPen)
|
|
||||||
painter.setBrush(QColor(COLOR_WARNING))
|
|
||||||
margin = max(1, size // 6)
|
|
||||||
painter.drawEllipse(margin, margin, size - margin * 2, size - margin * 2)
|
|
||||||
painter.end()
|
|
||||||
return QIcon(pixmap)
|
|
||||||
|
|
||||||
|
|
||||||
def _build_empty_state_card(object_name, button_text="前往账号管理"):
|
def _build_empty_state_card(object_name, button_text="前往账号管理"):
|
||||||
card = QWidget()
|
card = QWidget()
|
||||||
card.setObjectName(object_name)
|
card.setObjectName(object_name)
|
||||||
|
|||||||
+1343
-66
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,5 @@
|
|||||||
import os
|
import os
|
||||||
|
import re
|
||||||
|
|
||||||
from .config import make_slug
|
from .config import make_slug
|
||||||
|
|
||||||
@@ -22,6 +23,27 @@ def task_image_path(image_root, task, account=None, suffix="old", ext=".jpg"):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def list_task_cover_candidates(image_root, task, account=None):
|
||||||
|
"""Return generated cover candidates for one task from the image directory."""
|
||||||
|
|
||||||
|
canonical_path = task_image_path(image_root, task, account=account, suffix="new", ext=".jpg")
|
||||||
|
directory = os.path.dirname(canonical_path)
|
||||||
|
prefix = os.path.splitext(os.path.basename(canonical_path))[0]
|
||||||
|
if not os.path.isdir(directory):
|
||||||
|
return []
|
||||||
|
candidates = []
|
||||||
|
for filename in os.listdir(directory):
|
||||||
|
full_path = os.path.join(directory, filename)
|
||||||
|
if not os.path.isfile(full_path):
|
||||||
|
continue
|
||||||
|
stem, ext = os.path.splitext(filename)
|
||||||
|
if ext.lower() != ".jpg":
|
||||||
|
continue
|
||||||
|
if stem == prefix or stem.startswith(prefix + "_"):
|
||||||
|
candidates.append(os.path.abspath(full_path))
|
||||||
|
return sorted(candidates, key=lambda path: _cover_candidate_sort_key(path, prefix))
|
||||||
|
|
||||||
|
|
||||||
def _account_slug(account, task):
|
def _account_slug(account, task):
|
||||||
slug = _get(account, "slug")
|
slug = _get(account, "slug")
|
||||||
if slug:
|
if slug:
|
||||||
@@ -32,6 +54,22 @@ def _account_slug(account, task):
|
|||||||
return "unknown_account"
|
return "unknown_account"
|
||||||
|
|
||||||
|
|
||||||
|
def _cover_candidate_sort_key(path, prefix):
|
||||||
|
filename = os.path.basename(path)
|
||||||
|
stem, _ext = os.path.splitext(filename)
|
||||||
|
if stem == prefix:
|
||||||
|
return (0, 0, 0, filename)
|
||||||
|
marker = prefix + "_"
|
||||||
|
if stem.startswith(marker):
|
||||||
|
suffix = stem[len(marker):]
|
||||||
|
match = re.fullmatch(r"(\d{14})(?:_(\d+))?", suffix)
|
||||||
|
if match:
|
||||||
|
timestamp = int(match.group(1))
|
||||||
|
collision = int(match.group(2) or 1)
|
||||||
|
return (1, -timestamp, -collision, filename)
|
||||||
|
return (2, 0, 0, filename)
|
||||||
|
|
||||||
|
|
||||||
def _get(obj, name, default=None):
|
def _get(obj, name, default=None):
|
||||||
if obj is None:
|
if obj is None:
|
||||||
return default
|
return default
|
||||||
|
|||||||
+2064
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,368 @@
|
|||||||
|
"""Safe local export helpers for AI image studio final selections."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import uuid
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from . import appconfig, image_studio
|
||||||
|
|
||||||
|
|
||||||
|
EXISTING_FAIL = "fail"
|
||||||
|
EXISTING_OVERWRITE_MANAGED = "overwrite_managed"
|
||||||
|
EXISTING_TIMESTAMP = "timestamp"
|
||||||
|
EXISTING_MODES = {EXISTING_FAIL, EXISTING_OVERWRITE_MANAGED, EXISTING_TIMESTAMP}
|
||||||
|
|
||||||
|
|
||||||
|
class ImageStudioExportError(RuntimeError):
|
||||||
|
"""Raised when AI studio selections cannot be exported safely."""
|
||||||
|
|
||||||
|
|
||||||
|
class ExportTargetExistsError(ImageStudioExportError):
|
||||||
|
"""Raised when the default target directory already exists."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ExportedFile:
|
||||||
|
selection_type: str
|
||||||
|
asset_id: int
|
||||||
|
source_path: str
|
||||||
|
output_path: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ExportResult:
|
||||||
|
target_dir: str
|
||||||
|
files: tuple[ExportedFile, ...]
|
||||||
|
main_count: int
|
||||||
|
detail_count: int
|
||||||
|
existing_mode: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class GenerationRoundExportedFile:
|
||||||
|
job_id: int
|
||||||
|
asset_id: int
|
||||||
|
job_type: str
|
||||||
|
source_path: str
|
||||||
|
output_path: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class GenerationRoundExportResult:
|
||||||
|
target_dir: str
|
||||||
|
files: tuple[GenerationRoundExportedFile, ...]
|
||||||
|
skipped_count: int
|
||||||
|
cancelled: bool
|
||||||
|
|
||||||
|
|
||||||
|
def target_dir_for_project(project, parent_dir, suffix=None):
|
||||||
|
parent = _existing_parent_dir(parent_dir)
|
||||||
|
item = _safe_item_id(getattr(project, "item_id", ""))
|
||||||
|
dirname = item if not suffix else f"{item}_{suffix}"
|
||||||
|
return os.path.abspath(os.path.join(parent, dirname))
|
||||||
|
|
||||||
|
|
||||||
|
def export_project_selection(
|
||||||
|
project_id,
|
||||||
|
parent_dir,
|
||||||
|
*,
|
||||||
|
existing_mode=EXISTING_FAIL,
|
||||||
|
path=None,
|
||||||
|
config=None,
|
||||||
|
timestamp=None,
|
||||||
|
):
|
||||||
|
"""Export current main/detail selections as ordered JPEG files."""
|
||||||
|
|
||||||
|
mode = str(existing_mode or EXISTING_FAIL)
|
||||||
|
if mode not in EXISTING_MODES:
|
||||||
|
raise ImageStudioExportError("导出目录处理方式无效")
|
||||||
|
cfg = appconfig.load_config() if config is None else config
|
||||||
|
database_path = path or appconfig.db_path(cfg)
|
||||||
|
project = image_studio.get_project(project_id, path=database_path)
|
||||||
|
if project is None:
|
||||||
|
raise ImageStudioExportError("AI工场项目不存在")
|
||||||
|
parent = _existing_parent_dir(parent_dir)
|
||||||
|
planned = _planned_files(project, database_path)
|
||||||
|
if not planned:
|
||||||
|
raise ImageStudioExportError("主图和详情图终选都为空,不能导出")
|
||||||
|
target = _choose_target_dir(project, parent, mode, timestamp=timestamp)
|
||||||
|
quality = int(appconfig.ai_config(cfg).get("jpg_quality", 90) or 90)
|
||||||
|
staging = os.path.join(parent, f".{_safe_item_id(project.item_id)}_staging_{uuid.uuid4().hex}")
|
||||||
|
try:
|
||||||
|
os.makedirs(staging, exist_ok=False)
|
||||||
|
staged = []
|
||||||
|
for item in planned:
|
||||||
|
output_path = os.path.join(staging, item["filename"])
|
||||||
|
_save_jpeg(item["source_path"], output_path, quality)
|
||||||
|
staged.append(ExportedFile(item["selection_type"], item["asset_id"], item["source_path"], output_path))
|
||||||
|
os.makedirs(target, exist_ok=True)
|
||||||
|
if os.path.exists(target) and mode == EXISTING_OVERWRITE_MANAGED:
|
||||||
|
_remove_managed_exports(target, project.item_id)
|
||||||
|
final_files = []
|
||||||
|
for staged_file in staged:
|
||||||
|
final_path = os.path.join(target, os.path.basename(staged_file.output_path))
|
||||||
|
os.replace(staged_file.output_path, final_path)
|
||||||
|
final_files.append(
|
||||||
|
ExportedFile(
|
||||||
|
staged_file.selection_type,
|
||||||
|
staged_file.asset_id,
|
||||||
|
staged_file.source_path,
|
||||||
|
final_path,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return ExportResult(
|
||||||
|
target_dir=target,
|
||||||
|
files=tuple(final_files),
|
||||||
|
main_count=sum(1 for item in final_files if item.selection_type == "main"),
|
||||||
|
detail_count=sum(1 for item in final_files if item.selection_type == "detail"),
|
||||||
|
existing_mode=mode,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
if isinstance(exc, ImageStudioExportError):
|
||||||
|
raise
|
||||||
|
raise ImageStudioExportError(f"导出终选图片失败:{exc}") from exc
|
||||||
|
finally:
|
||||||
|
if os.path.isdir(staging):
|
||||||
|
shutil.rmtree(staging, ignore_errors=True)
|
||||||
|
|
||||||
|
|
||||||
|
def export_generation_round(
|
||||||
|
project_id,
|
||||||
|
generation_round_key,
|
||||||
|
parent_dir,
|
||||||
|
*,
|
||||||
|
path=None,
|
||||||
|
should_stop=None,
|
||||||
|
):
|
||||||
|
"""Copy usable successful output images from one persisted generation round."""
|
||||||
|
|
||||||
|
project = image_studio.get_project(project_id, path=path)
|
||||||
|
if project is None:
|
||||||
|
raise ImageStudioExportError("商品套图项目不存在,无法导出历史记录")
|
||||||
|
try:
|
||||||
|
parent = _existing_parent_dir(parent_dir)
|
||||||
|
except ImageStudioExportError as exc:
|
||||||
|
raise ImageStudioExportError("导出父目录不可用") from exc
|
||||||
|
jobs = image_studio.list_generation_round_current_jobs(
|
||||||
|
project.id,
|
||||||
|
generation_round_key,
|
||||||
|
path=path,
|
||||||
|
)
|
||||||
|
planned, skipped_count = _planned_generation_round_files(jobs, path)
|
||||||
|
if not planned:
|
||||||
|
raise ImageStudioExportError("本轮没有可导出的成功生成图片")
|
||||||
|
|
||||||
|
target = _create_generation_round_target_dir(project, parent, jobs)
|
||||||
|
copied = []
|
||||||
|
cancelled = False
|
||||||
|
try:
|
||||||
|
for item in planned:
|
||||||
|
if callable(should_stop) and should_stop():
|
||||||
|
cancelled = True
|
||||||
|
break
|
||||||
|
output_path = os.path.join(target, item["filename"])
|
||||||
|
try:
|
||||||
|
shutil.copy2(item["source_path"], output_path)
|
||||||
|
except OSError:
|
||||||
|
skipped_count += 1
|
||||||
|
if os.path.isfile(output_path):
|
||||||
|
try:
|
||||||
|
os.remove(output_path)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
continue
|
||||||
|
copied.append(
|
||||||
|
GenerationRoundExportedFile(
|
||||||
|
job_id=int(item["job_id"]),
|
||||||
|
asset_id=int(item["asset_id"]),
|
||||||
|
job_type=str(item["job_type"]),
|
||||||
|
source_path=item["source_path"],
|
||||||
|
output_path=output_path,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
raise ImageStudioExportError("导出本轮生成图片失败") from exc
|
||||||
|
|
||||||
|
if not copied:
|
||||||
|
try:
|
||||||
|
os.rmdir(target)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
if cancelled:
|
||||||
|
return GenerationRoundExportResult("", (), skipped_count, True)
|
||||||
|
raise ImageStudioExportError("本轮生成图片导出失败")
|
||||||
|
return GenerationRoundExportResult(
|
||||||
|
target_dir=target,
|
||||||
|
files=tuple(copied),
|
||||||
|
skipped_count=skipped_count,
|
||||||
|
cancelled=cancelled,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _choose_target_dir(project, parent_dir, existing_mode, timestamp=None):
|
||||||
|
target = target_dir_for_project(project, parent_dir)
|
||||||
|
if not os.path.exists(target):
|
||||||
|
return target
|
||||||
|
if not os.path.isdir(target):
|
||||||
|
raise ImageStudioExportError(f"导出目标已存在但不是目录:{target}")
|
||||||
|
if existing_mode == EXISTING_FAIL:
|
||||||
|
raise ExportTargetExistsError(f"商品目录已存在:{target}")
|
||||||
|
if existing_mode == EXISTING_OVERWRITE_MANAGED:
|
||||||
|
return target
|
||||||
|
stamp = timestamp or datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||||
|
base = target_dir_for_project(project, parent_dir, suffix=stamp)
|
||||||
|
candidate = base
|
||||||
|
index = 2
|
||||||
|
while os.path.exists(candidate):
|
||||||
|
candidate = f"{base}_{index}"
|
||||||
|
index += 1
|
||||||
|
return candidate
|
||||||
|
|
||||||
|
|
||||||
|
def _planned_files(project, db_path):
|
||||||
|
item = _safe_item_id(project.item_id)
|
||||||
|
planned = []
|
||||||
|
for selection_type, label in (("main", "主图"), ("detail", "详情图")):
|
||||||
|
selections = image_studio.list_selections(project.id, selection_type, path=db_path)
|
||||||
|
for index, selection in enumerate(selections, start=1):
|
||||||
|
asset = image_studio.get_asset(selection.asset_id, path=db_path)
|
||||||
|
if asset is None:
|
||||||
|
raise ImageStudioExportError(f"终选照片不存在:#{selection.asset_id}")
|
||||||
|
source_path = str(asset.local_path or "")
|
||||||
|
if not source_path or not os.path.isfile(source_path):
|
||||||
|
raise ImageStudioExportError(f"终选照片本地文件缺失:#{asset.id}")
|
||||||
|
planned.append(
|
||||||
|
{
|
||||||
|
"selection_type": selection_type,
|
||||||
|
"asset_id": int(asset.id),
|
||||||
|
"source_path": os.path.abspath(source_path),
|
||||||
|
"filename": f"{item}_{label}_{index}.jpg",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return planned
|
||||||
|
|
||||||
|
|
||||||
|
def _planned_generation_round_files(jobs, db_path):
|
||||||
|
planned = []
|
||||||
|
skipped_count = 0
|
||||||
|
for index, job in enumerate(jobs, 1):
|
||||||
|
if str(getattr(job, "status", "") or "") != "succeeded":
|
||||||
|
continue
|
||||||
|
asset_id = getattr(job, "output_asset_id", None)
|
||||||
|
asset = image_studio.get_asset(asset_id, path=db_path) if asset_id else None
|
||||||
|
source_path = str(getattr(asset, "local_path", "") or "")
|
||||||
|
if (
|
||||||
|
asset is None
|
||||||
|
or str(getattr(asset, "status", "") or "")
|
||||||
|
== image_studio.ASSET_STATUS_MISSING
|
||||||
|
or not source_path
|
||||||
|
or not os.path.isfile(source_path)
|
||||||
|
):
|
||||||
|
skipped_count += 1
|
||||||
|
continue
|
||||||
|
suffix = os.path.splitext(source_path)[1].lower()
|
||||||
|
if not suffix or len(suffix) > 8:
|
||||||
|
suffix = ".jpg"
|
||||||
|
job_type = _safe_export_component(
|
||||||
|
getattr(job, "job_type", ""),
|
||||||
|
fallback="套图",
|
||||||
|
)
|
||||||
|
planned.append(
|
||||||
|
{
|
||||||
|
"job_id": int(job.id),
|
||||||
|
"asset_id": int(asset.id),
|
||||||
|
"job_type": str(getattr(job, "job_type", "") or "套图"),
|
||||||
|
"source_path": os.path.abspath(source_path),
|
||||||
|
"filename": "%02d_%s%s" % (index, job_type, suffix),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return planned, skipped_count
|
||||||
|
|
||||||
|
|
||||||
|
def _create_generation_round_target_dir(project, parent_dir, jobs):
|
||||||
|
account = _safe_export_component(
|
||||||
|
getattr(project, "account_name", "") or getattr(project, "account_alias", ""),
|
||||||
|
fallback="未命名店铺",
|
||||||
|
)
|
||||||
|
item = _safe_export_component(getattr(project, "item_id", ""), fallback="临时草稿")
|
||||||
|
created_at = str(getattr(jobs[0], "created_at", "") or "") if jobs else ""
|
||||||
|
digits = "".join(character for character in created_at if character.isdigit())[:14]
|
||||||
|
stamp = digits or datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||||
|
base = os.path.join(parent_dir, "%s_%s_%s" % (account, item, stamp))
|
||||||
|
candidate = base
|
||||||
|
index = 2
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
os.makedirs(candidate, exist_ok=False)
|
||||||
|
return candidate
|
||||||
|
except FileExistsError:
|
||||||
|
candidate = "%s_%d" % (base, index)
|
||||||
|
index += 1
|
||||||
|
except OSError as exc:
|
||||||
|
raise ImageStudioExportError("无法创建导出目录") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _save_jpeg(source_path, output_path, quality):
|
||||||
|
try:
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
with Image.open(source_path) as image:
|
||||||
|
if image.mode in {"RGBA", "LA"} or (
|
||||||
|
image.mode == "P" and "transparency" in getattr(image, "info", {})
|
||||||
|
):
|
||||||
|
rgba = image.convert("RGBA")
|
||||||
|
background = Image.new("RGBA", rgba.size, (255, 255, 255, 255))
|
||||||
|
background.alpha_composite(rgba)
|
||||||
|
final_image = background.convert("RGB")
|
||||||
|
else:
|
||||||
|
final_image = image.convert("RGB")
|
||||||
|
final_image.save(output_path, format="JPEG", quality=max(1, min(95, int(quality or 90))))
|
||||||
|
with Image.open(output_path) as check:
|
||||||
|
check.verify()
|
||||||
|
except Exception as exc:
|
||||||
|
raise ImageStudioExportError(f"JPEG 转码失败:{exc}") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _remove_managed_exports(target_dir, item_id):
|
||||||
|
item = re.escape(_safe_item_id(item_id))
|
||||||
|
pattern = re.compile(rf"^{item}_(主图|详情图)_\d+\.jpg$", re.IGNORECASE)
|
||||||
|
for filename in os.listdir(target_dir):
|
||||||
|
if not pattern.match(filename):
|
||||||
|
continue
|
||||||
|
path = os.path.join(target_dir, filename)
|
||||||
|
if os.path.isfile(path):
|
||||||
|
os.remove(path)
|
||||||
|
|
||||||
|
|
||||||
|
def _existing_parent_dir(parent_dir):
|
||||||
|
parent = os.path.abspath(str(parent_dir or ""))
|
||||||
|
if not os.path.isdir(parent):
|
||||||
|
raise ImageStudioExportError(f"导出父目录不存在:{parent}")
|
||||||
|
return parent
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_item_id(item_id):
|
||||||
|
value = str(item_id or "").strip()
|
||||||
|
if not value:
|
||||||
|
raise ImageStudioExportError("商品ID不能为空")
|
||||||
|
if any(char in value for char in ('/', '\\', os.sep, os.altsep or "\0")):
|
||||||
|
raise ImageStudioExportError("商品ID包含非法路径字符")
|
||||||
|
if value in {".", ".."}:
|
||||||
|
raise ImageStudioExportError("商品ID不能是路径符号")
|
||||||
|
safe = "".join(ch if ch.isalnum() or ch in "_-" else "_" for ch in value).strip("_")
|
||||||
|
if not safe:
|
||||||
|
raise ImageStudioExportError("商品ID不能作为目录名")
|
||||||
|
return safe
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_export_component(value, *, fallback):
|
||||||
|
text = str(value or "").strip()
|
||||||
|
safe = "".join(ch if ch.isalnum() or ch in "_-" else "_" for ch in text).strip("_")
|
||||||
|
if safe:
|
||||||
|
return safe
|
||||||
|
return str(fallback)
|
||||||
@@ -0,0 +1,901 @@
|
|||||||
|
"""Multi-source generation orchestration for AI image studio jobs."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import urllib.parse
|
||||||
|
import uuid
|
||||||
|
from concurrent.futures import CancelledError, FIRST_COMPLETED, ThreadPoolExecutor, wait
|
||||||
|
|
||||||
|
from . import ai, appconfig, image_studio
|
||||||
|
from .version import APP_VERSION
|
||||||
|
|
||||||
|
|
||||||
|
MAX_IMAGE_STUDIO_WORKERS = 5
|
||||||
|
_GLOBAL_IMAGE_STUDIO_SLOTS = threading.BoundedSemaphore(MAX_IMAGE_STUDIO_WORKERS)
|
||||||
|
CMHUB_IMAGE_STUDIO_MAX_INPUT_IMAGES = 8
|
||||||
|
CMHUB_IMAGE_STUDIO_MAX_SINGLE_INPUT_BYTES = 10 * 1024 * 1024
|
||||||
|
CMHUB_IMAGE_STUDIO_MAX_TOTAL_INPUT_BYTES = 32 * 1024 * 1024
|
||||||
|
|
||||||
|
|
||||||
|
class ImageStudioGenerationError(RuntimeError):
|
||||||
|
"""Raised when AI studio image generation cannot complete."""
|
||||||
|
|
||||||
|
|
||||||
|
class ImageStudioRuntimeLease:
|
||||||
|
"""A process-lifetime lock used only to make startup recovery safe."""
|
||||||
|
|
||||||
|
def __init__(self, handle, path):
|
||||||
|
self._handle = handle
|
||||||
|
self.path = path
|
||||||
|
self.session_id = "startup-" + uuid.uuid4().hex
|
||||||
|
|
||||||
|
def release(self):
|
||||||
|
handle, self._handle = self._handle, None
|
||||||
|
if handle is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
handle.seek(0)
|
||||||
|
if os.name == "nt":
|
||||||
|
import msvcrt
|
||||||
|
|
||||||
|
msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1)
|
||||||
|
else: # pragma: no cover - Windows release is the supported path.
|
||||||
|
import fcntl
|
||||||
|
|
||||||
|
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
|
||||||
|
finally:
|
||||||
|
handle.close()
|
||||||
|
|
||||||
|
|
||||||
|
def acquire_startup_recovery_lease(data_dir):
|
||||||
|
"""Acquire a non-blocking process lease; return None when another app owns it."""
|
||||||
|
|
||||||
|
directory = os.path.abspath(str(data_dir or ""))
|
||||||
|
if not directory:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
os.makedirs(directory, exist_ok=True)
|
||||||
|
path = os.path.join(directory, ".image_studio_generation.lock")
|
||||||
|
handle = open(path, "a+b")
|
||||||
|
if not handle.read(1):
|
||||||
|
handle.write(b"0")
|
||||||
|
handle.flush()
|
||||||
|
handle.seek(0)
|
||||||
|
if os.name == "nt":
|
||||||
|
import msvcrt
|
||||||
|
|
||||||
|
msvcrt.locking(handle.fileno(), msvcrt.LK_NBLCK, 1)
|
||||||
|
else: # pragma: no cover - retained for developer tests outside Windows.
|
||||||
|
import fcntl
|
||||||
|
|
||||||
|
fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||||
|
return ImageStudioRuntimeLease(handle, path)
|
||||||
|
except (OSError, ImportError):
|
||||||
|
try:
|
||||||
|
handle.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def recover_stale_direct_jobs_at_startup(*, lease, path=None):
|
||||||
|
"""Recover interrupted direct jobs only while the process lease is held."""
|
||||||
|
|
||||||
|
if lease is None:
|
||||||
|
return {"recovered": [], "skipped": True}
|
||||||
|
recovered = image_studio.fail_stale_direct_jobs(path=path)
|
||||||
|
return {"recovered": recovered, "skipped": False}
|
||||||
|
|
||||||
|
|
||||||
|
RATIO_OUTPUT_SIZES = {
|
||||||
|
"1:1": ("1024x1024", False),
|
||||||
|
"3:4": ("1024x1536", True),
|
||||||
|
"9:16": ("1024x1536", True),
|
||||||
|
"4:3": ("1536x1024", True),
|
||||||
|
"16:9": ("1536x1024", True),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _notify(callback, event):
|
||||||
|
if callback is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
callback(dict(event))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _runtime(config, cmhub_config_path):
|
||||||
|
return ai._cmhub_runtime(config, "image", cmhub_config_path)
|
||||||
|
|
||||||
|
|
||||||
|
def _direct_runtime(config):
|
||||||
|
ai_cfg = appconfig.ai_config(config)
|
||||||
|
models_path = appconfig.ai_models_config_path(config)
|
||||||
|
model = ai._role_model(
|
||||||
|
"image",
|
||||||
|
ai_cfg.get("default_image_model"),
|
||||||
|
models_path,
|
||||||
|
models=ai._runtime_direct_models(config),
|
||||||
|
)
|
||||||
|
error = appconfig.image_model_config_error(model)
|
||||||
|
if error:
|
||||||
|
raise ImageStudioGenerationError(error)
|
||||||
|
return {
|
||||||
|
"model": model,
|
||||||
|
"connect_timeout": ai._connect_timeout(model),
|
||||||
|
"read_timeout": ai._read_timeout(model, config),
|
||||||
|
"quality": ai._jpg_quality(ai_cfg.get("jpg_quality", 90)),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _is_default_gateway_job(job):
|
||||||
|
return (
|
||||||
|
str(getattr(job, "generation_source", "") or "").strip().lower()
|
||||||
|
== image_studio.GENERATION_SOURCE_CMHUB
|
||||||
|
and str(getattr(job, "provider", "") or "").strip().lower()
|
||||||
|
== image_studio.PROVIDER_CMHUB
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_direct_gateway_job(job):
|
||||||
|
return (
|
||||||
|
str(getattr(job, "generation_source", "") or "").strip().lower()
|
||||||
|
== image_studio.GENERATION_SOURCE_DIRECT
|
||||||
|
and str(getattr(job, "provider", "") or "").strip().lower()
|
||||||
|
== image_studio.PROVIDER_OPENAI_IMAGES_EDITS
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def generation_source_for_config(config):
|
||||||
|
"""Return the persisted source/provider pair for one frozen run config."""
|
||||||
|
|
||||||
|
if appconfig.ai_backend(config) == "direct":
|
||||||
|
return {
|
||||||
|
"generation_source": image_studio.GENERATION_SOURCE_DIRECT,
|
||||||
|
"provider": image_studio.PROVIDER_OPENAI_IMAGES_EDITS,
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"generation_source": image_studio.GENERATION_SOURCE_CMHUB,
|
||||||
|
"provider": image_studio.PROVIDER_CMHUB,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def requested_output_spec(aspect_ratio):
|
||||||
|
"""Map a product-suite ratio to an explicit request size and approximation flag."""
|
||||||
|
|
||||||
|
ratio = str(aspect_ratio or "1:1")
|
||||||
|
size, approximate = RATIO_OUTPUT_SIZES.get(ratio, RATIO_OUTPUT_SIZES["1:1"])
|
||||||
|
return {
|
||||||
|
"aspect_ratio": ratio,
|
||||||
|
"requested_output_size": size,
|
||||||
|
"approximate_ratio": bool(approximate),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_new_submission_allowed(config):
|
||||||
|
if appconfig.ai_backend(config) not in {"cmhub", "direct"}:
|
||||||
|
raise ImageStudioGenerationError("商品套图生成网关配置无效,请到⑤设置检查")
|
||||||
|
|
||||||
|
|
||||||
|
def _generated_kind(job_type):
|
||||||
|
return "generated_detail" if str(job_type) == "detail" else "generated_main"
|
||||||
|
|
||||||
|
|
||||||
|
def _output_path(project, job, image_root):
|
||||||
|
dirs = image_studio.project_image_dirs(image_root, project)
|
||||||
|
filename = "job_%06d_%s.jpg" % (int(job.id), _generated_kind(job.job_type))
|
||||||
|
return os.path.join(dirs["generated"], filename)
|
||||||
|
|
||||||
|
|
||||||
|
def _source_path(source_asset):
|
||||||
|
path = str(getattr(source_asset, "local_path", "") or "").strip()
|
||||||
|
if not path or not os.path.isfile(path):
|
||||||
|
raise ImageStudioGenerationError("源图尚未下载到本地,不能生成")
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def _build_cmhub_images(source_asset, reference_assets=()):
|
||||||
|
"""Build the ordered cmhub image input array from local image assets."""
|
||||||
|
candidates = [source_asset] + list(reference_assets or [])
|
||||||
|
selected = candidates[:CMHUB_IMAGE_STUDIO_MAX_INPUT_IMAGES]
|
||||||
|
omitted_count = max(0, len(candidates) - len(selected))
|
||||||
|
images = []
|
||||||
|
total_bytes = 0
|
||||||
|
for index, asset in enumerate(selected, 1):
|
||||||
|
source_path = _source_path(asset)
|
||||||
|
file_size = os.path.getsize(source_path)
|
||||||
|
if file_size > CMHUB_IMAGE_STUDIO_MAX_SINGLE_INPUT_BYTES:
|
||||||
|
role = "主图" if index == 1 else "参考图"
|
||||||
|
raise ImageStudioGenerationError("商品套图%s超过10MiB,不能提交" % role)
|
||||||
|
data_url = ai._image_data_url(source_path)
|
||||||
|
total_bytes += len(data_url.encode("utf-8"))
|
||||||
|
if total_bytes > CMHUB_IMAGE_STUDIO_MAX_TOTAL_INPUT_BYTES:
|
||||||
|
raise ImageStudioGenerationError(
|
||||||
|
"商品套图提交图片总大小超过32MiB,请减少图片或更换较小原图"
|
||||||
|
)
|
||||||
|
images.append({"image_base64": data_url})
|
||||||
|
if not images:
|
||||||
|
raise ImageStudioGenerationError("商品套图至少需要一张本地原图")
|
||||||
|
return images, omitted_count
|
||||||
|
|
||||||
|
|
||||||
|
def create_generation_jobs(
|
||||||
|
project_id,
|
||||||
|
source_asset_id,
|
||||||
|
prompt,
|
||||||
|
count,
|
||||||
|
*,
|
||||||
|
job_type="main",
|
||||||
|
config=None,
|
||||||
|
path=None,
|
||||||
|
):
|
||||||
|
_ensure_new_submission_allowed(config)
|
||||||
|
total = max(0, int(count or 0))
|
||||||
|
if total <= 0:
|
||||||
|
raise ImageStudioGenerationError("生成数量必须大于0")
|
||||||
|
source = generation_source_for_config(config)
|
||||||
|
jobs = []
|
||||||
|
for _ in range(total):
|
||||||
|
jobs.append(
|
||||||
|
image_studio.create_job(
|
||||||
|
project_id,
|
||||||
|
source_asset_id=source_asset_id,
|
||||||
|
job_type=job_type,
|
||||||
|
prompt=prompt,
|
||||||
|
generation_source=source["generation_source"],
|
||||||
|
provider=source["provider"],
|
||||||
|
path=path,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return jobs
|
||||||
|
|
||||||
|
|
||||||
|
def generate_image_jobs(
|
||||||
|
project_id,
|
||||||
|
source_asset_id,
|
||||||
|
prompt,
|
||||||
|
count,
|
||||||
|
*,
|
||||||
|
job_type="main",
|
||||||
|
aspect_ratio="1:1",
|
||||||
|
config=None,
|
||||||
|
cmhub_config_path=None,
|
||||||
|
path=None,
|
||||||
|
should_stop=None,
|
||||||
|
on_event=None,
|
||||||
|
run_session_id=None,
|
||||||
|
):
|
||||||
|
jobs = create_generation_jobs(
|
||||||
|
project_id,
|
||||||
|
source_asset_id,
|
||||||
|
prompt,
|
||||||
|
count,
|
||||||
|
job_type=job_type,
|
||||||
|
config=config,
|
||||||
|
path=path,
|
||||||
|
)
|
||||||
|
return run_jobs(
|
||||||
|
jobs,
|
||||||
|
aspect_ratio=aspect_ratio,
|
||||||
|
config=config,
|
||||||
|
cmhub_config_path=cmhub_config_path,
|
||||||
|
path=path,
|
||||||
|
should_stop=should_stop,
|
||||||
|
on_event=on_event,
|
||||||
|
run_session_id=run_session_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def resume_image_jobs(
|
||||||
|
*,
|
||||||
|
project_id=None,
|
||||||
|
aspect_ratio="1:1",
|
||||||
|
config=None,
|
||||||
|
cmhub_config_path=None,
|
||||||
|
path=None,
|
||||||
|
should_stop=None,
|
||||||
|
on_event=None,
|
||||||
|
run_session_id=None,
|
||||||
|
):
|
||||||
|
jobs = image_studio.list_resumable_jobs(
|
||||||
|
path=path,
|
||||||
|
project_id=project_id,
|
||||||
|
include_failed_downloads=True,
|
||||||
|
)
|
||||||
|
return run_jobs(
|
||||||
|
jobs,
|
||||||
|
aspect_ratio=aspect_ratio,
|
||||||
|
config=config,
|
||||||
|
cmhub_config_path=cmhub_config_path,
|
||||||
|
path=path,
|
||||||
|
should_stop=should_stop,
|
||||||
|
on_event=on_event,
|
||||||
|
run_session_id=run_session_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def run_jobs(
|
||||||
|
jobs,
|
||||||
|
*,
|
||||||
|
aspect_ratio="1:1",
|
||||||
|
config=None,
|
||||||
|
cmhub_config_path=None,
|
||||||
|
path=None,
|
||||||
|
should_stop=None,
|
||||||
|
on_event=None,
|
||||||
|
run_session_id=None,
|
||||||
|
):
|
||||||
|
cfg = appconfig.load_config() if config is None else config
|
||||||
|
job_list = list(jobs or [])
|
||||||
|
if not job_list:
|
||||||
|
return {"total": 0, "success": 0, "failed": 0, "cancelled": 0, "jobs": []}
|
||||||
|
rejected = []
|
||||||
|
for job in job_list:
|
||||||
|
if _is_direct_gateway_job(job) and getattr(job, "task_id", None):
|
||||||
|
rejected.append(job)
|
||||||
|
elif not _is_default_gateway_job(job) and not _is_direct_gateway_job(job):
|
||||||
|
rejected.append(job)
|
||||||
|
job_list = [job for job in job_list if job not in rejected]
|
||||||
|
summary = {
|
||||||
|
"total": len(job_list) + len(rejected),
|
||||||
|
"success": 0,
|
||||||
|
"failed": len(rejected),
|
||||||
|
"cancelled": 0,
|
||||||
|
"jobs": [
|
||||||
|
{
|
||||||
|
"job": job,
|
||||||
|
"status": "failed",
|
||||||
|
"error": "AI工场任务来源无效,不能执行",
|
||||||
|
}
|
||||||
|
for job in rejected
|
||||||
|
],
|
||||||
|
}
|
||||||
|
if not job_list:
|
||||||
|
return summary
|
||||||
|
|
||||||
|
ai_cfg = appconfig.ai_config(cfg)
|
||||||
|
image_root = appconfig.image_dir(cfg)
|
||||||
|
runtimes = {}
|
||||||
|
if any(_is_default_gateway_job(job) for job in job_list):
|
||||||
|
runtimes[image_studio.GENERATION_SOURCE_CMHUB] = _runtime(
|
||||||
|
cfg,
|
||||||
|
cmhub_config_path,
|
||||||
|
)
|
||||||
|
if any(_is_direct_gateway_job(job) for job in job_list):
|
||||||
|
runtimes[image_studio.GENERATION_SOURCE_DIRECT] = _direct_runtime(cfg)
|
||||||
|
run_session_id = str(run_session_id or "").strip() or "direct-" + uuid.uuid4().hex
|
||||||
|
output_spec = requested_output_spec(aspect_ratio)
|
||||||
|
max_workers = min(
|
||||||
|
MAX_IMAGE_STUDIO_WORKERS,
|
||||||
|
max(1, int(ai_cfg.get("image_concurrency", 1) or 1)),
|
||||||
|
len(job_list),
|
||||||
|
)
|
||||||
|
should_stop = should_stop or (lambda: False)
|
||||||
|
lock = threading.Lock()
|
||||||
|
|
||||||
|
def record(result):
|
||||||
|
with lock:
|
||||||
|
summary["jobs"].append(result)
|
||||||
|
if result.get("status") == "succeeded":
|
||||||
|
summary["success"] += 1
|
||||||
|
elif result.get("status") == "cancelled":
|
||||||
|
summary["cancelled"] += 1
|
||||||
|
else:
|
||||||
|
summary["failed"] += 1
|
||||||
|
|
||||||
|
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
||||||
|
futures = {
|
||||||
|
executor.submit(
|
||||||
|
_run_one_job_with_global_slot,
|
||||||
|
job.id,
|
||||||
|
runtimes,
|
||||||
|
cfg,
|
||||||
|
image_root,
|
||||||
|
output_spec,
|
||||||
|
path,
|
||||||
|
should_stop,
|
||||||
|
on_event,
|
||||||
|
run_session_id,
|
||||||
|
): job
|
||||||
|
for job in job_list
|
||||||
|
}
|
||||||
|
while futures:
|
||||||
|
done, _ = wait(
|
||||||
|
set(futures),
|
||||||
|
timeout=0.2,
|
||||||
|
return_when=FIRST_COMPLETED,
|
||||||
|
)
|
||||||
|
for future in done:
|
||||||
|
futures.pop(future)
|
||||||
|
try:
|
||||||
|
record(future.result())
|
||||||
|
except Exception as exc:
|
||||||
|
record({"status": "failed", "error": str(exc)})
|
||||||
|
if should_stop():
|
||||||
|
for future, job in list(futures.items()):
|
||||||
|
if future.cancel():
|
||||||
|
image_studio.update_job_status(
|
||||||
|
job.id,
|
||||||
|
"cancelled",
|
||||||
|
error="用户停止",
|
||||||
|
recovery_action=_recovery_action_for_job(job),
|
||||||
|
path=path,
|
||||||
|
)
|
||||||
|
futures.pop(future, None)
|
||||||
|
record({"job": job, "status": "cancelled", "error": "用户停止"})
|
||||||
|
summary["output"] = dict(output_spec)
|
||||||
|
return summary
|
||||||
|
|
||||||
|
|
||||||
|
def _run_one_job_with_global_slot(*args):
|
||||||
|
with _GLOBAL_IMAGE_STUDIO_SLOTS:
|
||||||
|
return _run_one_job(*args)
|
||||||
|
|
||||||
|
|
||||||
|
def _run_one_job(
|
||||||
|
job_id,
|
||||||
|
runtimes,
|
||||||
|
config,
|
||||||
|
image_root,
|
||||||
|
output_spec,
|
||||||
|
db_path,
|
||||||
|
should_stop,
|
||||||
|
on_event,
|
||||||
|
run_session_id,
|
||||||
|
):
|
||||||
|
job = image_studio.get_job(job_id, path=db_path)
|
||||||
|
if job is None:
|
||||||
|
raise ImageStudioGenerationError("AI工场生图任务不存在")
|
||||||
|
if should_stop():
|
||||||
|
updated = image_studio.update_job_status(
|
||||||
|
job.id,
|
||||||
|
"cancelled",
|
||||||
|
error="用户停止",
|
||||||
|
recovery_action=_recovery_action_for_job(job),
|
||||||
|
path=db_path,
|
||||||
|
)
|
||||||
|
return {"job": updated, "status": "cancelled", "error": "用户停止"}
|
||||||
|
project = image_studio.get_project(job.project_id, path=db_path)
|
||||||
|
source_asset = image_studio.get_asset(job.source_asset_id, path=db_path)
|
||||||
|
if project is None or source_asset is None:
|
||||||
|
updated = image_studio.update_job_status(
|
||||||
|
job.id,
|
||||||
|
"failed",
|
||||||
|
error="项目或源图不存在",
|
||||||
|
recovery_action=image_studio.JOB_RECOVERY_REGENERATE,
|
||||||
|
path=db_path,
|
||||||
|
)
|
||||||
|
return {"job": updated, "status": "failed", "error": "项目或源图不存在"}
|
||||||
|
is_direct = _is_direct_gateway_job(job)
|
||||||
|
if not (_is_default_gateway_job(job) or is_direct):
|
||||||
|
raise ImageStudioGenerationError("AI工场任务来源无效,不能执行")
|
||||||
|
runtime = runtimes.get(job.generation_source)
|
||||||
|
if runtime is None:
|
||||||
|
raise ImageStudioGenerationError("当前运行缺少该任务来源的配置快照")
|
||||||
|
try:
|
||||||
|
image_studio.update_job_status(
|
||||||
|
job.id,
|
||||||
|
"running",
|
||||||
|
run_session_id=run_session_id if is_direct else None,
|
||||||
|
path=db_path,
|
||||||
|
)
|
||||||
|
reference_assets = ()
|
||||||
|
if not job.task_id:
|
||||||
|
reference_assets = _reference_assets_for_job(job, db_path)
|
||||||
|
out_path = _output_path(project, job, image_root)
|
||||||
|
if is_direct:
|
||||||
|
request_result = _submit_direct_job(
|
||||||
|
job,
|
||||||
|
source_asset,
|
||||||
|
runtime,
|
||||||
|
config,
|
||||||
|
output_spec,
|
||||||
|
should_stop,
|
||||||
|
on_event,
|
||||||
|
reference_assets=reference_assets,
|
||||||
|
)
|
||||||
|
# A submitted direct request cannot be cancelled. If bytes arrive after
|
||||||
|
# stop was requested, save them before ending the remaining queue.
|
||||||
|
saved_path = _save_direct_job_image(
|
||||||
|
request_result,
|
||||||
|
out_path,
|
||||||
|
on_event,
|
||||||
|
job.id,
|
||||||
|
)
|
||||||
|
remote_url = None
|
||||||
|
else:
|
||||||
|
request_result = _submit_or_resume_job(
|
||||||
|
job,
|
||||||
|
source_asset,
|
||||||
|
runtime,
|
||||||
|
config,
|
||||||
|
output_spec["aspect_ratio"],
|
||||||
|
db_path,
|
||||||
|
should_stop,
|
||||||
|
on_event,
|
||||||
|
reference_assets=reference_assets,
|
||||||
|
)
|
||||||
|
image_studio.update_job_status(job.id, "running", path=db_path)
|
||||||
|
request_result = _poll_job(
|
||||||
|
job.id,
|
||||||
|
request_result["task_id"],
|
||||||
|
runtime,
|
||||||
|
request_result,
|
||||||
|
db_path,
|
||||||
|
should_stop,
|
||||||
|
on_event,
|
||||||
|
)
|
||||||
|
_raise_if_stopped(should_stop)
|
||||||
|
request_result["resolution"] = output_spec["requested_output_size"]
|
||||||
|
saved_path = _download_and_save_job_image(
|
||||||
|
request_result,
|
||||||
|
out_path,
|
||||||
|
config,
|
||||||
|
on_event,
|
||||||
|
job.id,
|
||||||
|
should_stop=should_stop,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
_raise_if_stopped(should_stop)
|
||||||
|
except ImageStudioGenerationError:
|
||||||
|
try:
|
||||||
|
if os.path.isfile(saved_path):
|
||||||
|
os.remove(saved_path)
|
||||||
|
finally:
|
||||||
|
raise
|
||||||
|
remote_url = request_result["image_url"]
|
||||||
|
rendered_width, rendered_height = _rendered_image_size(saved_path)
|
||||||
|
asset = image_studio.add_asset(
|
||||||
|
project.id,
|
||||||
|
_generated_kind(job.job_type),
|
||||||
|
remote_url=remote_url,
|
||||||
|
local_path=saved_path,
|
||||||
|
aspect_ratio=output_spec["aspect_ratio"],
|
||||||
|
requested_output_size=output_spec["requested_output_size"],
|
||||||
|
rendered_width=rendered_width,
|
||||||
|
rendered_height=rendered_height,
|
||||||
|
parent_asset_id=source_asset.id,
|
||||||
|
prompt=job.prompt,
|
||||||
|
path=db_path,
|
||||||
|
)
|
||||||
|
updated = image_studio.update_job_status(
|
||||||
|
job.id,
|
||||||
|
"succeeded",
|
||||||
|
output_asset_id=asset.id,
|
||||||
|
points_balance=request_result.get("points_balance"),
|
||||||
|
path=db_path,
|
||||||
|
)
|
||||||
|
_notify(on_event, {"job_id": job.id, "step": "job_done", "result": "success"})
|
||||||
|
return {"job": updated, "asset": asset, "status": "succeeded"}
|
||||||
|
except Exception as exc:
|
||||||
|
cancelled = (
|
||||||
|
not is_direct
|
||||||
|
and (isinstance(exc, CancelledError) or "停止" in str(exc))
|
||||||
|
)
|
||||||
|
status = "cancelled" if cancelled else "failed"
|
||||||
|
if cancelled:
|
||||||
|
error = "用户已停止,已提交任务可稍后继续查询"
|
||||||
|
elif is_direct:
|
||||||
|
error = _direct_failure_message(exc)
|
||||||
|
else:
|
||||||
|
error = str(exc)
|
||||||
|
current_job = image_studio.get_job(job.id, path=db_path)
|
||||||
|
updated = image_studio.update_job_status(
|
||||||
|
job.id,
|
||||||
|
status,
|
||||||
|
error=error,
|
||||||
|
recovery_action=_recovery_action_for_job(current_job),
|
||||||
|
path=db_path,
|
||||||
|
)
|
||||||
|
_notify(
|
||||||
|
on_event,
|
||||||
|
{
|
||||||
|
"job_id": job.id,
|
||||||
|
"step": "job_done",
|
||||||
|
"result": status,
|
||||||
|
"detail": error,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return {"job": updated, "status": status, "error": error}
|
||||||
|
|
||||||
|
|
||||||
|
def _reference_assets_for_job(job, db_path):
|
||||||
|
assets = []
|
||||||
|
for asset_id in image_studio.job_reference_asset_ids(job):
|
||||||
|
asset = image_studio.get_asset(asset_id, path=db_path)
|
||||||
|
if asset is None:
|
||||||
|
raise ImageStudioGenerationError("参考图资产不存在,无法继续生成")
|
||||||
|
try:
|
||||||
|
_source_path(asset)
|
||||||
|
except ImageStudioGenerationError as exc:
|
||||||
|
raise ImageStudioGenerationError("参考图尚未下载到本地,无法继续生成") from exc
|
||||||
|
assets.append(asset)
|
||||||
|
return assets
|
||||||
|
|
||||||
|
|
||||||
|
def _direct_image_paths(source_asset, reference_assets=()):
|
||||||
|
"""Return ordered local paths; the first source image is the subject image."""
|
||||||
|
|
||||||
|
return [_source_path(source_asset)] + [
|
||||||
|
_source_path(asset) for asset in (reference_assets or ())
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _submit_direct_job(
|
||||||
|
job,
|
||||||
|
source_asset,
|
||||||
|
runtime,
|
||||||
|
config,
|
||||||
|
output_spec,
|
||||||
|
should_stop,
|
||||||
|
on_event,
|
||||||
|
reference_assets=(),
|
||||||
|
):
|
||||||
|
if job.task_id:
|
||||||
|
raise ImageStudioGenerationError("自定义网关任务不能继续查询,请手动重新生成")
|
||||||
|
_raise_if_stopped(should_stop)
|
||||||
|
image_paths = _direct_image_paths(source_asset, reference_assets)
|
||||||
|
body, content_type = ai._image_edit_body(
|
||||||
|
runtime["model"],
|
||||||
|
job.prompt,
|
||||||
|
image_paths,
|
||||||
|
output_spec["requested_output_size"],
|
||||||
|
)
|
||||||
|
_notify(on_event, {"job_id": job.id, "step": "cover_submit", "result": "start"})
|
||||||
|
# Direct images/edits has no idempotency or query endpoint. Retrying an
|
||||||
|
# uncertain request could create and charge a second image, so one call only.
|
||||||
|
data = ai._call_with_retry(
|
||||||
|
runtime["model"],
|
||||||
|
body,
|
||||||
|
config,
|
||||||
|
1,
|
||||||
|
request_kind="multipart",
|
||||||
|
content_type=content_type,
|
||||||
|
)
|
||||||
|
image_bytes = ai._extract_image_bytes(data, runtime["model"], config)
|
||||||
|
_notify(on_event, {"job_id": job.id, "step": "cover_request", "result": "success"})
|
||||||
|
return {
|
||||||
|
"image_bytes": image_bytes,
|
||||||
|
"resolution": output_spec["requested_output_size"],
|
||||||
|
"quality": runtime["quality"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _save_direct_job_image(request_result, out_path, on_event, job_id):
|
||||||
|
_notify(on_event, {"job_id": job_id, "step": "cover_download", "result": "start"})
|
||||||
|
saved_path = ai._save_jpeg(
|
||||||
|
request_result["image_bytes"],
|
||||||
|
out_path,
|
||||||
|
request_result["resolution"],
|
||||||
|
request_result["quality"],
|
||||||
|
)
|
||||||
|
_notify(on_event, {"job_id": job_id, "step": "cover_download", "result": "success"})
|
||||||
|
return saved_path
|
||||||
|
|
||||||
|
|
||||||
|
def _rendered_image_size(path):
|
||||||
|
try:
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
with Image.open(path) as image:
|
||||||
|
width, height = image.size
|
||||||
|
return int(width), int(height)
|
||||||
|
except Exception as exc:
|
||||||
|
raise ImageStudioGenerationError("生成图片保存后无法读取尺寸") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _direct_failure_message(exc):
|
||||||
|
text = str(exc or "").lower()
|
||||||
|
if "timeout" in text or "超时" in text:
|
||||||
|
return "自定义网关生成超时,无法确认服务商是否已处理,请手动重新生成"
|
||||||
|
return "自定义网关生成失败,结果无法确认,请检查图片模型配置后手动重新生成"
|
||||||
|
|
||||||
|
|
||||||
|
def _submit_or_resume_job(
|
||||||
|
job,
|
||||||
|
source_asset,
|
||||||
|
runtime,
|
||||||
|
config,
|
||||||
|
aspect_ratio,
|
||||||
|
db_path,
|
||||||
|
should_stop,
|
||||||
|
on_event,
|
||||||
|
reference_assets=(),
|
||||||
|
):
|
||||||
|
if job.task_id:
|
||||||
|
if not _is_default_gateway_job(job):
|
||||||
|
raise ImageStudioGenerationError("该已提交任务不属于默认网关,不能继续查询")
|
||||||
|
_notify(on_event, {"job_id": job.id, "step": "cover_request", "result": "resume", "task_id": job.task_id})
|
||||||
|
return _request_result(job.task_id, runtime, config)
|
||||||
|
_ensure_new_submission_allowed(config)
|
||||||
|
_raise_if_stopped(should_stop)
|
||||||
|
ai_cfg = appconfig.ai_config(config)
|
||||||
|
resolution = str(ai_cfg.get("resolution", "1k") or "1k")
|
||||||
|
images, omitted_count = _build_cmhub_images(source_asset, reference_assets)
|
||||||
|
if omitted_count:
|
||||||
|
_notify(
|
||||||
|
on_event,
|
||||||
|
{
|
||||||
|
"job_id": job.id,
|
||||||
|
"step": "cover_submit",
|
||||||
|
"result": "warning",
|
||||||
|
"detail": "图片最多提交8张,已忽略%d张参考图" % omitted_count,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
payload = {
|
||||||
|
"prompt": str(job.prompt or ""),
|
||||||
|
"model": runtime["alias"],
|
||||||
|
"images": images,
|
||||||
|
"resolution": ai._normalize_cmhub_resolution(resolution),
|
||||||
|
"aspect_ratio": str(aspect_ratio or "1:1"),
|
||||||
|
}
|
||||||
|
_notify(on_event, {"job_id": job.id, "step": "cover_submit", "result": "start"})
|
||||||
|
data = ai._cmhub_call_with_retry(
|
||||||
|
"POST",
|
||||||
|
appconfig.cmhub_request_url(runtime["base_url"], "/api/v1/generate/image/tasks"),
|
||||||
|
runtime["api_key"],
|
||||||
|
payload=payload,
|
||||||
|
connect_timeout=runtime["connect_timeout"],
|
||||||
|
read_timeout=ai.CMHUB_IMAGE_SUBMIT_READ_TIMEOUT_SECONDS,
|
||||||
|
attempts=max(1, int(ai_cfg.get("retry", 0) or 0) + 1),
|
||||||
|
headers_extra={
|
||||||
|
"Idempotency-Key": job.task_key,
|
||||||
|
"X-Client-Version": str(APP_VERSION),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
image_task_id = str(data.get("task_id") or "").strip()
|
||||||
|
if not image_task_id:
|
||||||
|
raise ImageStudioGenerationError("cmhub 生图任务提交返回格式错误")
|
||||||
|
submitted = image_studio.set_job_submitted(
|
||||||
|
job.id,
|
||||||
|
image_task_id,
|
||||||
|
call_id=data.get("call_id"),
|
||||||
|
points_cost=data.get("points_cost"),
|
||||||
|
points_balance=data.get("points_balance"),
|
||||||
|
path=db_path,
|
||||||
|
)
|
||||||
|
_notify(
|
||||||
|
on_event,
|
||||||
|
{
|
||||||
|
"job_id": job.id,
|
||||||
|
"step": "cover_submit",
|
||||||
|
"result": "success",
|
||||||
|
"task_id": submitted.task_id,
|
||||||
|
"call_id": submitted.call_id,
|
||||||
|
"points_cost": submitted.points_cost,
|
||||||
|
"points_balance": submitted.points_balance,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return _request_result(image_task_id, runtime, config, points_balance=data.get("points_balance"))
|
||||||
|
|
||||||
|
|
||||||
|
def _request_result(task_id, runtime, config, points_balance=None):
|
||||||
|
ai_cfg = appconfig.ai_config(config)
|
||||||
|
resolution = str(ai_cfg.get("resolution", "1k") or "1k")
|
||||||
|
return {
|
||||||
|
"task_id": task_id,
|
||||||
|
"connect_timeout": runtime["connect_timeout"],
|
||||||
|
"read_timeout": ai.CMHUB_IMAGE_READ_TIMEOUT_SECONDS,
|
||||||
|
"resolution": resolution,
|
||||||
|
"quality": ai._jpg_quality(ai_cfg.get("jpg_quality", 90)),
|
||||||
|
"use_system_proxy": runtime["use_system_proxy"],
|
||||||
|
"download_with_curl": runtime["download_with_curl"],
|
||||||
|
"points_balance": points_balance,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _poll_job(job_id, task_id, runtime, request_result, db_path, should_stop, on_event):
|
||||||
|
poll_url = appconfig.cmhub_request_url(
|
||||||
|
runtime["base_url"],
|
||||||
|
"/api/v1/generate/image/tasks/%s" % urllib.parse.quote(str(task_id), safe=""),
|
||||||
|
)
|
||||||
|
started = time.perf_counter()
|
||||||
|
deadline = started + max(1, int(ai.CMHUB_IMAGE_READ_TIMEOUT_SECONDS))
|
||||||
|
poll_index = 0
|
||||||
|
while True:
|
||||||
|
_raise_if_stopped(should_stop)
|
||||||
|
if time.perf_counter() >= deadline:
|
||||||
|
raise ImageStudioGenerationError("等待 cmhub 生图任务完成超时,下次可继续查询")
|
||||||
|
_notify(on_event, {"job_id": job_id, "step": "cover_poll", "result": "start", "task_id": task_id})
|
||||||
|
data = ai._cmhub_call_once(
|
||||||
|
"GET",
|
||||||
|
poll_url,
|
||||||
|
runtime["api_key"],
|
||||||
|
payload=None,
|
||||||
|
connect_timeout=runtime["connect_timeout"],
|
||||||
|
read_timeout=ai.CMHUB_IMAGE_POLL_READ_TIMEOUT_SECONDS,
|
||||||
|
headers_extra={"X-Client-Version": str(APP_VERSION)},
|
||||||
|
)
|
||||||
|
_raise_if_stopped(should_stop)
|
||||||
|
status = str(data.get("status") or "").strip().lower()
|
||||||
|
if status in {"queued", "running"}:
|
||||||
|
_notify(on_event, {"job_id": job_id, "step": "cover_poll", "result": status, "task_id": task_id})
|
||||||
|
ai._sleep_cmhub_poll(poll_index, should_stop)
|
||||||
|
poll_index += 1
|
||||||
|
continue
|
||||||
|
if status == "succeeded":
|
||||||
|
image_url = ai._extract_cmhub_image_url(data, runtime["base_url"])
|
||||||
|
if not image_url:
|
||||||
|
raise ImageStudioGenerationError("cmhub 生图任务成功但没有图片地址")
|
||||||
|
merged = dict(request_result)
|
||||||
|
merged["image_url"] = image_url
|
||||||
|
merged["points_balance"] = data.get("points_balance", merged.get("points_balance"))
|
||||||
|
_notify(on_event, {"job_id": job_id, "step": "cover_poll", "result": "success", "task_id": task_id})
|
||||||
|
return merged
|
||||||
|
if status in {"failed", "expired"}:
|
||||||
|
error = data.get("error") if isinstance(data.get("error"), dict) else {}
|
||||||
|
message = str(error.get("message") or error.get("code") or status)
|
||||||
|
image_studio.update_job_status(
|
||||||
|
job_id,
|
||||||
|
status,
|
||||||
|
error=message,
|
||||||
|
recovery_action=image_studio.JOB_RECOVERY_REGENERATE,
|
||||||
|
path=db_path,
|
||||||
|
)
|
||||||
|
raise ImageStudioGenerationError(message)
|
||||||
|
raise ImageStudioGenerationError("cmhub 生图任务状态返回格式错误")
|
||||||
|
|
||||||
|
|
||||||
|
def _download_and_save_job_image(
|
||||||
|
request_result,
|
||||||
|
out_path,
|
||||||
|
config,
|
||||||
|
on_event,
|
||||||
|
job_id,
|
||||||
|
should_stop=None,
|
||||||
|
):
|
||||||
|
_raise_if_stopped(should_stop)
|
||||||
|
_notify(on_event, {"job_id": job_id, "step": "cover_download", "result": "start"})
|
||||||
|
try:
|
||||||
|
image_bytes, _ = ai._download_cmhub_image_with_retry(
|
||||||
|
request_result["image_url"],
|
||||||
|
connect_timeout=request_result["connect_timeout"],
|
||||||
|
read_timeout=request_result["read_timeout"],
|
||||||
|
use_system_proxy=request_result.get("use_system_proxy", False),
|
||||||
|
download_with_curl=request_result.get("download_with_curl", "auto"),
|
||||||
|
should_stop=should_stop,
|
||||||
|
)
|
||||||
|
except CancelledError as exc:
|
||||||
|
raise ImageStudioGenerationError(
|
||||||
|
"用户已停止,已提交任务可稍后继续查询"
|
||||||
|
) from exc
|
||||||
|
_raise_if_stopped(should_stop)
|
||||||
|
saved_path = ai._save_jpeg(
|
||||||
|
image_bytes,
|
||||||
|
out_path,
|
||||||
|
request_result.get("resolution") or appconfig.ai_config(config).get("resolution", "1k"),
|
||||||
|
request_result.get("quality") or appconfig.ai_config(config).get("jpg_quality", 90),
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
_raise_if_stopped(should_stop)
|
||||||
|
except ImageStudioGenerationError:
|
||||||
|
try:
|
||||||
|
if os.path.isfile(saved_path):
|
||||||
|
os.remove(saved_path)
|
||||||
|
finally:
|
||||||
|
raise
|
||||||
|
_notify(on_event, {"job_id": job_id, "step": "cover_download", "result": "success"})
|
||||||
|
return saved_path
|
||||||
|
|
||||||
|
|
||||||
|
def _raise_if_stopped(should_stop):
|
||||||
|
try:
|
||||||
|
stopped = bool(should_stop and should_stop())
|
||||||
|
except Exception:
|
||||||
|
stopped = False
|
||||||
|
if stopped:
|
||||||
|
raise ImageStudioGenerationError("用户已停止,已提交任务可稍后继续查询")
|
||||||
|
|
||||||
|
|
||||||
|
def _recovery_action_for_job(job):
|
||||||
|
if (
|
||||||
|
job is not None
|
||||||
|
and getattr(job, "task_id", None)
|
||||||
|
and getattr(job, "recovery_action", None) == image_studio.JOB_RECOVERY_RESUME
|
||||||
|
):
|
||||||
|
return image_studio.JOB_RECOVERY_RESUME
|
||||||
|
return image_studio.JOB_RECOVERY_REGENERATE
|
||||||
@@ -0,0 +1,600 @@
|
|||||||
|
"""Remote image helpers for the AI image studio."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import io
|
||||||
|
import ipaddress
|
||||||
|
import os
|
||||||
|
import socket
|
||||||
|
import threading
|
||||||
|
import urllib.parse
|
||||||
|
import uuid
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Callable, Optional
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from . import appconfig, image_studio
|
||||||
|
|
||||||
|
|
||||||
|
THUMBNAIL_MAX_BYTES = 3 * 1024 * 1024
|
||||||
|
ORIGINAL_MAX_BYTES = 12 * 1024 * 1024
|
||||||
|
DEFAULT_CONNECT_TIMEOUT_SECONDS = 5
|
||||||
|
DEFAULT_READ_TIMEOUT_SECONDS = 30
|
||||||
|
DEFAULT_THUMBNAIL_SIZE = 220
|
||||||
|
DEFAULT_THUMBNAIL_WORKERS = 4
|
||||||
|
MAX_ORIGINAL_ASSETS = 16
|
||||||
|
|
||||||
|
|
||||||
|
class ImageStudioImageError(RuntimeError):
|
||||||
|
"""Raised when a remote image cannot be safely loaded or saved."""
|
||||||
|
|
||||||
|
|
||||||
|
class ImageStudioImageCancelled(ImageStudioImageError):
|
||||||
|
"""Raised when an original image download is cancelled safely."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class RemoteImage:
|
||||||
|
url: str
|
||||||
|
content: bytes
|
||||||
|
content_type: str
|
||||||
|
final_url: str
|
||||||
|
redirected: bool
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ThumbnailResult:
|
||||||
|
key: str
|
||||||
|
url: str
|
||||||
|
image_bytes: bytes
|
||||||
|
width: int
|
||||||
|
height: int
|
||||||
|
from_cache: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
def _session():
|
||||||
|
session = requests.Session()
|
||||||
|
session.trust_env = False
|
||||||
|
return session
|
||||||
|
|
||||||
|
|
||||||
|
def _assert_public_http_url(url):
|
||||||
|
parts = urllib.parse.urlsplit(str(url or ""))
|
||||||
|
if parts.scheme not in {"http", "https"}:
|
||||||
|
raise ImageStudioImageError("远程图片地址只允许 http/https")
|
||||||
|
host = parts.hostname
|
||||||
|
if not host:
|
||||||
|
raise ImageStudioImageError("远程图片地址缺少域名")
|
||||||
|
if _is_local_hostname(host):
|
||||||
|
raise ImageStudioImageError("远程图片地址不能指向本机或内网")
|
||||||
|
try:
|
||||||
|
_assert_public_ip(host)
|
||||||
|
return
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
addresses = socket.getaddrinfo(
|
||||||
|
host,
|
||||||
|
parts.port or (443 if parts.scheme == "https" else 80),
|
||||||
|
type=socket.SOCK_STREAM,
|
||||||
|
)
|
||||||
|
except OSError as exc:
|
||||||
|
raise ImageStudioImageError(f"远程图片地址无法解析:{exc}") from exc
|
||||||
|
if not addresses:
|
||||||
|
raise ImageStudioImageError("远程图片地址无法解析")
|
||||||
|
for address in addresses:
|
||||||
|
_assert_public_ip(address[4][0])
|
||||||
|
|
||||||
|
|
||||||
|
def _is_local_hostname(host):
|
||||||
|
lowered = str(host or "").strip().lower().rstrip(".")
|
||||||
|
return lowered in {"localhost"} or lowered.endswith(".localhost") or lowered.endswith(".local")
|
||||||
|
|
||||||
|
|
||||||
|
def _assert_public_ip(value):
|
||||||
|
ip = ipaddress.ip_address(value)
|
||||||
|
if (
|
||||||
|
ip.is_private
|
||||||
|
or ip.is_loopback
|
||||||
|
or ip.is_link_local
|
||||||
|
or ip.is_multicast
|
||||||
|
or ip.is_reserved
|
||||||
|
or ip.is_unspecified
|
||||||
|
):
|
||||||
|
raise ImageStudioImageError("远程图片地址不能指向本机或内网")
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_headers(referer=None):
|
||||||
|
headers = {
|
||||||
|
"User-Agent": "Mozilla/5.0",
|
||||||
|
"Accept": "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8",
|
||||||
|
}
|
||||||
|
if referer:
|
||||||
|
headers["Referer"] = str(referer)
|
||||||
|
return headers
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_content_type(value):
|
||||||
|
content_type = str(value or "").split(";", 1)[0].strip().lower()
|
||||||
|
if content_type and not content_type.startswith("image/"):
|
||||||
|
raise ImageStudioImageError("远程地址返回的不是图片内容")
|
||||||
|
return content_type
|
||||||
|
|
||||||
|
|
||||||
|
def download_remote_image(
|
||||||
|
url,
|
||||||
|
*,
|
||||||
|
referer=None,
|
||||||
|
max_bytes=ORIGINAL_MAX_BYTES,
|
||||||
|
timeout=(DEFAULT_CONNECT_TIMEOUT_SECONDS, DEFAULT_READ_TIMEOUT_SECONDS),
|
||||||
|
session=None,
|
||||||
|
should_stop=None,
|
||||||
|
) -> RemoteImage:
|
||||||
|
"""Download remote image bytes with SSRF, timeout and size guards."""
|
||||||
|
|
||||||
|
url = str(url or "").strip()
|
||||||
|
should_stop = should_stop or (lambda: False)
|
||||||
|
if should_stop():
|
||||||
|
raise ImageStudioImageCancelled("用户停止下载商品原图")
|
||||||
|
_assert_public_http_url(url)
|
||||||
|
client = session or _session()
|
||||||
|
try:
|
||||||
|
response = client.get(
|
||||||
|
url,
|
||||||
|
stream=True,
|
||||||
|
timeout=timeout,
|
||||||
|
headers=_safe_headers(referer),
|
||||||
|
)
|
||||||
|
except requests.exceptions.RequestException as exc:
|
||||||
|
raise ImageStudioImageError(f"远程图片下载失败:{exc}") from exc
|
||||||
|
try:
|
||||||
|
if should_stop():
|
||||||
|
raise ImageStudioImageCancelled("用户停止下载商品原图")
|
||||||
|
status = getattr(response, "status_code", 200)
|
||||||
|
if status >= 400:
|
||||||
|
raise ImageStudioImageError(f"远程图片下载失败:HTTP {status}")
|
||||||
|
final_url = str(getattr(response, "url", "") or url)
|
||||||
|
_assert_public_http_url(final_url)
|
||||||
|
content_type = _validate_content_type(
|
||||||
|
getattr(response, "headers", {}).get("Content-Type", "")
|
||||||
|
)
|
||||||
|
content_length = getattr(response, "headers", {}).get("Content-Length")
|
||||||
|
if content_length:
|
||||||
|
try:
|
||||||
|
if int(content_length) > int(max_bytes):
|
||||||
|
raise ImageStudioImageError("远程图片超过大小上限")
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
chunks = []
|
||||||
|
total = 0
|
||||||
|
iterator = (
|
||||||
|
response.iter_content(chunk_size=65536)
|
||||||
|
if hasattr(response, "iter_content")
|
||||||
|
else [response.content]
|
||||||
|
)
|
||||||
|
for chunk in iterator:
|
||||||
|
if should_stop():
|
||||||
|
raise ImageStudioImageCancelled("用户停止下载商品原图")
|
||||||
|
if not chunk:
|
||||||
|
continue
|
||||||
|
total += len(chunk)
|
||||||
|
if total > int(max_bytes):
|
||||||
|
raise ImageStudioImageError("远程图片超过大小上限")
|
||||||
|
chunks.append(chunk)
|
||||||
|
if should_stop():
|
||||||
|
raise ImageStudioImageCancelled("用户停止下载商品原图")
|
||||||
|
content = b"".join(chunks)
|
||||||
|
_image_info(content)
|
||||||
|
return RemoteImage(
|
||||||
|
url=url,
|
||||||
|
content=content,
|
||||||
|
content_type=content_type,
|
||||||
|
final_url=final_url,
|
||||||
|
redirected=final_url != url,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
close = getattr(response, "close", None)
|
||||||
|
if callable(close):
|
||||||
|
close()
|
||||||
|
|
||||||
|
|
||||||
|
def _image_info(image_bytes):
|
||||||
|
try:
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
with Image.open(io.BytesIO(image_bytes)) as image:
|
||||||
|
image.verify()
|
||||||
|
with Image.open(io.BytesIO(image_bytes)) as image:
|
||||||
|
return {
|
||||||
|
"format": str(image.format or "JPEG").upper(),
|
||||||
|
"width": int(image.width),
|
||||||
|
"height": int(image.height),
|
||||||
|
}
|
||||||
|
except Exception as exc:
|
||||||
|
raise ImageStudioImageError(f"图片解码失败:{exc}") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _thumbnail_bytes(image_bytes, max_size=DEFAULT_THUMBNAIL_SIZE):
|
||||||
|
try:
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
with Image.open(io.BytesIO(image_bytes)) as image:
|
||||||
|
image.thumbnail((int(max_size), int(max_size)))
|
||||||
|
output = io.BytesIO()
|
||||||
|
image.convert("RGBA").save(output, format="PNG")
|
||||||
|
return output.getvalue(), image.width, image.height
|
||||||
|
except Exception as exc:
|
||||||
|
raise ImageStudioImageError(f"缩略图解码失败:{exc}") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def load_thumbnail(url, *, key=None, max_size=DEFAULT_THUMBNAIL_SIZE, referer=None, session=None):
|
||||||
|
remote = download_remote_image(
|
||||||
|
url,
|
||||||
|
referer=referer,
|
||||||
|
max_bytes=THUMBNAIL_MAX_BYTES,
|
||||||
|
session=session,
|
||||||
|
)
|
||||||
|
image_bytes, width, height = _thumbnail_bytes(remote.content, max_size=max_size)
|
||||||
|
return ThumbnailResult(
|
||||||
|
key=str(key or url),
|
||||||
|
url=remote.url,
|
||||||
|
image_bytes=image_bytes,
|
||||||
|
width=width,
|
||||||
|
height=height,
|
||||||
|
from_cache=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ThumbnailLoader:
|
||||||
|
"""Small thread-pool thumbnail loader with memory cache and cooperative cancel."""
|
||||||
|
|
||||||
|
def __init__(self, max_workers=DEFAULT_THUMBNAIL_WORKERS, loader: Optional[Callable] = None):
|
||||||
|
self._executor = ThreadPoolExecutor(max_workers=max(1, int(max_workers or 1)))
|
||||||
|
self._loader = loader or load_thumbnail
|
||||||
|
self._cache = {}
|
||||||
|
self._cancelled = set()
|
||||||
|
self._closed = False
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
|
||||||
|
def submit(self, key, url, on_success=None, on_error=None):
|
||||||
|
key = str(key)
|
||||||
|
url = str(url or "").strip()
|
||||||
|
with self._lock:
|
||||||
|
if self._closed:
|
||||||
|
raise ImageStudioImageError("缩略图加载器已关闭")
|
||||||
|
cached = self._cache.get(url)
|
||||||
|
if cached is not None:
|
||||||
|
result = ThumbnailResult(key, url, cached.image_bytes, cached.width, cached.height, from_cache=True)
|
||||||
|
if on_success is not None:
|
||||||
|
on_success(result)
|
||||||
|
return None
|
||||||
|
self._cancelled.discard(key)
|
||||||
|
future = self._executor.submit(self._loader, url, key=key)
|
||||||
|
|
||||||
|
def done_callback(done):
|
||||||
|
try:
|
||||||
|
result = done.result()
|
||||||
|
with self._lock:
|
||||||
|
if key in self._cancelled or self._closed:
|
||||||
|
return
|
||||||
|
self._cache[url] = result
|
||||||
|
if on_success is not None:
|
||||||
|
on_success(result)
|
||||||
|
except Exception as exc:
|
||||||
|
with self._lock:
|
||||||
|
if key in self._cancelled or self._closed:
|
||||||
|
return
|
||||||
|
if on_error is not None:
|
||||||
|
on_error(key, exc)
|
||||||
|
|
||||||
|
future.add_done_callback(done_callback)
|
||||||
|
return future
|
||||||
|
|
||||||
|
def cancel(self, key=None):
|
||||||
|
with self._lock:
|
||||||
|
if key is None:
|
||||||
|
self._cancelled.add("*")
|
||||||
|
self._closed = True
|
||||||
|
else:
|
||||||
|
self._cancelled.add(str(key))
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
with self._lock:
|
||||||
|
self._closed = True
|
||||||
|
self._executor.shutdown(wait=False, cancel_futures=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _extension_for_format(format_name):
|
||||||
|
normalized = str(format_name or "").strip().upper()
|
||||||
|
if normalized in {"JPEG", "JPG"}:
|
||||||
|
return ".jpg"
|
||||||
|
if normalized == "PNG":
|
||||||
|
return ".png"
|
||||||
|
if normalized == "WEBP":
|
||||||
|
return ".webp"
|
||||||
|
return ".img"
|
||||||
|
|
||||||
|
|
||||||
|
def _original_file_path(project, asset, image_root):
|
||||||
|
dirs = image_studio.project_image_dirs(image_root, project)
|
||||||
|
digest = hashlib.sha1(str(asset.remote_url or asset.id).encode("utf-8")).hexdigest()[:10]
|
||||||
|
stem = "original_%02d_%s" % (max(1, int(asset.source_order or 1)), digest)
|
||||||
|
return dirs["originals"], stem
|
||||||
|
|
||||||
|
|
||||||
|
def _existing_local_asset(asset):
|
||||||
|
local_path = str(asset.local_path or "").strip()
|
||||||
|
if not local_path or not os.path.isfile(local_path):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
with open(local_path, "rb") as fh:
|
||||||
|
_image_info(fh.read())
|
||||||
|
return asset
|
||||||
|
except ImageStudioImageError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def import_original_files(
|
||||||
|
project_id,
|
||||||
|
file_paths,
|
||||||
|
*,
|
||||||
|
path=None,
|
||||||
|
config=None,
|
||||||
|
image_root=None,
|
||||||
|
max_assets=MAX_ORIGINAL_ASSETS,
|
||||||
|
):
|
||||||
|
"""Validate and atomically copy local product images into one studio project."""
|
||||||
|
|
||||||
|
imported = []
|
||||||
|
errors = []
|
||||||
|
for file_path in file_paths or []:
|
||||||
|
try:
|
||||||
|
with open(os.path.abspath(str(file_path)), "rb") as fh:
|
||||||
|
content = fh.read(int(ORIGINAL_MAX_BYTES) + 1)
|
||||||
|
imported.append(
|
||||||
|
import_original_bytes(
|
||||||
|
project_id,
|
||||||
|
content,
|
||||||
|
filename_hint=os.path.basename(str(file_path)),
|
||||||
|
path=path,
|
||||||
|
config=config,
|
||||||
|
image_root=image_root,
|
||||||
|
max_assets=max_assets,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
errors.append({"path": os.path.abspath(str(file_path)), "error": str(exc)})
|
||||||
|
return {"assets": imported, "errors": errors, "limit": int(max_assets)}
|
||||||
|
|
||||||
|
|
||||||
|
def import_original_bytes(
|
||||||
|
project_id,
|
||||||
|
content,
|
||||||
|
*,
|
||||||
|
filename_hint="clipboard.png",
|
||||||
|
path=None,
|
||||||
|
config=None,
|
||||||
|
image_root=None,
|
||||||
|
max_assets=MAX_ORIGINAL_ASSETS,
|
||||||
|
):
|
||||||
|
cfg = appconfig.load_config() if config is None else config
|
||||||
|
database_path = path or appconfig.db_path(cfg)
|
||||||
|
project = image_studio.get_project(project_id, path=database_path)
|
||||||
|
if project is None:
|
||||||
|
raise ImageStudioImageError("商品套图任务不存在")
|
||||||
|
image_bytes = bytes(content or b"")
|
||||||
|
if not image_bytes:
|
||||||
|
raise ImageStudioImageError("商品原图内容为空")
|
||||||
|
if len(image_bytes) > int(ORIGINAL_MAX_BYTES):
|
||||||
|
raise ImageStudioImageError("商品原图超过大小上限")
|
||||||
|
info = _image_info(image_bytes)
|
||||||
|
originals = image_studio.list_assets(
|
||||||
|
project.id,
|
||||||
|
kind=image_studio.ASSET_KIND_ORIGINAL,
|
||||||
|
path=database_path,
|
||||||
|
)
|
||||||
|
active_originals = [
|
||||||
|
asset for asset in originals if asset.status != image_studio.ASSET_STATUS_MISSING
|
||||||
|
]
|
||||||
|
digest = hashlib.sha256(image_bytes).hexdigest()[:16]
|
||||||
|
duplicate = next(
|
||||||
|
(
|
||||||
|
asset
|
||||||
|
for asset in originals
|
||||||
|
if digest in os.path.basename(str(asset.local_path or ""))
|
||||||
|
and _existing_local_asset(asset) is not None
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if duplicate is not None:
|
||||||
|
return duplicate
|
||||||
|
if len(active_originals) >= int(max_assets):
|
||||||
|
raise ImageStudioImageError("商品原图最多只能添加%d张" % int(max_assets))
|
||||||
|
|
||||||
|
directory = image_studio.project_image_dirs(
|
||||||
|
image_root or appconfig.image_dir(cfg),
|
||||||
|
project,
|
||||||
|
)["originals"]
|
||||||
|
os.makedirs(directory, exist_ok=True)
|
||||||
|
order = max([int(asset.source_order or 0) for asset in originals] + [0]) + 1
|
||||||
|
extension = _extension_for_format(info["format"])
|
||||||
|
safe_hint = os.path.splitext(os.path.basename(str(filename_hint or "image")))[0]
|
||||||
|
safe_hint = "".join(ch if ch.isalnum() or ch in "_-" else "_" for ch in safe_hint)
|
||||||
|
safe_hint = safe_hint.strip("_")[:32] or "image"
|
||||||
|
final_path = os.path.join(
|
||||||
|
directory,
|
||||||
|
"original_%02d_%s_%s%s" % (order, safe_hint, digest, extension),
|
||||||
|
)
|
||||||
|
temp_path = final_path + ".tmp-" + uuid.uuid4().hex
|
||||||
|
created = False
|
||||||
|
try:
|
||||||
|
with open(temp_path, "wb") as fh:
|
||||||
|
fh.write(image_bytes)
|
||||||
|
with open(temp_path, "rb") as fh:
|
||||||
|
_image_info(fh.read())
|
||||||
|
os.replace(temp_path, final_path)
|
||||||
|
created = True
|
||||||
|
return image_studio.add_asset(
|
||||||
|
project.id,
|
||||||
|
image_studio.ASSET_KIND_ORIGINAL,
|
||||||
|
local_path=final_path,
|
||||||
|
aspect_ratio="%d:%d" % (int(info["width"]), int(info["height"])),
|
||||||
|
source_order=order,
|
||||||
|
path=database_path,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
for candidate in (temp_path, final_path if created else None):
|
||||||
|
if candidate and os.path.exists(candidate):
|
||||||
|
try:
|
||||||
|
os.remove(candidate)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
if isinstance(exc, ImageStudioImageError):
|
||||||
|
raise
|
||||||
|
raise ImageStudioImageError(f"保存商品原图失败:{exc}") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def trash_generated_asset(asset_id, *, path=None, config=None, image_root=None):
|
||||||
|
"""Move one generated image to the app-managed trash without losing DB history."""
|
||||||
|
|
||||||
|
cfg = appconfig.load_config() if config is None else config
|
||||||
|
database_path = path or appconfig.db_path(cfg)
|
||||||
|
asset = image_studio.get_asset(asset_id, path=database_path)
|
||||||
|
if asset is None or not str(asset.kind or "").startswith("generated_"):
|
||||||
|
raise ImageStudioImageError("只能删除商品套图生成结果")
|
||||||
|
source_path = os.path.abspath(str(asset.local_path or ""))
|
||||||
|
if not source_path or not os.path.isfile(source_path):
|
||||||
|
raise ImageStudioImageError("生成图片文件不存在")
|
||||||
|
project = image_studio.get_project(asset.project_id, path=database_path)
|
||||||
|
if project is None:
|
||||||
|
raise ImageStudioImageError("商品套图任务不存在")
|
||||||
|
root = image_studio.project_image_dirs(
|
||||||
|
image_root or appconfig.image_dir(cfg),
|
||||||
|
project,
|
||||||
|
)["root"]
|
||||||
|
trash_dir = os.path.join(root, ".trash")
|
||||||
|
os.makedirs(trash_dir, exist_ok=True)
|
||||||
|
trash_path = os.path.join(
|
||||||
|
trash_dir,
|
||||||
|
"%s_%s" % (uuid.uuid4().hex, os.path.basename(source_path)),
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
os.replace(source_path, trash_path)
|
||||||
|
image_studio.update_asset_local_path(
|
||||||
|
asset.id,
|
||||||
|
trash_path,
|
||||||
|
status=image_studio.ASSET_STATUS_MISSING,
|
||||||
|
path=database_path,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
if os.path.isfile(trash_path) and not os.path.exists(source_path):
|
||||||
|
try:
|
||||||
|
os.replace(trash_path, source_path)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
raise ImageStudioImageError(f"删除生成图片失败:{exc}") from exc
|
||||||
|
return {
|
||||||
|
"asset_id": asset.id,
|
||||||
|
"original_path": source_path,
|
||||||
|
"trash_path": trash_path,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def restore_trashed_asset(record, *, path=None, config=None):
|
||||||
|
cfg = appconfig.load_config() if config is None else config
|
||||||
|
database_path = path or appconfig.db_path(cfg)
|
||||||
|
payload = dict(record or {})
|
||||||
|
asset = image_studio.get_asset(payload.get("asset_id"), path=database_path)
|
||||||
|
if asset is None:
|
||||||
|
raise ImageStudioImageError("待撤销的生成图片记录不存在")
|
||||||
|
trash_path = os.path.abspath(str(payload.get("trash_path") or ""))
|
||||||
|
original_path = os.path.abspath(str(payload.get("original_path") or ""))
|
||||||
|
if not os.path.isfile(trash_path):
|
||||||
|
raise ImageStudioImageError("废纸篓中的生成图片不存在")
|
||||||
|
if os.path.exists(original_path):
|
||||||
|
stem, extension = os.path.splitext(original_path)
|
||||||
|
original_path = "%s_restored_%s%s" % (stem, uuid.uuid4().hex[:8], extension)
|
||||||
|
os.makedirs(os.path.dirname(original_path), exist_ok=True)
|
||||||
|
os.replace(trash_path, original_path)
|
||||||
|
try:
|
||||||
|
return image_studio.update_asset_local_path(
|
||||||
|
asset.id,
|
||||||
|
original_path,
|
||||||
|
status=image_studio.ASSET_STATUS_AVAILABLE,
|
||||||
|
path=database_path,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
try:
|
||||||
|
os.replace(original_path, trash_path)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
raise ImageStudioImageError(f"撤销删除生成图片失败:{exc}") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def download_original_asset(
|
||||||
|
asset_id,
|
||||||
|
*,
|
||||||
|
path=None,
|
||||||
|
config=None,
|
||||||
|
image_root=None,
|
||||||
|
session=None,
|
||||||
|
should_stop=None,
|
||||||
|
):
|
||||||
|
"""Download one Shopee original image into originals/ and mark its asset available."""
|
||||||
|
|
||||||
|
cfg = appconfig.load_config() if config is None else config
|
||||||
|
should_stop = should_stop or (lambda: False)
|
||||||
|
if should_stop():
|
||||||
|
raise ImageStudioImageCancelled("用户停止下载商品原图")
|
||||||
|
database_path = path or appconfig.db_path(cfg)
|
||||||
|
asset = image_studio.get_asset(asset_id, path=database_path)
|
||||||
|
if asset is None:
|
||||||
|
raise ImageStudioImageError("原图资产不存在")
|
||||||
|
if not asset.remote_url:
|
||||||
|
raise ImageStudioImageError("原图资产缺少远程 URL")
|
||||||
|
existing = _existing_local_asset(asset)
|
||||||
|
if existing is not None:
|
||||||
|
return existing
|
||||||
|
project = image_studio.get_project(asset.project_id, path=database_path)
|
||||||
|
if project is None:
|
||||||
|
raise ImageStudioImageError("原图资产所属项目不存在")
|
||||||
|
remote = download_remote_image(
|
||||||
|
asset.remote_url,
|
||||||
|
max_bytes=ORIGINAL_MAX_BYTES,
|
||||||
|
session=session,
|
||||||
|
should_stop=should_stop,
|
||||||
|
)
|
||||||
|
if should_stop():
|
||||||
|
raise ImageStudioImageCancelled("用户停止下载商品原图")
|
||||||
|
info = _image_info(remote.content)
|
||||||
|
directory, stem = _original_file_path(project, asset, image_root or appconfig.image_dir(cfg))
|
||||||
|
os.makedirs(directory, exist_ok=True)
|
||||||
|
final_path = os.path.join(directory, stem + _extension_for_format(info["format"]))
|
||||||
|
temp_path = final_path + ".tmp-" + uuid.uuid4().hex
|
||||||
|
try:
|
||||||
|
if should_stop():
|
||||||
|
raise ImageStudioImageCancelled("用户停止下载商品原图")
|
||||||
|
with open(temp_path, "wb") as fh:
|
||||||
|
fh.write(remote.content)
|
||||||
|
if should_stop():
|
||||||
|
raise ImageStudioImageCancelled("用户停止下载商品原图")
|
||||||
|
with open(temp_path, "rb") as fh:
|
||||||
|
_image_info(fh.read())
|
||||||
|
os.replace(temp_path, final_path)
|
||||||
|
except Exception as exc:
|
||||||
|
try:
|
||||||
|
if os.path.exists(temp_path):
|
||||||
|
os.remove(temp_path)
|
||||||
|
finally:
|
||||||
|
if isinstance(exc, ImageStudioImageError):
|
||||||
|
raise
|
||||||
|
raise ImageStudioImageError(f"保存原图失败:{exc}") from exc
|
||||||
|
return image_studio.update_asset_local_path(
|
||||||
|
asset.id,
|
||||||
|
final_path,
|
||||||
|
status=image_studio.ASSET_STATUS_AVAILABLE,
|
||||||
|
path=database_path,
|
||||||
|
)
|
||||||
@@ -0,0 +1,317 @@
|
|||||||
|
"""Shared product-status rules for the collection, generation, and apply flows."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
|
||||||
|
|
||||||
|
STATUS_NORMAL = "normal"
|
||||||
|
STATUS_UNLISTED = "unlisted"
|
||||||
|
STATUS_REVIEWING = "reviewing"
|
||||||
|
STATUS_UNKNOWN = "unknown"
|
||||||
|
STATUS_UNCHECKED = "unchecked"
|
||||||
|
|
||||||
|
VALID_PRODUCT_STATUSES = frozenset(
|
||||||
|
{
|
||||||
|
STATUS_NORMAL,
|
||||||
|
STATUS_UNLISTED,
|
||||||
|
STATUS_REVIEWING,
|
||||||
|
STATUS_UNKNOWN,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
PRODUCT_STATUS_LABELS = {
|
||||||
|
STATUS_NORMAL: "正常",
|
||||||
|
STATUS_UNLISTED: "未上架",
|
||||||
|
STATUS_REVIEWING: "审核中",
|
||||||
|
STATUS_UNKNOWN: "状态未知",
|
||||||
|
}
|
||||||
|
|
||||||
|
PRODUCT_STATUS_FILTER_ITEMS = (
|
||||||
|
("全部商品状态", "all"),
|
||||||
|
("架上商品", STATUS_NORMAL),
|
||||||
|
("未上架", STATUS_UNLISTED),
|
||||||
|
("审核中", STATUS_REVIEWING),
|
||||||
|
("状态未知", STATUS_UNKNOWN),
|
||||||
|
("待检测", STATUS_UNCHECKED),
|
||||||
|
)
|
||||||
|
|
||||||
|
PRODUCT_STATUS_DISPLAY_LABELS = {
|
||||||
|
STATUS_NORMAL: "架上商品",
|
||||||
|
STATUS_UNLISTED: "未上架",
|
||||||
|
STATUS_REVIEWING: "审核中",
|
||||||
|
STATUS_UNKNOWN: "状态未知",
|
||||||
|
STATUS_UNCHECKED: "待检测",
|
||||||
|
}
|
||||||
|
|
||||||
|
SCOPE_NORMAL_ONLY = "normal_only"
|
||||||
|
SCOPE_ALL = "all"
|
||||||
|
|
||||||
|
# Kept as explicit collection aliases while generation and apply adopt the
|
||||||
|
# shared scope values in their own tasks.
|
||||||
|
COLLECT_SCOPE_NORMAL_ONLY = SCOPE_NORMAL_ONLY
|
||||||
|
COLLECT_SCOPE_ALL = SCOPE_ALL
|
||||||
|
|
||||||
|
_WHITESPACE_RE = re.compile(r"\s+")
|
||||||
|
_NOTE_LIMIT = 2000
|
||||||
|
_PROMOTION_EDIT_RESTRICTION_MARKERS = (
|
||||||
|
("促銷", "無法進行編輯"),
|
||||||
|
("促销", "无法进行编辑"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_status(value) -> str:
|
||||||
|
"""Return a stable status code; legacy and invalid values are unknown."""
|
||||||
|
|
||||||
|
normalized = str(value or "").strip().lower()
|
||||||
|
if normalized in VALID_PRODUCT_STATUSES:
|
||||||
|
return normalized
|
||||||
|
return STATUS_UNKNOWN
|
||||||
|
|
||||||
|
|
||||||
|
def status_label(value) -> str:
|
||||||
|
return PRODUCT_STATUS_LABELS[normalize_status(value)]
|
||||||
|
|
||||||
|
|
||||||
|
def display_status(value) -> str:
|
||||||
|
"""Return the UI label without treating an empty snapshot as unknown."""
|
||||||
|
|
||||||
|
return PRODUCT_STATUS_DISPLAY_LABELS[raw_status(value)]
|
||||||
|
|
||||||
|
|
||||||
|
def raw_status(value) -> str:
|
||||||
|
"""Return a UI filter status while keeping blank snapshots distinguishable."""
|
||||||
|
|
||||||
|
if not str(value or "").strip():
|
||||||
|
return STATUS_UNCHECKED
|
||||||
|
return normalize_status(value)
|
||||||
|
|
||||||
|
|
||||||
|
def is_normal(value) -> bool:
|
||||||
|
return normalize_status(value) == STATUS_NORMAL
|
||||||
|
|
||||||
|
|
||||||
|
def is_known_abnormal(value) -> bool:
|
||||||
|
return normalize_status(value) in {STATUS_UNLISTED, STATUS_REVIEWING}
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_text(value) -> str:
|
||||||
|
return _WHITESPACE_RE.sub(" ", str(value or "").strip())
|
||||||
|
|
||||||
|
|
||||||
|
def is_promotion_edit_restriction_alert(title, description="") -> bool:
|
||||||
|
"""Return whether an alert only describes a promotion editing limitation."""
|
||||||
|
|
||||||
|
text = normalize_text(f"{title or ''} {description or ''}")
|
||||||
|
return any(
|
||||||
|
promotion_marker in text and edit_marker in text
|
||||||
|
for promotion_marker, edit_marker in _PROMOTION_EDIT_RESTRICTION_MARKERS
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def is_promotion_edit_restriction_note(note) -> bool:
|
||||||
|
"""Recognize the persisted summary generated from a known benign alert."""
|
||||||
|
|
||||||
|
return is_promotion_edit_restriction_alert(note)
|
||||||
|
|
||||||
|
|
||||||
|
def classify_alerts(alerts) -> dict:
|
||||||
|
"""Classify normalized EDS warning-alert data without retaining page HTML."""
|
||||||
|
|
||||||
|
if not isinstance(alerts, list):
|
||||||
|
return _snapshot(STATUS_UNKNOWN, "")
|
||||||
|
if not alerts:
|
||||||
|
return _snapshot(STATUS_NORMAL, "")
|
||||||
|
|
||||||
|
valid_alert_seen = False
|
||||||
|
unknown_alert_note = ""
|
||||||
|
for alert in alerts:
|
||||||
|
if not isinstance(alert, dict):
|
||||||
|
continue
|
||||||
|
valid_alert_seen = True
|
||||||
|
title = normalize_text(alert.get("title"))
|
||||||
|
description = normalize_text(alert.get("description"))
|
||||||
|
note = _alert_note(title, description)
|
||||||
|
if title in {"審核中", "审核中"}:
|
||||||
|
return _snapshot(STATUS_REVIEWING, note)
|
||||||
|
if "您的商品未上架" in title:
|
||||||
|
return _snapshot(STATUS_UNLISTED, note)
|
||||||
|
if is_promotion_edit_restriction_alert(title, description):
|
||||||
|
continue
|
||||||
|
if not unknown_alert_note:
|
||||||
|
unknown_alert_note = note
|
||||||
|
|
||||||
|
if not valid_alert_seen:
|
||||||
|
return _snapshot(STATUS_UNKNOWN, "")
|
||||||
|
if not unknown_alert_note:
|
||||||
|
return _snapshot(STATUS_NORMAL, "")
|
||||||
|
return _snapshot(STATUS_UNKNOWN, unknown_alert_note)
|
||||||
|
|
||||||
|
|
||||||
|
def partition_tasks(tasks) -> dict:
|
||||||
|
"""Return tasks grouped by normalized status for callers' run planners."""
|
||||||
|
|
||||||
|
grouped = {status: [] for status in VALID_PRODUCT_STATUSES}
|
||||||
|
for task in tasks:
|
||||||
|
grouped[normalize_status(getattr(task, "product_status", None))].append(task)
|
||||||
|
return grouped
|
||||||
|
|
||||||
|
|
||||||
|
def build_generation_plan(tasks, generate_mode, scope=SCOPE_NORMAL_ONLY) -> dict:
|
||||||
|
"""Build a frozen, status-aware generation plan without mutating tasks."""
|
||||||
|
|
||||||
|
base_candidates = _deduplicate_tasks(tasks)
|
||||||
|
status_counts = {status: 0 for status in VALID_PRODUCT_STATUSES}
|
||||||
|
for task in base_candidates:
|
||||||
|
status_counts[_task_status(task)] += 1
|
||||||
|
|
||||||
|
normalized_scope = normalize_scope(scope)
|
||||||
|
if normalized_scope == SCOPE_ALL:
|
||||||
|
execution_tasks = list(base_candidates)
|
||||||
|
else:
|
||||||
|
execution_tasks = [
|
||||||
|
task for task in base_candidates if is_normal(_task_status(task))
|
||||||
|
]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"base_candidates": base_candidates,
|
||||||
|
"status_counts": status_counts,
|
||||||
|
"execution_tasks": execution_tasks,
|
||||||
|
"scope": normalized_scope,
|
||||||
|
"scope_excluded": len(base_candidates) - len(execution_tasks),
|
||||||
|
"fingerprint": _task_plan_fingerprint(base_candidates, generate_mode),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_apply_plan(tasks, update_mode) -> dict:
|
||||||
|
"""Return a status-first, content-aware plan for Shopee update actions."""
|
||||||
|
|
||||||
|
base_candidates = _deduplicate_tasks(tasks)
|
||||||
|
status_counts = {status: 0 for status in VALID_PRODUCT_STATUSES}
|
||||||
|
status_excluded = {
|
||||||
|
STATUS_UNLISTED: [],
|
||||||
|
STATUS_REVIEWING: [],
|
||||||
|
STATUS_UNKNOWN: [],
|
||||||
|
}
|
||||||
|
normal_candidates = []
|
||||||
|
for task in base_candidates:
|
||||||
|
status = _task_status(task)
|
||||||
|
status_counts[status] += 1
|
||||||
|
if status == STATUS_NORMAL:
|
||||||
|
normal_candidates.append(task)
|
||||||
|
else:
|
||||||
|
status_excluded[status].append(task)
|
||||||
|
|
||||||
|
mode = str(update_mode or "").strip().lower()
|
||||||
|
requires_title = mode in {"title", "title_cover"}
|
||||||
|
requires_cover = mode in {"cover", "title_cover"}
|
||||||
|
missing_title = [
|
||||||
|
task
|
||||||
|
for task in normal_candidates
|
||||||
|
if requires_title and not str(_task_value(task, "new_title") or "").strip()
|
||||||
|
]
|
||||||
|
missing_cover = [
|
||||||
|
task
|
||||||
|
for task in normal_candidates
|
||||||
|
if requires_cover and not str(_task_value(task, "new_cover_path") or "").strip()
|
||||||
|
]
|
||||||
|
content_excluded_ids = {id(task) for task in missing_title + missing_cover}
|
||||||
|
executable_tasks = [
|
||||||
|
task for task in normal_candidates if id(task) not in content_excluded_ids
|
||||||
|
]
|
||||||
|
status_excluded_count = sum(len(rows) for rows in status_excluded.values())
|
||||||
|
|
||||||
|
return {
|
||||||
|
"base_candidates": base_candidates,
|
||||||
|
"status_counts": status_counts,
|
||||||
|
"executable": executable_tasks,
|
||||||
|
"unlisted": status_excluded[STATUS_UNLISTED],
|
||||||
|
"reviewing": status_excluded[STATUS_REVIEWING],
|
||||||
|
"unknown": status_excluded[STATUS_UNKNOWN],
|
||||||
|
"missing_title": missing_title,
|
||||||
|
"missing_cover": missing_cover,
|
||||||
|
"status_scope_excluded": status_excluded_count,
|
||||||
|
"content_scope_excluded": len(content_excluded_ids),
|
||||||
|
"scope_excluded": status_excluded_count + len(content_excluded_ids),
|
||||||
|
"fingerprint": _task_plan_fingerprint(base_candidates, update_mode),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_collect_scope(value) -> str:
|
||||||
|
return normalize_scope(value)
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_scope(value) -> str:
|
||||||
|
value = str(value or "").strip().lower()
|
||||||
|
return SCOPE_ALL if value == SCOPE_ALL else SCOPE_NORMAL_ONLY
|
||||||
|
|
||||||
|
|
||||||
|
def should_collect_content(status, scope) -> bool:
|
||||||
|
return normalize_collect_scope(scope) == COLLECT_SCOPE_ALL or is_normal(status)
|
||||||
|
|
||||||
|
|
||||||
|
def collect_skip_reason(status) -> str:
|
||||||
|
return f"{status_label(status)},按本轮范围略过"
|
||||||
|
|
||||||
|
|
||||||
|
def _deduplicate_tasks(tasks) -> list:
|
||||||
|
seen = set()
|
||||||
|
unique = []
|
||||||
|
for task in list(tasks or []):
|
||||||
|
task_id = _task_value(task, "id")
|
||||||
|
key = ("id", str(task_id)) if task_id is not None else ("object", id(task))
|
||||||
|
if key in seen:
|
||||||
|
continue
|
||||||
|
seen.add(key)
|
||||||
|
unique.append(task)
|
||||||
|
return unique
|
||||||
|
|
||||||
|
|
||||||
|
def _task_plan_fingerprint(tasks, mode) -> str:
|
||||||
|
snapshots = [
|
||||||
|
{
|
||||||
|
"task_id": _task_value(task, "id"),
|
||||||
|
"updated_at": _task_value(task, "updated_at"),
|
||||||
|
"product_status": _task_status(task),
|
||||||
|
"new_title": _task_value(task, "new_title"),
|
||||||
|
"new_cover_path": _task_value(task, "new_cover_path"),
|
||||||
|
"mode": str(mode or ""),
|
||||||
|
}
|
||||||
|
for task in tasks
|
||||||
|
]
|
||||||
|
encoded = json.dumps(
|
||||||
|
snapshots,
|
||||||
|
ensure_ascii=False,
|
||||||
|
sort_keys=True,
|
||||||
|
separators=(",", ":"),
|
||||||
|
default=str,
|
||||||
|
).encode("utf-8")
|
||||||
|
return hashlib.sha256(encoded).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _task_status(task) -> str:
|
||||||
|
return normalize_status(_task_value(task, "product_status"))
|
||||||
|
|
||||||
|
|
||||||
|
def _task_value(task, name, default=None):
|
||||||
|
if isinstance(task, dict):
|
||||||
|
return task.get(name, default)
|
||||||
|
return getattr(task, name, default)
|
||||||
|
|
||||||
|
|
||||||
|
def _alert_note(title: str, description: str) -> str:
|
||||||
|
parts = []
|
||||||
|
if title:
|
||||||
|
parts.append(f"标题:{title}")
|
||||||
|
if description:
|
||||||
|
parts.append(f"说明:{description}")
|
||||||
|
return ";".join(parts)[:_NOTE_LIMIT]
|
||||||
|
|
||||||
|
|
||||||
|
def _snapshot(status: str, note: str) -> dict:
|
||||||
|
return {
|
||||||
|
"product_status": normalize_status(status),
|
||||||
|
"product_status_note": str(note or "")[:_NOTE_LIMIT] or None,
|
||||||
|
}
|
||||||
@@ -0,0 +1,417 @@
|
|||||||
|
"""Pure product-suite configuration and generation planning helpers."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections import OrderedDict
|
||||||
|
import re
|
||||||
|
|
||||||
|
|
||||||
|
FIXED_CATEGORIES = ("白底图", "场景图", "模特场景图", "细节说明图", "卖点图")
|
||||||
|
FIXED_CATEGORY_HELPERS = {
|
||||||
|
"白底图": "白底主图,多角度呈现商品细节",
|
||||||
|
"场景图": "生活化场景展示商品使用方式",
|
||||||
|
"模特场景图": "模特或手持场景展示商品真实使用",
|
||||||
|
"细节说明图": "突出材质、做工和关键细节",
|
||||||
|
"卖点图": "突出核心卖点和差异化优势",
|
||||||
|
}
|
||||||
|
FIXED_CATEGORY_DESCRIPTIONS = {
|
||||||
|
"白底图": "生成 Shopee 台灣商品白底主圖,純白背景,商品清晰置中,不添加多餘文字。",
|
||||||
|
"场景图": "生成生活化使用場景圖,展示商品在真實情境中的用途與氛圍,畫面自然可信。",
|
||||||
|
"模特场景图": "生成模特或手持使用情境圖,畫面自然可信,商品為主角。",
|
||||||
|
"细节说明图": "生成商品細節特寫說明圖,突出材質、做工、接口、紋理或關鍵細節。",
|
||||||
|
"卖点图": "生成賣點詳解圖,使用繁體中文短文案呈現核心優勢,版面乾淨。",
|
||||||
|
}
|
||||||
|
DEFAULT_CATEGORY_COUNTS = OrderedDict(
|
||||||
|
(
|
||||||
|
("白底图", 1),
|
||||||
|
("场景图", 2),
|
||||||
|
("模特场景图", 0),
|
||||||
|
("细节说明图", 0),
|
||||||
|
("卖点图", 2),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
PLATFORMS = ("Shopee", "Lazada", "TikTok Shop", "Amazon")
|
||||||
|
COUNTRIES = ("中国台湾", "新加坡", "马来西亚", "菲律宾", "泰国", "越南")
|
||||||
|
LANGUAGES = ("繁体中文", "简体中文", "英文", "泰文", "越南文")
|
||||||
|
RATIOS = ("1:1", "3:4", "4:3", "16:9", "9:16")
|
||||||
|
LAST_SETTING_KEYS = ("platform", "country", "language", "ratio")
|
||||||
|
MAX_CATEGORY_NAME_LENGTH = 10
|
||||||
|
MAX_GENERATION_COUNT_WITHOUT_CONFIRM = 16
|
||||||
|
PRODUCT_SUITE_PLACEHOLDERS = (
|
||||||
|
"生成目标",
|
||||||
|
"套图名称",
|
||||||
|
"补充描述",
|
||||||
|
"平台",
|
||||||
|
"国家地区",
|
||||||
|
"输出语言",
|
||||||
|
"图片比例",
|
||||||
|
"商品ID",
|
||||||
|
"主参考图序号",
|
||||||
|
"参考图规则",
|
||||||
|
"商品卖点与要求",
|
||||||
|
"尺寸与长图规则",
|
||||||
|
"禁用内容规则",
|
||||||
|
"价格信息规则",
|
||||||
|
"尺码信息规则",
|
||||||
|
)
|
||||||
|
PRODUCT_SUITE_COMMON_REQUIRED_PLACEHOLDERS = (
|
||||||
|
"图片比例",
|
||||||
|
"参考图规则",
|
||||||
|
"商品卖点与要求",
|
||||||
|
"尺寸与长图规则",
|
||||||
|
"禁用内容规则",
|
||||||
|
"价格信息规则",
|
||||||
|
"尺码信息规则",
|
||||||
|
)
|
||||||
|
PRODUCT_SUITE_REQUIRED_PLACEHOLDERS = (
|
||||||
|
"生成目标",
|
||||||
|
*PRODUCT_SUITE_COMMON_REQUIRED_PLACEHOLDERS,
|
||||||
|
)
|
||||||
|
PRODUCT_SUITE_LEGACY_REQUIRED_PLACEHOLDERS = (
|
||||||
|
"套图名称",
|
||||||
|
"补充描述",
|
||||||
|
*PRODUCT_SUITE_COMMON_REQUIRED_PLACEHOLDERS,
|
||||||
|
)
|
||||||
|
PRODUCT_SUITE_READ_ONLY_RULE_PLACEHOLDERS = (
|
||||||
|
"尺寸与长图规则",
|
||||||
|
"禁用内容规则",
|
||||||
|
"价格信息规则",
|
||||||
|
"尺码信息规则",
|
||||||
|
)
|
||||||
|
PRODUCT_SUITE_SIZE_RULE = (
|
||||||
|
"重要尺寸要求:最终输出必须严格符合所选比例的单张完整构图电商图,"
|
||||||
|
"禁止海报长图、详情页长图和多宫格拼接版面。"
|
||||||
|
)
|
||||||
|
PRODUCT_SUITE_FORBIDDEN_CONTENT_RULE = (
|
||||||
|
"重要禁用内容:禁止在画面中出现任何国旗、旗帜、国徽、地图轮廓、"
|
||||||
|
"政治符号或类似国家/地区标识。"
|
||||||
|
)
|
||||||
|
PRODUCT_SUITE_PRICE_RULE = (
|
||||||
|
"价格信息规则:除非用户明确提供价格、折扣或活动价,否则禁止自行添加"
|
||||||
|
"价格、币别符号、折扣数字或促销金额。"
|
||||||
|
)
|
||||||
|
PRODUCT_SUITE_SIZE_INFO_RULE = (
|
||||||
|
"尺码信息规则:除非用户或参考图明确提供尺码、尺寸或规格,否则禁止自行"
|
||||||
|
"编造尺码、尺寸、适用身高体重等内容。"
|
||||||
|
)
|
||||||
|
_PRODUCT_SUITE_PLACEHOLDER_RE = re.compile(r"\{([^{}\r\n]+)\}")
|
||||||
|
|
||||||
|
|
||||||
|
class ProductSuitePromptError(ValueError):
|
||||||
|
"""Raised when a product-suite prompt template is invalid."""
|
||||||
|
|
||||||
|
|
||||||
|
def default_suite_settings():
|
||||||
|
return {
|
||||||
|
"platform": "Shopee",
|
||||||
|
"country": "中国台湾",
|
||||||
|
"language": "繁体中文",
|
||||||
|
"ratio": "1:1",
|
||||||
|
"per_image_primary": False,
|
||||||
|
"categories": dict(DEFAULT_CATEGORY_COUNTS),
|
||||||
|
"custom_category_order": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_suite_settings(value=None):
|
||||||
|
raw = dict(value or {}) if isinstance(value, dict) else {}
|
||||||
|
normalized = default_suite_settings()
|
||||||
|
normalized["platform"] = _choice(raw.get("platform"), PLATFORMS, "Shopee")
|
||||||
|
normalized["country"] = _choice(raw.get("country"), COUNTRIES, "中国台湾")
|
||||||
|
normalized["language"] = _choice(raw.get("language"), LANGUAGES, "繁体中文")
|
||||||
|
normalized["ratio"] = _choice(raw.get("ratio"), RATIOS, "1:1")
|
||||||
|
normalized["per_image_primary"] = bool(raw.get("per_image_primary", False))
|
||||||
|
|
||||||
|
raw_categories = raw.get("categories") if isinstance(raw.get("categories"), dict) else {}
|
||||||
|
categories = OrderedDict()
|
||||||
|
for name, default_count in DEFAULT_CATEGORY_COUNTS.items():
|
||||||
|
categories[name] = _count(raw_categories.get(name, default_count))
|
||||||
|
requested_order = raw.get("custom_category_order")
|
||||||
|
requested_order = requested_order if isinstance(requested_order, list) else []
|
||||||
|
seen = set(FIXED_CATEGORIES)
|
||||||
|
custom_order = []
|
||||||
|
for candidate in list(requested_order) + list(raw_categories):
|
||||||
|
name = str(candidate or "")
|
||||||
|
if name in seen or suite_name_error(name, categories):
|
||||||
|
continue
|
||||||
|
seen.add(name)
|
||||||
|
custom_order.append(name)
|
||||||
|
categories[name] = _count(raw_categories.get(name, 1))
|
||||||
|
normalized["categories"] = dict(categories)
|
||||||
|
normalized["custom_category_order"] = custom_order
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
def last_suite_settings(value=None):
|
||||||
|
normalized = normalize_suite_settings(value)
|
||||||
|
return {key: normalized[key] for key in LAST_SETTING_KEYS}
|
||||||
|
|
||||||
|
|
||||||
|
def suite_name_error(name, existing=None, old_name=""):
|
||||||
|
value = str(name or "")
|
||||||
|
if not value.strip():
|
||||||
|
return "分类名称不能为空"
|
||||||
|
if value != value.strip() or any(character.isspace() for character in value):
|
||||||
|
return "分类名称不能包含空格"
|
||||||
|
if len(value) > MAX_CATEGORY_NAME_LENGTH:
|
||||||
|
return "分类名称不能超过10个字"
|
||||||
|
names = set(existing or ())
|
||||||
|
if value in names and value != str(old_name or ""):
|
||||||
|
return "分类名称已存在"
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def category_order(settings):
|
||||||
|
normalized = normalize_suite_settings(settings)
|
||||||
|
categories = normalized["categories"]
|
||||||
|
custom = [
|
||||||
|
name
|
||||||
|
for name in normalized["custom_category_order"]
|
||||||
|
if name in categories and name not in FIXED_CATEGORIES
|
||||||
|
]
|
||||||
|
return list(FIXED_CATEGORIES) + custom
|
||||||
|
|
||||||
|
|
||||||
|
def category_helper(category):
|
||||||
|
return FIXED_CATEGORY_HELPERS.get(str(category or ""), "")
|
||||||
|
|
||||||
|
|
||||||
|
def category_description(category):
|
||||||
|
name = str(category or "").strip()
|
||||||
|
fixed_description = FIXED_CATEGORY_DESCRIPTIONS.get(name)
|
||||||
|
if fixed_description:
|
||||||
|
return fixed_description
|
||||||
|
if name:
|
||||||
|
return "生成自定义分类图片:%s。" % name
|
||||||
|
return "生成自定义分类图片。"
|
||||||
|
|
||||||
|
|
||||||
|
def suite_total_count(settings, image_count):
|
||||||
|
normalized = normalize_suite_settings(settings)
|
||||||
|
categories = normalized["categories"]
|
||||||
|
base = sum(_count(categories.get(name, 0)) for name in category_order(normalized))
|
||||||
|
if not normalized["per_image_primary"]:
|
||||||
|
return base
|
||||||
|
white_count = _count(categories.get("白底图", 0))
|
||||||
|
other_count = max(0, base - white_count)
|
||||||
|
return white_count + other_count * max(1, int(image_count or 0))
|
||||||
|
|
||||||
|
|
||||||
|
def product_suite_prompt_errors(template_text):
|
||||||
|
text = str(template_text or "")
|
||||||
|
errors = []
|
||||||
|
if not text.strip():
|
||||||
|
return ["套图提示词模板不能为空"]
|
||||||
|
|
||||||
|
matches = list(_PRODUCT_SUITE_PLACEHOLDER_RE.finditer(text))
|
||||||
|
remainder = _PRODUCT_SUITE_PLACEHOLDER_RE.sub("", text)
|
||||||
|
if "{" in remainder or "}" in remainder:
|
||||||
|
errors.append("模板包含未闭合花括号或不支持的字面花括号")
|
||||||
|
|
||||||
|
names = [match.group(1) for match in matches]
|
||||||
|
unknown = sorted(set(names) - set(PRODUCT_SUITE_PLACEHOLDERS))
|
||||||
|
if unknown:
|
||||||
|
errors.append("模板包含未知变量:%s" % "、".join("{%s}" % name for name in unknown))
|
||||||
|
|
||||||
|
missing = [
|
||||||
|
name
|
||||||
|
for name in PRODUCT_SUITE_COMMON_REQUIRED_PLACEHOLDERS
|
||||||
|
if name not in names
|
||||||
|
]
|
||||||
|
if missing:
|
||||||
|
errors.append("模板缺少必需变量:%s" % "、".join("{%s}" % name for name in missing))
|
||||||
|
has_new_target = "生成目标" in names
|
||||||
|
has_legacy_target = all(
|
||||||
|
name in names for name in ("套图名称", "补充描述")
|
||||||
|
)
|
||||||
|
if not has_new_target and not has_legacy_target:
|
||||||
|
errors.append(
|
||||||
|
"模板缺少必需变量:{生成目标}(旧模板需同时包含{套图名称}和{补充描述})"
|
||||||
|
)
|
||||||
|
|
||||||
|
invalid_rule_lines = []
|
||||||
|
for line in text.splitlines():
|
||||||
|
line_names = _PRODUCT_SUITE_PLACEHOLDER_RE.findall(line)
|
||||||
|
for name in line_names:
|
||||||
|
if (
|
||||||
|
name in PRODUCT_SUITE_READ_ONLY_RULE_PLACEHOLDERS
|
||||||
|
and line.strip() != "{%s}" % name
|
||||||
|
):
|
||||||
|
invalid_rule_lines.append(name)
|
||||||
|
if invalid_rule_lines:
|
||||||
|
errors.append(
|
||||||
|
"只读规则变量必须独占一行:%s"
|
||||||
|
% "、".join("{%s}" % name for name in sorted(set(invalid_rule_lines)))
|
||||||
|
)
|
||||||
|
return errors
|
||||||
|
|
||||||
|
|
||||||
|
def validate_product_suite_prompt(template_text):
|
||||||
|
errors = product_suite_prompt_errors(template_text)
|
||||||
|
if errors:
|
||||||
|
raise ProductSuitePromptError(";".join(errors))
|
||||||
|
return str(template_text)
|
||||||
|
|
||||||
|
|
||||||
|
def reference_asset_count(settings, source_image_count):
|
||||||
|
"""Return the effective reference-image count for a planned cmhub request."""
|
||||||
|
normalized = normalize_suite_settings(settings)
|
||||||
|
if normalized["per_image_primary"]:
|
||||||
|
return 0
|
||||||
|
try:
|
||||||
|
image_count = max(0, int(source_image_count or 0))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
image_count = 0
|
||||||
|
return min(7, max(0, image_count - 1))
|
||||||
|
|
||||||
|
|
||||||
|
def product_suite_reference_rule(settings, reference_asset_count=0):
|
||||||
|
normalized = normalize_suite_settings(settings)
|
||||||
|
if normalized["per_image_primary"]:
|
||||||
|
return (
|
||||||
|
"参考图规则:当前上传图片是本任务唯一主参考图;保持商品主体、款式、颜色和关键细节准确;"
|
||||||
|
"不编造用户与参考图均未提供的信息。"
|
||||||
|
)
|
||||||
|
return "参考图规则:使用第一張上傳圖作為主商品圖,其餘圖片只作為參考。"
|
||||||
|
|
||||||
|
|
||||||
|
def product_suite_prompt_context(
|
||||||
|
base_prompt,
|
||||||
|
settings,
|
||||||
|
category,
|
||||||
|
item_id,
|
||||||
|
source_index=1,
|
||||||
|
reference_asset_count=0,
|
||||||
|
):
|
||||||
|
normalized = normalize_suite_settings(settings)
|
||||||
|
item_text = str(item_id or "").strip()
|
||||||
|
if not item_text or item_text.startswith("draft_"):
|
||||||
|
item_text = "未绑定商品"
|
||||||
|
reference_index = max(1, int(source_index or 1))
|
||||||
|
reference_rule = product_suite_reference_rule(
|
||||||
|
normalized,
|
||||||
|
reference_asset_count,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"生成目标": category_description(category),
|
||||||
|
"套图名称": str(category or ""),
|
||||||
|
"补充描述": "," + category_description(category),
|
||||||
|
"平台": normalized["platform"],
|
||||||
|
"国家地区": normalized["country"],
|
||||||
|
"输出语言": normalized["language"],
|
||||||
|
"图片比例": normalized["ratio"],
|
||||||
|
"商品ID": item_text,
|
||||||
|
"主参考图序号": str(reference_index),
|
||||||
|
"参考图规则": reference_rule,
|
||||||
|
"商品卖点与要求": str(base_prompt or "").strip(),
|
||||||
|
"尺寸与长图规则": PRODUCT_SUITE_SIZE_RULE,
|
||||||
|
"禁用内容规则": PRODUCT_SUITE_FORBIDDEN_CONTENT_RULE,
|
||||||
|
"价格信息规则": PRODUCT_SUITE_PRICE_RULE,
|
||||||
|
"尺码信息规则": PRODUCT_SUITE_SIZE_INFO_RULE,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def render_product_suite_prompt(template_text, context):
|
||||||
|
validate_product_suite_prompt(template_text)
|
||||||
|
values = {
|
||||||
|
name: str((context or {}).get(name, ""))
|
||||||
|
for name in PRODUCT_SUITE_PLACEHOLDERS
|
||||||
|
}
|
||||||
|
context = context or {}
|
||||||
|
missing_context = [
|
||||||
|
name
|
||||||
|
for name in PRODUCT_SUITE_COMMON_REQUIRED_PLACEHOLDERS
|
||||||
|
if name not in context
|
||||||
|
]
|
||||||
|
has_new_target = "生成目标" in context
|
||||||
|
has_legacy_target = all(
|
||||||
|
name in context for name in ("套图名称", "补充描述")
|
||||||
|
)
|
||||||
|
if not has_new_target and not has_legacy_target:
|
||||||
|
missing_context.append("生成目标")
|
||||||
|
if missing_context:
|
||||||
|
raise ProductSuitePromptError(
|
||||||
|
"提示词上下文缺少变量:%s"
|
||||||
|
% "、".join("{%s}" % name for name in missing_context)
|
||||||
|
)
|
||||||
|
rendered = _PRODUCT_SUITE_PLACEHOLDER_RE.sub(
|
||||||
|
lambda match: values[match.group(1)],
|
||||||
|
str(template_text),
|
||||||
|
)
|
||||||
|
# 模板已在替换前校验完成;业务文本中的花括号不是模板变量。
|
||||||
|
return rendered.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def build_suite_prompt(
|
||||||
|
base_prompt,
|
||||||
|
settings,
|
||||||
|
category,
|
||||||
|
item_id,
|
||||||
|
source_index=1,
|
||||||
|
reference_asset_count=0,
|
||||||
|
*,
|
||||||
|
template_text,
|
||||||
|
):
|
||||||
|
context = product_suite_prompt_context(
|
||||||
|
base_prompt,
|
||||||
|
settings,
|
||||||
|
category,
|
||||||
|
item_id,
|
||||||
|
source_index=source_index,
|
||||||
|
reference_asset_count=reference_asset_count,
|
||||||
|
)
|
||||||
|
return render_product_suite_prompt(template_text, context)
|
||||||
|
|
||||||
|
|
||||||
|
def build_job_specs(source_assets, base_prompt, settings, item_id, *, template_text):
|
||||||
|
assets = list(source_assets or [])
|
||||||
|
if not assets:
|
||||||
|
return []
|
||||||
|
normalized = normalize_suite_settings(settings)
|
||||||
|
primary_asset_id = int(getattr(assets[0], "id", assets[0]))
|
||||||
|
reference_asset_ids = []
|
||||||
|
if not normalized["per_image_primary"]:
|
||||||
|
for asset in assets[1:8]:
|
||||||
|
asset_id = int(getattr(asset, "id", asset))
|
||||||
|
if asset_id != primary_asset_id and asset_id not in reference_asset_ids:
|
||||||
|
reference_asset_ids.append(asset_id)
|
||||||
|
specs = []
|
||||||
|
for category in category_order(normalized):
|
||||||
|
count = _count(normalized["categories"].get(category, 0))
|
||||||
|
if count <= 0:
|
||||||
|
continue
|
||||||
|
targets = assets if normalized["per_image_primary"] and category != "白底图" else assets[:1]
|
||||||
|
for source_index, asset in enumerate(targets, 1):
|
||||||
|
for category_index in range(1, count + 1):
|
||||||
|
specs.append(
|
||||||
|
{
|
||||||
|
"source_asset_id": int(getattr(asset, "id", asset)),
|
||||||
|
"reference_asset_ids": list(reference_asset_ids),
|
||||||
|
"job_type": str(category),
|
||||||
|
"category": str(category),
|
||||||
|
"category_index": category_index,
|
||||||
|
"source_index": source_index,
|
||||||
|
"prompt": build_suite_prompt(
|
||||||
|
base_prompt,
|
||||||
|
normalized,
|
||||||
|
category,
|
||||||
|
item_id,
|
||||||
|
source_index=source_index,
|
||||||
|
reference_asset_count=len(reference_asset_ids),
|
||||||
|
template_text=template_text,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return specs
|
||||||
|
|
||||||
|
|
||||||
|
def _count(value):
|
||||||
|
try:
|
||||||
|
return max(0, int(value or 0))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _choice(value, choices, default):
|
||||||
|
text = str(value or "").strip()
|
||||||
|
return text if text in choices else default
|
||||||
+296
-18
@@ -3,13 +3,23 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import tempfile
|
||||||
|
from importlib import resources
|
||||||
|
|
||||||
from . import appconfig
|
from . import appconfig
|
||||||
|
|
||||||
TITLE_PROMPT_PATH = appconfig.title_prompt_path()
|
TITLE_PROMPT_PATH = appconfig.title_prompt_path()
|
||||||
|
TITLE_TEMPLATES_DIR = appconfig.title_templates_dir()
|
||||||
COVER_PROMPTS_DIR = appconfig.cover_prompts_dir()
|
COVER_PROMPTS_DIR = appconfig.cover_prompts_dir()
|
||||||
|
IMAGE_STUDIO_PROMPTS_DIR = appconfig.image_studio_prompts_dir()
|
||||||
|
PRODUCT_SUITE_PROMPT_PATH = appconfig.product_suite_prompt_path()
|
||||||
TEMPLATE_EXT = ".txt"
|
TEMPLATE_EXT = ".txt"
|
||||||
INVALID_NAME_CHARS = set('\\/:*?"<>|')
|
INVALID_NAME_CHARS = set('\\/:*?"<>|')
|
||||||
|
DEFAULT_PROMPTS_PACKAGE = "app.default_prompts"
|
||||||
|
DEFAULT_COVER_PROMPTS_PACKAGE = "app.default_prompts.cover"
|
||||||
|
DEFAULT_PRODUCT_SUITE_PROMPTS_PACKAGE = "app.default_prompts.product_suite"
|
||||||
|
DEFAULT_PRODUCT_SUITE_PROMPT_NAME = "base.txt"
|
||||||
|
PRODUCT_SUITE_DEFAULT_ERROR = "内置套图提示词模板无效,请重新安装软件或联系技术支持。"
|
||||||
|
|
||||||
|
|
||||||
class PromptError(RuntimeError):
|
class PromptError(RuntimeError):
|
||||||
@@ -35,8 +45,105 @@ def save_title_prompt(text, path=TITLE_PROMPT_PATH) -> None:
|
|||||||
fh.write(str(text or ""))
|
fh.write(str(text or ""))
|
||||||
|
|
||||||
|
|
||||||
def list_cover_templates(directory=COVER_PROMPTS_DIR):
|
def ensure_default_prompts(
|
||||||
"""Return cover template names sorted by display name."""
|
title_prompt_path=TITLE_PROMPT_PATH,
|
||||||
|
cover_prompts_dir=COVER_PROMPTS_DIR,
|
||||||
|
title_templates_dir=None,
|
||||||
|
) -> None:
|
||||||
|
"""Seed bundled default prompts into an empty user data directory.
|
||||||
|
|
||||||
|
Existing user prompts are never overwritten. Defaults live in the packaged
|
||||||
|
app and are copied into data/ only when the corresponding user files are
|
||||||
|
missing, or the title prompt file is blank.
|
||||||
|
"""
|
||||||
|
|
||||||
|
if not _has_non_empty_file(title_prompt_path):
|
||||||
|
default_title = _read_default_prompt_text("title_prompt.txt")
|
||||||
|
if default_title:
|
||||||
|
save_title_prompt(default_title, title_prompt_path)
|
||||||
|
|
||||||
|
if title_templates_dir is None:
|
||||||
|
title_templates_dir = _title_templates_dir_for_prompt_path(title_prompt_path)
|
||||||
|
if not list_templates(title_templates_dir):
|
||||||
|
default_title = _read_default_prompt_text("title_prompt.txt")
|
||||||
|
if default_title:
|
||||||
|
save_template("默认", default_title, title_templates_dir)
|
||||||
|
|
||||||
|
if not list_cover_templates(cover_prompts_dir):
|
||||||
|
for name, text in _iter_default_cover_templates():
|
||||||
|
save_cover_template(name, text, cover_prompts_dir)
|
||||||
|
|
||||||
|
|
||||||
|
def read_product_suite_prompt(path=PRODUCT_SUITE_PROMPT_PATH) -> str:
|
||||||
|
"""Read a product-suite template without hiding invalid user content."""
|
||||||
|
|
||||||
|
if not os.path.exists(path):
|
||||||
|
return ""
|
||||||
|
try:
|
||||||
|
with open(path, "r", encoding="utf-8") as fh:
|
||||||
|
return fh.read()
|
||||||
|
except (OSError, UnicodeError) as exc:
|
||||||
|
raise PromptError("套图提示词模板读取失败:%s" % exc) from exc
|
||||||
|
|
||||||
|
|
||||||
|
def load_product_suite_prompt(path=PRODUCT_SUITE_PROMPT_PATH) -> str:
|
||||||
|
"""Load and validate the current user product-suite template."""
|
||||||
|
|
||||||
|
text = read_product_suite_prompt(path)
|
||||||
|
if not text.strip():
|
||||||
|
raise PromptError("套图提示词模板不存在或为空")
|
||||||
|
_validate_product_suite_prompt(text, "套图提示词模板无效")
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def load_default_product_suite_prompt() -> str:
|
||||||
|
"""Load and validate the packaged product-suite template."""
|
||||||
|
|
||||||
|
try:
|
||||||
|
text = (
|
||||||
|
resources.files(DEFAULT_PRODUCT_SUITE_PROMPTS_PACKAGE)
|
||||||
|
.joinpath(DEFAULT_PRODUCT_SUITE_PROMPT_NAME)
|
||||||
|
.read_text(encoding="utf-8")
|
||||||
|
)
|
||||||
|
except (FileNotFoundError, ModuleNotFoundError, OSError, UnicodeError) as exc:
|
||||||
|
raise PromptError(PRODUCT_SUITE_DEFAULT_ERROR) from exc
|
||||||
|
try:
|
||||||
|
_validate_product_suite_prompt(text, "内置套图提示词模板无效")
|
||||||
|
except PromptError as exc:
|
||||||
|
raise PromptError(PRODUCT_SUITE_DEFAULT_ERROR) from exc
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def save_product_suite_prompt(text, path=PRODUCT_SUITE_PROMPT_PATH) -> None:
|
||||||
|
"""Validate and atomically save the user product-suite template."""
|
||||||
|
|
||||||
|
_validate_product_suite_prompt(text, "套图提示词模板无效")
|
||||||
|
_atomic_write_text(path, str(text))
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_default_product_suite_prompt(path=PRODUCT_SUITE_PROMPT_PATH) -> str:
|
||||||
|
"""Seed a validated packaged template without overwriting user content."""
|
||||||
|
|
||||||
|
if os.path.exists(path):
|
||||||
|
current = read_product_suite_prompt(path)
|
||||||
|
if current.strip():
|
||||||
|
_validate_product_suite_prompt(current, "套图提示词模板无效")
|
||||||
|
return current
|
||||||
|
default_text = load_default_product_suite_prompt()
|
||||||
|
save_product_suite_prompt(default_text, path)
|
||||||
|
return default_text
|
||||||
|
|
||||||
|
|
||||||
|
def restore_default_product_suite_prompt(path=PRODUCT_SUITE_PROMPT_PATH) -> str:
|
||||||
|
"""Validate and atomically restore the packaged product-suite template."""
|
||||||
|
|
||||||
|
default_text = load_default_product_suite_prompt()
|
||||||
|
save_product_suite_prompt(default_text, path)
|
||||||
|
return default_text
|
||||||
|
|
||||||
|
|
||||||
|
def list_templates(directory):
|
||||||
|
"""Return prompt template names sorted by display name."""
|
||||||
|
|
||||||
if not os.path.isdir(directory):
|
if not os.path.isdir(directory):
|
||||||
return []
|
return []
|
||||||
@@ -47,18 +154,18 @@ def list_cover_templates(directory=COVER_PROMPTS_DIR):
|
|||||||
return sorted(names, key=str.casefold)
|
return sorted(names, key=str.casefold)
|
||||||
|
|
||||||
|
|
||||||
def load_cover_template(name, directory=COVER_PROMPTS_DIR) -> str:
|
def load_template(name, directory) -> str:
|
||||||
"""Load one cover prompt template."""
|
"""Load one prompt template."""
|
||||||
|
|
||||||
path = _template_path(name, directory)
|
path = _template_path(name, directory)
|
||||||
if not os.path.exists(path):
|
if not os.path.exists(path):
|
||||||
raise PromptError(f"封面提示词模板不存在: {_normalize_name(name)}")
|
raise PromptError(f"提示词模板不存在: {_normalize_name(name)}")
|
||||||
with open(path, "r", encoding="utf-8") as fh:
|
with open(path, "r", encoding="utf-8") as fh:
|
||||||
return fh.read()
|
return fh.read()
|
||||||
|
|
||||||
|
|
||||||
def save_cover_template(name, text, directory=COVER_PROMPTS_DIR) -> None:
|
def save_template(name, text, directory) -> None:
|
||||||
"""Save one cover prompt template as UTF-8."""
|
"""Save one prompt template as UTF-8."""
|
||||||
|
|
||||||
path = _template_path(name, directory)
|
path = _template_path(name, directory)
|
||||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||||
@@ -66,26 +173,116 @@ def save_cover_template(name, text, directory=COVER_PROMPTS_DIR) -> None:
|
|||||||
fh.write(str(text or ""))
|
fh.write(str(text or ""))
|
||||||
|
|
||||||
|
|
||||||
def rename_cover_template(old, new, directory=COVER_PROMPTS_DIR) -> None:
|
def rename_template(old, new, directory) -> None:
|
||||||
"""Rename a cover prompt template with duplicate-name protection."""
|
"""Rename a prompt template with duplicate-name protection."""
|
||||||
|
|
||||||
old_path = _template_path(old, directory)
|
old_path = _template_path(old, directory)
|
||||||
new_path = _template_path(new, directory)
|
new_path = _template_path(new, directory)
|
||||||
if not os.path.exists(old_path):
|
if not os.path.exists(old_path):
|
||||||
raise PromptError(f"封面提示词模板不存在: {_normalize_name(old)}")
|
raise PromptError(f"提示词模板不存在: {_normalize_name(old)}")
|
||||||
if os.path.exists(new_path):
|
if os.path.exists(new_path):
|
||||||
raise PromptError(f"封面提示词模板已存在: {_normalize_name(new)}")
|
raise PromptError(f"提示词模板已存在: {_normalize_name(new)}")
|
||||||
os.makedirs(os.path.dirname(new_path), exist_ok=True)
|
os.makedirs(os.path.dirname(new_path), exist_ok=True)
|
||||||
os.replace(old_path, new_path)
|
os.replace(old_path, new_path)
|
||||||
|
|
||||||
|
|
||||||
|
def delete_template(name, directory) -> None:
|
||||||
|
"""Delete one prompt template."""
|
||||||
|
|
||||||
|
path = _template_path(name, directory)
|
||||||
|
if not os.path.exists(path):
|
||||||
|
raise PromptError(f"提示词模板不存在: {_normalize_name(name)}")
|
||||||
|
os.remove(path)
|
||||||
|
|
||||||
|
|
||||||
|
def list_title_templates(directory=TITLE_TEMPLATES_DIR):
|
||||||
|
"""Return title prompt template names sorted by display name."""
|
||||||
|
|
||||||
|
return list_templates(directory)
|
||||||
|
|
||||||
|
|
||||||
|
def load_title_template(name, directory=TITLE_TEMPLATES_DIR) -> str:
|
||||||
|
"""Load one title prompt template."""
|
||||||
|
|
||||||
|
return load_template(name, directory)
|
||||||
|
|
||||||
|
|
||||||
|
def save_title_template(name, text, directory=TITLE_TEMPLATES_DIR) -> None:
|
||||||
|
"""Save one title prompt template as UTF-8."""
|
||||||
|
|
||||||
|
save_template(name, text, directory)
|
||||||
|
|
||||||
|
|
||||||
|
def rename_title_template(old, new, directory=TITLE_TEMPLATES_DIR) -> None:
|
||||||
|
"""Rename a title prompt template with duplicate-name protection."""
|
||||||
|
|
||||||
|
rename_template(old, new, directory)
|
||||||
|
|
||||||
|
|
||||||
|
def delete_title_template(name, directory=TITLE_TEMPLATES_DIR) -> None:
|
||||||
|
"""Delete one title prompt template."""
|
||||||
|
|
||||||
|
delete_template(name, directory)
|
||||||
|
|
||||||
|
|
||||||
|
def list_image_studio_templates(directory=IMAGE_STUDIO_PROMPTS_DIR):
|
||||||
|
"""Return AI studio prompt template names sorted by display name."""
|
||||||
|
|
||||||
|
return list_templates(directory)
|
||||||
|
|
||||||
|
|
||||||
|
def load_image_studio_template(name, directory=IMAGE_STUDIO_PROMPTS_DIR) -> str:
|
||||||
|
"""Load one AI studio prompt template as raw text."""
|
||||||
|
|
||||||
|
return load_template(name, directory)
|
||||||
|
|
||||||
|
|
||||||
|
def save_image_studio_template(name, text, directory=IMAGE_STUDIO_PROMPTS_DIR) -> None:
|
||||||
|
"""Save one AI studio prompt template as raw UTF-8 text."""
|
||||||
|
|
||||||
|
save_template(name, text, directory)
|
||||||
|
|
||||||
|
|
||||||
|
def rename_image_studio_template(old, new, directory=IMAGE_STUDIO_PROMPTS_DIR) -> None:
|
||||||
|
"""Rename an AI studio prompt template with duplicate-name protection."""
|
||||||
|
|
||||||
|
rename_template(old, new, directory)
|
||||||
|
|
||||||
|
|
||||||
|
def delete_image_studio_template(name, directory=IMAGE_STUDIO_PROMPTS_DIR) -> None:
|
||||||
|
"""Delete one AI studio prompt template."""
|
||||||
|
|
||||||
|
delete_template(name, directory)
|
||||||
|
|
||||||
|
|
||||||
|
def list_cover_templates(directory=COVER_PROMPTS_DIR):
|
||||||
|
"""Return cover template names sorted by display name."""
|
||||||
|
|
||||||
|
return list_templates(directory)
|
||||||
|
|
||||||
|
|
||||||
|
def load_cover_template(name, directory=COVER_PROMPTS_DIR) -> str:
|
||||||
|
"""Load one cover prompt template."""
|
||||||
|
|
||||||
|
return load_template(name, directory)
|
||||||
|
|
||||||
|
|
||||||
|
def save_cover_template(name, text, directory=COVER_PROMPTS_DIR) -> None:
|
||||||
|
"""Save one cover prompt template as UTF-8."""
|
||||||
|
|
||||||
|
save_template(name, text, directory)
|
||||||
|
|
||||||
|
|
||||||
|
def rename_cover_template(old, new, directory=COVER_PROMPTS_DIR) -> None:
|
||||||
|
"""Rename a cover prompt template with duplicate-name protection."""
|
||||||
|
|
||||||
|
rename_template(old, new, directory)
|
||||||
|
|
||||||
|
|
||||||
def delete_cover_template(name, directory=COVER_PROMPTS_DIR) -> None:
|
def delete_cover_template(name, directory=COVER_PROMPTS_DIR) -> None:
|
||||||
"""Delete one cover prompt template."""
|
"""Delete one cover prompt template."""
|
||||||
|
|
||||||
path = _template_path(name, directory)
|
delete_template(name, directory)
|
||||||
if not os.path.exists(path):
|
|
||||||
raise PromptError(f"封面提示词模板不存在: {_normalize_name(name)}")
|
|
||||||
os.remove(path)
|
|
||||||
|
|
||||||
|
|
||||||
def render_prompt(template_text, task) -> str:
|
def render_prompt(template_text, task) -> str:
|
||||||
@@ -126,9 +323,90 @@ def _normalize_name(name) -> str:
|
|||||||
value = value[: -len(TEMPLATE_EXT)]
|
value = value[: -len(TEMPLATE_EXT)]
|
||||||
value = value.strip()
|
value = value.strip()
|
||||||
if not value:
|
if not value:
|
||||||
raise PromptError("封面提示词模板名不能为空")
|
raise PromptError("提示词模板名不能为空")
|
||||||
if value in {".", ".."} or any(char in INVALID_NAME_CHARS for char in value):
|
if value in {".", ".."} or any(char in INVALID_NAME_CHARS for char in value):
|
||||||
raise PromptError(f"封面提示词模板名非法: {value}")
|
raise PromptError(f"提示词模板名非法: {value}")
|
||||||
if os.path.basename(value) != value:
|
if os.path.basename(value) != value:
|
||||||
raise PromptError(f"封面提示词模板名非法: {value}")
|
raise PromptError(f"提示词模板名非法: {value}")
|
||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _title_templates_dir_for_prompt_path(title_prompt_path) -> str:
|
||||||
|
data_root = os.path.dirname(os.path.abspath(title_prompt_path))
|
||||||
|
return os.path.join(data_root, "prompts", "title")
|
||||||
|
|
||||||
|
|
||||||
|
def _has_non_empty_file(path) -> bool:
|
||||||
|
try:
|
||||||
|
if not os.path.exists(path):
|
||||||
|
return False
|
||||||
|
with open(path, "r", encoding="utf-8") as fh:
|
||||||
|
return bool(fh.read().strip())
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_product_suite_prompt(text, prefix) -> None:
|
||||||
|
from . import product_suite
|
||||||
|
|
||||||
|
try:
|
||||||
|
product_suite.validate_product_suite_prompt(text)
|
||||||
|
except product_suite.ProductSuitePromptError as exc:
|
||||||
|
raise PromptError("%s:%s" % (prefix, exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _atomic_write_text(path, text) -> None:
|
||||||
|
target = os.path.abspath(path)
|
||||||
|
directory = os.path.dirname(target)
|
||||||
|
if directory:
|
||||||
|
os.makedirs(directory, exist_ok=True)
|
||||||
|
temporary_path = ""
|
||||||
|
try:
|
||||||
|
with tempfile.NamedTemporaryFile(
|
||||||
|
mode="w",
|
||||||
|
encoding="utf-8",
|
||||||
|
dir=directory or None,
|
||||||
|
prefix=".prompt-",
|
||||||
|
suffix=".tmp",
|
||||||
|
delete=False,
|
||||||
|
) as handle:
|
||||||
|
temporary_path = handle.name
|
||||||
|
handle.write(str(text))
|
||||||
|
handle.flush()
|
||||||
|
os.fsync(handle.fileno())
|
||||||
|
os.replace(temporary_path, target)
|
||||||
|
except OSError as exc:
|
||||||
|
raise PromptError("套图提示词模板保存失败:%s" % exc) from exc
|
||||||
|
finally:
|
||||||
|
if temporary_path and os.path.exists(temporary_path):
|
||||||
|
try:
|
||||||
|
os.remove(temporary_path)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _read_default_prompt_text(filename) -> str:
|
||||||
|
try:
|
||||||
|
return (
|
||||||
|
resources.files(DEFAULT_PROMPTS_PACKAGE)
|
||||||
|
.joinpath(filename)
|
||||||
|
.read_text(encoding="utf-8")
|
||||||
|
)
|
||||||
|
except (FileNotFoundError, ModuleNotFoundError, OSError):
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _iter_default_cover_templates():
|
||||||
|
try:
|
||||||
|
root = resources.files(DEFAULT_COVER_PROMPTS_PACKAGE)
|
||||||
|
except (ModuleNotFoundError, OSError):
|
||||||
|
return
|
||||||
|
for entry in root.iterdir():
|
||||||
|
if not entry.is_file() or not entry.name.lower().endswith(TEMPLATE_EXT):
|
||||||
|
continue
|
||||||
|
name = entry.name[: -len(TEMPLATE_EXT)]
|
||||||
|
try:
|
||||||
|
text = entry.read_text(encoding="utf-8")
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
yield name, text
|
||||||
|
|||||||
@@ -0,0 +1,174 @@
|
|||||||
|
"""发布包清单与服务端发布元数据生成。"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
PACKAGE_FORMAT = "cmshopee-portable-v1"
|
||||||
|
UPDATER_PROTOCOL = 1
|
||||||
|
ENTRY_POINT = "cmshopee.exe"
|
||||||
|
MANIFEST_FILENAME = "package-manifest.json"
|
||||||
|
ALLOWED_ROOTS = {
|
||||||
|
ENTRY_POINT,
|
||||||
|
"_internal",
|
||||||
|
"version.txt",
|
||||||
|
"README.txt",
|
||||||
|
MANIFEST_FILENAME,
|
||||||
|
"cmshopee-updater.exe",
|
||||||
|
}
|
||||||
|
FORBIDDEN_ROOTS = {"data", ".cmshopee-update"}
|
||||||
|
|
||||||
|
|
||||||
|
class ReleaseManifestError(ValueError):
|
||||||
|
"""发布目录不符合自动升级包契约。"""
|
||||||
|
|
||||||
|
|
||||||
|
def sha256_file(path):
|
||||||
|
digest = hashlib.sha256()
|
||||||
|
with Path(path).open("rb") as stream:
|
||||||
|
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
|
||||||
|
digest.update(chunk)
|
||||||
|
return digest.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _normalized_relative(path, root):
|
||||||
|
relative = path.relative_to(root).as_posix()
|
||||||
|
if relative.startswith("/") or ".." in Path(relative).parts:
|
||||||
|
raise ReleaseManifestError("发布包包含不安全的文件路径:%s" % relative)
|
||||||
|
return relative
|
||||||
|
|
||||||
|
|
||||||
|
def build_package_manifest(release_dir, app_version):
|
||||||
|
release_dir = Path(release_dir).resolve()
|
||||||
|
if not release_dir.is_dir():
|
||||||
|
raise ReleaseManifestError("发布目录不存在:%s" % release_dir)
|
||||||
|
|
||||||
|
required = [ENTRY_POINT, "_internal", "version.txt", "README.txt"]
|
||||||
|
missing = [name for name in required if not (release_dir / name).exists()]
|
||||||
|
if missing:
|
||||||
|
raise ReleaseManifestError("发布目录缺少必要项目:%s" % "、".join(missing))
|
||||||
|
if not (release_dir / "_internal").is_dir():
|
||||||
|
raise ReleaseManifestError("_internal 必须是目录")
|
||||||
|
|
||||||
|
version_text = (release_dir / "version.txt").read_text(
|
||||||
|
encoding="utf-8-sig"
|
||||||
|
).strip()
|
||||||
|
if version_text != app_version:
|
||||||
|
raise ReleaseManifestError(
|
||||||
|
"version.txt 与应用版本不一致:%s != %s"
|
||||||
|
% (version_text, app_version)
|
||||||
|
)
|
||||||
|
|
||||||
|
root_names = {entry.name for entry in release_dir.iterdir()}
|
||||||
|
forbidden = sorted(root_names & FORBIDDEN_ROOTS)
|
||||||
|
if forbidden:
|
||||||
|
raise ReleaseManifestError("发布目录包含用户数据:%s" % "、".join(forbidden))
|
||||||
|
unknown = sorted(root_names - ALLOWED_ROOTS)
|
||||||
|
if unknown:
|
||||||
|
raise ReleaseManifestError("发布目录包含未授权根项目:%s" % "、".join(unknown))
|
||||||
|
|
||||||
|
files = []
|
||||||
|
seen = set()
|
||||||
|
for path in sorted(release_dir.rglob("*"), key=lambda value: value.as_posix().lower()):
|
||||||
|
if path.is_symlink():
|
||||||
|
raise ReleaseManifestError("发布包不允许符号链接:%s" % path)
|
||||||
|
if not path.is_file() or path.name == MANIFEST_FILENAME:
|
||||||
|
continue
|
||||||
|
relative = _normalized_relative(path, release_dir)
|
||||||
|
key = relative.casefold()
|
||||||
|
if key in seen:
|
||||||
|
raise ReleaseManifestError("发布包包含重复路径:%s" % relative)
|
||||||
|
seen.add(key)
|
||||||
|
files.append(
|
||||||
|
{
|
||||||
|
"path": relative,
|
||||||
|
"size_bytes": path.stat().st_size,
|
||||||
|
"sha256": sha256_file(path),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
replace_roots = sorted(root_names | {MANIFEST_FILENAME}, key=str.casefold)
|
||||||
|
return {
|
||||||
|
"schema_version": 1,
|
||||||
|
"package_format": PACKAGE_FORMAT,
|
||||||
|
"app_version": app_version,
|
||||||
|
"entry_point": ENTRY_POINT,
|
||||||
|
"updater_protocol": UPDATER_PROTOCOL,
|
||||||
|
"min_updater_protocol": UPDATER_PROTOCOL,
|
||||||
|
"replace_roots": replace_roots,
|
||||||
|
"files": files,
|
||||||
|
"signature_algorithm": "",
|
||||||
|
"manifest_signature": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def write_package_manifest(release_dir, app_version):
|
||||||
|
release_dir = Path(release_dir)
|
||||||
|
manifest = build_package_manifest(release_dir, app_version)
|
||||||
|
output = release_dir / MANIFEST_FILENAME
|
||||||
|
output.write_text(
|
||||||
|
json.dumps(manifest, ensure_ascii=False, indent=2) + "\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
return output
|
||||||
|
|
||||||
|
|
||||||
|
def build_release_metadata(zip_path, app_version):
|
||||||
|
zip_path = Path(zip_path)
|
||||||
|
if not zip_path.is_file():
|
||||||
|
raise ReleaseManifestError("发布压缩包不存在:%s" % zip_path)
|
||||||
|
return {
|
||||||
|
"platform": "windows",
|
||||||
|
"release": {
|
||||||
|
"version": app_version,
|
||||||
|
"force_update": False,
|
||||||
|
"min_supported_version": "",
|
||||||
|
"download_url": "",
|
||||||
|
"download_filename": zip_path.name,
|
||||||
|
"sha256": sha256_file(zip_path),
|
||||||
|
"size_bytes": zip_path.stat().st_size,
|
||||||
|
"package_format": PACKAGE_FORMAT,
|
||||||
|
"updater_protocol": UPDATER_PROTOCOL,
|
||||||
|
"min_updater_protocol": UPDATER_PROTOCOL,
|
||||||
|
"release_notes": "",
|
||||||
|
"signature_algorithm": "",
|
||||||
|
"manifest_signature": "",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def write_release_metadata(zip_path, app_version, output_path):
|
||||||
|
metadata = build_release_metadata(zip_path, app_version)
|
||||||
|
output_path = Path(output_path)
|
||||||
|
output_path.write_text(
|
||||||
|
json.dumps(metadata, ensure_ascii=False, indent=2) + "\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
return output_path
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv=None):
|
||||||
|
parser = argparse.ArgumentParser(description="生成蝦皮圈优化助手发布校验文件")
|
||||||
|
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||||
|
|
||||||
|
manifest_parser = subparsers.add_parser("manifest")
|
||||||
|
manifest_parser.add_argument("--release-dir", required=True)
|
||||||
|
manifest_parser.add_argument("--version", required=True)
|
||||||
|
|
||||||
|
metadata_parser = subparsers.add_parser("metadata")
|
||||||
|
metadata_parser.add_argument("--zip", required=True)
|
||||||
|
metadata_parser.add_argument("--version", required=True)
|
||||||
|
metadata_parser.add_argument("--output", required=True)
|
||||||
|
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
if args.command == "manifest":
|
||||||
|
write_package_manifest(args.release_dir, args.version)
|
||||||
|
else:
|
||||||
|
write_release_metadata(args.zip, args.version, args.output)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,263 @@
|
|||||||
|
"""cmshopee account subscription status helpers.
|
||||||
|
|
||||||
|
The remote service remains the authority for subscription enforcement. This
|
||||||
|
module only normalizes the status response for desktop UI and preflight use;
|
||||||
|
it deliberately never persists or exposes the API key it uses.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime
|
||||||
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
|
from . import ai, appconfig
|
||||||
|
|
||||||
|
|
||||||
|
PRODUCT_CODE = "cmshopee"
|
||||||
|
STATUS_NOT_CONFIGURED = "not_configured"
|
||||||
|
STATUS_ACTIVE = "active"
|
||||||
|
STATUS_GRACE = "grace"
|
||||||
|
STATUS_REQUIRED = "required"
|
||||||
|
STATUS_EXPIRED = "expired"
|
||||||
|
STATUS_REVOKED = "revoked"
|
||||||
|
STATUS_ACCOUNT_DISABLED = "account_disabled"
|
||||||
|
STATUS_KEY_INVALID = "key_invalid"
|
||||||
|
STATUS_UNAVAILABLE = "unavailable"
|
||||||
|
STATUS_LEGACY = "legacy"
|
||||||
|
|
||||||
|
_ALLOWED_STATES = {
|
||||||
|
STATUS_NOT_CONFIGURED,
|
||||||
|
STATUS_ACTIVE,
|
||||||
|
STATUS_GRACE,
|
||||||
|
STATUS_REQUIRED,
|
||||||
|
STATUS_EXPIRED,
|
||||||
|
STATUS_REVOKED,
|
||||||
|
STATUS_ACCOUNT_DISABLED,
|
||||||
|
STATUS_KEY_INVALID,
|
||||||
|
STATUS_UNAVAILABLE,
|
||||||
|
STATUS_LEGACY,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SubscriptionStatus:
|
||||||
|
"""A redacted, UI-safe view of one cmshopee subscription lookup."""
|
||||||
|
|
||||||
|
state: str
|
||||||
|
account_name: str = ""
|
||||||
|
plan_name: str = ""
|
||||||
|
expires_at: str = ""
|
||||||
|
grace_expires_at: str = ""
|
||||||
|
manage_url: str = ""
|
||||||
|
notice_id: str = ""
|
||||||
|
real_entitlement_allowed: bool = False
|
||||||
|
|
||||||
|
def __post_init__(self):
|
||||||
|
if self.state not in _ALLOWED_STATES:
|
||||||
|
raise ValueError("未知订阅状态")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def allows_product_workflows(self) -> bool:
|
||||||
|
return self.real_entitlement_allowed
|
||||||
|
|
||||||
|
@property
|
||||||
|
def credentials_accepted(self) -> bool:
|
||||||
|
return self.state not in {
|
||||||
|
STATUS_NOT_CONFIGURED,
|
||||||
|
STATUS_KEY_INVALID,
|
||||||
|
STATUS_UNAVAILABLE,
|
||||||
|
}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def interface_available(self) -> bool:
|
||||||
|
return self.state != STATUS_LEGACY
|
||||||
|
|
||||||
|
@property
|
||||||
|
def user_message(self) -> str:
|
||||||
|
return {
|
||||||
|
STATUS_NOT_CONFIGURED: "请先在设置配置 cmhub API Key",
|
||||||
|
STATUS_ACTIVE: "会员有效",
|
||||||
|
STATUS_GRACE: "会员处于宽限期",
|
||||||
|
STATUS_REQUIRED: "当前账号尚未开通蝦皮圈会员",
|
||||||
|
STATUS_EXPIRED: "当前账号的蝦皮圈会员已到期",
|
||||||
|
STATUS_REVOKED: "当前账号的蝦皮圈会员已撤销",
|
||||||
|
STATUS_ACCOUNT_DISABLED: "cmhub 账号当前不可用",
|
||||||
|
STATUS_KEY_INVALID: "cmhub API Key 无效,请在设置重新填写",
|
||||||
|
STATUS_UNAVAILABLE: "暂时无法确认会员状态",
|
||||||
|
STATUS_LEGACY: "会员服务尚未启用,当前按原有方式运行",
|
||||||
|
}[self.state]
|
||||||
|
|
||||||
|
|
||||||
|
def check_status(
|
||||||
|
config=None,
|
||||||
|
*,
|
||||||
|
cmhub_config_path=None,
|
||||||
|
base_url=None,
|
||||||
|
api_key=None,
|
||||||
|
request_json=None,
|
||||||
|
) -> SubscriptionStatus:
|
||||||
|
"""Look up the current API-key account subscription without leaking secrets.
|
||||||
|
|
||||||
|
A service that has not yet deployed the endpoint returns ``404``. That is
|
||||||
|
an explicit rollout compatibility state rather than an invalid membership.
|
||||||
|
"""
|
||||||
|
|
||||||
|
cfg = appconfig.load_config() if config is None else config
|
||||||
|
try:
|
||||||
|
cmhub = appconfig.cmhub_config(cfg)
|
||||||
|
configured_base_url = (
|
||||||
|
cmhub.get("base_url") if base_url is None else base_url
|
||||||
|
)
|
||||||
|
resolved_base_url = appconfig.normalize_cmhub_base_url(configured_base_url)
|
||||||
|
key_path = (
|
||||||
|
cmhub_config_path
|
||||||
|
or cfg.get("cmhub_config_path")
|
||||||
|
or appconfig.cmhub_config_file_path(cfg)
|
||||||
|
)
|
||||||
|
resolved_api_key = (
|
||||||
|
appconfig.get_cmhub_api_key(path=key_path)
|
||||||
|
if api_key is None
|
||||||
|
else str(api_key or "").strip()
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
return SubscriptionStatus(STATUS_NOT_CONFIGURED)
|
||||||
|
if not resolved_base_url or not resolved_api_key:
|
||||||
|
return SubscriptionStatus(STATUS_NOT_CONFIGURED)
|
||||||
|
|
||||||
|
requester = request_json or ai.request_cmhub_json
|
||||||
|
try:
|
||||||
|
data = requester(
|
||||||
|
"GET",
|
||||||
|
resolved_base_url,
|
||||||
|
"/api/v1/cmshopee/subscription/status",
|
||||||
|
resolved_api_key,
|
||||||
|
connect_timeout=cmhub.get(
|
||||||
|
"connect_timeout",
|
||||||
|
appconfig.CMHUB_CONNECT_TIMEOUT_DEFAULT,
|
||||||
|
),
|
||||||
|
read_timeout=30,
|
||||||
|
use_system_proxy=bool(appconfig.ai_config(cfg).get("use_system_proxy")),
|
||||||
|
)
|
||||||
|
except ai.CMHubError as exc:
|
||||||
|
return _status_from_cmhub_error(exc)
|
||||||
|
except Exception:
|
||||||
|
return SubscriptionStatus(STATUS_UNAVAILABLE)
|
||||||
|
return _parse_status_response(data, resolved_base_url)
|
||||||
|
|
||||||
|
|
||||||
|
def format_expiry(value: str) -> str:
|
||||||
|
"""Return a compact local date for the title-area membership label."""
|
||||||
|
|
||||||
|
text = str(value or "").strip()
|
||||||
|
if not text:
|
||||||
|
return ""
|
||||||
|
try:
|
||||||
|
parsed = datetime.fromisoformat(text.replace("Z", "+00:00"))
|
||||||
|
except ValueError:
|
||||||
|
return ""
|
||||||
|
return parsed.date().isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def _status_from_cmhub_error(exc: ai.CMHubError) -> SubscriptionStatus:
|
||||||
|
code = str(getattr(exc, "code", "") or "").strip().lower()
|
||||||
|
if code == "not_found":
|
||||||
|
return SubscriptionStatus(STATUS_LEGACY)
|
||||||
|
if code == "unauthorized":
|
||||||
|
return SubscriptionStatus(STATUS_KEY_INVALID)
|
||||||
|
if code == "account_disabled":
|
||||||
|
return SubscriptionStatus(STATUS_ACCOUNT_DISABLED)
|
||||||
|
if code in {"subscription_required", "subscription_missing"}:
|
||||||
|
return SubscriptionStatus(STATUS_REQUIRED)
|
||||||
|
if code in {"subscription_expired", "license_expired"}:
|
||||||
|
return SubscriptionStatus(STATUS_EXPIRED)
|
||||||
|
if code in {"subscription_revoked", "subscription_cancelled"}:
|
||||||
|
return SubscriptionStatus(STATUS_REVOKED)
|
||||||
|
return SubscriptionStatus(STATUS_UNAVAILABLE)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_status_response(data, base_url: str) -> SubscriptionStatus:
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return SubscriptionStatus(STATUS_UNAVAILABLE)
|
||||||
|
if str(data.get("product_code") or "").strip().lower() != PRODUCT_CODE:
|
||||||
|
return SubscriptionStatus(STATUS_UNAVAILABLE)
|
||||||
|
|
||||||
|
normalized = _normalize_remote_state(data.get("status"))
|
||||||
|
if not normalized:
|
||||||
|
return SubscriptionStatus(STATUS_UNAVAILABLE)
|
||||||
|
real_entitlement_allowed = data.get("real_entitlement_allowed")
|
||||||
|
if type(real_entitlement_allowed) is not bool:
|
||||||
|
return SubscriptionStatus(STATUS_UNAVAILABLE)
|
||||||
|
if real_entitlement_allowed and normalized not in {
|
||||||
|
STATUS_ACTIVE,
|
||||||
|
STATUS_GRACE,
|
||||||
|
}:
|
||||||
|
return SubscriptionStatus(STATUS_UNAVAILABLE)
|
||||||
|
if normalized in {STATUS_ACTIVE, STATUS_GRACE} and not real_entitlement_allowed:
|
||||||
|
return SubscriptionStatus(STATUS_UNAVAILABLE)
|
||||||
|
account = data.get("account") if isinstance(data.get("account"), dict) else {}
|
||||||
|
plan = data.get("plan") if isinstance(data.get("plan"), dict) else {}
|
||||||
|
account_name = str(account.get("display_name") or "").strip()
|
||||||
|
plan_name = str(plan.get("display_name") or "").strip()
|
||||||
|
expires_at = str(data.get("expires_at") or "").strip()
|
||||||
|
grace_expires_at = str(data.get("grace_expires_at") or "").strip()
|
||||||
|
manage_url = safe_manage_url(data.get("manage_url"), base_url)
|
||||||
|
notice_id = str(data.get("notice_id") or "").strip()
|
||||||
|
|
||||||
|
if normalized in {STATUS_ACTIVE, STATUS_GRACE}:
|
||||||
|
if not account_name or not plan_name or not format_expiry(expires_at):
|
||||||
|
return SubscriptionStatus(STATUS_UNAVAILABLE)
|
||||||
|
if (
|
||||||
|
normalized == STATUS_GRACE
|
||||||
|
and grace_expires_at
|
||||||
|
and not format_expiry(grace_expires_at)
|
||||||
|
):
|
||||||
|
return SubscriptionStatus(STATUS_UNAVAILABLE)
|
||||||
|
|
||||||
|
return SubscriptionStatus(
|
||||||
|
normalized,
|
||||||
|
account_name=account_name,
|
||||||
|
plan_name=plan_name,
|
||||||
|
expires_at=expires_at,
|
||||||
|
grace_expires_at=grace_expires_at,
|
||||||
|
manage_url=manage_url,
|
||||||
|
notice_id=notice_id,
|
||||||
|
real_entitlement_allowed=real_entitlement_allowed,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_remote_state(value) -> str:
|
||||||
|
normalized = str(value or "").strip().lower().replace("-", "_")
|
||||||
|
return {
|
||||||
|
"active": STATUS_ACTIVE,
|
||||||
|
"grace": STATUS_GRACE,
|
||||||
|
"required": STATUS_REQUIRED,
|
||||||
|
"subscription_required": STATUS_REQUIRED,
|
||||||
|
"expired": STATUS_EXPIRED,
|
||||||
|
"subscription_expired": STATUS_EXPIRED,
|
||||||
|
"revoked": STATUS_REVOKED,
|
||||||
|
"cancelled": STATUS_REVOKED,
|
||||||
|
"subscription_revoked": STATUS_REVOKED,
|
||||||
|
"account_disabled": STATUS_ACCOUNT_DISABLED,
|
||||||
|
}.get(normalized, "")
|
||||||
|
|
||||||
|
|
||||||
|
def safe_manage_url(value, base_url: str) -> str:
|
||||||
|
text = str(value or "").strip()
|
||||||
|
if not text:
|
||||||
|
return ""
|
||||||
|
parsed = urlsplit(text)
|
||||||
|
base = urlsplit(str(base_url or ""))
|
||||||
|
if parsed.scheme != "https" or not parsed.hostname:
|
||||||
|
return ""
|
||||||
|
if (
|
||||||
|
parsed.username
|
||||||
|
or parsed.password
|
||||||
|
or not base.hostname
|
||||||
|
or parsed.hostname.lower() != base.hostname.lower()
|
||||||
|
):
|
||||||
|
return ""
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
_safe_manage_url = safe_manage_url
|
||||||
+73
-2
@@ -11,6 +11,7 @@ import re
|
|||||||
import urllib.request
|
import urllib.request
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from . import client_policy
|
||||||
from .version import APP_CODE_NAME, APP_UPDATE_CHECK_URL, APP_VERSION
|
from .version import APP_CODE_NAME, APP_UPDATE_CHECK_URL, APP_VERSION
|
||||||
|
|
||||||
DEFAULT_TIMEOUT_SECONDS = 3.0
|
DEFAULT_TIMEOUT_SECONDS = 3.0
|
||||||
@@ -27,6 +28,12 @@ class UpdateInfo:
|
|||||||
force_update: bool = False
|
force_update: bool = False
|
||||||
download_url: str = ""
|
download_url: str = ""
|
||||||
sha256: str = ""
|
sha256: str = ""
|
||||||
|
size_bytes: int = 0
|
||||||
|
package_format: str = ""
|
||||||
|
updater_protocol: int = 0
|
||||||
|
min_updater_protocol: int = 0
|
||||||
|
signature_algorithm: str = ""
|
||||||
|
manifest_signature: str = ""
|
||||||
message: str = ""
|
message: str = ""
|
||||||
|
|
||||||
|
|
||||||
@@ -39,8 +46,17 @@ class UpdateCheckResult:
|
|||||||
min_supported_version: str = ""
|
min_supported_version: str = ""
|
||||||
download_url: str = ""
|
download_url: str = ""
|
||||||
sha256: str = ""
|
sha256: str = ""
|
||||||
|
size_bytes: int = 0
|
||||||
|
package_format: str = ""
|
||||||
|
updater_protocol: int = 0
|
||||||
|
min_updater_protocol: int = 0
|
||||||
|
signature_algorithm: str = ""
|
||||||
|
manifest_signature: str = ""
|
||||||
|
automatic_update_error: str = ""
|
||||||
message: str = ""
|
message: str = ""
|
||||||
error: str = ""
|
error: str = ""
|
||||||
|
client_policy: client_policy.ClientPolicy | None = None
|
||||||
|
client_policy_error: str = ""
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def can_enter(self) -> bool:
|
def can_enter(self) -> bool:
|
||||||
@@ -111,6 +127,28 @@ def parse_update_info(payload) -> UpdateInfo:
|
|||||||
force_update=_as_bool(payload.get("force_update", release.get("force_update"))),
|
force_update=_as_bool(payload.get("force_update", release.get("force_update"))),
|
||||||
download_url=str(payload.get("download_url") or release.get("download_url") or "").strip(),
|
download_url=str(payload.get("download_url") or release.get("download_url") or "").strip(),
|
||||||
sha256=str(payload.get("sha256") or release.get("sha256") or "").strip(),
|
sha256=str(payload.get("sha256") or release.get("sha256") or "").strip(),
|
||||||
|
size_bytes=int(payload.get("size_bytes") or release.get("size_bytes") or 0),
|
||||||
|
package_format=str(
|
||||||
|
payload.get("package_format") or release.get("package_format") or ""
|
||||||
|
).strip(),
|
||||||
|
updater_protocol=int(
|
||||||
|
payload.get("updater_protocol") or release.get("updater_protocol") or 0
|
||||||
|
),
|
||||||
|
min_updater_protocol=int(
|
||||||
|
payload.get("min_updater_protocol")
|
||||||
|
or release.get("min_updater_protocol")
|
||||||
|
or 0
|
||||||
|
),
|
||||||
|
signature_algorithm=str(
|
||||||
|
payload.get("signature_algorithm")
|
||||||
|
or release.get("signature_algorithm")
|
||||||
|
or ""
|
||||||
|
).strip(),
|
||||||
|
manifest_signature=str(
|
||||||
|
payload.get("manifest_signature")
|
||||||
|
or release.get("manifest_signature")
|
||||||
|
or ""
|
||||||
|
).strip(),
|
||||||
message=str(
|
message=str(
|
||||||
payload.get("message")
|
payload.get("message")
|
||||||
or payload.get("release_notes")
|
or payload.get("release_notes")
|
||||||
@@ -167,8 +205,31 @@ def check_for_update(
|
|||||||
return UpdateCheckResult(current_version=current_version, checked=False)
|
return UpdateCheckResult(current_version=current_version, checked=False)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
payload = (fetcher or fetch_update_payload)(url, timeout)
|
payload = _decode_payload((fetcher or fetch_update_payload)(url, timeout))
|
||||||
info = parse_update_info(_decode_payload(payload))
|
except Exception as exc:
|
||||||
|
return UpdateCheckResult(
|
||||||
|
current_version=current_version,
|
||||||
|
checked=True,
|
||||||
|
forced=False,
|
||||||
|
error=f"启动版本检查失败,已允许继续使用:{exc}",
|
||||||
|
)
|
||||||
|
|
||||||
|
resolved_policy = None
|
||||||
|
policy_error = ""
|
||||||
|
if isinstance(payload, dict) and "client_policy" in payload:
|
||||||
|
try:
|
||||||
|
resolved_policy = client_policy.parse_client_policy(
|
||||||
|
payload.get("client_policy")
|
||||||
|
)
|
||||||
|
policy_error = resolved_policy.warning
|
||||||
|
except client_policy.ClientPolicyError as exc:
|
||||||
|
policy_error = str(exc)
|
||||||
|
|
||||||
|
try:
|
||||||
|
if isinstance(payload, dict) and payload.get("release", object()) is None:
|
||||||
|
info = UpdateInfo()
|
||||||
|
else:
|
||||||
|
info = parse_update_info(payload)
|
||||||
forced = is_forced_update(info, current_version)
|
forced = is_forced_update(info, current_version)
|
||||||
return UpdateCheckResult(
|
return UpdateCheckResult(
|
||||||
current_version=current_version,
|
current_version=current_version,
|
||||||
@@ -178,7 +239,15 @@ def check_for_update(
|
|||||||
min_supported_version=info.min_supported_version,
|
min_supported_version=info.min_supported_version,
|
||||||
download_url=info.download_url,
|
download_url=info.download_url,
|
||||||
sha256=info.sha256,
|
sha256=info.sha256,
|
||||||
|
size_bytes=info.size_bytes,
|
||||||
|
package_format=info.package_format,
|
||||||
|
updater_protocol=info.updater_protocol,
|
||||||
|
min_updater_protocol=info.min_updater_protocol,
|
||||||
|
signature_algorithm=info.signature_algorithm,
|
||||||
|
manifest_signature=info.manifest_signature,
|
||||||
message=info.message,
|
message=info.message,
|
||||||
|
client_policy=resolved_policy,
|
||||||
|
client_policy_error=policy_error,
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
return UpdateCheckResult(
|
return UpdateCheckResult(
|
||||||
@@ -186,4 +255,6 @@ def check_for_update(
|
|||||||
checked=True,
|
checked=True,
|
||||||
forced=False,
|
forced=False,
|
||||||
error=f"启动版本检查失败,已允许继续使用:{exc}",
|
error=f"启动版本检查失败,已允许继续使用:{exc}",
|
||||||
|
client_policy=resolved_policy,
|
||||||
|
client_policy_error=policy_error,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
"""自动升级后的启动健康标记与失败版本熔断记录。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
HEALTH_STATUSES = {"process_started", "main_window_ready", "environment_blocked"}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class UpdateHealthContext:
|
||||||
|
install_root: Path
|
||||||
|
transaction_id: str
|
||||||
|
target_version: str
|
||||||
|
|
||||||
|
@property
|
||||||
|
def transaction_dir(self):
|
||||||
|
return self.install_root / ".cmshopee-update" / "transactions" / self.transaction_id
|
||||||
|
|
||||||
|
@property
|
||||||
|
def health_path(self):
|
||||||
|
return self.transaction_dir / "health.json"
|
||||||
|
|
||||||
|
|
||||||
|
def _atomic_json(path, payload):
|
||||||
|
path = Path(path)
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
temporary = path.with_suffix(path.suffix + ".tmp")
|
||||||
|
temporary.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||||
|
os.replace(str(temporary), str(path))
|
||||||
|
|
||||||
|
|
||||||
|
def context_from_argv(argv, install_root, app_version):
|
||||||
|
values = list(argv or [])
|
||||||
|
try:
|
||||||
|
transaction_index = values.index("--update-transaction")
|
||||||
|
version_index = values.index("--update-target-version")
|
||||||
|
transaction_id = values[transaction_index + 1]
|
||||||
|
target_version = values[version_index + 1]
|
||||||
|
except (ValueError, IndexError):
|
||||||
|
return None
|
||||||
|
if not re.fullmatch(r"[A-Za-z0-9-]{8,64}", transaction_id):
|
||||||
|
return None
|
||||||
|
if target_version != app_version:
|
||||||
|
return None
|
||||||
|
return UpdateHealthContext(Path(install_root).resolve(), transaction_id, target_version)
|
||||||
|
|
||||||
|
|
||||||
|
def write_health(context, status, message=""):
|
||||||
|
if context is None:
|
||||||
|
return None
|
||||||
|
if status not in HEALTH_STATUSES:
|
||||||
|
raise ValueError("未知升级健康状态")
|
||||||
|
payload = {
|
||||||
|
"schema_version": 1,
|
||||||
|
"transaction_id": context.transaction_id,
|
||||||
|
"target_version": context.target_version,
|
||||||
|
"status": status,
|
||||||
|
"message": str(message or "")[:300],
|
||||||
|
"updated_at": time.strftime("%Y-%m-%dT%H:%M:%S%z"),
|
||||||
|
}
|
||||||
|
_atomic_json(context.health_path, payload)
|
||||||
|
return context.health_path
|
||||||
|
|
||||||
|
|
||||||
|
def failed_versions_path(install_root):
|
||||||
|
return Path(install_root).resolve() / ".cmshopee-update" / "failed-versions.json"
|
||||||
|
|
||||||
|
|
||||||
|
def load_failed_versions(install_root):
|
||||||
|
path = failed_versions_path(install_root)
|
||||||
|
try:
|
||||||
|
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, ValueError):
|
||||||
|
return {"schema_version": 1, "releases": {}}
|
||||||
|
releases = payload.get("releases")
|
||||||
|
if not isinstance(releases, dict):
|
||||||
|
releases = {}
|
||||||
|
return {"schema_version": 1, "releases": releases}
|
||||||
|
|
||||||
|
|
||||||
|
def failed_release_key(version, sha256):
|
||||||
|
return "%s:%s" % (str(version or "").strip(), str(sha256 or "").strip().lower())
|
||||||
|
|
||||||
|
|
||||||
|
def record_failed_release(install_root, version, sha256, reason):
|
||||||
|
payload = load_failed_versions(install_root)
|
||||||
|
key = failed_release_key(version, sha256)
|
||||||
|
previous = payload["releases"].get(key) or {}
|
||||||
|
payload["releases"][key] = {
|
||||||
|
"version": str(version),
|
||||||
|
"sha256": str(sha256).lower(),
|
||||||
|
"reason": str(reason or "自动升级后新版未能正常启动")[:300],
|
||||||
|
"attempts": int(previous.get("attempts") or 0) + 1,
|
||||||
|
"failed_at": time.strftime("%Y-%m-%dT%H:%M:%S%z"),
|
||||||
|
}
|
||||||
|
_atomic_json(failed_versions_path(install_root), payload)
|
||||||
|
return payload["releases"][key]
|
||||||
|
|
||||||
|
|
||||||
|
def get_failed_release(install_root, version, sha256):
|
||||||
|
payload = load_failed_versions(install_root)
|
||||||
|
return payload["releases"].get(failed_release_key(version, sha256))
|
||||||
@@ -0,0 +1,457 @@
|
|||||||
|
"""自动升级包的安全下载、解压和暂存校验。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import ipaddress
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import socket
|
||||||
|
import stat
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
import uuid
|
||||||
|
import zipfile
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path, PurePosixPath
|
||||||
|
|
||||||
|
from .release_manifest import (
|
||||||
|
ALLOWED_ROOTS,
|
||||||
|
ENTRY_POINT,
|
||||||
|
MANIFEST_FILENAME,
|
||||||
|
PACKAGE_FORMAT,
|
||||||
|
UPDATER_PROTOCOL,
|
||||||
|
sha256_file,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
MAX_DOWNLOAD_BYTES = 512 * 1024 * 1024
|
||||||
|
MAX_EXTRACTED_BYTES = 2 * 1024 * 1024 * 1024
|
||||||
|
MAX_FILE_COUNT = 20000
|
||||||
|
MAX_COMPRESSION_RATIO = 200
|
||||||
|
RESERVED_NAMES = {
|
||||||
|
"CON",
|
||||||
|
"PRN",
|
||||||
|
"AUX",
|
||||||
|
"NUL",
|
||||||
|
*("COM%d" % number for number in range(1, 10)),
|
||||||
|
*("LPT%d" % number for number in range(1, 10)),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateInstallError(RuntimeError):
|
||||||
|
"""升级包不能安全下载或暂存。"""
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateCancelled(UpdateInstallError):
|
||||||
|
"""用户取消升级包下载。"""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class UpdatePackageMetadata:
|
||||||
|
version: str
|
||||||
|
download_url: str
|
||||||
|
sha256: str
|
||||||
|
size_bytes: int
|
||||||
|
package_format: str
|
||||||
|
updater_protocol: int
|
||||||
|
min_updater_protocol: int = 1
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class StagedUpdate:
|
||||||
|
version: str
|
||||||
|
zip_path: Path
|
||||||
|
staging_dir: Path
|
||||||
|
pending_path: Path
|
||||||
|
sha256: str
|
||||||
|
|
||||||
|
|
||||||
|
def metadata_from_update_info(info):
|
||||||
|
metadata = UpdatePackageMetadata(
|
||||||
|
version=str(getattr(info, "latest_version", "") or "").strip(),
|
||||||
|
download_url=str(getattr(info, "download_url", "") or "").strip(),
|
||||||
|
sha256=str(getattr(info, "sha256", "") or "").strip().lower(),
|
||||||
|
size_bytes=int(getattr(info, "size_bytes", 0) or 0),
|
||||||
|
package_format=PACKAGE_FORMAT,
|
||||||
|
updater_protocol=UPDATER_PROTOCOL,
|
||||||
|
min_updater_protocol=UPDATER_PROTOCOL,
|
||||||
|
)
|
||||||
|
validate_metadata(metadata)
|
||||||
|
return metadata
|
||||||
|
|
||||||
|
|
||||||
|
def validate_metadata(metadata):
|
||||||
|
if not re.fullmatch(r"\d+(?:\.\d+)*", metadata.version):
|
||||||
|
raise UpdateInstallError("新版版本号格式不正确")
|
||||||
|
validate_download_url(metadata.download_url)
|
||||||
|
if not re.fullmatch(r"[0-9a-fA-F]{64}", metadata.sha256):
|
||||||
|
raise UpdateInstallError("新版安装包校验值缺失或格式不正确")
|
||||||
|
if metadata.size_bytes <= 0 or metadata.size_bytes > MAX_DOWNLOAD_BYTES:
|
||||||
|
raise UpdateInstallError("新版安装包大小不正确或超过限制")
|
||||||
|
if metadata.package_format != PACKAGE_FORMAT:
|
||||||
|
raise UpdateInstallError("新版安装包格式不受支持")
|
||||||
|
if metadata.updater_protocol != UPDATER_PROTOCOL:
|
||||||
|
raise UpdateInstallError("新版安装包更新协议不受支持")
|
||||||
|
if metadata.min_updater_protocol > UPDATER_PROTOCOL:
|
||||||
|
raise UpdateInstallError("当前更新器版本过低")
|
||||||
|
|
||||||
|
|
||||||
|
def _system_resolve_host(host, port):
|
||||||
|
try:
|
||||||
|
answers = socket.getaddrinfo(host, port, type=socket.SOCK_STREAM)
|
||||||
|
except OSError as exc:
|
||||||
|
raise UpdateInstallError("新版下载地址无法解析") from exc
|
||||||
|
return [answer[4][0] for answer in answers if answer[4]]
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_public_addresses(addresses):
|
||||||
|
addresses = list(addresses or ())
|
||||||
|
if not addresses:
|
||||||
|
raise UpdateInstallError("新版下载地址无法解析")
|
||||||
|
for value in addresses:
|
||||||
|
try:
|
||||||
|
address = ipaddress.ip_address(str(value).split("%", 1)[0])
|
||||||
|
except ValueError as exc:
|
||||||
|
raise UpdateInstallError("新版下载地址解析结果无效") from exc
|
||||||
|
if not address.is_global:
|
||||||
|
raise UpdateInstallError("新版下载地址不能指向本机或内网")
|
||||||
|
|
||||||
|
|
||||||
|
def uses_plain_http(url):
|
||||||
|
try:
|
||||||
|
return urllib.parse.urlsplit(str(url or "")).scheme.lower() == "http"
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def validate_download_url(url, *, resolver=None):
|
||||||
|
try:
|
||||||
|
parsed = urllib.parse.urlsplit(str(url or ""))
|
||||||
|
except ValueError as exc:
|
||||||
|
raise UpdateInstallError("新版下载地址格式不正确") from exc
|
||||||
|
host = (parsed.hostname or "").lower().rstrip(".")
|
||||||
|
if parsed.scheme.lower() not in {"http", "https"} or not host:
|
||||||
|
raise UpdateInstallError("新版下载地址必须使用 HTTP 或 HTTPS")
|
||||||
|
if parsed.username or parsed.password:
|
||||||
|
raise UpdateInstallError("新版下载地址不能包含账号信息")
|
||||||
|
if host == "localhost" or host.endswith(".localhost") or host == "localhost.localdomain":
|
||||||
|
raise UpdateInstallError("新版下载地址不能指向本机或内网")
|
||||||
|
try:
|
||||||
|
port = parsed.port or (443 if parsed.scheme.lower() == "https" else 80)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise UpdateInstallError("新版下载地址端口无效") from exc
|
||||||
|
try:
|
||||||
|
address = ipaddress.ip_address(host)
|
||||||
|
except ValueError:
|
||||||
|
address = None
|
||||||
|
if address is not None:
|
||||||
|
_validate_public_addresses((address,))
|
||||||
|
elif resolver is not None:
|
||||||
|
try:
|
||||||
|
addresses = resolver(host, port)
|
||||||
|
except UpdateInstallError:
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
raise UpdateInstallError("新版下载地址无法解析") from exc
|
||||||
|
_validate_public_addresses(addresses)
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
|
||||||
|
class _SafeRedirectHandler(urllib.request.HTTPRedirectHandler):
|
||||||
|
def __init__(self, resolver):
|
||||||
|
super().__init__()
|
||||||
|
self.resolver = resolver
|
||||||
|
|
||||||
|
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
||||||
|
validate_download_url(newurl, resolver=self.resolver)
|
||||||
|
return super().redirect_request(req, fp, code, msg, headers, newurl)
|
||||||
|
|
||||||
|
|
||||||
|
def _update_root(install_root):
|
||||||
|
return Path(install_root).resolve() / ".cmshopee-update"
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_unlink(path):
|
||||||
|
try:
|
||||||
|
Path(path).unlink()
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def download_package(
|
||||||
|
metadata,
|
||||||
|
install_root,
|
||||||
|
*,
|
||||||
|
resolver=None,
|
||||||
|
opener=None,
|
||||||
|
cancelled=None,
|
||||||
|
progress=None,
|
||||||
|
timeout=66,
|
||||||
|
):
|
||||||
|
validate_metadata(metadata)
|
||||||
|
resolver = resolver or _system_resolve_host
|
||||||
|
validate_download_url(metadata.download_url, resolver=resolver)
|
||||||
|
downloads = _update_root(install_root) / "downloads"
|
||||||
|
downloads.mkdir(parents=True, exist_ok=True)
|
||||||
|
part_path = downloads / (metadata.version + ".zip.part")
|
||||||
|
zip_path = downloads / (metadata.version + ".zip")
|
||||||
|
_safe_unlink(part_path)
|
||||||
|
|
||||||
|
if shutil.disk_usage(str(downloads)).free < metadata.size_bytes:
|
||||||
|
raise UpdateInstallError("磁盘空间不足,无法下载新版")
|
||||||
|
|
||||||
|
request = urllib.request.Request(
|
||||||
|
metadata.download_url,
|
||||||
|
headers={"Accept": "application/zip", "User-Agent": "cmshopee-updater/1"},
|
||||||
|
)
|
||||||
|
if opener is None:
|
||||||
|
opener = urllib.request.build_opener(_SafeRedirectHandler(resolver))
|
||||||
|
|
||||||
|
digest = hashlib.sha256()
|
||||||
|
downloaded = 0
|
||||||
|
try:
|
||||||
|
with opener.open(request, timeout=timeout) as response, part_path.open("wb") as output:
|
||||||
|
validate_download_url(response.geturl(), resolver=resolver)
|
||||||
|
while True:
|
||||||
|
if cancelled and cancelled():
|
||||||
|
raise UpdateCancelled("已取消下载新版")
|
||||||
|
chunk = response.read(1024 * 1024)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
downloaded += len(chunk)
|
||||||
|
if downloaded > metadata.size_bytes or downloaded > MAX_DOWNLOAD_BYTES:
|
||||||
|
raise UpdateInstallError("新版安装包大小超过接口声明")
|
||||||
|
output.write(chunk)
|
||||||
|
digest.update(chunk)
|
||||||
|
if progress:
|
||||||
|
progress(downloaded, metadata.size_bytes)
|
||||||
|
if downloaded != metadata.size_bytes:
|
||||||
|
raise UpdateInstallError("新版安装包大小校验失败")
|
||||||
|
if digest.hexdigest().lower() != metadata.sha256.lower():
|
||||||
|
raise UpdateInstallError("新版安装包完整性校验失败")
|
||||||
|
os.replace(str(part_path), str(zip_path))
|
||||||
|
return zip_path
|
||||||
|
except UpdateInstallError:
|
||||||
|
_safe_unlink(part_path)
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
_safe_unlink(part_path)
|
||||||
|
raise UpdateInstallError("下载新版失败,请检查网络后重试") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_member_name(name):
|
||||||
|
if not name or "\\" in name or name.startswith(("/", "\\")):
|
||||||
|
raise UpdateInstallError("新版安装包包含不安全路径")
|
||||||
|
if re.match(r"^[A-Za-z]:", name) or ":" in name:
|
||||||
|
raise UpdateInstallError("新版安装包包含不安全路径")
|
||||||
|
path = PurePosixPath(name)
|
||||||
|
if any(part in {"", ".", ".."} for part in path.parts):
|
||||||
|
raise UpdateInstallError("新版安装包包含路径穿越")
|
||||||
|
for part in path.parts:
|
||||||
|
if part.endswith((".", " ")):
|
||||||
|
raise UpdateInstallError("新版安装包包含 Windows 不支持的路径")
|
||||||
|
stem = part.split(".", 1)[0].upper()
|
||||||
|
if stem in RESERVED_NAMES:
|
||||||
|
raise UpdateInstallError("新版安装包包含 Windows 保留名称")
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def _is_zip_symlink(info):
|
||||||
|
mode = (info.external_attr >> 16) & 0xFFFF
|
||||||
|
return stat.S_ISLNK(mode)
|
||||||
|
|
||||||
|
|
||||||
|
def safe_extract(zip_path, target_dir):
|
||||||
|
target_dir = Path(target_dir)
|
||||||
|
seen = set()
|
||||||
|
total_size = 0
|
||||||
|
with zipfile.ZipFile(str(zip_path), "r") as archive:
|
||||||
|
infos = archive.infolist()
|
||||||
|
if len(infos) > MAX_FILE_COUNT:
|
||||||
|
raise UpdateInstallError("新版安装包文件数量超过限制")
|
||||||
|
validated = []
|
||||||
|
for info in infos:
|
||||||
|
path = _validate_member_name(info.filename.rstrip("/") if info.is_dir() else info.filename)
|
||||||
|
key = path.as_posix().casefold()
|
||||||
|
if key in seen:
|
||||||
|
raise UpdateInstallError("新版安装包包含重复路径")
|
||||||
|
seen.add(key)
|
||||||
|
if _is_zip_symlink(info):
|
||||||
|
raise UpdateInstallError("新版安装包不允许符号链接")
|
||||||
|
total_size += info.file_size
|
||||||
|
if total_size > MAX_EXTRACTED_BYTES:
|
||||||
|
raise UpdateInstallError("新版安装包解压大小超过限制")
|
||||||
|
if info.file_size and (
|
||||||
|
info.compress_size == 0
|
||||||
|
or info.file_size / max(info.compress_size, 1) > MAX_COMPRESSION_RATIO
|
||||||
|
):
|
||||||
|
raise UpdateInstallError("新版安装包压缩比异常")
|
||||||
|
validated.append((info, path))
|
||||||
|
|
||||||
|
target_dir.mkdir(parents=True, exist_ok=False)
|
||||||
|
for info, relative in validated:
|
||||||
|
destination = target_dir.joinpath(*relative.parts)
|
||||||
|
if info.is_dir():
|
||||||
|
destination.mkdir(parents=True, exist_ok=True)
|
||||||
|
continue
|
||||||
|
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with archive.open(info, "r") as source, destination.open("wb") as output:
|
||||||
|
shutil.copyfileobj(source, output, length=1024 * 1024)
|
||||||
|
return target_dir
|
||||||
|
|
||||||
|
|
||||||
|
def validate_staging(staging_dir, metadata):
|
||||||
|
staging_dir = Path(staging_dir)
|
||||||
|
for name in (ENTRY_POINT, "_internal", "version.txt", MANIFEST_FILENAME):
|
||||||
|
if not (staging_dir / name).exists():
|
||||||
|
raise UpdateInstallError("新版安装包缺少必要程序文件")
|
||||||
|
if not (staging_dir / "_internal").is_dir():
|
||||||
|
raise UpdateInstallError("新版安装包依赖目录无效")
|
||||||
|
if (staging_dir / "version.txt").read_text(encoding="utf-8-sig").strip() != metadata.version:
|
||||||
|
raise UpdateInstallError("新版安装包版本不一致")
|
||||||
|
|
||||||
|
try:
|
||||||
|
manifest = json.loads((staging_dir / MANIFEST_FILENAME).read_text(encoding="utf-8"))
|
||||||
|
except (OSError, ValueError) as exc:
|
||||||
|
raise UpdateInstallError("新版安装包清单无效") from exc
|
||||||
|
if (
|
||||||
|
manifest.get("app_version") != metadata.version
|
||||||
|
or manifest.get("package_format") != metadata.package_format
|
||||||
|
or manifest.get("entry_point") != ENTRY_POINT
|
||||||
|
or int(manifest.get("updater_protocol") or 0) != metadata.updater_protocol
|
||||||
|
or int(manifest.get("min_updater_protocol") or 0) > UPDATER_PROTOCOL
|
||||||
|
):
|
||||||
|
raise UpdateInstallError("新版安装包清单与发布信息不一致")
|
||||||
|
|
||||||
|
replace_roots = manifest.get("replace_roots")
|
||||||
|
if not isinstance(replace_roots, list) or not replace_roots:
|
||||||
|
raise UpdateInstallError("新版安装包替换范围无效")
|
||||||
|
if any(str(root) not in ALLOWED_ROOTS for root in replace_roots):
|
||||||
|
raise UpdateInstallError("新版安装包包含未授权替换范围")
|
||||||
|
|
||||||
|
declared = {}
|
||||||
|
for item in manifest.get("files") or []:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
raise UpdateInstallError("新版安装包清单文件项无效")
|
||||||
|
relative = _validate_member_name(str(item.get("path") or ""))
|
||||||
|
if relative.parts[0] not in ALLOWED_ROOTS or relative.name == MANIFEST_FILENAME:
|
||||||
|
raise UpdateInstallError("新版安装包清单包含未授权文件")
|
||||||
|
key = relative.as_posix().casefold()
|
||||||
|
if key in declared:
|
||||||
|
raise UpdateInstallError("新版安装包清单包含重复路径")
|
||||||
|
try:
|
||||||
|
size_bytes = int(item["size_bytes"])
|
||||||
|
except (KeyError, TypeError, ValueError) as exc:
|
||||||
|
raise UpdateInstallError("新版安装包清单文件大小无效") from exc
|
||||||
|
if size_bytes < 0:
|
||||||
|
raise UpdateInstallError("新版安装包清单文件大小无效")
|
||||||
|
declared[key] = (relative, size_bytes, str(item.get("sha256") or ""))
|
||||||
|
|
||||||
|
actual = {}
|
||||||
|
for path in staging_dir.rglob("*"):
|
||||||
|
if path.is_symlink():
|
||||||
|
raise UpdateInstallError("新版暂存目录不允许符号链接")
|
||||||
|
if not path.is_file() or path.name == MANIFEST_FILENAME:
|
||||||
|
continue
|
||||||
|
relative = PurePosixPath(path.relative_to(staging_dir).as_posix())
|
||||||
|
actual[relative.as_posix().casefold()] = relative
|
||||||
|
if set(actual) != set(declared):
|
||||||
|
raise UpdateInstallError("新版安装包文件与清单不一致")
|
||||||
|
for key, (relative, size_bytes, expected_hash) in declared.items():
|
||||||
|
path = staging_dir.joinpath(*relative.parts)
|
||||||
|
if path.stat().st_size != size_bytes or sha256_file(path).lower() != expected_hash.lower():
|
||||||
|
raise UpdateInstallError("新版安装包文件校验失败")
|
||||||
|
return manifest
|
||||||
|
|
||||||
|
|
||||||
|
def _write_pending(update_root, metadata, staging_dir, zip_path):
|
||||||
|
pending_path = update_root / "pending.json"
|
||||||
|
payload = {
|
||||||
|
"schema_version": 1,
|
||||||
|
"stage": "verified",
|
||||||
|
"version": metadata.version,
|
||||||
|
"install_root": str(update_root.parent),
|
||||||
|
"staging_dir": str(Path(staging_dir).relative_to(update_root).as_posix()),
|
||||||
|
"zip_path": str(Path(zip_path).relative_to(update_root).as_posix()),
|
||||||
|
"sha256": metadata.sha256,
|
||||||
|
"package_format": metadata.package_format,
|
||||||
|
"updater_protocol": metadata.updater_protocol,
|
||||||
|
}
|
||||||
|
temporary = pending_path.with_suffix(".json.tmp")
|
||||||
|
temporary.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||||
|
os.replace(str(temporary), str(pending_path))
|
||||||
|
return pending_path
|
||||||
|
|
||||||
|
|
||||||
|
def _discard_pending(update_root, payload=None):
|
||||||
|
if isinstance(payload, dict):
|
||||||
|
relative = payload.get("staging_dir")
|
||||||
|
if relative:
|
||||||
|
try:
|
||||||
|
staging_dir = (update_root / relative).resolve()
|
||||||
|
if update_root in staging_dir.parents and staging_dir.exists():
|
||||||
|
shutil.rmtree(str(staging_dir), ignore_errors=True)
|
||||||
|
except (OSError, ValueError):
|
||||||
|
pass
|
||||||
|
_safe_unlink(update_root / "pending.json")
|
||||||
|
|
||||||
|
|
||||||
|
def load_verified_pending(install_root, metadata):
|
||||||
|
update_root = _update_root(install_root)
|
||||||
|
pending_path = update_root / "pending.json"
|
||||||
|
payload = None
|
||||||
|
try:
|
||||||
|
payload = json.loads(pending_path.read_text(encoding="utf-8"))
|
||||||
|
if (
|
||||||
|
payload.get("stage") != "verified"
|
||||||
|
or payload.get("version") != metadata.version
|
||||||
|
or payload.get("sha256") != metadata.sha256
|
||||||
|
):
|
||||||
|
raise UpdateInstallError("待安装更新记录已失效")
|
||||||
|
staging_dir = (update_root / payload["staging_dir"]).resolve()
|
||||||
|
zip_path = (update_root / payload["zip_path"]).resolve()
|
||||||
|
if update_root not in staging_dir.parents or update_root not in zip_path.parents:
|
||||||
|
raise UpdateInstallError("待安装更新路径无效")
|
||||||
|
validate_staging(staging_dir, metadata)
|
||||||
|
if not zip_path.is_file() or sha256_file(zip_path) != metadata.sha256:
|
||||||
|
raise UpdateInstallError("待安装更新压缩包已失效")
|
||||||
|
return StagedUpdate(metadata.version, zip_path, staging_dir, pending_path, metadata.sha256)
|
||||||
|
except FileNotFoundError:
|
||||||
|
return None
|
||||||
|
except (OSError, ValueError, KeyError, UpdateInstallError):
|
||||||
|
_discard_pending(update_root, payload)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def prepare_update(metadata, install_root, stage_callback=None, **download_kwargs):
|
||||||
|
validate_metadata(metadata)
|
||||||
|
if stage_callback:
|
||||||
|
stage_callback("正在检查已下载的新版")
|
||||||
|
reusable = load_verified_pending(install_root, metadata)
|
||||||
|
if reusable is not None:
|
||||||
|
if stage_callback:
|
||||||
|
stage_callback("新版已经完成校验")
|
||||||
|
return reusable
|
||||||
|
update_root = _update_root(install_root)
|
||||||
|
if stage_callback:
|
||||||
|
stage_callback("正在下载新版")
|
||||||
|
zip_path = download_package(metadata, install_root, **download_kwargs)
|
||||||
|
staging_parent = update_root / "staging"
|
||||||
|
staging_parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
staging_dir = staging_parent / (metadata.version + "-" + uuid.uuid4().hex[:12])
|
||||||
|
try:
|
||||||
|
if stage_callback:
|
||||||
|
stage_callback("正在校验并准备新版")
|
||||||
|
safe_extract(zip_path, staging_dir)
|
||||||
|
validate_staging(staging_dir, metadata)
|
||||||
|
pending_path = _write_pending(update_root, metadata, staging_dir, zip_path)
|
||||||
|
except Exception:
|
||||||
|
if staging_dir.exists():
|
||||||
|
shutil.rmtree(str(staging_dir), ignore_errors=True)
|
||||||
|
raise
|
||||||
|
if stage_callback:
|
||||||
|
stage_callback("新版已经完成校验")
|
||||||
|
return StagedUpdate(metadata.version, zip_path, staging_dir, pending_path, metadata.sha256)
|
||||||
@@ -0,0 +1,464 @@
|
|||||||
|
"""在主程序退出后执行程序根项目事务替换的独立更新器。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path, PurePosixPath
|
||||||
|
|
||||||
|
from app import update_health
|
||||||
|
|
||||||
|
|
||||||
|
PACKAGE_FORMAT = "cmshopee-portable-v1"
|
||||||
|
UPDATER_PROTOCOL = 1
|
||||||
|
ALLOWED_ROOTS = {
|
||||||
|
"cmshopee.exe",
|
||||||
|
"_internal",
|
||||||
|
"version.txt",
|
||||||
|
"README.txt",
|
||||||
|
"package-manifest.json",
|
||||||
|
"cmshopee-updater.exe",
|
||||||
|
}
|
||||||
|
PROTECTED_ROOTS = {"data", ".cmshopee-update"}
|
||||||
|
|
||||||
|
|
||||||
|
class UpdaterError(RuntimeError):
|
||||||
|
"""独立更新器拒绝计划或无法安全完成事务。"""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class UpdatePlan:
|
||||||
|
parent_pid: int
|
||||||
|
install_root: Path
|
||||||
|
staging_root: Path
|
||||||
|
target_version: str
|
||||||
|
transaction_id: str
|
||||||
|
log_path: Path
|
||||||
|
package_sha256: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
def create_plan(install_root, staging_root, target_version, parent_pid, package_sha256=""):
|
||||||
|
install_root = Path(install_root).resolve()
|
||||||
|
transaction_id = uuid.uuid4().hex
|
||||||
|
update_root = install_root / ".cmshopee-update"
|
||||||
|
plan = UpdatePlan(
|
||||||
|
parent_pid=int(parent_pid),
|
||||||
|
install_root=install_root,
|
||||||
|
staging_root=Path(staging_root).resolve(),
|
||||||
|
target_version=str(target_version),
|
||||||
|
transaction_id=transaction_id,
|
||||||
|
log_path=(update_root / "logs" / (transaction_id + ".log")).resolve(),
|
||||||
|
package_sha256=str(package_sha256 or "").lower(),
|
||||||
|
)
|
||||||
|
validate_plan(plan)
|
||||||
|
plan_path = update_root / "plans" / (transaction_id + ".json")
|
||||||
|
_atomic_json(
|
||||||
|
plan_path,
|
||||||
|
{
|
||||||
|
"parent_pid": plan.parent_pid,
|
||||||
|
"install_root": str(plan.install_root),
|
||||||
|
"staging_root": str(plan.staging_root),
|
||||||
|
"target_version": plan.target_version,
|
||||||
|
"transaction_id": plan.transaction_id,
|
||||||
|
"log_path": str(plan.log_path),
|
||||||
|
"package_sha256": plan.package_sha256,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return plan_path
|
||||||
|
|
||||||
|
|
||||||
|
def _is_child(path, parent):
|
||||||
|
try:
|
||||||
|
Path(path).resolve().relative_to(Path(parent).resolve())
|
||||||
|
return True
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _atomic_json(path, payload):
|
||||||
|
path = Path(path)
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
temporary = path.with_suffix(path.suffix + ".tmp")
|
||||||
|
temporary.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||||
|
os.replace(str(temporary), str(path))
|
||||||
|
|
||||||
|
|
||||||
|
def _append_log(path, message):
|
||||||
|
Path(path).parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with Path(path).open("a", encoding="utf-8") as stream:
|
||||||
|
stream.write("%s %s\n" % (time.strftime("%Y-%m-%d %H:%M:%S"), message))
|
||||||
|
|
||||||
|
|
||||||
|
def load_plan(plan_path):
|
||||||
|
plan_path = Path(plan_path).resolve()
|
||||||
|
try:
|
||||||
|
payload = json.loads(plan_path.read_text(encoding="utf-8"))
|
||||||
|
plan = UpdatePlan(
|
||||||
|
parent_pid=int(payload["parent_pid"]),
|
||||||
|
install_root=Path(payload["install_root"]).resolve(),
|
||||||
|
staging_root=Path(payload["staging_root"]).resolve(),
|
||||||
|
target_version=str(payload["target_version"]),
|
||||||
|
transaction_id=str(payload["transaction_id"]),
|
||||||
|
log_path=Path(payload["log_path"]).resolve(),
|
||||||
|
package_sha256=str(payload.get("package_sha256") or "").lower(),
|
||||||
|
)
|
||||||
|
except (OSError, ValueError, KeyError, TypeError) as exc:
|
||||||
|
raise UpdaterError("更新计划无效") from exc
|
||||||
|
validate_plan(plan, plan_path)
|
||||||
|
return plan
|
||||||
|
|
||||||
|
|
||||||
|
def validate_plan(plan, plan_path=None):
|
||||||
|
if plan.parent_pid <= 0:
|
||||||
|
raise UpdaterError("更新计划缺少主程序进程编号")
|
||||||
|
if not plan.install_root.is_absolute() or not plan.install_root.is_dir():
|
||||||
|
raise UpdaterError("程序安装目录无效")
|
||||||
|
update_root = plan.install_root / ".cmshopee-update"
|
||||||
|
staging_parent = update_root / "staging"
|
||||||
|
if not plan.staging_root.is_dir() or plan.staging_root.parent != staging_parent.resolve():
|
||||||
|
raise UpdaterError("新版暂存目录越界")
|
||||||
|
if not re.fullmatch(r"\d+(?:\.\d+)*", plan.target_version):
|
||||||
|
raise UpdaterError("目标版本号格式不正确")
|
||||||
|
if not re.fullmatch(r"[A-Za-z0-9-]{8,64}", plan.transaction_id):
|
||||||
|
raise UpdaterError("更新事务编号无效")
|
||||||
|
if plan.package_sha256 and not re.fullmatch(r"[0-9a-f]{64}", plan.package_sha256):
|
||||||
|
raise UpdaterError("更新安装包校验值无效")
|
||||||
|
if not _is_child(plan.log_path, update_root / "logs"):
|
||||||
|
raise UpdaterError("更新日志路径越界")
|
||||||
|
if plan_path is not None and not _is_child(plan_path, update_root):
|
||||||
|
raise UpdaterError("更新计划文件路径越界")
|
||||||
|
probe = update_root / (".write-probe-" + plan.transaction_id)
|
||||||
|
try:
|
||||||
|
probe.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
probe.write_bytes(b"")
|
||||||
|
probe.unlink()
|
||||||
|
except OSError as exc:
|
||||||
|
raise UpdaterError("程序安装目录不可写") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _sha256(path):
|
||||||
|
digest = hashlib.sha256()
|
||||||
|
with Path(path).open("rb") as stream:
|
||||||
|
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
|
||||||
|
digest.update(chunk)
|
||||||
|
return digest.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def validate_staging(plan):
|
||||||
|
manifest_path = plan.staging_root / "package-manifest.json"
|
||||||
|
try:
|
||||||
|
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, ValueError) as exc:
|
||||||
|
raise UpdaterError("新版程序清单无效") from exc
|
||||||
|
if (
|
||||||
|
manifest.get("package_format") != PACKAGE_FORMAT
|
||||||
|
or manifest.get("app_version") != plan.target_version
|
||||||
|
or manifest.get("entry_point") != "cmshopee.exe"
|
||||||
|
or int(manifest.get("updater_protocol") or 0) != UPDATER_PROTOCOL
|
||||||
|
):
|
||||||
|
raise UpdaterError("新版程序清单与更新计划不一致")
|
||||||
|
roots = manifest.get("replace_roots")
|
||||||
|
if not isinstance(roots, list) or not roots:
|
||||||
|
raise UpdaterError("新版程序替换范围无效")
|
||||||
|
roots = list(dict.fromkeys(str(root) for root in roots))
|
||||||
|
if any(root not in ALLOWED_ROOTS or root in PROTECTED_ROOTS for root in roots):
|
||||||
|
raise UpdaterError("新版程序包含未授权替换范围")
|
||||||
|
if "cmshopee.exe" not in roots or "_internal" not in roots:
|
||||||
|
raise UpdaterError("新版程序缺少必要替换项目")
|
||||||
|
|
||||||
|
declared = {}
|
||||||
|
for item in manifest.get("files") or []:
|
||||||
|
relative = PurePosixPath(str(item.get("path") or ""))
|
||||||
|
if not relative.parts or ".." in relative.parts or relative.parts[0] not in roots:
|
||||||
|
raise UpdaterError("新版程序清单包含越界文件")
|
||||||
|
key = relative.as_posix().casefold()
|
||||||
|
if key in declared:
|
||||||
|
raise UpdaterError("新版程序清单包含重复文件")
|
||||||
|
try:
|
||||||
|
size_bytes = int(item["size_bytes"])
|
||||||
|
except (KeyError, TypeError, ValueError) as exc:
|
||||||
|
raise UpdaterError("新版程序清单文件大小无效") from exc
|
||||||
|
if size_bytes < 0:
|
||||||
|
raise UpdaterError("新版程序清单文件大小无效")
|
||||||
|
declared[key] = (relative, size_bytes, str(item.get("sha256") or ""))
|
||||||
|
actual = {}
|
||||||
|
for path in plan.staging_root.rglob("*"):
|
||||||
|
if path.is_symlink():
|
||||||
|
raise UpdaterError("新版程序不允许符号链接")
|
||||||
|
if path.is_file() and path.name != "package-manifest.json":
|
||||||
|
relative = PurePosixPath(path.relative_to(plan.staging_root).as_posix())
|
||||||
|
actual[relative.as_posix().casefold()] = relative
|
||||||
|
if set(actual) != set(declared):
|
||||||
|
raise UpdaterError("新版程序文件与清单不一致")
|
||||||
|
for key, (relative, size_bytes, expected_hash) in declared.items():
|
||||||
|
path = plan.staging_root.joinpath(*relative.parts)
|
||||||
|
if path.stat().st_size != size_bytes or _sha256(path).lower() != expected_hash.lower():
|
||||||
|
raise UpdaterError("新版程序文件校验失败")
|
||||||
|
if (plan.staging_root / "version.txt").read_text(encoding="utf-8-sig").strip() != plan.target_version:
|
||||||
|
raise UpdaterError("新版程序版本文件不一致")
|
||||||
|
return roots
|
||||||
|
|
||||||
|
|
||||||
|
def _pid_running(pid):
|
||||||
|
if pid <= 0:
|
||||||
|
return False
|
||||||
|
if os.name == "nt":
|
||||||
|
import ctypes
|
||||||
|
|
||||||
|
handle = ctypes.windll.kernel32.OpenProcess(0x1000, False, pid)
|
||||||
|
if handle:
|
||||||
|
ctypes.windll.kernel32.CloseHandle(handle)
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
os.kill(pid, 0)
|
||||||
|
return True
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def wait_for_parent_exit(pid, timeout=60, poll_seconds=0.2):
|
||||||
|
deadline = time.monotonic() + timeout
|
||||||
|
while _pid_running(pid):
|
||||||
|
if time.monotonic() >= deadline:
|
||||||
|
raise UpdaterError("等待旧版程序退出超时")
|
||||||
|
time.sleep(poll_seconds)
|
||||||
|
|
||||||
|
|
||||||
|
class TransactionLock:
|
||||||
|
def __init__(self, path):
|
||||||
|
self.path = Path(path)
|
||||||
|
self.fd = None
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
try:
|
||||||
|
self.fd = os.open(str(self.path), os.O_CREAT | os.O_EXCL | os.O_WRONLY)
|
||||||
|
os.write(self.fd, str(os.getpid()).encode("ascii"))
|
||||||
|
except FileExistsError as exc:
|
||||||
|
raise UpdaterError("已有更新程序正在处理当前安装目录") from exc
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *_args):
|
||||||
|
if self.fd is not None:
|
||||||
|
os.close(self.fd)
|
||||||
|
try:
|
||||||
|
self.path.unlink()
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _default_launch(executable, args):
|
||||||
|
creationflags = getattr(subprocess, "CREATE_NO_WINDOW", 0)
|
||||||
|
return subprocess.Popen([str(executable), *args], close_fds=True, creationflags=creationflags)
|
||||||
|
|
||||||
|
|
||||||
|
def wait_for_health(plan, process, timeout=90, poll_seconds=0.25):
|
||||||
|
deadline = time.monotonic() + timeout
|
||||||
|
health_path = (
|
||||||
|
plan.install_root
|
||||||
|
/ ".cmshopee-update"
|
||||||
|
/ "transactions"
|
||||||
|
/ plan.transaction_id
|
||||||
|
/ "health.json"
|
||||||
|
)
|
||||||
|
last_status = ""
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
try:
|
||||||
|
payload = json.loads(health_path.read_text(encoding="utf-8"))
|
||||||
|
if (
|
||||||
|
payload.get("transaction_id") == plan.transaction_id
|
||||||
|
and payload.get("target_version") == plan.target_version
|
||||||
|
):
|
||||||
|
last_status = str(payload.get("status") or "")
|
||||||
|
if last_status in {"main_window_ready", "environment_blocked"}:
|
||||||
|
return last_status
|
||||||
|
except (OSError, ValueError):
|
||||||
|
pass
|
||||||
|
if process is not None and hasattr(process, "poll") and process.poll() is not None:
|
||||||
|
raise UpdaterError("新版程序在主窗口就绪前退出")
|
||||||
|
time.sleep(poll_seconds)
|
||||||
|
if last_status == "process_started":
|
||||||
|
raise UpdaterError("新版程序启动后未能显示主窗口")
|
||||||
|
raise UpdaterError("等待新版程序启动确认超时")
|
||||||
|
|
||||||
|
|
||||||
|
def _cleanup_after_health(plan, backup_root, remove_backup):
|
||||||
|
update_root = plan.install_root / ".cmshopee-update"
|
||||||
|
for path in (
|
||||||
|
update_root / "pending.json",
|
||||||
|
update_root / "downloads" / (plan.target_version + ".zip"),
|
||||||
|
update_root / "downloads" / (plan.target_version + ".zip.part"),
|
||||||
|
update_root / "plans" / (plan.transaction_id + ".json"),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
path.unlink()
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
if plan.staging_root.exists():
|
||||||
|
shutil.rmtree(str(plan.staging_root), ignore_errors=True)
|
||||||
|
if remove_backup and backup_root.exists():
|
||||||
|
shutil.rmtree(str(backup_root), ignore_errors=True)
|
||||||
|
|
||||||
|
|
||||||
|
def prune_backups(update_root, keep=2):
|
||||||
|
backup_parent = Path(update_root) / "backup"
|
||||||
|
if not backup_parent.is_dir():
|
||||||
|
return
|
||||||
|
backups = sorted(
|
||||||
|
(path for path in backup_parent.iterdir() if path.is_dir()),
|
||||||
|
key=lambda path: path.stat().st_mtime,
|
||||||
|
reverse=True,
|
||||||
|
)
|
||||||
|
for path in backups[max(0, int(keep)) :]:
|
||||||
|
shutil.rmtree(str(path), ignore_errors=True)
|
||||||
|
|
||||||
|
|
||||||
|
def apply_update(
|
||||||
|
plan,
|
||||||
|
*,
|
||||||
|
wait_parent=wait_for_parent_exit,
|
||||||
|
move=os.replace,
|
||||||
|
launcher=_default_launch,
|
||||||
|
health_waiter=wait_for_health,
|
||||||
|
):
|
||||||
|
validate_plan(plan)
|
||||||
|
roots = validate_staging(plan)
|
||||||
|
update_root = plan.install_root / ".cmshopee-update"
|
||||||
|
old_version_path = plan.install_root / "version.txt"
|
||||||
|
old_version = old_version_path.read_text(encoding="utf-8-sig").strip() if old_version_path.exists() else "unknown"
|
||||||
|
backup_root = update_root / "backup" / (old_version + "-" + plan.transaction_id)
|
||||||
|
failed_root = update_root / "failed" / plan.transaction_id
|
||||||
|
journal_path = update_root / "transactions" / plan.transaction_id / "journal.json"
|
||||||
|
operations = []
|
||||||
|
|
||||||
|
def journal(status, error=""):
|
||||||
|
_atomic_json(
|
||||||
|
journal_path,
|
||||||
|
{
|
||||||
|
"transaction_id": plan.transaction_id,
|
||||||
|
"target_version": plan.target_version,
|
||||||
|
"status": status,
|
||||||
|
"operations": operations,
|
||||||
|
"error": error,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
with TransactionLock(update_root / "update.lock"):
|
||||||
|
wait_parent(plan.parent_pid)
|
||||||
|
backup_root.mkdir(parents=True, exist_ok=False)
|
||||||
|
journal("moving_old")
|
||||||
|
try:
|
||||||
|
for root in roots:
|
||||||
|
source = plan.install_root / root
|
||||||
|
if source.exists():
|
||||||
|
destination = backup_root / root
|
||||||
|
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
move(str(source), str(destination))
|
||||||
|
operations.append({"kind": "old_to_backup", "root": root})
|
||||||
|
journal("moving_old")
|
||||||
|
for root in roots:
|
||||||
|
source = plan.staging_root / root
|
||||||
|
if not source.exists():
|
||||||
|
raise UpdaterError("新版程序缺少替换项目:%s" % root)
|
||||||
|
destination = plan.install_root / root
|
||||||
|
move(str(source), str(destination))
|
||||||
|
operations.append({"kind": "new_to_install", "root": root})
|
||||||
|
journal("moving_new")
|
||||||
|
process = launcher(
|
||||||
|
plan.install_root / "cmshopee.exe",
|
||||||
|
["--update-transaction", plan.transaction_id, "--update-target-version", plan.target_version],
|
||||||
|
)
|
||||||
|
journal("launched")
|
||||||
|
_append_log(plan.log_path, "新版程序已替换并启动:%s" % plan.target_version)
|
||||||
|
health_status = health_waiter(plan, process)
|
||||||
|
if health_status == "environment_blocked":
|
||||||
|
journal("environment_blocked")
|
||||||
|
_append_log(plan.log_path, "新版程序已启动,但本地环境需要用户处理")
|
||||||
|
_cleanup_after_health(plan, backup_root, remove_backup=False)
|
||||||
|
prune_backups(update_root)
|
||||||
|
return backup_root
|
||||||
|
journal("healthy")
|
||||||
|
_append_log(plan.log_path, "新版主窗口已就绪:%s" % plan.target_version)
|
||||||
|
_cleanup_after_health(plan, backup_root, remove_backup=True)
|
||||||
|
prune_backups(update_root)
|
||||||
|
return backup_root
|
||||||
|
except Exception as exc:
|
||||||
|
if any(operation["kind"] == "new_to_install" for operation in operations):
|
||||||
|
update_health.record_failed_release(
|
||||||
|
plan.install_root,
|
||||||
|
plan.target_version,
|
||||||
|
plan.package_sha256,
|
||||||
|
str(exc),
|
||||||
|
)
|
||||||
|
journal("rolling_back", str(exc))
|
||||||
|
failed_root.mkdir(parents=True, exist_ok=True)
|
||||||
|
rollback_errors = []
|
||||||
|
for operation in reversed(operations):
|
||||||
|
root = operation["root"]
|
||||||
|
try:
|
||||||
|
if operation["kind"] == "new_to_install":
|
||||||
|
source = plan.install_root / root
|
||||||
|
if source.exists():
|
||||||
|
destination = failed_root / root
|
||||||
|
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
move(str(source), str(destination))
|
||||||
|
else:
|
||||||
|
source = backup_root / root
|
||||||
|
if source.exists():
|
||||||
|
move(str(source), str(plan.install_root / root))
|
||||||
|
except Exception as rollback_exc:
|
||||||
|
rollback_errors.append(str(rollback_exc))
|
||||||
|
status = "rollback_failed" if rollback_errors else "rolled_back"
|
||||||
|
journal(status, ";".join(rollback_errors) or str(exc))
|
||||||
|
_append_log(plan.log_path, "更新失败,已尝试恢复旧版:%s" % exc)
|
||||||
|
if rollback_errors:
|
||||||
|
raise UpdaterError("更新失败且旧版恢复不完整,请联系管理员") from exc
|
||||||
|
raise UpdaterError("更新失败,已恢复旧版程序") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def copy_and_launch_updater(updater_path, plan_path):
|
||||||
|
updater_path = Path(updater_path).resolve()
|
||||||
|
if not updater_path.is_file():
|
||||||
|
raise UpdaterError("独立更新程序不存在")
|
||||||
|
temporary_dir = Path(tempfile.mkdtemp(prefix="cmshopee-update-"))
|
||||||
|
external_updater = temporary_dir / updater_path.name
|
||||||
|
shutil.copy2(str(updater_path), str(external_updater))
|
||||||
|
creationflags = getattr(subprocess, "CREATE_NO_WINDOW", 0) | getattr(
|
||||||
|
subprocess, "DETACHED_PROCESS", 0
|
||||||
|
)
|
||||||
|
return subprocess.Popen(
|
||||||
|
[str(external_updater), "--plan", str(Path(plan_path).resolve())],
|
||||||
|
close_fds=True,
|
||||||
|
creationflags=creationflags,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv=None):
|
||||||
|
parser = argparse.ArgumentParser(description="蝦皮圈优化助手独立更新程序")
|
||||||
|
parser.add_argument("--plan", required=True)
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
try:
|
||||||
|
plan = load_plan(args.plan)
|
||||||
|
apply_update(plan)
|
||||||
|
return 0
|
||||||
|
except Exception as exc:
|
||||||
|
try:
|
||||||
|
if "plan" in locals():
|
||||||
|
_append_log(plan.log_path, "自动更新失败:%s" % exc)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# -*- mode: python ; coding: utf-8 -*-
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
# Same mark as the main executable: the updater window belongs to this product.
|
||||||
|
APP_ICON = os.path.join(SPECPATH, "app", "assets", "cmshopee.ico")
|
||||||
|
|
||||||
|
a = Analysis(
|
||||||
|
["app/updater_entry.py"],
|
||||||
|
pathex=[],
|
||||||
|
binaries=[],
|
||||||
|
datas=[],
|
||||||
|
hiddenimports=[],
|
||||||
|
hookspath=[],
|
||||||
|
hooksconfig={},
|
||||||
|
runtime_hooks=[],
|
||||||
|
excludes=["PySide6"],
|
||||||
|
noarchive=False,
|
||||||
|
)
|
||||||
|
pyz = PYZ(a.pure)
|
||||||
|
exe = EXE(
|
||||||
|
pyz,
|
||||||
|
a.scripts,
|
||||||
|
a.binaries,
|
||||||
|
a.datas,
|
||||||
|
[],
|
||||||
|
name="cmshopee-updater",
|
||||||
|
debug=False,
|
||||||
|
bootloader_ignore_signals=False,
|
||||||
|
strip=False,
|
||||||
|
upx=True,
|
||||||
|
console=False,
|
||||||
|
icon=APP_ICON,
|
||||||
|
)
|
||||||
+31
-1
@@ -7,17 +7,46 @@ include config files, SQLite databases, Chrome profiles, generated images,
|
|||||||
logs, prompts, or other operator data.
|
logs, prompts, or other operator data.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
|
import os
|
||||||
|
|
||||||
|
from PyInstaller.utils.hooks import collect_data_files
|
||||||
|
|
||||||
block_cipher = None
|
block_cipher = None
|
||||||
|
try:
|
||||||
|
backports_tarfile_spec = importlib.util.find_spec("backports.tarfile")
|
||||||
|
except ModuleNotFoundError:
|
||||||
|
backports_tarfile_spec = None
|
||||||
|
if backports_tarfile_spec is None:
|
||||||
|
raise SystemExit(
|
||||||
|
"Missing backports.tarfile. Run: "
|
||||||
|
"py -3.10 -m pip install -r requirements-build.txt"
|
||||||
|
)
|
||||||
|
default_prompt_datas = collect_data_files(
|
||||||
|
"app.default_prompts",
|
||||||
|
includes=["**/*.txt"],
|
||||||
|
)
|
||||||
|
# Window icon at runtime; the same file is the executable icon below.
|
||||||
|
asset_datas = collect_data_files(
|
||||||
|
"app.assets",
|
||||||
|
includes=["*.ico", "*.png", "**/*.ico", "**/*.png"],
|
||||||
|
)
|
||||||
|
if not asset_datas:
|
||||||
|
raise SystemExit(
|
||||||
|
"app/assets 下没有找到图标资源,请先运行:py -3.10 scripts/gen_logo.py"
|
||||||
|
)
|
||||||
|
APP_ICON = os.path.join(SPECPATH, "app", "assets", "cmshopee.ico")
|
||||||
|
|
||||||
a = Analysis(
|
a = Analysis(
|
||||||
["main.py"],
|
["main.py"],
|
||||||
pathex=[],
|
pathex=[],
|
||||||
binaries=[],
|
binaries=[],
|
||||||
datas=[],
|
datas=default_prompt_datas + asset_datas,
|
||||||
hiddenimports=[
|
hiddenimports=[
|
||||||
"PySide6.QtCore",
|
"PySide6.QtCore",
|
||||||
"PySide6.QtGui",
|
"PySide6.QtGui",
|
||||||
"PySide6.QtWidgets",
|
"PySide6.QtWidgets",
|
||||||
|
"backports.tarfile",
|
||||||
],
|
],
|
||||||
hookspath=[],
|
hookspath=[],
|
||||||
hooksconfig={},
|
hooksconfig={},
|
||||||
@@ -42,6 +71,7 @@ exe = EXE(
|
|||||||
upx=True,
|
upx=True,
|
||||||
console=False,
|
console=False,
|
||||||
disable_windowed_traceback=False,
|
disable_windowed_traceback=False,
|
||||||
|
icon=APP_ICON,
|
||||||
)
|
)
|
||||||
coll = COLLECT(
|
coll = COLLECT(
|
||||||
exe,
|
exe,
|
||||||
|
|||||||
@@ -4,10 +4,10 @@
|
|||||||
|
|
||||||
## 一句话定位
|
## 一句话定位
|
||||||
|
|
||||||
蝦皮圈優化助手(代号 cmshopee)是一个 Windows 本地桌面自动化工具(PySide6,5 Tab),让运营管理多个 Shopee 账号,并用 CDP 驱动 Chrome + AI 批量改商品标题、换商品封面。
|
蝦皮圈優化助手(代号 cmshopee)是一个 Windows 本地桌面自动化工具(PySide6,当前正式界面显示 6 个工作流 Tab),让运营管理多个 Shopee 卖家账号,并用 CDP 驱动 Chrome + AI 批量改商品标题、换商品封面;⑥「商品套图」用于按账号与商品生成本地电商套图,不自动上传蝦皮。
|
||||||
|
|
||||||
5 Tab 流水线(工作流优先顺序):
|
主流水线(工作流优先顺序):
|
||||||
**① 导入采集 → ② AI生成 → ③ 更新蝦皮 → ④ 账号管理 → ⑤ 设置**
|
**① 导入采集 → ② AI生成 → ③ 更新蝦皮 → ④ 账号管理 → ⑤ 设置 → ⑥ 商品套图**。①~③是 Excel 批量更新主流水线;⑥是独立的本地商品套图工作区,复用 cmhub 托管生图与既有 `image_studio_*` 数据,不自动上传蝦皮。
|
||||||
|
|
||||||
目标闭环:④ 配账号并登录 → ① 导入 Excel(按“别名”列关联账号)、采集旧标题/旧封面并回写 → ② 用提示词 AI 生成新标题/新封面(不设逐条确认阶段)→ ③ 对已生成任务点击「开始更新」,弹窗确认后批量改标题+换封面并点「更新」提交 → 结果实时存 SQLite、批量回写原 Excel。
|
目标闭环:④ 配账号并登录 → ① 导入 Excel(按“别名”列关联账号)、采集旧标题/旧封面并回写 → ② 用提示词 AI 生成新标题/新封面(不设逐条确认阶段)→ ③ 对已生成任务点击「开始更新」,弹窗确认后批量改标题+换封面并点「更新」提交 → 结果实时存 SQLite、批量回写原 Excel。
|
||||||
|
|
||||||
@@ -30,7 +30,7 @@
|
|||||||
|
|
||||||
## 当前阶段
|
## 当前阶段
|
||||||
|
|
||||||
当前项目处于:**V0 单账号流程已验证,正在实现 V1 多账号 + Excel + AI + 5 Tab GUI 流水线**。
|
当前项目处于:**V0 单账号流程已验证,V1 多账号 + Excel + AI 的 6 Tab GUI 工作台持续收口中;⑥商品套图已接入**。
|
||||||
|
|
||||||
优先路径:
|
优先路径:
|
||||||
|
|
||||||
@@ -65,7 +65,7 @@
|
|||||||
|
|
||||||
**V1 当前 coding 目标**:
|
**V1 当前 coding 目标**:
|
||||||
|
|
||||||
- 5 Tab 流水线:① 导入采集 → ② AI生成 → ③ 更新蝦皮 → ④ 账号管理 → ⑤ 设置。
|
- 当前正式界面为 6 Tab 工作台:① 导入采集 → ② AI生成 → ③ 更新蝦皮 → ④ 账号管理 → ⑤ 设置 → ⑥ 商品套图。旧 `ImageStudioTab` 只保留内部兼容,主界面入口由 `ProductSuiteTab` 替代;⑥只生成和管理本地图片,不自动提交线上。
|
||||||
- GUI 固定为 PySide6;后台采集/生成/更新用 `QObject` worker + `QThread` + signal 回传进度。
|
- GUI 固定为 PySide6;后台采集/生成/更新用 `QObject` worker + `QThread` + signal 回传进度。
|
||||||
- 多账号管理;账号以独立 user-data-dir 隔离。③ 更新默认串行,提供「检查本轮更新」按钮;⑤ 可开启按账号并行和设置每批最大更新条数。
|
- 多账号管理;账号以独立 user-data-dir 隔离。③ 更新默认串行,提供「检查本轮更新」按钮;⑤ 可开启按账号并行和设置每批最大更新条数。
|
||||||
- Excel 导入/回写 + SQLite 实时落库 + 本地图片目录。
|
- Excel 导入/回写 + SQLite 实时落库 + 本地图片目录。
|
||||||
|
|||||||
@@ -32,9 +32,9 @@
|
|||||||
| 换封面 | 上传一张本地图片,自动设为商品封面(满 9 张时必须先确认本地旧封面备份存在,再删原封面并上传) | P0 |
|
| 换封面 | 上传一张本地图片,自动设为商品封面(满 9 张时必须先确认本地旧封面备份存在,再删原封面并上传) | P0 |
|
||||||
| 提交更新 | 在显式确认后点击「更新」,把改动提交到线上;原型脚本默认不提交 | P0 |
|
| 提交更新 | 在显式确认后点击「更新」,把改动提交到线上;原型脚本默认不提交 | P0 |
|
||||||
|
|
||||||
### V1 当前目标(多账号 + Excel + AI,5 Tab 流水线)
|
### V1 当前目标(多账号 + Excel + AI,6 Tab 工作台)
|
||||||
|
|
||||||
5 个 Tab,顺序:① 导入采集 → ② AI生成 → ③ 更新蝦皮 → ④ 账号管理 → ⑤ 设置。
|
6 个 Tab,顺序:① 导入采集 → ② AI生成 → ③ 更新蝦皮 → ④ 账号管理 → ⑤ 设置 → ⑥ 商品套图。
|
||||||
|
|
||||||
| 功能 | 用户能做什么 | 优先级 |
|
| 功能 | 用户能做什么 | 优先级 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
@@ -42,13 +42,14 @@
|
|||||||
| 导入(①) | 导入多个 Excel,按模板解析输入列(账号名/别名/商品id)为任务列表 | P0 |
|
| 导入(①) | 导入多个 Excel,按模板解析输入列(账号名/别名/商品id)为任务列表 | P0 |
|
||||||
| 导入校验汇总(①) | 导入后展示文件数、解析行数(原始数据量)、有效/无效行、匹配账号行数(按账号细分)、未匹配行数;跑采集前先纠错 | P0 |
|
| 导入校验汇总(①) | 导入后展示文件数、解析行数(原始数据量)、有效/无效行、匹配账号行数(按账号细分)、未匹配行数;跑采集前先纠错 | P0 |
|
||||||
| 采集旧数据(①) | 程序只读打开商品页,抓取旧标题、下载旧封面到本地,回写 Excel 旧字段 | P0 |
|
| 采集旧数据(①) | 程序只读打开商品页,抓取旧标题、下载旧封面到本地,回写 Excel 旧字段 | P0 |
|
||||||
| AI 生成(②) | 输入标题/封面提示词,AI 据提示词+旧标题生成新标题;封面图片按②本轮「生成封面图片(成本较高)」开关可选生成,避免用户无意产生图片模型成本;②任务表格拆分显示「标题状态 / 图片状态」,商品ID列缩窄约 50%,状态列再缩到当前约 33%,多出的宽度平均给旧标题和新标题;标题/图片两条进度条右侧分别显示生标题/生图实时用时秒数;点击「开始生成」时清空②界面旧日志,只显示本轮生成日志,历史 run_logs 仍保留 | P0 |
|
| AI 生成(②) | 输入标题/封面提示词,按②本轮「生成内容」下拉选择只生成标题、只生成封面或生成标题和封面;只生成封面时不调用生文,标题语义优先使用新标题、没有新标题时回退已采集的旧标题,并且只保存新封面、不把旧标题写成新标题;②任务表格拆分显示「标题状态 / 图片状态」,商品ID列缩窄约 50%,状态列再缩到当前约 33%,多出的宽度平均给旧标题和新标题;标题/图片两条进度条右侧分别显示生标题/生图实时用时秒数;点击「开始生成」时清空②界面旧日志,只显示本轮生成日志,历史 run_logs 仍保留 | P0 |
|
||||||
| 提示词管理(②) | 标题提示词「保存」到 `data/title_prompt.txt` 并启动回显;封面提示词多模板(下拉 + 新建/保存/另存为/重命名/删除)+ 插入 `{新标题}` + 预览(变量替换) | P0 |
|
| 提示词管理(②) | 标题提示词「保存」到 `data/title_prompt.txt` 并启动回显;封面提示词多模板(下拉 + 新建/保存/另存为/重命名/删除)+ 插入 `{新标题}` + 预览(变量替换) | P0 |
|
||||||
| 查看对照(②) | 双击任务弹窗查看新旧封面(纯查看,无逐条审核阶段);可选对单行重生成 | P0 |
|
| 查看对照(②) | 双击任务弹窗查看旧封面、新封面和历史候选图;可在弹窗内对当前行「重置图片」,退出后用状态筛选「待生成」批量补生成封面;不在弹窗内启动单条生图 | P0 |
|
||||||
| 更新蝦皮(③) | 按批次/店铺/状态筛选;可先「检查本轮更新」确认范围;点击「开始更新」后弹窗确认,确认后对当前筛选出的已生成任务按每批最大条数分批更新蝦皮商品,逐条换标题+封面并点「更新」提交线上;普通正式更新不再受测试商品 ID 限制,可批量更新真实商品;可按状态=失败重试 | P0 |
|
| 更新蝦皮(③) | 按批次/店铺/状态筛选;③左下角「更新内容」下拉选择只更新标题、只更新封面或更新标题和封面;点击「开始更新」前先校验所选内容已生成,缺失则中文弹窗阻断且不打开 Chrome;确认后对当前筛选出的已生成任务按每批最大条数分批更新蝦皮商品并点「更新」提交线上;普通正式更新不再受测试商品 ID 限制,可批量更新真实商品;可按状态=失败重试 | P0 |
|
||||||
| 检查本轮更新 / 运行日志 / 多账号并行(③/⑤) | ③ 提供「检查本轮更新」按钮,不打开 Shopee、不提交、不改任务状态,只显示当前筛选范围、店铺分布、预计分批和会更新字段;点击「检查本轮更新」或「开始更新」时清空③界面旧日志,只显示本轮检查/更新日志;真实更新写运行日志;可在⑤开启多账号并行,同账号内仍串行 | V2 已接入,检查按钮已接入 |
|
| 检查本轮更新 / 运行日志 / 多账号并行(③/⑤) | ③ 提供「检查本轮更新」按钮,不打开 Shopee、不提交、不改任务状态,只显示当前筛选范围、店铺分布、预计分批、更新内容和会更新字段;点击「检查本轮更新」或「开始更新」时清空③界面旧日志,只显示本轮检查/更新日志;真实更新写运行日志;可在⑤开启多账号并行,同账号内仍串行 | V2 已接入,检查按钮已接入 |
|
||||||
| 结果存储与回写 | 各阶段结果实时存 SQLite;该文件全部完成后把旧/新数据+状态批量回写原 Excel | P0 |
|
| 结果存储与回写 | 各阶段结果实时存 SQLite;该文件全部完成后把旧/新数据+状态批量回写原 Excel | P0 |
|
||||||
| 设置(⑤) | AI 模型管理(下拉+新增/删除/详情/测试连接,至少各一个文本+图像模型);标题/图片大模型角色选择;分辨率(512/1k/2k/4k,普通默认 cmhub 模式下只控制生图尺寸,返回超时只读展示实际口径:标题 600 秒 / 图片 900 秒;direct 兼容路径仍按分辨率映射);并发/重试/jpg质量,其中 cmhub 生图请求与图片下载实际并发最大 5,超过后内部限流且运行日志展示实际并发;图片目录/Chrome 路径/端口;设置页采用居中内容区、适度左右留白和响应式三列表单,长字段跨列显示;点击「保存设置」成功后弹轻量提示框 | P0 |
|
| 设置(⑤) | AI 模型管理(下拉+新增/删除/详情/测试连接,至少各一个文本+图像模型);标题/图片大模型角色选择;分辨率(512/1k/2k/4k,普通默认 cmhub 模式下只控制生图尺寸,返回超时只读展示实际口径:标题 600 秒 / 图片 900 秒;direct 兼容路径仍按分辨率映射);并发/重试/jpg质量,其中 cmhub 生图请求与图片下载实际并发最大 5,超过后内部限流且运行日志展示实际并发;图片目录/Chrome 路径/端口;设置页采用居中内容区、适度左右留白和响应式三列表单,长字段跨列显示;点击「保存设置」成功后弹轻量提示框 | P0 |
|
||||||
|
| 商品套图(⑥) | 以账号+商品ID建立本地图片项目;可拉取蝦皮主图或添加本地原图,按白底图/场景图/卖点图及自定义分类生成套图;支持多任务并行、历史结果、单图重试、删除撤销;只写本地项目,不自动上传或修改蝦皮 | P1 |
|
||||||
| 首次引导保护 | 未配账号、对应账号 Chrome 未启动或未登录时,① ③ 执行按钮禁用/执行前拦截并提示去④;③ 不自动打开缺失账号 Chrome,必须中止本轮更新 | P0 |
|
| 首次引导保护 | 未配账号、对应账号 Chrome 未启动或未登录时,① ③ 执行按钮禁用/执行前拦截并提示去④;③ 不自动打开缺失账号 Chrome,必须中止本轮更新 | P0 |
|
||||||
|
|
||||||
### 后续迭代
|
### 后续迭代
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
| WebSocket | `websocket-client`(import `websocket`) | 已定 | 讲 CDP 协议;用 `suppress_origin=True` 绕过 403 |
|
| WebSocket | `websocket-client`(import `websocket`) | 已定 | 讲 CDP 协议;用 `suppress_origin=True` 绕过 403 |
|
||||||
| HTTP | `requests` | 已定 | 读 `/json` 拿 tab 列表;`trust_env=False` 忽略代理 |
|
| HTTP | `requests` | 已定 | 读 `/json` 拿 tab 列表;`trust_env=False` 忽略代理 |
|
||||||
| 浏览器 | Google Chrome(已安装) | 已定 | 带 `--remote-debugging-port` 启动 |
|
| 浏览器 | Google Chrome(已安装) | 已定 | 带 `--remote-debugging-port` 启动 |
|
||||||
| GUI 框架 | PySide6(Qt for Python,`QTabWidget` 5 Tab) | 已定 | 当前环境已安装 PySide6;V1 需要表格、图片预览、后台任务进度、确认弹窗,Qt 的 signal/slot + QThread 更适合 |
|
| GUI 框架 | PySide6(Qt for Python,`QTabWidget` 6 Tab) | 已定 | 当前环境已安装 PySide6;V1 需要表格、图片预览、后台任务进度、确认弹窗,Qt 的 signal/slot + QThread 更适合 |
|
||||||
| 应用配置 | `data/config.json`(JSON,stdlib) | 已定 | 少量应用级设置:Chrome 路径、目录根、端口、DB 路径等 |
|
| 应用配置 | `data/config.json`(JSON,stdlib) | 已定 | 少量应用级设置:Chrome 路径、目录根、端口、DB 路径等 |
|
||||||
| 业务数据 | SQLite(stdlib `sqlite3`,`data/cmshopee.db`) | 已定 | 账号、任务、结果:成行增长、要查询/统计/导出 |
|
| 业务数据 | SQLite(stdlib `sqlite3`,`data/cmshopee.db`) | 已定 | 账号、任务、结果:成行增长、要查询/统计/导出 |
|
||||||
| Excel 读写 | `openpyxl` | 已定 | 导入任务、回写结果;stdlib 读不了 .xlsx |
|
| Excel 读写 | `openpyxl` | 已定 | 导入任务、回写结果;stdlib 读不了 .xlsx |
|
||||||
@@ -30,7 +30,7 @@
|
|||||||
## 二、决策记录与演进
|
## 二、决策记录与演进
|
||||||
|
|
||||||
- **CDP 自研而非 playwright**:当前已验证根目录 `cdp.py`,正式代码迁入 `app/cdp.py`;它零重依赖、完全可控,并已在开发环境绕开了代理(`*_proxy` 指向本地 :1080)和 Chrome 的 Origin 403 两个坑。未来若交互复杂度大幅上升,再评估 playwright。
|
- **CDP 自研而非 playwright**:当前已验证根目录 `cdp.py`,正式代码迁入 `app/cdp.py`;它零重依赖、完全可控,并已在开发环境绕开了代理(`*_proxy` 指向本地 :1080)和 Chrome 的 Origin 403 两个坑。未来若交互复杂度大幅上升,再评估 playwright。
|
||||||
- **GUI 选 PySide6**:V1 是 5 Tab 运营工作台,包含任务表格、筛选、图片预览、后台采集/生成/更新、进度与停止。当前环境已安装 PySide6,且 Tkinter 不可用;Qt 的 `QThread`/signal-slot 比 Tkinter 手动 queue/after 更适合长任务回传 UI。
|
- **GUI 选 PySide6**:V1 是 6 Tab 运营工作台,包含任务表格、筛选、图片预览、后台采集/生成/更新、商品套图多任务、进度与停止。⑥采用原生 `ProductSuiteTab`,不引入 QtWebEngine;Qt 的 `QThread`/signal-slot 用于长任务回传 UI。
|
||||||
- **存储拆两层**:应用设置进 `data/config.json`,账号/任务/结果进 SQLite。判据:少量人改无需查询 → 配置文件;成行增长要查询/导出 → DB。同一事实只存一处,不重复。取代早期的 `accounts.json` 方案。
|
- **存储拆两层**:应用设置进 `data/config.json`,账号/任务/结果进 SQLite。判据:少量人改无需查询 → 配置文件;成行增长要查询/导出 → DB。同一事实只存一处,不重复。取代早期的 `accounts.json` 方案。
|
||||||
- **Excel 用 openpyxl**:运营用真实 .xlsx;stdlib 无法读写 xlsx,引入一个轻依赖比改用 CSV 更贴合用户习惯。
|
- **Excel 用 openpyxl**:运营用真实 .xlsx;stdlib 无法读写 xlsx,引入一个轻依赖比改用 CSV 更贴合用户习惯。
|
||||||
- **多账号隔离用独立 user-data-dir,不用 Chrome profile**:profile 共享同一 user-data-dir/进程/调试端口,无法每账号独立 CDP 与并行;独立 user-data-dir 才契合自动化。详见 [架构 3.0](04-architecture.md)。
|
- **多账号隔离用独立 user-data-dir,不用 Chrome profile**:profile 共享同一 user-data-dir/进程/调试端口,无法每账号独立 CDP 与并行;独立 user-data-dir 才契合自动化。详见 [架构 3.0](04-architecture.md)。
|
||||||
@@ -81,7 +81,8 @@ set AUTO=1 && python prototypes/demo.py
|
|||||||
- 运行时第三方依赖统一写入根目录 `requirements.txt` 并锁定版本;换机或 CI 使用 `python -m pip install -r requirements.txt` 安装。
|
- 运行时第三方依赖统一写入根目录 `requirements.txt` 并锁定版本;换机或 CI 使用 `python -m pip install -r requirements.txt` 安装。
|
||||||
- 开发检查依赖写入 `requirements-dev.txt`;当前只包含 ruff,不属于运行时或打包依赖。
|
- 开发检查依赖写入 `requirements-dev.txt`;当前只包含 ruff,不属于运行时或打包依赖。
|
||||||
- 打包依赖只写入 `requirements-build.txt`;PyInstaller 不属于运行时依赖,不写进 `requirements.txt`。
|
- 打包依赖只写入 `requirements-build.txt`;PyInstaller 不属于运行时依赖,不写进 `requirements.txt`。
|
||||||
- 当前直接依赖锁定:PySide6 6.5.3、openpyxl 3.1.3、requests 2.31.0、websocket-client 1.6.1、Pillow 9.5.0。
|
- 当前直接依赖锁定:PySide6 6.5.3、NumPy 1.26.4、openpyxl 3.1.3、requests 2.31.0、websocket-client 1.6.1、Pillow 9.5.0。
|
||||||
|
- NumPy 1.26.4 是 PySide6/Shiboken 6.5.3 的二进制兼容约束。项目目前不直接调用 NumPy API,但不得让其他依赖把运行或打包环境升级到 NumPy 2.x,否则可能在 GUI 启动导入阶段触发 ABI 不兼容错误。
|
||||||
- 新增第三方依赖前,先在本文说明用途、替代方案和维护成本,并同步更新 `requirements.txt`。
|
- 新增第三方依赖前,先在本文说明用途、替代方案和维护成本,并同步更新 `requirements.txt`。
|
||||||
- GUI 框架固定为 PySide6,不允许混入 Tkinter/PyQt/Web 形成两套 UI。
|
- GUI 框架固定为 PySide6,不允许混入 Tkinter/PyQt/Web 形成两套 UI。
|
||||||
- 不引入第二套浏览器自动化方案(不要 `app/cdp.py` 之外再混入 selenium/playwright)。
|
- 不引入第二套浏览器自动化方案(不要 `app/cdp.py` 之外再混入 selenium/playwright)。
|
||||||
|
|||||||
+178
-75
@@ -5,14 +5,14 @@
|
|||||||
|
|
||||||
## 一、系统结构
|
## 一、系统结构
|
||||||
|
|
||||||
Windows 本地桌面自动化工具,无后端服务,5 Tab GUI 驱动一条流水线。
|
Windows 本地桌面自动化工具,无后端服务。当前正式 GUI 显示 6 个工作流 Tab;商品套图使用新的原生 PySide6 界面,并复用既有 `image_studio_*` 数据与生图服务。
|
||||||
|
|
||||||
```text
|
```text
|
||||||
运营(人)
|
运营(人)
|
||||||
|
|
|
|
||||||
v
|
v
|
||||||
GUI(PySide6 QTabWidget,5 Tab)
|
GUI(PySide6 QTabWidget,当前显示 6 Tab)
|
||||||
① 导入采集 ② AI生成 ③ 更新蝦皮 ④ 账号管理 ⑤ 设置
|
① 导入采集 ② AI生成 ③ 更新蝦皮 账号管理 设置 商品套图
|
||||||
|
|
|
|
||||||
v
|
v
|
||||||
核心模块(Python)
|
核心模块(Python)
|
||||||
@@ -23,8 +23,16 @@ GUI(PySide6 QTabWidget,5 Tab)
|
|||||||
├── accounts 账号 CRUD 服务:生成 slug/目录、启动登录、检测登录
|
├── accounts 账号 CRUD 服务:生成 slug/目录、启动登录、检测登录
|
||||||
├── chrome 按账号拼启动参数、启动/探测 Chrome、生成快捷方式
|
├── chrome 按账号拼启动参数、启动/探测 Chrome、生成快捷方式
|
||||||
├── cdp CDP 客户端(连接、找/开 tab、执行 JS、拖拽)
|
├── cdp CDP 客户端(连接、找/开 tab、执行 JS、拖拽)
|
||||||
├── editor 登录检测 / 采集旧标题旧封面 / 改标题 / 换封面 / 点更新
|
├── editor 登录检测 / 检测商品状态 / 采集旧标题旧封面 / 改标题 / 换封面 / 点更新
|
||||||
└── ai 文本生成(提示词+旧标题→新标题)/ 图像生成(提示词+旧封面→新封面)
|
├── product_status 商品状态代码、显示和分组规则
|
||||||
|
├── client_policy 启动订阅策略解析、非敏感缓存与兼容回退
|
||||||
|
├── subscription cmhub 账号订阅状态查询、响应归一化与客户端预检状态
|
||||||
|
├── ai 文本生成(提示词+旧标题→新标题)/ 图像生成(提示词+旧封面→新封面)/ 商品原图理解(商品套图AI帮写)
|
||||||
|
├── image_studio 商品套图项目/资产/job数据服务(兼容旧AI工场终选)
|
||||||
|
├── product_suite 套图设置归一化、数量计算、完整提示词与job规划
|
||||||
|
├── image_studio_images 远程原图安全下载、本地原图导入、生成图废纸篓
|
||||||
|
├── image_studio_generation cmhub 托管多图异步 submit/poll/download 编排
|
||||||
|
└── image_studio_export 终选图片本地 JPEG 转码、目录安全导出
|
||||||
|
|
|
|
||||||
v
|
v
|
||||||
Google Chrome(每账号独立 --user-data-dir + --remote-debugging-port) + AI 服务(默认 cmhub 网关;direct 仅内部兼容/回滚)
|
Google Chrome(每账号独立 --user-data-dir + --remote-debugging-port) + AI 服务(默认 cmhub 网关;direct 仅内部兼容/回滚)
|
||||||
@@ -35,8 +43,8 @@ Shopee 卖家中心页面 / 本地图片目录
|
|||||||
|
|
||||||
真实组件:
|
真实组件:
|
||||||
|
|
||||||
- GUI 入口:根目录 `main.py` 调用 `app/gui/` 包(PySide6 + `QMainWindow` + `QTabWidget`,5 Tab);包入口 `app/gui/__init__.py` 提供 `main()` 并兼容 `from app import gui` / `from app.gui import MainWindow`;也支持 `python -m app`。
|
- GUI 入口:根目录 `main.py` 调用 `app/gui/` 包(PySide6 + `QMainWindow` + `QTabWidget`,当前正式界面显示 6 Tab);包入口 `app/gui/__init__.py` 提供 `main()` 并兼容 `from app import gui` / `from app.gui import MainWindow`;也支持 `python -m app`。第六 Tab 只创建 `ProductSuiteTab`;旧 `ImageStudioTab` 保留内部兼容,但不加入顶层 `QTabWidget`。
|
||||||
- 核心模块统一放在正式代码包 `app/`:`appconfig.py`、`db.py`、`excel.py`、`config.py`、`accounts.py`、`chrome.py`、`editor.py`、`workers.py`、`ai.py`、`prompts.py`;CDP 底座迁入 `app/cdp.py`(当前根目录 `cdp.py` 为已验证来源)。
|
- 核心模块统一放在正式代码包 `app/`:`appconfig.py`、`db.py`、`excel.py`、`config.py`、`accounts.py`、`chrome.py`、`editor.py`、`workers.py`、`ai.py`、`prompts.py`、`image_studio.py`、`product_suite.py`、`image_studio_images.py`、`image_studio_generation.py`、`image_studio_export.py`;CDP 底座迁入 `app/cdp.py`(当前根目录 `cdp.py` 为已验证来源)。
|
||||||
- 已验证脚本(重构进模块):`prototypes/demo.py`、`prototypes/set_title.py`、`prototypes/set_cover.py`、`prototypes/get_title.py`、`prototypes/cookies.py`、`prototypes/inspect_images.py`、`prototypes/grab.py`。
|
- 已验证脚本(重构进模块):`prototypes/demo.py`、`prototypes/set_title.py`、`prototypes/set_cover.py`、`prototypes/get_title.py`、`prototypes/cookies.py`、`prototypes/inspect_images.py`、`prototypes/grab.py`。
|
||||||
- 外部依赖:本机 Google Chrome;Shopee;AI 服务(文本+图像;普通产品默认 cmhub 网关,由 `data/config.json` 的 `ai.cmhub` + `data/config/cmhub.json` 配置;direct 直连模型清单仅作为内部兼容/手工回滚路径保留);`openpyxl`。
|
- 外部依赖:本机 Google Chrome;Shopee;AI 服务(文本+图像;普通产品默认 cmhub 网关,由 `data/config.json` 的 `ai.cmhub` + `data/config/cmhub.json` 配置;direct 直连模型清单仅作为内部兼容/手工回滚路径保留);`openpyxl`。
|
||||||
|
|
||||||
@@ -52,14 +60,14 @@ imported → collected → generated → applied
|
|||||||
|
|
||||||
- **imported**:openpyxl 解析输入列入库。
|
- **imported**:openpyxl 解析输入列入库。
|
||||||
- **collected**:只读打开商品页,读旧标题、下载旧封面到本地,写 `old_title/old_cover_path`,回写 Excel 旧字段。
|
- **collected**:只读打开商品页,读旧标题、下载旧封面到本地,写 `old_title/old_cover_path`,回写 Excel 旧字段。
|
||||||
- **generated**:AI 用提示词+旧标题生成新标题;封面按②本轮「生成封面图片(成本较高)」开关可选生成,开启时用提示词+旧封面生成新封面(image-to-image)并存本地,关闭时 `new_cover_path=NULL`。**不设逐条人工审核阶段**,标题生成成功即可进入 ③;有新封面时 ③ 可在安全开关允许后换封面,没有新封面时 ③ 只改标题。
|
- **generated**:AI 按②「生成内容」下拉生成新标题和/或新封面;选择只生成标题时 `new_cover_path=NULL`;选择只生成封面时不调用生文,封面标题上下文取 `new_title or old_title`,只写 `new_cover_path`,因此“有新封面、无新标题”是合法组件状态;选择生成标题和封面时可按缺失组件增量补齐。**不设逐条人工审核阶段**,生成成功即可进入 ③;③ 再按「更新内容」下拉决定只更新标题、只更新封面或更新图文。
|
||||||
- **applied**:③ 点击「开始更新」后弹窗确认当前筛选范围和任务数量;确认后打开编辑页换标题+封面,逐条点「更新」提交线上,回写结果。
|
- **applied**:③ 点击「开始更新」后弹窗确认当前筛选范围和任务数量;确认后打开编辑页换标题+封面,逐条点「更新」提交线上,回写结果。
|
||||||
|
|
||||||
任意阶段失败 → `stage` 不前进、记 `error`、`result/skipped`,不影响其他任务。
|
任意阶段失败 → `stage` 不前进、记 `error`、`result/skipped`,不影响其他任务。
|
||||||
|
|
||||||
## 三、职责划分
|
## 三、职责划分
|
||||||
|
|
||||||
**GUI(5 Tab)**:见 [routes.md](routes.md)。只做交互与预览,不写业务逻辑;耗时操作走 PySide6 `QObject` worker + `QThread`,用 signal 回主线程刷新 UI。**首次未配账号、对应账号 Chrome 未启动或未登录时,① ③ 执行按钮禁用或执行前拦截,并提示去 ④;③ 不静默自动打开账号 Chrome。**
|
**GUI(当前显示 6 Tab)**:见 [routes.md](routes.md)。只做交互与预览,不写业务逻辑;耗时操作走 PySide6 `QObject` worker + `QThread`,用 signal 回主线程刷新 UI。**① 采集点击后会为本轮匹配到的账号自动确保 Chrome 就绪:已打开则复用,未打开才启动;随后只检测登录态,未登录账号的任务跳过并汇总提示前往账号管理人工登录。③ 更新蝦皮仍是线上提交高风险链路:执行前只检测账号 Chrome/CDP/登录态,不自动启动缺失账号 Chrome。商品套图的只读拉图、原图下载/导入、AI帮写和 cmhub 生成均在独立 worker 中运行;多任务可并行,且不会自动上传蝦皮。**
|
||||||
|
|
||||||
**核心模块**
|
**核心模块**
|
||||||
|
|
||||||
@@ -70,26 +78,33 @@ imported → collected → generated → applied
|
|||||||
- `accounts`:账号 CRUD 服务;生成目录、启动登录、检测登录;不自动登录/填密码。
|
- `accounts`:账号 CRUD 服务;生成目录、启动登录、检测登录;不自动登录/填密码。
|
||||||
- `chrome`:拼接启动命令、启动、探测端口、(可选)生成快捷方式。
|
- `chrome`:拼接启动命令、启动、探测端口、(可选)生成快捷方式。
|
||||||
- `cdp`:连接调试端口、找/开 tab、执行 JS、拖拽、注入文件。
|
- `cdp`:连接调试端口、找/开 tab、执行 JS、拖拽、注入文件。
|
||||||
- `editor`:登录检测、**采集**(读旧标题、下载旧封面)、改标题、换封面、点更新。
|
- `editor`:登录检测、读取商品状态、**采集**(读旧标题、下载旧封面)、改标题、换封面、点更新。
|
||||||
- `ai`:`gen_title(prompt, old_title)`、`gen_cover(prompt, old_cover_path)`(外部 AI;默认走 `cmhub` 网关,保留 `direct` 模型清单作为内部兼容/回滚 backend;返回值保持标题字符串/本地 JPEG 路径)。
|
- `product_status`:商品状态代码 `normal/unlisted/reviewing/unknown` 的归一化、中文显示、EDS 提示分类和下游任务分组;①②③只能复用该模块,不各自判断。
|
||||||
|
- `client_policy`:解析启动版本接口顶层 `client_policy`,严格校验 `policy_version=1`、布尔开关和带时区 `updated_at`;把最后一次有效的非敏感策略及取得时间原子缓存到 `data/config/client_policy.json`。本轮运行按 `off/observe/enforce` 三种模式执行;远端缺失或非法时优先读缓存,无缓存回退观察模式。`check=false,enforcement=true` 防御性归一化为关闭,不会在未检测时锁死客户端。
|
||||||
|
- `subscription`:使用默认网关 API Key 查询 `GET /api/v1/cmshopee/subscription/status`,把远端结果归一化为有效、宽限、未订阅、到期、账号不可用、Key 无效或暂时不可用;兼容识别历史撤销/旧接口状态,但不把它们视为真实权益。强制模式只以服务端顶层严格布尔字段 `real_entitlement_allowed` 放行业务功能,不依据展示用 `status` 或兼容 `allowed` 推断;服务端始终是最终授权方。T-701 后缺少 Key 的干净安装显示只收集“会员 API Key”的激活窗口,输入值先在内存中验证,只有服务端未拒绝该凭据时才保存到 `data/config/cmhub.json`。
|
||||||
|
- `ai`:`gen_title(prompt, old_title)`、`gen_cover(prompt, old_cover_path)`、`analyze_product_images(instruction, context, image_paths)`;前两者分别负责②标题/生图,后者只供商品套图中的「AI帮写」调用 cmhub 图片理解接口,读取1至8张按 `source_order` 排序的本地商品原图并返回可编辑卖点与白名单计费元数据。
|
||||||
|
- `cmhub_models`:格式化 cmhub 模型别名,并维护仅进程内有效的短期模型目录缓存;缓存键使用规整网关地址和别名,不含 API Key,不写入配置、SQLite、日志或导出文件。
|
||||||
|
|
||||||
**存储(同一事实只存一处)**
|
**存储(同一事实只存一处)**
|
||||||
|
|
||||||
- 应用配置(模型选择、生成参数、目录、Chrome 路径)→ `data/config.json`。
|
- 应用配置(模型选择、生成参数、目录、Chrome 路径)→ `data/config.json`。
|
||||||
- AI 模型清单(direct 内部兼容模式 url/模型/密钥/类型/连接超时)→ `data/config/ai_models.json`(API Key 本地明文保存,必须 gitignore,UI 打码显示;普通设置页不再暴露 direct 切换入口)。
|
- AI 模型清单(direct 内部兼容模式 url/模型/密钥/类型/连接超时)→ `data/config/ai_models.json`(API Key 本地明文保存,必须 gitignore,UI 打码显示;普通设置页不再暴露 direct 切换入口)。
|
||||||
- cmhub 网关 Key → `data/config/cmhub.json`,schema `{ "api_key": "..." }`;`config.json` 只保存 Base URL、别名和超时,不保存 Key。
|
- cmhub 网关 Key → `data/config/cmhub.json`,schema `{ "api_key": "..." }`;`config.json` 只保存 Base URL、别名和超时,不保存 Key。
|
||||||
|
- 客户端订阅策略 → `data/config/client_policy.json`,只含策略版本、两个布尔开关、服务端更新时间和本地取得时间,不含 Key、账号、套餐或订阅结果;线上有效响应覆盖缓存,接口失败、旧响应或策略字段非法时读取缓存,无缓存使用观察模式。
|
||||||
|
- 订阅状态 → 仅进程内 `SubscriptionStatus`;有效/宽限状态把服务端返回的账号显示名、套餐名和有效期追加到 Windows 原生窗口标题,其他状态恢复纯应用名称并在底部状态栏显示脱敏中文结果。`off` 不请求状态且放行;`observe` 查询展示但不限制;`enforce` 查询期间及真实权益无效时只保留“设置”Tab,并按未配置、未开通、到期、撤销兼容、账号不可用、Key 无效、接口不可用显示对应中文处理窗口。窗口按状态转换去重,恢复真实权益后才允许同类状态再次提示;网络异常不得描述成未付费。首次激活后的使用清单只在 `config.json` 保存非敏感状态 `onboarding.first_use_guide_state`,取值为空、`pending`、`completed` 或 `dismissed`。
|
||||||
|
- cmhub 模型目录与 AI帮写/正式套图预估价格 → 仅内存短期缓存;预估值只供用户确认,实际扣点仍以网关响应 metadata 为准。
|
||||||
- 业务数据(账号、任务、各阶段结果)→ SQLite `data/cmshopee.db`。
|
- 业务数据(账号、任务、各阶段结果)→ SQLite `data/cmshopee.db`。
|
||||||
- 图片(采集的旧封面、AI 生成的新封面)→ `data/images/`(路径记在 DB)。
|
- 图片(采集的旧封面、AI 生成的新封面)→ `data/images/`(路径记在 DB)。
|
||||||
- 提示词 → 标题提示词存单文件 `data/title_prompt.txt`;封面提示词存多模板 `data/prompts/cover/<名称>.txt`。
|
- 提示词 → 标题当前工作文本存单文件 `data/title_prompt.txt`;标题命名模板存 `data/prompts/title/<名称>.txt`;封面命名模板存 `data/prompts/cover/<名称>.txt`;旧 AI工场模板目录 `data/prompts/image_studio/<名称>.txt` 仅保留兼容,商品套图直接把卖点文本与结构化设置保存在项目表。
|
||||||
- 登录态 → 各账号 `data/chrome_user_data_dir/<slug>/`。
|
- 登录态 → 各账号 `data/chrome_user_data_dir/<slug>/`。
|
||||||
|
|
||||||
T-538 后统一数据根为 `data/`:打包版默认 `<exe目录>/data`,源码运行默认项目根 `data/`。`config.json` 中 `user_data_root`、`image_dir`、`db_path` 默认仍保存为 `chrome_user_data_dir`、`images`、`cmshopee.db` 等相对值,运行时由 `appconfig` 解析到 `data/` 下;绝对路径作为高级自定义仍按原值使用。启动时会迁移 T-524 旧包的 exe 顶层数据到 `data/`,并检测 `data/` 可写。
|
T-538 后统一数据根为 `data/`:打包版默认 `<exe目录>/data`,源码运行默认项目根 `data/`。`config.json` 中 `user_data_root`、`image_dir`、`db_path` 默认仍保存为 `chrome_user_data_dir`、`images`、`cmshopee.db` 等相对值,运行时由 `appconfig` 解析到 `data/` 下;绝对路径作为高级自定义仍按原值使用。启动时会迁移 T-524 旧包的 exe 顶层数据到 `data/`,并检测 `data/` 可写。
|
||||||
|
|
||||||
## 四、多账号隔离方案(决策)
|
## 四、多账号隔离方案(决策)
|
||||||
|
|
||||||
采用**每账号独立 user-data-dir**(非 Chrome profile)。`--remote-debugging-port` 绑定在 user-data-dir/进程上,profile 方案无法每账号独立 CDP、串号风险高。启动主路径用程序 `subprocess` 直启(`--remote-debugging-port` + `--remote-allow-origins=*` + `--user-data-dir`);可选生成 `.lnk` 快捷方式(PowerShell `WScript.Shell`,参数写在「目标」字段)。
|
采用**每账号独立 user-data-dir**(非 Chrome profile)。`--remote-debugging-port` 绑定在 user-data-dir/进程上,profile 方案无法每账号独立 CDP、串号风险高。启动主路径用程序 `subprocess` 直启(`--remote-debugging-port` + `--remote-allow-origins=*` + `--user-data-dir`);冷启动登录入口还追加 `--no-first-run`、`--no-default-browser-check`,并把经过校验的 `https://<region_host>/portal/` 作为唯一启动 URL 放在参数末尾,避免全新 profile 的 `chrome://intro/` 另开首次运行窗口。可选生成 `.lnk` 快捷方式(PowerShell `WScript.Shell`,参数写在「目标」字段),快捷方式不携带上述冷启动专用参数和 URL,保持原行为。
|
||||||
|
|
||||||
同一账号重复点击④「启动登录」不得重复执行 `subprocess.Popen`。正确流程是先探测该账号 `debug_port` 的 `/json/version`:端口未响应才按上述启动参数新开 Chrome;端口已响应则复用该账号现有 Chrome/CDP,打开或激活卖家中心登录 tab 供人工登录,并记录为复用,不创建第二个账号窗口。
|
同一账号重复点击账号管理中的「启动登录」不得重复执行 `subprocess.Popen`。正确流程是先探测该账号 `debug_port` 的 `/json/version`:端口未响应才按上述启动参数新开 Chrome;CDP 就绪后在最多 2 秒的有界预算内等待初始卖家中心 page target,出现后直接激活。若只出现唯一的 `about:blank`、Chrome 新标签页或全新 profile 的 `chrome://intro/`,则导航并复用该 target;只有完全没有安全可复用 page target 时才兜底创建一次,并返回 `fallback_reason`。端口已响应时复用该账号现有 Chrome/CDP,优先激活卖家中心/登录 tab;没有时才新增一个 tab,绝不改写或关闭用户已有普通页面。账号管理界面在启动期间禁用账号选择和操作,同账号连续触发写 `result=ignored reason=launch_in_progress`,不进入第二次底层启动。
|
||||||
|
|
||||||
## 五、数据模型
|
## 五、数据模型
|
||||||
|
|
||||||
@@ -108,11 +123,13 @@ T-538 后统一数据根为 `data/`:打包版默认 `<exe目录>/data`,源
|
|||||||
"default_text_model": "GPT-5.5 文本",
|
"default_text_model": "GPT-5.5 文本",
|
||||||
"default_image_model": "Nano Banana 2",
|
"default_image_model": "Nano Banana 2",
|
||||||
"generate_cover": false,
|
"generate_cover": false,
|
||||||
|
"generate_mode": "title",
|
||||||
"backend": "cmhub",
|
"backend": "cmhub",
|
||||||
"cmhub": {
|
"cmhub": {
|
||||||
"base_url": "",
|
"base_url": "https://cm.833729.com",
|
||||||
"title_alias": "",
|
"title_alias": "",
|
||||||
"image_alias": "",
|
"image_alias": "",
|
||||||
|
"vision_alias": "vision-standard",
|
||||||
"connect_timeout": 66,
|
"connect_timeout": 66,
|
||||||
"check_balance_before_batch": false
|
"check_balance_before_batch": false
|
||||||
},
|
},
|
||||||
@@ -125,40 +142,54 @@ T-538 后统一数据根为 `data/`:打包版默认 `<exe目录>/data`,源
|
|||||||
},
|
},
|
||||||
"shopee_update": {
|
"shopee_update": {
|
||||||
"test_item_id": "51100639510",
|
"test_item_id": "51100639510",
|
||||||
"allow_real_submit": false,
|
"update_mode": "title",
|
||||||
"allow_cover_update": false,
|
|
||||||
"max_items_per_run": 1,
|
"max_items_per_run": 1,
|
||||||
"close_success_tab": false,
|
|
||||||
"dry_run": false,
|
"dry_run": false,
|
||||||
"parallel_accounts": false,
|
"max_parallel_accounts": 1
|
||||||
"max_parallel_accounts": 2
|
},
|
||||||
|
"product_suite": {
|
||||||
|
"last_account_alias": "",
|
||||||
|
"last_settings": {
|
||||||
|
"platform": "Shopee",
|
||||||
|
"country": "中国台湾",
|
||||||
|
"language": "繁体中文",
|
||||||
|
"ratio": "1:1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"subscription": {
|
||||||
|
"last_notice_id": ""
|
||||||
|
},
|
||||||
|
"onboarding": {
|
||||||
|
"first_use_guide_state": ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
`ai` 段只放**选择 + 全局生成参数**:
|
`ai` 段只放**选择 + 全局生成参数**:
|
||||||
|
|
||||||
- `backend`:内部字段,取值仍支持 `cmhub` / `direct`;普通产品默认 `cmhub`,⑤设置页不再展示「AI 后端」label 或 direct/cmhub 下拉,保存设置固定写 `cmhub`。`direct` 仅保留为内部兼容/手工回滚路径。
|
- `backend`:内部字段,取值仍支持 `cmhub` / `direct`;普通产品默认 `cmhub`,设置页不再展示「AI 后端」label 或 direct/cmhub 下拉,保存设置固定写 `cmhub`。`direct` 仅保留为内部兼容/手工回滚路径。
|
||||||
- `cmhub`:cmhub 网关配置,`base_url` 为网关根地址,保存和请求前会规整为 scheme+host(+port),去掉 `/api`、`/api/v1`、其它路径、查询串和片段;`title_alias` / `image_alias` 为 `GET /api/v1/models` 发现的能力别名,`connect_timeout` 为连接超时;API Key 不在此处保存。
|
- `cmhub`:cmhub 网关配置,`base_url` 为网关根地址,干净安装和空值默认使用 `https://cm.833729.com`,已有非空地址保持不变;保存和请求前会规整为 scheme+host(+port),去掉 `/api`、`/api/v1`、其它路径、查询串和片段。`title_alias` / `image_alias` / `vision_alias` 分别对应②标题、②与商品套图正式生图、商品套图中的「AI帮写」图片理解,`vision_alias` 缺失时旧配置迁移为 `vision-standard`,`connect_timeout` 为连接超时;API Key 不在此处保存。设置页展示三类别名和扣点提示,其中图片理解下拉只接受 `operation_type=vision`、`requires_image=true` 且已定价的模型;已保存但暂不可用的值保留并明确提示。商品套图正式生成仍只使用已保存的生图 alias,不展示 OpenAI Key、Provider URL、上游接口路径或直连模型 slug。
|
||||||
- `default_text_model` / `default_image_model`:仅 direct 内部兼容模式下引用 `ai_models.json` 里的模型名(标题用文本模型、封面用图像模型);普通 cmhub 模式不读取这些模型定义,⑤设置页不再展示标题/图片模型角色下拉。
|
- `default_text_model` / `default_image_model`:仅 direct 内部兼容模式下引用 `ai_models.json` 里的模型名(标题用文本模型、封面用图像模型);普通 cmhub 模式不读取这些模型定义,设置页不再展示标题/图片模型角色下拉。
|
||||||
- `generate_cover`:②「开始生成」时是否调用图片模型生成新封面;默认 `false`,避免用户无意产生图片生成成本。该字段只控制 AI 生成阶段,不等同于 ③ 的 `allow_cover_update`。
|
- `generate_mode`:②「生成内容」下拉的主字段,取值 `title` / `cover` / `title_cover`,分别表示只生成标题、只生成封面、生成标题和封面;默认 `title`,避免用户无意产生封面生成成本。
|
||||||
|
- `generate_cover`:旧兼容字段;保存配置时仍写回,值由 `generate_mode` 推导。旧配置 `false` 会迁移为 `title`,`true` 会迁移为 `title_cover`。GUI 和生成逻辑以 `generate_mode` 为准。
|
||||||
- `resolution`:当前分辨率,下拉 `512 / 1k / 2k / 4k`。
|
- `resolution`:当前分辨率,下拉 `512 / 1k / 2k / 4k`。
|
||||||
- `resolution_timeouts`:direct 内部兼容路径使用的分辨率 → **等待大模型返回超时(秒)** 映射。普通默认 `cmhub` 模式下,分辨率只控制生图尺寸;⑤设置页「返回超时」只读展示实际等待口径:标题 600 秒 / 图片 900 秒,不再随分辨率切换显示 180/240/360/600。
|
- `resolution_timeouts`:direct 内部兼容路径使用的分辨率 → **等待大模型返回超时(秒)** 映射。普通默认 `cmhub` 模式下,分辨率只控制生图尺寸;设置页「返回超时」只读展示实际等待口径:标题 600 秒 / 图片 900 秒,不再随分辨率切换显示 180/240/360/600。
|
||||||
- direct 内部兼容模式模型本身的定义(url/key/类型/连接超时…)在 `data/config/ai_models.json`,见 5.1b。
|
- direct 内部兼容模式模型本身的定义(url/key/类型/连接超时…)在 `data/config/ai_models.json`,见 5.1b。
|
||||||
- 密钥不在 `config.json`:cmhub API Key 存于 `data/config/cmhub.json`;direct 内部兼容模式每个模型的 `api_key` 存于 `data/config/ai_models.json`。两者均本地明文保存、保存/变更时弹窗提示、UI 打码、gitignore、不入日志/导出。
|
- 密钥不在 `config.json`:cmhub API Key 存于 `data/config/cmhub.json`;direct 内部兼容模式每个模型的 `api_key` 存于 `data/config/ai_models.json`。两者均本地明文保存、保存/变更时弹窗提示、UI 打码、gitignore、不入日志/导出。
|
||||||
|
|
||||||
`shopee_update` 段放**真实更新前的安全开关**:
|
`shopee_update` 段放**③ 更新蝦皮执行参数**:
|
||||||
|
|
||||||
- `test_item_id`:历史/调试兼容字段;默认 `51100639510`。普通正式更新不再以该字段限制商品 ID,也不因当前筛选结果包含非测试商品而阻断;后续如需要调试模式,可单独启用测试商品限制。
|
- `test_item_id`:历史/调试兼容字段;默认 `51100639510`。普通正式更新不再以该字段限制商品 ID,也不因当前筛选结果包含非测试商品而阻断;后续如需要调试模式,可单独启用测试商品限制。
|
||||||
- `allow_real_submit`:是否允许 ③ 创建 `ApplyWorker` 并点击「更新」提交线上;默认 `false`,未开启时 ③ 在确认弹窗前阻断。
|
- `update_mode`:③「更新内容」下拉的主字段,取值 `title` / `cover` / `title_cover`,分别表示只更新标题、只更新封面、更新标题和封面;默认 `title`。
|
||||||
- `allow_cover_update`:是否允许本次更新包含新封面路径;默认 `false`,未开启时只允许标题更新任务。
|
|
||||||
- `max_items_per_run`:每批最大更新任务数;默认 `1`,当前筛选结果超过该值时自动分批,不再按总数阻断。
|
- `max_items_per_run`:每批最大更新任务数;默认 `1`,当前筛选结果超过该值时自动分批,不再按总数阻断。
|
||||||
- `close_success_tab`:成功提交后是否关闭本轮程序自动新开的商品编辑页;默认 `false`。只关闭 `open_product()` 本轮新建且已提交成功的 tab,失败任务和用户原本打开的 tab 保留。Shopee 确认成功并跳回商品列表页后,关闭前等待 2 秒,让列表页跳转和页面状态稳定。
|
|
||||||
- `dry_run`:内部兼容字段;普通用户界面不展示该开关,③「检查本轮更新」按钮触发检查模式,只写运行日志,不打开 Shopee、不点击「更新」、不改任务状态;默认 `false`。
|
- `dry_run`:内部兼容字段;普通用户界面不展示该开关,③「检查本轮更新」按钮触发检查模式,只写运行日志,不打开 Shopee、不点击「更新」、不改任务状态;默认 `false`。
|
||||||
- `parallel_accounts`:是否按账号并行执行③真实更新;默认 `false`,即保持串行。
|
- `max_parallel_accounts`:最多同时执行的账号数,范围 `1..5`;`1` 表示逐个账号串行,`>=2` 表示按账号分组并行,同一账号内仍按任务串行;默认 `1`。旧配置中的并行布尔开关为关闭时会迁移为 `max_parallel_accounts=1`,开启时会保留旧数量并夹紧到 `1..5`,保存后不再写回旧并行布尔字段。
|
||||||
- `max_parallel_accounts`:最多同时执行的账号数;同一账号内仍按任务串行,默认 `2`。
|
|
||||||
|
|
||||||
该段不是替代 ③ 确认弹窗的常驻授权;③ 仍必须弹窗确认,用户点是后才执行。`dry_run=true` 时不会真实提交;`dry_run=false` 时仍必须先通过真实更新安全开关检查。普通正式更新的安全检查不读取 `test_item_id` 做阻断。
|
该段不是替代 ③ 确认弹窗的常驻授权;③「开始更新」仍必须弹窗确认,用户点是后才执行。`dry_run=true` 时不会真实提交;`dry_run=false` 时以③确认弹窗作为线上提交前的唯一显式确认边界。普通正式更新不读取 `test_item_id` 做阻断。旧配置中的真实提交、封面更新、成功关页等开关只作迁移兼容读取,保存后不再写回。
|
||||||
|
|
||||||
|
`product_suite.last_account_alias` 只保存商品套图最近一次有效选择的账号别名。软件重启后的第一个新任务优先恢复该账号;账号已删除或不可用时回退到第一个可用账号。已有账号+商品 ID 项目和恢复的临时草稿始终以 SQLite 中自己的 `account_alias` 为准,不被最近账号覆盖。
|
||||||
|
|
||||||
|
`product_suite.last_settings` 只保存商品套图最近一次选择的平台、站点、语言和比例,作为软件重启后第一个未绑定商品任务的默认值。已有账号+商品 ID 项目仍以 SQLite `image_studio_projects.suite_settings_json` 为准;同一次运行中新任务优先继承当前任务;绑定已有项目后再由项目设置覆盖最近默认。旧配置缺少该段或值不在当前选项集合时,分别回退到 `Shopee / 中国台湾 / 繁体中文 / 1:1`。`product_suite` 配置不保存商品 ID、卖点提示词、图片路径或密钥。
|
||||||
|
|
||||||
### 5.1b AI 模型清单 `data/config/ai_models.json`
|
### 5.1b AI 模型清单 `data/config/ai_models.json`
|
||||||
|
|
||||||
@@ -199,8 +230,9 @@ T-538 后统一数据根为 `data/`:打包版默认 `<exe目录>/data`,源
|
|||||||
```
|
```
|
||||||
|
|
||||||
- 文件必须 gitignore,不提交;UI 展示打码。
|
- 文件必须 gitignore,不提交;UI 展示打码。
|
||||||
- `appconfig.load_cmhub_config()` 缺文件时返回空 Key;普通产品默认 backend 仍为 cmhub,但未配置 Key/Base URL/别名时生成阶段会给出清晰配置错误,不静默回退 direct。
|
- `appconfig.load_cmhub_config()` 缺文件时返回空 Key;普通产品默认 backend 和公开 Base URL 已内置,但 API Key/别名未配置时仍给出清晰错误,不静默回退 direct。
|
||||||
- `backend=cmhub` 但 Base URL、API Key 或别名缺失时,`app/ai.py` 抛 `CMHubError(code="cmhub_not_configured")`,提示去⑤设置配置,不静默回退 direct;cmhub HTTP 404 映射为 `CMHubError(code="not_found")`,提示检查 Base URL 或实例是否已部署 `/api/v1/models`。
|
- `backend=cmhub` 但 API Key 或本轮所需别名缺失时,`app/ai.py` 抛 `CMHubError(code="cmhub_not_configured")`,提示完成默认网关配置,不静默回退 direct;cmhub HTTP 404 映射为 `CMHubError(code="not_found")`,提示检查 Base URL 或实例是否已部署 `/api/v1/models`。
|
||||||
|
- cmhub `/models` 如返回 `display_name/tags/recommended_for/tier/prices`,GUI 优先用这些字段生成中文档位说明;缺少这些字段时按 alias/tag 的保守规则兜底到“默认档”。客户端不得把 OpenAI 原始模型名作为默认执行事实。
|
||||||
|
|
||||||
### 5.2 SQLite `cmshopee.db`
|
### 5.2 SQLite `cmshopee.db`
|
||||||
|
|
||||||
@@ -217,7 +249,7 @@ CREATE TABLE batches (
|
|||||||
deleted_reason TEXT -- 软删除原因/来源
|
deleted_reason TEXT -- 软删除原因/来源
|
||||||
);
|
);
|
||||||
|
|
||||||
-- 账号(④ 账号管理)
|
-- 账号(账号管理)
|
||||||
CREATE TABLE accounts (
|
CREATE TABLE accounts (
|
||||||
id INTEGER PRIMARY KEY,
|
id INTEGER PRIMARY KEY,
|
||||||
account_name TEXT NOT NULL, -- Shopee 登录账号名(展示/参考)
|
account_name TEXT NOT NULL, -- Shopee 登录账号名(展示/参考)
|
||||||
@@ -247,12 +279,18 @@ CREATE TABLE tasks (
|
|||||||
account_name TEXT,
|
account_name TEXT,
|
||||||
alias TEXT NOT NULL,
|
alias TEXT NOT NULL,
|
||||||
item_id TEXT NOT NULL,
|
item_id TEXT NOT NULL,
|
||||||
|
-- 商品状态快照(采集页面探测,不宣称实时在售)
|
||||||
|
product_status TEXT, -- normal/unlisted/reviewing/unknown;符合历史兼容条件的 NULL 启动时迁移为 normal
|
||||||
|
product_status_note TEXT, -- 归一化 EDS 标题/说明摘要,最多 2000 字符
|
||||||
|
product_status_at TEXT, -- 本次页面状态探测时间
|
||||||
-- 采集输出(程序写,改前快照)
|
-- 采集输出(程序写,改前快照)
|
||||||
old_title TEXT,
|
old_title TEXT,
|
||||||
old_cover_path TEXT, -- 旧封面本地图片路径
|
old_cover_path TEXT, -- 旧封面本地图片路径
|
||||||
-- AI 输出
|
-- AI 输出
|
||||||
new_title TEXT,
|
new_title TEXT,
|
||||||
new_cover_path TEXT, -- 新封面本地图片路径
|
new_cover_path TEXT, -- 新封面本地图片路径
|
||||||
|
image_task_id TEXT, -- cmhub 异步生图任务ID;用于重启/重试续查
|
||||||
|
image_task_key TEXT, -- 本次生图提交幂等键;用于 submit 网络抖动重发
|
||||||
-- 应用
|
-- 应用
|
||||||
committed INTEGER NOT NULL DEFAULT 0, -- 是否成功点「更新」提交
|
committed INTEGER NOT NULL DEFAULT 0, -- 是否成功点「更新」提交
|
||||||
stage TEXT NOT NULL DEFAULT 'imported', -- imported/collected/generated/applied
|
stage TEXT NOT NULL DEFAULT 'imported', -- imported/collected/generated/applied
|
||||||
@@ -266,12 +304,15 @@ CREATE TABLE tasks (
|
|||||||
generated_at TEXT,
|
generated_at TEXT,
|
||||||
applied_at TEXT,
|
applied_at TEXT,
|
||||||
updated_at TEXT NOT NULL,
|
updated_at TEXT NOT NULL,
|
||||||
|
deleted_at TEXT, -- T-670 单条任务软删除时间;默认业务查询排除
|
||||||
|
deleted_reason TEXT, -- 软删除原因/来源
|
||||||
UNIQUE(batch_id, source_file_abs, source_sheet, source_row)
|
UNIQUE(batch_id, source_file_abs, source_sheet, source_row)
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE INDEX idx_tasks_batch_stage_status ON tasks(batch_id, stage, status);
|
CREATE INDEX idx_tasks_batch_stage_status ON tasks(batch_id, stage, status);
|
||||||
CREATE INDEX idx_tasks_alias ON tasks(alias);
|
CREATE INDEX idx_tasks_alias ON tasks(alias);
|
||||||
CREATE INDEX idx_tasks_item ON tasks(item_id);
|
CREATE INDEX idx_tasks_item ON tasks(item_id);
|
||||||
|
CREATE INDEX idx_tasks_deleted_at ON tasks(deleted_at);
|
||||||
```
|
```
|
||||||
|
|
||||||
运行日志(T-504):
|
运行日志(T-504):
|
||||||
@@ -316,10 +357,11 @@ CREATE TABLE run_log_events (
|
|||||||
|
|
||||||
优先级:
|
优先级:
|
||||||
|
|
||||||
1. **T-207 已接入 ① 采集**:记录批次开始/结束、账号预检、每个商品开始/成功/失败/略过;关键步骤覆盖 `preflight`、`open_product`、`wait_ready`、`read_title`、`read_cover`、`download_cover`、`db_write`、`excel_write_back`。失败时 DB 事件保存最后步骤和简短错误,本地 `data/logs/cmshopee.log` 保存完整脱敏 traceback。
|
1. **T-207/T-662b 已接入 ① 采集**:记录批次开始/结束、账号预检、每个商品开始/成功/失败/略过;关键步骤覆盖 `preflight`、`open_product`、`wait_ready`、`read_product_status`、`read_title`、`read_cover`、`download_cover`、`db_write`、`excel_write_back`。运行日志选项记录本轮范围,汇总记录四类商品状态与按范围略过数。失败时 DB 事件保存最后步骤和简短错误,本地 `data/logs/cmshopee.log` 保存完整脱敏 traceback。
|
||||||
2. **② AI生成已先补诊断**:`GenerateWorker` 创建 `run_type=generate`,按任务记录标题/封面阶段事件;关键步骤覆盖 `title_submit`、`load_text_model`、`title_build_request`、`title_request`、`title_parse_response`、`cover_prompt_render`、`cover_submit`、`cover_validate_input`、`load_image_model`、`cover_build_request`、`cover_request`、`cover_parse_response`、`cover_save`、`db_write`。失败时任务 `last_error`、DB 运行日志和本地 `data/logs/cmshopee.log` 都写脱敏错误。
|
2. **② AI生成已先补诊断**:`GenerateWorker` 创建 `run_type=generate`,按任务记录标题/封面阶段事件;关键步骤覆盖 `title_submit`、`load_text_model`、`title_build_request`、`title_request`、`title_parse_response`、`cover_prompt_render`、`cover_submit`、`cover_validate_input`、`load_image_model`、`cover_build_request`、`cover_request`、`cover_parse_response`、`cover_save`、`db_write`。失败时任务 `last_error`、DB 运行日志和本地 `data/logs/cmshopee.log` 都写脱敏错误。
|
||||||
3. **T-505 已扩展全流程**:Excel 导入创建 `run_type=import`,记录选中文件、缺列、脏行、入库统计;Excel 回写创建 `run_type=write_back`,记录文件写入、文件锁/保存异常和行数;③ 更新蝦皮 的 `run_type=apply` 覆盖安全/账号预检、Chrome/CDP 检查、登录检测、打开商品页、改标题、换封面、点更新、写库;④「启动登录」创建 `run_type=chrome_launch`,④「检测登录」创建 `run_type=login_check`;⑤ AI 模型测试连接创建 `run_type=ai_model_test`。失败时本地 `data/logs/cmshopee.log` 写脱敏 traceback,业务日志和状态栏只写脱敏短错误。
|
3. **T-505 已扩展全流程**:Excel 导入创建 `run_type=import`,记录选中文件、缺列、脏行、入库统计;Excel 回写创建 `run_type=write_back`,记录文件写入、文件锁/保存异常和行数;③ 更新蝦皮 的 `run_type=apply` 覆盖安全/账号预检、Chrome/CDP 检查、登录检测、打开商品页、改标题、换封面、点更新、写库;账号管理中的「启动登录」创建 `run_type=chrome_launch`,账号管理中的「检测登录」创建 `run_type=login_check`;设置中的 AI 模型测试连接创建 `run_type=ai_model_test`。失败时本地 `data/logs/cmshopee.log` 写脱敏 traceback,业务日志和状态栏只写脱敏短错误。
|
||||||
4. **T-404b 已接入商品页失败 toast 捕获**:①采集和③更新在 `open_product`/等待详情页就绪失败时,不应只报等待超时。进入/刷新商品编辑页后应捕获 `.eds-toasts .eds-toast__content` 的文本和 `outerHTML`,记录当前 URL、时间、可见状态,并在关键元素超时时把最近错误 toast 作为用户可读失败原因写入 `last_error`、`run_log_events` 与本地诊断日志;例如商品 ID 失效时提示 `please input correct product id`。若 toast 明确属于商品失效/商品不存在/无权限类错误,底层仍写 `stage=imported/status=failed/last_error=商品失效:<原始toast>`,只在①导入采集列表“阶段”显示“商品失效”;其他打开失败仍显示“失败”。若失败发生在 `open_product()` 内部且 `cdp` 尚未返回上层,`open_product()` 必须自行清理本轮自动新建 tab;复用用户已有 tab 只断开 CDP,不关闭页面。失败现场可保存 HTML/toast JSON 片段,但不得记录 Cookie、密码、token。
|
4. **T-404b 已接入商品页失败 toast 捕获**:①采集和③更新在 `open_product`/等待详情页就绪失败时,不应只报等待超时。进入/刷新商品编辑页后应捕获 `.eds-toasts .eds-toast__content` 的文本和 `outerHTML`,记录当前 URL、时间、可见状态,并在关键元素超时时把最近错误 toast 作为用户可读失败原因写入 `last_error`、`run_log_events` 与本地诊断日志;例如商品 ID 失效时提示 `please input correct product id`。若 toast 明确属于商品失效/商品不存在/无权限类错误,底层仍写 `stage=imported/status=failed/last_error=商品失效:<原始toast>`,只在①导入采集列表“阶段”显示“商品失效”;其他打开失败仍显示“失败”。若失败发生在 `open_product()` 内部且 `cdp` 尚未返回上层,`open_product()` 必须自行清理本轮自动新建 tab;复用用户已有 tab 只断开 CDP,不关闭页面。失败现场可保存 HTML/toast JSON 片段,但不得记录 Cookie、密码、token。
|
||||||
|
5. **T-563 已接入①采集登录检测容错**:采集前预检和采集中途登录检测遇到 `NO_SESSION_COOKIE`、`LOGIN_CHECK_FAILED`、CDP 短暂读取异常时,应短暂重试并记录 reason、URL、Cookie 名称和重试次数;连续不确定仍不得把同账号后续任务批量标记为 `skipped`。只有明确检测到登录页 URL / `LOGIN_PAGE` 时,才把账号加入需补登录列表并整组略过后续任务。日志不得记录 Cookie 值。
|
||||||
|
|
||||||
|
|
||||||
原则:数据库日志给运营和 GUI 看“哪个商品、哪个步骤、为什么失败”;本地 log 文件给开发看完整错误栈。两者都必须脱敏。
|
原则:数据库日志给运营和 GUI 看“哪个商品、哪个步骤、为什么失败”;本地 log 文件给开发看完整错误栈。两者都必须脱敏。
|
||||||
@@ -329,8 +371,9 @@ CREATE TABLE run_log_events (
|
|||||||
- `account_name` 仅展示/参考;匹配以 `alias` 为准。
|
- `account_name` 仅展示/参考;匹配以 `alias` 为准。
|
||||||
- `source_file_abs/source_sheet/source_row` 是回写 Excel 的权威定位;即使商品 ID 重复,也按原行回写。
|
- `source_file_abs/source_sheet/source_row` 是回写 Excel 的权威定位;即使商品 ID 重复,也按原行回写。
|
||||||
- `row_key` 防止同一批次内重复导入同一行。
|
- `row_key` 防止同一批次内重复导入同一行。
|
||||||
|
- `product_status/product_status_note/product_status_at`:①每次打开商品编辑页后写入的状态快照;`normal` 只表示没有已知异常横幅,不等于实时“在售”。商品状态功能上线时间 `2026-07-18T16:34:37` 前的活动历史批次中,NULL/空状态在 `init_db()` 时按产品决策迁移为 `normal`。为兼容用户保留旧数据、复制安装目录或重新导入导致批次时间不可靠的情况,活动且未软删除的空状态任务,只要已有 `collected/generated/applied` 阶段、对应阶段时间或任一新旧标题/封面内容,也执行相同历史默认迁移;附「历史批次默认按架上商品处理(未实时检测)」备注并保持 `product_status_at=NULL`。迁移前在数据库同级 `backups/` 生成一次 SQLite 一致性备份;刚导入且没有处理证据的空状态与非法值仍按 `unknown` 处理。
|
||||||
- `old_title/old_cover_path`:程序**采集阶段抓取**的快照(输出)。
|
- `old_title/old_cover_path`:程序**采集阶段抓取**的快照(输出)。
|
||||||
- `new_title/new_cover_path`:**AI 生成**结果(输出);`new_title` 是进入 ③ 的必需结果,`new_cover_path` 是可选结果,只有②勾选生成封面且图片成功时才写入本地新封面路径;不设逐条确认阶段,标题生成成功即可进入 ③。
|
- `new_title/new_cover_path`:**AI 生成**的两个独立组件结果(输出)。③只更新标题要求 `new_title`,只更新封面要求 `new_cover_path`,更新图文才同时要求二者;只生成封面允许 `new_title=NULL`,已采集的 `old_title` 只作为封面prompt语义参考,不回填 `new_title`。不设逐条确认阶段。
|
||||||
- `stage` 表示已完成到哪个业务阶段;`status` 表示当前处理结果。失败时 `stage` 保持在最后成功阶段,`status=failed`,错误写 `last_error`。
|
- `stage` 表示已完成到哪个业务阶段;`status` 表示当前处理结果。失败时 `stage` 保持在最后成功阶段,`status=failed`,错误写 `last_error`。
|
||||||
- `committed`:③ 用户确认批量更新弹窗后逐条点「更新」提交;该字段记录提交是否成功。
|
- `committed`:③ 用户确认批量更新弹窗后逐条点「更新」提交;该字段记录提交是否成功。
|
||||||
- 各字段**每阶段处理完立即写回 SQLite**(实时落库);阶段结束后批量回写 Excel。
|
- 各字段**每阶段处理完立即写回 SQLite**(实时落库);阶段结束后批量回写 Excel。
|
||||||
@@ -361,7 +404,7 @@ CREATE TABLE run_log_events (
|
|||||||
|
|
||||||
- 输入列(账号名/别名/商品id)运营填;其余为程序各阶段输出,**回写到原 Excel**(被锁→提示重试/另存副本)。
|
- 输入列(账号名/别名/商品id)运营填;其余为程序各阶段输出,**回写到原 Excel**(被锁→提示重试/另存副本)。
|
||||||
- 别名以此列为匹配权威,不解析文件名。
|
- 别名以此列为匹配权威,不解析文件名。
|
||||||
- 标准模板推荐前三列表头固定为:`账号名 | 别名 | 商品id`。不要使用 `账号`、`店铺名`、`店名` 代替匹配列;`别名` 必须与 ④ 账号管理中的 `accounts.alias` 一致。
|
- 标准模板推荐前三列表头固定为:`账号名 | 别名 | 商品id`。不要使用 `账号`、`店铺名`、`店名` 代替匹配列;`别名` 必须与 账号管理中的 `accounts.alias` 一致。
|
||||||
- 导入容错:缺少必需列(别名/商品id)时拒绝整个文件,记入 `file_errors`,不导入该文件任何行。
|
- 导入容错:缺少必需列(别名/商品id)时拒绝整个文件,记入 `file_errors`,不导入该文件任何行。
|
||||||
- 脏行处理:单行缺别名/商品id、商品id 格式错误等逐行跳过,计入 `invalid`,不阻塞同文件其他有效行。
|
- 脏行处理:单行缺别名/商品id、商品id 格式错误等逐行跳过,计入 `invalid`,不阻塞同文件其他有效行。
|
||||||
|
|
||||||
@@ -387,9 +430,14 @@ data/images/<batch_id>/<slug>/<task_id>_<item_id>_new.<ext> # AI 生成的新
|
|||||||
|
|
||||||
### 6.1 采集(① Tab,只读)
|
### 6.1 采集(① Tab,只读)
|
||||||
|
|
||||||
- 用账号 Chrome 打开商品页,等就绪,读旧标题(标题输入框 value)。
|
- 用账号 Chrome 打开商品页,等就绪,先读商品状态,再读旧标题(标题输入框 value)。
|
||||||
- `open_product` 先复用已打开的同商品 tab;没有才新建商品编辑页 tab。采集完成后只关闭本次程序自动新建的商品 tab,不关闭用户原本已经打开的 tab。`CDP.close()` 只断开 WebSocket 控制连接,不等于关闭浏览器 tab。
|
- `open_product` 先复用已打开的同商品 tab;没有才新建商品编辑页 tab。采集完成后只关闭本次程序自动新建的商品 tab,并在最多 2 秒内轮询 `/json` 确认该 target ID 已消失;确认超时只记警告并保留采集成功结果。用户原本已经打开的 tab 不关闭、不等待。`CDP.close()` 只断开 WebSocket 控制连接,不等于关闭浏览器 tab。
|
||||||
- 若商品 ID 失效、无权限或店铺不匹配导致商品编辑页无法就绪,`open_product` 必须读取/捕获 Shopee toast,把最近错误文案写入采集失败原因和诊断日志,不能只返回泛化超时。①列表只在明确捕获商品失效类 toast 时把“阶段”显示为“商品失效”;底层 `stage` 不新增中文值。若这个失败发生在程序自动新建的商品 tab 内,`open_product` 要关闭该 tab;复用用户已有 tab 不关闭。
|
- ①采集调用 `open_product(..., bring_to_front=False)`,不主动执行 `Page.bringToFront`;新建商品 tab 时尝试 `Target.createTarget(background=true)` 降低 Chrome 抢焦点概率,若当前 Chrome/CDP 不接受该参数则退回普通新建 tab。③更新是上传、拖拽和线上提交流程,每条任务均以前台方式打开或激活当前商品 tab,优先保障页面交互稳定;后台态失败安全恢复逻辑仅为兼容直接调用保留,正常③批量路径不依赖它。
|
||||||
|
- 采集前和采集中途的登录检测必须区分“明确未登录”和“暂时不确定”。明确 `LOGIN_PAGE` / 登录页 URL 才整组略过该账号后续任务;`NO_SESSION_COOKIE`、检测超时或 CDP 短暂异常只记录为不确定并继续尝试采集当前商品,不得级联跳过同账号剩余任务。Cookie API 调用失败不能伪装成空 Cookie;登录检测若连到正在关闭的旧商品 target,应在本次检测预算内重新枚举并改连其他有效 Shopee 页面。
|
||||||
|
- 若商品 ID 失效、无权限或店铺不匹配导致商品编辑页无法就绪,`open_product` 必须读取/捕获 Shopee toast,把最近错误文案写入采集失败原因和诊断日志,不能只返回泛化超时。①列表只在明确捕获商品失效类 toast 时把“阶段”显示为“商品失效”;底层 `stage` 不新增中文值。若这个失败发生在后台只读、程序自动新建的商品 tab 内,`open_product` 要关闭并执行同样的有界 target 消失确认;复用用户已有 tab 不关闭。③前台更新打开失败仍沿用既有清理路径,不引入额外等待。
|
||||||
|
- 商品状态只读取 `.eds-alert.eds-alert--warning` 内的 `.eds-alert-title/.eds-alert-desc`,不得依赖 Vue `data-v-*`。`審核中/审核中` 归为 `reviewing`,`您的商品未上架` 归为 `unlisted`;已知的「促销期间部分字段无法编辑」横幅不是商品生命周期状态,单独出现时仍归为 `normal`。没有 warning 为 `normal`,其他未识别横幅、DOM 异常或非法返回为 `unknown`。保存归一化摘要而非页面 HTML;状态探测异常只写脱敏诊断,不伪造技术采集失败。
|
||||||
|
- 每次点击采集先在主线程选择本轮范围,策略值为 `normal_only/all`,默认 `normal_only`,不跨轮记忆。两种范围都会逐条重新打开页面检测并独立保存状态,不能用旧数据库状态预过滤;`normal_only` 仅正常商品读取标题和封面,其他三类状态记为业务略过并保留旧内容,`all` 则四类状态均走现有采集流程。范围略过不是技术失败,汇总分别统计状态和略过数。
|
||||||
|
- 不提供历史商品状态重检按钮或批量打开详情页的补检流程。历史默认迁移只在数据库初始化时执行,条件为活动批次创建时间严格早于上线时间且状态为空;另仅修复同一范围内由已知促销编辑提示误写的 `unknown`,保留原检测时间并注明历史修正。其他已有四类状态、软删除批次、上线时点及之后的空状态均不改写。
|
||||||
|
|
||||||
- 旧封面:取第一张 itembox 的 `img.src`(CDN 链接),下载到 `data/images/<batch_id>/<slug>/<task_id>_<item_id>_old.jpg`。
|
- 旧封面:取第一张 itembox 的 `img.src`(CDN 链接),下载到 `data/images/<batch_id>/<slug>/<task_id>_<item_id>_old.jpg`。
|
||||||
- 写 `old_title/old_cover_path`、stage=collected;批量回写 Excel 旧字段。
|
- 写 `old_title/old_cover_path`、stage=collected;批量回写 Excel 旧字段。
|
||||||
@@ -397,52 +445,83 @@ data/images/<batch_id>/<slug>/<task_id>_<item_id>_new.<ext> # AI 生成的新
|
|||||||
|
|
||||||
### 6.2 AI 生成(② Tab)
|
### 6.2 AI 生成(② Tab)
|
||||||
|
|
||||||
单个「开始生成」按钮,标题必生成,封面按本轮成本开关可选生成:
|
单个「开始生成」按钮,按本轮「生成内容」模式生成标题、封面或图文:
|
||||||
|
|
||||||
|
- 点击后先从当前筛选结果按生成模式构造真实候选,再在主线程按 `normal_only/all` 选择范围:①②共用最小宽度 520px、纵向全宽按钮的中文范围选择框。①按钮文案为“采集架上商品”(检测结果为 `normal`)和“采集全部商品”;②按钮文案为“生成架上商品”(仅 `normal`)和“生成全部商品”。“全部商品”用警示橙色描边表示扩大范围/可能增加成本,不使用删除红色或 `DestructiveRole`。它才允许未上架、审核中、状态未知(历史 `NULL` 同未知)商品进入 Worker。范围确认显示四类数量;取消、无候选或确认期间候选指纹变化时不创建 Worker、不请求 AI、不改变 DB。警示选择不跨轮记忆,且没有非正常候选时禁用。范围只决定本轮生成,不修改商品状态,也不授权③更新。
|
||||||
|
- 共享 `product_status.build_generation_plan()` 对候选按稳定任务 ID 去重并冻结 `task_id/updated_at/product_status/new_title/new_cover_path/生成内容模式` 指纹。`GenerateWorker` 接收确认后的精确任务清单和范围代码,在 `normal_only` 下再次防御性排除非正常状态,`ai.generate_batch()` 只能收到最终执行清单。`run_logs.options` 与汇总记录选择范围、四类数量、按范围排除数和计划指纹;用户日志只显示中文范围摘要。
|
||||||
|
|
||||||
1. **并发生成标题**:线程池大小 = `title_concurrency`,调 `gen_title(标题提示词, old_title)` → new_title。默认 `backend=cmhub`,调用 `POST /api/v1/generate/title` 并使用 `title_alias`;`backend=direct` 仅内部兼容时使用 `default_text_model`。
|
1. **并发生成标题**:线程池大小 = `title_concurrency`,调 `gen_title(标题提示词, old_title)` → new_title。默认 `backend=cmhub`,调用 `POST /api/v1/generate/title` 并使用 `title_alias`;`backend=direct` 仅内部兼容时使用 `default_text_model`。
|
||||||
2. **若②勾选「生成封面图片(成本较高)」**:接着并发生成图片,线程池大小 = `image_concurrency`,调 `gen_cover(封面提示词, old_cover_path, resolution, jpg_quality)` → 新图存 `data/images/<batch_id>/<slug>/<task_id>_<item_id>_new.jpg`。默认 `backend=cmhub`,调用 `POST /api/v1/generate/image`,拿 `image_url` 后立即安全下载并转本地 JPEG;`backend=direct` 仅内部兼容时使用 `default_image_model`。
|
2. **若②「生成内容」包含封面**:接着并发生成封面,线程池大小 = `image_concurrency`,调 `generate_batch()` 的封面阶段 → 新图存 `data/images/<batch_id>/<slug>/<task_id>_<item_id>_new.jpg`。选择 `cover` 时处理缺新封面且存在 `new_title or old_title` 的任务,优先用新标题、否则只在prompt上下文中用旧标题回退;选择 `title_cover` 时仍先补新标题,再按缺失组件补封面,不使用旧标题绕过失败的生文。默认 `backend=cmhub` 的②批量生成走异步任务接口:先 `POST /api/v1/generate/image/tasks` 提交并预扣点,带 `Idempotency-Key` 与 `X-Client-Version`,submit 成功后立即把 `task_id` 写入 `tasks.image_task_id`;再循环 `GET /api/v1/generate/image/tasks/{task_id}` 轮询,`succeeded` 后取 `result.image_url` 并安全下载转本地 JPEG。若本地已有 `image_task_id`,下次开始生成直接续查,不重新 submit、不重复扣点。`gen_cover()` 单独直接调用无 DB 上下文,仍保留旧同步 `POST /api/v1/generate/image` 兼容路径;`backend=direct` 仅内部兼容时使用 `default_image_model`。用户日志只展示“cmhub 托管档位 / 别名 / 扣点 / 余额 / call_id”,不得输出 cmhub 内部接口路径、Provider 请求体、Authorization、Key 或完整 prompt。
|
||||||
- T-545 已实现:cmhub 模式下不再直接按用户填写的 `image_concurrency` 全量打到网关;实际生图请求并发 = `min(image_concurrency, 5)`。拿到 `image_url` 后交给独立下载/保存线程池,下载线程数量与实际生图请求并发一致,同样最大 5。这样批量时“下一批生图请求”和“上一批图片下载/保存”可以流水线并行,但不会对 cmhub 生图接口或 `/media/generated/images/*.png` 静态下载打出超过 5 的并发。direct 兼容路径暂不改变。
|
- T-545/T-564 已实现:cmhub 模式下不再直接按用户填写的 `image_concurrency` 全量打到网关;实际 submit+poll 在途并发 = `min(image_concurrency, 5)`。拿到 `image_url` 后交给独立下载/保存线程池,下载线程数量与实际生图并发一致,同样最大 5。这样批量时“下一批生图任务”和“上一批图片下载/保存”可以流水线并行,但不会对 cmhub 生图任务接口或 `/media/generated/images/*.png` 静态下载打出超过 5 的并发。direct 兼容路径暂不改变。
|
||||||
- cmhub 生图连接超时取 `ai.cmhub.connect_timeout`(默认 66 秒);生图请求和随后 `image_url` 下载的读取等待统一固定 900 秒,读超时不自动重发,避免重复扣点。下载层最多安全重试 3 次,只复用同一个 `image_url`,不重新请求 cmhub 生图;下载总耗时超过 20 秒时写“图片下载较慢”警告。`backend=direct` 兼容路径仍按模型 `timeout_seconds` 或 `resolution_timeouts[resolution]`(512→180/1k→240/2k→360/4k→600)取返回超时。
|
- cmhub 生图连接超时取 `ai.cmhub.connect_timeout`(默认 66 秒);异步 submit 读取等待 30 秒,poll 单次读取等待 15 秒,本地总等待预算 900 秒,撞预算或用户停止时保留 `image_task_id/image_task_key` 供下次续查;poll 返回 `failed/expired`(cmhub 已退点)时清空 `image_task_id/image_task_key`,后续重试会生成新的幂等键并重新 submit。下载层读取等待 900 秒,最多安全重试 3 次,只复用同一个 `image_url`,不重新请求 cmhub 生图;下载总耗时超过 20 秒时写“图片下载较慢”警告。`backend=direct` 兼容路径仍按模型 `timeout_seconds` 或 `resolution_timeouts[resolution]`(512→180/1k→240/2k→360/4k→600)取返回超时。
|
||||||
- T-548 已实现:cmhub 图片下载后端新增 `ai.cmhub.download_with_curl`(`auto`/`true`/`false`,默认 `auto`)。Windows 且检测到系统 curl 时优先用 curl 下载,否则或 curl 执行失败时回退 requests;生成、models、balance 仍走共享 requests Session。curl 下载前仍执行公网 URL 校验;URL 写入临时 curl 配置文件并通过 `-K` 传入,避免带 token 的 `image_url` 出现在进程命令行;`use_system_proxy=false` 时 curl 加 `--noproxy "*"`。
|
- T-548 已实现:cmhub 图片下载后端新增 `ai.cmhub.download_with_curl`(`auto`/`true`/`false`,默认 `auto`)。Windows 且检测到系统 curl 时优先用 curl 下载,否则或 curl 执行失败时回退 requests;生成、models、balance 仍走共享 requests Session。curl 下载前仍执行公网 URL 校验;URL 写入临时 curl 配置文件并通过 `-K` 传入,避免带 token 的 `image_url` 出现在进程命令行;`use_system_proxy=false` 时 curl 加 `--noproxy "*"`。
|
||||||
- cmhub 返回的完整 `image_url` 默认只在内存中临时用于下载,不写入 `tasks` 或 `run_log_events`。本机调试时可设置环境变量 `CMSHOPEE_DEBUG_CMHUB_IMAGE_URL=1`,②本轮可见运行日志会显示脱敏后的 URL 调试行,且该行不持久化到 SQLite。
|
- cmhub 返回的完整 `image_url` 默认只在内存中临时用于下载,不写入 `tasks` 或 `run_log_events`。本机调试时可设置环境变量 `CMSHOPEE_DEBUG_CMHUB_IMAGE_URL=1`,②本轮可见运行日志会显示脱敏后的 URL 调试行,且该行不持久化到 SQLite。
|
||||||
3. **若未勾选生成封面**:标题成功后立即写 `new_title`,`new_cover_path=NULL`,不渲染封面提示词、不调用 `gen_cover()`、不创建本地新封面文件。
|
3. **若②「生成内容」为只生成标题**:标题成功后立即写 `new_title`,`new_cover_path=NULL`,不渲染封面提示词、不调用 `gen_cover()`、不创建本地新封面文件。
|
||||||
|
|
||||||
- 失败重试:每次调用失败按 `retry` 次重试,仍失败则记 error(不阻塞其余)。
|
- 失败重试:每次调用失败按 `retry` 次重试,仍失败则记 error(不阻塞其余)。
|
||||||
- 标题-only 模式在标题成功后立即 `set_generated(task_id, new_title, NULL)` 写库;标题+封面模式在图片成功后 `set_generated(task_id, new_title, new_cover_path)` 写库。两种模式都实时落库,停止或崩溃不丢已生成结果。
|
- 只生成标题模式在标题成功后立即 `set_generated(task_id, new_title, existing_cover_path)` 写库;只生成封面模式不调用生文,封面成功通过 `set_generated_cover(task_id, new_cover_path)` 只写封面并保留 `new_title`(包括保持NULL);生成标题和封面模式先保存标题,封面成功再用组件级helper写封面。三种模式都实时落库,停止或崩溃不丢已生成结果。
|
||||||
- **「停止」**:取消未开始的任务,正在跑的少量完成或中断;停止后可再次「开始生成」对剩余继续。
|
- **「停止」**:取消未开始的任务,正在跑的少量完成或中断;停止后可再次「开始生成」对剩余继续。
|
||||||
- 进度:标题和图片两条进度分开显示;未勾选生成封面时图片进度显示本轮未生成/0 张,并在运行日志写明“本轮仅生成标题”。
|
- 进度:标题和图片两条进度分开显示;只生成标题时图片进度显示本轮未生成/0 张,并在运行日志写明本轮生成内容。
|
||||||
- 生成后 stage=generated;**不设逐条人工审核阶段**。若未生成封面,任务仍可进入③并只更新标题;若后续需要封面,用户可重置生成结果或重新生成该任务。双击任务弹窗查看新旧封面(纯查看,无新封面时显示为空);T-404a 已提供对当前选中单条的「重置生成结果」,确认后只清本地 AI 结果并退回 collected 供重新生成,默认不删除本地新封面文件;新标题直接用 AI 输出(不可编辑)。
|
- 任一组件生成后 `stage=generated`;**不设逐条人工审核阶段**。若只有标题,③可选择只更新标题;若只有封面,③可选择只更新封面,②标题状态仍为待生成,后续补标题会保留已有封面且不重复生图。双击任务弹窗查看旧封面、新封面和历史候选图;T-577 后弹窗内「重置图片」只清当前任务 `new_cover_path` 并归档旧图,不启动单条 `GenerateWorker`,用户退出后用状态筛选「待生成」批量补生成封面。②「重置生成结果」提供标题/封面/全部的多选或当前筛选范围重置,默认不删除本地新封面文件;已生成且未提交线上的新标题可在②表格本地微调。
|
||||||
- 并发数、重试、分辨率、jpg 质量、模型/Key 均来自 ⑤ 设置(`data/config.json` 的 `ai` 段;Key 存 `data/config/cmhub.json` 或 direct 兼容清单)。T-547 后标题并发和图片并发都限制为 1..5,失败重试次数限制为 0..10;旧 `config.json` 或手工配置的超限值会在加载/保存时夹紧。⑤仍只展示一个「图片并发」设置;cmhub 模式下②运行日志显示“图片并发 X,cmhub实际生图并发 Y,下载并发 Y”。
|
- 并发数、重试、分辨率、jpg 质量、模型/Key 均来自设置(`data/config.json` 的 `ai` 段;Key 存 `data/config/cmhub.json` 或 direct 兼容清单)。T-547 后标题并发和图片并发都限制为 1..5,失败重试次数限制为 0..10;旧 `config.json` 或手工配置的超限值会在加载/保存时夹紧。设置仍只展示一个「图片并发」设置;cmhub 模式下②运行日志显示“图片并发 X,cmhub实际生图并发 Y,下载并发 Y”。
|
||||||
|
- T-679a 后商品套图按 worker 启动时冻结的配置建立并持久化来源:默认网关 job 精确为 `cmhub/cmhub`,保留异步提交、轮询、下载、幂等键和点数账本;自定义网关 job 精确为 `direct/openai_images_edits`,走 T-678 的同步多图编辑、固定 `n=1`、无 `task_id`、无轮询和无自动重试。执行分派只读 job 持久化来源,不能受运行中设置变化影响;不匹配的来源/提供方或 direct job 带 task ID 均安全失败,避免跨来源串路。T-679b 已接通⑥新建入口与确认,T-679c 已补齐手动重试与混合来源历史展示,T-679 已完成跨来源集成验收。
|
||||||
|
- T-679b 后,⑥在自定义网关下仅本地校验当前已启用图片模型的接口类型、网址、模型 ID、密钥与连接超时;合格后可新建同步 direct job,但不读取 cmhub 模型目录、余额或价格。确认框按冻结 specs 展示数量、主参考图/参考图、逐图模式、请求尺寸和近似比例,并明确“自定义网关不计点数,实际费用以服务商为准”及主体一致性限制。AI帮写始终使用 cmhub `vision_alias`,其可用性只取决于 cmhub 图片理解配置,不受当前生图 backend 影响。生成期间当前任务输入锁定,任务切换、历史、日志和已完成图片查看不阻塞。
|
||||||
|
- direct job 提交前写入 `running + run_session_id`;停止只取消尚未提交的 job。同步请求已返回图片字节时,即使停止信号随后到达,仍保存资产并标成功。连接/读取超时或进程中断不自动补发、不进入 cmhub 恢复轮询,用户只能手动重新生成。应用仅在启动阶段持有单进程恢复租约时处理遗留 direct `running` job;活动会话跳过,确认陈旧的 job 标为“程序中断,无法确认生成结果,请手动重新生成”。进入项目、切换任务或刷新结果不做该清理。T-679c 后「继续查询已提交图片」严格只处理 `cmhub/cmhub + task_id`,切换⑤设置不会改变该筛选;direct 永不进入此入口。结果卡、项目历史与全局历史均以 job 的持久化来源显示“默认网关 / 自定义网关”,不读取当前设置。direct 失败只允许用户手动“重新生成”:当前设置仍为自定义网关且配置合格时,必须重新展示完整生成确认,并额外警告服务商可能已经对上次未确认请求计费、本次可能再次收费;确认后始终新建 job,绝不复用旧 job 或自动补发。
|
||||||
|
- 套图比例输出固定映射为 `1:1 -> 1024x1024`、`3:4/9:16 -> 1024x1536`、`4:3/16:9 -> 1536x1024`。`image_studio_assets.aspect_ratio` 保持用户原选项,新增 `requested_output_size`、`rendered_width`、`rendered_height` 记录请求尺寸与真实像素;旧记录保持空值,SQLite 迁移仅附加且幂等。服务返回近似比例结构化结果,T-679b 在确认界面呈现。
|
||||||
|
- T-679 组发布门禁已通过:同一项目可保留默认网关与自定义网关历史,worker 启动后保存⑤设置不改变本轮冻结来源,切换来源后仍可继续查询已有 cmhub task ID 且不会重新提交。图片理解「AI帮写」固定使用 cmhub `vision_alias`,即使套图生图选择自定义网关也不改走 direct。
|
||||||
|
- T-658a 后商品套图异步生图提交统一使用 `images` 数组,单图也使用单元素 `{"image_base64": ...}`,不再提交顶层 `image_base64`。提交层最多传 8 张本地图片,单图原文件上限10MiB、编码后总输入上限32MiB;超限在请求前以中文错误阻断,后续多图参考的资产快照和提示词语义由 T-658b/T-658c 负责。
|
||||||
|
- T-658b 后 `image_studio_jobs.reference_asset_ids` 以有序 JSON 图片 ID 列表冻结每个商品套图 job 的参考图;新一轮未勾选逐图主图时,第1张为主图、后续最多7张写入快照,勾选时写空数组。历史 `NULL` 行继续按单图任务处理。商品原图 UI 的第1张明确显示「主图」。内部排序由列表自行创建 `QDrag`,不得调用 `QAbstractItemView.startDrag(MoveAction)` 的源端清理;`dropEvent()` 按坐标以 `takeItem()` + `insertItem()` 唯一移动同一个项,不调用 Qt 默认 model drop,也不走“投放到图片上”的复制路径。`QDrag` 返回并清理拖拽上下文后才发送一次排序信号;结束时数量或 ID 集合异常则不写库,并从 SQLite 恢复界面。最终 ID 顺序唯一且确有变化时才由事务更新 `source_order`,排序只影响之后新建的一轮 job,不回写既有 job 快照。外部文件 URL 继续走导入。恢复或重试只读 job 快照,不回读当前原图列表;参考图资产或本地文件缺失时提交前失败,不静默减少提交数量。
|
||||||
|
- T-658d 后逐图主图 checkbox 右侧显示可换行中文 helper,说明多SKU应逐图生成、同商品多角度应作为参考图一同提交。常规生成确认框从最终 specs 计算主图/参考图数量;未开启逐图主图且可用原图超过8张时,明确第1张主图加前7张参考图的上限和忽略数量。确认总点数仍只按 job 请求数计算,不因单请求图片数量重复计算。
|
||||||
|
- T-647 后商品套图中的「AI帮写」不是②标题生成的复用入口:它使用 `vision_alias` 调 `POST /api/v1/analyze/images`,把当前项目中1至8张可用本地原图按 `source_order` 在一次请求中发送;超过8张时明确只使用前8张,原图勾选状态不改变该输入集合。视觉提示词将这些图片定义为同一商品项目的多角度、细节、包装或场景证据集,要求产出一份商品级联合分析,不按“图1/图2”逐图罗列;结果按商品概述、可确认卖点、适用人群与场景、套图画面要求、待确认或避免编造的信息组织,并把可见差异明确为待确认项,不强行合并为事实。单图上限10MiB、总计上限32MiB,缺图、未下载、超限或未配置别名时不发请求且保留用户现有卖点。读取等待固定120秒、连接等待沿用设置;读超时或网络中断只提示“结果未确认”,不自动重发。成功只显示图片张数、扣点和余额,不显示路径、base64、接口URL、完整提示词或上游原始响应;取消只在请求前/返回后协作生效,不强杀网络线程。
|
||||||
|
- `product_suite.render_product_suite_prompt()` 是弹窗预览和真实生成的唯一 renderer;`build_job_specs()` 建立本轮 specs 前只读取一次用户模板并冻结,每个 job 保存最终完整 prompt,运行中修改模板只影响下一轮。新模板使用 `{生成目标}`,其值为内置分类固定目标描述或含实际名称的“生成自定义分类图片:分类名称。”;同时保留 `{套图名称}`、`{补充描述}` 及平台/地区/语言/比例、可选商品ID/主参考图序号、参考图规则、商品卖点和四个只读规则。旧模板必须同时包含 `{套图名称}` 与 `{补充描述}` 才能按兼容路径继续使用;必需变量缺失、未知/未闭合花括号、只读规则变量未独占一行都视为无效。模板校验仅针对模板文本;替换值是业务数据,商品卖点与要求、分类名等可原样包含 `{}`,不再二次作为变量解析。`product_suite_reference_rule()` 是 `{参考图规则}` 的唯一事实来源,变量值都以 `参考图规则:` 开头:逐图主图开启时为单图主参考规则;未开启时固定为“使用第一張上傳圖作為主商品圖,其餘圖片只作為參考。”,不随实际参考图数量变化。提示词弹窗与 job 共用 renderer;实际提交的参考图数量仍由冻结的 `reference_asset_ids` 决定。默认模板独占一行,不额外重复标签或序号。四个只读规则覆盖尺寸与长图(含禁止多宫格拼接)、政治标识、价格和尺码;商品主体一致性与禁止编造并入参考图规则。比例仍同时传入 `image_studio_generation.run_jobs(aspect_ratio=...)`,进入 cmhub 请求与输出资产元数据。
|
||||||
|
- `image_studio_projects.suite_settings_json` 持久化套图设置,旧数据库由 `db.init_db()` 原位补列,默认 `{}`;`draft_prompt` 继续保存卖点文本。`image_studio_assets` 中有效商品原图最多16张,历史 missing 记录不占有效名额;手工原图不会因再次同步蝦皮 URL 被误标 missing。商品套图原图列表的批量勾选只保存在当前 `SuiteTaskState` 对应的界面上下文,不写库;批量移除由 `remove_original_assets_if_unused()` 一次校验项目归属、原图类型、终选直接引用及 job 的主图/参考图快照,并在单个 SQLite 事务中删除资产行、连续重排 `source_order`。`pending/submitted/running` 或仍可 `resume/regenerate` 的任务继续保护其原图;`succeeded + recovery_action=none` 及其他已放弃终态只保留历史、不阻断删除。删除后 SQLite 外键将历史 job 的 `source_asset_id` 和输出图 `parent_asset_id` 置空,job 状态、提示词、扣点、轮次和生成结果保留;参考图 JSON 快照不改写。快照无法解析时保守阻断。服务不删除本地文件或蝦皮线上图片,任一资产校验失败时整批回滚。
|
||||||
|
- T-636 起,`image_studio_projects` 增加 `binding_state`(`draft` / `bound`)和稳定 `storage_key`。既有项目迁移为 `bound`,并以原 `item_id` 回填 `storage_key`;项目目录改用 `storage_key`,因此临时草稿绑定正式商品 ID 后不移动目录、不改写已有资产路径。草稿内部使用 `draft_<uuid>` 作为仅数据库可见的非空 `item_id`,GUI 输入框始终保持空白,用户日志和 cmhub 提示词只使用“临时草稿”或“未绑定商品”,不得暴露该内部值。
|
||||||
|
- 商品套图的正式商品 ID 统一经 `image_studio.normalize_item_id_input()` 清除首尾空白和 Unicode 格式控制字符,再由 `is_formal_item_id()` 按 ASCII 数字判定;空值只允许用于未绑定临时项目。商品 ID 失焦、拉取蝦皮主图、添加本地原图和开始生成在使用上下文前必须走同一提交入口。非法输入只在输入框提示,不得覆盖当前已绑定项目的商品 ID、原图、job 或生成结果;合法切换仍须用户确认。
|
||||||
|
- 在商品套图中,已选账号但未填写商品 ID 时允许输入卖点、导入、拖入或粘贴本地图片。非空卖点在现有防抖稳定后会创建一个可恢复临时草稿并保存到 `image_studio_projects.draft_prompt`;首次有效图片导入也会创建草稿。空白卖点、取消选择和全部导入失败不保留空草稿。只有卖点的草稿同样属于可恢复业务内容;卖点清空且没有资产/job 时仍可按空草稿规则清理。草稿可管理本地图片、AI 帮写、生成套图、查看历史和打开结果目录,但在创建 worker、启动 Chrome 或执行 CDP 前禁止「拉取蝦皮主图」。输入合法数字商品 ID 后,经确认原地绑定同一个 `project_id`;资产、job、selection、提示词、套图设置和 `storage_key` 均保持不变。若同账号目标 ID(含软删除项目)已存在则拒绝覆盖或合并。
|
||||||
|
- 商品套图拉取蝦皮主图使用独立内存 `pull_run_token` 覆盖 URL 读取和本轮后台下载。运行中按钮提供「停止并保留 / 停止并清除本次新增 / 继续拉取」;停止采用协作式安全边界,不强杀线程、Chrome 或已发出的 CDP/HTTP 请求。选择清除时只移除本轮新增、属于当前项目且未被 job/selection 引用的远程原图,并恢复拉取前已有原图的状态和顺序;本地手动导入图片、拉取前已有图片、用户文件和蝦皮线上图片不删除。拉取期间仅关闭商品原图的内部拖拽排序,避免同步刷新与用户排序竞争,文件导入、预览和其他非冲突操作仍保持可用;`reorder_originals()` 也会防御性拒绝迟到排序信号并按数据库顺序刷新。旧 token 的 URL/下载迟到结果不得覆盖新一轮状态。
|
||||||
|
- 启动时恢复未软删除、至少含一条资产或生成任务的草稿为独立中文“临时草稿”标签,按最近更新时间排序。关闭非空草稿可选择保留、软删除或取消;软删除不物理删除图片目录。③「更新蝦皮」只处理正式任务,不接受临时草稿。
|
||||||
|
- 第六 Tab 的多个 `SuiteTaskState` 各自保留 generation/pull/import/AI/download worker 与线程引用;切换任务不取消任务。多个任务可并行,但 `image_studio_generation` 使用进程级来源中立 semaphore,保证默认网关与自定义网关生图合计最多5个在途 job。线程还在运行时关闭任务只请求协作式停止,模块级引用保留到 `QThread.finished`,不得提前销毁线程对象;下载前后均检查停止信号,停止后的临时文件不入资产库。
|
||||||
|
- T-639 后每轮套图生成使用仅存在内存的 `run_token` 隔离迟到信号,progress/finished/cancelled/failed 通过主线程绑定槽统一处理;正常 worker 结果、`QThread.finished` 和本轮 job 连续两次全部终态看门狗共同进入幂等 finalize。GUI 只按本轮明确 `job_ids` 判断完成,不用历史图片数量;即使最终信号丢失也会恢复按钮,旧线程引用仍保留到真实结束。停止为协作式:调度循环约每200ms检查标记并取消未开始 future,提交/轮询在有界请求返回后停止;requests 在流式数据块边界取消,Windows curl 由隐藏窗口 `Popen` 有界 terminate/kill。已有 `task_id` 的停止任务保留 resume,不假设服务端任务被取消或点数退回。
|
||||||
|
- T-643 后 `image_studio_projects.current_generation_round_key` 是商品套图主结果区的持久化当前轮次;每次常规「生成套图」创建独立 UUID `generation_round_key`,并为每条 `image_studio_jobs` 写入稳定的 `generation_slot_index`。内存 `run_token` 仍只用于线程和迟到信号隔离,不承担业务轮次语义。只有一轮中至少一条 job 成功时,才在同一 SQLite 事务内将该轮提升为当前轮;全失败或全取消保留原当前轮,部分成功则保留成功、失败槽位供后续重试。单张重试新建 job 但继承原轮次和槽位,当前展示按同轮同槽位最新 job、槽位顺序读取;所有尝试、错误、计费和输出资产均保留。旧 job 的轮次/槽位保持 NULL,查询层统一作为“旧版历史记录”,不以时间、文件名或数量猜测轮次;下一次成功的新格式整轮才建立当前轮。
|
||||||
|
- T-646 后商品套图主结果区仍只显示项目的当前轮次;「历史生成」改为全局非模态只读窗口,默认跨所有未软删除商品项目按轮次创建时间倒序读取(同一时间以稳定 job ID 补序),首屏及每次翻页最多30轮。窗口只读取 SQLite 的轮次摘要、每槽最新有效 job 和受限尺寸缩略图,不扫描图片目录或一次加载原图;店铺/账号、商品 ID 与“仅当前商品”筛选都在查询层完成,当前商品快捷筛选默认关闭。正常 `generation_round_key` 一轮一行,单张重试只更新该槽位当前状态并计入重试数,不新增历史行;NULL 轮次继续作为“旧版历史记录”兼容,不猜测轮次边界。每行最多显示5张缩略图,余量显示 `+N`;文件缺失只显示中文占位,不删除 DB。双击缩略图从被点图片打开该轮所有可用图片的自适应原尺寸浏览;“导出本轮”在后台仅复制成功且本地存在的输出 asset 到用户选择目录下安全命名的新子目录,确定性追加序号避免覆盖,不移动/重命名/删除内部 asset。窗口不提供批量导出、删除、重试、设为当前轮或重新生成;关闭商品任务不关闭全局窗口,应用退出时协作停止导出并释放窗口资源,生成 worker 不受历史窗口影响。
|
||||||
|
- T-648 后,用户发起一轮常规「生成套图」前,服务层仅根据 SQLite 中当前项目每个稳定槽位的最新有效成功 job(NULL 旧版轮次仍按一轮兼容)汇总成功轮数、图片数和最近成功时间;不扫描生成目录。若摘要非空,GUI 先显示默认取消的「已有套图生成记录」三选一确认:查看历史仅打开同一全局历史窗口并临时勾选“仅当前商品”,不创建轮次或请求;继续生成新一轮才进入原有费用确认,并在最终确认后生成新的 UUID 轮次;取消或关闭不写 job、asset、轮次或计费。单张重试、恢复未完成任务及只有失败/取消记录的项目不经过该确认。
|
||||||
|
- T-649 后,历史窗口的店铺筛选改为结构化下拉:首项固定为「全部店铺」,当前账号按「账号名(账号别名)」显示;未软删除历史项目中仍存在、但账号管理已删除的别名按「历史店铺:别名(账号已删除)」保留。下拉值始终是稳定的 `account_alias`,查询使用 `projects.account_alias = ?` 精确匹配;商品 ID 与“仅当前商品”可叠加,刷新时保留仍存在的选择。商品套图顶部不再提供打开内部生成目录的按钮,用户导出只从历史记录的「导出本轮」进入外部目标目录;结果卡的单图文件操作保持不变。
|
||||||
|
|
||||||
提示词管理:
|
提示词管理:
|
||||||
|
|
||||||
- **标题提示词**:单个文本,「保存」写入 `data/title_prompt.txt`;软件启动时加载该文件回显到输入框(缺失则空)。T-549 后标题提示词支持 `{旧标题}` 占位符:若提示词含 `{旧标题}`,生成前替换为该任务旧标题且不再自动追加旧标题块;若不含,则保持旧行为自动追加“旧标题:...”块。两种情况都会保留“请只返回新标题,不要解释。”输出约束。
|
- **标题提示词**:`data/title_prompt.txt` 是当前工作文本,「保存标题提示词」写入该文件;软件启动时加载该文件回显到输入框(缺失则空)。标题命名模板另存于 `data/prompts/title/*.txt`,左侧模板行提供下拉、新建、保存模板、另存为、重命名、删除;模板只负责被用户选中后载入编辑框,或把当前编辑框内容保存为命名模板,不改变生成读取路径,也不在启动时覆盖 `title_prompt.txt`。T-549 后标题提示词支持 `{旧标题}` 占位符:若提示词含 `{旧标题}`,生成前替换为该任务旧标题且不再自动追加旧标题块;若不含,则保持旧行为自动追加“旧标题:...”块。两种情况都会保留“请只返回新标题,不要解释。”输出约束。
|
||||||
- **封面提示词**:多模板。下拉选模板(读 `data/prompts/cover/*.txt`),图标工具栏 新建/保存/另存为/重命名/删除;重名校验、删除二次确认、删空给默认。
|
- **封面提示词**:多模板。左侧模板行提供下拉(读 `data/prompts/cover/*.txt`)、新建、保存模板、模板操作(另存为/重命名/删除);重名校验、删除二次确认、删空给默认。T-661 后内置首次播种资源为 `app/default_prompts/cover/cover_prompt.txt`,全新用户初始化为 `data/prompts/cover/cover_prompt.txt`,下拉显示 `cover_prompt`;已有任意用户模板(包括历史 `papa1.txt`)不覆盖、不重命名、不迁移。
|
||||||
|
- **商品套图提示词**:单一全局基础模板,用户文件为 `data/prompts/product_suite/base.txt`,内置恢复源为 `app/default_prompts/product_suite/base.txt`。商品套图中的「商品卖点与要求」标题行提供「提示词设置」;弹窗左侧编辑模板、右侧按白底图或其他当前分类只读预览最终请求,默认模板首行是“生成目标:{套图名称},{生成目标}”,编辑使用 200ms 防抖。保存与「恢复默认」共用同一个 UTF-8 原子写入服务;恢复默认会先校验内置资源并经确认立即落盘。用户可移动只读规则占位符位置,但不能修改规则内容或删除必需变量;预览和新 job prompt 必须逐字一致。旧版用户模板中的 `{套图名称}` 与 `{补充描述}` 继续兼容,不被启动升级静默替换。
|
||||||
- **变量**:标题提示词本阶段只支持 `{旧标题}`,左侧按钮「插入旧标题」在标题提示词光标处插入 `{旧标题}`。封面提示词支持占位符 `{旧标题}`、`{新标题}`、`{商品id}`、`{店铺}`,生成前用该任务真实值替换(`render_prompt`)。「插入标题」= 在封面提示词光标处插入 `{新标题}`;「预览」= 用某条任务的值替换封面变量后展示,确认实际发送给 AI 的内容。
|
- **变量**:标题提示词本阶段只支持 `{旧标题}`,左侧按钮「插入旧标题」在标题提示词光标处插入 `{旧标题}`。封面提示词支持占位符 `{旧标题}`、`{新标题}`、`{商品id}`、`{店铺}`,生成前用该任务真实值替换(`render_prompt`)。「插入标题」= 在封面提示词光标处插入 `{新标题}`;「预览」= 用某条任务的值替换封面变量后展示,确认实际发送给 AI 的内容。
|
||||||
|
|
||||||
### 6.3 应用更新(③ Tab)
|
### 6.3 应用更新(③ Tab)
|
||||||
|
|
||||||
- ③ 顶部筛选确定本次作用范围;点击「开始更新」后弹窗展示筛选条件、任务数量和“将提交线上”的风险提示。
|
- ③ 顶部筛选确定本次作用范围;点击「开始更新」后弹窗展示筛选条件、任务数量和“将提交线上”的风险提示。
|
||||||
- ③ 提供「检查本轮更新」按钮:只读取当前筛选结果和写运行日志,不打开 Shopee、不提交、不改任务状态;检查汇总展示总数、店铺分布、每批最大条数、预计批次数、更新字段和略过原因。
|
- ③ 左下角提供「更新内容」下拉:`只更新标题` / `只更新封面` / `更新标题和封面`。点击「开始更新」或「检查本轮更新」时,`product_status.build_apply_plan()` 先把候选按商品状态冻结:仅 `normal` 可继续检查 `new_title` / `new_cover_path`,`unlisted`、`reviewing`、`unknown` 与未满足历史迁移条件的 NULL 均从 Worker 输入剔除;再对正常商品分出可执行项和内容缺失项。状态功能上线前的活动历史批次,以及批次时间不可靠但已有处理证据的历史空状态任务,由 `init_db()` 迁移为默认 `normal` 后可进入内容完整性检查;无任何处理证据的新导入空状态任务仍被拦截。状态异常与内容缺失仅在确认框、状态栏和本轮日志列为预检排除,不打开其 Chrome、不写其失败状态、Excel 或运行内 worker 统计。无可执行项时,优先展示真实商品状态原因;只有没有状态异常时才沿用「更新内容未生成」文案。
|
||||||
- 弹确认前先读取 `data/config.json` 的 `shopee_update`:未开启 `allow_real_submit`,或任务含新封面但未开启 `allow_cover_update` 时,直接弹警告阻断,不创建真实更新 `ApplyWorker`。普通正式更新不再检查 `test_item_id`,当前筛选结果可以包含多个真实商品 ID。`max_items_per_run` 作为每批最大任务数,当前筛选总数超过该值时自动分批,不再按总数阻断。
|
- 计划指纹覆盖 `task_id/updated_at/product_status/new_title/new_cover_path/更新模式`;用户确认后重新计算,任何变化均废弃旧计划并要求重新开始。`ApplyWorker` 仍在执行层再次验证 `product_status=normal`,防止直接构造 Worker 绕过 GUI 预检。①、②本轮的“所有状态”范围选择不向③传递更新授权。
|
||||||
- 用户点「是/确认」才开始批量更新;点「否/取消」不执行、不改库。
|
- ③ 提供「检查本轮更新」按钮:只读取当前筛选结果和写运行日志,不打开 Shopee、不提交、不改任务状态;检查汇总展示总数、店铺分布、每批最大条数、预计批次数、更新内容和略过原因。
|
||||||
- 真实更新前必须做账号就绪预检:按当前筛选结果汇总需要的账号;无账号、账号 Chrome 未启动、CDP 端口不可访问、未登录或本轮账号端口冲突时,整体返回 `blocked` 并由 GUI 弹窗列出账号/原因、引导去④账号管理。预检不通过时不创建商品编辑页、不调用 `editor.apply_task()`、不写失败状态、不自动调用「启动登录」或静默打开 Chrome。
|
- 弹确认前先读取 `data/config.json` 的 `shopee_update` 执行参数。普通正式更新不再检查 `test_item_id` 或旧真实提交开关,当前筛选结果可以包含多个真实商品 ID。`max_items_per_run` 作为每批最大任务数,当前筛选总数超过该值时自动分批,不再按总数阻断。
|
||||||
- 对确认后的**已生成(generated)任务**:`open_product` → `change_title(new_title)`(如有)→ `replace_cover(new_cover_path)`(如有)→ `click_update` 提交。标题-only 任务没有 `new_cover_path` 时跳过封面更新,也不需要开启 ⑤「允许更新封面」。
|
- 用户在③确认弹窗点「是/确认」才开始批量更新;点「否/取消」不执行、不改库。
|
||||||
|
- 真实更新前必须做账号就绪预检:按当前筛选结果汇总需要的账号;无账号、账号 Chrome 未启动、CDP 端口不可访问、未登录或本轮账号端口冲突时,整体返回 `blocked` 并由 GUI 弹窗列出账号/原因、引导去账号管理。预检不通过时不创建商品编辑页、不调用 `editor.apply_task()`、不写失败状态、不自动调用「启动登录」或静默打开 Chrome。
|
||||||
|
- 对确认后的**已生成(generated)任务**:按③「更新内容」模式执行 `open_product` → `change_title(new_title)`(选择标题时)→ `replace_cover(new_cover_path)`(选择封面时)→ `click_update` 提交。选择 `只更新标题` 时即使任务有 `new_cover_path` 也不会替换封面;选择 `只更新封面` 时即使任务有 `new_title` 也不会改标题。
|
||||||
- `open_product` 打开商品详情页失败时,要把页面 toast 中的错误原因上浮到③运行日志和任务失败原因;商品 ID 失效、无权限或店铺不匹配时应能看到 Shopee 原始提示,而不是只看到等待详情页超时;如果此时 tab 是本轮自动新建的,`open_product` 要负责关闭该失败 tab。
|
- `open_product` 打开商品详情页失败时,要把页面 toast 中的错误原因上浮到③运行日志和任务失败原因;商品 ID 失效、无权限或店铺不匹配时应能看到 Shopee 原始提示,而不是只看到等待详情页超时;如果此时 tab 是本轮自动新建的,`open_product` 要负责关闭该失败 tab。
|
||||||
|
|
||||||
- `click_update` 点击页面「更新」后必须处理 Shopee 站点侧二次确认框。2026-06-29 真实测试商品实测:页面会出现 `.eds-modal__content` / `.eds-modal__box`,标题为 `確定您要更新商品嗎?`,正文提示建议优化,底部两个按钮分别是 `立即優化` 与主按钮 `更新`。实现时只允许在标题匹配该确认框、且按钮位于可见 modal footer 内时点击 `button.eds-button--primary` / 文案 `更新`;不得点击 `立即優化`。如果弹窗出现但未成功点击主按钮,当前任务必须视为未提交失败,不得写 `committed=1`。
|
- `click_update` 点击页面「更新」后必须处理 Shopee 站点侧二次确认框。2026-06-29 真实测试商品实测:页面会出现 `.eds-modal__content` / `.eds-modal__box`,标题为 `確定您要更新商品嗎?`,正文提示建议优化,底部两个按钮分别是 `立即優化` 与主按钮 `更新`。实现时只允许在标题匹配该确认框、且按钮位于可见 modal footer 内时点击 `button.eds-button--primary` / 文案 `更新`;不得点击 `立即優化`。如果弹窗出现但未成功点击主按钮,当前任务必须视为未提交失败,不得写 `committed=1`。
|
||||||
- 更新封面时,`replace_cover()` 必须按“替换第一张”语义执行:无论当前商品图片是 8 张还是 9 张,只要本次有新封面,就先确认该任务已有本地旧封面备份(`old_cover_path` 非空且文件存在),再删除当前线上第一张图、等待图片管理器稳定、上传新图并拖到第一位。缺失备份时不删除线上第一张图,直接返回明确错误,要求先回到①采集旧封面或修复本地备份。
|
- 更新封面时,`replace_cover()` 必须按“替换第一张”语义执行:无论当前商品图片是 8 张还是 9 张,只要本次有新封面,就先确认该任务已有本地旧封面备份(`old_cover_path` 非空且文件存在),再删除当前线上第一张图、等待图片管理器稳定、上传新图并拖到第一位。缺失备份时不删除线上第一张图,直接返回明确错误,要求先回到①采集旧封面或修复本地备份。
|
||||||
- 默认串行、单条失败继续;⑤ 开启 `parallel_accounts` 后按账号分组并行,不同账号可同时跑,同一账号内仍串行。真实更新前检查本轮账号 `debug_port`,端口冲突直接阻断。
|
- 默认串行、单条失败继续;设置「同时更新蝦皮账号」设为 `1` 时逐个账号执行,设为 `2..5` 时按账号分组并行,不同账号可同时跑,同一账号内仍串行。真实更新前检查本轮账号 `debug_port`,端口冲突直接阻断。
|
||||||
- 「检查本轮更新」只写 `run_logs/run_log_events` 和弹窗/状态栏检查汇总,不调用 `editor.apply_task()`,不做账号登录预检,不写任务状态,不回写 Excel。
|
- 「检查本轮更新」只写 `run_logs/run_log_events` 和弹窗/状态栏检查汇总,不调用 `editor.apply_task()`,不做账号登录预检,不写任务状态,不回写 Excel。
|
||||||
- 真实更新按每批最大条数分批执行,每条立即写 SQLite;全部完成回写 Excel(新字段+状态)+ 弹窗汇总。真实更新与检查都写运行日志,日志 payload 走脱敏工具。
|
- 真实更新按每批最大条数分批执行,每条立即写 SQLite;全部完成回写 Excel(新字段+状态)+ 弹窗汇总。真实更新与检查都写运行日志,日志 payload 走脱敏工具。
|
||||||
- 分批更新停止语义为协作式停止:点击停止后设置取消标记;当前正在执行的商品跑到安全边界后写库结束,不再开始新商品,也不进入下一批。未开始任务保持原状态,后续可继续。
|
- 分批更新停止语义为协作式停止:点击停止后设置取消标记;当前正在执行的商品跑到安全边界后写库结束,不再开始新商品,也不进入下一批。未开始任务保持原状态,后续可继续。
|
||||||
- T-404a 已在③提供「重置更新状态」:仅当前选中单条,保留 `new_title/new_cover_path`,本地退回 `stage=generated/status=pending` 以便重复测试上传/提交;若 `committed=1`,必须提示线上已提交过、本地重置不回滚蝦皮、重复更新会再次提交,并保留 committed 历史事实/运行日志。
|
- T-404a 已在③提供「重置更新状态」:仅当前选中单条,保留 `new_title/new_cover_path`,本地退回 `stage=generated/status=pending` 以便重复测试上传/提交;若 `committed=1`,必须提示线上已提交过、本地重置不回滚蝦皮、重复更新会再次提交,并保留 committed 历史事实/运行日志。
|
||||||
- 若 `close_success_tab=true`,且商品页是本轮程序自动新建、并已成功提交,则提交后关闭该商品编辑页;若确认后已跳回商品列表页,关闭前等待 2 秒;进入编辑页后的失败任务和复用的用户已有 tab 不关闭;`open_product` 内部打开失败的新建 tab 要关闭。
|
- 若商品页是本轮程序自动新建,`apply_task()` 结束时成功/失败都关闭该商品编辑页;成功提交后关闭前等待 2 秒,便于 Shopee 成功状态渲染。复用用户已有 tab 时只断开 CDP,不关闭页面。`open_product` 内部打开失败的新建 tab 仍由 `open_product` 自行关闭。
|
||||||
|
- ③人工查看采用独立 `open_or_focus_product_tab()`,不能复用 `open_product()`:按账号地区域名和 `/portal/product/<商品ID>` 精确查找 target,命中时仅 `Page.bringToFront`,不得 `Page.navigate` 或等待重载;缺失时才新建前台 target、安装 toast 监听并按商品页就绪规则等待。该入口只在后台 Worker 中执行,先确认账号存在且 Chrome/CDP 已运行,不自动启动/登录;完成只关闭 CDP WebSocket,不关闭成功页或复用页,不读写 SQLite、Excel、图片、AI 或线上商品。新建页失败仅关闭该自动 target。
|
||||||
|
|
||||||
### 6.4 登录检测
|
### 6.4 登录检测
|
||||||
|
|
||||||
- 无 Shopee tab 时打开卖家中心根地址 `https://<region_host>/`(默认 `https://seller.shopee.tw/`),重定向到登录页或缺会话 Cookie(`SPC_ST`/`SPC_U`)→ 未登录;不自动登录,提示人工登录。
|
- 无 Shopee tab 时打开卖家中心根地址 `https://<region_host>/`(默认 `https://seller.shopee.tw/`);不自动登录。重定向到登录页返回明确 `LOGIN_PAGE`;页面 target 可用且 Cookie API 至少成功一次、但确实缺少 `SPC_ST`/`SPC_U` 时返回 `NO_SESSION_COOKIE`;websocket/target 正在关闭、整个检测周期一次 Cookie 都未成功读取时返回 `LOGIN_CHECK_TARGET_UNAVAILABLE`,界面显示“登录状态暂不可确认”,不得误报账号退出登录。
|
||||||
- ④「启动登录」只负责准备该账号独立 user-data-dir + CDP 端口的 Chrome,供用户人工登录;该入口必须幂等:若端口已响应,复用现有账号 Chrome 并打开/激活卖家中心登录 tab,不再新开 Chrome;若端口未响应,才启动 Chrome。「检测登录」只验证当前 Chrome/CDP/会话 Cookie 是否可用。①/③ 的预检只检测,不自动启动缺失浏览器。若商品详情页或卖家中心重定向到 `accounts.shopee.tw/seller/login`,必须按登录页处理,返回 `LOGIN_PAGE` 并在 GUI 显示未登录。
|
- 多个 Shopee page target 并存时优先稳定的卖家中心 portal 页面,再尝试商品页,显式登录页最后判定;单个 target 连接或 Cookie 读取失败后立即重枚举/改连其他未尝试 target,不等待完整 8 秒才交给外层重试。①采集的 retry/recovered 日志必须带当前任务 ID、商品 ID;关闭上一条商品页的 target 仍短暂出现在 `/json` 时,不得把上一条 URL 误记为下一条账号掉线。
|
||||||
|
- 账号管理中的「启动登录」只负责准备该账号独立 user-data-dir + CDP 端口的 Chrome,供用户人工登录;该入口必须幂等:若端口已响应,复用现有账号 Chrome 并打开/激活卖家中心登录 tab,不再新开 Chrome;若端口未响应,才用卖家中心初始 URL 启动 Chrome,并优先复用本轮初始 page target,避免空白窗口后再建第二页。返回结果用 `created_tab/startup_target_reused/startup_page_navigated/page_target_count/fallback_reason` 说明实际路径,`chrome_launch` 日志同步记录且不得包含密码、Cookie 或 token。「检测登录」只验证当前 Chrome/CDP/会话 Cookie 是否可用。① 采集的预检会复用同一幂等启动能力,自动确保本轮匹配账号 Chrome 就绪但不自动登录;③ 更新的预检只检测,不自动启动缺失浏览器。若商品详情页或卖家中心重定向到 `accounts.shopee.tw/seller/login`,必须按登录页处理,返回 `LOGIN_PAGE` 并在 GUI 显示未登录。
|
||||||
|
- T-585 后新配置的 `chrome_path` 默认留空。程序在数据目录就绪、强制升级检查通过后、创建主窗口前,仅当当前路径为空或不可启动时,按当前用户/本机 Windows App Paths(含 64/32 位视图)、标准 Chrome 目录和 `PATH` 自动寻找 `chrome.exe`;命中才持久化归一化绝对路径,并在状态栏显示“已自动定位 Chrome”。有效的自定义/便携版路径和可从 PATH 启动的 `chrome.exe` 不覆盖;不扫描整盘、不检测 Edge 或 Chromium。设置的“自动检测”只填入输入框并标记未保存,仍由用户点“保存设置”确认写入。
|
||||||
|
|
||||||
## 七、CDP 已验证事实(务必遵守)
|
## 七、CDP 已验证事实(务必遵守)
|
||||||
|
|
||||||
@@ -451,30 +530,34 @@ data/images/<batch_id>/<slug>/<task_id>_<item_id>_new.<ext> # AI 生成的新
|
|||||||
| Chrome 启动参数 | 全关后带 `--remote-debugging-port=<port> --remote-allow-origins=* --user-data-dir=<dir>`;缺 allow-origins 则 WebSocket 403 |
|
| Chrome 启动参数 | 全关后带 `--remote-debugging-port=<port> --remote-allow-origins=* --user-data-dir=<dir>`;缺 allow-origins 则 WebSocket 403 |
|
||||||
| 代理干扰 | 清除 `*_proxy`(requests `trust_env=False`),否则连本地 CDP 超时 |
|
| 代理干扰 | 清除 `*_proxy`(requests `trust_env=False`),否则连本地 CDP 超时 |
|
||||||
| WebSocket Origin | `websocket-client` `suppress_origin=True` |
|
| WebSocket Origin | `websocket-client` `suppress_origin=True` |
|
||||||
| 关闭连接 vs 关闭 tab | `CDP.close()` 只关闭 WebSocket;需要关闭浏览器页面时必须调用浏览器 target 关闭接口。采集只关闭本轮自动新建的商品页,复用的用户已有 tab 不关闭;③ 仅在设置 `close_success_tab=true`、成功提交、且 tab 为本轮自动新建时关闭;确认成功跳回商品列表页时,关闭前等待 2 秒 |
|
| 关闭连接 vs 关闭 tab | `CDP.close()` 只关闭 WebSocket;需要关闭浏览器页面时必须调用浏览器 target 关闭接口。①采集和商品套图只读商品页关闭本轮自动新建 target 后,最多等待 2 秒确认 target 从 `/json` 消失;超时只记诊断,不覆盖成功结果,复用的用户已有 tab 不关闭。③ 更新时程序自动新建的商品页成功/失败都关闭,成功提交且确认跳回商品列表页时关闭前等待 2 秒;③ 复用用户已有商品页时不关闭页面 |
|
||||||
| SPA 就绪 | 不用 load 事件;轮询“标题输入框 + 图片 itembox + 上传输入框”三者都在 |
|
| 登录 target 竞态 | `/json/close/<target_id>` 返回成功不代表 target 已立即从 `/json` 消失。登录检测不得固定使用枚举到的第一个 Shopee page;连接或 Cookie API 因 target 销毁失败时应快速重选有效页面。只有 Cookie API 成功返回且确实缺会话 Cookie 才是 `NO_SESSION_COOKIE`;一次都未成功读取是 `LOGIN_CHECK_TARGET_UNAVAILABLE` |
|
||||||
| 商品页错误 toast | Shopee 错误提示使用 `.eds-toasts` / `.eds-toast__content`,可能很快隐藏或 `display:none`。打开商品页/等待 SPA 就绪前应注入 `MutationObserver` 或等价监听,把 toast 文本、`outerHTML`、当前 URL、时间、可见状态保存到页面缓存(如 `window.__cmshopee_toasts`);等待详情页关键元素超时时,再兜底读取当前 DOM 中的 toast。最近错误 toast 应优先成为 `open_product` 失败原因,并写入 DB 运行日志和本地脱敏诊断日志。只有明确商品失效/不存在/无权限类 toast 才驱动①阶段列显示“商品失效”;网络、CDP、未登录、页面超时、风控等其他失败仍显示“失败” |
|
| 前台激活 | ①采集和商品套图只读打开商品页时不主动 `Page.bringToFront`;新建 tab 尝试 `Target.createTarget(background=true)`,不支持时退回普通新建。③更新真实提交每条任务都以前台方式新建或激活商品 tab,并执行 `Page.bringToFront`,保障上传、图片管理器刷新和拖拽排序稳定;③左键双击人工查看也以前台聚焦为目标,但命中已有精确商品页时仅聚焦、绝不导航或刷新。后台态封面恢复逻辑仅保留给兼容直接调用,不作为正常③批量路径 |
|
||||||
|
| SPA 就绪 | 不用 load 事件;轮询“唯一商品名称输入框 + 至少一张主图 itembox + 唯一主图上传输入框”三者都在。脚本返回标题命中数、主图/上传入口状态和当前 URL 的就绪快照;超时错误必须指出具体缺失组件,不能只报泛化超时 |
|
||||||
|
| 商品页错误 toast | Shopee 错误提示使用 `.eds-toasts` / `.eds-toast__content`,可能很快隐藏或 `display:none`。打开商品页/等待 SPA 就绪前应注入 `MutationObserver` 或等价监听,把 toast 文本、`outerHTML`、当前 URL、时间、可见状态保存到页面缓存(如 `window.__cmshopee_toasts`);等待详情页关键元素超时时,再兜底读取当前 DOM 中的 toast。明确商品失效/不存在/无权限类 toast 即使已经隐藏,也优先成为 `open_product` 失败原因并驱动①阶段列显示“商品失效”;其他普通 toast 只有仍可见且属于当前页面 URL 时,才以“页面提示(可能无关)”附加在就绪快照后。已隐藏的物流、备货、库存、价格等编辑校验提示不得覆盖真正缺失的就绪组件;网络、CDP、未登录、页面超时、风控等其他失败仍显示“失败” |
|
||||||
|
| 商品状态警示 | 编辑页状态只读取 `.eds-alert.eds-alert--warning` 内的 `.eds-alert-title/.eds-alert-desc`,不得使用 `data-v-*`。`審核中/审核中` → `reviewing`,`您的商品未上架` → `unlisted`;已知促销字段编辑限制不构成商品状态;无状态横幅 → `normal`;其他未识别 warning、DOM 异常或非法响应 → `unknown`。多个横幅仍优先取审核中/未上架;只保存归一化文本摘要,不保存整段 HTML |
|
||||||
|
|
||||||
| 标题输入框 | XPath `//input[@class='eds-input__input' and string-length(@modelvalue)>24]` |
|
| 标题输入框 | 主定位为 `data-product-edit-field-unique-id="name"` 业务字段内唯一可见 `input.eds-input__input`,不再用标题字符数判断身份;仅当该业务字段根不存在时,才回退旧 XPath `//input[@class='eds-input__input' and string-length(@modelvalue)>24]`。主定位命中多个时明确失败,不猜测写入 |
|
||||||
| 写标题 | 原生 setter + 派发 `input`/`change`;`value`==`modelvalue`==新值 |
|
| 写标题 | 原生 setter + 派发 `input`/`change`;`value`==`modelvalue`==新值 |
|
||||||
| 读旧封面 | 第一张 itembox 的 `img.src`(`susercontent` CDN),下载到本地 |
|
| 主图范围与读旧封面 | 主定位为 `data-product-edit-field-unique-id="images"` 内的 `.shopee-image-manager`,其中可拖拽且 `data-draggable=true` 的第一张 itembox `img.src` 是旧封面(`susercontent` CDN);只有业务字段根不存在时才回退旧全局图片管理器/itembox 定位 |
|
||||||
| 上传输入框 | `.shopee-image-manager__upload input[type=file]`;上传前先点击 `.shopee-image-manager__upload` 上传块以模拟人工选择图片入口,短暂等待后重新获取 input,再用 `DOM.setFileInputFiles` 传 Windows 路径并派发 `input`/`change` |
|
| 商品套图原主图读取 | 复用 `open_product(..., bring_to_front=False)` 后台只读打开商品详情页,使用已验证 itembox 顺序读取全部主图 `img.src` 并返回 `{index, src}`;不要求上传 input 之外的新选择器、不改标题/封面、不拖拽、不点击更新;URL 读取完成后再由最多2个图片下载 worker 落盘;本轮自动新建 tab 按采集规则关闭,复用用户已有 tab 不关闭 |
|
||||||
| 上传成功 | 上传前先等图片管理器稳定。注意分两种状态:未满 9 张时,上传前要求图片 src 连续稳定、无 loading/blob、上传 input 存在且未禁用;满 9 张时,删除第一张之前只要求现有图片列表稳定,不得要求上传 input 可用,因为 Shopee 可能因满格隐藏/禁用上传入口;删除成功后再要求上传 input 恢复可用。上传后等新图 src 为 `susercontent`。若手动上传成功但自动上传一直转圈,优先检查是否绕过了上传块点击导致 Shopee 前端上传队列未完整初始化;代码应走“点击上传块 → 等待 → 重新取 input → `DOM.setFileInputFiles`”的人工等价路径。T-404 补丁后超时失败会返回 `upload_state`,区分仍在转圈(`UPLOAD_STILL_PROCESSING`)、图片上传错误(`UPLOAD_PAGE_ERROR`)、裁剪弹窗(`UPLOAD_CROP_REQUIRED`)和上传入口未恢复(`UPLOAD_INPUT_NOT_READY`);上传阶段只能把图片管理器内错误或图片/文件/上传相关 toast 归为封面上传错误,物流/备货等页面级校验错误不能阻断封面上传,应留到点击「更新」提交阶段处理;`有1張重複的圖片` / `重複` / `重复` / `duplicate` 属于封面上传错误,必须立即失败并提示新封面与现有商品图片重复 |
|
| 上传输入框 | 与主图读取共用 `images` 业务字段内同一个 `.shopee-image-manager`,从中取唯一 `.shopee-image-manager__upload input[type=file]`;上传前先点击同一 manager 内 `.shopee-image-manager__upload` 上传块以模拟人工选择图片入口,短暂等待后重新获取 input,再用 `DOM.setFileInputFiles` 传 Windows 路径并派发 `input`/`change` |
|
||||||
|
| 上传成功 | 上传前先等图片管理器稳定。注意分两种状态:未满 9 张时,上传前要求图片 src 连续稳定、无 loading/blob、上传 input 存在且未禁用;满 9 张时,删除第一张之前只要求现有图片列表稳定,不得要求上传 input 可用,因为 Shopee 可能因满格隐藏/禁用上传入口;删除成功后再要求上传 input 恢复可用。上传后等新图 src 为 `susercontent`。若手动上传成功但自动上传一直转圈,优先检查是否绕过了上传块点击导致 Shopee 前端上传队列未完整初始化;代码应走“点击上传块 → 等待 → 重新取 input → `DOM.setFileInputFiles`”的人工等价路径。T-404 补丁后超时失败会返回 `upload_state`,区分仍在转圈(`UPLOAD_STILL_PROCESSING`)、图片上传错误(`UPLOAD_PAGE_ERROR`)、裁剪弹窗(`UPLOAD_CROP_REQUIRED`)和上传入口未恢复(`UPLOAD_INPUT_NOT_READY`);上传阶段只能把图片管理器内错误或明确包含图片、封面、上传、文件、重复等语义的 toast 归为封面上传错误,`超過/超过`、格式、大小、尺寸等通用限定词不能单独构成上传语义;物流/备货等页面级校验错误不能阻断封面上传,应保留在 `page_error_toasts` 并留到点击「更新」提交阶段处理;`圖片大小超過限制`、`有1張重複的圖片` / `重複` / `重复` / `duplicate` 属于封面上传错误,必须立即失败并提示对应原因 |
|
||||||
| 封面=第一位 | `Input.dispatchMouseEvent` 拖到第一位,落点 `第一张.left - 0.30*宽` |
|
| 封面=第一位 | `Input.dispatchMouseEvent` 拖到第一位,落点 `第一张.left - 0.30*宽` |
|
||||||
| 换封面删除 | 更新封面统一先删当前第一张,不再只限满 9 张;先确认本地旧封面备份存在,再点第一张删除(`.shopee-image-manager__icon--delete` 或同类 delete 标记)并在可见 dialog/modal/popover 内点删除/确认按钮。关键顺序:删除前只等当前图片列表稳定,不检查上传 input;删除后不能只看数量减少,必须等图片管理器达到删除后数量、无 busy/blob、上传 input 恢复并短暂稳定,再重新获取 input 上传新图、确认取得 Shopee CDN 地址后拖到第一位;备份缺失则拒绝删除。已在 9 图测试商品 `29671243750` 上实测不提交流程,8 图商品也按同一替换语义删除第一张后再上传 |
|
| 换封面删除 | 更新封面统一先删当前第一张,不再只限满 9 张;先确认本地旧封面备份存在,再点第一张删除(`.shopee-image-manager__icon--delete` 或同类 delete 标记)并在可见 dialog/modal/popover 内点删除/确认按钮。关键顺序:删除前只等当前图片列表稳定,不检查上传 input;删除后不能只看数量减少,必须等图片管理器达到删除后数量、无 busy/blob、上传 input 恢复并短暂稳定,再重新获取 input 上传新图、确认取得 Shopee CDN 地址后拖到第一位;备份缺失则拒绝删除。已在 9 图测试商品 `29671243750` 上实测不提交流程,8 图商品也按同一替换语义删除第一张后再上传 |
|
||||||
| 更新按钮 | 页面主更新按钮为 `button.eds-button` 中 `<span>更新</span>`;③ 批量确认后逐条点提交;禁用态(校验未过)记为失败。2026-06-29 实测点击后会弹 Shopee 站点侧确认框:可见 `.eds-modal__content` / `.eds-modal__box`,标题 `確定您要更新商品嗎?`,footer 中 `立即優化` 为次按钮,`更新` 为 `eds-button--primary` 主按钮;代码必须点击确认框内主按钮 `更新` 才算提交,不点 `立即優化`。2026-06-30 实测确认成功后会跳回 `https://seller.shopee.tw/portal/product/list/all?operationSortBy=modified_time` 商品列表页,代码需记录 `post_update.url/redirected_to_list` 作为提交后观测结果;若随后要关闭本轮自动新开 tab,必须先暂停 2 秒再关闭。判断优先级:跳转到 `/portal/product/list/` 是强成功信号,应优先于残留/短暂 error toast;只有在未跳转列表页、无成功 toast,且错误 toast 持续存在时,才判 `POST_UPDATE_ERROR`。未处理确认框时不得认为已提交 |
|
| 更新按钮 | 页面主更新按钮为 `button.eds-button` 中 `<span>更新</span>`;③ 批量确认后逐条点提交;禁用态(校验未过)记为失败。2026-06-29 实测点击后会弹 Shopee 站点侧确认框:可见 `.eds-modal__content` / `.eds-modal__box`,标题 `確定您要更新商品嗎?`,footer 中 `立即優化` 为次按钮,`更新` 为 `eds-button--primary` 主按钮;代码必须点击确认框内主按钮 `更新` 才算提交,不点 `立即優化`。2026-06-30 实测确认成功后会跳回 `https://seller.shopee.tw/portal/product/list/all?operationSortBy=modified_time` 商品列表页,代码需记录 `post_update.url/redirected_to_list` 作为提交后观测结果;若随后要关闭本轮自动新开 tab,必须先暂停 2 秒再关闭。判断优先级:跳转到 `/portal/product/list/` 是强成功信号,应优先于残留/短暂 error toast;只有在未跳转列表页、无成功 toast,且错误 toast 持续存在时,才判 `POST_UPDATE_ERROR`。未处理确认框时不得认为已提交 |
|
||||||
| 登录检测 | 重定向到登录页(含 `accounts.shopee.tw/seller/login`)或缺 `SPC_ST`/`SPC_U` → 未登录 |
|
| 登录检测 | 重定向到登录页(含 `accounts.shopee.tw/seller/login`)或缺 `SPC_ST`/`SPC_U` → 未登录 |
|
||||||
|
|
||||||
高风险动作(删除线上封面、点更新提交、AI 图上线)先在测试商品验证。删除线上第一张封面前必须已有本地旧封面备份,不能在备份缺失时盲删线上图片。注意:本设计无逐条人工审核阶段、无常驻提交开关;③ 点击「开始更新」后必须先通过 `shopee_update` 安全开关,再弹窗确认,确认后才把当前筛选结果中的 AI 标题/封面提交线上。新图本地留档+回写 Excel 是事后追溯手段。
|
高风险动作(删除线上封面、点更新提交、AI 图上线)先在测试商品验证。删除线上第一张封面前必须已有本地旧封面备份,不能在备份缺失时盲删线上图片。注意:本设计无逐条人工审核阶段、无常驻提交开关;③ 点击「开始更新」后必须弹窗确认,确认后才把当前筛选结果中的 AI 标题/封面提交线上。新图本地留档+回写 Excel 是事后追溯手段。
|
||||||
|
|
||||||
## 八、推荐开发顺序
|
## 八、推荐开发顺序
|
||||||
|
|
||||||
1. **地基**:先建 `app/` 包、`app/cdp.py`、`app/__main__.py`、根目录 `main.py`;再做 `app/editor.py`(含采集)、`app/appconfig.py`+`data/config.json`、`app/db.py`、`.gitignore`。
|
1. **地基**:先建 `app/` 包、`app/cdp.py`、`app/__main__.py`、根目录 `main.py`;再做 `app/editor.py`(含采集)、`app/appconfig.py`+`data/config.json`、`app/db.py`、`.gitignore`。
|
||||||
2. **账号与启动**(④):`config` 建目录、`chrome` 启动器/快捷方式、登录保活与检测、账号 CRUD。
|
2. **账号与启动**(账号管理):`config` 建目录、`chrome` 启动器/快捷方式、登录保活与检测、账号 CRUD。
|
||||||
3. **导入采集**(①):`excel` 导入、采集旧标题/旧封面、回写。
|
3. **导入采集**(①):`excel` 导入、采集旧标题/旧封面、回写。
|
||||||
4. **AI 生成**(②):`ai` 模块、提示词、对照预览。
|
4. **AI 生成**(②):`ai` 模块、提示词、对照预览。
|
||||||
5. **更新蝦皮**(③):对已生成任务弹窗批量确认后换标题+封面、提交、回写。
|
5. **更新蝦皮**(③):对已生成任务弹窗批量确认后换标题+封面、提交、回写。
|
||||||
6. **设置**(⑤)+ 首次未配账号引导保护。
|
6. **设置** + 首次未配账号引导保护。
|
||||||
|
|
||||||
## 九、项目结构建议
|
## 九、项目结构建议
|
||||||
|
|
||||||
@@ -487,6 +570,11 @@ cmshopee/
|
|||||||
│ ├── cdp.py # CDP 底座(由根目录 cdp.py 迁入)
|
│ ├── cdp.py # CDP 底座(由根目录 cdp.py 迁入)
|
||||||
│ ├── appconfig.py / db.py / excel.py / config.py / accounts.py / chrome.py
|
│ ├── appconfig.py / db.py / excel.py / config.py / accounts.py / chrome.py
|
||||||
│ ├── editor.py / ai.py / prompts.py / gui.py / workers.py
|
│ ├── editor.py / ai.py / prompts.py / gui.py / workers.py
|
||||||
|
│ ├── release_manifest.py # 发布包文件清单、zip哈希与服务端元数据模板
|
||||||
|
│ ├── update_installer.py # 自动升级安全下载、解压、manifest校验与同盘暂存
|
||||||
|
│ ├── updater_entry.py # 独立更新器入口、事务根项目切换、journal与回滚
|
||||||
|
│ ├── gui/update_dialog.py # 强制升级模态进度、下载worker与重启编排
|
||||||
|
│ ├── update_health.py # 新版启动健康标记与失败版本熔断
|
||||||
├── main.py # GUI 启动入口:from app.gui import main
|
├── main.py # GUI 启动入口:from app.gui import main
|
||||||
├── shopee待处理任务模板.xlsx # 标准空 Excel 模板,可提交;业务填写后的副本不提交
|
├── shopee待处理任务模板.xlsx # 标准空 Excel 模板,可提交;业务填写后的副本不提交
|
||||||
├── data/ # 用户本地数据根(整体 gitignore;打包更新时保留)
|
├── data/ # 用户本地数据根(整体 gitignore;打包更新时保留)
|
||||||
@@ -496,8 +584,11 @@ cmshopee/
|
|||||||
│ ├── cmshopee.db # SQLite(账号/任务/结果)
|
│ ├── cmshopee.db # SQLite(账号/任务/结果)
|
||||||
│ ├── chrome_user_data_dir/ # 各账号 Chrome 配置(含登录态)
|
│ ├── chrome_user_data_dir/ # 各账号 Chrome 配置(含登录态)
|
||||||
│ ├── images/ # 旧封面/新封面本地图片
|
│ ├── images/ # 旧封面/新封面本地图片
|
||||||
│ ├── title_prompt.txt # 标题提示词(单文件,启动回显)
|
│ ├── title_prompt.txt # 标题当前工作文本(启动回显)
|
||||||
│ └── prompts/cover/<名称>.txt
|
│ └── prompts/
|
||||||
|
│ ├── title/<名称>.txt # 标题提示词命名模板
|
||||||
|
│ ├── cover/<名称>.txt # 封面提示词命名模板
|
||||||
|
│ └── image_studio/<名称>.txt # 旧AI工场模板兼容目录,商品套图不依赖
|
||||||
└── prototypes/ # 已验证原型/探查脚本(demo/set_*/get_title/cookies/inspect_images/grab/1.py)
|
└── prototypes/ # 已验证原型/探查脚本(demo/set_*/get_title/cookies/inspect_images/grab/1.py)
|
||||||
# 逻辑待并入 app/editor.py 后清理;见 prototypes/README.md
|
# 逻辑待并入 app/editor.py 后清理;见 prototypes/README.md
|
||||||
```
|
```
|
||||||
@@ -506,6 +597,18 @@ cmshopee/
|
|||||||
|
|
||||||
## 十、架构纪律
|
## 十、架构纪律
|
||||||
|
|
||||||
|
### 10.1 发布与自动升级边界
|
||||||
|
|
||||||
|
- `app/version.py` 是应用版本唯一来源;`app/release_manifest.py` 生成 `package-manifest.json` 和 `release-metadata<APP_VERSION>.json`,构建脚本不得自行维护另一套 hash 或路径规则。版本化元数据与对应 zip 一一匹配,同版本可覆盖、其他历史版本保留,旧无版本文件在构建时清理。
|
||||||
|
- 发布包格式固定为 `cmshopee-portable-v1`,入口为 `cmshopee.exe`,程序依赖集中在 `_internal/`。manifest 覆盖除自身外的所有程序文件,并记录规范化相对路径、字节数和 SHA-256。
|
||||||
|
- 发布包只能包含程序根项目,`data/` 和 `.cmshopee-update/` 永远在替换边界之外。第一阶段预留签名字段,但 SHA-256 只负责传输完整性,不等同于发布者身份认证。
|
||||||
|
- T-615 只提供可验证发布契约;启动门禁仍保持 T-544 的人工下载行为,直到后续下载、独立更新器、事务替换和失败熔断任务全部接入。
|
||||||
|
- T-616 的下载暂存根固定为安装目录下 `.cmshopee-update/`,与 `data/` 完全隔离。T-623 为兼容对象存储/CDN外链,临时允许任意公网 HTTP/HTTPS 域名或公网 IP 及跨域、跨协议重定向,但版本接口继续使用受信任 HTTPS;初始地址、每次跳转和最终地址均拒绝本机、内网及非 HTTP(S) 目标。远程zip必须通过声明大小、整包SHA-256、安全zip路径和包内manifest逐文件校验,才写 `pending.json`;HTTP 不降低任何安装校验,也不替换运行中程序文件。
|
||||||
|
- T-617 的独立 `cmshopee-updater.exe` 必须先复制到系统临时目录运行,并等待主程序退出。替换粒度是manifest允许的程序根项目,旧根先整体移动到同盘backup,新根再整体移入;事务锁防止并发更新,journal记录每次移动,任一步失败逆序恢复。`data/`、更新管理目录与未知安装根项目永不进入替换清单。
|
||||||
|
- T-618 在创建 `MainWindow` 前显示强制升级 `QDialog`;下载与校验只能在 `QObject + QThread` worker中执行,线程结束前保留引用,取消时等待part清理。独立更新器进程成功创建后才退出旧主程序;强制版本后续失败保持阻断,只有版本接口本身不可达/非法继续失败放行。
|
||||||
|
- T-619 要求新版按事务写 `process_started`、`main_window_ready`、`environment_blocked` 健康标记。更新器只在主窗口就绪后认定升级成功并清理成功备份;无标记/早期退出/超时则事务回滚并按目标版本+包hash熔断。环境阻断保留新版,不把本地数据目录、配置或Chrome问题误判成坏发布包。
|
||||||
|
- T-624 以 cmhub 实际响应作为版本接口边界:服务端只需提供版本、下载地址、zip SHA-256、准确大小、强制策略、发布说明和发布时间;包格式与更新器协议由客户端内置,并在下载解压后通过包内manifest验证,不要求版本接口重复返回。
|
||||||
|
|
||||||
- CDP 交互事实变化同步第七节。
|
- CDP 交互事实变化同步第七节。
|
||||||
- 正式代码只放 `app/` 包;根目录只保留 `main.py`、配置/数据目录、文档和原型目录,不新增正式业务模块。
|
- 正式代码只放 `app/` 包;根目录只保留 `main.py`、配置/数据目录、文档和原型目录,不新增正式业务模块。
|
||||||
- `app` 包内模块优先用相对导入(如 `from .cdp import CDP`);根入口 `main.py` 用 `from app.gui import main`。
|
- `app` 包内模块优先用相对导入(如 `from .cdp import CDP`);根入口 `main.py` 用 `from app.gui import main`。
|
||||||
@@ -513,6 +616,6 @@ cmshopee/
|
|||||||
- 别名是账号↔任务唯一关联键。
|
- 别名是账号↔任务唯一关联键。
|
||||||
- 密码与 AI Key 本地明文保存,保存/变更时弹窗提示;UI 打码、不外传、不写日志/导出;不自动登录。
|
- 密码与 AI Key 本地明文保存,保存/变更时弹窗提示;UI 打码、不外传、不写日志/导出;不自动登录。
|
||||||
- AI 生成内容直接进入 ③ 更新候选;③ 批量确认后提交,新图本地留档 + 回写 Excel 以备追溯。
|
- AI 生成内容直接进入 ③ 更新候选;③ 批量确认后提交,新图本地留档 + 回写 Excel 以备追溯。
|
||||||
- 批次删除是软删除:写 `batches.deleted_at/deleted_reason`,不物理删除 `batches/tasks`;默认 `list_batches/list_tasks/get_task` 与 ①/②/③ 页面和执行入口都排除已删除批次。
|
- 批次删除是软删除:写 `batches.deleted_at/deleted_reason`,不物理删除 `batches/tasks`;默认 `list_batches/list_tasks/get_task` 与 ①/②/③ 页面和执行入口都排除已删除批次。T-670 后①还可对单条非运行任务写 `tasks.deleted_at/deleted_reason`;默认 `list_tasks/get_task` 和所有工作流候选同样排除该任务,不删除 Excel、图片或线上商品。
|
||||||
- 高风险模块先单独验证,再接入流水线。
|
- 高风险模块先单独验证,再接入流水线。
|
||||||
- GUI 只通过 signal/slot 接收 worker 进度;禁止后台线程直接操作 Qt widget 或共享 SQLite connection。
|
- GUI 只通过 signal/slot 接收 worker 进度;禁止后台线程直接操作 Qt widget 或共享 SQLite connection。
|
||||||
|
|||||||
+5
-2
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
## 一句话定位
|
## 一句话定位
|
||||||
|
|
||||||
蝦皮圈優化助手(代号 cmshopee)是一个给**电商运营**使用的 Windows PySide6 桌面自动化工具,用于**管理多个 Shopee 卖家账号、并用 CDP 驱动 Chrome 批量修改商品标题、替换商品封面图**。V0 已验证单账号「改标题 + 换封面」闭环;当前 V1 目标是 5 Tab 流水线:导入采集 → AI 生成 → 点击「开始更新」并确认后批量提交 → 回写结果。
|
蝦皮圈優化助手(代号 cmshopee)是一个给**电商运营**使用的 Windows PySide6 桌面自动化工具,用于**管理多个 Shopee 卖家账号、并用 CDP 驱动 Chrome 批量修改商品标题、替换商品封面图**。V0 已验证单账号「改标题 + 换封面」闭环;当前 V1 工作台为 6 Tab:①导入采集 → ②AI生成 → ③点击「开始更新」并确认后批量提交 → 回写结果,④账号管理,⑤设置,⑥商品套图生成。
|
||||||
|
|
||||||
## 文档导航
|
## 文档导航
|
||||||
|
|
||||||
@@ -21,9 +21,12 @@
|
|||||||
- [任务看板(历史归档)](06-tasks.md):T-000~T-549 历史任务,已冻结,不再新增。
|
- [任务看板(历史归档)](06-tasks.md):T-000~T-549 历史任务,已冻结,不再新增。
|
||||||
- [模块 / CLI 合约](api.md):本地模块接口、Chrome 启动参数、账号配置 schema。
|
- [模块 / CLI 合约](api.md):本地模块接口、Chrome 启动参数、账号配置 schema。
|
||||||
- [界面与流程结构](routes.md):GUI 窗口、操作流程、按钮职责(无前端路由,用 GUI 流程替代)。
|
- [界面与流程结构](routes.md):GUI 窗口、操作流程、按钮职责(无前端路由,用 GUI 流程替代)。
|
||||||
|
- [旧 AI工场兼容层端到端验收](ai-studio-e2e-checklist.md):T-622 前图片工作区的自动化覆盖;当前⑥商品套图继续复用其中的数据与 cmhub 服务。
|
||||||
|
- [AI工场托管模型评测与默认档位策略](ai-studio-model-evaluation.md):OpenAI / GPT 托管能力经 cmhub 别名落地前的评测样本、档位策略、上线门槛与运营用法。
|
||||||
|
- [AI工场 BYOK 启动门禁记录](byok-startup-gate.md):自定义模型 / BYOK 开工前的授权、设备凭证、Provider 白名单、task secret 和接口契约检查记录。
|
||||||
- [当前实现状态](current-state.md):当前代码现实、可运行命令、下一步可做任务。
|
- [当前实现状态](current-state.md):当前代码现实、可运行命令、下一步可做任务。
|
||||||
- [常见问题排查](troubleshooting.md):本地配置、启动报错、敏感文件修复等排障记录。
|
- [常见问题排查](troubleshooting.md):本地配置、启动报错、敏感文件修复等排障记录。
|
||||||
- [产品与 UI 评估](ux-review.md):以 PM + UI 设计视角评估 5 Tab 模块 / 组件合理性,含优化方案与优先级清单。
|
- [产品与 UI 评估](ux-review.md):以 PM + UI 设计视角评估主流程模块 / 组件合理性,含优化方案与优先级清单。
|
||||||
- [界面配色设计](ui-color-design.md):语义色板与组件配色映射规范,指导给状态 / 按钮 / 校验 / 登录状态上色。
|
- [界面配色设计](ui-color-design.md):语义色板与组件配色映射规范,指导给状态 / 按钮 / 校验 / 登录状态上色。
|
||||||
- [工程评估](engineering-review.md):全栈视角评估工程基础设施与可维护性(依赖清单 / CI / 打包 / gui.py 拆分 / lint),含 P0-P2 与优先级。
|
- [工程评估](engineering-review.md):全栈视角评估工程基础设施与可维护性(依赖清单 / CI / 打包 / gui.py 拆分 / lint),含 P0-P2 与优先级。
|
||||||
- [打包与分发](packaging.md):PyInstaller 免安装 exe 打包命令、排除本地数据规则和用户手动更新方式。
|
- [打包与分发](packaging.md):PyInstaller 免安装 exe 打包命令、排除本地数据规则和用户手动更新方式。
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
# AI工场端到端验收与发布检查
|
||||||
|
|
||||||
|
> T-622 后主窗口入口已由「⑥商品套图」替换;本文保留为旧 `ImageStudioTab` 与共享 `image_studio_*` 服务的兼容回归清单,不再代表当前第六 Tab 的界面验收。当前商品套图交互以 [`tasks/T-622.md`](tasks/T-622.md) 和 [`routes.md`](routes.md) 为准。
|
||||||
|
|
||||||
|
> T-595 阶段性收口文档。本文只记录 AI工场当前已实现的 cmhub 托管主线,不包含 BYOK、自定义 Provider、自动上传蝦皮或本地图片导入。
|
||||||
|
|
||||||
|
## 已实现闭环
|
||||||
|
|
||||||
|
当前 ⑥「AI工场」支持:
|
||||||
|
|
||||||
|
1. 按账号别名 + 商品 ID 创建/恢复项目。
|
||||||
|
2. 后台只读打开蝦皮商品详情页,读取原主图 URL 并写入照片资产。
|
||||||
|
3. 单击原主图下载到本地 `data/images/pool/<slug>/<item_id>/originals/`,并可作为源图。
|
||||||
|
4. 使用完整提示词和 cmhub 托管生图别名创建 N 个独立 job。
|
||||||
|
5. submit 后立即保存 `task_key/task_id/call_id/扣点/余额`;重启后继续查询时只 poll/download,不重复 submit。
|
||||||
|
6. 成功图片进入照片池;失败/过期/停止任务在照片池任务行独立显示。
|
||||||
|
7. 将照片拖入主图/详情图终选,可插入、重排、移出,同类别去重,跨类别允许复用。
|
||||||
|
8. 允许主图/详情图未满数量时导出当前终选为 JPEG。
|
||||||
|
9. 商品目录已存在时只允许覆盖受管命名文件、新建带时间目录或取消。
|
||||||
|
|
||||||
|
## 自动化覆盖
|
||||||
|
|
||||||
|
自动化不连接真实蝦皮或真实 cmhub,不产生费用。当前覆盖:
|
||||||
|
|
||||||
|
- SQLite:项目、资产、job、终选顺序。
|
||||||
|
- 远程图片:公网 URL、大小/类型/解码校验、原图落盘幂等。
|
||||||
|
- cmhub 托管:N 张独立 job、submit/poll/download、续查、下载失败恢复、单张失败不影响其它任务。
|
||||||
|
- GUI:6 Tab 顺序、AI工场控件、照片池、终选拖放等价操作、导出入口、继续查询入口和计费状态。
|
||||||
|
- 导出:部分导出、JPEG 真转码、透明铺白底、已存在目录三选、受管文件覆盖保护、源文件缺失失败不创建目标。
|
||||||
|
- 端到端 mock:项目 → 原图 URL → 原图下载 → cmhub mock 生图 → 终选 → 导出。
|
||||||
|
|
||||||
|
推荐验证命令:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
python -m ruff check app tests main.py
|
||||||
|
py -3.10 -m compileall app main.py
|
||||||
|
py -3.10 -m unittest discover -s tests
|
||||||
|
git diff --check
|
||||||
|
```
|
||||||
|
|
||||||
|
## 人工真实只读验收
|
||||||
|
|
||||||
|
需要本机存在已登录的测试账号 Chrome、可用 cmhub Key 和允许测试的商品 ID。人工验收时必须遵守:
|
||||||
|
|
||||||
|
- 只读拉取原主图,不点击蝦皮更新,不上传图片,不修改标题/封面。
|
||||||
|
- 不把 API Key、Cookie、账号、真实商品图片或 `data/` 内容提交到仓库。
|
||||||
|
- 真实 cmhub 小样本建议 1~2 张,确认扣点、余额、失败提示和继续查询即可,不做大批量费用测试。
|
||||||
|
|
||||||
|
建议步骤:
|
||||||
|
|
||||||
|
1. ④账号管理确认测试账号已登录。
|
||||||
|
2. ⑥AI工场选择该账号和测试商品 ID,点击「打开项目」。
|
||||||
|
3. 点击「拉取主图」,确认原主图列表出现,浏览器不进入编辑提交动作。
|
||||||
|
4. 单击一张原主图,确认照片池出现本地可用源图。
|
||||||
|
5. 输入短提示词,数量设为 1,点击「开始生成」。
|
||||||
|
6. 生成成功后把图片拖入主图终选,点击「导出终选」到临时目录。
|
||||||
|
7. 关闭软件后重开,确认项目、照片池、job 状态、终选顺序仍保留。
|
||||||
|
8. 若中途停止或下载失败,点击「继续查询任务」,确认不重复 submit。
|
||||||
|
|
||||||
|
当前 T-595 自动化已完成;真实只读验收需要运行环境,不在仓库中伪造结果。
|
||||||
|
|
||||||
|
## 发布检查
|
||||||
|
|
||||||
|
发布前确认:
|
||||||
|
|
||||||
|
- `release/` 或 `dist/` 不包含 `data/`、真实 DB、真实图片、Chrome user-data-dir、日志或 cmhub Key。
|
||||||
|
- ⑤设置页和 ⑥AI工场没有 OpenAI Key、Provider URL、direct backend 或“自定义模型”入口。
|
||||||
|
- ⑥AI工场没有「导入本地图片」按钮或隐藏入口。
|
||||||
|
- 运行日志、状态栏、弹窗不展示 API Key、Authorization、Cookie、完整请求体或 cmhub 内部接口路径。
|
||||||
|
- 导出只写用户选择目录下的图片文件,不导出 prompt、task key、SQLite 数据或日志。
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
# AI工场托管模型评测与默认档位策略
|
||||||
|
|
||||||
|
> 任务来源:T-597。本文是 AI工场从“cmhub 当前默认托管模型”评估切换到 OpenAI / GPT 系列托管能力时的选型说明与评测模板。本文不代表已经执行付费真实样本测试;真实结论必须由运营样本跑数后补充。
|
||||||
|
|
||||||
|
## 资料来源
|
||||||
|
|
||||||
|
- OpenAI Model guidance:`https://developers.openai.com/api/docs/guides/latest-model`
|
||||||
|
- OpenAI Image generation:`https://developers.openai.com/api/docs/guides/image-generation`
|
||||||
|
- 核对日期:2026-07-11
|
||||||
|
|
||||||
|
官方文档当前把 GPT-5.6 家族分成 `gpt-5.6-sol`、`gpt-5.6-terra`、`gpt-5.6-luna`,分别对应旗舰能力、能力与成本平衡、高频低成本任务;`gpt-5.6` alias 指向 `gpt-5.6-sol`。图片方向可参考 GPT Image 能力,尤其 `gpt-image-2`,但落地到本项目时仍由 cmhub 暴露能力别名,客户端不得保存 OpenAI 原始 Key 或把 OpenAI slug 当执行事实。
|
||||||
|
|
||||||
|
## 产品原则
|
||||||
|
|
||||||
|
- 普通用户只看到“cmhub 托管模型 / 默认档 / 高质量档 / 省点档 / 扣点成本”,不暴露 Provider URL、OpenAI API Key、上游接口路径或内部请求体。
|
||||||
|
- cmshopee 客户端只保存 cmhub 返回的 `title_alias` / `image_alias`,不把 `gpt-5.6-*`、`gpt-image-*` 写成强执行配置。
|
||||||
|
- 默认档必须以真实商品样本的一次通过率、平均耗时、P95 耗时和扣点成本决定;未跑样本前不得因为模型名更强就替换默认。
|
||||||
|
- 图片生成是高成本能力,推荐默认走“平衡档”,重点商品/失败重试才用“高质量档”,低价值 SKU 初稿才用“省点档”。
|
||||||
|
|
||||||
|
## 推荐档位
|
||||||
|
|
||||||
|
| 档位 | 文本/提示词辅助建议 | 图片生成/编辑建议 | 使用场景 | 默认策略 |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| 默认档 | cmhub 平衡文本别名,后台可映射 `gpt-5.6-terra` 或等价能力 | cmhub 平衡图像别名,后台可映射 `gpt-image-2` 或当前稳定图像能力 | 日常批量主图/详情图候选、提示词改写、标题/卖点辅助 | 真实样本通过率不低于当前默认模型且扣点可接受后,才设为默认 |
|
||||||
|
| 高质量档 | cmhub 高质量文本别名,后台可映射 `gpt-5.6-sol` 或等价能力 | cmhub 高质量图像别名,优先用于重点商品、复杂背景、失败重试 | 高毛利 SKU、广告款、默认档失败后重试 | 不作为全局默认,避免成本失控 |
|
||||||
|
| 省点档 | cmhub 省点文本别名,后台可映射 `gpt-5.6-luna` 或等价能力 | cmhub 省点图像别名,只用于低价值 SKU 初稿或批量探索 | 大批量草稿、运营快速预览 | 不建议直接作为最终主图默认档 |
|
||||||
|
|
||||||
|
## 评测样本集
|
||||||
|
|
||||||
|
真实评测建议建立 `STUDIO-EVAL-YYYYMMDD` 样本包,至少 20~30 个商品,覆盖以下类型。样本文件和真实商品图属于业务数据,不提交 Git。
|
||||||
|
|
||||||
|
| 样本组 | 数量 | 商品类型 | 旧图特征 | 重点观察 |
|
||||||
|
| --- | ---: | --- | --- | --- |
|
||||||
|
| A | 5 | 服饰/配件 | 模特图、材质细节、颜色差异明显 | 主体保真、颜色不漂、服饰结构不乱 |
|
||||||
|
| B | 5 | 生活用品 | 白底主图、多个小件组合 | 主体数量不变、边缘干净、白底合规 |
|
||||||
|
| C | 5 | 美妆/个护 | 包装文字、瓶身反光、细节图 | 不乱写品牌/文字、包装比例稳定 |
|
||||||
|
| D | 5 | 小物/工具 | 尺寸小、部件多、背景杂 | 细节不丢、构图清楚、可商用感 |
|
||||||
|
| E | 5 | 详情图/信息图 | 文字多、说明块多、版式复杂 | 文字处理是否可接受,是否需要转人工 |
|
||||||
|
| F | 5 | 失败回归样本 | 当前模型曾失败或效果差 | 新档位是否显著改善 |
|
||||||
|
|
||||||
|
## 评分口径
|
||||||
|
|
||||||
|
每张候选图按 1~5 分打分,4 分以上才算“一次通过”。标题/提示词辅助按“可直接使用 / 需轻改 / 不可用”记录。
|
||||||
|
|
||||||
|
| 指标 | 说明 | 权重 |
|
||||||
|
| --- | --- | ---: |
|
||||||
|
| 商品主体保真 | 主体形状、数量、颜色、关键细节是否保持 | 25% |
|
||||||
|
| 商用感 | 是否像可上线主图/详情图,光影、背景、清晰度是否专业 | 20% |
|
||||||
|
| 主图合规 | 白底/构图/主体占比/无多余元素是否符合运营要求 | 15% |
|
||||||
|
| 少幻觉 | 不添加不存在的配件、文字、Logo 或错误卖点 | 15% |
|
||||||
|
| 文字稳定 | 包装文字、详情图文字是否可接受;文字多的图允许标记需人工 | 10% |
|
||||||
|
| 耗时 | 平均耗时、P95 耗时,是否影响批量生产节奏 | 10% |
|
||||||
|
| 成本 | 单图扣点、一次通过成本、失败重试成本 | 5% |
|
||||||
|
|
||||||
|
## 上线门槛
|
||||||
|
|
||||||
|
- 默认档候选的一次通过率必须不低于当前 cmhub 默认图像模型;若提升小于 5%,不建议仅因模型名切换默认。
|
||||||
|
- 平均耗时不超过当前默认模型 20%,P95 耗时不超过运营可接受阈值;超出时只放高质量档或重点商品档。
|
||||||
|
- 单图扣点上涨时,必须用“一次通过成本”判断:如果质量提升减少重试,才可接受更高单次成本。
|
||||||
|
- 文本辅助默认优先平衡档;高质量档只用于复杂商品、失败原因分析、重点 SKU 复核。
|
||||||
|
- `gpt-image-2` 或等价高质量图像别名如果文字/包装稳定性仍不满足详情图要求,详情图文字密集场景必须保留人工复核。
|
||||||
|
|
||||||
|
## 当前建议
|
||||||
|
|
||||||
|
在没有真实样本跑数前,AI工场默认仍保持当前 cmhub 托管图像别名,不直接切换。T-598 可以先把 UI 和日志口径收口成“默认 / 高质量 / 省点”档位提示,并继续以 cmhub `/models` 返回的别名、展示名、能力标签和扣点作为真实执行依据。
|
||||||
|
|
||||||
|
建议第一轮真实评测结果这样填写:
|
||||||
|
|
||||||
|
| 日期 | 样本包 | cmhub 别名 | 后台能力 | 一次通过率 | 平均耗时 | P95耗时 | 平均扣点 | 结论 |
|
||||||
|
| --- | --- | --- | --- | ---: | ---: | ---: | ---: | --- |
|
||||||
|
| 待补 | STUDIO-EVAL-YYYYMMDD | 当前默认图像别名 | 当前 cmhub 默认 | 待补 | 待补 | 待补 | 待补 | 对照组 |
|
||||||
|
| 待补 | STUDIO-EVAL-YYYYMMDD | 平衡图像别名 | GPT Image 等价能力 | 待补 | 待补 | 待补 | 待补 | 候选默认档 |
|
||||||
|
| 待补 | STUDIO-EVAL-YYYYMMDD | 高质量图像别名 | GPT Image 高质量能力 | 待补 | 待补 | 待补 | 待补 | 重点商品/重试 |
|
||||||
|
| 待补 | STUDIO-EVAL-YYYYMMDD | 省点图像别名 | 低成本图像能力 | 待补 | 待补 | 待补 | 待补 | 初稿/低价值 SKU |
|
||||||
|
|
||||||
|
## 给运营的用法说明
|
||||||
|
|
||||||
|
- 默认档:日常批量先用它,目标是“成本可控、稳定可用”。
|
||||||
|
- 高质量档:用于重点商品、默认档失败后的第二次生成、复杂背景或需要更强保真的商品。
|
||||||
|
- 省点档:用于低价值 SKU、灵感草稿或大批量初筛;上线前仍要人工看图。
|
||||||
|
- 失败重试是否换档:如果失败原因是主体变形、文字乱、构图不合规,优先换高质量档;如果失败原因是网络、下载、上游任务超时,不应换模型,应继续查询或重试原任务。
|
||||||
|
|
||||||
|
## 客户端落地边界
|
||||||
|
|
||||||
|
- T-598 只做档位展示和日志口径,不新增 BYOK、自定义 Provider 或 OpenAI Key UI。
|
||||||
|
- cmhub 后台可以调整档位对应的真实模型;客户端不需要发版即可承接。
|
||||||
|
- 日志只写“cmhub 托管高质量档 / 扣点 / call_id / task_id”等业务可读字段,不写上游接口路径、Authorization、OpenAI API Key、完整 prompt 或请求体。
|
||||||
+251
-71
@@ -29,17 +29,18 @@ prepare_data_dir(...) -> str # 启动时迁移旧布局并检测
|
|||||||
default_debug_port(config=None) -> int
|
default_debug_port(config=None) -> int
|
||||||
debug_port_range(config=None) -> tuple # (start, end)
|
debug_port_range(config=None) -> tuple # (start, end)
|
||||||
cdp_ready_timeout(config=None) -> int
|
cdp_ready_timeout(config=None) -> int
|
||||||
ai_config(config=None) -> dict # default_text_model/default_image_model/
|
ai_config(config=None) -> dict # default_text_model/default_image_model/generate_mode/generate_cover/
|
||||||
# title_concurrency/image_concurrency/retry/jpg_quality/
|
# title_concurrency/image_concurrency/retry/jpg_quality/
|
||||||
# resolution/resolution_timeouts
|
# resolution/resolution_timeouts
|
||||||
|
product_suite_last_settings(config=None) -> dict # 最近的平台/站点/语言/比例;非法值回退默认
|
||||||
ai_backend(config=None) -> str # 默认 cmhub;direct 仅内部兼容/手工回滚
|
ai_backend(config=None) -> str # 默认 cmhub;direct 仅内部兼容/手工回滚
|
||||||
cmhub_config(config=None) -> dict # base_url/title_alias/image_alias/connect_timeout/download_with_curl
|
cmhub_config(config=None) -> dict # base_url/title_alias/image_alias/vision_alias/connect_timeout/download_with_curl
|
||||||
normalize_cmhub_base_url(base_url) -> str # 规整为 cmhub 网关根:scheme+host(+port)
|
normalize_cmhub_base_url(base_url) -> str # 规整为 cmhub 网关根:scheme+host(+port)
|
||||||
cmhub_request_url(base_url, endpoint) -> str # 先规整 base_url,再拼 /api/v1/...
|
cmhub_request_url(base_url, endpoint) -> str # 先规整 base_url,再拼 /api/v1/...
|
||||||
response_timeout(config=None) -> int # = resolution_timeouts[resolution](返回超时,随分辨率)
|
response_timeout(config=None) -> int # = resolution_timeouts[resolution](返回超时,随分辨率)
|
||||||
```
|
```
|
||||||
|
|
||||||
`default_config()` / `load_config()` 包含 `shopee_update` 安全配置段:历史/调试兼容测试商品 ID、是否允许真实提交、是否允许更新封面、每批最大更新条数、成功后是否关闭本轮新开编辑页、内部兼容 `dry_run`、多账号并行、最大并行账号数。普通正式更新不再用测试商品 ID 阻断当前筛选结果。`config.json` 不保存 AI Key;写入 `api_key` / `*_key` / `token` / `*_token` / `password` / `*_password` 等敏感字段时抛 `ConfigError`。普通产品默认 cmhub,AI Key 存 `data/config/cmhub.json`;`data/config/ai_models.json` 仅为 direct 内部兼容路径。T-538 后,配置中默认仍保存 `chrome_user_data_dir`、`images`、`cmshopee.db` 等相对值,运行时解析到 `data/` 下,保持免安装目录可移动。
|
`default_config()` / `load_config()` 包含 `shopee_update` 执行配置段:历史/调试兼容测试商品 ID、更新内容模式 `update_mode`、每批最大更新条数、内部兼容 `dry_run`、同时更新蝦皮账号数 `max_parallel_accounts`。普通正式更新不再用测试商品 ID 或旧真实提交开关阻断当前筛选结果;封面是否参与本轮更新由③「更新内容」下拉决定;线上提交前的显式确认边界是③「开始更新」确认弹窗。`product_suite.last_settings` 只保存商品套图最近选择的平台/站点/语言/比例,供未绑定商品的新任务初始化;已有项目自己的 `suite_settings_json` 优先。`config.json` 不保存 AI Key;写入 `api_key` / `*_key` / `token` / `*_token` / `password` / `*_password` 等敏感字段时抛 `ConfigError`。普通产品默认 cmhub,AI Key 存 `data/config/cmhub.json`;`data/config/ai_models.json` 仅为 direct 内部兼容路径。T-538 后,配置中默认仍保存 `chrome_user_data_dir`、`images`、`cmshopee.db` 等相对值,运行时解析到 `data/` 下,保持免安装目录可移动。
|
||||||
|
|
||||||
敏感信息展示/日志辅助:
|
敏感信息展示/日志辅助:
|
||||||
|
|
||||||
@@ -59,8 +60,55 @@ save_cmhub_config(config, path="data/config/cmhub.json") -> dict
|
|||||||
get_cmhub_api_key(path="data/config/cmhub.json", masked=False) -> str
|
get_cmhub_api_key(path="data/config/cmhub.json", masked=False) -> str
|
||||||
```
|
```
|
||||||
|
|
||||||
`data/config.json` 只保存 `ai.backend`、`ai.cmhub.base_url/title_alias/image_alias/connect_timeout` 等非密钥配置;T-529 后普通设置页固定保存 `ai.backend=cmhub`,不暴露后端切换;`data/config/cmhub.json` 必须 gitignore,展示时打码,不写日志/导出。
|
`data/config.json` 只保存 `ai.backend`、`ai.cmhub.base_url/title_alias/image_alias/vision_alias/connect_timeout` 等非密钥配置;`vision_alias` 默认 `vision-standard`,供商品套图中的「AI帮写」独立使用。T-529 后普通设置页固定保存 `ai.backend=cmhub`,不暴露后端切换;`data/config/cmhub.json` 必须 gitignore,展示时打码,不写日志/导出。
|
||||||
AI 模型清单(`data/config/ai_models.json`,含本地明文密钥,已建;UI 由 ⑤ 设置复用):
|
|
||||||
|
## client_policy / update_check 模块(`app/client_policy.py`、`app/update_check.py`)
|
||||||
|
|
||||||
|
启动版本接口 `GET /api/v1/client/releases/latest?platform=windows` 除 `release` 外可返回顶层 `client_policy`。策略解析与版本解析互相隔离:策略字段错误不能破坏强制升级判断,`release=null` 也可单独下发策略。
|
||||||
|
|
||||||
|
```python
|
||||||
|
class ClientPolicyError(ValueError): ...
|
||||||
|
class ClientPolicy:
|
||||||
|
policy_version: int
|
||||||
|
subscription_check_enabled: bool
|
||||||
|
subscription_enforcement_enabled: bool
|
||||||
|
updated_at: str
|
||||||
|
source: str
|
||||||
|
warning: str
|
||||||
|
mode: str # off / observe / enforce
|
||||||
|
|
||||||
|
parse_client_policy(value, source="remote") -> ClientPolicy
|
||||||
|
policy_cache_path(config=None) -> str # data/config/client_policy.json
|
||||||
|
save_cached_policy(policy, config=None, path=None, cached_at=None) -> str
|
||||||
|
load_cached_policy(config=None, path=None) -> ClientPolicy
|
||||||
|
resolve_client_policy(remote_policy, config=None, path=None) -> ClientPolicy
|
||||||
|
check_for_update(...) -> UpdateCheckResult # 同时透传 client_policy/client_policy_error
|
||||||
|
```
|
||||||
|
|
||||||
|
`policy_version` 必须为整数 `1`,两个开关必须为 JSON 布尔值,`updated_at` 必须是带时区 ISO 8601。`false/true` 非法组合归一化为 `false/false`。有效远程策略写非敏感原子缓存;远程缺失/非法/不可达时先用缓存,无缓存回退 `true/false` 观察模式。缓存不得包含 API Key、账号、套餐或订阅结果。
|
||||||
|
|
||||||
|
## subscription 模块(`app/subscription.py`)
|
||||||
|
|
||||||
|
```python
|
||||||
|
class SubscriptionStatus:
|
||||||
|
state: str
|
||||||
|
account_name: str
|
||||||
|
plan_name: str
|
||||||
|
expires_at: str
|
||||||
|
grace_expires_at: str
|
||||||
|
manage_url: str
|
||||||
|
notice_id: str
|
||||||
|
real_entitlement_allowed: bool
|
||||||
|
|
||||||
|
allows_product_workflows: bool # 只返回 real_entitlement_allowed
|
||||||
|
|
||||||
|
check_status(config=None, ...) -> SubscriptionStatus
|
||||||
|
safe_manage_url(value, base_url) -> str
|
||||||
|
```
|
||||||
|
|
||||||
|
订阅状态接口为 `GET /api/v1/cmshopee/subscription/status`。成功响应必须提供顶层严格布尔字段 `real_entitlement_allowed`;强制模式只认该字段,不使用展示状态 `status` 或服务端兼容字段 `allowed` 推断授权。`true` 只允许与 `active/grace` 同时出现,字段缺失、类型错误或组合矛盾统一归为“暂时无法验证”。`manage_url` 只接受与默认网关同主机的 HTTPS 地址。
|
||||||
|
|
||||||
|
AI 模型清单(`data/config/ai_models.json`,含本地明文密钥,已建;UI 由 设置复用):
|
||||||
|
|
||||||
```python
|
```python
|
||||||
default_ai_models_config() -> dict
|
default_ai_models_config() -> dict
|
||||||
@@ -72,6 +120,8 @@ update_ai_model(name, **fields) -> None
|
|||||||
delete_ai_model(name) -> None # 至少各留一个 text+image;删到剩一禁用
|
delete_ai_model(name) -> None # 至少各留一个 text+image;删到剩一禁用
|
||||||
model_request_url(model) -> str # url 为 /v1 或 /api/v1 base 时按 api_type 补 /chat/completions 或 /images/edits
|
model_request_url(model) -> str # url 为 /v1 或 /api/v1 base 时按 api_type 补 /chat/completions 或 /images/edits
|
||||||
test_ai_model(name) -> dict # 「测试连接」:用 key/url/model 发最小请求 -> {ok, status?, error?}
|
test_ai_model(name) -> dict # 「测试连接」:用 key/url/model 发最小请求 -> {ok, status?, error?}
|
||||||
|
check_image_model_config(name) -> dict # 图片模型本地校验,不发网络/计费请求 -> {ok, check_only, error?}
|
||||||
|
is_image_edit_model(model) -> bool # category=image 且 api_type=images_edits
|
||||||
get_model(name) -> dict # 返回模型定义,含 api_key(调用方不得写日志)
|
get_model(name) -> dict # 返回模型定义,含 api_key(调用方不得写日志)
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -101,15 +151,26 @@ delete_account(alias) -> None
|
|||||||
insert_tasks(batch_id, rows, path=None) -> int # 写输入列;rows 含 source_file_abs/source_sheet/source_row/row_key
|
insert_tasks(batch_id, rows, path=None) -> int # 写输入列;rows 含 source_file_abs/source_sheet/source_row/row_key
|
||||||
list_tasks(batch_id=None, stage=None, status=None, alias=None, path=None, include_deleted=False) -> list[Task]
|
list_tasks(batch_id=None, stage=None, status=None, alias=None, path=None, include_deleted=False) -> list[Task]
|
||||||
get_task(task_id, path=None, include_deleted=False) -> Task | None
|
get_task(task_id, path=None, include_deleted=False) -> Task | None
|
||||||
# 默认排除已软删除批次;include_deleted 仅供内部诊断/测试使用
|
# 默认排除已软删除批次和任务;include_deleted 仅供内部诊断/测试使用
|
||||||
|
|
||||||
delete_batch(batch_id, reason=None, path=None) -> dict
|
delete_batch(batch_id, reason=None, path=None) -> dict
|
||||||
# T-206 软删除:写 batches.deleted_at/deleted_reason,不物理删除 batches/tasks;默认业务列表和执行流程不可见/不可调用;返回任务数、committed 数和关联图片路径
|
# T-206 软删除:写 batches.deleted_at/deleted_reason,不物理删除 batches/tasks;默认业务列表和执行流程不可见/不可调用;返回任务数、committed 数和关联图片路径
|
||||||
|
delete_task(task_id, reason=None, path=None) -> dict
|
||||||
|
# T-670 软删除单条非 running 任务:写 tasks.deleted_at/deleted_reason;不删除 Excel、图片或线上商品,默认业务查询和执行入口不可见/不可调用
|
||||||
mark_running(task_id, phase) -> None
|
mark_running(task_id, phase) -> None
|
||||||
mark_failed(task_id, phase, error) -> None # status=failed,stage 不前进,对应 attempts+1
|
mark_failed(task_id, phase, error) -> None # status=failed,stage 不前进,对应 attempts+1
|
||||||
mark_skipped(task_id, reason) -> None # status=skipped,stage 不前进
|
mark_skipped(task_id, reason) -> None # status=skipped,stage 不前进
|
||||||
set_collected(task_id, old_title, old_cover_path) -> None # stage=collected,status=success,collect_attempts+1
|
set_product_status(task_id, product_status, product_status_note=None, product_status_at=None) -> None
|
||||||
|
# 只写商品状态快照,不改变 stage/status/标题/封面;非法状态规范为 unknown
|
||||||
|
# init_db() 会将 2026-07-18T16:34:37 前活动历史批次的 NULL/空状态一次性迁移为 normal,保留 product_status_at=NULL 并写历史默认备注
|
||||||
|
set_collected(task_id, old_title, old_cover_path, path=None, conn=None, *, product_status_value=None, product_status_note=None, product_status_at=None) -> None
|
||||||
|
# stage=collected,status=success,collect_attempts+1;提供状态快照时与旧标题/封面同一事务写入
|
||||||
set_generated(task_id, new_title, new_cover_path) -> None # stage=generated,status=success,generate_attempts+1;new_cover_path 可为 None 表示只生成标题
|
set_generated(task_id, new_title, new_cover_path) -> None # stage=generated,status=success,generate_attempts+1;new_cover_path 可为 None 表示只生成标题
|
||||||
|
set_generated_cover(task_id, new_cover_path) -> None # 只写AI封面并保留new_title(含NULL);stage=generated,status=success,generate_attempts+1
|
||||||
|
ensure_image_task_key(task_id) -> str # T-564 cmhub 异步生图:无幂等键则生成并写 tasks.image_task_key
|
||||||
|
set_image_task_submitted(task_id, image_task_id, image_task_key=None) -> None
|
||||||
|
# T-564 submit 202 后立即写 tasks.image_task_id/image_task_key,供重启/重试续查
|
||||||
|
clear_image_task(task_id) -> None # poll failed/expired 或重置封面后清空异步生图状态
|
||||||
update_generated_title(task_id, new_title, path=None) -> None # T-509 本地人工微调新标题;仅 generated/未提交/非运行中;清空 last_error 并回到 pending,不触碰 Shopee/CDP/Excel/封面
|
update_generated_title(task_id, new_title, path=None) -> None # T-509 本地人工微调新标题;仅 generated/未提交/非运行中;清空 last_error 并回到 pending,不触碰 Shopee/CDP/Excel/封面
|
||||||
set_applied(task_id, committed, error=None) -> None # 成功 stage=applied;失败 status=failed 且 stage 不前进
|
set_applied(task_id, committed, error=None) -> None # 成功 stage=applied;失败 status=failed 且 stage 不前进
|
||||||
# T-404a 已实现:选中记录重置
|
# T-404a 已实现:选中记录重置
|
||||||
@@ -173,7 +234,7 @@ export_copy(batch_id, out_dir_or_path, path=None) -> dict
|
|||||||
# 退路:另存带旧字段的副本,不动原文件;目录输出时生成 *_cmshopee回写.xlsx
|
# 退路:另存带旧字段的副本,不动原文件;目录输出时生成 *_cmshopee回写.xlsx
|
||||||
```
|
```
|
||||||
|
|
||||||
列模板见 [架构 5.3](04-architecture.md);别名以“别名”列为权威,必须与 ④ 账号管理中的账号别名一致。`shopee待处理任务模板.xlsx` 是可提交的标准空模板;运营填写后的 Excel 副本属于业务数据,不提交。
|
列模板见 [架构 5.3](04-architecture.md);别名以“别名”列为权威,必须与 账号管理中的账号别名一致。`shopee待处理任务模板.xlsx` 是可提交的标准空模板;运营填写后的 Excel 副本属于业务数据,不提交。
|
||||||
|
|
||||||
## config 模块(`app/config.py`,已建)
|
## config 模块(`app/config.py`,已建)
|
||||||
|
|
||||||
@@ -188,7 +249,7 @@ ensure_user_data_dir(slug, root=None, config=None) -> str
|
|||||||
|
|
||||||
## accounts 模块(`app/accounts.py`,已建)
|
## accounts 模块(`app/accounts.py`,已建)
|
||||||
|
|
||||||
账号管理服务层,供 ④ Tab 调用;不操作 QWidget,不自动登录/填密码。
|
账号管理服务层,供 账号管理 Tab 调用;不操作 QWidget,不自动登录/填密码。
|
||||||
|
|
||||||
```python
|
```python
|
||||||
class AccountError(RuntimeError): ...
|
class AccountError(RuntimeError): ...
|
||||||
@@ -216,8 +277,8 @@ login_status_text(status) -> str
|
|||||||
- `create_account()` / `update_account()` 负责生成 slug、创建 `chrome_user_data_dir/<slug>` 并写 DB;编辑别名会生成新 slug/目录,但不删除旧 user-data-dir。
|
- `create_account()` / `update_account()` 负责生成 slug、创建 `chrome_user_data_dir/<slug>` 并写 DB;编辑别名会生成新 slug/目录,但不删除旧 user-data-dir。
|
||||||
- `debug_port` 在账号服务层按账号唯一校验;默认端口取 `debug_port_range` 中第一个未占用端口。
|
- `debug_port` 在账号服务层按账号唯一校验;默认端口取 `debug_port_range` 中第一个未占用端口。
|
||||||
- `delete_account()` 只删除 DB 账号记录,不删除本地 user-data-dir,避免误删登录态。
|
- `delete_account()` 只删除 DB 账号记录,不删除本地 user-data-dir,避免误删登录态。
|
||||||
- `launch_for_login()` 是幂等入口。先检查 `chrome.is_running(account.debug_port)`;若该账号 CDP 端口已响应,不再调用 `subprocess.Popen`,而是复用已打开的账号 Chrome,优先激活/打开 `https://<region_host>/portal/` 或卖家中心登录 tab,并返回 `reused=true`;若端口未响应,才启动带该账号 user-data-dir 和 CDP 端口的 Chrome,等待 CDP 就绪并返回 `launched=true`。不会读取、填写或提交密码,不绕过验证码。
|
- `launch_for_login()` 是幂等入口。先检查 `chrome.is_running(account.debug_port)`;若该账号 CDP 端口已响应,不再调用 `subprocess.Popen`,而是复用已打开的账号 Chrome,优先激活/打开 `https://<region_host>/portal/` 或卖家中心登录 tab,并返回 `reused=true`,且不会改写已有普通页面。若端口未响应,才启动带该账号 user-data-dir、CDP 端口和唯一卖家中心初始 URL 的 Chrome;CDP 就绪后短时等待并复用初始 target,只有无安全可复用 page target 时才兜底创建一次。返回值除 `action/pid/target_id/url/created_tab` 外,还带 `initial_url/startup_target_reused/startup_page_navigated/page_target_count/fallback_reason/target_probe_error` 供日志诊断。不会读取、填写或提交密码,不绕过验证码。
|
||||||
- ③ 更新蝦皮的账号预检不会自动调用 `launch_for_login()`;Chrome 未启动/端口不可达时只返回阻断原因,由 GUI 提示用户去④手动打开账号浏览器并登录。T-105b 的复用逻辑只作用于④用户主动点击「启动登录」这一入口。
|
- ③ 更新蝦皮的账号预检不会自动调用 `launch_for_login()`;Chrome 未启动/端口不可达时只返回阻断原因,由 GUI 提示用户前往账号管理手动打开账号浏览器并登录。T-105b 的复用逻辑只作用于用户在账号管理中主动点击「启动登录」这一入口。
|
||||||
- `create_shortcut()` 生成 `.lnk`,目标/参数复用 `chrome.build_launch_args()`,不包含密码。
|
- `create_shortcut()` 生成 `.lnk`,目标/参数复用 `chrome.build_launch_args()`,不包含密码。
|
||||||
- `detect_login()` 复用 `editor.login_status()`;检测为已登录时更新 `last_login_at`。
|
- `detect_login()` 复用 `editor.login_status()`;检测为已登录时更新 `last_login_at`。
|
||||||
|
|
||||||
@@ -225,39 +286,43 @@ login_status_text(status) -> str
|
|||||||
|
|
||||||
```python
|
```python
|
||||||
class ChromeLaunchError(RuntimeError): ...
|
class ChromeLaunchError(RuntimeError): ...
|
||||||
build_launch_args(account, config=None) -> list[str]
|
build_launch_args(account, config=None, initial_url=None) -> list[str]
|
||||||
# chrome + --remote-debugging-port + --remote-allow-origins=* + --user-data-dir
|
# chrome + --remote-debugging-port + --remote-allow-origins=* + --user-data-dir
|
||||||
launch_chrome(account, config=None) -> subprocess.Popen
|
launch_chrome(account, config=None, initial_url=None) -> subprocess.Popen
|
||||||
create_shortcut(account, shortcut_path=None, desktop_dir=None, name=None, config=None) -> str
|
create_shortcut(account, shortcut_path=None, desktop_dir=None, name=None, config=None) -> str
|
||||||
wait_debug_ready(port, timeout=60, host="127.0.0.1") -> bool
|
wait_debug_ready(port, timeout=60, host="127.0.0.1") -> bool
|
||||||
is_running(port, host="127.0.0.1") -> bool
|
is_running(port, host="127.0.0.1") -> bool
|
||||||
```
|
```
|
||||||
|
|
||||||
`build_launch_args()` 接受 dict 或对象形式账号;账号需有 `debug_port`,并有 `user_data_dir` 或 `slug/alias`。端口探测访问 `/json/version`,显式禁用环境代理。`create_shortcut()` 使用 PowerShell `WScript.Shell.CreateShortcut` 生成 `.lnk`,`TargetPath` 为 Chrome,`Arguments` 含 `--remote-debugging-port`、`--remote-allow-origins=*`、`--user-data-dir=<该账号目录>`。
|
`build_launch_args()` 接受 dict 或对象形式账号;账号需有 `debug_port`,并有 `user_data_dir` 或 `slug/alias`。可选 `initial_url` 仅接受不含账号密码的蝦皮 `http/https` 地址;传入时追加 `--no-first-run`、`--no-default-browser-check`,并把该 URL 作为唯一 URL 参数放在末尾,避免全新 profile 的首次运行页占用独立窗口;不传时参数保持兼容。端口探测访问 `/json/version`,显式禁用环境代理。`create_shortcut()` 使用 PowerShell `WScript.Shell.CreateShortcut` 生成 `.lnk`,`TargetPath` 为 Chrome,`Arguments` 含 `--remote-debugging-port`、`--remote-allow-origins=*`、`--user-data-dir=<该账号目录>`,不自动追加上述冷启动专用参数或卖家中心 URL。
|
||||||
|
|
||||||
## cdp 模块(`app/cdp.py`,T-000 由根目录 `cdp.py` 迁入)
|
## cdp 模块(`app/cdp.py`,T-000 由根目录 `cdp.py` 迁入)
|
||||||
|
|
||||||
```python
|
```python
|
||||||
CDP_HOST: str
|
CDP_HOST: str
|
||||||
http_get(path, host=None); find_product_tab(item_id, host=None); create_tab_info(url, host=None); create_tab(url, host=None)
|
http_get(path, host=None, timeout=10); find_product_tab(item_id, host=None); create_tab_info(url, host=None); create_tab(url, host=None)
|
||||||
close_tab(target_id, host=None) -> bool # 关闭浏览器 target/page;区别于 CDP.close()
|
close_tab(target_id, host=None) -> bool # 关闭浏览器 target/page;区别于 CDP.close()
|
||||||
|
wait_target_closed(target_id, host=None, timeout=2.0, poll_interval=0.1) -> bool
|
||||||
|
close_tab_and_wait(target_id, host=None, timeout=2.0, poll_interval=0.1) -> bool
|
||||||
class CDP: send/ev/val/object_id/drag/close # close 只断开 WebSocket;suppress_origin、trust_env=False
|
class CDP: send/ev/val/object_id/drag/close # close 只断开 WebSocket;suppress_origin、trust_env=False
|
||||||
```
|
```
|
||||||
|
|
||||||
## editor 模块(`app/editor.py`,已建,重构自现有脚本)
|
## editor 模块(`app/editor.py`,已建,重构自现有脚本)
|
||||||
|
|
||||||
```python
|
```python
|
||||||
login_status(account, timeout=8) -> dict # {logged_in, reason, url, host, cookie_names}
|
login_status(account, timeout=8) -> dict # {logged_in, reason, url, host, cookie_names, cookie_read_succeeded, probe_error, probe_attempts}
|
||||||
is_logged_in(account) -> bool # login_status(...).logged_in;重定向登录页或缺 SPC_ST/SPC_U → False
|
is_logged_in(account) -> bool # login_status(...).logged_in;重定向登录页或缺 SPC_ST/SPC_U → False
|
||||||
install_toast_observer(cdp) -> None # 监听 Shopee `.eds-toasts`,保存最近 toast 文本/HTML/URL/时间
|
install_toast_observer(cdp) -> None # 监听 Shopee `.eds-toasts`,保存最近 toast 文本/HTML/URL/时间
|
||||||
read_page_toasts(cdp) -> list[dict] # [{text, html, url, visible, created_at}],用于失败诊断
|
read_page_toasts(cdp) -> list[dict] # [{text, html, url, visible, created_at}],用于失败诊断
|
||||||
open_product(account, item_id) -> CDP # 连端口、导航商品页、等就绪;标记该 tab 是否本轮自动新建;失败时上浮最近错误 toast,并清理本轮自动新建的失败 tab
|
open_product(account, item_id) -> CDP # 连端口、导航商品页、等业务字段就绪;标记该 tab 是否本轮自动新建;失败时返回缺失组件/有效错误 toast,并清理本轮自动新建的失败 tab
|
||||||
|
open_or_focus_product_tab(account, item_id) -> dict # ③人工查看:精确匹配已开商品路径时只聚焦、不导航;缺失时新建前台 tab 并等就绪,始终断开 CDP、不改任务
|
||||||
|
|
||||||
# 采集(只读)
|
# 采集(只读)
|
||||||
|
read_product_status(cdp) -> dict # {product_status, product_status_note, product_status_error};仅读 EDS warning,不保存 HTML
|
||||||
read_title(cdp) -> str
|
read_title(cdp) -> str
|
||||||
read_cover_src(cdp) -> str # 第一张 itembox 的 img.src
|
read_cover_src(cdp) -> str # 第一张 itembox 的 img.src
|
||||||
download_cover(src, out_path) -> str # 下载旧封面到本地
|
download_cover(src, out_path) -> str # 下载旧封面到本地
|
||||||
collect(account, task) -> dict # -> {old_title, old_cover_path}
|
collect(account, task) -> dict # task.collection_scope=normal_only/all;范围略过时返回 collection_skipped/reason,不读取标题/封面
|
||||||
|
|
||||||
# 应用
|
# 应用
|
||||||
change_title(cdp, new_title) -> dict # {ok, value, modelvalue},要求三者相等
|
change_title(cdp, new_title) -> dict # {ok, value, modelvalue},要求三者相等
|
||||||
@@ -265,24 +330,27 @@ replace_cover(cdp, image_win_path, old_cover_path=None) -> dict
|
|||||||
# 更新封面统一先确认 old_cover_path 非空且文件存在,再删除当前第一张、上传、等 Shopee CDN 地址、拖第一位;失败返回 upload_state 诊断;备份缺失时返回 OLD_COVER_BACKUP_MISSING,不删除线上图片
|
# 更新封面统一先确认 old_cover_path 非空且文件存在,再删除当前第一张、上传、等 Shopee CDN 地址、拖第一位;失败返回 upload_state 诊断;备份缺失时返回 OLD_COVER_BACKUP_MISSING,不删除线上图片
|
||||||
click_update(cdp, confirm_timeout=3) -> dict # {clicked, reason, toasts, confirm?};禁用或 Shopee 二次确认未完成则记失败
|
click_update(cdp, confirm_timeout=3) -> dict # {clicked, reason, toasts, confirm?};禁用或 Shopee 二次确认未完成则记失败
|
||||||
apply_task(account, task, close_success_tab=False) -> dict
|
apply_task(account, task, close_success_tab=False) -> dict
|
||||||
# 对已生成任务:换标题+换封面+点页面「更新」;若出现 Shopee 确认框,只点弹窗主按钮「更新」,不点「立即優化」(调用前必须已通过安全开关和批量确认)
|
# 对已生成任务:换标题+换封面+点页面「更新」;若出现 Shopee 确认框,只点弹窗主按钮「更新」,不点「立即優化」(调用前必须已经由③确认弹窗确认本轮真实更新)
|
||||||
# close_success_tab=True 时,仅成功提交且商品页为本轮自动新开时关闭该 tab;确认后跳回商品列表页时,关闭前等待 2 秒
|
# 程序自动新开的商品编辑页成功/失败都关闭,成功提交后关闭前等待 2 秒;复用用户已有页不关闭。close_success_tab 为旧调用兼容参数,不再控制当前行为
|
||||||
# -> {committed, error}
|
# -> {committed, error}
|
||||||
```
|
```
|
||||||
|
|
||||||
`login_status()` 不自动登录;无 Shopee tab 时打开卖家中心根地址 `https://<region_host>/`(默认 `https://seller.shopee.tw/`)用于检测/人工登录。判断规则:最终 URL 是登录页 → `LOGIN_PAGE`;其中必须显式识别 `https://accounts.shopee.tw/seller/login...` 这类 Shopee accounts 登录页;缺少 `SPC_ST`/`SPC_U` → `NO_SESSION_COOKIE`;有会话 Cookie → 已登录。
|
`login_status()` 不自动登录;无 Shopee tab 时打开卖家中心根地址 `https://<region_host>/`(默认 `https://seller.shopee.tw/`)用于检测/人工登录。判断规则:最终 URL 是登录页 → `LOGIN_PAGE`,其中必须显式识别 `https://accounts.shopee.tw/seller/login...` 这类 Shopee accounts 登录页;Cookie API 至少成功一次且确实缺少 `SPC_ST`/`SPC_U` → `NO_SESSION_COOKIE`;有会话 Cookie → 已登录;所有候选 target 都因关闭/连接错误而一次 Cookie 都未成功读取 → `LOGIN_CHECK_TARGET_UNAVAILABLE`。多个 Shopee page 并存时优先稳定 portal 页面,候选连接或 Cookie 读取失败后在同一超时预算内快速重选,不能把 target 错误降级成空 Cookie。
|
||||||
|
|
||||||
采集 tab 生命周期:
|
采集 tab 生命周期:
|
||||||
|
|
||||||
- `CDP.close()` 只断开当前 websocket 控制连接,不关闭 Chrome 页面。
|
- `CDP.close()` 只断开当前 websocket 控制连接,不关闭 Chrome 页面。
|
||||||
- `open_product()` 若复用已存在商品 tab,则标记为用户已有页面;若调用 `create_tab()` 新建,则记录 target id。
|
- `open_product()` 若复用已存在商品 tab,则标记为用户已有页面;若调用 `create_tab()` 新建,则记录 target id。
|
||||||
- `open_product()` 进入/刷新商品编辑页后要安装 toast 监听;若标题输入框、图片管理器、上传入口等关键元素等待超时,或页面明显不是商品编辑页,应读取最近 `.eds-toast__content`。如果存在错误 toast,例如 `please input correct product id`,返回/抛出的错误信息必须包含该文案,并把 toast 文本、`outerHTML`、URL、时间、可见状态交给上层运行日志/诊断日志;不得记录 Cookie、密码、token。调用方只在明确商品失效/商品不存在/无权限类 toast 时写 `last_error=商品失效:<原始toast>`,数据库 `stage/status` 仍使用既有流程值。若失败发生在 `open_product()` 返回 `cdp` 前,`open_product()` 自己负责清理:自动新建 tab 断开 CDP 后关闭浏览器 target,复用用户已有 tab 只断开 CDP。
|
- `open_product()` 进入/刷新商品编辑页后要安装 toast 监听。标题主定位是 `data-product-edit-field-unique-id="name"` 内唯一可见的 `input.eds-input__input`,主图和上传入口共同限定在 `data-product-edit-field-unique-id="images"` 内同一个 `.shopee-image-manager`;只有相应业务字段根不存在时才使用旧 DOM 回退,不得再用标题长度判断主定位。就绪检查返回标题命中数、主图数量/CDN/blob、上传 input 数量和当前 URL;超时必须先说明缺少标题、主图还是上传入口。明确商品失效/不存在/无权限 toast(如 `please input correct product id`)即使已隐藏也上浮,并把 toast 文本、`outerHTML`、URL、时间、可见状态交给上层运行日志/诊断日志;普通物流/备货等 toast 只有仍可见且属于当前页面 URL 时才作为“可能无关”的附加提示,不能覆盖就绪快照。不得记录 Cookie、密码、token。调用方只在明确商品失效类 toast 时写 `last_error=商品失效:<原始toast>`,数据库 `stage/status` 仍使用既有流程值。若失败发生在 `open_product()` 返回 `cdp` 前,`open_product()` 自己负责清理:后台只读自动新建 tab 断开 CDP、关闭 target 并执行最多 2 秒的关闭确认;③前台更新自动新建 tab 沿用原关闭路径;复用用户已有 tab 只断开 CDP。
|
||||||
|
|
||||||
- `collect()` 结束时只关闭本轮自动新建的商品编辑页 tab;如果 `open_product()` 尚未返回就失败,也由 `open_product()` 关闭本轮自动新建 tab;用户原本打开的商品 tab 不关闭。
|
- `read_product_status()` 在读取标题/封面前读取 `.eds-alert.eds-alert--warning` 下的 `.eds-alert-title/.eds-alert-desc`。`審核中/审核中` → `reviewing`,`您的商品未上架` → `unlisted`,无 warning → `normal`,未识别 warning、DOM 异常或非法响应 → `unknown`;多横幅按 DOM 顺序取第一个白名单命中,不依赖 `data-v-*`,只返回归一化 note,不保存 HTML。状态读取异常不阻断 `collect()`,由调用方写脱敏诊断日志和 `unknown` 快照。不存在独立历史状态重检接口或批量详情页补检流程。
|
||||||
- ③ 更新流程默认不关闭已进入编辑页的商品页;若 `close_success_tab=True`,只在提交成功且商品页为本轮自动新开时关闭,已进入编辑页后的失败任务和用户原本打开的 tab 保留现场;但 `open_product()` 内部打开失败的新建 tab 要关闭。Shopee 确认成功后可能把当前 tab 跳回 `/portal/product/list/all?operationSortBy=modified_time`,`click_update()` 会把该 URL 记录到 `post_update.url` 并标记 `redirected_to_list=true`;若本次会关闭该自动新开 tab,关闭前等待 2 秒。
|
|
||||||
- `click_update()` 的提交成功定义:页面主「更新」按钮已点击,且 Shopee 站点侧确认框未出现或已在可见 `.eds-modal__content` / `.eds-modal__box` 内点击主按钮「更新」。如果确认框仍停留、只点到页面主按钮、或误入「立即優化」,必须返回失败并保留现场。
|
- `collect()` 先读取状态再决定是否读取标题/封面。`task.collection_scope=normal_only` 时,未上架、审核中、状态未知返回 `collection_skipped=True` 和中文原因,不下载封面、不覆盖旧内容;`all` 时四类状态都继续采集。两种策略都重新读取页面状态,不能用历史状态预过滤。结束时只关闭本轮自动新建的商品编辑页 tab,并通过 `close_tab_and_wait()` 在最多 2 秒内确认 target 从 `/json` 消失,结果写入 `close_target_confirmed`。确认超时只记警告,不覆盖已成功读取的标题/封面;如果 `open_product()` 尚未返回就失败,也由 `open_product()` 关闭本轮自动新建 tab;用户原本打开的商品 tab 不关闭、不等待。
|
||||||
|
- ③ 更新流程中程序自动新建的商品编辑页成功/失败都关闭,复用用户原本打开的 tab 只断开 CDP、不关闭页面;`open_product()` 内部打开失败的新建 tab 仍由 `open_product()` 自行关闭。Shopee 确认成功后可能把当前 tab 跳回 `/portal/product/list/all?operationSortBy=modified_time`,`click_update()` 会把该 URL 记录到 `post_update.url` 并标记 `redirected_to_list=true`;自动新建页成功关闭前等待 2 秒。
|
||||||
|
- `open_or_focus_product_tab()` 仅供③左键双击人工查看:按当前账号区域域名与 `/portal/product/<商品ID>` 精确匹配既有 page target,命中后只调用 `Page.bringToFront`,不得执行 `Page.navigate`、刷新或等待页面重载;未命中才前台新建并按既有就绪/toast 规则等待。无论成功路径均只断开 CDP WebSocket、保留浏览器 tab;失败时只关闭本轮新建 tab,绝不关闭复用的用户 tab,也不读写任务、Excel、图片或线上商品。
|
||||||
|
- `click_update()` 的提交成功定义:页面主「更新」按钮已点击,且 Shopee 站点侧确认框未出现或已在可见 `.eds-modal__content` / `.eds-modal__box` 内点击主按钮「更新」。如果确认框仍停留、只点到页面主按钮、或误入「立即優化」,必须返回失败;若 tab 是本轮自动新建,失败后由 `apply_task()` 关闭该 tab。
|
||||||
- T-404/T-502 封面更新删除前,`apply_task()` 应把任务的 `old_cover_path` 传给 `replace_cover()`;`replace_cover()` 只有在本地旧封面备份存在时才允许进入删第一张流程。更新封面统一先删当前第一张,不再只在满 9 张时删除;8 张商品图也按替换语义先删再上传。
|
- T-404/T-502 封面更新删除前,`apply_task()` 应把任务的 `old_cover_path` 传给 `replace_cover()`;`replace_cover()` 只有在本地旧封面备份存在时才允许进入删第一张流程。更新封面统一先删当前第一张,不再只在满 9 张时删除;8 张商品图也按替换语义先删再上传。
|
||||||
- T-404 封面上传稳定性:`replace_cover()` 上传前必须模拟人工路径,先点击 `.shopee-image-manager__upload` 上传块,短暂等待并重新获取最新 `input[type=file]` 后,再用 CDP `DOM.setFileInputFiles` 注入本地图片并派发 `input`/`change`。该策略用于处理手动上传成功但直接注入文件后 Shopee 前端一直转圈、迟迟不生成 `susercontent` CDN 地址的场景。`有1張重複的圖片` / `重複` / `重复` / `duplicate` 属于封面上传错误,必须立即返回明确失败,不继续等超时。
|
- T-404 封面上传稳定性:`replace_cover()` 上传前必须模拟人工路径,在 `images` 业务字段内同一个主图 manager 中先点击 `.shopee-image-manager__upload` 上传块,短暂等待并重新获取最新 `input[type=file]` 后,再用 CDP `DOM.setFileInputFiles` 注入本地图片并派发 `input`/`change`。该策略用于处理手动上传成功但直接注入文件后 Shopee 前端一直转圈、迟迟不生成 `susercontent` CDN 地址的场景。`有1張重複的圖片` / `重複` / `重复` / `duplicate` 属于封面上传错误,必须立即返回明确失败,不继续等超时。
|
||||||
|
|
||||||
## ai 模块(`app/ai.py`,已建,外部 AI,通用 HTTP)
|
## ai 模块(`app/ai.py`,已建,外部 AI,通用 HTTP)
|
||||||
|
|
||||||
@@ -293,13 +361,17 @@ gen_title(title_prompt, old_title, retry=None, config=None, models_path="data/co
|
|||||||
# 文本生成:按 backend 分流;direct 走 chat JSON,cmhub 走 /generate/title;提示词 + 旧标题 → 新标题
|
# 文本生成:按 backend 分流;direct 走 chat JSON,cmhub 走 /generate/title;提示词 + 旧标题 → 新标题
|
||||||
|
|
||||||
gen_cover(cover_prompt, old_cover_path, out_path, resolution=None, jpg_quality=None, retry=None, config=None, models_path="data/config/ai_models.json", on_step=None, on_event=None, cmhub_config_path="data/config/cmhub.json") -> str
|
gen_cover(cover_prompt, old_cover_path, out_path, resolution=None, jpg_quality=None, retry=None, config=None, models_path="data/config/ai_models.json", on_step=None, on_event=None, cmhub_config_path="data/config/cmhub.json") -> str
|
||||||
# 图像生成(image-to-image):按 backend 分流;direct 走 chat/images_edits,cmhub 走 /generate/image;
|
# 图像生成(image-to-image):按 backend 分流;direct 走 chat/images_edits;cmhub 单独调用走旧同步 /generate/image 兼容路径;
|
||||||
# 支持返回 url / data URL / b64_json,按 resolution resize 并以 jpg_quality 保存 JPEG,返回路径;新生成默认写入 `image_dir/<batch_id>/<slug>/<task_id>_<item_id>_new.jpg`,历史 DB 已存路径继续按原路径读取
|
# 支持返回 url / data URL / b64_json,按 resolution resize 并以 jpg_quality 保存 JPEG,返回路径;新生成默认写入 `image_dir/<batch_id>/<slug>/<task_id>_<item_id>_new.jpg`,历史 DB 已存路径继续按原路径读取
|
||||||
|
|
||||||
|
analyze_product_images(instruction, context, image_paths, config=None, cmhub_config_path="data/config/cmhub.json", on_event=None) -> dict
|
||||||
|
# 仅商品套图AI帮写使用;backend 必须为 cmhub。按输入顺序校验1..8张本地图片(单张<=10MiB、合计<=32MiB),在一次 POST /api/v1/analyze/images 中作为同商品多角度证据集发送;固定提示词要求一份商品级联合分析,不输出逐图编号,并按商品概述/可确认卖点/人群场景/套图画面/待确认信息组织文本。返回 {text, image_count, metadata};metadata 只允许 alias/model_used/points_cost/points_balance/call_id。
|
||||||
|
|
||||||
generate_batch(tasks, prompts, ai_cfg=None, on_progress=None, should_stop=None) -> dict
|
generate_batch(tasks, prompts, ai_cfg=None, on_progress=None, should_stop=None) -> dict
|
||||||
fetch_cmhub_models(base_url, api_key, connect_timeout=10, read_timeout=30) -> list[dict]
|
fetch_cmhub_models(base_url, api_key, connect_timeout=10, read_timeout=30) -> list[dict]
|
||||||
# 编排:先以 title_concurrency 线程池并发跑 gen_title;ai.generate_cover 为 true 时再以 image_concurrency 并发跑 gen_cover
|
# 编排:按 ai.generate_mode 跑标题和/或封面;title/title_cover 用 title_concurrency,cover/title_cover 用 image_concurrency
|
||||||
# 标题-only 模式标题成功即 db.set_generated(task_id, new_title, None);标题+封面模式图片成功后写 new_cover_path;should_stop() 为真则取消未开始项
|
# cmhub 批量生图由 generate_batch 直接走异步 /generate/image/tasks:submit 落库 task_id,再 poll 续查,成功后仍返回本地 JPEG 路径
|
||||||
|
# 只生成标题时标题成功即 db.set_generated(task_id, new_title, existing_cover_path);只生成封面不调用生文,标题上下文取new_title or old_title,成功后db.set_generated_cover只写封面;should_stop() 为真则取消未开始项
|
||||||
# on_progress({"total","title_done","cover_done","failed","cancelled","ok"}) 回调刷新进度
|
# on_progress({"total","title_done","cover_done","failed","cancelled","ok"}) 回调刷新进度
|
||||||
# ai_cfg 可传 on_event/on_error 回调,逐条报告 title/cover 阶段 step/result/detail,供 GUI run_logs 与本地诊断日志使用
|
# ai_cfg 可传 on_event/on_error 回调,逐条报告 title/cover 阶段 step/result/detail,供 GUI run_logs 与本地诊断日志使用
|
||||||
# 返回同结构 summary;失败任务 mark_failed(..., "generate", error),不阻塞其余
|
# 返回同结构 summary;失败任务 mark_failed(..., "generate", error),不阻塞其余
|
||||||
@@ -307,14 +379,15 @@ fetch_cmhub_models(base_url, api_key, connect_timeout=10, read_timeout=30) -> li
|
|||||||
|
|
||||||
要点:
|
要点:
|
||||||
|
|
||||||
- `backend=direct`:内部兼容/手工回滚路径;标题用 `default_text_model`、封面用 `default_image_model`(`appconfig.get_model` 取定义,含 url/key/api_type)。
|
- `backend=direct`:设置页「自定义网关」的客户端直连兼容路径;标题用 `default_text_model`,保留 `chat` / `auto`;封面用 `default_image_model`,只接受 `category=image` 且 `api_type=images_edits`(`appconfig.get_model` 取定义,含 url/key/api_type)。封面固定 `POST /v1/images/edits`,multipart 中按顺序重复 `image[]`,首项是主体图,固定 `n=1`,只读取 `data[].b64_json` 或 `data[].url`,仅允许 data URL/base64/HTTP(S) 下载结果。②在创建 worker 前按本轮生成内容校验所需模型;运行中使用冻结的内存模型快照,不重读 `ai_models.json`。历史 `chat` / `auto` 图片模型仍可保存和查看,但不可提交图片生成。它不支持图片理解或商品套图新建任务。
|
||||||
- `backend=cmhub`:普通产品默认路径;标题调用 `POST /api/v1/generate/title`,封面调用 `POST /api/v1/generate/image`,模型字段使用 `ai.cmhub.title_alias/image_alias`,Key 来自 `data/config/cmhub.json`。
|
- `backend=cmhub`:设置页「默认网关」及普通产品默认路径;标题调用 `POST /api/v1/generate/title`;②批量封面生成调用 `POST /api/v1/generate/image/tasks` + `GET /api/v1/generate/image/tasks/{task_id}`,模型字段使用 `ai.cmhub.title_alias/image_alias`,Key 来自 `data/config/cmhub.json`。商品套图中的「AI帮写」单独调用 `POST /api/v1/analyze/images`,只使用 `ai.cmhub.vision_alias`,不得回退或混用生文/生图别名;同一商品项目的原图在一条请求内联合理解,用户勾选状态不参与选图。`gen_cover()` 单独调用没有任务/DB 上下文,第一版保留旧同步 `POST /api/v1/generate/image` 兼容路径。
|
||||||
- 标题提示词组装:`gen_title()` 的 direct 与 cmhub 路径共用标题 prompt 规则。若标题提示词包含 `{旧标题}`,生成前替换为该任务旧标题,不再自动追加旧标题块;若不包含 `{旧标题}`,保持旧行为自动追加“旧标题:...”块。两种情况都会追加“请只返回新标题,不要解释。”输出约束;其它 `{...}` 原样保留。
|
- 标题提示词组装:`gen_title()` 的 direct 与 cmhub 路径共用标题 prompt 规则。若标题提示词包含 `{旧标题}`,生成前替换为该任务旧标题,不再自动追加旧标题块;若不包含 `{旧标题}`,保持旧行为自动追加“旧标题:...”块。两种情况都会追加“请只返回新标题,不要解释。”输出约束;其它 `{...}` 原样保留。
|
||||||
- `fetch_cmhub_models()` 调 `GET /api/v1/models` 返回别名清单,供⑤设置页动态下拉使用;Base URL 会先规整为网关根,HTTP 404 映射为 `not_found` 并提示检查 Base URL 或实例是否部署 `/api/v1/models`。
|
- `fetch_cmhub_models()` 调 `GET /api/v1/models` 返回别名清单,供设置页动态下拉、商品套图 AI帮写和商品套图正式生成的付费前确认共用;Base URL 会先规整为网关根,HTTP 404 映射为 `not_found` 并提示检查 Base URL 或实例是否部署 `/api/v1/models`。GUI 只在进程内按规整网关地址和别名短期缓存公开模型元数据,不缓存 API Key,也不写入配置、SQLite、日志或导出。AI帮写仅在当前 `vision_alias` 命中 `operation_type=vision`、`requires_image=true`、`pricing_status=priced` 且有唯一无条件 `points_cost` 时显示预计扣点;正式套图同样只在当前 `image_alias` 命中唯一无条件的图片生图价格时显示单张和按最终 `specs` 计算的总价。其它情况只提示实际以网关返回为准,预估值不参与扣减或成功判定。
|
||||||
- `api_type=chat/auto` 走 OpenAI-compatible chat JSON;`api_type=images_edits` 走 multipart form。
|
- `api_type=chat/auto` 走 OpenAI-compatible chat JSON;`api_type=images_edits` 走 multipart form。
|
||||||
- direct 连接超时参考模型 `connect_timeout_seconds`;**返回超时 = 模型 `timeout_seconds` 或 `appconfig.response_timeout()`(随分辨率:512/1k/2k/4k → 180/240/360/600)**。cmhub 使用 `requests timeout=(connect, read)`,connect 来自 `ai.cmhub.connect_timeout`;cmhub 生文读取等待固定 600s,cmhub 生图请求和图片下载读取等待固定 650s,且生图读超时不自动重发。
|
- direct 连接超时参考模型 `connect_timeout_seconds`;**返回超时 = 模型 `timeout_seconds` 或 `appconfig.response_timeout()`(随分辨率:512/1k/2k/4k → 180/240/360/600)**。cmhub 使用 `requests timeout=(connect, read)`,connect 来自 `ai.cmhub.connect_timeout`;cmhub 生文读取等待固定600秒,商品套图图片理解读取等待固定120秒且一次读超时不重试,提示结果未确认;②批量生图异步 submit 读取等待36秒、poll 单次读取等待15秒、本地总预算900秒,图片下载读取等待900秒;`gen_cover()` 旧同步兼容路径仍用900秒读取等待。
|
||||||
- 并发数/重试/分辨率/jpg 质量来自 `appconfig.ai_config()`;标题/图片并发会被夹到 1..5,失败重试次数会被夹到 0..10,兼容旧配置中的超限值;Key 本地明文存储,但不入日志、不导出。cmhub 响应的 `points_cost/points_balance/call_id` 不改变返回值,通过 `on_event` metadata 事件上报,GUI 余额/计费展示留给 T-528。
|
- 并发数/重试/分辨率/jpg 质量来自 `appconfig.ai_config()`;标题/图片并发会被夹到 1..5,失败重试次数会被夹到 0..10,兼容旧配置中的超限值;Key 本地明文存储,但不入日志、不导出。②/⑥ worker 启动时通过 `freeze_runtime_config()` 深拷贝来源、端点、别名/模型、超时、代理与独立配置文件中的 Key 到仅内存快照,运行期间不再读取配置文件;cmhub 响应的 `points_cost/points_balance/call_id` 不改变返回值,通过 `on_event` metadata 事件上报,GUI 余额/计费展示留给 T-528。
|
||||||
- 标题快、图片慢:分两段、各用各自并发数;失败按 `retry` 重试,仍失败记 error 不阻塞其余。cmhub 生图使用内部实际并发 `min(image_concurrency, 5)`,拿到 `image_url` 后交给独立下载/保存线程池,下载线程数与实际生图并发一致且最大 5;T-548 后图片下载后端由 `ai.cmhub.download_with_curl` 控制,默认 `auto`:Windows 且检测到系统 curl 时优先用 curl 下载,否则回退 requests;curl 失败也会自动回退 requests。下载前仍执行公网 URL 校验,curl 使用 `-K` 临时配置文件传入 URL,不把带 token 的 `image_url` 放进命令行参数;`use_system_proxy=false` 时 curl 加 `--noproxy "*"`。下载失败最多安全重试 3 次,只复用同一个 `image_url`,不会重新调用 cmhub 生图接口;下载总耗时超过 20 秒时写“图片下载较慢”警告;direct 兼容路径暂保持原 `image_concurrency` 语义。
|
- 标题快、图片慢:分两段、各用各自并发数;失败按 `retry` 重试,仍失败记 error 不阻塞其余。cmhub 批量生图使用内部实际并发 `min(image_concurrency, 5)` 约束 submit+poll 在途数;已有 `tasks.image_task_id` 时直接 GET 续查,不二次 submit;submit 前先持久化 `image_task_key`,submit 成功立即持久化 `image_task_id`;poll `failed/expired` 会清空二者,poll 超时/用户停止/程序退出则保留二者供下次续查。拿到 `image_url` 后交给独立下载/保存线程池,下载线程数与实际生图并发一致且最大 5;T-548 后图片下载后端由 `ai.cmhub.download_with_curl` 控制,默认 `auto`:Windows 且检测到系统 curl 时优先用 curl 下载,否则回退 requests;curl 失败也会自动回退 requests。下载前仍执行公网 URL 校验,curl 使用 `-K` 临时配置文件传入 URL,不把带 token 的 `image_url` 放进命令行参数;`use_system_proxy=false` 时 curl 加 `--noproxy "*"`。下载失败最多安全重试 3 次,只复用同一个 `image_url`,不会重新调用 cmhub 生图接口;下载总耗时超过 20 秒时写“图片下载较慢”警告;direct 兼容路径暂保持原 `image_concurrency` 语义。
|
||||||
|
- cmhub 图片地址兼容:优先递归读取 `image_url` / `image_url.url` / `images[]` / `image_urls[]` 等字段;若返回 `/generated/...` 或 `generated/...` 相对路径,先按 cmhub Base URL 补全为完整 `http(s)` URL,再进入原有公网 URL 安全校验和下载。
|
||||||
- 调试 cmhub 图片 URL:默认不在日志或 DB 中记录完整 `image_url`。若本机调试需要查看,启动程序前设置环境变量 `CMSHOPEE_DEBUG_CMHUB_IMAGE_URL=1`,②本轮可见运行日志会显示脱敏后的“cmhub 图片 URL”,但该调试行不写入 SQLite `run_log_events`。
|
- 调试 cmhub 图片 URL:默认不在日志或 DB 中记录完整 `image_url`。若本机调试需要查看,启动程序前设置环境变量 `CMSHOPEE_DEBUG_CMHUB_IMAGE_URL=1`,②本轮可见运行日志会显示脱敏后的“cmhub 图片 URL”,但该调试行不写入 SQLite `run_log_events`。
|
||||||
- 调用有成本与失败可能:超时、限流、内容安全拒绝都要返回明确错误。
|
- 调用有成本与失败可能:超时、限流、内容安全拒绝都要返回明确错误。
|
||||||
- 生成结果**直接进入 ③ 更新候选**;③ 点击「开始更新」后弹窗批量确认,确认后提交线上。本地留档 + 回写 Excel 供追溯。
|
- 生成结果**直接进入 ③ 更新候选**;③ 点击「开始更新」后弹窗批量确认,确认后提交线上。本地留档 + 回写 Excel 供追溯。
|
||||||
@@ -345,61 +418,163 @@ render_prompt(template_text, task) -> str
|
|||||||
- 「插入旧标题」在标题提示词光标处插入 `{旧标题}`;「插入标题」在封面提示词光标处插入 `{新标题}`;「预览」对选中任务调用 `render_prompt` 后展示封面提示词。
|
- 「插入旧标题」在标题提示词光标处插入 `{旧标题}`;「插入标题」在封面提示词光标处插入 `{新标题}`;「预览」对选中任务调用 `render_prompt` 后展示封面提示词。
|
||||||
- 生成封面时 `gen_cover` 的 prompt = `render_prompt(当前封面模板, task)`。
|
- 生成封面时 `gen_cover` 的 prompt = `render_prompt(当前封面模板, task)`。
|
||||||
- 模板与 `data/title_prompt.txt` 均为可手改的纯文本文件。
|
- 模板与 `data/title_prompt.txt` 均为可手改的纯文本文件。
|
||||||
- `list_cover_templates()` 不会在启动时创建文件;只有保存/新建/另存为才写 `data/prompts/cover/*.txt`。
|
- `list_cover_templates()` 本身不创建文件;②AI生成页调用 `ensure_default_prompts()` 时,封面模板目录为空才将内置 `app/default_prompts/cover/cover_prompt.txt` 播种为 `data/prompts/cover/cover_prompt.txt`。已有任意用户模板(包括历史 `papa1.txt`)不覆盖、不重命名、不迁移。
|
||||||
- 模板名不可为空,不允许路径分隔符、`..` 或 Windows 非法文件名字符;重命名时目标重名会报错。
|
- 模板名不可为空,不允许路径分隔符、`..` 或 Windows 非法文件名字符;重命名时目标重名会报错。
|
||||||
|
|
||||||
|
## 商品套图模块(`app/product_suite.py` + `app/image_studio*.py`,已建)
|
||||||
|
|
||||||
|
商品套图是主窗口中的本地图片项目工作区,不自动上传蝦皮、不修改线上商品。底层继续复用旧 AI工场的数据表与生成服务,避免迁移既有项目数据。
|
||||||
|
|
||||||
|
```python
|
||||||
|
# app/image_studio.py
|
||||||
|
ImageStudioProject / ImageStudioAsset / ImageStudioJob / ImageStudioSelection
|
||||||
|
ImageStudioHistoryAccount
|
||||||
|
ImageStudioGenerationRound / ImageStudioHistoryRound
|
||||||
|
normalize_item_id_input(item_id) -> str
|
||||||
|
is_formal_item_id(item_id) -> bool
|
||||||
|
create_or_get_project(account_or_fields, item_id, ...) -> ImageStudioProject
|
||||||
|
list_projects(path=None) -> list[ImageStudioProject]
|
||||||
|
update_project_prompt(project_id, draft_prompt, path=None) -> ImageStudioProject
|
||||||
|
project_suite_settings(project) -> dict
|
||||||
|
update_project_suite_settings(project_id, settings, path=None) -> ImageStudioProject
|
||||||
|
sync_original_asset_urls(project_id, image_urls, path=None) -> list[ImageStudioAsset]
|
||||||
|
list_assets(project_id, kind=None, include_missing=True, path=None) -> list[ImageStudioAsset]
|
||||||
|
reorder_original_assets(project_id, asset_ids, path=None) -> list[ImageStudioAsset]
|
||||||
|
remove_original_assets_if_unused(project_id, asset_ids, path=None) -> list[ImageStudioAsset]
|
||||||
|
create_job(project_id, source_asset_id=None, job_type="main", prompt="", generation_source="cmhub", provider="cmhub", ...) -> ImageStudioJob
|
||||||
|
list_jobs(project_id, statuses=None, path=None) -> list[ImageStudioJob]
|
||||||
|
list_resumable_jobs(project_id=None, include_failed_downloads=False, path=None) -> list[ImageStudioJob]
|
||||||
|
fail_stale_direct_jobs(active_run_session_ids=(), path=None) -> int
|
||||||
|
list_generation_rounds(project_id, limit=None, offset=0, path=None) -> list[ImageStudioGenerationRound]
|
||||||
|
get_successful_generation_history_summary(project_id, path=None) -> ImageStudioSuccessfulGenerationHistorySummary
|
||||||
|
list_global_history_accounts(path=None) -> list[ImageStudioHistoryAccount]
|
||||||
|
list_global_generation_rounds(account_alias=None, account_query="", item_query="", project_id=None,
|
||||||
|
limit=None, offset=0, path=None) -> list[ImageStudioHistoryRound]
|
||||||
|
list_generation_round_current_jobs(project_id, generation_round_key, path=None) -> list[ImageStudioJob]
|
||||||
|
list_generation_round_attempts(project_id, generation_round_key, path=None) -> list[ImageStudioJob]
|
||||||
|
replace_selections(project_id, selection_type, asset_ids, path=None) -> list[ImageStudioSelection]
|
||||||
|
pull_remote_main_image_urls(account_or_alias, item_id, path=None, config=None,
|
||||||
|
should_stop=None) -> dict
|
||||||
|
|
||||||
|
# app/image_studio_images.py
|
||||||
|
download_remote_image(url, max_bytes=..., timeout=(connect, read)) -> RemoteImage
|
||||||
|
load_thumbnail(url, key=None, max_size=220) -> ThumbnailResult
|
||||||
|
download_original_asset(asset_id, path=None, config=None,
|
||||||
|
should_stop=None) -> ImageStudioAsset
|
||||||
|
import_original_files(project_id, file_paths, path=None, config=None) -> dict
|
||||||
|
import_original_bytes(project_id, content, filename_hint="clipboard.png", ...) -> ImageStudioAsset
|
||||||
|
trash_generated_asset(asset_id, path=None, config=None) -> dict
|
||||||
|
restore_trashed_asset(record, path=None, config=None) -> ImageStudioAsset
|
||||||
|
|
||||||
|
# app/product_suite.py
|
||||||
|
normalize_suite_settings(value=None) -> dict
|
||||||
|
suite_total_count(settings, image_count) -> int
|
||||||
|
build_suite_prompt(base_prompt, settings, category, item_id, source_index=1) -> str
|
||||||
|
build_job_specs(source_assets, base_prompt, settings, item_id) -> list[dict]
|
||||||
|
|
||||||
|
# app/image_studio_generation.py
|
||||||
|
generate_image_jobs(project_id, source_asset_id, prompt, count, job_type="main", aspect_ratio="1:1", run_session_id=None, ...) -> dict
|
||||||
|
resume_image_jobs(project_id=None, aspect_ratio="1:1", run_session_id=None, ...) -> dict
|
||||||
|
requested_output_spec(aspect_ratio) -> dict
|
||||||
|
recover_stale_direct_jobs_at_startup(lease, path=None) -> int
|
||||||
|
|
||||||
|
# app/image_studio_export.py
|
||||||
|
export_project_selection(project_id, parent_dir, existing_mode="fail", path=None, config=None) -> ExportResult
|
||||||
|
export_generation_round(project_id, generation_round_key, parent_dir, path=None,
|
||||||
|
should_stop=None) -> GenerationRoundExportResult
|
||||||
|
```
|
||||||
|
|
||||||
|
要点:
|
||||||
|
|
||||||
|
- 项目唯一键为账号别名 + 商品 ID;图片文件默认在 `data/images/pool/<slug>/<item_id>/` 下分 `originals/generated/exports`,删除生成图进入项目内 `.trash` 并可撤销。
|
||||||
|
- `image_studio_projects.suite_settings_json` 保存平台/国家/语言/比例/逐图主图/分类数量;固定分类顺序为白底图、场景图、模特场景图、细节说明图、卖点图,默认数量为1、2、0、0、2;自定义分类名称和数量继续保存在同一分类映射中。新增分类默认数量为0,不会无提示增加任务或点数消耗。`draft_prompt` 保存商品卖点。有效原图上限16张,missing 历史不占名额。
|
||||||
|
- T-643 后 `image_studio_projects.current_generation_round_key` 记录项目主界面的当前生成轮次;`image_studio_jobs.generation_round_key` 与 `generation_slot_index` 记录整轮归属和稳定展示槽位。`promote_generation_round_if_success()` 只会在同项目、同轮已存在成功 job 时原子切换当前轮;`list_generation_rounds()` 返回轮次摘要,`list_generation_round_current_jobs()` 返回每个槽位最新尝试,`list_generation_round_attempts()` 返回完整尝试历史。旧 job 保持 NULL 并按“旧版历史记录”兼容,不猜测轮次边界。
|
||||||
|
- T-646 后 `list_global_generation_rounds()` 是商品套图跨项目历史的唯一查询入口:仅返回未软删除项目的轮次摘要,按创建时间倒序并支持账号/店铺、商品 ID、项目 ID、`limit`/`offset` 筛选;正常轮次只统计每个稳定槽位的最新有效 job,完整尝试数仅用于计算重试数,NULL 轮次保留“旧版历史记录”语义。`ProductSuiteGlobalHistoryDialog` 组合该查询和 `list_generation_round_current_jobs()`,默认读取30轮,显示受限缩略图并可按轮预览;窗口不写业务数据,也不改变当前轮次。`ProductSuiteHistoryExportWorker` 在后台调用 `export_generation_round()`,只复制该轮成功且存在的输出 asset 到用户选择目录中新建的安全子目录;同名目录追加稳定序号,不覆盖外部文件,部分文件失败只汇总中文结果,源 asset 保持不变。
|
||||||
|
- T-648 后 `get_successful_generation_history_summary()` 是商品套图常规新一轮生成前的只读判据:它只查询未软删除当前项目的最新有效成功 job,返回成功轮数、成功图片数和最近成功时间;正常轮次按稳定槽位取最新尝试,NULL 旧版轮次整体按一轮计数,只有失败/取消/进行中的 job 返回零摘要。该 API 不扫描文件目录、不创建 job/asset/轮次,也不改变当前轮;GUI 的“查看历史”仅调用全局历史窗口并临时启用当前商品筛选。
|
||||||
|
- T-649 后 `list_global_history_accounts()` 只返回含 job 且未软删除项目的历史账号别名,供历史窗口补齐已删除账号的可选项。`list_global_generation_rounds(account_alias=...)` 以 `projects.account_alias = ?` 精确筛选;传入该参数时优先于旧兼容参数 `account_query`,后者仅保留给既有调用的模糊查询。当前账号显示文本来自账号管理,历史缺失账号只显示明确的历史标识,UI 不通过显示文本反查筛选值。
|
||||||
|
- 拉取蝦皮原主图只读:复用 `editor.open_product(..., bring_to_front=False)` 和 `editor.read_product_image_urls()`,不上传、不拖拽、不点击更新。
|
||||||
|
- 原图下载走 `image_studio_images` 的公网 URL、大小、Content-Type、重定向和 PIL 解码校验;只在用户单击时落盘。
|
||||||
|
- `remove_original_assets_if_unused()` 会先校验整批原图的项目归属、资产类型、终选直接引用,以及 job 的 `source_asset_id/reference_asset_ids` 快照;运行中或仍可 `resume/regenerate` 的任务阻断删除,`succeeded + recovery_action=none` 等不可再执行的历史 job 不阻断。参考图快照无效时保守阻断。删除在单个事务中完成并连续重排 `source_order`;历史 job/输出 asset 的外键关联按 SQLite 置空,状态、提示词、轮次和生成结果保留。任一图片不可删除时整批不变,本地源文件和蝦皮线上图片始终保留。
|
||||||
|
- T-679a 后,商品套图 job 固定持久化来源,不以运行时设置反推:默认网关为 `generation_source="cmhub"`、`provider="cmhub"`,沿用托管异步 `task_key/task_id/status/call_id/points_cost/points_balance` 及 submit/poll/download;自定义网关为 `generation_source="direct"`、`provider="openai_images_edits"`,`task_id` 必须为空,复用 T-678 的同步多图编辑请求,固定 `n=1`,收到图片字节即保存本地资产,不轮询、不写远程 URL、不自动重试。任何来源/提供方组合不精确匹配的 job 都拒绝执行,不能借 task ID 串到 cmhub。
|
||||||
|
- T-679 集成验收确认 `ProductSuiteGenerateWorker` 在构造时冻结来源、模型和密钥快照;之后保存⑤设置只影响新 worker。同一项目的 direct 新 job 与已有 cmhub task ID 可并存,`resume_image_jobs()` 仍只读取 `cmhub/cmhub + task_id`,不会把 direct job 送入轮询或把旧任务重新提交。
|
||||||
|
- direct job 进入 `running` 时保存仅用于本次进程的 `run_session_id`。只有应用启动阶段持有本地单进程恢复租约时,才将不属于活动会话的遗留 direct `running` job 标为失败,提示“程序中断,无法确认生成结果,请手动重新生成”;切换项目、刷新和恢复 cmhub 任务绝不触发该清理。同步请求返回图片后即使用户刚点击停止,也必须保存为成功;停止只取消未开始 job。连接/读取超时或进程中断的 direct job 不自动补发、不进入继续查询。
|
||||||
|
- `image_studio_assets.aspect_ratio` 始终保存用户选择值;新生成资产另保存可空的 `requested_output_size`、`rendered_width`、`rendered_height`。比例映射为 `1:1 -> 1024x1024`、`3:4/9:16 -> 1024x1536`、`4:3/16:9 -> 1536x1024`,后四种由运行结果返回近似比例标记。旧资产字段保持 `NULL`;初始化迁移只附加字段且可重复执行。
|
||||||
|
- T-658a 后商品套图异步提交的图片字段统一为 `images` 数组(每项仅含本地编码的 `image_base64`),单图不再保留顶层 `image_base64` 兼容字段。提交层限制最多8张、单图原文件10MiB、编码后总输入32MiB;参考图快照字段由 T-658b 扩展。
|
||||||
|
- T-658b 后 `image_studio.create_job(..., reference_asset_ids=...)` 接收同项目、去重且不包含主图的有序图片 ID 列表,并以 JSON 快照写入 `image_studio_jobs.reference_asset_ids`;`job_reference_asset_ids(job)` 负责解析和校验。历史 `NULL` 快照返回空列表,恢复/重试不根据当前商品原图补图。
|
||||||
|
- `include_failed_downloads=True` 允许 failed 但已有 `task_id`、无输出 asset 的任务继续查询,用于下载失败或本地保存失败恢复。
|
||||||
|
- 终选顺序由 `replace_selections()` 事务替换,主图/详情图同类别去重、跨类别可复用。
|
||||||
|
- 导出只写 JPEG 图片文件,透明图铺白底;商品目录已存在时只能覆盖受管命名文件或新建带时间目录,不合并、不递归清空。
|
||||||
|
- 所有真实网络/CDP/图片生成/下载/转码由 GUI worker 调用;Widget slot 不直接执行长耗时操作。
|
||||||
|
- 商品套图默认提示词首行使用 `{套图名称}` 与 `{生成目标}`,结构为“生成目标:{套图名称},{生成目标}”;五个内置分类使用固定目标描述,自定义分类使用“生成自定义分类图片:分类名称。”并注入实际分类名称。旧模板同时包含 `{套图名称}` 和 `{补充描述}` 时继续兼容,预览和 `build_job_specs()` 使用同一 renderer,job 保存最终提示词快照。
|
||||||
|
- `product_suite_reference_rule(settings, reference_asset_count)` 是 `{参考图规则}` 的唯一文案来源:逐图主图开启时返回单图规则;未开启时始终返回“参考图规则:使用第一張上傳圖作為主商品圖,其餘圖片只作為參考。”,不随 `reference_asset_count` 变化。`product_suite_prompt_context()`、提示词弹窗预览和 `build_job_specs()` 都使用该 helper;默认模板独占该变量行,不额外重复标签和序号。该固定提示词不改变实际 `reference_asset_ids` 的提交数量和顺序。
|
||||||
|
- `ProductSuiteTab._generation_confirmation_message()` 从最终 specs 的 `reference_asset_ids` 计算本次主图与参考图说明;未开启逐图主图且上传超过8张时,必须在确认框显示“参考图仅取前7张”和忽略数量。该说明不改变 `len(specs)`、点数估算或 cmhub 并发计划。
|
||||||
|
|
||||||
## gui 模块(`app/gui/` 包,已建,PySide6)
|
## gui 模块(`app/gui/` 包,已建,PySide6)
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# GUI 入口
|
# GUI 入口
|
||||||
main() -> int # 创建 QApplication + MainWindow
|
main() -> int # 创建 QApplication + MainWindow
|
||||||
class MainWindow(QMainWindow) # QTabWidget: ①②③④⑤;支持注入 db_path/config/config_path/ai_models_path 便于测试
|
class MainWindow(QMainWindow) # QTabWidget: ①、②、③、账号管理、设置、商品套图;支持注入 db_path/config/config_path/ai_models_path 便于测试
|
||||||
class CollectTab(QWidget) # ① 导入采集:导入 Excel + 汇总栏 + QTableView 任务列表 + 未匹配略过标记
|
class CollectTab(QWidget) # ① 导入采集:导入 Excel + 汇总栏 + QTableView 任务列表 + 未匹配略过标记
|
||||||
class GenerateTab(QWidget) # ② AI生成:提示词管理 + 筛选任务 + 生成封面图片成本开关 + 开始/停止生成 + 新旧封面预览 + AI生成运行日志
|
class GenerateTab(QWidget) # ② AI生成:提示词管理 + 筛选任务 + 状态范围确认 + 开始/停止生成 + 新旧封面预览 + AI生成运行日志
|
||||||
class ApplyTab(QWidget) # ③ 更新蝦皮:筛选已生成任务 + 检查本轮更新 + 安全开关拦截 + 确认后分批真实更新 + 运行日志
|
class ProductStatusScopeDialog(QDialog) # ①②复用:纵向商品状态范围选择、中文说明、默认正常范围、警示扩展范围
|
||||||
class SettingsTab(QWidget) # ⑤ 设置:cmhub 网关配置 + 响应式三列布局 + 角色/生成参数/路径端口 + 蝦皮更新安全 + 未保存状态追踪
|
class ApplyTab(QWidget) # ③ 更新蝦皮:筛选已生成任务 + 商品状态/内容预检剔除 + 检查本轮更新 + 确认后分批真实更新 + 运行日志
|
||||||
class CollectWorker(BaseWorker) # ① 后台采集:账号就绪预检 -> editor.collect -> db.set_collected/mark_skipped/mark_failed
|
class SettingsTab(QWidget) # 设置:cmhub 网关配置 + 响应式三列布局 + 角色/生成参数/路径端口 + 蝦皮更新安全 + 未保存状态追踪
|
||||||
class GenerateWorker(BaseWorker) # ② 后台生成:ai.generate_batch -> db.set_generated/mark_failed + 进度
|
class ProductSuiteTab(QWidget) # 商品套图:多任务、原图、结构配置、AI帮写、cmhub生成、历史结果
|
||||||
class ApplyWorker(BaseWorker) # ③ 后台更新:账号就绪预检 -> 检查或按批调用 editor.apply_task(close_success_tab=...) -> db.set_applied/mark_skipped
|
class ImageStudioTab(QWidget) # 旧AI工场兼容实现;主窗口不再创建
|
||||||
|
class CollectWorker(BaseWorker) # ① 后台采集:范围 normal_only/all + 账号预检 -> editor.collect -> 状态独立落库,采集或略过
|
||||||
|
class GenerateWorker(BaseWorker) # ② 后台生成:确认后的 normal_only/all 精确任务 -> ai.generate_batch -> 写库 + 进度
|
||||||
|
class ProductTabOpenWorker(BaseWorker) # ③ 手工查看:检查已有账号/Chrome 后调用 editor.open_or_focus_product_tab,不写任务数据、不自动启动或登录
|
||||||
|
class ApplyWorker(BaseWorker) # ③ 后台更新:再次拒绝非正常状态 -> 账号就绪预检 -> 检查或按批调用 editor.apply_task(...) -> db.set_applied/mark_skipped
|
||||||
class WriteBackWorker(BaseWorker) # ①/③ 后台回写:旧字段或更新结果写回原 Excel
|
class WriteBackWorker(BaseWorker) # ①/③ 后台回写:旧字段或更新结果写回原 Excel
|
||||||
class AIModelTestWorker(BaseWorker) # ⑤ 后台测试 AI 模型连接:appconfig.test_ai_model
|
class AIModelTestWorker(BaseWorker) # 设置 后台测试 AI 模型连接:appconfig.test_ai_model
|
||||||
|
class ImageStudioPullImagesWorker(BaseWorker) # 商品套图 后台只读拉蝦皮原主图 URL;安全边界协作停止
|
||||||
|
class ImageStudioDownloadOriginalWorker(BaseWorker)# 商品套图 后台下载远程原图
|
||||||
|
class ImageStudioGenerateJobsWorker(BaseWorker) # 商品套图 后台提交/查询/下载 cmhub 生图 job
|
||||||
|
class ImageStudioResumeJobsWorker(BaseWorker) # 商品套图 后台恢复已有 task_id 的生图 job
|
||||||
|
class ImageStudioExportWorker(BaseWorker) # 商品套图 后台导出终选 JPEG
|
||||||
|
class ProductSuiteImportImagesWorker(BaseWorker) # 商品套图 后台校验并复制本地/剪贴板商品原图
|
||||||
|
class ProductSuiteGenerateWorker(BaseWorker) # 商品套图 按套图job规划提交/查询/下载
|
||||||
|
class ProductSuiteAiWriteWorker(BaseWorker) # 商品套图 后台用本地原图调用图片理解,生成商品卖点与画面要求
|
||||||
|
class CMHubModelCatalogWorker(BaseWorker) # 后台读取公开模型目录并填充进程内缓存,不提交图片、不扣点
|
||||||
|
class ProductSuiteHistoryExportWorker(BaseWorker) # 商品套图 后台复制一轮历史成功生成图
|
||||||
class TaskTableModel(QAbstractTableModel) # 任务表格模型:账号/别名/商品ID/阶段;未匹配别名显示“略过”
|
class TaskTableModel(QAbstractTableModel) # 任务表格模型:账号/别名/商品ID/阶段;未匹配别名显示“略过”
|
||||||
class GenerateTaskTableModel(QAbstractTableModel) # ② 任务表格模型:店铺/商品ID/旧标题/新标题/状态;generated/未提交/非运行中新标题可本地编辑
|
class GenerateTaskTableModel(QAbstractTableModel) # ② 任务表格模型:店铺/商品ID/旧标题/新标题/状态;generated/未提交/非运行中新标题可本地编辑
|
||||||
class ApplyTaskTableModel(QAbstractTableModel) # ③ 任务表格模型:店铺/商品ID/新标题/新封面/阶段/结果;保持只读,重置更新状态走右键菜单
|
class ApplyTaskTableModel(QAbstractTableModel) # ③ 任务表格模型:店铺/商品ID/新标题/新封面/阶段/结果;保持只读,重置更新状态走右键菜单
|
||||||
class AccountsTab(QWidget) # ④ 账号管理:表格 + 增删改 + 启动登录 + 检测登录 + 快捷方式
|
class AccountsTab(QWidget) # 账号管理:表格 + 增删改 + 启动登录 + 检测登录 + 快捷方式
|
||||||
class AccountDialog(QDialog) # 账号编辑弹窗;密码 QLineEdit.Password
|
class AccountDialog(QDialog) # 账号编辑弹窗;密码 QLineEdit.Password
|
||||||
TAB_TITLES: list[str] # 固定 Tab 顺序
|
TAB_TITLES: list[str] # 固定 Tab 顺序
|
||||||
TAB_STYLE: str # 顶层 Tab 栏防误点样式:最小宽度/padding/间距/当前态
|
TAB_STYLE: str # 顶层 Tab 栏防误点样式:最小宽度/padding/间距/当前态
|
||||||
```
|
```
|
||||||
|
|
||||||
T-523 后 GUI 已从旧 `app/gui.py` 拆为 `app/gui/` 包:`__init__.py` 负责旧导入路径兼容与 `main()`;`main_window.py` 放 `MainWindow`;`models.py` 放 3 个 TableModel;`widgets.py` 放色板、空状态卡、批次总览和日志 helper;`workers.py` 放具体 GUI worker;`tabs/` 下按 ①~⑤ 拆分各 Tab。对外仍保留 `from app import gui`、`from app.gui import MainWindow/CollectTab/GenerateWorker/...`。
|
T-523 后 GUI 已从旧 `app/gui.py` 拆为 `app/gui/` 包:`__init__.py` 负责旧导入路径兼容与 `main()`;`main_window.py` 放 `MainWindow`;`models.py` 放 3 个 TableModel;`widgets.py` 放色板、空状态卡、批次总览和日志 helper;`workers.py` 放具体 GUI worker;`tabs/` 下按六个主 Tab 拆分。对外仍保留 `from app import gui`、`from app.gui import MainWindow/CollectTab/GenerateWorker/...`。
|
||||||
|
|
||||||
`MainWindow` 已实现五 Tab、① 导入采集任务列表、② AI生成布局/提示词/开始生成/停止/封面对照预览、③ 更新蝦皮筛选列表与检查/确认后分批真实更新、④ 账号管理、⑤ AI 模型管理。缺 PySide6 时 `main()` 返回 1 并输出明确提示。
|
`MainWindow` 已实现六 Tab、① 导入采集任务列表、② AI生成布局/提示词/开始生成/停止/封面对照预览、③ 更新蝦皮筛选列表与检查/确认后分批真实更新、账号管理、设置中的 cmhub 配置、商品套图生成。缺 PySide6 时 `main()` 返回 1 并输出明确提示。
|
||||||
|
|
||||||
主 Tab 栏必须在 `MainWindow` 初始化时应用 `TAB_STYLE`:5 个 Tab 不使用 Qt 默认紧凑宽度,需保证点击区域稳定、间距清晰、当前 Tab 高亮明显。该样式属于全局导航基础,不归后续业务 Tab 任务重复实现。
|
主 Tab 栏必须在 `MainWindow` 初始化时应用 `TAB_STYLE`:6 个 Tab 不使用 Qt 默认紧凑宽度,需保证点击区域稳定、间距清晰、当前 Tab 高亮明显。该样式属于全局导航基础,不归后续业务 Tab 任务重复实现。
|
||||||
|
|
||||||
④ 账号管理要点:
|
账号管理要点:
|
||||||
|
|
||||||
- 表格列:账号名、别名、地区、端口、登录状态、备注;不展示密码。
|
- 表格列:账号名、别名、地区、端口、登录状态、备注;不展示密码。
|
||||||
- 弹窗字段:账号名、别名、地区、调试端口、密码、备注、slug、数据目录;密码框使用打码显示;首次保存/变更非空密码前弹窗提示“本地明文保存”。
|
- 弹窗字段:账号名、别名、地区、调试端口、密码、备注、slug、数据目录;密码框使用打码显示;首次保存/变更非空密码前弹窗提示“本地明文保存”。
|
||||||
- 「启动登录」必须幂等:若该账号 Chrome/CDP 已打开,则复用现有窗口并打开/激活卖家中心登录 tab,不重复启动 Chrome;未打开时才新启动 Chrome,仍由用户人工登录。「检测登录」用 worker 跑 `accounts.detect_login()` 并刷新状态列;若当前 URL 跳到 `accounts.shopee.tw/seller/login`,状态必须显示未登录;「快捷方式」调用 `accounts.create_shortcut()` 生成默认桌面 `.lnk`。
|
- 「启动登录」必须幂等:新增账号后不自动启动;若该账号 Chrome/CDP 已打开,则复用现有窗口并打开/激活卖家中心登录 tab,不重复启动 Chrome,也不覆盖普通页面;未打开时用卖家中心初始 URL 新启动并复用初始 page target,异常时最多兜底新建一个 tab。启动期间禁用账号选择及增删改/登录相关操作,连续触发只记 `ignored` 日志;成功或失败均恢复控件。`chrome_launch` 日志记录初始 target 是否复用、是否兜底建 tab 和原因。「检测登录」用 worker 跑 `accounts.detect_login()` 并刷新状态列;若当前 URL 跳到 `accounts.shopee.tw/seller/login`,状态必须显示未登录;「快捷方式」调用 `accounts.create_shortcut()` 生成默认桌面 `.lnk`。
|
||||||
|
|
||||||
⑤ 设置当前要点(T-501):
|
设置当前要点(T-501):
|
||||||
|
|
||||||
- `SettingsTab` 使用居中内容区 + 适度左右留白布局,当前留白已从 T-506 初始实现缩短到约 40%;实现上使用最大内容宽度和自适应 margin,避免固定像素导致小屏挤压。各设置组默认响应式 3 列表单:短字段占 1 格,长字段(URL/API Key/路径)跨 2 格或 3 格,窄窗口降为 2 列/1 列。点击「保存设置」成功后,调用 `QMessageBox.information` 弹出“设置已保存”轻量提示框,同时保留状态栏提示。T-531 已完成:`save_app_settings()` 返回 bool,成功写 `data/config.json` + `data/config/cmhub.json` 后清 dirty,失败保留 dirty 并让调用方阻止离开。
|
- `SettingsTab` 使用居中内容区 + 适度左右留白布局,当前留白已从 T-506 初始实现缩短到约 40%;实现上使用最大内容宽度和自适应 margin,避免固定像素导致小屏挤压。各设置组默认响应式 3 列表单:短字段占 1 格,长字段(URL/API Key/路径)跨 2 格或 3 格,窄窗口降为 2 列/1 列。点击「保存设置」成功后,调用 `QMessageBox.information` 弹出“设置已保存”轻量提示框,同时保留状态栏提示。T-531 已完成:`save_app_settings()` 返回 bool,成功写 `data/config.json` + `data/config/cmhub.json` 后清 dirty,失败保留 dirty 并让调用方阻止离开。
|
||||||
- `SettingsTab` 的 cmhub 网关配置:Base URL 保存/刷新前规整为网关根;API Key 单独读写 `data/config/cmhub.json`;别名下拉来自 `fetch_cmhub_models()`,按 `operation_type` 分生文/生图并过滤未计价别名;「测试连接/查余额」调用 cmhub models + balance helper;保存设置固定写 `ai.backend=cmhub`。`refresh_cmhub_models()` / `test_cmhub_connection()` 使用输入框实时值但不得自动保存,成功文案提醒用户保存。`is_dirty()` / `discard_unsaved_changes()` / `_suspend_dirty`(或等价机制)用于 T-531:用户编辑置脏,程序化回填不置脏,放弃时重新加载 `data/config.json` + `data/config/cmhub.json` 并回填控件。
|
- `SettingsTab` 的生成网关配置:用 `gatewayDefaultButton` / `gatewayCustomButton` 分段选择器表达 `cmhub` / `direct` 来源,选择仅标记待保存,保存后才写 `ai.backend`。默认网关 Base URL 保存/刷新前规整为网关根,API Key 单独读写 `data/config/cmhub.json`,别名下拉来自 `fetch_cmhub_models()`;自定义网关复用 `data/config/ai_models.json` 的模型 CRUD 与文本/图片角色选择。图片模型只展示「OpenAI 图片编辑接口」;历史不兼容配置保留并标示。图片模型的「检查图片配置」只做本地校验,不发送网络图片请求。两套配置并存,模型 CRUD 立即保存;`refresh_cmhub_models()` / `test_cmhub_connection()` 使用输入框实时值但不得自动保存。`settingsSaved` 通知主窗口刷新⑥能力状态;`is_dirty()` / `discard_unsaved_changes()` / `_suspend_dirty`(或等价机制)用于保存和放弃来源选择。
|
||||||
- T-532 要求 `SettingsTab._on_cmhub_finished()` 从 worker payload 的 `balance` / user/account 字段提取 cmhub 账号身份,成功文案优先显示 `cmhub 账号「<账号名>」连接成功:...`;当前 `/balance` 结构兼容 `{ "user": "cmhub_user", "points_balance": 88, "account": { "username": "cmhub_user", "display_name": "主账号" } }`,显示名优先 `account.display_name`,再兜底 `account.username` / `user` / 顶层常见字段;账号字段缺失时保持 `cmhub 连接成功:...`。显示名必须脱敏处理邮箱,且不得把 API Key、token 或完整敏感响应写入 GUI、run log 或诊断日志。
|
- T-532 要求 `SettingsTab._on_cmhub_finished()` 从 worker payload 的 `balance` / user/account 字段提取 cmhub 账号身份,成功文案优先显示 `cmhub 账号「<账号名>」连接成功:...`;当前 `/balance` 结构兼容 `{ "user": "cmhub_user", "points_balance": 88, "account": { "username": "cmhub_user", "display_name": "主账号" } }`,显示名优先 `account.display_name`,再兜底 `account.username` / `user` / 顶层常见字段;账号字段缺失时保持 `cmhub 连接成功:...`。显示名必须脱敏处理邮箱,且不得把 API Key、token 或完整敏感响应写入 GUI、run log 或诊断日志。
|
||||||
- `MainWindow` 已负责⑤设置页离开守卫:切 Tab 与 `closeEvent` 发现 `SettingsTab.is_dirty()` 时弹保存/放弃/取消;保存成功后继续,保存失败或取消时回到⑤。由于 `QTabWidget.currentChanged` 是切换后信号,需维护上一个 index,并用 `_reverting_tab_change` 或等价 guard 防止 `setCurrentIndex()` 递归。
|
- `MainWindow` 已负责设置页离开守卫:切 Tab 与 `closeEvent` 发现 `SettingsTab.is_dirty()` 时弹保存/放弃/取消;保存成功后继续,保存失败或取消时回到设置。由于 `QTabWidget.currentChanged` 是切换后信号,需维护上一个 index,并用 `_reverting_tab_change` 或等价 guard 防止 `setCurrentIndex()` 递归。
|
||||||
- 模型详情字段按 3 个组件一组排列:启用、类别、api_type、连接超时等短字段一格;服务商名、模型 ID 视宽度占一格或两格;网址、密钥跨整行或跨 2/3 列。
|
- 模型详情字段按 3 个组件一组排列:启用、类别、api_type、连接超时等短字段一格;服务商名、模型 ID 视宽度占一格或两格;网址、密钥跨整行或跨 2/3 列。
|
||||||
- 模型数据读写复用 `appconfig.list_ai_models(..., reveal_api_key=True)`、`add_ai_model()`、`update_ai_model()`、`delete_ai_model()`;保存时保留现有 `extra_body` 与 `timeout_seconds`。
|
- 模型数据读写复用 `appconfig.list_ai_models(..., reveal_api_key=True)`、`add_ai_model()`、`update_ai_model()`、`delete_ai_model()`;保存时保留现有 `extra_body` 与 `timeout_seconds`。
|
||||||
- 密钥字段使用 `QLineEdit.Password` 打码显示;首次保存/变更非空 Key 前弹窗提示“本地明文保存”;明文只写入已 gitignore 的 `data/config/ai_models.json`,不得进入日志/导出。
|
- 密钥字段使用 `QLineEdit.Password` 打码显示;首次保存/变更非空 Key 前弹窗提示“本地明文保存”;明文只写入已 gitignore 的 `data/config/ai_models.json`,不得进入日志/导出。
|
||||||
- 删除按钮在当前类别只剩 1 个模型时禁用;后端仍以“至少启用一个 text 和 image 模型”为硬约束。
|
- 删除按钮在当前类别只剩 1 个模型时禁用;后端仍以“至少启用一个 text 和 image 模型”为硬约束。
|
||||||
- 「测试连接」创建 `AIModelTestWorker` 后台调用 `appconfig.test_ai_model()`,GUI 主线程不直接发网络请求。
|
- 「测试连接」创建 `AIModelTestWorker` 后台调用 `appconfig.test_ai_model()`,GUI 主线程不直接发网络请求。
|
||||||
- 角色与生成参数读写 `config.json`,并按 3 个组件一组排列:标题大模型(仅 text)、图片大模型(仅 image)、标题/图片并发、失败重试、分辨率、jpg 质量。
|
- 角色与生成参数读写 `config.json`,并按 3 个组件一组排列:标题大模型(仅 text)、图片大模型(仅 image)、标题/图片并发、失败重试、分辨率、jpg 质量。
|
||||||
- 分辨率下拉固定 `512/1k/2k/4k`;普通默认 cmhub 模式下,返回超时标签只读展示实际等待口径「标题 600 秒 / 图片 650 秒」,分辨率只控制生成图片尺寸。direct 兼容路径仍使用 `resolution_timeouts[resolution]`。
|
- 分辨率下拉固定 `512/1k/2k/4k`;普通默认 cmhub 模式下,返回超时标签只读展示实际等待口径「标题 600 秒 / 图片 900 秒」,分辨率只控制生成图片尺寸。direct 兼容路径仍使用 `resolution_timeouts[resolution]`。
|
||||||
- 路径与端口读写 `config.json`,并按 3 个组件一组排列:默认调试端口、调试端口范围、CDP 就绪超时等短字段一格;Chrome 路径、账号数据根目录、图片目录、DB 路径等长字段跨整行或跨 2/3 列。保存时校验端口范围和默认端口。
|
- 路径与端口读写 `config.json`,并按 3 个组件一组排列:默认调试端口、调试端口范围、Chrome 就绪超时等短字段一格;Chrome 路径、账号数据根目录、图片目录、DB 路径等长字段跨整行或跨 2/3 列。保存时校验端口范围和默认端口。
|
||||||
- 蝦皮更新安全读写 `config.json` 的 `shopee_update` 段,并按 3 个组件一组排列:允许真实提交、允许更新封面、每批最大更新条数、成功后关闭本次新开编辑页、多账号并行、最大并行账号数;其中「多账号并行更新」与「最大并行账号数」必须合并为同一个横向组件,最大并行账号数紧跟在多账号并行更新后面,不允许被三列表单排到下一行。`dry_run` 字段可保留为内部兼容,但普通用户界面不再展示 dry-run 开关,③ 使用「检查本轮更新」按钮触发检查模式;测试商品 ID 仅作为历史/调试兼容字段保留,不参与普通正式更新安全检查,普通设置页已隐藏该入口。
|
- 蝦皮更新执行读写 `config.json` 的 `shopee_update` 段,并按 3 个组件一组排列:每批最大更新条数、同时更新蝦皮账号。`dry_run` 字段可保留为内部兼容,但普通用户界面不再展示 dry-run 开关,③ 使用「检查本轮更新」按钮触发检查模式;测试商品 ID 和旧封面开关仅作为历史/调试兼容字段读取,普通设置页无入口,保存后不再写回。
|
||||||
- 真实提交与封面更新默认关闭;用户在 ⑤ 保存开启后,③ 仍需要通过安全开关检查并弹窗确认,才会创建更新 worker。
|
- 更新内容默认只更新标题。③ 左下角「更新内容」下拉选择只更新标题、只更新封面或更新标题和封面;开始前先按所选内容剔除缺失记录,仍有可执行记录时在确认框说明跳过项后创建更新 worker,全部缺失时才弹窗中止。
|
||||||
|
|
||||||
① 导入采集当前要点(T-202/T-202b):
|
① 导入采集当前要点(T-202/T-202b):
|
||||||
|
|
||||||
@@ -409,13 +584,14 @@ T-523 后 GUI 已从旧 `app/gui.py` 拆为 `app/gui/` 包:`__init__.py` 负
|
|||||||
- 任务列表使用 `QTableView + TaskTableModel`,列为:账号、别名、商品ID、阶段。
|
- 任务列表使用 `QTableView + TaskTableModel`,列为:账号、别名、商品ID、阶段。
|
||||||
- 账号列优先显示匹配到的 `accounts.account_name`;未匹配账号时保留 Excel 输入账号名。
|
- 账号列优先显示匹配到的 `accounts.account_name`;未匹配账号时保留 Excel 输入账号名。
|
||||||
- 别名未匹配 `accounts.alias` 时列表阶段列显示“略过”;点击「采集旧标题/旧封面」后由 `CollectWorker` 逐条写库为 `skipped`,原因 `别名未匹配账号`。
|
- 别名未匹配 `accounts.alias` 时列表阶段列显示“略过”;点击「采集旧标题/旧封面」后由 `CollectWorker` 逐条写库为 `skipped`,原因 `别名未匹配账号`。
|
||||||
- 「采集旧标题/旧封面」通过 `CollectWorker` 后台执行,只处理 `stage=imported` 的任务;采集前先做账号就绪预检。无账号、当前批次匹配账号未启动 CDP 端口或未登录时,返回 `blocked=True`,GUI 弹窗汇总并跳转/引导去④账号管理,不进入逐条采集、不写 skipped/failed。预检通过后,已匹配任务调用 `editor.collect()` 下载旧封面到 `image_dir/<batch_id>/<slug>/<task_id>_<item_id>_old.jpg` 并 `db.set_collected()`;别名未匹配任务仍逐条 `mark_skipped`;单条失败 `mark_failed(..., "collect", error)` 后继续。采集完成且本轮成功采集数量大于 0 时,自动触发当前批次旧字段回写;锁文件失败时只提示,不回滚 SQLite。
|
- 「采集旧标题/旧封面」先弹 `ProductStatusScopeDialog` 范围确认框,默认按钮为“采集架上商品”(稳定策略 `normal_only`,仅继续采集检测结果为正常的商品),扩展按钮为“采集全部商品”(`all`,还包括未上架、审核中和状态未知商品);后者是警示橙色的扩大范围操作而非删除操作。对话框最小宽度 520px,三个中文选项纵向全宽显示,取消不创建 Worker。`CollectWorker` 只处理 `stage=imported` 的任务;采集前先做账号就绪预检。无账号、当前批次匹配账号未启动 CDP 端口或未登录时,返回 `blocked=True`,GUI 弹窗汇总并跳转/引导去账号管理,不进入逐条采集、不写 skipped/failed。预检通过后,已匹配任务调用 `editor.collect()` 先检测和保存页面状态;默认范围仅正常商品下载旧封面到 `image_dir/<batch_id>/<slug>/<task_id>_<item_id>_old.jpg` 并 `db.set_collected()`,其他状态 `set_product_status()` 后按范围 `mark_skipped` 且保留原旧内容;选择全部范围时四类状态均采集。别名未匹配任务仍逐条 `mark_skipped`;单条技术失败 `mark_failed(..., "collect", error)` 后继续。采集完成且本轮成功采集数量大于 0 时,自动触发当前批次旧字段回写;锁文件失败时只提示,不回滚 SQLite。商品状态功能上线时间前的活动历史批次空状态由 `init_db()` 默认迁移为 `normal`;批次时间不可靠但已有采集、生成、更新阶段/时间或任一新旧标题封面内容的活动空状态任务,也在启动时按相同历史默认语义迁移,并在数据库同级 `backups/` 先生成一致性备份。刚导入且没有处理证据的空状态不迁移;同一范围内仅修复由已知促销编辑提示误写的 `unknown`,不打开 Chrome、不创建 Worker,其他未知状态保持未知语义。
|
||||||
- 采集打开商品页时,若本轮自动新建 tab,采集完成/失败后会关闭该 tab;若失败发生在 `open_product()` 内部且尚未返回 `cdp`,也要关闭本轮自动新建 tab;若复用用户已打开的商品页,只断开 CDP 连接不关闭页面。
|
- 采集打开商品页时,若本轮自动新建 tab,采集完成后会关闭该 tab,并在最多 2 秒内确认 target 已从 `/json` 消失;确认超时只写 warning/诊断,不把采集成功改成失败。若失败发生在 `open_product()` 内部且尚未返回 `cdp`,也要关闭本轮自动新建 tab;若复用用户已打开的商品页,只断开 CDP 连接不关闭页面。
|
||||||
|
- 采集中途登录检测必须快速跳过正在销毁的旧商品 target,改连其他有效 Shopee 页面。Cookie API 调用失败返回 `LOGIN_CHECK_TARGET_UNAVAILABLE`,只有 Cookie API 成功返回空会话时才返回 `NO_SESSION_COOKIE`;两者都不按明确掉登录批量略过,显式 `LOGIN_PAGE` 仍按账号需登录处理。retry/recovered 运行日志包含当前任务 ID 和商品 ID,避免与上一条采集成功日志混淆。
|
||||||
- 采集打开商品页失败时,`CollectWorker` 应把 `open_product()` 捕获到的 Shopee toast 文案写入 `run_log_events` 和 `tasks.last_error`;商品 ID 失效、无权限、店铺不匹配等场景不得只显示泛化超时。① `TaskTableModel` 的“阶段”列只在 `last_error` 明确为商品失效类错误时显示“商品失效”,否则仍按 `status=failed` 显示“失败”;底层不新增 `stage` 枚举。
|
- 采集打开商品页失败时,`CollectWorker` 应把 `open_product()` 捕获到的 Shopee toast 文案写入 `run_log_events` 和 `tasks.last_error`;商品 ID 失效、无权限、店铺不匹配等场景不得只显示泛化超时。① `TaskTableModel` 的“阶段”列只在 `last_error` 明确为商品失效类错误时显示“商品失效”,否则仍按 `status=failed` 显示“失败”;底层不新增 `stage` 枚举。
|
||||||
|
|
||||||
- 「停止」调用 worker 的协作式 `cancel()`,已开始的单条跑到安全边界后结束。
|
- 「停止」调用 worker 的协作式 `cancel()`,已开始的单条跑到安全边界后结束。
|
||||||
- 「回写旧数据到 Excel」通过 `WriteBackWorker` 后台调用 `excel.write_back()`,把已采集旧标题/旧封面路径按原 Excel 行定位写回;该按钮主要作为自动回写失败后的手动重试入口。原文件被占用时弹窗提示关闭后重试,SQLite 采集结果不回滚。
|
- 「回写旧数据到 Excel」通过 `WriteBackWorker` 后台调用 `excel.write_back()`,把已采集旧标题/旧封面路径按原 Excel 行定位写回;该按钮主要作为自动回写失败后的手动重试入口。原文件被占用时弹窗提示关闭后重试,SQLite 采集结果不回滚。
|
||||||
- 采集前由 `CollectWorker` 做账号就绪预检:无账号、当前批次匹配账号未启动 CDP 端口或未登录时,返回 `blocked=True`,GUI 弹窗汇总并跳转/引导去④账号管理;不无提示批量启动所有账号 Chrome。匹配账号未登录属于预检阻断,不是逐条 skipped。
|
- 采集前由 `CollectWorker` 做账号就绪预检:无账号、当前批次匹配账号未启动 CDP 端口或未登录时,返回 `blocked=True`,GUI 弹窗汇总并跳转/引导去账号管理;不无提示批量启动所有账号 Chrome。匹配账号未登录属于预检阻断,不是逐条 skipped。
|
||||||
|
|
||||||
② AI生成当前要点(T-302/T-302p/T-303/T-303b + 诊断日志补丁):
|
② AI生成当前要点(T-302/T-302p/T-303/T-303b + 诊断日志补丁):
|
||||||
|
|
||||||
@@ -424,31 +600,35 @@ T-523 后 GUI 已从旧 `app/gui.py` 拆为 `app/gui/` 包:`__init__.py` 负
|
|||||||
- 封面提示词模板下拉读取 `data/prompts/cover/*.txt`;支持新建、保存、另存为、重命名、删除。删除由 GUI 二次确认,删空后下拉显示内存态“默认”,不会自动建文件。
|
- 封面提示词模板下拉读取 `data/prompts/cover/*.txt`;支持新建、保存、另存为、重命名、删除。删除由 GUI 二次确认,删空后下拉显示内存态“默认”,不会自动建文件。
|
||||||
- 「插入标题」在封面提示词光标处插入 `{新标题}`;「预览」使用当前选中任务(无选择则用第一条)调用 `prompts.render_prompt()` 并弹窗展示。
|
- 「插入标题」在封面提示词光标处插入 `{新标题}`;「预览」使用当前选中任务(无选择则用第一条)调用 `prompts.render_prompt()` 并弹窗展示。
|
||||||
- 筛选栏包含:批次、店铺、商品ID、状态、刷新。批次来自 `db.list_batches()`;店铺来自当前任务别名并优先显示匹配账号名;商品ID输入框按包含匹配 `item_id`,清空表示全部;状态支持全部/待生成/已生成/失败/略过/已更新。
|
- 筛选栏包含:批次、店铺、商品ID、状态、刷新。批次来自 `db.list_batches()`;店铺来自当前任务别名并优先显示匹配账号名;商品ID输入框按包含匹配 `item_id`,清空表示全部;状态支持全部/待生成/已生成/失败/略过/已更新。
|
||||||
|
- ②商品状态筛选初始为「架上商品」;③商品状态筛选初始也为「架上商品」。①保持初始「全部商品状态」,避免待检测的新导入记录被隐藏。用户主动选择任一商品状态后,刷新或调整批次、店铺、商品ID、处理/生成/更新状态时继续保留该筛选值。商品状态筛选只缩小当前列表,不能绕过①采集范围确认、②生成范围确认或③状态/内容预检。
|
||||||
- 任务列表使用 `QTableView + GenerateTaskTableModel`,列为:店铺、商品ID、旧标题、新标题、状态。`stage=collected` 显示“待生成”,`stage=generated` 显示“已生成”,`status=failed/skipped/running` 优先显示对应状态;已生成、未提交线上、非运行中的「新标题」列可双击编辑,调用 `db.update_generated_title()` 写回本地并清空 `last_error`。
|
- 任务列表使用 `QTableView + GenerateTaskTableModel`,列为:店铺、商品ID、旧标题、新标题、状态。`stage=collected` 显示“待生成”,`stage=generated` 显示“已生成”,`status=failed/skipped/running` 优先显示对应状态;已生成、未提交线上、非运行中的「新标题」列可双击编辑,调用 `db.update_generated_title()` 写回本地并清空 `last_error`。
|
||||||
- 双击「新标题」列进入本地编辑;双击其他列弹窗展示旧封面与新封面路径对应图片;图片不存在时显示空态/路径提示,只做查看,不做审核。
|
- 双击「新标题」列进入本地编辑;双击其他列弹窗展示旧封面与新封面路径对应图片;图片不存在时显示空态/路径提示,只做查看,不做审核。
|
||||||
- 底部「开始生成」只处理当前筛选结果里 `stage=collected` 的任务;批次/店铺/商品ID/状态筛选共同决定当前筛选结果;点击「开始生成」时先清空 `GenerateTab` 可见日志文本并写入本轮开始摘要,后续只追加本轮日志;通过 `GenerateWorker` 调 `ai.generate_batch()`,先并发标题;只有②「生成封面图片(成本较高)」勾选时才继续并发封面。cmhub 模式开始摘要显示“图片并发 X,cmhub实际生图并发 Y,下载并发 Y”,其中 `Y=min(X,5)`。
|
- 底部「开始生成」只处理当前筛选结果里可补齐的任务;批次/店铺/商品ID/状态筛选共同决定当前筛选结果;②「生成内容」下拉支持只生成标题、只生成封面、生成标题和封面。只生成封面时不调用生文,任务有新标题则使用新标题,否则使用已采集旧标题作为封面prompt回退;新旧标题都为空才不纳入。候选存在时由 `product_status.build_generation_plan()` 统计正常/未上架/审核中/未知并冻结指纹,弹出与①共用的 `ProductStatusScopeDialog`:默认按钮为“生成架上商品”(`normal_only`,仅将检测结果为正常的商品交给 AI),扩展按钮为“生成全部商品”(`all`,还会处理未上架、审核中和状态未知商品,可能额外消耗点数),后者使用警示橙色的扩大范围样式;历史未采集状态按未知处理,默认不入队。取消或指纹变化时不创建 Worker;无异常候选时 `all` 禁用。`GenerateWorker` 接收最终精确任务列表,并在 `normal_only` 下再次排除非正常任务后才调用 `ai.generate_batch()`。点击「开始生成」时先清空 `GenerateTab` 可见日志文本并写入本轮开始摘要,后续只追加本轮日志;运行日志 options/summary 记录范围、状态计数、范围排除数和计划指纹。cmhub 模式开始摘要显示“图片并发 X,cmhub实际生图并发 Y,下载并发 Y”,其中 `Y=min(X,5)`。
|
||||||
- 「停止」调用 worker 的协作式 `cancel()`;未开始的 Future 取消,不记失败;已完成的 `db.set_generated()` 结果保留,可再次开始生成剩余任务。
|
- 「停止」调用 worker 的协作式 `cancel()`;未开始的 Future 取消,不记失败;已完成的 `db.set_generated()` / `db.set_generated_cover()` 组件结果保留,可再次开始生成剩余任务。
|
||||||
- 进度标签显示标题/图片/失败;未勾选生成封面时图片进度显示本轮未生成,标题成功即 `db.set_generated(task_id, new_title, None)` 并进入 generated;勾选时每条封面生成成功后立即 `db.set_generated(task_id, new_title, new_cover_path)`。失败 `db.mark_failed(..., "generate", error)`,GUI 刷新任务表。
|
- ②任务表右键先选中鼠标命中行,仅提供「删除本条记录」;确认后调用 `db.delete_task()`,不物理删除 Excel、图片、账号 Chrome 或蝦皮线上商品。生成中或任务 `running` 时禁用;成功后通过主窗口刷新①②③。
|
||||||
|
- 进度标签显示标题/图片/失败;只生成标题时图片进度显示本轮未生成,标题成功即 `db.set_generated(task_id, new_title, existing_cover_path)` 并进入 generated;封面成功立即调用 `db.set_generated_cover(task_id, new_cover_path)`,只更新封面、不覆盖或伪造新标题。只生成封面得到“有封面、无新标题”时,②标题状态继续待生成、图片状态已生成。失败 `db.mark_failed(..., "generate", error)`,GUI 刷新任务表。
|
||||||
- T-404a 已实现:「重置生成结果」读取当前选中单条;确认后调用 DB reset,清空本地 AI 结果并退回可生成状态;默认不删除本地新封面文件;写 `run_type=reset` 事件。
|
- T-404a 已实现:「重置生成结果」读取当前选中单条;确认后调用 DB reset,清空本地 AI 结果并退回可生成状态;默认不删除本地新封面文件;写 `run_type=reset` 事件。
|
||||||
|
|
||||||
③ 更新蝦皮当前要点(T-303b/T-401/T-402/T-403):
|
③ 更新蝦皮当前要点(T-303b/T-401/T-402/T-403):
|
||||||
|
|
||||||
- `ApplyTab` 顶部筛选栏包含:批次、店铺、商品ID、状态、刷新。批次来自 `db.list_batches()`;店铺来自当前更新候选任务别名并优先显示匹配账号名;商品ID输入框按包含匹配 `item_id`,清空表示全部。
|
- `ApplyTab` 顶部筛选栏包含:批次、店铺、商品ID、状态、刷新。批次来自 `db.list_batches()`;店铺来自当前更新候选任务别名并优先显示匹配账号名;商品ID输入框按包含匹配 `item_id`,清空表示全部。
|
||||||
- ③ 只列出已具备新标题/新封面、可进入更新阶段的任务:`stage=generated/applied`,或已有新字段且 `status=failed/skipped` 的任务。
|
- ③ 只列出已生成或可查看的更新候选任务:`stage=generated/applied`,或已有新字段且 `status=failed/skipped` 的任务;真正开始更新前先按 `product_status` 分出可进入线上更新的 `normal` 与必须排除的 `unlisted/reviewing/unknown`。商品状态功能上线前的活动历史空状态,以及批次时间不可靠但已有处理证据的活动空状态,启动时默认迁移为 `normal`;同一范围内已知促销编辑提示误写的 `unknown` 会精确修正为 `normal`。无处理证据的 NULL 和其他未知状态仍归入未知,再按③「更新内容」模式分出可执行任务与缺少 `new_title` / `new_cover_path` 的预检跳过记录。
|
||||||
- 状态筛选支持:已生成(默认,`stage=generated` 且 `status=success/pending`)、失败、已更新、略过、全部状态。
|
- 状态筛选支持:已生成(默认,`stage=generated` 且 `status=success/pending`)、失败、已更新、略过、全部状态。
|
||||||
- 任务列表使用 `QTableView + ApplyTaskTableModel`,列为:店铺、商品ID、新标题、新封面、阶段、结果。
|
- 任务列表使用 `QTableView + ApplyTaskTableModel`,列为:店铺、商品ID、新标题、新封面、阶段、结果。
|
||||||
- 「开始更新」只读取当前筛选结果;无任务时只提示,不弹确认、不改库;该按钮是③的主操作,视觉上强于检查、停止和回写。
|
- 「开始更新」只读取当前筛选结果;无任务时只提示,不弹确认、不改库;该按钮是③的主操作,视觉上强于检查、停止和回写。
|
||||||
- 「检查本轮更新」只读取当前筛选结果并创建检查运行日志,不打开 Shopee、不调用 `editor.apply_task()`、不写任务状态、不回写 Excel;检查内容包含任务总数、店铺分布、每批最大条数、预计批次数、会更新字段和略过原因。
|
- 「检查本轮更新」只读取当前筛选结果并创建检查运行日志,不打开 Shopee、不调用 `editor.apply_task()`、不写任务状态、不回写 Excel;检查内容包含任务总数、店铺分布、每批最大条数、预计批次数、更新内容、会更新字段和略过原因。
|
||||||
- 点击「开始更新」先读取 `shopee_update`:未允许真实提交,或包含新封面但未允许封面更新时,弹警告并阻断;拦截弹窗写明具体设置项并提供「前往设置」跳到⑤;普通正式更新不再检查 `test_item_id`,当前筛选结果可包含多个真实商品 ID;`max_items_per_run` 作为每批最大更新条数,当前筛选结果超过该值时自动分批。通过后才弹窗展示批次/店铺/商品ID/状态/任务总数、每批最大条数、预计批次数、提交线上风险和当前安全设置。
|
- 点击「开始更新」和「检查本轮更新」都先使用同一 `build_apply_plan()` 预检当前筛选任务:仅商品状态 `normal` 继续按③「更新内容」模式检查,只更新标题要求 `new_title`,只更新封面要求 `new_cover_path`,更新标题和封面要求两者都有。未上架、审核中、状态未知和历史 NULL 均不能传入正式或检查模式的 `ApplyWorker`;状态异常与内容缺失在确认框、状态栏摘要和本轮日志分类列出数量与示例商品ID,不打开其 Chrome、不写其失败状态或 Excel,也不计入 worker 成功/略过/失败统计。所有记录被排除时,若存在状态异常则弹「商品状态不允许更新」,否则弹「更新内容未生成」。确认后按 `task_id/updated_at/product_status/new_title/new_cover_path/更新模式` 重新验证计划指纹,变化即中止。再读取 `shopee_update` 执行参数:普通正式更新不再检查 `test_item_id` 或旧真实提交开关,当前筛选结果可包含多个真实商品 ID;`max_items_per_run` 作为每批最大更新条数,当前可执行任务超过该值时自动分批。通过后才弹窗展示批次/店铺/商品ID/状态/更新内容/可执行任务数、预检跳过项、每批最大条数、预计批次数、提交线上风险和当前执行设置。
|
||||||
- 用户点否/取消时不执行、不改库;用户点是后才创建 `ApplyWorker` 做真实提交。
|
- 用户点否/取消时不执行、不改库;用户点是后才创建 `ApplyWorker` 做真实提交。
|
||||||
- `ApplyWorker` 只处理当前筛选结果里 `stage=generated` 且已有新标题或新封面、状态为 `success/pending/failed` 的任务;已更新和略过记录仅查看,不会再次提交,除非用户先用 T-404a 的「重置更新状态」把选中记录退回可更新。
|
- `ApplyWorker` 只处理经过 `ApplyTab` 预检后的任务,并在执行层再次拒绝商品状态不是 `normal` 的记录:原候选范围为 `stage=generated`、状态为 `success/pending/failed`,缺少当前 `update_mode` 所需内容的记录不传入 worker;只更新标题时不替换封面,只更新封面时不改标题。预检跳过记录保持原 stage/status/Excel,不被计入本轮 worker 的成功、略过或失败统计。已更新和略过记录仅查看,不会再次提交,除非用户先用 T-404a 的「重置更新状态」把选中记录退回可更新。
|
||||||
|
- ③任务表右键先选中鼠标命中行,保留「重置更新状态」,并以分隔线提供「删除本条记录」。删除调用 `db.delete_task()`,运行中禁用;已提交线上时确认框明确本地删除不回滚蝦皮。成功后刷新①②③,不修改 Excel、图片、账号 Chrome 或线上商品。
|
||||||
|
- ③任务表仅左键双击命中行才创建 `ProductTabOpenWorker`。该 Worker 只检查当前别名账号与其已运行的 Chrome/CDP,不自动启动 Chrome、不自动登录;现有精确商品页只聚焦,缺失时才新建前台页。右键双击不触发,更新或结果回写运行中不启动;成功/失败通过状态栏显示中文结果,不写任务、Excel、图片或运行日志。
|
||||||
- 检查本轮更新:不做账号登录预检,不调用 `editor.apply_task()`,不写任务状态,不回写 Excel;只把每条“将更新/将略过”写入运行日志并弹汇总。
|
- 检查本轮更新:不做账号登录预检,不调用 `editor.apply_task()`,不写任务状态,不回写 Excel;只把每条“将更新/将略过”写入运行日志并弹汇总。
|
||||||
- 真实更新前先做账号就绪预检:无账号、当前筛选结果匹配账号 Chrome 未启动、CDP 端口不可访问、未登录,或本轮涉及账号调试端口冲突时,返回 `blocked=True`,GUI 弹窗汇总并跳转/引导去④账号管理;预检不通过时不调用 `editor.apply_task()`、不写失败状态、不自动启动 Chrome。
|
- 真实更新前先做账号就绪预检:无账号、当前筛选结果匹配账号 Chrome 未启动、CDP 端口不可访问、未登录,或本轮涉及账号调试端口冲突时,返回 `blocked=True`,GUI 弹窗汇总并跳转/引导去账号管理;预检不通过时不调用 `editor.apply_task()`、不写失败状态、不自动启动 Chrome。
|
||||||
- 预检通过后默认串行;若 `parallel_accounts=true` 且 `max_parallel_accounts>1`,按账号分组并行执行,不同账号可同时跑,同一账号内仍串行。每条执行 `db.mark_running(..., "apply")` → `editor.apply_task(account, task, close_success_tab=设置值)` → `db.set_applied()`;成功推进 `stage=applied/status=success/committed=1`,失败保持原 stage、`status=failed/committed=0/last_error`,单条失败继续下一条。
|
- 预检通过后默认串行;若 `max_parallel_accounts>1`,按账号分组并行执行,不同账号可同时跑,同一账号内仍串行。每条执行 `db.mark_running(..., "apply")` → `editor.apply_task(account, task)` → `db.set_applied()`;成功推进 `stage=applied/status=success/committed=1`,失败保持原 stage、`status=failed/committed=0/last_error`,单条失败继续下一条。
|
||||||
- 真实更新打开商品页失败时,`ApplyWorker` 应把 `open_product()` 捕获到的 Shopee toast 文案上浮到③可见运行日志、`run_log_events` 和任务失败原因;若失败发生在 `open_product()` 内部,本轮自动新建 tab 要关闭,复用用户已有 tab 不关闭;本地 `data/logs/` 可保存失败现场 HTML/toast JSON 片段供开发排查,但必须脱敏。
|
- 真实更新打开商品页失败时,`ApplyWorker` 应把 `open_product()` 捕获到的 Shopee toast 文案上浮到③可见运行日志、`run_log_events` 和任务失败原因;若失败发生在 `open_product()` 内部,本轮自动新建 tab 要关闭,复用用户已有 tab 不关闭;本地 `data/logs/` 可保存失败现场 HTML/toast JSON 片段供开发排查,但必须脱敏。
|
||||||
|
|
||||||
- 检查和真实更新都会创建 `run_logs`,并把逐条事件写入 `run_log_events`;点击「检查本轮更新」或「开始更新」时先清空 `ApplyTab` 可见日志文本并写入本轮开始摘要,后续只追加本轮日志;③ 页面不自动把上一轮历史日志混入当前运行界面。
|
- 检查和真实更新都会创建 `run_logs`,并把逐条事件写入 `run_log_events`;点击「检查本轮更新」或「开始更新」时先清空 `ApplyTab` 可见日志文本并写入本轮开始摘要,后续只追加本轮日志;③ 页面不自动把上一轮历史日志混入当前运行界面。
|
||||||
- 若 `close_success_tab=true`,`editor.apply_task()` 只关闭本轮自动新开且成功提交的商品页;确认后跳回商品列表页时,关闭前等待 2 秒;进入编辑页后的失败和复用的用户已有 tab 不关闭。`open_product()` 内部打开失败的新建 tab 由 `open_product()` 自行关闭。
|
- `editor.apply_task()` 对本轮自动新开的商品页成功/失败都关闭,成功提交后关闭前等待 2 秒;复用用户已有 tab 不关闭。`open_product()` 内部打开失败的新建 tab 由 `open_product()` 自行关闭。
|
||||||
- 别名未匹配账号的任务逐条 `db.mark_skipped()`,原因 `别名未匹配账号`;「停止」调用 worker 协作式 `cancel()`,已开始单条跑到安全边界后结束。
|
- 别名未匹配账号的任务逐条 `db.mark_skipped()`,原因 `别名未匹配账号`;「停止」调用 worker 协作式 `cancel()`,已开始单条跑到安全边界后结束。
|
||||||
- T-404a/T-508 已实现:「重置更新状态」从底部批处理按钮移到任务表右键菜单,读取当前选中单条,运行中禁用;确认后保留 `new_title/new_cover_path`,本地退回 `stage=generated/status=pending` 供重复更新;`committed=1` 时必须提示线上已提交过且不回滚蝦皮,并保留 committed 历史事实/运行日志。
|
- T-404a/T-508 已实现:「重置更新状态」从底部批处理按钮移到任务表右键菜单,读取当前选中单条,运行中禁用;确认后保留 `new_title/new_cover_path`,本地退回 `stage=generated/status=pending` 供重复更新;`committed=1` 时必须提示线上已提交过且不回滚蝦皮,并保留 committed 历史事实/运行日志。
|
||||||
- ③ 没有常驻提交开关;确认弹窗是提交线上前的边界。
|
- ③ 没有常驻提交开关;确认弹窗是提交线上前的边界。
|
||||||
@@ -487,7 +667,7 @@ run_worker(worker: BaseWorker, thread_name=None, start=True) -> QThread
|
|||||||
- 采集、AI 生成、更新、Excel 回写都通过 worker 执行,用 signal 回传进度。
|
- 采集、AI 生成、更新、Excel 回写都通过 worker 执行,用 signal 回传进度。
|
||||||
- 每个 worker/线程按需创建自己的 SQLite connection,不跨线程共享连接。
|
- 每个 worker/线程按需创建自己的 SQLite connection,不跨线程共享连接。
|
||||||
- ③ 的批量确认弹窗在 GUI 主线程完成;用户确认后才创建 `ApplyWorker`。
|
- ③ 的批量确认弹窗在 GUI 主线程完成;用户确认后才创建 `ApplyWorker`。
|
||||||
- `ApplyWorker` 支持检查、默认串行和按账号并行;真实更新按 `max_items_per_run` 分批调用 `editor.apply_task(..., close_success_tab=...)`,逐条 `set_applied()`,失败继续;账号未就绪或端口冲突时整体阻断并引导④,不进入逐条提交,也不静默启动账号 Chrome。点击停止为协作式停止:当前商品完成后不再开始新商品或下一批。
|
- `ApplyWorker` 支持检查、默认串行和按账号并行;真实更新按 `max_items_per_run` 分批调用 `editor.apply_task(...)`,逐条 `set_applied()`,失败继续;账号未就绪或端口冲突时整体阻断并引导账号管理,不进入逐条提交,也不静默启动账号 Chrome。点击停止为协作式停止:当前商品完成后不再开始新商品或下一批。
|
||||||
- `WriteBackWorker` 默认 `mode="old"` 回写旧字段;③ 使用 `mode="results"` 回写新标题/新封面/更新状态,支持单批次或多批次列表。
|
- `WriteBackWorker` 默认 `mode="old"` 回写旧字段;③ 使用 `mode="results"` 回写新标题/新封面/更新状态,支持单批次或多批次列表。
|
||||||
- `execute()` 未捕获异常会发 `failed(-1, error)` 与 `finished({"ok": False, "error": ...})`;普通单行失败由业务 worker 自己发 `failed(task_id, error)` 后继续处理。
|
- `execute()` 未捕获异常会发 `failed(-1, error)` 与 `finished({"ok": False, "error": ...})`;普通单行失败由业务 worker 自己发 `failed(task_id, error)` 后继续处理。
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
# AI工场 BYOK 启动门禁记录
|
||||||
|
|
||||||
|
> 任务来源:T-596
|
||||||
|
> 核验日期:2026-07-11
|
||||||
|
> 结论:**暂不具备开工条件,T-596 标记 BLOCKED;T-599~T-602 不得开工。**
|
||||||
|
|
||||||
|
## 证据来源
|
||||||
|
|
||||||
|
- Obsidian:`ila/项目文档/蝦皮圈優化助手/卡密设备绑定与套餐授权方案.md`
|
||||||
|
- 状态:评审修订中,第五节为当前权威口径,关键商业决策仍待确认。
|
||||||
|
- Obsidian:`ila/项目文档/cmhub/cmhub-软件授权与设备凭证实施方案.md`
|
||||||
|
- 状态:方案草案,待落入 cmhub 仓库 `docs/` 后拆任务实施。
|
||||||
|
- 本仓任务:T-597、T-598 已完成;T-586~T-595 已完成 AI工场 cmhub 托管主线。
|
||||||
|
|
||||||
|
## 门禁检查表
|
||||||
|
|
||||||
|
| 条件 | 当前状态 | 证据 / 说明 | 结论 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| cmhub V1A 授权与设备凭证已实现 | 未满足 | cmhub 实施方案仍写明“方案草案,待落入 cmhub 仓库 docs 后拆任务实施” | 阻塞 |
|
||||||
|
| 产品专属设备凭证鉴权可用 | 未满足 | 方案建议新增 `DeviceCredential`、`key_type=device_credential` 和产品专属接口,但未提供已上线接口 | 阻塞 |
|
||||||
|
| 产品专属生成接口已版本化 | 未满足 | 建议路径为 `/api/v1/products/cmshopee/generate/...`,当前未见可联调 API 文档 | 阻塞 |
|
||||||
|
| BYOK Provider 白名单已冻结 | 未满足 | 方案只定义“第一版 Provider 白名单,不允许任意 URL”,未列出具体 Provider、模型能力、参数和域名 | 阻塞 |
|
||||||
|
| BYOK task secret 契约已冻结 | 未满足 | 方案要求 KMS/Fernet 加密 task secret、secret_id、TTL、终态清理,但未提供字段、接口和错误码 | 阻塞 |
|
||||||
|
| BYOK submit/poll 状态机兼容 AI工场 job | 未满足 | 原则上必须复用异步任务,但缺少请求/响应样例、状态映射和幂等字段 | 阻塞 |
|
||||||
|
| 零点数使用记录契约可用 | 未满足 | 方案要求 `points_cost=0` 和独立使用记录,但未提供账本字段或接口返回 | 阻塞 |
|
||||||
|
| 错误码可供 GUI 分支 | 部分满足 | 文档列出建议错误码,但不是已实现、版本化接口契约 | 阻塞 |
|
||||||
|
| 测试凭证 / mock 环境可用 | 未满足 | 当前会话没有 cmhub BYOK 测试环境、测试 device credential 或 mock server 文档 | 阻塞 |
|
||||||
|
|
||||||
|
## 决策
|
||||||
|
|
||||||
|
T-596 不进入编码实现,标记 `BLOCKED`。依赖 T-596 的任务必须暂停:
|
||||||
|
|
||||||
|
- T-599:BYOK OpenAI Provider 契约、白名单与套餐校验对齐
|
||||||
|
- T-600:BYOK Key 本地 DPAPI 存储与设置 UI
|
||||||
|
- T-601:BYOK 异步 submit/poll 接入 `image_studio_jobs`
|
||||||
|
- T-602:BYOK 安全日志、零点数账本与端到端回归验收
|
||||||
|
|
||||||
|
原因:如果现在先做客户端 UI、DPAPI 保存或 submit/poll 适配,必然会猜接口字段,且可能恢复“客户端 direct / 同步长等待 / 绕过套餐设备授权”的错误路径。
|
||||||
|
|
||||||
|
## 解除阻塞需要的交付物
|
||||||
|
|
||||||
|
cmhub 侧 owner 需要先交付以下内容:
|
||||||
|
|
||||||
|
1. cmhub 仓库内正式 docs:授权、设备凭证、产品专属生成、BYOK Provider 白名单和 BYOK task secret。
|
||||||
|
2. 已实现或 mock 可测的接口:
|
||||||
|
- `POST /api/v1/licenses/activate`
|
||||||
|
- `GET /api/v1/licenses/me`
|
||||||
|
- `POST /api/v1/products/cmshopee/generate/title`
|
||||||
|
- `POST /api/v1/products/cmshopee/generate/image/tasks`
|
||||||
|
- `GET /api/v1/products/cmshopee/generate/image/tasks/{task_id}`
|
||||||
|
3. 结构化错误码、HTTP 状态、中文外围提示映射。
|
||||||
|
4. OpenAI Provider 第一版白名单:允许的模型档位、图片编辑/生成能力、参数白名单、超时、最大图片大小。
|
||||||
|
5. BYOK task secret 字段:`secret_id` 生命周期、加密方式、TTL、终态删除、reaper 行为、worker 解密边界。
|
||||||
|
6. 零点数使用记录字段:provider、model_alias、task_id/call_id、耗时、状态、`points_cost=0`、费用由服务商收取的展示口径。
|
||||||
|
7. 测试环境或 mock server、测试 device credential、无套餐/设备不匹配/Key 无效/Provider 禁用/任务超时等样例。
|
||||||
|
|
||||||
|
解除阻塞后,再从 T-599 开始,仍必须一任务一提交。
|
||||||
@@ -12,12 +12,16 @@
|
|||||||
> **v3.4 修订(2026-07-07,T-538 路径收敛)**:打包版和源码运行的用户数据统一落在 `data/` 下;本文早期提到的 `config.json`、`config/ai_models.json`、`config/cmhub.json`、`images/`,当前默认路径分别为 `data/config.json`、`data/config/ai_models.json`、`data/config/cmhub.json`、`data/images/`。文件名和 schema 不变,旧布局由启动迁移逻辑处理。
|
> **v3.4 修订(2026-07-07,T-538 路径收敛)**:打包版和源码运行的用户数据统一落在 `data/` 下;本文早期提到的 `config.json`、`config/ai_models.json`、`config/cmhub.json`、`images/`,当前默认路径分别为 `data/config.json`、`data/config/ai_models.json`、`data/config/cmhub.json`、`data/images/`。文件名和 schema 不变,旧布局由启动迁移逻辑处理。
|
||||||
> **v3.4 核对(2026-07-06,刷新别名 notfound)**:核对对接文档 §4.4 与 cmhub `ModelsView` 路由,确认 cmshopee `GET /api/v1/models` + Bearer 请求**已达标**;「notfound」为 404,根因是 Base URL 带多余 `/api(/v1)` 路径(双拼)或所连实例未部署 `/api/v1/models`(见 `docs/troubleshooting.md`)。**T-530 已落地**:保存/请求前规整 Base URL 到网关根,HTTP 404 映射为 `not_found` 并给出中文排障提示。
|
> **v3.4 核对(2026-07-06,刷新别名 notfound)**:核对对接文档 §4.4 与 cmhub `ModelsView` 路由,确认 cmshopee `GET /api/v1/models` + Bearer 请求**已达标**;「notfound」为 404,根因是 Base URL 带多余 `/api(/v1)` 路径(双拼)或所连实例未部署 `/api/v1/models`(见 `docs/troubleshooting.md`)。**T-530 已落地**:保存/请求前规整 Base URL 到网关根,HTTP 404 映射为 `not_found` 并给出中文排障提示。
|
||||||
> **v3.5 修订(2026-07-08,T-553 待实现)**:cmhub 生图稳定性口径调整为连接超时默认 66 秒、生图请求和 `image_url` 下载读取等待统一 900 秒,与线上 Nginx/Gunicorn 的长等待窗口对齐;生图读超时仍不自动重发,避免重复扣点。
|
> **v3.5 修订(2026-07-08,T-553 待实现)**:cmhub 生图稳定性口径调整为连接超时默认 66 秒、生图请求和 `image_url` 下载读取等待统一 900 秒,与线上 Nginx/Gunicorn 的长等待窗口对齐;生图读超时仍不自动重发,避免重复扣点。
|
||||||
|
> **v3.6 修订(2026-07-08,T-564)**:cmhub 已新增异步生图任务接口,②批量生图改为 `POST /api/v1/generate/image/tasks` submit + `GET /api/v1/generate/image/tasks/{task_id}` poll;cmshopee 持久化 `tasks.image_task_id/image_task_key`,支持停止/超时/重启后续查,避免 900 秒同步长连接和读超时重复扣点。旧同步 `POST /api/v1/generate/image` 仅保留给单独 `gen_cover()` 兼容/回滚路径。
|
||||||
|
> **v3.7 修订(2026-07-17,T-645)**:⑥「商品套图」的 AI帮写接入独立图片理解能力:使用 `vision_alias` 调 `POST /api/v1/analyze/images`,不复用标题接口或 `title_alias`。请求最多8张有序本地原图,单图不超过10MiB、总计不超过32MiB;读取等待120秒且读超时不自动重发,避免结果未确认时重复扣点。
|
||||||
|
> **v3.8 修订(2026-07-20,T-677)**:T-529 的普通 UI 单来源收口有限放开为「默认网关 / 自定义网关」分段选择器,来源仅在保存设置后切换。自定义网关是客户端直连的过渡兼容路径,只覆盖②生文、生图;模型清单与默认网关配置并存。⑥商品套图和图片理解仍只允许默认网关新建请求,但此前已由默认网关接受、且同时带 `generation_source=cmhub`、`provider=cmhub` 与 `task_id` 的套图 job 可继续轮询、下载、保存,避免已扣点结果丢失。运行中的 worker 冻结来源、模型与密钥的内存快照,后续保存设置不影响本轮;密钥不写入 SQLite、日志或 UI。
|
||||||
|
> **v3.9 修订(2026-07-20,T-678)**:自定义网关的图片生成固定采用 OpenAI 图片编辑契约:`POST /v1/images/edits`、multipart 有序重复 `image[]`、第一张为业务主体图、`n=1`,仅从 `data[].b64_json` 或 `data[].url` 读取结果。`category=image` 只有 `api_type=images_edits` 可发起生成;历史 `chat` / `auto` 图片模型保留可查看但在设置和生成预检中明确为不支持,绝不静默迁移。默认网关的 cmhub 请求、异步任务与宽松响应解析不受该收敛影响。⑤对图片模型只做本地配置检查,不发送可能计费的空图片请求;实际可用性通过②或后续⑥的正常生成确认验证。
|
||||||
|
|
||||||
## 1. 背景与目标
|
## 1. 背景与目标
|
||||||
|
|
||||||
**现状**:`app/ai.py` 支持 cmhub 网关和 direct 兼容路径。direct 模式下每个模型在 `data/config/ai_models.json` 配 `url/model/api_key/api_type`,`gen_title` 自拼 chat `messages` 解析文本,`gen_cover` 走 `images_edits` multipart 或 vision chat 直接拿图片字节本地存。
|
**现状**:`app/ai.py` 支持 cmhub 网关和 direct 兼容路径。direct 模式下每个模型在 `data/config/ai_models.json` 配 `url/model/api_key/api_type`,`gen_title` 自拼 chat `messages` 解析文本,`gen_cover` 走 `images_edits` multipart 或 vision chat 直接拿图片字节本地存。
|
||||||
|
|
||||||
**目标**:改为对接 cmhub 的生成、余额和模型发现接口:`POST /api/v1/generate/title`、`POST /api/v1/generate/image`、`GET /api/v1/balance`、`GET /api/v1/models`,一把 `Authorization: Bearer <API_KEY>` 即可调用。上游 provider、密钥、计费、SSRF 防护、分辨率映射、对象存储与别名发现由 cmhub 承担。
|
**目标**:改为对接 cmhub 的生成、余额和模型发现接口:`POST /api/v1/generate/title`、②批量生图 `POST /api/v1/generate/image/tasks` + `GET /api/v1/generate/image/tasks/{task_id}`、兼容生图 `POST /api/v1/generate/image`、`GET /api/v1/balance`、`GET /api/v1/models`,一把 `Authorization: Bearer <API_KEY>` 即可调用。上游 provider、密钥、计费、SSRF 防护、分辨率映射、对象存储与别名发现由 cmhub 承担。
|
||||||
|
|
||||||
**收益**:密钥收敛(本地只留一把 cmhub Key);换上游模型对 cmshopee 零改动(cmhub 用能力别名);可删除大量 provider 适配代码;计费/额度统一。
|
**收益**:密钥收敛(本地只留一把 cmhub Key);换上游模型对 cmshopee 零改动(cmhub 用能力别名);可删除大量 provider 适配代码;计费/额度统一。
|
||||||
|
|
||||||
@@ -29,9 +33,9 @@
|
|||||||
| 生文请求 | 自拼 chat `messages`(system+user) | `{prompt, model:别名, image_url?/image_base64?, resolution?, parameters?}` |
|
| 生文请求 | 自拼 chat `messages`(system+user) | `{prompt, model:别名, image_url?/image_base64?, resolution?, parameters?}` |
|
||||||
| 生文响应 | chat completion → 取单条文本 | `{titles:[...], alias, model_used, points_cost, points_balance, call_id}` |
|
| 生文响应 | chat completion → 取单条文本 | `{titles:[...], alias, model_used, points_cost, points_balance, call_id}` |
|
||||||
| 生图请求 | `images_edits` multipart 或 vision chat | `{prompt, model:别名, image_base64?/image_url?, resolution?, aspect_ratio?, parameters?}` |
|
| 生图请求 | `images_edits` multipart 或 vision chat | `{prompt, model:别名, image_base64?/image_url?, resolution?, aspect_ratio?, parameters?}` |
|
||||||
| 生图响应 | 直接返回 image bytes | `{image_url, ...}` → 需再下载 |
|
| 生图响应 | 直接返回 image bytes | ②批量:submit 返回 `{task_id, ...}`,poll 成功返回 `{result:{image_url}}`;兼容同步:`{image_url, ...}` → 需再下载 |
|
||||||
| 错误 | HTTP error 文本 | `{error:{code,message}}`:`insufficient_points`(402)/`upstream_error`(502)/`rate_limited`(429)/`unauthorized`(401)/`account_disabled`(403)/`bad_request`(400)/`model_not_allowed`/`no_pricing_rule` |
|
| 错误 | HTTP error 文本 | `{error:{code,message}}`:`insufficient_points`(402)/`upstream_error`(502)/`rate_limited`(429)/`unauthorized`(401)/`account_disabled`(403)/`bad_request`(400)/`model_not_allowed`/`no_pricing_rule` |
|
||||||
| 超时 | 按 `resolution_timeouts` | 生图同步且慢;T-553 目标为 cmshopee 生图请求和图片下载读取等待统一固定 900s,不再按分辨率变化;生图读超时仍不自动重发 |
|
| 超时 | 按 `resolution_timeouts` | ②批量生图 submit 读取等待 36s、poll 总预算 900s、下载 900s;旧同步兼容路径仍 900s |
|
||||||
| 幂等 | 直连一次成功一次 | **非幂等、无幂等键**:客户端超时 ≠ 未扣点,读超时后不可无脑重发 |
|
| 幂等 | 直连一次成功一次 | **非幂等、无幂等键**:客户端超时 ≠ 未扣点,读超时后不可无脑重发 |
|
||||||
|
|
||||||
关键差异(决定改造点):
|
关键差异(决定改造点):
|
||||||
@@ -58,6 +62,7 @@
|
|||||||
"base_url": "https://<cmhub>", // 网关根地址,请求时拼 /api/v1/...
|
"base_url": "https://<cmhub>", // 网关根地址,请求时拼 /api/v1/...
|
||||||
"title_alias": "title-standard", // 生文能力别名
|
"title_alias": "title-standard", // 生文能力别名
|
||||||
"image_alias": "image-hd", // 生图能力别名
|
"image_alias": "image-hd", // 生图能力别名
|
||||||
|
"vision_alias": "vision-standard",// ⑥AI帮写图片理解能力别名
|
||||||
"connect_timeout": 66,
|
"connect_timeout": 66,
|
||||||
"check_balance_before_batch": false
|
"check_balance_before_batch": false
|
||||||
},
|
},
|
||||||
@@ -68,7 +73,7 @@
|
|||||||
|
|
||||||
- **cmhub API Key 不进 `data/config.json`**(避免与其它设置混放、避免误提交)。固定存到 `data/config/cmhub.json`,文件 schema 第一版为 `{ "api_key": "sk_cmhub_xxx" }`;新增 `CMHUB_CONFIG_PATH`、`load_cmhub_config()`、`save_cmhub_config()`、`get_cmhub_api_key(masked=False)` 等 helper;`data/config/cmhub.json` 必须加入 `.gitignore`。沿用现有"本地明文保存但 gitignore + UI 打码 + 日志脱敏"纪律(T-503)。
|
- **cmhub API Key 不进 `data/config.json`**(避免与其它设置混放、避免误提交)。固定存到 `data/config/cmhub.json`,文件 schema 第一版为 `{ "api_key": "sk_cmhub_xxx" }`;新增 `CMHUB_CONFIG_PATH`、`load_cmhub_config()`、`save_cmhub_config()`、`get_cmhub_api_key(masked=False)` 等 helper;`data/config/cmhub.json` 必须加入 `.gitignore`。沿用现有"本地明文保存但 gitignore + UI 打码 + 日志脱敏"纪律(T-503)。
|
||||||
- **`ai_models.json` 去留**:`direct` 模式继续用;`cmhub` 模式不读它。文件保留但标记 legacy。
|
- **`ai_models.json` 去留**:`direct` 模式继续用;`cmhub` 模式不读它。文件保留但标记 legacy。
|
||||||
- **默认 backend 与配置不完整处理(T-529 后)**:`DEFAULT_CONFIG` / `default_config()` 造全新配置时写 `backend=cmhub`;⑤设置页隐藏 direct/cmhub 下拉并固定保存 `backend=cmhub`。允许先保存不完整 cmhub 配置,便于用户先保存其它路径/安全设置;②生成真正调用时如果 `base_url`/Key/别名缺失,必须抛**清晰的"请去⑤配置 cmhub"错误**(`CMHubError`/`AIError`),不得崩溃或静默回退 direct。显式手工配置 `backend=direct` 仍作为内部回滚路径保留,但普通 UI 不提供入口。
|
- **默认 backend 与配置不完整处理(T-529/T-645 后)**:`DEFAULT_CONFIG` / `default_config()` 造全新配置时写 `backend=cmhub`,`vision_alias` 默认 `vision-standard`;⑤设置页隐藏 direct/cmhub 下拉并固定保存 `backend=cmhub`。允许先保存不完整 cmhub 配置,便于用户先保存其它路径/安全设置;②或⑥真正调用时如果 `base_url`/Key/对应别名缺失,必须抛**清晰的"请去⑤配置 cmhub"错误**(`CMHubError`/`AIError`),不得崩溃或静默回退 direct。显式手工配置 `backend=direct` 仍作为内部回滚路径保留,但⑥AI帮写不提供 direct 回退。
|
||||||
- **Base URL 必须是网关根**:只填 `https://<cmhub-域名>`,**不带** `/api`、`/api/v1` 或任何路径。`cmhub_request_url()` 会自行拼 `/api/v1/...`;若 Base URL 已含 `/api/v1`,会双拼成 `.../api/v1/api/v1/...` → 404(正是「刷新别名 notfound」现象,见 `docs/troubleshooting.md`)。**当前请求本身已达标**(`GET /api/v1/models` + `Authorization: Bearer <key>`,对齐对接文档 §4.4 与 cmhub `ModelsView` 路由);404 的根因是 Base URL 带多余路径或所连实例未部署 `/api/v1/models`。**T-530 已在保存/请求前规整 Base URL**,会去掉多余路径、查询串和片段,只保留 scheme+host(+port);404 会给出明确中文提示。
|
- **Base URL 必须是网关根**:只填 `https://<cmhub-域名>`,**不带** `/api`、`/api/v1` 或任何路径。`cmhub_request_url()` 会自行拼 `/api/v1/...`;若 Base URL 已含 `/api/v1`,会双拼成 `.../api/v1/api/v1/...` → 404(正是「刷新别名 notfound」现象,见 `docs/troubleshooting.md`)。**当前请求本身已达标**(`GET /api/v1/models` + `Authorization: Bearer <key>`,对齐对接文档 §4.4 与 cmhub `ModelsView` 路由);404 的根因是 Base URL 带多余路径或所连实例未部署 `/api/v1/models`。**T-530 已在保存/请求前规整 Base URL**,会去掉多余路径、查询串和片段,只保留 scheme+host(+port);404 会给出明确中文提示。
|
||||||
|
|
||||||
### 4.2 `gen_title` 改造(cmhub 分支)
|
### 4.2 `gen_title` 改造(cmhub 分支)
|
||||||
@@ -85,20 +90,29 @@
|
|||||||
- **响应**:解析 `titles`,取 `titles[0]`(当前一条任务要一个新标题);空列表/空串按现有语义抛 `AIError("AI 返回为空标题")`。
|
- **响应**:解析 `titles`,取 `titles[0]`(当前一条任务要一个新标题);空列表/空串按现有语义抛 `AIError("AI 返回为空标题")`。
|
||||||
- 不复用现有 `_call_with_retry` 的一刀切重试逻辑;cmhub 分支新增专用 HTTP helper,优先使用项目已依赖的 `requests`,传 `timeout=(connect_timeout, read_timeout)` 以区分连接超时和读超时。`model_used`/`points_cost`/`points_balance`/`call_id` 不改变返回值,通过 `on_step`/事件回调上报给 `generate_batch` 与 GUI worker;T-526 只保证 metadata 事件完整传出,不直接要求写 GUI `run_logs`,T-528 再由 GUI worker 脱敏写 `run_logs` 和展示余额;不入 Excel。
|
- 不复用现有 `_call_with_retry` 的一刀切重试逻辑;cmhub 分支新增专用 HTTP helper,优先使用项目已依赖的 `requests`,传 `timeout=(connect_timeout, read_timeout)` 以区分连接超时和读超时。`model_used`/`points_cost`/`points_balance`/`call_id` 不改变返回值,通过 `on_step`/事件回调上报给 `generate_batch` 与 GUI worker;T-526 只保证 metadata 事件完整传出,不直接要求写 GUI `run_logs`,T-528 再由 GUI worker 脱敏写 `run_logs` 和展示余额;不入 Excel。
|
||||||
|
|
||||||
|
### 4.2a ⑥商品套图图片理解(T-645)
|
||||||
|
|
||||||
|
- **请求**:仅 `backend=cmhub` 时由 `analyze_product_images()` 调 `POST {base_url}/api/v1/analyze/images`,头沿用 Bearer Key,体为 `{prompt, model: vision_alias, images:[{image_base64:data_url}], parameters:{temperature:0.2}}`。`images` 按⑥项目原图 `source_order` 顺序传入,数量1至8;单图超过10MiB、总计超过32MiB、路径缺失或无图时在本地阻断,不发请求。
|
||||||
|
- **响应**:只接收可编辑文本和白名单元数据 `alias/model_used/points_cost/points_balance/call_id`;不保存 base64、完整提示词、图片路径、接口URL或 Provider 原始响应。成功由⑥状态栏显示“已理解N张商品原图、图片理解扣点、当前余额”。
|
||||||
|
- **超时/取消**:连接等待沿用 `connect_timeout`,读取等待120秒;一次 `read_timeout`、连接中断的结果均视为未确认,不自动重发。取消只在发请求前或返回后协作生效,不强杀网络线程,也不声称未扣点。
|
||||||
|
- **边界**:该能力不改 `gen_title()`、`gen_cover()`、②批量生成状态或⑥正式套图生图 job;已有卖点只作为补充要求,模型必须以图片可见信息为主,避免虚构不可确认的规格、认证、价格和物流信息。
|
||||||
|
|
||||||
### 4.3 `gen_cover` 改造(cmhub 分支)
|
### 4.3 `gen_cover` 改造(cmhub 分支)
|
||||||
|
|
||||||
返回值不变(仍返回已存 JPEG 路径),现有调用保持兼容;允许新增可选事件回调参数承载计费元数据。内层:
|
返回值不变(仍返回已存 JPEG 路径),现有调用保持兼容;允许新增可选事件回调参数承载计费元数据。内层:
|
||||||
|
|
||||||
- **请求**:`POST {base_url}/api/v1/generate/image`,体:
|
- **②批量请求(T-564)**:`POST {base_url}/api/v1/generate/image/tasks`,头带 `Idempotency-Key` 与 `X-Client-Version`,体与旧生图一致:
|
||||||
```jsonc
|
```jsonc
|
||||||
{ "prompt": <cover_prompt>, "model": <image_alias>,
|
{ "prompt": <cover_prompt>, "model": <image_alias>,
|
||||||
"image_base64": <旧封面转 data URL>, "resolution": <resolution>, "aspect_ratio": <可选> }
|
"image_base64": <旧封面转 data URL>, "resolution": <resolution>, "aspect_ratio": <可选> }
|
||||||
```
|
```
|
||||||
旧封面必传(改图类),复用现有 `_image_data_url(old_cover_path)` 生成 base64。
|
旧封面必传(改图类),复用现有 `_image_data_url(old_cover_path)` 生成 base64。
|
||||||
|
- **②批量响应(T-564)**:submit 返回 `202 {task_id,status:"queued",call_id,points_cost,points_balance,expires_at}`;cmshopee 立即写 `tasks.image_task_id`,随后 `GET /api/v1/generate/image/tasks/{task_id}` 轮询。`queued/running` 继续等待;`succeeded` 取 `result.image_url`;`failed/expired` 视为终态失败且 cmhub 已退点。
|
||||||
|
- **单独 `gen_cover()` 兼容**:公开函数没有本地 `task.id/db_path` 上下文,第一版继续调用旧同步 `POST /api/v1/generate/image`,保持返回值和旧调用方兼容;②批量生成才使用完整持久化、幂等键和重启续查。
|
||||||
- `resolution` 归一为**大写** `512/1K/2K/4K`(cmshopee 内部用小写 `1k`,发请求前转 `1K`);`aspect_ratio` 默认 `1:1`(Shopee 封面)。
|
- `resolution` 归一为**大写** `512/1K/2K/4K`(cmshopee 内部用小写 `1k`,发请求前转 `1K`);`aspect_ratio` 默认 `1:1`(Shopee 封面)。
|
||||||
- **响应**:拿 `image_url` → **新增一步下载**该图字节(cmhub 自家对象存储公网 URL)→ 交给现有 `_save_jpeg(image_bytes, out_path, resolution, quality)` 落盘。下载 helper 必须校验 URL scheme 只允许 `http/https`,拒绝内网/回环/本机地址,并校验域名解析后的 IP 仍不属于内网/回环/本机地址,设置超时和大小上限;生成后**立即下载**(对象存储 URL 可能有有效期)。`points_cost`/`points_balance`/`call_id` 同样通过事件回调传播,不改变 `gen_cover` 返回值。
|
- **下载**:拿 `image_url` 后下载该图字节(cmhub 自家对象存储公网 URL)→ 交给现有 `_save_jpeg(image_bytes, out_path, resolution, quality)` 落盘。cmshopee 兼容 `image_url` / `image_url.url` / `images[]` / `image_urls[]` 等返回结构;若 cmhub 返回相对路径(如 `/generated/...`),先按 cmhub Base URL 补全为完整 `http(s)` URL。下载 helper 必须校验 URL scheme 只允许 `http/https`,拒绝内网/回环/本机地址,并校验域名解析后的 IP 仍不属于内网/回环/本机地址,设置超时和大小上限;生成后**立即下载**(对象存储 URL 可能有有效期)。`points_cost`/`points_balance`/`call_id` 通过事件回调传播,不改变 `gen_cover` 返回值。
|
||||||
- **超时(关键)**:生图同步且慢。T-553 目标为 cmshopee 的 cmhub 连接超时默认 66 秒,生图请求和随后 `image_url` 下载读取等待统一固定 900 秒,不再按分辨率变化,绝不用 30s/60s 调生图——否则客户端超时但服务端仍在算并扣点(见 §4.4 幂等)。
|
- **超时(关键)**:②批量生图 submit 读取等待 36 秒,poll 单次读取等待约 15 秒,本地总预算 900 秒;撞预算、用户停止或程序退出都保留 `image_task_id/image_task_key`,下次直接续查,不重新 submit。下载读取等待 900 秒;旧同步兼容路径也使用 900 秒。绝不用 30s/60s 同步死等旧生图,否则客户端超时但服务端仍在算并扣点。
|
||||||
- **并发(T-545 已实现)**:最近实测 `/media/generated/images/*.png` 下载链路在 10 并发下明显慢且有连接失败。cmhub 模式下采用内置保护:实际生图请求并发 = `min(ai.image_concurrency, 5)`;下载/保存使用独立线程池,线程数与实际生图请求并发一致,同样最大 5;不新增用户可见配置项。运行日志必须同时显示用户设置和实际并发,避免用户误解设置 10 就会对 cmhub 打 10 并发。下载失败记为该任务失败,不得重新调用生图接口导致重复扣点;读超时仍按 §4.4 的非幂等规则处理。
|
- **并发(T-545/T-564 已实现)**:最近实测 `/media/generated/images/*.png` 下载链路在 10 并发下明显慢且有连接失败。cmhub 模式下采用内置保护:实际 submit+poll 在途并发 = `min(ai.image_concurrency, 5)`;下载/保存使用独立线程池,线程数与实际生图并发一致,同样最大 5;不新增用户可见配置项。运行日志必须同时显示用户设置和实际并发,避免用户误解设置 10 就会对 cmhub 打 10 并发。下载失败记为该任务失败,不得重新调用生图接口导致重复扣点。
|
||||||
- **下载后端(T-548 已实现)**:cmhub 生成/models/balance 仍走共享 requests Session;仅 `image_url` 图片下载可按 `ai.cmhub.download_with_curl` 选择系统 curl。默认 `auto` 在 Windows 且检测到系统 curl 时优先 curl,非 Windows、无 curl 或 curl 失败自动回退 requests。curl 调用前仍做公网 URL 校验,用 `-K` 临时配置文件传 URL,避免 token 出现在进程命令行;`use_system_proxy=false` 时加 `--noproxy "*"`。
|
- **下载后端(T-548 已实现)**:cmhub 生成/models/balance 仍走共享 requests Session;仅 `image_url` 图片下载可按 `ai.cmhub.download_with_curl` 选择系统 curl。默认 `auto` 在 Windows 且检测到系统 curl 时优先 curl,非 Windows、无 curl 或 curl 失败自动回退 requests。curl 调用前仍做公网 URL 校验,用 `-K` 临时配置文件传 URL,避免 token 出现在进程命令行;`use_system_proxy=false` 时加 `--noproxy "*"`。
|
||||||
|
|
||||||
### 4.4 错误映射与重试策略
|
### 4.4 错误映射与重试策略
|
||||||
@@ -112,14 +126,19 @@ cmhub 返回结构化 `{error:{code}}`。映射层**按 `code` 优先分支**(
|
|||||||
| `account_disabled` | 403 | 提示 Key 被吊销/账号禁用,去网页端重生成 | 否 |
|
| `account_disabled` | 403 | 提示 Key 被吊销/账号禁用,去网页端重生成 | 否 |
|
||||||
| `bad_request` / `model_not_allowed` / `no_pricing_rule` / `content_blocked` | 400 | 记录具体 code,判为配置/参数/内容错 | 否 |
|
| `bad_request` / `model_not_allowed` / `no_pricing_rule` / `content_blocked` | 400 | 记录具体 code,判为配置/参数/内容错 | 否 |
|
||||||
| `upstream_error` | 502 | 上游失败(cmhub **已自动退点**),可提示稍后重试 | 是(安全)|
|
| `upstream_error` | 502 | 上游失败(cmhub **已自动退点**),可提示稍后重试 | 是(安全)|
|
||||||
|
| `upstream_timeout` / `task_timeout` | 200/5xx | 上游或任务超时(cmhub **已自动退点**),可提示稍后重试 | 是(安全)|
|
||||||
|
| `idempotency_conflict` | 409/400 | 本地幂等键被不同 payload 复用,清空本地 key 后失败提示 | 否 |
|
||||||
| `rate_limited` | 429 | 退避重试,读 `Retry-After`(默认限流 60次/分)| 是 |
|
| `rate_limited` | 429 | 退避重试,读 `Retry-After`(默认限流 60次/分)| 是 |
|
||||||
| **未列出的 code** | 任意 | 展示 `message`,当不可重试错误(前向兼容)| 否 |
|
| **未列出的 code** | 任意 | 展示 `message`,当不可重试错误(前向兼容)| 否 |
|
||||||
|
|
||||||
**幂等与超时——生图重试必须特别处理(会亏钱)**:cmhub 生成接口**非幂等、无幂等键**,客户端超时 ≠ 未扣点。
|
**幂等与超时——②批量生图以 task_id 续查为准**:旧同步生图接口非幂等,客户端超时 ≠ 未扣点;T-564 后②批量生图必须使用任务化接口和本地持久化规避这个问题。
|
||||||
|
|
||||||
- **生图(`gen_cover`)**:**读超时后绝不自动重发**——服务端可能已算完并扣点,重发 = 重复扣点。首选办法是把读超时设够大(§4.3,T-553 目标固定 900s)从源头避免歧义;只对**连接超时**(请求根本没送达服务端)安全重试。
|
- submit 前先生成并写入 `tasks.image_task_key`,同一次网络抖动重发复用同一个 `Idempotency-Key`;submit 成功后立即写入 `tasks.image_task_id`。
|
||||||
|
- 只要本地已有 `image_task_id`,下一轮直接 GET 续查,不重新 submit;`succeeded` 下载保存并 `set_generated()`;本地总预算超时、用户停止、程序退出都保留 `image_task_id/image_task_key`。
|
||||||
|
- poll 返回 `failed/expired`(已退点)时清空 `image_task_id/image_task_key`,后续重试会生成新的 key 并重新 submit;submit 阶段 `content_blocked`、`insufficient_points`、`bad_request`、`idempotency_conflict` 等没有有效任务时也清空 key。
|
||||||
|
- **单独 `gen_cover()` 兼容路径**:没有 DB 上下文,仍走旧同步接口;读超时后仍不自动重发,避免重复扣点。
|
||||||
- **生文(`gen_title`)**:秒级返回、点数低,读超时重试风险小,但仍建议同样区分连接超时/读超时;重试次数可小。
|
- **生文(`gen_title`)**:秒级返回、点数低,读超时重试风险小,但仍建议同样区分连接超时/读超时;重试次数可小。
|
||||||
- 只对 `502`/`429`/**连接**超时重试;`402/401/403/400`/读超时立即失败。现状 `_call_with_retry` 是**一刀切重试**,cmhub 模式必须替换为这套区分策略。
|
- 只对 `502`/`429`/**连接**超时以及已退点的 `upstream_timeout/task_timeout` 重试;`402/401/403/400`/读超时立即失败或进入续查。现状 `_call_with_retry` 是**一刀切重试**,cmhub 模式必须替换为这套区分策略。
|
||||||
- `insufficient_points` 是新的用户可见态:② 生成页应弹明确提示并引导去网页端充值,不当普通失败淹没在计数里。
|
- `insufficient_points` 是新的用户可见态:② 生成页应弹明确提示并引导去网页端充值,不当普通失败淹没在计数里。
|
||||||
|
|
||||||
### 4.5 余额展示与额度预检
|
### 4.5 余额展示与额度预检
|
||||||
@@ -131,22 +150,23 @@ cmhub 返回结构化 `{error:{code}}`。映射层**按 `code` 优先分支**(
|
|||||||
|
|
||||||
对接文档已把别名发现从"未来提供"落地为**真实接口**(第 4 个接口,鉴权同为 Bearer Key):
|
对接文档已把别名发现从"未来提供"落地为**真实接口**(第 4 个接口,鉴权同为 Bearer Key):
|
||||||
|
|
||||||
- 响应 `{models:[{alias, operation_type:"title"|"image", capabilities[], requires_image:bool, pricing_status:"priced"|"unpriced", prices:[{resolution, points_cost}]}]}`——只含别名侧信息,不含具体模型名/URL/密钥。
|
- 响应 `{models:[{alias, operation_type:"title"|"image"|"vision", capabilities[], requires_image:bool, pricing_status:"priced"|"unpriced", prices:[{resolution, points_cost}]}]}`——只含别名侧信息,不含具体模型名/URL/密钥。
|
||||||
- **⑤设置的别名由手填改为动态下拉**:新增 `app/appconfig.py`(或 `app/ai.py`)helper `fetch_cmhub_models(base_url, api_key)`,⑤按 `operation_type` 拆成「生文别名 / 生图别名」两个下拉;`requires_image` 供 UI 提示(cmshopee 生图恒传旧封面 base64,天然满足);`pricing_status="unpriced"` 的别名**不放入下拉**(直接调用会 `no_pricing_rule`);单价用 `prices` 展示。
|
- **⑤设置的别名由手填改为动态下拉**:新增 `app/appconfig.py`(或 `app/ai.py`)helper `fetch_cmhub_models(base_url, api_key)`,⑤按 `operation_type` 拆成「生文别名 / 生图别名 / 图片理解别名」三个下拉;图片理解下拉额外只接受 `requires_image=true` 且 `pricing_status="priced"` 的别名。已保存但本次模型列表不可用的值必须保留并标注,不可无声清空;`requires_image` 也供生图 UI 提示(cmshopee 生图恒传旧封面 base64,天然满足);`pricing_status="unpriced"` 的别名**不放入下拉**(直接调用会 `no_pricing_rule`);单价用 `prices` 展示。
|
||||||
- 拉取时机:⑤ 打开或用户点「测试连接/刷新别名」时拉一次,选中的别名仍持久化到 `config.json` 的 `ai.cmhub.title_alias/image_alias`(网关临时不可达时用已存值)。
|
- 拉取时机:⑤打开或用户点「测试连接/刷新别名」时拉一次,选中的别名仍持久化到 `config.json` 的 `ai.cmhub.title_alias/image_alias/vision_alias`(网关临时不可达时用已存值)。
|
||||||
- 这**关闭了原待确认 #1(别名清单)**——不再需部署方单独提供;仅 Base URL 仍待部署方给。
|
- 这**关闭了原待确认 #1(别名清单)**——不再需部署方单独提供;仅 Base URL 仍待部署方给。
|
||||||
|
|
||||||
## 5. 各模块改动点
|
## 5. 各模块改动点
|
||||||
|
|
||||||
| 模块 | 改动 | 量 |
|
| 模块 | 改动 | 量 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `app/ai.py` | `gen_title`/`gen_cover` 加 cmhub 分支(请求体+解析+生图下载);抽 backend 选择;错误映射 + 区分重试。`direct` 分支保留现有代码 | M |
|
| `app/ai.py` | `gen_title`/`gen_cover` 加 cmhub 分支(请求体+解析+生图下载);T-564 后②批量生图走 submit+poll+续查,单独 `gen_cover()` 保留旧同步兼容;抽 backend 选择;错误映射 + 区分重试。`direct` 分支保留现有代码 | M |
|
||||||
|
| `app/db.py` | T-564 新增 `tasks.image_task_id/image_task_key`,提供 `ensure_image_task_key()`、`set_image_task_submitted()`、`clear_image_task()`;重置封面时清空异步生图状态,只重置标题时保留 | S |
|
||||||
| `app/appconfig.py` | `ai` 段加 `backend`/`cmhub` 子段默认值与校验;cmhub Key 的读写与打码(复用脱敏工具);新增 `cmhub_request_url()` 类 helper | S |
|
| `app/appconfig.py` | `ai` 段加 `backend`/`cmhub` 子段默认值与校验;cmhub Key 的读写与打码(复用脱敏工具);新增 `cmhub_request_url()` 类 helper | S |
|
||||||
| `app/gui/tabs/settings.py` / `app/gui/workers.py` | ⑤ AI 设置按 backend 切换:cmhub 模式显示「网关地址 + API Key + 生文/生图别名 + 测试连接/查余额」;direct 模式保留现有 master-detail;测试连接/查余额走后台 worker。**最大 UI 触点** | M |
|
| `app/gui/tabs/settings.py` / `app/gui/workers.py` | ⑤ AI 设置按 backend 切换:cmhub 模式显示「网关地址 + API Key + 生文/生图别名 + 测试连接/查余额」;direct 模式保留现有 master-detail;测试连接/查余额走后台 worker。**最大 UI 触点** | M |
|
||||||
| 测试 | `tests/test_ai.py` 增 cmhub mock(titles 列表、image_url 下载、安全下载、各错误码与重试、连接/读超时差异);`test_appconfig` 加 schema 与 `data/config/cmhub.json` helper;⑤ gui 设置测试跟随 | M |
|
| 测试 | `tests/test_ai.py` 增 cmhub mock(titles 列表、image_url 下载、安全下载、各错误码与重试、连接/读超时差异);`test_appconfig` 加 schema 与 `data/config/cmhub.json` helper;⑤ gui 设置测试跟随 | M |
|
||||||
| 文档 | `docs/04-architecture.md` §5.1b/§6.2、`docs/api.md`、`docs/03-tech-stack.md`、`current-state.md` 同步 | S |
|
| 文档 | `docs/04-architecture.md` §5.1b/§6.2、`docs/api.md`、`docs/03-tech-stack.md`、`current-state.md` 同步 | S |
|
||||||
|
|
||||||
**明确不动(T-526)**:`editor.py`/`cdp.py`/`chrome.py`/`accounts.py`/`excel.py`/`db.py`,以及 ①采集/③更新/④账号全流程;`generate_batch` 主编排、图片本地路径方案和 T-520 封面开关保持原语义。T-527/T-528 可按任务边界修改 `app/gui/tabs/settings.py`、`app/gui/tabs/generate.py`、`app/gui/workers.py` 的设置与用户提示层。
|
**明确不动(T-564)**:不改 CDP/Shopee/Excel/账号流程,不改图片本地路径方案和 T-520 封面开关;不引入 cancel 接口;不做 DB 版本化迁移重构。`generate_batch` 仍保持“先标题、后封面、失败不阻塞其余”的骨架,只替换 cmhub 批量封面请求内核。
|
||||||
|
|
||||||
## 6. 安全与合规
|
## 6. 安全与合规
|
||||||
|
|
||||||
@@ -158,8 +178,8 @@ cmhub 返回结构化 `{error:{code}}`。映射层**按 `code` 优先分支**(
|
|||||||
|
|
||||||
- **单元(mock cmhub,不连真实网关)**:
|
- **单元(mock cmhub,不连真实网关)**:
|
||||||
- 生文:`titles` 多条取首条;空 `titles`/空串 → `AIError`。
|
- 生文:`titles` 多条取首条;空 `titles`/空串 → `AIError`。
|
||||||
- 生图:`image_url` → mock 下载字节 → `_save_jpeg` 落盘校验分辨率/质量;覆盖 scheme、内网/回环字符串地址、域名解析到内网 IP 的拒绝路径。
|
- 生图:②批量 mock submit 202 → `task_id` 落库 → poll succeeded → `image_url` → mock 下载字节 → `_save_jpeg` 落盘校验分辨率/质量;覆盖已有 `image_task_id` 续查不 POST、failed/expired 清 key、取消/超时保留 task_id。单独 `gen_cover()` 继续覆盖旧同步 `image_url` 下载路径。
|
||||||
- 错误码矩阵:402/401/403/400 不重试且原因正确;502/429/连接超时按 attempts 重试;生图读超时不重发;未知 code 不重试。
|
- 错误码矩阵:402/401/403/400 不重试且原因正确;502/429/连接超时按 attempts 重试;`upstream_timeout/task_timeout` 可重试且清 key;未知 code 不重试。
|
||||||
- 配置:`backend=cmhub` 走 cmhub 分支、显式 `direct` 走旧分支;T-529 后缺 `backend` 的配置按 `DEFAULT_CONFIG` 补为 cmhub;缺 `base_url`/Key/别名时明确报错。
|
- 配置:`backend=cmhub` 走 cmhub 分支、显式 `direct` 走旧分支;T-529 后缺 `backend` 的配置按 `DEFAULT_CONFIG` 补为 cmhub;缺 `base_url`/Key/别名时明确报错。
|
||||||
- **GUI**:⑤ cmhub 面板读写、测试连接 worker、别名下拉;② 生成在 `insufficient_points` 时的提示路径。
|
- **GUI**:⑤ cmhub 面板读写、测试连接 worker、别名下拉;② 生成在 `insufficient_points` 时的提示路径。
|
||||||
- **回归**:`direct` 模式现有 `test_ai.py` 用例保持绿。
|
- **回归**:`direct` 模式现有 `test_ai.py` 用例保持绿。
|
||||||
@@ -167,7 +187,7 @@ cmhub 返回结构化 `{error:{code}}`。映射层**按 `code` 优先分支**(
|
|||||||
|
|
||||||
## 8. 迁移与回退
|
## 8. 迁移与回退
|
||||||
|
|
||||||
- T-529 后普通配置默认 `backend=cmhub`;显式手工配置 `backend=direct` 仍可作为内部回滚路径。⑤普通 UI 不再提供后端切换,用户只需配好 `base_url + Key + 两个别名`。
|
- T-529/T-645 后普通配置默认 `backend=cmhub`;显式手工配置 `backend=direct` 仍可作为内部回滚路径,但⑥AI帮写必须使用 cmhub 图片理解。⑤普通 UI 不再提供后端切换,用户只需配好 `base_url + Key + 三类别名`。
|
||||||
- 回退:`backend=direct` 立即切回本地直连,`ai_models.json` 仍有效。
|
- 回退:`backend=direct` 立即切回本地直连,`ai_models.json` 仍有效。
|
||||||
- 灰度:可先在 ② 单条生成上验证 cmhub 联通与计费,再放批量。
|
- 灰度:可先在 ② 单条生成上验证 cmhub 联通与计费,再放批量。
|
||||||
|
|
||||||
@@ -178,7 +198,7 @@ cmhub 返回结构化 `{error:{code}}`。映射层**按 `code` 优先分支**(
|
|||||||
3. ~~`resolution` 取值~~ **已解决**:`512/1K/2K/4K`(大写 K),默认 `1K`;cmshopee 小写值发请求前归一。
|
3. ~~`resolution` 取值~~ **已解决**:`512/1K/2K/4K`(大写 K),默认 `1K`;cmshopee 小写值发请求前归一。
|
||||||
4. ~~`aspect_ratio`~~ **已解决**:默认 `1:1`,Shopee 封面用 `1:1`。
|
4. ~~`aspect_ratio`~~ **已解决**:默认 `1:1`,Shopee 封面用 `1:1`。
|
||||||
5. **`image_url` 有效期(按最坏处理)**:对象存储 URL 可能过期——本设计已是**生成后立即下载落盘**,无需长期持有。
|
5. **`image_url` 有效期(按最坏处理)**:对象存储 URL 可能过期——本设计已是**生成后立即下载落盘**,无需长期持有。
|
||||||
6. ~~超时上限~~ **已解决**:T-553 目标为 cmshopee 生图请求和图片下载读取等待统一固定 900s,连接超时默认 66s。
|
6. ~~超时上限~~ **已解决**:T-553/T-564 后②批量生图 submit 短等待、poll 总预算 900s,图片下载读取等待 900s,连接超时默认 66s。
|
||||||
7. **Base URL / API Key 形态**:域名待部署方提供;Key 形如 `sk_cmhub_xxx`,仅网页端生成时显示一次——⑤设置需提示用户从网页端复制粘贴,本地保存。
|
7. **Base URL / API Key 形态**:域名待部署方提供;Key 形如 `sk_cmhub_xxx`,仅网页端生成时显示一次——⑤设置需提示用户从网页端复制粘贴,本地保存。
|
||||||
|
|
||||||
## 10. 落地拆分与任务顺序
|
## 10. 落地拆分与任务顺序
|
||||||
@@ -188,6 +208,7 @@ cmhub 返回结构化 `{error:{code}}`。映射层**按 `code` 优先分支**(
|
|||||||
- **第三步**:② 计费错误提示(`insufficient_points` 引导充值)+ 可选余额展示。
|
- **第三步**:② 计费错误提示(`insufficient_points` 引导充值)+ 可选余额展示。
|
||||||
- **第四步(T-529)**:产品默认 cmhub,⑤去掉 AI 后端选择,保存固定 `backend=cmhub`。
|
- **第四步(T-529)**:产品默认 cmhub,⑤去掉 AI 后端选择,保存固定 `backend=cmhub`。
|
||||||
- **第五步(T-530)**:Base URL 规整到网关根,404 给出明确中文提示。
|
- **第五步(T-530)**:Base URL 规整到网关根,404 给出明确中文提示。
|
||||||
|
- **第六步(T-564)**:②批量生图切到 cmhub 异步任务接口,持久化 `image_task_id/image_task_key` 支持续查;旧同步生图只保留给单独 `gen_cover()` 兼容。
|
||||||
- 文档随每步同步。
|
- 文档随每步同步。
|
||||||
|
|
||||||
> 当前已在 `docs/06-tasks.md` 落成 T-526~T-535,且 T-525 工程基础设施已完成;下一步按看板进入 T-539 设置简化,仍遵守 `docs/05-coding-rules.md` 验证清单。
|
> T-526~T-535、T-545、T-548、T-549 与 T-564 已按当前任务文件体系落地;后续新任务继续写入 `docs/tasks/T-<编号>.md`,不再新增到冻结的 `docs/06-tasks.md`。
|
||||||
|
|||||||
+13
-12
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
|||||||
|
<div data-v-88e86385="" data-v-7123838c="" class="eds-alert eds-alert--warning eds-alert--with-desc" style="margin-bottom: 16px;"><div data-v-88e86385="" class="eds-alert-icon"><i data-v-ef5019c0="" data-v-88e86385="" class="eds-icon"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path d="M14.5824678,5.27561285 C14.229699,4.44154703 13.7237902,3.69362242 13.0811494,3.05098154 C12.4385085,2.40834066 11.6905839,1.90243188 10.856518,1.54966305 C9.99373848,1.18458834 9.07626607,1 8.13144724,1 C7.18662841,1 6.269156,1.18458834 5.40637643,1.54966305 C4.5723106,1.90243188 3.824386,2.40834066 3.18174512,3.05098154 C2.53910423,3.69362242 2.03319545,4.44154703 1.68042663,5.27561285 C1.31535192,6.13839242 1.13076358,7.05586483 1.13076358,8.00068366 C1.13076358,8.94550249 1.31535192,9.8629749 1.68042663,10.7257545 C2.03319545,11.5598203 2.53910423,12.3077449 3.18174512,12.9503858 C3.824386,13.5930267 4.57367792,14.0975681 5.40637643,14.4503369 C6.269156,14.8154117 7.18662841,15 8.13144724,15 C9.07626607,15 9.99373848,14.8154117 10.856518,14.4503369 C11.6905839,14.0989354 12.4385085,13.5930267 13.0811494,12.9503858 C13.7237902,12.3077449 14.2283317,11.558453 14.5811005,10.7257545 C14.9461752,9.8629749 15.1307636,8.94550249 15.1307636,8.00068366 C15.1307636,7.05586483 14.9475426,6.13839242 14.5824678,5.27561285 Z M8.13144724,11.9385682 C7.64878291,11.9385682 7.25636178,11.5461471 7.25636178,11.0634828 C7.25636178,10.5808184 7.64878291,10.1883973 8.13144724,10.1883973 C8.61411156,10.1883973 9.00653269,10.5808184 9.00653269,11.0634828 C9.00653269,11.5461471 8.61411156,11.9385682 8.13144724,11.9385682 Z M9.00653269,4.93514992 C9.00653269,4.93651724 9.00653269,4.93651724 9.00653269,4.93514992 L9.00653269,4.94745581 C9.00653269,4.99941401 9.00106341,5.05000488 8.99149216,5.09922844 L8.56625532,8.92362535 C8.54164355,9.14513136 8.35432056,9.31331185 8.13144724,9.31331185 C7.90857391,9.31331185 7.72125093,9.14513136 7.69663915,8.92499268 L7.27140231,5.10059576 C7.26183106,5.0513722 7.25636178,5.00078133 7.25636178,4.94882313 L7.25636178,4.9146401 C7.26866767,4.44291435 7.65561952,4.06143178 8.13144724,4.06143178 C8.60727495,4.06143178 8.99422681,4.44154703 9.00653269,4.9146401 L9.00653269,4.93514992 Z"></path></svg></i></div><div data-v-88e86385="" class="eds-alert-content"><div data-v-88e86385="" class="eds-alert-title">審核中 </div><p data-v-88e86385="" class="eds-alert-desc"><span data-v-7123838c="">商品最晚將於提交後2個工作日內完成審核,確認通過後再由系統自動上架 (特殊案件不在此限),未通過審核的商品將依照賣家中心說明降低搜尋排序、禁賣或刪除。</span></p></div><!----></div>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<div data-v-2055a8cc="" data-v-7123838c="" class="product-detail-status"><!----><div data-v-88e86385="" data-v-2055a8cc="" class="eds-alert eds-alert--warning eds-alert--with-desc"><div data-v-88e86385="" class="eds-alert-icon"><i data-v-ef5019c0="" data-v-88e86385="" class="eds-icon"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path d="M14.5824678,5.27561285 C14.229699,4.44154703 13.7237902,3.69362242 13.0811494,3.05098154 C12.4385085,2.40834066 11.6905839,1.90243188 10.856518,1.54966305 C9.99373848,1.18458834 9.07626607,1 8.13144724,1 C7.18662841,1 6.269156,1.18458834 5.40637643,1.54966305 C4.5723106,1.90243188 3.824386,2.40834066 3.18174512,3.05098154 C2.53910423,3.69362242 2.03319545,4.44154703 1.68042663,5.27561285 C1.31535192,6.13839242 1.13076358,7.05586483 1.13076358,8.00068366 C1.13076358,8.94550249 1.31535192,9.8629749 1.68042663,10.7257545 C2.03319545,11.5598203 2.53910423,12.3077449 3.18174512,12.9503858 C3.824386,13.5930267 4.57367792,14.0975681 5.40637643,14.4503369 C6.269156,14.8154117 7.18662841,15 8.13144724,15 C9.07626607,15 9.99373848,14.8154117 10.856518,14.4503369 C11.6905839,14.0989354 12.4385085,13.5930267 13.0811494,12.9503858 C13.7237902,12.3077449 14.2283317,11.558453 14.5811005,10.7257545 C14.9461752,9.8629749 15.1307636,8.94550249 15.1307636,8.00068366 C15.1307636,7.05586483 14.9475426,6.13839242 14.5824678,5.27561285 Z M8.13144724,11.9385682 C7.64878291,11.9385682 7.25636178,11.5461471 7.25636178,11.0634828 C7.25636178,10.5808184 7.64878291,10.1883973 8.13144724,10.1883973 C8.61411156,10.1883973 9.00653269,10.5808184 9.00653269,11.0634828 C9.00653269,11.5461471 8.61411156,11.9385682 8.13144724,11.9385682 Z M9.00653269,4.93514992 C9.00653269,4.93651724 9.00653269,4.93651724 9.00653269,4.93514992 L9.00653269,4.94745581 C9.00653269,4.99941401 9.00106341,5.05000488 8.99149216,5.09922844 L8.56625532,8.92362535 C8.54164355,9.14513136 8.35432056,9.31331185 8.13144724,9.31331185 C7.90857391,9.31331185 7.72125093,9.14513136 7.69663915,8.92499268 L7.27140231,5.10059576 C7.26183106,5.0513722 7.25636178,5.00078133 7.25636178,4.94882313 L7.25636178,4.9146401 C7.26866767,4.44291435 7.65561952,4.06143178 8.13144724,4.06143178 C8.60727495,4.06143178 8.99422681,4.44154703 9.00653269,4.9146401 L9.00653269,4.93514992 Z"></path></svg></i></div><div data-v-88e86385="" class="eds-alert-content"><div data-v-88e86385="" class="eds-alert-title">您的商品未上架</div><p data-v-88e86385="" class="eds-alert-desc">您的商品已被您下架。</p></div><!----></div></div>
|
||||||
+32
-14
@@ -1,6 +1,6 @@
|
|||||||
# 打包与分发
|
# 打包与分发
|
||||||
|
|
||||||
> T-524 目标:先把当前 Windows 桌面工具打成免安装 onedir `.exe`,方便第一版交付使用。暂不做自动更新器、安装器、增量补丁或在线升级。
|
> 当前发布仍是免安装 onedir;T-615 已增加自动升级所需的机器可验证清单和发布元数据,但尚不下载或替换客户端程序。
|
||||||
|
|
||||||
## 一、打包前提
|
## 一、打包前提
|
||||||
|
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
py -3.10 -m pip install -r requirements-build.txt
|
py -3.10 -m pip install -r requirements-build.txt
|
||||||
```
|
```
|
||||||
|
|
||||||
`requirements-build.txt` 会先安装 `requirements.txt` 的运行依赖,再安装 PyInstaller。依赖必须安装到 Python 3.10 环境里,避免 PATH 上的 Python 3.7/3.12/3.14 打出不可比对的包。
|
`requirements-build.txt` 会先安装 `requirements.txt` 的运行依赖,再精确安装 PyInstaller 6.11.1、pyinstaller-hooks-contrib 2026.6、setuptools 80.9.0 和 backports.tarfile 1.2.0。依赖必须安装到 Python 3.10 环境里,避免 PATH 上的 Python 3.7/3.12/3.14 或未锁定的全局构建包装出不可比对的包。运行依赖固定使用 NumPy 1.26.4,以兼容 PySide6/Shiboken 6.5.3;正式打包前不得单独升级到 NumPy 2.x。
|
||||||
|
|
||||||
## 二、打包命令
|
## 二、打包命令
|
||||||
|
|
||||||
@@ -38,7 +38,7 @@ dist\cmshopee\
|
|||||||
|
|
||||||
正式分发入口是 `release\蝦皮圈優化助手<APP_VERSION>.zip`。`dist\cmshopee\` 只是 PyInstaller 构建中间产物,不直接作为正式发包名称。
|
正式分发入口是 `release\蝦皮圈優化助手<APP_VERSION>.zip`。`dist\cmshopee\` 只是 PyInstaller 构建中间产物,不直接作为正式发包名称。
|
||||||
|
|
||||||
当前 `requirements-build.txt` 锁定 PyInstaller 6.11.1,onedir 产物必须是 `cmshopee.exe` + `_internal\` 集中依赖布局。`scripts\build_exe.ps1` 会校验 `_internal\` 存在;缺失时直接失败,避免把不一致的打包环境误发给用户。
|
当前 `requirements-build.txt` 锁定完整构建工具链,`cmshopee.spec` 还会显式检查并收集 `backports.tarfile`,避免 setuptools 的 `pkg_resources/jaraco` 运行时钩子因 vendored 依赖漏收集而在应用入口前崩溃。onedir 产物必须是 `cmshopee.exe` + `_internal\` 集中依赖布局。`scripts\build_exe.ps1` 会校验 `_internal\` 存在,并在组装 release 和压缩 ZIP 前运行 `cmshopee.exe --startup-smoke-test`;自检会完成 PyInstaller 运行时钩子与 GUI 模块导入后立即退出,不创建 GUI、不准备 `data\`、不检查更新或会员状态。自检非零退出或 30 秒超时会直接判定构建失败。
|
||||||
|
|
||||||
发布包只包含程序文件和依赖文件:
|
发布包只包含程序文件和依赖文件:
|
||||||
|
|
||||||
@@ -53,7 +53,7 @@ cmshopee\
|
|||||||
|
|
||||||
## 三、版本号与发布目录
|
## 三、版本号与发布目录
|
||||||
|
|
||||||
参考 `D:\chengma\cmbot` 的打包方式,版本控制采用“单一版本源 + 发布目录带版本号”的轻量方案,暂不引入启动器、自动更新或 manifest。
|
版本控制采用“单一版本源 + 发布目录带版本号 + 可验证 manifest”的方案。
|
||||||
|
|
||||||
### 3.1 单一版本源
|
### 3.1 单一版本源
|
||||||
|
|
||||||
@@ -86,7 +86,9 @@ release\
|
|||||||
<PyInstaller 依赖文件和依赖目录>
|
<PyInstaller 依赖文件和依赖目录>
|
||||||
version.txt
|
version.txt
|
||||||
README.txt
|
README.txt
|
||||||
|
package-manifest.json
|
||||||
蝦皮圈優化助手0.1.0.zip
|
蝦皮圈優化助手0.1.0.zip
|
||||||
|
release-metadata0.1.0.json
|
||||||
```
|
```
|
||||||
|
|
||||||
说明:
|
说明:
|
||||||
@@ -96,6 +98,8 @@ release\
|
|||||||
- `release\蝦皮圈優化助手<APP_VERSION>.zip` 是交付给用户的便携压缩包。
|
- `release\蝦皮圈優化助手<APP_VERSION>.zip` 是交付给用户的便携压缩包。
|
||||||
- `version.txt` 使用 ASCII/UTF-8 无 BOM 写入 `APP_VERSION`,用于人工排查和未来更新机制读取。
|
- `version.txt` 使用 ASCII/UTF-8 无 BOM 写入 `APP_VERSION`,用于人工排查和未来更新机制读取。
|
||||||
- `README.txt` 用中文写明启动方式、不要放入 `Program Files`、保留 `data\`、升级时覆盖程序文件但不覆盖 `data\`。
|
- `README.txt` 用中文写明启动方式、不要放入 `Program Files`、保留 `data\`、升级时覆盖程序文件但不覆盖 `data\`。
|
||||||
|
- `package-manifest.json` 记录包格式、版本、入口、更新器协议、允许替换根项目,以及除清单自身外每个程序文件的规范化路径、字节数和 SHA-256。
|
||||||
|
- `release-metadata<APP_VERSION>.json` 记录对应版本整个 zip 的准确字节数和 SHA-256,并提供服务端发布字段模板;例如 `0.1.6` 生成 `release-metadata0.1.6.json`。它不放进 zip。
|
||||||
|
|
||||||
当前 PyInstaller 6.11.1 的 onedir 为集中依赖结构,所以 `release\蝦皮圈優化助手<APP_VERSION>\` 必须包含 `cmshopee.exe` 和 `_internal\`,不得只复制单个 exe。
|
当前 PyInstaller 6.11.1 的 onedir 为集中依赖结构,所以 `release\蝦皮圈優化助手<APP_VERSION>\` 必须包含 `cmshopee.exe` 和 `_internal\`,不得只复制单个 exe。
|
||||||
|
|
||||||
@@ -103,8 +107,8 @@ release\
|
|||||||
|
|
||||||
1. 修改 `app/version.py` 中的 `APP_VERSION`。
|
1. 修改 `app/version.py` 中的 `APP_VERSION`。
|
||||||
2. 运行语法检查和单元测试。
|
2. 运行语法检查和单元测试。
|
||||||
3. 运行 `powershell -ExecutionPolicy Bypass -File scripts\build_exe.ps1`。
|
3. 运行 `powershell -ExecutionPolicy Bypass -File scripts\build_exe.ps1`;确认日志包含 `Verified packaged startup smoke test`。
|
||||||
4. 检查 `release\蝦皮圈優化助手<APP_VERSION>\version.txt`、GUI 标题栏版本、压缩包文件名三者一致。
|
4. 检查 `version.txt`、GUI 标题栏、`package-manifest.json`、压缩包文件名和 `release-metadata<APP_VERSION>.json` 的版本一致。
|
||||||
5. 在无 Python 环境的 Windows 10/11 机器上解压 `蝦皮圈優化助手<APP_VERSION>.zip` 并启动验证。
|
5. 在无 Python 环境的 Windows 10/11 机器上解压 `蝦皮圈優化助手<APP_VERSION>.zip` 并启动验证。
|
||||||
|
|
||||||
正式发布构建固定使用 Python 3.10。源码在 Python 3.7.9 上曾验证可运行,但发布包不再以 3.7.9 作为打包基线;脚本会直接调用 `py -3.10` 并校验返回版本必须是 `3.10.x`,避免同一个 `APP_VERSION` 在不同 Python 版本下打出不可比对的包。CI 仍可继续使用 Python 3.11 做自动测试。
|
正式发布构建固定使用 Python 3.10。源码在 Python 3.7.9 上曾验证可运行,但发布包不再以 3.7.9 作为打包基线;脚本会直接调用 `py -3.10` 并校验返回版本必须是 `3.10.x`,避免同一个 `APP_VERSION` 在不同 Python 版本下打出不可比对的包。CI 仍可继续使用 Python 3.11 做自动测试。
|
||||||
@@ -166,15 +170,25 @@ T-544 第一版目标是**启动时检查是否必须升级**,但仍不做自
|
|||||||
|
|
||||||
原因:Windows 下运行中的 `cmshopee.exe` 和 PyInstaller onedir 依赖目录很容易出现文件占用、半覆盖失败、权限不足或杀毒拦截。第一版只做“强制提示 + 下载新版 + 用户手动覆盖”,和当前便携包更新方式一致。
|
原因:Windows 下运行中的 `cmshopee.exe` 和 PyInstaller onedir 依赖目录很容易出现文件占用、半覆盖失败、权限不足或杀毒拦截。第一版只做“强制提示 + 下载新版 + 用户手动覆盖”,和当前便携包更新方式一致。
|
||||||
|
|
||||||
### 3.5 暂不引入自动覆盖升级
|
### 3.5 自动升级发布基础(T-615)
|
||||||
|
|
||||||
本阶段不迁移 `cmbot` 的 `Launcher.exe`、`manifest.json`、SHA-256 下载校验、自动替换程序目录或增量补丁。原因:
|
`app/release_manifest.py` 是发布清单与 zip 元数据的唯一生成逻辑。构建脚本按以下顺序执行:组装 release 目录、拒绝用户数据和未知根项目、生成 `package-manifest.json`、压缩 zip、计算 zip hash/大小、生成 `release-metadata<APP_VERSION>.json`。同版本重打只覆盖当前版本元数据,保留其他版本文件,并清理历史无版本 `release-metadata.json`。允许的根项目仅为 `cmshopee.exe`、`_internal/`、`version.txt`、`README.txt`、清单和后续独立更新器;明确禁止 `data/` 与 `.cmshopee-update/`。
|
||||||
|
|
||||||
- 当前用户已验证“拷贝发布目录到无 Python 环境运行”的第一版交付路径。
|
T-615 不改变客户端行为:T-544 仍只打开浏览器下载。客户端下载、解压校验、独立进程替换、回滚和重启分别由 T-616 至 T-619 实现。清单预留签名字段,但当前只验证完整性,不宣称已验证发布者身份。
|
||||||
- `cmshopee` 刚完成 `data\` 数据隔离,先把手动发版和版本号一致性做稳。
|
|
||||||
- 自动更新需要安装根可写、下载暂存、目录切换、回滚和安全校验,属于独立后续任务,不能混入当前打包版本号控制。
|
|
||||||
|
|
||||||
后续如果要做真正自动覆盖升级,再另起任务,参考 `cmbot` 的 `Launcher.exe + app\ + manifest.json + sha256 + version.txt` 设计。
|
T-616 提供无Qt依赖的安全下载暂存层 `app/update_installer.py`。安装包下载到 `<安装目录>/.cmshopee-update/downloads/`,完成大小与zip SHA-256校验后安全解压到同盘 `staging/`;路径穿越、符号链接、大小写重复、Windows保留名、ADS、异常压缩比、文件数/解压总量超限,以及manifest缺失或逐文件hash不一致都会拒绝。完整验证后才原子写 `pending.json`,当前程序目录和 `data/` 不变。
|
||||||
|
|
||||||
|
T-617 增加独立 `cmshopee-updater.exe`。构建脚本用 `cmshopee-updater.spec` 生成无控制台单文件更新器并放进release和manifest。执行更新前,主程序把它复制到系统临时目录;更新器有上限地等待主程序退出,再按manifest白名单将旧程序根项目整体移动到 `.cmshopee-update/backup/`,把已验证暂存根项目移入安装目录。每一步写事务journal,移动或新版启动失败时逆序恢复;`data/` 与安装根未知文件不扫描、不移动、不删除。
|
||||||
|
|
||||||
|
T-618 已把安全暂存和独立更新器接回启动门禁。强制升级窗口使用QThread执行下载、hash、解压和manifest校验,主线程持续显示中文阶段、进度和字节数;协作式取消会等待线程清理,避免线程仍运行时销毁。确认独立更新器进程创建成功后旧主程序退出;强制响应后的任何准备失败都保持阻断,只能重试或退出。版本接口完全不可达/非法仍按T-544策略失败放行。
|
||||||
|
|
||||||
|
T-619 增加启动健康确认与失败熔断。新版带事务参数启动,在 `.cmshopee-update/transactions/<事务>/health.json` 原子写 `process_started`、`main_window_ready` 或 `environment_blocked`。更新器在主窗口就绪前保留旧根项目;早期退出/超时会回滚并按版本+zip hash写 `failed-versions.json`,防止同一坏包无限循环。主窗口就绪后清理pending、zip、staging和本次成功备份;环境阻断保留新版与备份。备份清理只处理 `.cmshopee-update/backup/` 且保留最近两份,不扫描 `data/`。
|
||||||
|
|
||||||
|
T-623 为外部对象存储/CDN下载链接增加临时 HTTP 兼容。版本检查接口仍固定使用受信任 HTTPS;安装包 `download_url` 不限制域名,可以使用任意公网 HTTP/HTTPS 域名或公网 IP,并允许跨域、跨协议重定向。初始地址、每次跳转和最终响应都会拒绝本机、内网、账号信息和非 HTTP(S) 协议。HTTP 不会降低安装校验标准:声明大小、整包 SHA-256、安全解压、包内manifest、事务替换和健康回滚全部照常执行;强制升级窗口会显示临时 HTTP 通道提示。服务端外链支持 HTTPS 后应删除 HTTP 分支。
|
||||||
|
|
||||||
|
T-624 将 cmhub 版本接口收敛为实际字段:`version`、`download_url`、`sha256`、`release_notes`、`force_update`、`size_bytes`、`published_at`。服务端不需要返回 `package_format` 或更新器协议;客户端固定使用当前 `cmshopee-portable-v1` 和更新器协议构造安装元数据,并在解压后以包内manifest复核真实格式和协议。因此接口字段减少不等于跳过包格式或协议安全检查。
|
||||||
|
|
||||||
|
T-705 复用同一 HTTPS 版本接口顶层 `client_policy` 下发订阅检测策略,不增加新的同步启动请求。最后一次有效策略缓存到 `data/config/client_policy.json`,只含非敏感开关与时间;远端和缓存都不可用时回退观察模式。安装包不得预置该缓存,干净安装第一次启动以线上响应为准。远程 `off` 跳过订阅状态请求,`observe` 只展示不限制,`enforce` 才在主窗口显示后异步查询并按 `real_entitlement_allowed` 执行门禁。
|
||||||
|
|
||||||
## 四、绝不打包的本地数据
|
## 四、绝不打包的本地数据
|
||||||
|
|
||||||
@@ -212,6 +226,7 @@ cmshopee\
|
|||||||
config\
|
config\
|
||||||
ai_models.json
|
ai_models.json
|
||||||
cmhub.json
|
cmhub.json
|
||||||
|
client_policy.json
|
||||||
cmshopee.db
|
cmshopee.db
|
||||||
chrome_user_data_dir\
|
chrome_user_data_dir\
|
||||||
images\
|
images\
|
||||||
@@ -222,6 +237,8 @@ cmshopee\
|
|||||||
|
|
||||||
源码运行时同理使用项目根目录下的 `data\`。`config.json` 内的 `user_data_root`、`image_dir`、`db_path` 默认仍保存为 `chrome_user_data_dir`、`images`、`cmshopee.db` 等相对值,运行时再解析到 `data\` 下,保持便携。
|
源码运行时同理使用项目根目录下的 `data\`。`config.json` 内的 `user_data_root`、`image_dir`、`db_path` 默认仍保存为 `chrome_user_data_dir`、`images`、`cmshopee.db` 等相对值,运行时再解析到 `data\` 下,保持便携。
|
||||||
|
|
||||||
|
干净安装的默认网关地址已经内置为 `https://cm.833729.com`,用户不需要先填写 Base URL。`data\config\cmhub.json` 不随安装包分发真实 Key;当线上策略为强制模式且检测到 Key 缺失时显示「激活蝦皮圈优化助手」,用户从官方会员中心取得会员 API Key 后在该窗口验证。无效 Key 或网络失败不会写入本地,验证通过后才保存到 `data\config\cmhub.json`,随后显示添加店铺、人工登录、导入 Excel 和开始采集的首次使用清单。关闭或观察模式不得因缺 Key 阻断启动。
|
||||||
|
|
||||||
这些文件属于用户本地数据,不随新版本程序包覆盖。
|
这些文件属于用户本地数据,不随新版本程序包覆盖。
|
||||||
|
|
||||||
### 旧布局迁移
|
### 旧布局迁移
|
||||||
@@ -234,7 +251,7 @@ cmshopee\
|
|||||||
|
|
||||||
## 六、用户后续更新方式
|
## 六、用户后续更新方式
|
||||||
|
|
||||||
第一版不做自动覆盖升级。给用户发新版本时,或 T-544 启动检查弹出“必须升级”时:
|
尚未安装自动升级引导版本的旧用户仍按以下方式人工覆盖;安装T-619及之后版本后,强制升级窗口可自动下载、替换和重启:
|
||||||
|
|
||||||
1. 让用户先关闭 cmshopee。
|
1. 让用户先关闭 cmshopee。
|
||||||
2. 建议用户备份当前整个程序文件夹。
|
2. 建议用户备份当前整个程序文件夹。
|
||||||
@@ -260,7 +277,8 @@ powershell -ExecutionPolicy Bypass -File scripts\build_exe.ps1
|
|||||||
- 当前 PyInstaller 6.11.1 下 `dist\cmshopee\_internal\` 必须存在。
|
- 当前 PyInstaller 6.11.1 下 `dist\cmshopee\_internal\` 必须存在。
|
||||||
- `dist\cmshopee\` 中没有第四节列出的本地数据,尤其不能含 `data\`。
|
- `dist\cmshopee\` 中没有第四节列出的本地数据,尤其不能含 `data\`。
|
||||||
- `release\蝦皮圈優化助手<APP_VERSION>\version.txt`、GUI 标题栏版本、压缩包文件名三者一致。
|
- `release\蝦皮圈優化助手<APP_VERSION>\version.txt`、GUI 标题栏版本、压缩包文件名三者一致。
|
||||||
- 在干净目录首次启动时能生成 `data\config.json` 并进入 GUI。
|
- 在干净目录首次启动时能生成 `data\config.json`,默认网关地址为 `https://cm.833729.com`;安装包和新建 `data\` 中不得预置真实 Key 或 `client_policy.json`。线上强制策略且缺 Key 时才出现只要求“会员 API Key”的中文激活窗口。
|
||||||
|
- 使用测试接口模拟 `off/observe/enforce`、无效 Key、网络失败和有效套餐:关闭模式不请求订阅状态,观察模式不限制,强制模式只有 `real_entitlement_allowed=true` 才启用业务 Tab;输入的无效 Key 和网络失败不得写入本地文件或日志。
|
||||||
- 在目标 Windows 10/11 机器或虚拟机上启动 release exe 后,主窗口标题栏完整可见,左边缘不出屏,用户能用标题栏拖动窗口;小分辨率环境不得出现窗口卡在左上角且标题栏不可拖动的问题(见 T-541)。
|
- 在目标 Windows 10/11 机器或虚拟机上启动 release exe 后,主窗口标题栏完整可见,左边缘不出屏,用户能用标题栏拖动窗口;小分辨率环境不得出现窗口卡在左上角且标题栏不可拖动的问题(见 T-541)。
|
||||||
|
|
||||||
涉及 Shopee/CDP 的真实更新能力,仍按任务文档要求用测试商品做人工回归;打包任务本身不新增自动绕过登录、验证码或风控的能力。
|
涉及 Shopee/CDP 的真实更新能力,仍按任务文档要求用测试商品做人工回归;打包任务本身不新增自动绕过登录、验证码或风控的能力。
|
||||||
|
|||||||
+144
-73
@@ -1,26 +1,46 @@
|
|||||||
# 界面与流程结构
|
# 界面与流程结构
|
||||||
|
|
||||||
> 桌面工具,无前端路由。用 **5 Tab GUI(PySide6 `QMainWindow` + `QTabWidget`)+ 流水线** 约定界面职责与导航。
|
> 桌面工具,无前端路由。当前正式界面用 **6 Tab GUI(PySide6 `QMainWindow` + `QTabWidget`)+ 流水线** 约定界面职责与导航;商品套图已替换旧 AI工场入口。
|
||||||
|
|
||||||
## Tab 顺序与职责(工作流优先)
|
## Tab 顺序与职责(工作流优先)
|
||||||
|
|
||||||
```
|
```
|
||||||
① 导入采集 │ ② AI生成 │ ③ 更新蝦皮 │ ④ 账号管理 │ ⑤ 设置
|
① 导入采集 │ ② AI生成 │ ③ 更新蝦皮 │ 账号管理 │ 设置 │ 商品套图
|
||||||
```
|
```
|
||||||
|
|
||||||
| Tab | 职责 | 风险 |
|
| Tab | 职责 | 风险 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| ① 导入采集 | 导入多个 Excel;任务列表;**采集**商品当前的旧标题/旧封面(只读),封面图下载本地;回写 Excel 旧字段 | 只读,低 |
|
| ① 导入采集 | 导入多个 Excel;任务列表;**采集**商品当前的旧标题/旧封面(只读),封面图下载本地;回写 Excel 旧字段 | 只读,低 |
|
||||||
| ② AI生成 | 左侧标题/封面**提示词**;右侧按批次/店铺/商品ID/状态筛选任务列表;AI 生成新标题,并按本轮开关可选生成新封面;表格拆分显示「标题状态 / 图片状态」;已生成任务可本地微调新标题;双击看新旧封面 | 不触线上,中 |
|
| ② AI生成 | 左侧标题/封面**提示词**;右侧按批次/店铺/商品ID/状态筛选任务列表;开始前确认“仅状态正常”或“所有状态”生成范围后,AI 生成新标题,并按本轮开关可选生成新封面;表格拆分显示「标题状态 / 图片状态」;已生成任务可本地微调新标题;双击看新旧封面 | 不触线上,中 |
|
||||||
| ③ 更新蝦皮 | 对**已生成**任务点击「开始更新」后弹窗确认;确认后打开编辑页换标题+封面并逐条点「更新」提交;结果回写 Excel | **写线上,高** |
|
| ③ 更新蝦皮 | 对**已生成**任务点击「开始更新」后弹窗确认;确认后打开编辑页换标题+封面并逐条点「更新」提交;结果回写 Excel | **写线上,高** |
|
||||||
| ④ 账号管理 | Shopee 账号(账号名/别名/数据目录/端口/密码本地明文仅参考/登录状态);启动登录、检测登录、生成快捷方式;启动登录必须复用已打开的同账号 Chrome,避免重复开窗口;检测登录遇到 `accounts.shopee.tw/seller/login` 必须显示未登录 | 中 |
|
| 账号管理 | Shopee 账号(账号名/别名/数据目录/端口/密码本地明文仅参考/登录状态);启动登录、检测登录、生成快捷方式;新增账号不自动启动 Chrome,首次启动复用初始卖家中心页,重复操作复用已打开的同账号 Chrome,避免重复开窗口;检测登录遇到 `accounts.shopee.tw/seller/login` 必须显示未登录 | 中 |
|
||||||
| ⑤ 设置 | cmhub 网关/API Key、生文/生图别名、生成参数、本地图片目录、Chrome 路径、默认端口、蝦皮更新安全开关等 | — |
|
| 设置 | 默认网关/API Key 或自定义网关模型配置、生成参数、Chrome 路径、默认端口、蝦皮更新执行参数等 | — |
|
||||||
|
| 商品套图 | 按账号+商品ID管理本地图片项目;拉取/添加商品原图,AI帮写理解图片内容,按套图分类异步生图,查看历史与重试 | 本地生成,中 |
|
||||||
|
|
||||||
任务的**阶段状态**贯穿各 Tab:`imported → collected → generated → applied`(或 `failed/skipped`)。② 不设逐条人工确认阶段;③ 无常驻提交开关,但点击「开始更新」后必须先通过 ⑤ 的蝦皮更新安全开关,再弹窗确认当前筛选范围和任务数量。各 Tab 聚焦各自阶段的列与按钮,但操作同一批任务(同一 batch)。
|
旧 `ImageStudioTab` 与 `image_studio_*` SQLite/图片资产服务继续保留作内部兼容;主窗口只创建 `ProductSuiteTab`,不会并列暴露两套第六 Tab,也不会删除或迁移用户既有项目数据。
|
||||||
|
|
||||||
|
任务的**阶段状态**贯穿各 Tab:`imported → collected → generated → applied`(或 `failed/skipped`)。② 不设逐条人工确认阶段;③ 无常驻提交开关,点击「开始更新」后必须弹窗确认当前筛选范围、任务数量和线上提交风险。各 Tab 聚焦各自阶段的列与按钮,但操作同一批任务(同一 batch)。
|
||||||
|
|
||||||
|
## 启动强制升级门禁
|
||||||
|
|
||||||
|
创建六个业务Tab之前先请求版本接口。服务端明确要求强制升级时,不创建 `MainWindow`,而是显示「必须升级」模态进度窗口:用户点击「立即升级」后可看到下载、校验、准备新版和重启阶段,以及百分比和字节数;运行中可「取消并退出」,失败后可重试。校验完成后软件启动安装目录外的独立更新器并退出,更新器替换程序后自动重启新版。版本接口完全不可达或非法时仍失败放行;一旦已明确强制,元数据缺失或后续失败都不允许进入旧版主界面。
|
||||||
|
|
||||||
|
## 会员订阅接入
|
||||||
|
|
||||||
|
- 启动 HTTPS 版本接口同时返回顶层 `client_policy`,本次运行据此确定 `off/observe/enforce`。有效策略缓存到 `data/config/client_policy.json`;接口失败、字段缺失或非法时优先用缓存,无缓存回退观察模式。策略只在下次启动刷新,设置保存和手动重检不改变本次模式。
|
||||||
|
- `off` 不请求订阅状态、不弹会员处理窗口、不限制业务功能,设置页入口显示「会员检测暂未启用」并禁用;`observe` 后台查询并展示账号/套餐/有效期,但不锁 Tab、不拦截新提交、不弹强制窗口;`enforce` 查询期间及真实权益无效时只保留「设置」Tab,并阻止 AI生成、AI帮写和商品套图的新提交。
|
||||||
|
- 需要查询时,主窗口显示后后台用 `data/config/cmhub.json` 的 API Key 请求 `GET /api/v1/cmshopee/subscription/status`;不会阻塞 Qt 主线程或把 Key 放入 URL、状态栏、日志和错误提示。强制模式只认服务端 `real_entitlement_allowed=true`,不根据展示状态或 `allowed` 兼容字段推断放行。
|
||||||
|
- 干净安装默认网关地址内置为 `https://cm.833729.com`。检测到 Key 缺失时不把零基础用户直接丢进技术设置,而是显示窗口级模态框「激活蝦皮圈优化助手」:只收集“会员 API Key”,提供显示/隐藏、验证、前往官方会员中心和退出。输入值先由 worker 在内存中验证;Key 无效或网络失败不落盘,服务端未拒绝凭据后才写入 `data/config/cmhub.json`。关闭激活窗口等同退出程序,后台业务 Tab 始终不可操作。
|
||||||
|
- 首次激活成功后显示四步使用清单“添加店铺账号 → 人工登录 Chrome → 导入 Excel → 采集/生成/更新”。「开始配置店铺」进入账号管理,「稍后提醒」仅延后到下次启动,「不再提示」停止提示。该状态只存 `onboarding.first_use_guide_state`;默认空值不触发,因此已有 Key 的存量用户不会被误弹新手引导。
|
||||||
|
- Tabs 上方不保留应用内标题或会员状态行。Windows 原生标题默认只显示应用名称;订阅有效时追加“账号名 · 套餐名 · 有效至日期”,宽限期追加宽限截止日。检测中和其他状态立即恢复纯应用名称并在底部状态栏显示脱敏中文结果。标题不得展示 API Key、接口地址、会员中心地址、通知标识或原始错误。
|
||||||
|
- 强制模式按原因处理:缺 Key 或 Key 无效打开会员 API Key 激活窗口;未开通、到期、撤销兼容状态和账号不可用分别显示对应中文窗口;接口/网络/格式异常显示「暂时无法验证会员状态」,不得描述成未付费。窗口提供适用的会员中心、重新检测、打开设置和退出操作;同一状态连续结果只提示一次,恢复真实权益后才允许同类状态再次提示。
|
||||||
|
- 设置页底部提供「重新检测会员状态」;检测期间按钮禁用并显示运行状态。它只通知主窗口复用现有异步检查,保存 cmhub 设置后的自动重查和陈旧线程结果隔离保持不变。远程 `off` 时按钮保留但禁用,避免用户误以为检测失败。
|
||||||
|
- 订阅接口 `404` 只作为旧服务兼容状态展示,不再在强制模式中代表真实权益;网络或格式错误同样不放行,但必须明确是验证不可用而不是会员到期。服务端产品接口始终独立校验授权,客户端策略开关不是授权凭据。
|
||||||
|
- ② AI生成、⑥商品套图和「AI帮写」继续调用同一订阅预检入口;非允许状态不创建新 worker/job。cmhub 产品接口仍是最终授权方;已提交异步任务的查询、下载、历史、预览和导出不因会员状态变化中断。
|
||||||
|
|
||||||
## 全局 Tab 栏可用性
|
## 全局 Tab 栏可用性
|
||||||
|
|
||||||
5 个主 Tab 是高频导航入口,不能使用 Qt 默认的紧凑宽度。`MainWindow` 必须为 `QTabWidget/QTabBar` 设置基础样式:
|
当前 6 个主 Tab 是高频导航入口,不能使用 Qt 默认的紧凑宽度。`MainWindow` 必须为 `QTabWidget/QTabBar` 设置基础样式:
|
||||||
|
|
||||||
- 每个 Tab 设置稳定最小宽度和足够左右 padding,避免文字贴边或窄到误点。
|
- 每个 Tab 设置稳定最小宽度和足够左右 padding,避免文字贴边或窄到误点。
|
||||||
- Tab 之间保留明显间距,当前 Tab 有清晰背景/边框高亮。
|
- Tab 之间保留明显间距,当前 Tab 有清晰背景/边框高亮。
|
||||||
@@ -41,10 +61,10 @@
|
|||||||
|
|
||||||
## 首次使用引导保护
|
## 首次使用引导保护
|
||||||
|
|
||||||
- ① 导入采集 与 ③ 更新蝦皮 都依赖**账号已配置且已登录**(在 ④ 账号管理)。
|
- ① 导入采集 与 ③ 更新蝦皮 都依赖**账号已配置且已登录**(在 账号管理)。
|
||||||
- 当无账号 / 对应账号 Chrome 未启动 / 账号未登录时:相关执行按钮**禁用或在执行前汇总拦截**,并提示「请先到『账号管理』配置账号并登录」。
|
- ① 点击「采集旧标题/旧封面」后,会为本轮匹配到账号的店铺自动确保 Chrome 就绪:已打开则复用,未打开才启动;随后逐账号检测蝦皮登录态。明确进入登录页的账号任务整组略过并在结束汇总中提示去账号管理人工登录;`NO_SESSION_COOKIE` 或 CDP 短暂读取异常只视为登录状态暂不确定,重试后仍不确定也继续尝试采集,避免误判批量略过。
|
||||||
- ③ 点击「开始更新」后必须检查当前筛选结果涉及的账号;只要有账号 Chrome 未启动、CDP 端口不可访问或 Shopee 未登录,就弹窗列出账号并中止本轮更新,不创建真实更新 worker,不提交任何商品。
|
- ③ 点击「开始更新」后必须检查当前筛选结果涉及的账号;只要有账号 Chrome 未启动、CDP 端口不可访问或 Shopee 未登录,就弹窗列出账号并中止本轮更新,不创建真实更新 worker,不提交任何商品。
|
||||||
- 可以提供「打开账号管理」或「启动登录」入口辅助用户处理当前账号;不要无提示批量启动所有账号 Chrome,避免开错账号或启动过多浏览器进程。用户主动点击④「启动登录」时也必须先检查该账号 CDP 端口,已打开则复用现有 Chrome 并打开/激活卖家中心 tab,不重复 `Popen` 新窗口。
|
- 可以提供「打开账号管理」或「启动登录」入口辅助用户处理当前账号;③ 不要无提示批量启动所有账号 Chrome,避免在提交线上前开错账号或启动过多浏览器进程。用户主动点击账号管理中的「启动登录」时必须先检查该账号 CDP 端口:已打开则复用现有 Chrome 并打开/激活卖家中心 tab,不重复 `Popen`;未打开则直接携带卖家中心 URL 冷启动并复用初始 page target,不先开空白页再额外创建页面。启动过程中账号列表和相关按钮禁用,连续点击不排队启动第二轮。
|
||||||
- 老用户账号已就绪则无感。
|
- 老用户账号已就绪则无感。
|
||||||
|
|
||||||
## ① 导入采集
|
## ① 导入采集
|
||||||
@@ -65,83 +85,92 @@
|
|||||||
|
|
||||||
- 导入:openpyxl 解析**输入列**(账号名/别名/商品id)入 SQLite。
|
- 导入:openpyxl 解析**输入列**(账号名/别名/商品id)入 SQLite。
|
||||||
- **导入汇总栏**(导入后即时刷新,跑采集前的校验关口):显示 文件数、解析行数(原始数据量)、有效/无效行、匹配账号行数(按账号细分)、未匹配行数。未匹配/无效数字标红可点,点击在列表筛出便于定位纠错。
|
- **导入汇总栏**(导入后即时刷新,跑采集前的校验关口):显示 文件数、解析行数(原始数据量)、有效/无效行、匹配账号行数(按账号细分)、未匹配行数。未匹配/无效数字标红可点,点击在列表筛出便于定位纠错。
|
||||||
- 采集:用该账号已启动并登录的 Chrome 只读打开商品页,读旧标题、下载旧封面到 `data/images/<batch_id>/<slug>/<task_id>_<item_id>_old.jpg`,写 `old_title/old_cover_path`,stage=collected。若程序为采集自动新建商品页 tab,采集结束后自动关闭;若复用用户原本打开的 tab,则不关闭。
|
- 采集:点击后先从专用范围选择框选择范围,默认「采集架上商品」(即检测结果为正常),「采集全部商品」为警示橙色描边而非删除红色,包含未上架、审核中和状态未知商品;三个选项纵向全宽显示,常见 Windows 缩放下不截断。随后为本轮匹配账号确保 Chrome 就绪(已开复用、未开启动),再检测登录;明确未登录账号的任务整组略过并汇总提示。`NO_SESSION_COOKIE`、登录检测超时或 CDP 短暂异常会重试,连续不确定时不批量略过,继续打开商品页由真实页面结果决定成功/失败。登录账号用对应 Chrome 只读打开商品页,先检测并保存商品状态;默认范围下只有正常商品才读旧标题、下载旧封面到 `data/images/<batch_id>/<slug>/<task_id>_<item_id>_old.jpg`,未上架、审核中和状态未知商品按范围略过且不覆盖已有内容。选择全部范围时四类商品均继续采集。采集不主动把商品页切到前台;程序自动新建商品页 tab 时尽量后台创建,采集结束后自动关闭;若复用用户原本打开的 tab,则不关闭。采集结束不关闭账号 Chrome,用户可自行关闭。
|
||||||
|
- ①任务表显示账号、别名、商品 ID、阶段和商品状态。商品状态筛选位于「处理状态」筛选左侧,选项为全部商品状态、架上商品、未上架、审核中、状态未知、待检测;新打开①默认「全部商品状态」,确保新导入的待检测记录可见。它与批次、店铺、商品 ID 和处理状态叠加。空状态在①显示为「待检测」,显式 `unknown` 显示为「状态未知」;历史默认的架上商品可在 tooltip 查看“未实时检测”备注。商品状态只影响列表可见范围,不改变采集、生成或更新的安全规则。
|
||||||
|
- 商品状态功能于 `2026-07-18T16:34:37` 上线。更早创建的活动历史批次中,空状态在启动数据库时自动按「架上商品」写为 `normal`,备注「历史批次默认按架上商品处理(未实时检测)」且不写检测时间;同一范围内仅把由已知促销编辑提示误写的 `unknown` 修正为 `normal`,保留原检测时间并写明历史修正。其他真实保存的未上架、审核中、状态未知,以及上线时点及之后的空状态均不覆盖;不打开 Chrome、不访问详情页。①不提供「重新检测商品状态」入口。
|
||||||
- 若商品 ID 已失效、无权限或店铺不匹配,Shopee 可能只弹出短暂错误 toast;采集失败时界面日志应显示捕获到的 toast 文案,并把 toast HTML/URL 写入本地诊断日志,避免用户手动抢复制。只有明确捕获商品失效/商品不存在/无权限类 toast 时,①列表“阶段”列显示“商品失效”;其他商品页打开失败仍显示“失败”。如果失败发生在 `open_product()` 内部,本轮自动新建的商品 tab 必须关闭,复用用户已有 tab 不关闭。
|
- 若商品 ID 已失效、无权限或店铺不匹配,Shopee 可能只弹出短暂错误 toast;采集失败时界面日志应显示捕获到的 toast 文案,并把 toast HTML/URL 写入本地诊断日志,避免用户手动抢复制。只有明确捕获商品失效/商品不存在/无权限类 toast 时,①列表“阶段”列显示“商品失效”;其他商品页打开失败仍显示“失败”。如果失败发生在 `open_product()` 内部,本轮自动新建的商品 tab 必须关闭,复用用户已有 tab 不关闭。
|
||||||
|
|
||||||
- 回写:采集完成后自动把旧标题/旧封面路径批量回写原 Excel;保留「回写旧数据到 Excel」作为手动重试入口(原文件被锁→提示关闭后重试/另存)。
|
- 回写:采集完成后自动把旧标题/旧封面路径批量回写原 Excel;保留「回写旧数据到 Excel」作为手动重试入口(原文件被锁→提示关闭后重试/另存)。
|
||||||
- 别名未匹配账号 / 账号未登录 → 该行 skipped 并记原因。`T-207` 接入后,① 采集会像③更新一样写 `run_logs/run_log_events`,并把完整脱敏 traceback 写入本地 `data/logs/`,用于定位失败卡在哪个步骤。
|
- 别名未匹配账号 → 该行 skipped 并记原因,不为未匹配别名启动 Chrome。账号预检或采集中途明确进入登录页 → 该账号剩余任务整组 skipped 并记原因,结束汇总列出需补登录的账号;`NO_SESSION_COOKIE` 等不确定检测结果只写日志和诊断信息,不触发整组 skipped。`T-207` 接入后,① 采集会像③更新一样写 `run_logs/run_log_events`,并把完整脱敏 traceback 写入本地 `data/logs/`,用于定位失败卡在哪个步骤。
|
||||||
|
|
||||||
- 「删除批次」位于①批次筛选旁,只能对当前选中的具体批次执行,不能在“全部批次”下执行;运行中禁用。删除是软删除:写本地批次删除标记,不物理删除 DB 记录,不删除原 Excel,不回滚蝦皮。删除后该批次不再出现在①/②/③任何批次下拉、任务列表、筛选、采集、生成、更新、回写入口中。确认框必须显示任务数、已上线任务数,并提示软删除只隐藏本地批次、不会回滚线上修改。
|
- 「删除批次」位于①批次筛选旁,只能对当前选中的具体批次执行,不能在“全部批次”下执行;运行中禁用。删除是软删除:写本地批次删除标记,不物理删除 DB 记录,不删除原 Excel,不回滚蝦皮。删除后该批次不再出现在①/②/③任何批次下拉、任务列表、筛选、采集、生成、更新、回写入口中。确认框必须显示任务数、已上线任务数,并提示软删除只隐藏本地批次、不会回滚线上修改。
|
||||||
|
- ①任务表右键先选中命中行并打开菜单,第一版仅提供「删除本条记录」;右键本身不弹确认框。点击后才确认商品 ID、店铺和批次;已提交线上时明确提示仅删除本地记录、不回滚蝦皮。单条删除写 `tasks.deleted_at/deleted_reason`,运行中禁用且数据库拒绝 `running` 任务;删除后该记录不再进入①/②/③列表、筛选、采集、生成、更新、回写和默认统计,不删除 Excel、本地图片或账号 Chrome 数据。
|
||||||
## ② AI生成
|
## ② AI生成
|
||||||
|
|
||||||
左右布局:左侧约 1/4 放提示词,右侧放筛选 + 任务列表。
|
左右布局:左侧约 1/4 放提示词,右侧放筛选 + 任务列表。
|
||||||
|
|
||||||
```
|
```
|
||||||
┌ AI生成 ───────────────────────────────────────────────────────┐
|
┌ AI生成 ───────────────────────────────────────────────────────┐
|
||||||
│ ┌─左 ~1/4─┐ ┌──────────────── 右 3/4 ──────────────────────┐ │
|
│ 左侧提示词区: │
|
||||||
│ │标题提示词│ │ 批次[本次▼] 店铺[全部▼] 商品ID[____] 状态[全部▼] [筛选] │ │
|
│ 标题提示词 │
|
||||||
│ │[ ]│ │ ┌──────────────────────────────────────────┐ │ │
|
│ 模板[▼] [新建] [保存模板] [模板操作▼] │
|
||||||
│ │[ ]│ │ │ 店铺名 商品id 旧标题 新标题 标题状态 图片状态 │ │ │
|
│ [标题提示词输入框,较旧版增高] │
|
||||||
│ │ │ │ │ 女装店 511..639 …T恤 …百搭 已生成 未启用 │ │ │
|
│ [插入旧标题] [保存标题提示词] │
|
||||||
│ │封面提示词│ │ │ 女装店 511..640 … — 待生成 待生成 │ │ │
|
│ 封面提示词 │
|
||||||
│ │[ ]│ │ └──────────────────────────────────────────┘ │ │
|
│ 模板[▼] [新建] [保存模板] [模板操作▼] │
|
||||||
│ │[ ]│ │ (双击某行 → 弹窗看 旧封面 | 新封面) │ │
|
│ [封面提示词输入框] [插入标题] [预览] │
|
||||||
│ │ │ │ 运行日志:逐条记录标题/封面生成步骤与失败原因 │ │
|
│ 右侧任务区:批次/店铺/商品ID/状态筛选 + 任务表 + AI生成运行日志 │
|
||||||
│ └──────────┘ └───────────────────────────────────────────────┘ │
|
│ 生成内容[只生成标题▼] 标题30/30 ━━━━━ 生标题用时 18 秒 [▶ 开始生成][■停止][重置生成结果] │
|
||||||
│ [ ] 生成封面图片(成本较高) 标题30/30 ━━━━━ 生标题用时 18 秒 [▶ 开始生成][■停止][重置生成结果] │
|
|
||||||
│ 图片0/0 ━━━━━ 生图用时 0 秒 │
|
│ 图片0/0 ━━━━━ 生图用时 0 秒 │
|
||||||
└───────────────────────────────────────────────────────────────┘
|
└───────────────────────────────────────────────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
- 左侧(提示词管理,上下两块):
|
- 左侧(提示词管理,上下两块):
|
||||||
- **标题提示词**:多行输入 + 「保存标题提示词」(写 `data/title_prompt.txt`)+「插入旧标题」(插 `{旧标题}`);启动时自动加载回显。若标题提示词包含 `{旧标题}`,生成前替换为当前任务旧标题且不重复追加旧标题块;不包含时保持旧行为自动追加旧标题。
|
- **标题提示词**:模板下拉(读 `data/prompts/title/*.txt`)+「新建 / 保存模板 / 模板操作(另存为、重命名、删除)」+ 多行输入 + 下方工具条「插入旧标题」(插 `{旧标题}`)/「保存标题提示词」(写 `data/title_prompt.txt`)。启动时自动加载 `title_prompt.txt` 回显,不自动用模板覆盖工作文本;用户选择模板时才把模板内容载入输入框。若标题提示词包含 `{旧标题}`,生成前替换为当前任务旧标题且不重复追加旧标题块;不包含时保持旧行为自动追加旧标题。
|
||||||
- **封面提示词**:模板下拉(读 `data/prompts/cover/*.txt`)+ 图标工具栏(新建/保存/另存为/重命名/删除)+ 多行输入 + 「插入标题」(插 `{新标题}`)/「预览」(变量替换后查看)。
|
- **封面提示词**:模板控件压缩到一行(读 `data/prompts/cover/*.txt`,新建/保存模板/另存为/重命名/删除)+ 多行输入 + 下方工具条「插入标题」(插 `{新标题}`)/「预览」(变量替换后查看)。
|
||||||
- 变量:标题提示词本阶段只支持 `{旧标题}`;封面提示词支持 `{旧标题}`/`{新标题}`/`{商品id}`/`{店铺}`,生成前按任务替换。
|
- 变量:标题提示词本阶段只支持 `{旧标题}`;封面提示词支持 `{旧标题}`/`{新标题}`/`{商品id}`/`{店铺}`,生成前按任务替换。
|
||||||
- 右上:按导入批次 / 店铺 / 商品ID / 状态筛选任务;商品ID输入框按包含匹配 `item_id`,清空表示全部。
|
- 右上:按导入批次 / 店铺 / 商品ID / 商品状态 / 生成状态筛选任务;商品状态位于生成状态左侧,选项与①一致,按原始快照区分架上商品、未上架、审核中、状态未知和待检测;新打开②默认「架上商品」。生成状态继续表示待生成、已生成、失败、略过、已更新。商品状态筛选只缩小当前列表,商品ID输入框按包含匹配 `item_id`,清空表示全部;用户主动改选商品状态后,刷新或切换其他筛选条件保留该选择。
|
||||||
- 右下:任务列表(店铺名、商品id、旧标题、新标题、标题状态、图片状态)+ AI生成运行日志;标题/图片状态由 `new_title`、`new_cover_path`、`stage/status` 和失败步骤推导,帮助用户区分“标题未生成 / 图片未生成 / 标题成功但图片失败”。商品ID列按原等分宽度约 50% 显示;标题状态和图片状态列在 T-554 基础上再缩到约 33%,缩出的宽度平均给旧标题和新标题。已生成、未提交线上、非运行中的任务可双击「新标题」列本地微调,写回 `tasks.new_title`,清空 `last_error` 并回到可更新;双击其他列弹窗展示旧封面 | 新封面(纯查看)。
|
- 筛选行提供「打开图片文件夹」按钮,用于只读打开本地图片目录:选中某行时打开该商品所在账号图片文件夹(优先打开已有新/旧封面文件的真实父目录,缺失时回退到规范账号目录);未选行且选择具体批次时打开该批次图片文件夹;未选行且为全部批次时打开图片根目录。目录不存在只中文提示,不自动创建目录,不修改任务状态。
|
||||||
- 底部**单个「开始生成」+「停止」**,并增加「生成封面图片(成本较高)」复选框:默认不勾选,只生成标题;勾选后才在标题完成后按 `image_concurrency` 生成封面。标题/图片两条进度条右侧分别显示同宽用时标签(`生标题用时 N 秒` / `生图用时 N 秒`),运行中每秒递增,完成/停止后冻结;原图片进度条右侧的失败数和 cmhub 余额不再占用该位置。cmhub 模式会把用户设置的图片并发内部限制到最大 5,并用同样最大 5 的独立下载线程池拉取 `image_url`,不新增用户可见下载并发配置;运行日志显示用户设置并发和实际并发。该开关状态持久化到 `config.json` 的 `ai.generate_cover`,但入口放在②,便于用户在每轮生成前做成本判断。
|
- 右下:任务列表(店铺名、商品id、旧标题、新标题、标题状态、图片状态)+ AI生成运行日志;标题/图片状态由 `new_title`、`new_cover_path`、`stage/status` 和失败步骤推导,帮助用户区分“标题未生成 / 图片未生成 / 标题成功但图片失败”。商品ID列按原等分宽度约 50% 显示;标题状态和图片状态列在 T-554 基础上再缩到约 33%,缩出的宽度平均给旧标题和新标题。已生成、未提交线上、非运行中的任务可双击「新标题」列本地微调,写回 `tasks.new_title`,清空 `last_error` 并回到可更新;双击其他列弹窗展示旧封面、新封面和历史候选图。
|
||||||
- 生成参数(标题/图片并发数、失败重试、分辨率、jpg 质量、cmhub 网关/Key/别名)在 **⑤ 设置**;②只暴露“本轮是否生成封面”的成本开关。⑤ 不新增“下载并发”控件;cmhub 图片下载并发由程序按实际生图并发自动计算,最大 5。
|
- 底部**单个「开始生成」+「停止」**,并增加「生成内容」下拉:默认只生成标题,可选只生成封面或生成标题和封面;只生成封面不调用生文,有新标题时优先使用,没有时用已采集旧标题作为封面prompt参考,新旧标题都为空才不纳入。开始前先从真实候选重新分组商品状态,弹出与①共用的纵向范围确认框:默认「生成架上商品」(即仅检测结果为正常的商品),「生成全部商品」为警示橙色描边,还会处理未上架、审核中、状态未知商品,可能额外消耗点数;未上架、审核中、状态未知默认不入队、不请求 AI、不消耗点数,用户明确选择全部范围才可入队。商品状态功能上线前的历史空状态会启动时迁移为默认正常;上线后仍为空的记录按未知处理。范围确认不跨轮记忆,取消或候选在确认期间变化均不启动生成;无异常候选时该警示选项禁用。标题/图片两条进度条右侧分别显示同宽用时标签(`生标题用时 N 秒` / `生图用时 N 秒`),运行中每秒递增,完成/停止后冻结;原图片进度条右侧的失败数和 cmhub 余额不再占用该位置。cmhub 模式会把用户设置的图片并发内部限制到最大 5,并用同样最大 5 的独立下载线程池拉取 `image_url`,不新增用户可见下载并发配置;运行日志显示用户设置并发和实际并发。下拉状态持久化到 `config.json` 的 `ai.generate_mode`,并继续写回旧兼容 `ai.generate_cover`。
|
||||||
- 标题-only 模式标题成功即写库并进入 `generated`,`new_cover_path` 留空;标题+封面模式图片成功后写入本地新封面路径。两种模式都写 `run_type=generate` 的 `run_logs/run_log_events` 和用户可读滚动日志;未勾选生成封面时日志明确显示“本轮仅生成标题”。点击「开始生成」时先清空②界面可见日志并写入本轮开始摘要,运行中只追加本轮日志;不删除历史 `run_logs/run_log_events` 或本地 `data/logs/`。进入页面默认可显示“本轮日志会在开始运行后显示”,历史日志不自动混入当前运行界面。「停止」取消未开始项,可再次「开始生成」对剩余继续。
|
- 点击「开始生成」还会经过主窗口统一会员预检。远程策略为 `off/observe` 时直接放行;`enforce` 时只有 `real_entitlement_allowed=true` 才创建 `GenerateWorker`,未订阅、到期、Key 无效或状态暂时无法确认时引导到设置、激活窗口或线上会员中心。
|
||||||
- 「重置生成结果」仅作用当前选中单条,运行中禁用;确认后只改本地 DB,清空 `new_title/new_cover_path/last_error` 并退回 `collected/success` 供重新生成,默认不删除本地新封面文件。若该记录已提交过线上,必须在确认框提示本地重置不回滚蝦皮。
|
- 生成参数(标题/图片并发数、失败重试、分辨率、jpg 质量、cmhub 网关/Key/别名)在 **设置**;②只暴露本轮生成标题/封面/图文的内容模式。设置不新增“下载并发”控件;cmhub 图片下载并发由程序按实际生图并发自动计算,最大 5。
|
||||||
- **无逐条人工审核环节**;新标题默认直接用 AI 输出,但允许对已生成且未提交线上的单行做本地微调;可选对单行 `重生成`。生成完即可进入 ③,③ 开始更新前会做批量确认。
|
- 只生成标题时标题成功即写库并进入 `generated`,保留已有封面;只生成封面时通过组件级写库只保存 `new_cover_path`,不覆盖已有标题,也不把旧标题写入空的 `new_title`;生成标题和封面时按缺失组件增量补齐。“有新封面、无新标题”时标题状态为待生成、图片状态为已生成,后续补标题不重复生图。三种模式都写 `run_type=generate` 的 `run_logs/run_log_events` 和用户可读滚动日志;日志开头明确显示本轮生成内容。点击「开始生成」时先清空②界面可见日志并写入本轮开始摘要,运行中只追加本轮日志;不删除历史 `run_logs/run_log_events` 或本地 `data/logs/`。进入页面默认可显示“本轮日志会在开始运行后显示”,历史日志不自动混入当前运行界面。「停止」取消未开始项,可再次「开始生成」对剩余继续。即使用户通过商品状态筛选查看异常商品,也必须在范围确认框主动选择「生成全部商品」才会入队;默认「生成架上商品」不会因筛选而放宽。
|
||||||
|
- 「重置生成结果」支持选中任务或当前筛选结果,运行中禁用;确认框提供「重置标题 / 重置封面 / 重置全部」,只改本地 DB,默认不删除本地新封面文件。若范围内包含已提交线上记录,必须提示本地重置不回滚蝦皮,重生成后再更新会再次提交线上。
|
||||||
|
- ②任务表右键仅作用于鼠标命中的单条记录,提供「删除本条记录」。确认后调用任务级软删除:记录立即从①②③的列表、筛选和后续采集/生成/更新/回写范围排除;不删除原 Excel、本地图片、账号 Chrome 或蝦皮线上商品。生成中或任务运行中禁用该入口;已提交线上记录的确认框明确提示不回滚线上。
|
||||||
|
- **无逐条人工审核环节**;新标题默认使用 AI 输出,但允许对已生成且未提交线上的单行做本地微调。封面画廊内「重置图片」只清当前任务 `new_cover_path` 并归档旧图,不启动单条生图;用户翻看完后用状态筛选「待生成」并点击「开始生成」批量补封面。生成完即可进入 ③,③ 开始更新前会做批量确认。
|
||||||
|
|
||||||
## ③ 更新蝦皮
|
## ③ 更新蝦皮
|
||||||
|
|
||||||
```
|
```
|
||||||
┌ 更新蝦皮 ───────────────────────────────────────────────────┐
|
┌ 更新蝦皮 ───────────────────────────────────────────────────┐
|
||||||
│ 批次[本次▼] 店铺[全部▼] 商品ID[____] 状态[已生成▼] [筛选] │
|
│ 批次[本次▼] 店铺[全部▼] 商品ID[____] 状态[已生成▼] [筛选] │
|
||||||
│ ⚠ 点击「开始更新」后先过安全开关,再确认【当前筛选结果】并提交 │
|
│ ⚠ 点击「开始更新」后先校验更新内容,再确认【当前筛选结果】并提交 │
|
||||||
│ ┌───────────────────────────────────────────────────────────┐ │
|
│ ┌───────────────────────────────────────────────────────────┐ │
|
||||||
│ │ 店铺 商品ID 新标题 新封面 阶段 结果 │ │
|
│ │ 店铺 商品ID 新标题 新封面 阶段 结果 │ │
|
||||||
│ └───────────────────────────────────────────────────────────┘ │
|
│ └───────────────────────────────────────────────────────────┘ │
|
||||||
│ 运行日志:检查/真实更新逐条留痕 │
|
│ 运行日志:检查/真实更新逐条留痕 │
|
||||||
│ [检查本轮更新] [▶ 开始更新] [■停止] [回写结果到 Excel] │
|
│ 更新内容[只更新标题▼] [检查本轮更新] [▶ 开始更新] [■停止] [回写结果到 Excel] │
|
||||||
└───────────────────────────────────────────────────────────────┘
|
└───────────────────────────────────────────────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
- 顶部**按批次 / 店铺 / 商品ID / 状态筛选**(与 ①②一致);商品ID输入框按包含匹配 `item_id`,清空表示全部;「开始更新」作用于**当前筛选结果**,是一道范围控制。
|
- 顶部**按批次 / 店铺 / 商品ID / 商品状态 / 更新状态筛选**(与①②一致);商品状态位于更新状态左侧,选项与①一致,按原始快照区分架上商品、未上架、审核中、状态未知和待检测;新打开③默认「架上商品」,用户主动改选后刷新或切换其他筛选条件保留该选择。更新状态继续表示已生成、失败、已更新、略过和全部状态。商品ID输入框按包含匹配 `item_id`,清空表示全部;「开始更新」作用于**当前筛选结果**,是一道范围控制。
|
||||||
- 店铺筛选:建议**逐店铺更新**(每店铺需先启动其 Chrome 并登录)。
|
- 店铺筛选:建议**逐店铺更新**(每店铺需先启动其 Chrome 并登录)。
|
||||||
- 状态筛选:`已生成` 只跑未更新的;`失败` 用于**失败重试**;`已更新成功/略过` 仅查看。
|
- 状态筛选:`已生成` 只跑未更新的;`失败` 用于**失败重试**;`已更新成功/略过` 仅查看。
|
||||||
- 「检查本轮更新」只读取当前筛选结果并写运行日志,不打开 Shopee、不提交、不改任务状态;弹窗/日志展示总任务数、店铺分布、会更新标题/封面、略过原因、每批最大条数和预计批次数。
|
- 「更新内容」下拉支持只更新标题、只更新封面、更新标题和封面;点击「检查本轮更新」或「开始更新」前统一用状态优先的预检计划:仅 `product_status=normal` 可进入标题/封面完整性检查,`unlisted`、`reviewing`、`unknown` 及上线后历史 NULL 一律排除,不传给 `ApplyWorker`。商品状态功能上线前的活动历史批次空状态已在数据库初始化时默认迁移为 `normal`,可进入既有内容完整性检查;该默认值会标注为未实时检测,但不要求用户额外打开商品页。状态异常和缺失内容记录都在确认框、状态栏和本轮日志列出分类、数量与示例商品ID;全部被排除时按真实原因中文弹窗阻断,不打开 Chrome、不改任务状态。用户确认后重新计算计划指纹,任务状态、更新时间、新标题或新封面变化则中止并要求重新开始。
|
||||||
- 点击「开始更新」先读取 ⑤ `shopee_update` 安全设置:未允许真实提交,或包含新封面但未允许封面更新时,直接弹警告并阻断;拦截弹窗必须写明具体未开启的设置项,并提供「前往设置」跳到 ⑤;普通正式更新不再以测试商品 ID 限制当前筛选结果,允许当前筛选结果包含多个真实商品 ID;`max_items_per_run` 作为**每批最大更新条数**,当前筛选总数超过该值时不阻断,而是自动分批执行。
|
- 「检查本轮更新」只读取当前筛选结果并写运行日志,不打开 Shopee、不提交、不改任务状态;弹窗/日志展示总任务数、店铺分布、当前商品状态/更新状态筛选、当前更新内容、会更新标题/封面、略过原因、每批最大条数和预计批次数。
|
||||||
- 安全开关通过后,弹窗展示本次筛选条件、任务总数、每批最大条数、预计批次数、安全设置和“将提交线上”的风险提示;用户点「是/确认」才开始,点「否/取消」不执行。
|
- 完成缺失内容预检并剔除不合格记录后,点击「开始更新」读取设置中的 `shopee_update` 执行设置:普通正式更新不再以测试商品 ID 或旧真实提交开关限制当前筛选结果,允许当前筛选结果包含多个真实商品 ID;`max_items_per_run` 作为**每批最大更新条数**,当前可执行任务超过该值时不阻断,而是自动分批执行。
|
||||||
|
- 弹窗展示本次筛选条件、更新内容、任务总数、每批最大条数、预计批次数、执行设置和“将提交线上”的风险提示;用户点「是/确认」才开始,点「否/取消」不执行。
|
||||||
- 真实更新第一条商品前做账号就绪预检:按当前筛选结果汇总需要的账号;无账号、Chrome 未启动、CDP 端口不可访问、未登录或端口冲突时,弹窗列出具体账号/原因并中止本轮,不自动调用「启动登录」或静默打开 Chrome。
|
- 真实更新第一条商品前做账号就绪预检:按当前筛选结果汇总需要的账号;无账号、Chrome 未启动、CDP 端口不可访问、未登录或端口冲突时,弹窗列出具体账号/原因并中止本轮,不自动调用「启动登录」或静默打开 Chrome。
|
||||||
- 对确认后的**已生成(generated)任务**执行:打开编辑页换标题+换封面 → 点页面「更新」 → 如 Shopee 弹出“確定您要更新商品嗎?”确认框(`.eds-modal__content` / `.eds-modal__box`),则只点弹窗主按钮「更新」提交,不点「立即優化」。
|
- 对确认后的**已生成(generated)任务**按当前更新内容执行:打开编辑页换标题和/或换封面 → 点页面「更新」 → 如 Shopee 弹出“確定您要更新商品嗎?”确认框(`.eds-modal__content` / `.eds-modal__box`),则只点弹窗主按钮「更新」提交,不点「立即優化」。
|
||||||
|
- ③每条更新任务都会把对应 Chrome 的当前商品 tab 切到前台;新建 tab 不使用后台创建,复用 tab 时也显式激活。上传封面、等待图片管理器刷新、拖到第一位和提交线上均以稳定性优先,因此批量更新期间 Chrome 会随任务切换到前台。后台态封面安全恢复逻辑仅为兼容直接调用保留,正常③批量路径不依赖它。
|
||||||
- 打开编辑页失败时,如果 Shopee 弹出错误 toast(如商品 ID 不正确、商品不存在、无权限),③运行日志和任务失败原因必须显示该 toast 文案;同时把 toast HTML/URL/时间写入本地诊断日志。用户不需要手动复制瞬时 toast 的 HTML。
|
- 打开编辑页失败时,如果 Shopee 弹出错误 toast(如商品 ID 不正确、商品不存在、无权限),③运行日志和任务失败原因必须显示该 toast 文案;同时把 toast HTML/URL/时间写入本地诊断日志。用户不需要手动复制瞬时 toast 的 HTML。
|
||||||
|
|
||||||
- 更新封面时统一按替换第一张执行:删除第一张前必须已有该任务的本地旧封面备份(①采集得到的 `old_cover_path` 且文件存在);备份缺失时阻断该条更新并提示先采集/修复备份,不盲删线上图片。
|
- 更新封面时统一按替换第一张执行:删除第一张前必须已有该任务的本地旧封面备份(①采集得到的 `old_cover_path` 且文件存在);备份缺失时阻断该条更新并提示先采集/修复备份,不盲删线上图片。
|
||||||
- 默认串行、单条失败继续;⑤ 可开启多账号并行,不同账号同时执行,同一账号内仍串行;真实更新前若本轮账号调试端口冲突则阻断。
|
- 默认串行、单条失败继续;设置中的「同时更新蝦皮账号」设为 1 时逐个账号执行,设为 2..5 时不同账号同时执行,同一账号内仍串行;真实更新前若本轮账号调试端口冲突则阻断。
|
||||||
- 「检查本轮更新」只写运行日志与检查汇总,不打开 Shopee、不调用 `editor.apply_task()`、不写任务状态、不回写 Excel。
|
- 「检查本轮更新」只写运行日志与检查汇总,不打开 Shopee、不调用 `editor.apply_task()`、不写任务状态、不回写 Excel。
|
||||||
- 真实更新每条立即写回 SQLite(committed/状态/error),失败不阻塞后续任务;检查和真实更新都会写 `run_logs/run_log_events`。点击「检查本轮更新」或「开始更新」时先清空③界面可见日志并写入本轮检查/更新开始摘要,运行中只追加本轮日志;不删除历史 `run_logs/run_log_events` 或本地 `data/logs/`,历史日志不自动混入当前运行界面。
|
- 真实更新每条立即写回 SQLite(committed/状态/error),失败不阻塞后续任务;检查和真实更新都会写 `run_logs/run_log_events`。点击「检查本轮更新」或「开始更新」时先清空③界面可见日志并写入本轮检查/更新开始摘要,运行中只追加本轮日志;不删除历史 `run_logs/run_log_events` 或本地 `data/logs/`,历史日志不自动混入当前运行界面。
|
||||||
- 分批更新时「停止」为协作式停止:已开始的当前商品跑到安全边界并写库后停止,不再开始新商品、不进入下一批;未开始任务保持原状态,下次可继续。
|
- 分批更新时「停止」为协作式停止:已开始的当前商品跑到安全边界并写库后停止,不再开始新商品、不进入下一批;未开始任务保持原状态,下次可继续。
|
||||||
- 更新前做账号就绪预检:无账号、对应账号 Chrome 未启动、CDP 端口不可访问或未登录时整体阻断并引导去④账号管理,不进入逐条提交,也不自动打开账号 Chrome。
|
- 更新前做账号就绪预检:无账号、对应账号 Chrome 未启动、CDP 端口不可访问或未登录时整体阻断并引导去账号管理,不进入逐条提交,也不自动打开账号 Chrome。
|
||||||
- 若 ⑤ 开启“成功后关闭本次新开编辑页”,则仅关闭本轮程序自动新开且成功提交的商品页;确认后跳回我的商品列表页时,关闭前等待 2 秒;已进入编辑页后的失败任务和用户原本打开的 tab 不关闭。但如果失败发生在 `open_product()` 内部,程序自动新开的无效商品页 tab 要关闭,避免 Chrome 残留空错误页。
|
- 若设置中开启“成功后关闭本次新开编辑页”,则仅关闭本轮程序自动新开且成功提交的商品页;确认后跳回我的商品列表页时,关闭前等待 2 秒;已进入编辑页后的失败任务和用户原本打开的 tab 不关闭。但如果失败发生在 `open_product()` 内部,程序自动新开的无效商品页 tab 要关闭,避免 Chrome 残留空错误页。
|
||||||
- 「开始更新」是③的主操作按钮,视觉上必须强于其他批处理按钮。
|
- 「开始更新」是③的主操作按钮,视觉上必须强于其他批处理按钮。
|
||||||
- 「重置更新状态」从底部批处理按钮移到任务表右键菜单/高级入口,仅作用当前选中单条,运行中禁用;保留 `new_title/new_cover_path`,只把本地状态退回可更新,用于重复测试上传/提交。若 `committed=1`,确认框必须提示线上已提交过、本地重置不回滚蝦皮、重复更新会再次提交;不得静默清除 committed 历史事实。
|
- 「重置更新状态」从底部批处理按钮移到任务表右键菜单/高级入口,仅作用当前选中单条,运行中禁用;保留 `new_title/new_cover_path`,只把本地状态退回可更新,用于重复测试上传/提交。若 `committed=1`,确认框必须提示线上已提交过、本地重置不回滚蝦皮、重复更新会再次提交;不得静默清除 committed 历史事实。
|
||||||
|
- ③任务表右键保留「重置更新状态」,并以分隔线增加「删除本条记录」。删除只对鼠标命中的一条本地记录生效,复用任务级软删除;更新或结果回写运行中禁用。确认内容须说明商品、店铺、批次及不删除 Excel、图片、账号 Chrome 和蝦皮线上商品;已提交线上记录额外提示本地删除不回滚线上。
|
||||||
|
- ③任务表**左键双击**某行可人工查看该商品:在对应账号 Chrome 中,已存在同商品详情页时只切到前台,不刷新、不重新导航;没有才新建一个前台详情页并等待就绪。右键双击不触发此行为。账号不存在、Chrome/CDP 未启动、未登录或页面失败只在状态栏显示中文引导前往④账号管理,不自动启动 Chrome 或登录;查看不会读写任务、Excel、图片、AI 或线上商品。新建页失败只关闭本轮新建 tab,成功页和用户既有页都保留。
|
||||||
- 更新完成后自动回写原 Excel:写入新标题、新封面图片路径、更新状态;原文件被锁时提示关闭后点击「回写结果到 Excel」手动重试。
|
- 更新完成后自动回写原 Excel:写入新标题、新封面图片路径、更新状态;原文件被锁时提示关闭后点击「回写结果到 Excel」手动重试。
|
||||||
- 自动回写完成后弹窗汇总成功/失败/略过数量与 Excel 回写文件/行数。
|
- 自动回写完成后弹窗汇总成功/失败/略过数量与 Excel 回写文件/行数。
|
||||||
|
|
||||||
## ④ 账号管理
|
## 账号管理
|
||||||
|
|
||||||
```
|
```
|
||||||
┌ 账号管理 ─────────────────────────────────────────────────────┐
|
┌ 账号管理 ─────────────────────────────────────────────────────┐
|
||||||
@@ -153,58 +182,100 @@
|
|||||||
|
|
||||||
账号弹窗字段:账号名、别名(唯一,Excel 用它匹配)、地区域名、调试端口、密码(本地明文仅参考,保存/变更时提示,UI 打码,不自动登录)、备注;配置目录按别名生成 slug 只读显示。
|
账号弹窗字段:账号名、别名(唯一,Excel 用它匹配)、地区域名、调试端口、密码(本地明文仅参考,保存/变更时提示,UI 打码,不自动登录)、备注;配置目录按别名生成 slug 只读显示。
|
||||||
|
|
||||||
## ⑤ 设置
|
## 设置
|
||||||
|
|
||||||
- 设置页整体布局:内容区居中,左右留白已从 T-506 初始实现缩短到约 40%;实现上用最大内容宽度 + 自适应 margin,而不是写死窗口像素。所有设置组默认响应式 3 列表单:短字段占 1 格,URL/API Key/路径等长字段跨 2 格或 3 格;窄窗口自动降为 2 列/1 列。点击「保存设置」成功后,状态栏显示“设置已保存”,并弹出轻量提示框。T-531 已完成:保存成功会清除未保存标记;保存失败时保留未保存标记并阻止离开。
|
- 设置页整体布局:内容区居中,左右留白已从 T-506 初始实现缩短到约 40%;实现上用最大内容宽度 + 自适应 margin,而不是写死窗口像素。所有设置组默认响应式 3 列表单:短字段占 1 格,URL/API Key/路径等长字段跨 2 格或 3 格;窄窗口自动降为 2 列/1 列。点击「保存设置」成功后,状态栏显示“设置已保存”,并弹出轻量提示框。T-531 已完成:保存成功会清除未保存标记;保存失败时保留未保存标记并阻止离开。设置页所有数字微调框禁用滚轮改值,滚动输入交给外层设置页,仍可通过箭头、键盘和直接输入修改。
|
||||||
- cmhub 网关配置(T-529 后普通用户唯一 AI 入口):⑤设置页不再显示「AI 后端」label 或 direct/cmhub 下拉,直接展示 cmhub 网关 Base URL、API Key、生文别名、生图别名、连接超时、刷新别名、测试连接/查余额;Base URL 输入框旁提示“只填网关根,如 https://host”,保存/刷新前会规整掉 `/api`、`/api/v1` 或其它路径。刷新别名/测试连接使用当前输入框内容发起请求,但不自动保存 URL/API Key,成功文案需提醒“记得点保存设置持久化”。
|
- 生成网关(T-677/T-680/T-684):在原「AI 模型配置」标题下使用同行标准单选框「默认网关 / 自定义网关(仅支持 OpenAI 格式)」替代历史「AI 后端」下拉和不明显的分段按钮;选中自定义网关时,在其下显示“只有服务商明确说明‘支持 OpenAI 格式接口’时才选择此项。”,并提供同义 tooltip。启动/放弃未保存更改时按已保存的 `ai.backend=cmhub/direct` 自动勾选对应项,圆点与焦点状态表达当前选择。切换只改变待保存状态并立即切换原有面板,点击「保存设置」才写入配置;默认网关的 `data/config/cmhub.json` 与自定义模型的 `data/config/ai_models.json` 始终并存,模型新增、保存、删除仍立即写盘,不受来源待保存语义影响。除这两个选择控件外,不改变设置页其它组件、表单布局或业务逻辑。
|
||||||
- T-532 需求:测试连接/查余额成功时,如果 cmhub `/balance` 返回账号名、用户名或邮箱等可识别信息,成功提示应显示 `cmhub 账号「<账号名>」连接成功:...`,让用户确认当前 API Key 属于哪个 cmhub 账号;当前接口结构兼容 `{ "user": "cmhub_user", "points_balance": 88, "account": { "username": "cmhub_user", "display_name": "主账号" } }`,优先显示 `account.display_name`,没有时再用 `account.username` / `user` 等兜底;邮箱需脱敏,没有账号信息时保留 `cmhub 连接成功:...` 兜底文案。
|
- 默认网关面板展示 Base URL、API Key、生文/生图/图片理解别名、连接超时、批量生成前检查余额、刷新别名和测试连接/查余额;Base URL 输入框旁提示“只填网关根,如 https://host”,保存/刷新前规整掉 `/api`、`/api/v1` 或其它路径。测试连接成功优先显示 `账号「<账号名>」连接默认网关成功`,邮箱脱敏;没有账号信息时显示「默认网关连接成功」。Key 只本地明文保存、UI 打码显示,不进入日志或导出。
|
||||||
- API Key 存在 `data/config/cmhub.json`,本地明文保存;保存/变更时提示;UI 使用密码框打码显示,不进入日志/导出。
|
- 自定义网关复用既有模型列表、其下紧凑显示的模型配置字段、默认文本模型与默认图像模型;不再显示重复的“模型详情”标题,模型选择区到配置表单保留 8px 间距。文本模型保留 `chat` / `auto`;图片模型固定使用「OpenAI 图片编辑接口」(`images_edits`),②和⑥通过 `/v1/images/edits` 以有序重复 `image[]` 上传,第一张是商品主体图、每次只请求 `n=1` 输出。历史 `chat` / `auto` 图片模型不会被改写或隐藏,但会标示「图片接口不支持」,生成前会被拦截。图片响应只接受 `data[].b64_json` / `data[].url`,不适配聊天响应或服务商私有结构。说明行明确「图片理解仅支持默认网关」。
|
||||||
- 生文/生图别名来自 `GET /api/v1/models` 动态下拉,过滤未定价别名并展示单价/需原图提示;网关临时不可达时保留已存别名。
|
- 图片模型按钮显示「检查图片配置」,只在本地检查接口类型、URL、模型 ID、密钥和超时,绝不对 `/images/edits` 发送无图片的测试请求;实际可用性必须以②或后续⑥的正常生成确认判断。允许保存不完整配置。②点击生成后、自定义 worker 创建前按本轮标题/封面选择校验所需模型的启用状态、类别、地址、模型 ID、API Key 和接口类型,失败提示去⑤补齐且不发请求、不改任务状态;自定义网关不显示默认网关余额,运行日志和状态栏说明“不计点数,费用由服务商收取”。
|
||||||
- 保存设置固定写 `ai.backend=cmhub`。允许先保存不完整 cmhub 配置,②真正生成时如果缺 Base URL/API Key/别名,会提示去⑤补配置,不静默回退 direct。
|
- T-679b 后,⑥商品套图在自定义网关下先本地校验已启用的图片模型、接口类型、网址、模型 ID、密钥与连接超时;不合格时阻止创建 job 并引导到⑤补齐。合格后可创建同步直连 job,确认框显示数量、主图/参考图、逐图模式、比例映射与“自定义网关不计点数,实际费用以服务商为准”,并说明首图为主要商品参考、主体一致性可能弱于默认网关;不请求 cmhub 生图模型目录、余额或价格。AI帮写与生图来源解耦,只要 cmhub 地址、API Key 和图片理解别名完整,在自定义生图网关下仍可使用。已有 `generation_source=cmhub`、`provider=cmhub` 且 `task_id` 非空的可恢复任务仍显示「继续查询已提交图片」,仅轮询、下载和保存,不新建 job 或重复扣点。所有②/⑥ worker 开始时冻结来源、实际模型和密钥的仅内存快照,保存设置不会改变正在执行的一轮。
|
||||||
- T-531 已完成:⑤设置页任意可编辑控件变更都进入未保存状态,保存按钮旁显示“● 未保存更改”;切换到其它 Tab 或关闭窗口时弹出保存/放弃/取消。放弃会重新从本地配置文件回填控件,避免未保存的 URL/API Key 留在界面上;程序化回填、保存后重载和刷新别名填充下拉不会误触发未保存状态。
|
- T-679c 后,「继续查询已提交图片」只显示并处理 `generation_source=cmhub`、`provider=cmhub` 且 `task_id` 非空的任务,切换到自定义网关后仍可继续查询旧默认网关任务;direct job 永不进入该入口。结果卡、项目历史和全局历史固定显示 job 已保存的“默认网关 / 自定义网关”,不因⑤设置切换改写;同轮混合来源显示“默认网关、自定义网关”。direct 失败只能手动“重新生成”,并再次显示完整生成确认及“服务商可能已对上次未确认请求计费,本次可能再次收费”的默认取消警告。确认后新建 job,不自动重试或复用旧 job;默认网关历史、导出、删除撤销和继续查询保持原有行为。
|
||||||
- direct 模型清单和 `data/config/ai_models.json` 代码路径保留为内部兼容/手工回滚,不在普通 UI 暴露。
|
- T-679 集成验收后,两来源混合项目、运行中保存⑤设置和切换来源后继续查询旧默认网关任务均有跨层回归保护;来源切换只影响后续新建 worker,不改写当前运行快照、已保存 job 或历史展示。
|
||||||
- AI 生成参数:标题并发、图片并发、失败重试、分辨率、返回超时、jpg 质量等短字段按三列排列;标题/图片并发可选 1..5,失败重试可选 0..10,旧配置超限值会自动夹紧。
|
- T-531 已完成:设置页任意可编辑控件变更都进入未保存状态,保存按钮旁显示“● 未保存更改”;切换到其它 Tab 或关闭窗口时弹出保存/放弃/取消。放弃会重新从本地配置文件回填控件,避免未保存的 URL/API Key 留在界面上;程序化回填、保存后重载和刷新别名填充下拉不会误触发未保存状态。
|
||||||
- 分辨率为 `512 / 1k / 2k / 4k`,在 cmhub 默认模式下只控制生成图片尺寸;⑤「返回超时」只读展示当前实际等待口径:标题 600 秒、图片 650 秒,不再随分辨率切换显示 180/240/360/600,避免用户误解生图等待时间。
|
- 自定义模型清单继续保存于 `data/config/ai_models.json`;在自定义网关面板中可通过「新增 / 重命名 / 删除」维护并立即保存,切换回默认网关不会清除这些配置。重命名会保留模型的连接参数和本地 API Key,并同步迁移 `config.json` 中标题/图片默认模型对旧名称的引用;取消、空名称或重名不会写入。
|
||||||
|
- AI 生成参数:标题并发、图片并发、失败重试、分辨率、返回超时等短字段按三列排列;标题/图片并发可选 1..5,失败重试可选 0..10,旧配置超限值会自动夹紧;图片保存质量保留内部默认 90,不在普通 UI 展示。自定义模型角色选择只在自定义网关面板显示。
|
||||||
|
- 分辨率为 `512 / 1k / 2k / 4k`,在 cmhub 默认模式下只控制生成图片尺寸;设置中的「返回超时」只读展示当前实际等待口径:标题 600 秒、图片 900 秒,不再随分辨率切换显示 180/240/360/600,避免用户误解生图等待时间。
|
||||||
- 保存写入 `config.json` 的 `ai` 段,供 ② AI生成复用;标题/图片模型角色下拉随 direct UI 一起隐藏。
|
- 保存写入 `config.json` 的 `ai` 段,供 ② AI生成复用;标题/图片模型角色下拉随 direct UI 一起隐藏。
|
||||||
- 路径与端口(T-501b/T-506/T-539 已接入):组件组改为 3 个组件一组;普通设置页只显示 Chrome 路径、默认端口、端口起止、CDP 就绪超时。T-538 后账号数据根目录、图片目录、DB 路径固定解析到 `data/` 下,普通 UI 不再提供输入框,避免用户误改后数据分裂;`config.json` 中 `user_data_root` / `image_dir` / `db_path` 字段继续作为内部兼容字段保留,手工配置值仍会被读取和保存。
|
- 路径与端口(T-501b/T-506/T-539/T-580 已接入):组件组改为 3 个组件一组;普通设置页只显示 Chrome 路径、默认端口、端口起止、Chrome 就绪超时。T-538 后账号数据根目录、图片目录、DB 路径固定解析到 `data/` 下,普通 UI 不再提供输入框,避免用户误改后数据分裂;`config.json` 中 `user_data_root` / `image_dir` / `db_path` 字段继续作为内部兼容字段保留,手工配置值仍会被读取和保存。
|
||||||
- 蝦皮更新安全(T-501c/T-506 已接入):组件组改为 3 个组件一组;允许真实提交、允许更新封面、每批最大更新条数、成功后关闭本次新开编辑页等短字段三列排列;「多账号并行更新」与「最大并行账号数」必须合并为同一个横向组件,最大并行账号数紧跟在多账号并行更新后面,不允许换到下一行;测试商品 ID 仅作为历史/调试兼容字段保留,不参与普通正式更新安全检查,普通设置页已隐藏该入口。
|
- 蝦皮更新执行(T-580 已接入):组件组改为 3 个组件一组;普通设置页只保留「每批最大更新条数」和「同时更新蝦皮账号(1..5)」两个执行参数。`1` 表示逐个账号串行,`2..5` 表示按账号分组并行;“dry-run”不作为用户可见开关,改到③成为「检查本轮更新」按钮;测试商品 ID 和旧封面开关仅作为历史/调试兼容字段读取,普通设置页无入口,保存后不再写回。
|
||||||
- 默认关闭真实提交和封面更新,每批最大更新条数默认 1。
|
- ③「更新内容」默认只更新标题,每批最大更新条数默认 1,同时更新蝦皮账号默认 1。
|
||||||
- ③ 点击「开始更新」会读取这些设置,先拦截不符合条件的更新,再弹确认框。
|
- ③ 点击「开始更新」和「检查本轮更新」会先排除非正常商品状态,再按「更新内容」剔除缺失内容记录并在确认框说明;仅无可执行记录时中止。①的“采集所有状态”和②的“生成所有状态”不构成③线上更新授权。
|
||||||
- 更新执行模式(T-504/T-506 已接入):⑤ 只保留多账号并行更新、最大并行账号数等执行设置;“dry-run”不再作为用户可见开关,改到③成为「检查本轮更新」按钮。
|
|
||||||
- 默认多账号并行关闭;开启多账号并行后同一账号内仍串行。
|
## 商品套图
|
||||||
|
|
||||||
|
```
|
||||||
|
┌ 套图任务1 │ 套图任务2 │ + ───────────────────────────────────┐
|
||||||
|
│ [历史生成][添加图片] 账号[▼] 商品ID[____][拉取主图] │
|
||||||
|
├ 左侧配置(滚动)────────────┬ 右侧生成结果 ─────────────────────┤
|
||||||
|
│ 商品原图 N/16 已选N张[全选][反选]│ 共N张·成功M张 │
|
||||||
|
│ 主图/参考图按宽度换行自然展开 │ │
|
||||||
|
│ 平台 站点 语言 比例(同行) │ [结果卡][结果卡][失败卡·重试] │
|
||||||
|
│ 每张上传图分别作为主图生成 │ │
|
||||||
|
│ 商品卖点与要求 [AI帮写/取消] │ │
|
||||||
|
│ 白底图/场景图/模特图/细节图/卖点图 │ 进度条 套图X/Y(秒)·失败N │
|
||||||
|
│ [生成套图(N) ⇄ 停止生成] │ │
|
||||||
|
└─────────────────────────────┴──────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
- 每个顶部任务标签持有独立账号、商品ID、设置、原图、当前 job 集合和 worker;任务可并行生成。切换任务不停止后台操作;关闭运行中任务先确认并协作式取消,线程引用保留到真正结束,避免 `QThread: Destroyed while thread is still running`。
|
||||||
|
- 二级套图任务标签使用独立紧凑样式,不继承主模块 Tab 的大尺寸点击区。上下文栏左侧集中「历史生成 / 添加图片」,右侧集中账号、商品 ID 和拉取入口;常见 11~13 位商品 ID 不得裁切,长账号可通过 tooltip 查看完整名称。内部生成目录不提供顶部直达入口,正式取图统一通过历史窗口的「导出本轮」选择外部目录。
|
||||||
|
- 项目仍以 `账号别名 + 商品ID` 唯一,复用 `image_studio_projects/assets/jobs`。商品ID输入框只接受数字;提交时清除不可见格式字符,失焦、拉取、添加原图和生成共用提交校验。非数字输入只提示并保留在输入框,不覆盖当前已绑定项目;合法切换商品继续弹窗确认。`suite_settings_json` 保存平台、国家地区、语言、比例、逐图主图模式和分类数量;卖点文本继续使用 `draft_prompt`,用户停止输入约 500ms 后自动保存,切换任务、关闭任务或程序前同步补保存。未创建项目时只保留在当前任务内存,不因输入文字自动创建临时草稿。
|
||||||
|
- 「商品卖点与要求」关闭内部横向和纵向滚动条,输入框按完整换行内容自然增高;文本删除后可缩回最低高度,页面过长时统一由左侧 `suiteConfigScroll` 滚动。
|
||||||
|
- 商品原图最多16张。前6个槽位固定显示主图与参考1~5;列表关闭内部滚动条,按可用宽度换行并自然向下展开,由左侧配置区统一滚动。支持文件选择、外部拖入、剪贴板粘贴和列表内排序;内部排序由列表自建拖拽对象并只移动当前图片一次,不调用 Qt 默认 Move 的源项删除或投放复制行为。第1张以「主图」文字、粗体和语义色标识,拖动任意图片到第一位即可设为主图,排序成功提示“已更新商品原图顺序,第一张图片为主图”。拖放期间只记录待提交顺序,待拖拽完全结束后才写库并刷新;数量或 ID 集合异常时自动按 SQLite 恢复并提示重试。最终顺序必须唯一且实际变化才回写;外部文件拖入只添加图片。历史失效远程图不占有效名额;第1张是主参考图。
|
||||||
|
- 每张真实原图左上角提供独立勾选框,标题行显示「已选 N 张 / 全选 / 反选」;添加占位图不参与选择。勾选只在当前任务界面内临时保留,普通刷新和排序按资产 ID 保留,切换任务或删除成功后清空。右键或 Delete 可批量移除,确认框说明准确数量、主图变化及非破坏性边界;生成中或勾选项仍在下载时整批阻断。删除会继续保护终选直接引用、运行中和可继续查询/重试 job 的主图及参考图;已成功且不可重试的历史 job 不阻断删除,删除后可按剩余原图开始新一轮生成。移除只删除当前项目的本地资产记录,不删除本地源文件或蝦皮线上图片。
|
||||||
|
- 「拉取蝦皮主图」复用只读 CDP,读取 URL 后由最多2个下载 worker 后台落盘;不改标题/封面、不拖拽、不点击更新。拉取、下载期间其余界面和其他任务仍可操作,但商品原图列表仅锁定内部拖拽排序,并用 tooltip 说明“正在拉取蝦皮主图,完成后可调整图片顺序”;文件拖入、预览等非冲突操作不因此整页禁用。
|
||||||
|
- 套图按分类配置生成图片,固定分类顺序为白底图、场景图、模特场景图、细节说明图、卖点图,之后是自定义分类;默认数量为白底图1、场景图2、模特场景图0、细节说明图0、卖点图2。新增分类默认数量为0,用户未主动配置时不增加生成任务或点数消耗。自定义分类名称非空、无空格、最多10字且不可重名。逐图主图开启后,白底图只生成一次,其余分类按每张有效原图展开。
|
||||||
|
- 套图提示词设置弹窗默认模板首行使用“生成目标:{套图名称},{生成目标}”。五个内置分类使用统一固定目标描述;自定义分类使用“生成自定义分类图片:分类名称。”,实际分类名称必须进入最终提示词。旧模板若仍使用 `{套图名称}` 与 `{补充描述}` 会继续读取和渲染,不静默覆盖用户文件;新模板与旧模板都由同一个 renderer 提供预览和 job prompt。模板变量只在替换前校验,商品卖点与要求、分类名等业务文本允许原样包含 `{}`,不会再次按变量解析;发生其他渲染异常时显示「无法生成商品套图」,不创建 job、worker、价格查询或网关请求。
|
||||||
|
- `{参考图规则}` 由实际提交图片数决定:逐图主图开启、或当前只有1张可用图时,使用“参考图规则:当前上传图片是本任务唯一主参考图;保持商品主体、款式、颜色和关键细节准确;不编造用户与参考图均未提供的信息。”;未开启且有2至8张可用图时,第1张为主商品图,第2至N张仅作风格、构图、场景或排版参考,N 为本次实际提交总数。提示词弹窗按当前可用原图数预览,`build_job_specs()` 按冻结的参考图快照渲染;默认模板中的 `{参考图规则}` 独占一行,不额外重复标签或序号。
|
||||||
|
- 「每张上传图分别作为主图生成」右侧固定显示可换行 helper:“多款式或多SKU图请勾选;同一商品多角度图不勾选,其余图会作为参考图一同提交。”确认生成框必须说明当前主图/参考图模式和实际参考图数;未开启且上传超过8张时,明确仅提交第1张主图加前7张参考图,以及未参与本轮的剩余数量。
|
||||||
|
- 平台、国家地区、语言和比例以四个带独立标签的同行下拉展示,选项只显示真实值;四项都写进每个 job 的完整提示词,比例还透传到 cmhub 生图请求,不是装饰字段。已有项目保存自己的完整设置;未绑定商品的新任务在重启后采用 `config.json` 的最近四项选择。生成仍走 `image_studio_generation.run_jobs()` 的 submit → poll → download 管线。
|
||||||
|
- 平台、站点、语言和比例四个生成设置下拉仅在闭合时拦截自身滚轮改值,并把滚动交给 `suiteConfigScroll`;展开时滚轮只浏览候选列表,不静默提交当前值。鼠标点击、方向键、回车和 `Alt+↓` 保持原有显式选择语义;账号、历史筛选和设置页等其它下拉不受影响。
|
||||||
|
- 生成按钮按当前总数显示并在运行时切换为「停止生成」;确认停止后显示「正在停止...」,重复点击不再弹确认框。默认网关停止会取消未开始任务、保留已提交任务供继续查询;自定义网关停止同样只取消未开始 job,已发出的同步请求不可强杀,界面明确显示“正在停止,等待当前图片返回后结束”,已收到图片仍会保存成功。生成期间可切换其他任务、查看日志、历史和已完成图片;当前任务的账号、商品ID、原图、提示词、比例、分类和生成设置锁定,避免把运行时修改误认为会影响已冻结 job。每轮生成用独立运行标识隔离旧信号,本轮全部 job 终态或线程结束时都会统一恢复按钮;最终 worker 信号缺失时由数据库终态看门狗兜底,不要求用户重启。T-643 后项目持久化当前生成轮次:常规新轮至少成功一张才替换主结果区,全部失败/取消保留上一当前轮;主结果按稳定槽位显示同轮最新 job,单张重试留在原槽位。旧版无轮次 job 临时显示为“旧版历史记录”,不按时间或图片数量猜测归属。成功图可预览、复制路径、打开目录、重新生成、移入项目废纸篓并撤销,失败卡显示脱敏中文摘要与重试入口。
|
||||||
|
- T-648 后,常规「生成套图」在原图、卖点和数量校验通过后、费用确认前,若 SQLite 记录显示当前商品已有成功套图,会出现「已有套图生成记录」确认框:用户可查看仅当前商品的全局历史、继续生成新一轮或取消,默认取消;查看历史和取消都不提交任务,继续仍须通过原费用确认后才创建新轮次。失败图片重试、恢复未完成任务和仅失败/已取消历史不出现该确认。
|
||||||
|
- T-646 后「历史生成」打开全局非模态「套图历史生成记录」窗口,默认显示所有未删除商品项目最近创建的生成轮次,主结果区不切换。T-649 将店铺筛选固定为首项「全部店铺」的下拉:当前账号显示「账号名(账号别名)」,已删除但仍有历史项目的账号显示「历史店铺:别名(账号已删除)」,选择值使用 `account_alias` 精确查询;商品 ID 关键字和“仅当前商品”可与其叠加,默认不限制当前任务。每一行就是一次正常生成轮次,单张失败重试仍归入原行。行内固定显示时间、店铺/账号、商品 ID、成功/失败/停止/重试统计、最多5张缩略图及余量 `+N`,当前轮标记“当前”,NULL 轮次标记“旧版历史记录”,临时项目显示“临时草稿”。双击缩略图或整行从对应图片打开该轮所有可用图的自适应原尺寸浏览;“导出本轮”后台复制该轮成功且本地存在的图片到用户选择目录下的新安全子目录,不覆盖或修改内部图片。旧版记录、全失败轮和本地文件缺失项保留中文说明;不提供批量导出、删除、重试、切换当前轮或再次生成。重复点击复用同一窗口;关闭任务页不关闭全局窗口,应用退出时正常释放。
|
||||||
|
- AI帮写和生图按任务独立运行。AI帮写固定使用 cmhub 设置的「图片理解别名」调用图片理解能力,不走②标题生成,也不随套图生图 backend 切换到自定义网关;cmhub 地址、API Key 和图片理解别名完整时,在自定义生图网关下仍可使用。按商品原图 `source_order` 取1至8张已下载的本地图片,在一次请求中作为同商品的多角度/细节/包装/场景证据集联合理解,超过8张时状态提示只使用前8张,原图勾选不改变输入图片。返回一份可直接编辑的商品级卖点与套图画面要求,按商品概述、可确认卖点、人群与场景、套图画面要求、待确认或避免编造的信息组织,不按图1、图2逐图说明;图片有可见差异时明确为待确认项。单图超过10MiB、总计超过32MiB、没有可用本地图、别名未配置或服务异常时不改现有卖点;图片理解读超时或网络中断提示“结果未确认,请先查看点数余额或稍后重试”,不自动重发。成功状态显示理解图片张数、扣点和余额;AI帮写期间若用户改过卖点,返回后必须确认才覆盖;全部用户可见错误隐藏图片路径、URL、接口路径、base64、完整提示词和敏感信息。自定义网关仍只用于新建同步直连套图,已提交默认网关任务继续查询、下载和保存。
|
||||||
|
- AI帮写提交图片理解前先显示「开始AI帮写」确认框:按 `source_order` 说明会理解当前商品前1至8张可用原图并生成商品卖点与要求。模型目录只走后台读取或进程内短期缓存;仅当前图片理解别名有唯一无条件价格时显示「预计扣点:X 点」,否则明确实际以网关返回为准。确认框默认、Esc 和关闭均取消,不提交图片;开始后可取消本地等待,但已提交网关的请求仍可能产生扣点。预估不写入业务数据,完成后仍只显示接口返回的实际扣点和余额。
|
||||||
|
- 常规「生成套图」保留“已有成功历史”优先确认,选择继续后才后台读取或复用同一模型目录缓存,并显示正式生成确认。确认严格按最终 planned `specs` 展示各分类实际张数、总张数和比例;逐图主图开启时明确白底图只用第一张原图,其他分类按每张原图生成;关闭时所有分类使用第1张主图及同一批冻结参考图。仅唯一无条件的生图价格显示预计单张和总扣点,总价只按 `len(specs)` 计算;价格未知时不显示数字。默认、Esc、关闭、切换任务、取消读取或计划变化均不创建生图 job;单图失败重试和恢复未完成任务不增加这一层批量确认。
|
||||||
|
- 商品套图只管理本地图片资产,不自动上传或修改蝦皮;③线上更新边界不受影响。旧 `ImageStudioTab` 留作代码兼容但不再作为主窗口入口。
|
||||||
|
- 「AI帮写」和「生成套图」在新提交前共用主窗口会员预检;默认网关或自定义网关直连都一样。`off/observe` 放行,`enforce` 只按真实权益放行。继续查询已提交默认网关任务、下载、预览、历史查看和导出不属于新提交,不因会员状态变化中断。
|
||||||
|
|
||||||
## 流程导航
|
## 流程导航
|
||||||
|
|
||||||
```text
|
```text
|
||||||
④ 账号管理:配账号 + 启动登录(首次必做;重复点击应复用已打开 Chrome)
|
账号管理:配账号 + 启动登录(首次必做;重复点击应复用已打开 Chrome)
|
||||||
│
|
│
|
||||||
① 导入采集:导入 Excel → 采集旧标题/旧封面 → 自动回写旧字段(失败可手动重试)
|
① 导入采集:导入 Excel → 采集旧标题/旧封面 → 自动回写旧字段(失败可手动重试)
|
||||||
│
|
│
|
||||||
② AI生成:提示词 → 生成新标题/可选生成新封面(无逐条审核)
|
② AI生成:提示词 → 选择生成标题/封面/图文(无逐条审核)
|
||||||
│
|
│
|
||||||
⑤ 设置:配置蝦皮更新安全、每批最大更新条数和执行模式
|
设置:配置每批最大更新条数和蝦皮更新执行模式
|
||||||
│
|
│
|
||||||
③ 更新蝦皮:对已生成任务点击开始更新 → 安全开关检查 → 弹窗确认 → 账号就绪预检(未启动/未登录则中止) → 换标题/允许时换封面 → 点「更新」提交 → 回写结果 → 弹窗汇总
|
③ 更新蝦皮:选择更新标题/封面/图文 → 对已生成任务点击开始更新 → 缺失内容校验 → 弹窗确认 → 账号就绪预检(未启动/未登录则中止) → 按模式换标题/封面 → 点「更新」提交 → 回写结果 → 弹窗汇总
|
||||||
```
|
```
|
||||||
|
|
||||||
- 未配账号 / Chrome 未启动 / 未登录:① ③ 的执行按钮禁用或执行前提示去 ④;③ 不静默启动缺失账号 Chrome。
|
- 未配账号 / 未登录:① 会自动确保匹配账号 Chrome 就绪但不自动登录,未登录账号略过并提示去账号管理;③ 更新前仍只检测账号 Chrome/CDP/登录态,未启动或未登录则中止,不静默启动缺失账号 Chrome。
|
||||||
- 已生成的任务即可进 ③;③ 通过 ⑤ 安全开关并经用户确认批量弹窗后提交线上,无常驻提交开关。
|
- 已生成的任务即可进 ③;③ 经用户确认批量弹窗后提交线上,无常驻提交开关。
|
||||||
- 任意步骤失败:记入该任务、日志标明,不影响其他任务。
|
- 任意步骤失败:记入该任务、日志标明,不影响其他任务。
|
||||||
|
|
||||||
## 组件建议(PySide6)
|
## 组件建议(PySide6)
|
||||||
|
|
||||||
| 组件 | 归属 | 说明 |
|
| 组件 | 归属 | 说明 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `MainWindow(QMainWindow)` | 根窗口 | 持有 `QTabWidget`、状态栏、全局消息 |
|
| `MainWindow(QMainWindow)` | 根窗口 | 持有 `QTabWidget`、原生窗口标题会员摘要、底部状态栏、首次激活/引导、订阅受限恢复入口和全局消息 |
|
||||||
|
| `MembershipActivationDialog(QDialog)` | 根窗口 | 干净安装只收集会员 API Key,展示本地明文保存说明并发出验证/会员中心操作;不持有网络请求或保存业务 |
|
||||||
| `CollectTab(QWidget)` | ① | 导入、任务表、采集、回写 |
|
| `CollectTab(QWidget)` | ① | 导入、任务表、采集、回写 |
|
||||||
| `GenerateTab(QWidget)` | ② | 左提示词管理 + 右筛选/任务列表;双击看新旧封面;开始生成/停止/进度;本轮「生成封面图片(成本较高)」开关接入 `GenerateWorker` |
|
| `GenerateTab(QWidget)` | ② | 左提示词管理 + 右筛选/任务列表;双击看新旧封面;先确认商品状态生成范围,再按本轮「生成内容」下拉接入 `GenerateWorker` |
|
||||||
| `ApplyTab(QWidget)` | ③ | 已生成任务筛选 +「检查本轮更新」+ 蝦皮更新安全拦截 + 分批开始更新确认 + 检查/真实更新运行日志 + 结果回写与结束汇总 |
|
| `ApplyTab(QWidget)` | ③ | 已生成任务筛选 +「更新内容」下拉 + 商品状态优先/内容完整性预检剔除 +「检查本轮更新」+ 分批开始更新确认 + 检查/真实更新运行日志 + 结果回写与结束汇总 |
|
||||||
| `AccountsTab(QWidget)` | ④ | 账号增删改、启动登录、检测登录、生成快捷方式;登录检测把 Shopee accounts 登录页判为未登录 |
|
| `AccountsTab(QWidget)` | 账号管理 | 账号增删改、启动登录、检测登录、生成快捷方式;首次启动复用初始卖家中心页,启动中防重复触发,登录检测把 Shopee accounts 登录页判为未登录 |
|
||||||
| `SettingsTab(QWidget)` | ⑤ | cmhub 网关配置 + 响应式三列设置表单 + 生成参数 + Chrome/端口配置 + 蝦皮更新安全;数据路径字段隐藏但保留配置兼容 |
|
| `SettingsTab(QWidget)` | 设置 | cmhub 网关配置 + 响应式三列设置表单 + 生成参数 + Chrome/端口配置 + 蝦皮更新执行;数据路径字段隐藏但保留配置兼容 |
|
||||||
|
| `ProductSuiteTab(QWidget)` | 商品套图 | 商品套图多任务、账号+商品ID上下文、原图导入/排序、套图分类、默认网关异步或自定义网关同步生成、AI帮写、结果历史与删除撤销 |
|
||||||
| `TaskTableModel(QAbstractTableModel)` | ①②③ | 任务表格数据模型,供 `QTableView` 使用 |
|
| `TaskTableModel(QAbstractTableModel)` | ①②③ | 任务表格数据模型,供 `QTableView` 使用 |
|
||||||
| `BaseWorker(QObject)` | 后台 | 定义 `progress/log/row_updated/failed/finished/cancelled` signals |
|
| `BaseWorker(QObject)` | 后台 | 定义 `progress/log/row_updated/failed/finished/cancelled` signals |
|
||||||
| `ApplyWorker(BaseWorker)` | ③ | 账号就绪预检、检查本轮更新、按每批最大条数分批、按账号并行或串行调用 `editor.apply_task(..., close_success_tab=...)`、逐条 `set_applied()`,失败继续,写运行日志 |
|
| `ApplyWorker(BaseWorker)` | ③ | 账号就绪预检、检查本轮更新、按每批最大条数分批、按账号并行或串行调用 `editor.apply_task(...)`、逐条 `set_applied()`,失败继续,写运行日志;执行层再次拒绝非正常商品状态 |
|
||||||
| `AIModelTestWorker(BaseWorker)` | ⑤ | 后台调用 `appconfig.test_ai_model()` 测试模型连接 |
|
| `AIModelTestWorker(BaseWorker)` | 设置 | 后台调用 `appconfig.test_ai_model()` 测试模型连接 |
|
||||||
|
| `SubscriptionCheckWorker(BaseWorker)` | 根窗口 | 后台查询默认网关账号订阅状态;首次激活可使用只在内存中存在的 Base URL/Key 覆盖值,结果只通过 signal 回主线程更新会员状态和工作流可用性 |
|
||||||
| `WriteBackWorker(BaseWorker)` | ①③ | ①回写旧字段;③回写新标题/新封面/更新状态 |
|
| `WriteBackWorker(BaseWorker)` | ①③ | ①回写旧字段;③回写新标题/新封面/更新状态 |
|
||||||
|
| `ImageStudioPullImagesWorker / ImageStudioDownloadOriginalWorker / ProductSuiteImportImagesWorker / ProductSuiteGenerateWorker / ProductSuiteAiWriteWorker / CMHubModelCatalogWorker` | 商品套图 | 后台执行只读拉主图、远程原图下载、本地图片校验复制、默认网关异步或自定义网关同步套图生成、AI帮写和只读模型目录;拉图和本轮下载支持安全边界协作停止,worker 不直接操作 QWidget |
|
||||||
|
|
||||||
> 采集、生成、更新都是耗时操作,使用 `QObject` worker + `QThread`。Worker 不直接操作 QWidget,只通过 signal 通知主线程刷新 UI。
|
> 采集、生成、更新都是耗时操作,使用 `QObject` worker + `QThread`。Worker 不直接操作 QWidget,只通过 signal 通知主线程刷新 UI。
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
---
|
||||||
|
id: T-560
|
||||||
|
title: ① 采集前批量确保账号 Chrome 就绪 + 登录预检(自动开、不自动登录)
|
||||||
|
phase: 2
|
||||||
|
deps: [T-203, T-205, T-105b, T-103b, T-523]
|
||||||
|
status: DONE
|
||||||
|
created: 2026-07-08
|
||||||
|
---
|
||||||
|
|
||||||
|
## 问题 / 背景
|
||||||
|
|
||||||
|
需求(客户要求):① 导入采集点「采集」后,让程序**自动**把本批**匹配到账号的**各店铺 Chrome 一个个确保就绪、逐账号检查蝦皮登录态,未登录的账号标记并跳过其任务、最后弹窗汇总要补登录的账号;避免运营在几百个任务前**逐个手动开 Chrome、逐个肉眼确认登录**。用户机器日常已能并发开 ~10 个店铺 Chrome,资源可承受。
|
||||||
|
|
||||||
|
**这与现有明文规则冲突**:当前 ①③ 预检**"不自动启动 Chrome、不无提示批量启动所有账号"**(T-205/T-402),未就绪就中止并引导去④手动处理。本任务是**有意在①采集侧推翻该规则**(③更新侧不改,形成有意的不对称)。
|
||||||
|
|
||||||
|
**架构选择(已定)**:方案 A——预检期把本批需要的账号 Chrome 都**开着保留**(不关),采集复用;不做逐账号懒开懒关。**预检 ≠ 保证**:几百任务可能跑数小时,登录会中途失效,采集期必须逐任务/逐账号处理掉登录。
|
||||||
|
|
||||||
|
## 方案(settled 一句话)
|
||||||
|
|
||||||
|
点采集 → 对本批**匹配到的每个账号**:确保其 Chrome 在跑(已开则复用、没开才启动;开失败则中止+弹窗)→ 就地查蝦皮登录 → 未登录账号标记、其任务整组跳过 → **全部保持开着** → 挨个采集(复用这些 Chrome)→ 中途掉登录 = 该账号剩余任务 skip 记因 → 结束弹窗汇总(哪些账号要补登录)+ 提示用户自行关闭 Chrome。
|
||||||
|
|
||||||
|
## 验收要点(7 条约束)
|
||||||
|
|
||||||
|
1. **确保就绪=幂等**:先 `chrome.is_running(debug_port)`,已响应则**复用**现有 Chrome(沿用 T-105b),没开才 `launch`;绝不为已开账号重复开窗/撞端口。
|
||||||
|
2. **中途掉登录=账号级跳过**:采集期某账号被判掉登录,**把该账号剩余任务整组标 `skipped`、记原因("采集中途掉登录")**,不逐任务撞死刷一屏失败。
|
||||||
|
3. **两类跳过合并汇总**:预检未登录的账号 + 中途掉登录的账号,在结束弹窗里一起列出,提示"这些账号要补登录"。
|
||||||
|
4. **开 Chrome 失败=中止+弹窗**:端口冲突、Chrome 路径错、OOM、CDP 未就绪等任一开失败 → 中止本轮采集并弹窗说明(沿用/扩展现有端口冲突阻断口径)。
|
||||||
|
5. **不自动关**:采集结束**不关 Chrome**,弹窗提示用户自行关闭;不得关闭用户日常开着的窗口。
|
||||||
|
6. **只为匹配账号开 Chrome**:未匹配账号的别名照旧逐行 `skipped` 记原因,不为其开浏览器。
|
||||||
|
7. **不自动登录/不绕过风控**:全程只**检测**登录、不自动填密码、不绕验证码;未登录只标记引导人工去④补登录。
|
||||||
|
|
||||||
|
## 文档同步(推翻 T-205 规则,必须一起改)
|
||||||
|
|
||||||
|
- 更新 `AGENTS.md`、`docs/current-state.md`、`docs/routes.md`、`docs/04-architecture.md` 里"①③预检不自动启动 Chrome"的表述:**①采集改为自动确保就绪**;否则代码与文档打架、后续 agent 会照旧文档把本功能当 bug 改回去。
|
||||||
|
- **明确 ①③ 不对称**:① 采集自动开 Chrome、③ 更新仍需手动就绪(T-402 不改)——写清这是有意设计,不是遗漏。
|
||||||
|
- 归档任务 T-205 状态不改(历史),但在上述现状文档里注明"① 采集就绪策略已由 T-560 改为自动"。
|
||||||
|
|
||||||
|
## 单测点(`tests/test_gui.py` + 相关)
|
||||||
|
|
||||||
|
- 预检:某账号 Chrome 已在跑 → 复用、不调用 `launch`;没跑 → 调用 `launch`。
|
||||||
|
- 预检开 Chrome 失败(端口冲突/启动异常)→ 中止、不进入采集、弹窗提示。
|
||||||
|
- 某账号预检未登录 → 其任务整组 `skipped` 记因、其余账号照常采集。
|
||||||
|
- 采集中途某账号掉登录 → 该账号剩余任务 `skipped` 记因、不逐任务重复失败、其余账号继续。
|
||||||
|
- 结束汇总:未登录 + 中途掉登录的账号都出现在汇总提示里。
|
||||||
|
- 未匹配别名仍逐行 `skipped`、不为其开 Chrome。
|
||||||
|
- 不自动登录/不填密码(沿用现有断言口径)。
|
||||||
|
|
||||||
|
## 边界(不改什么)
|
||||||
|
|
||||||
|
只改 ① 采集就绪/登录预检链路(`CollectWorker` + `accounts`/`chrome` 就绪、`editor` 登录检测复用)、① 结束汇总弹窗、相关现状文档、单测;不改 ③ 更新侧就绪规则(T-402 保持手动)、不改 CDP 选择器/采集读取逻辑本身、DB schema、AI、Excel、Shopee 交互事实。
|
||||||
|
|
||||||
|
## 执行记录
|
||||||
|
|
||||||
|
- 2026-07-08:同步 `AGENTS.md`、`docs/04-architecture.md`、`docs/routes.md`、`docs/current-state.md`,明确 T-560 后 ①采集会自动确保匹配账号 Chrome 就绪,③更新仍保持只检测不自动启动的高风险边界。
|
||||||
|
- 2026-07-08:`CollectWorker` 采集预检从“Chrome 未启动即 blocked”改为“先幂等确保 Chrome 就绪”:已运行账号复用,未运行账号调用 `accounts.launch_for_login()` 启动;启动失败才 blocked 并弹窗中止。
|
||||||
|
- 2026-07-08:预检未登录账号不再 blocked,改为把该账号任务整组 `skipped` 并记原因;采集过程中若二次登录检测发现掉登录,则把该账号当前和剩余任务整组 `skipped` 为“采集中途掉登录”,不逐条重复失败。
|
||||||
|
- 2026-07-08:①采集完成后新增账号汇总弹窗:列出自动启动账号、需要补登录账号,并提示采集结束不会自动关闭账号 Chrome;未匹配别名仍逐行略过且不启动 Chrome。
|
||||||
|
- 2026-07-08:更新 GUI 单测,覆盖 Chrome 未运行时自动启动、启动失败阻断、预检未登录账号级略过、中途掉登录账号级略过、结束汇总弹窗;③更新侧不自动启动 Chrome 的测试保持不变。
|
||||||
|
- 验证通过:`python -m ruff check app tests main.py`、`py -3.10 -m compileall app main.py`、`py -3.10 -m unittest discover -s tests`。
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
---
|
||||||
|
id: T-561
|
||||||
|
title: cmhub curl 下载隐藏 Windows 黑窗
|
||||||
|
phase: 7
|
||||||
|
deps: [T-548, T-524]
|
||||||
|
status: DONE
|
||||||
|
created: 2026-07-08
|
||||||
|
---
|
||||||
|
|
||||||
|
## 问题 / 背景
|
||||||
|
|
||||||
|
T-548 后,②AI生成在 Windows 下默认优先调用系统 `curl.exe` 下载 cmhub 返回的 `image_url`,用于绕过系统代理并提升图片下载速度。实际打包运行时,用户看到每次 curl 下载图片都会弹出黑色控制台窗口,持续几秒后消失。
|
||||||
|
|
||||||
|
根因:`app/ai.py` 的 `_download_cmhub_image_with_curl()` 使用 `subprocess.run([...], shell=False)` 启动系统 `curl.exe`,并已把 `stdout/stderr` pipe 住,但没有设置 Windows 隐藏窗口参数。打包后的 PySide6 程序是 GUI 进程,从 GUI 进程启动控制台程序 `curl.exe` 时,Windows 会为子进程创建可见控制台窗口;下载耗时几秒,黑窗就停留几秒。
|
||||||
|
|
||||||
|
## 方案
|
||||||
|
|
||||||
|
1. **只隐藏 curl 子进程窗口**
|
||||||
|
- 在 `app/ai.py` 增加 Windows 专用 helper,例如 `_subprocess_hidden_window_kwargs()`。
|
||||||
|
- 当 `os.name == "nt"` 时,优先给 `subprocess.run()` 传 `creationflags=subprocess.CREATE_NO_WINDOW`。
|
||||||
|
- 如需兼容环境没有该常量,兜底使用 `subprocess.STARTUPINFO()` + `STARTF_USESHOWWINDOW` + `SW_HIDE`。
|
||||||
|
- 非 Windows 返回空 dict,保持原逻辑。
|
||||||
|
|
||||||
|
2. **保持安全和下载语义不变**
|
||||||
|
- 仍使用 `shell=False`。
|
||||||
|
- 仍通过 `-K <临时配置文件>` 传 URL,避免 `image_url` 出现在进程命令行。
|
||||||
|
- 仍保留 `--silent`、`--show-error`、`--noproxy "*"`、超时、大小上限和失败回退 requests。
|
||||||
|
- 不把完整 URL、token、API Key 写入日志。
|
||||||
|
|
||||||
|
3. **测试**
|
||||||
|
- 更新 `tests/test_ai.py`,覆盖 Windows 分支下 `subprocess.run()` 收到隐藏窗口参数。
|
||||||
|
- 保留现有 curl 参数测试:URL 不在 argv、`shell=False`、`--noproxy`、超时、大小上限。
|
||||||
|
- 非 Windows 分支不强加隐藏窗口参数,避免影响 CI/Linux/macOS。
|
||||||
|
|
||||||
|
## 验收要点
|
||||||
|
|
||||||
|
- 打包版 ②AI生成用 curl 下载 cmhub 图片时,不再弹出黑色控制台窗口。
|
||||||
|
- curl 下载仍优先使用系统 curl,失败仍可回退 requests。
|
||||||
|
- `image_url` 仍不出现在 `subprocess.run()` 的 argv 中。
|
||||||
|
- 不改 cmhub 生文/生图 API、下载并发、DB、Excel、Shopee/CDP。
|
||||||
|
- `python -m ruff check app tests main.py`、`py -3.10 -m compileall app main.py`、`py -3.10 -m unittest discover -s tests`、`git diff --check` 通过。
|
||||||
|
|
||||||
|
## 边界(不改什么)
|
||||||
|
|
||||||
|
只改 `app/ai.py` 的 curl 子进程启动参数和对应测试;不改 `ai.cmhub.download_with_curl` 配置 schema、不改系统 curl 检测、不改 requests fallback、不改图片保存 JPEG 逻辑、不改 GUI、DB、Excel、Shopee/CDP。
|
||||||
|
|
||||||
|
## 执行记录
|
||||||
|
|
||||||
|
- 2026-07-08:`app/ai.py` 新增 `_subprocess_hidden_window_kwargs()`,Windows 下优先使用 `subprocess.CREATE_NO_WINDOW` 隐藏 curl 子进程控制台窗口;缺少该常量时兜底 `STARTUPINFO + STARTF_USESHOWWINDOW + SW_HIDE`;非 Windows 返回空参数。
|
||||||
|
- 2026-07-08:`_download_cmhub_image_with_curl()` 调用 `subprocess.run()` 时传入隐藏窗口 kwargs,保留 `shell=False`、`-K` 临时配置文件、`--noproxy`、超时、大小上限、stderr/stdout 捕获和 requests fallback 语义。
|
||||||
|
- 2026-07-08:更新 `tests/test_ai.py`,覆盖 Windows curl 调用携带隐藏窗口参数、URL 不在 argv、`shell=False` 保持,以及非 Windows 不加隐藏窗口参数。
|
||||||
|
- 验证通过:`py -3.10 -m unittest tests.test_ai.AITests.test_cmhub_image_download_uses_curl_without_url_in_argv tests.test_ai.AITests.test_cmhub_curl_hidden_window_kwargs_are_windows_only tests.test_ai.AITests.test_cmhub_image_download_falls_back_to_requests_when_curl_fails tests.test_ai.AITests.test_cmhub_image_download_auto_without_curl_uses_requests`、`python -m ruff check app tests main.py`。
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
---
|
||||||
|
id: T-562
|
||||||
|
title: ①采集打开商品页不主动切换 Chrome 前台
|
||||||
|
phase: 2
|
||||||
|
deps: [T-203, T-205b, T-560]
|
||||||
|
status: DONE
|
||||||
|
created: 2026-07-08
|
||||||
|
---
|
||||||
|
|
||||||
|
## 问题 / 背景
|
||||||
|
|
||||||
|
运行①「导入采集」时,每采集一个商品都会打开或复用对应账号 Chrome 的商品详情页,并把该 Chrome/标签页切到前台。批量采集多店铺、多商品时,用户正在操作其它窗口会被频繁抢焦点,体验很差。
|
||||||
|
|
||||||
|
代码原因:
|
||||||
|
|
||||||
|
- `app/editor.py::open_product()` 在打开/复用商品页后固定执行 `Page.bringToFront`,这是 Chrome DevTools Protocol 明确的前台激活命令。
|
||||||
|
- `open_product()` 同时服务①采集和③更新。早期强制前台便于调试和观察更新动作,但①采集是只读流程,不需要每条都切前台。
|
||||||
|
- 新建商品 tab 使用 `app/cdp.py::create_tab_info()` 的 `Target.createTarget`。Chrome 新建 tab 本身也可能激活标签页;可尝试传 `background=true` 降低新建 tab 抢焦点概率。
|
||||||
|
|
||||||
|
## 方案
|
||||||
|
|
||||||
|
1. **给打开商品页增加前台控制参数**
|
||||||
|
- `editor.open_product(account, item_id, on_step=None, bring_to_front=True)` 增加参数,默认保持旧行为。
|
||||||
|
- 仅当 `bring_to_front=True` 时执行 `Page.bringToFront`。
|
||||||
|
- ③ `apply_task()` 继续使用默认 `True`,避免影响改标题、换封面、上传、拖拽和 Shopee 确认框稳定性。
|
||||||
|
- ① `collect()` 调用 `open_product(..., bring_to_front=False)`,采集不主动把页面带到前台。
|
||||||
|
|
||||||
|
2. **新建 tab 尽量后台创建**
|
||||||
|
- `cdp.create_tab_info(url, host=None, background=False)` 增加 `background` 参数。
|
||||||
|
- 当 `background=True` 时,`Target.createTarget` 参数中带 `background: true`。
|
||||||
|
- `open_product(..., bring_to_front=False)` 新建商品 tab 时传 `background=True`;复用已有 tab 时不激活。
|
||||||
|
- 如果 Chrome 版本忽略或拒绝 `background`,则退回普通新建 tab,仍至少不再执行 `Page.bringToFront`。
|
||||||
|
|
||||||
|
3. **测试**
|
||||||
|
- editor 单测覆盖:采集调用不发送 `Page.bringToFront`,更新调用仍发送。
|
||||||
|
- cdp 单测覆盖:`create_tab_info(background=True)` 会把 `background: true` 传给 `Target.createTarget`,并覆盖 `background` 被拒绝后的普通新建回退。
|
||||||
|
- 保持失败时自动关闭本轮新建 tab、复用用户已有 tab 不关闭的原语义。
|
||||||
|
|
||||||
|
## 验收要点
|
||||||
|
|
||||||
|
- ①采集每个商品详情页时不再主动调用 `Page.bringToFront`。
|
||||||
|
- ①采集新建商品 tab 时尽量后台创建,降低 Chrome 抢焦点概率。
|
||||||
|
- ③更新蝦皮仍保持默认前台行为,不改变上传、拖拽、点击更新和确认框处理。
|
||||||
|
- 商品失效/打开失败时,本轮自动新建 tab 仍会关闭;复用用户已有 tab 不关闭。
|
||||||
|
- `python -m ruff check app tests main.py`、`py -3.10 -m compileall app main.py`、`py -3.10 -m unittest discover -s tests`、`git diff --check` 通过。
|
||||||
|
|
||||||
|
## 边界(不改什么)
|
||||||
|
|
||||||
|
只改①采集打开商品页的前台激活策略和 CDP 新建 tab 参数;不改标题/封面采集逻辑、不改商品详情页选择器、不改③更新流程、不改 DB schema、不改 Excel、AI、cmhub、账号登录检测或 Shopee 提交逻辑。
|
||||||
|
|
||||||
|
## 执行记录
|
||||||
|
|
||||||
|
- 2026-07-08:完成。`editor.open_product()` 增加 `bring_to_front` 参数;①采集调用 `bring_to_front=False`,不主动 `Page.bringToFront`;③更新继续默认前台激活。`cdp.create_tab_info()` 增加 `background` 参数,后台创建失败时自动回退普通新建。已同步 `docs/04-architecture.md` 和 `docs/routes.md`。
|
||||||
|
- 2026-07-08:验证通过:`python -m ruff check app tests main.py`、`py -3.10 -m compileall app main.py`、`py -3.10 -m unittest discover -s tests`、`git diff --check`。
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
---
|
||||||
|
id: T-563
|
||||||
|
title: ①采集中途登录检测容错,避免 NO_SESSION_COOKIE 误判批量略过
|
||||||
|
phase: 2
|
||||||
|
deps: [T-207, T-560, T-562]
|
||||||
|
status: DONE
|
||||||
|
created: 2026-07-08
|
||||||
|
---
|
||||||
|
|
||||||
|
## 问题 / 背景
|
||||||
|
|
||||||
|
运行①「导入采集」时,偶发出现部分任务被标记为“略过”,但再次点击采集后又能成功。最近本地运行日志显示,触发原因是:
|
||||||
|
|
||||||
|
`采集中途掉登录: 账号未登录: NO_SESSION_COOKIE`
|
||||||
|
|
||||||
|
这不一定代表账号真的退出登录。当前 `CollectWorker` 在每条商品采集前都会再次调用 `accounts.detect_login()`,只要某一次检测结果为未登录,就会把该账号后续未采集任务全部写成 `status=skipped`。如果检测时 Chrome 页面刚好在跳转、商品 tab 刚被关闭/新建、CDP 暂时读不到 Shopee Cookie,`detect_login()` 可能返回 `NO_SESSION_COOKIE`,从而造成误判和批量略过。
|
||||||
|
|
||||||
|
需要把“明确未登录”和“登录状态暂时不确定”区分开:明确登录页可以跳过该账号后续任务;`NO_SESSION_COOKIE`、短暂 CDP 读取失败、检测超时等不应一次性跳过同账号剩余任务。
|
||||||
|
|
||||||
|
## 方案
|
||||||
|
|
||||||
|
1. **保留采集前账号预检**
|
||||||
|
- ①采集开始前仍按账号确保 Chrome 就绪,并做登录检测。
|
||||||
|
- 若明确检测到 `LOGIN_PAGE` 或登录页 URL(如 `accounts.shopee.tw/seller/login`),该账号任务仍整组略过并提示用户去④账号管理补登录。
|
||||||
|
- 采集前预检中的 `NO_SESSION_COOKIE` 应增加短暂重试,避免刚启动 Chrome 或页面尚未稳定时误判。
|
||||||
|
|
||||||
|
2. **中途登录检测改为容错判定**
|
||||||
|
- 在 `CollectWorker` 中封装登录检测确认逻辑,例如 `_confirmed_login_status(account, context, task=None)`。
|
||||||
|
- 对 `NO_SESSION_COOKIE`、`LOGIN_CHECK_FAILED`、CDP 读取异常等不确定结果,等待短暂间隔后重试 2-3 次,并把每次 reason、URL、cookie 名称列表写入运行日志或诊断日志;不得记录 Cookie 值。
|
||||||
|
- 只有明确检测到登录页 URL / `LOGIN_PAGE` 时,才把该账号加入 `login_skip_reasons`,并跳过该账号后续任务。
|
||||||
|
- 连续 `NO_SESSION_COOKIE` 仍视为“不确定”,不批量略过后续任务;继续尝试当前商品采集,让 `open_product()` 的真实页面结果决定成功或失败。
|
||||||
|
|
||||||
|
3. **避免级联略过**
|
||||||
|
- 当前商品采集前如果登录状态不确定,不调用 `db.mark_skipped()`。
|
||||||
|
- 若后续 `editor.collect()` 打开商品页失败,应按现有失败链路写 `status=failed`、`last_error` 和 run log,不影响同账号后续任务。
|
||||||
|
- 只有明确掉登录时,才沿用现有“该账号剩余任务略过,并在结束汇总中提示补登录”的语义。
|
||||||
|
|
||||||
|
4. **日志与用户提示**
|
||||||
|
- 运行日志中把“不确定”写成用户能理解的中文,例如:`登录状态检测暂时不稳定,继续尝试采集当前商品`。
|
||||||
|
- 结束弹窗中的“以下账号需要补登录”只展示明确登录页/明确未登录的账号,不把单次 `NO_SESSION_COOKIE` 放进去。
|
||||||
|
- DB 运行日志和本地诊断日志保留脱敏细节:reason、URL、cookie 名称、重试次数、最终判定。
|
||||||
|
|
||||||
|
5. **测试**
|
||||||
|
- 增加/更新 `CollectWorker` 单测:
|
||||||
|
- 中途第一次 `NO_SESSION_COOKIE`、重试后已登录:当前任务继续采集成功,不跳过后续任务。
|
||||||
|
- 中途连续 `NO_SESSION_COOKIE`:当前任务继续走 `editor.collect()`;如果 collect 成功则成功,不产生 skipped。
|
||||||
|
- 中途明确 `LOGIN_PAGE`:当前账号当前及后续任务仍标记 `skipped`,并出现在 `login_required_accounts`。
|
||||||
|
- `LOGIN_CHECK_FAILED` 不应导致同账号后续任务批量略过。
|
||||||
|
|
||||||
|
## 验收要点
|
||||||
|
|
||||||
|
- 同一账号采集过程中,单次或连续 `NO_SESSION_COOKIE` 不会把该账号剩余任务批量标记为“略过”。
|
||||||
|
- 明确检测到 Shopee 登录页时,仍会跳过该账号剩余任务并提示用户补登录。
|
||||||
|
- 再次采集成功的瞬时波动场景,第一次运行应尽量继续采集;即使当前商品失败,也不能级联跳过后续全部商品。
|
||||||
|
- 用户可见日志使用中文,不暴露 Cookie 值、密码、token 或其它敏感信息。
|
||||||
|
- `python -m ruff check app tests main.py`、`py -3.10 -m compileall app main.py`、`py -3.10 -m unittest discover -s tests`、`git diff --check` 通过。
|
||||||
|
|
||||||
|
## 边界(不改什么)
|
||||||
|
|
||||||
|
只改①采集 worker 的登录检测容错和日志提示;不改账号自动登录,不保存或读取账号密码,不绕过 Shopee 验证码/风控,不改商品采集选择器,不改②AI生成、③更新蝦皮、Excel schema、DB schema 或 cmhub 逻辑。
|
||||||
|
|
||||||
|
## 执行记录
|
||||||
|
|
||||||
|
- 2026-07-08:完成。`CollectWorker` 增加登录检测重试与明确/不确定判定:`LOGIN_PAGE` 或登录页 URL 才整组略过;`NO_SESSION_COOKIE`、`LOGIN_CHECK_FAILED`、CDP 短暂异常重试后仍不确定时继续采集当前商品,不缓存到同账号后续任务。已同步 `docs/04-architecture.md` 和 `docs/routes.md`。
|
||||||
|
- 2026-07-08:补充单测覆盖中途 `NO_SESSION_COOKIE` 重试成功、连续 `NO_SESSION_COOKIE` 不略过、`LOGIN_CHECK_FAILED` 不批量略过、预检 `NO_SESSION_COOKIE` 不整组略过、明确 `LOGIN_PAGE` 仍整组略过。
|
||||||
|
- 2026-07-08:验证通过:`python -m ruff check app tests main.py`、`py -3.10 -m compileall app main.py`、`py -3.10 -m unittest discover -s tests`、`git diff --check`。
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
---
|
||||||
|
id: T-564
|
||||||
|
title: 生图对接 cmhub 异步任务接口(submit + 轮询 + task_id 续查),替代 900s 同步长等待
|
||||||
|
phase: 7
|
||||||
|
deps: [T-526, T-529, T-546]
|
||||||
|
status: DONE
|
||||||
|
created: 2026-07-08
|
||||||
|
---
|
||||||
|
|
||||||
|
## 问题 / 背景
|
||||||
|
|
||||||
|
当前 cmshopee 生封面(`app/ai.py`)是两段串行同步阻塞:`POST /api/v1/generate/image` 读超时 900s 死等,拿到 `image_url` 后再下载最长 900s。上游卡死时 cmshopee QThread 与 cmhub Gunicorn worker 双双被占满 15 分钟,且第 899 秒抖动会「扣了点却拿不到图、无法找回」(这步 `retryable=False` 防重复扣点)。
|
||||||
|
|
||||||
|
cmhub 侧已实现并测试通过异步任务化接口(新增 `/generate/image/tasks` 子资源,T-612~T-615),旧同步接口保持不变仍可用。本任务把 cmshopee 生图链路切到新异步路:submit 拿 `task_id` → 轮询到 `succeeded` → 下载保存;`task_id` 持久化支撑重启续查、幂等键防重复扣点。
|
||||||
|
|
||||||
|
设计与逐点落地评估见 Obsidian「生图接口异步任务化-提交轮询方案」§「对接契约 v3」+「cmshopee 侧落地评估」。**对接字段/错误码以该文 v3 节与 cmhub `docs/api.md` 为准。**
|
||||||
|
|
||||||
|
> 编号说明:当前仓库任务文件为 `T-564`。如果外部沟通里写成 `T-654`,按本文件 `T-564` 执行,避免任务编号分叉。
|
||||||
|
|
||||||
|
## 方案(改哪个文件、改成什么)
|
||||||
|
|
||||||
|
> 实现提示:本任务横跨 DB 层与 ai.py 对接,建议**分两步 commit**——先落 `app/db.py`(加列 + 同步 `Task` dataclass + 迁移 + 3 个 helper + `test_db` 跑绿),确认 `list_tasks`/`get_task` 因 `cls(**dict(row))`(`db.py:323`)不因新列崩溃后,再落 `app/ai.py` 的 submit/poll/续查对接。降低单次改动风险,也便于回滚。
|
||||||
|
> 续查前置的三态判断(实现时写清、单测覆盖):`image_task_id` 有值 = 已 submit,直接 GET 续查;`image_task_key` 有值但 `image_task_id` 空 = submit 前崩溃,带同 key 重发;两者皆空 = 全新 submit。
|
||||||
|
|
||||||
|
### 接口(cmhub 已实现,本任务对接)
|
||||||
|
- submit:`POST /api/v1/generate/image/tasks` → `202 {task_id, status:queued, call_id, points_cost, points_balance, expires_at}`(**提交即预扣**)。请求体与旧生图一致;头带 `Idempotency-Key`、`X-Client-Version`。
|
||||||
|
- poll:`GET /api/v1/generate/image/tasks/{task_id}` → 除 404 外恒 `200`,按 `status` 分支:`queued/running` / `succeeded`(带 `result.image_url`) / `failed`|`expired`(带 `error.code`,**均已退点**)。
|
||||||
|
- 无 cancel 接口。
|
||||||
|
|
||||||
|
### `app/ai.py`
|
||||||
|
1. `_request_cmhub_cover_image`(`ai.py:900`)改为两段:
|
||||||
|
- **submit**:POST `/generate/image/tasks`,带 `Idempotency-Key`、`X-Client-Version`;连接超时保留 66s、读超时降 ~30s。拿 `task_id` 后**立即写库 `image_task_id`**(见结构改动 ①)。
|
||||||
|
- **poll**:循环 GET,递增退避 `3s→5s→8s→10s` 封顶带抖动,撞本地总预算(默认 900s,可配)才放弃;`succeeded` → 返回 `result.image_url` 交给原 `_download_and_save_cmhub_cover`;`failed`/`expired` → 抛结构化 `CMHubError`(已退点,按可重试);撞预算 → 超时错误但**保留 `task_id`**。
|
||||||
|
2. **续查前置**:进 submit 前先看 task 有无 `image_task_id`,有则直接 GET 续查,命中 `succeeded` 取图,**不重新 submit**。
|
||||||
|
3. `_cmhub_call_once`(`ai.py:1184`)加请求头 `Idempotency-Key`、`X-Client-Version`。
|
||||||
|
4. 错误码表 / `_cmhub_retryable`(`ai.py:1252-1265`)补 `upstream_timeout`、`task_timeout`(可重试、已退点)。
|
||||||
|
5. 超时分段:不再用 `_cmhub_read_timeout` 恒 900(`ai.py:1074`),submit/poll 各自超时。
|
||||||
|
6. 余额/计费事件回调(`_emit_cmhub_metadata`)在 submit 202 触发一次(`points_cost/points_balance/call_id`)。
|
||||||
|
7. 重试语义分层:失败重试是「换新 Idempotency-Key 重新 submit」的上层重跑,不走 `_cmhub_call_with_retry`(`ai.py:1149`)复用同 payload 的内层重试;内层对 submit 关闭或强制带同一幂等键。
|
||||||
|
8. **`gen_cover(...)` 单独调用兼容**:`generate_batch()` 内有 `task.id/db_path`,可完整使用异步 submit/poll/续查/落库;单独直接调用 `gen_cover()` 时没有任务上下文,不得强行写库。第一版可走“无持久化异步”(submit+poll 但不支持重启续查),或保留旧同步兼容路径;必须在代码和文档里明确,不能让公开函数签名变成必须传 DB。
|
||||||
|
|
||||||
|
### 结构改动
|
||||||
|
- **① request 段写库落 `task_id`**:`_request_cmhub_cover_image` 现无状态、不碰 DB。需把 `task.id` + 持久化回调(或 `db_path`)传进去,在 submit 成功点写 `image_task_id`。调用点在 `run_cmhub_cover_tasks`(`ai.py:687`)。
|
||||||
|
- **② per-attempt 幂等键不要直接依赖 `generate_attempts`**:当前 `generate_attempts` 只在 `set_generated()` / `mark_failed()` 时递增,用户重置封面后重新生成时可能仍复用旧值。新增本地 `image_task_key`(例如 `task.id + uuid/nonce`)作为 `Idempotency-Key` 更稳:submit 前若没有 key 先落库生成;同一次网络抖动重发复用同 key;用户重置封面、poll 返回 `failed/expired` 后重试、或需要重新 submit 时清空并生成新 key。
|
||||||
|
- **③ 轮询占 request-pool 线程**:submit+poll 同步塞进 `request_executor` future 即可(本地线程、廉价,非稀缺资源);`cmhub_image_concurrency_plan`(`ai.py:241`)现约束「在途轮询数」,第一版保持池形、可放宽上限,不重构编排。
|
||||||
|
|
||||||
|
### `app/db.py`
|
||||||
|
- 加列 `tasks.image_task_id TEXT`、`tasks.image_task_key TEXT`(沿用现有 `ALTER TABLE ADD COLUMN` ad-hoc 迁移模式,参考 `db.py:363`)。
|
||||||
|
- 同步 `Task` dataclass、历史 DB 迁移、`SELECT t.*` 映射与相关测试,避免 SQLite 多列后 dataclass 构造失败。
|
||||||
|
- 提供读写 helper(命名可按实现调整):
|
||||||
|
- `ensure_image_task_key(task_id)`:无 key 时生成并写入,返回稳定 key。
|
||||||
|
- `set_image_task_submitted(task_id, image_task_id, image_task_key)`:submit 202 后立即落库。
|
||||||
|
- `clear_image_task(task_id)`:清空 `image_task_id/image_task_key`,用于失败终态或用户重置封面后重新生成。
|
||||||
|
- 生命周期规则:
|
||||||
|
- submit 前先落 `image_task_key`;submit 成功后立即落 `image_task_id`。
|
||||||
|
- 本地总预算超时、用户点击停止、程序退出:保留 `image_task_id/image_task_key`,下次可续查。
|
||||||
|
- poll 返回 `succeeded`:下载保存并 `set_generated()`;可保留 `image_task_id/image_task_key` 作审计,不影响已生成任务。
|
||||||
|
- poll 返回 `failed` / `expired`(已退点):清空 `image_task_id/image_task_key`,再按重试次数决定是否新 submit,避免后续一直续查旧失败任务。
|
||||||
|
- `reset_generated(..., reset_cover=True)` 或重置全部生成结果时必须清空 `image_task_id/image_task_key`;只重置标题且保留封面时不必清空。
|
||||||
|
- submit 阶段 `content_blocked`、`insufficient_points`、`bad_request` 没有有效任务时不保留 key;`idempotency_conflict` 说明本地 key/payload 复用错误,应清空 key 后提示或失败。
|
||||||
|
|
||||||
|
### 文档
|
||||||
|
- `docs/cmhub-integration-design.md` 出 v3.6:不推翻 v3.5 全量,标注「旧同步接口沿用 v3.5 口径 + 新增任务化接口口径」,记弃用计划。
|
||||||
|
- 同步 `docs/api.md`、`docs/current-state.md` 里生图链路的描述。
|
||||||
|
|
||||||
|
## 验收要点
|
||||||
|
|
||||||
|
- submit 返回 202 → `image_task_id` **已落库**、进入轮询、不阻塞主线程;单测断言写库发生在轮询前。
|
||||||
|
- 轮询 `running`→`succeeded` → 走下载保存、`gen_cover` 仍返回已存 JPEG 路径;`failed`/`expired`(已退点) → 抛结构化错误、按可重试、**不重复扣点**。
|
||||||
|
- **重启续查**:已有 `image_task_id` 的 task 直接 GET,命中 `succeeded` 取图,**无二次 submit、无二次扣点**(断言未调 POST /tasks)。
|
||||||
|
- **幂等键 per-attempt 隔离**:同 task 同 `image_task_key` 重发 → 同 `task_id`;用户重置封面、旧任务 `failed/expired` 或需要重新 submit 时清空旧 key 并生成新 `image_task_key` → 新 `task_id`。
|
||||||
|
- 撞本地总预算未出结果 → 超时错误但 `image_task_id` 已持久化、可续查。
|
||||||
|
- 轮询期 `should_cancel()` → 停止轮询、不再下载(服务端继续跑完,按结果结算),GUI 日志明确提示“已停止等待生图结果;服务端任务可能仍在完成,下次可继续查询”。
|
||||||
|
- 错误码 `upstream_timeout`/`task_timeout`/`content_blocked`/`insufficient_points`/`idempotency_conflict` 各有对应处理与中文提示。
|
||||||
|
- `gen_cover()` 单独直接调用不破坏旧调用方;②批量生成才要求完整持久化和重启续查。
|
||||||
|
- 未配置 cmhub / Base URL 双拼等既有错误口径不回归。
|
||||||
|
- 验证命令:新增异步路用例用 `unittest`,不要引入 pytest。至少运行:
|
||||||
|
- `py -3.10 -m unittest tests.test_ai tests.test_db`
|
||||||
|
- `python -m ruff check app tests main.py`
|
||||||
|
- `py -3.10 -m compileall app main.py`
|
||||||
|
- `py -3.10 -m unittest discover -s tests`
|
||||||
|
- `git diff --check`
|
||||||
|
|
||||||
|
## 边界(不改什么)
|
||||||
|
|
||||||
|
- 不改旧同步 `POST /api/v1/generate/image` 的对接(保留为兼容/回滚路径)。
|
||||||
|
- 不改 `gen_cover(...)` 对外返回值(仍返回已存 JPEG 路径);单独调用没有 DB 上下文时不强制支持重启续查。
|
||||||
|
- 不改 `generate_batch` 编排骨架、`_download_and_save_cmhub_cover` 下载/保存逻辑、`data/images/<batch_id>/<slug>/` 路径、封面开关。
|
||||||
|
- 不改 CDP 选择器 / Shopee 交互 / Excel / 生文链路。
|
||||||
|
- 不引入 cancel 接口(cmhub 本期未实现)。
|
||||||
|
- 不做 DB 版本化迁移重构(沿用 ad-hoc ALTER;版本化仍在 backlog)。
|
||||||
|
|
||||||
|
## 执行记录
|
||||||
|
|
||||||
|
- 2026-07-08:完成 T-564。
|
||||||
|
- `app/db.py`:新增 `tasks.image_task_id` / `tasks.image_task_key` schema 与 ad-hoc 迁移,补 `Task` dataclass 字段;新增 `ensure_image_task_key()`、`set_image_task_submitted()`、`clear_image_task()`;`reset_generated(..., reset_cover=True)` 会清空异步生图状态,只重置标题时保留。
|
||||||
|
- `app/ai.py`:②批量 cmhub 生图切到 `POST /api/v1/generate/image/tasks` + `GET /api/v1/generate/image/tasks/{task_id}`;submit 带 `Idempotency-Key` / `X-Client-Version`,submit 成功后立即落库 `image_task_id`;已有 `image_task_id` 时直接续查;poll `succeeded` 后复用原图片下载/保存;poll `failed/expired` 清空 key/id;poll 超时或用户停止保留 key/id;单独 `gen_cover()` 保留旧同步接口兼容。
|
||||||
|
- `tests/test_db.py`:覆盖新列、幂等键稳定、submit 落库、清空、重置标题/封面生命周期。
|
||||||
|
- `tests/test_ai.py`:覆盖 submit 后 poll 前已落库、已有 task_id 续查不 POST、failed 任务清 key、停止轮询保留 task_id,以及既有下载/并发回归。
|
||||||
|
- 文档:同步 `docs/04-architecture.md`、`docs/api.md`、`docs/cmhub-integration-design.md`、`docs/routes.md`、`docs/ui/tab5-settings.svg`;按仓库规则未手动覆盖 `docs/current-state.md`,未修改冻结 `docs/06-tasks.md`。
|
||||||
|
- 验证通过:`py -3.10 -m unittest tests.test_ai tests.test_db`、`python -m ruff check app tests main.py`、`py -3.10 -m compileall app main.py`、`py -3.10 -m unittest discover -s tests`、`git diff --check`。
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
---
|
||||||
|
id: T-565
|
||||||
|
title: 兼容 cmhub 异步生图返回相对图片地址
|
||||||
|
phase: 7
|
||||||
|
deps: [T-564]
|
||||||
|
status: DONE
|
||||||
|
created: 2026-07-09
|
||||||
|
---
|
||||||
|
|
||||||
|
## 问题 / 背景
|
||||||
|
|
||||||
|
T-564 切到 cmhub 异步生图任务接口后,②AI生成在 poll 返回 `succeeded` 后进入下载阶段,但真实运行出现:
|
||||||
|
|
||||||
|
```text
|
||||||
|
图片生成失败:下载新封面失败:cmhub 图片地址只允许 http/https
|
||||||
|
```
|
||||||
|
|
||||||
|
从日志看,submit 和 poll 都成功,失败发生在本地下载前的 URL 安全校验。根因是当前 `app/ai.py` 的异步 poll 成功分支只读取 `result.image_url` / 顶层 `image_url`,并直接传给下载器;如果 cmhub 返回的是相对路径(如 `/generated/images/...png`)、协议相对 URL(`//host/path`)、`image_url` 对象或图片列表字段,本地会把非完整 URL 字符串交给 `_assert_public_http_url()`,从而报“只允许 http/https”。
|
||||||
|
|
||||||
|
## 方案
|
||||||
|
|
||||||
|
- `app/ai.py`
|
||||||
|
- 新增 cmhub 异步任务图片地址提取/规整 helper。
|
||||||
|
- poll `succeeded` 后复用现有 `_find_image_ref()` 递归提取图片地址,兼容 `result.image_url`、`result.image_url.url`、`result.images[]`、`result.image_urls[]`、顶层同类字段。
|
||||||
|
- 对相对路径按当前 cmhub Base URL 规整为完整 `http(s)://host/...` 后再进入原有公网 URL 安全校验和下载流程。
|
||||||
|
- 如果仍无法提取地址,报清晰中文错误“cmhub 生图任务成功但没有图片地址”;如果字段存在但格式不可用,报“cmhub 生图任务返回的图片地址格式错误”。
|
||||||
|
- `tests/test_ai.py`
|
||||||
|
- 覆盖异步 poll 返回相对路径时能拼成完整 URL 下载。
|
||||||
|
- 覆盖异步 poll 返回 `image_urls` 列表时能提取第一张图片。
|
||||||
|
|
||||||
|
## 验收要点
|
||||||
|
|
||||||
|
- cmhub poll 返回 `result.image_url="/generated/xxx.png"` 时,下载 URL 应为 `<base_url>/generated/xxx.png`。
|
||||||
|
- cmhub poll 返回 `result.image_urls=["https://..."]` 时可正常下载。
|
||||||
|
- 原有绝对 `https://...` 图片地址路径不回归。
|
||||||
|
- 验证命令:
|
||||||
|
- `py -3.10 -m unittest tests.test_ai`
|
||||||
|
- `python -m ruff check app tests main.py`
|
||||||
|
- `py -3.10 -m compileall app main.py`
|
||||||
|
- `git diff --check`
|
||||||
|
|
||||||
|
## 边界(不改什么)
|
||||||
|
|
||||||
|
- 不改 DB schema、`tasks.image_task_id/image_task_key` 生命周期。
|
||||||
|
- 不改 cmhub submit/poll 状态机和重试策略。
|
||||||
|
- 不改 curl/requests 下载实现和公网 URL 安全校验。
|
||||||
|
- 不改 CDP/Shopee、Excel、GUI 布局。
|
||||||
|
|
||||||
|
## 执行记录
|
||||||
|
|
||||||
|
- 2026-07-09:完成 T-565。
|
||||||
|
- `app/ai.py`:新增 `_extract_cmhub_image_url()` / `_normalize_cmhub_image_url()`,异步 poll 和旧同步兼容路径统一通过 helper 提取图片地址;兼容 `image_url`、`image_url.url`、`images[]`、`image_urls[]`、协议相对 URL 与相对路径,并在下载前按 cmhub Base URL 补全。
|
||||||
|
- `tests/test_ai.py`:新增/调整 cmhub 异步生图测试,覆盖 poll 返回相对路径时拼接完整 URL 下载,以及 `image_urls` 列表提取。
|
||||||
|
- 文档:同步 `docs/api.md`、`docs/cmhub-integration-design.md` 的图片地址兼容口径。
|
||||||
|
- 验证通过:`py -3.10 -m unittest tests.test_ai`、`python -m ruff check app tests main.py`、`py -3.10 -m compileall app main.py`、`py -3.10 -m unittest discover -s tests`、`git diff --check`。
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
---
|
||||||
|
id: T-566
|
||||||
|
title: 封面历史归档数据层:重置改名保留旧图 + update_generated_cover + 目录枚举
|
||||||
|
phase: 7
|
||||||
|
deps: [T-564]
|
||||||
|
status: DONE
|
||||||
|
created: 2026-07-09
|
||||||
|
---
|
||||||
|
|
||||||
|
## 问题 / 背景
|
||||||
|
|
||||||
|
运营需要对同一商品多次 AI 生图的结果做**对比挑选**(哪张更适合商品)。当前重置/重生封面时旧图被直接覆盖或删除(`reset_generated(delete_file=True)` 走 `os.remove`),历史生成图无法保留,也没有把 DB 封面指针改到某张历史图的手段。
|
||||||
|
|
||||||
|
设计定案(grill 评审收口):
|
||||||
|
- **不把多条路径塞进 `tasks.new_cover_path`**(一列多值反模式:所有读方按单路径消费、分隔符无安全选择、DB 与磁盘两个真相源必漂移)。`new_cover_path` 永远只存**当前生效的一张**。
|
||||||
|
- **目录即数据库**:历史候选图只存在文件系统,靠文件名约定 `{task_id}_{item_id}_new_<时间戳>.jpg` 索引,弹窗打开时现场扫目录枚举。
|
||||||
|
- 归档钩子放在**重置**(运营主动、非时间敏感),不放在生图写入热路径(避免在已扣点的成功路径上引入 Windows 文件锁/TOCTOU 新失败点)。
|
||||||
|
|
||||||
|
本任务是数据层前置;GUI 画廊弹窗见 T-567。
|
||||||
|
|
||||||
|
## 方案(改哪个文件、改成什么)
|
||||||
|
|
||||||
|
### `app/db.py` — `reset_generated` 归档改名(替代删除)
|
||||||
|
|
||||||
|
- `reset_generated(..., reset_cover=True)` 时,若 `new_cover_path` 指向的文件存在:
|
||||||
|
- 改名为 `{原名去扩展}_{YYYYMMDDHHMMSS}{ext}`,如 `42_51513423876_new.jpg` → `42_51513423876_new_20260709094800.jpg`;
|
||||||
|
- **撞名兜底**:目标名已存在(同秒重置两次)则追加 `_2`/`_3`;
|
||||||
|
- **顺序**:先 `os.rename` 成功、再清 DB 指针;改名抛 `PermissionError`(Windows 文件被预览/资源管理器占用)→ **整体中止本次重置**,抛 `DbError`(提示"请先关闭正在查看的封面图片再重置"),不留中间态(先清指针后改名失败会导致下次生成覆盖旧图、归档丢失);
|
||||||
|
- 旧文件不存在 → 无事发生,照常清指针(幂等,不报错)。
|
||||||
|
- `delete_file=True` 的旧语义处理:归档取代删除(或保留 `delete_file` 为真删、新增 `archive_previous` 参数,默认对封面重置开启——具体参数形状实现时定,验收看行为)。
|
||||||
|
- 仅重置标题(`reset_cover=False`)不触碰任何图片文件(既有行为不回归)。
|
||||||
|
|
||||||
|
### `app/db.py` — 新增 `update_generated_cover(task_id, new_cover_path)`
|
||||||
|
|
||||||
|
- 校验任务存在、目标文件存在,目标路径必须是本地文件路径;供 T-567「保存」调用,把 `tasks.new_cover_path` 指向运营选中的那张(不复制、不改名文件)。
|
||||||
|
- 状态语义必须明确:
|
||||||
|
- 写入后设置 `stage='generated'`、`status='pending'`、`last_error=NULL`、`updated_at=<now>`,表示本地已有可用于③更新蝦皮的新封面;
|
||||||
|
- 不增加 `generate_attempts` / `apply_attempts`,因为这不是一次新的 AI 调用或线上提交;
|
||||||
|
- 不改 `committed`、`applied_at`、`apply_attempts`,保留“曾经提交过线上”的历史事实;
|
||||||
|
- 不改 `image_task_id` / `image_task_key`,它们只记录/续查 cmhub 异步生图任务,封面指针切换不应误清审计信息;真正重置封面仍由 `reset_generated(..., reset_cover=True)` 清空。
|
||||||
|
- 对已 `committed=1` 或 `stage='applied'` 的任务,DB 层允许写入并按上面规则退回 `generated/pending`,由 GUI 在保存前弹“本地换封面不会回滚蝦皮,重复更新会再次提交线上”确认。
|
||||||
|
- **有意的不对称,勿"顺手对齐"**:`update_generated_title`(`db.py:819`)对 committed/applied 任务在 DB 层直接拒绝,而本函数允许(退回 + GUI 警示)。这是设计决定:换封面重挑是运营核心场景、走「重置更新状态」同款警示口径(`apply.py:311/334`);改标题重提交没有此需求。后续不要参照 title 的守卫给本函数加拒绝逻辑,否则 committed 记录重挑封面功能会被改死。
|
||||||
|
|
||||||
|
### 批次软删除边界:不做归档图片清理
|
||||||
|
|
||||||
|
- 不修改 `delete_batch()` 的软删除语义,不删除 `<image_root>/<batch_id>/` 目录。
|
||||||
|
- 理由:T-206 已定“删除批次”为软删除,只隐藏本地批次,不物理删除任务与图片;把本任务改成删目录会造成用户误删历史 AI 图和旧封面备份。
|
||||||
|
- 归档图片清理如后续需要,另做独立“清理已软删除批次图片”工具,必须有明确二次确认和删除清单预览。
|
||||||
|
|
||||||
|
### `app/image_paths.py` — 新增画廊枚举函数
|
||||||
|
|
||||||
|
- `list_task_cover_candidates(image_root, task, account=None)`:扫 `<image_root>/<batch_id>/<slug>/` 下匹配 `{task_id}_{item_id}_new*.jpg` 的文件,返回绝对路径列表;只包含新封面候选,不含 `_old` 旧封面。
|
||||||
|
- 排序规则必须稳定、可测试:
|
||||||
|
- 最新优先;
|
||||||
|
- 规范名 `{task_id}_{item_id}_new.jpg` 视为“当前最新输出槽”,排在同任务候选最前;
|
||||||
|
- 归档名 `{task_id}_{item_id}_new_YYYYMMDDHHMMSS.jpg` 按时间戳倒序;
|
||||||
|
- 同秒撞名后缀 `_2` / `_3` 按数字倒序;
|
||||||
|
- 无法解析时间戳的匹配文件排在最后,并按文件名升序兜底。
|
||||||
|
- GUI 默认选中由 DB 当前 `new_cover_path` 决定,不依赖排序;排序只影响画廊展示顺序。
|
||||||
|
- 只读、不建表、不加列——文件系统是唯一真相源。
|
||||||
|
|
||||||
|
## 验收要点
|
||||||
|
|
||||||
|
- 重置封面且旧图存在 → 旧图改名为带 `YYYYMMDDHHMMSS` 后缀、原规范名腾出、DB 指针已清;同秒二次重置 → 追加 `_2` 不覆盖。
|
||||||
|
- 改名 `PermissionError` → 本次重置整体失败、DB 指针未清、原文件原名保留(断言不留中间态)。
|
||||||
|
- 旧图不存在 → 重置照常成功(幂等)。
|
||||||
|
- 仅重置标题 → 不触碰图片文件。
|
||||||
|
- `update_generated_cover` 写入后 `stage=generated/status=pending/last_error=NULL`;不增加 attempts,不改 committed/applied_at/apply_attempts/image_task_*;目标文件不存在 → 报错。
|
||||||
|
- `delete_batch` 仍只做软删除,不删除 `<batch_id>/` 图片目录;归档图片继续保留。
|
||||||
|
- `list_task_cover_candidates` 返回全部 `_new*` 候选、不含 `_old`,排序符合“规范名优先、归档时间倒序、撞名数字倒序、异常文件名兜底”的规则。
|
||||||
|
- 验证命令(unittest,不引入 pytest):
|
||||||
|
- `py -3.10 -m unittest tests.test_db tests.test_image_paths`
|
||||||
|
- `python -m ruff check app tests main.py`
|
||||||
|
- `py -3.10 -m unittest discover -s tests`
|
||||||
|
- `git diff --check`
|
||||||
|
|
||||||
|
## 边界(不改什么)
|
||||||
|
|
||||||
|
- 不改 `tasks` 表 schema(不加列、不建子表);`new_cover_path` 仍为单路径。
|
||||||
|
- 不改生图写入链(`_save_jpeg`/`persist_cover_success`/规范名 `..._new.jpg`);归档只钩在重置。
|
||||||
|
- 不改 ③更新/Excel 回写对 `new_cover_path` 的消费方式。
|
||||||
|
- 不做 GUI(画廊弹窗在 T-567)。
|
||||||
|
- 不改 CDP/Shopee 交互、AI 请求链路。
|
||||||
|
|
||||||
|
## 执行记录
|
||||||
|
|
||||||
|
- 2026-07-09 完成 T-566。
|
||||||
|
- 修改 `app/db.py`:
|
||||||
|
- `reset_generated(..., reset_cover=True)` 在清空 DB 指针前先把当前 `new_cover_path` 指向的本地文件改名归档为 `{stem}_YYYYMMDDHHMMSS{ext}`;同秒撞名追加 `_2/_3`;旧 `delete_file=True` 不再物理删除,归档取代删除,返回结果新增 `archived_file`,保留 `deleted_file=None` 兼容旧调用。
|
||||||
|
- 文件不存在时幂等跳过归档;`PermissionError` 抛中文 `DbError` 并保持 DB 指针不变。
|
||||||
|
- 新增 `update_generated_cover(task_id, new_cover_path)`,只切换当前生效封面指针,写入后退回 `generated/pending`,不增加 attempts,不清 `committed/applied_at/apply_attempts/image_task_id/image_task_key`。
|
||||||
|
- 修改 `app/image_paths.py`:新增 `list_task_cover_candidates(image_root, task, account=None)`,只读扫描任务目录下 `_new*.jpg` 候选,返回绝对路径,排序为规范名优先、归档时间倒序、同秒后缀数字倒序、异常文件名最后。
|
||||||
|
- 修改测试:
|
||||||
|
- `tests/test_db.py` 覆盖归档撞名、文件锁失败 DB 不变、缺失文件幂等、`update_generated_cover` 状态语义、运行中拒绝切换、批次软删除不删图片目录。
|
||||||
|
- `tests/test_image_paths.py` 覆盖候选图过滤和稳定排序。
|
||||||
|
- `tests/test_gui.py` 同步重置后本地文件“归档保留而非原名保留”的新语义。
|
||||||
|
- 验证通过:
|
||||||
|
- `py -3.10 -m unittest tests.test_db tests.test_image_paths`
|
||||||
|
- `python -m ruff check app tests main.py`
|
||||||
|
- `py -3.10 -m compileall app main.py`
|
||||||
|
- `py -3.10 -m unittest discover -s tests`(285 tests;PySide6 字体目录警告不影响结果)
|
||||||
|
- `git diff --check`
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
---
|
||||||
|
id: T-567
|
||||||
|
title: ②封面画廊基础:候选图查看、单选保存、原图弹窗
|
||||||
|
phase: 7
|
||||||
|
deps: [T-566]
|
||||||
|
status: DONE
|
||||||
|
created: 2026-07-09
|
||||||
|
---
|
||||||
|
|
||||||
|
## 问题 / 背景
|
||||||
|
|
||||||
|
运营需要在软件内对比同一商品多次 AI 生图的候选,并把其中一张设为当前生效封面。当前②列表双击打开的「封面对照」弹窗只显示旧封面 + 当前新封面各一张,无法看到历史候选,也不能保存选中的历史图。
|
||||||
|
|
||||||
|
T-566 已提供数据层能力:重置封面时归档旧图、`db.update_generated_cover()` 切换当前封面指针、`image_paths.list_task_cover_candidates()` 枚举候选图。本任务只做 GUI 第一阶段:画廊查看 + 单选保存 + 原图弹窗。
|
||||||
|
|
||||||
|
## 方案(改哪个文件、改成什么)
|
||||||
|
|
||||||
|
### `app/gui/tabs/generate.py` — 重构封面对照弹窗
|
||||||
|
|
||||||
|
- 将现有 `show_task_images` 改为封面画廊弹窗(可拆成独立 `QDialog` 类,如 `CoverGalleryDialog`)。
|
||||||
|
- 布局:
|
||||||
|
- 左侧保留「旧封面」参照,推荐缩略图约 `300x300`;
|
||||||
|
- 右侧为候选画廊:横向 `QScrollArea`,每个候选项包含缩略图、`QRadioButton`、时间/当前标记;
|
||||||
|
- 底部按钮第一阶段只保留「保存」「取消」;不做「重新生成」「上一条/下一条」。
|
||||||
|
- 尺寸:
|
||||||
|
- 默认 `resize(min(960x580, 屏幕 availableGeometry 的 90%))`;
|
||||||
|
- 不用 `setFixedSize()`,拉伸增量给画廊区域;
|
||||||
|
- 小屏下窗口标题栏必须完整可见。
|
||||||
|
|
||||||
|
### 候选加载与默认选中
|
||||||
|
|
||||||
|
- 打开弹窗时调用 T-566 `list_task_cover_candidates(image_root, task, account)` 现场扫目录。
|
||||||
|
- 默认选中 = DB 当前 `task.new_cover_path` 指向的文件;不是无条件选最新。
|
||||||
|
- 如果 DB 当前路径不存在但候选列表不空:
|
||||||
|
- 默认不自动改库;
|
||||||
|
- 画廊显示候选但不预选,状态 label 提示“当前生效封面文件不存在,请选择一张后保存”。
|
||||||
|
- 如果无候选图:
|
||||||
|
- 右侧显示空状态“暂无生成封面图片”;
|
||||||
|
- 「保存」置灰。
|
||||||
|
- 缩略图加载必须用 `QImage` 读入内存后转 `QPixmap`,不长期持有文件句柄,避免后续重置/重生时 Windows 文件锁影响归档改名。
|
||||||
|
|
||||||
|
### 保存
|
||||||
|
|
||||||
|
- 选中项与 DB 当前 `new_cover_path` 相同:直接关闭。
|
||||||
|
- 选中项不同:调用 `db.update_generated_cover(task_id, selected_path)`;刷新②列表该行;关闭弹窗。
|
||||||
|
- 若该任务 `committed=1` 或 `stage='applied'`,保存前必须弹确认:
|
||||||
|
- 文案说明“本地换封面不会回滚蝦皮,重复更新会再次提交线上”;
|
||||||
|
- 用户取消则不写库、不关闭。
|
||||||
|
- 保存失败(文件不存在、DB 错误等)应弹中文错误并保留弹窗,不吞异常。
|
||||||
|
|
||||||
|
### 原图弹窗
|
||||||
|
|
||||||
|
- 双击任一候选缩略图打开原图弹窗:
|
||||||
|
- `QDialog + QScrollArea` 展示原始尺寸图片;
|
||||||
|
- 窗口最大不超过屏幕 availableGeometry 的 90%,超出可滚动;
|
||||||
|
- 标题显示图片文件名和原始分辨率,如 `1024x1024`。
|
||||||
|
|
||||||
|
## 验收要点
|
||||||
|
|
||||||
|
- 双击②任务列表仍能打开封面查看入口;旧封面仍作为参照显示。
|
||||||
|
- 画廊列出该任务全部 `_new*` 候选,不显示 `_old`。
|
||||||
|
- 默认选中 DB 当前 `new_cover_path`;DB 当前路径缺失时不自动改库。
|
||||||
|
- 选择另一张并保存会调用 `db.update_generated_cover()`,②列表新封面路径刷新;未变更直接关闭。
|
||||||
|
- 已提交线上任务保存前弹二次确认;取消不写库。
|
||||||
|
- 双击缩略图可打开原图弹窗,超出屏幕可滚动,标题含分辨率。
|
||||||
|
- 缩略图加载不持文件句柄;Windows 下打开画廊后仍可重命名候选文件。
|
||||||
|
- 验证命令(unittest,不引入 pytest):
|
||||||
|
- `py -3.10 -m unittest tests.test_gui tests.test_db tests.test_image_paths`
|
||||||
|
- `python -m ruff check app tests main.py`
|
||||||
|
- `py -3.10 -m compileall app main.py`
|
||||||
|
- `py -3.10 -m unittest discover -s tests`
|
||||||
|
- `git diff --check`
|
||||||
|
|
||||||
|
## 边界(不改什么)
|
||||||
|
|
||||||
|
- 不做单任务重新生成(T-568)。
|
||||||
|
- 不做上一条/下一条切换和未保存三态拦截(T-569)。
|
||||||
|
- 不改 T-566 的数据层契约。
|
||||||
|
- 不改 `GenerateWorker`、`generate_batch`、cmhub 请求链路。
|
||||||
|
- 不改 ③更新/Excel 回写/CDP/Shopee。
|
||||||
|
|
||||||
|
## 执行记录
|
||||||
|
|
||||||
|
- 2026-07-09 完成 T-567。
|
||||||
|
- 修改 `app/gui/tabs/generate.py`:
|
||||||
|
- 将②任务列表双击封面查看入口重构为 `CoverGalleryDialog`,左侧显示旧封面参照,右侧横向滚动画廊显示该任务全部 `_new*.jpg` 候选。
|
||||||
|
- 画廊打开时调用 T-566 的 `image_paths.list_task_cover_candidates()` 现场扫描目录;默认选中 DB 当前 `new_cover_path`,当前路径缺失时不自动改库并提示选择后保存;无候选时显示空状态并禁用保存。
|
||||||
|
- 保存时未变更直接关闭;变更时调用 `db.update_generated_cover()`,保存成功后刷新②任务列表。已提交线上任务保存前弹中文二次确认,说明本地换封面不会回滚蝦皮、重复更新会再次提交线上。
|
||||||
|
- 缩略图和旧封面预览统一用 `QImage` 读入内存后转 `QPixmap`,不长期持有文件句柄;候选缩略图双击打开 `OriginalImageDialog` 原图弹窗,标题显示文件名与原始分辨率,超出屏幕时可滚动。
|
||||||
|
- 修改 `app/gui/widgets.py`:补充 `QImage`、`QButtonGroup`、`QRadioButton` 聚合导入,供画廊弹窗使用。
|
||||||
|
- 修改 `tests/test_gui.py`:
|
||||||
|
- 覆盖候选图展示和 `_old` 过滤、当前封面默认选中、打开画廊后候选文件可重命名、保存切换当前封面、已提交任务取消确认不写库、当前路径缺失/无候选提示和禁用保存、原图弹窗分辨率标题。
|
||||||
|
- 验证通过:
|
||||||
|
- `py -3.10 -m unittest tests.test_gui`
|
||||||
|
- `py -3.10 -m unittest tests.test_gui tests.test_db tests.test_image_paths`
|
||||||
|
- `python -m ruff check app tests main.py`
|
||||||
|
- `py -3.10 -m compileall app main.py`
|
||||||
|
- `py -3.10 -m unittest discover -s tests`(290 tests;PySide6 字体目录警告不影响结果)
|
||||||
|
- `git diff --check`
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
---
|
||||||
|
id: T-568
|
||||||
|
title: ②封面画廊单任务重新生成与并发守卫
|
||||||
|
phase: 7
|
||||||
|
deps: [T-567]
|
||||||
|
status: DONE
|
||||||
|
created: 2026-07-09
|
||||||
|
---
|
||||||
|
|
||||||
|
## 问题 / 背景
|
||||||
|
|
||||||
|
T-567 只提供候选封面查看与保存。运营在对比时仍需要对当前商品单独再生成一张新封面,并在画廊中立即看到新候选。该能力涉及 AI 点数、DB 归档、任务状态和后台 worker 并发,必须单独拆出,避免 T-567 弹窗基础功能过大。
|
||||||
|
|
||||||
|
## 方案(改哪个文件、改成什么)
|
||||||
|
|
||||||
|
### 重新生成按钮
|
||||||
|
|
||||||
|
- 在 T-567 的封面画廊弹窗底部新增「重新生成」按钮。
|
||||||
|
- 点击后弹确认框:
|
||||||
|
- 文案说明将重新生成当前商品封面,可能消耗 cmhub 点数;
|
||||||
|
- 用户取消则无副作用。
|
||||||
|
- 用户确认后:
|
||||||
|
1. 调用 `db.reset_generated(task_id, reset_title=False, reset_cover=True)`,由 T-566 归档当前 `new_cover_path` 并清空封面指针;
|
||||||
|
2. reset 成功后必须重新 `db.get_task(task_id)` 获取最新任务对象,不能复用弹窗打开时的旧 task(旧对象可能还带旧 `new_cover_path`,导致 `generate_batch()` 判断无需补封面);
|
||||||
|
3. 启动单任务 `GenerateWorker([fresh_task])`,沿用当前②页面的提示词、cmhub配置、生成参数和“生成封面图片”语义,但本任务强制只补当前任务缺失封面,不重生标题;
|
||||||
|
4. 弹窗左下状态 label 显示“开始重新生成商品 <item_id> 的封面图片”。
|
||||||
|
|
||||||
|
### 完成/失败刷新
|
||||||
|
|
||||||
|
- 单任务生成完成:
|
||||||
|
- 刷新②列表该行;
|
||||||
|
- 重新调用 `list_task_cover_candidates()` 刷新画廊;
|
||||||
|
- 新生成的规范名 `_new.jpg` 应成为 DB 当前 `new_cover_path`,画廊默认选中它;
|
||||||
|
- 状态 label 显示“封面重新生成完成”。
|
||||||
|
- 失败:
|
||||||
|
- 保留已归档旧图;
|
||||||
|
- 列表按既有生成失败口径显示;
|
||||||
|
- 状态 label 显示失败摘要;
|
||||||
|
- 画廊继续显示历史候选,允许用户选择历史图保存。
|
||||||
|
- 若 `reset_generated()` 因 Windows 文件占用导致归档改名失败:
|
||||||
|
- 中止重新生成;
|
||||||
|
- 不启动 `GenerateWorker`;
|
||||||
|
- 弹中文提示“请先关闭正在查看的封面图片再重试”。
|
||||||
|
|
||||||
|
### 并发守卫
|
||||||
|
|
||||||
|
- ②批量生成运行中时,画廊「重新生成」按钮禁用,并显示 tooltip。
|
||||||
|
- 画廊单任务重新生成运行中时,②主界面「开始生成」按钮也应禁用或阻断,避免两个 `GenerateWorker` 同时写同一 DB/图片目录。
|
||||||
|
- 同一画廊弹窗运行中再次点击「重新生成」无效。
|
||||||
|
- 本任务不新增 cmhub cancel 接口;关闭弹窗时若单任务仍在运行,只允许隐藏/关闭视图,不强杀服务端任务,遵守 T-564 续查语义。
|
||||||
|
|
||||||
|
## 验收要点
|
||||||
|
|
||||||
|
- 重新生成确认取消 → 不归档、不启动 worker、不改 DB。
|
||||||
|
- 确认后先归档旧图,再用 fresh task 启动单任务生成;不会因旧 task 对象残留 `new_cover_path` 而跳过封面生成。
|
||||||
|
- 成功后画廊出现新图并默认选中;②列表刷新为新封面路径。
|
||||||
|
- 失败后历史候选仍可选择保存。
|
||||||
|
- 归档改名失败时不清 DB 指针、不启动 worker。
|
||||||
|
- 批量生成和单任务重生互斥,不允许两个 GenerateWorker 并行。
|
||||||
|
- 验证命令:
|
||||||
|
- `py -3.10 -m unittest tests.test_gui tests.test_db`
|
||||||
|
- `python -m ruff check app tests main.py`
|
||||||
|
- `py -3.10 -m compileall app main.py`
|
||||||
|
- `py -3.10 -m unittest discover -s tests`
|
||||||
|
- `git diff --check`
|
||||||
|
|
||||||
|
## 边界(不改什么)
|
||||||
|
|
||||||
|
- 不改 T-566 数据层契约。
|
||||||
|
- 不改 cmhub submit/poll/download 逻辑。
|
||||||
|
- 不做上一条/下一条切换和未保存拦截(T-569)。
|
||||||
|
- 不改 ③更新/Excel/CDP/Shopee。
|
||||||
|
|
||||||
|
## 执行记录
|
||||||
|
|
||||||
|
- 2026-07-09 完成 T-568。
|
||||||
|
- 修改 `app/gui/tabs/generate.py`:
|
||||||
|
- 在 T-567 封面画廊底部新增「重新生成」按钮;点击后先弹中文确认,说明会重新生成当前商品封面并可能消耗 cmhub 点数。
|
||||||
|
- 确认后调用 `db.reset_generated(task_id, reset_title=False, reset_cover=True)`,由 T-566 归档当前新封面并清空封面指针;随后重新 `db.get_task()` 获取 fresh task,再启动单任务 `GenerateWorker([fresh_task])`,避免旧 task 残留 `new_cover_path` 导致跳过生图。
|
||||||
|
- 单任务重生使用当前②页面传入的标题/封面提示词和配置,但只在 worker 配置副本中强制 `ai.generate_cover=True`,不保存到设置,确保只补缺失封面、不重生标题。
|
||||||
|
- worker 完成后刷新②列表与画廊候选;成功时新 `_new.jpg` 默认选中;失败时保留已归档旧图并允许选择历史候选保存;归档改名失败时弹中文错误并不启动 worker。
|
||||||
|
- 增加并发守卫:②批量生成运行中时画廊「重新生成」禁用并显示 tooltip;画廊单任务重生运行中时②主界面「开始生成」和「重置生成结果」禁用/阻断。
|
||||||
|
- 修改 `tests/test_gui.py`:
|
||||||
|
- 用 fake `GenerateWorker`/thread 覆盖重新生成确认取消、fresh task 启动、临时强制 `generate_cover=True`、成功刷新并选中新图、失败后历史候选可保存、归档失败不启动 worker、批量生成与单任务重生互斥、主界面启动阻断。
|
||||||
|
- 验证通过:
|
||||||
|
- `py -3.10 -m unittest tests.test_gui`
|
||||||
|
- `py -3.10 -m unittest tests.test_gui tests.test_db`
|
||||||
|
- `python -m ruff check app tests main.py`
|
||||||
|
- `py -3.10 -m compileall app main.py`
|
||||||
|
- `py -3.10 -m unittest discover -s tests`(297 tests;PySide6 字体目录警告不影响结果)
|
||||||
|
- `git diff --check`
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
---
|
||||||
|
id: T-569
|
||||||
|
title: ②封面画廊上下条切换与未保存选择拦截
|
||||||
|
phase: 7
|
||||||
|
deps: [T-567]
|
||||||
|
status: DONE
|
||||||
|
created: 2026-07-09
|
||||||
|
---
|
||||||
|
|
||||||
|
## 问题 / 背景
|
||||||
|
|
||||||
|
T-567 的封面画廊只处理当前单条任务。运营批量检查时需要在弹窗内连续查看上一条/下一条商品,而不是关闭弹窗后回到列表再双击。切换记录时,如果当前画廊已经勾选了另一张候选但尚未保存,必须避免静默丢失选择。
|
||||||
|
|
||||||
|
## 方案(改哪个文件、改成什么)
|
||||||
|
|
||||||
|
### 上一条/下一条按钮
|
||||||
|
|
||||||
|
- 在 T-567 封面画廊弹窗底部增加「上一条」「下一条」。
|
||||||
|
- 目标任务来自②当前表格模型的相邻可见行,必须尊重当前筛选结果和排序。
|
||||||
|
- 第一条时「上一条」置灰;最后一条时「下一条」置灰。
|
||||||
|
- 切换记录时只刷新弹窗内容区和内部当前 task,不重建整个窗口;窗口大小和位置保持不变。
|
||||||
|
|
||||||
|
### 键盘切换
|
||||||
|
|
||||||
|
- 支持 `↑` / `↓` 键切换上一条/下一条,和按钮走同一个切换函数。
|
||||||
|
- 画廊内 `QRadioButton` 设置 `ClickFocus` 或 `NoFocus`,避免方向键在 radio 组内改变候选选择而不是切换记录。
|
||||||
|
- 输入框/按钮等获得焦点时如不适合消费方向键,应保持 Qt 默认行为;不为键盘切换破坏可访问性。
|
||||||
|
|
||||||
|
### 未保存选择拦截
|
||||||
|
|
||||||
|
- 判断条件:当前 radio 选中路径 != DB 当前 `task.new_cover_path`。
|
||||||
|
- 在按钮切换、方向键切换、关闭弹窗前都必须进入同一套拦截流程。
|
||||||
|
- 弹三态确认框,按钮必须是中文:
|
||||||
|
- 「保存」:调用 `db.update_generated_cover()`;如任务已提交线上,先弹 T-567 同款“本地换封面不会回滚蝦皮,重复更新会再次提交线上”确认;保存成功后继续切换/关闭。
|
||||||
|
- 「不保存」:丢弃当前勾选,继续切换/关闭。
|
||||||
|
- 「取消」:留在当前任务,不切换、不关闭。
|
||||||
|
- 保存失败时保留当前任务和弹窗,显示中文错误。
|
||||||
|
|
||||||
|
### 与 T-568 的关系
|
||||||
|
|
||||||
|
- 如果 T-568 已完成且当前任务正在单任务重生:
|
||||||
|
- 可允许切换到其它任务,但切回时必须刷新最新 DB/候选;
|
||||||
|
- 当前任务运行中时画廊显示“生成中”状态,不显示旧 `new_cover_path` 为当前有效图误导用户。
|
||||||
|
- 如果 T-568 尚未完成,本任务只需保证 T-567 基础画廊切换逻辑正确。
|
||||||
|
|
||||||
|
## 验收要点
|
||||||
|
|
||||||
|
- 上一条/下一条按钮按当前②可见列表相邻行切换;边界按钮置灰。
|
||||||
|
- `↑` / `↓` 与按钮共用切换逻辑;方向键不会改变 radio 选中。
|
||||||
|
- 切换记录不改变弹窗尺寸和屏幕位置。
|
||||||
|
- 当前有未保存选择时,按钮切换、键盘切换、关闭弹窗都会弹中文三态确认。
|
||||||
|
- 「保存 / 不保存 / 取消」三种路径行为正确;保存失败不切换。
|
||||||
|
- 已提交线上任务保存前仍弹二次警示。
|
||||||
|
- 验证命令:
|
||||||
|
- `py -3.10 -m unittest tests.test_gui`
|
||||||
|
- `python -m ruff check app tests main.py`
|
||||||
|
- `py -3.10 -m compileall app main.py`
|
||||||
|
- `py -3.10 -m unittest discover -s tests`
|
||||||
|
- `git diff --check`
|
||||||
|
|
||||||
|
## 边界(不改什么)
|
||||||
|
|
||||||
|
- 不改 T-566 数据层契约。
|
||||||
|
- 不改 T-568 单任务重新生成逻辑。
|
||||||
|
- 不改 cmhub/AI 请求、DB schema、Excel、③更新、CDP/Shopee。
|
||||||
|
|
||||||
|
## 执行记录
|
||||||
|
|
||||||
|
- 2026-07-09 完成 T-569。
|
||||||
|
- 修改 `app/gui/tabs/generate.py`:
|
||||||
|
- ②封面画廊新增「上一条」「下一条」按钮,目标任务来自打开画廊时②当前可见表格任务列表,按相邻行切换,首尾边界按钮自动置灰。
|
||||||
|
- 切换任务时只刷新弹窗内部任务、旧封面和候选画廊内容,不重建整个窗口,窗口尺寸和位置保持不变;切换时会重新读取 DB 最新任务和候选文件。
|
||||||
|
- 支持 `↑` / `↓` 键走同一套上一条/下一条切换逻辑;候选 `QRadioButton` 改为 `ClickFocus`,避免方向键在 radio 组内改候选。
|
||||||
|
- 增加统一未保存选择拦截:按钮切换、方向键切换、取消/关闭弹窗前,如果当前选中候选不同于当前 DB 封面指针,会弹中文三态确认「保存 / 不保存 / 取消」。保存路径复用 `db.update_generated_cover()`,已提交线上任务仍先弹“本地换封面不会回滚蝦皮,重复更新会再次提交线上”确认;保存失败保留当前任务。
|
||||||
|
- 与 T-568 兼容:单任务封面重生运行中切换回该任务时显示“封面重新生成中”,完成/失败后继续刷新 DB 和候选列表。
|
||||||
|
- 修改 `tests/test_gui.py`:
|
||||||
|
- 覆盖上一条/下一条按当前可见列表切换、边界按钮状态、切换不改变窗口尺寸/位置。
|
||||||
|
- 覆盖 `↑`/`↓` 键切换任务且不改变候选 radio 选择。
|
||||||
|
- 覆盖未保存选择在切换和关闭时的「保存 / 不保存 / 取消」三种路径,以及已提交任务保存前的二次确认。
|
||||||
|
- 验证通过:
|
||||||
|
- `py -3.10 -m unittest tests.test_gui`
|
||||||
|
- `python -m ruff check app tests main.py`
|
||||||
|
- `py -3.10 -m compileall app main.py`
|
||||||
|
- `py -3.10 -m unittest discover -s tests`(302 tests;PySide6 字体目录警告不影响结果)
|
||||||
|
- `git diff --check`
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
---
|
||||||
|
id: T-570
|
||||||
|
title: cmhub 异步生图 submit 读取等待调整为 36 秒
|
||||||
|
phase: 7
|
||||||
|
deps: [T-564]
|
||||||
|
status: DONE
|
||||||
|
created: 2026-07-09
|
||||||
|
---
|
||||||
|
|
||||||
|
## 问题 / 背景
|
||||||
|
|
||||||
|
T-564 后,②批量生图已改为异步任务接口:先 `POST /api/v1/generate/image/tasks` 提交任务,再轮询 `GET /api/v1/generate/image/tasks/{task_id}` 获取图片地址。当前 submit 阶段单次 read timeout 是 30 秒。
|
||||||
|
|
||||||
|
真实使用中,cmhub submit 可能因为上游排队、网关抖动或服务端首次调度稍慢,30 秒边界略紧,容易在服务端可能已受理任务但客户端未拿到 `task_id` 的窗口报超时。为降低边界抖动,本任务把 submit 阶段单次 read timeout 从 30 秒调到 36 秒。
|
||||||
|
|
||||||
|
## 方案
|
||||||
|
|
||||||
|
- `app/ai.py`
|
||||||
|
- 将 `CMHUB_IMAGE_SUBMIT_READ_TIMEOUT_SECONDS` 从 `30` 改为 `36`。
|
||||||
|
- 只影响 `POST /api/v1/generate/image/tasks` 的读取等待。
|
||||||
|
- 不改 poll 单次 15 秒、本地总预算 900 秒、图片下载 900 秒。
|
||||||
|
- `tests/test_ai.py`
|
||||||
|
- 补充/更新 cmhub 异步生图测试,断言 submit 请求实际使用 `timeout=(connect_timeout, 36)`。
|
||||||
|
- 文档
|
||||||
|
- 同步 `docs/api.md` 和 `docs/cmhub-integration-design.md` 中关于 submit 读取等待的精确说明。
|
||||||
|
|
||||||
|
## 验收要点
|
||||||
|
|
||||||
|
- `POST /api/v1/generate/image/tasks` 的 read timeout 为 36 秒。
|
||||||
|
- poll 单次读取等待仍为 15 秒,本地总预算仍为 900 秒。
|
||||||
|
- 下载读取等待仍为 900 秒。
|
||||||
|
- 不改 cmhub submit/poll 状态机、DB schema、幂等键和重启续查逻辑。
|
||||||
|
- 验证命令:
|
||||||
|
- `py -3.10 -m unittest tests.test_ai`
|
||||||
|
- `python -m ruff check app tests main.py`
|
||||||
|
- `py -3.10 -m compileall app main.py`
|
||||||
|
- `py -3.10 -m unittest discover -s tests`
|
||||||
|
- `git diff --check`
|
||||||
|
|
||||||
|
## 边界(不改什么)
|
||||||
|
|
||||||
|
- 不改 `ai.cmhub.connect_timeout` 默认值 66 秒。
|
||||||
|
- 不改生图 poll 总等待 900 秒、poll 单次 15 秒和轮询间隔。
|
||||||
|
- 不改图片下载逻辑、curl/requests 选择、图片 URL 安全校验。
|
||||||
|
- 不改 CDP/Shopee、Excel、GUI 布局或 DB schema。
|
||||||
|
|
||||||
|
## 执行记录
|
||||||
|
|
||||||
|
- 2026-07-09:完成 T-570。
|
||||||
|
- `app/ai.py`:将 `CMHUB_IMAGE_SUBMIT_READ_TIMEOUT_SECONDS` 从 30 调整为 36,只影响 `POST /api/v1/generate/image/tasks` 的 submit 读取等待;poll 单次 15 秒、本地总预算 900 秒和下载 900 秒不变。
|
||||||
|
- `tests/test_ai.py`:在 cmhub 异步 submit 落库测试中断言实际请求 timeout 为 `(connect_timeout, 36)`,防止后续回退。
|
||||||
|
- 文档:同步 `docs/api.md` 与 `docs/cmhub-integration-design.md` 的 submit 读取等待口径。
|
||||||
|
- 验证通过:`py -3.10 -m unittest tests.test_ai`、`python -m ruff check app tests main.py`、`py -3.10 -m compileall app main.py`、`py -3.10 -m unittest discover -s tests`、`git diff --check`。
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
---
|
||||||
|
id: T-571
|
||||||
|
title: ③更新抢前台降频:每账号只提一次 Chrome 前台 + 拖拽/上传失败安全升级前台恢复
|
||||||
|
phase: 7
|
||||||
|
deps: [T-402, T-562]
|
||||||
|
status: DONE
|
||||||
|
created: 2026-07-09
|
||||||
|
---
|
||||||
|
|
||||||
|
## 问题 / 背景
|
||||||
|
|
||||||
|
③更新蝦皮时,`apply_task`(`app/editor.py:1248`)每条任务调 `open_product(account, item_id)`,走默认 `bring_to_front=True`(`editor.py:756`):`create_tab_info(background=False)` 以激活态建 tab + `Page.bringToFront`(`editor.py:777`)把整个 Chrome 窗口提到 OS 前台。**用户切到其他软件后,每开始一条任务(几十秒一条)Chrome 就抢一次前台**,批量几百条 = 焦点被抢几百次,正在输入的内容可能误入 Chrome。
|
||||||
|
|
||||||
|
①采集已是 `bring_to_front=False`(T-562,`editor.py:830`)。**①③不对称有技术原因,不能无脑把③改 False**:①只读;③要做真实 UI 交互(`cdp.drag` 34 步拖拽换首图、上传等待、点更新)。CDP `Input.dispatchMouseEvent` 注入渲染进程不需要 OS 焦点,但 Windows 上 Chrome 有遮挡检测(occlusion tracking):窗口被**完全遮挡或最小化** → `visibilityState: hidden` → rAF 暂停、JS timer 节流到 1 次/秒,Shopee 拖拽排序库/上传组件可能失灵(`DRAG_NOT_FIRST`、`UPLOAD_STILL_PROCESSING` 假超时)。部分可见则正常。
|
||||||
|
|
||||||
|
结论:「不抢前台」与「交互必成」在完全遮挡时真冲突 → 用「每账号一次 + 失败升级」组合化解。
|
||||||
|
|
||||||
|
## 方案(改哪个文件、改成什么)
|
||||||
|
|
||||||
|
### 方案 1:抢前台从「每任务」降为「每账号一次」
|
||||||
|
|
||||||
|
- `apply_task` 增加 `bring_to_front` 参数(默认 `True` 保持函数级向后兼容),透传给 `open_product`。
|
||||||
|
- ③ `ApplyWorker`(`app/gui/workers.py` 更新循环)维护「本轮已提过前台的账号集合」:同一账号**第一条任务** `bring_to_front=True`(换账号提示一次,有「正在处理该店」的信号意义),**该账号后续任务传 `False`**。
|
||||||
|
- 该集合必须在 `ApplyWorker` 生命周期内维护,跨当前本轮的所有分批生效;worker 重开/新一轮更新才重置。
|
||||||
|
- 因 ③ 支持多账号并行,集合访问必须线程安全,建议 `self._foregrounded_aliases = set()` + `self._foreground_lock = threading.Lock()`,通过 helper 原子判断“本账号是否本轮首次前台”。
|
||||||
|
|
||||||
|
### 方案 2:拖拽/上传失败自动升级前台安全恢复(兜底)
|
||||||
|
|
||||||
|
- `apply_task`(或 `replace_cover` 调用层)当封面替换结果 reason ∈ {`DRAG_NOT_FIRST`, `UPLOAD_STILL_PROCESSING`, `UPLOAD_TIMEOUT`} 且本次是后台态(`bring_to_front=False`)时:
|
||||||
|
- `Page.bringToFront` 提前台;
|
||||||
|
- **不得完整重跑 `replace_cover()`**。`replace_cover()` 是破坏性流程,开头会删除当前第一张图;如果第一次已经删除旧封面,第二次完整重跑可能继续删除第二张原图或处理中的新图。
|
||||||
|
- 只能在同一 tab 内做安全恢复:
|
||||||
|
- `UPLOAD_STILL_PROCESSING` / `UPLOAD_TIMEOUT`:提前台后继续等待当前上传队列完成,重新读取 `_image_rects()` / `_upload_state()`,如果发现本次新上传图已取得 `susercontent` 地址,再进入拖到第一位校验;不得重新删除、不得重新选择本地文件。
|
||||||
|
- `DRAG_NOT_FIRST`:使用第一次 `replace_cover()` 返回的 `new_src`,提前台后重新读取当前图片列表,只对已存在的 `new_src` 再拖到第一位并校验;不得重新上传、不得重新删除。
|
||||||
|
- 如果缺少 `new_src` 或无法安全识别当前新图,直接按既有失败口径返回,保留现场,不做猜测性二次操作。
|
||||||
|
- 安全恢复只做一次;仍失败 → 按既有失败口径返回,不无限重试。
|
||||||
|
- 升级恢复要发 `on_step` 事件(如 `cover_retry_foreground`),run_logs 可见「后台上传/拖拽疑似受限,已提前台恢复一次」。
|
||||||
|
- 只对上述三个「疑似后台节流」的 reason 升级;其他失败(`OLD_COVER_BACKUP_MISSING`、`UPLOAD_DUPLICATE_IMAGE`、`UPLOAD_CROP_REQUIRED` 等)与前台与否无关,不升级、不重试。
|
||||||
|
- 升级重试前须重新读取页面图片状态(`_image_rects`/`_upload_state`),不能拿失败前的旧 rects 继续拖。
|
||||||
|
- 可在 `editor.py` 内抽小 helper,复用现有图片状态读取、拖拽和上传完成判断逻辑;但不要改变 `replace_cover()` 对外返回结构,除非仅补充安全恢复所需的非破坏性上下文字段(例如 `new_src`、上传后的 count/状态快照)。
|
||||||
|
- **`UPLOAD_*` 失败返回必须带上传前 src 快照(`before_srcs`)**:恢复时识别「本次新上传的图」的唯一可靠判据是 src 不在上传前集合里(`editor.py:982/1045`)。当前 `UPLOAD_STILL_PROCESSING`/`UPLOAD_TIMEOUT` 返回不含 `before_srcs` 且 `new_src` 为 None——不补此字段,恢复只能启发式猜新图,违反上面「不做猜测性二次操作」的原则。
|
||||||
|
- **恢复等待必须有明确时限**:提前台后的「继续等待上传完成」不得无界,给固定预算(≤ 首跑同款 `timeout=180s`,建议 120s),超时按原口径失败。否则并行模式下一个卡死上传会占住一个账号线程不放。
|
||||||
|
- **可选备注**:`NEW_IMAGE_NOT_FOUND`(`editor.py:1084`,上传已拿到 `new_src` 但随后 rects 中找不到)同样可能是后台重渲染/节流所致,且已带 `new_src`、恢复方式与 `DRAG_NOT_FIRST` 同款(重读 rects + 重拖)。是否纳入升级清单由实现者定;纳入则复用 `DRAG_NOT_FIRST` 的恢复路径与单测口径。
|
||||||
|
|
||||||
|
### 文档同步(必须一起改,防止被当 bug 改回)
|
||||||
|
|
||||||
|
- `docs/04-architecture.md` / `docs/routes.md` 里「③更新打开商品页保持前台」的表述更新为「每账号首任务提一次前台 + 失败升级」;注明 ①(T-562 完全不抢)与 ③(低频抢 + 升级)的不对称是有意设计及技术原因(遮挡节流)。
|
||||||
|
|
||||||
|
### 不做(本任务边界内明确排除)
|
||||||
|
|
||||||
|
- 不加 `--disable-backgrounding-occluded-windows` 等 Chrome 启动参数(典型自动化指纹,触碰「不绕过 Shopee 风控」红线的灰区,收益不值)。
|
||||||
|
- ⑤设置开关「更新时保持 Chrome 前台」本期不做,若后续有用户环境后台老失败再提任务。
|
||||||
|
|
||||||
|
## 验收要点
|
||||||
|
|
||||||
|
- 同一账号连续 N 条任务:仅第一条调用 `Page.bringToFront`(单测 mock `open_product`/CDP 断言调用次数=账号数,不是任务数)。
|
||||||
|
- 换账号 → 新账号第一条再提一次前台。
|
||||||
|
- 分批更新时,同一账号跨多个批次仍只在本轮第一次提一次前台;新一轮 worker 才重置。
|
||||||
|
- 多账号并行时,前台集合判断线程安全;同账号不会因并发/分批重复判为首次。
|
||||||
|
- 后台态封面替换返回 `DRAG_NOT_FIRST`/`UPLOAD_STILL_PROCESSING`/`UPLOAD_TIMEOUT` → 自动提前台安全恢复一次;恢复成功计成功;恢复失败按原失败口径。
|
||||||
|
- 后台安全恢复不得完整调用第二次 `replace_cover()`;不得第二次删除第一张图;不得第二次重新上传同一文件。单测需覆盖该安全边界。
|
||||||
|
- `DRAG_NOT_FIRST` 恢复只允许基于第一次返回的 `new_src` 重拖;`new_src` 缺失或页面找不到该图时失败并保留现场。
|
||||||
|
- `UPLOAD_STILL_PROCESSING` / `UPLOAD_TIMEOUT` 恢复只允许继续等待当前上传结果;无法确认新图时失败并保留现场。
|
||||||
|
- `UPLOAD_*` 失败返回含 `before_srcs`(上传前 src 快照),恢复用它识别新图(单测断言字段存在且被恢复逻辑消费)。
|
||||||
|
- 恢复等待有固定时限(≤180s),超时按原口径失败、不无限等待(单测用 mock 时钟或短时限覆盖)。
|
||||||
|
- 前台态(该账号首任务)失败 → 不重复升级(已在前台,无升级意义),按原口径。
|
||||||
|
- 其他失败 reason(重复图/需裁剪/备份缺失等)→ 不触发升级重试。
|
||||||
|
- 升级恢复有 run_logs 事件可见,文案为中文,不暴露技术栈细节给普通用户。
|
||||||
|
- `apply_task` 默认参数不变,直接调用的旧代码行为不回归。
|
||||||
|
- 既有 ③ 更新链路测试(`tests/test_gui.py`/`tests/test_editor_login.py` 相关)不回归。
|
||||||
|
- 验证命令(unittest,不引入 pytest):
|
||||||
|
- `py -3.10 -m unittest tests.test_gui tests.test_editor_login`
|
||||||
|
- `python -m ruff check app tests main.py`
|
||||||
|
- `py -3.10 -m compileall app main.py`
|
||||||
|
- `py -3.10 -m unittest discover -s tests`
|
||||||
|
- `git diff --check`
|
||||||
|
|
||||||
|
## 边界(不改什么)
|
||||||
|
|
||||||
|
- 不改①采集就绪/前台策略(T-562 保持)、④登录页 `activate_tab` 行为。
|
||||||
|
- 不改 `change_title`/`click_update` 的交互逻辑。
|
||||||
|
- 不把后台失败恢复实现为第二次完整 `replace_cover()`;不做可能二次删除线上图片的自动操作。
|
||||||
|
- 不改 Chrome 启动参数、CDP 选择器、DB schema、AI/cmhub 链路。
|
||||||
|
- 不做⑤设置开关。
|
||||||
|
|
||||||
|
## 执行记录
|
||||||
|
|
||||||
|
- 2026-07-09:完成 T-571。
|
||||||
|
- `app/gui/workers.py`:`ApplyWorker` 增加线程安全的本轮账号前台集合,同一账号本轮首条更新传 `bring_to_front=True`,后续同账号任务传 `False`;分批更新跨批次保持该集合,多账号并行下用锁保护。
|
||||||
|
- `app/editor.py`:`apply_task()` 增加 `bring_to_front` 参数并透传 `open_product()`;后台态封面替换遇到 `UPLOAD_STILL_PROCESSING` / `UPLOAD_TIMEOUT` / `DRAG_NOT_FIRST` / `NEW_IMAGE_NOT_FOUND` 时,只将当前 tab 提前台做一次非破坏性安全恢复,不完整重跑 `replace_cover()`。
|
||||||
|
- `app/editor.py`:`UPLOAD_*` 失败返回新增 `before_srcs` 上传前快照;恢复上传时用 `before_srcs` 识别本次新图,固定恢复等待预算 120 秒;拖拽恢复只基于已有 `new_src` 重读图片列表并重拖。
|
||||||
|
- `tests/test_editor_login.py`:覆盖 `before_srcs` 字段、后台拖拽失败前台重拖、后台上传处理中前台续等、缺少 `before_srcs` 时不猜测恢复且不点击更新。
|
||||||
|
- `tests/test_gui.py`:覆盖同账号跨分批只首条前台、不同账号并行各自首条前台,并更新 fake `apply_task` 签名兼容 `bring_to_front`。
|
||||||
|
- 文档:同步 `docs/04-architecture.md` 与 `docs/routes.md` 的③更新前台策略。
|
||||||
|
- 验证通过:`py -3.10 -m unittest tests.test_editor_login`、`py -3.10 -m unittest tests.test_gui`、`python -m ruff check app tests main.py`、`py -3.10 -m compileall app main.py`、`py -3.10 -m unittest discover -s tests`、`git diff --check`。
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
---
|
||||||
|
id: T-572
|
||||||
|
title: ②封面画廊打开后上下键切换记录不受按钮焦点影响
|
||||||
|
phase: 7
|
||||||
|
deps: [T-569]
|
||||||
|
status: DONE
|
||||||
|
created: 2026-07-09
|
||||||
|
---
|
||||||
|
|
||||||
|
## 问题 / 背景
|
||||||
|
|
||||||
|
T-569 给②AI生成的封面画廊 `CoverGalleryDialog` 增加了「上一条 / 下一条」按钮和 `↑ / ↓` 键切换当前可见任务记录。但真实使用中,用户双击②表格某条记录打开弹窗后,不做任何点击,直接按 `↓` / `↑`,无法切换到其他行的图片。
|
||||||
|
|
||||||
|
原因是弹窗打开后 Qt 可能把焦点默认放在某个 `QPushButton` 上,例如「上一条」「下一条」「重新生成」「保存」「取消」。当前 `_should_consume_navigation_key()` 明确把 `QPushButton` 排除:
|
||||||
|
|
||||||
|
```python
|
||||||
|
return not isinstance(focus, (QPushButton, QLineEdit, QPlainTextEdit, QComboBox, QSpinBox))
|
||||||
|
```
|
||||||
|
|
||||||
|
所以当焦点在按钮上时,`CoverGalleryDialog.keyPressEvent()` 不会调用 `switch_task()`,上下键被按钮焦点/默认事件链消费。T-569 单测只覆盖了候选 radio 获得焦点时的方向键行为,没有覆盖弹窗刚打开、焦点在按钮上的真实路径。
|
||||||
|
|
||||||
|
## 方案
|
||||||
|
|
||||||
|
- `app/gui/tabs/generate.py`
|
||||||
|
- 调整 `CoverGalleryDialog` 的方向键处理,让 `↑ / ↓` 在焦点位于按钮、图片、空白区域、radio 时都切换上一条/下一条。
|
||||||
|
- 仍保留输入类控件默认行为:焦点在 `QLineEdit`、`QPlainTextEdit`、`QComboBox`、`QSpinBox` 等输入/选择控件时,不消费上下键。
|
||||||
|
- 可选实现方式:
|
||||||
|
- 修改 `_should_consume_navigation_key()`,不再把 `QPushButton` 视为排除项;或
|
||||||
|
- 给弹窗安装 `eventFilter` / `QShortcut` 做窗口级快捷键,但必须避免输入类控件被抢键。
|
||||||
|
- 不改「上一条 / 下一条」按钮点击逻辑,不改未保存选择确认逻辑。
|
||||||
|
|
||||||
|
- `tests/test_gui.py`
|
||||||
|
- 增加回归测试:创建 `CoverGalleryDialog` 后把焦点放到 `next_button` 或 `save_button`,发送 `Qt.Key_Down`,应切到下一条。
|
||||||
|
- 增加保护测试:焦点在输入类控件时,`Qt.Key_Down` 不触发 `switch_task()`。
|
||||||
|
|
||||||
|
## 验收要点
|
||||||
|
|
||||||
|
- 双击②AI生成表格某条记录打开封面画廊后,不点击任何控件,直接按 `↓` 可以切换到下一条记录;按 `↑` 可以切回上一条记录。
|
||||||
|
- 焦点在「上一条 / 下一条 / 重新生成 / 保存 / 取消」按钮上时,`↑ / ↓` 仍切换记录。
|
||||||
|
- 焦点在候选 radio 上时,`↑ / ↓` 仍切换记录,且不会改变 radio 选中的候选封面。
|
||||||
|
- 焦点在输入/选择类控件上时,不抢占上下键,保留控件默认行为。
|
||||||
|
- 切换记录仍走 T-569 的未保存选择确认;不绕过「保存 / 不保存 / 取消」。
|
||||||
|
- 验证命令:
|
||||||
|
- `py -3.10 -m unittest tests.test_gui`
|
||||||
|
- `python -m ruff check app tests main.py`
|
||||||
|
- `py -3.10 -m compileall app main.py`
|
||||||
|
- `py -3.10 -m unittest discover -s tests`
|
||||||
|
- `git diff --check`
|
||||||
|
|
||||||
|
## 边界(不改什么)
|
||||||
|
|
||||||
|
- 不改 `CoverGalleryDialog.switch_task()` 的数据切换逻辑。
|
||||||
|
- 不改封面候选保存、重新生成、未保存选择确认。
|
||||||
|
- 不改 AI/cmhub、DB schema、Excel、③更新蝦皮或 CDP/Shopee 交互。
|
||||||
|
|
||||||
|
## 执行记录
|
||||||
|
|
||||||
|
- 2026-07-09:完成 T-572。
|
||||||
|
- `app/gui/tabs/generate.py`:调整 `CoverGalleryDialog._should_consume_navigation_key()`,不再把 `QPushButton` 作为方向键切换的排除焦点;同时给弹窗和子控件安装 `eventFilter`,处理真实 Qt 运行时按键先发给按钮/radio 等子控件、父弹窗 `keyPressEvent()` 收不到的路径。焦点在上一条/下一条/重新生成/保存/取消等按钮上时,`↑ / ↓` 会切换记录;焦点在输入类控件上仍不抢键。
|
||||||
|
- `tests/test_gui.py`:新增按钮焦点下通过真实子控件事件分发 `Qt.Key_Down` 可切到下一条的回归测试;新增输入框焦点下 `Qt.Key_Down` 不切换记录的保护测试;保留 radio 焦点不改变候选选择的既有测试。
|
||||||
|
- 验证通过:`py -3.10 -m unittest tests.test_gui`、`python -m ruff check app tests main.py`、`py -3.10 -m compileall app main.py`、`py -3.10 -m unittest discover -s tests`、`git diff --check`。
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
---
|
||||||
|
id: T-573
|
||||||
|
title: ②封面画廊候选图横向滚动内容宽度修复
|
||||||
|
phase: 7
|
||||||
|
deps: [T-567, T-569]
|
||||||
|
status: DONE
|
||||||
|
created: 2026-07-09
|
||||||
|
---
|
||||||
|
|
||||||
|
## 问题 / 背景
|
||||||
|
|
||||||
|
②AI生成模块中,双击任务行打开封面画廊后,如果某条任务有多张生成封面候选图,弹窗里可能出现第一张图显示不完整、被第二张图遮盖一半的现象。
|
||||||
|
|
||||||
|
当前 `CoverGalleryDialog._candidate_panel()` 使用 `QScrollArea` + `QHBoxLayout` 横向展示候选图:
|
||||||
|
|
||||||
|
```python
|
||||||
|
scroll = QScrollArea()
|
||||||
|
scroll.setWidgetResizable(False)
|
||||||
|
content = QWidget()
|
||||||
|
self.candidate_layout = QHBoxLayout(content)
|
||||||
|
...
|
||||||
|
scroll.setWidget(content)
|
||||||
|
```
|
||||||
|
|
||||||
|
每个候选图 item 固定宽度约 `THUMBNAIL_SIZE + 16`,候选之间还有 spacing。但外层 `content` 没有按候选图数量显式撑开宽度,也没有在初始化或 `_rebuild_candidate_items()` 后根据 `candidate_layout.sizeHint()` 更新 `content` 尺寸。`setWidgetResizable(False)` 时,`QScrollArea` 不会自动帮内部 content 扩到实际所需宽度,导致横向布局被压缩/裁剪,表现为候选图重叠或遮挡。
|
||||||
|
|
||||||
|
T-568/T-569 后封面画廊支持切换任务和重新生成,候选图列表会动态重建;如果重建后仍沿用旧 content 尺寸,更容易出现多图显示错位。
|
||||||
|
|
||||||
|
## 方案
|
||||||
|
|
||||||
|
- `app/gui/tabs/generate.py`
|
||||||
|
- 在 `CoverGalleryDialog` 中保存候选图滚动区和内容容器引用,例如 `self.candidate_scroll`、`self.candidate_content`。
|
||||||
|
- 抽出 `_sync_candidate_content_size()`:
|
||||||
|
- 根据 `self.candidate_layout.sizeHint()` 或候选数量计算内容最小宽度。
|
||||||
|
- `minimumWidth` 至少覆盖所有候选 item 宽度 + spacing + margins。
|
||||||
|
- `minimumHeight` 至少覆盖缩略图、radio、meta 文案所需高度。
|
||||||
|
- 调用 `adjustSize()` 或 `resize(sizeHint)`,确保横向滚动条按真实内容宽度工作。
|
||||||
|
- 在 `_candidate_panel()` 初始化候选后调用一次。
|
||||||
|
- 在 `_rebuild_candidate_items()` 重建候选后再次调用,覆盖任务切换和重新生成后的动态列表。
|
||||||
|
- 可保留 `scroll.setWidgetResizable(False)`,但必须显式设置 content 尺寸;或改为 `setWidgetResizable(True)` 并同时设置 content `minimumWidth`,避免 viewport 变窄时重新压缩候选图。
|
||||||
|
|
||||||
|
- `tests/test_gui.py`
|
||||||
|
- 增加封面画廊多候选图布局测试:准备 3 张以上候选图,断言 `candidate_content.minimumWidth()` / `sizeHint().width()` 大于等于所有候选 item 宽度和间距。
|
||||||
|
- 增加切换任务后的测试:从候选较少任务切到候选较多任务后,content 宽度重新同步,不沿用旧宽度。
|
||||||
|
|
||||||
|
## 验收要点
|
||||||
|
|
||||||
|
- 多张生成封面候选图在画廊中横向排列,不互相遮盖。
|
||||||
|
- 候选图数量超过可视宽度时出现横向滚动,滚动后每张图都能完整查看。
|
||||||
|
- 切换上一条/下一条任务后,候选区宽度按新任务候选数量重新计算。
|
||||||
|
- 单张候选图、无候选图、图片读取失败占位状态不回归。
|
||||||
|
- 不影响 T-572 的 `↑ / ↓` 记录切换和输入控件保护。
|
||||||
|
- 验证命令:
|
||||||
|
- `py -3.10 -m unittest tests.test_gui`
|
||||||
|
- `python -m ruff check app tests main.py`
|
||||||
|
- `py -3.10 -m compileall app main.py`
|
||||||
|
- `py -3.10 -m unittest discover -s tests`
|
||||||
|
- `git diff --check`
|
||||||
|
|
||||||
|
## 边界(不改什么)
|
||||||
|
|
||||||
|
- 不改候选图文件发现规则 `image_paths.list_task_cover_candidates()`。
|
||||||
|
- 不改封面保存、重新生成、未保存选择确认、上一条/下一条切换逻辑。
|
||||||
|
- 不改 AI/cmhub、DB schema、Excel、③更新蝦皮或 CDP/Shopee 交互。
|
||||||
|
|
||||||
|
## 执行记录
|
||||||
|
|
||||||
|
- 2026-07-09:已完成。
|
||||||
|
- `app/gui/tabs/generate.py`:`CoverGalleryDialog` 保存 `candidate_scroll` / `candidate_content` 引用,新增 `_sync_candidate_content_size()`;候选图初始化和 `_rebuild_candidate_items()` 后按候选数量、item 宽度、spacing 与 layout sizeHint 同步 content 最小尺寸和实际尺寸,保留横向滚动行为。
|
||||||
|
- `tests/test_gui.py`:新增多候选图内容宽度测试,以及从少候选任务切换到多候选任务后的宽度重新同步测试。
|
||||||
|
- 验证通过:
|
||||||
|
- `py -3.10 -m unittest tests.test_gui`
|
||||||
|
- `python -m ruff check app tests main.py`
|
||||||
|
- `py -3.10 -m compileall app main.py`
|
||||||
|
- `py -3.10 -m unittest discover -s tests`
|
||||||
|
- `git diff --check`
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
---
|
||||||
|
id: T-574
|
||||||
|
title: ②封面画廊保存后停留并提示已保存
|
||||||
|
phase: 7
|
||||||
|
deps: [T-573]
|
||||||
|
status: DONE
|
||||||
|
created: 2026-07-09
|
||||||
|
---
|
||||||
|
|
||||||
|
## 问题 / 背景
|
||||||
|
|
||||||
|
②AI生成模块中,双击任务行打开封面画廊后,用户可以在多张 AI 生成封面候选图之间切换。当前行为是:勾选其它候选图后点击「保存」,程序会把该图片写入任务的 `new_cover_path`,但弹窗立即退出。
|
||||||
|
|
||||||
|
这个行为在数据层是正确的,但从用户体验看反馈不够清楚:
|
||||||
|
|
||||||
|
- 用户无法在弹窗内立刻确认“刚才选择的图片已经保存为本地待更新封面”。
|
||||||
|
- 弹窗关闭后,用户如果还想继续比较其它候选图,需要重新打开。
|
||||||
|
- 「保存后关闭」容易让用户误解为已经更新到蝦皮线上商品主图。实际上这一步只更新本地任务的待更新封面,只有后续③「更新蝦皮」成功后,线上主图才会变化。
|
||||||
|
|
||||||
|
## 方案
|
||||||
|
|
||||||
|
- `app/gui/tabs/generate.py`
|
||||||
|
- 在 `CoverGalleryDialog` 底部按钮区,「保存」按钮左侧增加轻量状态提示 label。
|
||||||
|
- 用户点击「保存」后:
|
||||||
|
- 调用原有保存逻辑,继续写入 `db.update_generated_cover()`。
|
||||||
|
- 保存成功后不关闭弹窗。
|
||||||
|
- 提示 label 显示类似「已保存为本次更新封面」。
|
||||||
|
- 刷新当前候选图状态,让新保存的图片标记为当前生效封面。
|
||||||
|
- 保留未保存切换/关闭保护:如果用户选择了另一张但未保存,切换上一条/下一条或关闭时仍提示「保存 / 不保存 / 取消」。
|
||||||
|
- 底部原「取消」按钮建议改为「关闭」,因为保存后弹窗可继续停留,关闭按钮语义更准确。
|
||||||
|
|
||||||
|
## 文案原则
|
||||||
|
|
||||||
|
- 不使用「商品封面主图」作为保存成功提示,避免误导用户以为已经提交到蝦皮线上。
|
||||||
|
- 推荐提示:
|
||||||
|
- `已保存为本次更新封面`
|
||||||
|
- 如保存失败,继续沿用现有警告弹窗和状态保护。
|
||||||
|
|
||||||
|
## 验收要点
|
||||||
|
|
||||||
|
- 在封面画廊中勾选其它 AI 生成图片,点击「保存」后:
|
||||||
|
- DB 中当前任务 `new_cover_path` 更新为选中图片。
|
||||||
|
- 弹窗保持打开,不自动退出。
|
||||||
|
- 保存按钮左侧显示成功提示。
|
||||||
|
- 当前候选图状态刷新为「当前生效封面」。
|
||||||
|
- 点击「关闭」可以关闭弹窗。
|
||||||
|
- 如果选择了另一张但未保存,切换任务或关闭弹窗仍会触发未保存选择确认。
|
||||||
|
- 不影响 T-572 的 `↑ / ↓` 记录切换。
|
||||||
|
- 不影响 T-573 的候选图横向滚动布局。
|
||||||
|
- 验证命令:
|
||||||
|
- `py -3.10 -m unittest tests.test_gui`
|
||||||
|
- `python -m ruff check app tests main.py`
|
||||||
|
- `py -3.10 -m compileall app main.py`
|
||||||
|
- `py -3.10 -m unittest discover -s tests`
|
||||||
|
- `git diff --check`
|
||||||
|
|
||||||
|
## 边界(不改什么)
|
||||||
|
|
||||||
|
- 不改候选图文件发现规则。
|
||||||
|
- 不改重新生成封面流程。
|
||||||
|
- 不改 DB schema,只继续使用现有 `update_generated_cover()`。
|
||||||
|
- 不改 AI/cmhub、Excel、③更新蝦皮或 CDP/Shopee 交互。
|
||||||
|
|
||||||
|
## 执行记录
|
||||||
|
|
||||||
|
- 2026-07-09:已完成。
|
||||||
|
- `app/gui/tabs/generate.py`:封面画廊底部新增保存提示 label;「保存」后不再关闭弹窗,成功写入本地 `new_cover_path` 后显示「已保存为本次更新封面」,并重建候选项刷新「当前生效封面」标记;原「取消」按钮改为「关闭」,仍走原关闭/未保存保护逻辑。
|
||||||
|
- `tests/test_gui.py`:更新封面画廊保存测试,覆盖保存后弹窗不退出、DB 生效、提示文案显示、关闭按钮文案以及新候选项立即标记为当前生效。
|
||||||
|
- 验证通过:
|
||||||
|
- `py -3.10 -m unittest tests.test_gui`
|
||||||
|
- `python -m ruff check app tests main.py`
|
||||||
|
- `py -3.10 -m compileall app main.py`
|
||||||
|
- `py -3.10 -m unittest discover -s tests`
|
||||||
|
- `git diff --check`
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
---
|
||||||
|
id: T-575
|
||||||
|
title: ②AI生成图片失败红色进度段与完成汇总弹窗
|
||||||
|
phase: 7
|
||||||
|
deps: [T-574]
|
||||||
|
status: DONE
|
||||||
|
created: 2026-07-09
|
||||||
|
---
|
||||||
|
|
||||||
|
## 问题 / 背景
|
||||||
|
|
||||||
|
②AI生成模块中,图片生成进度条当前主要按成功数量推进。例如本轮需要生成 13 张图片,其中 11 张成功、2 张失败,结束后进度条会显示大部分绿色、剩余部分白色/空白。
|
||||||
|
|
||||||
|
从用户视角看,这个表现容易误解:
|
||||||
|
|
||||||
|
- 白色区域像是“还有任务没处理完”,但实际是已经处理完且失败。
|
||||||
|
- 失败任务没有在进度条上形成直观的红色反馈,用户需要看日志或失败数字才知道失败占比。
|
||||||
|
- 一批生成结束后,只有底部状态和日志,缺少一次清晰中文汇总提示。
|
||||||
|
|
||||||
|
## 方案
|
||||||
|
|
||||||
|
- `app/gui/tabs/generate.py`
|
||||||
|
- 为②AI生成页增加一个轻量分段进度条控件,至少支持:
|
||||||
|
- 绿色:成功数量。
|
||||||
|
- 红色:失败数量。
|
||||||
|
- 灰白/空白:未处理、停止后未执行或尚未完成数量。
|
||||||
|
- 图片进度条改用分段进度条。
|
||||||
|
- 统计口径:
|
||||||
|
- `cover_done` 表示图片成功数。
|
||||||
|
- `failed` 中包含本轮标题/图片失败总数;为了第一版范围可控,图片进度条的红色失败段先使用“本轮失败数中可归入图片处理的数量”或在 GUI 侧保守用 `min(failed, cover_total - cover_done)`。
|
||||||
|
- 完整结束时,已处理部分应显示为绿色 + 红色,剩余未执行才保留空白。
|
||||||
|
- 本轮生成 `finished` / `cancelled` 后弹一次中文汇总框:
|
||||||
|
- 全成功用信息提示。
|
||||||
|
- 有失败或停止用警告提示。
|
||||||
|
- 汇总标题和图片各自成功/总数、失败数、用时秒数。
|
||||||
|
- 弹窗只做汇总,不塞完整商品 ID 清单;失败明细继续看滚动日志。
|
||||||
|
|
||||||
|
## 文案建议
|
||||||
|
|
||||||
|
- 全成功:
|
||||||
|
- 标题:`AI生成完成`
|
||||||
|
- 正文:`本轮AI生成完成:标题 13/13,图片 13/13,失败 0,用时 126 秒。`
|
||||||
|
- 有失败:
|
||||||
|
- 标题:`AI生成完成,有失败任务`
|
||||||
|
- 正文:`本轮AI生成完成:标题 13/13,图片 11/13,失败 2,用时 492 秒。失败明细请查看下方运行日志,可稍后重新生成失败任务。`
|
||||||
|
- 停止:
|
||||||
|
- 标题:`AI生成已停止`
|
||||||
|
- 正文:`本轮AI生成已停止:标题 8/13,图片 5/13,失败 1,用时 180 秒。未完成任务可再次点击开始生成继续处理。`
|
||||||
|
|
||||||
|
## 验收要点
|
||||||
|
|
||||||
|
- 本轮图片 13 张、成功 11、失败 2 时,图片进度条结束后显示 11 份绿色 + 2 份红色,不再留下误导性的白色失败区域。
|
||||||
|
- 运行中未完成部分仍可显示为空白/灰白。
|
||||||
|
- 本轮生成完成后只弹一次中文汇总框。
|
||||||
|
- 全成功、部分失败、用户停止分别使用合适标题和提示级别。
|
||||||
|
- 滚动日志仍保留失败明细,弹窗不展示敏感信息、不展示完整接口路径或 token。
|
||||||
|
- 不影响标题进度条、用时秒数、cmhub余额显示和现有运行日志。
|
||||||
|
- 验证命令:
|
||||||
|
- `py -3.10 -m unittest tests.test_gui`
|
||||||
|
- `python -m ruff check app tests main.py`
|
||||||
|
- `py -3.10 -m compileall app main.py`
|
||||||
|
- `py -3.10 -m unittest discover -s tests`
|
||||||
|
- `git diff --check`
|
||||||
|
|
||||||
|
## 边界(不改什么)
|
||||||
|
|
||||||
|
- 不改 AI/cmhub 请求协议、并发、重试、下载保存逻辑。
|
||||||
|
- 不改 DB schema。
|
||||||
|
- 不改任务筛选、重置生成、封面画廊、Excel 或③更新蝦皮流程。
|
||||||
|
- 第一版不做逐商品失败弹窗,不做失败商品 ID 大列表。
|
||||||
|
|
||||||
|
## 执行记录
|
||||||
|
|
||||||
|
- 2026-07-09:已完成。
|
||||||
|
- `app/gui/tabs/generate.py`:新增 `_SegmentedProgressBar`,图片进度条保持 `QProgressBar` 兼容但改为绿色成功段 + 红色失败段 + 灰白未完成段;②图片进度按 `cover_done` 和保守失败数 `min(failed, cover_total - cover_done)` 显示红色失败区域;本轮完成、失败或停止后弹一次中文汇总框,弹窗只显示汇总,不暴露接口路径或底层异常细节。
|
||||||
|
- `tests/test_gui.py`:新增分段图片进度条测试、图片部分失败完成汇总弹窗测试、停止汇总弹窗测试。
|
||||||
|
- 验证通过:
|
||||||
|
- `py -3.10 -m unittest tests.test_gui`
|
||||||
|
- `python -m ruff check app tests main.py`
|
||||||
|
- `py -3.10 -m compileall app main.py`
|
||||||
|
- `py -3.10 -m unittest discover -s tests`
|
||||||
|
- `git diff --check`(通过;仅提示 `docs/tasks-board.md` 未来触碰时 CRLF 会转 LF,非空白错误)
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
---
|
||||||
|
id: T-576
|
||||||
|
title: ②③生成/更新内容模式下拉与缺失内容阻断
|
||||||
|
phase: 7
|
||||||
|
deps: [T-575]
|
||||||
|
status: DONE
|
||||||
|
created: 2026-07-09
|
||||||
|
---
|
||||||
|
|
||||||
|
## 问题 / 背景
|
||||||
|
|
||||||
|
当前②AI生成模块用「生成封面图片(成本较高)」checkbox 控制是否生成封面:
|
||||||
|
|
||||||
|
- 关闭:只生成标题。
|
||||||
|
- 开启:生成标题和封面。
|
||||||
|
|
||||||
|
这个设计能控制生图成本,但正式使用时不够精确:
|
||||||
|
|
||||||
|
- 用户无法只补生成封面;例如已生成标题后,想单独补封面,需要依赖当前增量逻辑和 checkbox 语义,不够直观。
|
||||||
|
- 「图片」文案不够准确,本模块实际处理的是商品封面,不是商品全部图片。
|
||||||
|
- ③更新蝦皮目前依赖⑤设置里的「允许更新封面」checkbox。这个开关离真实更新动作太远,用户在③点击开始更新时不一定清楚本轮到底更新标题、封面还是图文。
|
||||||
|
- 如果②只生成了标题,③却想更新封面,当前需要更明确地在开始前阻断并提示缺少新封面,避免进入 Chrome/CDP 后才失败。
|
||||||
|
|
||||||
|
## 产品结论
|
||||||
|
|
||||||
|
需求合理,建议做,但需要统一成“内容模式”而不是布尔开关。
|
||||||
|
|
||||||
|
②AI生成模块下拉:
|
||||||
|
|
||||||
|
- `只生成标题`
|
||||||
|
- `只生成封面`
|
||||||
|
- `生成标题和封面`
|
||||||
|
|
||||||
|
③更新蝦皮模块下拉:
|
||||||
|
|
||||||
|
- `只更新标题`
|
||||||
|
- `只更新封面`
|
||||||
|
- `更新标题和封面`
|
||||||
|
|
||||||
|
文案统一使用「封面」,不用「图片」,避免用户误解为商品详情图或所有商品图片。
|
||||||
|
|
||||||
|
## 方案
|
||||||
|
|
||||||
|
### 1. ②AI生成模式
|
||||||
|
|
||||||
|
- `app/appconfig.py`
|
||||||
|
- 增加兼容字段 `ai.generate_mode`,取值:
|
||||||
|
- `title`
|
||||||
|
- `cover`
|
||||||
|
- `title_cover`
|
||||||
|
- 兼容旧字段 `ai.generate_cover`:
|
||||||
|
- `false` → `generate_mode=title`
|
||||||
|
- `true` → `generate_mode=title_cover`
|
||||||
|
- 保存设置时可继续写回 `generate_cover` 兼容值,但 GUI 主逻辑以 `generate_mode` 为准。
|
||||||
|
|
||||||
|
- `app/ai.py`
|
||||||
|
- `is_generatable_task()`、`generation_component_totals()`、`generate_batch()` 支持三种生成模式。
|
||||||
|
- `title`:只补缺失标题,不调用生图。
|
||||||
|
- `cover`:只补缺失封面;需要当前任务已有 `new_title`,否则该任务不进入本轮,并在 GUI 给出可理解提示。
|
||||||
|
- `title_cover`:缺标题先生成标题,再生成封面;已有标题只补封面,已有封面只补标题或跳过。
|
||||||
|
|
||||||
|
- `app/gui/tabs/generate.py`
|
||||||
|
- 把「生成封面图片(成本较高)」checkbox 改成下拉框,例如 label「生成内容」。
|
||||||
|
- 下拉选项:
|
||||||
|
- `只生成标题`
|
||||||
|
- `只生成封面`
|
||||||
|
- `生成标题和封面`
|
||||||
|
- 开始生成时按选项筛选任务和计算标题/封面进度。
|
||||||
|
- 只生成封面但当前筛选任务都缺少新标题时,中止并中文提示:`当前筛选结果没有可生成封面的任务,请先生成标题。`
|
||||||
|
- 日志开头写明本轮生成内容,例如:`本轮生成内容:只生成封面`。
|
||||||
|
|
||||||
|
### 2. ③更新蝦皮模式
|
||||||
|
|
||||||
|
- `app/appconfig.py`
|
||||||
|
- 增加兼容字段 `shopee_update.update_mode`,取值:
|
||||||
|
- `title`
|
||||||
|
- `cover`
|
||||||
|
- `title_cover`
|
||||||
|
- 兼容旧字段 `shopee_update.allow_cover_update`:
|
||||||
|
- `false` → `update_mode=title`
|
||||||
|
- `true` → 默认 `update_mode=title_cover`,或保留用户当前选择。
|
||||||
|
- `allow_real_submit` 仍保留在⑤设置里,继续作为真实提交总开关。
|
||||||
|
|
||||||
|
- `app/gui/tabs/settings.py`
|
||||||
|
- 移除普通设置页里的「允许更新封面」用户控件。
|
||||||
|
- 保存设置时保留旧字段兼容,不破坏已有配置文件。
|
||||||
|
|
||||||
|
- `app/gui/tabs/apply.py`
|
||||||
|
- 在③更新蝦皮左下角增加「更新内容」下拉框。
|
||||||
|
- 下拉选项:
|
||||||
|
- `只更新标题`
|
||||||
|
- `只更新封面`
|
||||||
|
- `更新标题和封面`
|
||||||
|
- 原「检查本轮更新」「开始更新」「停止」等按钮向右挪,避免控件拥挤。
|
||||||
|
- 选择包含封面的模式时,确认弹窗中明确提示:`本轮将更新线上封面`。
|
||||||
|
- 选择「只更新标题」时,不要求 `new_cover_path`,也不触发封面更新。
|
||||||
|
- 选择「只更新封面」时,不要求标题改动,但必须有 `new_cover_path`。
|
||||||
|
- 选择「更新标题和封面」时,必须同时有 `new_title` 和 `new_cover_path`。
|
||||||
|
|
||||||
|
### 3. 开始更新前置校验
|
||||||
|
|
||||||
|
点击③「开始更新」后,在账号预检和 CDP 打开商品页之前,先按当前更新模式校验当前筛选结果:
|
||||||
|
|
||||||
|
- `只更新标题`:缺 `new_title` 的任务阻断。
|
||||||
|
- `只更新封面`:缺 `new_cover_path` 的任务阻断。
|
||||||
|
- `更新标题和封面`:缺任一内容的任务阻断。
|
||||||
|
|
||||||
|
阻断时不启动 ApplyWorker、不打开 Chrome tab、不改 DB 状态,弹中文提示,例如:
|
||||||
|
|
||||||
|
```text
|
||||||
|
当前筛选结果中有 3 条缺少新封面,不能执行“只更新封面”。
|
||||||
|
请先回到②AI生成选择“只生成封面”或“生成标题和封面”。
|
||||||
|
示例商品ID:25120403046、26887160467、28431952912
|
||||||
|
```
|
||||||
|
|
||||||
|
商品 ID 最多展示 5 个,避免弹窗过长。
|
||||||
|
|
||||||
|
「检查本轮更新」也应按当前更新模式展示缺失汇总,但检查模式可以只报告、不真实执行。
|
||||||
|
|
||||||
|
## 验收要点
|
||||||
|
|
||||||
|
- ②AI生成页不再显示「生成封面图片(成本较高)」checkbox,改为「生成内容」下拉。
|
||||||
|
- ②选择 `只生成标题` 时不调用生图、不要求旧封面/封面提示词。
|
||||||
|
- ②选择 `只生成封面` 时只补封面;缺新标题的任务不进入本轮,并给中文提示。
|
||||||
|
- ②选择 `生成标题和封面` 时保持原图文生成闭环,仍支持增量补缺。
|
||||||
|
- ⑤设置页普通 UI 不再显示「允许更新封面」checkbox。
|
||||||
|
- ③更新蝦皮左下角新增「更新内容」下拉,按钮布局不拥挤。
|
||||||
|
- ③选择 `只更新标题` 时允许任务无新封面。
|
||||||
|
- ③选择 `只更新封面` 时缺新封面必须在开始更新前阻断。
|
||||||
|
- ③选择 `更新标题和封面` 时缺新标题或新封面都必须阻断。
|
||||||
|
- 阻断发生在打开 Chrome 商品详情页之前,不写失败状态,不启动真实更新。
|
||||||
|
- 确认弹窗能按更新模式展示本轮将更新标题/封面/图文。
|
||||||
|
- 保留敏感和高风险边界:仍需要⑤ `allow_real_submit=true` 和③最终确认弹窗。
|
||||||
|
- 验证命令:
|
||||||
|
- `py -3.10 -m unittest tests.test_gui`
|
||||||
|
- `python -m ruff check app tests main.py`
|
||||||
|
- `py -3.10 -m compileall app main.py`
|
||||||
|
- `py -3.10 -m unittest discover -s tests`
|
||||||
|
- `git diff --check`
|
||||||
|
|
||||||
|
## 边界(不改什么)
|
||||||
|
|
||||||
|
- 不改 AI/cmhub HTTP 协议、并发、重试、下载保存逻辑。
|
||||||
|
- 不改 DB schema,继续使用 `new_title` / `new_cover_path` 判断内容是否就绪。
|
||||||
|
- 不改 Excel 导入/回写格式。
|
||||||
|
- 不改 CDP/Shopee 页面操作细节;只在进入真实更新前增加本地前置校验。
|
||||||
|
- 不增加逐条人工审核流程。
|
||||||
|
|
||||||
|
## 执行记录
|
||||||
|
|
||||||
|
- 2026-07-09 完成。
|
||||||
|
- 文档:同步 `docs/04-architecture.md`、`docs/02-requirements.md`、`docs/api.md`、`docs/routes.md`、`docs/current-state.md`,明确②「生成内容」下拉、③「更新内容」下拉、旧布尔字段兼容和开始更新前缺失内容阻断。
|
||||||
|
- 配置:`app/appconfig.py` 新增 `ai.generate_mode` / `shopee_update.update_mode` 归一与兼容 helper;旧 `ai.generate_cover`、`shopee_update.allow_cover_update` 继续写回兼容。
|
||||||
|
- ②AI生成:`app/gui/tabs/generate.py` 将封面 checkbox 改为「生成内容」下拉;`app/ai.py`、`GenerateWorker` 支持只生成标题、只生成封面、生成标题和封面,进度和日志按模式统计。
|
||||||
|
- ③更新蝦皮:`app/gui/tabs/apply.py` 新增「更新内容」下拉;开始更新/检查前按模式校验缺少的新标题或新封面,阻断时不启动 `ApplyWorker`、不打开 Chrome、不写失败状态;确认弹窗和运行日志展示更新内容。
|
||||||
|
- ⑤设置:`app/gui/tabs/settings.py` 从普通设置页移除「允许更新封面」用户入口,仅保留旧字段兼容写回;保存设置时尽量读取磁盘最新更新模式,避免覆盖③下拉选择。
|
||||||
|
- 更新执行:`app/editor.py`、`ApplyWorker` 按 `update_mode` 只传递本轮需要改的标题/封面,避免只更新标题时误换封面、只更新封面时误改标题。
|
||||||
|
- 测试:补充/更新 `tests/test_appconfig.py`、`tests/test_ai.py`、`tests/test_gui.py`,覆盖模式兼容、只生成封面、②/③下拉持久化、③缺失内容阻断和 worker 传参。
|
||||||
|
- 验证通过:`python -m compileall app main.py`;`python -m unittest discover -s tests`(313 tests);`git diff --check`。
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
---
|
||||||
|
id: T-577
|
||||||
|
title: ②封面画廊「重新生成」改为「重置图片」:删单任务重生链路,复用筛选+批量生成
|
||||||
|
phase: 7
|
||||||
|
deps: [T-566, T-568]
|
||||||
|
status: DONE
|
||||||
|
created: 2026-07-09
|
||||||
|
---
|
||||||
|
|
||||||
|
## 问题 / 背景
|
||||||
|
|
||||||
|
T-568 已实现弹窗内「重新生成」:确认弹窗 → 重置封面 → 单任务 `GenerateWorker` 就地重生,并为此引入批量/单任务互斥守卫(`_bulk_generate_running`/`_set_regenerate_running`/回调/tooltip,`app/gui/tabs/generate.py:613-650` 附近)。运营实际节奏是**审阅→批处理**:↑↓翻看几百条、把差图逐条标记,退出后一把批量重生;弹窗内阻塞等单图重生反而打断翻查。
|
||||||
|
|
||||||
|
新方案(已评审定案):弹窗按钮改为**「重置图片」**——只重置、不就地重生。重置后该行图片状态自动变「待生成」,运营翻完全部记录后退出弹窗,在②用状态筛选「待生成」+ 点「开始生成」批量补生成。
|
||||||
|
|
||||||
|
**整条后半链路已存在,零新建**(已核):
|
||||||
|
- 重置底层:②「重置生成结果」按钮同款 `db.reset_generated(reset_title=False, reset_cover=True)`(`generate.py:442`),T-566 归档旧图自动生效;
|
||||||
|
- 状态显示:无新封面 → 图片状态列「待生成」(`app/gui/models.py:383`);
|
||||||
|
- 筛选:状态下拉已有「待生成」(`generate.py:834`,`to_generate` → `ai.is_generatable_task`);
|
||||||
|
- 批量生成:`start_generate` 作用于当前筛选结果、只生成缺失内容(`generate.py:1477`)。
|
||||||
|
|
||||||
|
收益:删掉单任务重生这条第二生成路径及其互斥机械,系统回到**唯一生成路径**(批量),双 worker 抢 DB/并发池的风险类别永久消失;N 次「确认→串行单图重生」变一次「开始生成」(并发 5、一次点数确认)。重置本身不触碰蝦皮;若旧图文件存在且归档成功,可在画廊里选中归档图再「保存」恢复指针,因此普通行**不需要重确认弹窗**(T-568 的弹窗 C 一并删除)。
|
||||||
|
|
||||||
|
## 方案(改哪个文件、改成什么)
|
||||||
|
|
||||||
|
### `app/gui/tabs/generate.py` — 画廊弹窗按钮改造
|
||||||
|
|
||||||
|
- 「重新生成」按钮改名「重置图片」,点击后:
|
||||||
|
1. 复用 ② 重置的同一条底层路径(`reset_generated(task_id, reset_title=False, reset_cover=True)`),**不复制逻辑**;保留批量运行判断:批量生成运行中 → 拒绝并提示(同 `generate.py:1537`「AI 生成正在进行,不能重置」口径);归档改名 `PermissionError` → 中止并弹中文提示(T-566 口径),且 DB 指针不清空。
|
||||||
|
2. 成功后刷新②列表该行(图片状态变「待生成」)+ 重新枚举画廊(规范名已归档改名,列表变化)。
|
||||||
|
3. 左下状态 label 提示:「已重置图片,该行图片状态已改为待生成;请在②选择『只生成封面』或『生成标题和封面』后点击开始生成」。
|
||||||
|
4. **不弹确认弹窗**(重置可撤销:归档图仍在画廊,选中→保存即恢复);committed/applied 行例外,见下。
|
||||||
|
- **删除单任务重生链路**(T-568 引入的机械):`regenerate_thread`、单任务 `GenerateWorker` 启动、`_set_regenerate_running`/`_update_regenerate_button_state`/`regenerate_running_callback`、②「开始生成」对单任务重生的感知禁用、弹窗 C 确认、完成/失败刷新回调。批量运行检查不能删,只能改为通用重置守卫(例如保留 `bulk_running_checker` 或改名为 `_is_bulk_generate_running()`)。`rg regenerate` 核对无残留死代码。
|
||||||
|
|
||||||
|
### 重置后弹窗状态语义(防 T-569 拦截误触发)
|
||||||
|
|
||||||
|
- 重置后:画廊重新枚举、radio **无选中**、「保存」置灰直到用户另选一张。
|
||||||
|
- **T-569 未保存拦截**须把「重置后无选中 + DB 指针为空」视为**无待存变更**:↑↓/上一条/下一条/关闭都不弹三态确认。
|
||||||
|
- 用户重置后仍可选中某张归档图「保存」→ `update_generated_cover` 恢复指针(有意的撤销路径,写进代码注释与单测)。
|
||||||
|
|
||||||
|
### committed/applied 行重置
|
||||||
|
|
||||||
|
- 重置 → 重生 → 再更新 = 再次提交线上。对 `committed=1` 或 `stage='applied'` 的行,重置前弹既有口径警示(「本地重置不会回滚蝦皮,重新生成并更新会再次提交线上」,参考 `apply.py:334` / 保存路径同款);确认才执行。普通行仍免确认。
|
||||||
|
|
||||||
|
### 文档同步
|
||||||
|
|
||||||
|
- T-568 保持 DONE(历史),本任务在其之上改造;`docs/04-architecture.md`、`docs/02-requirements.md`、`docs/routes.md`、`docs/ui/README.md`、`docs/ui/tab2-ai-generate.svg` 等仍描述「单行重生成 / 弹窗内单任务重新生成」的位置更新为「重置图片 + 批量补生成」。历史任务文件无需回写。
|
||||||
|
|
||||||
|
## 验收要点
|
||||||
|
|
||||||
|
- 弹窗按钮文案为「重置图片」;点击后该行图片状态列显示「待生成」、画廊重新枚举(原规范名图出现在归档候选中)、label 提示出现。
|
||||||
|
- 状态筛选「待生成」能筛出被重置的行(生成模式含封面时);「开始生成」只补这些行的封面,已有封面的行不动。
|
||||||
|
- 重置后无选中状态下 ↑↓/切换/关闭**不触发**三态确认;另选归档图「保存」可恢复指针(撤销路径单测覆盖)。
|
||||||
|
- 批量生成运行中点「重置图片」→ 拒绝并提示,不改 DB。
|
||||||
|
- committed/applied 行重置前弹警示,取消则无副作用;普通行不弹确认。
|
||||||
|
- 归档改名失败(文件占用)→ 中止、DB 指针不清(T-566 口径不回归)。
|
||||||
|
- 单任务重生机械已删净:`rg "regenerate_thread|_set_regenerate_running|regenerate_running_callback|CoverRegenerateWorker"` 无残留;②「开始生成」不再有单任务重生感知逻辑。批量运行判断可保留,但命名不应再表达「重新生成」。
|
||||||
|
- 生成模式为「仅标题」时:重置行不出现在「待生成」筛选、图片状态列走未启用分支——界面不误导(label 提示已写明要选含封面模式)。
|
||||||
|
- 旧 `regenerate` GUI 单测要替换为重置语义单测:普通行重置不弹确认、不启动 `GenerateWorker`;committed/applied 行确认后才重置;批量生成中拒绝重置且 DB 不变;重置后无选中切换/关闭不触发未保存确认;选择归档图保存可恢复指针。
|
||||||
|
- 验证命令(unittest,不引入 pytest):
|
||||||
|
- `py -3.10 -m unittest tests.test_gui tests.test_db`
|
||||||
|
- `python -m ruff check app tests main.py`
|
||||||
|
- `py -3.10 -m compileall app main.py`
|
||||||
|
- `py -3.10 -m unittest discover -s tests`
|
||||||
|
- `git diff --check`
|
||||||
|
|
||||||
|
## 边界(不改什么)
|
||||||
|
|
||||||
|
- 不改 T-566 数据层契约(归档、`update_generated_cover`、枚举)。
|
||||||
|
- 不改批量 `GenerateWorker`/`generate_batch`/cmhub 链路、并发计划。
|
||||||
|
- 不改 ②「重置生成结果」按钮既有行为(弹窗按钮只是其单条封面模式的快捷入口)。
|
||||||
|
- 不改 T-567 画廊查看/保存、T-569 切换拦截本身(仅按上文调整重置态的拦截判定)。
|
||||||
|
- 不改 ③更新/Excel/CDP/Shopee。
|
||||||
|
|
||||||
|
## 执行记录
|
||||||
|
|
||||||
|
- 2026-07-09:已完成。
|
||||||
|
- 代码:
|
||||||
|
- `app/gui/tabs/generate.py`:封面画廊底部「重新生成」改为「重置图片」;点击后只调用 `db.reset_generated(task_id, reset_title=False, reset_cover=True)` 清空当前任务封面指针并重新枚举候选图,不再启动单任务 `GenerateWorker`。
|
||||||
|
- 删除弹窗单任务重生 worker、`CoverRegenerateWorker`、重生运行回调、②主界面对“画廊重生中”的互斥逻辑;保留批量生成运行判断,批量生成中拒绝「重置图片」。
|
||||||
|
- committed/applied 行重置前增加线上风险确认;普通行免确认。重置后无选中、保存置灰,切换/关闭不触发未保存确认;归档图仍可选中保存恢复指针。
|
||||||
|
- `tests/test_gui.py`:旧 `regenerate` 单测替换为重置语义单测,覆盖普通重置、已提交线上确认/取消、归档失败不清 DB、批量生成中拒绝、归档图恢复。
|
||||||
|
- 文档:
|
||||||
|
- 更新 `docs/04-architecture.md`、`docs/02-requirements.md`、`docs/routes.md`、`docs/ui/README.md`、`docs/ui/tab2-ai-generate.svg`,把“单行重生成”改为“重置图片 + 批量补生成”。
|
||||||
|
- 验证:
|
||||||
|
- `py -3.10 -m unittest tests.test_gui.GuiTests.test_cover_gallery_reset_image_clears_pointer_without_worker_or_confirmation ... test_cover_gallery_reset_image_disabled_while_bulk_generation_runs`:通过,6 tests。
|
||||||
|
- `py -3.10 -m unittest tests.test_gui tests.test_db`:通过,153 tests。
|
||||||
|
- `python -m ruff check app tests main.py`:通过。
|
||||||
|
- `py -3.10 -m compileall app main.py`:通过。
|
||||||
|
- `py -3.10 -m unittest discover -s tests`:通过,312 tests。
|
||||||
|
- `git diff --check`:通过,无输出。
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
---
|
||||||
|
id: T-578
|
||||||
|
title: ③更新失败时关闭本次新开的商品编辑页(app 建的 tab),只保留成功页由⑤开关决定
|
||||||
|
phase: 7
|
||||||
|
deps: [T-402, T-404b, T-501c, T-571]
|
||||||
|
status: DONE
|
||||||
|
created: 2026-07-09
|
||||||
|
---
|
||||||
|
|
||||||
|
## 问题 / 背景
|
||||||
|
|
||||||
|
③更新蝦皮时,`apply_task`(`app/editor.py:1414`)在对应账号 Chrome 里为商品新开编辑页;当拖图到第一位失败(`DRAG_NOT_FIRST`)、上传超时等**失败**时,页面不会自动关闭。
|
||||||
|
|
||||||
|
根因(已核):`finally` 里 `_close_applied_product(cdp, close_success_tab=close_success_tab and committed)`(`editor.py:1512`)。关闭 tab 需同时满足 `close_success_tab`(⑤设置「成功后关闭本次新开编辑页」,默认 False)**且** `committed`(点了「更新」才为 True)。失败时 `committed=False`,条件必然不成立 → `cdp.close()` 只断 WebSocket、tab 原样留着。这是「失败保全现场」的原设计。
|
||||||
|
|
||||||
|
用户决定:**失败时关闭本次由程序自动新建的商品编辑页**,理由——失败原因/上传状态已进 run_logs 与诊断日志(`_notify_apply_step` 失败详情、run 事件),关掉页面不丢诊断数据;而失败页上的改动(删了旧封面、传了新图但未排首位)都是**未提交**表单状态,关闭 = Shopee 编辑器丢弃这些草稿,**线上商品不受影响**(未点「更新」即未持久化),重试会重新开页从头跑 `replace_cover`。累积的失败 tab(重试还会复用、进而连成功后也关不掉)问题一并消除。
|
||||||
|
|
||||||
|
保留的边界:**只关 `created_by_app=True` 的 tab**;若复用用户已经打开的商品编辑页,只断开 CDP 连接,不关闭浏览器 tab。
|
||||||
|
|
||||||
|
## 方案(改哪个文件、改成什么)
|
||||||
|
|
||||||
|
### `app/editor.py` — `apply_task` finally + `_close_applied_product`
|
||||||
|
|
||||||
|
统一口径:**app 新开的 tab 在「失败」或「成功且⑤开关开」时关闭;复用的 tab 永不关。** 成功页默认保留(供人工复核,⑤开关控制),失败页直接关。
|
||||||
|
|
||||||
|
- 改 `finally` 的传参与 `_close_applied_product`,不要继续把 `close_success_tab and committed` 预先折叠成布尔值,避免失败状态在调用处丢失。建议签名与调用改为:
|
||||||
|
- `finally: _close_applied_product(cdp, committed=committed, close_success_tab=close_success_tab)`
|
||||||
|
- `def _close_applied_product(cdp, committed=False, close_success_tab=False): ...`
|
||||||
|
- `_close_applied_product` 内部关闭判定为:
|
||||||
|
- `created_by_app and target_id and ((not committed) or close_success_tab)`。
|
||||||
|
- 即:`committed=False`(含 `replace_cover` 失败、`change_title` 失败、`click_update` 未点成、抛异常各分支)→ 关;`committed=True` 且 `close_success_tab` → 关;`committed=True` 且开关关 → 保留(不变)。
|
||||||
|
- **关闭前的 `time.sleep(2)` 只用于成功且 `close_success_tab=True` 的路径**(让「更新成功」状态渲染后再关,是人工复核缓冲);失败关闭不需要该延时,避免每条失败多等 2 秒拖慢批量。
|
||||||
|
- 复用现有 `close_tab`(`app/cdp.py`),关闭异常继续 try/except 吞掉(沿用现状,关不掉不致命)。
|
||||||
|
- 早期打开就失败仍走 `_close_open_product_failure`(`editor.py:745`,本就关 app tab),与本改动口径一致,不动。
|
||||||
|
|
||||||
|
### 影响面确认(不新增 worker/配置)
|
||||||
|
|
||||||
|
- 调用方 `ApplyWorker`(`workers.py:939`)传参不变(`close_success_tab` 仍来自⑤设置,只影响成功路径);**不新增设置项**——失败关闭是无条件行为(受 `created_by_app` 守卫),符合「失败了直接关闭」。
|
||||||
|
- ⑤「成功后关闭本次新开编辑页」文案/语义不变(它本就只管成功页)。
|
||||||
|
|
||||||
|
### 文档同步
|
||||||
|
|
||||||
|
- `docs/04-architecture.md` 第七节 CDP 已验证事实里「③更新失败保留页面」的表述,更新为「③更新失败关闭 app 新开页(复用页不关)、成功页由⑤开关决定」。
|
||||||
|
- `docs/api.md` 里 `apply_task()` / ③更新流程关于“失败任务保留现场”的接口说明同步改为同一口径,避免 API 文档与实现冲突。
|
||||||
|
|
||||||
|
## 验收要点
|
||||||
|
|
||||||
|
- `replace_cover` 返回 `ok=False`(如 `DRAG_NOT_FIRST`/`UPLOAD_TIMEOUT`)且 `created_by_app=True` → `apply_task` 结束后 `close_tab` 被调用,且不调用 `time.sleep(2)`。
|
||||||
|
- `replace_cover` 返回 `ok=False` 且 `created_by_app=False` → 只 `cdp.close()` 断开连接,不调用 `close_tab`。
|
||||||
|
- `change_title` 失败、`click_update` 未点成、`apply_task` 抛异常各分支:`created_by_app=True` 时关闭 app 新开 tab,`created_by_app=False` 时不关闭浏览器 tab。
|
||||||
|
- 成功且 `close_success_tab=False`(默认)→ tab **保留**,不调用 `time.sleep(2)`(既有行为不回归)。
|
||||||
|
- 成功且 `close_success_tab=True` → 先 `time.sleep(2)`,再关闭 app 新开 tab。
|
||||||
|
- 复用的既有 tab(`created_by_app=False`)→ 失败/成功均不调用 `close_tab`。
|
||||||
|
- `open_product()` 内部打开/就绪失败仍由 `_close_open_product_failure` 负责清理本轮自动新建 tab;本任务不重复改这条早期失败路径。
|
||||||
|
- 失败原因/上传状态仍出现在 run_logs / 诊断日志(关闭不吞诊断)。
|
||||||
|
- 验证命令(unittest,不引入 pytest):
|
||||||
|
- `py -3.10 -m unittest tests.test_editor_login tests.test_gui`
|
||||||
|
- `python -m ruff check app tests main.py`
|
||||||
|
- `py -3.10 -m compileall app main.py`
|
||||||
|
- `py -3.10 -m unittest discover -s tests`
|
||||||
|
- `git diff --check`
|
||||||
|
|
||||||
|
## 边界(不改什么)
|
||||||
|
|
||||||
|
- 不关闭复用的用户自有页面(`created_by_app=False`)。
|
||||||
|
- 不改 ①采集关闭逻辑(`_close_collected_product`)、④登录页。
|
||||||
|
- 不新增⑤设置项;不改 `close_success_tab` 对成功路径的语义。
|
||||||
|
- 不改 `replace_cover`/`change_title`/`click_update` 交互本身、CDP 选择器、DB、AI/cmhub。
|
||||||
|
- 不改「失败不写回蝦皮」的既有安全事实(失败页本就未提交)。
|
||||||
|
|
||||||
|
## 执行记录
|
||||||
|
|
||||||
|
- 2026-07-09:已完成。
|
||||||
|
- 文档预提交:
|
||||||
|
- 先按评审建议更新任务定义并单独提交 `7c606e8 T-578 更新任务定义`:补依赖、明确只关闭程序自动新建页、实现时传完整 `committed`、同步 `docs/api.md`、细化验收分支。
|
||||||
|
- 代码:
|
||||||
|
- `app/editor.py`:`apply_task()` finally 改为 `_close_applied_product(cdp, committed=committed, close_success_tab=close_success_tab)`;`_close_applied_product()` 内部统一判断 tab 生命周期。
|
||||||
|
- 更新失败(`committed=False`,含标题失败、封面失败、更新按钮/确认失败、异常分支)且 `created_by_app=True` 时关闭浏览器 target;复用用户已有 tab 时只断开 CDP。
|
||||||
|
- 成功路径保持原语义:`close_success_tab=False` 时保留;`close_success_tab=True` 且 app 新建页时先等 2 秒再关闭。失败关闭不等待 2 秒。
|
||||||
|
- `tests/test_editor_login.py`:补/改成功默认保留、失败关闭、复用失败不关、封面失败关闭、异常关闭等单测。
|
||||||
|
- 文档:
|
||||||
|
- `docs/04-architecture.md`:更新 `close_success_tab`、③真实更新、CDP tab 生命周期表述。
|
||||||
|
- `docs/api.md`:同步 `apply_task()` 和 ③更新流程接口说明,移除“失败保留现场”的旧口径。
|
||||||
|
- 验证:
|
||||||
|
- `py -3.10 -m unittest tests.test_editor_login`:通过,45 tests。
|
||||||
|
- `py -3.10 -m unittest tests.test_editor_login tests.test_gui`:通过,184 tests。
|
||||||
|
- `python -m ruff check app tests main.py`:通过。
|
||||||
|
- `py -3.10 -m compileall app main.py`:通过。
|
||||||
|
- `py -3.10 -m unittest discover -s tests`:通过,316 tests。
|
||||||
|
- `git diff --check`:通过,无输出。
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
---
|
||||||
|
id: T-579
|
||||||
|
title: 打包版内置默认标题和封面提示词,首次运行自动写入用户 data
|
||||||
|
phase: 8
|
||||||
|
deps: [T-524, T-538, T-540]
|
||||||
|
status: DONE
|
||||||
|
created: 2026-07-10
|
||||||
|
---
|
||||||
|
|
||||||
|
## 问题 / 背景
|
||||||
|
|
||||||
|
当前 release 包不包含 `data/title_prompt.txt` 和 `data/prompts/cover/*.txt`。全新用户第一次打开软件时,②AI生成模块的标题提示词为空,封面模板下拉只显示空占位「默认」,用户必须先手工填写提示词才能使用。
|
||||||
|
|
||||||
|
用户希望把当前已验证使用的标题提示词和封面提示词模板随打包版本一起提供,让最终用户安装后无需手动填写即可直接生成标题和封面。
|
||||||
|
|
||||||
|
必须注意:`data/` 是用户数据目录,包含配置、数据库、账号登录态、图片、日志和可能的密钥,**不能整体打进 release 包**。本任务只能打包“默认提示词资源”,并在用户本地缺失提示词时初始化到 `data/`。
|
||||||
|
|
||||||
|
## 方案
|
||||||
|
|
||||||
|
### 1. 新增内置默认提示词资源
|
||||||
|
|
||||||
|
- 新增 `app/default_prompts/title_prompt.txt`:来自当前本机 `data/title_prompt.txt`,发布前已人工检查不包含 API Key、Cookie、账号密码等密钥类内容。
|
||||||
|
- 新增 `app/default_prompts/cover/papa1.txt`:来自当前本机 `data/prompts/cover/papa1.txt`,发布前已人工检查不包含 API Key、Cookie、账号密码等密钥类内容。
|
||||||
|
- 新增 `app/default_prompts/__init__.py`,使其作为应用资源包存在。
|
||||||
|
|
||||||
|
### 2. 首次运行自动初始化到用户 `data/`
|
||||||
|
|
||||||
|
- 在 `app/prompts.py` 增加默认提示词初始化函数,例如 `ensure_default_prompts(...)`。
|
||||||
|
- 初始化规则:
|
||||||
|
- 若用户 `data/title_prompt.txt` 不存在或为空,则写入内置默认标题提示词。
|
||||||
|
- 若用户 `data/prompts/cover/` 下没有任何 `.txt` 模板,则复制内置默认封面模板。
|
||||||
|
- 若用户已经有标题提示词或封面模板,**不得覆盖**。
|
||||||
|
- ②AI生成页初始化时,在读取提示词前调用该函数,确保首开界面能直接看到默认标题提示词和封面模板。
|
||||||
|
|
||||||
|
### 3. 打包脚本包含默认资源,但仍排除用户数据
|
||||||
|
|
||||||
|
- 修改 `cmshopee.spec`,把 `app/default_prompts/**/*.txt` 作为 PyInstaller data 放入 `_internal/app/default_prompts/...`。
|
||||||
|
- 保持 `scripts/build_exe.ps1` 的用户数据排除规则不变:`data/`、`title_prompt.txt`、`prompts/`、DB、配置、图片、日志、登录态仍不得出现在 release 根目录。
|
||||||
|
- 发布包允许包含 `_internal/app/default_prompts/...`,这是只读默认资源,不是用户数据。
|
||||||
|
|
||||||
|
## 验收要点
|
||||||
|
|
||||||
|
- 全新 data 目录下打开②AI生成页后:
|
||||||
|
- 自动生成 `data/title_prompt.txt`,内容等于内置默认标题提示词。
|
||||||
|
- 自动生成 `data/prompts/cover/papa1.txt`,封面模板下拉显示 `papa1` 且内容回显。
|
||||||
|
- 用户已有 `data/title_prompt.txt` 时不覆盖用户内容。
|
||||||
|
- 用户已有任意封面模板时不复制默认 `papa1`,不打乱用户模板。
|
||||||
|
- `cmshopee.spec` 会把 `app/default_prompts` 文本资源打进 PyInstaller 包。
|
||||||
|
- release 根目录和 zip 仍不包含用户数据:`data/`、根 `title_prompt.txt`、根 `prompts/`、DB、配置、图片、日志、登录态。
|
||||||
|
- 验证命令:
|
||||||
|
- `py -3.10 -m unittest tests.test_prompts tests.test_gui tests.test_packaging`
|
||||||
|
- `python -m ruff check app tests main.py`
|
||||||
|
- `py -3.10 -m compileall app main.py`
|
||||||
|
- `py -3.10 -m unittest discover -s tests`
|
||||||
|
- `git diff --check`
|
||||||
|
|
||||||
|
## 边界(不改什么)
|
||||||
|
|
||||||
|
- 不把整个 `data/` 打进 release 包。
|
||||||
|
- 不提交 `data/config.json`、`data/config/cmhub.json`、`data/cmshopee.db`、`data/images/`、`data/chrome_user_data_dir/`、`data/logs/`。
|
||||||
|
- 不改 AI/cmhub 请求协议、生成并发、DB schema、Excel、Shopee/CDP 更新流程。
|
||||||
|
- 不在升级时覆盖用户已经修改过的提示词。
|
||||||
|
|
||||||
|
## 执行记录
|
||||||
|
|
||||||
|
- 2026-07-10:完成 T-579。
|
||||||
|
- 新增 `app/default_prompts/` 默认提示词资源包,内置当前已验证使用的标题提示词和封面模板 `papa1`;发布前已扫描常见密钥关键词,未发现 API Key、Cookie、token、密码等密钥类内容。
|
||||||
|
- `app/prompts.py` 新增 `ensure_default_prompts()`:用户 `data/title_prompt.txt` 缺失或为空时写入默认标题提示词;用户 `data/prompts/cover/` 没有模板时写入默认封面模板;已有用户提示词或模板时不覆盖。
|
||||||
|
- `app/gui/tabs/generate.py` 在②AI生成页读取提示词前执行默认提示词初始化,保证全新用户首次打开即可看到默认标题提示词和 `papa1` 封面模板。
|
||||||
|
- `cmshopee.spec` 改为通过 `collect_data_files("app.default_prompts")` 收集内置默认提示词文本;仍不打包 release 根目录的 `data/`、`title_prompt.txt`、`prompts/`、DB、配置、图片、日志或登录态。
|
||||||
|
- 测试:`tests/test_prompts.py` 覆盖默认提示词播种和不覆盖用户提示词;`tests/test_gui.py` 覆盖②AI生成页全新用户自动回显默认提示词;`tests/test_packaging.py` 覆盖 PyInstaller spec 收集默认资源。
|
||||||
|
- 验证通过:`py -3.10 -m unittest tests.test_prompts tests.test_gui tests.test_packaging`、`python -m ruff check app tests main.py`、`py -3.10 -m compileall app main.py`、`py -3.10 -m unittest discover -s tests`(319 tests)、`git diff --check`。
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
---
|
||||||
|
id: T-580
|
||||||
|
title: ⑤设置精简:删真实提交闸/关页开关/jpg质量,合并并行为「同时更新蝦皮账号(≤5)」,改名Chrome就绪超时
|
||||||
|
phase: 7
|
||||||
|
deps: [T-571, T-578]
|
||||||
|
status: DONE
|
||||||
|
created: 2026-07-10
|
||||||
|
---
|
||||||
|
|
||||||
|
## 问题 / 背景
|
||||||
|
|
||||||
|
软件已打包给运营正式使用,⑤设置混装了开发期安全闸、部署项、能被数字蕴含的冗余布尔和运营用不到的技术旋钮,导致「很多设置不知道怎么用」。本任务按定案精简五处,但必须保留③「开始更新」确认弹窗作为线上提交的唯一显式确认边界。核过的关键事实:
|
||||||
|
|
||||||
|
- `allow_real_submit`(默认 False)是 dry-run 之外点「开始更新」时的第二道闸;`_update_safety_error`(`apply.py`)**只**检查它。
|
||||||
|
- `close_success_tab`(默认 False):T-578 后失败已必关,成功由它决定是否关。
|
||||||
|
- `parallel_accounts`(bool,默认F) + `max_parallel_accounts`(int,默认2,范围1-16):`workers.py:674` 判定 `parallel_accounts and max>1`。**`max` 在 bool 关时是沉睡默认值 2**——已上线用户刻意关并行的 config 是 `False+2`。
|
||||||
|
- `cdp_ready_timeout`(默认60)管的是 `chrome.wait_debug_ready(port,timeout)`(`accounts.py:286`)——Chrome 启动后等调试端口就绪的超时。
|
||||||
|
- `jpg_quality` 默认本就是 90(`appconfig.py:97`、`ai.py:200`),多处 `generation_cfg.get("jpg_quality")` 读取。
|
||||||
|
|
||||||
|
## 方案(改哪个文件、改成什么)
|
||||||
|
|
||||||
|
### 1. 删「允许真实提交线上商品」,默认允许提交
|
||||||
|
- `app/gui/tabs/settings.py`:删 `allow_real_submit_checkbox` 及保存/加载分支;连带删已隐藏的 `allow_cover_update_checkbox`(死控件)。
|
||||||
|
- `app/gui/tabs/apply.py`:删 `_update_safety_error`(删后恒 None,是死代码)、`_show_update_safety_error`、`_start_update` 里的安全拦截调用。真实提交守闸只保留「开始更新」确认弹窗(红线弹窗不变)。
|
||||||
|
- `app/appconfig.py`:从 DEFAULT_CONFIG 删 `allow_real_submit`、`allow_cover_update` key。
|
||||||
|
- `docs/04-architecture.md` / `docs/api.md`:把“安全开关阻断”改成“开始更新确认弹窗阻断”;明确「检查本轮更新」仍是 dry-run/预览,不提交线上。
|
||||||
|
- **不加**开发环境变量兜底(用户明确要纯删除)。
|
||||||
|
|
||||||
|
### 2. 删「成功后关闭本次新开编辑页」,成功也自动关页
|
||||||
|
- `app/gui/tabs/settings.py`:删 `close_success_tab_checkbox` 及保存/加载分支。
|
||||||
|
- `app/appconfig.py`:删 `close_success_tab` key;旧配置里出现该 key 时加载不报错,保存后不再写回。
|
||||||
|
- `app/editor.py`:`_close_applied_product` 简化为——**app 新建的 tab(`created_by_app=True`)成功失败都关;复用的用户页永不关**。成功关闭保留 `time.sleep(2)` 渲染缓冲;失败不等待(T-578 口径)。
|
||||||
|
- `app/gui/workers.py` `ApplyWorker` 不再从 config 读 `close_success_tab` 传参(`workers.py:939` 附近),改为固定成功关闭语义。
|
||||||
|
- 若为减少调用面破坏而临时保留 `apply_task(..., close_success_tab=...)` 参数,只能作为兼容参数存在,不得再由 GUI/config 控制行为;验收以 app 新建 tab 成功/失败都关闭为准。
|
||||||
|
|
||||||
|
### 3. 合并并行为单控件「同时更新蝦皮账号」,上限锁 5,**带强制迁移**
|
||||||
|
- `app/gui/tabs/settings.py`:删 `parallel_accounts_checkbox`;`max_parallel_accounts_spin` 的 `setRange(1, 16)` → `setRange(1, 5)`;label「最大并行账号数」→「同时更新蝦皮账号」;tooltip 注明「1=逐个更新,不并行」。
|
||||||
|
- `app/gui/workers.py:674`、`app/gui/tabs/apply.py:285,500`、`workers.py:595/636/1106/1128` 等**所有读 `parallel_accounts` 的分支改为 `max_parallel_accounts > 1` 单条件**(串行/并行日志同步)。
|
||||||
|
- `app/appconfig.py`:新默认 `max_parallel_accounts` 改为 **1**;加载/保存时都把 `max_parallel_accounts` 夹紧到 `1..5`,不能只靠 UI 限制。
|
||||||
|
- **强制迁移(必须做,否则事故)**:`app/appconfig.py` 加载/规整 config 时,若旧 config 含 `parallel_accounts`:`==False` → 强制把 `max_parallel_accounts` 置 **1**;`==True` → 保留旧 `max_parallel_accounts` 后夹紧到 `1..5`;处理后丢弃 `parallel_accounts` key。没有这步,已上线的串行用户(`False+2`)升级后会静默变 2 账号并行。
|
||||||
|
- 若旧/手工配置没有 `parallel_accounts`:缺失 `max_parallel_accounts` 时用新默认 1;已有 `max_parallel_accounts` 时只夹紧并保留,避免 T-580 后用户在 UI 设置成 2/3 后下一次启动又被重置。
|
||||||
|
- 保存配置后不再写 `parallel_accounts`;运行分支只认 `max_parallel_accounts > 1`。
|
||||||
|
|
||||||
|
### 4. 「CDP就绪超时(秒)」改名「Chrome 就绪超时(秒)」
|
||||||
|
- `app/gui/tabs/settings.py`:改 `cdp_ready_timeout` 对应 label 文案为「Chrome 就绪超时(秒)」(避免与 AI/cmhub section 已有的「连接超时(秒)」撞词)。
|
||||||
|
- 只改显示文案,不改 config key `cdp_ready_timeout`、不改 `wait_debug_ready` 语义。
|
||||||
|
|
||||||
|
### 5. 删「jpg质量」控件,锁定 90
|
||||||
|
- `app/gui/tabs/settings.py`:删 `jpg_quality_spin` 及保存/加载分支(从 `ai_form` 移除该行)。
|
||||||
|
- **保留读取逻辑**:`app/appconfig.py` DEFAULT_CONFIG `jpg_quality` 留 90,`ai.py` 各处 `generation_cfg.get("jpg_quality")`/默认 90 不动——只删 UI,不删数据默认;旧配置带 `jpg_quality` 时仍正常读取/保存,不向用户暴露控件。
|
||||||
|
|
||||||
|
### 文档同步
|
||||||
|
- `docs/04-architecture.md`(第七节 CDP tab 生命周期:成功也关 app 页)、`docs/api.md`(`apply_task`/③流程去掉 allow_real_submit 闸、成功关页)、⑤设置相关 UI 文档/svg;⑤ section「蝦皮更新安全 / 执行模式」删掉唯一"安全"项后更名为「蝦皮更新执行」之类(实现定名)。
|
||||||
|
- `docs/routes.md` / 相关 UI 线框图:⑤设置页删除开发期控件,③更新页仍展示最终确认与检查按钮语义。
|
||||||
|
|
||||||
|
## 实施顺序建议
|
||||||
|
|
||||||
|
1. 先改 `app/appconfig.py` 默认值、旧配置迁移、夹紧和保存去旧 key,并补 `tests/test_appconfig.py`。
|
||||||
|
2. 再改⑤设置页 UI 删除控件/改 label,并补 `tests/test_gui.py`。
|
||||||
|
3. 再改③更新页、`ApplyWorker`、`editor` tab 生命周期,并补 `tests/test_editor_login.py` / 相关 GUI worker 测试。
|
||||||
|
4. 最后同步 `docs/04-architecture.md`、`docs/api.md`、`docs/routes.md` 和必要 UI 图;不要把 release 打包或版本号更新混进本任务。
|
||||||
|
|
||||||
|
## 验收要点
|
||||||
|
|
||||||
|
- ⑤不再有「允许真实提交线上商品」「允许更新封面」「成功后关闭本次新开编辑页」「多账号并行更新」「jpg质量」控件。
|
||||||
|
- 点「开始更新」不再被安全开关拦截,直接进确认弹窗;`_update_safety_error` 相关死代码已删净(`rg allow_real_submit` 无残留)。
|
||||||
|
- 「检查本轮更新」仍只预览/写日志,不打开蝦皮、不提交线上、不改任务状态。
|
||||||
|
- 应用成功:app 新建 tab 关闭(含 2 秒渲染缓冲);应用失败:app 新建 tab 关闭(不等待,T-578 不回归);复用的用户 tab 成功/失败均不关。
|
||||||
|
- 「同时更新蝦皮账号」spin 范围 1–5;默认值 1;值 1 → 串行(`_run_parallel_by_account` 不触发)、≥2 → 并行到该上限。
|
||||||
|
- **迁移**:旧 config `parallel_accounts=False, max=2` 加载后 `max` 变 1(串行不回归);`parallel_accounts=True, max=3` → max 保留 3;`parallel_accounts=True, max=16` → max 夹紧为 5;加载/保存后 config 不再含 `parallel_accounts` key。单测覆盖上述旧值。
|
||||||
|
- `parallel_accounts` 读取点全部改为 `max>1`:`rg "parallel_accounts"` 仅剩迁移代码,无行为分支残留。
|
||||||
|
- Chrome 就绪超时 label 文案更新,功能(`wait_debug_ready` 超时)不变。
|
||||||
|
- jpg 质量删 UI 后,生成图仍按 90 质量保存(`ai.py` 读取默认 90 不回归)。
|
||||||
|
- 配置保存后不再写 `allow_real_submit`、`allow_cover_update`、`close_success_tab`、`parallel_accounts`;旧配置包含这些 key 时加载不报错。
|
||||||
|
- 验证命令(unittest,不引入 pytest):
|
||||||
|
- `py -3.10 -m unittest tests.test_gui tests.test_appconfig tests.test_editor_login tests.test_ai`
|
||||||
|
- `python -m ruff check app tests main.py`
|
||||||
|
- `py -3.10 -m compileall app main.py`
|
||||||
|
- `py -3.10 -m unittest discover -s tests`
|
||||||
|
- `git diff --check`
|
||||||
|
|
||||||
|
## 边界(不改什么)
|
||||||
|
|
||||||
|
- 不改③「开始更新」确认弹窗(红线,仍是真实提交唯一守闸)、「检查本轮更新」dry-run、其余③按钮。
|
||||||
|
- 不改 `wait_debug_ready`/CDP 就绪判断逻辑本身,只改 label。
|
||||||
|
- 不改 AI 生成图保存链路(`_save_jpeg` 仍按传入质量),只锁 UI 默认。
|
||||||
|
- 不改 ①采集/④登录/CDP 选择器/DB schema/cmhub 链路。
|
||||||
|
- 不加开发环境变量兜底(本任务纯删 `allow_real_submit`)。
|
||||||
|
- 不在本任务构建 release、不改 `APP_VERSION`、不处理当前工作区里与 T-580 无关的文件。
|
||||||
|
|
||||||
|
## 执行记录
|
||||||
|
|
||||||
|
- 2026-07-10:完成设置精简与执行语义调整。
|
||||||
|
- `app/appconfig.py`:`shopee_update` 默认删除真实提交、封面兼容、成功关页、并行 bool 等旧开关;新增 `max_parallel_accounts` 默认 1、范围 1..5;旧配置 `parallel_accounts=false` 迁移为 1,`true` 保留并夹紧;保存后不再写回旧开关。
|
||||||
|
- `app/gui/tabs/settings.py`:⑤设置页删除「允许真实提交线上商品」「允许更新封面」「成功后关闭本次新开编辑页」「多账号并行更新」「jpg质量」控件;「最大并行账号数」改为「同时更新蝦皮账号」,范围 1..5;「CDP就绪超时」改为「Chrome 就绪超时」;分区标题改为「蝦皮更新执行」。
|
||||||
|
- `app/gui/tabs/apply.py` / `app/gui/workers.py`:③「开始更新」不再被真实提交开关阻断,仍保留最终确认弹窗;按 `max_parallel_accounts > 1` 决定账号并行;worker 不再传成功关页开关。
|
||||||
|
- `app/editor.py`:程序自动新开的商品编辑页成功/失败都关闭;成功关闭前等待 2 秒;复用用户原本 tab 不关闭。`close_success_tab` 仅保留为旧调用兼容参数。
|
||||||
|
- `docs/04-architecture.md`、`docs/api.md`、`docs/routes.md` 同步当前配置 schema、③确认边界、tab 生命周期和⑤设置文案;更新 `tests/test_appconfig.py`、`tests/test_gui.py`、`tests/test_editor_login.py` 覆盖迁移、UI 删除、并行语义和成功关页。
|
||||||
|
- 验证:
|
||||||
|
- 当前工作区运行 `python -m ruff check app tests main.py`:通过。
|
||||||
|
- 当前工作区运行 `py -3.10 -m unittest tests.test_appconfig tests.test_editor_login tests.test_ai`:通过,108 tests OK。
|
||||||
|
- 当前工作区运行 T-580 相关 GUI 单测 10 项:通过。
|
||||||
|
- 当前工作区运行 `python -m compileall app main.py`:通过。
|
||||||
|
- 因当前工作区存在非 T-580 未归属默认封面提示词改名(`papa1.txt` 删除、`默认.txt` 未跟踪),直接跑 `py -3.10 -m unittest tests.test_gui ...` 会有 2 个 T-579 提示词名称断言失败。为隔离该非本任务影响,创建临时干净 worktree,仅应用 T-580 diff 后运行:
|
||||||
|
- `py -3.10 -m unittest tests.test_gui tests.test_appconfig tests.test_editor_login tests.test_ai`:通过,246 tests OK。
|
||||||
|
- `python -m ruff check app tests main.py`:通过。
|
||||||
|
- `py -3.10 -m compileall app main.py`:通过。
|
||||||
|
- `py -3.10 -m unittest discover -s tests`:通过,318 tests OK。
|
||||||
|
- `git diff --check`:通过。
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
---
|
||||||
|
id: T-581
|
||||||
|
title: ②AI生成增加「打开图片文件夹」按钮(级联:选中行账号图 / 批次 / 全部批次根)
|
||||||
|
phase: 7
|
||||||
|
deps: [T-566]
|
||||||
|
status: DONE
|
||||||
|
created: 2026-07-10
|
||||||
|
---
|
||||||
|
|
||||||
|
## 问题 / 背景
|
||||||
|
|
||||||
|
运营需要把某商品/某账号/整批的 AI 图片(旧封面、新封面、归档候选)用文件管理器打开,便于打包发给他人做文件级操作。当前②只能在画廊弹窗里看单商品候选(T-567),无法跳到文件夹做批量文件操作。
|
||||||
|
|
||||||
|
已核事实:
|
||||||
|
- 图片落盘层级 `<image_root>/<batch_id>/<slug>/{task_id}_{item_id}_{old|new|new_<ts>}.jpg`(`app/image_paths.py`)。slug 文件夹里直接是图片文件(含旧封面、新封面、归档候选);批次文件夹下一层是各账号 slug 子文件夹。
|
||||||
|
- `image_root = appconfig.image_dir(config)`(`app/appconfig.py:622`)。
|
||||||
|
- ②批次来源 = `batch_filter.currentData()`(`generate.py:1866`,None/"all" 即全部批次);行选中 = 表格 selectionModel。
|
||||||
|
- **仓库无任何"打开文件夹"实现**——净新增,需跨平台且 Linux 测试环境不崩(单测在 WSL 跑)。
|
||||||
|
|
||||||
|
## 方案(改哪个文件、改成什么)
|
||||||
|
|
||||||
|
### 级联打开规则(越具体开越深)
|
||||||
|
|
||||||
|
| 状态 | 打开目录 |
|
||||||
|
| --- | --- |
|
||||||
|
| 选中了某行 | 优先打开该行已有 `new_cover_path` / `old_cover_path` 的真实父目录;若没有已有图片路径,再回退到 `<image_root>/<batch_id>/<slug>/`(该行任务对应账号的图片,直达图片) |
|
||||||
|
| 未选行、批次筛选=具体批次 | `<image_root>/<batch_id>/`(整批,含各账号子文件夹,供整批打包) |
|
||||||
|
| 未选行、批次筛选=全部批次 | `<image_root>/`(所有批次根) |
|
||||||
|
|
||||||
|
- 选中多行时以表格当前焦点行(`currentIndex/currentRow`)为准;若当前行无效,再取 selectionModel 的第一条选中行。只打开一个目录,不同时打开多个目录。
|
||||||
|
- 选中行有历史图片路径时,优先打开真实存在图片文件的父目录,避免老数据或路径结构调整后“有图但打开空目录”。如果已有路径不存在或为空,再用规范路径回退。
|
||||||
|
- slug 目录路径复用 `image_paths.task_image_path(image_root, task, account, "new")` 取 `os.path.dirname(...)`,保证与新图片实际落盘位置一致;账号解析复用②既有 account 解析(`_account_for_task`/`account_by_alias` 同款)。
|
||||||
|
- 若选中行缺少账号 alias、或 alias 找不到账号,不能猜测 slug;直接中文提示「选中任务没有匹配账号,无法定位图片文件夹」,不打开、不创建目录。
|
||||||
|
- 未选行时只按批次筛选级联:具体批次打开 `<image_root>/<batch_id>/`,全部批次打开 `<image_root>/`,不需要账号解析。
|
||||||
|
|
||||||
|
### `app/gui/tabs/generate.py` — 按钮与布局
|
||||||
|
|
||||||
|
- 新增 `self.open_image_dir_button = QPushButton("打开图片文件夹")`,放在**筛选行**(`filter_layout`)紧挨 `refresh_button` 右侧(`generate.py:895` 后)。理由:级联依据(批次筛选、选中行)都在这一行,且它是只读导航动作,不与「开始生成/停止/重置生成结果」这类消耗点数/改数据的执行按钮混排。
|
||||||
|
- tooltip:「选中某行→打开该账号图片文件夹;未选中→打开当前批次文件夹;全部批次→打开图片根目录」。
|
||||||
|
- 点击 handler:按上表算出目标目录 → 若目录**不存在**:弹提示(如「该批次/账号还没有图片」或「图片根目录不存在,请先采集或生成图片」),**不自动创建目录**、不打开 → 存在则调用跨平台打开 helper。
|
||||||
|
|
||||||
|
### 跨平台打开 helper(可 mock)
|
||||||
|
|
||||||
|
- 新增小工具,优先放在独立模块 `app/gui/file_manager.py`,提供 `open_in_file_manager(path)`,便于单测 mock 且不把系统调用塞进通用控件模块。
|
||||||
|
- `open_in_file_manager(path)` 先把路径转为绝对路径并确认 `os.path.isdir(path)`;目录不存在时抛出可中文化处理的错误,不负责创建目录。
|
||||||
|
- Windows 用 `os.startfile(path)`;macOS 用 `subprocess.Popen(["open", path], stdout=DEVNULL, stderr=DEVNULL, shell=False)`;Linux/其它平台用 `subprocess.Popen(["xdg-open", path], stdout=DEVNULL, stderr=DEVNULL, shell=False)`。不拼 shell 字符串、不用 `shell=True`,避免路径空格/特殊字符问题;测试环境 mock 掉真实调用。
|
||||||
|
- handler 调用该 helper,单测通过 `mock.patch` 断言「用正确目标路径调用了 helper」,**不真正打开**(Linux CI/WSL 不炸)。
|
||||||
|
|
||||||
|
### 文档同步
|
||||||
|
|
||||||
|
- `docs/routes.md` / ②相关 UI 文档:登记「打开图片文件夹」按钮及级联规则。
|
||||||
|
|
||||||
|
## 验收要点
|
||||||
|
|
||||||
|
- 选中某行 → 若已有图片路径存在,优先用该图片父目录调用打开 helper;否则用 `<image_root>/<batch_id>/<slug>/`(= 该行 new 图 dirname)调用。
|
||||||
|
- 多选时以当前焦点行打开,不因 selectionModel 返回顺序导致打开到非当前行。
|
||||||
|
- 选中行账号无法匹配 → 中文提示,不调用打开 helper、不猜路径。
|
||||||
|
- 未选行 + 批次筛选具体批次 → 用 `<image_root>/<batch_id>/` 调用。
|
||||||
|
- 未选行 + 全部批次 → 用 `<image_root>/` 调用。
|
||||||
|
- 目标目录不存在 → 弹提示、**不调用打开 helper、不创建目录**。
|
||||||
|
- 打开 helper 被 mock:单测断言目标路径正确、平台分支正确;不真实执行 `os.startfile` / `open` / `xdg-open`。
|
||||||
|
- 按钮位于筛选行、不在生成/重置按钮排;tooltip 说明级联。
|
||||||
|
- 生成运行中按钮仍可用(只读动作,不必禁用)。
|
||||||
|
- 验证命令(unittest,不引入 pytest):
|
||||||
|
- `py -3.10 -m unittest tests.test_gui`
|
||||||
|
- `python -m ruff check app tests main.py`
|
||||||
|
- `py -3.10 -m compileall app main.py`
|
||||||
|
- `py -3.10 -m unittest discover -s tests`
|
||||||
|
- `git diff --check`
|
||||||
|
|
||||||
|
## 边界(不改什么)
|
||||||
|
|
||||||
|
- 不改图片落盘路径/层级、`image_paths` 生成逻辑、`image_dir` 配置。
|
||||||
|
- 不新建「打包/压缩」功能(只打开文件夹,压缩由用户在文件管理器做)。
|
||||||
|
- 不改画廊弹窗(T-567)、生成/重置链路、批次筛选逻辑本身。
|
||||||
|
- 不自动创建缺失目录、不删除任何文件。
|
||||||
|
- 不改 ③/①/CDP/DB/cmhub。
|
||||||
|
- 不把打开目录失败写成任务失败,不修改任务状态、批次状态或图片路径字段。
|
||||||
|
|
||||||
|
## 执行记录
|
||||||
|
|
||||||
|
- 2026-07-10:已实现②「打开图片文件夹」按钮,位于 AI生成筛选行刷新按钮右侧;按钮按选中行、具体批次、全部批次三级规则定位本地图片目录,运行中不禁用,不修改任何任务/批次状态。
|
||||||
|
- 2026-07-10:新增 `app/gui/file_manager.py`,封装跨平台打开目录逻辑;Windows 使用 `os.startfile`,macOS/Linux 使用 `subprocess.Popen([...], shell=False)`,helper 不创建目录。
|
||||||
|
- 2026-07-10:选中行优先打开已有 `new_cover_path` / `old_cover_path` 的真实父目录;没有真实图片文件时回退到 `image_paths.task_image_path(..., "new")` 的账号目录;账号无法匹配时中文提示,不猜 slug。
|
||||||
|
- 2026-07-10:同步 `docs/routes.md` ②AI生成说明;补 `tests/test_gui.py` 覆盖选中行当前焦点优先、规范账号目录回退、具体批次目录、图片根目录、目录缺失不创建、账号未匹配提示、平台打开 helper 分支。
|
||||||
|
- 验证通过:
|
||||||
|
- `py -3.10 -m unittest tests.test_gui.GuiTests.test_generate_tab_opens_current_row_image_directory tests.test_gui.GuiTests.test_generate_tab_open_image_directory_falls_back_to_canonical_account_dir tests.test_gui.GuiTests.test_generate_tab_opens_batch_or_root_directory_without_selected_row tests.test_gui.GuiTests.test_generate_tab_open_image_directory_warns_without_creating_missing_dir tests.test_gui.GuiTests.test_generate_tab_open_image_directory_warns_when_selected_account_unmatched tests.test_gui.GuiTests.test_open_in_file_manager_uses_platform_file_manager`
|
||||||
|
- `python -m ruff check app tests main.py`
|
||||||
|
- `py -3.10 -m compileall app main.py`
|
||||||
|
- `git diff --check`(仅输出当前工作区既有 CRLF 提示,无空白错误)
|
||||||
|
- 提交后在干净 worktree `D:\chengma\cmshopee_verify_t581` 验证本次 T-581 提交:`python -m ruff check app tests main.py` 通过,`py -3.10 -m compileall app main.py` 通过,`git diff --check` 通过,`py -3.10 -m unittest tests.test_gui` 通过 144 项,`py -3.10 -m unittest discover -s tests` 通过 324 项。
|
||||||
|
- 当前工作区直接运行 `py -3.10 -m unittest tests.test_gui` 失败 2 项,原因是本任务开始前已有未提交改动把默认封面提示词从 `papa1` 改成「默认」,导致两个旧断言仍期望 `papa1`;该提示词改名不属于 T-581,本次未处理、未提交。
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
---
|
||||||
|
id: T-582
|
||||||
|
title: 修复①导入 Excel 失败通知弹窗偶发只显示一半(延迟一拍弹窗)
|
||||||
|
phase: 7
|
||||||
|
deps: []
|
||||||
|
status: DONE
|
||||||
|
created: 2026-07-10
|
||||||
|
---
|
||||||
|
|
||||||
|
## 问题 / 背景
|
||||||
|
|
||||||
|
用户反馈:①采集导入 Excel 后的通知弹窗**偶发只显示一半、显示不全**。**界面本身不卡死**——导入通常很快、无可感知冻结,纯粹是弹窗画不全。「偶尔」也排除纯 HiDPI 缩放(那会稳定半截),更像绘制时序竞态。
|
||||||
|
|
||||||
|
已核代码事实:
|
||||||
|
- 当前 `CollectTab.import_excel()` **成功导入后没有完成弹窗**,只是在 `refresh_tasks()` 后调用 `_set_status("导入完成...")` 更新左下角状态栏。因此本任务不得新增“导入成功”弹窗,否则会变成新需求。
|
||||||
|
- 当前导入失败路径在 `except` 中写 run log / 诊断日志后直接调用 `_show_error(safe_error)`;`_show_error()` 立即执行 `QMessageBox.warning(self, "导入采集", str(message))`。
|
||||||
|
- 失败弹窗可能在同一调用栈内、界面刷新/状态更新还未完成时立即进入模态 `exec()`,首帧几何/内容没算稳 → 偶发只画一半。修法不是“把导入改慢/改 worker”,而是“让通知弹窗晚一拍,等当前事件循环先把待处理绘制冲刷完”。
|
||||||
|
|
||||||
|
范围收敛:本任务**只修①导入 Excel 失败通知弹窗**,不建统一弹窗 helper、不重构其它 `QMessageBox` 调用点、不改导入线程模型(导入 worker 化不在本任务)。
|
||||||
|
|
||||||
|
## 方案(改哪个文件、改成什么)
|
||||||
|
|
||||||
|
### 1. 让导入失败通知弹窗晚一拍弹(`app/gui/tabs/collect.py`)
|
||||||
|
- 把 `_show_error(message)` 改为:先同步更新状态栏 `_set_status(str(message), level="danger" 或现有口径)`;再用 `QTimer.singleShot(0, lambda: QMessageBox.warning(...))` 延迟显示导入失败通知。
|
||||||
|
- 这样导入失败路径原有 run log、diagnostic log、状态栏错误信息仍在当前调用栈完成;真正的模态弹窗等当前事件循环下一拍再打开,降低首帧绘制不完整概率。
|
||||||
|
- 只改 `_show_error()` 这一处导入失败通知;不改 `import_excel()` 成功路径,不新增导入成功弹窗。
|
||||||
|
- 不改 `_show_account_guide()`:它属于采集前账号就绪引导,不是导入 Excel 完成链路;本任务不外扩到采集链路。
|
||||||
|
- 不改 `QMessageBox.question` / 删除批次确认等需要立即返回用户选择的确认类弹窗。
|
||||||
|
|
||||||
|
### 2. 本任务不做 HiDPI 全局设置
|
||||||
|
- 先不在 `app/gui/__init__.py` 增加 `QGuiApplication.setHighDpiScaleFactorRoundingPolicy(...)`。该设置影响全局窗口缩放,且当前问题是“偶发”,优先按事件循环时序修复。
|
||||||
|
- 如果后续能稳定复现 HiDPI 缩放导致的弹窗裁切,再另立任务处理全局缩放策略。
|
||||||
|
|
||||||
|
## 验收要点
|
||||||
|
- `excel.import_tasks()` 抛异常时,导入失败通知经 `QTimer.singleShot(0, ...)` 延迟显示;单测 mock `QTimer.singleShot` 断言被调用,不真正弹窗。
|
||||||
|
- `excel.import_tasks()` 抛异常时,不再在同一调用栈直接调用 `QMessageBox.warning`;可通过 mock `QMessageBox.warning` 断言在执行 `singleShot` 回调前未被直接调用。
|
||||||
|
- 导入失败既有行为不回归:run log 标记 failed、诊断日志写入、状态栏显示脱敏错误文案、`_show_error` 的窗口标题/正文仍为中文导入失败提示。
|
||||||
|
- 导入成功路径不新增 `QMessageBox`;仍然刷新列表、更新 summary、写状态栏“导入完成...”。
|
||||||
|
- 未改动导入链路以外的弹窗调用点(`git diff` 范围仅 `app/gui/tabs/collect.py` + 对应 `tests/test_gui.py`)。
|
||||||
|
- 验证命令(unittest,不引入 pytest;GUI 测试用 offscreen):
|
||||||
|
- `py -3.10 -m unittest tests.test_gui`
|
||||||
|
- `python -m ruff check app tests main.py`
|
||||||
|
- `py -3.10 -m compileall app main.py`
|
||||||
|
- `py -3.10 -m unittest discover -s tests`
|
||||||
|
- `git diff --check`
|
||||||
|
|
||||||
|
## 边界(不改什么)
|
||||||
|
- 不建统一弹窗 helper、不收敛/替换其它模块的 `QMessageBox` 调用。
|
||||||
|
- 不把①导入改成 worker 线程(如后续要治界面卡死另立任务/backlog)。
|
||||||
|
- 不新增导入成功弹窗;成功导入仍只用状态栏提示。
|
||||||
|
- 不改 `_show_account_guide()` 账号未就绪弹窗;它属于采集链路,不属于导入 Excel 链路。
|
||||||
|
- 不做 HiDPI 全局缩放策略调整。
|
||||||
|
- 不改弹窗文案/业务语义、不改 `excel.import_tasks` 解析入库逻辑。
|
||||||
|
- 不改 CDP/DB/AI/cmhub、主窗口尺寸逻辑。
|
||||||
|
|
||||||
|
## 执行记录
|
||||||
|
|
||||||
|
- 2026-07-10:已实现 `_show_error()` 延迟弹出导入失败通知:状态栏仍同步显示错误,`QMessageBox.warning(self, "导入采集", text)` 改为经 `QTimer.singleShot(0, ...)` 下一拍执行,降低刷新/绘制同栈内立即弹模态框导致首帧显示不完整的概率。
|
||||||
|
- 2026-07-10:未改导入成功路径;成功导入仍只刷新列表、更新 summary、状态栏显示“导入完成...”,不新增成功弹窗。未改 `_show_account_guide()`、删除批次确认框、HiDPI 全局设置、导入解析入库逻辑。
|
||||||
|
- 2026-07-10:补 `tests/test_gui.py`:成功导入断言不会调用 `QTimer.singleShot` 和弹窗;导入异常时断言 `QTimer.singleShot(0, callback)` 被调用、回调执行前不直接弹窗、run log/脱敏错误/弹窗标题正文不回归。
|
||||||
|
- 当前工作区验证通过:
|
||||||
|
- `py -3.10 -m unittest tests.test_gui.GuiTests.test_collect_tab_import_button_imports_excel_and_refreshes_tasks tests.test_gui.GuiTests.test_collect_tab_import_error_dialog_is_delayed_one_event_loop tests.test_gui.GuiTests.test_collect_tab_import_excel_writes_diagnostic_run_log`
|
||||||
|
- `python -m ruff check app tests main.py`
|
||||||
|
- `py -3.10 -m compileall app main.py`
|
||||||
|
- `git diff --check`(仅输出当前工作区既有 CRLF 提示,无空白错误)
|
||||||
|
- 当前工作区直接运行 `py -3.10 -m unittest tests.test_gui` 失败 2 项,原因是本任务开始前已有未提交改动把默认封面提示词从 `papa1` 改成「默认」,导致两个旧断言仍期望 `papa1`;该提示词改名不属于 T-582,本次未处理、未提交。提交后需用干净 worktree 验证本次提交本身。
|
||||||
|
- 提交后在干净 worktree `D:\chengma\cmshopee_verify_t582` 验证本次 T-582 提交:`python -m ruff check app tests main.py` 通过,`py -3.10 -m compileall app main.py` 通过,`git diff --check` 通过,`py -3.10 -m unittest tests.test_gui` 通过 145 项,`py -3.10 -m unittest discover -s tests` 通过 325 项。
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
---
|
||||||
|
id: T-583
|
||||||
|
title: 标题提示词模板数据层:泛化 prompts 模板 CRUD + 新增 prompts/title 目录(加法,不换单文件)
|
||||||
|
phase: 7
|
||||||
|
deps: []
|
||||||
|
status: DONE
|
||||||
|
created: 2026-07-10
|
||||||
|
---
|
||||||
|
|
||||||
|
## 问题 / 背景
|
||||||
|
|
||||||
|
②标题提示词现为**单文件** `title_prompt.txt`(一条,`prompts.load/save_title_prompt`),无模板管理;封面提示词已是**目录多模板**(`cover_prompts_dir` + 一套 CRUD)。运营希望标题也能像封面一样存/取多个命名模板。
|
||||||
|
|
||||||
|
已核关键事实(决定改动范围):
|
||||||
|
- 生成时 `generate_batch(tasks, prompts, ...)` 的 `prompts["title"]/["cover"]` **取编辑框当前文本**(`generate.py:1219`/`1281`),**不读文件/模板**。模板只是"把某段文本载入编辑框"的持久化层——**本任务不碰 `generate_batch`、不碰生成读取路径**。
|
||||||
|
- `prompts.py` 现有 `list/load/save/rename/delete_cover_template` 五个写死 cover 的函数 + `render_prompt`(共享)+ `ensure_default_prompts`(播种默认封面模板)。
|
||||||
|
|
||||||
|
定案(已评审):
|
||||||
|
1. **加法、不换单文件**:`title_prompt.txt` **保留为"当前工作文本"**(语义不变、不迁移、编辑框仍从它载入),**另加** `data/prompts/title/` 目录放命名模板。纯新增能力,零迁移、零丢失风险。
|
||||||
|
2. **泛化 CRUD**:把 cover 那五个函数泛化成按目录参数化的通用模板 CRUD,标题/封面共用一套,不复制第二份(防两套分叉)。
|
||||||
|
3. **标题默认模板**:拿现有默认标题提示词播一条进 `data/prompts/title/`(不新建 `default_prompts/title` 包)。
|
||||||
|
|
||||||
|
本任务只做数据层,无 UI(UI reorg 见 T-584)。
|
||||||
|
|
||||||
|
## 方案(改哪个文件、改成什么)
|
||||||
|
|
||||||
|
### `app/prompts.py` — 泛化模板 CRUD
|
||||||
|
- 抽出目录参数化的通用函数(命名实现定,如):`list_templates(directory)`、`load_template(name, directory)`、`save_template(name, text, directory)`、`rename_template(old, new, directory)`、`delete_template(name, directory)`,含现有 `_normalize_name`/`_template_path`/重名保护。
|
||||||
|
- 现有 `*_cover_template` 保留为**薄封装**(转调通用函数,传 `COVER_PROMPTS_DIR`),保证封面既有调用点零改动、零回归。
|
||||||
|
- 新增标题模板薄封装或直接让 GUI 传 `title_templates` 目录调用通用函数(择一,UI 任务据此对接)。
|
||||||
|
- 通用函数的错误文案使用中性中文,例如「提示词模板名不能为空 / 提示词模板不存在 / 提示词模板已存在」,不要在标题模板路径上暴露“封面提示词模板”。
|
||||||
|
|
||||||
|
### `app/appconfig.py` — 目录路径
|
||||||
|
- 新增 `title_templates_dir(config=None)` → `data_path("prompts", "title", config=config)`,与现有 `cover_prompts_dir(config=None)` 的 `data/prompts/cover/` 形成同层目录。
|
||||||
|
- 不把标题模板放到 `data/title_templates/`,避免提示词存储分散。
|
||||||
|
|
||||||
|
### `app/prompts.py` `ensure_default_prompts` — 播种标题默认模板
|
||||||
|
- 现有逻辑保留(`title_prompt.txt` 缺失时写默认、封面模板缺失时播默认)。
|
||||||
|
- 新增第三参数 `title_templates_dir=None`:若为空时播入一条默认标题模板(内容 = `_read_default_prompt_text("title_prompt.txt")`,模板名如「默认」)。不新建 `app/default_prompts/title` 包。
|
||||||
|
- 调用方显式传入 `title_templates_dir` 时按该目录播种;未传时必须从 `title_prompt_path` 推导同一个用户数据根下的 `prompts/title/`,避免测试或临时配置只传 `title_prompt_path` / `cover_prompts_dir` 时误写真实默认 `data/`。
|
||||||
|
- 已有标题模板时不覆盖;`title_prompt.txt` 为空时仍按旧逻辑写默认工作文本。
|
||||||
|
|
||||||
|
### 数据/边界
|
||||||
|
- `title_prompt.txt` 语义**不变**:仍是编辑框当前工作文本的持久化,`load/save_title_prompt` 不动。
|
||||||
|
- 标题模板与封面模板是**两个独立目录**:`data/prompts/title/*.txt` 与 `data/prompts/cover/*.txt`,占位符(标题 `{旧标题}` 等 vs 封面 `{新标题}` 等)各自语义不变;`render_prompt` 共享不改。
|
||||||
|
|
||||||
|
### 文档同步
|
||||||
|
- 更新 `docs/04-architecture.md` 提示词存储说明:`data/title_prompt.txt` 是当前工作标题提示词;`data/prompts/title/*.txt` 是标题模板;`data/prompts/cover/*.txt` 是封面模板。
|
||||||
|
- 若 `docs/routes.md` / 设置或打包文档已有提示词目录描述,同步保持 `prompts/title` / `prompts/cover` 口径。
|
||||||
|
|
||||||
|
## 验收要点
|
||||||
|
- 通用模板 CRUD 存在且被 cover 薄封装复用;`list/load/save/rename/delete_cover_template` 行为不回归(既有封面模板测试全绿)。
|
||||||
|
- 新增 `title_templates_dir` 指向 `data/prompts/title`;`list_templates` 能对该目录增删查、重名保护生效。
|
||||||
|
- `ensure_default_prompts`:`prompts/title/` 为空时播入一条默认标题模板(内容 = 现有默认标题提示词);已有模板时不覆盖;传临时 `title_prompt_path` 时不会误写真实默认 `data/`。
|
||||||
|
- `title_prompt.txt` 及 `load/save_title_prompt` 行为不变(编辑框工作文本不受影响,无迁移、无丢失)。
|
||||||
|
- 标题/封面模板互不干扰(各自目录)。
|
||||||
|
- `docs/04-architecture.md` 已同步提示词存储结构。
|
||||||
|
- 不改 `generate_batch`/生成读取路径/`render_prompt`。
|
||||||
|
- 验证命令(unittest,不引入 pytest):
|
||||||
|
- `py -3.10 -m unittest tests.test_prompts tests.test_appconfig`(若无 test_prompts 则新建)
|
||||||
|
- `python -m ruff check app tests main.py`
|
||||||
|
- `py -3.10 -m compileall app main.py`
|
||||||
|
- `py -3.10 -m unittest discover -s tests`
|
||||||
|
- `git diff --check`
|
||||||
|
|
||||||
|
## 边界(不改什么)
|
||||||
|
- 不动 `generate_batch`、生成时的 prompt 读取(仍取编辑框文本)。
|
||||||
|
- 不改 `title_prompt.txt` 语义、不迁移/删除该文件。
|
||||||
|
- 不复制第二份模板 CRUD;封面走薄封装复用通用实现。
|
||||||
|
- 不做 GUI(T-584);不新建 `default_prompts/title` 包。
|
||||||
|
- 不把标题模板放到 `data/title_templates/`;统一使用 `data/prompts/title/`。
|
||||||
|
- 不改 CDP/DB/AI 请求/cmhub/Excel。
|
||||||
|
|
||||||
|
## 执行记录
|
||||||
|
|
||||||
|
- 2026-07-10:已完成数据层实现。`app/appconfig.py` 新增 `title_templates_dir(config=None)`,指向 `data/prompts/title`;`app/prompts.py` 新增通用 `list/load/save/rename/delete_templates` 能力,标题/封面模板复用同一套 CRUD,保留封面旧 API 薄封装,并新增标题模板薄封装。
|
||||||
|
- 2026-07-10:`ensure_default_prompts()` 增加 `title_templates_dir=None`,为空时从 `title_prompt_path` 推导同一数据根的 `prompts/title/`,为空目录时播入「默认」标题模板;不改 `title_prompt.txt` 工作文本语义,不改生成读取路径。
|
||||||
|
- 2026-07-10:同步 `docs/04-architecture.md` 提示词存储结构;补 `tests/test_prompts.py` 和 `tests/test_appconfig.py` 覆盖标题模板目录、通用 CRUD、默认播种、互不干扰与封面旧 API 回归。
|
||||||
|
- 当前工作区验证:`python -m ruff check app tests main.py` 通过;`py -3.10 -m compileall app main.py` 通过;`git diff --check` 通过。
|
||||||
|
- 当前工作区运行 `py -3.10 -m unittest tests.test_prompts tests.test_appconfig` 失败 1 项,原因是本任务开始前已有未提交改动把默认封面模板从 `papa1` 改成「默认」,导致旧断言仍期望 `papa1`;该封面默认模板改名不属于 T-583,本次未处理、未提交。提交后需用干净 worktree 验证本次提交本身。
|
||||||
|
- 提交后在干净 worktree `D:\chengma\cmshopee_verify_t583` 验证本次 T-583 提交:`py -3.10 -m unittest tests.test_prompts tests.test_appconfig` 通过 24 项,`python -m ruff check app tests main.py` 通过,`py -3.10 -m compileall app main.py` 通过,`git diff --check` 通过,`py -3.10 -m unittest discover -s tests` 通过 327 项。
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
---
|
||||||
|
id: T-584
|
||||||
|
title: ②左侧提示词区 reorg:标题提示词加模板控件行 + 封面控件压一行 + 插入类按钮下移工具条 + 增高标题输入框
|
||||||
|
phase: 7
|
||||||
|
deps: [T-583]
|
||||||
|
status: DONE
|
||||||
|
created: 2026-07-10
|
||||||
|
---
|
||||||
|
|
||||||
|
## 问题 / 背景
|
||||||
|
|
||||||
|
②标题提示词区现无模板管理(只有 label + 输入框 + 「保存标题提示词」+「插入旧标题」);封面提示词区已有「下拉框/新建/保存/模板操作(另存为/重命名/删除)」但铺了多行。T-583 已提供标题模板数据层(`data/prompts/title/` + 通用 CRUD + 默认模板播种)。本任务做左侧 UI 重排,让标题拥有与封面对等的模板管理,并腾出纵向空间给标题输入框。
|
||||||
|
|
||||||
|
定案排布(已评审):
|
||||||
|
- 模板控件放在 **label 下方单独一行**(不贴 label 同行——窄栏塞不下下拉+3按钮)。
|
||||||
|
- **插入类按钮**(标题「插入旧标题」T-549、封面「插入标题」、「预览」)移到**各自输入框下方的工具条**,与"模板管理"分离。
|
||||||
|
- 封面模板控件压到一行,省出的纵向空间给标题输入框增高。
|
||||||
|
|
||||||
|
## 方案(改哪个文件、改成什么)
|
||||||
|
|
||||||
|
### `app/gui/tabs/generate.py` — 左侧布局重排
|
||||||
|
|
||||||
|
标题提示词区(新结构,自上而下):
|
||||||
|
1. label「标题提示词」
|
||||||
|
2. **模板控件行**(新增):`[标题模板下拉] [新建] [保存模板] [模板操作▾(另存为/重命名/删除)]`——镜像封面那套,控件用 `title_template_*` 命名,调 T-583 通用 CRUD(传 `title_templates_dir`)。
|
||||||
|
3. 标题输入框 `title_prompt_edit`(**增高**,吃掉封面压行省出的空间)。
|
||||||
|
4. **输入框下工具条**:`[插入旧标题] [保存标题提示词]`(`insert_old_title_button` 保留 T-549 行为;「保存标题提示词」= 存当前工作文本到 `title_prompt.txt`,与"保存模板"区分)。
|
||||||
|
|
||||||
|
封面提示词区(压缩):
|
||||||
|
1. label「封面提示词」
|
||||||
|
2. **模板控件行**(压到一行):`[封面模板下拉] [新建] [保存模板] [模板操作▾]`。
|
||||||
|
3. 封面输入框 `cover_prompt_edit`。
|
||||||
|
4. **输入框下工具条**:`[插入标题] [预览]`。
|
||||||
|
|
||||||
|
### 构造参数 / 路径
|
||||||
|
- `GenerateTab.__init__()` 新增 `title_templates_dir=None`,与现有 `title_prompt_path`、`cover_prompts_dir` 一样支持测试传临时目录。
|
||||||
|
- 默认值来自 `appconfig.title_templates_dir(self.config)`;创建 `GenerateTab` 时调用 `prompts.ensure_default_prompts(self.title_prompt_path, self.cover_prompts_dir, self.title_templates_dir)`,避免写到真实默认 `data/`。
|
||||||
|
|
||||||
|
### 标题模板控件行为(镜像封面 handler)
|
||||||
|
- 下拉:`refresh_title_templates`/`load_selected_title_template`(选中→载入内容到 `title_prompt_edit`,记 `current_title_template`)。
|
||||||
|
- 新建 / 保存(存当前模板) / 模板操作(另存为/重命名/删除)——复用 T-583 通用 CRUD,逻辑镜像 `*_cover_template` 现有 handler。
|
||||||
|
- 「保存标题提示词」(工作文本→`title_prompt.txt`)与「保存模板」(当前模板→`data/prompts/title/<名称>.txt`)是两件事,二者都保留、语义在 tooltip 说清。
|
||||||
|
- 若当前没有选中标题模板时点击「保存模板」,走“另存为标题提示词模板”逻辑,不覆盖 `title_prompt.txt`。
|
||||||
|
|
||||||
|
### 命名 / 一致性
|
||||||
|
- 新控件 `objectName` 用 `titleTemplateCombo`/`newTitleTemplateButton`/`saveTitleTemplateButton`/`titleTemplateActionsButton` + `saveTitleTemplateAsAction`/`renameTitleTemplateAction`/`deleteTitleTemplateAction`,供测试定位。
|
||||||
|
- 「模板操作」下拉/菜单结构与封面一致(另存为/重命名/删除)。
|
||||||
|
- 为降低用户误解,标题/封面的模板保存按钮文案统一用「保存模板」;如果担心窄栏宽度,至少给按钮加 tooltip「保存当前选中的模板」。`save_title_button` 继续显示「保存标题提示词」或等价中文,tooltip 说明它保存当前工作文本。
|
||||||
|
- `_set_generate_running(running)` 需要同步禁用新增标题模板控件和 action(下拉、新建、保存模板、模板操作、另存为/重命名/删除),避免生成运行中修改模板状态;插入/保存工作文本禁用口径沿用现有标题按钮。
|
||||||
|
|
||||||
|
### 文档同步
|
||||||
|
- `docs/routes.md`/②UI 文档/线框图:登记标题模板控件行、插入类按钮下移、封面压行。
|
||||||
|
- 如果提交 `docs/ui/tab2-ai-generate-T584.svg` 线框图,必须确保它是本任务范围内的 UI 文档资产;否则不要把未确认的 SVG 混入代码提交。
|
||||||
|
|
||||||
|
## 验收要点
|
||||||
|
- 标题提示词区出现模板控件行(下拉/新建/保存/模板操作),能新建/切换/保存/另存为/重命名/删除标题模板(走 T-583 CRUD),切换模板载入内容到标题输入框。
|
||||||
|
- 「插入旧标题」移到标题输入框下方工具条、T-549 插入 `{旧标题}` 行为不回归;封面「插入标题」「预览」移到封面输入框下方工具条、行为不回归。
|
||||||
|
- 封面模板控件压到一行;标题输入框可用高度较改动前增大(建议人工验收记录为主,offscreen 下不写脆弱像素断言;若写布局断言,只断控件结构/拉伸因子,不断绝对高度)。
|
||||||
|
- 「保存标题提示词」(工作文本)与「保存模板」并存、各自写 `title_prompt.txt` / 模板文件,不混淆。
|
||||||
|
- 生成仍取编辑框当前文本(不回归);标题/封面模板互不干扰。
|
||||||
|
- 生成运行中标题模板控件随封面模板控件一起禁用,停止/完成后恢复。
|
||||||
|
- `GenerateTab(..., title_templates_dir=<临时目录>)` 不会写真实默认 `data/`。
|
||||||
|
- 既有封面模板 UI 测试不回归。
|
||||||
|
- 验证命令(unittest,不引入 pytest;GUI offscreen):
|
||||||
|
- `py -3.10 -m unittest tests.test_gui`
|
||||||
|
- `python -m ruff check app tests main.py`
|
||||||
|
- `py -3.10 -m compileall app main.py`
|
||||||
|
- `py -3.10 -m unittest discover -s tests`
|
||||||
|
- `git diff --check`
|
||||||
|
|
||||||
|
## 边界(不改什么)
|
||||||
|
- 不改 T-583 数据层契约(CRUD/目录/默认播种)。
|
||||||
|
- 不改 `generate_batch`/生成读取路径/`render_prompt`/AI/cmhub。
|
||||||
|
- 不改封面模板 CRUD 数据语义;可为文案一致把按钮显示改成「保存模板」,但不改保存/另存/重命名/删除行为。
|
||||||
|
- 不改标题/封面占位符语义、`title_prompt.txt` 工作文本语义。
|
||||||
|
- 不改右侧任务表、生成/停止/重置按钮区、③/①/CDP/DB。
|
||||||
|
|
||||||
|
## 执行记录
|
||||||
|
|
||||||
|
2026-07-10 Codex:
|
||||||
|
- `app/gui/tabs/generate.py`:`GenerateTab` 增加 `title_templates_dir` 注入,默认走 `appconfig.title_templates_dir(self.config)`;`ensure_default_prompts` 传入标题模板目录,避免测试或新用户初始化写错目录。标题提示词区新增模板下拉/新建/保存模板/模板操作(另存为/重命名/删除),封面模板控件压缩到一行并把保存按钮文案统一为「保存模板」。标题「插入旧标题」「保存标题提示词」移动到输入框下工具条;封面「插入标题」「预览」保留在封面输入框下工具条。生成运行中会禁用标题/封面模板下拉、模板按钮和菜单 action。
|
||||||
|
- 标题模板行为:启动时仍回显 `title_prompt.txt` 工作文本,不自动用默认模板覆盖;用户选择标题模板时才载入到标题输入框。「保存标题提示词」只写 `title_prompt.txt`,「保存模板」只写当前标题模板,未选中模板时进入另存为。
|
||||||
|
- `tests/test_gui.py`:覆盖标题模板控件 objectName/菜单、默认标题模板播种、标题模板 CRUD、工作文本与模板保存互不混淆、运行中禁用新增模板控件;同步封面保存按钮新文案。
|
||||||
|
- `docs/routes.md`、`docs/04-architecture.md`:同步 ② AI生成左侧提示词区结构、标题模板目录和“选择模板才载入,不覆盖工作文本”的约束。
|
||||||
|
- 当前工作树有无关的默认封面提示词/版本号/任务总表本地改动,未纳入本任务;因此先跑不依赖默认封面文件名的关键 GUI 子集。
|
||||||
|
- 验证:
|
||||||
|
- `python -m ruff check app tests main.py` 通过。
|
||||||
|
- `py -3.10 -m compileall app main.py` 通过。
|
||||||
|
- `git diff --check` 通过。
|
||||||
|
- `py -3.10 -m unittest tests.test_gui.GuiTests.test_generate_tab_manages_prompt_files_and_preview tests.test_gui.GuiTests.test_generate_tab_disables_reset_while_generation_running` 通过。
|
||||||
|
|
||||||
|
2026-07-10 Codex 干净 worktree 验证(`D:\chengma\cmshopee_verify_t584`,HEAD=`da68f92`):
|
||||||
|
- `py -3.10 -m unittest tests.test_gui` 通过,145 tests。
|
||||||
|
- `python -m ruff check app tests main.py` 通过。
|
||||||
|
- `py -3.10 -m compileall app main.py` 通过。
|
||||||
|
- `git diff --check` 通过。
|
||||||
|
- `py -3.10 -m unittest discover -s tests` 通过,327 tests。
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
---
|
||||||
|
id: T-585
|
||||||
|
title: Chrome 路径自动检测(注册表+标准目录,Chrome-only,仅空/失效才填)+ ⑤「自动检测」按钮
|
||||||
|
phase: 7
|
||||||
|
deps: [T-557]
|
||||||
|
status: DONE
|
||||||
|
created: 2026-07-10
|
||||||
|
---
|
||||||
|
|
||||||
|
## 问题 / 背景
|
||||||
|
|
||||||
|
`chrome_path` 默认硬编码 `C:\Program Files\Google\Chrome\Application\chrome.exe`(`appconfig.py:74`),对**用户级安装**(`%LOCALAPPDATA%\Google\Chrome\Application\chrome.exe`,免管理员安装,很常见)、`Program Files (x86)` 会失效。非标准盘的**正常安装版**通常可由 Windows 注册表定位;任意目录的便携版不应扫描整盘,继续由 T-557「选择...」手动指定。小白遇到路径不存在只能手动翻 `chrome.exe`,是"开箱即用"要消灭的摩擦。
|
||||||
|
|
||||||
|
产品原则对齐(运营日常零配置 + 逃生口 + 反馈):**自动检测填路径(小白无感)+ 保留手动「选择...」(逃生口)+ 检测结果给反馈(不做看不见的决定)**。
|
||||||
|
|
||||||
|
## 方案(改哪个文件、改成什么)
|
||||||
|
|
||||||
|
### 检测与有效性函数(`app/chrome.py`,可 mock、跨平台不崩)
|
||||||
|
- 新增 `detect_chrome_path() -> str`,按可靠性顺序返回第一个**归一化后确实存在**的 Chrome 可执行路径;无结果返回 `""`:
|
||||||
|
1. Windows App Paths:优先 `HKCU\SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths\chrome.exe`(当前用户安装),再 `HKLM` 同键;每个 hive 都尝试 64 位、32 位注册表视图,读取默认值。`winreg` 仅 Windows 导入/调用,平台不支持、键不存在或读取失败时静默跳过。
|
||||||
|
2. 标准目录兜底:`%ProgramFiles%\Google\Chrome\Application\chrome.exe`、`%ProgramFiles(x86)%\Google\Chrome\Application\chrome.exe`、`%LOCALAPPDATA%\Google\Chrome\Application\chrome.exe`。
|
||||||
|
3. 都没有则返回空字符串。
|
||||||
|
- 所有候选路径先去除成对引号、展开 `%环境变量%` / `~`、转绝对路径,再 `os.path.isfile` 验证;不返回带启动参数、不存在的注册表值或目录路径。
|
||||||
|
- 新增内部可复用的“已配置路径是否有效”判断:绝对/相对文件路径按上述归一化校验;仅文件名 `chrome.exe` 时也允许 `shutil.which()` 从 PATH 解析成功,避免把历史上实际可启动的配置误覆盖。
|
||||||
|
- **Chrome-only**:只从上述 `chrome.exe` App Paths/目录/PATH 候选取值,不检测、不返回 Edge、Chromium 等浏览器。
|
||||||
|
- 注册表读取和文件探测需可 mock;Linux/WSL 不导入或依赖真实 `winreg`,仍可测试目录/PATH 兜底。
|
||||||
|
|
||||||
|
### 启动自动填(仅空/失效才填,不覆盖有效设置)
|
||||||
|
- `appconfig.DEFAULT_CONFIG["chrome_path"]` 改为空字符串,不再把不存在的 Program Files 路径写入新用户配置。
|
||||||
|
- 在 `prepare_data_dir()` 与强制升级检查之后、创建 `MainWindow` 之前加载配置:若 `chrome_path` 为空或“已配置路径是否有效”返回否,则调 `detect_chrome_path()`;命中后写入**归一化绝对路径**并通过既有 `appconfig.save_config()` 持久化。
|
||||||
|
- 自动填服务须返回“是否改动、最终配置、用户可见消息”,让 GUI 入口将同一份配置传给 `MainWindow`;不把检测逻辑塞进 `load_config()`,避免任意配置读取都隐式访问注册表或写文件。
|
||||||
|
- **绝不覆盖有效的已设路径**(便携版/自定义绝对路径或可从 PATH 启动的 `chrome.exe`);检测不到时不写配置、不阻断启动。
|
||||||
|
|
||||||
|
### ⑤设置「自动检测」按钮 + 反馈
|
||||||
|
- `app/gui/tabs/settings.py`:Chrome 路径行在「选择...」旁加「自动检测」按钮,点击调 `detect_chrome_path()`:
|
||||||
|
- 命中 → 填入 `chrome_path_edit` 并提示「已自动定位 Chrome:<路径>」;通过既有文本变更机制标记“未保存更改”,**不直接写 `config.json`**,由用户点击「保存设置」后持久化。
|
||||||
|
- 未命中 → 提示「未找到 Chrome,请点『选择...』手动指定,或先安装 Chrome」(指路,不是死胡同)。
|
||||||
|
- 未命中时不清空用户当前输入值;既有 T-557「选择...」不改。
|
||||||
|
- 启动自动填命中时,在 `MainWindow` 构造完成后写状态栏「已自动定位 Chrome:<路径>」,确保不会被初始“就绪”覆盖;启动不弹窗、不因未检测到 Chrome 而阻断。
|
||||||
|
|
||||||
|
### 文档同步
|
||||||
|
- `docs/04-architecture.md`(Chrome 启动/路径来源、启动自动填边界)、`docs/troubleshooting.md`(Chrome 找不到的排查改为先点“自动检测”再手动选择)。
|
||||||
|
|
||||||
|
## 验收要点
|
||||||
|
- 新配置默认 `chrome_path=""`,不会写入不存在的硬编码路径。
|
||||||
|
- `detect_chrome_path()`:mock HKCU/HKLM(含注册表视图)返回某路径且文件存在 → 返回归一化绝对路径;注册表无 → 落到标准目录,命中 `%LOCALAPPDATA%` fixture → 返回该路径;带引号/环境变量的注册表值能识别;全无 → 返回 `""`。
|
||||||
|
- 非 Windows/`winreg` 不可用 → 不抛异常,仍可走目录/PATH 兜底;不真读注册表于测试。
|
||||||
|
- 只返回 Chrome,不返回 Edge/Chromium(构造 Edge 存在但 Chrome 不存在的场景,断言返回 `""` 或不误取 Edge)。
|
||||||
|
- 启动自动填:`chrome_path` 为空 → 检测命中后写入;指向不存在文件 → 覆盖为检测结果;已设且文件存在、或 `chrome.exe` 可由 PATH 解析 → 不改(断言不覆盖);未检测到时不写配置、不阻断启动。
|
||||||
|
- 启动命中后的状态栏保留“已自动定位 Chrome”;初始“就绪”不覆盖该消息。
|
||||||
|
- ⑤「自动检测」按钮:命中填入、标记未保存并给成功提示;未命中给指路提示、不清空原值,也不写配置。
|
||||||
|
- 既有 T-557「选择...」手动选择不回归。
|
||||||
|
- 验证命令(unittest,不引入 pytest):
|
||||||
|
- `py -3.10 -m unittest tests.test_chrome tests.test_appconfig tests.test_gui`
|
||||||
|
- `python -m ruff check app tests main.py`
|
||||||
|
- `py -3.10 -m compileall app main.py`
|
||||||
|
- `py -3.10 -m unittest discover -s tests`
|
||||||
|
- `git diff --check`
|
||||||
|
|
||||||
|
## 边界(不改什么)
|
||||||
|
- 不扫描其它盘或用户目录来猜测便携版;便携版继续由用户点「选择...」指定。
|
||||||
|
- 不覆盖用户已设且有效的 `chrome_path`(便携版/自定义路径/PATH 逃生口)。
|
||||||
|
- 不检测/不自动使用 Edge、Chromium 等非 Chrome 浏览器。
|
||||||
|
- 不改 `chrome.py` 启动/CDP 逻辑本身、`chrome_user_data_dir`、端口分配。
|
||||||
|
- 不真读注册表于测试(mock);不引入新第三方依赖;不把注册表读取或配置写入放进通用 `load_config()`。
|
||||||
|
- 不改 ①采集/②生成/③更新/DB/AI/cmhub。
|
||||||
|
|
||||||
|
## 执行记录
|
||||||
|
|
||||||
|
- 已完成:`appconfig.DEFAULT_CONFIG["chrome_path"]` 改为空;`app.chrome` 新增 Chrome-only 路径归一化、HKCU/HKLM App Paths(64/32 位视图)、标准目录和 PATH 检测,以及仅在当前配置无效时持久化自动定位结果的服务。
|
||||||
|
- 已完成:GUI 启动在数据目录和强制升级检查通过后执行自动定位,并把同一份配置传给主窗口;命中后状态栏保留“已自动定位 Chrome”。⑤设置页新增“自动检测”,只填输入框并标记未保存,不直接写配置;未命中不清空原值。
|
||||||
|
- 已同步:`docs/04-architecture.md` 记录路径来源与覆盖边界;`docs/troubleshooting.md` 增加 Chrome 路径排障步骤。
|
||||||
|
- 验证:干净工作树执行 `py -3.10 -m unittest discover -s tests`(335 项通过)、`python -m ruff check app tests main.py`、`py -3.10 -m compileall app main.py`、`git diff --check`,全部通过。新增测试覆盖注册表视图/优先级、带引号环境变量值、LOCALAPPDATA、Edge 排除、PATH 保护、空/失效/有效配置,以及设置页和启动状态栏行为。
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
---
|
||||||
|
id: T-586
|
||||||
|
title: AI工场数据地基:项目、图片资产、异步任务与终选顺序 SQLite 模型
|
||||||
|
phase: 7
|
||||||
|
deps: [T-538]
|
||||||
|
status: DONE
|
||||||
|
created: 2026-07-11
|
||||||
|
---
|
||||||
|
|
||||||
|
## 问题 / 背景
|
||||||
|
|
||||||
|
⑥ AI工场需要同时管理多个账号/商品、蝦皮原图、迭代生成图、多个在途 cmhub task、主图/详情图终选顺序和重启续查。只靠 `data/images/pool/<slug>/<item_id>/` 文件名无法可靠恢复来源、任务状态和排序,因此采用“SQLite 保存状态事实、目录保存图片字节”。完整产品口径见 Obsidian《图片精修-主图详情图生成方案》和 `docs/ui/tab6-ai-studio-v3.svg`。
|
||||||
|
|
||||||
|
## 方案
|
||||||
|
|
||||||
|
- `app/db.py` 增加 ad-hoc 兼容迁移和数据对象:
|
||||||
|
- `image_studio_projects`:账号、商品 ID、目标主图数/详情数、草稿提示词、项目状态、软删除/时间字段;同一账号+商品唯一。
|
||||||
|
- `image_studio_assets`:项目、类别(原图/生成主图/生成详情图)、远程 URL、本地路径、比例、父资产、生成提示词、可用/缺失状态。
|
||||||
|
- `image_studio_jobs`:资产候选、生成来源、task key/id、Provider、类型、状态、错误、重试和时间字段;当前阶段 `provider` 固定为 `cmhub`,字段仅用于状态自描述和后续兼容。
|
||||||
|
- `image_studio_selections`:项目、`main/detail`、连续 position、asset;类别+位置唯一,同一资产在同类别唯一。
|
||||||
|
- 新增独立 service 模块(命名按实现确定)封装 CRUD、事务排序、路径计算和状态转换,GUI 不直接写 SQL。
|
||||||
|
- 图片目录固定在 `data/images/pool/<slug>/<item_id>/{originals,generated,exports}/`,所有路径继续通过 `appconfig` 从 `data/` 解析。
|
||||||
|
- 数据写入顺序保证崩溃可恢复:先建 job/key,submit 后立即写 task_id,图片完整落盘后才创建/启用 asset。
|
||||||
|
|
||||||
|
## 验收要点
|
||||||
|
|
||||||
|
- 历史 DB 启动可自动补表/索引且不影响既有 tasks/batches/accounts。
|
||||||
|
- 同账号+商品重复创建返回已有项目;不同账号可绑定不同商品项目。
|
||||||
|
- 资产来源、父资产、比例和本地路径可完整读回;文件缺失可标记但不物理删记录。
|
||||||
|
- job 支持 `pending/submitted/running/succeeded/failed` 及 task key/id 生命周期,重启后可列出待续查任务。
|
||||||
|
- 终选位置连续、并发写入事务安全、同资产同类别不重复;允许同资产各出现一次 main/detail。
|
||||||
|
- 纯逻辑测试覆盖迁移、唯一约束、CRUD、状态转换、排序和路径隔离;运行全量 unittest、ruff、compileall、`git diff --check`。
|
||||||
|
|
||||||
|
## 边界(不改什么)
|
||||||
|
|
||||||
|
- 不做 GUI、CDP、网络下载、AI 请求、导出或蝦皮写操作。
|
||||||
|
- 不实现自定义 Provider、API Key 或生成来源切换。
|
||||||
|
- 不把图片 BLOB 写 SQLite,不采用目录扫描猜状态。
|
||||||
|
- 不提供本地图片导入,不物理删除项目历史。
|
||||||
|
|
||||||
|
## 执行记录
|
||||||
|
|
||||||
|
- 2026-07-11:完成 AI工场 SQLite 数据地基。
|
||||||
|
- `app/db.py` 增加 `image_studio_projects`、`image_studio_assets`、`image_studio_jobs`、`image_studio_selections` 四张表与索引;沿用现有 `CREATE TABLE IF NOT EXISTS` ad-hoc 迁移风格,历史 DB 启动时自动补表,不影响 `batches/accounts/tasks`。
|
||||||
|
- 新增 `app/image_studio.py` service 层,封装项目创建/软删除恢复、图片目录计算、资产 CRUD、cmhub job task key/task_id 生命周期、可续查任务列表、主图/详情图终选排序替换;校验 job/终选引用的资产必须属于同一项目。
|
||||||
|
- 新增 `tests/test_image_studio.py` 覆盖 schema 初始化、项目唯一性、软删除恢复、路径隔离、资产父子关系、任务状态转换、续查查询、终选连续排序与错误路径。
|
||||||
|
- 验证:在只套用 T-586 diff 的 clean worktree 中运行 `python -m ruff check app tests main.py`、`py -3.10 -m compileall app main.py`、`py -3.10 -m unittest discover -s tests`(343 tests)和 `git diff --check`,全部通过。
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
---
|
||||||
|
id: T-587
|
||||||
|
title: AI工场商品项目入口与 CDP 只读拉取蝦皮原主图 URL
|
||||||
|
phase: 7
|
||||||
|
deps: [T-586, T-563]
|
||||||
|
status: DONE
|
||||||
|
created: 2026-07-11
|
||||||
|
---
|
||||||
|
|
||||||
|
## 问题 / 背景
|
||||||
|
|
||||||
|
AI工场以“账号 + 商品 ID”建立项目,并从该账号 Chrome 的商品详情页只读取得全部主图 URL。该流程必须复用已验证 CDP、登录检测、商品失效 toast 和 tab 清理纪律,不能另写页面选择器或修改蝦皮商品。
|
||||||
|
|
||||||
|
## 方案
|
||||||
|
|
||||||
|
- 新增项目入口服务:校验账号、商品 ID 和重复项目;重复时打开已有项目。
|
||||||
|
- 抽取/复用 `app.editor` 图片列表读取能力,返回有序 `{src, index}`,不要求上传 input,不下载图片、不改标题/图片。
|
||||||
|
- 按①采集的只读边界确保账号 Chrome 就绪:已打开复用,未打开才启动;只检测登录,不自动登录、不填密码。
|
||||||
|
- 商品页后台打开,不主动抢焦点;只关闭本轮自动新建 tab,复用用户已有 tab 不关闭。
|
||||||
|
- 详情页失效、无权限、未登录、CDP 超时继续上浮结构化中文原因和 Shopee 原始 toast。
|
||||||
|
- 抓取结果写项目的原图 URL 快照,刷新时按 URL/顺序更新,不创建本地 asset,下载留给 T-588。
|
||||||
|
|
||||||
|
## 验收要点
|
||||||
|
|
||||||
|
- mock 覆盖已开 Chrome 复用、未开启动、明确未登录、暂时不确定登录、商品失效和成功读 1..9 张 URL。
|
||||||
|
- 成功路径不调用标题修改、文件上传、拖拽或更新提交。
|
||||||
|
- URL 顺序与蝦皮主图顺序一致;重复刷新幂等。
|
||||||
|
- 新建失败 tab 清理、复用 tab 保留;`bring_to_front=False`。
|
||||||
|
- 涉及 CDP 变更时同步 `docs/04-architecture.md` 并在测试商品做只读实跑;不点击更新。
|
||||||
|
|
||||||
|
## 边界(不改什么)
|
||||||
|
|
||||||
|
- 不下载/显示图片,不做照片池 GUI,不写蝦皮。
|
||||||
|
- 不更改现有①采集和③更新行为。
|
||||||
|
- 不绕过登录、验证码、风控或权限检查。
|
||||||
|
|
||||||
|
## 执行记录
|
||||||
|
|
||||||
|
- 2026-07-11:完成 AI工场商品项目入口与原主图 URL 只读拉取代码。
|
||||||
|
- `app/editor.py` 新增 `read_product_image_urls()`,复用已验证 itembox rects 顺序返回 `{index, src}`;新增 `close_readonly_product()` 复用①采集 tab 清理规则。
|
||||||
|
- `app/image_studio.py` 新增项目入口 `pull_remote_main_image_urls()`:按账号+商品创建/打开项目,复用/启动账号 Chrome,只检测登录;明确登录页阻断,不确定登录继续尝试打开商品页;商品页用 `open_product(..., bring_to_front=False)` 后台只读打开,读取 URL 后同步为 remote-only `original` assets,不下载图片、不修改蝦皮、不点击更新。
|
||||||
|
- 原图 URL 刷新走 `sync_original_asset_urls()`:相同 URL 复用原 asset 并更新顺序,不再出现的 URL 标记 `missing`,不物理删除历史记录。
|
||||||
|
- `docs/04-architecture.md` 已补充 AI工场原主图读取的 CDP 约束。
|
||||||
|
- 测试覆盖:已开 Chrome 复用、未开 Chrome 启动、明确未登录阻断、`NO_SESSION_COOKIE` 不确定登录继续、商品失效 toast 文案上浮、成功读取 1..9 张 URL、重复刷新幂等、自动新建/复用 tab 清理接口,以及成功路径不调用标题修改/封面替换/点击更新。
|
||||||
|
- 验证:在只套用 T-587 diff 的 clean worktree 中运行 `python -m ruff check app tests main.py`、`py -3.10 -m compileall app main.py`、`py -3.10 -m unittest discover -s tests`(350 tests)和 `git diff --check`,全部通过。
|
||||||
|
- 真实蝦皮只读实跑未执行:本轮未指定可用的已登录测试账号别名;为避免擅自打开真实浏览器,只做 mock/CDP 合约验证,后续接 GUI 前可用测试账号对 ITEM_ID 51100639510 做一次只读确认。
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
---
|
||||||
|
id: T-588
|
||||||
|
title: AI工场远程缩略图与蝦皮原图进入照片池
|
||||||
|
phase: 7
|
||||||
|
deps: [T-587, T-548]
|
||||||
|
status: DONE
|
||||||
|
created: 2026-07-11
|
||||||
|
---
|
||||||
|
|
||||||
|
## 问题 / 背景
|
||||||
|
|
||||||
|
拉取商品后要低成本浏览远程主图:缩略图只进内存,用户单击才下载原图落照片池。网络请求不能卡 GUI,失败不能阻断其他缩略图,也不能因为显示缩略图就把全部原图写盘。
|
||||||
|
|
||||||
|
## 方案
|
||||||
|
|
||||||
|
- 实现异步缩略图 loader(QNetworkAccessManager 或受控线程池+signal),含内存缓存、并发上限、取消、单图重试和占位状态。
|
||||||
|
- 实探并记录 Shopee CDN 缩略图尺寸后缀、Referer、Content-Type 和重定向事实;不可用时安全回退原 URL 的受限读取。
|
||||||
|
- 复用现有无系统代理、安全超时、大小上限和图片解码校验;只允许 http/https、公网地址。
|
||||||
|
- 单击蝦皮原图时下载原图到 `originals/`,先写临时文件并校验解码,再原子替换目标并创建原图 asset;重复单击幂等。
|
||||||
|
- 明确不增加“导入本地图片”入口;照片池来源仅蝦皮原图和后续 AI 生成图。
|
||||||
|
|
||||||
|
## 验收要点
|
||||||
|
|
||||||
|
- 缩略图加载不在 GUI 主线程,不落盘;单图失败/超时显示中文状态且其他图继续。
|
||||||
|
- 缓存命中不重复请求;切项目或关闭窗口可取消未完成加载。
|
||||||
|
- 单击后原图真实落盘、asset 可用;失败不留下半文件或可用 asset。
|
||||||
|
- 非图片、超限、内网 URL、坏图片和重定向边界有测试。
|
||||||
|
- CDN 两项事实有真实探查记录,测试不依赖公网。
|
||||||
|
|
||||||
|
## 边界(不改什么)
|
||||||
|
|
||||||
|
- 不做 AI 生成、终选、导出或蝦皮写操作。
|
||||||
|
- 不自动下载全部原图,不加入本地图片导入。
|
||||||
|
- 不在日志记录 Cookie、带敏感查询串的完整 URL 或图片字节。
|
||||||
|
|
||||||
|
## 执行记录
|
||||||
|
|
||||||
|
- 2026-07-11:完成 AI工场远程图片底层能力。
|
||||||
|
- 修正任务依赖:原 `T-546` 任务文件不存在,实际复用已完成的 T-548 图片下载安全/超时口径,frontmatter 改为 `deps: [T-587, T-548]`。
|
||||||
|
- 新增 `app/image_studio_images.py`:提供远程图片安全下载(http/https、公网地址、无系统代理、连接/读取超时、大小上限、Content-Type 与最终重定向 URL 校验、PIL 解码校验)、内存缩略图生成、线程池 `ThumbnailLoader`(缓存、取消、失败回调)和单张原图下载落 `originals/`。
|
||||||
|
- `download_original_asset()` 只在用户单击时下载对应原图,写临时文件、解码校验后 `os.replace()` 原子落盘,并通过 `image_studio.update_asset_local_path()` 回写同一 remote asset;重复单击已有可解码本地文件时直接返回,不重复请求。
|
||||||
|
- 新增 `tests/test_image_studio_images.py` 覆盖缩略图不落盘、缓存命中、取消、原图落盘幂等、坏图片不留下半文件/可用 asset、非图片、超限、内网 URL 和重定向到内网 URL。
|
||||||
|
- 验证:在只套用 T-588 diff 的 clean worktree 中运行 `python -m ruff check app tests main.py`、`py -3.10 -m compileall app main.py`、`py -3.10 -m unittest discover -s tests`(355 tests)和 `git diff --check`,全部通过。
|
||||||
|
- CDN 真实实探未执行:本轮没有指定可用的已登录测试账号/商品现场;代码已记录可观测字段 `content_type/final_url/redirected` 并覆盖回退与边界,后续接入 T-591 GUI 前建议用测试账号补一条真实 Shopee CDN 记录。
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
---
|
||||||
|
id: T-589
|
||||||
|
title: AI工场独立提示词模板数据层与完整 CRUD
|
||||||
|
phase: 7
|
||||||
|
deps: [T-583]
|
||||||
|
status: DONE
|
||||||
|
created: 2026-07-11
|
||||||
|
---
|
||||||
|
|
||||||
|
## 问题 / 背景
|
||||||
|
|
||||||
|
AI工场只有一个完整提示词框,不再区分“主提示词/每张动作词”,但需要较窄模板下拉和“新建、重命名、保存、删除”。模板不能与②封面模板混用,避免不同业务语义互相覆盖。
|
||||||
|
|
||||||
|
## 方案
|
||||||
|
|
||||||
|
- `appconfig` 增加 `image_studio_prompts_dir()`,固定为 `data/prompts/image_studio/`。
|
||||||
|
- 复用 T-583 通用模板 CRUD,不复制另一套文件名校验、重名保护或删除逻辑。
|
||||||
|
- 新建输入不重复名称;重命名只改名;保存把当前完整文本写入选中模板;删除二次确认。
|
||||||
|
- 不解析模板内容、不拆段、不按行生成;模板只是把完整文本载入编辑框。
|
||||||
|
- 当前项目草稿提示词由 T-586 项目字段保存,选择模板只覆盖编辑框/草稿,不自动改历史 job。
|
||||||
|
|
||||||
|
## 验收要点
|
||||||
|
|
||||||
|
- AI工场模板与标题/封面模板目录完全隔离,CRUD 和中文错误完整。
|
||||||
|
- 空名、非法路径、重名、目标不存在、删除最后模板均有确定行为。
|
||||||
|
- 选择模板返回原始完整文本;换行不触发任务拆分。
|
||||||
|
- 测试只用临时目录,不写真实 data;通用模板既有测试不回归。
|
||||||
|
|
||||||
|
## 边界(不改什么)
|
||||||
|
|
||||||
|
- 本任务只做路径/数据层,不做 GUI 和 AI 请求。
|
||||||
|
- 不复制②模板 CRUD,不引入本地图片或提示词自动补写。
|
||||||
|
- 不把 AI工场模板打成②封面模板的别名。
|
||||||
|
|
||||||
|
## 执行记录
|
||||||
|
|
||||||
|
- 2026-07-11:完成 AI工场独立提示词模板数据层。
|
||||||
|
- `app/appconfig.py` 新增 `image_studio_prompts_dir(config=None)`,固定解析到 `data/prompts/image_studio/`。
|
||||||
|
- `app/prompts.py` 复用 T-583 通用 `list/load/save/rename/delete_template`,新增 `list/load/save/rename/delete_image_studio_template` 薄封装;模板内容按原始完整文本保存,不解析、不拆行。
|
||||||
|
- `tests/test_prompts.py` 覆盖 AI工场模板 CRUD、空名/非法名/重名/不存在错误、原始多行文本保持,以及与标题/封面模板目录隔离;`tests/test_appconfig.py` 覆盖目录路径。
|
||||||
|
- `docs/04-architecture.md` 已同步 `data/prompts/image_studio/<名称>.txt` 存储口径。
|
||||||
|
- 当前工作树运行 `py -3.10 -m unittest tests.test_prompts tests.test_appconfig` 仍因无关未提交默认封面模板改名失败(`papa1` 变为「默认」);T-589 本身在 clean worktree 验证通过。
|
||||||
|
- 验证:在只套用 T-589 diff 的 clean worktree 中运行 `py -3.10 -m unittest tests.test_prompts tests.test_appconfig`(25 tests)、`python -m ruff check app tests main.py`、`py -3.10 -m compileall app main.py`、`py -3.10 -m unittest discover -s tests`(356 tests)和 `git diff --check`,全部通过。
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
---
|
||||||
|
id: T-590
|
||||||
|
title: AI工场托管模式多图异步生成编排与重启续查
|
||||||
|
phase: 7
|
||||||
|
deps: [T-586, T-564]
|
||||||
|
status: DONE
|
||||||
|
created: 2026-07-11
|
||||||
|
---
|
||||||
|
|
||||||
|
## 问题 / 背景
|
||||||
|
|
||||||
|
AI工场一次可生成 N 张,且每张都要独立计费、失败、重试、停止和续查。T-564 只为普通 tasks 表的一张封面保存一组 task_id/key,不能承载同商品多轮、多源图、多候选任务,需要把 cmhub submit/poll/download 能力复用到 image_studio_jobs。
|
||||||
|
|
||||||
|
## 方案
|
||||||
|
|
||||||
|
- 抽取可复用的 cmhub 异步生图客户端/服务,以持久化回调或 job repository 注入 task key/id;既有②路径行为保持兼容。
|
||||||
|
- 对同一源 asset 和同一完整提示词创建 N 条 job:一次调用一张,不按行拆词、不自动补动作、不保证去重。
|
||||||
|
- 每条 job 在 submit 前落稳定 key,202 后立即落 task_id;poll 成功下载校验后创建 generated asset,记录 main/detail、比例、父 asset、提示词和计费元数据。
|
||||||
|
- 并发沿用当前最大 5 的 submit+poll/下载双池保护;逐条事件回调供 GUI 显示。
|
||||||
|
- 停止不删除已提交任务:未开始 job 取消,已提交 job 停止等待并保留 task_id,下次继续查询。
|
||||||
|
- 本任务只实现 cmhub 托管来源;`generation_source/provider` 固定写入 `cmhub`,只作为状态自描述和后续兼容字段。本轮不展示或实现来源切换,自定义模型后置到 T-596。
|
||||||
|
|
||||||
|
## 验收要点
|
||||||
|
|
||||||
|
- N=3 产生 3 个不同 job/key/task,使用完全相同 prompt/source;每个成功独立进池。
|
||||||
|
- submit 响应后、poll 前 task_id 已落库;重启续查不再次 POST、不重复扣点。
|
||||||
|
- 单张失败不回滚成功图;下载失败不重新提交生成。
|
||||||
|
- 停止、总预算超时、服务端 failed/expired、幂等冲突和点数不足生命周期正确。
|
||||||
|
- 计费事件按 job 记录;全程不阻塞 GUI 主线程。
|
||||||
|
- 既有② AI生成 T-564 路径全量测试不回归。
|
||||||
|
|
||||||
|
## 边界(不改什么)
|
||||||
|
|
||||||
|
- 不做 BYOK、自定义 Provider、API Key、来源切换、自动去重或自动重生成。
|
||||||
|
- 不做 GUI、终选、导出、CDP 或蝦皮写操作。
|
||||||
|
- 不改变 cmhub 单次调用一张、预扣/失败退点契约。
|
||||||
|
|
||||||
|
## 执行记录
|
||||||
|
|
||||||
|
- 2026-07-11:完成 AI工场 cmhub 托管多图异步生成 service。
|
||||||
|
- 新增 `app/image_studio_generation.py`,不改现有② AI生成路径;AI工场单独使用 `image_studio_jobs/assets` 持久化 job/key/task_id/计费/状态和输出 asset。
|
||||||
|
- `generate_image_jobs()` 对同一源 asset + 完整提示词创建 N 条独立 job;每条 job 单独提交 `/api/v1/generate/image/tasks`,使用自身 `task_key` 作为 `Idempotency-Key`,submit 成功后立即写 `task_id/call_id/points_cost/points_balance`。
|
||||||
|
- `resume_image_jobs()` 对已有 `task_id` 的 `submitted/running` job 只 GET poll,不再 POST,避免重启续查重复扣点;poll 成功后下载保存 JPEG 到项目 `generated/` 并创建 `generated_main/generated_detail` asset,关联父源图、比例、提示词和远程 `image_url`。
|
||||||
|
- 单张失败只更新该 job 为 `failed`,不回滚其他成功图;下载失败发生在 poll 成功之后,不重新 submit、不创建输出 asset;停止语义保留已提交 job 的 task_id,未开始/可取消 job 标记 `cancelled`。
|
||||||
|
- 新增 `tests/test_image_studio_generation.py` 覆盖 N=3 独立 job/key/task、submit 后 poll 前已落库、续查不 POST、下载失败不重提交/不创建 asset、服务端单张 failed 不影响其他成功。
|
||||||
|
- 验证:在只套用 T-590 diff 的 clean worktree 中运行 `py -3.10 -m unittest tests.test_image_studio_generation tests.test_ai tests.test_db`(66 tests)、`python -m ruff check app tests main.py`、`py -3.10 -m compileall app main.py`、`py -3.10 -m unittest discover -s tests`(360 tests)和 `git diff --check`,全部通过。
|
||||||
|
- 本任务未接 GUI,也未打真实 cmhub 收费接口;T-591 接 UI 时应在 worker/QThread 中调用该 service,避免阻塞主线程。
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user