fix(automation): 修复作业完成状态回写崩溃、统计字段对齐及表单布局挤压

1. 修复 DouyuBatchRunner 在 run 结束时私自关闭外部 Session 导致作业状态无法回写并死锁在 running 的问题(增加 close_db 参数);
2. 修复资料补全作业返回值映射,将 succeeded/failed 对齐到 valid/invalid,并在全库暂无待查账号时给出清晰说明;
3. 增强调度器 _run_one 边界防御:支持字段名兜底与 Session 崩溃时独立连接兜底回写,防止作业无限重复发起;
4. 修复巡检作业在全库无到期账号时的说明信息,如实展示最久检测时间与重检间隔;
5. 修复 AutomationPage 布局:移除外层 Form 的 layout=inline,Card 恢复 100% 满宽纵向排列,内部字段横向弹性自适应;
6. 修复未启用作业因残留历史 next_run_at 而错误显示「排队中」的问题。
This commit is contained in:
yml2213
2026-10-04 22:11:44 +08:00
parent e5f2d2d5d2
commit 19f1cbceba
6 changed files with 245 additions and 146 deletions
@@ -145,7 +145,14 @@ def run_account_overview_refresh(db: Session, job: AutomationJob) -> dict[str, A
now = datetime.now(UTC)
accounts = build_select_query(db, job, now).all()
if not accounts:
return {"scanned": 0, "succeeded": 0, "failed": 0, "message": "没有待查账号"}
return {
"scanned": 0,
"valid": 0,
"invalid": 0,
"succeeded": 0,
"failed": 0,
"message": "全库暂无待补全或到期的账号",
}
account_ids = [a.id for a in accounts]
batch_id, count = create_douyu_planned_tasks(
@@ -158,14 +165,23 @@ def run_account_overview_refresh(db: Session, job: AutomationJob) -> dict[str, A
{"source": "scheduled_refresh"},
)
if count == 0:
return {"scanned": 0, "succeeded": 0, "failed": 0, "message": "账号都不可查"}
return {
"scanned": 0,
"valid": 0,
"invalid": 0,
"succeeded": 0,
"failed": 0,
"message": "候选账号均不可查(可能正有在途任务)",
}
# 复用现有执行器:并发、代理、进度、失败处理都是现成的
# ⚠️ 必须 close_db=False,避免 runner 结束时把调度线程的 session 关闭
runner = DouyuBatchRunner(
db,
batch_id,
"query_account_overview",
concurrency=max(1, min(int(job.concurrency or 1), 10)),
close_db=False,
)
runner.run()
db.commit()
@@ -200,6 +216,8 @@ def run_account_overview_refresh(db: Session, job: AutomationJob) -> dict[str, A
logger.info(f"[overview] {message}")
return {
"scanned": len(rows),
"valid": succeeded,
"invalid": failed,
"succeeded": succeeded,
"failed": failed,
"batch_id": batch_id,
@@ -13,13 +13,13 @@ from concurrent.futures import ThreadPoolExecutor, as_completed
from datetime import UTC, datetime, timedelta
from typing import Any
from sqlalchemy import or_
from sqlalchemy import func, or_
from sqlalchemy.orm import Session
from ....models import Account, AutomationJob
from ...account_lifecycle import MANUAL_STATES, on_ck_verdict
from ...cookie_check_service import check_douyu_cookie
from ...douyu_service import cookie_operable_filter
from ...douyu_service import cookie_holding_filter, cookie_operable_filter
JOB_KEY = "ck_health"
FLUSH_EVERY = 25
@@ -135,7 +135,19 @@ def run_ck_health(db: Session, job: AutomationJob) -> dict[str, Any]:
now = datetime.now(UTC)
accounts = _select_accounts(db, job, now)
if not accounts:
return {"scanned": 0, "valid": 0, "invalid": 0, "skipped": 0}
earliest_checked = (
db.query(func.min(Account.ck_checked_at))
.filter(cookie_holding_filter())
.scalar()
)
msg = "全库暂无到期待检账号"
if earliest_checked:
if earliest_checked.tzinfo is None:
earliest_checked = earliest_checked.replace(tzinfo=UTC)
hours_ago = (now - earliest_checked).total_seconds() / 3600
recheck_hours = max(int(job.interval_seconds or 0), 60) / 3600
msg = f"全库暂无到期待检账号(最久前检测于 {hours_ago:.1f} 小时前,重检间隔为 {recheck_hours:.1f} 小时)"
return {"scanned": 0, "valid": 0, "invalid": 0, "skipped": 0, "message": msg}
results: dict[int, dict[str, Any]] = {}
concurrency = max(min(int(job.concurrency or 1), 16), 1)
+85 -30
View File
@@ -301,46 +301,94 @@ def _run_one(db: Session, job: AutomationJob, spec: JobSpec) -> dict[str, Any]:
logger.exception("自动化作业 {} 执行失败", job.key)
finished = datetime.now(UTC)
run.finished_at = finished
run.status = status
run.message = message
run.scanned = int(counters.get("scanned", 0) or 0)
run.valid = int(counters.get("valid", 0) or 0)
run.invalid = int(counters.get("invalid", 0) or 0)
run.relogin_started = int(counters.get("relogin_started", 0) or 0)
run.relogin_success = int(counters.get("relogin_success", 0) or 0)
run.skipped = int(counters.get("skipped", 0) or 0)
run.detail = counters.get("detail") or None
scanned = int(counters.get("scanned", 0) or 0)
valid = int(counters.get("valid", counters.get("succeeded", 0)) or 0)
invalid = int(counters.get("invalid", counters.get("failed", 0)) or 0)
relogin_started = int(counters.get("relogin_started", 0) or 0)
relogin_success = int(counters.get("relogin_success", 0) or 0)
skipped = int(counters.get("skipped", 0) or 0)
detail = counters.get("detail") or None
job.last_run_at = finished
# 下一轮 = 跑完 + **运行间隔**(可配置)。
# ⚠️ 别再用 `interval_seconds` —— 那是「同一行多久重检」,
# 由作业自己的选行逻辑使用,**不参与调度**(两个概念以前挤在一个字段里)。
job.next_run_at = finished + timedelta(
seconds=max(int(job.run_interval_seconds or 0), MIN_RUN_INTERVAL_SECONDS)
)
job.last_status = status
job.last_message = message
job.updated_at = finished
db.commit()
try:
run.finished_at = finished
run.status = status
run.message = message
run.scanned = scanned
run.valid = valid
run.invalid = invalid
run.relogin_started = relogin_started
run.relogin_success = relogin_success
run.skipped = skipped
run.detail = detail
job.last_run_at = finished
# 下一轮 = 跑完 + **运行间隔**(可配置)。
# ⚠️ 别再用 `interval_seconds` —— 那是「同一行多久重检」,
# 由作业自己的选行逻辑使用,**不参与调度**(两个概念以前挤在一个字段里)。
job.next_run_at = finished + timedelta(
seconds=max(int(job.run_interval_seconds or 0), MIN_RUN_INTERVAL_SECONDS)
)
job.last_status = status
job.last_message = message
job.updated_at = finished
db.commit()
except Exception: # noqa: BLE001
# 如果 commit 失败(例如外部把 session 关了或连接抖动),用独立 session 挽救
db.rollback()
logger.exception("自动化作业 {} 提交状态失败,尝试独立连接回写", job.key)
try:
rescue_db = SessionLocal()
try:
r = (
rescue_db.query(AutomationRun)
.filter(AutomationRun.id == run.id)
.first()
)
if r:
r.finished_at = finished
r.status = status
r.message = message
r.scanned = scanned
r.valid = valid
r.invalid = invalid
j = (
rescue_db.query(AutomationJob)
.filter(AutomationJob.key == job.key)
.first()
)
if j:
j.last_run_at = finished
j.next_run_at = finished + timedelta(
seconds=max(
int(j.run_interval_seconds or 0), MIN_RUN_INTERVAL_SECONDS
)
)
j.last_status = status
j.last_message = message
j.updated_at = finished
rescue_db.commit()
finally:
rescue_db.close()
except Exception: # noqa: BLE001
logger.exception("自动化作业 {} 独立连接回写仍失败", job.key)
logger.info(
"自动化作业 {} 完成:status={} scanned={} valid={} invalid={} skipped={}",
job.key,
status,
run.scanned,
run.valid,
run.invalid,
run.skipped,
scanned,
valid,
invalid,
skipped,
)
return {
"job_key": job.key,
"status": status,
"message": message,
"scanned": run.scanned,
"valid": run.valid,
"invalid": run.invalid,
"skipped": run.skipped,
"scanned": scanned,
"valid": valid,
"invalid": invalid,
"skipped": skipped,
}
@@ -353,7 +401,14 @@ def run_job_now(db: Session, key: str) -> dict[str, Any]:
spec = _REGISTRY.get(key)
if job is None or spec is None:
raise KeyError(key)
return _run_one(db, job, spec)
with _IN_FLIGHT_LOCK:
if key in _IN_FLIGHT:
raise RuntimeError(f"作业 {key} 正在执行中,请勿重复触发")
_IN_FLIGHT.add(key)
try:
return _run_one(db, job, spec)
finally:
_release(key)
def _loop() -> None:
+2 -1
View File
@@ -192,4 +192,5 @@ class DouyuBatchRunner(
self._push_log("info", f"斗鱼任务批次 {self.batch_id} 完成")
self._push_log("result", "")
finally:
self.db.close()
if getattr(self, "close_db", True):
self.db.close()
@@ -69,8 +69,10 @@ class DouyuBatchRunnerCore:
log_queue: asyncio.Queue | None = None,
loop: asyncio.AbstractEventLoop | None = None,
concurrency: int = 3,
close_db: bool = True,
):
self.db = db
self.close_db = close_db
self.batch_id = batch_id
self.task_type = task_type
self.payload = payload or {}