From f3defdeb95364965c64a2bc969f3f40b67aa4d30 Mon Sep 17 00:00:00 2001 From: chengma Date: Thu, 16 Jul 2026 23:27:25 +0800 Subject: [PATCH] feat(product-suite): persist current generation rounds --- app/db.py | 34 +++++ app/gui/tabs/product_suite.py | 79 +++++++++++ app/gui/workers.py | 5 + app/image_studio.py | 232 +++++++++++++++++++++++++++++++- docs/04-architecture.md | 1 + docs/api.md | 1 + docs/routes.md | 2 +- docs/tasks/T-643.md | 25 ++-- tests/test_image_studio.py | 172 ++++++++++++++++++++++- tests/test_product_suite_gui.py | 214 +++++++++++++++++++++++++++++ tests/test_workers.py | 57 +++++++- 11 files changed, 806 insertions(+), 16 deletions(-) diff --git a/app/db.py b/app/db.py index b270624..e72412d 100644 --- a/app/db.py +++ b/app/db.py @@ -302,6 +302,7 @@ CREATE TABLE IF NOT EXISTS image_studio_projects ( target_detail_count INTEGER NOT NULL DEFAULT 12, draft_prompt TEXT, suite_settings_json TEXT NOT NULL DEFAULT '{}', + current_generation_round_key TEXT, status TEXT NOT NULL DEFAULT 'active', created_at TEXT NOT NULL, updated_at TEXT NOT NULL, @@ -351,6 +352,8 @@ CREATE TABLE IF NOT EXISTS image_studio_jobs ( points_cost INTEGER, points_balance INTEGER, call_id TEXT, + generation_round_key TEXT, + generation_slot_index INTEGER, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, submitted_at TEXT, @@ -484,6 +487,7 @@ def init_db(path=None, conn=None) -> None: _ensure_image_studio_project_suite_columns(database) _ensure_image_studio_project_draft_columns(database) _ensure_image_studio_job_recovery_columns(database) + _ensure_image_studio_generation_round_columns(database) def _ensure_batch_delete_columns(database): @@ -578,6 +582,36 @@ def _ensure_image_studio_job_recovery_columns(database): """ ) + +def _ensure_image_studio_generation_round_columns(database): + project_columns = { + row["name"] + for row in database.execute("PRAGMA table_info(image_studio_projects)").fetchall() + } + if "current_generation_round_key" not in project_columns: + database.execute( + "ALTER TABLE image_studio_projects " + "ADD COLUMN current_generation_round_key TEXT" + ) + + job_columns = { + row["name"] + for row in database.execute("PRAGMA table_info(image_studio_jobs)").fetchall() + } + if "generation_round_key" not in job_columns: + database.execute( + "ALTER TABLE image_studio_jobs ADD COLUMN generation_round_key TEXT" + ) + if "generation_slot_index" not in job_columns: + database.execute( + "ALTER TABLE image_studio_jobs ADD COLUMN generation_slot_index INTEGER" + ) + database.execute( + "CREATE INDEX IF NOT EXISTS idx_image_studio_jobs_generation_round " + "ON image_studio_jobs(" + "project_id, generation_round_key, generation_slot_index, id)" + ) + def create_batch(file_paths: Iterable[str], note=None, path=None, conn=None) -> str: batch_id = datetime.now().strftime("%Y%m%d_%H%M%S_") + uuid.uuid4().hex[:8] files = [os.path.abspath(file_path) for file_path in file_paths] diff --git a/app/gui/tabs/product_suite.py b/app/gui/tabs/product_suite.py index f763e6c..937db99 100644 --- a/app/gui/tabs/product_suite.py +++ b/app/gui/tabs/product_suite.py @@ -696,10 +696,12 @@ class SuiteTaskState: last_saved_prompt: str = "" settings: dict = field(default_factory=product_suite.default_suite_settings) current_job_ids: list = field(default_factory=list) + current_generation_round_key: str = "" show_history: bool = False generation_job_ids: list = field(default_factory=list) generation_mode: str = "batch" generation_retry_job_id: int = None + generation_round_key: str = "" worker: object = None thread: object = None generation_run_token: str = "" @@ -1457,6 +1459,7 @@ class ProductSuiteTab(QWidget): def _load_state(self, state): self._sync_state_project_binding(state) + self._restore_current_generation_results(state) self._loading = True try: self.custom_category_edit.hide() @@ -1723,6 +1726,8 @@ class ProductSuiteTab(QWidget): return None state.project_id = int(project.id) state.project_binding_state = project.binding_state + if previous_id != state.project_id: + self._restore_current_generation_results(state, force=True) stored_prompt = str(project.draft_prompt or "") if load_existing and previous_id != state.project_id: state.prompt = stored_prompt @@ -3101,6 +3106,25 @@ class ProductSuiteTab(QWidget): if not specs: self._message("生成数量为0", "请至少把一个套图分类的数量设为1。") return False + generation_round_key = "" + if retrying: + try: + original_job = image_studio.get_job(retry_job_id, path=self.db_path) + except Exception as exc: + self._message("读取重试图片失败", _user_error(exc)) + return False + if original_job is None or int(original_job.project_id) != int(state.project_id): + self._message("重试图片无效", "该图片不属于当前商品套图任务。") + return False + for spec in specs: + spec["generation_round_key"] = original_job.generation_round_key + spec["generation_slot_index"] = original_job.generation_slot_index + generation_round_key = str(original_job.generation_round_key or "") + else: + generation_round_key = uuid.uuid4().hex + for slot_index, spec in enumerate(specs): + spec["generation_round_key"] = generation_round_key + spec["generation_slot_index"] = slot_index if confirm_batch and not self._confirm( "确认生成商品套图", self._generation_confirmation_message( @@ -3118,6 +3142,7 @@ class ProductSuiteTab(QWidget): state.project_id, specs, run_token=run_token, + generation_round_key=generation_round_key or None, aspect_ratio=state.settings["ratio"], db_path=self.db_path, config=self.config, @@ -3130,6 +3155,7 @@ class ProductSuiteTab(QWidget): state.generation_job_ids = [] state.generation_mode = "retry" if retrying else "batch" state.generation_retry_job_id = retry_job_id + state.generation_round_key = generation_round_key state.done = 0 state.failed = 0 state.total = len(specs) @@ -3406,6 +3432,30 @@ class ProductSuiteTab(QWidget): current.append(job_id) state.current_job_ids = current + def _restore_current_generation_results(self, state, *, force=False, allow_running=False): + if state.project_id is None or ( + state.generation_running() and not allow_running + ): + return False + if state.current_job_ids and not force: + return False + try: + round_key = image_studio.get_current_generation_round( + state.project_id, + path=self.db_path, + ) + jobs = image_studio.list_generation_round_current_jobs( + state.project_id, + round_key, + path=self.db_path, + ) + except Exception as exc: + self._status("当前生成结果恢复失败:%s" % _user_error(exc), "danger") + return False + state.current_generation_round_key = str(round_key or "") + state.current_job_ids = [int(job.id) for job in jobs] + return True + def _generation_job_snapshot(self, state): job_ids = self._generation_job_ids(state) counts = { @@ -3501,12 +3551,40 @@ class ProductSuiteTab(QWidget): if state.started_at else 0 ) + promoted = False + if not retrying and state.generation_round_key and success: + try: + promoted = image_studio.promote_generation_round_if_success( + state.project_id, + state.generation_round_key, + path=self.db_path, + ) + except Exception as exc: + self._status("当前生成轮次保存失败:%s" % _user_error(exc), "danger") + self._restore_current_generation_results( + state, + force=True, + allow_running=True, + ) + elif not retrying: + self._restore_current_generation_results( + state, + force=True, + allow_running=True, + ) + elif state.generation_round_key: + self._restore_current_generation_results( + state, + force=True, + allow_running=True, + ) self._generation_run_states.pop(run_token, None) state.generation_run_token = "" state.generation_stop_requested = False state.generation_terminal_streak = 0 state.generation_job_ids = [] state.generation_retry_job_id = None + state.generation_round_key = "" state.worker = None state.thread = None state.done = success + failed + cancelled @@ -3530,6 +3608,7 @@ class ProductSuiteTab(QWidget): "active": active, "elapsed_seconds": elapsed, "mode": "retry" if retrying else "batch", + "current_round_promoted": promoted, }, level="WARNING" if active or result.get("ok") is False else "INFO", ) diff --git a/app/gui/workers.py b/app/gui/workers.py index 38d006f..4af53a9 100644 --- a/app/gui/workers.py +++ b/app/gui/workers.py @@ -334,6 +334,7 @@ class ProductSuiteGenerateWorker(BaseWorker): job_specs, *, run_token="", + generation_round_key=None, aspect_ratio="1:1", db_path=None, config=None, @@ -343,6 +344,7 @@ class ProductSuiteGenerateWorker(BaseWorker): self.project_id = int(project_id) self.job_specs = [dict(spec) for spec in (job_specs or [])] self.run_token = str(run_token or "") + self.generation_round_key = str(generation_round_key or "").strip() self.aspect_ratio = str(aspect_ratio or "1:1") self.db_path = db_path self.config = config @@ -366,6 +368,8 @@ class ProductSuiteGenerateWorker(BaseWorker): prompt=spec.get("prompt") or "", generation_source="cmhub", provider="cmhub", + generation_round_key=spec.get("generation_round_key") or self.generation_round_key or None, + generation_slot_index=spec.get("generation_slot_index"), path=self.db_path, ) ) @@ -410,6 +414,7 @@ class ProductSuiteGenerateWorker(BaseWorker): summary["job_ids"] = list(self.job_ids) summary["cancelled_count"] = int(summary.get("cancelled", 0) or 0) summary["run_token"] = self.run_token + summary["generation_round_key"] = self.generation_round_key or None return summary diff --git a/app/image_studio.py b/app/image_studio.py index 441be40..25f334c 100644 --- a/app/image_studio.py +++ b/app/image_studio.py @@ -49,6 +49,7 @@ class ImageStudioProject: target_detail_count: int draft_prompt: Optional[str] suite_settings_json: str + current_generation_round_key: Optional[str] status: str created_at: str updated_at: str @@ -91,6 +92,8 @@ class ImageStudioJob: points_cost: Optional[int] points_balance: Optional[int] call_id: Optional[str] + generation_round_key: Optional[str] + generation_slot_index: Optional[int] created_at: str updated_at: str submitted_at: Optional[str] @@ -108,6 +111,23 @@ class ImageStudioSelection: updated_at: str +@dataclass(frozen=True) +class ImageStudioGenerationRound: + project_id: int + generation_round_key: Optional[str] + created_at: Optional[str] + updated_at: Optional[str] + job_count: int + slot_count: int + succeeded_count: int + failed_count: int + cancelled_count: int + active_count: int + retry_count: int + is_current: bool + is_legacy: bool + + class ImageStudioError(RuntimeError): """Raised when the AI image studio service cannot complete an operation.""" @@ -1059,11 +1079,24 @@ def create_job( task_key=None, generation_source="cmhub", provider="cmhub", + generation_round_key=None, + generation_slot_index=None, path=None, conn=None, ): now = _now() task_key = str(task_key or _task_key(project_id)) + if generation_round_key is not None: + generation_round_key = str(generation_round_key).strip() + if not generation_round_key: + raise db.DbError("AI工场生成轮次标识不能为空") + if generation_slot_index is not None: + try: + generation_slot_index = int(generation_slot_index) + except (TypeError, ValueError) as exc: + raise db.DbError("AI工场生成槽位编号无效") from exc + if generation_slot_index < 0: + raise db.DbError("AI工场生成槽位编号不能小于0") with _connection(conn, path) as database: try: with database: @@ -1072,8 +1105,9 @@ def create_job( """ INSERT INTO image_studio_jobs (project_id, source_asset_id, generation_source, provider, - job_type, task_key, status, prompt, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, 'pending', ?, ?, ?) + job_type, task_key, status, prompt, generation_round_key, + generation_slot_index, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, 'pending', ?, ?, ?, ?, ?) """, ( int(project_id), @@ -1083,6 +1117,8 @@ def create_job( str(job_type), task_key, str(prompt or ""), + generation_round_key, + generation_slot_index, now, now, ), @@ -1120,6 +1156,198 @@ def list_jobs(project_id, *, statuses=None, path=None, conn=None): return _fetch_all(database, sql, params, ImageStudioJob) +def get_current_generation_round(project_id, path=None, conn=None): + project = get_project(project_id, path=path, conn=conn) + if project is None: + return None + value = project.current_generation_round_key + return str(value).strip() if value else None + + +def set_current_generation_round(project_id, generation_round_key, path=None, conn=None): + project_id = int(project_id) + round_key = None + if generation_round_key is not None: + round_key = str(generation_round_key).strip() + if not round_key: + raise db.DbError("当前生成轮次标识不能为空") + with _connection(conn, path) as database: + with database: + project = get_project(project_id, conn=database) + if project is None: + raise db.DbError("AI工场商品项目不存在或已删除") + if round_key is not None: + row = database.execute( + """ + SELECT 1 FROM image_studio_jobs + WHERE project_id = ? AND generation_round_key = ? + LIMIT 1 + """, + (project_id, round_key), + ).fetchone() + if row is None: + raise db.DbError("当前生成轮次不属于该商品项目") + database.execute( + """ + UPDATE image_studio_projects + SET current_generation_round_key = ?, updated_at = ? + WHERE id = ? AND deleted_at IS NULL + """, + (round_key, _now(), project_id), + ) + return get_project(project_id, conn=database) + + +def promote_generation_round_if_success(project_id, generation_round_key, path=None, conn=None): + project_id = int(project_id) + round_key = str(generation_round_key or "").strip() + if not round_key: + raise db.DbError("当前生成轮次标识不能为空") + with _connection(conn, path) as database: + with database: + project = get_project(project_id, conn=database) + if project is None: + raise db.DbError("AI工场商品项目不存在或已删除") + success = database.execute( + """ + SELECT 1 FROM image_studio_jobs + WHERE project_id = ? + AND generation_round_key = ? + AND status = 'succeeded' + LIMIT 1 + """, + (project_id, round_key), + ).fetchone() + if success is None: + return False + database.execute( + """ + UPDATE image_studio_projects + SET current_generation_round_key = ?, updated_at = ? + WHERE id = ? AND deleted_at IS NULL + """, + (round_key, _now(), project_id), + ) + return True + + +def list_generation_rounds(project_id, *, limit=None, offset=0, path=None, conn=None): + project_id = int(project_id) + try: + offset = max(0, int(offset or 0)) + except (TypeError, ValueError) as exc: + raise db.DbError("生成轮次偏移量无效") from exc + if limit is not None: + try: + limit = int(limit) + except (TypeError, ValueError) as exc: + raise db.DbError("生成轮次数量无效") from exc + if limit <= 0: + return [] + current_round_key = get_current_generation_round(project_id, path=path, conn=conn) + sql = """ + SELECT generation_round_key, + MIN(created_at) AS created_at, + MAX(updated_at) AS updated_at, + COUNT(*) AS job_count, + COUNT(DISTINCT generation_slot_index) AS slot_count, + SUM(CASE WHEN status = 'succeeded' THEN 1 ELSE 0 END) AS succeeded_count, + SUM(CASE WHEN status IN ('failed', 'expired') THEN 1 ELSE 0 END) AS failed_count, + SUM(CASE WHEN status = 'cancelled' THEN 1 ELSE 0 END) AS cancelled_count, + SUM(CASE WHEN status IN ('pending', 'submitted', 'running') THEN 1 ELSE 0 END) AS active_count + FROM image_studio_jobs + WHERE project_id = ? + GROUP BY generation_round_key + ORDER BY CASE WHEN generation_round_key IS NULL THEN 1 ELSE 0 END, + MAX(id) DESC + """ + params = [project_id] + if limit is not None: + sql += " LIMIT ? OFFSET ?" + params.extend([limit, offset]) + elif offset: + sql += " LIMIT -1 OFFSET ?" + params.append(offset) + with _connection(conn, path) as database: + rows = database.execute(sql, params).fetchall() + rounds = [] + for row in rows: + round_key = row["generation_round_key"] + job_count = int(row["job_count"] or 0) + slot_count = int(row["slot_count"] or 0) + rounds.append( + ImageStudioGenerationRound( + project_id=project_id, + generation_round_key=round_key, + created_at=row["created_at"], + updated_at=row["updated_at"], + job_count=job_count, + slot_count=slot_count, + succeeded_count=int(row["succeeded_count"] or 0), + failed_count=int(row["failed_count"] or 0), + cancelled_count=int(row["cancelled_count"] or 0), + active_count=int(row["active_count"] or 0), + retry_count=max(0, job_count - slot_count) if round_key else 0, + is_current=bool(round_key and round_key == current_round_key), + is_legacy=round_key is None, + ) + ) + return rounds + + +def list_generation_round_current_jobs(project_id, generation_round_key, path=None, conn=None): + project_id = int(project_id) + if generation_round_key is None: + sql = """ + SELECT * FROM image_studio_jobs + WHERE project_id = ? AND generation_round_key IS NULL + ORDER BY created_at ASC, id ASC + """ + params = (project_id,) + else: + round_key = str(generation_round_key).strip() + if not round_key: + raise db.DbError("生成轮次标识不能为空") + sql = """ + SELECT jobs.* + FROM image_studio_jobs AS jobs + INNER JOIN ( + SELECT generation_slot_index, MAX(id) AS latest_id + FROM image_studio_jobs + WHERE project_id = ? AND generation_round_key = ? + GROUP BY generation_slot_index + ) AS latest ON latest.latest_id = jobs.id + WHERE jobs.project_id = ? AND jobs.generation_round_key = ? + ORDER BY jobs.generation_slot_index ASC, jobs.id ASC + """ + params = (project_id, round_key, project_id, round_key) + with _connection(conn, path) as database: + return _fetch_all(database, sql, params, ImageStudioJob) + + +def list_generation_round_attempts(project_id, generation_round_key, path=None, conn=None): + project_id = int(project_id) + if generation_round_key is None: + sql = """ + SELECT * FROM image_studio_jobs + WHERE project_id = ? AND generation_round_key IS NULL + ORDER BY created_at ASC, id ASC + """ + params = (project_id,) + else: + round_key = str(generation_round_key).strip() + if not round_key: + raise db.DbError("生成轮次标识不能为空") + sql = """ + SELECT * FROM image_studio_jobs + WHERE project_id = ? AND generation_round_key = ? + ORDER BY generation_slot_index ASC, id ASC + """ + params = (project_id, round_key) + with _connection(conn, path) as database: + return _fetch_all(database, sql, params, ImageStudioJob) + + def set_job_submitted(job_id, task_id, *, call_id=None, points_cost=None, points_balance=None, path=None, conn=None): now = _now() with _connection(conn, path) as database: diff --git a/docs/04-architecture.md b/docs/04-architecture.md index 592077f..5f53a24 100644 --- a/docs/04-architecture.md +++ b/docs/04-architecture.md @@ -442,6 +442,7 @@ data/images///__new. # AI 生成的新 - 启动时恢复未软删除、至少含一条资产或生成任务的草稿为独立中文“临时草稿”标签,按最近更新时间排序。关闭非空草稿可选择保留、软删除或取消;软删除不物理删除图片目录。③「更新蝦皮」只处理正式任务,不接受临时草稿。 - 第六 Tab 的多个 `SuiteTaskState` 各自保留 generation/pull/import/AI/download worker 与线程引用;切换任务不取消任务。多个任务可并行,但 `image_studio_generation` 使用进程级 semaphore 保证所有套图任务合计最多5个 cmhub 在途 job。线程还在运行时关闭任务只请求协作式停止,模块级引用保留到 `QThread.finished`,不得提前销毁线程对象;下载前后均检查停止信号,停止后的临时文件不入资产库。 - T-639 后每轮套图生成使用仅存在内存的 `run_token` 隔离迟到信号,progress/finished/cancelled/failed 通过主线程绑定槽统一处理;正常 worker 结果、`QThread.finished` 和本轮 job 连续两次全部终态看门狗共同进入幂等 finalize。GUI 只按本轮明确 `job_ids` 判断完成,不用历史图片数量;即使最终信号丢失也会恢复按钮,旧线程引用仍保留到真实结束。停止为协作式:调度循环约每200ms检查标记并取消未开始 future,提交/轮询在有界请求返回后停止;requests 在流式数据块边界取消,Windows curl 由隐藏窗口 `Popen` 有界 terminate/kill。已有 `task_id` 的停止任务保留 resume,不假设服务端任务被取消或点数退回。 +- T-643 后 `image_studio_projects.current_generation_round_key` 是⑥主结果区的持久化当前轮次;每次常规「生成套图」创建独立 UUID `generation_round_key`,并为每条 `image_studio_jobs` 写入稳定的 `generation_slot_index`。内存 `run_token` 仍只用于线程和迟到信号隔离,不承担业务轮次语义。只有一轮中至少一条 job 成功时,才在同一 SQLite 事务内将该轮提升为当前轮;全失败或全取消保留原当前轮,部分成功则保留成功、失败槽位供后续重试。单张重试新建 job 但继承原轮次和槽位,当前展示按同轮同槽位最新 job、槽位顺序读取;所有尝试、错误、计费和输出资产均保留。旧 job 的轮次/槽位保持 NULL,查询层统一作为“旧版历史记录”,不以时间、文件名或数量猜测轮次;下一次成功的新格式整轮才建立当前轮。 提示词管理: diff --git a/docs/api.md b/docs/api.md index 681af8a..8ad0c07 100644 --- a/docs/api.md +++ b/docs/api.md @@ -409,6 +409,7 @@ export_project_selection(project_id, parent_dir, existing_mode="fail", path=None - 项目唯一键为账号别名 + 商品 ID;图片文件默认在 `data/images/pool///` 下分 `originals/generated/exports`,删除生成图进入项目内 `.trash` 并可撤销。 - `image_studio_projects.suite_settings_json` 保存平台/国家/语言/比例/逐图主图/分类数量;`draft_prompt` 保存商品卖点。有效原图上限16张,missing 历史不占名额。 +- T-643 后 `image_studio_projects.current_generation_round_key` 记录项目主界面的当前生成轮次;`image_studio_jobs.generation_round_key` 与 `generation_slot_index` 记录整轮归属和稳定展示槽位。`promote_generation_round_if_success()` 只会在同项目、同轮已存在成功 job 时原子切换当前轮;`list_generation_rounds()` 返回轮次摘要,`list_generation_round_current_jobs()` 返回每个槽位最新尝试,`list_generation_round_attempts()` 返回完整尝试历史。旧 job 保持 NULL 并按“旧版历史记录”兼容,不猜测轮次边界。 - 拉取蝦皮原主图只读:复用 `editor.open_product(..., bring_to_front=False)` 和 `editor.read_product_image_urls()`,不上传、不拖拽、不点击更新。 - 原图下载走 `image_studio_images` 的公网 URL、大小、Content-Type、重定向和 PIL 解码校验;只在用户单击时落盘。 - `remove_original_assets_if_unused()` 会先校验整批原图的项目归属、资产类型及 job/终选引用,再在单个事务中删除资产行并连续重排 `source_order`;任一图片不可删除时整批不变,本地源文件和蝦皮线上图片始终保留。 diff --git a/docs/routes.md b/docs/routes.md index f5979ec..61b9ae3 100644 --- a/docs/routes.md +++ b/docs/routes.md @@ -205,7 +205,7 @@ - 「拉取蝦皮主图」复用只读 CDP,读取 URL 后由最多2个下载 worker 后台落盘;不改标题/封面、不拖拽、不点击更新。拉取、下载期间其余界面和其他任务仍可操作。 - 套图只有一个图片类型,不再展示详情图、终选盘或模板 CRUD。默认分类为白底图1、场景图2、卖点图2;自定义分类名称非空、无空格、最多10字且不可重名。逐图主图开启后,白底图只生成一次,其余分类按每张有效原图展开。 - 平台、国家地区、语言和比例以四个带独立标签的同行下拉展示,选项只显示真实值;四项都写进每个 job 的完整提示词,比例还透传到 cmhub 生图请求,不是装饰字段。已有项目保存自己的完整设置;未绑定商品的新任务在重启后采用 `config.json` 的最近四项选择。生成仍走 `image_studio_generation.run_jobs()` 的 submit → poll → download 管线。 -- 生成按钮按当前总数显示并在运行时切换为「停止生成」;确认停止后显示「正在停止...」,重复点击不再弹确认框。每轮生成用独立运行标识隔离旧信号,本轮全部 job 终态或线程结束时都会统一恢复按钮;最终 worker 信号缺失时由数据库终态看门狗兜底,不要求用户重启。停止会取消未开始任务,已提交任务停止本地等待并保留后续继续查询语义;客户端不承诺取消服务端任务或退回点数。结果区显示本轮或历史 job;成功图可预览、复制路径、打开目录、重新生成、移入项目废纸篓并撤销,失败卡显示脱敏中文摘要与重试入口。 +- 生成按钮按当前总数显示并在运行时切换为「停止生成」;确认停止后显示「正在停止...」,重复点击不再弹确认框。每轮生成用独立运行标识隔离旧信号,本轮全部 job 终态或线程结束时都会统一恢复按钮;最终 worker 信号缺失时由数据库终态看门狗兜底,不要求用户重启。停止会取消未开始任务,已提交任务停止本地等待并保留后续继续查询语义;客户端不承诺取消服务端任务或退回点数。T-643 后项目持久化当前生成轮次:常规新轮至少成功一张才替换主结果区,全部失败/取消保留上一当前轮;主结果按稳定槽位显示同轮最新 job,单张重试留在原槽位。旧版无轮次 job 临时显示为“旧版历史记录”,不按时间或图片数量猜测归属。成功图可预览、复制路径、打开目录、重新生成、移入项目废纸篓并撤销,失败卡显示脱敏中文摘要与重试入口。 - AI帮写和生图按任务独立运行。AI帮写期间若用户改过卖点,返回后必须确认才覆盖;全部用户可见错误隐藏 URL/接口路径和敏感信息。 - ⑥只管理本地图片资产,不自动上传或修改蝦皮;③线上更新边界不受影响。旧 `ImageStudioTab` 留作代码兼容但不再作为主窗口入口。 diff --git a/docs/tasks/T-643.md b/docs/tasks/T-643.md index bbee431..01f376f 100644 --- a/docs/tasks/T-643.md +++ b/docs/tasks/T-643.md @@ -3,7 +3,7 @@ id: T-643 title: 商品套图持久化当前生成轮次 phase: 7 deps: [T-642] -status: TODO +status: DONE created: 2026-07-16 --- @@ -92,15 +92,15 @@ T-640 的单张失败重试继续新建 job,但必须: ## 验收要点 -- [ ] 商品完成一轮生成后,关闭软件并重新打开相同店铺和商品 ID,主结果区恢复同一轮、同一顺序的图片。 -- [ ] 再生成一整轮后,主界面只展示新当前轮,旧轮 job、图片、错误和计费记录仍保留。 -- [ ] 新轮全部失败或全部取消时,原当前轮继续显示;失败轮可以被历史查询读取。 -- [ ] 新轮部分成功时切换为当前轮,成功和失败槽位均可见,失败槽位可继续单张重试。 -- [ ] 单张失败重试沿用原轮次和槽位,其他当前图片不消失、不换序。 -- [ ] 程序重启后重试结果仍占据原槽位,原失败 job 仍存在。 -- [ ] 旧数据库可以自动迁移;旧 job 不被猜测性拆分、删除或覆盖。 -- [ ] 当前轮和历史轮查询不会跨店铺、跨商品或跨项目串数据。 -- [ ] 不影响生成停止、迟到信号隔离、恢复未完成 cmhub 任务和本地图片保存。 +- [x] 商品完成一轮生成后,关闭软件并重新打开相同店铺和商品 ID,主结果区恢复同一轮、同一顺序的图片。 +- [x] 再生成一整轮后,主界面只展示新当前轮,旧轮 job、图片、错误和计费记录仍保留。 +- [x] 新轮全部失败或全部取消时,原当前轮继续显示;失败轮可以被历史查询读取。 +- [x] 新轮部分成功时切换为当前轮,成功和失败槽位均可见,失败槽位可继续单张重试。 +- [x] 单张失败重试沿用原轮次和槽位,其他当前图片不消失、不换序。 +- [x] 程序重启后重试结果仍占据原槽位,原失败 job 仍存在。 +- [x] 旧数据库可以自动迁移;旧 job 不被猜测性拆分、删除或覆盖。 +- [x] 当前轮和历史轮查询不会跨店铺、跨商品或跨项目串数据。 +- [x] 不影响生成停止、迟到信号隔离、恢复未完成 cmhub 任务和本地图片保存。 ## 测试 @@ -138,4 +138,7 @@ git diff --check ## 执行记录 -- 待执行。 +- 2026-07-16:为 `image_studio_projects` 增加 `current_generation_round_key`,为 `image_studio_jobs` 增加 `generation_round_key`、`generation_slot_index` 及组合索引;旧数据库原位补列,既有 job 保持 NULL 作为旧版历史记录,不猜测轮次。 +- 2026-07-16:新增轮次当前值、原子成功提升、轮次摘要、当前槽位和完整尝试查询 API;常规生成写入 UUID 轮次和稳定槽位,单张重试继承原轮次和槽位。⑥重新绑定或重启后从持久化当前轮恢复结果;全失败/全取消恢复原当前轮,部分成功切换新轮。 +- 2026-07-16:生成轮次的 GUI 测试改为在 GUI 事件循环中等待 `QThread` 实际退出,避免在主线程直接阻塞 `thread.quit()` 的排队事件;连续运行 5 次均通过。 +- 验证:`py -3.10 -m unittest tests.test_image_studio tests.test_workers tests.test_product_suite_gui`(75 项通过);`py -3.10 -m unittest discover -s tests`(554 项通过);`py -3.10 -m ruff check app tests main.py`、`py -3.10 -m compileall app main.py`、`git diff --check` 均通过。 diff --git a/tests/test_image_studio.py b/tests/test_image_studio.py index 17369ad..b8baa80 100644 --- a/tests/test_image_studio.py +++ b/tests/test_image_studio.py @@ -62,6 +62,7 @@ class ImageStudioTests(TempDirMixin, unittest.TestCase): "target_main_count", "target_detail_count", "suite_settings_json", + "current_generation_round_key", "deleted_at", }.issubset(projects_columns) ) @@ -69,7 +70,20 @@ class ImageStudioTests(TempDirMixin, unittest.TestCase): row["name"] for row in conn.execute("PRAGMA table_info(image_studio_jobs)").fetchall() } - self.assertIn("recovery_action", jobs_columns) + self.assertTrue( + { + "recovery_action", + "generation_round_key", + "generation_slot_index", + }.issubset(jobs_columns) + ) + indexes = { + row["name"] + for row in conn.execute( + "PRAGMA index_list(image_studio_jobs)" + ).fetchall() + } + self.assertIn("idx_image_studio_jobs_generation_round", indexes) finally: conn.close() @@ -820,6 +834,162 @@ class ImageStudioTests(TempDirMixin, unittest.TestCase): self.assertEqual(image_studio.JOB_RECOVERY_REGENERATE, recovery_actions["legacy-failed"]) self.assertEqual(image_studio.JOB_RECOVERY_NONE, recovery_actions["legacy-success"]) + legacy_jobs = image_studio.list_generation_round_current_jobs( + 1, + None, + path=db_path, + ) + self.assertEqual([1, 2, 3, 4], [job.id for job in legacy_jobs]) + self.assertTrue( + all(job.generation_round_key is None for job in legacy_jobs) + ) + + self.assert_removed(temp_dir) + + def test_generation_round_queries_keep_slots_attempts_and_project_boundaries(self): + with self.make_temp_dir() as temp_dir: + db_path = os.path.join(temp_dir, "cmshopee.db") + db.init_db(db_path) + project = image_studio.create_or_get_project( + account_alias="alias-a", + account_slug="alias-a", + item_id="51100639510", + path=db_path, + ) + other_project = image_studio.create_or_get_project( + account_alias="alias-b", + account_slug="alias-b", + item_id="51100639510", + path=db_path, + ) + source = image_studio.add_asset( + project.id, + image_studio.ASSET_KIND_ORIGINAL, + path=db_path, + ) + legacy = image_studio.create_job( + project.id, + source_asset_id=source.id, + task_key="legacy-generation-round", + path=db_path, + ) + round_one = "round-one" + first = image_studio.create_job( + project.id, + source_asset_id=source.id, + task_key="round-one-slot-0", + generation_round_key=round_one, + generation_slot_index=0, + path=db_path, + ) + first = image_studio.update_job_status( + first.id, + "succeeded", + path=db_path, + ) + failed = image_studio.create_job( + project.id, + source_asset_id=source.id, + task_key="round-one-slot-1-failed", + generation_round_key=round_one, + generation_slot_index=1, + path=db_path, + ) + failed = image_studio.update_job_status( + failed.id, + "failed", + path=db_path, + ) + retry = image_studio.create_job( + project.id, + source_asset_id=source.id, + task_key="round-one-slot-1-retry", + generation_round_key=round_one, + generation_slot_index=1, + path=db_path, + ) + retry = image_studio.update_job_status( + retry.id, + "succeeded", + path=db_path, + ) + failed_round = "round-two-all-failed" + failed_round_job = image_studio.create_job( + project.id, + source_asset_id=source.id, + task_key="round-two-slot-0", + generation_round_key=failed_round, + generation_slot_index=0, + path=db_path, + ) + image_studio.update_job_status( + failed_round_job.id, + "failed", + path=db_path, + ) + + current = image_studio.set_current_generation_round( + project.id, + round_one, + path=db_path, + ) + self.assertEqual(round_one, current.current_generation_round_key) + self.assertEqual( + round_one, + image_studio.get_current_generation_round(project.id, path=db_path), + ) + self.assertFalse( + image_studio.promote_generation_round_if_success( + project.id, + failed_round, + path=db_path, + ) + ) + self.assertEqual( + round_one, + image_studio.get_current_generation_round(project.id, path=db_path), + ) + with self.assertRaisesRegex(db.DbError, "不属于"): + image_studio.set_current_generation_round( + other_project.id, + round_one, + path=db_path, + ) + + current_jobs = image_studio.list_generation_round_current_jobs( + project.id, + round_one, + path=db_path, + ) + self.assertEqual([first.id, retry.id], [job.id for job in current_jobs]) + self.assertEqual([0, 1], [job.generation_slot_index for job in current_jobs]) + attempts = image_studio.list_generation_round_attempts( + project.id, + round_one, + path=db_path, + ) + self.assertEqual([first.id, failed.id, retry.id], [job.id for job in attempts]) + legacy_jobs = image_studio.list_generation_round_current_jobs( + project.id, + None, + path=db_path, + ) + self.assertEqual([legacy.id], [job.id for job in legacy_jobs]) + + rounds = image_studio.list_generation_rounds(project.id, path=db_path) + self.assertEqual( + [failed_round, round_one, None], + [round_.generation_round_key for round_ in rounds], + ) + summary = rounds[1] + self.assertTrue(summary.is_current) + self.assertEqual(3, summary.job_count) + self.assertEqual(2, summary.slot_count) + self.assertEqual(1, summary.retry_count) + self.assertEqual(2, summary.succeeded_count) + self.assertEqual(1, summary.failed_count) + self.assertTrue(rounds[-1].is_legacy) + self.assert_removed(temp_dir) def test_selections_are_consecutive_unique_and_replaceable(self): diff --git a/tests/test_product_suite_gui.py b/tests/test_product_suite_gui.py index 6901bc4..e987f22 100644 --- a/tests/test_product_suite_gui.py +++ b/tests/test_product_suite_gui.py @@ -1358,6 +1358,220 @@ class ProductSuiteGuiTests(TempDirMixin, unittest.TestCase): self.assert_removed(temp_dir) + def test_generation_round_restores_after_project_rebind_and_retry_keeps_slot(self): + with self.make_temp_dir() as temp_dir: + config = self._config(temp_dir) + project, sources = self._create_project_with_assets(temp_dir, config, 1) + source = sources[0] + round_key = "persisted-round" + first = image_studio.create_job( + project.id, + source_asset_id=source.id, + job_type="白底图", + prompt="第一张", + generation_round_key=round_key, + generation_slot_index=0, + path=config["db_path"], + ) + first = image_studio.update_job_status( + first.id, + "succeeded", + path=config["db_path"], + ) + failed = image_studio.create_job( + project.id, + source_asset_id=source.id, + job_type="场景图", + prompt="第二张", + generation_round_key=round_key, + generation_slot_index=1, + path=config["db_path"], + ) + failed = image_studio.update_job_status( + failed.id, + "failed", + path=config["db_path"], + ) + image_studio.set_current_generation_round( + project.id, + round_key, + path=config["db_path"], + ) + + tab = ProductSuiteTab(config=config, db_path=config["db_path"]) + self.addCleanup(tab.close) + state = tab._displayed_state + state.account_alias = "alias-a" + state.item_id = project.item_id + tab._bind_project(state, load_existing=True) + self.assertEqual(round_key, state.current_generation_round_key) + self.assertEqual([first.id, failed.id], state.current_job_ids) + + retry = image_studio.create_job( + project.id, + source_asset_id=source.id, + job_type=failed.job_type, + prompt=failed.prompt, + generation_round_key=failed.generation_round_key, + generation_slot_index=failed.generation_slot_index, + path=config["db_path"], + ) + image_studio.update_job_status(retry.id, "succeeded", path=config["db_path"]) + + reopened = ProductSuiteTab(config=config, db_path=config["db_path"]) + self.addCleanup(reopened.close) + reopened_state = reopened._displayed_state + reopened_state.account_alias = "alias-a" + reopened_state.item_id = project.item_id + reopened._bind_project(reopened_state, load_existing=True) + self.assertEqual(round_key, reopened_state.current_generation_round_key) + self.assertEqual([first.id, retry.id], reopened_state.current_job_ids) + self.assertEqual( + [first.id, retry.id], + [job.id for job in reopened._jobs_for_state(reopened_state)], + ) + + self.assert_removed(temp_dir) + + def test_new_generation_round_promotes_partial_success_and_keeps_previous_on_failure(self): + with self.make_temp_dir() as temp_dir: + config = self._config(temp_dir) + project, sources = self._create_project_with_assets(temp_dir, config, 1) + source = sources[0] + old_round = "old-current-round" + old_job = image_studio.create_job( + project.id, + source_asset_id=source.id, + job_type="白底图", + prompt="旧结果", + generation_round_key=old_round, + generation_slot_index=0, + path=config["db_path"], + ) + old_job = image_studio.update_job_status( + old_job.id, + "succeeded", + path=config["db_path"], + ) + image_studio.set_current_generation_round( + project.id, + old_round, + path=config["db_path"], + ) + + tab = ProductSuiteTab(config=config, db_path=config["db_path"]) + self.addCleanup(tab.close) + state = tab._displayed_state + state.account_alias = "alias-a" + state.item_id = project.item_id + tab._bind_project(state, load_existing=True) + messages = [] + tab._message = lambda title, message, **kwargs: messages.append(title) + + first_run_jobs = [] + + def partial_success(jobs, **kwargs): + first_run_jobs[:] = list(jobs) + image_studio.update_job_status( + first_run_jobs[0].id, + "succeeded", + path=config["db_path"], + ) + image_studio.update_job_status( + first_run_jobs[1].id, + "failed", + path=config["db_path"], + ) + return {"total": 2, "success": 1, "failed": 1, "cancelled": 0} + + specs = [ + {"source_asset_id": source.id, "job_type": "白底图", "prompt": "新图1"}, + {"source_asset_id": source.id, "job_type": "场景图", "prompt": "新图2"}, + ] + with mock.patch( + "app.gui.workers.image_studio_generation.run_jobs", + side_effect=partial_success, + ): + self.assertTrue(tab.start_generation(state, specs=specs)) + first_thread = state.thread + deadline = time.monotonic() + 3 + while state.worker is not None and time.monotonic() < deadline: + QTest.qWait(20) + self.app.processEvents() + if first_thread is not None: + try: + deadline = time.monotonic() + 3 + while first_thread.isRunning() and time.monotonic() < deadline: + QTest.qWait(20) + self.app.processEvents() + self.assertFalse(first_thread.isRunning()) + except RuntimeError: + pass + + new_round = image_studio.get_current_generation_round( + project.id, + path=config["db_path"], + ) + self.assertNotEqual(old_round, new_round) + self.assertEqual( + [(new_round, 0), (new_round, 1)], + [ + (job.generation_round_key, job.generation_slot_index) + for job in first_run_jobs + ], + ) + self.assertEqual( + [job.id for job in first_run_jobs], + state.current_job_ids, + ) + + def all_failed(jobs, **kwargs): + for job in jobs: + image_studio.update_job_status( + job.id, + "failed", + path=config["db_path"], + ) + return {"total": 2, "success": 0, "failed": 2, "cancelled": 0} + + with mock.patch( + "app.gui.workers.image_studio_generation.run_jobs", + side_effect=all_failed, + ): + self.assertTrue(tab.start_generation(state, specs=specs)) + second_thread = state.thread + deadline = time.monotonic() + 3 + while state.worker is not None and time.monotonic() < deadline: + QTest.qWait(20) + self.app.processEvents() + if second_thread is not None: + try: + deadline = time.monotonic() + 3 + while second_thread.isRunning() and time.monotonic() < deadline: + QTest.qWait(20) + self.app.processEvents() + self.assertFalse(second_thread.isRunning()) + except RuntimeError: + pass + + self.assertEqual( + new_round, + image_studio.get_current_generation_round( + project.id, + path=config["db_path"], + ), + ) + self.assertEqual( + [job.id for job in first_run_jobs], + state.current_job_ids, + ) + self.assertEqual( + ["商品套图生成完成", "商品套图生成完成"], + messages, + ) + + self.assert_removed(temp_dir) + def test_retry_tracks_only_new_job_and_preserves_history_view(self): with self.make_temp_dir() as temp_dir: config = self._config(temp_dir) diff --git a/tests/test_workers.py b/tests/test_workers.py index 27b3a2a..04864b3 100644 --- a/tests/test_workers.py +++ b/tests/test_workers.py @@ -1,4 +1,5 @@ import os +import tempfile import unittest from types import SimpleNamespace from unittest import mock @@ -7,7 +8,7 @@ os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") from _helpers import REPO_ROOT # noqa: F401 -from app import image_studio_images, workers +from app import db, image_studio, image_studio_images, workers if workers.QT_IMPORT_ERROR is not None: raise unittest.SkipTest("PySide6 未安装") @@ -19,6 +20,7 @@ from app.workers import BaseWorker, run_worker from app.gui.workers import ( ImageStudioDownloadOriginalWorker, ImageStudioPullImagesWorker, + ProductSuiteGenerateWorker, ) @@ -196,6 +198,59 @@ class WorkerTests(unittest.TestCase): self.assertEqual({"asset_id": 12, "cancelled": True}, summary) + def test_product_suite_worker_writes_generation_round_and_stable_slots(self): + with tempfile.TemporaryDirectory() as temp_dir: + db_path = os.path.join(temp_dir, "cmshopee.db") + db.init_db(db_path) + project = image_studio.create_or_get_project( + account_alias="alias-a", + account_slug="alias-a", + item_id="51100639510", + path=db_path, + ) + source = image_studio.add_asset( + project.id, + image_studio.ASSET_KIND_ORIGINAL, + path=db_path, + ) + worker = ProductSuiteGenerateWorker( + project.id, + [ + { + "source_asset_id": source.id, + "job_type": "白底图", + "prompt": "第一张", + "generation_round_key": "worker-round", + "generation_slot_index": 0, + }, + { + "source_asset_id": source.id, + "job_type": "场景图", + "prompt": "第二张", + "generation_round_key": "worker-round", + "generation_slot_index": 1, + }, + ], + generation_round_key="worker-round", + db_path=db_path, + ) + + with mock.patch( + "app.gui.workers.image_studio_generation.run_jobs", + return_value={"total": 2, "success": 0, "failed": 0, "cancelled": 0}, + ): + summary = worker.execute() + + jobs = [image_studio.get_job(job_id, path=db_path) for job_id in worker.job_ids] + self.assertEqual("worker-round", summary["generation_round_key"]) + self.assertEqual( + [("worker-round", 0), ("worker-round", 1)], + [ + (job.generation_round_key, job.generation_slot_index) + for job in jobs + ], + ) + if __name__ == "__main__": unittest.main()