feat: 完成T-504更新执行增强

- 新增 dry-run、多账号并行和最大并行账号数设置

- 增加 run_logs/run_log_events 运行日志表及读写接口

- ApplyWorker 支持 dry-run 预览、按账号并行、端口冲突阻断和日志展示

- 补充 DB/GUI 单元测试并同步任务、架构、API、路由和当前状态文档

验证: python -m compileall app main.py tests; python -m unittest discover -s tests
This commit is contained in:
chengma
2026-06-29 10:25:09 +08:00
parent 01e319cad8
commit 0e0ed193b6
17 changed files with 1071 additions and 149 deletions
+199
View File
@@ -28,6 +28,15 @@ VALID_ACCOUNT_FIELDS = {
"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",
@@ -104,6 +113,43 @@ class Task:
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,
@@ -163,6 +209,36 @@ 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);
"""
@@ -561,3 +637,126 @@ def set_applied(task_id, committed, error=None, path=None, conn=None) -> None:
""",
(str(error or "未提交更新"), now, int(task_id)),
)
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))