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
+51 -37
View File
@@ -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:
+18 -5
View File
@@ -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:
+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 请求与输出资产元数据。
- `image_studio_projects.suite_settings_json` 持久化套图设置,旧数据库由 `db.init_db()` 原位补列,默认 `{}`;`draft_prompt` 继续保存卖点文本。`image_studio_assets` 中有效商品原图最多16张,历史 missing 记录不占有效名额;手工原图不会因再次同步蝦皮 URL 被误标 missing。商品套图原图列表的批量勾选只保存在当前 `SuiteTaskState` 对应的界面上下文,不写库;批量移除由 `remove_original_assets_if_unused()` 一次校验项目归属、原图类型、终选直接引用及 job 的主图/参考图快照,并在单个 SQLite 事务中删除资产行、连续重排 `source_order`。`pending/submitted/running` 或仍可 `resume/regenerate` 的任务继续保护其原图;`succeeded + recovery_action=none` 及其他已放弃终态只保留历史、不阻断删除。删除后 SQLite 外键将历史 job 的 `source_asset_id` 和输出图 `parent_asset_id` 置空,job 状态、提示词、扣点、轮次和生成结果保留;参考图 JSON 快照不改写。快照无法解析时保守阻断。服务不删除本地文件或蝦皮线上图片,任一资产校验失败时整批回滚。
- T-636 起,`image_studio_projects` 增加 `binding_state`(`draft` / `bound`)和稳定 `storage_key`。既有项目迁移为 `bound`,并以原 `item_id` 回填 `storage_key`;项目目录改用 `storage_key`,因此临时草稿绑定正式商品 ID 后不移动目录、不改写已有资产路径。草稿内部使用 `draft_<uuid>` 作为仅数据库可见的非空 `item_id`,GUI 输入框始终保持空白,用户日志和 cmhub 提示词只使用“临时草稿”或“未绑定商品”,不得暴露该内部值。
- 商品套图的正式商品 ID 统一经 `image_studio.normalize_item_id_input()` 清除首尾空白和 Unicode 格式控制字符,再由 `is_formal_item_id()` 按 ASCII 数字判定;空值只允许用于未绑定临时项目。商品 ID 失焦、拉取蝦皮主图、添加本地原图和开始生成在使用上下文前必须走同一提交入口。非法输入只在输入框提示,不得覆盖当前已绑定项目的商品 ID、原图、job 或生成结果;合法切换仍须用户确认。
- 在商品套图中,已选账号但未填写商品 ID 时允许输入卖点、导入、拖入或粘贴本地图片。非空卖点在现有防抖稳定后会创建一个可恢复临时草稿并保存到 `image_studio_projects.draft_prompt`;首次有效图片导入也会创建草稿。空白卖点、取消选择和全部导入失败不保留空草稿。只有卖点的草稿同样属于可恢复业务内容;卖点清空且没有资产/job 时仍可按空草稿规则清理。草稿可管理本地图片、AI 帮写、生成套图、查看历史和打开结果目录,但在创建 worker、启动 Chrome 或执行 CDP 前禁止「拉取蝦皮主图」。输入合法数字商品 ID 后,经确认原地绑定同一个 `project_id`;资产、job、selection、提示词、套图设置和 `storage_key` 均保持不变。若同账号目标 ID(含软删除项目)已存在则拒绝覆盖或合并。
- 商品套图拉取蝦皮主图使用独立内存 `pull_run_token` 覆盖 URL 读取和本轮后台下载。运行中按钮提供「停止并保留 / 停止并清除本次新增 / 继续拉取」;停止采用协作式安全边界,不强杀线程、Chrome 或已发出的 CDP/HTTP 请求。选择清除时只移除本轮新增、属于当前项目且未被 job/selection 引用的远程原图,并恢复拉取前已有原图的状态和顺序;本地手动导入图片、拉取前已有图片、用户文件和蝦皮线上图片不删除。拉取期间仅关闭商品原图的内部拖拽排序,避免同步刷新与用户排序竞争,文件导入、预览和其他非冲突操作仍保持可用;`reorder_originals()` 也会防御性拒绝迟到排序信号并按数据库顺序刷新。旧 token 的 URL/下载迟到结果不得覆盖新一轮状态。
- 启动时恢复未软删除、至少含一条资产或生成任务的草稿为独立中文“临时草稿”标签,按最近更新时间排序。关闭非空草稿可选择保留、软删除或取消;软删除不物理删除图片目录。③「更新蝦皮」只处理正式任务,不接受临时草稿。
+2
View File
@@ -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
+1 -1
View File
@@ -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 不阻断删除,删除后可按剩余原图开始新一轮生成。移除只删除当前项目的本地资产记录,不删除本地源文件或蝦皮线上图片。
+7 -2
View File
@@ -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 有字体目录/插件告警,但无测试失败。
+11
View File
@@ -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")
+38
View File
@@ -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)