Files
cmshoppe/app/gui/workers.py
T

3103 lines
117 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Concrete PySide6 workers used by GUI tabs."""
from __future__ import annotations
import datetime as _dt
import re
import threading
import time
try:
from PySide6.QtCore import Signal
except ModuleNotFoundError: # pragma: no cover - GUI import guard
Signal = None
from .. import ai, image_studio, image_studio_export, image_studio_generation, image_studio_images
from ..collect_skip import ALIAS_UNMATCHED, LOGIN_REQUIRED, empty_skip_reason_counts
from .widgets import *
_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,,;;))\]]*)"
)
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:
prefix += f",{_image_studio_user_detail(detail)}"
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)
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
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,
):
super().__init__()
self.account_alias = account_alias
self.item_id = item_id
self.pull_run_token = str(pull_run_token or "")
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": [],
}
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 []),
}
project = result.get("project")
assets = result.get("assets") or []
return {
"pull_run_token": self.pull_run_token,
"project": project,
"assets": assets,
"count": len(assets),
"account": result.get("account"),
"cancelled": self.should_cancel(),
}
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),
):
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 ()))
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()
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"),
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 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,
)
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
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,
}
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, "只更新标题")
class GenerateWorker(BaseWorker):
"""Generate titles and covers for eligible collected or failed generation tasks."""
def __init__(
self,
tasks,
prompt_values,
db_path=None,
config=None,
diagnostic_log_dir=None,
):
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._run_id = None
self._account_by_alias = {}
self._task_positions = {}
self._eligible_total = 0
self._last_progress_payload = {}
self._cmhub_points_balance = None
self._billing_error = None
self._billing_stop_requested = False
self._run_started_at_text = ""
self._run_started_monotonic = None
def execute(self):
self._run_started_at_text = self._format_local_time()
self._run_started_monotonic = time.monotonic()
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
ai_cfg = appconfig.ai_config(self.config)
generate_mode = appconfig.ai_generate_mode(self.config)
generate_cover = appconfig.generate_mode_includes_cover(generate_mode)
eligible = [
task for task in self.tasks
if ai.is_generatable_task(task, generate_mode=generate_mode)
]
component_totals = ai.generation_component_totals(
eligible,
generate_mode=generate_mode,
)
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)
mode_text = _generation_mode_label(generate_mode)
if generate_cover:
if appconfig.ai_backend(self.config) == "cmhub":
concurrency = ai.cmhub_image_concurrency_plan(ai_cfg)
start_message = "[开始] 本轮生成 {total} 条:本轮生成内容:{mode_text};标题{title_total},图片{cover_total};标题并发{title_concurrency},图片并发{image_concurrency},cmhub实际生图并发{request_concurrency},下载并发{download_concurrency};开始时间 {started_at}".format(
total=len(eligible),
mode_text=mode_text,
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"],
started_at=self._run_started_at_text,
)
else:
start_message = "[开始] 本轮生成 {total} 条:本轮生成内容:{mode_text};标题{title_total},图片{cover_total};标题并发{title_concurrency},图片并发{image_concurrency};开始时间 {started_at}".format(
total=len(eligible),
mode_text=mode_text,
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),
started_at=self._run_started_at_text,
)
else:
start_message = "[开始] 本轮生成 {total} 条:本轮生成内容:{mode_text};标题{title_total};标题并发{title_concurrency};开始时间 {started_at}".format(
total=len(eligible),
mode_text=mode_text,
title_total=component_totals["title_total"],
title_concurrency=ai_cfg.get("title_concurrency", 1),
started_at=self._run_started_at_text,
)
self._log_run_event(start_message)
try:
summary = ai.generate_batch(
self.tasks,
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,
"generate_mode": generate_mode,
},
on_progress=self._emit_generate_progress,
should_stop=self._should_stop_generation,
)
except Exception as exc:
error = diagnostics.redact_log_text(str(exc) or exc.__class__.__name__)
summary = {
"ok": False,
"error": error,
"total": len(eligible),
"title_total": component_totals["title_total"],
"title_done": 0,
"cover_done": 0,
"cover_total": component_totals["cover_total"] if generate_cover else 0,
"generated_done": 0,
"failed": len(eligible),
"cancelled": self.should_cancel(),
"generate_cover": generate_cover,
"generate_mode": generate_mode,
}
user_error = self._user_log_detail(error) or "未知错误"
self._log_run_event(
f"[失败] AI 生成运行失败:{user_error}",
level="error",
)
self._write_diagnostic_log(
"AI生成运行失败",
level="ERROR",
step="execute",
payload={"error": error},
exc=exc,
)
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["run_id"] = self._run_id
summary["batch_ids"] = batch_ids
status = "failed" if summary.get("billing_error") or summary.get("error") else ("cancelled" if summary.get("cancelled") else "done")
level = "error" if summary.get("billing_error") or summary.get("error") else ("warning" if summary.get("cancelled") else "info")
self._log_run_event(self._format_generate_completion(summary), level=level)
self._finish_run_log(status, summary)
return summary
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
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")
self._remember_cmhub_metadata(payload)
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")),
)
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)
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"
detail = self._user_log_detail(
payload.get("detail"),
phase=phase,
step=step,
code=payload.get("code"),
status=payload.get("status"),
)
if isinstance(payload.get("metadata"), dict):
return self._format_cmhub_billing_event(task, phase, payload.get("metadata"))
if phase == "title":
if result == "start" and step == "title_submit":
return f"[标题] {self._task_progress_label(task)} 开始生成"
if result == "skipped":
if detail and "旧标题作为封面参考" in detail:
return f"[图片] {self._task_progress_label(task)} {detail}"
return f"[标题] {self._task_progress_label(task)} 已有标题,跳过生文"
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}"
if result == "start" and step == "cover_submit":
return f"[图片] {self._task_progress_label(task)} 开始生成"
if result == "success" and step == "cover_request":
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 '图片下载较慢'}"
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 '耗时未知'}"
if result == "success" and step == "db_write":
suffix = ",已保存到本地" 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
return None
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)
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
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 无效,请去⑤设置重填"
if code_text in {"model_not_allowed", "no_pricing_rule"} or "模型别名" in raw or "模型配置不可用" in raw:
return "cmhub 模型别名不可用,请去⑤设置刷新别名并保存"
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,或联系服务方确认网关版本"
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
def _format_generate_completion(self, summary):
progress = self._summary_text(summary)
billing_error = summary.get("billing_error") or {}
finished_at = self._format_local_time()
elapsed = self._format_run_elapsed()
suffix = f";{self._completion_time_label(summary)} {finished_at},总用时 {elapsed}"
if billing_error:
message = self._user_log_detail(billing_error.get("message") or "点数不足,请先充值") or "点数不足,请先充值"
return f"[失败] AI 生成已中止:{message},{progress}{suffix}"
if summary.get("cancelled"):
return f"[停止] AI 生成已停止:{progress}{suffix}"
if summary.get("error"):
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}秒"
def _summary_text(self, summary):
title_total = summary.get("title_total", summary.get("total", 0))
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,
total=title_total,
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"
exception = payload.get("exception")
code = payload.get("code") or getattr(exception, "code", None)
status = payload.get("status") or getattr(exception, "status", None)
error = diagnostics.redact_log_text(payload.get("error") or "未知错误")
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
self._write_diagnostic_log(
"AI生成任务失败",
level="ERROR",
step=step,
task=task,
payload=diagnostic_payload,
exc=exception,
)
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),
"backend": ai_cfg.get("backend", "direct"),
},
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):
safe_message = diagnostics.redact_log_text(message)
self.log.emit(str(safe_message))
if self._run_id is None or not persist:
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,
update_mode=None,
max_parallel_accounts=1,
batch_size=None,
diagnostic_log_dir=None,
):
super().__init__()
self.tasks = list(tasks)
self.db_path = db_path
self.config = config
self.preflight = preflight
self.dry_run = bool(dry_run)
self.update_mode = appconfig.normalize_update_mode(
update_mode,
allow_cover_update=appconfig.shopee_update_config(config).get("allow_cover_update", False),
)
self.max_parallel_accounts = max(
appconfig.SHOPEE_PARALLEL_ACCOUNTS_MIN,
min(appconfig.SHOPEE_PARALLEL_ACCOUNTS_MAX, int(max_parallel_accounts or 1)),
)
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._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}".format(
mode="检查本轮更新" if self.dry_run else "真实更新",
update_mode=_update_mode_label(self.update_mode),
total=total,
batch_size=batch_size,
batch_count=len(batches),
parallel=(
f"多账号并行最多{self.max_parallel_accounts}"
if self.max_parallel_accounts > 1
else "串行"
),
)
)
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)
elif self.max_parallel_accounts > 1:
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 (
(
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))
)
)
)
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 = []
if appconfig.update_mode_includes_title(self.update_mode) and getattr(task, "new_title", None):
action_parts.append("标题")
if appconfig.update_mode_includes_cover(self.update_mode) and getattr(task, "new_cover_path", None):
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})
current_step = "apply_task"
result = editor.apply_task(
account,
task,
on_step=on_step,
bring_to_front=True,
update_mode=self.update_mode,
)
committed = bool(result.get("committed")) and not result.get("error")
error = result.get("error")
failed_step = self._failed_apply_step(result, current_step)
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 "更新未提交")
display_error = db.format_failure_error(error, failed_step)
db.set_applied(task.id, False, error, path=self.db_path, step=failed_step)
elapsed_ms = self._elapsed_ms(started)
self.failed.emit(task.id, str(display_error))
self.row_updated.emit(
task.id,
{"status": "failed", "last_error": str(display_error), "committed": 0},
)
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(
"蝦皮更新任务失败",
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__)
display_error = db.format_failure_error(error, current_step)
db.set_applied(task.id, False, error, path=self.db_path, step=current_step)
elapsed_ms = self._elapsed_ms(started)
self.failed.emit(task.id, display_error)
self.row_updated.emit(
task.id,
{"status": "failed", "last_error": display_error, "committed": 0},
)
self._log_run_event(
f"step={current_step} result=failed detail={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": error},
exc=exc,
)
return "failed"
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,
"update_mode": self.update_mode,
}
)
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,
"account_parallel": self.max_parallel_accounts > 1,
"batch_size": self._current_batch_size,
"batch_count": self._batch_count,
"update_mode": self.update_mode,
"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(
"apply",
dry_run=self.dry_run,
total=len(eligible),
options={
"batch_ids": batch_ids,
"dry_run": self.dry_run,
"account_parallel": self.max_parallel_accounts > 1,
"max_parallel_accounts": self.max_parallel_accounts,
"batch_size": self._current_batch_size,
"batch_count": self._batch_count,
"update_mode": self.update_mode,
},
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(
"step=login_check result=blocked detail=账号 {alias} 未登录蝦皮: {reason}".format(
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
def __init__(
self,
tasks,
db_path=None,
config=None,
preflight=True,
diagnostic_log_dir=None,
):
super().__init__()
self.tasks = list(tasks)
self.db_path = db_path
self.config = config
self.preflight = preflight
self.diagnostic_log_dir = diagnostic_log_dir
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
login_skip_reasons = {}
login_required_accounts = {}
preflight_info = {}
skip_reason_counts = empty_skip_reason_counts()
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=collected,
skipped=skipped,
failed=failed,
batch_ids=batch_ids,
blocked=True,
extra={
**blocked,
"skip_reason_counts": dict(skip_reason_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
skip_reason_counts[ALIAS_UNMATCHED] += 1
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",
)
self._emit_progress(done, total, collected, skipped, failed)
continue
alias = str(task.alias).strip()
if alias in login_skip_reasons:
skipped += 1
skip_reason_counts[LOGIN_REQUIRED] += 1
done += 1
reason = login_skip_reasons[alias]
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(
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_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):
alias = str(task.alias).strip()
skipped += 1
skip_reason_counts[LOGIN_REQUIRED] += 1
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(
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_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",
)
started = time.monotonic()
current_step = "db_write"
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:
self._emit_activity(
"task_step",
task=task,
index=index,
total=total,
step="prepare_task",
)
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),
},
on_step=on_step,
)
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,
},
)
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_collected(
task.id,
result.get("old_title", ""),
result.get("old_cover_path", ""),
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"
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)
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})
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_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,
extra={
**preflight_info,
"login_required_accounts": list(login_required_accounts.values()),
"skip_reason_counts": dict(skip_reason_counts),
},
)
self._finish_run_log("cancelled" if self.should_cancel() else "done", summary)
return summary
def _preflight_prepare(self, eligible, account_rows, account_by_alias):
if not account_rows:
return (
{
"reason": "NO_ACCOUNTS",
"no_accounts": True,
},
{},
)
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)
launch_failed = []
logged_out = []
launched = []
reused = []
for account in required_accounts:
self._log_run_event(
f"step=ensure_chrome result=start detail=账号 {account.alias} debug_port={account.debug_port}",
level="info",
)
if chrome.is_running(account.debug_port):
self._log_run_event(
f"step=ensure_chrome result=reused detail=账号 {account.alias} Chrome 已打开,复用现有窗口 debug_port={account.debug_port}",
level="info",
)
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))
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):
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",
)
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
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)
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}"
def _login_skip_reason(self, status):
reason = status.get("reason")
return f"账号未登录: {reason}" if reason else "账号未登录"
def _midrun_login_skip_reason(self, status):
reason = self._login_skip_reason(status)
return f"采集中途掉登录: {reason}"
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,
},
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(
"step=preflight result=blocked detail=账号 {alias} 未登录蝦皮: {reason}".format(
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)
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)
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,
)
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")
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(
title_count=title_count,
image_count=image_count,
vision_count=vision_count,
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"))
)
)
)
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)
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)