fix(product-suite): allow completed source image removal

This commit is contained in:
chengma
2026-07-22 17:51:37 +08:00
parent 70c4323c52
commit 4550369bc1
6 changed files with 234 additions and 15 deletions
+67 -10
View File
@@ -871,11 +871,68 @@ def remove_asset_if_unused(asset_id, path=None, conn=None):
return asset return asset
def remove_original_assets_if_unused(project_id, asset_ids, path=None, conn=None): def _job_requires_original_assets(status, recovery_action):
"""Atomically remove unreferenced original assets from one project. """Return whether a job can still need its frozen source/reference assets."""
if status in {"pending", "submitted", "running"}:
return True
return recovery_action in {JOB_RECOVERY_RESUME, JOB_RECOVERY_REGENERATE}
def _original_asset_delete_blocker(database, project_id, asset_ids):
"""Return a user-facing blocker for deleting project original assets, if any."""
selected_ids = {int(asset_id) for asset_id in asset_ids}
placeholders = ",".join("?" for _ in selected_ids)
selection = database.execute(
f"""
SELECT 1 FROM image_studio_selections
WHERE project_id = ? AND asset_id IN ({placeholders})
LIMIT 1
""",
[int(project_id), *selected_ids],
).fetchone()
if selection is not None:
return "选中的图片已被终选引用,请先从终选中移除后再删除"
jobs = database.execute(
"""
SELECT id, status, recovery_action, source_asset_id, reference_asset_ids
FROM image_studio_jobs
WHERE project_id = ?
ORDER BY id
""",
(int(project_id),),
).fetchall()
for job in jobs:
status = str(job["status"] or "")
recovery_action = str(job["recovery_action"] or "")
if status not in JOB_STATUSES or recovery_action not in JOB_RECOVERY_ACTIONS:
return "生成任务状态无效,无法确认图片引用,不能删除"
try:
reference_ids = _parse_reference_asset_ids(
job["reference_asset_ids"],
job["source_asset_id"],
)
except db.DbError:
return "生成任务参考图快照无效,无法确认图片引用,不能删除"
if not _job_requires_original_assets(status, recovery_action):
continue
source_asset_id = job["source_asset_id"]
source_selected = (
source_asset_id is not None and int(source_asset_id) in selected_ids
)
if source_selected or selected_ids.intersection(reference_ids):
return "选中的图片仍被可继续处理的生成任务引用,请先完成、停止或放弃该任务后再删除"
return None
def remove_original_assets_if_unused(project_id, asset_ids, path=None, conn=None):
"""Atomically remove original assets that no runnable job or selection needs.
Completed, non-retryable jobs remain as history but do not block removal.
Local files are intentionally retained. If any requested asset is invalid or Local files are intentionally retained. If any requested asset is invalid or
referenced, no asset row is removed. still needed, no asset row is removed.
""" """
project_id = int(project_id) project_id = int(project_id)
@@ -906,13 +963,13 @@ def remove_original_assets_if_unused(project_id, asset_ids, path=None, conn=None
if set(by_id) != set(ordered_ids): if set(by_id) != set(ordered_ids):
raise db.DbError("选中的商品原图不存在或不属于当前项目") raise db.DbError("选中的商品原图不存在或不属于当前项目")
referenced_ids = [ blocker = _original_asset_delete_blocker(
asset_id database,
for asset_id in ordered_ids project_id,
if asset_reference_counts(asset_id, conn=database)["total"] ordered_ids,
] )
if referenced_ids: if blocker:
raise db.DbError("选中的图片正在被生成任务或终选引用,不能移除") raise db.DbError(blocker)
database.execute( database.execute(
f""" f"""
+1 -1
View File
@@ -465,7 +465,7 @@ data/images/<batch_id>/<slug>/<task_id>_<item_id>_new.<ext> # AI 生成的新
- T-658d 后逐图主图 checkbox 右侧显示可换行中文 helper,说明多SKU应逐图生成、同商品多角度应作为参考图一同提交。常规生成确认框从最终 specs 计算主图/参考图数量;未开启逐图主图且可用原图超过8张时,明确第1张主图加前7张参考图的上限和忽略数量。确认总点数仍只按 job 请求数计算,不因单请求图片数量重复计算。 - T-658d 后逐图主图 checkbox 右侧显示可换行中文 helper,说明多SKU应逐图生成、同商品多角度应作为参考图一同提交。常规生成确认框从最终 specs 计算主图/参考图数量;未开启逐图主图且可用原图超过8张时,明确第1张主图加前7张参考图的上限和忽略数量。确认总点数仍只按 job 请求数计算,不因单请求图片数量重复计算。
- T-647 后商品套图中的「AI帮写」不是②标题生成的复用入口:它使用 `vision_alias` 调 `POST /api/v1/analyze/images`,把当前项目中1至8张可用本地原图按 `source_order` 在一次请求中发送;超过8张时明确只使用前8张,原图勾选状态不改变该输入集合。视觉提示词将这些图片定义为同一商品项目的多角度、细节、包装或场景证据集,要求产出一份商品级联合分析,不按“图1/图2”逐图罗列;结果按商品概述、可确认卖点、适用人群与场景、套图画面要求、待确认或避免编造的信息组织,并把可见差异明确为待确认项,不强行合并为事实。单图上限10MiB、总计上限32MiB,缺图、未下载、超限或未配置别名时不发请求且保留用户现有卖点。读取等待固定120秒、连接等待沿用设置;读超时或网络中断只提示“结果未确认”,不自动重发。成功只显示图片张数、扣点和余额,不显示路径、base64、接口URL、完整提示词或上游原始响应;取消只在请求前/返回后协作生效,不强杀网络线程。 - T-647 后商品套图中的「AI帮写」不是②标题生成的复用入口:它使用 `vision_alias` 调 `POST /api/v1/analyze/images`,把当前项目中1至8张可用本地原图按 `source_order` 在一次请求中发送;超过8张时明确只使用前8张,原图勾选状态不改变该输入集合。视觉提示词将这些图片定义为同一商品项目的多角度、细节、包装或场景证据集,要求产出一份商品级联合分析,不按“图1/图2”逐图罗列;结果按商品概述、可确认卖点、适用人群与场景、套图画面要求、待确认或避免编造的信息组织,并把可见差异明确为待确认项,不强行合并为事实。单图上限10MiB、总计上限32MiB,缺图、未下载、超限或未配置别名时不发请求且保留用户现有卖点。读取等待固定120秒、连接等待沿用设置;读超时或网络中断只提示“结果未确认”,不自动重发。成功只显示图片张数、扣点和余额,不显示路径、base64、接口URL、完整提示词或上游原始响应;取消只在请求前/返回后协作生效,不强杀网络线程。
- `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`。服务不删除本地文件或蝦皮线上图片,任一资产校验失败时整批回滚。 - `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_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/下载迟到结果不得覆盖新一轮状态。
+1 -1
View File
@@ -445,7 +445,7 @@ export_generation_round(project_id, generation_round_key, parent_dir, path=None,
- T-649 后 `list_global_history_accounts()` 只返回含 job 且未软删除项目的历史账号别名,供历史窗口补齐已删除账号的可选项。`list_global_generation_rounds(account_alias=...)` 以 `projects.account_alias = ?` 精确筛选;传入该参数时优先于旧兼容参数 `account_query`,后者仅保留给既有调用的模糊查询。当前账号显示文本来自账号管理,历史缺失账号只显示明确的历史标识,UI 不通过显示文本反查筛选值。 - T-649 后 `list_global_history_accounts()` 只返回含 job 且未软删除项目的历史账号别名,供历史窗口补齐已删除账号的可选项。`list_global_generation_rounds(account_alias=...)` 以 `projects.account_alias = ?` 精确筛选;传入该参数时优先于旧兼容参数 `account_query`,后者仅保留给既有调用的模糊查询。当前账号显示文本来自账号管理,历史缺失账号只显示明确的历史标识,UI 不通过显示文本反查筛选值。
- 拉取蝦皮原主图只读:复用 `editor.open_product(..., bring_to_front=False)` 和 `editor.read_product_image_urls()`,不上传、不拖拽、不点击更新。 - 拉取蝦皮原主图只读:复用 `editor.open_product(..., bring_to_front=False)` 和 `editor.read_product_image_urls()`,不上传、不拖拽、不点击更新。
- 原图下载走 `image_studio_images` 的公网 URL、大小、Content-Type、重定向和 PIL 解码校验;只在用户单击时落盘。 - 原图下载走 `image_studio_images` 的公网 URL、大小、Content-Type、重定向和 PIL 解码校验;只在用户单击时落盘。
- `remove_original_assets_if_unused()` 会先校验整批原图的项目归属、资产类型及 job/终选引用,再在单个事务中删除资产行并连续重排 `source_order`;任一图片不可删除时整批不变,本地源文件和蝦皮线上图片始终保留。 - `remove_original_assets_if_unused()` 会先校验整批原图的项目归属、资产类型、终选直接引用,以及 job 的 `source_asset_id/reference_asset_ids` 快照;运行中或仍可 `resume/regenerate` 的任务阻断删除,`succeeded + recovery_action=none` 等不可再执行的历史 job 不阻断。参考图快照无效时保守阻断。删除在单个事务中完成并连续重排 `source_order`;历史 job/输出 asset 的外键关联按 SQLite 置空,状态、提示词、轮次和生成结果保留。任一图片不可删除时整批不变,本地源文件和蝦皮线上图片始终保留。
- T-679a 后,商品套图 job 固定持久化来源,不以运行时设置反推:默认网关为 `generation_source="cmhub"`、`provider="cmhub"`,沿用托管异步 `task_key/task_id/status/call_id/points_cost/points_balance` 及 submit/poll/download;自定义网关为 `generation_source="direct"`、`provider="openai_images_edits"`,`task_id` 必须为空,复用 T-678 的同步多图编辑请求,固定 `n=1`,收到图片字节即保存本地资产,不轮询、不写远程 URL、不自动重试。任何来源/提供方组合不精确匹配的 job 都拒绝执行,不能借 task ID 串到 cmhub。 - T-679a 后,商品套图 job 固定持久化来源,不以运行时设置反推:默认网关为 `generation_source="cmhub"`、`provider="cmhub"`,沿用托管异步 `task_key/task_id/status/call_id/points_cost/points_balance` 及 submit/poll/download;自定义网关为 `generation_source="direct"`、`provider="openai_images_edits"`,`task_id` 必须为空,复用 T-678 的同步多图编辑请求,固定 `n=1`,收到图片字节即保存本地资产,不轮询、不写远程 URL、不自动重试。任何来源/提供方组合不精确匹配的 job 都拒绝执行,不能借 task ID 串到 cmhub。
- direct job 进入 `running` 时保存仅用于本次进程的 `run_session_id`。只有应用启动阶段持有本地单进程恢复租约时,才将不属于活动会话的遗留 direct `running` job 标为失败,提示“程序中断,无法确认生成结果,请手动重新生成”;切换项目、刷新和恢复 cmhub 任务绝不触发该清理。同步请求返回图片后即使用户刚点击停止,也必须保存为成功;停止只取消未开始 job。连接/读取超时或进程中断的 direct job 不自动补发、不进入继续查询。 - direct job 进入 `running` 时保存仅用于本次进程的 `run_session_id`。只有应用启动阶段持有本地单进程恢复租约时,才将不属于活动会话的遗留 direct `running` job 标为失败,提示“程序中断,无法确认生成结果,请手动重新生成”;切换项目、刷新和恢复 cmhub 任务绝不触发该清理。同步请求返回图片后即使用户刚点击停止,也必须保存为成功;停止只取消未开始 job。连接/读取超时或进程中断的 direct job 不自动补发、不进入继续查询。
- `image_studio_assets.aspect_ratio` 始终保存用户选择值;新生成资产另保存可空的 `requested_output_size`、`rendered_width`、`rendered_height`。比例映射为 `1:1 -> 1024x1024`、`3:4/9:16 -> 1024x1536`、`4:3/16:9 -> 1536x1024`,后四种由运行结果返回近似比例标记。旧资产字段保持 `NULL`;初始化迁移只附加字段且可重复执行。 - `image_studio_assets.aspect_ratio` 始终保存用户选择值;新生成资产另保存可空的 `requested_output_size`、`rendered_width`、`rendered_height`。比例映射为 `1:1 -> 1024x1024`、`3:4/9:16 -> 1024x1536`、`4:3/16:9 -> 1536x1024`,后四种由运行结果返回近似比例标记。旧资产字段保持 `NULL`;初始化迁移只附加字段且可重复执行。
+1 -1
View File
@@ -218,7 +218,7 @@
- 项目仍以 `账号别名 + 商品ID` 唯一,复用 `image_studio_projects/assets/jobs`。`suite_settings_json` 保存平台、国家地区、语言、比例、逐图主图模式和分类数量;卖点文本继续使用 `draft_prompt`,用户停止输入约 500ms 后自动保存,切换任务、关闭任务或程序前同步补保存。未创建项目时只保留在当前任务内存,不因输入文字自动创建临时草稿。 - 项目仍以 `账号别名 + 商品ID` 唯一,复用 `image_studio_projects/assets/jobs`。`suite_settings_json` 保存平台、国家地区、语言、比例、逐图主图模式和分类数量;卖点文本继续使用 `draft_prompt`,用户停止输入约 500ms 后自动保存,切换任务、关闭任务或程序前同步补保存。未创建项目时只保留在当前任务内存,不因输入文字自动创建临时草稿。
- 「商品卖点与要求」关闭内部横向和纵向滚动条,输入框按完整换行内容自然增高;文本删除后可缩回最低高度,页面过长时统一由左侧 `suiteConfigScroll` 滚动。 - 「商品卖点与要求」关闭内部横向和纵向滚动条,输入框按完整换行内容自然增高;文本删除后可缩回最低高度,页面过长时统一由左侧 `suiteConfigScroll` 滚动。
- 商品原图最多16张。前6个槽位固定显示主图与参考1~5;列表关闭内部滚动条,按可用宽度换行并自然向下展开,由左侧配置区统一滚动。支持文件选择、外部拖入、剪贴板粘贴和列表内排序;第1张以「主图」文字、粗体和语义色标识,拖动任意图片到第一位即可设为主图,排序成功提示“已更新商品原图顺序,第一张图片为主图”。历史失效远程图不占有效名额;第1张是主参考图。 - 商品原图最多16张。前6个槽位固定显示主图与参考1~5;列表关闭内部滚动条,按可用宽度换行并自然向下展开,由左侧配置区统一滚动。支持文件选择、外部拖入、剪贴板粘贴和列表内排序;第1张以「主图」文字、粗体和语义色标识,拖动任意图片到第一位即可设为主图,排序成功提示“已更新商品原图顺序,第一张图片为主图”。历史失效远程图不占有效名额;第1张是主参考图。
- 每张真实原图左上角提供独立勾选框,标题行显示「已选 N 张 / 全选 / 反选」;添加占位图不参与选择。勾选只在当前任务界面内临时保留,普通刷新和排序按资产 ID 保留,切换任务或删除成功后清空。右键或 Delete 可批量移除,确认框说明准确数量、主图变化及非破坏性边界;生成中或勾选项仍在下载时整批阻断。移除只删除当前项目的本地资产记录,不删除本地源文件或蝦皮线上图片。 - 每张真实原图左上角提供独立勾选框,标题行显示「已选 N 张 / 全选 / 反选」;添加占位图不参与选择。勾选只在当前任务界面内临时保留,普通刷新和排序按资产 ID 保留,切换任务或删除成功后清空。右键或 Delete 可批量移除,确认框说明准确数量、主图变化及非破坏性边界;生成中或勾选项仍在下载时整批阻断。删除会继续保护终选直接引用、运行中和可继续查询/重试 job 的主图及参考图;已成功且不可重试的历史 job 不阻断删除,删除后可按剩余原图开始新一轮生成。移除只删除当前项目的本地资产记录,不删除本地源文件或蝦皮线上图片。
- 「拉取蝦皮主图」复用只读 CDP,读取 URL 后由最多2个下载 worker 后台落盘;不改标题/封面、不拖拽、不点击更新。拉取、下载期间其余界面和其他任务仍可操作,但商品原图列表仅锁定内部拖拽排序,并用 tooltip 说明“正在拉取蝦皮主图,完成后可调整图片顺序”;文件拖入、预览等非冲突操作不因此整页禁用。 - 「拉取蝦皮主图」复用只读 CDP,读取 URL 后由最多2个下载 worker 后台落盘;不改标题/封面、不拖拽、不点击更新。拉取、下载期间其余界面和其他任务仍可操作,但商品原图列表仅锁定内部拖拽排序,并用 tooltip 说明“正在拉取蝦皮主图,完成后可调整图片顺序”;文件拖入、预览等非冲突操作不因此整页禁用。
- 套图按分类配置生成图片,固定分类顺序为白底图、场景图、模特场景图、细节说明图、卖点图,之后是自定义分类;默认数量为白底图1、场景图2、模特场景图0、细节说明图0、卖点图2。新增分类默认数量为0,用户未主动配置时不增加生成任务或点数消耗。自定义分类名称非空、无空格、最多10字且不可重名。逐图主图开启后,白底图只生成一次,其余分类按每张有效原图展开。 - 套图按分类配置生成图片,固定分类顺序为白底图、场景图、模特场景图、细节说明图、卖点图,之后是自定义分类;默认数量为白底图1、场景图2、模特场景图0、细节说明图0、卖点图2。新增分类默认数量为0,用户未主动配置时不增加生成任务或点数消耗。自定义分类名称非空、无空格、最多10字且不可重名。逐图主图开启后,白底图只生成一次,其余分类按每张有效原图展开。
- 套图提示词设置弹窗默认模板首行使用“生成目标:{套图名称},{生成目标}”。五个内置分类使用统一固定目标描述;自定义分类使用“生成自定义分类图片:分类名称。”,实际分类名称必须进入最终提示词。旧模板若仍使用 `{套图名称}` 与 `{补充描述}` 会继续读取和渲染,不静默覆盖用户文件;新模板与旧模板都由同一个 renderer 提供预览和 job prompt。模板变量只在替换前校验,商品卖点与要求、分类名等业务文本允许原样包含 `{}`,不会再次按变量解析;发生其他渲染异常时显示「无法生成商品套图」,不创建 job、worker、价格查询或网关请求。 - 套图提示词设置弹窗默认模板首行使用“生成目标:{套图名称},{生成目标}”。五个内置分类使用统一固定目标描述;自定义分类使用“生成自定义分类图片:分类名称。”,实际分类名称必须进入最终提示词。旧模板若仍使用 `{套图名称}` 与 `{补充描述}` 会继续读取和渲染,不静默覆盖用户文件;新模板与旧模板都由同一个 renderer 提供预览和 job prompt。模板变量只在替换前校验,商品卖点与要求、分类名等业务文本允许原样包含 `{}`,不会再次按变量解析;发生其他渲染异常时显示「无法生成商品套图」,不创建 job、worker、价格查询或网关请求。
+8 -2
View File
@@ -3,7 +3,7 @@ id: T-689
title: 商品套图已完成历史任务不阻断原图删除 title: 商品套图已完成历史任务不阻断原图删除
phase: 7 phase: 7
deps: [T-631, T-658b] deps: [T-631, T-658b]
status: TODO status: DONE
created: 2026-07-22 created: 2026-07-22
--- ---
@@ -65,4 +65,10 @@ created: 2026-07-22
## 执行记录 ## 执行记录
- 待实施。 - 2026-07-22 完成。
- `remove_original_assets_if_unused()` 改用商品原图专用引用判定,不再由通用 `asset_reference_counts()` 的“所有历史 job 一律阻断”语义决定;通用资产删除和拉取停止清理逻辑保持不变。
- 删除前会校验终选直接引用,以及所有 job 的主图和 `reference_asset_ids` 快照。`pending/submitted/running`、或仍可 `resume/regenerate` 的失败/停止任务继续阻断;`succeeded + recovery_action=none` 和其它不可再执行的历史 job 不再阻断第二轮生成前的原图调整。
- 已完成历史原图删除后,SQLite 外键将 job 的 `source_asset_id` 和生成图 `parent_asset_id` 置空;历史 job 的状态、提示词、扣点、轮次、生成结果和参考图 JSON 快照保持可查。本地源文件与蝦皮线上图片均不删除。
- 参考图快照解析失败或 job 状态异常时采取保守阻断;批量删除继续保持全有或全无,并给出“可继续处理的生成任务”或“终选”中文原因。
- 已同步 `docs/04-architecture.md`、`docs/routes.md` 与 `docs/api.md`。新增服务层测试覆盖成功历史的主图/参考图可删、六种运行或可恢复状态的主图/参考图阻断、参考图快照无效阻断,以及历史外键置空后生成结果保持。
- 验证通过:`py -3.10 -m unittest tests.test_image_studio tests.test_product_suite tests.test_product_suite_gui`(105项)、`py -3.10 -m unittest discover -s tests`(672项)、`py -3.10 -m ruff check app tests main.py`、`py -3.10 -m compileall app main.py`、`git diff --check`。离屏 PySide6 输出字体目录/插件告警,但无测试失败。
+156
View File
@@ -574,6 +574,162 @@ class ImageStudioTests(TempDirMixin, unittest.TestCase):
self.assert_removed(temp_dir) self.assert_removed(temp_dir)
def test_remove_original_assets_allows_completed_history_source_and_reference(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",
account_slug="alias_slug",
item_id="51100639510",
path=db_path,
)
source = image_studio.add_asset(
project.id,
image_studio.ASSET_KIND_ORIGINAL,
source_order=1,
path=db_path,
)
reference = image_studio.add_asset(
project.id,
image_studio.ASSET_KIND_ORIGINAL,
source_order=2,
path=db_path,
)
output = image_studio.add_asset(
project.id,
"generated_main",
parent_asset_id=source.id,
path=db_path,
)
job = image_studio.create_job(
project.id,
source_asset_id=source.id,
reference_asset_ids=[reference.id],
path=db_path,
)
job = image_studio.update_job_status(
job.id,
"succeeded",
output_asset_id=output.id,
path=db_path,
)
self.assertEqual(image_studio.JOB_RECOVERY_NONE, job.recovery_action)
removed = image_studio.remove_original_assets_if_unused(
project.id,
[source.id, reference.id],
path=db_path,
)
self.assertEqual([source.id, reference.id], [asset.id for asset in removed])
self.assertIsNone(image_studio.get_asset(source.id, path=db_path))
self.assertIsNone(image_studio.get_asset(reference.id, path=db_path))
saved_job = image_studio.get_job(job.id, path=db_path)
saved_output = image_studio.get_asset(output.id, path=db_path)
self.assertIsNone(saved_job.source_asset_id)
self.assertEqual("[%d]" % reference.id, saved_job.reference_asset_ids)
self.assertIsNone(saved_output.parent_asset_id)
self.assert_removed(temp_dir)
def test_remove_original_assets_blocks_active_and_retryable_source_or_reference(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",
account_slug="alias_slug",
item_id="51100639510",
path=db_path,
)
statuses = ("pending", "submitted", "running", "failed", "expired", "cancelled")
for index, status in enumerate(statuses, 1):
source = image_studio.add_asset(
project.id,
image_studio.ASSET_KIND_ORIGINAL,
source_order=index * 2 - 1,
path=db_path,
)
reference = image_studio.add_asset(
project.id,
image_studio.ASSET_KIND_ORIGINAL,
source_order=index * 2,
path=db_path,
)
job = image_studio.create_job(
project.id,
source_asset_id=source.id,
reference_asset_ids=[reference.id],
path=db_path,
)
if status in {"submitted", "running"}:
job = image_studio.set_job_submitted(
job.id,
"task-%d" % index,
path=db_path,
)
if status == "running":
job = image_studio.update_job_status(job.id, "running", path=db_path)
elif status in {"failed", "expired", "cancelled"}:
job = image_studio.update_job_status(
job.id,
status,
recovery_action=image_studio.JOB_RECOVERY_REGENERATE,
path=db_path,
)
self.assertEqual(status, job.status)
with self.assertRaisesRegex(db.DbError, "可继续处理的生成任务引用"):
image_studio.remove_original_assets_if_unused(
project.id,
[reference.id],
path=db_path,
)
self.assertIsNotNone(image_studio.get_asset(reference.id, path=db_path))
self.assert_removed(temp_dir)
def test_remove_original_assets_blocks_invalid_reference_snapshot(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",
account_slug="alias_slug",
item_id="51100639510",
path=db_path,
)
source = image_studio.add_asset(
project.id,
image_studio.ASSET_KIND_ORIGINAL,
path=db_path,
)
job = image_studio.create_job(
project.id,
source_asset_id=source.id,
path=db_path,
)
job = image_studio.update_job_status(job.id, "succeeded", path=db_path)
conn = db.connect(db_path)
try:
with conn:
conn.execute(
"UPDATE image_studio_jobs SET reference_asset_ids = ? WHERE id = ?",
("{坏快照", job.id),
)
finally:
conn.close()
with self.assertRaisesRegex(db.DbError, "参考图快照无效"):
image_studio.remove_original_assets_if_unused(
project.id,
[source.id],
path=db_path,
)
self.assertIsNotNone(image_studio.get_asset(source.id, path=db_path))
self.assert_removed(temp_dir)
def test_sync_original_asset_urls_is_idempotent_and_marks_missing(self): def test_sync_original_asset_urls_is_idempotent_and_marks_missing(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")