fix(product-suite): validate item id before generation

This commit is contained in:
chengma
2026-07-22 18:14:50 +08:00
parent 189b5855e9
commit 01b4a05c26
8 changed files with 129 additions and 45 deletions
+58 -44
View File
@@ -14,6 +14,7 @@ from PySide6.QtCore import (
QIODevice, QIODevice,
QPointF, QPointF,
QRect, QRect,
QRegularExpression,
QSize, QSize,
Qt, Qt,
QTimer, QTimer,
@@ -29,6 +30,7 @@ from PySide6.QtGui import (
QKeySequence, QKeySequence,
QPainter, QPainter,
QPixmap, QPixmap,
QRegularExpressionValidator,
QWheelEvent, QWheelEvent,
) )
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
@@ -2163,6 +2165,9 @@ class ProductSuiteTab(QWidget):
self.item_id_edit = QLineEdit() self.item_id_edit = QLineEdit()
self.item_id_edit.setObjectName("suiteItemIdEdit") self.item_id_edit.setObjectName("suiteItemIdEdit")
self.item_id_edit.setPlaceholderText("输入商品ID") self.item_id_edit.setPlaceholderText("输入商品ID")
self.item_id_edit.setValidator(
QRegularExpressionValidator(QRegularExpression("^[0-9]*$"), self.item_id_edit)
)
item_width = max( item_width = max(
120, 120,
min(140, self.item_id_edit.fontMetrics().horizontalAdvance("0" * 13) + 30), 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: if self._loading or state is None:
return return
state.account_alias = str(self.account_combo.currentData() or "") 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.prompt = self.prompt_edit.toPlainText()
state.settings = self._settings_from_controls() state.settings = self._settings_from_controls()
@@ -2791,6 +2795,7 @@ class ProductSuiteTab(QWidget):
if index >= 0: if index >= 0:
self.account_combo.setCurrentIndex(index) self.account_combo.setCurrentIndex(index)
self.item_id_edit.setText(state.item_id) self.item_id_edit.setText(state.item_id)
self.item_id_edit.setStyleSheet("")
self.prompt_edit.setPlainText(state.prompt) self.prompt_edit.setPlainText(state.prompt)
self._set_combo_value(self.platform_combo, state.settings.get("platform")) self._set_combo_value(self.platform_combo, state.settings.get("platform"))
self._set_combo_value(self.country_combo, state.settings.get("country")) 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()) self.account_combo.setToolTip(self.account_combo.currentText())
def _on_item_finished(self): def _on_item_finished(self):
if self._loading or self._displayed_state is None: self._commit_item_id(self._displayed_state)
return
state = self._displayed_state def _restore_committed_item_id(self, state):
item_id = self.item_id_edit.text().strip()
if item_id and not item_id.isdigit():
self.item_id_edit.setStyleSheet("border: 1px solid #9a6700;")
self._status("商品ID只能输入数字", "warning")
self._update_context_actions(state)
return
self.item_id_edit.setStyleSheet("")
if self._is_draft_state(state):
if not item_id:
state.item_id = ""
self._update_context_actions(state)
return
if not self._confirm(
"绑定正式商品",
"将当前临时草稿绑定到商品%s吗?\n已添加图片和生成记录会继续保留。" % item_id,
):
self._loading = True self._loading = True
try: try:
self.item_id_edit.setText(state.item_id) self.item_id_edit.setText(state.item_id)
finally: finally:
self._loading = False 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) 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 True
if not self._confirm(
"绑定正式商品",
"将当前临时草稿绑定到商品%s吗?\n已添加图片和生成记录会继续保留。" % item_id,
):
self._restore_committed_item_id(state)
self._update_context_actions(state)
return False
self._flush_prompt_save(state) self._flush_prompt_save(state)
try: try:
project = image_studio.bind_draft_project( project = image_studio.bind_draft_project(
@@ -2896,47 +2915,36 @@ class ProductSuiteTab(QWidget):
) )
except image_studio.ImageStudioProjectConflictError as exc: except image_studio.ImageStudioProjectConflictError as exc:
self._message("商品项目已存在", _user_error(exc)) self._message("商品项目已存在", _user_error(exc))
self._loading = True self._restore_committed_item_id(state)
try:
self.item_id_edit.setText(state.item_id)
finally:
self._loading = False
self._update_context_actions(state) self._update_context_actions(state)
return return False
except Exception as exc: except Exception as exc:
self._message("绑定正式商品失败", _user_error(exc)) self._message("绑定正式商品失败", _user_error(exc))
self._loading = True self._restore_committed_item_id(state)
try:
self.item_id_edit.setText(state.item_id)
finally:
self._loading = False
self._update_context_actions(state) self._update_context_actions(state)
return return False
state.item_id = project.item_id state.item_id = project.item_id
state.project_binding_state = project.binding_state state.project_binding_state = project.binding_state
self._set_task_title(state) self._set_task_title(state)
self._status("临时草稿已绑定商品%s" % project.item_id, "success") self._status("临时草稿已绑定商品%s" % project.item_id, "success")
self._update_context_actions(state) self._update_context_actions(state)
return return True
if state.project_id is not None and item_id != state.item_id: if state.project_id is not None and item_id != state.item_id:
if not self._confirm( if not self._confirm(
"切换商品", "切换商品",
"切换商品ID后,原图和生成结果会按新商品重新载入。确认继续吗?", "切换商品ID后,原图和生成结果会按新商品重新载入。确认继续吗?",
): ):
self._loading = True self._restore_committed_item_id(state)
try: return False
self.item_id_edit.setText(state.item_id)
finally:
self._loading = False
return
self._clear_project_binding(state) self._clear_project_binding(state)
state.item_id = item_id state.item_id = item_id
if not item_id: if not item_id:
self._update_context_actions(state) self._update_context_actions(state)
return return True
if item_id and state.account_alias: if item_id and state.account_alias:
self._bind_project(state, load_existing=True) self._bind_project(state, load_existing=True)
self._update_context_actions(state) self._update_context_actions(state)
return True
def _clear_project_binding(self, state): def _clear_project_binding(self, state):
self._flush_prompt_save(state) self._flush_prompt_save(state)
@@ -3012,7 +3020,7 @@ class ProductSuiteTab(QWidget):
def _valid_context(self, state, *, show_message=True): def _valid_context(self, state, *, show_message=True):
if not self._has_account_context(state, show_message=show_message): if not self._has_account_context(state, show_message=show_message):
return False 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: if show_message:
self._message("商品ID无效", "请输入正确的数字商品ID。") self._message("商品ID无效", "请输入正确的数字商品ID。")
return False return False
@@ -3465,6 +3473,8 @@ class ProductSuiteTab(QWidget):
state = self._displayed_state state = self._displayed_state
if state is None: if state is None:
return return
if not self._commit_item_id(state):
return
self._save_controls_to_state(state) self._save_controls_to_state(state)
state.import_created_draft = False state.import_created_draft = False
created_project = state.project_id is None created_project = state.project_id is None
@@ -3662,6 +3672,8 @@ class ProductSuiteTab(QWidget):
if state.pull_running(): if state.pull_running():
self._request_stop_pull(state) self._request_stop_pull(state)
return return
if not self._commit_item_id(state):
return
self._save_controls_to_state(state) self._save_controls_to_state(state)
if self._is_draft_state(state): if self._is_draft_state(state):
self._message( self._message(
@@ -4801,6 +4813,10 @@ class ProductSuiteTab(QWidget):
retry_job_id=None, retry_job_id=None,
confirm_direct_retry=False, 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("生成商品套图"): if self.subscription_preflight_callback is not None and not self.subscription_preflight_callback("生成商品套图"):
return False return False
if not self._ensure_generation_gateway(): 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 retry_job_id = int(retry_job_id) if retry_job_id is not None else None
retrying = retry_job_id is not None retrying = retry_job_id is not None
confirm_batch = specs is None and not retrying confirm_batch = specs is None and not retrying
if state is self._displayed_state:
self._save_controls_to_state(state)
template_text = None template_text = None
if specs is None: if specs is None:
try: try:
+18 -5
View File
@@ -4,6 +4,7 @@ from __future__ import annotations
import json import json
import os import os
import unicodedata
import uuid import uuid
from contextlib import contextmanager from contextlib import contextmanager
from dataclasses import dataclass from dataclasses import dataclass
@@ -253,15 +254,27 @@ def _db_path(path=None, config=None) -> str:
return path or appconfig.db_path(config) 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() 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: if not text:
raise ImageStudioError("商品ID不能为空") raise ImageStudioError("商品ID不能为空")
return text return text
def is_formal_item_id(item_id) -> bool: 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: 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): def get_project_by_account_item(account_alias, item_id, path=None, conn=None, include_deleted=False):
clauses = ["account_alias = ?", "item_id = ?"] 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: if not include_deleted:
clauses.append("deleted_at IS NULL") clauses.append("deleted_at IS NULL")
sql = "SELECT * FROM image_studio_projects WHERE " + " AND ".join(clauses) sql = "SELECT * FROM image_studio_projects WHERE " + " AND ".join(clauses)
@@ -431,7 +444,7 @@ def create_or_get_project(
account_name=account_name, account_name=account_name,
account_slug=account_slug, 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: if not item:
raise db.DbError("AI工场项目缺少商品ID") raise db.DbError("AI工场项目缺少商品ID")
now = _now() now = _now()
@@ -535,7 +548,7 @@ def create_draft_project(
def bind_draft_project(project_id, item_id, path=None, conn=None): 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.""" """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): if not is_formal_item_id(item):
raise ImageStudioError("正式商品ID必须是数字") raise ImageStudioError("正式商品ID必须是数字")
with _connection(conn, path) as database: with _connection(conn, path) as database:
+1
View File
@@ -467,6 +467,7 @@ data/images/<batch_id>/<slug>/<task_id>_<item_id>_new.<ext> # 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 请求与输出资产元数据。 - `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 快照不改写。快照无法解析时保守阻断。服务不删除本地文件或蝦皮线上图片,任一资产校验失败时整批回滚。 - `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 提示词只使用“临时草稿”或“未绑定商品”,不得暴露该内部值。 - 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(含软删除项目)已存在则拒绝覆盖或合并。 - 在商品套图中,已选账号但未填写商品 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/下载迟到结果不得覆盖新一轮状态。 - 商品套图拉取蝦皮主图使用独立内存 `pull_run_token` 覆盖 URL 读取和本轮后台下载。运行中按钮提供「停止并保留 / 停止并清除本次新增 / 继续拉取」;停止采用协作式安全边界,不强杀线程、Chrome 或已发出的 CDP/HTTP 请求。选择清除时只移除本轮新增、属于当前项目且未被 job/selection 引用的远程原图,并恢复拉取前已有原图的状态和顺序;本地手动导入图片、拉取前已有图片、用户文件和蝦皮线上图片不删除。拉取期间仅关闭商品原图的内部拖拽排序,避免同步刷新与用户排序竞争,文件导入、预览和其他非冲突操作仍保持可用;`reorder_originals()` 也会防御性拒绝迟到排序信号并按数据库顺序刷新。旧 token 的 URL/下载迟到结果不得覆盖新一轮状态。
- 启动时恢复未软删除、至少含一条资产或生成任务的草稿为独立中文“临时草稿”标签,按最近更新时间排序。关闭非空草稿可选择保留、软删除或取消;软删除不物理删除图片目录。③「更新蝦皮」只处理正式任务,不接受临时草稿。 - 启动时恢复未软删除、至少含一条资产或生成任务的草稿为独立中文“临时草稿”标签,按最近更新时间排序。关闭非空草稿可选择保留、软删除或取消;软删除不物理删除图片目录。③「更新蝦皮」只处理正式任务,不接受临时草稿。
+2
View File
@@ -383,6 +383,8 @@ render_prompt(template_text, task) -> str
ImageStudioProject / ImageStudioAsset / ImageStudioJob / ImageStudioSelection ImageStudioProject / ImageStudioAsset / ImageStudioJob / ImageStudioSelection
ImageStudioHistoryAccount ImageStudioHistoryAccount
ImageStudioGenerationRound / ImageStudioHistoryRound 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 create_or_get_project(account_or_fields, item_id, ...) -> ImageStudioProject
list_projects(path=None) -> list[ImageStudioProject] list_projects(path=None) -> list[ImageStudioProject]
update_project_prompt(project_id, draft_prompt, path=None) -> ImageStudioProject update_project_prompt(project_id, draft_prompt, path=None) -> ImageStudioProject
+1 -1
View File
@@ -215,7 +215,7 @@
- 每个顶部任务标签持有独立账号、商品ID、设置、原图、当前 job 集合和 worker;任务可并行生成。切换任务不停止后台操作;关闭运行中任务先确认并协作式取消,线程引用保留到真正结束,避免 `QThread: Destroyed while thread is still running`。 - 每个顶部任务标签持有独立账号、商品ID、设置、原图、当前 job 集合和 worker;任务可并行生成。切换任务不停止后台操作;关闭运行中任务先确认并协作式取消,线程引用保留到真正结束,避免 `QThread: Destroyed while thread is still running`。
- 二级套图任务标签使用独立紧凑样式,不继承主模块 Tab 的大尺寸点击区。上下文栏左侧集中「历史生成 / 添加图片」,右侧集中账号、商品 ID 和拉取入口;常见 11~13 位商品 ID 不得裁切,长账号可通过 tooltip 查看完整名称。内部生成目录不提供顶部直达入口,正式取图统一通过历史窗口的「导出本轮」选择外部目录。 - 二级套图任务标签使用独立紧凑样式,不继承主模块 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` 滚动。 - 「商品卖点与要求」关闭内部横向和纵向滚动条,输入框按完整换行内容自然增高;文本删除后可缩回最低高度,页面过长时统一由左侧 `suiteConfigScroll` 滚动。
- 商品原图最多16张。前6个槽位固定显示主图与参考1~5;列表关闭内部滚动条,按可用宽度换行并自然向下展开,由左侧配置区统一滚动。支持文件选择、外部拖入、剪贴板粘贴和列表内排序;第1张以「主图」文字、粗体和语义色标识,拖动任意图片到第一位即可设为主图,排序成功提示“已更新商品原图顺序,第一张图片为主图”。历史失效远程图不占有效名额;第1张是主参考图。 - 商品原图最多16张。前6个槽位固定显示主图与参考1~5;列表关闭内部滚动条,按可用宽度换行并自然向下展开,由左侧配置区统一滚动。支持文件选择、外部拖入、剪贴板粘贴和列表内排序;第1张以「主图」文字、粗体和语义色标识,拖动任意图片到第一位即可设为主图,排序成功提示“已更新商品原图顺序,第一张图片为主图”。历史失效远程图不占有效名额;第1张是主参考图。
- 每张真实原图左上角提供独立勾选框,标题行显示「已选 N 张 / 全选 / 反选」;添加占位图不参与选择。勾选只在当前任务界面内临时保留,普通刷新和排序按资产 ID 保留,切换任务或删除成功后清空。右键或 Delete 可批量移除,确认框说明准确数量、主图变化及非破坏性边界;生成中或勾选项仍在下载时整批阻断。删除会继续保护终选直接引用、运行中和可继续查询/重试 job 的主图及参考图;已成功且不可重试的历史 job 不阻断删除,删除后可按剩余原图开始新一轮生成。移除只删除当前项目的本地资产记录,不删除本地源文件或蝦皮线上图片。 - 每张真实原图左上角提供独立勾选框,标题行显示「已选 N 张 / 全选 / 反选」;添加占位图不参与选择。勾选只在当前任务界面内临时保留,普通刷新和排序按资产 ID 保留,切换任务或删除成功后清空。右键或 Delete 可批量移除,确认框说明准确数量、主图变化及非破坏性边界;生成中或勾选项仍在下载时整批阻断。删除会继续保护终选直接引用、运行中和可继续查询/重试 job 的主图及参考图;已成功且不可重试的历史 job 不阻断删除,删除后可按剩余原图开始新一轮生成。移除只删除当前项目的本地资产记录,不删除本地源文件或蝦皮线上图片。
+7 -2
View File
@@ -3,7 +3,7 @@ id: T-690
title: 商品套图统一商品ID校验与生成前提交 title: 商品套图统一商品ID校验与生成前提交
phase: 7 phase: 7
deps: [T-688, T-689] deps: [T-688, T-689]
status: TODO status: DONE
created: 2026-07-22 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 有字体目录/插件告警,但无测试失败。
+11
View File
@@ -23,6 +23,17 @@ class ImageStudioTests(TempDirMixin, unittest.TestCase):
"cdp_ready_timeout": 1, "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): def test_init_db_adds_image_studio_tables_without_breaking_existing_tables(self):
with self.make_temp_dir() as temp_dir: with self.make_temp_dir() as temp_dir:
db_path = os.path.join(temp_dir, "cmshopee.db") db_path = os.path.join(temp_dir, "cmshopee.db")
+38
View File
@@ -3074,6 +3074,44 @@ class ProductSuiteGuiTests(TempDirMixin, unittest.TestCase):
self.assert_removed(temp_dir) 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): def test_pull_confirmation_always_shows_account_item_and_existing_count(self):
with self.make_temp_dir() as temp_dir: with self.make_temp_dir() as temp_dir:
config = self._config(temp_dir) config = self._config(temp_dir)