"""SQLite persistence for accounts, import batches, and tasks.""" from __future__ import annotations import json import os import sqlite3 import uuid from contextlib import contextmanager from dataclasses import dataclass from datetime import datetime from typing import Iterable, Optional from . import appconfig from .config import make_slug DEFAULT_BUSY_TIMEOUT_MS = 5000 VALID_BATCH_FIELDS = { "source_files_json", "status", "note", "deleted_at", "deleted_reason", } FAILURE_STEP_LABELS = { "preflight": "预检", "check_chrome": "检查Chrome", "login_check": "检测登录", "open_product": "打开商品页", "wait_ready": "页面就绪", "read_title": "读标题", "read_cover": "读封面", "download_cover": "下载封面", "db_write": "写库", "excel_write_back": "回写Excel", "write_excel": "回写Excel", "load_text_model": "加载文本模型", "title_submit": "提交标题任务", "title_build_request": "构建标题请求", "title_request": "请求生成标题", "title_parse_response": "解析标题响应", "load_image_model": "加载图片模型", "cover_validate_input": "校验旧封面", "cover_prompt_render": "渲染封面提示词", "cover_submit": "提交封面任务", "cover_build_request": "构建封面请求", "cover_request": "请求生成封面", "cover_poll": "等待封面结果", "cover_download": "下载新封面", "cover_save": "保存新封面", "cover_parse_response": "解析封面响应", "apply_task": "更新商品", "change_title": "修改标题", "replace_cover": "更新封面", "click_update": "点击更新", } VALID_ACCOUNT_FIELDS = { "account_name", "alias", "region_host", "slug", "user_data_dir", "debug_port", "password", "note", "last_login_at", } VALID_RUN_LOG_FIELDS = { "status", "done", "success_count", "skipped_count", "failed_count", "finished_at", "summary_json", } PHASE_ATTEMPT_FIELDS = { "collect": "collect_attempts", "collected": "collect_attempts", "generate": "generate_attempts", "generated": "generate_attempts", "apply": "apply_attempts", "applied": "apply_attempts", "update": "apply_attempts", "updated": "apply_attempts", } class DbError(RuntimeError): """Raised when SQLite persistence cannot complete an operation.""" @dataclass(frozen=True) class Batch: id: str source_files_json: str status: str note: Optional[str] created_at: str updated_at: str deleted_at: Optional[str] deleted_reason: Optional[str] @property def source_files(self) -> list[str]: return json.loads(self.source_files_json) @dataclass(frozen=True) class Account: id: int account_name: str alias: str region_host: str slug: str user_data_dir: str debug_port: int password: Optional[str] note: Optional[str] created_at: str updated_at: str last_login_at: Optional[str] @dataclass(frozen=True) class Task: id: int batch_id: str source_file: str source_file_abs: str source_sheet: str source_row: int row_key: str account_name: Optional[str] alias: str item_id: str old_title: Optional[str] old_cover_path: Optional[str] new_title: Optional[str] new_cover_path: Optional[str] image_task_id: Optional[str] image_task_key: Optional[str] cover_reset_count: int cover_reset_at: Optional[str] committed: int stage: str status: str last_error: Optional[str] collect_attempts: int generate_attempts: int apply_attempts: int imported_at: str collected_at: Optional[str] generated_at: Optional[str] applied_at: Optional[str] updated_at: str @dataclass(frozen=True) class RunLog: id: int run_type: str dry_run: int status: str total: int done: int success_count: int skipped_count: int failed_count: int options_json: Optional[str] summary_json: Optional[str] started_at: str finished_at: Optional[str] @property def options(self) -> dict: return json.loads(self.options_json or "{}") @property def summary(self) -> dict: return json.loads(self.summary_json or "{}") @dataclass(frozen=True) class RunLogEvent: id: int run_id: int task_id: Optional[int] alias: Optional[str] item_id: Optional[str] level: str message: str created_at: str SCHEMA_SQL = """ CREATE TABLE IF NOT EXISTS batches ( id TEXT PRIMARY KEY, source_files_json TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'active', note TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, deleted_at TEXT, deleted_reason TEXT ); CREATE TABLE IF NOT EXISTS accounts ( id INTEGER PRIMARY KEY, account_name TEXT NOT NULL, alias TEXT UNIQUE NOT NULL, region_host TEXT NOT NULL, slug TEXT UNIQUE NOT NULL, user_data_dir TEXT NOT NULL, debug_port INTEGER NOT NULL, password TEXT, note TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, last_login_at TEXT ); CREATE TABLE IF NOT EXISTS tasks ( id INTEGER PRIMARY KEY, batch_id TEXT NOT NULL REFERENCES batches(id), source_file TEXT NOT NULL, source_file_abs TEXT NOT NULL, source_sheet TEXT NOT NULL, source_row INTEGER NOT NULL, row_key TEXT NOT NULL UNIQUE, account_name TEXT, alias TEXT NOT NULL, item_id TEXT NOT NULL, old_title TEXT, old_cover_path TEXT, new_title TEXT, new_cover_path TEXT, image_task_id TEXT, image_task_key TEXT, cover_reset_count INTEGER NOT NULL DEFAULT 0, cover_reset_at TEXT, committed INTEGER NOT NULL DEFAULT 0, stage TEXT NOT NULL DEFAULT 'imported', status TEXT NOT NULL DEFAULT 'pending', last_error TEXT, collect_attempts INTEGER NOT NULL DEFAULT 0, generate_attempts INTEGER NOT NULL DEFAULT 0, apply_attempts INTEGER NOT NULL DEFAULT 0, imported_at TEXT NOT NULL, collected_at TEXT, generated_at TEXT, applied_at TEXT, updated_at TEXT NOT NULL, UNIQUE(batch_id, source_file_abs, source_sheet, source_row) ); CREATE INDEX IF NOT EXISTS idx_tasks_batch_stage_status ON tasks(batch_id, stage, status); CREATE INDEX IF NOT EXISTS idx_tasks_alias ON tasks(alias); CREATE INDEX IF NOT EXISTS idx_tasks_item ON tasks(item_id); CREATE TABLE IF NOT EXISTS run_logs ( id INTEGER PRIMARY KEY, run_type TEXT NOT NULL, dry_run INTEGER NOT NULL DEFAULT 0, status TEXT NOT NULL DEFAULT 'running', total INTEGER NOT NULL DEFAULT 0, done INTEGER NOT NULL DEFAULT 0, success_count INTEGER NOT NULL DEFAULT 0, skipped_count INTEGER NOT NULL DEFAULT 0, failed_count INTEGER NOT NULL DEFAULT 0, options_json TEXT, summary_json TEXT, started_at TEXT NOT NULL, finished_at TEXT ); CREATE TABLE IF NOT EXISTS run_log_events ( id INTEGER PRIMARY KEY, run_id INTEGER NOT NULL REFERENCES run_logs(id) ON DELETE CASCADE, task_id INTEGER, alias TEXT, item_id TEXT, level TEXT NOT NULL DEFAULT 'info', message TEXT NOT NULL, created_at TEXT NOT NULL ); CREATE INDEX IF NOT EXISTS idx_run_logs_started ON run_logs(started_at DESC, id DESC); CREATE INDEX IF NOT EXISTS idx_run_log_events_run ON run_log_events(run_id, id); CREATE TABLE IF NOT EXISTS image_studio_projects ( id INTEGER PRIMARY KEY, account_alias TEXT NOT NULL, account_slug TEXT NOT NULL, account_name TEXT, item_id TEXT NOT NULL, target_main_count INTEGER NOT NULL DEFAULT 9, target_detail_count INTEGER NOT NULL DEFAULT 12, draft_prompt TEXT, status TEXT NOT NULL DEFAULT 'active', created_at TEXT NOT NULL, updated_at TEXT NOT NULL, deleted_at TEXT, deleted_reason TEXT, UNIQUE(account_alias, item_id) ); CREATE INDEX IF NOT EXISTS idx_image_studio_projects_status ON image_studio_projects(status, updated_at DESC); CREATE TABLE IF NOT EXISTS image_studio_assets ( id INTEGER PRIMARY KEY, project_id INTEGER NOT NULL REFERENCES image_studio_projects(id) ON DELETE CASCADE, kind TEXT NOT NULL, remote_url TEXT, local_path TEXT, aspect_ratio TEXT, parent_asset_id INTEGER REFERENCES image_studio_assets(id) ON DELETE SET NULL, prompt TEXT, status TEXT NOT NULL DEFAULT 'available', source_order INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); CREATE INDEX IF NOT EXISTS idx_image_studio_assets_project_kind ON image_studio_assets(project_id, kind, source_order, id); CREATE INDEX IF NOT EXISTS idx_image_studio_assets_parent ON image_studio_assets(parent_asset_id); CREATE TABLE IF NOT EXISTS image_studio_jobs ( id INTEGER PRIMARY KEY, project_id INTEGER NOT NULL REFERENCES image_studio_projects(id) ON DELETE CASCADE, source_asset_id INTEGER REFERENCES image_studio_assets(id) ON DELETE SET NULL, output_asset_id INTEGER REFERENCES image_studio_assets(id) ON DELETE SET NULL, generation_source TEXT NOT NULL DEFAULT 'cmhub', provider TEXT NOT NULL DEFAULT 'cmhub', job_type TEXT NOT NULL, task_key TEXT NOT NULL UNIQUE, task_id TEXT, status TEXT NOT NULL DEFAULT 'pending', prompt TEXT, error TEXT, recovery_action TEXT NOT NULL DEFAULT 'regenerate', attempts INTEGER NOT NULL DEFAULT 0, points_cost INTEGER, points_balance INTEGER, call_id TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, submitted_at TEXT, finished_at TEXT ); CREATE INDEX IF NOT EXISTS idx_image_studio_jobs_project_status ON image_studio_jobs(project_id, status, id); CREATE INDEX IF NOT EXISTS idx_image_studio_jobs_task_id ON image_studio_jobs(task_id); CREATE TABLE IF NOT EXISTS image_studio_selections ( id INTEGER PRIMARY KEY, project_id INTEGER NOT NULL REFERENCES image_studio_projects(id) ON DELETE CASCADE, selection_type TEXT NOT NULL, position INTEGER NOT NULL, asset_id INTEGER NOT NULL REFERENCES image_studio_assets(id) ON DELETE CASCADE, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, UNIQUE(project_id, selection_type, position), UNIQUE(project_id, selection_type, asset_id) ); CREATE INDEX IF NOT EXISTS idx_image_studio_selections_project ON image_studio_selections(project_id, selection_type, position); """ def _now() -> str: return datetime.now().isoformat(timespec="seconds") def _cover_archive_timestamp() -> str: return datetime.now().strftime("%Y%m%d%H%M%S") def _archive_generated_cover_file(file_path) -> Optional[str]: path_text = str(file_path or "").strip() if not path_text or not os.path.isfile(path_text): return None directory = os.path.dirname(path_text) filename = os.path.basename(path_text) stem, ext = os.path.splitext(filename) timestamp = _cover_archive_timestamp() suffix = 1 while True: archive_name = f"{stem}_{timestamp}{ext}" if suffix == 1 else f"{stem}_{timestamp}_{suffix}{ext}" archive_path = os.path.join(directory, archive_name) if not os.path.exists(archive_path): break suffix += 1 try: os.rename(path_text, archive_path) except PermissionError as exc: raise DbError("请先关闭正在查看的封面图片再重置") from exc except OSError as exc: raise DbError(f"归档当前封面图片失败: {exc}") from exc return archive_path def _db_path(path=None) -> str: return path or appconfig.db_path() def connect(path=None) -> sqlite3.Connection: """Open a SQLite connection with the project concurrency pragmas.""" db_path = _db_path(path) if db_path != ":memory:": directory = os.path.dirname(os.path.abspath(db_path)) if directory: os.makedirs(directory, exist_ok=True) conn = sqlite3.connect(db_path, timeout=DEFAULT_BUSY_TIMEOUT_MS / 1000) conn.row_factory = sqlite3.Row conn.execute("PRAGMA foreign_keys=ON") conn.execute("PRAGMA journal_mode=WAL") conn.execute(f"PRAGMA busy_timeout={DEFAULT_BUSY_TIMEOUT_MS}") conn.execute("PRAGMA synchronous=NORMAL") return conn @contextmanager def _connection(conn=None, path=None): owned = conn is None database = connect(path) if owned else conn try: yield database finally: if owned: database.close() def _as_dataclass(row, cls): return None if row is None else cls(**dict(row)) def _fetch_one(conn, sql, params, cls): return _as_dataclass(conn.execute(sql, params).fetchone(), cls) def _fetch_all(conn, sql, params, cls): return [_as_dataclass(row, cls) for row in conn.execute(sql, params).fetchall()] def _validate_fields(fields, allowed): unknown = sorted(set(fields) - allowed) if unknown: raise DbError(f"不支持更新字段: {', '.join(unknown)}") def _row_key(batch_id, source_file_abs, source_sheet, source_row) -> str: return f"{batch_id}:{source_file_abs}:{source_sheet}:{source_row}" def _attempt_field(phase: str) -> str: key = str(phase).lower() if key not in PHASE_ATTEMPT_FIELDS: raise DbError(f"未知阶段: {phase}") return PHASE_ATTEMPT_FIELDS[key] def init_db(path=None, conn=None) -> None: """Create tables and indexes if they do not already exist.""" with _connection(conn, path) as database: with database: database.executescript(SCHEMA_SQL) _ensure_batch_delete_columns(database) _ensure_task_image_task_columns(database) _ensure_task_cover_reset_columns(database) _ensure_image_studio_job_recovery_columns(database) def _ensure_batch_delete_columns(database): columns = {row["name"] for row in database.execute("PRAGMA table_info(batches)").fetchall()} if "deleted_at" not in columns: database.execute("ALTER TABLE batches ADD COLUMN deleted_at TEXT") if "deleted_reason" not in columns: database.execute("ALTER TABLE batches ADD COLUMN deleted_reason TEXT") def _ensure_task_image_task_columns(database): columns = {row["name"] for row in database.execute("PRAGMA table_info(tasks)").fetchall()} if "image_task_id" not in columns: database.execute("ALTER TABLE tasks ADD COLUMN image_task_id TEXT") if "image_task_key" not in columns: database.execute("ALTER TABLE tasks ADD COLUMN image_task_key TEXT") def _ensure_task_cover_reset_columns(database): columns = {row["name"] for row in database.execute("PRAGMA table_info(tasks)").fetchall()} if "cover_reset_count" not in columns: database.execute("ALTER TABLE tasks ADD COLUMN cover_reset_count INTEGER NOT NULL DEFAULT 0") if "cover_reset_at" not in columns: database.execute("ALTER TABLE tasks ADD COLUMN cover_reset_at TEXT") def _ensure_image_studio_job_recovery_columns(database): columns = { row["name"] for row in database.execute("PRAGMA table_info(image_studio_jobs)").fetchall() } if "recovery_action" not in columns: database.execute( "ALTER TABLE image_studio_jobs ADD COLUMN recovery_action TEXT NOT NULL DEFAULT 'regenerate'" ) database.execute( """ UPDATE image_studio_jobs SET recovery_action = CASE WHEN status = 'succeeded' THEN 'none' WHEN task_id IS NOT NULL AND status IN ('submitted', 'running') THEN 'resume' ELSE 'regenerate' END WHERE recovery_action IS NULL OR recovery_action NOT IN ('none', 'resume', 'regenerate') OR ( recovery_action = 'regenerate' AND task_id IS NOT NULL AND status IN ('submitted', 'running') ) OR ( recovery_action = 'regenerate' AND status = 'succeeded' ) """ ) def create_batch(file_paths: Iterable[str], note=None, path=None, conn=None) -> str: batch_id = datetime.now().strftime("%Y%m%d_%H%M%S_") + uuid.uuid4().hex[:8] files = [os.path.abspath(file_path) for file_path in file_paths] now = _now() with _connection(conn, path) as database: with database: database.execute( """ INSERT INTO batches (id, source_files_json, status, note, created_at, updated_at) VALUES (?, ?, 'active', ?, ?, ?) """, (batch_id, json.dumps(files, ensure_ascii=False), note, now, now), ) return batch_id def get_batch(batch_id, path=None, conn=None, include_deleted=False): sql = "SELECT * FROM batches WHERE id = ?" params = [batch_id] if not include_deleted: sql += " AND deleted_at IS NULL" with _connection(conn, path) as database: return _fetch_one(database, sql, params, Batch) def list_batches(status=None, path=None, conn=None, include_deleted=False): clauses = [] params = [] if status is not None: clauses.append("status = ?") params.append(status) if not include_deleted: clauses.append("deleted_at IS NULL") sql = "SELECT * FROM batches" if clauses: sql += " WHERE " + " AND ".join(clauses) sql += " ORDER BY created_at DESC, id DESC" with _connection(conn, path) as database: return _fetch_all(database, sql, params, Batch) def update_batch(batch_id, path=None, conn=None, **fields) -> None: _validate_fields(fields, VALID_BATCH_FIELDS) if not fields: return fields["updated_at"] = _now() assignments = ", ".join(f"{field} = ?" for field in fields) params = list(fields.values()) + [batch_id] with _connection(conn, path) as database: with database: database.execute( f"UPDATE batches SET {assignments} WHERE id = ?", params, ) def list_accounts(path=None, conn=None): with _connection(conn, path) as database: return _fetch_all( database, "SELECT * FROM accounts ORDER BY alias", (), Account, ) def get_account_by_alias(alias, path=None, conn=None): with _connection(conn, path) as database: return _fetch_one( database, "SELECT * FROM accounts WHERE alias = ?", (alias,), Account, ) def add_account( account_name, alias, region_host, debug_port, password=None, note=None, slug=None, user_data_dir=None, path=None, conn=None, ): slug = slug or make_slug(alias) user_data_dir = user_data_dir or os.path.join("chrome_user_data_dir", slug) now = _now() with _connection(conn, path) as database: try: with database: database.execute( """ INSERT INTO accounts (account_name, alias, region_host, slug, user_data_dir, debug_port, password, note, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( account_name, alias, region_host, slug, user_data_dir, int(debug_port), password, note, now, now, ), ) except sqlite3.IntegrityError as exc: raise DbError(f"账号别名或 slug 已存在: {alias}") from exc return get_account_by_alias(alias, conn=database) def update_account(account_alias, path=None, conn=None, **fields) -> None: _validate_fields(fields, VALID_ACCOUNT_FIELDS) if not fields: return fields["updated_at"] = _now() assignments = ", ".join(f"{field} = ?" for field in fields) params = list(fields.values()) + [account_alias] with _connection(conn, path) as database: try: with database: database.execute( f"UPDATE accounts SET {assignments} WHERE alias = ?", params, ) except sqlite3.IntegrityError as exc: raise DbError( f"账号别名或 slug 已存在: {fields.get('alias', account_alias)}" ) from exc def delete_account(alias, path=None, conn=None) -> None: with _connection(conn, path) as database: with database: database.execute("DELETE FROM accounts WHERE alias = ?", (alias,)) def insert_tasks(batch_id, rows, path=None, conn=None) -> int: now = _now() values = [] for row in rows: source_file_abs = os.path.abspath(row["source_file_abs"]) source_sheet = row["source_sheet"] source_row = int(row["source_row"]) values.append( ( batch_id, row.get("source_file") or source_file_abs, source_file_abs, source_sheet, source_row, row.get("row_key") or _row_key(batch_id, source_file_abs, source_sheet, source_row), row.get("account_name"), row["alias"], str(row["item_id"]), now, now, ) ) with _connection(conn, path) as database: try: with database: database.executemany( """ INSERT INTO tasks (batch_id, source_file, source_file_abs, source_sheet, source_row, row_key, account_name, alias, item_id, imported_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, values, ) except sqlite3.IntegrityError as exc: raise DbError("任务行重复或批次不存在") from exc return len(values) def list_tasks( batch_id=None, stage=None, status=None, alias=None, path=None, conn=None, include_deleted=False, ): clauses = [] params = [] filters = { "t.batch_id": batch_id, "t.stage": stage, "t.status": status, "t.alias": alias, } for field, value in filters.items(): if value is not None: clauses.append(f"{field} = ?") params.append(value) if not include_deleted: clauses.append("b.deleted_at IS NULL") sql = "SELECT t.* FROM tasks t JOIN batches b ON b.id = t.batch_id" if clauses: sql += " WHERE " + " AND ".join(clauses) sql += " ORDER BY t.id" with _connection(conn, path) as database: return _fetch_all(database, sql, params, Task) def delete_batch(batch_id, reason=None, path=None, conn=None) -> dict: """Soft delete a batch so it disappears from normal UI and workflows.""" with _connection(conn, path) as database: batch = get_batch(batch_id, conn=database) if batch is None: raise DbError(f"批次不存在或已删除: {batch_id}") tasks = list_tasks(batch_id=batch_id, conn=database) image_paths = [] for task in tasks: for image_path in (task.old_cover_path, task.new_cover_path): if image_path and image_path not in image_paths: image_paths.append(image_path) committed_count = sum(1 for task in tasks if int(task.committed or 0) == 1) now = _now() with database: database.execute( """ UPDATE batches SET deleted_at = ?, deleted_reason = ?, updated_at = ? WHERE id = ? AND deleted_at IS NULL """, (now, str(reason or ""), now, batch_id), ) return { "batch_id": batch_id, "deleted_at": now, "task_count": len(tasks), "committed_count": committed_count, "image_paths": image_paths, } def get_task(task_id, path=None, conn=None, include_deleted=False): clauses = ["t.id = ?"] params = [int(task_id)] if not include_deleted: clauses.append("b.deleted_at IS NULL") sql = "SELECT t.* FROM tasks t JOIN batches b ON b.id = t.batch_id" sql += " WHERE " + " AND ".join(clauses) with _connection(conn, path) as database: return _fetch_one(database, sql, params, Task) def mark_running(task_id, phase, path=None, conn=None) -> None: _attempt_field(phase) with _connection(conn, path) as database: with database: database.execute( """ UPDATE tasks SET status = 'running', last_error = NULL, updated_at = ? WHERE id = ? """, (_now(), int(task_id)), ) def failure_step_label(step): value = str(step or "").strip() return FAILURE_STEP_LABELS.get(value, value) def format_failure_error(error, step=None): message = str(error or "") label = failure_step_label(step) if not label: return message prefix = f"{label}失败:" if message.startswith(prefix): return message for known_label in FAILURE_STEP_LABELS.values(): if message.startswith(f"{known_label}失败:") or message.startswith(f"{known_label}失败:"): return message return prefix + message def mark_failed(task_id, phase, error, path=None, conn=None, step=None) -> None: attempt_field = _attempt_field(phase) formatted_error = format_failure_error(error, step) with _connection(conn, path) as database: with database: database.execute( f""" UPDATE tasks SET status = 'failed', last_error = ?, {attempt_field} = {attempt_field} + 1, updated_at = ? WHERE id = ? """, (formatted_error, _now(), int(task_id)), ) def mark_skipped(task_id, reason, path=None, conn=None) -> None: with _connection(conn, path) as database: with database: database.execute( """ UPDATE tasks SET status = 'skipped', last_error = ?, updated_at = ? WHERE id = ? """, (str(reason), _now(), int(task_id)), ) def ensure_image_task_key(task_id, path=None, conn=None) -> str: """Return a stable cmhub image idempotency key for the current cover attempt.""" task_id = int(task_id) with _connection(conn, path) as database: with database: row = database.execute( "SELECT image_task_key FROM tasks WHERE id = ?", (task_id,), ).fetchone() if row is None: raise DbError(f"任务不存在: {task_id}") key = str(row["image_task_key"] or "").strip() if not key: key = f"cmshopee-task-{task_id}-{uuid.uuid4().hex}" database.execute( """ UPDATE tasks SET image_task_key = ?, updated_at = ? WHERE id = ? """, (key, _now(), task_id), ) return key def set_image_task_submitted(task_id, image_task_id, image_task_key=None, path=None, conn=None) -> None: """Persist the cmhub image task id immediately after submit succeeds.""" task_id = int(task_id) image_task_id = str(image_task_id or "").strip() if not image_task_id: raise DbError("缺少 cmhub 生图任务ID") with _connection(conn, path) as database: with database: row = database.execute( "SELECT image_task_key FROM tasks WHERE id = ?", (task_id,), ).fetchone() if row is None: raise DbError(f"任务不存在: {task_id}") key = str(image_task_key or row["image_task_key"] or "").strip() if not key: key = f"cmshopee-task-{task_id}-{uuid.uuid4().hex}" database.execute( """ UPDATE tasks SET image_task_id = ?, image_task_key = ?, updated_at = ? WHERE id = ? """, (image_task_id, key, _now(), task_id), ) def clear_image_task(task_id, path=None, conn=None) -> None: """Clear persisted cmhub image task state so the next cover run can submit anew.""" with _connection(conn, path) as database: with database: database.execute( """ UPDATE tasks SET image_task_id = NULL, image_task_key = NULL, updated_at = ? WHERE id = ? """, (_now(), int(task_id)), ) def set_collected(task_id, old_title, old_cover_path, path=None, conn=None) -> None: now = _now() with _connection(conn, path) as database: with database: database.execute( """ UPDATE tasks SET old_title = ?, old_cover_path = ?, stage = 'collected', status = 'success', last_error = NULL, collect_attempts = collect_attempts + 1, collected_at = ?, updated_at = ? WHERE id = ? """, (old_title, old_cover_path, now, now, int(task_id)), ) def set_generated(task_id, new_title, new_cover_path, path=None, conn=None) -> None: now = _now() with _connection(conn, path) as database: with database: database.execute( """ UPDATE tasks SET new_title = ?, new_cover_path = ?, stage = 'generated', status = 'success', last_error = NULL, generate_attempts = generate_attempts + 1, generated_at = ?, updated_at = ? WHERE id = ? """, (new_title, new_cover_path, now, now, int(task_id)), ) def update_generated_title(task_id, new_title, path=None, conn=None) -> None: """Update a generated title manually without touching Shopee or local cover files.""" title = str(new_title or "").strip() if not title: raise DbError("新标题不能为空") with _connection(conn, path) as database: task = get_task(task_id, conn=database) if task is None: raise DbError(f"任务不存在或批次已删除: {task_id}") if int(task.committed or 0) == 1 or task.stage == "applied": raise DbError("已提交线上商品不能在本地直接修改新标题") if task.stage != "generated": raise DbError("只有已生成且未提交线上的任务可以修改新标题") if task.status == "running": raise DbError("任务正在运行,不能修改新标题") now = _now() with database: database.execute( """ UPDATE tasks SET new_title = ?, status = 'pending', last_error = NULL, updated_at = ? WHERE id = ? """, (title, now, int(task_id)), ) def update_generated_cover(task_id, new_cover_path, path=None, conn=None) -> None: """Point a task at an existing local generated cover without copying the file.""" cover_path = str(new_cover_path or "").strip() if not cover_path: raise DbError("新封面路径不能为空") abs_cover_path = os.path.abspath(cover_path) if not os.path.isfile(abs_cover_path): raise DbError("新封面图片不存在") with _connection(conn, path) as database: task = get_task(task_id, conn=database) if task is None: raise DbError(f"任务不存在: {task_id}") if task.status == "running": raise DbError("任务正在运行,不能修改新封面") now = _now() with database: database.execute( """ UPDATE tasks SET new_cover_path = ?, stage = 'generated', status = 'pending', last_error = NULL, updated_at = ? WHERE id = ? """, (abs_cover_path, now, int(task_id)), ) def set_applied(task_id, committed, error=None, path=None, conn=None, step=None) -> None: now = _now() success = bool(committed) and error is None with _connection(conn, path) as database: with database: if success: database.execute( """ UPDATE tasks SET committed = 1, stage = 'applied', status = 'success', last_error = NULL, apply_attempts = apply_attempts + 1, applied_at = ?, updated_at = ? WHERE id = ? """, (now, now, int(task_id)), ) else: formatted_error = format_failure_error(error or "未提交更新", step) database.execute( """ UPDATE tasks SET committed = 0, status = 'failed', last_error = ?, apply_attempts = apply_attempts + 1, updated_at = ? WHERE id = ? """, (formatted_error, now, int(task_id)), ) def reset_generated( task_id, reset_title=True, reset_cover=True, delete_file=False, path=None, conn=None, ) -> dict: """Clear selected local AI result components and keep the task generatable.""" if not reset_title and not reset_cover: raise DbError("至少需要选择重置标题或重置封面") if delete_file and not reset_cover: raise DbError("只有重置封面时才允许删除本地新封面文件") with _connection(conn, path) as database: before = get_task(task_id, conn=database) if before is None: raise DbError(f"任务不存在: {task_id}") new_cover_path = before.new_cover_path archived_file = None if reset_cover: archived_file = _archive_generated_cover_file(new_cover_path) now = _now() new_title = None if reset_title else before.new_title new_cover = None if reset_cover else before.new_cover_path image_task_id = None if reset_cover else before.image_task_id image_task_key = None if reset_cover else before.image_task_key should_record_cover_reset = bool(reset_cover and str(before.new_cover_path or "").strip()) cover_reset_count = int(before.cover_reset_count or 0) cover_reset_at = before.cover_reset_at if should_record_cover_reset: cover_reset_count += 1 cover_reset_at = now with database: database.execute( """ UPDATE tasks SET new_title = ?, new_cover_path = ?, image_task_id = ?, image_task_key = ?, cover_reset_count = ?, cover_reset_at = ?, stage = 'generated', status = 'success', last_error = NULL, updated_at = ? WHERE id = ? """, ( new_title, new_cover, image_task_id, image_task_key, cover_reset_count, cover_reset_at, now, int(task_id), ), ) after = get_task(task_id, conn=database) return { "task_id": int(task_id), "before": before, "after": after, "new_cover_path": new_cover_path, "archived_file": archived_file, "deleted_file": None, "reset_title": bool(reset_title), "reset_cover": bool(reset_cover), } def reset_apply_status(task_id, path=None, conn=None) -> dict: """Move a generated/applied local task back to pending update state.""" with _connection(conn, path) as database: before = get_task(task_id, conn=database) if before is None: raise DbError(f"任务不存在: {task_id}") now = _now() with database: database.execute( """ UPDATE tasks SET stage = 'generated', status = 'pending', last_error = NULL, updated_at = ? WHERE id = ? """, (now, int(task_id)), ) after = get_task(task_id, conn=database) return { "task_id": int(task_id), "before": before, "after": after, } def create_run_log( run_type, dry_run=False, total=0, options=None, path=None, conn=None, ) -> int: """Create a high-level operation log and return its id.""" now = _now() options_json = json.dumps( appconfig.sanitize_for_log(options or {}), ensure_ascii=False, sort_keys=True, ) with _connection(conn, path) as database: with database: cursor = database.execute( """ INSERT INTO run_logs (run_type, dry_run, status, total, options_json, started_at) VALUES (?, ?, 'running', ?, ?, ?) """, (str(run_type), 1 if dry_run else 0, int(total), options_json, now), ) return int(cursor.lastrowid) def finish_run_log(run_id, path=None, conn=None, **fields) -> None: """Mark a run log complete/blocked/failed with a sanitized summary.""" _validate_fields(fields, VALID_RUN_LOG_FIELDS) if "summary_json" in fields and not isinstance(fields["summary_json"], str): fields["summary_json"] = json.dumps( appconfig.sanitize_for_log(fields["summary_json"] or {}), ensure_ascii=False, sort_keys=True, ) if "finished_at" not in fields: fields["finished_at"] = _now() assignments = ", ".join(f"{field} = ?" for field in fields) params = list(fields.values()) + [int(run_id)] with _connection(conn, path) as database: with database: database.execute( f"UPDATE run_logs SET {assignments} WHERE id = ?", params, ) def add_run_log_event( run_id, message, task_id=None, alias=None, item_id=None, level="info", path=None, conn=None, ) -> int: """Append one sanitized event line to a run log.""" sanitized = appconfig.sanitize_for_log( { "message": str(message), "alias": alias, "item_id": item_id, "level": level, } ) with _connection(conn, path) as database: with database: cursor = database.execute( """ INSERT INTO run_log_events (run_id, task_id, alias, item_id, level, message, created_at) VALUES (?, ?, ?, ?, ?, ?, ?) """, ( int(run_id), int(task_id) if task_id is not None else None, sanitized.get("alias"), sanitized.get("item_id"), sanitized.get("level") or "info", sanitized.get("message") or "", _now(), ), ) return int(cursor.lastrowid) def list_run_logs(limit=50, run_type=None, path=None, conn=None): clauses = [] params = [] if run_type is not None: clauses.append("run_type = ?") params.append(str(run_type)) sql = "SELECT * FROM run_logs" if clauses: sql += " WHERE " + " AND ".join(clauses) sql += " ORDER BY started_at DESC, id DESC LIMIT ?" params.append(max(1, int(limit))) with _connection(conn, path) as database: return _fetch_all(database, sql, params, RunLog) def list_run_log_events(run_id=None, limit=200, path=None, conn=None): clauses = [] params = [] if run_id is not None: clauses.append("run_id = ?") params.append(int(run_id)) sql = "SELECT * FROM run_log_events" if clauses: sql += " WHERE " + " AND ".join(clauses) sql += " ORDER BY id DESC LIMIT ?" params.append(max(1, int(limit))) with _connection(conn, path) as database: events = _fetch_all(database, sql, params, RunLogEvent) return list(reversed(events))