feat(generate): confirm product status scope
This commit is contained in:
@@ -5,6 +5,7 @@ from __future__ import annotations
|
||||
from PySide6.QtCore import QEvent, QRectF, QSize
|
||||
from PySide6.QtGui import QColor, QPainter
|
||||
|
||||
from ... import product_status
|
||||
from .. import file_manager
|
||||
from ..models import GenerateTaskTableModel
|
||||
from ..widgets import *
|
||||
@@ -1732,16 +1733,33 @@ class GenerateTab(QWidget):
|
||||
return
|
||||
generate_mode = self._current_generate_mode()
|
||||
generate_cover = appconfig.generate_mode_includes_cover(generate_mode)
|
||||
tasks = [
|
||||
task for task in self.model.tasks
|
||||
if ai.is_generatable_task(task, generate_mode=generate_mode)
|
||||
]
|
||||
if not tasks:
|
||||
base_candidates = self._generation_candidates(generate_mode)
|
||||
if not base_candidates:
|
||||
if generate_mode == "cover":
|
||||
self._set_status("当前筛选结果没有可生成的封面;请确认商品已完成采集且尚未生成新封面。")
|
||||
else:
|
||||
self._set_status("当前筛选结果没有可生成的缺失内容;请先在①导入采集完成采集,或调整生成内容")
|
||||
return
|
||||
preview_plan = product_status.build_generation_plan(
|
||||
base_candidates,
|
||||
generate_mode,
|
||||
)
|
||||
generation_scope = self._choose_generation_scope(preview_plan)
|
||||
if generation_scope is None:
|
||||
self._set_status("已取消 AI 生成")
|
||||
return
|
||||
current_plan = product_status.build_generation_plan(
|
||||
self._generation_candidates(generate_mode),
|
||||
generate_mode,
|
||||
generation_scope,
|
||||
)
|
||||
if current_plan["fingerprint"] != preview_plan["fingerprint"]:
|
||||
self._set_status("当前任务数据已变化,请重新开始生成")
|
||||
return
|
||||
tasks = current_plan["execution_tasks"]
|
||||
if not tasks:
|
||||
self._set_status("当前筛选结果没有状态正常的可生成任务;请先完成采集或选择生成所有状态的商品")
|
||||
return
|
||||
component_totals = ai.generation_component_totals(
|
||||
tasks,
|
||||
generate_mode=generate_mode,
|
||||
@@ -1756,6 +1774,10 @@ class GenerateTab(QWidget):
|
||||
db_path=self.db_path,
|
||||
config=self.config,
|
||||
diagnostic_log_dir=diagnostics.DEFAULT_LOG_DIR,
|
||||
generation_scope=generation_scope,
|
||||
product_status_counts=current_plan["status_counts"],
|
||||
status_scope_excluded=current_plan["scope_excluded"],
|
||||
generation_plan_fingerprint=current_plan["fingerprint"],
|
||||
)
|
||||
worker.progress.connect(self._on_generate_progress)
|
||||
worker.row_updated.connect(self._on_generate_row_updated)
|
||||
@@ -1790,6 +1812,54 @@ class GenerateTab(QWidget):
|
||||
self._set_status(f"开始 AI 生成:{len(tasks)} 条")
|
||||
thread.start()
|
||||
|
||||
def _generation_candidates(self, generate_mode):
|
||||
return [
|
||||
task
|
||||
for task in self.model.tasks
|
||||
if ai.is_generatable_task(task, generate_mode=generate_mode)
|
||||
]
|
||||
|
||||
def _choose_generation_scope(self, plan):
|
||||
status_counts = dict(plan.get("status_counts") or {})
|
||||
normal_count = status_counts.get(product_status.STATUS_NORMAL, 0)
|
||||
unlisted_count = status_counts.get(product_status.STATUS_UNLISTED, 0)
|
||||
reviewing_count = status_counts.get(product_status.STATUS_REVIEWING, 0)
|
||||
unknown_count = status_counts.get(product_status.STATUS_UNKNOWN, 0)
|
||||
total = len(plan.get("base_candidates") or [])
|
||||
non_normal_count = total - normal_count
|
||||
|
||||
box = QMessageBox(self)
|
||||
box.setIcon(QMessageBox.Question)
|
||||
box.setWindowTitle("选择生成范围")
|
||||
box.setText("请选择本轮要生成的商品范围。")
|
||||
box.setInformativeText(
|
||||
"真实候选共{total}条:正常{normal},未上架{unlisted},审核中{reviewing},状态未知{unknown}。\n"
|
||||
"生成所有状态的商品会让非正常状态商品也调用 AI,可能额外消耗点数。"
|
||||
"状态未知商品请优先回到①重新采集确认。".format(
|
||||
total=total,
|
||||
normal=normal_count,
|
||||
unlisted=unlisted_count,
|
||||
reviewing=reviewing_count,
|
||||
unknown=unknown_count,
|
||||
)
|
||||
)
|
||||
normal_button = box.addButton("只生成状态正常的商品", QMessageBox.AcceptRole)
|
||||
normal_button.setObjectName("generateNormalOnlyButton")
|
||||
all_button = box.addButton("生成所有状态的商品", QMessageBox.DestructiveRole)
|
||||
all_button.setObjectName("generateAllStatusesButton")
|
||||
all_button.setStyleSheet("color: #cf222e; font-weight: 600;")
|
||||
all_button.setEnabled(non_normal_count > 0)
|
||||
cancel_button = box.addButton("取消", QMessageBox.RejectRole)
|
||||
cancel_button.setObjectName("generateScopeCancelButton")
|
||||
box.setDefaultButton(normal_button)
|
||||
box.setEscapeButton(cancel_button)
|
||||
box.exec()
|
||||
if box.clickedButton() is normal_button:
|
||||
return product_status.SCOPE_NORMAL_ONLY
|
||||
if all_button.isEnabled() and box.clickedButton() is all_button:
|
||||
return product_status.SCOPE_ALL
|
||||
return None
|
||||
|
||||
def stop_generate(self, checked=False):
|
||||
if self.generate_worker is not None:
|
||||
self.generate_worker.cancel()
|
||||
|
||||
+36
-1
@@ -678,6 +678,10 @@ class GenerateWorker(BaseWorker):
|
||||
db_path=None,
|
||||
config=None,
|
||||
diagnostic_log_dir=None,
|
||||
generation_scope="all",
|
||||
product_status_counts=None,
|
||||
status_scope_excluded=0,
|
||||
generation_plan_fingerprint=None,
|
||||
):
|
||||
super().__init__()
|
||||
self.tasks = list(tasks)
|
||||
@@ -685,6 +689,15 @@ class GenerateWorker(BaseWorker):
|
||||
self.db_path = db_path
|
||||
self.config = config
|
||||
self.diagnostic_log_dir = diagnostic_log_dir
|
||||
self.generation_scope = product_status.normalize_scope(generation_scope)
|
||||
self.product_status_counts = {
|
||||
status: int((product_status_counts or {}).get(status, 0) or 0)
|
||||
for status in product_status.VALID_PRODUCT_STATUSES
|
||||
}
|
||||
self.status_scope_excluded = max(0, int(status_scope_excluded or 0))
|
||||
self.generation_plan_fingerprint = (
|
||||
str(generation_plan_fingerprint or "") or None
|
||||
)
|
||||
self._run_id = None
|
||||
self._account_by_alias = {}
|
||||
self._task_positions = {}
|
||||
@@ -713,6 +726,12 @@ class GenerateWorker(BaseWorker):
|
||||
task for task in self.tasks
|
||||
if ai.is_generatable_task(task, generate_mode=generate_mode)
|
||||
]
|
||||
if self.generation_scope == product_status.SCOPE_NORMAL_ONLY:
|
||||
eligible = [
|
||||
task
|
||||
for task in eligible
|
||||
if product_status.is_normal(getattr(task, "product_status", None))
|
||||
]
|
||||
component_totals = ai.generation_component_totals(
|
||||
eligible,
|
||||
generate_mode=generate_mode,
|
||||
@@ -757,10 +776,18 @@ class GenerateWorker(BaseWorker):
|
||||
title_concurrency=ai_cfg.get("title_concurrency", 1),
|
||||
started_at=self._run_started_at_text,
|
||||
)
|
||||
start_message += ";生成范围:{scope};按范围排除{excluded}条".format(
|
||||
scope=(
|
||||
"仅状态正常"
|
||||
if self.generation_scope == product_status.SCOPE_NORMAL_ONLY
|
||||
else "所有状态"
|
||||
),
|
||||
excluded=self.status_scope_excluded,
|
||||
)
|
||||
self._log_run_event(start_message)
|
||||
try:
|
||||
summary = ai.generate_batch(
|
||||
self.tasks,
|
||||
eligible,
|
||||
self.prompt_values,
|
||||
ai_cfg={
|
||||
"config": self.config,
|
||||
@@ -810,6 +837,10 @@ class GenerateWorker(BaseWorker):
|
||||
summary["billing_error"] = dict(self._billing_error)
|
||||
summary["ok"] = False
|
||||
summary["cancelled"] = True
|
||||
summary["generation_scope"] = self.generation_scope
|
||||
summary["product_status_counts"] = dict(self.product_status_counts)
|
||||
summary["status_scope_excluded"] = self.status_scope_excluded
|
||||
summary["generation_plan_fingerprint"] = self.generation_plan_fingerprint
|
||||
summary["run_id"] = self._run_id
|
||||
summary["batch_ids"] = batch_ids
|
||||
status = "failed" if summary.get("billing_error") or summary.get("error") else ("cancelled" if summary.get("cancelled") else "done")
|
||||
@@ -1131,6 +1162,10 @@ class GenerateWorker(BaseWorker):
|
||||
"image_concurrency": ai_cfg.get("image_concurrency"),
|
||||
"generate_cover": ai_cfg.get("generate_cover", False),
|
||||
"backend": ai_cfg.get("backend", "direct"),
|
||||
"generation_scope": self.generation_scope,
|
||||
"product_status_counts": dict(self.product_status_counts),
|
||||
"status_scope_excluded": self.status_scope_excluded,
|
||||
"generation_plan_fingerprint": self.generation_plan_fingerprint,
|
||||
},
|
||||
path=self.db_path,
|
||||
)
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
|
||||
|
||||
@@ -101,7 +103,37 @@ def partition_tasks(tasks) -> dict:
|
||||
return grouped
|
||||
|
||||
|
||||
def build_generation_plan(tasks, generate_mode, scope=SCOPE_NORMAL_ONLY) -> dict:
|
||||
"""Build a frozen, status-aware generation plan without mutating tasks."""
|
||||
|
||||
base_candidates = _deduplicate_tasks(tasks)
|
||||
status_counts = {status: 0 for status in VALID_PRODUCT_STATUSES}
|
||||
for task in base_candidates:
|
||||
status_counts[_task_status(task)] += 1
|
||||
|
||||
normalized_scope = normalize_scope(scope)
|
||||
if normalized_scope == SCOPE_ALL:
|
||||
execution_tasks = list(base_candidates)
|
||||
else:
|
||||
execution_tasks = [
|
||||
task for task in base_candidates if is_normal(_task_status(task))
|
||||
]
|
||||
|
||||
return {
|
||||
"base_candidates": base_candidates,
|
||||
"status_counts": status_counts,
|
||||
"execution_tasks": execution_tasks,
|
||||
"scope": normalized_scope,
|
||||
"scope_excluded": len(base_candidates) - len(execution_tasks),
|
||||
"fingerprint": _generation_fingerprint(base_candidates, generate_mode),
|
||||
}
|
||||
|
||||
|
||||
def normalize_collect_scope(value) -> str:
|
||||
return normalize_scope(value)
|
||||
|
||||
|
||||
def normalize_scope(value) -> str:
|
||||
value = str(value or "").strip().lower()
|
||||
return SCOPE_ALL if value == SCOPE_ALL else SCOPE_NORMAL_ONLY
|
||||
|
||||
@@ -114,6 +146,51 @@ def collect_skip_reason(status) -> str:
|
||||
return f"{status_label(status)},按本轮范围略过"
|
||||
|
||||
|
||||
def _deduplicate_tasks(tasks) -> list:
|
||||
seen = set()
|
||||
unique = []
|
||||
for task in list(tasks or []):
|
||||
task_id = _task_value(task, "id")
|
||||
key = ("id", str(task_id)) if task_id is not None else ("object", id(task))
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
unique.append(task)
|
||||
return unique
|
||||
|
||||
|
||||
def _generation_fingerprint(tasks, generate_mode) -> str:
|
||||
snapshots = [
|
||||
{
|
||||
"task_id": _task_value(task, "id"),
|
||||
"updated_at": _task_value(task, "updated_at"),
|
||||
"product_status": _task_status(task),
|
||||
"new_title": _task_value(task, "new_title"),
|
||||
"new_cover_path": _task_value(task, "new_cover_path"),
|
||||
"generate_mode": str(generate_mode or ""),
|
||||
}
|
||||
for task in tasks
|
||||
]
|
||||
encoded = json.dumps(
|
||||
snapshots,
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
default=str,
|
||||
).encode("utf-8")
|
||||
return hashlib.sha256(encoded).hexdigest()
|
||||
|
||||
|
||||
def _task_status(task) -> str:
|
||||
return normalize_status(_task_value(task, "product_status"))
|
||||
|
||||
|
||||
def _task_value(task, name, default=None):
|
||||
if isinstance(task, dict):
|
||||
return task.get(name, default)
|
||||
return getattr(task, name, default)
|
||||
|
||||
|
||||
def _alert_note(title: str, description: str) -> str:
|
||||
parts = []
|
||||
if title:
|
||||
|
||||
@@ -431,6 +431,9 @@ data/images/<batch_id>/<slug>/<task_id>_<item_id>_new.<ext> # AI 生成的新
|
||||
|
||||
单个「开始生成」按钮,按本轮「生成内容」模式生成标题、封面或图文:
|
||||
|
||||
- 点击后先从当前筛选结果按生成模式构造真实候选,再在主线程按 `normal_only/all` 选择范围:默认只生成 `normal`,红色危险选项才允许未上架、审核中、状态未知(历史 `NULL` 同未知)商品进入 Worker。范围确认显示四类数量;取消、无候选或确认期间候选指纹变化时不创建 Worker、不请求 AI、不改变 DB。危险选择不跨轮记忆,且没有非正常候选时禁用。范围只决定本轮生成,不修改商品状态,也不授权③更新。
|
||||
- 共享 `product_status.build_generation_plan()` 对候选按稳定任务 ID 去重并冻结 `task_id/updated_at/product_status/new_title/new_cover_path/生成内容模式` 指纹。`GenerateWorker` 接收确认后的精确任务清单和范围代码,在 `normal_only` 下再次防御性排除非正常状态,`ai.generate_batch()` 只能收到最终执行清单。`run_logs.options` 与汇总记录选择范围、四类数量、按范围排除数和计划指纹;用户日志只显示中文范围摘要。
|
||||
|
||||
1. **并发生成标题**:线程池大小 = `title_concurrency`,调 `gen_title(标题提示词, old_title)` → new_title。默认 `backend=cmhub`,调用 `POST /api/v1/generate/title` 并使用 `title_alias`;`backend=direct` 仅内部兼容时使用 `default_text_model`。
|
||||
2. **若②「生成内容」包含封面**:接着并发生成封面,线程池大小 = `image_concurrency`,调 `generate_batch()` 的封面阶段 → 新图存 `data/images/<batch_id>/<slug>/<task_id>_<item_id>_new.jpg`。选择 `cover` 时处理缺新封面且存在 `new_title or old_title` 的任务,优先用新标题、否则只在prompt上下文中用旧标题回退;选择 `title_cover` 时仍先补新标题,再按缺失组件补封面,不使用旧标题绕过失败的生文。默认 `backend=cmhub` 的②批量生成走异步任务接口:先 `POST /api/v1/generate/image/tasks` 提交并预扣点,带 `Idempotency-Key` 与 `X-Client-Version`,submit 成功后立即把 `task_id` 写入 `tasks.image_task_id`;再循环 `GET /api/v1/generate/image/tasks/{task_id}` 轮询,`succeeded` 后取 `result.image_url` 并安全下载转本地 JPEG。若本地已有 `image_task_id`,下次开始生成直接续查,不重新 submit、不重复扣点。`gen_cover()` 单独直接调用无 DB 上下文,仍保留旧同步 `POST /api/v1/generate/image` 兼容路径;`backend=direct` 仅内部兼容时使用 `default_image_model`。用户日志只展示“cmhub 托管档位 / 别名 / 扣点 / 余额 / call_id”,不得输出 cmhub 内部接口路径、Provider 请求体、Authorization、Key 或完整 prompt。
|
||||
- T-545/T-564 已实现:cmhub 模式下不再直接按用户填写的 `image_concurrency` 全量打到网关;实际 submit+poll 在途并发 = `min(image_concurrency, 5)`。拿到 `image_url` 后交给独立下载/保存线程池,下载线程数量与实际生图并发一致,同样最大 5。这样批量时“下一批生图任务”和“上一批图片下载/保存”可以流水线并行,但不会对 cmhub 生图任务接口或 `/media/generated/images/*.png` 静态下载打出超过 5 的并发。direct 兼容路径暂不改变。
|
||||
|
||||
+3
-3
@@ -454,13 +454,13 @@ export_generation_round(project_id, generation_round_key, parent_dir, path=None,
|
||||
main() -> int # 创建 QApplication + MainWindow
|
||||
class MainWindow(QMainWindow) # QTabWidget: ①、②、③、账号管理、设置、商品套图;支持注入 db_path/config/config_path/ai_models_path 便于测试
|
||||
class CollectTab(QWidget) # ① 导入采集:导入 Excel + 汇总栏 + QTableView 任务列表 + 未匹配略过标记
|
||||
class GenerateTab(QWidget) # ② AI生成:提示词管理 + 筛选任务 + 生成封面图片成本开关 + 开始/停止生成 + 新旧封面预览 + AI生成运行日志
|
||||
class GenerateTab(QWidget) # ② AI生成:提示词管理 + 筛选任务 + 状态范围确认 + 开始/停止生成 + 新旧封面预览 + AI生成运行日志
|
||||
class ApplyTab(QWidget) # ③ 更新蝦皮:筛选已生成任务 + 缺失内容预检剔除 + 检查本轮更新 + 确认后分批真实更新 + 运行日志
|
||||
class SettingsTab(QWidget) # 设置:cmhub 网关配置 + 响应式三列布局 + 角色/生成参数/路径端口 + 蝦皮更新安全 + 未保存状态追踪
|
||||
class ProductSuiteTab(QWidget) # 商品套图:多任务、原图、结构配置、AI帮写、cmhub生成、历史结果
|
||||
class ImageStudioTab(QWidget) # 旧AI工场兼容实现;主窗口不再创建
|
||||
class CollectWorker(BaseWorker) # ① 后台采集:范围 normal_only/all + 账号预检 -> editor.collect -> 状态独立落库,采集或略过
|
||||
class GenerateWorker(BaseWorker) # ② 后台生成:ai.generate_batch -> db.set_generated/set_generated_cover/mark_failed + 进度
|
||||
class GenerateWorker(BaseWorker) # ② 后台生成:确认后的 normal_only/all 精确任务 -> ai.generate_batch -> 写库 + 进度
|
||||
class ApplyWorker(BaseWorker) # ③ 后台更新:账号就绪预检 -> 检查或按批调用 editor.apply_task(...) -> db.set_applied/mark_skipped
|
||||
class WriteBackWorker(BaseWorker) # ①/③ 后台回写:旧字段或更新结果写回原 Excel
|
||||
class AIModelTestWorker(BaseWorker) # 设置 后台测试 AI 模型连接:appconfig.test_ai_model
|
||||
@@ -538,7 +538,7 @@ T-523 后 GUI 已从旧 `app/gui.py` 拆为 `app/gui/` 包:`__init__.py` 负
|
||||
- 筛选栏包含:批次、店铺、商品ID、状态、刷新。批次来自 `db.list_batches()`;店铺来自当前任务别名并优先显示匹配账号名;商品ID输入框按包含匹配 `item_id`,清空表示全部;状态支持全部/待生成/已生成/失败/略过/已更新。
|
||||
- 任务列表使用 `QTableView + GenerateTaskTableModel`,列为:店铺、商品ID、旧标题、新标题、状态。`stage=collected` 显示“待生成”,`stage=generated` 显示“已生成”,`status=failed/skipped/running` 优先显示对应状态;已生成、未提交线上、非运行中的「新标题」列可双击编辑,调用 `db.update_generated_title()` 写回本地并清空 `last_error`。
|
||||
- 双击「新标题」列进入本地编辑;双击其他列弹窗展示旧封面与新封面路径对应图片;图片不存在时显示空态/路径提示,只做查看,不做审核。
|
||||
- 底部「开始生成」只处理当前筛选结果里可补齐的任务;批次/店铺/商品ID/状态筛选共同决定当前筛选结果;②「生成内容」下拉支持只生成标题、只生成封面、生成标题和封面。只生成封面时不调用生文,任务有新标题则使用新标题,否则使用已采集旧标题作为封面prompt回退;新旧标题都为空才不纳入。点击「开始生成」时先清空 `GenerateTab` 可见日志文本并写入本轮开始摘要,后续只追加本轮日志;通过 `GenerateWorker` 调 `ai.generate_batch()`,按当前 `ai.generate_mode` 并发标题和/或封面。cmhub 模式开始摘要显示“图片并发 X,cmhub实际生图并发 Y,下载并发 Y”,其中 `Y=min(X,5)`。
|
||||
- 底部「开始生成」只处理当前筛选结果里可补齐的任务;批次/店铺/商品ID/状态筛选共同决定当前筛选结果;②「生成内容」下拉支持只生成标题、只生成封面、生成标题和封面。只生成封面时不调用生文,任务有新标题则使用新标题,否则使用已采集旧标题作为封面prompt回退;新旧标题都为空才不纳入。候选存在时由 `product_status.build_generation_plan()` 统计正常/未上架/审核中/未知并冻结指纹,弹出范围确认:默认 `normal_only`,红色危险选项 `all`;历史未采集状态按未知处理,默认不入队。取消或指纹变化时不创建 Worker;无异常候选时 `all` 禁用。`GenerateWorker` 接收最终精确任务列表,并在 `normal_only` 下再次排除非正常任务后才调用 `ai.generate_batch()`。点击「开始生成」时先清空 `GenerateTab` 可见日志文本并写入本轮开始摘要,后续只追加本轮日志;运行日志 options/summary 记录范围、状态计数、范围排除数和计划指纹。cmhub 模式开始摘要显示“图片并发 X,cmhub实际生图并发 Y,下载并发 Y”,其中 `Y=min(X,5)`。
|
||||
- 「停止」调用 worker 的协作式 `cancel()`;未开始的 Future 取消,不记失败;已完成的 `db.set_generated()` / `db.set_generated_cover()` 组件结果保留,可再次开始生成剩余任务。
|
||||
- 进度标签显示标题/图片/失败;只生成标题时图片进度显示本轮未生成,标题成功即 `db.set_generated(task_id, new_title, existing_cover_path)` 并进入 generated;封面成功立即调用 `db.set_generated_cover(task_id, new_cover_path)`,只更新封面、不覆盖或伪造新标题。只生成封面得到“有封面、无新标题”时,②标题状态继续待生成、图片状态已生成。失败 `db.mark_failed(..., "generate", error)`,GUI 刷新任务表。
|
||||
- T-404a 已实现:「重置生成结果」读取当前选中单条;确认后调用 DB reset,清空本地 AI 结果并退回可生成状态;默认不删除本地新封面文件;写 `run_type=reset` 事件。
|
||||
|
||||
+3
-3
@@ -11,7 +11,7 @@
|
||||
| Tab | 职责 | 风险 |
|
||||
| --- | --- | --- |
|
||||
| ① 导入采集 | 导入多个 Excel;任务列表;**采集**商品当前的旧标题/旧封面(只读),封面图下载本地;回写 Excel 旧字段 | 只读,低 |
|
||||
| ② AI生成 | 左侧标题/封面**提示词**;右侧按批次/店铺/商品ID/状态筛选任务列表;AI 生成新标题,并按本轮开关可选生成新封面;表格拆分显示「标题状态 / 图片状态」;已生成任务可本地微调新标题;双击看新旧封面 | 不触线上,中 |
|
||||
| ② AI生成 | 左侧标题/封面**提示词**;右侧按批次/店铺/商品ID/状态筛选任务列表;开始前确认“仅状态正常”或“所有状态”生成范围后,AI 生成新标题,并按本轮开关可选生成新封面;表格拆分显示「标题状态 / 图片状态」;已生成任务可本地微调新标题;双击看新旧封面 | 不触线上,中 |
|
||||
| ③ 更新蝦皮 | 对**已生成**任务点击「开始更新」后弹窗确认;确认后打开编辑页换标题+封面并逐条点「更新」提交;结果回写 Excel | **写线上,高** |
|
||||
| 账号管理 | Shopee 账号(账号名/别名/数据目录/端口/密码本地明文仅参考/登录状态);启动登录、检测登录、生成快捷方式;新增账号不自动启动 Chrome,首次启动复用初始卖家中心页,重复操作复用已打开的同账号 Chrome,避免重复开窗口;检测登录遇到 `accounts.shopee.tw/seller/login` 必须显示未登录 | 中 |
|
||||
| 设置 | cmhub 网关/API Key、生文/生图/图片理解别名、托管档位提示、生成参数、Chrome 路径、默认端口、蝦皮更新执行参数等 | — |
|
||||
@@ -106,7 +106,7 @@
|
||||
- 右上:按导入批次 / 店铺 / 商品ID / 状态筛选任务;商品ID输入框按包含匹配 `item_id`,清空表示全部。
|
||||
- 筛选行提供「打开图片文件夹」按钮,用于只读打开本地图片目录:选中某行时打开该商品所在账号图片文件夹(优先打开已有新/旧封面文件的真实父目录,缺失时回退到规范账号目录);未选行且选择具体批次时打开该批次图片文件夹;未选行且为全部批次时打开图片根目录。目录不存在只中文提示,不自动创建目录,不修改任务状态。
|
||||
- 右下:任务列表(店铺名、商品id、旧标题、新标题、标题状态、图片状态)+ AI生成运行日志;标题/图片状态由 `new_title`、`new_cover_path`、`stage/status` 和失败步骤推导,帮助用户区分“标题未生成 / 图片未生成 / 标题成功但图片失败”。商品ID列按原等分宽度约 50% 显示;标题状态和图片状态列在 T-554 基础上再缩到约 33%,缩出的宽度平均给旧标题和新标题。已生成、未提交线上、非运行中的任务可双击「新标题」列本地微调,写回 `tasks.new_title`,清空 `last_error` 并回到可更新;双击其他列弹窗展示旧封面、新封面和历史候选图。
|
||||
- 底部**单个「开始生成」+「停止」**,并增加「生成内容」下拉:默认只生成标题,可选只生成封面或生成标题和封面;只生成封面不调用生文,有新标题时优先使用,没有时用已采集旧标题作为封面prompt参考,新旧标题都为空才不纳入。标题/图片两条进度条右侧分别显示同宽用时标签(`生标题用时 N 秒` / `生图用时 N 秒`),运行中每秒递增,完成/停止后冻结;原图片进度条右侧的失败数和 cmhub 余额不再占用该位置。cmhub 模式会把用户设置的图片并发内部限制到最大 5,并用同样最大 5 的独立下载线程池拉取 `image_url`,不新增用户可见下载并发配置;运行日志显示用户设置并发和实际并发。下拉状态持久化到 `config.json` 的 `ai.generate_mode`,并继续写回旧兼容 `ai.generate_cover`。
|
||||
- 底部**单个「开始生成」+「停止」**,并增加「生成内容」下拉:默认只生成标题,可选只生成封面或生成标题和封面;只生成封面不调用生文,有新标题时优先使用,没有时用已采集旧标题作为封面prompt参考,新旧标题都为空才不纳入。开始前先从真实候选重新分组商品状态,弹出范围确认框:默认「只生成状态正常的商品」,红色危险选项为「生成所有状态的商品」;未上架、审核中、状态未知(含历史未采集状态)默认不入队、不请求 AI、不消耗点数,用户明确选择全部范围才可入队。范围确认不跨轮记忆,取消或候选在确认期间变化均不启动生成;无异常候选时危险选项禁用。标题/图片两条进度条右侧分别显示同宽用时标签(`生标题用时 N 秒` / `生图用时 N 秒`),运行中每秒递增,完成/停止后冻结;原图片进度条右侧的失败数和 cmhub 余额不再占用该位置。cmhub 模式会把用户设置的图片并发内部限制到最大 5,并用同样最大 5 的独立下载线程池拉取 `image_url`,不新增用户可见下载并发配置;运行日志显示用户设置并发和实际并发。下拉状态持久化到 `config.json` 的 `ai.generate_mode`,并继续写回旧兼容 `ai.generate_cover`。
|
||||
- 生成参数(标题/图片并发数、失败重试、分辨率、jpg 质量、cmhub 网关/Key/别名)在 **设置**;②只暴露本轮生成标题/封面/图文的内容模式。设置不新增“下载并发”控件;cmhub 图片下载并发由程序按实际生图并发自动计算,最大 5。
|
||||
- 只生成标题时标题成功即写库并进入 `generated`,保留已有封面;只生成封面时通过组件级写库只保存 `new_cover_path`,不覆盖已有标题,也不把旧标题写入空的 `new_title`;生成标题和封面时按缺失组件增量补齐。“有新封面、无新标题”时标题状态为待生成、图片状态为已生成,后续补标题不重复生图。三种模式都写 `run_type=generate` 的 `run_logs/run_log_events` 和用户可读滚动日志;日志开头明确显示本轮生成内容。点击「开始生成」时先清空②界面可见日志并写入本轮开始摘要,运行中只追加本轮日志;不删除历史 `run_logs/run_log_events` 或本地 `data/logs/`。进入页面默认可显示“本轮日志会在开始运行后显示”,历史日志不自动混入当前运行界面。「停止」取消未开始项,可再次「开始生成」对剩余继续。
|
||||
- 「重置生成结果」支持选中任务或当前筛选结果,运行中禁用;确认框提供「重置标题 / 重置封面 / 重置全部」,只改本地 DB,默认不删除本地新封面文件。若范围内包含已提交线上记录,必须提示本地重置不回滚蝦皮,重生成后再更新会再次提交线上。
|
||||
@@ -241,7 +241,7 @@
|
||||
| --- | --- | --- |
|
||||
| `MainWindow(QMainWindow)` | 根窗口 | 持有 `QTabWidget`、状态栏、全局消息 |
|
||||
| `CollectTab(QWidget)` | ① | 导入、任务表、采集、回写 |
|
||||
| `GenerateTab(QWidget)` | ② | 左提示词管理 + 右筛选/任务列表;双击看新旧封面;开始生成/停止/进度;本轮「生成内容」下拉接入 `GenerateWorker` |
|
||||
| `GenerateTab(QWidget)` | ② | 左提示词管理 + 右筛选/任务列表;双击看新旧封面;先确认商品状态生成范围,再按本轮「生成内容」下拉接入 `GenerateWorker` |
|
||||
| `ApplyTab(QWidget)` | ③ | 已生成任务筛选 +「更新内容」下拉 + 缺失内容预检剔除 +「检查本轮更新」+ 分批开始更新确认 + 检查/真实更新运行日志 + 结果回写与结束汇总 |
|
||||
| `AccountsTab(QWidget)` | 账号管理 | 账号增删改、启动登录、检测登录、生成快捷方式;首次启动复用初始卖家中心页,启动中防重复触发,登录检测把 Shopee accounts 登录页判为未登录 |
|
||||
| `SettingsTab(QWidget)` | 设置 | cmhub 网关配置 + 响应式三列设置表单 + 生成参数 + Chrome/端口配置 + 蝦皮更新执行;数据路径字段隐藏但保留配置兼容 |
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
id: T-662c
|
||||
title: AI生成范围确认与异常商品点数保护
|
||||
status: TODO
|
||||
status: DONE
|
||||
phase: 7
|
||||
deps: [T-662a]
|
||||
created: 2026-07-18
|
||||
@@ -60,4 +60,8 @@ git diff --check
|
||||
|
||||
## 执行记录
|
||||
|
||||
- 待实现。
|
||||
- 已新增共享纯逻辑 `product_status.build_generation_plan()`:按任务 ID 去重、汇总四类状态、生成 `normal_only/all` 精确执行清单,并冻结候选指纹。
|
||||
- ②「开始生成」在真实候选存在时弹出中文范围确认,默认只生成状态正常商品;“生成所有状态的商品”为红色危险选项,无异常候选时禁用。取消或候选在确认期间变化不会创建 Worker。
|
||||
- `GenerateWorker` 接收范围、状态汇总和计划指纹;默认范围下再次防御性过滤非正常商品,`ai.generate_batch()` 只接收最终执行清单,运行日志记录范围和排除数量。
|
||||
- 已更新 `docs/routes.md`、`docs/04-architecture.md`、`docs/api.md`。
|
||||
- 验证通过:`py -3.10 -m unittest tests.test_product_status tests.test_gui`(203 项)、`py -3.10 -m unittest discover -s tests`(612 项)、`py -3.10 -m ruff check app tests main.py`、`py -3.10 -m compileall app main.py`、`git diff --check`。
|
||||
|
||||
+312
-5
@@ -97,12 +97,21 @@ class FakeThread:
|
||||
class FakeGenerateWorker:
|
||||
instances = []
|
||||
|
||||
def __init__(self, tasks, prompt_values, db_path=None, config=None, diagnostic_log_dir=None):
|
||||
def __init__(
|
||||
self,
|
||||
tasks,
|
||||
prompt_values,
|
||||
db_path=None,
|
||||
config=None,
|
||||
diagnostic_log_dir=None,
|
||||
**kwargs,
|
||||
):
|
||||
self.tasks = list(tasks)
|
||||
self.prompt_values = dict(prompt_values or {})
|
||||
self.db_path = db_path
|
||||
self.config = config
|
||||
self.diagnostic_log_dir = diagnostic_log_dir
|
||||
self.kwargs = dict(kwargs)
|
||||
self.progress = DummySignal()
|
||||
self.row_updated = DummySignal()
|
||||
self.log = DummySignal()
|
||||
@@ -6008,7 +6017,13 @@ class GuiTests(TempDirMixin, unittest.TestCase):
|
||||
path=cfg["db_path"],
|
||||
)
|
||||
task = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])[0]
|
||||
db.set_collected(task.id, "旧标题", "old.jpg", path=cfg["db_path"])
|
||||
db.set_collected(
|
||||
task.id,
|
||||
"旧标题",
|
||||
"old.jpg",
|
||||
product_status_value="normal",
|
||||
path=cfg["db_path"],
|
||||
)
|
||||
tab = GenerateTab(config=cfg)
|
||||
self.addCleanup(tab.close)
|
||||
tab.run_log_view.setPlainText("上一轮失败日志")
|
||||
@@ -6029,7 +6044,11 @@ class GuiTests(TempDirMixin, unittest.TestCase):
|
||||
self.started = True
|
||||
|
||||
fake_thread = FakeThread()
|
||||
with mock.patch("app.gui.run_worker", return_value=fake_thread):
|
||||
with mock.patch.object(
|
||||
tab,
|
||||
"_choose_generation_scope",
|
||||
return_value="normal_only",
|
||||
), mock.patch("app.gui.run_worker", return_value=fake_thread):
|
||||
tab.start_generate()
|
||||
|
||||
text = tab.run_log_view.toPlainText()
|
||||
@@ -6063,7 +6082,13 @@ class GuiTests(TempDirMixin, unittest.TestCase):
|
||||
path=cfg["db_path"],
|
||||
)
|
||||
task = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])[0]
|
||||
db.set_collected(task.id, "旧标题", "old.jpg", path=cfg["db_path"])
|
||||
db.set_collected(
|
||||
task.id,
|
||||
"旧标题",
|
||||
"old.jpg",
|
||||
product_status_value="normal",
|
||||
path=cfg["db_path"],
|
||||
)
|
||||
tab = GenerateTab(config=cfg)
|
||||
self.addCleanup(tab.close)
|
||||
tab.status_filter.setCurrentIndex(tab.status_filter.findData("to_generate"))
|
||||
@@ -6085,7 +6110,11 @@ class GuiTests(TempDirMixin, unittest.TestCase):
|
||||
self.started = True
|
||||
|
||||
fake_thread = FakeThread()
|
||||
with mock.patch("app.gui.run_worker", return_value=fake_thread):
|
||||
with mock.patch.object(
|
||||
tab,
|
||||
"_choose_generation_scope",
|
||||
return_value="normal_only",
|
||||
), mock.patch("app.gui.run_worker", return_value=fake_thread):
|
||||
tab.start_generate()
|
||||
|
||||
self.assertTrue(fake_thread.started)
|
||||
@@ -6097,6 +6126,284 @@ class GuiTests(TempDirMixin, unittest.TestCase):
|
||||
|
||||
self.assert_removed(temp_dir)
|
||||
|
||||
def test_generate_scope_dialog_uses_safe_default_and_red_all_status_choice(self):
|
||||
with self.make_temp_dir() as temp_dir:
|
||||
tab = GenerateTab(config=self.make_config(temp_dir))
|
||||
self.addCleanup(tab.close)
|
||||
plan = {
|
||||
"base_candidates": [object(), object()],
|
||||
"status_counts": {
|
||||
"normal": 1,
|
||||
"unlisted": 0,
|
||||
"reviewing": 0,
|
||||
"unknown": 1,
|
||||
},
|
||||
}
|
||||
captured = {}
|
||||
|
||||
def click_all_statuses():
|
||||
box = QApplication.activeModalWidget()
|
||||
normal_button = box.findChild(QPushButton, "generateNormalOnlyButton")
|
||||
all_button = box.findChild(QPushButton, "generateAllStatusesButton")
|
||||
captured["default"] = box.defaultButton().objectName()
|
||||
captured["all_style"] = all_button.styleSheet()
|
||||
captured["all_enabled"] = all_button.isEnabled()
|
||||
captured["detail"] = box.informativeText()
|
||||
self.assertIsNotNone(normal_button)
|
||||
self.assertIsNotNone(all_button)
|
||||
all_button.click()
|
||||
|
||||
QTimer.singleShot(0, click_all_statuses)
|
||||
scope = tab._choose_generation_scope(plan)
|
||||
|
||||
self.assertEqual("all", scope)
|
||||
self.assertEqual("generateNormalOnlyButton", captured["default"])
|
||||
self.assertTrue(captured["all_enabled"])
|
||||
self.assertIn("#cf222e", captured["all_style"])
|
||||
self.assertIn("状态未知1", captured["detail"])
|
||||
|
||||
self.assert_removed(temp_dir)
|
||||
|
||||
def test_generate_scope_dialog_disables_all_statuses_without_abnormal_tasks(self):
|
||||
with self.make_temp_dir() as temp_dir:
|
||||
tab = GenerateTab(config=self.make_config(temp_dir))
|
||||
self.addCleanup(tab.close)
|
||||
plan = {
|
||||
"base_candidates": [object()],
|
||||
"status_counts": {
|
||||
"normal": 1,
|
||||
"unlisted": 0,
|
||||
"reviewing": 0,
|
||||
"unknown": 0,
|
||||
},
|
||||
}
|
||||
captured = {}
|
||||
|
||||
def click_normal_only():
|
||||
box = QApplication.activeModalWidget()
|
||||
normal_button = box.findChild(QPushButton, "generateNormalOnlyButton")
|
||||
all_button = box.findChild(QPushButton, "generateAllStatusesButton")
|
||||
captured["all_enabled"] = all_button.isEnabled()
|
||||
normal_button.click()
|
||||
|
||||
QTimer.singleShot(0, click_normal_only)
|
||||
scope = tab._choose_generation_scope(plan)
|
||||
|
||||
self.assertEqual("normal_only", scope)
|
||||
self.assertFalse(captured["all_enabled"])
|
||||
|
||||
self.assert_removed(temp_dir)
|
||||
|
||||
def test_generate_tab_normal_scope_passes_only_normal_tasks_to_worker(self):
|
||||
with self.make_temp_dir() as temp_dir:
|
||||
cfg = self.make_config(temp_dir)
|
||||
db.init_db(cfg["db_path"])
|
||||
batch_id = db.create_batch(["input.xlsx"], path=cfg["db_path"])
|
||||
rows = [
|
||||
{
|
||||
"source_file_abs": os.path.join(temp_dir, "input.xlsx"),
|
||||
"source_sheet": "商品",
|
||||
"source_row": index + 2,
|
||||
"alias": "alias-a",
|
||||
"item_id": str(51100639510 + index),
|
||||
}
|
||||
for index in range(4)
|
||||
]
|
||||
db.insert_tasks(batch_id, rows, path=cfg["db_path"])
|
||||
statuses = ["normal", "unlisted", "reviewing", "unknown"]
|
||||
for task, status in zip(
|
||||
db.list_tasks(batch_id=batch_id, path=cfg["db_path"]),
|
||||
statuses,
|
||||
):
|
||||
db.set_collected(
|
||||
task.id,
|
||||
"旧标题",
|
||||
"old.jpg",
|
||||
product_status_value=status,
|
||||
path=cfg["db_path"],
|
||||
)
|
||||
tab = GenerateTab(config=cfg)
|
||||
self.addCleanup(tab.close)
|
||||
FakeGenerateWorker.instances.clear()
|
||||
fake_thread = FakeThread()
|
||||
|
||||
with mock.patch.object(
|
||||
tab,
|
||||
"_choose_generation_scope",
|
||||
return_value="normal_only",
|
||||
), mock.patch(
|
||||
"app.gui.tabs.generate.GenerateWorker",
|
||||
FakeGenerateWorker,
|
||||
), mock.patch("app.gui.tabs.generate.run_worker", return_value=fake_thread):
|
||||
tab.start_generate()
|
||||
|
||||
self.assertTrue(fake_thread.started)
|
||||
worker = FakeGenerateWorker.instances[-1]
|
||||
self.assertEqual(["51100639510"], [task.item_id for task in worker.tasks])
|
||||
self.assertEqual("normal_only", worker.kwargs["generation_scope"])
|
||||
self.assertEqual(3, worker.kwargs["status_scope_excluded"])
|
||||
self.assertEqual(
|
||||
{"normal": 1, "unlisted": 1, "reviewing": 1, "unknown": 1},
|
||||
worker.kwargs["product_status_counts"],
|
||||
)
|
||||
|
||||
self.assert_removed(temp_dir)
|
||||
|
||||
def test_generate_tab_cancelled_scope_does_not_start_worker(self):
|
||||
with self.make_temp_dir() as temp_dir:
|
||||
cfg = self.make_config(temp_dir)
|
||||
db.init_db(cfg["db_path"])
|
||||
batch_id = db.create_batch(["input.xlsx"], path=cfg["db_path"])
|
||||
db.insert_tasks(
|
||||
batch_id,
|
||||
[
|
||||
{
|
||||
"source_file_abs": os.path.join(temp_dir, "input.xlsx"),
|
||||
"source_sheet": "商品",
|
||||
"source_row": 2,
|
||||
"alias": "alias-a",
|
||||
"item_id": "51100639510",
|
||||
}
|
||||
],
|
||||
path=cfg["db_path"],
|
||||
)
|
||||
task = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])[0]
|
||||
db.set_collected(
|
||||
task.id,
|
||||
"旧标题",
|
||||
"old.jpg",
|
||||
product_status_value="normal",
|
||||
path=cfg["db_path"],
|
||||
)
|
||||
states = []
|
||||
tab = GenerateTab(config=cfg, status_callback=states.append)
|
||||
self.addCleanup(tab.close)
|
||||
|
||||
with mock.patch.object(
|
||||
tab,
|
||||
"_choose_generation_scope",
|
||||
return_value=None,
|
||||
), mock.patch("app.gui.tabs.generate.GenerateWorker") as worker:
|
||||
tab.start_generate()
|
||||
|
||||
worker.assert_not_called()
|
||||
self.assertIsNone(tab.generate_worker)
|
||||
self.assertIn("已取消 AI 生成", states[-1])
|
||||
|
||||
self.assert_removed(temp_dir)
|
||||
|
||||
def test_generate_tab_discards_plan_when_candidates_change_during_confirmation(self):
|
||||
with self.make_temp_dir() as temp_dir:
|
||||
states = []
|
||||
tab = GenerateTab(
|
||||
config=self.make_config(temp_dir),
|
||||
status_callback=states.append,
|
||||
)
|
||||
self.addCleanup(tab.close)
|
||||
before = SimpleNamespace(
|
||||
id=1,
|
||||
updated_at="2026-07-18T10:00:00",
|
||||
product_status="normal",
|
||||
new_title=None,
|
||||
new_cover_path=None,
|
||||
)
|
||||
after = SimpleNamespace(
|
||||
id=1,
|
||||
updated_at="2026-07-18T10:01:00",
|
||||
product_status="normal",
|
||||
new_title=None,
|
||||
new_cover_path=None,
|
||||
)
|
||||
|
||||
with mock.patch.object(tab, "_generation_candidates", side_effect=[[before], [after]]), mock.patch.object(
|
||||
tab,
|
||||
"_choose_generation_scope",
|
||||
return_value="normal_only",
|
||||
), mock.patch("app.gui.tabs.generate.GenerateWorker") as worker:
|
||||
tab.start_generate()
|
||||
|
||||
worker.assert_not_called()
|
||||
self.assertIn("当前任务数据已变化", states[-1])
|
||||
|
||||
self.assert_removed(temp_dir)
|
||||
|
||||
def test_generate_worker_normal_scope_defensively_filters_non_normal_tasks(self):
|
||||
with self.make_temp_dir() as temp_dir:
|
||||
cfg = self.make_config(temp_dir)
|
||||
cfg["ai"] = appconfig.ai_config(cfg)
|
||||
cfg["ai"]["backend"] = "direct"
|
||||
db.init_db(cfg["db_path"])
|
||||
batch_id = db.create_batch(["input.xlsx"], path=cfg["db_path"])
|
||||
db.insert_tasks(
|
||||
batch_id,
|
||||
[
|
||||
{
|
||||
"source_file_abs": os.path.join(temp_dir, "input.xlsx"),
|
||||
"source_sheet": "商品",
|
||||
"source_row": index + 2,
|
||||
"alias": "alias-a",
|
||||
"item_id": str(51100639510 + index),
|
||||
}
|
||||
for index in range(2)
|
||||
],
|
||||
path=cfg["db_path"],
|
||||
)
|
||||
tasks = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])
|
||||
db.set_collected(
|
||||
tasks[0].id,
|
||||
"旧标题",
|
||||
"old.jpg",
|
||||
product_status_value="normal",
|
||||
path=cfg["db_path"],
|
||||
)
|
||||
db.set_collected(
|
||||
tasks[1].id,
|
||||
"旧标题",
|
||||
"old.jpg",
|
||||
product_status_value="unknown",
|
||||
path=cfg["db_path"],
|
||||
)
|
||||
tasks = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])
|
||||
worker = GenerateWorker(
|
||||
tasks,
|
||||
{"title": "标题提示", "cover": "封面提示"},
|
||||
db_path=cfg["db_path"],
|
||||
config=cfg,
|
||||
generation_scope="normal_only",
|
||||
product_status_counts={"normal": 1, "unknown": 1},
|
||||
status_scope_excluded=1,
|
||||
generation_plan_fingerprint="frozen-plan",
|
||||
)
|
||||
submitted = []
|
||||
|
||||
def fake_generate_batch(tasks_arg, *_args, **_kwargs):
|
||||
submitted.extend(tasks_arg)
|
||||
return {
|
||||
"ok": True,
|
||||
"total": len(tasks_arg),
|
||||
"title_total": len(tasks_arg),
|
||||
"title_done": 0,
|
||||
"cover_total": 0,
|
||||
"cover_done": 0,
|
||||
"generated_done": 0,
|
||||
"failed": 0,
|
||||
"cancelled": False,
|
||||
"generate_cover": False,
|
||||
"generate_mode": "title",
|
||||
}
|
||||
|
||||
with mock.patch("app.gui.ai.generate_batch", side_effect=fake_generate_batch):
|
||||
summary = worker.execute()
|
||||
|
||||
self.assertEqual([tasks[0].id], [task.id for task in submitted])
|
||||
self.assertEqual("normal_only", summary["generation_scope"])
|
||||
self.assertEqual(1, summary["status_scope_excluded"])
|
||||
run_log = db.list_run_logs(limit=1, run_type="generate", path=cfg["db_path"])[0]
|
||||
self.assertEqual("normal_only", run_log.options["generation_scope"])
|
||||
self.assertEqual(1, run_log.options["status_scope_excluded"])
|
||||
|
||||
self.assert_removed(temp_dir)
|
||||
|
||||
def test_generate_tab_explains_collect_failed_records_are_not_generatable(self):
|
||||
with self.make_temp_dir() as temp_dir:
|
||||
cfg = self.make_config(temp_dir)
|
||||
|
||||
@@ -78,3 +78,57 @@ class ProductStatusTests(unittest.TestCase):
|
||||
self.assertEqual(product_status.STATUS_REVIEWING, result["product_status"])
|
||||
self.assertLessEqual(len(result["product_status_note"]), 2000)
|
||||
self.assertTrue(result["product_status_note"].startswith("标题:审核中;说明:"))
|
||||
|
||||
def test_generation_plan_filters_unknown_by_default_and_freezes_snapshot(self):
|
||||
normal = SimpleNamespace(
|
||||
id=1,
|
||||
updated_at="2026-07-18T10:00:00",
|
||||
product_status="normal",
|
||||
new_title=None,
|
||||
new_cover_path=None,
|
||||
)
|
||||
unlisted = {
|
||||
"id": 2,
|
||||
"updated_at": "2026-07-18T10:00:01",
|
||||
"product_status": "unlisted",
|
||||
"new_title": "已有标题",
|
||||
"new_cover_path": None,
|
||||
}
|
||||
unknown = SimpleNamespace(
|
||||
id=3,
|
||||
updated_at="2026-07-18T10:00:02",
|
||||
product_status=None,
|
||||
new_title=None,
|
||||
new_cover_path=None,
|
||||
)
|
||||
|
||||
normal_only = product_status.build_generation_plan(
|
||||
[normal, unlisted, unknown, normal],
|
||||
"title_cover",
|
||||
)
|
||||
all_statuses = product_status.build_generation_plan(
|
||||
[normal, unlisted, unknown],
|
||||
"title_cover",
|
||||
product_status.SCOPE_ALL,
|
||||
)
|
||||
changed = product_status.build_generation_plan(
|
||||
[
|
||||
normal,
|
||||
{
|
||||
**unlisted,
|
||||
"updated_at": "2026-07-18T10:01:00",
|
||||
},
|
||||
unknown,
|
||||
],
|
||||
"title_cover",
|
||||
)
|
||||
|
||||
self.assertEqual([1, 2, 3], [task.id if hasattr(task, "id") else task["id"] for task in normal_only["base_candidates"]])
|
||||
self.assertEqual([1], [task.id for task in normal_only["execution_tasks"]])
|
||||
self.assertEqual(2, normal_only["scope_excluded"])
|
||||
self.assertEqual(
|
||||
{"normal": 1, "unlisted": 1, "reviewing": 0, "unknown": 1},
|
||||
normal_only["status_counts"],
|
||||
)
|
||||
self.assertEqual([1, 2, 3], [task.id if hasattr(task, "id") else task["id"] for task in all_statuses["execution_tasks"]])
|
||||
self.assertNotEqual(normal_only["fingerprint"], changed["fingerprint"])
|
||||
|
||||
Reference in New Issue
Block a user