用户问「同一账号多久重检是啥意思」,顺着查发现**我的作业没在用它**。
## 「同一账号多久重检」是什么
**每个账号的体检周期**:一个账号这轮被查了,多久之后才重新进入候选。
批量扫描类作业的通用参数,配合另外两个决定"扫不扫得过来":
需要的能力 = 总账号数 ÷ 重检间隔
实际的能力 = 每轮条数 ÷ 运行间隔
**实际 < 需要 → 永远扫不完**(队列越来越长)。
按这个口径核对「CK 自动巡检」的默认值(生产 14,574 个持有 CK 的账号):
需要 14,574 ÷ 6 小时 = 2,429 个/小时
实际 200 条 ÷ 5 分钟 = 2,400 个/小时
**几乎精确匹配** —— 那两个默认值是校准过的,不用动。
## 但我的作业读的是 config 里的 refresh_days
于是界面上那个「同一账号多久重检」**对本作业是死的**(改了没反应)——
正是用户上一条批评过的同一类缺陷:「看起来能配、其实配的是不相干的东西」。
我在同一个问题上连着栽了两次(上一次是拿 `interval_seconds` 当调度频率算吞吐)。
改成 `job.interval_seconds`,与其它批量作业**同一套口径**;
默认 7 天(查过的账号 7 天后再查)。config 里的 `refresh_days` 整个去掉。
## 顺带
- 注册处显式写明两个参数:`interval_seconds=7 天`(重检)+ `run_interval_seconds=5 分钟`(调度)
- 用例改名并加注释,钉住"重检间隔必须来自界面那个字段"
## 验证
TZ=Asia/Shanghai **639 passed / 6 skipped**;ruff 全绿;前端 build 通过。
209 lines
8.3 KiB
Python
209 lines
8.3 KiB
Python
"""作业:**定时分批**补全账号基础资料(一键查询的内容)。
|
||
|
||
## 为什么不能一次性跑全量
|
||
|
||
生产有约 8,000 个「CK 有效但没查过」的账号。一次性发起等于
|
||
**~8 万次斗鱼请求**,很容易触发风控 —— 而风控的代价是所有账号一起受影响。
|
||
|
||
所以做成**定时分批**:每轮只取 `batch_size` 个(默认 30),
|
||
跑完就结束,下一轮再来。慢一点没关系,**稳**最重要。
|
||
|
||
口径与「自动巡检」(`ck_health`)一致,并复用它的两道保护:
|
||
- `cookie_operable_filter()`:账号有 CK 且没有在途尝试
|
||
- **错误率熔断**:一轮里失败比例过高(通常是风控或接口抖动)就
|
||
**自己把自己关掉**,不再继续污染账号
|
||
|
||
## 两类账号分开
|
||
|
||
| 类型 | 条件 | 优先级 |
|
||
| --- | --- | --- |
|
||
| **没查过的** | `overview_queried_at IS NULL` | **优先**(补历史欠账)|
|
||
| 查过但过期了 | `overview_queried_at < now - interval_seconds` | 其次(周期性刷新)|
|
||
|
||
用户明确要求这两类分开 —— 否则"周期性刷新老账号"会永远挤掉"从没查过的新账号"。
|
||
|
||
## 查询记录
|
||
|
||
每次都会建真实的 `douyu_tasks` 行(`query_account_overview`),
|
||
所以「哪个账号、什么时候、查出来什么」都有据可查;
|
||
账号上的 `overview_queried_at` 则是"下次该不该再查"的依据。
|
||
|
||
## 只查有效 CK
|
||
|
||
`ck_check_status == 'valid'`。CK 失效的账号查了也是白查(只会多一条失败记录),
|
||
交给「CK 检测与重登」去处理。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from datetime import UTC, datetime, timedelta
|
||
from typing import Any
|
||
|
||
from loguru import logger
|
||
from sqlalchemy import or_
|
||
from sqlalchemy.orm import Session
|
||
|
||
from ....models import Account, AutomationJob
|
||
from ...account_lifecycle import MANUAL_STATES
|
||
from ...douyu_runner import DouyuBatchRunner
|
||
from ...douyu_service import cookie_operable_filter, create_douyu_planned_tasks
|
||
|
||
JOB_KEY = "account_overview_refresh"
|
||
|
||
#: `interval_seconds` 缺失时的兜底(天)—— 正常都会走界面上那个「同一账号多久重检」
|
||
DEFAULT_RECHECK_SECONDS = 7 * 24 * 3600
|
||
|
||
#: 触发熔断的最小样本量:样本太少时比例没有意义(1 个失败就是 100%)
|
||
ERROR_PAUSE_MIN_SCANNED = 10
|
||
|
||
#: 失败比例超过这个值就熔断(与 ck_health 同口径)
|
||
ERROR_PAUSE_RATIO = 0.5
|
||
|
||
|
||
def _positive_int(value: Any, default: int) -> int:
|
||
try:
|
||
parsed = int(value)
|
||
except (TypeError, ValueError):
|
||
return default
|
||
return parsed if parsed > 0 else default
|
||
|
||
|
||
def build_select_query(db: Session, job: AutomationJob, now: datetime):
|
||
"""选出本轮要查的账号。
|
||
|
||
- 只要 **CK 有效**(`ck_check_status == 'valid'`)—— 失效的查了也是白查
|
||
- **没查过的优先**:`overview_queried_at IS NULL` 排在最前
|
||
(MySQL 没有 `NULLS FIRST`,用 `IS NULL` 的布尔值降序等价实现)
|
||
- 查过的只在超过 `interval_seconds` 后才重新入选
|
||
(就是界面上那个「同一账号多久重检」—— 与其它作业**同一套口径**)
|
||
"""
|
||
# ⚠️ 用 `interval_seconds`(界面上那个「同一账号多久重检」),
|
||
# 与其它批量作业**同一套口径**。
|
||
#
|
||
# 之前这里读的是 `job.config["refresh_days"]` —— 于是界面上那个字段
|
||
# 对本作业是**死的**(改了没反应)。那正是用户刚批评过的
|
||
# 「看起来能配、其实配的是不相干的东西」。
|
||
batch = _positive_int(job.batch_size, 30)
|
||
return (
|
||
_base_query(db, job, now)
|
||
# 没查过的(IS NULL = 1)排最前 —— MySQL 没有 NULLS FIRST,
|
||
# 用 `IS NULL` 的布尔值降序等价实现
|
||
.order_by(Account.overview_queried_at.is_(None).desc(), Account.id.asc())
|
||
.limit(batch)
|
||
)
|
||
|
||
|
||
def _base_query(db: Session, job: AutomationJob, now: datetime):
|
||
"""**不带排序和 LIMIT** 的基础查询。
|
||
|
||
单独抽出来是因为计数不能再走 `build_select_query`:
|
||
SQLAlchemy 不允许在已 `LIMIT` 的查询上再 `order_by(None)`
|
||
(抛 "order_by() being called on a Query which already has LIMIT")。
|
||
"""
|
||
# ⚠️ 用 `interval_seconds`(界面上那个「同一账号多久重检」),
|
||
# 与其它批量作业**同一套口径**。
|
||
#
|
||
# 之前这里读的是 `job.config["refresh_days"]` —— 于是界面上那个字段
|
||
# 对本作业是**死的**(改了没反应)。那正是用户刚批评过的
|
||
# 「看起来能配、其实配的是不相干的东西」。
|
||
recheck_seconds = _positive_int(job.interval_seconds, DEFAULT_RECHECK_SECONDS)
|
||
cutoff = now - timedelta(seconds=recheck_seconds)
|
||
return db.query(Account).filter(
|
||
cookie_operable_filter(),
|
||
Account.auto_managed.is_(True),
|
||
Account.status.notin_(MANUAL_STATES),
|
||
Account.ck_check_status == "valid",
|
||
or_(
|
||
Account.overview_queried_at.is_(None),
|
||
Account.overview_queried_at < cutoff,
|
||
),
|
||
)
|
||
|
||
|
||
def count_remaining(db: Session, job: AutomationJob, now: datetime) -> int:
|
||
"""还剩多少待查(只用于提示,不参与调度)。"""
|
||
return _base_query(db, job, now).count()
|
||
|
||
|
||
def _system_user_id(db: Session) -> int:
|
||
"""任务行的 created_by —— 定时任务不是某个人的操作,记在超管名下。"""
|
||
from ....models import User
|
||
|
||
user = (
|
||
db.query(User)
|
||
.filter(User.role == "super_admin")
|
||
.order_by(User.id.asc())
|
||
.first()
|
||
)
|
||
if user is None:
|
||
raise RuntimeError("库中没有超管用户,无法为定时任务提供 created_by")
|
||
return int(user.id)
|
||
|
||
|
||
def run_account_overview_refresh(db: Session, job: AutomationJob) -> dict[str, Any]:
|
||
"""本轮:选一批 → 建任务 → 就地跑完 → 看失败率决定要不要熔断。"""
|
||
now = datetime.now(UTC)
|
||
accounts = build_select_query(db, job, now).all()
|
||
if not accounts:
|
||
return {"scanned": 0, "succeeded": 0, "failed": 0, "message": "没有待查账号"}
|
||
|
||
account_ids = [a.id for a in accounts]
|
||
batch_id, count = create_douyu_planned_tasks(
|
||
db,
|
||
account_ids,
|
||
"query_account_overview",
|
||
"elite",
|
||
_system_user_id(db),
|
||
# 标上来源,便于在任务列表里区分「定时巡检」与「人工点查询」
|
||
{"source": "scheduled_refresh"},
|
||
)
|
||
if count == 0:
|
||
return {"scanned": 0, "succeeded": 0, "failed": 0, "message": "账号都不可查"}
|
||
|
||
# 复用现有执行器:并发、代理、进度、失败处理都是现成的
|
||
runner = DouyuBatchRunner(
|
||
db,
|
||
batch_id,
|
||
"query_account_overview",
|
||
concurrency=max(1, min(int(job.concurrency or 1), 10)),
|
||
)
|
||
runner.run()
|
||
db.commit()
|
||
|
||
from ....models import DouyuTask
|
||
|
||
rows = db.query(DouyuTask).filter(DouyuTask.batch_id == batch_id).all()
|
||
succeeded = sum(1 for t in rows if t.status == "success")
|
||
failed = sum(1 for t in rows if t.status == "failed")
|
||
|
||
error_ratio = failed / len(rows) if rows else 0.0
|
||
paused = False
|
||
if len(rows) >= ERROR_PAUSE_MIN_SCANNED and error_ratio > ERROR_PAUSE_RATIO:
|
||
# 大面积失败通常是风控或斗鱼接口抖动。继续跑只会把更多账号写坏,
|
||
# 所以**自己把自己关掉**,交给人来确认(与 ck_health 同口径)。
|
||
job.enabled = False
|
||
db.commit()
|
||
paused = True
|
||
logger.warning(
|
||
f"[overview] 失败率 {error_ratio:.0%} 过高,已自动关闭作业"
|
||
f"(batch={batch_id},failed={failed}/{len(rows)})—— 请确认是否被风控"
|
||
)
|
||
|
||
message = (
|
||
f"本轮查 {len(rows)} 个:成功 {succeeded} / 失败 {failed}"
|
||
f"(剩 {count_remaining(db, job, now)} 个待查)"
|
||
)
|
||
if paused:
|
||
# 把**可能的原因**写进消息里:运维看作业历史时才知道该去查什么,
|
||
# 否则只看到"失败率高"会一头雾水。
|
||
message += ";失败率过高,已自动关闭作业(大面积失败通常是**被风控**或接口抖动,请先确认再重开)"
|
||
logger.info(f"[overview] {message}")
|
||
return {
|
||
"scanned": len(rows),
|
||
"succeeded": succeeded,
|
||
"failed": failed,
|
||
"batch_id": batch_id,
|
||
"paused": paused,
|
||
"message": message,
|
||
}
|