From eec42aa8a7bbdd078ff0e0bba80f478deb521c64 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Sun, 4 Oct 2026 17:34:58 +0800 Subject: [PATCH] =?UTF-8?q?feat(check):=20=E8=B4=A6=E5=8F=B7=E6=A3=80?= =?UTF-8?q?=E6=B5=8B=E6=96=B0=E5=A2=9E=E3=80=8C=E6=8C=89=E8=B4=A6=E5=8F=B7?= =?UTF-8?q?=E9=80=89=E6=8B=A9=E3=80=8D=E2=80=94=E2=80=94=20=E6=97=A7?= =?UTF-8?q?=E7=9A=84=E6=89=8B=E5=8A=A8=E8=BE=93=E5=85=A5=E4=B8=8E=E9=80=BB?= =?UTF-8?q?=E8=BE=91=E5=AE=8C=E6=95=B4=E4=BF=9D=E7=95=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按反馈是**增加**而不是**替换**:旧路径一行未动,新路径另起一条。 ## 两套来源并存 | | 手动输入(旧,默认) | 选择账号(新) | | --- | --- | --- | | 来源 | 粘贴文本 | 库里账号的凭据(不用人工粘贴) | | 条目 `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) 注:验证时真实触发了几次斗鱼登录检测(开发库那两个账号),结果都是「已认证」。 --- tests/test_account_check_modes.py | 332 ++++++++++++++++++ web/backend/routers/account_check.py | 61 +++- web/backend/schemas.py | 19 +- web/backend/services/account_check_service.py | 94 +++++ web/frontend/src/api/types.ts | 12 + web/frontend/src/pages/AccountCheckPage.tsx | 201 ++++++++++- 6 files changed, 701 insertions(+), 18 deletions(-) create mode 100644 tests/test_account_check_modes.py diff --git a/tests/test_account_check_modes.py b/tests/test_account_check_modes.py new file mode 100644 index 0000000..cefbcc0 --- /dev/null +++ b/tests/test_account_check_modes.py @@ -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] diff --git a/web/backend/routers/account_check.py b/web/backend/routers/account_check.py index cd44842..3d6c660 100644 --- a/web/backend/routers/account_check.py +++ b/web/backend/routers/account_check.py @@ -10,12 +10,13 @@ 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 ..models import User 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, ) @@ -45,12 +46,60 @@ def create_account_check_batch( db: Session = Depends(get_db), current: User = Depends(get_current_user), ): - """启动斗鱼账号检测批次。""" + """启动斗鱼账号检测批次。 + + 两种来源**二选一**: + - `text`:手动粘贴(旧模式,逻辑一字未动); + - `account_ids`:按账号选择(新模式)—— 凭据从库里取,结果写回账号时间线。 + """ _require_account_check_perm(current) - try: - accounts = parse_account_check_lines(req.text) - except ValueError as exc: - raise HTTPException(status_code=400, detail=str(exc)) from exc + 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="没有识别到有效账号") diff --git a/web/backend/schemas.py b/web/backend/schemas.py index a451000..48ca1bd 100644 --- a/web/backend/schemas.py +++ b/web/backend/schemas.py @@ -287,7 +287,23 @@ class LoginTaskOut(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) max_login_retries: int = Field(0, ge=0, le=50) max_total_time: float = Field(300, ge=0, le=3600) @@ -298,6 +314,7 @@ class AccountCheckItemOut(BaseModel): line: int username: str email: str + account_id: int | None = None status: str message: str started_at: datetime | None = None diff --git a/web/backend/services/account_check_service.py b/web/backend/services/account_check_service.py index 0e50bac..41548c4 100644 --- a/web/backend/services/account_check_service.py +++ b/web/backend/services/account_check_service.py @@ -12,11 +12,15 @@ from datetime import UTC, datetime from types import SimpleNamespace from typing import cast +from loguru import logger + from core.douyu import DouyuLogin, WgapiLoginAPI from core.douyu.login import AccountLike from core.douyu.proxy_fetcher import ProxyFetcher +from ..database import SessionLocal from ..models import ProxyConfig as ProxyConfigModel +from .account_events_service import CATEGORY_AUTH, emit def _now() -> datetime: @@ -24,6 +28,10 @@ def _now() -> datetime: return datetime.now(UTC) +#: 账号检测结果写进账号时间线的事件类型(仅「按账号选择」模式会有, +#: 模式下手动粘贴的文本**没有 account_id**,无从归属,保持原样不落库)。 +EVENT_ACCOUNT_CHECK = "account_check" + STATUS_LABELS = { "account_cancelled": "账号已注销", "password_wrong": "账号密码错误", @@ -54,6 +62,9 @@ class AccountCheckInput: password: str email: str email_password: str + #: 「按账号选择」模式下才有:把检测结果归回库里的账号。 + #: 手动粘贴的文本没有它 —— 那正是旧模式与账号域脱节的地方,保留原样。 + account_id: int | None = None def export_line(self) -> str: """导出为统一四段格式。""" @@ -68,6 +79,7 @@ class AccountCheckItemState: username: str email: str export_text: str + account_id: int | None = None status: str = "pending" message: str = "等待开始" started_at: datetime | None = None @@ -78,6 +90,7 @@ class AccountCheckItemState: "line": self.line, "username": self.username, "email": self.email, + "account_id": self.account_id, "status": self.status, "message": self.message, "started_at": self.started_at, @@ -103,6 +116,36 @@ class AccountCheckBatch: 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]: """解析账号检测导入文本,支持 ---- 和 | 两种分隔符。""" accounts: list[AccountCheckInput] = [] @@ -210,6 +253,54 @@ class AccountCheckRunner: item = self.batch.items[index] for key, value in updates.items(): 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): with self._lock: @@ -411,6 +502,9 @@ class AccountCheckRegistry: username=account.username, email=account.email, export_text=account.export_line(), + # ⚠️ 必须带上:`_set_item` 靠它决定要不要把结果写进账号时间线。 + # 漏了这一行 → 条目看着有账号,事件却永远不发(静默)。 + account_id=account.account_id, ) for account in accounts ], diff --git a/web/frontend/src/api/types.ts b/web/frontend/src/api/types.ts index 47228ad..f7b94ce 100644 --- a/web/frontend/src/api/types.ts +++ b/web/frontend/src/api/types.ts @@ -363,7 +363,17 @@ export interface BatchLoginResult { // ==================== Account Check ==================== export interface AccountCheckBatchRequest { + /** 手动粘贴(旧模式):条目无账号归属,结果只在批次与导出 zip 里 */ text: string; + /** 按账号选择(新模式):凭据从库里取,结果写回账号时间线。与 text 二选一 */ + account_ids?: number[]; + /** 按当前筛选全选(与账号列表的批量操作同款语义,含未列出的) */ + select_all_matching?: boolean; + search?: string; + tag?: string; + status?: string; + ck_status?: string; + bind_status?: string; concurrency?: number; max_login_retries?: number; max_total_time?: number; @@ -371,6 +381,8 @@ export interface AccountCheckBatchRequest { } export interface AccountCheckItem { + /** 按账号选择时才有:用于回链到账号详情 */ + account_id?: number | null; line: number; username: string; email: string; diff --git a/web/frontend/src/pages/AccountCheckPage.tsx b/web/frontend/src/pages/AccountCheckPage.tsx index 9b0ce5c..4521395 100644 --- a/web/frontend/src/pages/AccountCheckPage.tsx +++ b/web/frontend/src/pages/AccountCheckPage.tsx @@ -1,11 +1,12 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; 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'; import { message } from '../utils/antdMessage'; import type { TableProps } from 'antd'; 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 { getErrorMessage } from '../utils/error'; @@ -85,11 +86,23 @@ function fileTimestamp() { } export default function AccountCheckPage() { + // 两种来源:手动粘贴(旧,默认)与按账号选择(新)。 + // 旧模式**完全保留** —— 包括它的解析、批次、导出逻辑。 + const [sourceMode, setSourceMode] = useState<'text' | 'accounts'>('text'); + const [selectedAccountIds, setSelectedAccountIds] = useState([]); + 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([]); const [text, setText] = useState(readStoredText); const [concurrency, setConcurrency] = useState(() => readStoredNumber('account_check_concurrency', 3)); const [maxTotalTime, setMaxTotalTime] = useState(() => readStoredNumber('account_check_max_total_time', 300)); const [maxLoginRetries, setMaxLoginRetries] = useState(() => readStoredNumber('account_check_max_login_retries', 0)); const [useProxy, setUseProxy] = useState(() => localStorage.getItem('account_check_use_proxy') === 'true'); + const navigate = useNavigate(); const [batch, setBatch] = useState(null); const [starting, setStarting] = useState(false); const [refreshing, setRefreshing] = useState(false); @@ -172,15 +185,64 @@ export default function AccountCheckPage() { { title: '已认证', value: statusCounts.account_verified || 0 }, ], [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 () => { - if (!text.trim()) { + if (sourceMode === 'accounts') { + if (!pickAllMatching && selectedAccountIds.length === 0) { + message.warning('请先选择要检测的账号,或打开「按筛选全选」'); + return; + } + } else if (!text.trim()) { message.warning('请先导入账号'); return; } setStarting(true); try { 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, max_total_time: maxTotalTime, max_login_retries: maxLoginRetries, @@ -226,7 +288,26 @@ export default function AccountCheckPage() { const columns: TableProps['columns'] = [ { 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 ? ( + + ) : username + ), + }, { title: '邮箱', dataIndex: 'email', width: 220, ellipsis: true }, { title: '状态', @@ -251,15 +332,113 @@ export default function AccountCheckPage() { 账号检测 - + -