Files
live-hub-py/web/backend/services/automation/jobs/account_overview_refresh.py
T
yml2213 9ac98dad89 fix(automation): 补全作业改用界面上的「同一账号多久重检」—— 之前那个字段对它无效
用户问「同一账号多久重检是啥意思」,顺着查发现**我的作业没在用它**。

## 「同一账号多久重检」是什么

**每个账号的体检周期**:一个账号这轮被查了,多久之后才重新进入候选。
批量扫描类作业的通用参数,配合另外两个决定"扫不扫得过来":

    需要的能力 = 总账号数 ÷ 重检间隔
    实际的能力 = 每轮条数 ÷ 运行间隔

**实际 < 需要 → 永远扫不完**(队列越来越长)。

按这个口径核对「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 通过。
2026-10-04 21:33:50 +08:00

209 lines
8.3 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""作业:**定时分批**补全账号基础资料(一键查询的内容)。
## 为什么不能一次性跑全量
生产有约 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,
}