2026-06-27 09:02:05 +08:00
|
|
|
"""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
|
|
|
|
|
|
2026-07-18 16:34:37 +08:00
|
|
|
from . import appconfig, product_status
|
2026-06-27 09:21:33 +08:00
|
|
|
from .config import make_slug
|
2026-06-27 09:02:05 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
DEFAULT_BUSY_TIMEOUT_MS = 5000
|
2026-07-01 09:41:10 +08:00
|
|
|
VALID_BATCH_FIELDS = {
|
|
|
|
|
"source_files_json",
|
|
|
|
|
"status",
|
|
|
|
|
"note",
|
|
|
|
|
"deleted_at",
|
|
|
|
|
"deleted_reason",
|
|
|
|
|
}
|
2026-07-08 11:08:01 +08:00
|
|
|
FAILURE_STEP_LABELS = {
|
|
|
|
|
"preflight": "预检",
|
|
|
|
|
"check_chrome": "检查Chrome",
|
|
|
|
|
"login_check": "检测登录",
|
|
|
|
|
"open_product": "打开商品页",
|
|
|
|
|
"wait_ready": "页面就绪",
|
|
|
|
|
"read_title": "读标题",
|
2026-07-18 16:34:37 +08:00
|
|
|
"read_product_status": "读取商品状态",
|
2026-07-08 11:08:01 +08:00
|
|
|
"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": "请求生成封面",
|
2026-07-09 00:07:09 +08:00
|
|
|
"cover_poll": "等待封面结果",
|
2026-07-08 11:08:01 +08:00
|
|
|
"cover_download": "下载新封面",
|
|
|
|
|
"cover_save": "保存新封面",
|
|
|
|
|
"cover_parse_response": "解析封面响应",
|
|
|
|
|
"apply_task": "更新商品",
|
|
|
|
|
"change_title": "修改标题",
|
|
|
|
|
"replace_cover": "更新封面",
|
|
|
|
|
"click_update": "点击更新",
|
|
|
|
|
}
|
2026-06-27 09:02:05 +08:00
|
|
|
VALID_ACCOUNT_FIELDS = {
|
|
|
|
|
"account_name",
|
2026-06-27 10:30:45 +08:00
|
|
|
"alias",
|
2026-06-27 09:02:05 +08:00
|
|
|
"region_host",
|
|
|
|
|
"slug",
|
|
|
|
|
"user_data_dir",
|
|
|
|
|
"debug_port",
|
|
|
|
|
"password",
|
|
|
|
|
"note",
|
|
|
|
|
"last_login_at",
|
|
|
|
|
}
|
2026-06-29 10:25:09 +08:00
|
|
|
VALID_RUN_LOG_FIELDS = {
|
|
|
|
|
"status",
|
|
|
|
|
"done",
|
|
|
|
|
"success_count",
|
|
|
|
|
"skipped_count",
|
|
|
|
|
"failed_count",
|
|
|
|
|
"finished_at",
|
|
|
|
|
"summary_json",
|
|
|
|
|
}
|
2026-06-27 09:02:05 +08:00
|
|
|
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
|
2026-07-01 09:41:10 +08:00
|
|
|
deleted_at: Optional[str]
|
|
|
|
|
deleted_reason: Optional[str]
|
2026-06-27 09:02:05 +08:00
|
|
|
|
|
|
|
|
@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
|
2026-07-18 16:34:37 +08:00
|
|
|
product_status: Optional[str]
|
|
|
|
|
product_status_note: Optional[str]
|
|
|
|
|
product_status_at: Optional[str]
|
2026-06-27 09:02:05 +08:00
|
|
|
old_title: Optional[str]
|
|
|
|
|
old_cover_path: Optional[str]
|
|
|
|
|
new_title: Optional[str]
|
|
|
|
|
new_cover_path: Optional[str]
|
2026-07-09 00:07:09 +08:00
|
|
|
image_task_id: Optional[str]
|
|
|
|
|
image_task_key: Optional[str]
|
2026-07-11 12:06:17 +08:00
|
|
|
cover_reset_count: int
|
|
|
|
|
cover_reset_at: Optional[str]
|
2026-06-27 09:02:05 +08:00
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2026-06-29 10:25:09 +08:00
|
|
|
@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
|
|
|
|
|
|
|
|
|
|
|
2026-06-27 09:02:05 +08:00
|
|
|
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,
|
2026-07-01 09:41:10 +08:00
|
|
|
updated_at TEXT NOT NULL,
|
|
|
|
|
deleted_at TEXT,
|
|
|
|
|
deleted_reason TEXT
|
2026-06-27 09:02:05 +08:00
|
|
|
);
|
|
|
|
|
|
|
|
|
|
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,
|
2026-07-18 16:34:37 +08:00
|
|
|
product_status TEXT,
|
|
|
|
|
product_status_note TEXT,
|
|
|
|
|
product_status_at TEXT,
|
2026-06-27 09:02:05 +08:00
|
|
|
old_title TEXT,
|
|
|
|
|
old_cover_path TEXT,
|
|
|
|
|
new_title TEXT,
|
|
|
|
|
new_cover_path TEXT,
|
2026-07-09 00:07:09 +08:00
|
|
|
image_task_id TEXT,
|
|
|
|
|
image_task_key TEXT,
|
2026-07-11 12:06:17 +08:00
|
|
|
cover_reset_count INTEGER NOT NULL DEFAULT 0,
|
|
|
|
|
cover_reset_at TEXT,
|
2026-06-27 09:02:05 +08:00
|
|
|
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);
|
2026-06-29 10:25:09 +08:00
|
|
|
|
|
|
|
|
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);
|
2026-07-11 12:15:58 +08:00
|
|
|
|
|
|
|
|
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,
|
2026-07-16 11:10:51 +08:00
|
|
|
storage_key TEXT NOT NULL,
|
|
|
|
|
binding_state TEXT NOT NULL DEFAULT 'bound',
|
2026-07-11 12:15:58 +08:00
|
|
|
target_main_count INTEGER NOT NULL DEFAULT 9,
|
|
|
|
|
target_detail_count INTEGER NOT NULL DEFAULT 12,
|
|
|
|
|
draft_prompt TEXT,
|
2026-07-14 09:53:13 +08:00
|
|
|
suite_settings_json TEXT NOT NULL DEFAULT '{}',
|
2026-07-16 23:27:25 +08:00
|
|
|
current_generation_round_key TEXT,
|
2026-07-11 12:15:58 +08:00
|
|
|
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,
|
2026-07-17 17:03:40 +08:00
|
|
|
reference_asset_ids TEXT,
|
2026-07-11 12:15:58 +08:00
|
|
|
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,
|
2026-07-13 09:54:08 +08:00
|
|
|
recovery_action TEXT NOT NULL DEFAULT 'regenerate',
|
2026-07-11 12:15:58 +08:00
|
|
|
attempts INTEGER NOT NULL DEFAULT 0,
|
|
|
|
|
points_cost INTEGER,
|
|
|
|
|
points_balance INTEGER,
|
|
|
|
|
call_id TEXT,
|
2026-07-16 23:27:25 +08:00
|
|
|
generation_round_key TEXT,
|
|
|
|
|
generation_slot_index INTEGER,
|
2026-07-11 12:15:58 +08:00
|
|
|
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);
|
2026-06-27 09:02:05 +08:00
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _now() -> str:
|
|
|
|
|
return datetime.now().isoformat(timespec="seconds")
|
|
|
|
|
|
|
|
|
|
|
2026-07-09 11:23:18 +08:00
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2026-06-27 09:02:05 +08:00
|
|
|
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)
|
2026-07-01 09:41:10 +08:00
|
|
|
_ensure_batch_delete_columns(database)
|
2026-07-09 00:07:09 +08:00
|
|
|
_ensure_task_image_task_columns(database)
|
2026-07-11 12:06:17 +08:00
|
|
|
_ensure_task_cover_reset_columns(database)
|
2026-07-18 16:34:37 +08:00
|
|
|
_ensure_task_product_status_columns(database)
|
2026-07-14 09:53:13 +08:00
|
|
|
_ensure_image_studio_project_suite_columns(database)
|
2026-07-16 11:10:51 +08:00
|
|
|
_ensure_image_studio_project_draft_columns(database)
|
2026-07-13 09:54:08 +08:00
|
|
|
_ensure_image_studio_job_recovery_columns(database)
|
2026-07-16 23:27:25 +08:00
|
|
|
_ensure_image_studio_generation_round_columns(database)
|
2026-07-17 17:03:40 +08:00
|
|
|
_ensure_image_studio_job_reference_asset_ids_column(database)
|
2026-06-27 09:02:05 +08:00
|
|
|
|
|
|
|
|
|
2026-07-01 09:41:10 +08:00
|
|
|
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")
|
|
|
|
|
|
2026-07-09 00:07:09 +08:00
|
|
|
|
|
|
|
|
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")
|
|
|
|
|
|
2026-07-11 12:06:17 +08:00
|
|
|
|
|
|
|
|
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")
|
|
|
|
|
|
2026-07-13 09:54:08 +08:00
|
|
|
|
2026-07-18 16:34:37 +08:00
|
|
|
def _ensure_task_product_status_columns(database):
|
|
|
|
|
columns = {row["name"] for row in database.execute("PRAGMA table_info(tasks)").fetchall()}
|
|
|
|
|
if "product_status" not in columns:
|
|
|
|
|
database.execute("ALTER TABLE tasks ADD COLUMN product_status TEXT")
|
|
|
|
|
if "product_status_note" not in columns:
|
|
|
|
|
database.execute("ALTER TABLE tasks ADD COLUMN product_status_note TEXT")
|
|
|
|
|
if "product_status_at" not in columns:
|
|
|
|
|
database.execute("ALTER TABLE tasks ADD COLUMN product_status_at TEXT")
|
|
|
|
|
|
|
|
|
|
|
2026-07-14 09:53:13 +08:00
|
|
|
def _ensure_image_studio_project_suite_columns(database):
|
|
|
|
|
columns = {
|
|
|
|
|
row["name"]
|
|
|
|
|
for row in database.execute("PRAGMA table_info(image_studio_projects)").fetchall()
|
|
|
|
|
}
|
|
|
|
|
if "suite_settings_json" not in columns:
|
|
|
|
|
database.execute(
|
|
|
|
|
"ALTER TABLE image_studio_projects "
|
|
|
|
|
"ADD COLUMN suite_settings_json TEXT NOT NULL DEFAULT '{}'"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-07-16 11:10:51 +08:00
|
|
|
def _ensure_image_studio_project_draft_columns(database):
|
|
|
|
|
columns = {
|
|
|
|
|
row["name"]
|
|
|
|
|
for row in database.execute("PRAGMA table_info(image_studio_projects)").fetchall()
|
|
|
|
|
}
|
|
|
|
|
if "storage_key" not in columns:
|
|
|
|
|
database.execute("ALTER TABLE image_studio_projects ADD COLUMN storage_key TEXT")
|
|
|
|
|
if "binding_state" not in columns:
|
|
|
|
|
database.execute(
|
|
|
|
|
"ALTER TABLE image_studio_projects "
|
|
|
|
|
"ADD COLUMN binding_state TEXT NOT NULL DEFAULT 'bound'"
|
|
|
|
|
)
|
|
|
|
|
database.execute(
|
|
|
|
|
"UPDATE image_studio_projects SET storage_key = item_id "
|
|
|
|
|
"WHERE storage_key IS NULL OR TRIM(storage_key) = ''"
|
|
|
|
|
)
|
|
|
|
|
database.execute(
|
|
|
|
|
"UPDATE image_studio_projects SET binding_state = 'bound' "
|
|
|
|
|
"WHERE binding_state IS NULL OR binding_state NOT IN ('draft', 'bound')"
|
|
|
|
|
)
|
|
|
|
|
database.execute(
|
|
|
|
|
"CREATE INDEX IF NOT EXISTS idx_image_studio_projects_binding "
|
|
|
|
|
"ON image_studio_projects(binding_state, updated_at DESC)"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-07-13 09:54:08 +08:00
|
|
|
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'
|
|
|
|
|
)
|
|
|
|
|
"""
|
|
|
|
|
)
|
|
|
|
|
|
2026-07-16 23:27:25 +08:00
|
|
|
|
|
|
|
|
def _ensure_image_studio_generation_round_columns(database):
|
|
|
|
|
project_columns = {
|
|
|
|
|
row["name"]
|
|
|
|
|
for row in database.execute("PRAGMA table_info(image_studio_projects)").fetchall()
|
|
|
|
|
}
|
|
|
|
|
if "current_generation_round_key" not in project_columns:
|
|
|
|
|
database.execute(
|
|
|
|
|
"ALTER TABLE image_studio_projects "
|
|
|
|
|
"ADD COLUMN current_generation_round_key TEXT"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
job_columns = {
|
|
|
|
|
row["name"]
|
|
|
|
|
for row in database.execute("PRAGMA table_info(image_studio_jobs)").fetchall()
|
|
|
|
|
}
|
|
|
|
|
if "generation_round_key" not in job_columns:
|
|
|
|
|
database.execute(
|
|
|
|
|
"ALTER TABLE image_studio_jobs ADD COLUMN generation_round_key TEXT"
|
|
|
|
|
)
|
|
|
|
|
if "generation_slot_index" not in job_columns:
|
|
|
|
|
database.execute(
|
|
|
|
|
"ALTER TABLE image_studio_jobs ADD COLUMN generation_slot_index INTEGER"
|
|
|
|
|
)
|
|
|
|
|
database.execute(
|
|
|
|
|
"CREATE INDEX IF NOT EXISTS idx_image_studio_jobs_generation_round "
|
|
|
|
|
"ON image_studio_jobs("
|
|
|
|
|
"project_id, generation_round_key, generation_slot_index, id)"
|
|
|
|
|
)
|
|
|
|
|
|
2026-07-17 17:03:40 +08:00
|
|
|
|
|
|
|
|
def _ensure_image_studio_job_reference_asset_ids_column(database):
|
|
|
|
|
columns = {
|
|
|
|
|
row["name"] for row in database.execute("PRAGMA table_info(image_studio_jobs)").fetchall()
|
|
|
|
|
}
|
|
|
|
|
if "reference_asset_ids" not in columns:
|
|
|
|
|
database.execute(
|
|
|
|
|
"ALTER TABLE image_studio_jobs ADD COLUMN reference_asset_ids TEXT"
|
|
|
|
|
)
|
|
|
|
|
|
2026-06-27 09:02:05 +08:00
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2026-07-01 09:41:10 +08:00
|
|
|
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"
|
2026-06-27 09:02:05 +08:00
|
|
|
with _connection(conn, path) as database:
|
2026-07-01 09:41:10 +08:00
|
|
|
return _fetch_one(database, sql, params, Batch)
|
2026-06-27 09:02:05 +08:00
|
|
|
|
|
|
|
|
|
2026-07-01 09:41:10 +08:00
|
|
|
def list_batches(status=None, path=None, conn=None, include_deleted=False):
|
|
|
|
|
clauses = []
|
2026-06-27 09:02:05 +08:00
|
|
|
params = []
|
|
|
|
|
if status is not None:
|
2026-07-01 09:41:10 +08:00
|
|
|
clauses.append("status = ?")
|
2026-06-27 09:02:05 +08:00
|
|
|
params.append(status)
|
2026-07-01 09:41:10 +08:00
|
|
|
if not include_deleted:
|
|
|
|
|
clauses.append("deleted_at IS NULL")
|
|
|
|
|
sql = "SELECT * FROM batches"
|
|
|
|
|
if clauses:
|
|
|
|
|
sql += " WHERE " + " AND ".join(clauses)
|
2026-06-27 09:02:05 +08:00
|
|
|
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,
|
|
|
|
|
):
|
2026-06-27 09:21:33 +08:00
|
|
|
slug = slug or make_slug(alias)
|
2026-06-27 09:02:05 +08:00
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
2026-06-27 10:30:45 +08:00
|
|
|
def update_account(account_alias, path=None, conn=None, **fields) -> None:
|
2026-06-27 09:02:05 +08:00
|
|
|
_validate_fields(fields, VALID_ACCOUNT_FIELDS)
|
|
|
|
|
if not fields:
|
|
|
|
|
return
|
|
|
|
|
fields["updated_at"] = _now()
|
|
|
|
|
assignments = ", ".join(f"{field} = ?" for field in fields)
|
2026-06-27 10:30:45 +08:00
|
|
|
params = list(fields.values()) + [account_alias]
|
2026-06-27 09:02:05 +08:00
|
|
|
with _connection(conn, path) as database:
|
2026-06-27 10:30:45 +08:00
|
|
|
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
|
2026-06-27 09:02:05 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
2026-07-01 09:41:10 +08:00
|
|
|
def list_tasks(
|
|
|
|
|
batch_id=None,
|
|
|
|
|
stage=None,
|
|
|
|
|
status=None,
|
|
|
|
|
alias=None,
|
|
|
|
|
path=None,
|
|
|
|
|
conn=None,
|
|
|
|
|
include_deleted=False,
|
|
|
|
|
):
|
2026-06-27 09:02:05 +08:00
|
|
|
clauses = []
|
|
|
|
|
params = []
|
|
|
|
|
filters = {
|
2026-07-01 09:41:10 +08:00
|
|
|
"t.batch_id": batch_id,
|
|
|
|
|
"t.stage": stage,
|
|
|
|
|
"t.status": status,
|
|
|
|
|
"t.alias": alias,
|
2026-06-27 09:02:05 +08:00
|
|
|
}
|
|
|
|
|
for field, value in filters.items():
|
|
|
|
|
if value is not None:
|
|
|
|
|
clauses.append(f"{field} = ?")
|
|
|
|
|
params.append(value)
|
2026-07-01 09:41:10 +08:00
|
|
|
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"
|
2026-06-27 09:02:05 +08:00
|
|
|
if clauses:
|
|
|
|
|
sql += " WHERE " + " AND ".join(clauses)
|
2026-07-01 09:41:10 +08:00
|
|
|
sql += " ORDER BY t.id"
|
2026-06-27 09:02:05 +08:00
|
|
|
with _connection(conn, path) as database:
|
|
|
|
|
return _fetch_all(database, sql, params, Task)
|
|
|
|
|
|
|
|
|
|
|
2026-07-01 09:41:10 +08:00
|
|
|
def delete_batch(batch_id, reason=None, path=None, conn=None) -> dict:
|
|
|
|
|
"""Soft delete a batch so it disappears from normal UI and workflows."""
|
|
|
|
|
|
2026-06-30 11:50:49 +08:00
|
|
|
with _connection(conn, path) as database:
|
2026-07-01 09:41:10 +08:00
|
|
|
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)
|
2026-06-30 11:50:49 +08:00
|
|
|
|
|
|
|
|
|
2026-06-27 09:02:05 +08:00
|
|
|
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)),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-07-08 11:08:01 +08:00
|
|
|
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:
|
2026-06-27 09:02:05 +08:00
|
|
|
attempt_field = _attempt_field(phase)
|
2026-07-08 11:08:01 +08:00
|
|
|
formatted_error = format_failure_error(error, step)
|
2026-06-27 09:02:05 +08:00
|
|
|
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 = ?
|
|
|
|
|
""",
|
2026-07-08 11:08:01 +08:00
|
|
|
(formatted_error, _now(), int(task_id)),
|
2026-06-27 09:02:05 +08:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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)),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-07-09 00:07:09 +08:00
|
|
|
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)),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-07-18 16:34:37 +08:00
|
|
|
def set_product_status(
|
|
|
|
|
task_id,
|
|
|
|
|
product_status_value,
|
|
|
|
|
product_status_note=None,
|
|
|
|
|
product_status_at=None,
|
|
|
|
|
path=None,
|
|
|
|
|
conn=None,
|
|
|
|
|
) -> None:
|
|
|
|
|
"""Persist a status snapshot without changing the collection lifecycle."""
|
|
|
|
|
|
2026-06-27 09:02:05 +08:00
|
|
|
now = _now()
|
2026-07-18 16:34:37 +08:00
|
|
|
detected_at = product_status_at or now
|
|
|
|
|
with _connection(conn, path) as database:
|
|
|
|
|
with database:
|
|
|
|
|
database.execute(
|
|
|
|
|
"""
|
|
|
|
|
UPDATE tasks
|
|
|
|
|
SET product_status = ?,
|
|
|
|
|
product_status_note = ?,
|
|
|
|
|
product_status_at = ?,
|
|
|
|
|
updated_at = ?
|
|
|
|
|
WHERE id = ?
|
|
|
|
|
""",
|
|
|
|
|
(
|
|
|
|
|
product_status.normalize_status(product_status_value),
|
|
|
|
|
str(product_status_note or "")[:2000] or None,
|
|
|
|
|
detected_at,
|
|
|
|
|
now,
|
|
|
|
|
int(task_id),
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def set_collected(
|
|
|
|
|
task_id,
|
|
|
|
|
old_title,
|
|
|
|
|
old_cover_path,
|
|
|
|
|
path=None,
|
|
|
|
|
conn=None,
|
|
|
|
|
*,
|
|
|
|
|
product_status_value=None,
|
|
|
|
|
product_status_note=None,
|
|
|
|
|
product_status_at=None,
|
|
|
|
|
) -> None:
|
|
|
|
|
now = _now()
|
|
|
|
|
has_status_snapshot = product_status_value is not None
|
|
|
|
|
detected_at = product_status_at or now
|
2026-06-27 09:02:05 +08:00
|
|
|
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 = ?,
|
2026-07-18 16:34:37 +08:00
|
|
|
product_status = CASE WHEN ? THEN ? ELSE product_status END,
|
|
|
|
|
product_status_note = CASE WHEN ? THEN ? ELSE product_status_note END,
|
|
|
|
|
product_status_at = CASE WHEN ? THEN ? ELSE product_status_at END,
|
2026-06-27 09:02:05 +08:00
|
|
|
updated_at = ?
|
|
|
|
|
WHERE id = ?
|
|
|
|
|
""",
|
2026-07-18 16:34:37 +08:00
|
|
|
(
|
|
|
|
|
old_title,
|
|
|
|
|
old_cover_path,
|
|
|
|
|
now,
|
|
|
|
|
int(has_status_snapshot),
|
|
|
|
|
product_status.normalize_status(product_status_value),
|
|
|
|
|
int(has_status_snapshot),
|
|
|
|
|
str(product_status_note or "")[:2000] or None,
|
|
|
|
|
int(has_status_snapshot),
|
|
|
|
|
detected_at,
|
|
|
|
|
now,
|
|
|
|
|
int(task_id),
|
|
|
|
|
),
|
2026-06-27 09:02:05 +08:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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)),
|
|
|
|
|
)
|
|
|
|
|
|
2026-07-13 15:22:15 +08:00
|
|
|
|
|
|
|
|
def set_generated_cover(task_id, new_cover_path, path=None, conn=None) -> None:
|
|
|
|
|
"""Persist an AI-generated cover without changing the generated title."""
|
|
|
|
|
|
|
|
|
|
now = _now()
|
|
|
|
|
with _connection(conn, path) as database:
|
|
|
|
|
with database:
|
|
|
|
|
database.execute(
|
|
|
|
|
"""
|
|
|
|
|
UPDATE tasks
|
|
|
|
|
SET new_cover_path = ?,
|
|
|
|
|
stage = 'generated',
|
|
|
|
|
status = 'success',
|
|
|
|
|
last_error = NULL,
|
|
|
|
|
generate_attempts = generate_attempts + 1,
|
|
|
|
|
generated_at = ?,
|
|
|
|
|
updated_at = ?
|
|
|
|
|
WHERE id = ?
|
|
|
|
|
""",
|
|
|
|
|
(new_cover_path, now, now, int(task_id)),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-07-01 14:30:49 +08:00
|
|
|
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)),
|
|
|
|
|
)
|
2026-06-27 09:02:05 +08:00
|
|
|
|
2026-07-09 11:23:18 +08:00
|
|
|
|
|
|
|
|
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)),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-07-08 11:08:01 +08:00
|
|
|
def set_applied(task_id, committed, error=None, path=None, conn=None, step=None) -> None:
|
2026-06-27 09:02:05 +08:00
|
|
|
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:
|
2026-07-08 11:08:01 +08:00
|
|
|
formatted_error = format_failure_error(error or "未提交更新", step)
|
2026-06-27 09:02:05 +08:00
|
|
|
database.execute(
|
|
|
|
|
"""
|
|
|
|
|
UPDATE tasks
|
|
|
|
|
SET committed = 0,
|
|
|
|
|
status = 'failed',
|
|
|
|
|
last_error = ?,
|
|
|
|
|
apply_attempts = apply_attempts + 1,
|
|
|
|
|
updated_at = ?
|
|
|
|
|
WHERE id = ?
|
|
|
|
|
""",
|
2026-07-08 11:08:01 +08:00
|
|
|
(formatted_error, now, int(task_id)),
|
2026-06-27 09:02:05 +08:00
|
|
|
)
|
2026-06-29 10:25:09 +08:00
|
|
|
|
|
|
|
|
|
2026-07-06 20:35:47 +08:00
|
|
|
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."""
|
2026-06-30 11:50:49 +08:00
|
|
|
|
2026-07-06 20:35:47 +08:00
|
|
|
if not reset_title and not reset_cover:
|
|
|
|
|
raise DbError("至少需要选择重置标题或重置封面")
|
|
|
|
|
if delete_file and not reset_cover:
|
|
|
|
|
raise DbError("只有重置封面时才允许删除本地新封面文件")
|
2026-06-30 11:50:49 +08:00
|
|
|
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
|
2026-07-09 11:23:18 +08:00
|
|
|
archived_file = None
|
|
|
|
|
if reset_cover:
|
|
|
|
|
archived_file = _archive_generated_cover_file(new_cover_path)
|
2026-06-30 11:50:49 +08:00
|
|
|
now = _now()
|
2026-07-06 20:35:47 +08:00
|
|
|
new_title = None if reset_title else before.new_title
|
|
|
|
|
new_cover = None if reset_cover else before.new_cover_path
|
2026-07-09 00:07:09 +08:00
|
|
|
image_task_id = None if reset_cover else before.image_task_id
|
|
|
|
|
image_task_key = None if reset_cover else before.image_task_key
|
2026-07-11 12:06:17 +08:00
|
|
|
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
|
2026-06-30 11:50:49 +08:00
|
|
|
with database:
|
|
|
|
|
database.execute(
|
|
|
|
|
"""
|
|
|
|
|
UPDATE tasks
|
2026-07-06 20:35:47 +08:00
|
|
|
SET new_title = ?,
|
|
|
|
|
new_cover_path = ?,
|
2026-07-09 00:07:09 +08:00
|
|
|
image_task_id = ?,
|
|
|
|
|
image_task_key = ?,
|
2026-07-11 12:06:17 +08:00
|
|
|
cover_reset_count = ?,
|
|
|
|
|
cover_reset_at = ?,
|
2026-07-06 20:35:47 +08:00
|
|
|
stage = 'generated',
|
2026-06-30 11:50:49 +08:00
|
|
|
status = 'success',
|
|
|
|
|
last_error = NULL,
|
|
|
|
|
updated_at = ?
|
|
|
|
|
WHERE id = ?
|
|
|
|
|
""",
|
2026-07-11 12:06:17 +08:00
|
|
|
(
|
|
|
|
|
new_title,
|
|
|
|
|
new_cover,
|
|
|
|
|
image_task_id,
|
|
|
|
|
image_task_key,
|
|
|
|
|
cover_reset_count,
|
|
|
|
|
cover_reset_at,
|
|
|
|
|
now,
|
|
|
|
|
int(task_id),
|
|
|
|
|
),
|
2026-06-30 11:50:49 +08:00
|
|
|
)
|
|
|
|
|
after = get_task(task_id, conn=database)
|
|
|
|
|
return {
|
|
|
|
|
"task_id": int(task_id),
|
|
|
|
|
"before": before,
|
|
|
|
|
"after": after,
|
|
|
|
|
"new_cover_path": new_cover_path,
|
2026-07-09 11:23:18 +08:00
|
|
|
"archived_file": archived_file,
|
|
|
|
|
"deleted_file": None,
|
2026-07-06 20:35:47 +08:00
|
|
|
"reset_title": bool(reset_title),
|
|
|
|
|
"reset_cover": bool(reset_cover),
|
2026-06-30 11:50:49 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
2026-06-29 10:25:09 +08:00
|
|
|
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))
|