diff --git a/app/gui/tabs/product_suite.py b/app/gui/tabs/product_suite.py index a3b35fd..d08c117 100644 --- a/app/gui/tabs/product_suite.py +++ b/app/gui/tabs/product_suite.py @@ -14,6 +14,7 @@ from PySide6.QtCore import ( QIODevice, QPointF, QRect, + QRegularExpression, QSize, Qt, QTimer, @@ -29,6 +30,7 @@ from PySide6.QtGui import ( QKeySequence, QPainter, QPixmap, + QRegularExpressionValidator, QWheelEvent, ) from PySide6.QtWidgets import ( @@ -2163,6 +2165,9 @@ class ProductSuiteTab(QWidget): self.item_id_edit = QLineEdit() self.item_id_edit.setObjectName("suiteItemIdEdit") self.item_id_edit.setPlaceholderText("输入商品ID") + self.item_id_edit.setValidator( + QRegularExpressionValidator(QRegularExpression("^[0-9]*$"), self.item_id_edit) + ) item_width = max( 120, min(140, self.item_id_edit.fontMetrics().horizontalAdvance("0" * 13) + 30), @@ -2776,7 +2781,6 @@ class ProductSuiteTab(QWidget): if self._loading or state is None: return state.account_alias = str(self.account_combo.currentData() or "") - state.item_id = self.item_id_edit.text().strip() state.prompt = self.prompt_edit.toPlainText() state.settings = self._settings_from_controls() @@ -2791,6 +2795,7 @@ class ProductSuiteTab(QWidget): if index >= 0: self.account_combo.setCurrentIndex(index) self.item_id_edit.setText(state.item_id) + self.item_id_edit.setStyleSheet("") self.prompt_edit.setPlainText(state.prompt) self._set_combo_value(self.platform_combo, state.settings.get("platform")) self._set_combo_value(self.country_combo, state.settings.get("country")) @@ -2861,32 +2866,46 @@ class ProductSuiteTab(QWidget): self.account_combo.setToolTip(self.account_combo.currentText()) def _on_item_finished(self): - if self._loading or self._displayed_state is None: - return - state = self._displayed_state - item_id = self.item_id_edit.text().strip() - if item_id and not item_id.isdigit(): + self._commit_item_id(self._displayed_state) + + def _restore_committed_item_id(self, state): + self._loading = True + try: + self.item_id_edit.setText(state.item_id) + finally: + self._loading = False + + def _commit_item_id(self, state): + """Validate and commit the visible product ID before an operation uses it.""" + + if self._loading or state is None: + return False + raw_item_id = self.item_id_edit.text() + item_id = image_studio.normalize_item_id_input(raw_item_id) + if item_id != raw_item_id: + self._loading = True + try: + self.item_id_edit.setText(item_id) + finally: + self._loading = False + if item_id and not image_studio.is_formal_item_id(item_id): self.item_id_edit.setStyleSheet("border: 1px solid #9a6700;") self._status("商品ID只能输入数字", "warning") self._update_context_actions(state) - return + return False self.item_id_edit.setStyleSheet("") if self._is_draft_state(state): if not item_id: state.item_id = "" self._update_context_actions(state) - return + return True if not self._confirm( "绑定正式商品", "将当前临时草稿绑定到商品%s吗?\n已添加图片和生成记录会继续保留。" % item_id, ): - self._loading = True - try: - self.item_id_edit.setText(state.item_id) - finally: - self._loading = False + self._restore_committed_item_id(state) self._update_context_actions(state) - return + return False self._flush_prompt_save(state) try: project = image_studio.bind_draft_project( @@ -2896,47 +2915,36 @@ class ProductSuiteTab(QWidget): ) except image_studio.ImageStudioProjectConflictError as exc: self._message("商品项目已存在", _user_error(exc)) - self._loading = True - try: - self.item_id_edit.setText(state.item_id) - finally: - self._loading = False + self._restore_committed_item_id(state) self._update_context_actions(state) - return + return False except Exception as exc: self._message("绑定正式商品失败", _user_error(exc)) - self._loading = True - try: - self.item_id_edit.setText(state.item_id) - finally: - self._loading = False + self._restore_committed_item_id(state) self._update_context_actions(state) - return + return False state.item_id = project.item_id state.project_binding_state = project.binding_state self._set_task_title(state) self._status("临时草稿已绑定商品%s" % project.item_id, "success") self._update_context_actions(state) - return + return True if state.project_id is not None and item_id != state.item_id: if not self._confirm( "切换商品", "切换商品ID后,原图和生成结果会按新商品重新载入。确认继续吗?", ): - self._loading = True - try: - self.item_id_edit.setText(state.item_id) - finally: - self._loading = False - return + self._restore_committed_item_id(state) + return False self._clear_project_binding(state) state.item_id = item_id if not item_id: self._update_context_actions(state) - return + return True if item_id and state.account_alias: self._bind_project(state, load_existing=True) self._update_context_actions(state) + return True def _clear_project_binding(self, state): self._flush_prompt_save(state) @@ -3012,7 +3020,7 @@ class ProductSuiteTab(QWidget): def _valid_context(self, state, *, show_message=True): if not self._has_account_context(state, show_message=show_message): return False - if not state.item_id or not state.item_id.isdigit(): + if not image_studio.is_formal_item_id(state.item_id): if show_message: self._message("商品ID无效", "请输入正确的数字商品ID。") return False @@ -3465,6 +3473,8 @@ class ProductSuiteTab(QWidget): state = self._displayed_state if state is None: return + if not self._commit_item_id(state): + return self._save_controls_to_state(state) state.import_created_draft = False created_project = state.project_id is None @@ -3662,6 +3672,8 @@ class ProductSuiteTab(QWidget): if state.pull_running(): self._request_stop_pull(state) return + if not self._commit_item_id(state): + return self._save_controls_to_state(state) if self._is_draft_state(state): self._message( @@ -4801,6 +4813,10 @@ class ProductSuiteTab(QWidget): retry_job_id=None, confirm_direct_retry=False, ): + if state is self._displayed_state: + if not self._commit_item_id(state): + return False + self._save_controls_to_state(state) if self.subscription_preflight_callback is not None and not self.subscription_preflight_callback("生成商品套图"): return False if not self._ensure_generation_gateway(): @@ -4811,8 +4827,6 @@ class ProductSuiteTab(QWidget): retry_job_id = int(retry_job_id) if retry_job_id is not None else None retrying = retry_job_id is not None confirm_batch = specs is None and not retrying - if state is self._displayed_state: - self._save_controls_to_state(state) template_text = None if specs is None: try: diff --git a/app/image_studio.py b/app/image_studio.py index 3c3ffd1..13eccdc 100644 --- a/app/image_studio.py +++ b/app/image_studio.py @@ -4,6 +4,7 @@ from __future__ import annotations import json import os +import unicodedata import uuid from contextlib import contextmanager from dataclasses import dataclass @@ -253,15 +254,27 @@ def _db_path(path=None, config=None) -> str: return path or appconfig.db_path(config) -def _normalize_item_id(item_id) -> str: +def normalize_item_id_input(item_id) -> str: + """Normalize product ID input without accepting non-numeric formal IDs.""" + text = str(item_id or "").strip() + return "".join( + character + for character in text + if unicodedata.category(character) != "Cf" + ).strip() + + +def _normalize_item_id(item_id) -> str: + text = normalize_item_id_input(item_id) if not text: raise ImageStudioError("商品ID不能为空") return text def is_formal_item_id(item_id) -> bool: - return str(item_id or "").strip().isdigit() + text = normalize_item_id_input(item_id) + return bool(text) and all("0" <= character <= "9" for character in text) def is_draft_project(project) -> bool: @@ -404,7 +417,7 @@ def get_project(project_id, path=None, conn=None, include_deleted=False): def get_project_by_account_item(account_alias, item_id, path=None, conn=None, include_deleted=False): clauses = ["account_alias = ?", "item_id = ?"] - params = [str(account_alias).strip(), str(item_id).strip()] + params = [str(account_alias).strip(), normalize_item_id_input(item_id)] if not include_deleted: clauses.append("deleted_at IS NULL") sql = "SELECT * FROM image_studio_projects WHERE " + " AND ".join(clauses) @@ -431,7 +444,7 @@ def create_or_get_project( account_name=account_name, account_slug=account_slug, ) - item = str(item_id or _get(account, "item_id") or "").strip() + item = normalize_item_id_input(item_id or _get(account, "item_id") or "") if not item: raise db.DbError("AI工场项目缺少商品ID") now = _now() @@ -535,7 +548,7 @@ def create_draft_project( def bind_draft_project(project_id, item_id, path=None, conn=None): """Bind one active draft to a formal numeric item ID without moving its files.""" - item = str(item_id or "").strip() + item = normalize_item_id_input(item_id) if not is_formal_item_id(item): raise ImageStudioError("正式商品ID必须是数字") with _connection(conn, path) as database: diff --git a/docs/04-architecture.md b/docs/04-architecture.md index ee15f31..d1d634e 100644 --- a/docs/04-architecture.md +++ b/docs/04-architecture.md @@ -467,6 +467,7 @@ data/images///__new. # AI 生成的新 - `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_` 作为仅数据库可见的非空 `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/下载迟到结果不得覆盖新一轮状态。 - 启动时恢复未软删除、至少含一条资产或生成任务的草稿为独立中文“临时草稿”标签,按最近更新时间排序。关闭非空草稿可选择保留、软删除或取消;软删除不物理删除图片目录。③「更新蝦皮」只处理正式任务,不接受临时草稿。 diff --git a/docs/api.md b/docs/api.md index 030c9ba..a5d1caf 100644 --- a/docs/api.md +++ b/docs/api.md @@ -383,6 +383,8 @@ render_prompt(template_text, task) -> str 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 diff --git a/docs/routes.md b/docs/routes.md index e3895d2..c8fbfa4 100644 --- a/docs/routes.md +++ b/docs/routes.md @@ -215,7 +215,7 @@ - 每个顶部任务标签持有独立账号、商品ID、设置、原图、当前 job 集合和 worker;任务可并行生成。切换任务不停止后台操作;关闭运行中任务先确认并协作式取消,线程引用保留到真正结束,避免 `QThread: Destroyed while thread is still running`。 - 二级套图任务标签使用独立紧凑样式,不继承主模块 Tab 的大尺寸点击区。上下文栏左侧集中「历史生成 / 添加图片」,右侧集中账号、商品 ID 和拉取入口;常见 11~13 位商品 ID 不得裁切,长账号可通过 tooltip 查看完整名称。内部生成目录不提供顶部直达入口,正式取图统一通过历史窗口的「导出本轮」选择外部目录。 -- 项目仍以 `账号别名 + 商品ID` 唯一,复用 `image_studio_projects/assets/jobs`。`suite_settings_json` 保存平台、国家地区、语言、比例、逐图主图模式和分类数量;卖点文本继续使用 `draft_prompt`,用户停止输入约 500ms 后自动保存,切换任务、关闭任务或程序前同步补保存。未创建项目时只保留在当前任务内存,不因输入文字自动创建临时草稿。 +- 项目仍以 `账号别名 + 商品ID` 唯一,复用 `image_studio_projects/assets/jobs`。商品ID输入框只接受数字;提交时清除不可见格式字符,失焦、拉取、添加原图和生成共用提交校验。非数字输入只提示并保留在输入框,不覆盖当前已绑定项目;合法切换商品继续弹窗确认。`suite_settings_json` 保存平台、国家地区、语言、比例、逐图主图模式和分类数量;卖点文本继续使用 `draft_prompt`,用户停止输入约 500ms 后自动保存,切换任务、关闭任务或程序前同步补保存。未创建项目时只保留在当前任务内存,不因输入文字自动创建临时草稿。 - 「商品卖点与要求」关闭内部横向和纵向滚动条,输入框按完整换行内容自然增高;文本删除后可缩回最低高度,页面过长时统一由左侧 `suiteConfigScroll` 滚动。 - 商品原图最多16张。前6个槽位固定显示主图与参考1~5;列表关闭内部滚动条,按可用宽度换行并自然向下展开,由左侧配置区统一滚动。支持文件选择、外部拖入、剪贴板粘贴和列表内排序;第1张以「主图」文字、粗体和语义色标识,拖动任意图片到第一位即可设为主图,排序成功提示“已更新商品原图顺序,第一张图片为主图”。历史失效远程图不占有效名额;第1张是主参考图。 - 每张真实原图左上角提供独立勾选框,标题行显示「已选 N 张 / 全选 / 反选」;添加占位图不参与选择。勾选只在当前任务界面内临时保留,普通刷新和排序按资产 ID 保留,切换任务或删除成功后清空。右键或 Delete 可批量移除,确认框说明准确数量、主图变化及非破坏性边界;生成中或勾选项仍在下载时整批阻断。删除会继续保护终选直接引用、运行中和可继续查询/重试 job 的主图及参考图;已成功且不可重试的历史 job 不阻断删除,删除后可按剩余原图开始新一轮生成。移除只删除当前项目的本地资产记录,不删除本地源文件或蝦皮线上图片。 diff --git a/docs/tasks/T-690.md b/docs/tasks/T-690.md index daf386b..6eb66b7 100644 --- a/docs/tasks/T-690.md +++ b/docs/tasks/T-690.md @@ -3,7 +3,7 @@ id: T-690 title: 商品套图统一商品ID校验与生成前提交 phase: 7 deps: [T-688, T-689] -status: TODO +status: DONE created: 2026-07-22 --- @@ -52,4 +52,9 @@ created: 2026-07-22 ## 执行记录 -- 待实现。 +- 2026-07-22 完成。 +- `app/image_studio.py` 新增 `normalize_item_id_input()`,清除首尾空白和 Unicode 格式控制字符;`is_formal_item_id()` 改为仅接受 ASCII 数字。项目查询、创建和草稿绑定同步使用该规范化逻辑,避免不可见字符形成不同项目键。 +- `ProductSuiteTab` 的商品ID输入框增加数字 validator,并抽出 `_commit_item_id()` 作为唯一提交入口。失焦、添加本地原图、拉取蝦皮主图和开始生成都会先规范化、校验、处理草稿绑定或商品切换确认;`_save_controls_to_state()` 不再直接覆写 `state.item_id`,因此非法输入不会污染已绑定项目上下文。 +- 已绑定商品ID未变时,点击生成会继续原流程且不重复弹出切换确认;零宽格式字符会被自动清除。非数字、切换取消、项目冲突或绑定失败均保留已提交商品ID与项目,不创建 worker 或 job。 +- 已同步 `docs/04-architecture.md`、`docs/routes.md`、`docs/api.md`。新增服务层测试覆盖格式字符和 ASCII 数字判定,新增 GUI 测试覆盖已绑定项目的零宽字符规范化、非法生成入口阻断与取消切换恢复。 +- 验证通过:`py -3.10 -m unittest tests.test_image_studio tests.test_product_suite tests.test_product_suite_gui`(107项)、`py -3.10 -m unittest discover -s tests`(674项)、`py -3.10 -m ruff check app tests main.py`、`py -3.10 -m compileall app main.py`、`git diff --check`。离屏 PySide6 有字体目录/插件告警,但无测试失败。 diff --git a/tests/test_image_studio.py b/tests/test_image_studio.py index a3500ce..382d3b0 100644 --- a/tests/test_image_studio.py +++ b/tests/test_image_studio.py @@ -23,6 +23,17 @@ class ImageStudioTests(TempDirMixin, unittest.TestCase): "cdp_ready_timeout": 1, } + def test_formal_item_id_normalization_removes_format_characters_only(self): + zero_width_item_id = "51100639510" + chr(0x200B) + self.assertEqual( + "51100639510", + image_studio.normalize_item_id_input(" " + zero_width_item_id + " "), + ) + self.assertTrue(image_studio.is_formal_item_id(zero_width_item_id)) + self.assertFalse(image_studio.is_formal_item_id("51100639510-1")) + self.assertFalse(image_studio.is_formal_item_id("商品51100639510")) + self.assertFalse(image_studio.is_formal_item_id("123")) + def test_init_db_adds_image_studio_tables_without_breaking_existing_tables(self): with self.make_temp_dir() as temp_dir: db_path = os.path.join(temp_dir, "cmshopee.db") diff --git a/tests/test_product_suite_gui.py b/tests/test_product_suite_gui.py index 96896cf..93b8a95 100644 --- a/tests/test_product_suite_gui.py +++ b/tests/test_product_suite_gui.py @@ -3074,6 +3074,44 @@ class ProductSuiteGuiTests(TempDirMixin, unittest.TestCase): self.assert_removed(temp_dir) + def test_item_id_commit_normalizes_and_blocks_generation_before_state_changes(self): + with self.make_temp_dir() as temp_dir: + config = self._config(temp_dir) + project, _assets = self._create_project_with_assets(temp_dir, config, 1) + tab = ProductSuiteTab(config=config, db_path=config["db_path"]) + self.addCleanup(tab.close) + state = tab._displayed_state + state.account_alias = project.account_alias + state.item_id = project.item_id + state.project_id = project.id + state.project_binding_state = project.binding_state + tab._load_state(state) + + tab.item_id_edit.setText(project.item_id + chr(0x200B)) + with mock.patch.object(tab, "_ensure_generation_gateway", return_value=False) as gateway: + self.assertFalse(tab.start_generation(state)) + gateway.assert_called_once() + self.assertEqual(project.item_id, tab.item_id_edit.text()) + self.assertEqual(project.item_id, state.item_id) + self.assertEqual(project.id, state.project_id) + + tab.item_id_edit.setText(project.item_id + "-1") + with mock.patch.object(tab, "_ensure_generation_gateway", return_value=False) as gateway: + self.assertFalse(tab.start_generation(state)) + gateway.assert_not_called() + self.assertEqual(project.item_id, state.item_id) + self.assertEqual(project.id, state.project_id) + self.assertEqual(project.item_id + "-1", tab.item_id_edit.text()) + + tab.item_id_edit.setText("51100639511") + with mock.patch.object(tab, "_confirm", return_value=False): + self.assertFalse(tab._commit_item_id(state)) + self.assertEqual(project.item_id, state.item_id) + self.assertEqual(project.id, state.project_id) + self.assertEqual(project.item_id, tab.item_id_edit.text()) + + self.assert_removed(temp_dir) + def test_pull_confirmation_always_shows_account_item_and_existing_count(self): with self.make_temp_dir() as temp_dir: config = self._config(temp_dir)