diff --git a/docs/自动化与资料收集规划.md b/docs/自动化与资料收集规划.md
index 4b11687..4a3af0e 100644
--- a/docs/自动化与资料收集规划.md
+++ b/docs/自动化与资料收集规划.md
@@ -1,7 +1,7 @@
# 自动化与资料收集规划
更新时间:2026-10-02
-状态:**规划中(未开工)**。产品决策已与负责人确认(见 §4),代码实现待排期。
+状态:**P1a(CK 自动巡检)已落地**,P0/P2/P3/P4/P1b 待排期。产品决策已确认(见 §4),施工单见附录 C。
---
@@ -356,6 +356,15 @@ lookup_order(order_no) -> {
- **P1a(先做)**:把「哪些账号 CK 失效了」自动找出来并展示 —— **只检测、只标记、只提示,不改动任何 CK**;重登仍然人工点。
- **P1b(观察后再开)**:把「失效账号自动重登」也自动化 —— 有退避、有熔断、有开关,**默认关闭**。
+> **P1a 已落地(2026-10-02)**,实际落点:
+> `services/automation/scheduler.py`(调度线程 + 到点判定 + 运行记录)、
+> `services/automation/jobs/ck_health.py`(巡检作业)、
+> `routers/automation.py`(4 个接口)、`permissions.py`(`automation:view` / `automation:manage`)、
+> `migrations/versions/20261002_0037_automation_jobs.py`、
+> 前端 `pages/AutomationPage.tsx`(菜单「自动巡检」)、
+> `tests/test_automation_ck_health.py`(22 条)。
+> 端到端已用 3 个真实账号验证:巡检结果与直接检测逐条一致,且 `cookie` / `status` / `message` / `attempt_status` 全程未变。
+
### 7.2 数据模型(迁移 1 个,P1a/P1b 共用)
**新表 `automation_jobs`**
@@ -1065,7 +1074,7 @@ OSS_PREFIX=collect
---
-## 附录 A · 迁移清单(共 5 个,均为加表/加字段,不动存量数据)
+## 附录 A · 迁移清单(均为加表/加字段,不动存量数据;编号按落地顺序取)
| 阶段 | 迁移内容 |
| --- | --- |
@@ -1126,15 +1135,20 @@ OSS_PREFIX=collect
战略与数据模型在正文已定;这里只写**按什么顺序动手、每步怎么验证**。
-### C.1 迁移编号分配(现状 head = `20261001_0036`)
+### C.1 迁移编号(按落地顺序取,**不预留**)
-| 阶段 | 文件名 | revision |
+现状 head = `20261001_0036`。**谁先落地谁取下一个号**,不要按阶段预分配。
+
+| 阶段 | revision | 说明 |
| --- | --- | --- |
-| P0 | `20261002_0037_external_order_cache.py` | `20261002_0037` |
-| P1 | `20261002_0038_automation_jobs.py` | `20261002_0038` |
-| P2 | `20261002_0039_collect_links_submissions.py` | `20261002_0039` |
-| P3 | `20261002_0040_skin_catalog_exchange_records.py` | `20261002_0040` |
-| P4 | `20261002_0041_exchange_order_tables.py` | `20261002_0041` |
+| P1a(已落地) | `20261002_0037` | `automation_jobs` / `automation_runs` / `login_tasks` +3 / `accounts.auto_managed` |
+| P0 | 落地时取下一个 | `external_order_cache` |
+| P2 / P3 / P4 | 各自落地时依次取号 | 见附录 A 的表清单 |
+
+> **为什么不能预留编号**:Alembic 的 `down_revision` 必须形成单链。若先给 P1 占 0038、
+> 把 0037 留给 P0,等 P0 真落地时它的 `down_revision` 只能指向 0038(否则与 0038 平级,
+> 出现**两个 head**,`tests/test_migrations.py` 的单头断言会直接失败),编号比父提交还小。
+> 所以按落地顺序递增,才是唯一不别扭的做法。
约定:每个迁移的 `down_revision` 指向前一个;**写成幂等的**(照 `20261001_0036` 的写法,先 `sa.inspect` 查存在再改);每加一个迁移,`tests/test_migrations.py` 的 `HEAD_REVISION` 要同步改。
@@ -1143,7 +1157,11 @@ OSS_PREFIX=collect
- 测试文件按功能命名:`tests/test_<域>_<主题>.py`(现有 38 个文件、235 条);
- **纯函数优先**:匹配打分、别名归一、365 天判定、订单号归一、模板必填校验都做成纯函数重点覆盖;
- **写操作不进自动回归**:兑换/重登/支付用 mock 客户端做契约测试(沿用 `tests/test_douyu_write_path_contracts.py`);
-- 提交门禁:pre-commit 跑 `ruff format` + `tsc -b`;pre-push 跑全量 pytest;
+- 提交门禁:pre-commit 跑 `ruff format --check`(仅暂存文件)+ `tsc -b`;pre-push 跑全量 pytest;
+- **DB 读出来的时间是 naive UTC**:`datetime` 列取回后不带时区,直接和 `datetime.now(UTC)` 比较会
+ `TypeError: can't compare offset-naive and offset-aware`(P1a 踩过一次)。比较前一律用
+ `_as_utc()`(`services/yyb_service.py` 与 `services/automation/scheduler.py` 各有一份)补时区。
+ SQL 里用绑定参数比较不受影响,只有 Python 侧比较需要注意 —— P0 判缓存过期时会再遇到;
- **每个阶段独立可发布**,新作业/新功能一律**开关默认关闭**。
### C.3 已核实的环境事实
@@ -1158,11 +1176,11 @@ OSS_PREFIX=collect
| OSS | 桶 `live-hub-assets`(私有+版本控制)、RAM `live-hub-oss`、`.env` 已配 | `oss2`;**不要用 HeadBucket** |
| 迁移 head / 测试基线 | `20261001_0036` / 235 条 | 见 C.1 |
-### C.4 P1a 施工单(下一个要做的,8 步)
+### C.4 P1a 施工单(✅ 已按此完成,8 步)
| # | 动作 | 文件 | 验证 |
| --- | --- | --- | --- |
-| 1 | 迁移 0038:`automation_jobs`、`automation_runs`;`login_tasks` +3 字段;`accounts.auto_managed` | `migrations/versions/20261002_0038_*.py`、`models.py` | `pytest tests/test_migrations.py`;本地建表通过 |
+| 1 | 迁移 0037:`automation_jobs`、`automation_runs`;`login_tasks` +3 字段;`accounts.auto_managed` | `migrations/versions/20261002_0037_automation_jobs.py`、`models.py` | `pytest tests/test_migrations.py`;本地建表通过 |
| 2 | 调度器骨架:作业注册表、60s tick、进程内锁、开关、`automation_runs` 记录、优雅停止 | `services/automation/scheduler.py` | 单测:假时钟驱动 tick,断言「作业 enabled=false 时不执行」「不重叠执行」 |
| 3 | `ck_health` 作业:选行(`cookie_holding_filter` + 超期 + 排除在途/`auto_managed=false`)、并发 5、调 `check_douyu_cookie()`、写回 `ck_check_*` | `services/automation/jobs/ck_health.py` | 单测:选行条件正确;**断言全程不写 `cookie` / `status`**(P1a 的核心约束) |
| 4 | 接口 + 权限 + 注册:4 个 `/api/automation/*`;`automation:view` / `automation:manage`;lifespan 启停 | `routers/automation.py`、`permissions.py`、`main.py` | `tests/test_automation_api.py`:权限校验、开关生效、手动触发 |
@@ -1177,7 +1195,7 @@ OSS_PREFIX=collect
| 阶段 | 顺序 |
| --- | --- |
-| **P0** | ① `pyproject.toml` 加 `psycopg[binary]` → ② 迁移 0037 + `models.py` → ③ `external_order_source.py`(唯一跨库 SQL;URL 归一成 `postgresql+psycopg://`;`statement_timeout`;2 条连接池) → ④ `tests/test_external_order_source.py`(用真实只读账号跑只读查询,覆盖 A/B/C 三桶与不存在订单) → ⑤ 降级开关验证 |
-| **P2** | ① 迁移 0039 → ② `object_storage.py`(`oss2`,Put/Get/Delete/List,不用 HeadBucket) → ③ `collect_service.py`(token/限流/图片校验+Pillow 重编码+缩略图) → ④ 公开路由 `/api/public/collect/*` + 内部 `/api/collect/*` → ⑤ 前端公开页(放 `App.tsx` 的 `/login` 同级)+ 内部两页 → ⑥ 测试(含"宿主机找不到图片副本"的验收) |
-| **P3** | ① 迁移 0040 → ② `skin_catalog` 从 `douyu_goods_snapshot` 同步 + `sku_catalog_sync` 作业(挂 P1 调度器) → ③ `skin_service.py`(别名归一 + `can_exchange` 365 天 + 落账) → ④ `/api/skins/*` → ⑤ `/skins` 页 → ⑥ 把 62 条唯一映射 + 14 条多选映射导入 |
-| **P4** | ① 迁移 0041 → ② `exchange_order_service.py`(`resolve_skin_rule` 按 SKU 四级匹配 + 状态机 + 事件) → ③ 前置校验(CK/365 天/绑定/积分) → ④ 工单接口 + 与 `collect_submissions` 衔接 → ⑤ `/exchange-rules` + `/exchange-orders` 两页 → ⑥ 端到端联调(发链接 → 提交 → 校验 → 执行 → 台账) |
+| **P0** | ① `pyproject.toml` 加 `psycopg[binary]` → ② 迁移(取下一个号)+ `models.py` → ③ `external_order_source.py`(唯一跨库 SQL;URL 归一成 `postgresql+psycopg://`;`statement_timeout`;2 条连接池) → ④ `tests/test_external_order_source.py`(用真实只读账号跑只读查询,覆盖 A/B/C 三桶与不存在订单) → ⑤ 降级开关验证 |
+| **P2** | ① 迁移(取下一个号)→ ② `object_storage.py`(`oss2`,Put/Get/Delete/List,不用 HeadBucket) → ③ `collect_service.py`(token/限流/图片校验+Pillow 重编码+缩略图) → ④ 公开路由 `/api/public/collect/*` + 内部 `/api/collect/*` → ⑤ 前端公开页(放 `App.tsx` 的 `/login` 同级)+ 内部两页 → ⑥ 测试(含"宿主机找不到图片副本"的验收) |
+| **P3** | ① 迁移(取下一个号)→ ② `skin_catalog` 从 `douyu_goods_snapshot` 同步 + `sku_catalog_sync` 作业(挂 P1 调度器) → ③ `skin_service.py`(别名归一 + `can_exchange` 365 天 + 落账) → ④ `/api/skins/*` → ⑤ `/skins` 页 → ⑥ 把 62 条唯一映射 + 14 条多选映射导入 |
+| **P4** | ① 迁移(取下一个号)→ ② `exchange_order_service.py`(`resolve_skin` 两步匹配 + 状态机 + 事件) → ③ 前置校验(CK/365 天/绑定/积分) → ④ 工单接口 + 与 `collect_submissions` 衔接 → ⑤ `/exchange-rules` + `/exchange-orders` 两页 → ⑥ 端到端联调(发链接 → 提交 → 校验 → 执行 → 台账) |
diff --git a/tests/test_automation_ck_health.py b/tests/test_automation_ck_health.py
new file mode 100644
index 0000000..1655b05
--- /dev/null
+++ b/tests/test_automation_ck_health.py
@@ -0,0 +1,443 @@
+"""P1a 回归:CK 自动巡检。
+
+两块约束必须一起守住:
+
+1. **调度器行为**:只跑到点的、打开开关的作业;作业抛异常只记 failed,不能
+ 把调度线程带崩;跑完排下一轮时间;已有配置不能被重启重置。
+2. **本作业绝不改 CK**:P1a 的定位是「只检测、只标记」,所以巡检后
+ ``cookie`` / ``status`` / ``message`` / ``attempt_*`` 必须一字未动 ——
+ 线上全靠这条保证「自动化上线了但人工流程照旧」,回归时一旦有人顺手写了
+ ``status``,这里要立刻红。
+"""
+
+from datetime import UTC, datetime, timedelta
+
+import pytest
+from fastapi import HTTPException
+from sqlalchemy import create_engine
+from sqlalchemy.orm import sessionmaker
+
+from web.backend.database import Base
+from web.backend.deps import require_permission
+from web.backend.models import Account, AutomationJob, AutomationRun, LoginTask, User
+from web.backend.services.automation import (
+ JobSpec,
+ register_default_jobs,
+ register_job,
+ reset_registry,
+ run_due_jobs,
+ run_job_now,
+)
+from web.backend.services.automation.jobs import ck_health
+
+
+class _Base:
+ def setup_method(self):
+ self.engine = create_engine("sqlite://")
+ Base.metadata.create_all(self.engine)
+ self.session = sessionmaker(bind=self.engine)()
+ self.admin = User(
+ username="admin", password_hash="h", role="super_admin", is_active=True
+ )
+ self.support = User(
+ username="kf", password_hash="h", role="support", is_active=True
+ )
+ self.session.add_all([self.admin, self.support])
+ self.session.commit()
+ # 注册表是模块级的,不重置会被上一个用例注册的探针作业污染。
+ reset_registry()
+ register_default_jobs()
+
+ def teardown_method(self):
+ self.session.close()
+
+ def make_account(self, name="acc", *, auto_managed=True, assigned_to=None):
+ account = Account(
+ username=name,
+ password="p",
+ email=f"{name}@example.com",
+ email_password="ep",
+ assigned_to=assigned_to,
+ auto_managed=auto_managed,
+ )
+ self.session.add(account)
+ self.session.commit()
+ return account
+
+ def make_task(
+ self,
+ account,
+ *,
+ cookie="acf_uid=1",
+ status="success",
+ attempt_status="",
+ ck_checked_at=None,
+ ck_check_status="",
+ ):
+ task = LoginTask(
+ batch_id="b1",
+ account_id=account.id,
+ created_by=self.admin.id,
+ status=status,
+ cookie=cookie,
+ attempt_status=attempt_status,
+ ck_checked_at=ck_checked_at,
+ ck_check_status=ck_check_status,
+ )
+ self.session.add(task)
+ self.session.commit()
+ return task
+
+
+class TestCkHealthSelection(_Base):
+ def _selected_ids(self, job):
+ return [
+ t.id for t in ck_health._select_tasks(self.session, job, datetime.now(UTC))
+ ]
+
+ def test_picks_only_rows_that_still_hold_cookie(self):
+ job = self._job()
+ with_cookie = self.make_task(self.make_account("a"))
+ self.make_task(self.make_account("b"), cookie="")
+ assert self._selected_ids(job) == [with_cookie.id]
+
+ def test_skips_accounts_with_inflight_attempt(self):
+ job = self._job()
+ idle = self.make_task(self.make_account("a"))
+ self.make_task(self.make_account("b"), attempt_status="running")
+ self.make_task(self.make_account("c"), attempt_status="pending")
+ assert self._selected_ids(job) == [idle.id]
+
+ def test_skips_accounts_opted_out_of_automation(self):
+ job = self._job()
+ managed = self.make_task(self.make_account("a"))
+ self.make_task(self.make_account("b", auto_managed=False))
+ assert self._selected_ids(job) == [managed.id]
+
+ def test_respects_interval_and_prefers_stalest(self):
+ job = self._job()
+ fresh = self.make_task(
+ self.make_account("fresh"), ck_checked_at=datetime.now(UTC)
+ )
+ stale = self.make_task(
+ self.make_account("stale"),
+ ck_checked_at=datetime.now(UTC) - timedelta(hours=30),
+ )
+ never = self.make_task(self.make_account("never"), ck_checked_at=None)
+ ids = self._selected_ids(job)
+ assert fresh.id not in ids
+ # 从没检测过的排最前,其次是久未检测的
+ assert ids[0] == never.id
+ assert stale.id in ids
+
+ def test_batch_size_caps_the_round(self):
+ job = self._job()
+ job.batch_size = 2
+ for index in range(5):
+ self.make_task(self.make_account(f"a{index}"))
+ assert len(self._selected_ids(job)) == 2
+
+ def _job(self):
+ return AutomationJob(
+ key=ck_health.JOB_KEY, interval_seconds=6 * 3600, batch_size=200
+ )
+
+
+class TestCkHealthNeverTouchesCookie(_Base):
+ def test_patrol_only_writes_check_fields(self):
+ job = self._job()
+ account = self.make_account("acc")
+ task = self.make_task(account, attempt_status="failed", ck_check_status="")
+ before = (task.cookie, task.status, task.message, task.attempt_status)
+
+ ck_health.check_douyu_cookie = _fake_check(valid=True)
+ try:
+ counters = ck_health.run_ck_health(self.session, job)
+ finally:
+ ck_health.check_douyu_cookie = _REAL_CHECK
+
+ self.session.refresh(task)
+ assert counters["scanned"] == 1 and counters["valid"] == 1
+ assert task.ck_check_status == "valid"
+ assert task.ck_checked_at is not None
+ assert task.ck_check_result["nickname"] == "测试"
+ # 核心断言:CK 本体与尝试状态一字未动
+ assert (task.cookie, task.status, task.message, task.attempt_status) == before
+
+ def test_invalid_result_marks_invalid_but_keeps_cookie(self):
+ job = self._job()
+ task = self.make_task(self.make_account("acc"))
+ ck_health.check_douyu_cookie = _fake_check(valid=False)
+ try:
+ counters = ck_health.run_ck_health(self.session, job)
+ finally:
+ ck_health.check_douyu_cookie = _REAL_CHECK
+ self.session.refresh(task)
+ assert counters["invalid"] == 1
+ assert task.ck_check_status == "invalid"
+ assert task.cookie == "acf_uid=1"
+
+ def test_detection_exception_does_not_abort_round(self):
+ job = self._job()
+ self.make_task(self.make_account("a"))
+ self.make_task(self.make_account("b"))
+ ck_health.check_douyu_cookie = _fake_check(raises=True)
+ try:
+ counters = ck_health.run_ck_health(self.session, job)
+ finally:
+ ck_health.check_douyu_cookie = _REAL_CHECK
+ assert counters["scanned"] == 2
+ assert counters["invalid"] == 2
+
+ def _job(self):
+ return AutomationJob(
+ key=ck_health.JOB_KEY, interval_seconds=6 * 3600, batch_size=200
+ )
+
+
+class TestScheduler(_Base):
+ def _register_probe(self, calls):
+ def run(db, job):
+ calls.append(job.key)
+ return {"scanned": 3, "valid": 2, "invalid": 1, "message": "ok"}
+
+ register_job(
+ JobSpec(
+ key="probe",
+ title="探针",
+ interval_seconds=3600,
+ batch_size=1,
+ concurrency=1,
+ run=run,
+ )
+ )
+
+ def test_only_enabled_jobs_run(self):
+ from web.backend.services.automation import ensure_jobs
+
+ calls: list[str] = []
+ self._register_probe(calls)
+ ensure_jobs(self.session)
+ self.session.query(AutomationJob).filter(AutomationJob.key == "probe").update(
+ {"enabled": True}
+ )
+ self.session.commit()
+
+ run_due_jobs(self.session)
+ assert calls == ["probe"]
+
+ calls.clear()
+ run_due_jobs(self.session)
+ # 上一轮已排了 next_run_at,未到点不再跑
+ assert calls == []
+
+ def test_job_failure_is_recorded_not_raised(self):
+ from web.backend.services.automation import ensure_jobs
+
+ def boom(db, job):
+ raise RuntimeError("接口挂了")
+
+ register_job(
+ JobSpec(
+ key="boom",
+ title="会炸的作业",
+ interval_seconds=3600,
+ batch_size=1,
+ concurrency=1,
+ run=boom,
+ )
+ )
+ ensure_jobs(self.session)
+ self.session.query(AutomationJob).filter(AutomationJob.key == "boom").update(
+ {"enabled": True}
+ )
+ self.session.commit()
+
+ outcomes = run_due_jobs(self.session)
+ assert outcomes[0]["status"] == "failed"
+ run = self.session.query(AutomationRun).one()
+ assert run.status == "failed" and "接口挂了" in run.message
+
+ def test_next_run_follows_interval_and_counters_persist(self):
+ from web.backend.services.automation import ensure_jobs
+
+ calls: list[str] = []
+ self._register_probe(calls)
+ ensure_jobs(self.session)
+ self.session.query(AutomationJob).filter(AutomationJob.key == "probe").update(
+ {"enabled": True}
+ )
+ self.session.commit()
+
+ run_due_jobs(self.session)
+ job = (
+ self.session.query(AutomationJob).filter(AutomationJob.key == "probe").one()
+ )
+ assert job.last_status == "success"
+ assert job.next_run_at - job.last_run_at == timedelta(seconds=3600)
+ run = self.session.query(AutomationRun).one()
+ assert (run.scanned, run.valid, run.invalid) == (3, 2, 1)
+
+ def test_ensure_jobs_keeps_operator_settings(self):
+ from web.backend.services.automation import ensure_jobs
+
+ ensure_jobs(self.session)
+ job = (
+ self.session.query(AutomationJob)
+ .filter(AutomationJob.key == ck_health.JOB_KEY)
+ .one()
+ )
+ job.enabled = True
+ job.batch_size = 7
+ self.session.commit()
+
+ ensure_jobs(self.session) # 模拟重启
+ self.session.refresh(job)
+ assert job.enabled is True and job.batch_size == 7
+
+ def test_run_job_now_ignores_switch(self):
+ from web.backend.services.automation import ensure_jobs
+
+ ensure_jobs(self.session)
+ outcome = run_job_now(self.session, ck_health.JOB_KEY)
+ assert outcome["job_key"] == ck_health.JOB_KEY
+ assert self.session.query(AutomationRun).count() == 1
+
+ def test_run_job_now_unknown_key(self):
+ with pytest.raises(KeyError):
+ run_job_now(self.session, "nope")
+
+
+class TestAutomationPermissions(_Base):
+ def test_view_allows_operation_denies_support(self):
+ from web.backend.permissions import ROLE_PERMISSIONS
+
+ assert "automation:view" in ROLE_PERMISSIONS["operation"]
+ assert "automation:view" not in ROLE_PERMISSIONS["support"]
+ assert "automation:manage" in ROLE_PERMISSIONS["super_admin"]
+ assert "automation:manage" not in ROLE_PERMISSIONS["operation"]
+
+ def test_manage_dependency_rejects_operator(self):
+ operator = User(
+ username="op", password_hash="h", role="operation", is_active=True
+ )
+ checker = require_permission("automation:manage")
+ with pytest.raises(HTTPException) as exc:
+ checker(current_user=operator)
+ assert exc.value.status_code == 403
+
+ def test_view_dependency_accepts_operator(self):
+ operator = User(
+ username="op", password_hash="h", role="operation", is_active=True
+ )
+ checker = require_permission("automation:view")
+ assert checker(current_user=operator).id == operator.id
+
+
+_REAL_CHECK = ck_health.check_douyu_cookie
+
+
+def _fake_check(*, valid=True, raises=False):
+ def fake(cookie: str) -> dict:
+ if raises:
+ raise RuntimeError("网络异常")
+ return {
+ "valid": valid,
+ "message": "ok" if valid else "Cookie 已失效",
+ "fish_ball": 1,
+ "nickname": "测试",
+ "level": 30,
+ "checked_at": datetime.now(UTC),
+ }
+
+ return fake
+
+
+class TestAutomationApi(_Base):
+ """接口层:序列化字段、开关落库、手动触发不受开关限制。"""
+
+ def _api(self):
+ from web.backend.routers import automation as api
+
+ return api
+
+ def test_list_jobs_exposes_scheduler_and_job_state(self):
+ api = self._api()
+ payload = api.list_jobs(db=self.session, current=self.admin)
+ assert payload["scheduler"]["tick_seconds"] == 60
+ assert payload["scheduler"]["running"] is False
+ keys = [item["key"] for item in payload["jobs"]]
+ assert keys == [ck_health.JOB_KEY]
+ job = payload["jobs"][0]
+ assert job["enabled"] is False
+ assert job["interval_seconds"] == 6 * 3600
+ assert job["batch_size"] == 200
+ assert job["latest_run"] is None
+
+ def test_update_job_persists_and_schedules_next_run(self):
+ api = self._api()
+ api.update_job(
+ ck_health.JOB_KEY,
+ api.JobUpdate(
+ enabled=True, interval_seconds=3600, batch_size=5, concurrency=2
+ ),
+ db=self.session,
+ current=self.admin,
+ )
+ job = self.session.query(AutomationJob).one()
+ assert job.enabled is True
+ assert job.interval_seconds == 3600
+ assert job.batch_size == 5
+ assert job.next_run_at is not None # 打开开关就排了一轮
+
+ # 审计留痕
+ from web.backend.models import AuditLog
+
+ entry = (
+ self.session.query(AuditLog)
+ .filter(AuditLog.action == "automation:update")
+ .one()
+ )
+ assert entry.target == ck_health.JOB_KEY
+
+ def test_run_endpoint_records_run_and_audit(self):
+ api = self._api()
+ self.make_task(self.make_account("a"))
+ ck_health.check_douyu_cookie = _fake_check(valid=True)
+ try:
+ outcome = api.run_job(
+ ck_health.JOB_KEY, db=self.session, current=self.admin
+ )
+ finally:
+ ck_health.check_douyu_cookie = _REAL_CHECK
+ assert outcome["status"] == "success"
+ assert outcome["scanned"] == 1
+ assert self.session.query(AutomationRun).count() == 1
+
+ from web.backend.models import AuditLog
+
+ assert (
+ self.session.query(AuditLog)
+ .filter(AuditLog.action == "automation:run")
+ .count()
+ == 1
+ )
+
+ def test_run_endpoint_unknown_job_returns_404(self):
+ api = self._api()
+ with pytest.raises(HTTPException) as exc:
+ api.run_job("nope", db=self.session, current=self.admin)
+ assert exc.value.status_code == 404
+
+ def test_runs_endpoint_filters_by_job_key(self):
+ api = self._api()
+ self.session.add(
+ AutomationRun(job_key=ck_health.JOB_KEY, status="success", scanned=1)
+ )
+ self.session.add(AutomationRun(job_key="other", status="failed"))
+ self.session.commit()
+ payload = api.list_runs(
+ job_key=ck_health.JOB_KEY, limit=50, db=self.session, current=self.admin
+ )
+ assert len(payload["items"]) == 1
+ assert payload["items"][0]["job_key"] == ck_health.JOB_KEY
diff --git a/tests/test_migrations.py b/tests/test_migrations.py
index cd993e1..aa76df6 100644
--- a/tests/test_migrations.py
+++ b/tests/test_migrations.py
@@ -22,7 +22,7 @@ TESTS_DIR = Path(__file__).resolve().parent
PROJECT_ROOT = TESTS_DIR.parent
VERSIONS_DIR = PROJECT_ROOT / "web" / "backend" / "migrations" / "versions"
-HEAD_REVISION = "20261001_0036"
+HEAD_REVISION = "20261002_0037"
# 迁移新增、但不声明在模型里的复合查询索引。
EXTRA_INDEXES = (
diff --git a/web/backend/main.py b/web/backend/main.py
index 1add633..7dc8ce6 100644
--- a/web/backend/main.py
+++ b/web/backend/main.py
@@ -20,6 +20,7 @@ from .routers import (
accounts,
audit,
auth,
+ automation,
cookies,
dashboard,
douyu,
@@ -75,7 +76,20 @@ async def lifespan(app: FastAPI):
logger.info(f"启动清理应用宝残留任务: {cleaned_yyb} 条")
finally:
db.close()
- yield
+
+ # 自动化调度器:AUTOMATION_ENABLED 未开启时不会起线程(默认关闭)。
+ from .services.automation import (
+ register_default_jobs,
+ start_scheduler,
+ stop_scheduler,
+ )
+
+ register_default_jobs()
+ start_scheduler()
+ try:
+ yield
+ finally:
+ stop_scheduler()
app = FastAPI(
@@ -135,6 +149,7 @@ app.include_router(huya.router)
app.include_router(douyu.router)
app.include_router(yyb.router)
app.include_router(audit.router)
+app.include_router(automation.router)
@app.get("/api/health")
diff --git a/web/backend/migrations/versions/20261002_0037_automation_jobs.py b/web/backend/migrations/versions/20261002_0037_automation_jobs.py
new file mode 100644
index 0000000..5d35935
--- /dev/null
+++ b/web/backend/migrations/versions/20261002_0037_automation_jobs.py
@@ -0,0 +1,135 @@
+"""自动化作业:作业定义表、运行记录表,以及 CK 巡检所需的退避字段。
+
+P1a 只落地「CK 自动巡检」需要的表结构;重登退避(relogin_*)与账号级开关
+(auto_managed)也一并建好,供 P1b 使用,避免之后再改一次表。
+
+上一版登录任务把 attempt_* 与 CK 本体拆开,这里延续同一原则:巡检只写
+ck_check_*,绝不碰 cookie / status / attempt_*。
+"""
+
+from collections.abc import Sequence
+
+import sqlalchemy as sa
+from alembic import op
+
+revision: str = "20261002_0037"
+down_revision: str | None = "20261001_0036"
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+
+def _columns(bind, table: str) -> set[str]:
+ return {item["name"] for item in sa.inspect(bind).get_columns(table)}
+
+
+def _tables(bind) -> set[str]:
+ return set(sa.inspect(bind).get_table_names())
+
+
+def upgrade() -> None:
+ bind = op.get_bind()
+
+ existing_tables = _tables(bind)
+ if "automation_jobs" not in existing_tables:
+ op.create_table(
+ "automation_jobs",
+ sa.Column("key", sa.String(length=64), primary_key=True),
+ sa.Column(
+ "title", sa.String(length=128), nullable=False, server_default=""
+ ),
+ sa.Column(
+ "enabled", sa.Boolean(), nullable=False, server_default=sa.text("0")
+ ),
+ sa.Column(
+ "interval_seconds",
+ sa.Integer(),
+ nullable=False,
+ server_default="21600",
+ ),
+ sa.Column("batch_size", sa.Integer(), nullable=False, server_default="200"),
+ sa.Column("concurrency", sa.Integer(), nullable=False, server_default="5"),
+ sa.Column("config", sa.JSON(), nullable=True),
+ sa.Column("last_run_at", sa.DateTime(), nullable=True),
+ sa.Column("next_run_at", sa.DateTime(), nullable=True),
+ sa.Column(
+ "last_status", sa.String(length=16), nullable=False, server_default=""
+ ),
+ sa.Column(
+ "last_message", sa.String(length=512), nullable=False, server_default=""
+ ),
+ sa.Column("updated_at", sa.DateTime(), nullable=False),
+ )
+ if "automation_runs" not in existing_tables:
+ op.create_table(
+ "automation_runs",
+ sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
+ sa.Column("job_key", sa.String(length=64), nullable=False),
+ sa.Column("started_at", sa.DateTime(), nullable=False),
+ sa.Column("finished_at", sa.DateTime(), nullable=True),
+ sa.Column(
+ "status", sa.String(length=16), nullable=False, server_default="running"
+ ),
+ sa.Column("scanned", sa.Integer(), nullable=False, server_default="0"),
+ sa.Column("valid", sa.Integer(), nullable=False, server_default="0"),
+ sa.Column("invalid", sa.Integer(), nullable=False, server_default="0"),
+ sa.Column(
+ "relogin_started", sa.Integer(), nullable=False, server_default="0"
+ ),
+ sa.Column(
+ "relogin_success", sa.Integer(), nullable=False, server_default="0"
+ ),
+ sa.Column("skipped", sa.Integer(), nullable=False, server_default="0"),
+ sa.Column(
+ "message", sa.String(length=512), nullable=False, server_default=""
+ ),
+ sa.Column("detail", sa.JSON(), nullable=True),
+ )
+ op.create_index("ix_automation_runs_job_key", "automation_runs", ["job_key"])
+
+ login_columns = _columns(bind, "login_tasks")
+ if "relogin_attempts" not in login_columns:
+ op.add_column(
+ "login_tasks",
+ sa.Column(
+ "relogin_attempts", sa.Integer(), nullable=False, server_default="0"
+ ),
+ )
+ if "relogin_next_at" not in login_columns:
+ op.add_column(
+ "login_tasks", sa.Column("relogin_next_at", sa.DateTime(), nullable=True)
+ )
+ if "relogin_last_error" not in login_columns:
+ op.add_column(
+ "login_tasks",
+ sa.Column(
+ "relogin_last_error",
+ sa.String(length=512),
+ nullable=False,
+ server_default="",
+ ),
+ )
+
+ account_columns = _columns(bind, "accounts")
+ if "auto_managed" not in account_columns:
+ op.add_column(
+ "accounts",
+ sa.Column(
+ "auto_managed",
+ sa.Boolean(),
+ nullable=False,
+ server_default=sa.text("1"),
+ ),
+ )
+
+
+def downgrade() -> None:
+ bind = op.get_bind()
+ for table in ("automation_runs", "automation_jobs"):
+ if table in _tables(bind):
+ op.drop_table(table)
+ login_columns = _columns(bind, "login_tasks")
+ for name in ("relogin_last_error", "relogin_next_at", "relogin_attempts"):
+ if name in login_columns:
+ op.drop_column("login_tasks", name)
+ if "auto_managed" in _columns(bind, "accounts"):
+ op.drop_column("accounts", "auto_managed")
diff --git a/web/backend/models.py b/web/backend/models.py
index 566c84d..692776f 100644
--- a/web/backend/models.py
+++ b/web/backend/models.py
@@ -111,6 +111,10 @@ class Account(Base):
esports_can_change_time: Mapped[int | None] = mapped_column(Integer, nullable=True)
xpd_game_name: Mapped[str] = mapped_column(String(128), default="")
xpd_openid: Mapped[str] = mapped_column(String(128), default="")
+ # 自动化后门:false 时巡检与自动重登都跳过这个账号(特殊账号人工维护)。
+ auto_managed: Mapped[bool] = mapped_column(
+ Boolean, default=True, server_default="1"
+ )
xpd_role_id: Mapped[str] = mapped_column(String(64), default="")
xpd_plat_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
xpd_area_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
@@ -171,6 +175,14 @@ class LoginTask(Base):
String(64), default="", server_default=""
)
attempt_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
+ # 自动重登(P1b)用的退避状态。P1a 的巡检不碰这几个字段。
+ relogin_attempts: Mapped[int] = mapped_column(
+ Integer, default=0, server_default="0"
+ ) # 连续重登尝试次数,成功即清零
+ relogin_next_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
+ relogin_last_error: Mapped[str] = mapped_column(
+ String(512), default="", server_default=""
+ )
account: Mapped[Account] = relationship("Account", back_populates="login_tasks")
@@ -673,3 +685,51 @@ class AuditLog(Base):
# 旧记录未标记结果时为 NULL,避免把历史操作误判成失败。
success: Mapped[bool | None] = mapped_column(Boolean, nullable=True, index=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=_utcnow)
+
+
+class AutomationJob(Base):
+ """自动化作业定义。状态落库,进程重启不丢调度进度。"""
+
+ __tablename__ = "automation_jobs"
+
+ key: Mapped[str] = mapped_column(String(64), primary_key=True)
+ title: Mapped[str] = mapped_column(String(128), default="")
+ # 默认关闭:按「渐进上线」原则,先观察再打开。
+ enabled: Mapped[bool] = mapped_column(Boolean, default=False, server_default="0")
+ interval_seconds: Mapped[int] = mapped_column(
+ Integer, default=21600, server_default="21600"
+ )
+ batch_size: Mapped[int] = mapped_column(Integer, default=200, server_default="200")
+ concurrency: Mapped[int] = mapped_column(Integer, default=5, server_default="5")
+ config: Mapped[dict | None] = mapped_column(JSON, nullable=True)
+ last_run_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
+ next_run_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
+ last_status: Mapped[str] = mapped_column(String(16), default="", server_default="")
+ last_message: Mapped[str] = mapped_column(
+ String(512), default="", server_default=""
+ )
+ updated_at: Mapped[datetime] = mapped_column(
+ DateTime, default=_utcnow, onupdate=_utcnow
+ )
+
+
+class AutomationRun(Base):
+ """自动化作业每一轮的执行记录,用于页面展示与排障。"""
+
+ __tablename__ = "automation_runs"
+
+ id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
+ job_key: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
+ started_at: Mapped[datetime] = mapped_column(DateTime, default=_utcnow)
+ finished_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
+ status: Mapped[str] = mapped_column(
+ String(16), default="running", server_default="running"
+ ) # running / success / failed / skipped
+ scanned: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
+ valid: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
+ invalid: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
+ relogin_started: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
+ relogin_success: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
+ skipped: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
+ message: Mapped[str] = mapped_column(String(512), default="", server_default="")
+ detail: Mapped[dict | None] = mapped_column(JSON, nullable=True)
diff --git a/web/backend/permissions.py b/web/backend/permissions.py
index 6882c18..6f5f754 100644
--- a/web/backend/permissions.py
+++ b/web/backend/permissions.py
@@ -51,6 +51,9 @@ PERMISSIONS = {
"proxy:manage": "代理配置管理",
"whitelist:manage": "白名单配置管理",
"whitelist:test": "测试白名单",
+ # 自动化
+ "automation:view": "查看自动化作业与运行历史",
+ "automation:manage": "修改自动化配置、手动触发作业",
# 系统
"system:settings": "系统设置",
"audit:view": "查看审计日志",
@@ -90,6 +93,7 @@ ROLE_PERMISSIONS = {
"proxy:manage",
"whitelist:manage",
"whitelist:test",
+ "automation:view",
],
"support": [
"account:view_assigned",
diff --git a/web/backend/routers/automation.py b/web/backend/routers/automation.py
new file mode 100644
index 0000000..5d434e8
--- /dev/null
+++ b/web/backend/routers/automation.py
@@ -0,0 +1,191 @@
+"""自动化作业接口。
+
+设计上「先能看、再能开」:查看需要 ``automation:view``(运营也有),改配置与
+手动触发需要 ``automation:manage``(仅超管)。作业默认关闭,打开后由进程内
+调度器按间隔执行。
+"""
+
+from __future__ import annotations
+
+from datetime import UTC, datetime
+from typing import Any
+
+from fastapi import APIRouter, Depends, HTTPException, Query
+from pydantic import BaseModel, Field
+from sqlalchemy.orm import Session
+
+from ..database import get_db
+from ..deps import require_permission
+from ..models import AutomationJob, AutomationRun, User
+from ..services.audit_service import record_audit
+from ..services.automation import (
+ ensure_jobs,
+ registered_jobs,
+ run_job_now,
+ scheduler_status,
+)
+
+router = APIRouter(prefix="/api/automation", tags=["自动化"])
+
+MAX_BATCH_SIZE = 2000
+MIN_INTERVAL_SECONDS = 300
+
+
+class JobUpdate(BaseModel):
+ """作业配置更新;只传要改的字段。"""
+
+ enabled: bool | None = None
+ interval_seconds: int | None = Field(default=None, ge=MIN_INTERVAL_SECONDS)
+ batch_size: int | None = Field(default=None, ge=1, le=MAX_BATCH_SIZE)
+ concurrency: int | None = Field(default=None, ge=1, le=16)
+
+
+def _job_out(job: AutomationJob) -> dict[str, Any]:
+ return {
+ "key": job.key,
+ "title": job.title,
+ "enabled": bool(job.enabled),
+ "interval_seconds": int(job.interval_seconds or 0),
+ "batch_size": int(job.batch_size or 0),
+ "concurrency": int(job.concurrency or 0),
+ "last_run_at": job.last_run_at.isoformat() if job.last_run_at else None,
+ "next_run_at": job.next_run_at.isoformat() if job.next_run_at else None,
+ "last_status": job.last_status or "",
+ "last_message": job.last_message or "",
+ "registered": job.key in registered_jobs(),
+ }
+
+
+def _run_out(run: AutomationRun) -> dict[str, Any]:
+ return {
+ "id": run.id,
+ "job_key": run.job_key,
+ "started_at": run.started_at.isoformat() if run.started_at else None,
+ "finished_at": run.finished_at.isoformat() if run.finished_at else None,
+ "status": run.status or "",
+ "scanned": int(run.scanned or 0),
+ "valid": int(run.valid or 0),
+ "invalid": int(run.invalid or 0),
+ "skipped": int(run.skipped or 0),
+ "message": run.message or "",
+ "detail": run.detail or None,
+ }
+
+
+@router.get("/jobs")
+def list_jobs(
+ db: Session = Depends(get_db),
+ current: User = Depends(require_permission("automation:view")),
+):
+ """列出作业配置、最近一次结果与调度器状态。"""
+ ensure_jobs(db)
+ jobs = db.query(AutomationJob).order_by(AutomationJob.key.asc()).all()
+ latest: dict[str, AutomationRun] = {}
+ for run in (
+ db.query(AutomationRun).order_by(AutomationRun.id.desc()).limit(200).all()
+ ):
+ latest.setdefault(run.job_key, run)
+ return {
+ "scheduler": scheduler_status(),
+ "jobs": [
+ {
+ **_job_out(job),
+ "latest_run": _run_out(latest[job.key]) if job.key in latest else None,
+ }
+ for job in jobs
+ ],
+ }
+
+
+@router.patch("/jobs/{key}")
+def update_job(
+ key: str,
+ payload: JobUpdate,
+ db: Session = Depends(get_db),
+ current: User = Depends(require_permission("automation:manage")),
+):
+ """改开关 / 间隔 / 批量 / 并发。打开开关时排定下一轮时间。"""
+ ensure_jobs(db)
+ job = db.query(AutomationJob).filter(AutomationJob.key == key).first()
+ if job is None:
+ raise HTTPException(status_code=404, detail="作业不存在")
+
+ changed: dict[str, Any] = {}
+ if payload.enabled is not None and bool(payload.enabled) != bool(job.enabled):
+ job.enabled = bool(payload.enabled)
+ changed["enabled"] = job.enabled
+ # 刚打开时排一轮:不早于一个 tick,避免打开瞬间就压满 CPU。
+ job.next_run_at = datetime.now(UTC) + _tick_delta() if job.enabled else None
+ if payload.interval_seconds is not None:
+ changed["interval_seconds"] = payload.interval_seconds
+ job.interval_seconds = payload.interval_seconds
+ if payload.batch_size is not None:
+ changed["batch_size"] = payload.batch_size
+ job.batch_size = payload.batch_size
+ if payload.concurrency is not None:
+ changed["concurrency"] = payload.concurrency
+ job.concurrency = payload.concurrency
+
+ if changed:
+ job.updated_at = datetime.now(UTC)
+ record_audit(
+ db,
+ current,
+ action="automation:update",
+ target=key,
+ detail=changed,
+ )
+ db.commit()
+ return _job_out(job)
+
+
+@router.post("/jobs/{key}/run")
+def run_job(
+ key: str,
+ db: Session = Depends(get_db),
+ current: User = Depends(require_permission("automation:manage")),
+):
+ """立刻跑一轮(不看 enabled)。用于正式打开开关前先试跑。"""
+ ensure_jobs(db)
+ try:
+ outcome = run_job_now(db, key)
+ except KeyError:
+ raise HTTPException(status_code=404, detail="作业不存在") from None
+ record_audit(
+ db,
+ current,
+ action="automation:run",
+ target=key,
+ detail={
+ "status": outcome.get("status"),
+ "scanned": outcome.get("scanned"),
+ "valid": outcome.get("valid"),
+ "invalid": outcome.get("invalid"),
+ "message": outcome.get("message"),
+ },
+ )
+ db.commit()
+ return outcome
+
+
+@router.get("/runs")
+def list_runs(
+ job_key: str | None = Query(default=None),
+ limit: int = Query(default=50, ge=1, le=200),
+ db: Session = Depends(get_db),
+ current: User = Depends(require_permission("automation:view")),
+):
+ """运行历史,最近的在前。"""
+ query = db.query(AutomationRun)
+ if job_key:
+ query = query.filter(AutomationRun.job_key == job_key)
+ runs = query.order_by(AutomationRun.id.desc()).limit(limit).all()
+ return {"items": [_run_out(run) for run in runs]}
+
+
+def _tick_delta():
+ from datetime import timedelta
+
+ from ..services.automation.scheduler import TICK_SECONDS
+
+ return timedelta(seconds=TICK_SECONDS)
diff --git a/web/backend/routers/login.py b/web/backend/routers/login.py
index c19b4df..36cf3a4 100644
--- a/web/backend/routers/login.py
+++ b/web/backend/routers/login.py
@@ -13,6 +13,7 @@ from ..models import Account, LoginTask, User
from ..models import ProxyConfig as ProxyConfigModel
from ..permissions import get_user_permissions, user_has_permission
from ..schemas import LoginBatchRequest, LoginTaskOut
+from ..services.audit_service import record_audit
from ..services.login_service import LoginBatchRunner, batch_registry
router = APIRouter(prefix="/api/login", tags=["登录任务"])
@@ -111,6 +112,26 @@ async def create_batch(
thread = threading.Thread(target=runner.run, daemon=True)
thread.start()
+ # 审计:批次是谁在什么时候发起、多少个账号、什么模式。
+ # 自动重登与人工登录共用同一条链路,没有这条记录就无法追溯来源。
+ try:
+ record_audit(
+ db,
+ current,
+ action="login:batch",
+ target=batch_id,
+ detail={
+ "mode": req.mode,
+ "requested": len(req.account_ids),
+ "accepted": len(valid_ids),
+ "concurrency": req.concurrency,
+ "api_strategy": req.api_strategy,
+ },
+ )
+ db.commit()
+ except Exception: # noqa: BLE001 审计失败不应影响已启动的批次
+ db.rollback()
+
return {"batch_id": batch_id, "count": len(valid_ids), "success": True}
diff --git a/web/backend/services/automation/__init__.py b/web/backend/services/automation/__init__.py
new file mode 100644
index 0000000..342708e
--- /dev/null
+++ b/web/backend/services/automation/__init__.py
@@ -0,0 +1,55 @@
+"""自动化调度:作业注册、调度线程与各作业实现。
+
+登记作业请放在 ``register_default_jobs()``,由 ``main.py`` 在启动时调用一次,
+避免 import 顺序影响注册结果。
+"""
+
+from __future__ import annotations
+
+from .jobs.ck_health import JOB_KEY as CK_HEALTH_KEY
+from .jobs.ck_health import run_ck_health
+from .scheduler import (
+ JobSpec,
+ automation_enabled,
+ ensure_jobs,
+ register_job,
+ registered_jobs,
+ reset_registry,
+ run_due_jobs,
+ run_job_now,
+ scheduler_status,
+ start_scheduler,
+ stop_scheduler,
+)
+
+__all__ = [
+ "CK_HEALTH_KEY",
+ "JobSpec",
+ "automation_enabled",
+ "ensure_jobs",
+ "register_default_jobs",
+ "register_job",
+ "registered_jobs",
+ "reset_registry",
+ "run_ck_health",
+ "run_due_jobs",
+ "run_job_now",
+ "scheduler_status",
+ "start_scheduler",
+ "stop_scheduler",
+]
+
+
+def register_default_jobs() -> None:
+ """登记内置作业(幂等)。"""
+ register_job(
+ JobSpec(
+ key=CK_HEALTH_KEY,
+ title="CK 自动巡检",
+ # 6 小时一轮;全库 12,881 条按此反推 batch_size=200(见规划附录 C)。
+ interval_seconds=6 * 3600,
+ batch_size=200,
+ concurrency=5,
+ run=run_ck_health,
+ )
+ )
diff --git a/web/backend/services/automation/jobs/__init__.py b/web/backend/services/automation/jobs/__init__.py
new file mode 100644
index 0000000..cbda5a4
--- /dev/null
+++ b/web/backend/services/automation/jobs/__init__.py
@@ -0,0 +1 @@
+"""自动化作业实现。"""
diff --git a/web/backend/services/automation/jobs/ck_health.py b/web/backend/services/automation/jobs/ck_health.py
new file mode 100644
index 0000000..93fe1d5
--- /dev/null
+++ b/web/backend/services/automation/jobs/ck_health.py
@@ -0,0 +1,125 @@
+"""CK 巡检作业(P1a)。
+
+只做一件事:把「持有 CK 但久未检测」的账号挑出来,调现成的
+``check_douyu_cookie()`` 检测,把结果写回 ``ck_check_*``。
+
+**本作业不修改 CK 本身**:不写 ``cookie`` / ``status`` / ``message``,也不碰
+``attempt_*``。P1a 上线后人工照旧可以点检测和重登,自动重登是 P1b 的事。
+"""
+
+from __future__ import annotations
+
+from concurrent.futures import ThreadPoolExecutor, as_completed
+from datetime import UTC, datetime, timedelta
+from typing import Any
+
+from sqlalchemy import or_
+from sqlalchemy.orm import Session
+
+from ....models import Account, AutomationJob, LoginTask
+from ...cookie_check_service import check_douyu_cookie
+from ...douyu_service import cookie_operable_filter
+
+JOB_KEY = "ck_health"
+FLUSH_EVERY = 25
+
+
+def _select_tasks(db: Session, job: AutomationJob, now: datetime) -> list[LoginTask]:
+ """挑本轮要检测的记录。
+
+ 口径与人工点检测一致(``cookie_operable_filter``:持有 CK 且没有在途尝试),
+ 再加三条:距上次检测超过作业间隔、账号允许自动管理、最久没查的优先。
+ """
+ cutoff = now - timedelta(seconds=max(int(job.interval_seconds or 0), 60))
+ return (
+ db.query(LoginTask)
+ .join(Account, LoginTask.account_id == Account.id)
+ .filter(cookie_operable_filter())
+ .filter(Account.auto_managed.is_(True))
+ .filter(
+ or_(
+ LoginTask.ck_checked_at.is_(None),
+ LoginTask.ck_checked_at < cutoff,
+ )
+ )
+ .order_by(LoginTask.ck_checked_at.asc().nullsfirst(), LoginTask.id.asc())
+ .limit(max(int(job.batch_size or 0), 1))
+ .all()
+ )
+
+
+def _detect(task: LoginTask) -> dict[str, Any]:
+ try:
+ return check_douyu_cookie(task.cookie or "")
+ except Exception as exc: # noqa: BLE001 单个账号失败不影响整轮
+ return {
+ "valid": False,
+ "message": f"检测异常: {exc}",
+ "fish_ball": None,
+ "nickname": None,
+ "level": None,
+ "checked_at": datetime.now(UTC),
+ }
+
+
+def run_ck_health(db: Session, job: AutomationJob) -> dict[str, Any]:
+ now = datetime.now(UTC)
+ tasks = _select_tasks(db, job, now)
+ if not tasks:
+ return {"scanned": 0, "valid": 0, "invalid": 0, "skipped": 0}
+
+ results: dict[int, dict[str, Any]] = {}
+ concurrency = max(min(int(job.concurrency or 1), 16), 1)
+ with ThreadPoolExecutor(max_workers=concurrency) as pool:
+ futures = {pool.submit(_detect, task): task for task in tasks}
+ for future in as_completed(futures):
+ task = futures[future]
+ results[task.id] = future.result()
+
+ # 只写检测结果三件套;cookie / status / attempt_* 一律不动。
+ valid = invalid = 0
+ for index, task in enumerate(tasks, start=1):
+ result = results.get(task.id)
+ if result is None:
+ continue
+ is_valid = bool(result.get("valid"))
+ if is_valid:
+ valid += 1
+ else:
+ invalid += 1
+ task.ck_check_status = "valid" if is_valid else "invalid"
+ task.ck_check_result = {
+ "fish_ball": result.get("fish_ball"),
+ "nickname": result.get("nickname"),
+ "level": result.get("level"),
+ "message": result.get("message", ""),
+ }
+ task.ck_checked_at = now
+ if index % FLUSH_EVERY == 0:
+ db.commit()
+ db.commit()
+
+ # 剩余待检数量只用于提示,不参与调度。
+ remaining = (
+ db.query(LoginTask)
+ .join(Account, LoginTask.account_id == Account.id)
+ .filter(cookie_operable_filter())
+ .filter(Account.auto_managed.is_(True))
+ .filter(
+ or_(
+ LoginTask.ck_checked_at.is_(None),
+ LoginTask.ck_checked_at
+ < now - timedelta(seconds=max(int(job.interval_seconds or 0), 60)),
+ )
+ )
+ .count()
+ )
+ message = f"本轮检测 {len(tasks)} 条,剩余待检 {remaining} 条"
+ return {
+ "scanned": len(tasks),
+ "valid": valid,
+ "invalid": invalid,
+ "skipped": 0,
+ "message": message,
+ "detail": {"remaining": remaining, "concurrency": concurrency},
+ }
diff --git a/web/backend/services/automation/scheduler.py b/web/backend/services/automation/scheduler.py
new file mode 100644
index 0000000..7271eeb
--- /dev/null
+++ b/web/backend/services/automation/scheduler.py
@@ -0,0 +1,267 @@
+"""进程内自动化调度器。
+
+背景:后台的日常动作原本全靠人工点(判断 CK 失效、重登、收集资料、记台账)。
+自动化按「渐进上线」推进,因此:
+
+- 调度器总开关 ``AUTOMATION_ENABLED`` **默认关闭**;
+- 每个作业自己还有开关,**默认关闭**,在 ``/automation`` 页面打开;
+- 作业状态全部落库(``automation_jobs`` / ``automation_runs``),进程重启不丢
+ 调度进度,也不会因为重启而重复跑一轮。
+
+部署是单容器 ``--workers 1``,所以一个进程内线程就够,不需要分布式锁。
+"""
+
+from __future__ import annotations
+
+import os
+import threading
+from collections.abc import Callable
+from dataclasses import dataclass
+from datetime import UTC, datetime, timedelta
+from typing import Any
+
+from loguru import logger
+from sqlalchemy.orm import Session
+
+from ...database import SessionLocal
+from ...models import AutomationJob, AutomationRun
+
+# 调度轮询间隔:作业间隔都以它为单位对齐(默认 6h 的作业不会差这一分钟)。
+TICK_SECONDS = 60
+
+TRUTHY = {"1", "true", "yes", "on"}
+
+
+@dataclass(frozen=True)
+class JobSpec:
+ """一个可调度的作业。``run`` 接收会话与作业行,返回本轮计数。"""
+
+ key: str
+ title: str
+ interval_seconds: int
+ batch_size: int
+ concurrency: int
+ run: Callable[[Session, AutomationJob], dict[str, Any]]
+
+
+_REGISTRY: dict[str, JobSpec] = {}
+_thread: threading.Thread | None = None
+_stop_event = threading.Event()
+_lock = threading.Lock()
+
+
+def register_job(spec: JobSpec) -> None:
+ _REGISTRY[spec.key] = spec
+
+
+def registered_jobs() -> dict[str, JobSpec]:
+ return dict(_REGISTRY)
+
+
+def reset_registry() -> None:
+ """清空作业注册表(测试隔离用;正常启动只调用 register_default_jobs)。"""
+ _REGISTRY.clear()
+
+
+def automation_enabled() -> bool:
+ return os.getenv("AUTOMATION_ENABLED", "").strip().lower() in TRUTHY
+
+
+def ensure_jobs(db: Session) -> None:
+ """把注册表里的作业补进 ``automation_jobs``(幂等)。
+
+ 只补缺失的行,**不覆盖已有配置** —— 运维在页面上改过的开关、间隔、批量
+ 不能因为重启被重置回默认值。
+ """
+ existing = {row.key for row in db.query(AutomationJob.key).all()}
+ for spec in _REGISTRY.values():
+ if spec.key in existing:
+ continue
+ db.add(
+ AutomationJob(
+ key=spec.key,
+ title=spec.title,
+ enabled=False,
+ interval_seconds=spec.interval_seconds,
+ batch_size=spec.batch_size,
+ concurrency=spec.concurrency,
+ config=None,
+ next_run_at=None,
+ last_status="",
+ last_message="",
+ updated_at=datetime.now(UTC),
+ )
+ )
+ db.commit()
+
+
+def _as_utc(value: datetime | None) -> datetime | None:
+ """DB 时间列存的是 naive UTC,读出来要补时区标记才能跟 now 比较。"""
+ if value is None:
+ return None
+ if value.tzinfo is None:
+ return value.replace(tzinfo=UTC)
+ return value.astimezone(UTC)
+
+
+def _due(job: AutomationJob, now: datetime) -> bool:
+ """到点判定:没排过时间视为到点(刚打开开关就会跑一轮)。"""
+ next_run_at = _as_utc(job.next_run_at)
+ return next_run_at is None or next_run_at <= now
+
+
+def run_due_jobs(db: Session, *, now: datetime | None = None) -> list[dict[str, Any]]:
+ """跑一轮「到点的作业」,返回每轮结果。
+
+ 抽成普通函数是为了可测:测试直接调用即可,不必启动线程或等 60 秒。
+ 作业串行执行 —— 目前只有 CK 巡检,单轮约 20~40 秒,远小于 tick,串行
+ 反而省掉了并发调度的复杂度。
+ """
+ now = now or datetime.now(UTC)
+ outcomes: list[dict[str, Any]] = []
+ jobs = (
+ db.query(AutomationJob)
+ .filter(AutomationJob.enabled.is_(True))
+ .order_by(AutomationJob.key.asc())
+ .all()
+ )
+ for job in jobs:
+ spec = _REGISTRY.get(job.key)
+ if spec is None or not _due(job, now):
+ continue
+ outcomes.append(_run_one(db, job, spec))
+
+ # 到期但没注册实现的作业(例如降级回滚后残留的行)只留日志,不静默消失。
+ for job in jobs:
+ if job.key not in _REGISTRY:
+ logger.warning("自动化作业 {} 没有注册实现,已跳过", job.key)
+ return outcomes
+
+
+def _run_one(db: Session, job: AutomationJob, spec: JobSpec) -> dict[str, Any]:
+ started = datetime.now(UTC)
+ run = AutomationRun(job_key=job.key, started_at=started, status="running")
+ db.add(run)
+ db.commit()
+
+ counters: dict[str, Any] = {}
+ status = "success"
+ message = ""
+ try:
+ counters = spec.run(db, job) or {}
+ message = str(counters.pop("message", "") or "")
+ except Exception as exc: # noqa: BLE001 作业边界要兜住任何异常,不能拖垮调度线程
+ db.rollback()
+ status = "failed"
+ message = f"{type(exc).__name__}: {exc}"[:500]
+ 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
+
+ job.last_run_at = finished
+ job.next_run_at = finished + timedelta(
+ seconds=max(int(job.interval_seconds or spec.interval_seconds), 60)
+ )
+ job.last_status = status
+ job.last_message = message
+ job.updated_at = finished
+ db.commit()
+
+ logger.info(
+ "自动化作业 {} 完成:status={} scanned={} valid={} invalid={} skipped={}",
+ job.key,
+ status,
+ run.scanned,
+ run.valid,
+ run.invalid,
+ run.skipped,
+ )
+ return {
+ "job_key": job.key,
+ "status": status,
+ "message": message,
+ "scanned": run.scanned,
+ "valid": run.valid,
+ "invalid": run.invalid,
+ "skipped": run.skipped,
+ }
+
+
+def run_job_now(db: Session, key: str) -> dict[str, Any]:
+ """立刻跑一个作业,**不看 enabled 开关**。
+
+ 页面上的「立即跑一次」用这个:运维需要能在正式打开开关之前先试一轮。
+ """
+ job = db.query(AutomationJob).filter(AutomationJob.key == key).first()
+ spec = _REGISTRY.get(key)
+ if job is None or spec is None:
+ raise KeyError(key)
+ return _run_one(db, job, spec)
+
+
+def _loop() -> None:
+ db = SessionLocal()
+ try:
+ ensure_jobs(db)
+ except Exception: # noqa: BLE001 初始化失败不应让线程静默退出
+ logger.exception("自动化作业初始化失败")
+ finally:
+ db.close()
+
+ logger.info("自动化调度器已启动,tick={}s", TICK_SECONDS)
+ while not _stop_event.wait(TICK_SECONDS):
+ db = SessionLocal()
+ try:
+ run_due_jobs(db)
+ except Exception: # noqa: BLE001 单轮异常不能让调度线程死掉
+ db.rollback()
+ logger.exception("自动化调度轮询异常")
+ finally:
+ db.close()
+ logger.info("自动化调度器已停止")
+
+
+def start_scheduler() -> bool:
+ """启动调度线程。返回是否真的启动了(未开启总开关时返回 False)。"""
+ global _thread
+ if not automation_enabled():
+ logger.info("AUTOMATION_ENABLED 未开启,自动化调度器不启动")
+ return False
+ with _lock:
+ if _thread is not None and _thread.is_alive():
+ return False
+ _stop_event.clear()
+ _thread = threading.Thread(
+ target=_loop, name="automation-scheduler", daemon=True
+ )
+ _thread.start()
+ return True
+
+
+def stop_scheduler() -> None:
+ global _thread
+ with _lock:
+ if _thread is None:
+ return
+ _stop_event.set()
+ _thread.join(timeout=5)
+ _thread = None
+
+
+def scheduler_status() -> dict[str, Any]:
+ return {
+ "enabled": automation_enabled(),
+ "running": _thread is not None and _thread.is_alive(),
+ "tick_seconds": TICK_SECONDS,
+ "registered": sorted(_REGISTRY),
+ }
diff --git a/web/frontend/src/App.tsx b/web/frontend/src/App.tsx
index 5a0166e..1206daa 100644
--- a/web/frontend/src/App.tsx
+++ b/web/frontend/src/App.tsx
@@ -15,6 +15,7 @@ const AccountCheckPage = lazy(() => import('./pages/AccountCheckPage'));
const AssignmentsPage = lazy(() => import('./pages/AssignmentsPage'));
const LoginTasksPage = lazy(() => import('./pages/LoginTasksPage'));
const ProxyPage = lazy(() => import('./pages/ProxyPage'));
+const AutomationPage = lazy(() => import('./pages/AutomationPage'));
const UsersPage = lazy(() => import('./pages/UsersPage'));
const CookiePage = lazy(() => import('./pages/CookiePage'));
const CookieOperationsPage = lazy(() => import('./pages/CookieOperationsPage'));
@@ -100,6 +101,7 @@ function AppContent() {
)} />
)} />
)} />
+ )} />
)} />
) : } />
diff --git a/web/frontend/src/api/automation.ts b/web/frontend/src/api/automation.ts
new file mode 100644
index 0000000..0ceb720
--- /dev/null
+++ b/web/frontend/src/api/automation.ts
@@ -0,0 +1,18 @@
+import api from './client';
+import type {
+ AutomationJobItem,
+ AutomationJobUpdate,
+ AutomationJobsResponse,
+ AutomationRunOutcome,
+ AutomationRunsResponse,
+} from './types';
+
+export const automationApi = {
+ jobs: () => api.get('/automation/jobs'),
+ updateJob: (key: string, payload: AutomationJobUpdate) =>
+ api.patch(`/automation/jobs/${key}`, payload),
+ runJob: (key: string) =>
+ api.post(`/automation/jobs/${key}/run`),
+ runs: (params: { job_key?: string; limit?: number }) =>
+ api.get('/automation/runs', { params }),
+};
diff --git a/web/frontend/src/api/modules.ts b/web/frontend/src/api/modules.ts
index 51bf8b5..4fce821 100644
--- a/web/frontend/src/api/modules.ts
+++ b/web/frontend/src/api/modules.ts
@@ -4,6 +4,7 @@ export { accountApi } from './accounts';
export { appApi } from './app';
export { auditApi } from './audit';
export { authApi } from './auth';
+export { automationApi } from './automation';
export { cookieApi } from './cookies';
export { dashboardApi } from './dashboard';
export { douyuApi } from './douyu';
diff --git a/web/frontend/src/api/types.ts b/web/frontend/src/api/types.ts
index f2ee7c7..b6a046c 100644
--- a/web/frontend/src/api/types.ts
+++ b/web/frontend/src/api/types.ts
@@ -827,3 +827,67 @@ export interface PermissionsListResult {
permissions: Record;
role_permissions: Record;
}
+
+// ==================== 自动化作业 ====================
+
+export interface AutomationRunItem {
+ id: number;
+ job_key: string;
+ started_at: string | null;
+ finished_at: string | null;
+ status: string;
+ scanned: number;
+ valid: number;
+ invalid: number;
+ skipped: number;
+ message: string;
+ detail: Record | null;
+}
+
+export interface AutomationJobItem {
+ key: string;
+ title: string;
+ enabled: boolean;
+ interval_seconds: number;
+ batch_size: number;
+ concurrency: number;
+ last_run_at: string | null;
+ next_run_at: string | null;
+ last_status: string;
+ last_message: string;
+ registered: boolean;
+ latest_run: AutomationRunItem | null;
+}
+
+export interface AutomationSchedulerStatus {
+ enabled: boolean;
+ running: boolean;
+ tick_seconds: number;
+ registered: string[];
+}
+
+export interface AutomationJobsResponse {
+ scheduler: AutomationSchedulerStatus;
+ jobs: AutomationJobItem[];
+}
+
+export interface AutomationRunsResponse {
+ items: AutomationRunItem[];
+}
+
+export interface AutomationJobUpdate {
+ enabled?: boolean;
+ interval_seconds?: number;
+ batch_size?: number;
+ concurrency?: number;
+}
+
+export interface AutomationRunOutcome {
+ job_key: string;
+ status: string;
+ message: string;
+ scanned: number;
+ valid: number;
+ invalid: number;
+ skipped: number;
+}
diff --git a/web/frontend/src/layouts/MainLayout.tsx b/web/frontend/src/layouts/MainLayout.tsx
index e410f2d..ff656c6 100644
--- a/web/frontend/src/layouts/MainLayout.tsx
+++ b/web/frontend/src/layouts/MainLayout.tsx
@@ -7,7 +7,7 @@ import {
MenuFoldOutlined, MenuUnfoldOutlined, SwapOutlined,
SunOutlined, MoonOutlined, DesktopOutlined, GiftOutlined, ApartmentOutlined, MobileOutlined, ShoppingCartOutlined,
BookOutlined, TrophyOutlined, ShopOutlined, GlobalOutlined,
- SafetyCertificateOutlined, FileSearchOutlined,
+ SafetyCertificateOutlined, FileSearchOutlined, RobotOutlined,
} from '@ant-design/icons';
import { useNavigate, useLocation, Outlet } from 'react-router-dom';
import { getUser, clearAuth, type AuthUser } from '../store/auth';
@@ -75,6 +75,7 @@ export default function MainLayout({ onLogout }: { onLogout?: () => void }) {
}
if (can('cookie:operate')) {
douyuItems.push({ key: '/cookie-operations', label: 'CK 检测与重登', icon: });
+ douyuItems.push({ key: '/automation', label: '自动巡检', icon: });
}
if (can('douyu:task')) {
douyuItems.push({ key: '/douyu/elite', label: '精英宝典', icon: });
diff --git a/web/frontend/src/pages/AutomationPage.tsx b/web/frontend/src/pages/AutomationPage.tsx
new file mode 100644
index 0000000..a41163b
--- /dev/null
+++ b/web/frontend/src/pages/AutomationPage.tsx
@@ -0,0 +1,281 @@
+import { useCallback, useEffect, useState } from 'react';
+import {
+ Alert,
+ Button,
+ Card,
+ Descriptions,
+ Form,
+ InputNumber,
+ Popconfirm,
+ Space,
+ Switch,
+ Table,
+ Tag,
+ Typography,
+} from 'antd';
+import type { ColumnsType } from 'antd/es/table';
+import { ReloadOutlined, ThunderboltOutlined } from '@ant-design/icons';
+import { message } from '../utils/antdMessage';
+import { getErrorMessage } from '../utils/error';
+import { automationApi, type AutomationJobItem, type AutomationRunItem } from '../api/modules';
+import { usePermissions } from '../hooks/usePermissions';
+import { formatTime } from '../utils/time';
+
+const STATUS_TAG: Record = {
+ success: { color: 'green', text: '成功' },
+ failed: { color: 'red', text: '失败' },
+ running: { color: 'blue', text: '执行中' },
+ skipped: { color: 'default', text: '已跳过' },
+};
+
+function secondsToHours(seconds: number): number {
+ return Math.round((seconds / 3600) * 10) / 10;
+}
+
+export default function AutomationPage() {
+ const [jobs, setJobs] = useState([]);
+ const [schedulerEnabled, setSchedulerEnabled] = useState(true);
+ const [schedulerRunning, setSchedulerRunning] = useState(false);
+ const [runs, setRuns] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const [savingKey, setSavingKey] = useState(null);
+ const [runningKey, setRunningKey] = useState(null);
+ const [forms] = Form.useForm();
+ const { can } = usePermissions();
+ const canManage = can('automation:manage');
+
+ const load = useCallback(async () => {
+ setLoading(true);
+ try {
+ const data = await automationApi.jobs();
+ setJobs(data.jobs);
+ setSchedulerEnabled(data.scheduler.enabled);
+ setSchedulerRunning(data.scheduler.running);
+ const history = await automationApi.runs({ limit: 50 });
+ setRuns(history.items);
+ // 统一回填表单:initialValue 只在首次渲染生效,刷新后不会跟着变。
+ forms.setFieldsValue(
+ Object.fromEntries(
+ data.jobs.map((job) => [
+ job.key,
+ {
+ enabled: job.enabled,
+ interval_hours: secondsToHours(job.interval_seconds),
+ batch_size: job.batch_size,
+ concurrency: job.concurrency,
+ },
+ ]),
+ ),
+ );
+ } catch (e: unknown) {
+ message.error(getErrorMessage(e));
+ } finally {
+ setLoading(false);
+ }
+ }, [forms]);
+
+ useEffect(() => {
+ load();
+ }, [load]);
+
+ const saveJob = async (job: AutomationJobItem) => {
+ const values = forms.getFieldValue(job.key) || {};
+ setSavingKey(job.key);
+ try {
+ await automationApi.updateJob(job.key, {
+ enabled: values.enabled,
+ interval_seconds: Math.max(Math.round((values.interval_hours ?? 6) * 3600), 300),
+ batch_size: values.batch_size,
+ concurrency: values.concurrency,
+ });
+ message.success(`${job.title} 配置已保存`);
+ await load();
+ } catch (e: unknown) {
+ message.error(getErrorMessage(e));
+ } finally {
+ setSavingKey(null);
+ }
+ };
+
+ const runOnce = async (job: AutomationJobItem) => {
+ setRunningKey(job.key);
+ try {
+ const outcome = await automationApi.runJob(job.key);
+ message.success(
+ `跑完一轮:扫描 ${outcome.scanned} 条,有效 ${outcome.valid},失效 ${outcome.invalid}`,
+ );
+ await load();
+ } catch (e: unknown) {
+ message.error(getErrorMessage(e));
+ } finally {
+ setRunningKey(null);
+ }
+ };
+
+ const runColumns: ColumnsType = [
+ {
+ title: '开始时间',
+ dataIndex: 'started_at',
+ width: 180,
+ render: (value: string | null) => formatTime(value),
+ },
+ {
+ title: '作业',
+ dataIndex: 'job_key',
+ width: 120,
+ },
+ {
+ title: '结果',
+ dataIndex: 'status',
+ width: 90,
+ render: (value: string) => {
+ const tag = STATUS_TAG[value] ?? { color: 'default', text: value || '-' };
+ return {tag.text};
+ },
+ },
+ { title: '扫描', dataIndex: 'scanned', width: 80 },
+ { title: '有效', dataIndex: 'valid', width: 80 },
+ {
+ title: '失效',
+ dataIndex: 'invalid',
+ width: 80,
+ render: (value: number) => (value > 0 ? {value} : 0),
+ },
+ { title: '耗时', width: 90, render: (_, row) => formatDuration(row.started_at, row.finished_at) },
+ { title: '说明', dataIndex: 'message', ellipsis: true },
+ ];
+
+ return (
+
+ } onClick={load} loading={loading}>
+ 刷新
+
+ }
+ >
+ {!schedulerEnabled && (
+
+ )}
+ {schedulerEnabled && !schedulerRunning && (
+
+ )}
+
+
+ {jobs.map((job) => (
+
+
+
+ {formatTime(job.last_run_at)}
+
+
+ {formatTime(job.next_run_at)}
+
+
+ {job.last_status ? (
+
+ {STATUS_TAG[job.last_status]?.text ?? job.last_status}
+
+ ) : (
+ '-'
+ )}
+
+ {job.last_message || '-'}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ runOnce(job)}
+ disabled={!canManage}
+ >
+ }
+ disabled={!canManage}
+ loading={runningKey === job.key}
+ >
+ 立即跑一次
+
+
+
+
+
+ {!canManage && (
+
+ 当前账号只能查看,修改配置需要 automation:manage 权限。
+
+ )}
+
+ ))}
+
+
+
+
+
+
+ );
+}
+
+function formatDuration(start: string | null, end: string | null): string {
+ if (!start || !end) return '-';
+ const ms = new Date(end).getTime() - new Date(start).getTime();
+ if (!Number.isFinite(ms) || ms < 0) return '-';
+ if (ms < 1000) return `${ms}ms`;
+ return `${(ms / 1000).toFixed(1)}s`;
+}