From b05a85679679aecf27de2b48525eb27eccb85c59 Mon Sep 17 00:00:00 2001 From: chengma Date: Mon, 29 Jun 2026 09:18:03 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=8C=E6=88=90T-501c=20Shopee?= =?UTF-8?q?=E6=9B=B4=E6=96=B0=E5=AE=89=E5=85=A8=E5=BC=80=E5=85=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 shopee_update 配置段,默认关闭真实提交和封面更新,限制测试商品ID、单次最大更新条数,并支持成功后关闭本轮自动新开编辑页。 Tab⑤ 设置页新增 Shopee 更新安全表单;Tab③ 开始更新前先读取安全配置并阻断未授权真实提交、超量、非测试商品和未允许的封面更新。 ApplyWorker 将 close_success_tab 传入 editor.apply_task;editor 仅在提交成功且页面为本轮自动新开时关闭 tab,失败和复用页面保留。 同步架构、API、路由、任务看板、当前状态与 progress 文档;补充 GUI 与 editor 单测覆盖安全设置保存、共享配置读取、安全拦截和 tab 关闭策略。 --- app/appconfig.py | 7 ++ app/editor.py | 20 +++- app/gui.py | 127 +++++++++++++++++++++++++- docs/04-architecture.md | 23 ++++- docs/06-tasks.md | 2 +- docs/api.md | 26 ++++-- docs/current-state.md | 24 ++--- docs/routes.md | 25 +++-- progress.md | 10 ++ tests/test_editor_login.py | 49 ++++++++++ tests/test_gui.py | 182 ++++++++++++++++++++++++++++++++++++- 11 files changed, 453 insertions(+), 42 deletions(-) diff --git a/app/appconfig.py b/app/appconfig.py index 8328268..235236b 100644 --- a/app/appconfig.py +++ b/app/appconfig.py @@ -40,6 +40,13 @@ DEFAULT_CONFIG = { "4k": 600, }, }, + "shopee_update": { + "test_item_id": "51100639510", + "allow_real_submit": False, + "allow_cover_update": False, + "max_items_per_run": 1, + "close_success_tab": False, + }, } DEFAULT_AI_MODELS_CONFIG = { diff --git a/app/editor.py b/app/editor.py index fefa613..d5bc01b 100644 --- a/app/editor.py +++ b/app/editor.py @@ -472,7 +472,7 @@ def click_update(cdp) -> dict: } -def apply_task(account, task) -> dict: +def apply_task(account, task, close_success_tab=False) -> dict: """Apply generated title/cover to Shopee. The caller must perform the batch confirmation before calling this function. @@ -480,6 +480,7 @@ def apply_task(account, task) -> dict: item_id = _item_id(task) cdp = open_product(account, item_id) + committed = False try: title_result = None cover_result = None @@ -494,8 +495,9 @@ def apply_task(account, task) -> dict: if not cover_result.get("ok"): return {"committed": False, "error": cover_result.get("reason"), "cover": cover_result} update_result = click_update(cdp) + committed = bool(update_result.get("clicked", False)) return { - "committed": update_result.get("clicked", False), + "committed": committed, "error": update_result.get("reason"), "title": title_result, "cover": cover_result, @@ -504,4 +506,18 @@ def apply_task(account, task) -> dict: except Exception as exc: return {"committed": False, "error": str(exc)} finally: + _close_applied_product(cdp, close_success_tab=close_success_tab and committed) + + +def _close_applied_product(cdp, close_success_tab=False): + target_id = getattr(cdp, "target_id", None) + created_by_app = bool(getattr(cdp, "created_by_app", False)) + host = getattr(cdp, "cdp_host", None) + try: cdp.close() + finally: + if close_success_tab and created_by_app and target_id: + try: + close_tab(target_id, host=host) + except Exception: + pass diff --git a/app/gui.py b/app/gui.py index 87ee889..18f9e32 100644 --- a/app/gui.py +++ b/app/gui.py @@ -1079,6 +1079,11 @@ if QT_IMPORT_ERROR is None: if not tasks: self._set_status("当前筛选结果没有可更新任务") return + safety_error = self._update_safety_error(tasks) + if safety_error: + QMessageBox.warning(self, "更新安全开关", safety_error) + self._set_status(safety_error.replace("\n", " ")) + return answer = QMessageBox.question( self, "确认开始更新", @@ -1089,7 +1094,13 @@ if QT_IMPORT_ERROR is None: if answer != QMessageBox.Yes: self._set_status("已取消开始更新") return - worker = ApplyWorker(tasks, db_path=self.db_path, config=self.config) + update_cfg = self._shopee_update_config() + worker = ApplyWorker( + tasks, + db_path=self.db_path, + config=self.config, + close_success_tab=bool(update_cfg.get("close_success_tab", False)), + ) worker.progress.connect(self._on_apply_progress) worker.row_updated.connect(self._on_apply_row_updated) worker.log.connect(self._set_status) @@ -1197,15 +1208,60 @@ if QT_IMPORT_ERROR is None: return True def _confirmation_message(self, tasks): + update_cfg = self._shopee_update_config() + cover_text = "允许" if update_cfg.get("allow_cover_update") else "不允许" + close_text = "是" if update_cfg.get("close_success_tab") else "否" return ( "即将按当前筛选结果开始更新 Shopee 线上商品。\n\n" f"批次:{self._batch_filter_label()}\n" f"店铺:{self._shop_filter_label()}\n" f"状态:{self._status_label()}\n" f"任务数:{len(tasks)}\n\n" - "确认后后续执行会打开商品编辑页、替换标题和封面,并点击「更新」提交线上。" + "安全设置:" + f"测试商品ID={update_cfg.get('test_item_id') or '未配置'}," + f"封面更新={cover_text}," + f"最大条数={update_cfg.get('max_items_per_run', 1)}," + f"成功后关闭新页={close_text}\n\n" + "确认后后续执行会打开商品编辑页、替换标题/允许时替换封面,并点击「更新」提交线上。" ) + def _update_safety_error(self, tasks): + update_cfg = self._shopee_update_config() + if not update_cfg.get("allow_real_submit", False): + return "设置未开启「允许真实提交线上商品」,已阻止本次更新。" + max_items = max(1, int(update_cfg.get("max_items_per_run", 1) or 1)) + if len(tasks) > max_items: + return f"当前筛选结果有 {len(tasks)} 条,超过单次最大更新条数 {max_items}。" + test_item_id = str(update_cfg.get("test_item_id", "")).strip() + if not test_item_id: + return "未配置测试商品ID,已阻止真实提交。" + mismatched = [ + str(getattr(task, "item_id", "")) + for task in tasks + if str(getattr(task, "item_id", "")) != test_item_id + ] + if mismatched: + shown = "、".join(mismatched[:5]) + return f"当前任务包含非测试商品ID:{shown}。只允许更新测试商品 {test_item_id}。" + 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 "设置未开启「允许更新封面」,当前任务包含新封面路径,已阻止本次更新。" + return None + + def _shopee_update_config(self): + defaults = appconfig.default_config().get("shopee_update", {}) + loaded = self.config.get("shopee_update", {}) + if not isinstance(loaded, dict): + loaded = {} + merged = dict(defaults) + merged.update(loaded) + return merged + def _set_apply_running(self, running): self.start_update_button.setEnabled(not running) self.stop_update_button.setEnabled(running) @@ -1967,12 +2023,20 @@ if QT_IMPORT_ERROR is None: class ApplyWorker(BaseWorker): """Apply generated title/cover changes to Shopee one task at a time.""" - def __init__(self, tasks, db_path=None, config=None, preflight=True): + def __init__( + self, + tasks, + db_path=None, + config=None, + preflight=True, + close_success_tab=False, + ): super().__init__() self.tasks = list(tasks) self.db_path = db_path self.config = config self.preflight = preflight + self.close_success_tab = close_success_tab def execute(self): account_rows = accounts.list_accounts(path=self.db_path, config=self.config) @@ -2022,7 +2086,11 @@ if QT_IMPORT_ERROR is None: try: db.mark_running(task.id, "apply", path=self.db_path) self.row_updated.emit(task.id, {"status": "running", "last_error": None}) - result = editor.apply_task(account, task) + result = editor.apply_task( + account, + task, + close_success_tab=self.close_success_tab, + ) committed = bool(result.get("committed")) and not result.get("error") error = result.get("error") if committed: @@ -2551,6 +2619,17 @@ if QT_IMPORT_ERROR is None: self.cdp_ready_timeout_spin.setObjectName("cdpReadyTimeoutSpin") self.cdp_ready_timeout_spin.setRange(1, 3600) self.save_config_button = QPushButton("保存设置") + self.test_item_id_edit = QLineEdit() + self.test_item_id_edit.setObjectName("testItemIdEdit") + self.allow_real_submit_checkbox = QCheckBox("允许真实提交线上商品") + self.allow_real_submit_checkbox.setObjectName("allowRealSubmitCheckbox") + self.allow_cover_update_checkbox = QCheckBox("允许更新封面") + self.allow_cover_update_checkbox.setObjectName("allowCoverUpdateCheckbox") + self.max_items_per_run_spin = QSpinBox() + self.max_items_per_run_spin.setObjectName("maxItemsPerRunSpin") + self.max_items_per_run_spin.setRange(1, 9999) + self.close_success_tab_checkbox = QCheckBox("成功后关闭本次新开编辑页") + self.close_success_tab_checkbox.setObjectName("closeSuccessTabCheckbox") form = QFormLayout() form.addRow("", self.enabled_checkbox) @@ -2591,6 +2670,13 @@ if QT_IMPORT_ERROR is None: path_form.addRow("调试端口范围", port_range_layout) path_form.addRow("CDP就绪超时(秒)", self.cdp_ready_timeout_spin) + update_form = QFormLayout() + update_form.addRow("测试商品ID", self.test_item_id_edit) + update_form.addRow("", self.allow_real_submit_checkbox) + update_form.addRow("", self.allow_cover_update_checkbox) + update_form.addRow("单次最大更新条数", self.max_items_per_run_spin) + update_form.addRow("", self.close_success_tab_checkbox) + right_panel = QWidget() right_layout = QVBoxLayout(right_panel) right_layout.setContentsMargins(12, 0, 0, 0) @@ -2604,6 +2690,9 @@ if QT_IMPORT_ERROR is None: right_layout.addSpacing(18) right_layout.addWidget(QLabel("路径与端口")) right_layout.addLayout(path_form) + right_layout.addSpacing(18) + right_layout.addWidget(QLabel("Shopee 更新安全")) + right_layout.addLayout(update_form) right_layout.addWidget(self.save_config_button) right_layout.addStretch(1) @@ -2817,6 +2906,13 @@ if QT_IMPORT_ERROR is None: "debug_port_range": [start_port, end_port], "cdp_ready_timeout": self.cdp_ready_timeout_spin.value(), "ai": ai_cfg, + "shopee_update": { + "test_item_id": self.test_item_id_edit.text().strip(), + "allow_real_submit": self.allow_real_submit_checkbox.isChecked(), + "allow_cover_update": self.allow_cover_update_checkbox.isChecked(), + "max_items_per_run": self.max_items_per_run_spin.value(), + "close_success_tab": self.close_success_tab_checkbox.isChecked(), + }, } ) return settings @@ -2867,8 +2963,31 @@ if QT_IMPORT_ERROR is None: self.cdp_ready_timeout_spin.setValue( int(appconfig.cdp_ready_timeout(self.config)) ) + update_cfg = self._shopee_update_config() + self.test_item_id_edit.setText(str(update_cfg.get("test_item_id", ""))) + self.allow_real_submit_checkbox.setChecked( + bool(update_cfg.get("allow_real_submit", False)) + ) + self.allow_cover_update_checkbox.setChecked( + bool(update_cfg.get("allow_cover_update", False)) + ) + self.max_items_per_run_spin.setValue( + max(1, int(update_cfg.get("max_items_per_run", 1) or 1)) + ) + self.close_success_tab_checkbox.setChecked( + bool(update_cfg.get("close_success_tab", False)) + ) self._update_response_timeout_label() + def _shopee_update_config(self): + defaults = appconfig.default_config().get("shopee_update", {}) + loaded = self.config.get("shopee_update", {}) + if not isinstance(loaded, dict): + loaded = {} + merged = dict(defaults) + merged.update(loaded) + return merged + def _populate_role_model_combos(self): ai_cfg = appconfig.ai_config(self.config) self._populate_role_combo( diff --git a/docs/04-architecture.md b/docs/04-architecture.md index d759bc6..70cb72f 100644 --- a/docs/04-architecture.md +++ b/docs/04-architecture.md @@ -108,6 +108,13 @@ imported → collected → generated → applied "jpg_quality": 90, "resolution": "1k", "resolution_timeouts": { "512": 180, "1k": 240, "2k": 360, "4k": 600 } + }, + "shopee_update": { + "test_item_id": "51100639510", + "allow_real_submit": false, + "allow_cover_update": false, + "max_items_per_run": 1, + "close_success_tab": false } } ``` @@ -120,6 +127,16 @@ imported → collected → generated → applied - 模型本身的定义(url/key/类型/连接超时…)在 `config/ai_models.json`,见 5.1b。 - 密钥不在 `config.json`:每个模型的 `api_key` 存于 `config/ai_models.json`,本地明文保存、UI 打码、gitignore、不入日志。 +`shopee_update` 段放**真实更新前的安全开关**: + +- `test_item_id`:允许真实提交的测试商品 ID;默认 `51100639510`。 +- `allow_real_submit`:是否允许 ③ 创建 `ApplyWorker` 并点击「更新」提交线上;默认 `false`,未开启时 ③ 在确认弹窗前阻断。 +- `allow_cover_update`:是否允许本次更新包含新封面路径;默认 `false`,未开启时只允许标题更新任务。 +- `max_items_per_run`:单次允许更新的最大任务数;默认 `1`,当前筛选结果超过即阻断。 +- `close_success_tab`:成功提交后是否关闭本轮程序自动新开的商品编辑页;默认 `false`。只关闭 `open_product()` 本轮新建且已提交成功的 tab,失败任务和用户原本打开的 tab 保留。 + +该段不是 V2 dry-run,也不是替代 ③ 确认弹窗的常驻授权;③ 仍必须先通过安全开关检查,再弹窗确认,用户点是后才真实提交。 + ### 5.1b AI 模型清单 `config/ai_models.json` 模型定义清单("有哪些模型"),与 `config.json` 的 `ai` 段("选了哪个 + 全局参数")职责分开。 @@ -315,9 +332,11 @@ images//_new. # AI 生成的新封面 ### 6.3 应用更新(③ Tab) - ③ 顶部筛选确定本次作用范围;点击「开始更新」后弹窗展示筛选条件、任务数量和“将提交线上”的风险提示。 +- 弹确认前先读取 `config.json` 的 `shopee_update`:未开启 `allow_real_submit`、任务数超过 `max_items_per_run`、包含非 `test_item_id` 商品、或任务含新封面但未开启 `allow_cover_update` 时,直接弹警告阻断,不创建 `ApplyWorker`。 - 用户点「是/确认」才开始批量更新;点「否/取消」不执行、不改库。 - 对确认后的**已生成(generated)任务**:`open_product` → `change_title(new_title)`(如有)→ `replace_cover(new_cover_path)`(如有)→ `click_update` 提交。 - 串行、单条失败继续;每条立即写 SQLite;全部完成回写 Excel(新字段+状态)+ 弹窗汇总。 +- 若 `close_success_tab=true`,且商品页是本轮程序自动新建、并已成功提交,则提交后关闭该商品编辑页;失败任务和复用的用户已有 tab 不关闭。 ### 6.4 登录检测 @@ -330,7 +349,7 @@ images//_new. # AI 生成的新封面 | Chrome 启动参数 | 全关后带 `--remote-debugging-port= --remote-allow-origins=* --user-data-dir=`;缺 allow-origins 则 WebSocket 403 | | 代理干扰 | 清除 `*_proxy`(requests `trust_env=False`),否则连本地 CDP 超时 | | WebSocket Origin | `websocket-client` `suppress_origin=True` | -| 关闭连接 vs 关闭 tab | `CDP.close()` 只关闭 WebSocket;需要关闭浏览器页面时必须调用浏览器 target 关闭接口。采集只关闭本轮自动新建的商品页,复用的用户已有 tab 不关闭 | +| 关闭连接 vs 关闭 tab | `CDP.close()` 只关闭 WebSocket;需要关闭浏览器页面时必须调用浏览器 target 关闭接口。采集只关闭本轮自动新建的商品页,复用的用户已有 tab 不关闭;③ 仅在设置 `close_success_tab=true`、成功提交、且 tab 为本轮自动新建时关闭 | | SPA 就绪 | 不用 load 事件;轮询“标题输入框 + 图片 itembox + 上传输入框”三者都在 | | 标题输入框 | XPath `//input[@class='eds-input__input' and string-length(@modelvalue)>24]` | | 写标题 | 原生 setter + 派发 `input`/`change`;`value`==`modelvalue`==新值 | @@ -342,7 +361,7 @@ images//_new. # AI 生成的新封面 | 更新按钮 | `button.eds-button` 中 `更新`;③ 批量确认后逐条点提交;禁用态(校验未过)记为失败 | | 登录检测 | 重定向到登录页或缺 `SPC_ST` → 未登录 | -高风险动作(删满 9 张封面、点更新提交、AI 图上线)先在测试商品验证。注意:本设计无逐条人工审核阶段、无常驻提交开关;③ 点击「开始更新」后必须弹窗确认,确认后才把当前筛选结果中的 AI 标题/封面提交线上。新图本地留档+回写 Excel 是事后追溯手段。 +高风险动作(删满 9 张封面、点更新提交、AI 图上线)先在测试商品验证。注意:本设计无逐条人工审核阶段、无常驻提交开关;③ 点击「开始更新」后必须先通过 `shopee_update` 安全开关,再弹窗确认,确认后才把当前筛选结果中的 AI 标题/封面提交线上。新图本地留档+回写 Excel 是事后追溯手段。 ## 八、推荐开发顺序 diff --git a/docs/06-tasks.md b/docs/06-tasks.md index fb99ff6..fe1ccf3 100644 --- a/docs/06-tasks.md +++ b/docs/06-tasks.md @@ -80,7 +80,7 @@ | --- | --- | --- | --- | --- | | T-501 | Tab⑤ 设置 · AI 模型管理 UI(master-detail):模型下拉 + 新增/删除 + 详情(服务商名/类别/api_type/模型ID/网址/密钥打码/连接超时) + 测试连接 | T-005, T-104 | 复用 T-005 后端;name 唯一;类别 text/image;至少各一个;密钥本地明文保存、UI 打码;测试连接可用 | DONE | | T-501b | Tab⑤ 设置 · 角色与生成参数:标题大模型/图片大模型下拉、分辨率(512/1k/2k/4k)、返回超时随分辨率、并发/重试/jpg质量、路径/端口 | T-501 | 角色下拉按类别过滤;分辨率选后返回超时按 resolution_timeouts 自动;持久化 config.json | DONE | -| T-501c | Tab⑤ 设置 · Shopee 更新安全开关 | T-501b | 配置测试商品 ID、是否允许真实提交、是否允许更新封面、单次最大更新条数、成功后是否自动关闭本次新开编辑 tab;默认关闭真实提交和封面更新;Tab③ 执行前读取并拦截不符合安全配置的更新 | TODO | +| T-501c | Tab⑤ 设置 · Shopee 更新安全开关 | T-501b | 配置测试商品 ID、是否允许真实提交、是否允许更新封面、单次最大更新条数、成功后是否自动关闭本次新开编辑 tab;默认关闭真实提交和封面更新;Tab③ 执行前读取并拦截不符合安全配置的更新 | DONE | | T-502 | 满 9 张封面:删第一张再上传 | T-001 | 实测删除确认框并处理;满 9 张流程正确 | TODO | | T-503 | 敏感信息本地明文存储提示与日志脱敏 | T-105, T-501 | 首次保存密码/API Key 时提示“本地明文保存”;UI 打码;日志/导出不含密码/API Key;文档说明 `config.json`/`config/ai_models.json`/DB/user-data-dir/images 必须 gitignore | TODO | | T-504 | 多账号并行 / dry-run / 运行日志(V2) | T-402 | 端口不冲突;dry-run 只预览;操作留痕 | TODO | diff --git a/docs/api.md b/docs/api.md index e21a5f3..58cd1a3 100644 --- a/docs/api.md +++ b/docs/api.md @@ -32,7 +32,7 @@ ai_config(config=None) -> dict # default_text_model/default_image response_timeout(config=None) -> int # = resolution_timeouts[resolution](返回超时,随分辨率) ``` -`config.json` 不保存 AI Key;写入 `api_key` / `*_key` / `token` / `*_token` / `password` / `*_password` 等敏感字段时抛 `ConfigError`。AI Key 留给 `config/ai_models.json`。 +`default_config()` / `load_config()` 包含 `shopee_update` 安全配置段:测试商品 ID、是否允许真实提交、是否允许封面更新、单次最大更新条数、成功后是否关闭本轮新开编辑页。`config.json` 不保存 AI Key;写入 `api_key` / `*_key` / `token` / `*_token` / `password` / `*_password` 等敏感字段时抛 `ConfigError`。AI Key 留给 `config/ai_models.json`。 AI 模型清单(`config/ai_models.json`,含本地明文密钥,已建;UI 由 ⑤ 设置复用): @@ -217,8 +217,10 @@ collect(account, task) -> dict # -> {old_title, old_cover_path} change_title(cdp, new_title) -> dict # {ok, value, modelvalue},要求三者相等 replace_cover(cdp, image_win_path) -> dict # 上传→等CDN→拖第一位;满9张暂返回 FULL_IMAGE_SLOTS,删除流程留给 T-502 click_update(cdp) -> dict # {clicked, reason};禁用则记失败 -apply_task(account, task) -> dict # 对已生成任务:换标题+换封面+点「更新」提交(调用前必须已做批量确认) - # -> {committed, error} +apply_task(account, task, close_success_tab=False) -> dict +# 对已生成任务:换标题+换封面+点「更新」提交(调用前必须已通过安全开关和批量确认) +# close_success_tab=True 时,仅成功提交且商品页为本轮自动新开时关闭该 tab +# -> {committed, error} ``` `login_status()` 不自动登录;无 Shopee tab 时打开卖家中心根地址 `https:///`(默认 `https://seller.shopee.tw/`)用于检测/人工登录。判断规则:最终 URL 是登录页 → `LOGIN_PAGE`;缺少 `SPC_ST`/`SPC_U` → `NO_SESSION_COOKIE`;有会话 Cookie → 已登录。 @@ -228,7 +230,7 @@ apply_task(account, task) -> dict # 对已生成任务:换标题+ - `CDP.close()` 只断开当前 websocket 控制连接,不关闭 Chrome 页面。 - `open_product()` 若复用已存在商品 tab,则标记为用户已有页面;若调用 `create_tab()` 新建,则记录 target id。 - `collect()` 结束时只关闭本轮自动新建的商品编辑页 tab;用户原本打开的商品 tab 不关闭。 -- ③ 更新流程暂不自动关闭商品页:成功后是否关闭留待后续体验任务,失败时保留现场便于排查。 +- ③ 更新流程默认不关闭商品页;若 `close_success_tab=True`,只在提交成功且商品页为本轮自动新开时关闭,失败任务和用户原本打开的 tab 保留现场。 ## ai 模块(`app/ai.py`,已建,外部 AI,通用 HTTP) @@ -296,11 +298,11 @@ main() -> int # 创建 QApplication + MainWindow class MainWindow(QMainWindow) # QTabWidget: ①②③④⑤;支持注入 db_path/config/config_path/ai_models_path 便于测试 class CollectTab(QWidget) # ① 导入采集:导入 Excel + 汇总栏 + QTableView 任务列表 + 未匹配略过标记 class GenerateTab(QWidget) # ② AI生成:提示词管理 + 筛选任务 + 开始/停止生成 + 新旧封面预览 -class ApplyTab(QWidget) # ③ 更新shopee:筛选已生成任务 + 确认后串行执行更新 -class SettingsTab(QWidget) # ⑤ 设置:AI 模型 master-detail 管理 +class ApplyTab(QWidget) # ③ 更新shopee:筛选已生成任务 + 安全开关拦截 + 确认后串行执行更新 +class SettingsTab(QWidget) # ⑤ 设置:AI 模型管理 + 角色/生成参数/路径端口 + Shopee 更新安全 class CollectWorker(BaseWorker) # ① 后台采集:账号就绪预检 -> editor.collect -> db.set_collected/mark_skipped/mark_failed class GenerateWorker(BaseWorker) # ② 后台生成:ai.generate_batch -> db.set_generated/mark_failed + 进度 -class ApplyWorker(BaseWorker) # ③ 后台更新:账号就绪预检 -> editor.apply_task -> db.set_applied/mark_skipped +class ApplyWorker(BaseWorker) # ③ 后台更新:账号就绪预检 -> editor.apply_task(close_success_tab=...) -> db.set_applied/mark_skipped class WriteBackWorker(BaseWorker) # ①/③ 后台回写:旧字段或更新结果写回原 Excel class AIModelTestWorker(BaseWorker) # ⑤ 后台测试 AI 模型连接:appconfig.test_ai_model class TaskTableModel(QAbstractTableModel) # 任务表格模型:账号/别名/商品ID/阶段;未匹配别名显示“略过” @@ -333,6 +335,8 @@ TAB_STYLE: str # 顶层 Tab 栏防误点样式: - 角色与生成参数读写 `config.json`:标题大模型(仅 text)、图片大模型(仅 image)、标题/图片并发、失败重试、分辨率、jpg 质量。 - 分辨率下拉固定 `512/1k/2k/4k`;返回超时标签只读展示 `resolution_timeouts[resolution]`。 - 路径与端口读写 `config.json`:Chrome 路径、账号数据根目录、图片目录、DB 路径、默认调试端口、调试端口范围、CDP 就绪超时。保存时校验端口范围和默认端口。 +- Shopee 更新安全读写 `config.json` 的 `shopee_update` 段:测试商品 ID、允许真实提交、允许更新封面、单次最大更新条数、成功后关闭本次新开编辑页。 +- 真实提交与封面更新默认关闭;用户在 ⑤ 保存开启后,③ 仍需要通过安全开关检查并弹窗确认,才会创建更新 worker。 ① 导入采集当前要点(T-202/T-202b): @@ -368,10 +372,12 @@ TAB_STYLE: str # 顶层 Tab 栏防误点样式: - 状态筛选支持:已生成(默认,`stage=generated` 且 `status=success/pending`)、失败、已更新、略过、全部状态。 - 任务列表使用 `QTableView + ApplyTaskTableModel`,列为:店铺、商品ID、新标题、新封面、阶段、结果。 - 「开始更新」只读取当前筛选结果;无任务时只提示,不弹确认、不改库。 -- 点击「开始更新」会弹窗展示批次/店铺/状态/任务数和提交线上风险;用户点否/取消时不执行、不改库;用户点是后才创建 `ApplyWorker` 串行执行更新。 +- 点击「开始更新」先读取 `shopee_update`:未允许真实提交、超过单次最大条数、包含非测试商品 ID、或包含新封面但未允许封面更新时,弹警告并阻断;通过后才弹窗展示批次/店铺/状态/任务数、提交线上风险和当前安全设置。 +- 用户点否/取消时不执行、不改库;用户点是后才创建 `ApplyWorker` 串行执行更新。 - `ApplyWorker` 只处理当前筛选结果里 `stage=generated` 且已有新标题或新封面、状态为 `success/pending/failed` 的任务;已更新和略过记录仅查看,不会再次提交。 - 更新前先做账号就绪预检:无账号、当前筛选结果匹配账号 Chrome 未启动或未登录时,返回 `blocked=True`,GUI 弹窗汇总并跳转/引导去④账号管理;预检不通过时不调用 `editor.apply_task()`、不写失败状态。 -- 预检通过后逐条 `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`,单条失败继续下一条。 +- 预检通过后逐条 `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`,单条失败继续下一条。 +- 若 `close_success_tab=true`,`editor.apply_task()` 只关闭本轮自动新开且成功提交的商品页;失败和复用的用户已有 tab 不关闭。 - 别名未匹配账号的任务逐条 `db.mark_skipped()`,原因 `别名未匹配账号`;「停止」调用 worker 协作式 `cancel()`,已开始单条跑到安全边界后结束。 - ③ 没有常驻提交开关;确认弹窗是提交线上前的边界。 - 更新完成后自动调用 `WriteBackWorker(mode="results")` → `excel.write_back_results()`,把新标题、新封面路径、更新状态写回原 Excel;原文件被占用时提示关闭后点击「回写结果到 Excel」手动重试,SQLite 更新结果不回滚。 @@ -409,7 +415,7 @@ run_worker(worker: BaseWorker, thread_name=None, start=True) -> QThread - 采集、AI 生成、更新、Excel 回写都通过 worker 执行,用 signal 回传进度。 - 每个 worker/线程按需创建自己的 SQLite connection,不跨线程共享连接。 - ③ 的批量确认弹窗在 GUI 主线程完成;用户确认后才创建 `ApplyWorker`。 -- `ApplyWorker` 串行调用 `editor.apply_task()`,逐条 `set_applied()`,失败继续;账号未就绪时整体阻断并引导④,不进入逐条提交。 +- `ApplyWorker` 串行调用 `editor.apply_task(..., close_success_tab=...)`,逐条 `set_applied()`,失败继续;账号未就绪时整体阻断并引导④,不进入逐条提交。 - `WriteBackWorker` 默认 `mode="old"` 回写旧字段;③ 使用 `mode="results"` 回写新标题/新封面/更新状态,支持单批次或多批次列表。 - `execute()` 未捕获异常会发 `failed(-1, error)` 与 `finished({"ok": False, "error": ...})`;普通单行失败由业务 worker 自己发 `failed(task_id, error)` 后继续处理。 diff --git a/docs/current-state.md b/docs/current-state.md index 45a3cf1..f1892fd 100644 --- a/docs/current-state.md +++ b/docs/current-state.md @@ -6,10 +6,10 @@ ## 当前快照 - 日期:2026-06-29 -- 阶段:V0 单账号 CDP 流程已验证;V1 已完成 T-000 正式代码包结构、T-001 `app/editor.py` 模块化、T-002 `app/appconfig.py` 应用配置、T-003 SQLite 持久化地基、T-004 本地数据忽略规则、T-005 AI 模型清单后端、T-006 单元测试基座、T-101 账号 user-data-dir 工具、T-102 Chrome 启动器、T-103 登录保活与检测、T-104 PySide6 主窗口骨架、T-104b PySide6 worker 基座、T-105 Tab④ 账号管理、T-106 账号快捷方式、T-201 Excel 导入入库、T-202 Tab① 任务列表与导入按钮、T-202b Tab① 导入汇总栏、T-203 采集旧标题旧封面、T-204 回写旧字段到原 Excel、T-204b 采集完成自动回写、T-205 首次未配账号/Chrome 未启动/未登录引导保护、T-205b 采集后关闭自动新建商品页 tab、T-301 AI 生成接口、T-302 Tab② AI 生成布局与任务列表、T-302p 提示词管理、T-303 Tab② 开始生成/停止/进度、T-401 Tab③ 更新列表筛选与开始更新确认、T-402 Tab③ 确认后串行更新、T-403 Tab③ 结果回写与结束汇总、T-501 Tab⑤ AI 模型管理 UI、T-501b Tab⑤ 角色与生成参数。 +- 阶段:V0 单账号 CDP 流程已验证;V1 已完成 T-000 正式代码包结构、T-001 `app/editor.py` 模块化、T-002 `app/appconfig.py` 应用配置、T-003 SQLite 持久化地基、T-004 本地数据忽略规则、T-005 AI 模型清单后端、T-006 单元测试基座、T-101 账号 user-data-dir 工具、T-102 Chrome 启动器、T-103 登录保活与检测、T-104 PySide6 主窗口骨架、T-104b PySide6 worker 基座、T-105 Tab④ 账号管理、T-106 账号快捷方式、T-201 Excel 导入入库、T-202 Tab① 任务列表与导入按钮、T-202b Tab① 导入汇总栏、T-203 采集旧标题旧封面、T-204 回写旧字段到原 Excel、T-204b 采集完成自动回写、T-205 首次未配账号/Chrome 未启动/未登录引导保护、T-205b 采集后关闭自动新建商品页 tab、T-301 AI 生成接口、T-302 Tab② AI 生成布局与任务列表、T-302p 提示词管理、T-303 Tab② 开始生成/停止/进度、T-401 Tab③ 更新列表筛选与开始更新确认、T-402 Tab③ 确认后串行更新、T-403 Tab③ 结果回写与结束汇总、T-501 Tab⑤ AI 模型管理 UI、T-501b Tab⑤ 角色与生成参数、T-501c Tab⑤ Shopee 更新安全开关。 - 技术栈:Python 3.10+,自研 CDP(websocket-client + requests),SQLite(sqlite3)+ `config.json` + openpyxl + AI(`config/ai_models.json` 通用 HTTP,chat JSON / images_edits),GUI PySide6 5 Tab(已定)。 -- 生产代码:已建立 `app/` 包 + 根入口 `main.py`;`app/cdp.py` 为已验证 CDP 底座,已区分 `CDP.close()` 断开 WebSocket 与 `close_tab()` 关闭浏览器 target;`app/editor.py` 已封装登录状态检测、标题/封面/采集/更新按钮能力,并在采集结束后只关闭本轮自动新建的商品编辑页 tab、保留用户已有 tab;`app/appconfig.py` 已实现 `config.json` 默认值/读写/更新、AI 参数与端口读取,以及 `config/ai_models.json` 模型清单 CRUD/过滤/打码/测试连接;`app/ai.py` 已实现 `gen_title()`/`gen_cover()`/`generate_batch()`,按默认文本/图片模型发起通用 HTTP 调用,支持重试、错误脱敏、图片 URL/base64 解析、resolution resize、jpg_quality 保存、先并发标题再并发封面、逐条 `set_generated`、失败 `mark_failed` 与停止取消未开始项;`app/prompts.py` 已实现标题提示词读写、封面模板 CRUD 与变量替换;`app/db.py` 已实现 SQLite schema、连接 PRAGMA、批次/账号/任务与阶段写库函数;`app/excel.py` 已实现多 Excel 输入列解析、整文件列校验、脏行统计跳过、导入批次与任务入库、别名匹配统计、旧标题/旧封面路径回写原 Excel、更新结果回写原 Excel 与另存副本;`app/config.py` 已实现账号 slug 与 user-data-dir 创建;`app/accounts.py` 已实现账号 CRUD 服务、端口默认分配、启动登录、检测登录、生成快捷方式;`app/chrome.py` 已实现 Chrome 参数拼装、启动、CDP 端口探测、PowerShell `.lnk` 快捷方式生成;`app/gui.py` 已实现 PySide6 `MainWindow`、五 Tab、顶部 Tab 栏防误点样式、① 导入采集的 Excel 导入按钮/导入汇总栏/QTableView 任务列表/未匹配筛选与略过标记/采集旧标题旧封面 worker/采集前账号就绪预检与④引导/采集完成自动回写/旧数据回写重试按钮与 worker、② AI生成左右布局/标题与封面提示词管理/批次店铺状态筛选/任务列表/变量预览/开始生成/停止/进度/双击新旧封面预览与 `GenerateWorker`、③ 更新shopee筛选栏/任务列表/开始更新确认弹窗/确认后 `ApplyWorker` 串行更新/账号就绪预检与④引导/逐条 `set_applied`/自动回写结果到 Excel/结束汇总弹窗/手动回写重试按钮、④ 账号管理表格/弹窗/按钮/快捷方式与状态栏、⑤ 设置 AI 模型 master-detail 管理、`AIModelTestWorker` 后台测试连接、角色/生成参数/路径/端口配置并持久化 `config.json`;`app/workers.py` 已实现 `BaseWorker`、通用 signals、取消标记和 `QThread` 启动包装。 -- 测试:`tests/` 已建立;T-006 后纯逻辑改动必须运行 `python -m unittest discover -s tests`,当前覆盖 appconfig/db/config/accounts/chrome 启动与快捷方式/editor 登录检测与商品 tab 生命周期/excel 导入/旧字段与更新结果回写/ai 标题与封面 HTTP 解析/`generate_batch` 正常、失败与停止/prompts 读写与渲染/gui ① 导入采集/gui ② AI生成布局筛选、提示词管理、生成 worker 与双击预览/gui ③ 更新shopee筛选列表、确认弹窗、`ApplyWorker` 串行更新与账号预检、结果回写与汇总/gui ④ 账号管理/gui ⑤ AI 模型管理和角色/生成参数设置/worker signal 与线程包装;CDP/Shopee 改动仍需测试商品手动验证。 +- 生产代码:已建立 `app/` 包 + 根入口 `main.py`;`app/cdp.py` 为已验证 CDP 底座,已区分 `CDP.close()` 断开 WebSocket 与 `close_tab()` 关闭浏览器 target;`app/editor.py` 已封装登录状态检测、标题/封面/采集/更新按钮能力,并在采集结束后只关闭本轮自动新建的商品编辑页 tab、保留用户已有 tab,更新提交成功且设置开启时可关闭本轮自动新建商品页;`app/appconfig.py` 已实现 `config.json` 默认值/读写/更新、AI 参数、端口读取与 Shopee 更新安全默认值,以及 `config/ai_models.json` 模型清单 CRUD/过滤/打码/测试连接;`app/ai.py` 已实现 `gen_title()`/`gen_cover()`/`generate_batch()`,按默认文本/图片模型发起通用 HTTP 调用,支持重试、错误脱敏、图片 URL/base64 解析、resolution resize、jpg_quality 保存、先并发标题再并发封面、逐条 `set_generated`、失败 `mark_failed` 与停止取消未开始项;`app/prompts.py` 已实现标题提示词读写、封面模板 CRUD 与变量替换;`app/db.py` 已实现 SQLite schema、连接 PRAGMA、批次/账号/任务与阶段写库函数;`app/excel.py` 已实现多 Excel 输入列解析、整文件列校验、脏行统计跳过、导入批次与任务入库、别名匹配统计、旧标题/旧封面路径回写原 Excel、更新结果回写原 Excel 与另存副本;`app/config.py` 已实现账号 slug 与 user-data-dir 创建;`app/accounts.py` 已实现账号 CRUD 服务、端口默认分配、启动登录、检测登录、生成快捷方式;`app/chrome.py` 已实现 Chrome 参数拼装、启动、CDP 端口探测、PowerShell `.lnk` 快捷方式生成;`app/gui.py` 已实现 PySide6 `MainWindow`、五 Tab、顶部 Tab 栏防误点样式、① 导入采集的 Excel 导入按钮/导入汇总栏/QTableView 任务列表/未匹配筛选与略过标记/采集旧标题旧封面 worker/采集前账号就绪预检与④引导/采集完成自动回写/旧数据回写重试按钮与 worker、② AI生成左右布局/标题与封面提示词管理/批次店铺状态筛选/任务列表/变量预览/开始生成/停止/进度/双击新旧封面预览与 `GenerateWorker`、③ 更新shopee筛选栏/任务列表/更新安全开关拦截/开始更新确认弹窗/确认后 `ApplyWorker` 串行更新/账号就绪预检与④引导/逐条 `set_applied`/自动回写结果到 Excel/结束汇总弹窗/手动回写重试按钮、④ 账号管理表格/弹窗/按钮/快捷方式与状态栏、⑤ 设置 AI 模型 master-detail 管理、`AIModelTestWorker` 后台测试连接、角色/生成参数/路径/端口配置与 Shopee 更新安全设置并持久化 `config.json`;`app/workers.py` 已实现 `BaseWorker`、通用 signals、取消标记和 `QThread` 启动包装。 +- 测试:`tests/` 已建立;T-006 后纯逻辑改动必须运行 `python -m unittest discover -s tests`,当前覆盖 appconfig/db/config/accounts/chrome 启动与快捷方式/editor 登录检测与商品 tab 生命周期、更新成功后关闭本轮新开 tab/excel 导入/旧字段与更新结果回写/ai 标题与封面 HTTP 解析/`generate_batch` 正常、失败与停止/prompts 读写与渲染/gui ① 导入采集/gui ② AI生成布局筛选、提示词管理、生成 worker 与双击预览/gui ③ 更新shopee筛选列表、确认弹窗、Shopee 更新安全拦截、`ApplyWorker` 串行更新与账号预检、结果回写与汇总/gui ④ 账号管理/gui ⑤ AI 模型管理、角色/生成参数设置和 Shopee 更新安全设置/worker signal 与线程包装;CDP/Shopee 改动仍需测试商品手动验证。 - 数据:`config.json`、`config/ai_models.json`、`cmshopee.db`、`chrome_user_data_dir/`、`images/` 已由 `.gitignore` 排除;运营填写后的 Excel 业务文件默认忽略,标准空模板 `shopee待处理任务模板.xlsx` 可提交;`app/appconfig.py` 首次读取缺失的 `config.json` 时会在本地写默认配置,`app/db.py` 调用 `init_db()` 时会在本地创建 SQLite DB。 ## 既定设计要点(文档已定) @@ -19,7 +19,7 @@ - 存储:`config.json`(应用设置)+ `config/ai_models.json`(AI 模型清单与本地明文 Key)+ SQLite `cmshopee.db`(账号/任务/各阶段结果)+ openpyxl(Excel)+ 本地 `images/`(旧/新封面)。 - 多账号隔离:每账号独立 user-data-dir(非 profile)。 - 账号↔任务绑定:以 Excel“别名”列为权威;未匹配略过,结束弹窗汇总。 -- 执行:多账号串行、单条失败继续;③ 点击「开始更新」后弹窗确认当前筛选范围和任务数量,确认后逐条点「更新」提交线上。 +- 执行:多账号串行、单条失败继续;③ 点击「开始更新」后先通过 ⑤ Shopee 更新安全开关(真实提交、封面更新、测试商品 ID、最大条数),再弹窗确认当前筛选范围和任务数量,确认后逐条点「更新」提交线上。 - AI:服务商/模型/Key 由 `config/ai_models.json` 配置;`app/ai.py` 支持 chat JSON 与 images_edits multipart;生成内容直接用于更新,本地留档+回写 Excel 供追溯。 - 登录:人工登录 + 程序检测,不自动登录;无 Shopee tab 时检测入口为 `https:///`(默认 `https://seller.shopee.tw/`);首次未配账号、对应账号 Chrome 未启动或未登录时,① ③ 应禁用或执行前预检提示,并引导去④。 @@ -32,17 +32,17 @@ | `prototypes/` | 已有 | 已验证原型/探查脚本(demo/set_title/set_cover/get_title/cookies/inspect_images/grab/1.py),保留作人工回归与探查参考;见 `prototypes/README.md` | | `chrome-remote-debug-lan.md` | 已有 | WSL→Windows CDP 转发排查记录 | | `app/__init__.py` / `app/__main__.py` / `main.py` | 已有 | 正式包与启动入口;`python main.py` / `python -m app` 可运行占位入口 | -| `app/gui.py` | 已有 | T-104/T-105/T-106/T-202/T-202b/T-203/T-204/T-204b/T-205/T-302/T-302p/T-303/T-401/T-402/T-403/T-501/T-501b 产出:PySide6 `QMainWindow` + 五 Tab;顶部 Tab 栏防误点样式;① 导入采集导入按钮、导入汇总栏、`QTableView` 任务列表、未匹配筛选与略过标记、采集旧标题旧封面 worker、采集前账号就绪预检与④引导、采集完成自动回写与手动重试;② AI生成左右布局、提示词管理、筛选栏、任务列表、开始生成/停止/进度、双击新旧封面预览与 `GenerateWorker`;③ 更新shopee筛选栏、任务列表、开始更新确认弹窗、确认后 `ApplyWorker` 串行更新、账号预检与④引导、逐条 `set_applied`、自动回写结果到 Excel、结束汇总弹窗与手动回写重试;④ 账号管理表格、账号弹窗、启动登录、检测登录、快捷方式;⑤ 设置 AI 模型下拉、新增/删除、详情编辑、密钥打码、测试连接 worker、默认角色下拉、生成参数、路径端口配置 | +| `app/gui.py` | 已有 | T-104/T-105/T-106/T-202/T-202b/T-203/T-204/T-204b/T-205/T-302/T-302p/T-303/T-401/T-402/T-403/T-501/T-501b/T-501c 产出:PySide6 `QMainWindow` + 五 Tab;顶部 Tab 栏防误点样式;① 导入采集导入按钮、导入汇总栏、`QTableView` 任务列表、未匹配筛选与略过标记、采集旧标题旧封面 worker、采集前账号就绪预检与④引导、采集完成自动回写与手动重试;② AI生成左右布局、提示词管理、筛选栏、任务列表、开始生成/停止/进度、双击新旧封面预览与 `GenerateWorker`;③ 更新shopee筛选栏、任务列表、Shopee 更新安全拦截、开始更新确认弹窗、确认后 `ApplyWorker` 串行更新、账号预检与④引导、逐条 `set_applied`、自动回写结果到 Excel、结束汇总弹窗与手动回写重试;④ 账号管理表格、账号弹窗、启动登录、检测登录、快捷方式;⑤ 设置 AI 模型下拉、新增/删除、详情编辑、密钥打码、测试连接 worker、默认角色下拉、生成参数、路径端口配置、Shopee 更新安全设置 | | `app/workers.py` | 已有 | T-104b 产出:`BaseWorker` + 通用 signals + 取消标记 + `run_worker()` QThread 包装 | | `app/accounts.py` | 已有 | T-105/T-106 产出:账号 CRUD 服务、目录创建、端口分配、启动登录、检测登录、快捷方式 | -| `app/editor.py` | 已有 | T-001/T-103/T-205b 产出:登录状态检测、打开商品页、读/写标题、读/下载封面、采集后关闭自动新建商品页 tab、上传拖封面、更新按钮、apply_task | -| `app/appconfig.py` | 已有 | T-002 产出:`config.json` 默认值、读写、更新、路径/端口/AI 参数读取;拒绝敏感字段写入 | +| `app/editor.py` | 已有 | T-001/T-103/T-205b/T-501c 产出:登录状态检测、打开商品页、读/写标题、读/下载封面、采集后关闭自动新建商品页 tab、上传拖封面、更新按钮、apply_task;更新成功后可按设置关闭本轮自动新建商品页 | +| `app/appconfig.py` | 已有 | T-002/T-501c 产出:`config.json` 默认值、读写、更新、路径/端口/AI 参数读取、Shopee 更新安全默认值;拒绝敏感字段写入 | | `app/ai.py` | 已有 | T-301/T-303 产出:`gen_title()`/`gen_cover()`/`generate_batch()`;读取默认模型;通用 HTTP 调用;失败重试;错误脱敏;封面按 resolution/jpg_quality 保存;批量生成先标题后封面、进度回调、逐条落库、失败标记、停止取消未开始项 | | `app/prompts.py` | 已有 | T-302p 产出:标题提示词读写、封面模板列表/读取/保存/重命名/删除、变量替换 | | `app/db.py` | 已有 | T-003 产出:batches/accounts/tasks schema;WAL/busy_timeout/foreign_keys;账号/批次/任务与 set_* 阶段写库 | | `app/config.py` | 已有 | T-101 产出:别名→稳定 slug;创建并返回绝对 user-data-dir | | `app/chrome.py` | 已有 | T-102/T-106 产出:Chrome 启动参数、`subprocess.Popen` 启动、`/json/version` 端口探测、PowerShell `.lnk` 快捷方式 | -| `tests/` | 已有 | T-006/T-201/T-202/T-202b/T-203/T-204/T-204b/T-205/T-301/T-302/T-302p/T-303/T-401/T-402/T-403/T-501/T-501b 产出:stdlib unittest 基座;覆盖 appconfig/db/config/accounts/chrome/editor/excel/ai/prompts/gui/workers | +| `tests/` | 已有 | T-006/T-201/T-202/T-202b/T-203/T-204/T-204b/T-205/T-301/T-302/T-302p/T-303/T-401/T-402/T-403/T-501/T-501b/T-501c 产出:stdlib unittest 基座;覆盖 appconfig/db/config/accounts/chrome/editor/excel/ai/prompts/gui/workers | | `app/excel.py` | 已有 | T-201/T-204/T-403 产出:多文件 Excel 输入列解析、必需列整文件拒绝、脏行逐行跳过、批次/任务入库、匹配统计;按源文件/工作表/行号回写旧标题与旧封面路径;按源文件/工作表/行号回写新标题、新封面路径、更新状态;支持原文件被占用时另存副本 | | `shopee待处理任务模板.xlsx` | 已有,已提交 | 标准空 Excel 模板;单工作表 `待处理任务`,表头 `账号名 | 别名 | 商品id | 旧标题 | 旧封面图片路径 | 新标题 | 新封面图片路径 | 更新状态`;运营复制后填写,填写副本不提交 | | `config.json` / `config/ai_models.json` / `cmshopee.db` / `chrome_user_data_dir/` / `images/` | 本地存在或按需生成,已忽略 | 含配置、密钥、业务、登录态、图片,不提交版本库 | @@ -60,16 +60,16 @@ 任务状态以 [`06-tasks.md`](06-tasks.md) 为准,历史记录见 [`../progress.md`](../progress.md)。 -- 已完成:T-000(正式代码包结构)、T-001(`app/editor.py` 模块化)、T-002(`app/appconfig.py` + `config.json`)、T-003(SQLite 建表)、T-004(本地数据 gitignore)、T-005(AI 模型清单后端)、T-006(单元测试基座)、T-101(账号 slug/user-data-dir)、T-102(Chrome 启动器)、T-103(登录保活与检测)、T-104(PySide6 五 Tab 主窗口骨架)、T-104b(PySide6 worker 基座)、T-105(Tab④ 账号管理)、T-106(账号快捷方式)、T-201(Excel 导入:解析多文件输入列入库)、T-202(Tab① 任务列表 + 导入按钮 + 别名匹配标记)、T-202b(Tab① 导入汇总栏)、T-203(采集旧标题+旧封面)、T-204(回写旧字段到原 Excel)、T-204b(采集完成自动回写旧字段)、T-205(首次未配账号 / Chrome 未启动 / 未登录引导保护)、T-205b(采集后关闭自动新建商品页 tab)、T-301(AI 生成接口)、T-302(Tab② 左右布局与任务列表)、T-302p(提示词管理)、T-303(Tab② 开始生成 + 停止 + 进度)、T-401(Tab③ 更新列表筛选 + 开始更新确认弹窗)、T-402(Tab③ 确认后串行更新)、T-403(Tab③ 结果回写与结束汇总)、T-501(Tab⑤ AI 模型管理 UI)、T-501b(Tab⑤ 角色与生成参数)。 +- 已完成:T-000(正式代码包结构)、T-001(`app/editor.py` 模块化)、T-002(`app/appconfig.py` + `config.json`)、T-003(SQLite 建表)、T-004(本地数据 gitignore)、T-005(AI 模型清单后端)、T-006(单元测试基座)、T-101(账号 slug/user-data-dir)、T-102(Chrome 启动器)、T-103(登录保活与检测)、T-104(PySide6 五 Tab 主窗口骨架)、T-104b(PySide6 worker 基座)、T-105(Tab④ 账号管理)、T-106(账号快捷方式)、T-201(Excel 导入:解析多文件输入列入库)、T-202(Tab① 任务列表 + 导入按钮 + 别名匹配标记)、T-202b(Tab① 导入汇总栏)、T-203(采集旧标题+旧封面)、T-204(回写旧字段到原 Excel)、T-204b(采集完成自动回写旧字段)、T-205(首次未配账号 / Chrome 未启动 / 未登录引导保护)、T-205b(采集后关闭自动新建商品页 tab)、T-301(AI 生成接口)、T-302(Tab② 左右布局与任务列表)、T-302p(提示词管理)、T-303(Tab② 开始生成 + 停止 + 进度)、T-401(Tab③ 更新列表筛选 + 开始更新确认弹窗)、T-402(Tab③ 确认后串行更新)、T-403(Tab③ 结果回写与结束汇总)、T-501(Tab⑤ AI 模型管理 UI)、T-501b(Tab⑤ 角色与生成参数)、T-501c(Tab⑤ Shopee 更新安全开关)。 - 正在进行:无。 -- 下一个可领取任务:**T-501c(Tab⑤ 设置 · Shopee 更新安全开关)**。T-404 真实 Shopee 单条更新冒烟已登记,但依赖 T-501c 设置安全开关完成后再执行。 +- 下一个可领取任务:**T-404(真实 Shopee 单条更新冒烟验收)**。仅用测试商品,默认先只测标题更新,必须经过 ⑤ 安全开关和 ③ 二次确认。 ## 当前已知限制 - ① 采集依赖对应账号 Chrome 已用专属 user-data-dir 和 CDP 端口启动并登录;T-205 已在采集前拦截未配置账号、Chrome 未启动、未登录,并引导去④账号管理,但不会无提示批量启动所有账号 Chrome。 -- T-501/T-501b 已完成 `config/ai_models.json` 模型清单 UI,以及 `config.json` 里的标题/图片默认模型角色、并发、重试、分辨率、jpg 质量、路径/端口设置。 +- T-501/T-501b/T-501c 已完成 `config/ai_models.json` 模型清单 UI,以及 `config.json` 里的标题/图片默认模型角色、并发、重试、分辨率、jpg 质量、路径/端口设置和 Shopee 更新安全开关。 - T-301/T-303 已完成通用 HTTP AI 接口、批量生成编排和 GUI 接入 mock 单测;真实 AI 生成还需要在 `config/ai_models.json` 填入可用 url/model/api_key 后做一次成本可控的小样本实测。 -- T-403 已完成③更新结果回写与结束汇总;真实 Shopee 更新冒烟暂不执行,延后到 T-501c 设置安全开关完成后做 T-404 单条验收。T-404 默认先只测标题更新,封面更新作为可选子项。 +- T-403/T-501c 已完成③更新结果回写、结束汇总与真实更新安全开关;真实 Shopee 更新冒烟仍未执行,下一步 T-404 只允许在测试商品范围内做单条验收,默认先只测标题更新,封面更新作为可选子项。 ## 当前可运行内容 diff --git a/docs/routes.md b/docs/routes.md index 9c65006..638e50d 100644 --- a/docs/routes.md +++ b/docs/routes.md @@ -14,9 +14,9 @@ | ② AI生成 | 左侧标题/封面**提示词**;右侧按批次/店铺筛选任务列表;AI 生成新标题/新封面;双击看新旧封面 | 不触线上,中 | | ③ 更新shopee | 对**已生成**任务点击「开始更新」后弹窗确认;确认后打开编辑页换标题+封面并逐条点「更新」提交;结果回写 Excel | **写线上,高** | | ④ 账号管理 | Shopee 账号(账号名/别名/数据目录/端口/密码本地明文仅参考/登录状态);启动登录、检测登录、生成快捷方式 | 中 | -| ⑤ 设置 | AI 模型/API Key、本地图片目录、Chrome 路径、默认端口等 | — | +| ⑤ 设置 | AI 模型/API Key、本地图片目录、Chrome 路径、默认端口、Shopee 更新安全开关等 | — | -任务的**阶段状态**贯穿各 Tab:`imported → collected → generated → applied`(或 `failed/skipped`)。② 不设逐条人工确认阶段;③ 无常驻提交开关,但点击「开始更新」后必须弹窗确认当前筛选范围和任务数量。各 Tab 聚焦各自阶段的列与按钮,但操作同一批任务(同一 batch)。 +任务的**阶段状态**贯穿各 Tab:`imported → collected → generated → applied`(或 `failed/skipped`)。② 不设逐条人工确认阶段;③ 无常驻提交开关,但点击「开始更新」后必须先通过 ⑤ 的 Shopee 更新安全开关,再弹窗确认当前筛选范围和任务数量。各 Tab 聚焦各自阶段的列与按钮,但操作同一批任务(同一 batch)。 ## 全局 Tab 栏可用性 @@ -91,7 +91,7 @@ ``` ┌ 更新shopee ───────────────────────────────────────────────────┐ │ 批次[本次▼] 店铺[全部▼] 状态[已生成▼] [筛选] │ -│ ⚠ 点击「开始更新」后确认【当前筛选结果】,确认后改标题+换封面并提交 │ +│ ⚠ 点击「开始更新」后先过安全开关,再确认【当前筛选结果】并提交 │ │ ┌───────────────────────────────────────────────────────────┐ │ │ │ 店铺 商品ID 新标题 新封面 阶段 结果 │ │ │ └───────────────────────────────────────────────────────────┘ │ @@ -102,10 +102,12 @@ - 顶部**按批次 / 店铺 / 状态筛选**(与 ①②一致);「开始更新」作用于**当前筛选结果**,是一道范围控制。 - 店铺筛选:建议**逐店铺更新**(每店铺需先启动其 Chrome 并登录)。 - 状态筛选:`已生成` 只跑未更新的;`失败` 用于**失败重试**;`已更新成功/略过` 仅查看。 -- 点击「开始更新」后弹窗展示本次筛选条件、任务数量和“将提交线上”的风险提示;用户点「是/确认」才开始,点「否/取消」不执行。 +- 点击「开始更新」先读取 ⑤ `shopee_update` 安全设置:未允许真实提交、超过单次最大条数、包含非测试商品 ID、或包含新封面但未允许封面更新时,直接弹警告并阻断。 +- 安全开关通过后,弹窗展示本次筛选条件、任务数量、安全设置和“将提交线上”的风险提示;用户点「是/确认」才开始,点「否/取消」不执行。 - 对确认后的**已生成(generated)任务**执行:打开编辑页换标题+换封面 → 点「更新」提交。 - 串行、单条失败继续;每条立即写回 SQLite(committed/状态/error),失败不阻塞后续任务。 - 更新前做账号就绪预检:无账号、对应账号 Chrome 未启动或未登录时整体阻断并引导去④账号管理,不进入逐条提交。 +- 若 ⑤ 开启“成功后关闭本次新开编辑页”,则仅关闭本轮程序自动新开且成功提交的商品页;失败任务和用户原本打开的 tab 不关闭。 - 更新完成后自动回写原 Excel:写入新标题、新封面图片路径、更新状态;原文件被锁时提示关闭后点击「回写结果到 Excel」手动重试。 - 自动回写完成后弹窗汇总成功/失败/略过数量与 Excel 回写文件/行数。 @@ -133,6 +135,9 @@ - 分辨率为 `512 / 1k / 2k / 4k`;返回超时只读展示 `resolution_timeouts[resolution]`,不单独编辑。 - 保存写入 `config.json` 的 `ai` 段,供 ② AI生成复用。 - 路径与端口(T-501b 已接入):本地图片目录、Chrome 路径、账号数据根目录、DB 路径、默认调试端口、端口范围、CDP 就绪超时。 +- Shopee 更新安全(T-501c 已接入):测试商品 ID、允许真实提交、允许更新封面、单次最大更新条数、成功后关闭本次新开编辑页。 + - 默认关闭真实提交和封面更新,单次最大更新条数默认 1。 + - ③ 点击「开始更新」会读取这些设置,先拦截不符合条件的更新,再弹确认框。 ## 流程导航 @@ -143,11 +148,13 @@ │ ② AI生成:提示词 → 生成新标题/新封面(无逐条审核) │ -③ 更新shopee:对已生成任务点击开始更新 → 弹窗确认 → 换标题+封面 → 点「更新」提交 → 回写结果 → 弹窗汇总 +⑤ 设置:开启测试商品范围内的 Shopee 更新安全开关 + │ +③ 更新shopee:对已生成任务点击开始更新 → 安全开关检查 → 弹窗确认 → 换标题/允许时换封面 → 点「更新」提交 → 回写结果 → 弹窗汇总 ``` - 未配账号 / Chrome 未启动 / 未登录:① ③ 的执行按钮禁用或执行前提示去 ④。 -- 已生成的任务即可进 ③;③ 用户确认批量弹窗后提交线上,无常驻提交开关。 +- 已生成的任务即可进 ③;③ 通过 ⑤ 安全开关并经用户确认批量弹窗后提交线上,无常驻提交开关。 - 任意步骤失败:记入该任务、日志标明,不影响其他任务。 ## 组件建议(PySide6) @@ -157,12 +164,12 @@ | `MainWindow(QMainWindow)` | 根窗口 | 持有 `QTabWidget`、状态栏、全局消息 | | `CollectTab(QWidget)` | ① | 导入、任务表、采集、回写 | | `GenerateTab(QWidget)` | ② | 左提示词管理 + 右筛选/任务列表;双击看新旧封面;开始生成/停止/进度已接入 `GenerateWorker` | -| `ApplyTab(QWidget)` | ③ | 已生成任务筛选 + 开始更新确认 + 确认后串行更新 + 结果回写与结束汇总 | +| `ApplyTab(QWidget)` | ③ | 已生成任务筛选 + Shopee 更新安全拦截 + 开始更新确认 + 确认后串行更新 + 结果回写与结束汇总 | | `AccountsTab(QWidget)` | ④ | 账号增删改、启动登录、检测登录、生成快捷方式 | -| `SettingsTab(QWidget)` | ⑤ | AI 模型 master-detail 管理 + 角色/生成参数/路径/端口配置 | +| `SettingsTab(QWidget)` | ⑤ | AI 模型 master-detail 管理 + 角色/生成参数/路径/端口配置 + Shopee 更新安全 | | `TaskTableModel(QAbstractTableModel)` | ①②③ | 任务表格数据模型,供 `QTableView` 使用 | | `BaseWorker(QObject)` | 后台 | 定义 `progress/log/row_updated/failed/finished/cancelled` signals | -| `ApplyWorker(BaseWorker)` | ③ | 账号就绪预检、串行调用 `editor.apply_task()`、逐条 `set_applied()`,失败继续 | +| `ApplyWorker(BaseWorker)` | ③ | 账号就绪预检、串行调用 `editor.apply_task(..., close_success_tab=...)`、逐条 `set_applied()`,失败继续 | | `AIModelTestWorker(BaseWorker)` | ⑤ | 后台调用 `appconfig.test_ai_model()` 测试模型连接 | | `WriteBackWorker(BaseWorker)` | ①③ | ①回写旧字段;③回写新标题/新封面/更新状态 | diff --git a/progress.md b/progress.md index 27c18c6..cbcd995 100644 --- a/progress.md +++ b/progress.md @@ -595,3 +595,13 @@ - 测试:`tests/test_gui.py` 覆盖角色下拉类别过滤、分辨率返回超时显示、生成参数/路径/端口保存到临时 `config.json`、非法端口范围拦截。 - 文档:`docs/06-tasks.md` 将 T-501b 标为 DONE;同步 `docs/routes.md`、`docs/api.md`、`docs/current-state.md`,下一个可领取任务更新为 T-501c。 - 验证:`python -m compileall app main.py tests` 通过;`python -m unittest discover -s tests -p test_gui.py` 通过(38 tests);`python -m unittest discover -s tests` 通过(94 tests)。 + +## 【2026-06-29】T-501c Tab⑤ Shopee 更新安全开关 + +- 状态:DONE +- 配置:`app/appconfig.py` 的 `config.json` 默认值新增 `shopee_update` 段:测试商品 ID、允许真实提交、允许更新封面、单次最大更新条数、成功后关闭本次新开编辑页。默认关闭真实提交和封面更新,单次最大更新 1 条。 +- GUI:`SettingsTab` 新增 Shopee 更新安全表单并持久化;`ApplyTab.start_update()` 在弹确认前读取安全设置,拦截未开启真实提交、超过最大条数、包含非测试商品 ID、或包含新封面但未允许封面更新的任务。确认弹窗会展示当前安全设置。 +- 执行:`ApplyWorker` 将 `close_success_tab` 传给 `editor.apply_task()`;`editor.apply_task()` 在成功提交、且商品页为本轮程序自动新建时,可按设置关闭该 tab。失败任务和复用的用户已有 tab 保留现场。 +- 测试:`tests/test_gui.py` 覆盖安全设置默认值、保存到 `config.json`、⑤保存后③立即读取共享安全配置、③安全拦截、最大条数/测试商品 ID 校验、确认后 worker 接收 `close_success_tab`;`tests/test_editor_login.py` 覆盖成功提交后关闭本轮新开 tab、失败时不关闭。 +- 文档:`docs/06-tasks.md` 将 T-501c 标为 DONE;同步 `docs/04-architecture.md`、`docs/api.md`、`docs/routes.md`、`docs/current-state.md`,下一个可领取任务更新为 T-404。 +- 验证:`python -m unittest discover -s tests -p test_gui.py` 通过(42 tests);`python -m unittest discover -s tests -p test_editor_login.py` 通过(11 tests);`python -m compileall app main.py tests` 通过;`python -m unittest discover -s tests` 通过(100 tests)。未执行真实 Shopee 提交,T-404 仍需在测试商品上手动冒烟。 diff --git a/tests/test_editor_login.py b/tests/test_editor_login.py index ebe5a18..4d52b48 100644 --- a/tests/test_editor_login.py +++ b/tests/test_editor_login.py @@ -244,6 +244,55 @@ class EditorLoginTests(unittest.TestCase): self.assertTrue(cdp.closed) close_tab.assert_not_called() + def test_apply_task_closes_auto_created_product_tab_after_success_when_enabled(self): + cdp = FakeProductCDP("ws-new") + cdp.target_id = "target-new" + cdp.created_by_app = True + cdp.cdp_host = "127.0.0.1:9222" + + with mock.patch("app.editor.open_product", return_value=cdp), mock.patch( + "app.editor.change_title", + return_value={"ok": True, "reason": None}, + ) as change_title, mock.patch("app.editor.replace_cover") as replace_cover, mock.patch( + "app.editor.click_update", + return_value={"clicked": True, "reason": None}, + ), mock.patch("app.editor.close_tab", return_value=True) as close_tab: + result = editor.apply_task( + {"debug_port": 9222}, + {"item_id": "51100639510", "new_title": "新标题"}, + close_success_tab=True, + ) + + self.assertTrue(result["committed"]) + change_title.assert_called_once_with(cdp, "新标题") + replace_cover.assert_not_called() + self.assertTrue(cdp.closed) + close_tab.assert_called_once_with("target-new", host="127.0.0.1:9222") + + def test_apply_task_keeps_product_tab_after_failed_update(self): + cdp = FakeProductCDP("ws-new") + cdp.target_id = "target-new" + cdp.created_by_app = True + cdp.cdp_host = "127.0.0.1:9222" + + with mock.patch("app.editor.open_product", return_value=cdp), mock.patch( + "app.editor.change_title", + return_value={"ok": True, "reason": None}, + ), mock.patch( + "app.editor.click_update", + return_value={"clicked": False, "reason": "UPDATE_DISABLED"}, + ), mock.patch("app.editor.close_tab") as close_tab: + result = editor.apply_task( + {"debug_port": 9222}, + {"item_id": "51100639510", "new_title": "新标题"}, + close_success_tab=True, + ) + + self.assertFalse(result["committed"]) + self.assertEqual("UPDATE_DISABLED", result["error"]) + self.assertTrue(cdp.closed) + close_tab.assert_not_called() + if __name__ == "__main__": unittest.main() diff --git a/tests/test_gui.py b/tests/test_gui.py index 39a74ca..efa37cf 100644 --- a/tests/test_gui.py +++ b/tests/test_gui.py @@ -57,6 +57,23 @@ class GuiTests(TempDirMixin, unittest.TestCase): "config_path": os.path.join(temp_dir, "config.json"), } + def allow_shopee_update( + self, + cfg, + item_id="51100639510", + allow_cover=True, + max_items=1, + close_success_tab=False, + ): + cfg["shopee_update"] = { + "test_item_id": item_id, + "allow_real_submit": True, + "allow_cover_update": allow_cover, + "max_items_per_run": max_items, + "close_success_tab": close_success_tab, + } + return cfg + def test_main_window_has_five_tabs_in_workflow_order(self): with self.make_temp_dir() as temp_dir: window = MainWindow(config=self.make_config(temp_dir)) @@ -132,6 +149,11 @@ class GuiTests(TempDirMixin, unittest.TestCase): self.assertEqual(QLineEdit.Password, tab.api_key_edit.echoMode()) self.assertEqual(11, tab.connect_timeout_spin.value()) self.assertFalse(tab.delete_model_button.isEnabled()) + self.assertEqual("51100639510", tab.test_item_id_edit.text()) + self.assertFalse(tab.allow_real_submit_checkbox.isChecked()) + self.assertFalse(tab.allow_cover_update_checkbox.isChecked()) + self.assertEqual(1, tab.max_items_per_run_spin.value()) + self.assertFalse(tab.close_success_tab_checkbox.isChecked()) self.assert_removed(temp_dir) @@ -319,6 +341,11 @@ class GuiTests(TempDirMixin, unittest.TestCase): tab.debug_port_start_spin.setValue(9300) tab.debug_port_end_spin.setValue(9350) tab.cdp_ready_timeout_spin.setValue(45) + tab.test_item_id_edit.setText("51100639510") + tab.allow_real_submit_checkbox.setChecked(True) + tab.allow_cover_update_checkbox.setChecked(True) + tab.max_items_per_run_spin.setValue(2) + tab.close_success_tab_checkbox.setChecked(True) tab.save_app_settings() @@ -337,12 +364,50 @@ class GuiTests(TempDirMixin, unittest.TestCase): self.assertEqual(9300, saved["default_debug_port"]) self.assertEqual([9300, 9350], saved["debug_port_range"]) self.assertEqual(45, saved["cdp_ready_timeout"]) + self.assertEqual( + { + "test_item_id": "51100639510", + "allow_real_submit": True, + "allow_cover_update": True, + "max_items_per_run": 2, + "close_success_tab": True, + }, + saved["shopee_update"], + ) self.assertNotIn("ai_models_path", saved) self.assertNotIn("config_path", saved) self.assertIn("设置已保存", statuses[-1]) self.assert_removed(temp_dir) + def test_settings_save_updates_apply_tab_shared_safety_config(self): + with self.make_temp_dir() as temp_dir: + cfg = self.make_config(temp_dir) + window = MainWindow( + config=cfg, + config_path=cfg["config_path"], + ai_models_path=cfg["ai_models_path"], + ) + self.addCleanup(window.close) + settings_tab = window.tabs.widget(TAB_TITLES.index("⑤ 设置")) + apply_tab = window.tabs.widget(TAB_TITLES.index("③ 更新shopee")) + + settings_tab.test_item_id_edit.setText("123456789") + settings_tab.allow_real_submit_checkbox.setChecked(True) + settings_tab.allow_cover_update_checkbox.setChecked(True) + settings_tab.max_items_per_run_spin.setValue(3) + settings_tab.close_success_tab_checkbox.setChecked(True) + settings_tab.save_app_settings() + + safety_cfg = apply_tab._shopee_update_config() + self.assertEqual("123456789", safety_cfg["test_item_id"]) + self.assertTrue(safety_cfg["allow_real_submit"]) + self.assertTrue(safety_cfg["allow_cover_update"]) + self.assertEqual(3, safety_cfg["max_items_per_run"]) + self.assertTrue(safety_cfg["close_success_tab"]) + + self.assert_removed(temp_dir) + def test_settings_tab_rejects_invalid_port_range(self): with self.make_temp_dir() as temp_dir: cfg = self.make_config(temp_dir) @@ -651,6 +716,7 @@ class GuiTests(TempDirMixin, unittest.TestCase): def test_apply_tab_start_update_requires_confirmation_before_starting_worker(self): with self.make_temp_dir() as temp_dir: cfg = self.make_config(temp_dir) + self.allow_shopee_update(cfg) accounts.create_account("主店", "alias-a", debug_port=9222, config=cfg) batch_id = db.create_batch(["input.xlsx"], path=cfg["db_path"]) db.insert_tasks( @@ -684,6 +750,7 @@ class GuiTests(TempDirMixin, unittest.TestCase): self.assertIn("任务数:1", message) self.assertIn("提交线上", message) self.assertIn("状态:已生成", message) + self.assertIn("测试商品ID=51100639510", message) self.assertEqual("已取消开始更新", statuses[-1]) apply_task.assert_not_called() @@ -692,6 +759,7 @@ class GuiTests(TempDirMixin, unittest.TestCase): def test_apply_tab_start_update_starts_apply_worker_after_confirmation(self): with self.make_temp_dir() as temp_dir: cfg = self.make_config(temp_dir) + self.allow_shopee_update(cfg, close_success_tab=True) accounts.create_account("主店", "alias-a", debug_port=9222, config=cfg) batch_id = db.create_batch(["input.xlsx"], path=cfg["db_path"]) db.insert_tasks( @@ -742,6 +810,7 @@ class GuiTests(TempDirMixin, unittest.TestCase): self.assertIsInstance(tab.apply_worker, ApplyWorker) self.assertIs(tab.apply_thread, fake_thread) self.assertTrue(fake_thread.started) + self.assertTrue(tab.apply_worker.close_success_tab) self.assertFalse(tab.start_update_button.isEnabled()) self.assertTrue(tab.stop_update_button.isEnabled()) self.assertEqual("开始更新:1 条", statuses[-1]) @@ -752,6 +821,107 @@ class GuiTests(TempDirMixin, unittest.TestCase): self.assert_removed(temp_dir) + def test_apply_tab_blocks_update_when_real_submit_switch_is_off(self): + with self.make_temp_dir() as temp_dir: + cfg = self.make_config(temp_dir) + accounts.create_account("主店", "alias-a", debug_port=9222, config=cfg) + batch_id = db.create_batch(["input.xlsx"], path=cfg["db_path"]) + db.insert_tasks( + batch_id, + [ + { + "source_file_abs": os.path.join(temp_dir, "input.xlsx"), + "source_sheet": "商品", + "source_row": 2, + "account_name": "Excel主店", + "alias": "alias-a", + "item_id": "51100639510", + } + ], + path=cfg["db_path"], + ) + task = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])[0] + db.set_collected(task.id, "旧标题", "old.jpg", path=cfg["db_path"]) + db.set_generated(task.id, "新标题", "new.jpg", path=cfg["db_path"]) + statuses = [] + tab = ApplyTab(config=cfg, status_callback=statuses.append) + self.addCleanup(tab.close) + + with mock.patch("app.gui.QMessageBox.warning") as warning, \ + mock.patch("app.gui.QMessageBox.question") as question, \ + mock.patch("app.gui.run_worker") as run_worker: + tab.start_update() + + message = warning.call_args[0][2] + self.assertIn("允许真实提交线上商品", message) + question.assert_not_called() + run_worker.assert_not_called() + self.assertIn("已阻止本次更新", statuses[-1]) + + self.assert_removed(temp_dir) + + def test_apply_tab_blocks_cover_update_when_cover_switch_is_off(self): + with self.make_temp_dir() as temp_dir: + cfg = self.make_config(temp_dir) + self.allow_shopee_update(cfg, allow_cover=False) + accounts.create_account("主店", "alias-a", debug_port=9222, config=cfg) + batch_id = db.create_batch(["input.xlsx"], path=cfg["db_path"]) + db.insert_tasks( + batch_id, + [ + { + "source_file_abs": os.path.join(temp_dir, "input.xlsx"), + "source_sheet": "商品", + "source_row": 2, + "account_name": "Excel主店", + "alias": "alias-a", + "item_id": "51100639510", + } + ], + path=cfg["db_path"], + ) + task = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])[0] + db.set_collected(task.id, "旧标题", "old.jpg", path=cfg["db_path"]) + db.set_generated(task.id, "新标题", "new.jpg", path=cfg["db_path"]) + tab = ApplyTab(config=cfg) + self.addCleanup(tab.close) + + with mock.patch("app.gui.QMessageBox.warning") as warning, \ + mock.patch("app.gui.QMessageBox.question") as question, \ + mock.patch("app.gui.run_worker") as run_worker: + tab.start_update() + + self.assertIn("允许更新封面", warning.call_args[0][2]) + question.assert_not_called() + run_worker.assert_not_called() + + self.assert_removed(temp_dir) + + def test_apply_tab_safety_error_limits_max_count_and_test_item(self): + with self.make_temp_dir() as temp_dir: + cfg = self.make_config(temp_dir) + self.allow_shopee_update(cfg, item_id="51100639510", max_items=1) + tab = ApplyTab(config=cfg) + self.addCleanup(tab.close) + + class Task: + def __init__(self, item_id, new_cover_path=None): + self.item_id = item_id + self.new_cover_path = new_cover_path + + max_error = tab._update_safety_error( + [ + Task("51100639510"), + Task("51100639510"), + ] + ) + item_error = tab._update_safety_error([Task("51100639511")]) + + self.assertIn("超过单次最大更新条数", max_error) + self.assertIn("非测试商品ID", item_error) + + self.assert_removed(temp_dir) + def test_apply_worker_applies_success_failure_and_unmatched_serially(self): with self.make_temp_dir() as temp_dir: cfg = self.make_config(temp_dir) @@ -793,11 +963,13 @@ class GuiTests(TempDirMixin, unittest.TestCase): db.set_generated(task.id, "新标题", "new.jpg", path=cfg["db_path"]) tasks = db.list_tasks(batch_id=batch_id, path=cfg["db_path"]) applied_aliases = [] + close_flags = [] progress = [] rows = [] - def fake_apply(account, task): + def fake_apply(account, task, close_success_tab=False): applied_aliases.append(account.alias) + close_flags.append(close_success_tab) if account.alias == "alias-a": return {"committed": True, "error": None} if account.alias == "alias-b": @@ -809,12 +981,18 @@ class GuiTests(TempDirMixin, unittest.TestCase): "app.gui.accounts.detect_login", return_value={"logged_in": True, "reason": None}, ), mock.patch("app.gui.editor.apply_task", side_effect=fake_apply): - worker = ApplyWorker(tasks, db_path=cfg["db_path"], config=cfg) + worker = ApplyWorker( + tasks, + db_path=cfg["db_path"], + config=cfg, + close_success_tab=True, + ) worker.progress.connect(progress.append) worker.row_updated.connect(lambda task_id, fields: rows.append((task_id, fields))) summary = worker.execute() self.assertEqual(["alias-a", "alias-b"], applied_aliases) + self.assertEqual([True, True], close_flags) self.assertEqual( { "ok": False,