Files
live-hub-py/web/backend/routers/account_check.py
T
yml2213 eec42aa8a7 feat(check): 账号检测新增「按账号选择」—— 旧的手动输入与逻辑完整保留
按反馈是**增加**而不是**替换**:旧路径一行未动,新路径另起一条。

## 两套来源并存

| | 手动输入(旧,默认) | 选择账号(新) |
| --- | --- | --- |
| 来源 | 粘贴文本 | 库里账号的凭据(不用人工粘贴) |
| 条目 `account_id` | **无** | 有 |
| 结果落账号时间线 | **否**(保持旧行为) | 是,新事件类型 `account_check` |
| 批次 / 并发 / 重试 / 代理 / 导出 zip | 完全不变 | 完全不变 |

**旧路径的「不发事件」是刻意的**:粘贴进来的账号没有归属,凭空造一条没有账号的
事件只是污染时间线。`parse_account_check_lines`、`export_line`、zip 分组全部未动,
有用例专门钉住这一点。

## 「选择账号」支持筛选(按反馈补充)

不是给一个扁平下拉,而是先按条件缩小范围再决定选法:

- 筛选:**标签 / 生命周期状态 / CK 健康**(复用账号列表同一套 `_filter_accounts_query`,
  两处口径不会不一致)
- 选法:手动多选(选项随筛选刷新)**或**「按筛选全选」(含列表里没显示出来的)

服务端支持 `select_all_matching + 各筛选条件`,与账号列表批量操作的字段形状一致。
可见性沿用账号域口径:客服只能检测自己名下的账号(越权 id 直接 403)。

## 结果表加账号回链

「选择账号」模式下条目带 `account_id`,账号列变成可点链接 → 直接跳到账号详情,
结果也在那里的时间线上。

## 排查中修掉两个 bug(都不是测试发现的)

**① `registry.create` 漏传 `account_id`**
它构造 `AccountCheckItemState` 时没把 `account_id` 从输入复制过去。
后果:批次照跑、结果照显示、zip 照导出,**事件永远不发且没有任何报错**。
是「跑一次真实检测再查事件表」才发现的(查出来 0 条)。已补,并加了用例。

**② 终态判断写成「键存在」而不是「非空」**
`_run_one` 置 `running` 时会显式传 `finished_at=None`,而判断写的是
`"finished_at" in updates` → **中间态也发事件**,于是时间线上记的是
`账号检查:running`;真正的终态再发时撞同一个幂等键被**静默丢弃** ——
结果是「只有一条,但记错了」,比没有更糟。

改成判非空。此处暴露了一个**假绿的用例**:我原来的「中间态不发事件」用例
没传 `finished_at=None`(与真实调用不一致),所以放过了这个 bug;
而且它还缺 `session.commit()`,在 MySQL REPEATABLE READ 下根本看不到
另一个会话刚提交的行 —— 双重假绿。两处都修了,并做了反向验证
(改回旧判断 → 用例立刻红)。

## 验证

- `TZ=Asia/Shanghai`:**520 passed / 6 skipped**(新增 17 条检测模式用例)
- `TZ=UTC`(检测 + 迁移)20 passed;ruff 全绿;前端 build 通过、lint 0 errors
- **真实端到端**:用真实库账号起批次 → 检测跑完(斗鱼返回「账号已认证」)→
  事件确实落库;清掉脏数据后用**与 `_run_one` 完全相同的调用序列**复验:
  两次 `_set_item` 只产生一条事件,且 title 是
  `账号检测:账号已认证`、`status=account_verified`(不再记成 running)

注:验证时真实触发了几次斗鱼登录检测(开发库那两个账号),结果都是「已认证」。
2026-10-04 17:34:58 +08:00

163 lines
5.8 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.
"""斗鱼账号检测路由。"""
from __future__ import annotations
import threading
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import StreamingResponse
from sqlalchemy.orm import Session
from ..database import get_db
from ..deps import get_current_user
from ..models import Account, User
from ..models import ProxyConfig as ProxyConfigModel
from ..permissions import user_has_permission
from ..schemas import AccountCheckBatchOut, AccountCheckBatchRequest
from ..services.account_check_service import (
account_check_registry,
build_inputs_from_accounts,
parse_account_check_lines,
)
router = APIRouter(prefix="/api/account-check", tags=["账号检测"])
def _get_runner_or_404(batch_id: str):
"""读取账号检测批次。"""
runner = account_check_registry.get(batch_id)
if not runner:
raise HTTPException(status_code=404, detail="批次不存在或服务已重启")
return runner
def _require_account_check_perm(user: User) -> None:
"""账号检测权限;兼容已有批量登录权限。"""
if not (
user_has_permission(user, "account:check")
or user_has_permission(user, "login:batch")
):
raise HTTPException(status_code=403, detail="权限不足")
@router.post("/batches", response_model=AccountCheckBatchOut)
def create_account_check_batch(
req: AccountCheckBatchRequest,
db: Session = Depends(get_db),
current: User = Depends(get_current_user),
):
"""启动斗鱼账号检测批次。
两种来源**二选一**:
- `text`:手动粘贴(旧模式,逻辑一字未动);
- `account_ids`:按账号选择(新模式)—— 凭据从库里取,结果写回账号时间线。
"""
_require_account_check_perm(current)
has_text = bool((req.text or "").strip())
has_ids = bool(req.account_ids)
has_filter = bool(req.select_all_matching)
picked = has_ids or has_filter
if has_text and picked:
raise HTTPException(status_code=400, detail="手动输入与选择账号只能二选一")
if not has_text and not picked:
raise HTTPException(status_code=400, detail="请粘贴账号或选择要检测的账号")
if picked:
# 可见性沿用账号域的口径:客服只能检测自己名下的账号。
from .accounts import _filter_accounts_query, _visible_accounts_query
query = _visible_accounts_query(db, current)
if has_filter:
# 「按筛选全选」:与账号列表同一套筛选条件(标签/状态/CK/绑定/搜索),
# 所以列表上筛出什么,这里就检测什么,不会两处口径不一致。
query = _filter_accounts_query(
db,
query,
current,
search=req.search,
tag=req.tag,
status=req.status,
ck_status=req.ck_status,
bind_status=req.bind_status,
)
rows = query.order_by(Account.id.asc()).all()
ordered_ids = [int(a.id) for a in rows]
else:
ordered_ids = [int(i) for i in req.account_ids]
rows = query.filter(Account.id.in_(ordered_ids)).all()
found = {int(a.id) for a in rows}
missing = [i for i in ordered_ids if i not in found]
if missing:
raise HTTPException(
status_code=403,
detail=f"以下账号不存在或无权查看:{missing[:10]}",
)
if not rows:
raise HTTPException(status_code=400, detail="当前条件下没有可检测的账号")
accounts = build_inputs_from_accounts(rows, ordered_ids)
else:
try:
accounts = parse_account_check_lines(req.text)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
if not accounts:
raise HTTPException(status_code=400, detail="没有识别到有效账号")
proxy_config = db.query(ProxyConfigModel).first() if req.use_proxy else None
runner = account_check_registry.create(
accounts=accounts,
created_by=current.id,
concurrency=req.concurrency,
max_login_retries=req.max_login_retries,
max_total_time=req.max_total_time,
use_proxy=req.use_proxy,
proxy_config=proxy_config,
)
thread = threading.Thread(target=runner.run, daemon=True)
thread.start()
return runner.snapshot()
@router.get("/batches/{batch_id}", response_model=AccountCheckBatchOut)
def get_account_check_batch(
batch_id: str,
current: User = Depends(get_current_user),
):
"""查询斗鱼账号检测批次。"""
_require_account_check_perm(current)
runner = _get_runner_or_404(batch_id)
return runner.snapshot()
@router.post("/batches/{batch_id}/stop")
def stop_account_check_batch(
batch_id: str,
current: User = Depends(get_current_user),
):
"""停止斗鱼账号检测批次。"""
_require_account_check_perm(current)
runner = _get_runner_or_404(batch_id)
runner.stop()
return {"message": "已发送停止信号", "success": True}
@router.get("/batches/{batch_id}/download")
def download_account_check_batch(
batch_id: str,
current: User = Depends(get_current_user),
):
"""下载斗鱼账号检测分类结果 zip。"""
_require_account_check_perm(current)
runner = _get_runner_or_404(batch_id)
snapshot = runner.snapshot()
if snapshot["status"] in {"pending", "running"}:
raise HTTPException(status_code=400, detail="批次尚未完成")
content, filename = runner.build_zip()
return StreamingResponse(
iter([content]),
media_type="application/zip",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)