Files
cmshoppe/app/gui/workers.py
T

3637 lines
138 KiB
Python
Raw Normal View History

2026-07-02 16:47:37 +08:00
"""Concrete PySide6 workers used by GUI tabs."""
from __future__ import annotations
2026-07-08 12:07:38 +08:00
import datetime as _dt
import re
2026-07-11 14:16:11 +08:00
import threading
import time
2026-07-08 12:07:38 +08:00
try:
from PySide6.QtCore import Signal
except ModuleNotFoundError: # pragma: no cover - GUI import guard
Signal = None
from .. import (
ai,
appconfig,
cmhub_models,
image_studio,
image_studio_export,
image_studio_generation,
image_studio_images,
2026-07-18 16:46:59 +08:00
product_status,
)
from ..collect_skip import ALIAS_UNMATCHED, LOGIN_REQUIRED, empty_skip_reason_counts
2026-07-02 16:47:37 +08:00
from .widgets import *
2026-07-08 12:07:38 +08:00
_USER_LOG_URL_RE = re.compile(r"https?://[^\s,,;;))\]]+", re.IGNORECASE)
_USER_LOG_PATH_RE = re.compile(
r"(?i)(/api(?:/v\d+)?/[^\s,,;;))\]]*|/generated/images/[^\s,,;;))\]]*)"
)
2026-07-11 14:16:11 +08:00
def _image_studio_step_label(step):
return {
"ensure_chrome": "准备账号浏览器",
"login_check": "检测登录",
"open_product": "打开商品页",
"read_main_images": "读取蝦皮原主图",
"cover_submit": "提交生图任务",
"cover_poll": "查询生图结果",
"cover_download": "下载生成图片",
"job_done": "完成单张任务",
}.get(str(step or ""), str(step or "处理"))
def _image_studio_result_label(result):
return {
"start": "开始",
"success": "成功",
"failed": "失败",
"cancelled": "已停止",
"blocked": "已阻断",
"reused": "已复用",
"launched": "已启动",
"resume": "继续查询",
"queued": "排队中",
"running": "生成中",
"expired": "已过期",
}.get(str(result or ""), str(result or ""))
def _format_image_studio_event(event):
event = dict(event or {})
step = _image_studio_step_label(event.get("step"))
result = _image_studio_result_label(event.get("result"))
detail = str(event.get("detail") or "").strip()
job_id = event.get("job_id")
prefix = f"[AI工场] {step}"
if job_id is not None:
prefix += f" #{job_id}"
if result:
prefix += f":{result}"
if detail:
2026-07-11 14:49:39 +08:00
prefix += f",{_image_studio_user_detail(detail)}"
2026-07-11 14:16:11 +08:00
if event.get("points_cost") is not None:
prefix += f",扣点 {event.get('points_cost')}"
if event.get("points_balance") is not None:
prefix += f",余额 {event.get('points_balance')}"
return prefix
def _format_product_suite_event(event):
return _format_image_studio_event(event).replace("[AI工场]", "[商品套图]", 1)
2026-07-11 14:49:39 +08:00
def _image_studio_user_detail(detail):
text = diagnostics.redact_log_text(str(detail or "")).replace("\r", " ").replace("\n", " ").strip()
text = _USER_LOG_URL_RE.sub("[链接已隐藏]", text)
text = _USER_LOG_PATH_RE.sub("[接口路径已隐藏]", text)
text = text.replace("GET [链接已隐藏]", "请求 cmhub")
text = text.replace("POST [链接已隐藏]", "请求 cmhub")
if len(text) > 180:
return text[:177] + "..."
return text
2026-07-11 14:16:11 +08:00
class ImageStudioPullImagesWorker(BaseWorker):
"""Read Shopee main image URLs for one AI studio project in background."""
def __init__(
self,
account_alias,
item_id,
*,
pull_run_token="",
db_path=None,
config=None,
):
2026-07-11 14:16:11 +08:00
super().__init__()
self.account_alias = account_alias
self.item_id = item_id
self.pull_run_token = str(pull_run_token or "")
2026-07-11 14:16:11 +08:00
self.db_path = db_path
self.config = config
def execute(self):
if self.should_cancel():
return {
"pull_run_token": self.pull_run_token,
"cancelled": True,
"assets": [],
}
2026-07-11 14:16:11 +08:00
def on_step(payload):
self.log.emit(_format_image_studio_event(payload))
try:
result = image_studio.pull_remote_main_image_urls(
self.account_alias,
self.item_id,
path=self.db_path,
config=self.config,
on_step=on_step,
should_stop=self.should_cancel,
)
except image_studio.ImageStudioPullCancelled as exc:
return {
"pull_run_token": self.pull_run_token,
"cancelled": True,
"project": exc.project,
"assets": list(exc.assets or []),
}
2026-07-11 14:16:11 +08:00
project = result.get("project")
assets = result.get("assets") or []
return {
"pull_run_token": self.pull_run_token,
2026-07-11 14:16:11 +08:00
"project": project,
"assets": assets,
"count": len(assets),
"account": result.get("account"),
"cancelled": self.should_cancel(),
2026-07-11 14:16:11 +08:00
}
class ImageStudioDownloadOriginalWorker(BaseWorker):
"""Download one remote original image into the project pool."""
def __init__(
self,
asset_id,
*,
db_path=None,
config=None,
open_after=False,
max_retries=2,
retry_delays=(1, 2),
):
2026-07-11 14:16:11 +08:00
super().__init__()
self.asset_id = int(asset_id)
self.db_path = db_path
self.config = config
self.open_after = bool(open_after)
self.max_retries = max(0, int(max_retries or 0))
self.retry_delays = tuple(float(delay) for delay in (retry_delays or ()))
2026-07-11 14:16:11 +08:00
def execute(self):
attempts = self.max_retries + 1
for attempt in range(1, attempts + 1):
if self.should_cancel():
return {"asset_id": self.asset_id, "cancelled": True}
self.progress.emit(
{
"asset_id": self.asset_id,
"state": "start",
"attempt": attempt,
"attempts": attempts,
}
)
self.log.emit(f"[AI工场] 下载蝦皮原主图 #{self.asset_id}:开始")
try:
asset = image_studio_images.download_original_asset(
self.asset_id,
path=self.db_path,
config=self.config,
should_stop=self.should_cancel,
)
except image_studio_images.ImageStudioImageCancelled:
return {"asset_id": self.asset_id, "cancelled": True}
except Exception:
retry = attempt
if attempt >= attempts:
self.progress.emit(
{
"asset_id": self.asset_id,
"state": "failed",
"attempt": attempt,
"attempts": attempts,
}
)
self.log.emit(f"[AI工场] 下载蝦皮原主图 #{self.asset_id}:最终失败")
return {
"ok": False,
"asset_id": self.asset_id,
"open_after": self.open_after,
"error": "蝦皮原主图下载失败,请稍后再次点击图片重试。",
}
delay = self._retry_delay(retry)
self.progress.emit(
{
"asset_id": self.asset_id,
"state": "retry",
"retry": retry,
"max_retries": self.max_retries,
"delay_seconds": delay,
}
)
self.log.emit(
f"[AI工场] 下载蝦皮原主图 #{self.asset_id}:失败,准备重试 {retry}/{self.max_retries}"
)
if not self._wait_for_retry(delay):
return {"asset_id": self.asset_id, "cancelled": True}
continue
self.progress.emit(
{
"asset_id": self.asset_id,
"state": "success",
"attempt": attempt,
"attempts": attempts,
}
)
self.log.emit(f"[AI工场] 下载蝦皮原主图 #{self.asset_id}:成功")
return {"asset": asset, "asset_id": self.asset_id, "open_after": self.open_after}
return {"asset_id": self.asset_id, "cancelled": True}
def _retry_delay(self, retry):
if retry <= 0:
return 0.0
index = min(retry - 1, len(self.retry_delays) - 1)
return self.retry_delays[index] if index >= 0 else 0.0
def _wait_for_retry(self, delay_seconds):
deadline = time.monotonic() + max(0.0, float(delay_seconds or 0))
while time.monotonic() < deadline:
if self.should_cancel():
return False
time.sleep(min(0.1, max(0.0, deadline - time.monotonic())))
return not self.should_cancel()
2026-07-11 14:16:11 +08:00
class ImageStudioGenerateJobsWorker(BaseWorker):
"""Run cmhub hosted image generation jobs for the AI studio."""
def __init__(
self,
project_id,
source_asset_id,
prompt,
count,
*,
job_type="main",
aspect_ratio="1:1",
db_path=None,
config=None,
cmhub_config_path=None,
):
super().__init__()
self.project_id = int(project_id)
self.source_asset_id = int(source_asset_id)
self.prompt = str(prompt or "")
self.count = int(count or 0)
self.job_type = str(job_type or "main")
self.aspect_ratio = str(aspect_ratio or "1:1")
self.db_path = db_path
self.config = config
self.cmhub_config_path = cmhub_config_path
self._done = 0
self._failed = 0
self._lock = threading.Lock()
def execute(self):
self.progress.emit({"total": self.count, "done": 0, "failed": 0})
def on_event(payload):
event = dict(payload or {})
self.log.emit(_format_image_studio_event(event))
if event.get("points_cost") is not None or event.get("points_balance") is not None:
self.progress.emit(
{
"total": self.count,
"done": self._done,
"failed": self._failed,
"points_cost": event.get("points_cost"),
"points_balance": event.get("points_balance"),
}
)
if event.get("step") == "job_done":
with self._lock:
self._done += 1
if event.get("result") not in {"success"}:
self._failed += 1
progress = {
"total": self.count,
"done": self._done,
"failed": self._failed,
}
self.progress.emit(progress)
summary = image_studio_generation.generate_image_jobs(
self.project_id,
self.source_asset_id,
self.prompt,
self.count,
job_type=self.job_type,
aspect_ratio=self.aspect_ratio,
config=self.config,
cmhub_config_path=self.cmhub_config_path,
path=self.db_path,
should_stop=self.should_cancel,
on_event=on_event,
)
summary["project_id"] = self.project_id
return summary
class ProductSuiteGenerateWorker(BaseWorker):
"""Create and run independently configured product-suite jobs."""
def __init__(
self,
project_id,
job_specs,
*,
run_token="",
generation_round_key=None,
aspect_ratio="1:1",
db_path=None,
config=None,
cmhub_config_path=None,
):
super().__init__()
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
self.cmhub_config_path = cmhub_config_path
self.job_ids = []
self._done = 0
self._failed = 0
self._lock = threading.Lock()
def execute(self):
total = len(self.job_specs)
if total <= 0:
raise ValueError("商品套图生成任务不能为空")
jobs = []
for spec in self.job_specs:
jobs.append(
image_studio.create_job(
self.project_id,
source_asset_id=spec.get("source_asset_id"),
reference_asset_ids=spec.get("reference_asset_ids"),
job_type=spec.get("job_type") or "套图",
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,
)
)
self.job_ids = [job.id for job in jobs]
self.progress.emit(
{
"run_token": self.run_token,
"total": len(jobs),
"done": 0,
"failed": 0,
"job_ids": list(self.job_ids),
}
)
def on_event(payload):
event = dict(payload or {})
self.log.emit(_format_product_suite_event(event))
if event.get("step") == "job_done":
with self._lock:
self._done += 1
if event.get("result") != "success":
self._failed += 1
progress = {
"run_token": self.run_token,
"total": len(jobs),
"done": self._done,
"failed": self._failed,
"job_ids": list(self.job_ids),
}
self.progress.emit(progress)
summary = image_studio_generation.run_jobs(
jobs,
aspect_ratio=self.aspect_ratio,
config=self.config,
cmhub_config_path=self.cmhub_config_path,
path=self.db_path,
should_stop=self.should_cancel,
on_event=on_event,
)
summary["project_id"] = self.project_id
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
class ProductSuiteHistoryExportWorker(BaseWorker):
"""Copy one product-suite generation round outside the GUI thread."""
def __init__(self, project_id, generation_round_key, parent_dir, *, db_path=None):
super().__init__()
self.project_id = int(project_id)
self.generation_round_key = generation_round_key
self.parent_dir = str(parent_dir or "")
self.db_path = db_path
def execute(self):
self.log.emit("[商品套图] 导出历史套图:开始")
result = image_studio_export.export_generation_round(
self.project_id,
self.generation_round_key,
self.parent_dir,
path=self.db_path,
should_stop=self.should_cancel,
)
self.log.emit("[商品套图] 导出历史套图:完成")
return {
"target_dir": result.target_dir,
"file_count": len(result.files),
"skipped_count": int(result.skipped_count),
"cancelled": bool(result.cancelled),
}
class ProductSuiteAiWriteWorker(BaseWorker):
"""Analyze local product images without blocking the suite workspace."""
def __init__(
self,
instruction,
context,
*,
image_paths=None,
config=None,
cmhub_config_path=None,
):
super().__init__()
self.instruction = str(instruction or "")
self.context = str(context or "")
self.image_paths = [str(path or "") for path in list(image_paths or [])]
self.config = config
self.cmhub_config_path = cmhub_config_path
def execute(self):
if self.should_cancel():
return {"cancelled": True}
result = ai.analyze_product_images(
self.instruction,
self.context,
self.image_paths,
config=self.config,
cmhub_config_path=self.cmhub_config_path,
)
if self.should_cancel():
return {"cancelled": True}
return {
"text": str(result.get("text") or "").strip(),
"image_count": int(result.get("image_count", 0) or 0),
"metadata": dict(result.get("metadata") or {}),
}
class CMHubModelCatalogWorker(BaseWorker):
"""Fetch a transient cmhub model catalog without blocking a product workflow."""
def __init__(
self,
base_url,
api_key,
*,
connect_timeout=10,
use_system_proxy=False,
):
super().__init__()
self.base_url = appconfig.normalize_cmhub_base_url(base_url)
self.api_key = str(api_key or "")
self.connect_timeout = max(1, int(connect_timeout or 10))
self.use_system_proxy = bool(use_system_proxy)
def execute(self):
if self.should_cancel():
return {"cancelled": True}
models = ai.fetch_cmhub_models(
self.base_url,
self.api_key,
connect_timeout=self.connect_timeout,
use_system_proxy=self.use_system_proxy,
)
if self.should_cancel():
return {"cancelled": True}
cmhub_models.cache_model_catalog(self.base_url, models)
return {"models": models}
class ProductSuiteImportImagesWorker(BaseWorker):
"""Validate and copy local product images outside the GUI thread."""
def __init__(
self,
project_id,
*,
file_paths=None,
image_bytes=None,
filename_hint="clipboard.png",
db_path=None,
config=None,
):
super().__init__()
self.project_id = int(project_id)
self.file_paths = list(file_paths or [])
self.image_bytes = bytes(image_bytes) if image_bytes is not None else None
self.filename_hint = str(filename_hint or "clipboard.png")
self.db_path = db_path
self.config = config
def execute(self):
if self.image_bytes is not None:
asset = image_studio_images.import_original_bytes(
self.project_id,
self.image_bytes,
filename_hint=self.filename_hint,
path=self.db_path,
config=self.config,
)
return {"assets": [asset], "errors": [], "limit": 16}
return image_studio_images.import_original_files(
self.project_id,
self.file_paths,
path=self.db_path,
config=self.config,
)
2026-07-11 14:29:58 +08:00
class ImageStudioResumeJobsWorker(BaseWorker):
"""Resume submitted/running or failed-download AI studio jobs."""
def __init__(
self,
*,
project_id=None,
aspect_ratio="1:1",
db_path=None,
config=None,
cmhub_config_path=None,
):
super().__init__()
self.project_id = int(project_id) if project_id is not None else None
self.aspect_ratio = str(aspect_ratio or "1:1")
self.db_path = db_path
self.config = config
self.cmhub_config_path = cmhub_config_path
self._done = 0
self._failed = 0
self._lock = threading.Lock()
def execute(self):
def on_event(payload):
event = dict(payload or {})
self.log.emit(_format_image_studio_event(event))
if event.get("step") == "job_done":
with self._lock:
self._done += 1
if event.get("result") not in {"success"}:
self._failed += 1
progress = {"done": self._done, "failed": self._failed}
self.progress.emit(progress)
summary = image_studio_generation.resume_image_jobs(
project_id=self.project_id,
aspect_ratio=self.aspect_ratio,
config=self.config,
cmhub_config_path=self.cmhub_config_path,
path=self.db_path,
should_stop=self.should_cancel,
on_event=on_event,
)
summary["project_id"] = self.project_id
return summary
2026-07-11 14:26:01 +08:00
class ImageStudioExportWorker(BaseWorker):
"""Export AI studio final selections to local JPEG files."""
def __init__(
self,
project_id,
parent_dir,
*,
existing_mode=image_studio_export.EXISTING_FAIL,
db_path=None,
config=None,
):
super().__init__()
self.project_id = int(project_id)
self.parent_dir = parent_dir
self.existing_mode = existing_mode
self.db_path = db_path
self.config = config
def execute(self):
self.log.emit("[AI工场] 导出终选:开始")
result = image_studio_export.export_project_selection(
self.project_id,
self.parent_dir,
existing_mode=self.existing_mode,
path=self.db_path,
config=self.config,
)
self.log.emit("[AI工场] 导出终选:成功")
return {
"target_dir": result.target_dir,
"main_count": result.main_count,
"detail_count": result.detail_count,
"file_count": len(result.files),
"existing_mode": result.existing_mode,
}
2026-07-09 17:44:03 +08:00
def _generation_mode_label(mode):
mode = appconfig.normalize_generate_mode(mode)
return {
"title": "只生成标题",
"cover": "只生成封面",
"title_cover": "生成标题和封面",
}.get(mode, "只生成标题")
def _update_mode_label(mode):
mode = appconfig.normalize_update_mode(mode)
return {
"title": "只更新标题",
"cover": "只更新封面",
"title_cover": "更新标题和封面",
}.get(mode, "只更新标题")
2026-07-02 16:47:37 +08:00
class GenerateWorker(BaseWorker):
"""Generate titles and covers for eligible collected or failed generation tasks."""
2026-07-02 16:47:37 +08:00
def __init__(
self,
tasks,
prompt_values,
db_path=None,
config=None,
diagnostic_log_dir=None,
generation_scope="all",
product_status_counts=None,
status_scope_excluded=0,
generation_plan_fingerprint=None,
2026-07-02 16:47:37 +08:00
):
super().__init__()
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.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
)
2026-07-02 16:47:37 +08:00
self._run_id = None
self._account_by_alias = {}
self._task_positions = {}
self._eligible_total = 0
2026-07-04 17:14:29 +08:00
self._last_progress_payload = {}
self._cmhub_points_balance = None
self._billing_error = None
self._billing_stop_requested = False
2026-07-08 12:07:38 +08:00
self._run_started_at_text = ""
self._run_started_monotonic = None
2026-07-02 16:47:37 +08:00
def execute(self):
2026-07-08 12:07:38 +08:00
self._run_started_at_text = self._format_local_time()
self._run_started_monotonic = time.monotonic()
2026-07-02 16:47:37 +08:00
account_rows = accounts.list_accounts(path=self.db_path, config=self.config)
account_by_alias = {
str(account.alias).strip(): account
for account in account_rows
if str(account.alias).strip()
}
self._account_by_alias = account_by_alias
2026-07-06 17:57:39 +08:00
ai_cfg = appconfig.ai_config(self.config)
2026-07-09 17:44:03 +08:00
generate_mode = appconfig.ai_generate_mode(self.config)
generate_cover = appconfig.generate_mode_includes_cover(generate_mode)
2026-07-02 16:47:37 +08:00
eligible = [
task for task in self.tasks
2026-07-09 17:44:03 +08:00
if ai.is_generatable_task(task, generate_mode=generate_mode)
2026-07-02 16:47:37 +08:00
]
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))
]
2026-07-06 17:57:39 +08:00
component_totals = ai.generation_component_totals(
eligible,
2026-07-09 17:44:03 +08:00
generate_mode=generate_mode,
2026-07-06 17:57:39 +08:00
)
2026-07-02 16:47:37 +08:00
self._eligible_total = len(eligible)
self._task_positions = {
getattr(task, "id", None): index
for index, task in enumerate(eligible, start=1)
}
batch_ids = self._batch_ids(eligible)
self._run_id = self._create_run_log(eligible, batch_ids)
2026-07-09 17:44:03 +08:00
mode_text = _generation_mode_label(generate_mode)
2026-07-02 16:47:37 +08:00
if generate_cover:
2026-07-07 21:00:43 +08:00
if appconfig.ai_backend(self.config) == "cmhub":
concurrency = ai.cmhub_image_concurrency_plan(ai_cfg)
2026-07-09 17:44:03 +08:00
start_message = "[开始] 本轮生成 {total} 条:本轮生成内容:{mode_text};标题{title_total},图片{cover_total};标题并发{title_concurrency},图片并发{image_concurrency},cmhub实际生图并发{request_concurrency},下载并发{download_concurrency};开始时间 {started_at}".format(
2026-07-07 21:00:43 +08:00
total=len(eligible),
2026-07-09 17:44:03 +08:00
mode_text=mode_text,
2026-07-07 21:00:43 +08:00
title_total=component_totals["title_total"],
cover_total=component_totals["cover_total"],
title_concurrency=ai_cfg.get("title_concurrency", 1),
image_concurrency=concurrency["configured_image_concurrency"],
request_concurrency=concurrency["request_concurrency"],
download_concurrency=concurrency["download_concurrency"],
2026-07-08 12:07:38 +08:00
started_at=self._run_started_at_text,
2026-07-07 21:00:43 +08:00
)
else:
2026-07-09 17:44:03 +08:00
start_message = "[开始] 本轮生成 {total} 条:本轮生成内容:{mode_text};标题{title_total},图片{cover_total};标题并发{title_concurrency},图片并发{image_concurrency};开始时间 {started_at}".format(
2026-07-07 21:00:43 +08:00
total=len(eligible),
2026-07-09 17:44:03 +08:00
mode_text=mode_text,
2026-07-07 21:00:43 +08:00
title_total=component_totals["title_total"],
cover_total=component_totals["cover_total"],
title_concurrency=ai_cfg.get("title_concurrency", 1),
image_concurrency=ai_cfg.get("image_concurrency", 1),
2026-07-08 12:07:38 +08:00
started_at=self._run_started_at_text,
2026-07-07 21:00:43 +08:00
)
2026-07-02 16:47:37 +08:00
else:
2026-07-09 17:44:03 +08:00
start_message = "[开始] 本轮生成 {total} 条:本轮生成内容:{mode_text};标题{title_total};标题并发{title_concurrency};开始时间 {started_at}".format(
2026-07-02 16:47:37 +08:00
total=len(eligible),
2026-07-09 17:44:03 +08:00
mode_text=mode_text,
2026-07-06 17:57:39 +08:00
title_total=component_totals["title_total"],
2026-07-02 16:47:37 +08:00
title_concurrency=ai_cfg.get("title_concurrency", 1),
2026-07-08 12:07:38 +08:00
started_at=self._run_started_at_text,
2026-07-02 16:47:37 +08:00
)
start_message += ";生成范围:{scope};按范围排除{excluded}条".format(
scope=(
"仅状态正常"
if self.generation_scope == product_status.SCOPE_NORMAL_ONLY
else "所有状态"
),
excluded=self.status_scope_excluded,
)
2026-07-02 16:47:37 +08:00
self._log_run_event(start_message)
try:
summary = ai.generate_batch(
eligible,
2026-07-02 16:47:37 +08:00
self.prompt_values,
ai_cfg={
"config": self.config,
"db_path": self.db_path,
"image_dir": appconfig.image_dir(self.config),
"account_by_alias": account_by_alias,
"on_task_update": self._emit_row_update,
"on_event": self._on_generation_event,
"on_error": self._on_generation_error,
"generate_cover": generate_cover,
2026-07-09 17:44:03 +08:00
"generate_mode": generate_mode,
2026-07-02 16:47:37 +08:00
},
2026-07-04 17:14:29 +08:00
on_progress=self._emit_generate_progress,
should_stop=self._should_stop_generation,
2026-07-02 16:47:37 +08:00
)
except Exception as exc:
error = diagnostics.redact_log_text(str(exc) or exc.__class__.__name__)
summary = {
"ok": False,
"error": error,
"total": len(eligible),
2026-07-06 17:57:39 +08:00
"title_total": component_totals["title_total"],
2026-07-02 16:47:37 +08:00
"title_done": 0,
"cover_done": 0,
2026-07-06 17:57:39 +08:00
"cover_total": component_totals["cover_total"] if generate_cover else 0,
2026-07-02 16:47:37 +08:00
"generated_done": 0,
"failed": len(eligible),
"cancelled": self.should_cancel(),
"generate_cover": generate_cover,
2026-07-09 17:44:03 +08:00
"generate_mode": generate_mode,
2026-07-02 16:47:37 +08:00
}
2026-07-08 12:07:38 +08:00
user_error = self._user_log_detail(error) or "未知错误"
2026-07-02 16:47:37 +08:00
self._log_run_event(
2026-07-08 12:07:38 +08:00
f"[失败] AI 生成运行失败:{user_error}",
2026-07-02 16:47:37 +08:00
level="error",
)
self._write_diagnostic_log(
"AI生成运行失败",
level="ERROR",
step="execute",
payload={"error": error},
exc=exc,
)
2026-07-04 17:14:29 +08:00
if self._cmhub_points_balance is not None:
summary["points_balance"] = self._cmhub_points_balance
if self._billing_error is not None:
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
2026-07-02 16:47:37 +08:00
summary["run_id"] = self._run_id
summary["batch_ids"] = batch_ids
2026-07-04 17:14:29 +08:00
status = "failed" if summary.get("billing_error") or summary.get("error") else ("cancelled" if summary.get("cancelled") else "done")
level = "error" if summary.get("billing_error") or summary.get("error") else ("warning" if summary.get("cancelled") else "info")
2026-07-02 16:47:37 +08:00
self._log_run_event(self._format_generate_completion(summary), level=level)
self._finish_run_log(status, summary)
return summary
2026-07-04 17:14:29 +08:00
def _emit_generate_progress(self, payload):
progress = dict(payload or {})
if self._cmhub_points_balance is not None:
progress["points_balance"] = self._cmhub_points_balance
if self._billing_error is not None:
progress["billing_error"] = dict(self._billing_error)
self._last_progress_payload = dict(progress)
self.progress.emit(progress)
def _should_stop_generation(self):
return self.should_cancel() or self._billing_stop_requested
2026-07-02 16:47:37 +08:00
def _emit_row_update(self, task_id, fields):
self.row_updated.emit(int(task_id), dict(fields or {}))
def _on_generation_event(self, payload):
task = payload.get("task")
2026-07-04 17:14:29 +08:00
self._remember_cmhub_metadata(payload)
2026-07-02 16:47:37 +08:00
message = self._format_generation_event(payload)
if not message:
return
self._log_run_event(
message,
task=task,
level=payload.get("level") or "info",
persist=not bool(payload.get("debug_only")),
)
2026-07-02 16:47:37 +08:00
2026-07-04 17:14:29 +08:00
def _remember_cmhub_metadata(self, payload):
metadata = payload.get("metadata")
if not isinstance(metadata, dict):
return
if metadata.get("points_balance") is not None:
self._cmhub_points_balance = metadata.get("points_balance")
self._emit_generate_progress(self._last_progress_payload)
2026-07-02 16:47:37 +08:00
def _format_generation_event(self, payload):
task = payload.get("task")
phase = payload.get("phase") or "generate"
step = payload.get("step") or "unknown"
result = payload.get("result") or "start"
2026-07-08 12:07:38 +08:00
detail = self._user_log_detail(
payload.get("detail"),
phase=phase,
step=step,
code=payload.get("code"),
status=payload.get("status"),
)
2026-07-04 17:14:29 +08:00
if isinstance(payload.get("metadata"), dict):
return self._format_cmhub_billing_event(task, phase, payload.get("metadata"))
2026-07-02 16:47:37 +08:00
if phase == "title":
if result == "start" and step == "title_submit":
return f"[标题] {self._task_progress_label(task)} 开始生成"
2026-07-06 17:57:39 +08:00
if result == "skipped":
if detail and "旧标题作为封面参考" in detail:
return f"[图片] {self._task_progress_label(task)} {detail}"
2026-07-06 17:57:39 +08:00
return f"[标题] {self._task_progress_label(task)} 已有标题,跳过生文"
2026-07-02 16:47:37 +08:00
if result == "success" and step == "title_done":
return f"[标题] {self._task_progress_label(task)} 成功"
if result == "success" and step == "db_write":
suffix = f",{detail}" if detail else ""
return f"[标题] {self._task_progress_label(task)} 已保存{suffix}"
if result == "retry":
return self._retry_message("标题", task, payload, detail)
if result == "failed":
return f"[失败] {self._task_plain_label(task)} 标题生成失败:{detail or '未知错误'}"
if result == "cancelled":
return f"[停止] {self._task_plain_label(task)} 标题生成已取消"
return None
if phase == "cover":
if result == "debug" and step == "cover_image_url":
debug_detail = self._debug_detail(payload.get("detail"))
return f"[调试] {self._task_progress_label(task)} {debug_detail}"
2026-07-02 16:47:37 +08:00
if result == "start" and step == "cover_submit":
return f"[图片] {self._task_progress_label(task)} 开始生成"
2026-07-07 21:00:43 +08:00
if result == "success" and step == "cover_request":
2026-07-08 12:07:38 +08:00
return f"[图片] {self._task_progress_label(task)} {detail or 'cmhub 已返回图片,耗时未知'}"
if result == "warning" and step == "cover_download":
return f"[图片] {self._task_progress_label(task)} {detail or '图片下载较慢'}"
2026-07-07 21:00:43 +08:00
if result == "success" and step == "cover_download":
return f"[图片] {self._task_progress_label(task)} {detail or '图片下载完成,耗时未知'}"
if result == "success" and step == "cover_save":
return f"[图片] {self._task_progress_label(task)} 本地保存完成,{detail or '耗时未知'}"
2026-07-02 16:47:37 +08:00
if result == "success" and step == "db_write":
2026-07-08 12:07:38 +08:00
suffix = ",已保存到本地" if detail else ""
2026-07-02 16:47:37 +08:00
return f"[图片] {self._task_progress_label(task)} 成功{suffix}"
if result == "retry":
return self._retry_message("图片", task, payload, detail)
if result == "failed":
return f"[失败] {self._task_plain_label(task)} 图片生成失败:{detail or '未知错误'}"
if result == "cancelled":
return f"[停止] {self._task_plain_label(task)} 图片生成已取消"
return None
return None
2026-07-04 17:14:29 +08:00
def _format_cmhub_billing_event(self, task, phase, metadata):
label = "标题" if phase == "title" else ("图片" if phase == "cover" else "AI")
parts = []
alias = metadata.get("alias") or metadata.get("model_used")
if alias:
parts.append(f"别名 {alias}")
if metadata.get("points_cost") is not None:
parts.append(f"扣点 {metadata.get('points_cost')}")
if metadata.get("points_balance") is not None:
parts.append(f"余额 {metadata.get('points_balance')}")
if metadata.get("call_id"):
parts.append(f"call_id={metadata.get('call_id')}")
if not parts:
return None
return f"[计费] {self._task_plain_label(task)} {label}生成:" + ",".join(str(part) for part in parts)
2026-07-02 16:47:37 +08:00
def _retry_message(self, label, task, payload, detail):
attempt = int(payload.get("attempt", 0) or 0)
attempts = int(payload.get("attempts", 0) or 0)
max_retries = max(0, attempts - 1)
retry_text = f"准备重试 {attempt}/{max_retries}" if max_retries else "准备重试"
reason = f":{detail}" if detail else ""
return f"[{label}] {self._task_progress_label(task)} 调用失败,{retry_text}{reason}"
def _task_progress_label(self, task):
index = self._task_positions.get(getattr(task, "id", None), 0)
total = self._eligible_total or 0
item_id = getattr(task, "item_id", "") or "未知商品"
shop = self._task_shop_label(task)
shop_text = f"({shop})" if shop else ""
return f"{index}/{total} 商品 {item_id}{shop_text}"
def _task_plain_label(self, task):
item_id = getattr(task, "item_id", "") or "未知商品"
shop = self._task_shop_label(task)
return f"商品 {item_id}({shop})" if shop else f"商品 {item_id}"
def _task_shop_label(self, task):
alias = str(getattr(task, "alias", "") or "").strip()
account = self._account_by_alias.get(alias)
if account is not None:
return getattr(account, "account_name", None) or getattr(account, "alias", None) or alias
return getattr(task, "account_name", None) or alias
def _short_detail(self, detail):
if detail is None:
return ""
text = diagnostics.redact_log_text(str(detail)).replace("\r", " ").replace("\n", " ").strip()
if len(text) > 180:
return text[:177] + "..."
return text
def _debug_detail(self, detail):
if detail is None:
return ""
text = diagnostics.redact_log_text(str(detail)).replace("\r", " ").replace("\n", " ").strip()
if len(text) > 2000:
return text[:1997] + "..."
return text
2026-07-08 12:07:38 +08:00
def _user_log_detail(self, detail, phase=None, step=None, code=None, status=None):
if detail is None:
return ""
raw = diagnostics.redact_log_text(str(detail)).replace("\r", " ").replace("\n", " ").strip()
lowered = raw.lower()
code_text = str(code or "").strip().lower()
if code_text == "insufficient_points" or "点数不足" in raw:
return "点数不足,请先充值"
if code_text == "unauthorized" or "api key 无效" in raw.lower():
return "cmhub API Key 无效,请去设置重填"
2026-07-08 12:07:38 +08:00
if code_text in {"model_not_allowed", "no_pricing_rule"} or "模型别名" in raw or "模型配置不可用" in raw:
return "cmhub 模型别名不可用,请去设置刷新别名并保存"
2026-07-08 12:07:38 +08:00
if code_text == "content_blocked" or "内容安全" in raw:
return "cmhub 内容安全策略拒绝本次生成"
if code_text == "rate_limited" or "rate_limited" in lowered or "请求过于频繁" in raw:
return "cmhub 请求过于频繁,请稍后重试"
if code_text == "connect_timeout" or "连接 cmhub 超时" in raw:
return "连接 cmhub 超时,请检查网络或稍后重试"
if code_text == "read_timeout" or "等待 cmhub 返回超时" in raw:
return "等待 cmhub 返回超时,本条已失败;可稍后重试"
if (
code_text == "not_found"
or "not_found" in lowered
or "接口不存在" in raw
or str(status or "") == "404"
):
return "cmhub 网关接口不可用,请检查设置中的 Base URL,或联系服务方确认网关版本"
2026-07-08 12:07:38 +08:00
if code_text == "upstream_error" or "upstream_error" in lowered or "上游" in raw:
return "cmhub 上游生成失败,请稍后重试"
if "下载 cmhub 图片失败" in raw:
return "下载 cmhub 图片失败,请检查网络后稍后重试"
text = raw.replace("image_url", "图片")
text = text.replace("返回 图片", "返回图片")
text = _USER_LOG_URL_RE.sub("[链接已隐藏]", text)
text = _USER_LOG_PATH_RE.sub("[接口路径已隐藏]", text)
text = text.replace("GET [链接已隐藏]", "请求 cmhub")
text = text.replace("POST [链接已隐藏]", "请求 cmhub")
if len(text) > 180:
return text[:177] + "..."
return text
2026-07-02 16:47:37 +08:00
def _format_generate_completion(self, summary):
progress = self._summary_text(summary)
2026-07-04 17:14:29 +08:00
billing_error = summary.get("billing_error") or {}
2026-07-08 12:07:38 +08:00
finished_at = self._format_local_time()
elapsed = self._format_run_elapsed()
suffix = f";{self._completion_time_label(summary)} {finished_at},总用时 {elapsed}"
2026-07-04 17:14:29 +08:00
if billing_error:
2026-07-08 12:07:38 +08:00
message = self._user_log_detail(billing_error.get("message") or "点数不足,请先充值") or "点数不足,请先充值"
return f"[失败] AI 生成已中止:{message},{progress}{suffix}"
2026-07-02 16:47:37 +08:00
if summary.get("cancelled"):
2026-07-08 12:07:38 +08:00
return f"[停止] AI 生成已停止:{progress}{suffix}"
2026-07-02 16:47:37 +08:00
if summary.get("error"):
2026-07-08 12:07:38 +08:00
error = self._user_log_detail(summary.get("error")) or "未知错误"
return f"[失败] AI 生成失败:{error},{progress}{suffix}"
return f"[完成] AI 生成完成:{progress}{suffix}"
def _completion_time_label(self, summary):
if summary.get("billing_error") or summary.get("error"):
return "失败时间"
if summary.get("cancelled"):
return "停止时间"
return "完成时间"
def _format_local_time(self):
return _dt.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
def _format_run_elapsed(self):
if self._run_started_monotonic is None:
return "0秒"
return self._format_duration(time.monotonic() - self._run_started_monotonic)
def _format_duration(self, seconds):
total = max(0, int(seconds or 0))
hours, remainder = divmod(total, 3600)
minutes, seconds = divmod(remainder, 60)
if hours:
return f"{hours}小时{minutes:02d}分{seconds:02d}秒"
if minutes:
return f"{minutes}分{seconds:02d}秒"
return f"{seconds}秒"
2026-07-02 16:47:37 +08:00
def _summary_text(self, summary):
2026-07-06 17:57:39 +08:00
title_total = summary.get("title_total", summary.get("total", 0))
2026-07-02 16:47:37 +08:00
cover_total = summary.get("cover_total", summary.get("total", 0))
return "标题{title}/{total},图片{cover}/{cover_total},失败{failed}".format(
title=summary.get("title_done", 0),
cover=summary.get("cover_done", 0),
cover_total=cover_total,
2026-07-06 17:57:39 +08:00
total=title_total,
2026-07-02 16:47:37 +08:00
failed=summary.get("failed", 0),
)
def _on_generation_error(self, payload):
task = payload.get("task")
phase = payload.get("phase") or "generate"
step = payload.get("step") or "unknown"
2026-07-04 17:14:29 +08:00
exception = payload.get("exception")
code = payload.get("code") or getattr(exception, "code", None)
status = payload.get("status") or getattr(exception, "status", None)
2026-07-02 16:47:37 +08:00
error = diagnostics.redact_log_text(payload.get("error") or "未知错误")
2026-07-04 17:14:29 +08:00
if str(code or "") == "insufficient_points":
self._billing_stop_requested = True
message = "点数不足,请先充值。本轮未开始任务将停止。"
self._billing_error = {
"code": "insufficient_points",
"message": message,
"phase": phase,
"task_id": getattr(task, "id", None),
"item_id": getattr(task, "item_id", None),
}
if status is not None:
self._billing_error["status"] = status
self._log_run_event(
f"[计费] {self._task_plain_label(task)} 点数不足,请先充值;本轮未开始任务将停止",
task=task,
level="error",
)
self._emit_generate_progress(self._last_progress_payload)
diagnostic_payload = {"phase": phase, "error": error}
if code is not None:
diagnostic_payload["code"] = str(code)
if status is not None:
diagnostic_payload["status"] = status
2026-07-02 16:47:37 +08:00
self._write_diagnostic_log(
"AI生成任务失败",
level="ERROR",
step=step,
task=task,
2026-07-04 17:14:29 +08:00
payload=diagnostic_payload,
exc=exception,
2026-07-02 16:47:37 +08:00
)
def _batch_ids(self, tasks):
batch_ids = []
for task in tasks:
batch_id = getattr(task, "batch_id", None)
if batch_id and batch_id not in batch_ids:
batch_ids.append(batch_id)
return batch_ids
def _create_run_log(self, eligible, batch_ids):
try:
ai_cfg = appconfig.ai_config(self.config)
return db.create_run_log(
"generate",
dry_run=False,
total=len(eligible),
options={
"batch_ids": batch_ids,
"default_text_model": ai_cfg.get("default_text_model"),
"default_image_model": ai_cfg.get("default_image_model"),
"resolution": ai_cfg.get("resolution"),
"title_concurrency": ai_cfg.get("title_concurrency"),
"image_concurrency": ai_cfg.get("image_concurrency"),
"generate_cover": ai_cfg.get("generate_cover", False),
2026-07-04 17:14:29 +08:00
"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,
2026-07-02 16:47:37 +08:00
},
path=self.db_path,
)
except Exception:
return None
def _finish_run_log(self, status, summary):
if self._run_id is None:
return
try:
generated_done = summary.get("generated_done")
if generated_done is None:
generated_done = summary.get("cover_done", 0)
if not summary.get("generate_cover", True) and not generated_done:
generated_done = summary.get("title_done", 0)
done = int(generated_done or 0) + int(summary.get("failed", 0) or 0)
db.finish_run_log(
self._run_id,
status=status,
done=done,
success_count=generated_done,
skipped_count=0,
failed_count=summary.get("failed", 0),
summary_json=summary,
path=self.db_path,
)
except Exception:
return
def _log_run_event(self, message, task=None, level="info", persist=True):
2026-07-02 16:47:37 +08:00
safe_message = diagnostics.redact_log_text(message)
self.log.emit(str(safe_message))
if self._run_id is None or not persist:
2026-07-02 16:47:37 +08:00
return
try:
db.add_run_log_event(
self._run_id,
safe_message,
task_id=getattr(task, "id", None),
alias=getattr(task, "alias", None),
item_id=getattr(task, "item_id", None),
level=level,
path=self.db_path,
)
except Exception:
return
def _write_diagnostic_log(
self,
message,
level="INFO",
step=None,
task=None,
payload=None,
exc=None,
):
try:
diagnostics.write_diagnostic_log(
message,
level=level,
step=step,
task_id=getattr(task, "id", None),
alias=getattr(task, "alias", None),
item_id=getattr(task, "item_id", None),
payload=payload,
exc=exc,
log_dir=self.diagnostic_log_dir,
)
except Exception:
return
class ApplyWorker(BaseWorker):
"""Apply generated title/cover changes, optionally previewing or grouping by account."""
def __init__(
self,
tasks,
db_path=None,
config=None,
preflight=True,
dry_run=False,
2026-07-09 17:44:03 +08:00
update_mode=None,
2026-07-02 16:47:37 +08:00
max_parallel_accounts=1,
batch_size=None,
diagnostic_log_dir=None,
product_status_counts=None,
status_scope_excluded=0,
content_scope_excluded=0,
apply_plan_fingerprint=None,
2026-07-02 16:47:37 +08:00
):
super().__init__()
self.tasks = list(tasks)
self.db_path = db_path
self.config = config
self.preflight = preflight
self.dry_run = bool(dry_run)
2026-07-09 17:44:03 +08:00
self.update_mode = appconfig.normalize_update_mode(
update_mode,
allow_cover_update=appconfig.shopee_update_config(config).get("allow_cover_update", False),
)
2026-07-10 10:12:15 +08:00
self.max_parallel_accounts = max(
appconfig.SHOPEE_PARALLEL_ACCOUNTS_MIN,
min(appconfig.SHOPEE_PARALLEL_ACCOUNTS_MAX, int(max_parallel_accounts or 1)),
)
2026-07-02 16:47:37 +08:00
self.batch_size = None if batch_size is None else max(1, int(batch_size or 1))
self._current_batch_size = None
self._batch_count = 0
self._progress_lock = threading.Lock()
self.diagnostic_log_dir = diagnostic_log_dir
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.content_scope_excluded = max(0, int(content_scope_excluded or 0))
self.apply_plan_fingerprint = str(apply_plan_fingerprint or "") or None
2026-07-02 16:47:37 +08:00
self._run_id = None
def execute(self):
account_rows = accounts.list_accounts(path=self.db_path, config=self.config)
account_by_alias = {
str(account.alias).strip(): account
for account in account_rows
if str(account.alias).strip()
}
eligible = [task for task in self.tasks if self._is_actionable_task(task)]
batch_ids = self._batch_ids(eligible)
total = len(eligible)
batch_size = self._effective_batch_size(total)
batches = self._task_batches(eligible, batch_size)
self._current_batch_size = batch_size
self._batch_count = len(batches)
counters = {
"done": 0,
"applied": 0,
"skipped": 0,
"failed": 0,
}
self._run_id = self._create_run_log(eligible, batch_ids)
self._log_run_event(
"step=start result=start detail=运行开始:{mode},更新内容{update_mode},任务{total},每批最多{batch_size},批次{batch_count},{parallel};商品状态异常排除{status_excluded}条;内容缺失排除{content_excluded}条".format(
2026-07-02 16:47:37 +08:00
mode="检查本轮更新" if self.dry_run else "真实更新",
2026-07-09 17:44:03 +08:00
update_mode=_update_mode_label(self.update_mode),
2026-07-02 16:47:37 +08:00
total=total,
batch_size=batch_size,
batch_count=len(batches),
parallel=(
f"多账号并行最多{self.max_parallel_accounts}"
2026-07-10 10:12:15 +08:00
if self.max_parallel_accounts > 1
2026-07-02 16:47:37 +08:00
else "串行"
),
status_excluded=self.status_scope_excluded,
content_excluded=self.content_scope_excluded,
2026-07-02 16:47:37 +08:00
)
)
if self.preflight and not self.dry_run:
self._log_run_event("step=preflight result=start detail=账号就绪检查")
blocked = self._preflight_block(eligible, account_rows, account_by_alias)
if blocked:
self._log_preflight_blocked(blocked)
summary = self._summary(
ok=False,
total=total,
counters=counters,
batch_ids=batch_ids,
blocked=True,
extra=blocked,
)
self._finish_run_log("blocked", summary)
return summary
self._log_run_event("step=preflight result=success detail=账号检查通过")
elif not self.preflight:
self._log_run_event(
"step=preflight result=skipped detail=测试模式跳过更新前检查",
level="warning",
)
for batch_index, batch_tasks in enumerate(batches, start=1):
if self.should_cancel():
break
self._log_batch_start(batch_index, len(batches), batch_tasks, counters, total)
if self.dry_run:
for task in batch_tasks:
if self.should_cancel():
break
outcome = self._preview_task(task, account_by_alias)
self._record_outcome(counters, total, outcome)
2026-07-10 10:12:15 +08:00
elif self.max_parallel_accounts > 1:
2026-07-02 16:47:37 +08:00
self._run_parallel_by_account(batch_tasks, account_by_alias, counters, total)
else:
for task in batch_tasks:
if self.should_cancel():
break
outcome = self._apply_one_task(task, account_by_alias)
self._record_outcome(counters, total, outcome)
summary = self._summary(
ok=counters["failed"] == 0,
total=total,
counters=counters,
batch_ids=batch_ids,
)
self._finish_run_log("cancelled" if self.should_cancel() else "done", summary)
return summary
def _is_actionable_task(self, task):
return (
getattr(task, "stage", None) == "generated"
and getattr(task, "status", None) in {"success", "pending", "failed"}
and product_status.is_normal(getattr(task, "product_status", None))
2026-07-09 17:44:03 +08:00
and (
(
appconfig.update_mode_includes_title(self.update_mode)
and bool(getattr(task, "new_title", None))
)
or (
appconfig.update_mode_includes_cover(self.update_mode)
and bool(getattr(task, "new_cover_path", None))
)
)
2026-07-02 16:47:37 +08:00
)
def _preflight_block(self, eligible, account_rows, account_by_alias):
if not account_rows:
return {
"reason": "NO_ACCOUNTS",
"no_accounts": True,
}
duplicate_ports = self._duplicate_debug_ports(account_rows, eligible, account_by_alias)
if duplicate_ports:
return {
"reason": "DUPLICATE_DEBUG_PORT",
"duplicate_ports": duplicate_ports,
}
required_accounts = []
seen_aliases = set()
for task in eligible:
alias = str(task.alias).strip()
account = account_by_alias.get(alias)
if account is not None and alias not in seen_aliases:
required_accounts.append(account)
seen_aliases.add(alias)
not_running = []
logged_out = []
for account in required_accounts:
self._log_run_event(
f"step=check_chrome result=start detail=账号 {account.alias} debug_port={account.debug_port}",
level="info",
)
if not chrome.is_running(account.debug_port):
self._log_run_event(
f"step=check_chrome result=blocked detail=账号 {account.alias} CDP 端口未响应 debug_port={account.debug_port}",
level="warning",
)
not_running.append(self._account_payload(account, "CDP 端口未响应"))
continue
self._log_run_event(
f"step=check_chrome result=success detail=账号 {account.alias} debug_port={account.debug_port}",
level="info",
)
self._log_run_event(
f"step=login_check result=start detail=账号 {account.alias}",
level="info",
)
status = self._login_status(account)
if not status.get("logged_in"):
reason = self._login_skip_reason(status)
self._log_run_event(
f"step=login_check result=blocked detail=账号 {account.alias} {reason}",
level="warning",
)
logged_out.append(
self._account_payload(account, reason)
)
else:
self._log_run_event(
f"step=login_check result=success detail=账号 {account.alias}",
level="info",
)
if not_running or logged_out:
return {
"reason": "ACCOUNT_NOT_READY",
"not_running": not_running,
"logged_out": logged_out,
}
return None
def _duplicate_debug_ports(self, account_rows, eligible, account_by_alias):
required_aliases = {
str(task.alias).strip()
for task in eligible
if account_by_alias.get(str(task.alias).strip()) is not None
}
by_port = {}
for account in account_rows:
if account.alias not in required_aliases:
continue
by_port.setdefault(int(account.debug_port), []).append(account)
duplicates = []
for port, rows in by_port.items():
if len(rows) > 1:
duplicates.append(
{
"debug_port": port,
"aliases": [row.alias for row in rows],
}
)
return duplicates
def _effective_batch_size(self, total):
if self.batch_size is None:
return max(1, int(total or 1))
return self.batch_size
def _task_batches(self, tasks, batch_size):
if not tasks:
return []
return [
tasks[index:index + batch_size]
for index in range(0, len(tasks), batch_size)
]
def _log_batch_start(self, batch_index, batch_count, batch_tasks, counters, total):
first = counters["done"] + 1
last = min(first + len(batch_tasks) - 1, total)
label = "检查批次" if self.dry_run else "更新批次"
self._log_run_event(
f"step=batch result=start detail={label} {batch_index}/{batch_count} 开始:任务 {first}-{last}/{total}"
)
def _run_parallel_by_account(self, eligible, account_by_alias, counters, total):
groups = self._group_tasks_by_alias(eligible)
max_workers = min(self.max_parallel_accounts, len(groups))
if max_workers <= 1:
for group_tasks in groups:
self._run_task_group(group_tasks, account_by_alias, counters, total)
return
with ThreadPoolExecutor(max_workers=max_workers) as executor:
futures = [
executor.submit(
self._run_task_group,
group_tasks,
account_by_alias,
counters,
total,
)
for group_tasks in groups
]
for future in as_completed(futures):
future.result()
def _group_tasks_by_alias(self, tasks):
groups = []
index_by_alias = {}
for task in tasks:
alias = str(task.alias).strip()
if alias not in index_by_alias:
index_by_alias[alias] = len(groups)
groups.append([])
groups[index_by_alias[alias]].append(task)
return groups
def _run_task_group(self, tasks, account_by_alias, counters, total):
for task in tasks:
if self.should_cancel():
break
outcome = self._apply_one_task(task, account_by_alias)
self._record_outcome(counters, total, outcome)
def _preview_task(self, task, account_by_alias):
account = account_by_alias.get(str(task.alias).strip())
if account is None:
reason = "别名未匹配账号"
self._log_run_event(
f"step=preview result=skipped detail=检查:任务 {task.id} 商品 {task.item_id} 将略过:{reason}",
task=task,
level="warning",
)
return "skipped"
action_parts = []
2026-07-09 17:44:03 +08:00
if appconfig.update_mode_includes_title(self.update_mode) and getattr(task, "new_title", None):
2026-07-02 16:47:37 +08:00
action_parts.append("标题")
2026-07-09 17:44:03 +08:00
if appconfig.update_mode_includes_cover(self.update_mode) and getattr(task, "new_cover_path", None):
2026-07-02 16:47:37 +08:00
action_parts.append("封面")
action_text = "+".join(action_parts) or "无变更"
self._log_run_event(
"step=preview result=success detail=检查:任务 {task_id} 商品 {item_id} 账号 {alias} 将更新 {action}".format(
task_id=task.id,
item_id=task.item_id,
alias=account.alias,
action=action_text,
),
task=task,
)
return "applied"
def _apply_one_task(self, task, account_by_alias):
account = account_by_alias.get(str(task.alias).strip())
if account is None:
reason = "别名未匹配账号"
db.mark_skipped(task.id, reason, path=self.db_path)
self.row_updated.emit(task.id, {"status": "skipped", "last_error": reason})
self._log_run_event(
f"step=preflight result=skipped detail=任务 {task.id} 商品 {task.item_id} 已略过:{reason}",
task=task,
level="warning",
)
return "skipped"
started = time.monotonic()
current_step = "db_write"
def on_step(event):
nonlocal current_step
if isinstance(event, dict):
step = str(event.get("step") or "apply_task")
result = str(event.get("result") or "start")
detail = event.get("detail")
else:
step = str(event)
result = "start"
detail = None
current_step = step
level = "error" if result == "failed" else "info"
detail_text = "任务 {task_id} 商品 {item_id}".format(
task_id=task.id,
item_id=task.item_id,
)
if detail:
detail_text = f"{detail_text} {detail}"
self._log_run_event(
f"step={step} result={result} detail={detail_text}",
task=task,
level=level,
)
try:
self._log_run_event(
f"step=apply_task result=start detail=任务 {task.id} 商品 {task.item_id} 开始更新,账号 {account.alias}",
task=task,
)
current_step = "db_write"
self._log_run_event(
f"step=db_write result=start detail=任务 {task.id} 商品 {task.item_id} 标记更新运行",
task=task,
)
db.mark_running(task.id, "apply", path=self.db_path)
self.row_updated.emit(task.id, {"status": "running", "last_error": None})
2026-07-08 11:08:01 +08:00
current_step = "apply_task"
2026-07-02 16:47:37 +08:00
result = editor.apply_task(
account,
task,
on_step=on_step,
2026-07-11 22:30:19 +08:00
bring_to_front=True,
2026-07-09 17:44:03 +08:00
update_mode=self.update_mode,
2026-07-02 16:47:37 +08:00
)
committed = bool(result.get("committed")) and not result.get("error")
error = result.get("error")
2026-07-08 11:08:01 +08:00
failed_step = self._failed_apply_step(result, current_step)
2026-07-02 16:47:37 +08:00
current_step = "db_write"
self._log_run_event(
f"step=db_write result=start detail=任务 {task.id} 商品 {task.item_id} 保存更新结果",
task=task,
)
if committed:
db.set_applied(task.id, True, path=self.db_path)
elapsed_ms = self._elapsed_ms(started)
self.row_updated.emit(
task.id,
{
"stage": "applied",
"status": "success",
"committed": 1,
"last_error": None,
},
)
self._log_run_event(
f"step=db_write result=success detail=任务 {task.id} 商品 {task.item_id} 更新成功 elapsed_ms={elapsed_ms}",
task=task,
)
return "applied"
error = diagnostics.redact_log_text(error or "更新未提交")
2026-07-08 11:08:01 +08:00
display_error = db.format_failure_error(error, failed_step)
db.set_applied(task.id, False, error, path=self.db_path, step=failed_step)
2026-07-02 16:47:37 +08:00
elapsed_ms = self._elapsed_ms(started)
2026-07-08 11:08:01 +08:00
self.failed.emit(task.id, str(display_error))
2026-07-02 16:47:37 +08:00
self.row_updated.emit(
task.id,
2026-07-08 11:08:01 +08:00
{"status": "failed", "last_error": str(display_error), "committed": 0},
2026-07-02 16:47:37 +08:00
)
self._log_run_event(
f"step={failed_step} result=failed detail={error} elapsed_ms={elapsed_ms}",
task=task,
level="error",
)
self._log_run_event(
f"step=db_write result=success detail=任务 {task.id} 商品 {task.item_id} 保存失败状态 elapsed_ms={elapsed_ms}",
task=task,
)
self._write_diagnostic_log(
2026-07-07 14:58:54 +08:00
"蝦皮更新任务失败",
2026-07-02 16:47:37 +08:00
level="ERROR",
step=failed_step,
task=task,
elapsed_ms=elapsed_ms,
payload={"error": error, "result": result},
)
return "failed"
except Exception as exc:
error = diagnostics.redact_log_text(str(exc) or exc.__class__.__name__)
2026-07-08 11:08:01 +08:00
display_error = db.format_failure_error(error, current_step)
db.set_applied(task.id, False, error, path=self.db_path, step=current_step)
2026-07-02 16:47:37 +08:00
elapsed_ms = self._elapsed_ms(started)
2026-07-08 11:08:01 +08:00
self.failed.emit(task.id, display_error)
2026-07-02 16:47:37 +08:00
self.row_updated.emit(
task.id,
2026-07-08 11:08:01 +08:00
{"status": "failed", "last_error": display_error, "committed": 0},
2026-07-02 16:47:37 +08:00
)
self._log_run_event(
f"step={current_step} result=failed detail={error} elapsed_ms={elapsed_ms}",
task=task,
level="error",
)
self._write_diagnostic_log(
2026-07-07 14:58:54 +08:00
"蝦皮更新任务异常",
2026-07-02 16:47:37 +08:00
level="ERROR",
step=current_step,
task=task,
elapsed_ms=elapsed_ms,
payload={"error": error},
exc=exc,
)
return "failed"
2026-07-09 15:04:20 +08:00
2026-07-02 16:47:37 +08:00
def _record_outcome(self, counters, total, outcome):
with self._progress_lock:
counters["done"] += 1
if outcome == "applied":
counters["applied"] += 1
elif outcome == "skipped":
counters["skipped"] += 1
else:
counters["failed"] += 1
self._emit_progress(
counters["done"],
total,
counters["applied"],
counters["skipped"],
counters["failed"],
)
def _account_payload(self, account, reason=None):
payload = {
"account_name": account.account_name,
"alias": account.alias,
"debug_port": account.debug_port,
}
if reason:
payload["reason"] = reason
return payload
def _emit_progress(self, done, total, applied, skipped, failed):
self.progress.emit(
{
"done": done,
"total": total,
"applied": applied,
"skipped": skipped,
"failed": failed,
"dry_run": self.dry_run,
"batch_size": self._current_batch_size,
"batch_count": self._batch_count,
2026-07-09 17:44:03 +08:00
"update_mode": self.update_mode,
2026-07-02 16:47:37 +08:00
}
)
def _login_status(self, account):
try:
return accounts.detect_login(account, path=self.db_path, config=self.config)
except Exception as exc:
return {
"logged_in": False,
"reason": f"LOGIN_CHECK_FAILED: {exc}",
}
def _login_skip_reason(self, status):
reason = status.get("reason")
return f"账号未登录: {reason}" if reason else "账号未登录"
def _batch_ids(self, tasks):
batch_ids = []
for task in tasks:
batch_id = getattr(task, "batch_id", None)
if batch_id and batch_id not in batch_ids:
batch_ids.append(batch_id)
return batch_ids
def _summary(self, ok, total, counters, batch_ids, blocked=False, extra=None):
summary = {
"ok": ok,
"total": total,
"done": counters["done"],
"applied": counters["applied"],
"skipped": counters["skipped"],
"failed": counters["failed"],
"batch_ids": batch_ids,
"dry_run": self.dry_run,
2026-07-10 10:12:15 +08:00
"account_parallel": self.max_parallel_accounts > 1,
2026-07-02 16:47:37 +08:00
"batch_size": self._current_batch_size,
"batch_count": self._batch_count,
2026-07-09 17:44:03 +08:00
"update_mode": self.update_mode,
2026-07-02 16:47:37 +08:00
"run_id": self._run_id,
"product_status_counts": dict(self.product_status_counts),
"status_scope_excluded": self.status_scope_excluded,
"content_scope_excluded": self.content_scope_excluded,
"apply_plan_fingerprint": self.apply_plan_fingerprint,
2026-07-02 16:47:37 +08:00
}
if blocked:
summary["blocked"] = True
if extra:
summary.update(extra)
return summary
def _create_run_log(self, eligible, batch_ids):
try:
return db.create_run_log(
"apply",
dry_run=self.dry_run,
total=len(eligible),
options={
"batch_ids": batch_ids,
"dry_run": self.dry_run,
2026-07-10 10:12:15 +08:00
"account_parallel": self.max_parallel_accounts > 1,
2026-07-02 16:47:37 +08:00
"max_parallel_accounts": self.max_parallel_accounts,
"batch_size": self._current_batch_size,
"batch_count": self._batch_count,
2026-07-09 17:44:03 +08:00
"update_mode": self.update_mode,
"product_status_counts": dict(self.product_status_counts),
"status_scope_excluded": self.status_scope_excluded,
"content_scope_excluded": self.content_scope_excluded,
"apply_plan_fingerprint": self.apply_plan_fingerprint,
2026-07-02 16:47:37 +08:00
},
path=self.db_path,
)
except Exception:
return None
def _finish_run_log(self, status, summary):
if self._run_id is None:
return
try:
db.finish_run_log(
self._run_id,
status=status,
done=summary.get("done", 0),
success_count=summary.get("applied", 0),
skipped_count=summary.get("skipped", 0),
failed_count=summary.get("failed", 0),
summary_json=summary,
path=self.db_path,
)
except Exception:
return
def _log_run_event(self, message, task=None, level="info"):
safe_message = diagnostics.redact_log_text(message)
self.log.emit(str(safe_message))
if self._run_id is None:
return
try:
db.add_run_log_event(
self._run_id,
safe_message,
task_id=getattr(task, "id", None),
alias=getattr(task, "alias", None),
item_id=getattr(task, "item_id", None),
level=level,
path=self.db_path,
)
except Exception:
return
def _log_preflight_blocked(self, blocked):
if blocked.get("no_accounts"):
self._log_run_event(
"step=preflight result=blocked detail=当前没有配置账号",
level="warning",
)
for item in blocked.get("duplicate_ports") or []:
self._log_run_event(
"step=preflight result=blocked detail=调试端口重复 debug_port={port} aliases={aliases}".format(
port=item.get("debug_port") or "",
aliases=",".join(item.get("aliases") or []),
),
level="warning",
)
for item in blocked.get("not_running") or []:
self._log_run_event(
"step=check_chrome result=blocked detail=账号 {alias} Chrome 未启动或调试端口不可访问: {reason}".format(
alias=item.get("alias") or "",
reason=item.get("reason") or "",
),
level="warning",
)
for item in blocked.get("logged_out") or []:
self._log_run_event(
2026-07-07 14:58:54 +08:00
"step=login_check result=blocked detail=账号 {alias} 未登录蝦皮: {reason}".format(
2026-07-02 16:47:37 +08:00
alias=item.get("alias") or "",
reason=item.get("reason") or "",
),
level="warning",
)
def _failed_apply_step(self, result, fallback):
if not isinstance(result, dict):
return fallback or "apply_task"
title = result.get("title")
if isinstance(title, dict) and not title.get("ok", True):
return "change_title"
cover = result.get("cover")
if isinstance(cover, dict) and not cover.get("ok", True):
return "replace_cover"
update = result.get("update")
if isinstance(update, dict):
return "click_update"
return fallback or "apply_task"
def _write_diagnostic_log(
self,
message,
level="INFO",
step=None,
task=None,
elapsed_ms=None,
payload=None,
exc=None,
):
_safe_write_diagnostic_log(
message,
level=level,
step=step,
task=task,
elapsed_ms=elapsed_ms,
payload=payload,
exc=exc,
log_dir=self.diagnostic_log_dir,
)
def _elapsed_ms(self, started):
return _elapsed_ms(started)
class CollectWorker(BaseWorker):
"""Collect old title and cover for imported tasks."""
if Signal is not None:
activity = Signal(dict)
LOGIN_CHECK_ATTEMPTS = 3
LOGIN_CHECK_RETRY_DELAY_SECONDS = 2.0
2026-07-02 16:47:37 +08:00
def __init__(
self,
tasks,
db_path=None,
config=None,
preflight=True,
diagnostic_log_dir=None,
2026-07-18 16:46:59 +08:00
collect_scope="all",
2026-07-02 16:47:37 +08:00
):
super().__init__()
self.tasks = list(tasks)
self.db_path = db_path
self.config = config
self.preflight = preflight
self.diagnostic_log_dir = diagnostic_log_dir
2026-07-18 16:46:59 +08:00
self.collect_scope = product_status.normalize_collect_scope(collect_scope)
2026-07-02 16:47:37 +08:00
self._run_id = None
def execute(self):
account_rows = accounts.list_accounts(path=self.db_path, config=self.config)
account_by_alias = {
str(account.alias).strip(): account
for account in account_rows
if str(account.alias).strip()
}
eligible = [
task for task in self.tasks
if getattr(task, "stage", None) == "imported"
]
batch_ids = self._batch_ids(eligible)
total = len(eligible)
collected = 0
skipped = 0
failed = 0
done = 0
2026-07-08 17:25:17 +08:00
login_skip_reasons = {}
login_required_accounts = {}
preflight_info = {}
skip_reason_counts = empty_skip_reason_counts()
2026-07-18 16:46:59 +08:00
product_status_counts = {
status: 0 for status in product_status.VALID_PRODUCT_STATUSES
}
status_scope_skipped = 0
2026-07-02 16:47:37 +08:00
self._run_id = self._create_run_log(eligible, batch_ids)
self._emit_activity(
"preflight_started",
total=total,
step="preflight",
)
2026-07-02 16:47:37 +08:00
self._log_run_event(
f"step=preflight result=start detail=采集运行开始 total={total}"
)
if self.preflight:
2026-07-08 17:25:17 +08:00
blocked, preflight_info = self._preflight_prepare(eligible, account_rows, account_by_alias)
2026-07-02 16:47:37 +08:00
if blocked:
self._log_preflight_blocked(blocked)
summary = self._summary(
ok=False,
total=total,
done=done,
collected=collected,
skipped=skipped,
failed=failed,
batch_ids=batch_ids,
blocked=True,
extra={
**blocked,
"skip_reason_counts": dict(skip_reason_counts),
2026-07-18 16:46:59 +08:00
"collect_scope": self.collect_scope,
"product_status_counts": dict(product_status_counts),
"status_scope_skipped": status_scope_skipped,
},
2026-07-02 16:47:37 +08:00
)
self._finish_run_log("blocked", summary)
return summary
2026-07-08 17:25:17 +08:00
for item in preflight_info.get("logged_out") or []:
alias = str(item.get("alias") or "").strip()
reason = item.get("reason") or "账号未登录"
if alias:
login_skip_reasons[alias] = reason
login_required_accounts[alias] = item
self._log_run_event("step=preflight result=success detail=账号就绪检查完成")
2026-07-02 16:47:37 +08:00
else:
self._log_run_event(
"step=preflight result=skipped detail=测试模式跳过采集前检查",
level="warning",
)
for index, task in enumerate(eligible, start=1):
2026-07-02 16:47:37 +08:00
if self.should_cancel():
break
self._emit_activity(
"task_started",
task=task,
index=index,
total=total,
step="match_account",
)
2026-07-02 16:47:37 +08:00
account = account_by_alias.get(str(task.alias).strip())
if account is None:
skipped += 1
skip_reason_counts[ALIAS_UNMATCHED] += 1
2026-07-02 16:47:37 +08:00
done += 1
reason = "别名未匹配账号"
db.mark_skipped(task.id, reason, path=self.db_path)
self.row_updated.emit(task.id, {"status": "skipped", "last_error": reason})
self._log_run_event(
"step=preflight result=skipped detail=任务 {task_id} 商品 {item_id} {reason}".format(
task_id=task.id,
item_id=task.item_id,
reason=reason,
),
task=task,
level="warning",
)
self._emit_activity(
"task_finished",
task=task,
index=index,
total=total,
step="match_account",
result="skipped",
)
2026-07-02 16:47:37 +08:00
self._emit_progress(done, total, collected, skipped, failed)
continue
2026-07-08 17:25:17 +08:00
alias = str(task.alias).strip()
if alias in login_skip_reasons:
2026-07-02 16:47:37 +08:00
skipped += 1
skip_reason_counts[LOGIN_REQUIRED] += 1
2026-07-02 16:47:37 +08:00
done += 1
2026-07-08 17:25:17 +08:00
reason = login_skip_reasons[alias]
2026-07-02 16:47:37 +08:00
db.mark_skipped(task.id, reason, path=self.db_path)
self.row_updated.emit(task.id, {"status": "skipped", "last_error": reason})
self._log_run_event(
2026-07-08 17:25:17 +08:00
"step=login_check result=skipped detail=任务 {task_id} 商品 {item_id} {reason}".format(
task_id=task.id,
item_id=task.item_id,
reason=reason,
),
task=task,
level="warning",
)
self._emit_activity(
"task_finished",
task=task,
index=index,
total=total,
step="check_login",
result="skipped",
)
2026-07-08 17:25:17 +08:00
self._emit_progress(done, total, collected, skipped, failed)
continue
self._emit_activity(
"task_step",
task=task,
index=index,
total=total,
step="check_login",
)
status = self._confirmed_login_status(account, context="midrun", task=task)
if self._is_definitive_logged_out(status):
2026-07-08 17:25:17 +08:00
alias = str(task.alias).strip()
skipped += 1
skip_reason_counts[LOGIN_REQUIRED] += 1
2026-07-08 17:25:17 +08:00
done += 1
reason = self._midrun_login_skip_reason(status)
login_skip_reasons[alias] = reason
login_required_accounts[alias] = self._account_payload(account, reason)
db.mark_skipped(task.id, reason, path=self.db_path)
self.row_updated.emit(task.id, {"status": "skipped", "last_error": reason})
self._log_run_event(
"step=login_check result=skipped detail=任务 {task_id} 商品 {item_id} {reason}".format(
2026-07-02 16:47:37 +08:00
task_id=task.id,
item_id=task.item_id,
reason=reason,
),
task=task,
level="warning",
)
self._emit_activity(
"task_finished",
task=task,
index=index,
total=total,
step="check_login",
result="skipped",
)
2026-07-02 16:47:37 +08:00
self._emit_progress(done, total, collected, skipped, failed)
continue
if not status.get("logged_in"):
self._log_run_event(
"step=login_check result=uncertain detail=任务 {task_id} 商品 {item_id} 登录状态检测暂时不稳定,继续尝试采集当前商品: {detail}".format(
task_id=task.id,
item_id=task.item_id,
detail=self._login_status_detail(status),
),
task=task,
level="warning",
)
2026-07-02 16:47:37 +08:00
started = time.monotonic()
current_step = "db_write"
activity_result = "success"
2026-07-02 16:47:37 +08:00
def on_step(step):
nonlocal current_step
current_step = str(step)
self._emit_activity(
"task_step",
task=task,
index=index,
total=total,
step=current_step,
)
2026-07-02 16:47:37 +08:00
self._log_run_event(
"step={step} result=start detail=任务 {task_id} 商品 {item_id}".format(
step=current_step,
task_id=task.id,
item_id=task.item_id,
),
task=task,
)
try:
self._emit_activity(
"task_step",
task=task,
index=index,
total=total,
step="prepare_task",
)
2026-07-02 16:47:37 +08:00
self._log_run_event(
"step=db_write result=start detail=任务 {task_id} 商品 {item_id} 标记采集运行".format(
task_id=task.id,
item_id=task.item_id,
),
task=task,
)
db.mark_running(task.id, "collect", path=self.db_path)
self.row_updated.emit(task.id, {"status": "running"})
result = editor.collect(
account,
{
"item_id": task.item_id,
"old_cover_path": self._old_cover_path(account, task),
2026-07-18 16:46:59 +08:00
"collection_scope": self.collect_scope,
2026-07-02 16:47:37 +08:00
},
on_step=on_step,
)
2026-07-18 16:46:59 +08:00
detected_status = product_status.normalize_status(
result.get("product_status")
)
product_status_counts[detected_status] += 1
if result.get("product_status_error"):
self._write_diagnostic_log(
"商品状态检测失败,已按状态未知保存",
level="WARNING",
step="read_product_status",
task=task,
payload={"error": result.get("product_status_error")},
)
if result.get("close_target_confirmed") is False:
self._log_run_event(
"step=close_product result=uncertain detail=任务 {task_id} 商品 {item_id} 商品页已请求关闭,但未在短时间内确认关闭;采集结果已保留,继续处理后续任务".format(
task_id=task.id,
item_id=task.item_id,
),
task=task,
level="warning",
)
self._write_diagnostic_log(
"采集商品页关闭确认超时",
level="WARNING",
step="close_product",
task=task,
payload={
"alias": getattr(account, "alias", None),
"close_target_confirmed": False,
},
)
2026-07-18 16:46:59 +08:00
if result.get("collection_skipped"):
activity_result = "skipped"
current_step = "read_product_status"
reason = result.get("collection_skip_reason") or product_status.collect_skip_reason(
detected_status
)
db.set_product_status(
task.id,
detected_status,
result.get("product_status_note"),
path=self.db_path,
)
db.mark_skipped(task.id, reason, path=self.db_path)
skipped += 1
status_scope_skipped += 1
self.row_updated.emit(
task.id,
{"status": "skipped", "last_error": reason},
)
self._log_run_event(
"step=read_product_status result=skipped detail=任务 {task_id} 商品 {item_id} {reason}".format(
task_id=task.id,
item_id=task.item_id,
reason=reason,
),
task=task,
level="warning",
)
continue
2026-07-02 16:47:37 +08:00
current_step = "db_write"
self._emit_activity(
"task_step",
task=task,
index=index,
total=total,
step="save_result",
)
2026-07-02 16:47:37 +08:00
self._log_run_event(
"step=db_write result=start detail=任务 {task_id} 商品 {item_id} 保存采集结果".format(
task_id=task.id,
item_id=task.item_id,
),
task=task,
)
db.set_collected(
task.id,
result.get("old_title", ""),
result.get("old_cover_path", ""),
product_status_value=result.get("product_status"),
product_status_note=result.get("product_status_note"),
2026-07-02 16:47:37 +08:00
path=self.db_path,
)
collected += 1
elapsed_ms = self._elapsed_ms(started)
self.row_updated.emit(
task.id,
{
"stage": "collected",
"status": "success",
"old_title": result.get("old_title", ""),
"old_cover_path": result.get("old_cover_path", ""),
},
)
self._log_run_event(
"step=db_write result=success detail=任务 {task_id} 商品 {item_id} 采集成功 elapsed_ms={elapsed_ms}".format(
task_id=task.id,
item_id=task.item_id,
elapsed_ms=elapsed_ms,
),
task=task,
)
except Exception as exc:
activity_result = "failed"
2026-07-02 16:47:37 +08:00
failed += 1
error = str(exc) or exc.__class__.__name__
safe_error = diagnostics.redact_log_text(error)
2026-07-08 11:08:01 +08:00
display_error = db.format_failure_error(safe_error, current_step)
2026-07-02 16:47:37 +08:00
elapsed_ms = self._elapsed_ms(started)
2026-07-08 11:08:01 +08:00
db.mark_failed(task.id, "collect", safe_error, path=self.db_path, step=current_step)
self.failed.emit(task.id, display_error)
self.row_updated.emit(task.id, {"status": "failed", "last_error": display_error})
2026-07-02 16:47:37 +08:00
self._log_run_event(
"step={step} result=failed detail={error} elapsed_ms={elapsed_ms}".format(
step=current_step,
error=safe_error,
elapsed_ms=elapsed_ms,
),
task=task,
level="error",
)
self._write_diagnostic_log(
"采集任务失败",
level="ERROR",
step=current_step,
task=task,
elapsed_ms=elapsed_ms,
payload={"error": safe_error},
exc=exc,
)
finally:
done += 1
self._emit_activity(
"task_finished",
task=task,
index=index,
total=total,
step=current_step,
result=activity_result,
)
2026-07-02 16:47:37 +08:00
self._emit_progress(done, total, collected, skipped, failed)
summary = self._summary(
ok=failed == 0,
total=total,
done=done,
collected=collected,
skipped=skipped,
failed=failed,
batch_ids=batch_ids,
2026-07-08 17:25:17 +08:00
extra={
**preflight_info,
"login_required_accounts": list(login_required_accounts.values()),
"skip_reason_counts": dict(skip_reason_counts),
2026-07-18 16:46:59 +08:00
"collect_scope": self.collect_scope,
"product_status_counts": dict(product_status_counts),
"status_scope_skipped": status_scope_skipped,
2026-07-08 17:25:17 +08:00
},
2026-07-02 16:47:37 +08:00
)
self._finish_run_log("cancelled" if self.should_cancel() else "done", summary)
return summary
2026-07-08 17:25:17 +08:00
def _preflight_prepare(self, eligible, account_rows, account_by_alias):
2026-07-02 16:47:37 +08:00
if not account_rows:
2026-07-08 17:25:17 +08:00
return (
{
"reason": "NO_ACCOUNTS",
"no_accounts": True,
},
{},
)
2026-07-02 16:47:37 +08:00
required_accounts = []
seen_aliases = set()
for task in eligible:
alias = str(task.alias).strip()
account = account_by_alias.get(alias)
if account is not None and alias not in seen_aliases:
required_accounts.append(account)
seen_aliases.add(alias)
2026-07-08 17:25:17 +08:00
launch_failed = []
2026-07-02 16:47:37 +08:00
logged_out = []
2026-07-08 17:25:17 +08:00
launched = []
reused = []
2026-07-02 16:47:37 +08:00
for account in required_accounts:
self._log_run_event(
2026-07-08 17:25:17 +08:00
f"step=ensure_chrome result=start detail=账号 {account.alias} debug_port={account.debug_port}",
2026-07-02 16:47:37 +08:00
level="info",
)
2026-07-08 17:25:17 +08:00
if chrome.is_running(account.debug_port):
2026-07-02 16:47:37 +08:00
self._log_run_event(
2026-07-08 17:25:17 +08:00
f"step=ensure_chrome result=reused detail=账号 {account.alias} Chrome 已打开,复用现有窗口 debug_port={account.debug_port}",
level="info",
2026-07-02 16:47:37 +08:00
)
2026-07-08 17:25:17 +08:00
reused.append(self._account_payload(account, "已复用"))
else:
try:
result = accounts.launch_for_login(account, path=self.db_path, config=self.config)
except Exception as exc:
reason = diagnostics.redact_log_text(str(exc) or exc.__class__.__name__)
self._log_run_event(
f"step=ensure_chrome result=blocked detail=账号 {account.alias} Chrome 启动失败: {reason}",
level="error",
)
launch_failed.append(self._account_payload(account, f"Chrome 启动失败: {reason}"))
continue
action = "launched" if result.get("launched") else "reused"
detail = "已启动" if action == "launched" else "已复用"
self._log_run_event(
f"step=ensure_chrome result={action} detail=账号 {account.alias} {detail} debug_port={account.debug_port}",
level="info",
)
target = launched if result.get("launched") else reused
target.append(self._account_payload(account, detail))
2026-07-02 16:47:37 +08:00
self._log_run_event(
f"step=login_check result=start detail=账号 {account.alias}",
level="info",
)
status = self._confirmed_login_status(account, context="preflight")
if status.get("logged_in"):
self._log_run_event(
f"step=login_check result=success detail=账号 {account.alias}",
level="info",
)
elif self._is_definitive_logged_out(status):
2026-07-02 16:47:37 +08:00
reason = self._login_skip_reason(status)
self._log_run_event(
f"step=login_check result=blocked detail=账号 {account.alias} {reason}",
level="warning",
)
logged_out.append(
self._account_payload(account, reason)
)
else:
self._log_run_event(
"step=login_check result=uncertain detail=账号 {alias} 登录状态检测暂时不稳定,继续进入采集流程: {detail}".format(
alias=account.alias,
detail=self._login_status_detail(status),
),
level="warning",
2026-07-02 16:47:37 +08:00
)
2026-07-08 17:25:17 +08:00
info = {
"launched_accounts": launched,
"reused_accounts": reused,
"logged_out": logged_out,
}
if launch_failed:
return (
{
"reason": "CHROME_LAUNCH_FAILED",
"launch_failed": launch_failed,
},
info,
)
return None, info
2026-07-02 16:47:37 +08:00
def _account_payload(self, account, reason=None):
payload = {
"account_name": account.account_name,
"alias": account.alias,
"debug_port": account.debug_port,
}
if reason:
payload["reason"] = reason
return payload
def _emit_activity(
self,
state,
*,
task=None,
index=0,
total=0,
step=None,
result=None,
):
signal = getattr(self, "activity", None)
if signal is None:
return
payload = {
"state": str(state),
"index": int(index or 0),
"total": int(total or 0),
}
if task is not None:
payload.update(
{
"task_id": getattr(task, "id", None),
"item_id": str(getattr(task, "item_id", "") or ""),
"alias": str(getattr(task, "alias", "") or ""),
}
)
if step:
payload["step"] = str(step)
if result:
payload["result"] = str(result)
signal.emit(payload)
2026-07-02 16:47:37 +08:00
def _emit_progress(self, done, total, collected, skipped, failed):
self.progress.emit(
{
"done": done,
"total": total,
"collected": collected,
"skipped": skipped,
"failed": failed,
}
)
def _login_status(self, account):
try:
return accounts.detect_login(account, path=self.db_path, config=self.config)
except Exception as exc:
return {
"logged_in": False,
"reason": f"LOGIN_CHECK_FAILED: {exc}",
}
def _confirmed_login_status(self, account, context, task=None):
started = time.monotonic()
subject = self._login_check_subject(account, task)
last_status = {}
for attempt in range(1, self.LOGIN_CHECK_ATTEMPTS + 1):
status = dict(self._login_status(account) or {})
status["login_check_attempts"] = attempt
last_status = status
if status.get("logged_in"):
if attempt > 1:
self._log_run_event(
"step=login_check result=recovered detail={subject} 登录检测已恢复,第{attempt}/{total}次确认已登录 elapsed_ms={elapsed_ms}".format(
subject=subject,
attempt=attempt,
total=self.LOGIN_CHECK_ATTEMPTS,
elapsed_ms=self._elapsed_ms(started),
),
task=task,
)
return status
if self._is_definitive_logged_out(status):
return status
if attempt < self.LOGIN_CHECK_ATTEMPTS:
self._log_run_event(
"step=login_check result=retry detail={subject} 登录状态暂时无法读取,第{attempt}/{total}次检测后将在{delay:g}秒后重试:{detail}".format(
subject=subject,
attempt=attempt,
total=self.LOGIN_CHECK_ATTEMPTS,
delay=self.LOGIN_CHECK_RETRY_DELAY_SECONDS,
detail=self._login_status_detail(status),
),
task=task,
level="warning",
)
time.sleep(self.LOGIN_CHECK_RETRY_DELAY_SECONDS)
last_status["login_check_uncertain"] = True
self._write_diagnostic_log(
"采集登录检测暂不确定",
level="WARNING",
step="login_check",
task=task,
payload={
"context": context,
"alias": getattr(account, "alias", None),
"reason": last_status.get("reason"),
"url": last_status.get("url"),
"cookie_names": list(last_status.get("cookie_names") or []),
"cookie_read_succeeded": bool(
last_status.get("cookie_read_succeeded")
),
"probe_error": last_status.get("probe_error"),
"probe_attempts": last_status.get("probe_attempts"),
"attempts": last_status.get("login_check_attempts"),
},
)
return last_status
def _login_check_subject(self, account, task=None):
alias = str(getattr(account, "alias", "") or "未知账号")
if task is None:
return f"账号 {alias}"
return f"任务 {task.id} 商品 {task.item_id} 账号 {alias}"
def _is_definitive_logged_out(self, status):
reason = str((status or {}).get("reason") or "").strip()
url = str((status or {}).get("url") or "").lower()
return reason.startswith("LOGIN_PAGE") or (
"accounts.shopee." in url and "/seller/login" in url
)
def _login_status_detail(self, status):
status = status or {}
raw_reason = str(status.get("reason") or "").strip()
if raw_reason == "LOGIN_CHECK_TARGET_UNAVAILABLE":
reason = "CDP页面暂时不可用"
elif raw_reason.startswith("LOGIN_CHECK_FAILED"):
reason = "登录检测调用失败"
elif raw_reason == "NO_SESSION_COOKIE":
reason = "暂未读取到登录会话"
elif raw_reason == "LOGIN_PAGE":
reason = "检测到登录页面"
else:
reason = raw_reason or "未知原因"
url = status.get("url") or "未知URL"
cookie_names = [str(name) for name in (status.get("cookie_names") or []) if name]
cookie_text = ",".join(sorted(cookie_names)) if cookie_names else "未读到登录Cookie"
return f"原因={reason},URL={url},Cookie名称={cookie_text}"
2026-07-02 16:47:37 +08:00
def _login_skip_reason(self, status):
reason = status.get("reason")
return f"账号未登录: {reason}" if reason else "账号未登录"
2026-07-08 17:25:17 +08:00
def _midrun_login_skip_reason(self, status):
reason = self._login_skip_reason(status)
return f"采集中途掉登录: {reason}"
2026-07-02 16:47:37 +08:00
def _old_cover_path(self, account, task):
image_root = appconfig.image_dir(self.config)
return image_paths.task_image_path(image_root, task, account, "old")
def _batch_ids(self, tasks):
batch_ids = []
for task in tasks:
batch_id = getattr(task, "batch_id", None)
if batch_id and batch_id not in batch_ids:
batch_ids.append(batch_id)
return batch_ids
def _summary(
self,
ok,
total,
done,
collected,
skipped,
failed,
batch_ids,
blocked=False,
extra=None,
):
summary = {
"ok": ok,
"total": total,
"done": done,
"collected": collected,
"skipped": skipped,
"failed": failed,
"batch_ids": batch_ids,
"run_id": self._run_id,
}
if blocked:
summary["blocked"] = True
if extra:
summary.update(extra)
return summary
def _create_run_log(self, eligible, batch_ids):
try:
return db.create_run_log(
"collect",
dry_run=False,
total=len(eligible),
options={
"batch_ids": batch_ids,
"preflight": self.preflight,
2026-07-18 16:46:59 +08:00
"collect_scope": self.collect_scope,
2026-07-02 16:47:37 +08:00
},
path=self.db_path,
)
except Exception:
return None
def _finish_run_log(self, status, summary):
if self._run_id is None:
return
try:
db.finish_run_log(
self._run_id,
status=status,
done=summary.get("done", 0),
success_count=summary.get("collected", 0),
skipped_count=summary.get("skipped", 0),
failed_count=summary.get("failed", 0),
summary_json=summary,
path=self.db_path,
)
except Exception:
return
def _log_run_event(self, message, task=None, level="info"):
safe_message = diagnostics.redact_log_text(message)
self.log.emit(str(safe_message))
if self._run_id is None:
return
try:
db.add_run_log_event(
self._run_id,
safe_message,
task_id=getattr(task, "id", None),
alias=getattr(task, "alias", None),
item_id=getattr(task, "item_id", None),
level=level,
path=self.db_path,
)
except Exception:
return
def _log_preflight_blocked(self, blocked):
if blocked.get("no_accounts"):
self._log_run_event(
"step=preflight result=blocked detail=当前没有配置账号",
level="warning",
)
for item in blocked.get("not_running") or []:
self._log_run_event(
"step=preflight result=blocked detail=账号 {alias} Chrome 未启动或调试端口不可访问: {reason}".format(
alias=item.get("alias") or "",
reason=item.get("reason") or "",
),
level="warning",
)
for item in blocked.get("logged_out") or []:
self._log_run_event(
2026-07-07 14:58:54 +08:00
"step=preflight result=blocked detail=账号 {alias} 未登录蝦皮: {reason}".format(
2026-07-02 16:47:37 +08:00
alias=item.get("alias") or "",
reason=item.get("reason") or "",
),
level="warning",
)
def _write_diagnostic_log(
self,
message,
level="INFO",
step=None,
task=None,
elapsed_ms=None,
payload=None,
exc=None,
):
try:
diagnostics.write_diagnostic_log(
message,
level=level,
step=step,
task_id=getattr(task, "id", None),
alias=getattr(task, "alias", None),
item_id=getattr(task, "item_id", None),
elapsed_ms=elapsed_ms,
payload=payload,
exc=exc,
log_dir=self.diagnostic_log_dir,
)
except Exception:
return
def _elapsed_ms(self, started):
return int((time.monotonic() - started) * 1000)
2026-07-18 18:09:07 +08:00
class StatusRecheckWorker(CollectWorker):
"""Re-read product status snapshots without changing task workflow fields."""
def __init__(
self,
tasks,
db_path=None,
config=None,
preflight=True,
diagnostic_log_dir=None,
):
super().__init__(
tasks,
db_path=db_path,
config=config,
preflight=preflight,
diagnostic_log_dir=diagnostic_log_dir,
collect_scope=product_status.COLLECT_SCOPE_ALL,
)
def execute(self):
account_rows = accounts.list_accounts(path=self.db_path, config=self.config)
account_by_alias = {
str(account.alias).strip(): account
for account in account_rows
if str(account.alias).strip()
}
eligible = list(self.tasks)
batch_ids = self._batch_ids(eligible)
total = len(eligible)
rechecked = 0
skipped = 0
failed = 0
done = 0
login_skip_reasons = {}
login_required_accounts = {}
preflight_info = {}
skip_reason_counts = empty_skip_reason_counts()
product_status_counts = {
status: 0 for status in product_status.VALID_PRODUCT_STATUSES
}
self._run_id = self._create_run_log(eligible, batch_ids)
self._emit_activity("preflight_started", total=total, step="preflight")
self._log_run_event(
f"step=preflight result=start detail=商品状态重检开始 total={total}"
)
if self.preflight:
blocked, preflight_info = self._preflight_prepare(
eligible,
account_rows,
account_by_alias,
)
if blocked:
self._log_preflight_blocked(blocked)
summary = self._summary(
ok=False,
total=total,
done=done,
collected=rechecked,
skipped=skipped,
failed=failed,
batch_ids=batch_ids,
blocked=True,
extra={
**blocked,
"rechecked": rechecked,
"skip_reason_counts": dict(skip_reason_counts),
"product_status_counts": dict(product_status_counts),
},
)
self._finish_run_log("blocked", summary)
return summary
for item in preflight_info.get("logged_out") or []:
alias = str(item.get("alias") or "").strip()
reason = item.get("reason") or "账号未登录"
if alias:
login_skip_reasons[alias] = reason
login_required_accounts[alias] = item
self._log_run_event("step=preflight result=success detail=账号就绪检查完成")
else:
self._log_run_event(
"step=preflight result=skipped detail=测试模式跳过商品状态重检前检查",
level="warning",
)
for index, task in enumerate(eligible, start=1):
if self.should_cancel():
break
self._emit_activity(
"task_started",
task=task,
index=index,
total=total,
step="match_account",
)
account = account_by_alias.get(str(task.alias).strip())
if account is None:
skipped += 1
done += 1
skip_reason_counts[ALIAS_UNMATCHED] += 1
reason = "别名未匹配账号"
self._log_run_event(
"step=match_account result=skipped detail=任务 {task_id} 商品 {item_id} {reason}".format(
task_id=task.id,
item_id=task.item_id,
reason=reason,
),
task=task,
level="warning",
)
self._emit_activity(
"task_finished",
task=task,
index=index,
total=total,
step="match_account",
result="skipped",
)
self._emit_recheck_progress(done, total, rechecked, skipped, failed)
continue
alias = str(task.alias).strip()
if alias in login_skip_reasons:
skipped += 1
done += 1
skip_reason_counts[LOGIN_REQUIRED] += 1
reason = login_skip_reasons[alias]
self._log_run_event(
"step=login_check result=skipped detail=任务 {task_id} 商品 {item_id} {reason}".format(
task_id=task.id,
item_id=task.item_id,
reason=reason,
),
task=task,
level="warning",
)
self._emit_activity(
"task_finished",
task=task,
index=index,
total=total,
step="check_login",
result="skipped",
)
self._emit_recheck_progress(done, total, rechecked, skipped, failed)
continue
self._emit_activity(
"task_step",
task=task,
index=index,
total=total,
step="check_login",
)
status = self._confirmed_login_status(account, context="status_recheck", task=task)
if self._is_definitive_logged_out(status):
skipped += 1
done += 1
skip_reason_counts[LOGIN_REQUIRED] += 1
reason = self._midrun_login_skip_reason(status)
login_skip_reasons[alias] = reason
login_required_accounts[alias] = self._account_payload(account, reason)
self._log_run_event(
"step=login_check result=skipped detail=任务 {task_id} 商品 {item_id} {reason}".format(
task_id=task.id,
item_id=task.item_id,
reason=reason,
),
task=task,
level="warning",
)
self._emit_activity(
"task_finished",
task=task,
index=index,
total=total,
step="check_login",
result="skipped",
)
self._emit_recheck_progress(done, total, rechecked, skipped, failed)
continue
if not status.get("logged_in"):
self._log_run_event(
"step=login_check result=uncertain detail=任务 {task_id} 商品 {item_id} 登录状态检测暂时不稳定,继续尝试读取商品状态: {detail}".format(
task_id=task.id,
item_id=task.item_id,
detail=self._login_status_detail(status),
),
task=task,
level="warning",
)
started = time.monotonic()
current_step = "read_product_status"
activity_result = "success"
def on_step(step):
nonlocal current_step
current_step = str(step)
self._emit_activity(
"task_step",
task=task,
index=index,
total=total,
step=current_step,
)
self._log_run_event(
"step={step} result=start detail=任务 {task_id} 商品 {item_id}".format(
step=current_step,
task_id=task.id,
item_id=task.item_id,
),
task=task,
)
try:
result = editor.recheck_product_status(
account,
{"item_id": task.item_id},
on_step=on_step,
)
detected_status = product_status.normalize_status(
result.get("product_status")
)
product_status_counts[detected_status] += 1
current_step = "db_write"
self._emit_activity(
"task_step",
task=task,
index=index,
total=total,
step="save_result",
)
self._log_run_event(
"step=db_write result=start detail=任务 {task_id} 商品 {item_id} 保存商品状态".format(
task_id=task.id,
item_id=task.item_id,
),
task=task,
)
db.set_product_status(
task.id,
detected_status,
result.get("product_status_note"),
path=self.db_path,
)
rechecked += 1
elapsed_ms = self._elapsed_ms(started)
self.row_updated.emit(
task.id,
{
"product_status": detected_status,
"product_status_note": result.get("product_status_note"),
},
)
self._log_run_event(
"step=db_write result=success detail=任务 {task_id} 商品 {item_id} 商品状态重检成功 elapsed_ms={elapsed_ms}".format(
task_id=task.id,
item_id=task.item_id,
elapsed_ms=elapsed_ms,
),
task=task,
)
if result.get("close_target_confirmed") is False:
self._log_run_event(
"step=close_product result=uncertain detail=任务 {task_id} 商品 {item_id} 商品页已请求关闭,但未在短时间内确认关闭;状态结果已保存,继续处理后续任务".format(
task_id=task.id,
item_id=task.item_id,
),
task=task,
level="warning",
)
except Exception as exc:
activity_result = "failed"
failed += 1
error = str(exc) or exc.__class__.__name__
safe_error = diagnostics.redact_log_text(error)
display_error = db.format_failure_error(safe_error, current_step)
elapsed_ms = self._elapsed_ms(started)
self.failed.emit(task.id, display_error)
self._log_run_event(
"step={step} result=failed detail=商品状态重检失败: {error} elapsed_ms={elapsed_ms}".format(
step=current_step,
error=safe_error,
elapsed_ms=elapsed_ms,
),
task=task,
level="error",
)
self._write_diagnostic_log(
"商品状态重检失败",
level="ERROR",
step=current_step,
task=task,
elapsed_ms=elapsed_ms,
payload={"error": safe_error},
exc=exc,
)
finally:
done += 1
self._emit_activity(
"task_finished",
task=task,
index=index,
total=total,
step=current_step,
result=activity_result,
)
self._emit_recheck_progress(done, total, rechecked, skipped, failed)
summary = self._summary(
ok=failed == 0,
total=total,
done=done,
collected=rechecked,
skipped=skipped,
failed=failed,
batch_ids=batch_ids,
extra={
**preflight_info,
"rechecked": rechecked,
"login_required_accounts": list(login_required_accounts.values()),
"skip_reason_counts": dict(skip_reason_counts),
"product_status_counts": dict(product_status_counts),
},
)
self._finish_run_log("cancelled" if self.should_cancel() else "done", summary)
return summary
def _emit_recheck_progress(self, done, total, rechecked, skipped, failed):
self.progress.emit(
{
"done": done,
"total": total,
"rechecked": rechecked,
"skipped": skipped,
"failed": failed,
}
)
def _create_run_log(self, eligible, batch_ids):
try:
return db.create_run_log(
"status_recheck",
dry_run=False,
total=len(eligible),
options={
"batch_ids": batch_ids,
"preflight": self.preflight,
"scope": "current_filters",
},
path=self.db_path,
)
except Exception:
return None
def _finish_run_log(self, status, summary):
if self._run_id is None:
return
try:
db.finish_run_log(
self._run_id,
status=status,
done=summary.get("done", 0),
success_count=summary.get("rechecked", 0),
skipped_count=summary.get("skipped", 0),
failed_count=summary.get("failed", 0),
summary_json=summary,
path=self.db_path,
)
except Exception:
return
2026-07-02 16:47:37 +08:00
class WriteBackWorker(BaseWorker):
"""Write Excel fields back in a background thread."""
def __init__(self, batch_id, db_path=None, excel_path=None, mode="old", diagnostic_log_dir=None):
super().__init__()
self.batch_id = batch_id
self.db_path = db_path
self.excel_path = excel_path
self.mode = mode
self.diagnostic_log_dir = diagnostic_log_dir
self._run_id = None
def execute(self):
batch_ids = self._batch_ids()
self._run_id = _safe_create_run_log(
"write_back",
db_path=self.db_path,
total=len(batch_ids),
options={
"batch_ids": batch_ids,
"mode": self.mode,
"excel_path": self.excel_path,
},
)
self._log_run_event(
f"step=start result=start detail=Excel 回写开始 mode={self.mode} batch_count={len(batch_ids)}"
)
results = []
try:
for batch_id in batch_ids:
started = time.monotonic()
self._log_run_event(
f"step=write_excel result=start detail=batch_id={batch_id} mode={self.mode}"
)
result = self._write_one(batch_id)
results.append(result)
self._log_run_event(
"step=write_excel result=success detail=batch_id={batch_id} files={files} rows={rows} elapsed_ms={elapsed_ms}".format(
batch_id=batch_id,
files=result.get("files", 0),
rows=result.get("rows", 0),
elapsed_ms=self._elapsed_ms(started),
)
)
except Exception as exc:
error = diagnostics.redact_log_text(str(exc) or exc.__class__.__name__)
self._log_run_event(
f"step=write_excel result=failed detail={error}",
level="error",
)
self._write_diagnostic_log(
"Excel回写失败",
level="ERROR",
step="write_excel",
payload={"batch_ids": batch_ids, "mode": self.mode, "error": error},
exc=exc,
)
_safe_finish_run_log(
self._run_id,
db_path=self.db_path,
status="failed",
done=len(results),
success_count=sum(result.get("rows", 0) for result in results),
failed_count=1,
summary_json={"ok": False, "error": error, "mode": self.mode},
)
raise
result = results[0] if len(results) == 1 else self._combined_result(results)
self.progress.emit(
{
"done": result.get("rows", 0),
"total": result.get("rows", 0),
"files": result.get("files", 0),
}
)
_safe_finish_run_log(
self._run_id,
db_path=self.db_path,
status="done",
done=len(batch_ids),
success_count=result.get("rows", 0),
failed_count=0,
summary_json={"ok": result.get("ok", False), "mode": self.mode, "result": result},
)
return result
def _batch_ids(self):
if isinstance(self.batch_id, (list, tuple, set)):
return list(self.batch_id)
return [self.batch_id]
def _write_one(self, batch_id):
if self.mode == "results":
return excel.write_back_results(
batch_id,
excel_path=self.excel_path,
path=self.db_path,
)
return excel.write_back(
batch_id,
excel_path=self.excel_path,
path=self.db_path,
)
def _combined_result(self, results):
written_files = []
for result in results:
for file_path in result.get("written_files", []):
if file_path not in written_files:
written_files.append(file_path)
return {
"ok": all(result.get("ok", False) for result in results),
"batch_id": [result.get("batch_id") for result in results],
"files": sum(result.get("files", 0) for result in results),
"rows": sum(result.get("rows", 0) for result in results),
"written_files": written_files,
}
def _log_run_event(self, message, level="info"):
safe_message = _safe_add_run_log_event(
self._run_id,
message,
db_path=self.db_path,
level=level,
)
self.log.emit(str(safe_message))
def _write_diagnostic_log(self, message, level="INFO", step=None, payload=None, exc=None):
_safe_write_diagnostic_log(
message,
level=level,
step=step,
payload=payload,
exc=exc,
log_dir=self.diagnostic_log_dir,
)
def _elapsed_ms(self, started):
return _elapsed_ms(started)
class AccountLoginCheckWorker(BaseWorker):
def __init__(self, account, db_path=None, config=None, timeout=8, diagnostic_log_dir=None):
super().__init__()
self.account = account
self.db_path = db_path
self.config = config
self.timeout = timeout
self.diagnostic_log_dir = diagnostic_log_dir
self._run_id = None
def execute(self):
self._run_id = _safe_create_run_log(
"login_check",
db_path=self.db_path,
total=1,
options={
"alias": self.account.alias,
"debug_port": self.account.debug_port,
"timeout": self.timeout,
},
)
started = time.monotonic()
self._log_run_event(
f"step=detect_login result=start detail=账号 {self.account.alias} debug_port={self.account.debug_port}"
)
try:
status = accounts.detect_login(
self.account,
timeout=self.timeout,
path=self.db_path,
config=self.config,
)
except Exception as exc:
error = diagnostics.redact_log_text(str(exc) or exc.__class__.__name__)
elapsed_ms = self._elapsed_ms(started)
self._log_run_event(
f"step=detect_login result=failed detail={error} elapsed_ms={elapsed_ms}",
level="error",
)
self._write_diagnostic_log(
"登录检测失败",
level="ERROR",
step="detect_login",
elapsed_ms=elapsed_ms,
payload={"alias": self.account.alias, "error": error},
exc=exc,
)
_safe_finish_run_log(
self._run_id,
db_path=self.db_path,
status="failed",
done=0,
failed_count=1,
summary_json={"ok": False, "alias": self.account.alias, "error": error},
)
raise
elapsed_ms = self._elapsed_ms(started)
logged_in = bool(status.get("logged_in"))
result_text = "success" if logged_in else "failed"
level = "info" if logged_in else "warning"
self._log_run_event(
"step=detect_login result={result} detail=账号 {alias} logged_in={logged_in} reason={reason} elapsed_ms={elapsed_ms}".format(
result=result_text,
alias=self.account.alias,
logged_in=logged_in,
reason=status.get("reason") or "",
elapsed_ms=elapsed_ms,
),
level=level,
)
_safe_finish_run_log(
self._run_id,
db_path=self.db_path,
status="done",
done=1,
success_count=1 if logged_in else 0,
failed_count=0 if logged_in else 1,
summary_json={"ok": logged_in, "alias": self.account.alias, "status": status},
)
self.row_updated.emit(self.account.id, status)
return {"alias": self.account.alias, "status": status}
def _log_run_event(self, message, level="info"):
safe_message = _safe_add_run_log_event(
self._run_id,
message,
db_path=self.db_path,
account=self.account,
level=level,
)
self.log.emit(str(safe_message))
def _write_diagnostic_log(
self,
message,
level="INFO",
step=None,
elapsed_ms=None,
payload=None,
exc=None,
):
_safe_write_diagnostic_log(
message,
level=level,
step=step,
account=self.account,
elapsed_ms=elapsed_ms,
payload=payload,
exc=exc,
log_dir=self.diagnostic_log_dir,
)
def _elapsed_ms(self, started):
return _elapsed_ms(started)
2026-07-04 16:29:15 +08:00
class CMHubSettingsWorker(BaseWorker):
"""Fetch cmhub aliases and optional balance without blocking the GUI."""
def __init__(
self,
base_url,
api_key,
connect_timeout=10,
include_balance=True,
db_path=None,
diagnostic_log_dir=None,
):
super().__init__()
self.base_url = str(base_url or "").strip()
self.api_key = str(api_key or "")
self.connect_timeout = max(1, int(connect_timeout or 10))
self.include_balance = bool(include_balance)
self.db_path = db_path
self.diagnostic_log_dir = diagnostic_log_dir
self._run_id = None
def execute(self):
self._run_id = self._create_run_log()
started = time.monotonic()
action = "测试连接/查余额" if self.include_balance else "刷新别名"
self._log_run_event(f"step=cmhub_settings result=start detail={action}")
try:
models = ai.fetch_cmhub_models(
self.base_url,
self.api_key,
connect_timeout=self.connect_timeout,
)
cmhub_models.cache_model_catalog(self.base_url, models)
2026-07-04 16:29:15 +08:00
balance = None
if self.include_balance:
balance = ai.fetch_cmhub_balance(
self.base_url,
self.api_key,
connect_timeout=self.connect_timeout,
)
except Exception as exc:
error = self._safe_error(exc)
elapsed_ms = self._elapsed_ms(started)
self._log_run_event(
f"step=cmhub_settings result=failed detail={error} elapsed_ms={elapsed_ms}",
level="error",
)
self._write_diagnostic_log(
"cmhub 设置检测失败",
level="ERROR",
step="cmhub_settings",
elapsed_ms=elapsed_ms,
payload={"base_url": self.base_url, "error": error},
exc=exc,
)
_safe_finish_run_log(
self._run_id,
db_path=self.db_path,
status="failed",
done=0,
failed_count=1,
summary_json={"ok": False, "error": error},
)
raise RuntimeError(error) from exc
elapsed_ms = self._elapsed_ms(started)
payload = {
"ok": True,
"models": appconfig.sanitize_for_log(models),
"balance": appconfig.sanitize_for_log(balance or {}),
"points_balance": (balance or {}).get("points_balance"),
}
title_count = self._priced_count(models, "title")
image_count = self._priced_count(models, "image")
vision_count = self._priced_count(models, "vision")
2026-07-04 16:29:15 +08:00
self._log_run_event(
"step=cmhub_settings result=success detail=title_aliases={title_count} image_aliases={image_count} vision_aliases={vision_count} points_balance={points_balance} elapsed_ms={elapsed_ms}".format(
2026-07-04 16:29:15 +08:00
title_count=title_count,
image_count=image_count,
vision_count=vision_count,
2026-07-04 16:29:15 +08:00
points_balance=payload.get("points_balance") if payload.get("points_balance") is not None else "",
elapsed_ms=elapsed_ms,
)
)
_safe_finish_run_log(
self._run_id,
db_path=self.db_path,
status="done",
done=1,
success_count=1,
summary_json=payload,
)
return payload
def _priced_count(self, models, operation):
return sum(
1
for model in models or []
if str(model.get("operation_type") or "").lower() == operation
and str(model.get("pricing_status") or "").lower() != "unpriced"
and str(model.get("alias") or "").strip()
and (
operation != "vision"
or (
str(model.get("pricing_status") or "").lower() == "priced"
and bool(model.get("requires_image"))
)
)
2026-07-04 16:29:15 +08:00
)
def _create_run_log(self):
if not self.db_path:
return None
return _safe_create_run_log(
"cmhub_settings_test",
db_path=self.db_path,
total=1,
options={"base_url": self.base_url, "include_balance": self.include_balance},
)
def _log_run_event(self, message, level="info"):
safe_message = _safe_add_run_log_event(
self._run_id,
message,
db_path=self.db_path,
level=level,
)
self.log.emit(str(safe_message))
def _write_diagnostic_log(
self,
message,
level="INFO",
step=None,
elapsed_ms=None,
payload=None,
exc=None,
):
_safe_write_diagnostic_log(
message,
level=level,
step=step,
elapsed_ms=elapsed_ms,
payload=payload,
exc=exc,
log_dir=self.diagnostic_log_dir,
)
def _safe_error(self, exc):
raw = str(exc) or exc.__class__.__name__
redacted = appconfig.redact_secrets(raw, [self.api_key])
return diagnostics.redact_log_text(redacted)
def _elapsed_ms(self, started):
return _elapsed_ms(started)
2026-07-02 16:47:37 +08:00
class AIModelTestWorker(BaseWorker):
"""Test one AI model connection without blocking the GUI thread."""
def __init__(self, model_name, ai_models_path=None, db_path=None, diagnostic_log_dir=None):
super().__init__()
self.model_name = model_name
self.ai_models_path = ai_models_path or appconfig.AI_MODELS_PATH
self.db_path = db_path
self.diagnostic_log_dir = diagnostic_log_dir
self._run_id = None
def execute(self):
self._run_id = self._create_run_log()
started = time.monotonic()
self._log_run_event(
f"step=test_connection result=start detail=AI模型 {self.model_name}"
)
try:
result = appconfig.test_ai_model(self.model_name, path=self.ai_models_path)
except Exception as exc:
error = diagnostics.redact_log_text(str(exc) or exc.__class__.__name__)
elapsed_ms = self._elapsed_ms(started)
self._log_run_event(
f"step=test_connection result=failed detail={error} elapsed_ms={elapsed_ms}",
level="error",
)
self._write_diagnostic_log(
"AI模型测试连接异常",
level="ERROR",
step="test_connection",
elapsed_ms=elapsed_ms,
payload={"model_name": self.model_name, "error": error},
exc=exc,
)
_safe_finish_run_log(
self._run_id,
db_path=self.db_path,
status="failed",
done=0,
failed_count=1,
summary_json={"ok": False, "name": self.model_name, "error": error},
)
raise
elapsed_ms = self._elapsed_ms(started)
payload = dict(appconfig.sanitize_for_log(result or {}))
payload["name"] = self.model_name
ok = bool(payload.get("ok"))
self._log_run_event(
"step=test_connection result={result} detail=AI模型 {name} status={status} error={error} elapsed_ms={elapsed_ms}".format(
result="success" if ok else "failed",
name=self.model_name,
status=payload.get("status") or "",
error=payload.get("error") or "",
elapsed_ms=elapsed_ms,
),
level="info" if ok else "warning",
)
_safe_finish_run_log(
self._run_id,
db_path=self.db_path,
status="done",
done=1,
success_count=1 if ok else 0,
failed_count=0 if ok else 1,
summary_json=payload,
)
return payload
def _create_run_log(self):
if not self.db_path:
return None
return _safe_create_run_log(
"ai_model_test",
db_path=self.db_path,
total=1,
options={"model_name": self.model_name},
)
def _log_run_event(self, message, level="info"):
safe_message = _safe_add_run_log_event(
self._run_id,
message,
db_path=self.db_path,
level=level,
)
self.log.emit(str(safe_message))
def _write_diagnostic_log(
self,
message,
level="INFO",
step=None,
elapsed_ms=None,
payload=None,
exc=None,
):
_safe_write_diagnostic_log(
message,
level=level,
step=step,
elapsed_ms=elapsed_ms,
payload=payload,
exc=exc,
log_dir=self.diagnostic_log_dir,
)
def _elapsed_ms(self, started):
return _elapsed_ms(started)