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)

注:验证时真实触发了几次斗鱼登录检测(开发库那两个账号),结果都是「已认证」。
This commit is contained in:
yml2213
2026-10-04 17:34:58 +08:00
parent 5ae5d92978
commit eec42aa8a7
6 changed files with 701 additions and 18 deletions
+332
View File
@@ -0,0 +1,332 @@
"""S7:账号检测保留旧的手动输入,新增「按账号选择」。
本文件守住的核心是**两套来源并存**,而不是替换:
| | 手动输入(旧) | 选择账号(新) |
| --- | --- | --- |
| 来源 | 粘贴文本 | 库里账号的凭据 |
| `account_id` | **无** | 有 |
| 结果落账号时间线 | **否**(保持旧行为) | 是 |
最容易做错的两件事:① 把旧路径一起改成发事件(那会凭空造出没有归属的事件);
② 新路径忘了按可见性过滤(客服就能检测别人名下的账号)。
"""
from datetime import UTC, datetime
import pytest
from tests.support.db import get_test_engine, make_test_session
from web.backend.models import Account, AccountEvent, User
from web.backend.services import account_check_service as svc
NOW = datetime(2026, 10, 4, 12, 0, 0, tzinfo=UTC)
class _Base:
def setup_method(self):
self.engine = get_test_engine()
self.session = make_test_session(self.engine)
self.session.query(AccountEvent).delete()
self.session.commit()
self.user = User(username="chk-admin", password_hash="h", role="super_admin")
self.session.add(self.user)
self.session.commit()
def teardown_method(self):
self.session.close()
def account(self, name: str, **kwargs) -> Account:
acc = Account(
username=name,
password=f"pw-{name}",
email=f"{name}@e.com",
email_password=f"ep-{name}",
**kwargs,
)
self.session.add(acc)
self.session.commit()
return acc
class TestLegacyTextPathUnchanged(_Base):
"""**旧逻辑一字未动** —— 这是本次明确要求保留的部分。"""
def test_parses_without_account_id(self):
inputs = svc.parse_account_check_lines("u1----p1----e1----ep1")
assert len(inputs) == 1
assert inputs[0].username == "u1"
assert inputs[0].password == "p1"
# 关键:没有归属 → 后续不会往任何账号写事件
assert inputs[0].account_id is None
def test_parse_supports_both_separators(self):
inputs = svc.parse_account_check_lines("u1----p1----e1----ep1\nu2|p2|e2|ep2")
assert [i.username for i in inputs] == ["u1", "u2"]
def test_export_line_unchanged(self):
inputs = svc.parse_account_check_lines("u1----p1----e1----ep1")
assert inputs[0].export_line() == "u1----p1----e1----ep1"
class TestSelectAccountsPath(_Base):
def test_builds_inputs_with_account_id(self):
a = self.account("sa-1")
b = self.account("sa-2")
rows = self.session.query(Account).filter(Account.id.in_([a.id, b.id])).all()
inputs = svc.build_inputs_from_accounts(rows, [a.id, b.id])
assert [i.account_id for i in inputs] == [a.id, b.id]
# 凭据从库里取,不用人工粘贴
assert inputs[0].username == "sa-1"
assert inputs[0].password == "pw-sa-1"
assert inputs[0].email == "sa-1@e.com"
def test_order_follows_requested_ids(self):
a = self.account("ord-1")
b = self.account("ord-2")
rows = self.session.query(Account).filter(Account.id.in_([a.id, b.id])).all()
inputs = svc.build_inputs_from_accounts(rows, [b.id, a.id])
assert [i.account_id for i in inputs] == [b.id, a.id]
def test_unknown_id_is_skipped_not_crashing(self):
a = self.account("sk-1")
rows = self.session.query(Account).filter(Account.id == a.id).all()
inputs = svc.build_inputs_from_accounts(rows, [a.id, 10_000_000])
assert [i.account_id for i in inputs] == [a.id]
class TestResultGoesToAccountTimeline(_Base):
"""只有「按账号选择」的条目才写事件。"""
def _runner(self):
runner = svc.AccountCheckRunner.__new__(svc.AccountCheckRunner)
runner.batch = svc.AccountCheckBatch(
batch_id="batch-x",
created_by=self.user.id,
concurrency=1,
max_login_retries=0,
max_total_time=60,
use_proxy=False,
items=[],
)
import threading
runner._lock = threading.Lock()
runner._stop = threading.Event()
return runner
def test_account_linked_item_emits_event(self):
a = self.account("evt-1")
runner = self._runner()
runner.batch.items = [
svc.AccountCheckItemState(
line=1,
username="evt-1",
email="evt-1@e.com",
export_text="evt-1----pw----e----ep",
account_id=a.id,
)
]
# 终态(带 finished_at)→ 写事件
runner._set_item(
0,
status="account_verified",
message="账号已认证",
finished_at=NOW,
)
# 事件由 runner 自己开的会话提交;本测试会话在 REPEATABLE READ 下
# 沿用旧快照看不到它 —— 先结束自己的事务再查。
self.session.commit()
events = (
self.session.query(AccountEvent)
.filter(
AccountEvent.account_id == a.id,
AccountEvent.event_type == svc.EVENT_ACCOUNT_CHECK,
)
.all()
)
assert len(events) == 1
assert events[0].detail["status"] == "account_verified"
assert events[0].detail["batch_id"] == "batch-x"
def test_text_item_emits_nothing(self):
"""手动粘贴的条目没有归属 —— 绝不能凭空造出没有账号的事件。"""
self.account("evt-2")
runner = self._runner()
runner.batch.items = [
svc.AccountCheckItemState(
line=1,
username="pasted",
email="pasted@e.com",
export_text="pasted----pw----e----ep",
account_id=None,
)
]
runner._set_item(0, status="account_verified", message="ok", finished_at=NOW)
assert self.session.query(AccountEvent).count() == 0
def test_non_terminal_update_emits_nothing(self):
"""中间态(running 等,不带 finished_at)不写事件,否则时间线会被刷屏。"""
a = self.account("evt-3")
runner = self._runner()
runner.batch.items = [
svc.AccountCheckItemState(
line=1,
username="evt-3",
email="e@e.com",
export_text="x",
account_id=a.id,
)
]
# ⚠️ 必须**照真实调用**传 `finished_at=None`:`_run_one` 就是这么传的。
# 第一版用例漏了它,于是「中间态也发事件」这个 bug 逃过了用例,
# 最后是靠跑一次真实检测、看到事件 title 是「running」才发现的。
runner._set_item(
0, status="running", message="检测中", started_at=NOW, finished_at=None
)
# 事件由 runner 自己的会话提交;本会话要结束事务才能看到新快照,
# 否则「没查到」可能只是快照旧了 —— 那会让这条断言假绿。
self.session.commit()
assert self.session.query(AccountEvent).count() == 0
def test_same_batch_does_not_duplicate(self):
"""同一次检测重试/重放不会写两条(幂等键带 account_id 与 batch_id)。"""
a = self.account("evt-4")
runner = self._runner()
runner.batch.items = [
svc.AccountCheckItemState(
line=1,
username="evt-4",
email="e@e.com",
export_text="x",
account_id=a.id,
)
]
runner._set_item(0, status="account_verified", message="ok", finished_at=NOW)
runner._set_item(0, status="account_verified", message="ok", finished_at=NOW)
self.session.commit()
assert (
self.session.query(AccountEvent)
.filter(AccountEvent.event_type == svc.EVENT_ACCOUNT_CHECK)
.count()
== 1
)
class TestRequestValidation:
"""两种来源二选一:都给或都不给都该被拒。"""
@staticmethod
def _call(**kwargs):
from fastapi import HTTPException
from web.backend.routers.account_check import create_account_check_batch
from web.backend.schemas import AccountCheckBatchRequest
return (
create_account_check_batch,
AccountCheckBatchRequest,
HTTPException,
kwargs,
)
def test_both_sources_rejected(self):
create, Request, HTTPException, _ = self._call()
with pytest.raises(HTTPException) as exc:
create(
Request(text="u----p----e----ep", account_ids=[1]),
db=None,
current=User(username="x", password_hash="h", role="super_admin"),
)
assert exc.value.status_code == 400
assert "二选一" in str(exc.value.detail)
def test_neither_source_rejected(self):
create, Request, HTTPException, _ = self._call()
with pytest.raises(HTTPException) as exc:
create(
Request(text=" ", account_ids=[]),
db=None,
current=User(username="x", password_hash="h", role="super_admin"),
)
assert exc.value.status_code == 400
class TestFilterBasedSelection(_Base):
"""「按筛选全选」:与账号列表同一套条件,列表筛出什么就检测什么。"""
def _resolve(self, **kwargs):
"""复现路由里的解析逻辑(用同一套 _filter_accounts_query)。"""
from web.backend.routers.accounts import (
_filter_accounts_query,
_visible_accounts_query,
)
query = _filter_accounts_query(
self.session,
_visible_accounts_query(self.session, self.user),
self.user,
search=kwargs.get("search", ""),
tag=kwargs.get("tag", ""),
status=kwargs.get("status", ""),
ck_status=kwargs.get("ck_status", ""),
bind_status="",
)
return [a.username for a in query.order_by(Account.id.asc()).all()]
def test_filters_by_tag(self):
self.account("tag-a", tag="青龙")
self.account("tag-b", tag="白虎")
assert self._resolve(tag="青龙") == ["tag-a"]
def test_filters_by_status_and_ck(self):
self.account("st-imp")
self.account("st-bad", cookie="ck", ck_check_status="invalid")
assert self._resolve(ck_status="invalid") == ["st-bad"]
assert "st-imp" in self._resolve(status="imported")
def test_no_filter_returns_all_visible(self):
self.account("all-1")
self.account("all-2")
assert {"all-1", "all-2"} <= set(self._resolve())
class TestInputToItemCarriesAccountId(_Base):
"""`registry.create` 必须把 `account_id` 从输入复制到条目。
漏了这一行时**没有任何报错** —— 批次照跑、结果照显示、zip 照导出,
只是事件永远不发。第一版就漏了,靠「跑一次真实检测再查事件」才发现。
"""
def test_item_keeps_account_id(self):
from web.backend.services.account_check_service import AccountCheckRegistry
a = self.account("reg-1")
rows = self.session.query(Account).filter(Account.id == a.id).all()
inputs = svc.build_inputs_from_accounts(rows, [a.id])
runner = AccountCheckRegistry().create(
accounts=inputs,
created_by=self.user.id,
concurrency=1,
max_login_retries=0,
max_total_time=60,
)
assert [i.account_id for i in runner.batch.items] == [a.id]
def test_text_mode_items_have_no_account_id(self):
from web.backend.services.account_check_service import AccountCheckRegistry
inputs = svc.parse_account_check_lines("u----p----e----ep")
runner = AccountCheckRegistry().create(
accounts=inputs,
created_by=self.user.id,
concurrency=1,
max_login_retries=0,
max_total_time=60,
)
assert [i.account_id for i in runner.batch.items] == [None]
+55 -6
View File
@@ -10,12 +10,13 @@ from sqlalchemy.orm import Session
from ..database import get_db from ..database import get_db
from ..deps import get_current_user from ..deps import get_current_user
from ..models import Account, User
from ..models import ProxyConfig as ProxyConfigModel from ..models import ProxyConfig as ProxyConfigModel
from ..models import User
from ..permissions import user_has_permission from ..permissions import user_has_permission
from ..schemas import AccountCheckBatchOut, AccountCheckBatchRequest from ..schemas import AccountCheckBatchOut, AccountCheckBatchRequest
from ..services.account_check_service import ( from ..services.account_check_service import (
account_check_registry, account_check_registry,
build_inputs_from_accounts,
parse_account_check_lines, parse_account_check_lines,
) )
@@ -45,12 +46,60 @@ def create_account_check_batch(
db: Session = Depends(get_db), db: Session = Depends(get_db),
current: User = Depends(get_current_user), current: User = Depends(get_current_user),
): ):
"""启动斗鱼账号检测批次。""" """启动斗鱼账号检测批次。
两种来源**二选一**:
- `text`:手动粘贴(旧模式,逻辑一字未动);
- `account_ids`:按账号选择(新模式)—— 凭据从库里取,结果写回账号时间线。
"""
_require_account_check_perm(current) _require_account_check_perm(current)
try: has_text = bool((req.text or "").strip())
accounts = parse_account_check_lines(req.text) has_ids = bool(req.account_ids)
except ValueError as exc: has_filter = bool(req.select_all_matching)
raise HTTPException(status_code=400, detail=str(exc)) from exc 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: if not accounts:
raise HTTPException(status_code=400, detail="没有识别到有效账号") raise HTTPException(status_code=400, detail="没有识别到有效账号")
+18 -1
View File
@@ -287,7 +287,23 @@ class LoginTaskOut(BaseModel):
# ---- 账号检测 ---- # ---- 账号检测 ----
class AccountCheckBatchRequest(BaseModel): class AccountCheckBatchRequest(BaseModel):
text: str = Field(..., min_length=1) """两种来源二选一(**旧的粘贴输入完整保留**)。
- `text`:手动粘贴(旧模式)—— 条目没有 account_id,结果只在批次里与 zip 里,
不写账号时间线(保持旧行为);
- `account_ids`:按账号选择(新模式)—— 凭据从库里取,结果写回账号时间线。
"""
text: str = ""
# 「选择账号」的两种选法(与账号列表的批量操作保持同一套字段):
# ① 手动挑几个 → account_ids;② 按筛选全选 → select_all_matching + 各筛选条件
account_ids: list[int] = Field(default_factory=list)
select_all_matching: bool = False
search: str = ""
tag: str = ""
status: str = ""
ck_status: str = ""
bind_status: str = ""
concurrency: int = Field(3, ge=1, le=10) concurrency: int = Field(3, ge=1, le=10)
max_login_retries: int = Field(0, ge=0, le=50) max_login_retries: int = Field(0, ge=0, le=50)
max_total_time: float = Field(300, ge=0, le=3600) max_total_time: float = Field(300, ge=0, le=3600)
@@ -298,6 +314,7 @@ class AccountCheckItemOut(BaseModel):
line: int line: int
username: str username: str
email: str email: str
account_id: int | None = None
status: str status: str
message: str message: str
started_at: datetime | None = None started_at: datetime | None = None
@@ -12,11 +12,15 @@ from datetime import UTC, datetime
from types import SimpleNamespace from types import SimpleNamespace
from typing import cast from typing import cast
from loguru import logger
from core.douyu import DouyuLogin, WgapiLoginAPI from core.douyu import DouyuLogin, WgapiLoginAPI
from core.douyu.login import AccountLike from core.douyu.login import AccountLike
from core.douyu.proxy_fetcher import ProxyFetcher from core.douyu.proxy_fetcher import ProxyFetcher
from ..database import SessionLocal
from ..models import ProxyConfig as ProxyConfigModel from ..models import ProxyConfig as ProxyConfigModel
from .account_events_service import CATEGORY_AUTH, emit
def _now() -> datetime: def _now() -> datetime:
@@ -24,6 +28,10 @@ def _now() -> datetime:
return datetime.now(UTC) return datetime.now(UTC)
#: 账号检测结果写进账号时间线的事件类型(仅「按账号选择」模式会有,
#: 模式下手动粘贴的文本**没有 account_id**,无从归属,保持原样不落库)。
EVENT_ACCOUNT_CHECK = "account_check"
STATUS_LABELS = { STATUS_LABELS = {
"account_cancelled": "账号已注销", "account_cancelled": "账号已注销",
"password_wrong": "账号密码错误", "password_wrong": "账号密码错误",
@@ -54,6 +62,9 @@ class AccountCheckInput:
password: str password: str
email: str email: str
email_password: str email_password: str
#: 「按账号选择」模式下才有:把检测结果归回库里的账号。
#: 手动粘贴的文本没有它 —— 那正是旧模式与账号域脱节的地方,保留原样。
account_id: int | None = None
def export_line(self) -> str: def export_line(self) -> str:
"""导出为统一四段格式。""" """导出为统一四段格式。"""
@@ -68,6 +79,7 @@ class AccountCheckItemState:
username: str username: str
email: str email: str
export_text: str export_text: str
account_id: int | None = None
status: str = "pending" status: str = "pending"
message: str = "等待开始" message: str = "等待开始"
started_at: datetime | None = None started_at: datetime | None = None
@@ -78,6 +90,7 @@ class AccountCheckItemState:
"line": self.line, "line": self.line,
"username": self.username, "username": self.username,
"email": self.email, "email": self.email,
"account_id": self.account_id,
"status": self.status, "status": self.status,
"message": self.message, "message": self.message,
"started_at": self.started_at, "started_at": self.started_at,
@@ -103,6 +116,36 @@ class AccountCheckBatch:
finished_at: datetime | None = None finished_at: datetime | None = None
def build_inputs_from_accounts(
accounts: list, account_ids: list[int]
) -> list[AccountCheckInput]:
"""把库里的账号转成检测输入(**按账号选择**模式)。
凭据直接取账号自己的字段 —— 这正是与旧模式的区别:旧模式要人工把凭据
粘贴进来,检测完结果无处可归;这里每个条目都带着 `account_id`,
结果能写回该账号的时间线。
只接受调用方已经按可见性筛过的账号列表(路由层负责过滤)。
"""
by_id = {int(a.id): a for a in accounts}
inputs: list[AccountCheckInput] = []
for line_no, account_id in enumerate(account_ids, 1):
account = by_id.get(int(account_id))
if account is None:
continue
inputs.append(
AccountCheckInput(
line=line_no,
username=str(account.username or ""),
password=str(account.password or ""),
email=str(account.email or ""),
email_password=str(account.email_password or ""),
account_id=int(account.id),
)
)
return inputs
def parse_account_check_lines(text: str) -> list[AccountCheckInput]: def parse_account_check_lines(text: str) -> list[AccountCheckInput]:
"""解析账号检测导入文本,支持 ---- 和 | 两种分隔符。""" """解析账号检测导入文本,支持 ---- 和 | 两种分隔符。"""
accounts: list[AccountCheckInput] = [] accounts: list[AccountCheckInput] = []
@@ -210,6 +253,54 @@ class AccountCheckRunner:
item = self.batch.items[index] item = self.batch.items[index]
for key, value in updates.items(): for key, value in updates.items():
setattr(item, key, value) setattr(item, key, value)
# ⚠️ 必须判**非空**而不是「键存在」:`_run_one` 置 running 时会显式传
# `finished_at=None`,用 `in` 判断会把中间态也当终态 —— 于是事件记为
# 「running」,而真正的终态撞同一个幂等键被静默丢弃(只剩一条错的)。
snapshot = dict(item.to_dict()) if updates.get("finished_at") else None
# 终态(带 finished_at)且这个条目归属于某个库内账号时,把结果写进它的时间线。
# 手动粘贴的条目没有 account_id → 保持旧行为(只在批次里 / 导出 zip),
# 这正是「旧逻辑保留」的部分。
if snapshot and snapshot.get("account_id"):
self._emit_result(snapshot)
def _emit_result(self, item: dict) -> None:
"""把一个条目的检测结果写成账号事件。
**绝不影响检测**:事件是旁路,任何失败都只记日志。
线程里自己开一个会话(批次跑在后台线程,不能借用请求的 session)。
"""
status = str(item.get("status") or "")
message = str(item.get("message") or "")
try:
db = SessionLocal()
try:
emit(
db,
account_id=int(item["account_id"]),
event_type=EVENT_ACCOUNT_CHECK,
title=f"账号检测:{STATUS_LABELS.get(status, status)}",
category=CATEGORY_AUTH,
occurred_at=item.get("finished_at") or _now(),
detail={
"batch_id": self.batch.batch_id,
"status": status,
"message": message,
},
source="account_check",
# 幂等键带上批次:同一次检测重试/重放不会写两条,
# 但再次检测同一账号是**新的事实**,应该各留一条。
source_ref=(
f"account_check:{self.batch.batch_id}:{item['account_id']}"
),
)
db.commit()
finally:
db.close()
except Exception as exc: # noqa: BLE001 旁路,绝不能影响检测
logger.warning(
f"账号检测结果写事件失败(account={item.get('account_id')}):{exc}"
)
def _set_batch(self, **updates): def _set_batch(self, **updates):
with self._lock: with self._lock:
@@ -411,6 +502,9 @@ class AccountCheckRegistry:
username=account.username, username=account.username,
email=account.email, email=account.email,
export_text=account.export_line(), export_text=account.export_line(),
# ⚠️ 必须带上:`_set_item` 靠它决定要不要把结果写进账号时间线。
# 漏了这一行 → 条目看着有账号,事件却永远不发(静默)。
account_id=account.account_id,
) )
for account in accounts for account in accounts
], ],
+12
View File
@@ -363,7 +363,17 @@ export interface BatchLoginResult {
// ==================== Account Check ==================== // ==================== Account Check ====================
export interface AccountCheckBatchRequest { export interface AccountCheckBatchRequest {
/** 手动粘贴(旧模式):条目无账号归属,结果只在批次与导出 zip 里 */
text: string; text: string;
/** 按账号选择(新模式):凭据从库里取,结果写回账号时间线。与 text 二选一 */
account_ids?: number[];
/** 按当前筛选全选(与账号列表的批量操作同款语义,含未列出的) */
select_all_matching?: boolean;
search?: string;
tag?: string;
status?: string;
ck_status?: string;
bind_status?: string;
concurrency?: number; concurrency?: number;
max_login_retries?: number; max_login_retries?: number;
max_total_time?: number; max_total_time?: number;
@@ -371,6 +381,8 @@ export interface AccountCheckBatchRequest {
} }
export interface AccountCheckItem { export interface AccountCheckItem {
/** 按账号选择时才有:用于回链到账号详情 */
account_id?: number | null;
line: number; line: number;
username: string; username: string;
email: string; email: string;
+190 -11
View File
@@ -1,11 +1,12 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { import {
Button, Card, Col, Input, InputNumber, Popconfirm, Row, Space, Statistic, Switch, Table, Tag, Typography, Button, Radio, Select, Card, Col, Input, InputNumber, Popconfirm, Row, Space, Statistic, Switch, Table, Tag, Typography,
} from 'antd'; } from 'antd';
import { message } from '../utils/antdMessage'; import { message } from '../utils/antdMessage';
import type { TableProps } from 'antd'; import type { TableProps } from 'antd';
import { DownloadOutlined, PlayCircleOutlined, ReloadOutlined, StopOutlined } from '@ant-design/icons'; import { DownloadOutlined, PlayCircleOutlined, ReloadOutlined, StopOutlined } from '@ant-design/icons';
import { accountCheckApi, type AccountCheckBatch, type AccountCheckItem } from '../api/modules'; import { accountApi, accountCheckApi, type AccountCheckBatch, type AccountCheckItem } from '../api/modules';
import { formatTime } from '../utils/time'; import { formatTime } from '../utils/time';
import { getErrorMessage } from '../utils/error'; import { getErrorMessage } from '../utils/error';
@@ -85,11 +86,23 @@ function fileTimestamp() {
} }
export default function AccountCheckPage() { export default function AccountCheckPage() {
// 两种来源:手动粘贴(旧,默认)与按账号选择(新)。
// 旧模式**完全保留** —— 包括它的解析、批次、导出逻辑。
const [sourceMode, setSourceMode] = useState<'text' | 'accounts'>('text');
const [selectedAccountIds, setSelectedAccountIds] = useState<number[]>([]);
const [accountOptions, setAccountOptions] = useState<{ value: number; label: string }[]>([]);
// 「选择账号」的筛选:与账号列表同一套条件,避免两处口径不一致
const [pickAllMatching, setPickAllMatching] = useState(false);
const [pickTag, setPickTag] = useState('');
const [pickStatus, setPickStatus] = useState('');
const [pickCk, setPickCk] = useState('');
const [tagOptions, setTagOptions] = useState<string[]>([]);
const [text, setText] = useState(readStoredText); const [text, setText] = useState(readStoredText);
const [concurrency, setConcurrency] = useState(() => readStoredNumber('account_check_concurrency', 3)); const [concurrency, setConcurrency] = useState(() => readStoredNumber('account_check_concurrency', 3));
const [maxTotalTime, setMaxTotalTime] = useState(() => readStoredNumber('account_check_max_total_time', 300)); const [maxTotalTime, setMaxTotalTime] = useState(() => readStoredNumber('account_check_max_total_time', 300));
const [maxLoginRetries, setMaxLoginRetries] = useState(() => readStoredNumber('account_check_max_login_retries', 0)); const [maxLoginRetries, setMaxLoginRetries] = useState(() => readStoredNumber('account_check_max_login_retries', 0));
const [useProxy, setUseProxy] = useState(() => localStorage.getItem('account_check_use_proxy') === 'true'); const [useProxy, setUseProxy] = useState(() => localStorage.getItem('account_check_use_proxy') === 'true');
const navigate = useNavigate();
const [batch, setBatch] = useState<AccountCheckBatch | null>(null); const [batch, setBatch] = useState<AccountCheckBatch | null>(null);
const [starting, setStarting] = useState(false); const [starting, setStarting] = useState(false);
const [refreshing, setRefreshing] = useState(false); const [refreshing, setRefreshing] = useState(false);
@@ -172,15 +185,64 @@ export default function AccountCheckPage() {
{ title: '已认证', value: statusCounts.account_verified || 0 }, { title: '已认证', value: statusCounts.account_verified || 0 },
], [statusCounts]); ], [statusCounts]);
useEffect(() => {
if (sourceMode !== 'accounts') return;
let alive = true;
void (async () => {
try {
const data = await accountApi.listPaged({
page: 1,
page_size: 200,
search: '',
tag: pickTag || undefined,
status: pickStatus || undefined,
ck_status: pickCk || undefined,
});
if (!alive) return;
setAccountOptions(
data.items.map((item) => ({
value: item.id,
label: `${item.username}(#${item.id})`,
})),
);
} catch (e: unknown) {
message.error(getErrorMessage(e));
}
})();
return () => { alive = false; };
}, [sourceMode, pickTag, pickStatus, pickCk]);
useEffect(() => {
if (sourceMode !== 'accounts' || tagOptions.length > 0) return;
void accountApi
.listTags()
.then(setTagOptions)
.catch(() => {
// 标签拉不到不影响检测,退化为无标签筛选
});
}, [sourceMode, tagOptions.length]);
const handleStart = async () => { const handleStart = async () => {
if (!text.trim()) { if (sourceMode === 'accounts') {
if (!pickAllMatching && selectedAccountIds.length === 0) {
message.warning('请先选择要检测的账号,或打开「按筛选全选」');
return;
}
} else if (!text.trim()) {
message.warning('请先导入账号'); message.warning('请先导入账号');
return; return;
} }
setStarting(true); setStarting(true);
try { try {
const data = await accountCheckApi.start({ const data = await accountCheckApi.start({
text, // 二选一:账号模式不传 text,文本模式不传 account_ids
text: sourceMode === 'text' ? text : '',
account_ids:
sourceMode === 'accounts' && !pickAllMatching ? selectedAccountIds : [],
select_all_matching: sourceMode === 'accounts' && pickAllMatching,
tag: pickTag,
status: pickStatus,
ck_status: pickCk,
concurrency, concurrency,
max_total_time: maxTotalTime, max_total_time: maxTotalTime,
max_login_retries: maxLoginRetries, max_login_retries: maxLoginRetries,
@@ -226,7 +288,26 @@ export default function AccountCheckPage() {
const columns: TableProps<AccountCheckItem>['columns'] = [ const columns: TableProps<AccountCheckItem>['columns'] = [
{ title: '行', dataIndex: 'line', width: 70 }, { title: '行', dataIndex: 'line', width: 70 },
{ title: '账号', dataIndex: 'username', width: 180, ellipsis: true }, {
title: '账号',
dataIndex: 'username',
width: 180,
ellipsis: true,
// 「按账号选择」模式下条目带 account_id → 回链到账号详情,
// 一眼从检测结果跳到该账号(结果也会在那里的时间线上)。
render: (username: string, record: AccountCheckItem) => (
record.account_id ? (
<Button
type="link"
size="small"
style={{ padding: 0 }}
onClick={() => navigate(`/accounts/${record.account_id}`)}
>
{username}
</Button>
) : username
),
},
{ title: '邮箱', dataIndex: 'email', width: 220, ellipsis: true }, { title: '邮箱', dataIndex: 'email', width: 220, ellipsis: true },
{ {
title: '状态', title: '状态',
@@ -251,15 +332,113 @@ export default function AccountCheckPage() {
<Space orientation="vertical" size={16} style={{ width: '100%' }}> <Space orientation="vertical" size={16} style={{ width: '100%' }}>
<Title level={3} style={{ margin: 0 }}>账号检测</Title> <Title level={3} style={{ margin: 0 }}>账号检测</Title>
<Card title="导入账号"> <Card title="选择账号来源">
<Space orientation="vertical" size={12} style={{ width: '100%' }}> <Space orientation="vertical" size={12} style={{ width: '100%' }}>
<TextArea {/* 两种来源并存:手动粘贴是旧逻辑,一条没动;
rows={9} 按账号选择是新增的,凭据从库里取、结果写回账号时间线。 */}
value={text} <Radio.Group
onChange={(event) => setText(event.target.value)} value={sourceMode}
placeholder={'用户3751569049----19910724----eRRWGm@nnw.pw----aa778899\n用户1508769674|aa778899|CBXFbx@nnw.pw|aa778899'} onChange={(event) => setSourceMode(event.target.value)}
disabled={isRunning} disabled={isRunning}
optionType="button"
buttonStyle="solid"
options={[
{ value: 'text', label: '手动输入' },
{ value: 'accounts', label: '选择账号' },
]}
/> />
{sourceMode === 'text' ? (
<TextArea
rows={9}
value={text}
onChange={(event) => setText(event.target.value)}
placeholder={'用户3751569049----19910724----eRRWGm@nnw.pw----aa778899\n用户1508769674|aa778899|CBXFbx@nnw.pw|aa778899'}
disabled={isRunning}
/>
) : (
<Space orientation="vertical" size={8} style={{ width: '100%' }}>
{/* 先按条件缩小范围,再决定「全选」还是「挑几个」——
与账号列表同一套筛选条件(标签/状态/CK)。 */}
<Space wrap size={8}>
<Select
allowClear
size="small"
style={{ width: 150 }}
placeholder="按标签筛选"
value={pickTag || undefined}
onChange={(v) => { setPickTag(v || ''); setSelectedAccountIds([]); }}
options={tagOptions.map((v) => ({ value: v, label: v }))}
disabled={isRunning}
/>
<Select
allowClear
size="small"
style={{ width: 130 }}
placeholder="按状态筛选"
value={pickStatus || undefined}
onChange={(v) => { setPickStatus(v || ''); setSelectedAccountIds([]); }}
options={[
{ value: 'active', label: '正常' },
{ value: 'invalid', label: 'CK 失效' },
{ value: 'manual', label: '待人工' },
{ value: 'imported', label: '未登录' },
{ value: 'disabled', label: '已停用' },
]}
disabled={isRunning}
/>
<Select
allowClear
size="small"
style={{ width: 120 }}
placeholder="按 CK 筛选"
value={pickCk || undefined}
onChange={(v) => { setPickCk(v || ''); setSelectedAccountIds([]); }}
options={[
{ value: 'invalid', label: 'CK 失效' },
{ value: 'valid', label: 'CK 正常' },
{ value: 'error', label: '检测异常' },
]}
disabled={isRunning}
/>
<Space size={4}>
<Switch
size="small"
checked={pickAllMatching}
onChange={setPickAllMatching}
disabled={isRunning}
/>
<Text>按筛选全选</Text>
</Space>
</Space>
{pickAllMatching ? (
<Text type="secondary">
将检测当前筛选条件下的全部账号(含列表里没显示出来的)。
</Text>
) : (
<Select
mode="multiple"
allowClear
showSearch
optionFilterProp="label"
style={{ width: '100%' }}
placeholder={
accountOptions.length
? `从筛选结果里选择(共 ${accountOptions.length} 个可挑)`
: '当前筛选下没有账号'
}
value={selectedAccountIds}
onChange={setSelectedAccountIds}
options={accountOptions}
disabled={isRunning}
maxTagCount="responsive"
/>
)}
<Text type="secondary">
凭据从账号库里取,结果会写进该账号的时间线;手动输入的账号没有归属,
结果只在批次与导出 zip 里(与以前一致)。
</Text>
</Space>
)}
<Row gutter={[12, 12]} align="bottom"> <Row gutter={[12, 12]} align="bottom">
<Col xs={8} md={4}> <Col xs={8} md={4}>
<Text type="secondary">并发</Text> <Text type="secondary">并发</Text>