feat(automation): CK 自动巡检(P1a),只检测不改 CK

后台原本靠人工逐批点「CK 检测」发现失效账号,1.2 万个账号根本盯不过来。
P1a 把「哪些账号失效了」自动化:自动挑久未检测的账号、调现成检测接口、
把结果写回 ck_check_*。**本作业不替换任何 CK**,重登仍由人工点,符合规划里
「先做只读/只提示,确认无误再做自动动作」的渐进上线原则。

实现:
- 迁移 20261002_0037:automation_jobs / automation_runs 两张表,
  login_tasks 加 relogin_attempts / relogin_next_at / relogin_last_error
  (为 P1b 备好),accounts 加 auto_managed(特殊账号不参与自动化);
- services/automation/scheduler.py:进程内调度线程,60s tick,状态全部落库
  (重启不丢调度进度、不重复跑轮);AUTOMATION_ENABLED 默认关闭,作业开关
  也默认关闭;ensure_jobs 只补缺失行、不覆盖运维改过的配置;
  作业异常只记 failed,不拖垮调度线程;单容器 --workers 1,无需分布式锁;
- services/automation/jobs/ck_health.py:选行口径与人工检测一致
  (cookie_holding_filter:持有 CK 且无在途尝试),叠加久未检测、
  auto_managed、batch_size,按 ck_checked_at 升序(最久没查的优先);
  只写 ck_check_status / ck_check_result / ck_checked_at;
- routers/automation.py:GET /jobs、PATCH /jobs/{key}、POST /jobs/{key}/run、
  GET /runs;新增权限点 automation:view(运营可看)与 automation:manage
  (仅超管),改配置与手动触发都写审计;
- 顺带补 POST /api/login/batch 的审计(此前批次是谁发起、多少账号无据可查);
- 前端 /automation 页:作业卡片(开关/周期/单轮条数/并发)、运行历史、
  「立即跑一次」;总开关未打开、调度线程未运行时给明确提示。

验证:
- 22 条新测试覆盖调度器行为(只跑到点的、异常不抛出、下轮时间、配置不被重置)、
  选行条件(在途尝试/auto_managed/间隔/batch_size)、接口与权限,其中一条
  专门断言巡检后 cookie/status/message/attempt_status 一字未动;
- 全量 257 条通过;前端 tsc 与构建通过;
- 端到端用 3 个真实账号跑真实斗鱼接口:巡检结果与直接调 check_douyu_cookie
  逐条一致(3/3 valid),CK 与尝试状态全程未变。

顺带修掉一处会炸生产的坑:DB 时间列读出来是 naive UTC,直接与
datetime.now(UTC) 比较会 TypeError,现统一走 _as_utc() 补时区。
This commit is contained in:
yml2213
2026-10-02 19:47:41 +08:00
parent 6537d77323
commit f745a61df1
19 changed files with 1721 additions and 19 deletions
+34 -16
View File
@@ -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` 两页 → ⑥ 端到端联调(发链接 → 提交 → 校验 → 执行 → 台账) |
+443
View File
@@ -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
+1 -1
View File
@@ -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 = (
+16 -1
View File
@@ -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")
@@ -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")
+60
View File
@@ -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)
+4
View File
@@ -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",
+191
View File
@@ -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)
+21
View File
@@ -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}
@@ -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,
)
)
@@ -0,0 +1 @@
"""自动化作业实现。"""
@@ -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},
}
@@ -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),
}
+2
View File
@@ -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() {
<Route path="huya/browser-exchange" element={lazyRoute(<HuyaBrowserExchangePage />)} />
<Route path="yyb/recharge" element={lazyRoute(<YybRechargePage />)} />
<Route path="proxy" element={lazyRoute(<ProxyPage />)} />
<Route path="automation" element={lazyRoute(<AutomationPage />)} />
<Route path="users" element={lazyRoute(<UsersPage />)} />
<Route path="audit-logs" element={isSuperAdmin ? lazyRoute(<AuditLogsPage />) : <Navigate to="/" replace />} />
</Route>
+18
View File
@@ -0,0 +1,18 @@
import api from './client';
import type {
AutomationJobItem,
AutomationJobUpdate,
AutomationJobsResponse,
AutomationRunOutcome,
AutomationRunsResponse,
} from './types';
export const automationApi = {
jobs: () => api.get<AutomationJobsResponse, AutomationJobsResponse>('/automation/jobs'),
updateJob: (key: string, payload: AutomationJobUpdate) =>
api.patch<AutomationJobItem, AutomationJobItem>(`/automation/jobs/${key}`, payload),
runJob: (key: string) =>
api.post<AutomationRunOutcome, AutomationRunOutcome>(`/automation/jobs/${key}/run`),
runs: (params: { job_key?: string; limit?: number }) =>
api.get<AutomationRunsResponse, AutomationRunsResponse>('/automation/runs', { params }),
};
+1
View File
@@ -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';
+64
View File
@@ -827,3 +827,67 @@ export interface PermissionsListResult {
permissions: Record<string, string>;
role_permissions: Record<string, string[]>;
}
// ==================== 自动化作业 ====================
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<string, unknown> | 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;
}
+2 -1
View File
@@ -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: <SafetyCertificateOutlined /> });
douyuItems.push({ key: '/automation', label: '自动巡检', icon: <RobotOutlined /> });
}
if (can('douyu:task')) {
douyuItems.push({ key: '/douyu/elite', label: '精英宝典', icon: <BookOutlined /> });
+281
View File
@@ -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<string, { color: string; text: string }> = {
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<AutomationJobItem[]>([]);
const [schedulerEnabled, setSchedulerEnabled] = useState(true);
const [schedulerRunning, setSchedulerRunning] = useState(false);
const [runs, setRuns] = useState<AutomationRunItem[]>([]);
const [loading, setLoading] = useState(false);
const [savingKey, setSavingKey] = useState<string | null>(null);
const [runningKey, setRunningKey] = useState<string | null>(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<AutomationRunItem> = [
{
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 color={tag.color}>{tag.text}</Tag>;
},
},
{ title: '扫描', dataIndex: 'scanned', width: 80 },
{ title: '有效', dataIndex: 'valid', width: 80 },
{
title: '失效',
dataIndex: 'invalid',
width: 80,
render: (value: number) => (value > 0 ? <Typography.Text type="warning">{value}</Typography.Text> : 0),
},
{ title: '耗时', width: 90, render: (_, row) => formatDuration(row.started_at, row.finished_at) },
{ title: '说明', dataIndex: 'message', ellipsis: true },
];
return (
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
<Card
title="自动巡检"
extra={
<Button icon={<ReloadOutlined />} onClick={load} loading={loading}>
刷新
</Button>
}
>
{!schedulerEnabled && (
<Alert
type="warning"
showIcon
style={{ marginBottom: 16 }}
message="调度器总开关未打开"
description="服务端环境变量 AUTOMATION_ENABLED 未开启,打开下面的作业开关不会真正执行。需要先打开总开关并重启服务,再回到本页打开作业。"
/>
)}
{schedulerEnabled && !schedulerRunning && (
<Alert
type="warning"
showIcon
style={{ marginBottom: 16 }}
message="调度线程未运行"
description="总开关已开但调度线程不在运行状态,请检查服务日志或重启服务。"
/>
)}
<Alert
type="info"
showIcon
style={{ marginBottom: 16 }}
message="巡检只检测、不改 CK"
description="本作业只把失效账号找出来并标记,不会替换任何 Cookie、也不会改账号状态;重登仍由人工在「CK 检测与重登」页面操作。"
/>
{jobs.map((job) => (
<Card key={job.key} type="inner" title={job.title} style={{ marginBottom: 16 }}>
<Descriptions size="small" column={2} style={{ marginBottom: 16 }}>
<Descriptions.Item label="最近运行">
{formatTime(job.last_run_at)}
</Descriptions.Item>
<Descriptions.Item label="下次运行">
{formatTime(job.next_run_at)}
</Descriptions.Item>
<Descriptions.Item label="最近结果">
{job.last_status ? (
<Tag color={STATUS_TAG[job.last_status]?.color ?? 'default'}>
{STATUS_TAG[job.last_status]?.text ?? job.last_status}
</Tag>
) : (
'-'
)}
</Descriptions.Item>
<Descriptions.Item label="最近说明">{job.last_message || '-'}</Descriptions.Item>
</Descriptions>
<Form form={forms} layout="inline" onFinish={() => saveJob(job)}>
<Form.Item
name={[job.key, 'enabled']}
label="启用"
valuePropName="checked"
>
<Switch disabled={!canManage} />
</Form.Item>
<Form.Item
name={[job.key, 'interval_hours']}
label="巡检周期(小时)"
>
<InputNumber min={0.1} step={1} disabled={!canManage} />
</Form.Item>
<Form.Item name={[job.key, 'batch_size']} label="单轮条数">
<InputNumber min={1} max={2000} disabled={!canManage} />
</Form.Item>
<Form.Item name={[job.key, 'concurrency']} label="并发">
<InputNumber min={1} max={16} disabled={!canManage} />
</Form.Item>
<Form.Item>
<Space>
<Button
type="primary"
htmlType="submit"
disabled={!canManage}
loading={savingKey === job.key}
>
保存
</Button>
<Popconfirm
title="立刻跑一轮?"
description="不看启用开关,立即执行一次巡检。"
onConfirm={() => runOnce(job)}
disabled={!canManage}
>
<Button
icon={<ThunderboltOutlined />}
disabled={!canManage}
loading={runningKey === job.key}
>
立即跑一次
</Button>
</Popconfirm>
</Space>
</Form.Item>
</Form>
{!canManage && (
<Typography.Text type="secondary">
当前账号只能查看,修改配置需要 automation:manage 权限。
</Typography.Text>
)}
</Card>
))}
</Card>
<Card title="运行历史">
<Table
rowKey="id"
size="small"
loading={loading}
columns={runColumns}
dataSource={runs}
pagination={{ pageSize: 20, showSizeChanger: false }}
/>
</Card>
</Space>
);
}
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`;
}