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:
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user