From 00125fbfc4e7160caa439bf1d1d033f4e27bcb0a Mon Sep 17 00:00:00 2001 From: yml2213 Date: Sun, 4 Oct 2026 14:10:13 +0800 Subject: [PATCH] =?UTF-8?q?refactor(account):=20S2=20=E8=AF=BB=E5=86=99?= =?UTF-8?q?=E4=B8=80=E6=AC=A1=E5=88=87=E6=8D=A2=20=E2=80=94=E2=80=94=20CK/?= =?UTF-8?q?=E5=81=A5=E5=BA=B7/=E9=87=8D=E7=99=BB=E5=BD=BB=E5=BA=95?= =?UTF-8?q?=E6=90=AC=E5=88=B0=E8=B4=A6=E5=8F=B7=EF=BC=8C=E7=99=BB=E5=BD=95?= =?UTF-8?q?=E8=AE=B0=E5=BD=95=E9=80=80=E5=8C=96=E4=B8=BA=E5=8E=86=E5=8F=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit S1 加好了列,本提交把**读与写一起切**过去。这是设计里「同一份数据的读与写必须 在同一个提交里切」的要求:先切读或先切写都会出现「写账号、读记录」的中间态, 跑起来不报错,只是默默用错数据。 ## 核心改动 **判定「谁有 CK」不再借道登录记录** - `cookie_holding_filter()` / `cookie_operable_filter()` / `cookie_holding_account_ids_query()` / `visible_douyu_task_accounts()` 全部改为作用于 `Account`; - `cookie_operable_filter()` 的「在途排除」仍查 `login_tasks`(那是**某次尝试**的属性), 用 `NOT EXISTS` 子查询 + 迁移 0042 新增的 `(account_id, attempt_status)` 索引; - 删除死代码 `latest_cookie_login_task()`;`latest_cookie()` 改为直接读账号。 **CK 写入** - `_write_login_success()` 写 `Account.cookie / cookie_updated_at / cookie_source`; - `_mark_attempt_failed()` 改看**账号**有没有 CK(决定是否沿用旧的失败语义); - 检测模式的 `task.cookie = ""` **不再清空账号的 CK**(见下「行为变更」)。 **自动化作业按账号调度** - `ck_health`:`_select_tasks` → `_select_accounts`,`db.query(Account)`;新增 `count_remaining()`(刻意不带 LIMIT,否则积压数恒等于批次上限); - `ck_relogin`:同样按账号选行;结算改为「账号 + 本轮尝试记录」, 退避进度写账号;`settle_fresh_results(batch_id)` 按 `attempt_batch_id` 配对, `run_ck_relogin` 把 **account_ids** 交给 runner; - 顺带让**没有任何登录历史的账号**也能被巡检/重登(以前它们永远进不了队列)。 **登录批次** - `LoginBatchRunner` 去掉 `relogin_task_ids`,重登与登录统一走 `account_ids`; - 批次行复用逻辑重写:只保证「同一账号不并发两行」,不再关心「哪一行持有 CK」。 失败行复用同时看 `attempt_status`(账号有 CK 时 `_mark_attempt_failed` 刻意不动 `status`,只看 status 会永远命不中 → 每次失败刷一行)。 **删除护栏语义变更(用户可感知)** - 去掉「持有 CK 的记录不可删」——CK 在账号上,删历史不再影响登录态,历史终于可清理; - **保留**「在途尝试不可删」(批次线程正拿着那条记录)。 ## 行为变更(需评审) 1. **账号检测不再清空 CK**。旧代码在 check 模式写 `task.cookie = ""`,而那时记录的 cookie 就是账号唯一凭证 —— 一次「批量检测」会把账号登录态删掉。解耦后 「检测账号凭据」与「CK 是否有效」本就是两件事(后者归 `ck_health` 判), 继续清空等于凭空制造不可逆的数据丢失(重登当前成功率极低)。 2. **Cookie 页的记录单位从「登录记录」变成「账号」**,所有接口的 id 入参/出参 变成 `account_id`;`DELETE /cookies/{id}` 清的是该账号的 CK,**必写审计**。 3. 重复检测从「同一账号多行持 CK」改为「**同一个 CK 出现在多个账号上**」—— 后者才是真风险(两个账号共用一个登录态)。用 sha256 指纹分组: CK 是 AES-GCM + 随机 nonce 加密,**同一明文每次密文都不同**,比密文永远比不出重复。 ## 修掉一个真实并发缺陷 `_write_login_success` 原先被传入 `self.db`(runner 主会话),而它是在**工作线程**里 用 `worker_db` 执行的。`emit()` 会往 `account_events` 插一行,其 `account_id` 外键对 `accounts` 那一行加**共享锁**;这把锁加在另一个会话上,紧接着本会话的 `UPDATE accounts SET cookie=...` 就被自己人挡住 —— 实测 **50 秒后 `Lock wait timeout exceeded`,登录明明成功却写不回 CK**(改造前 UPDATE 打在 `login_tasks` 上所以没撞上)。改为传 `worker_db`:事件本就该和状态变更同事务提交, 顺带修掉跨线程共用一个 Session 的隐患。 ## 验证 - 全量 pytest **368 passed / 6 skipped**;迁移链 3 passed; - 前端 `tsc -b && vite build` 通过; - `ruff check` / `ruff format --check` 全绿(顺带修掉 main 上遗留的 F821); - 新增/改写的关键回归:**删掉全部登录历史后账号的 CK 与健康状态仍在**、 **无登录历史的账号也能进巡检队列**、结算不碰 CK、删除护栏改为在途语义。 仍未做(设计里排期的后续阶段):`account_lifecycle` 状态收口(S3)、事件发射点补齐(S4)、 业务事实直发事件(S5)、账号页重构(S6)。注意 `status` 目前只有模型默认值与迁移回填, 所以 `disabled/archived` 还没有任何写入方——S3 落地时要把自动化选行的状态过滤一起补上。 --- tests/test_automation_ck_health.py | 218 ++++-- tests/test_automation_ck_relogin.py | 297 +++++--- tests/test_cookie_custom_order.py | 57 +- tests/test_cookie_operations.py | 185 ++--- tests/test_douyu_account_name_filter.py | 13 +- tests/test_douyu_cookie_visibility.py | 56 +- tests/test_douyu_gold_recharge_channel.py | 24 +- .../test_douyu_task_assigned_optimization.py | 24 +- tests/test_douyu_workbench_scopes.py | 13 +- tests/test_login_attempt_isolation.py | 49 +- tests/test_login_tasks_pagination.py | 122 ++-- web/backend/routers/accounts.py | 22 +- web/backend/routers/automation.py | 47 +- web/backend/routers/cookies.py | 644 +++++++++--------- web/backend/routers/dashboard.py | 10 +- web/backend/routers/douyu.py | 3 +- web/backend/routers/login.py | 75 +- web/backend/schemas.py | 2 +- .../services/automation/jobs/ck_health.py | 99 +-- .../services/automation/jobs/ck_relogin.py | 141 ++-- web/backend/services/douyu_runner.py | 13 +- web/backend/services/douyu_service.py | 66 +- web/backend/services/login_service.py | 235 ++++--- web/frontend/src/api/types.ts | 5 +- web/frontend/src/pages/AutomationPage.tsx | 2 +- web/frontend/src/pages/CookiePage.tsx | 26 +- 26 files changed, 1380 insertions(+), 1068 deletions(-) diff --git a/tests/test_automation_ck_health.py b/tests/test_automation_ck_health.py index 6a467ac..1cfa892 100644 --- a/tests/test_automation_ck_health.py +++ b/tests/test_automation_ck_health.py @@ -5,9 +5,15 @@ 1. **调度器行为**:只跑到点的、打开开关的作业;作业抛异常只记 failed,不能 把调度线程带崩;跑完排下一轮时间;已有配置不能被重启重置。 2. **本作业绝不改 CK**:P1a 的定位是「只检测、只标记」,所以巡检后 - ``cookie`` / ``status`` / ``message`` / ``attempt_*`` 必须一字未动 —— + **账号的** ``cookie`` / ``status`` / ``relogin_*`` 以及登录尝试历史的 + ``status`` / ``message`` / ``attempt_*`` 必须一字未动 —— 线上全靠这条保证「自动化上线了但人工流程照旧」,回归时一旦有人顺手写了 - ``status``,这里要立刻红。 + ``cookie`` 或 ``status``,这里要立刻红。 + +> CK 与健康/重登状态自 2026-10-04 起是 **``accounts`` 的列** +> (见 ``docs/设计-账号全生命周期.md`` §3.3);``login_tasks`` 退化为纯登录 +> 尝试历史,它的 ``cookie`` / ``ck_check_*`` / ``relogin_*`` 旧列留作回退、 +> 新代码不再读写,测试也不许再往上面写值。 """ from datetime import UTC, datetime, timedelta @@ -48,7 +54,28 @@ class _Base: def teardown_method(self): self.session.close() - def make_account(self, name="acc", *, auto_managed=True, assigned_to=None): + def make_account( + self, + name="acc", + *, + cookie="acf_uid=1", + status="active", + auto_managed=True, + assigned_to=None, + ck_check_status="", + ck_check_result=None, + ck_checked_at=None, + relogin_attempts=0, + relogin_next_at=None, + relogin_last_error="", + relogin_failure_kind="", + ): + """建一个账号。**CK 与健康/重登状态都是账号自己的列。** + + 默认 ``cookie="acf_uid=1"``(与迁移前 ``make_task`` 的默认一致): + 巡检的候选口径是「账号有 CK」,所以不显式给 cookie + 的账号本来就不该进队列 —— 要造这种账号就传 ``cookie=""``。 + """ account = Account( username=name, password="p", @@ -56,71 +83,98 @@ class _Base: email_password="ep", assigned_to=assigned_to, auto_managed=auto_managed, + cookie=cookie, + status=status, + ck_check_status=ck_check_status, + ck_check_result=ck_check_result, + ck_checked_at=ck_checked_at, + relogin_attempts=relogin_attempts, + relogin_next_at=relogin_next_at, + relogin_last_error=relogin_last_error, + relogin_failure_kind=relogin_failure_kind, ) self.session.add(account) self.session.commit() return account - def make_task( + def make_attempt( self, account, *, - cookie="acf_uid=1", status="success", + message="", attempt_status="", - ck_checked_at=None, - ck_check_status="", + attempt_message="", + attempt_batch_id="", + attempt_at=None, ): - task = LoginTask( + """建一条登录尝试历史(``LoginTask`` 已退化为纯尝试流水)。 + + 只写它保留下来的字段:``cookie`` / ``ck_check_*`` / ``relogin_*`` + 下一次发版就要 drop,测试再往上写值只会掩盖解耦没做干净。 + """ + attempt = LoginTask( batch_id="b1", account_id=account.id, created_by=self.admin.id, status=status, - cookie=cookie, + message=message, attempt_status=attempt_status, - ck_checked_at=ck_checked_at, - ck_check_status=ck_check_status, + attempt_message=attempt_message, + attempt_batch_id=attempt_batch_id, + attempt_at=attempt_at, ) - self.session.add(task) + self.session.add(attempt) self.session.commit() - return task + return attempt class TestCkHealthSelection(_Base): def _selected_ids(self, job): + """返回被选中**账号**的 id(解耦后按账号选行,不再按登录记录选)。""" return [ - t.id for t in ck_health._select_tasks(self.session, job, datetime.now(UTC)) + a.id + for a in ck_health._select_accounts(self.session, job, datetime.now(UTC)) ] - def test_picks_only_rows_that_still_hold_cookie(self): + def test_picks_only_accounts_that_still_hold_cookie(self): job = self._job() - with_cookie = self.make_task(self.make_account("a")) - self.make_task(self.make_account("b"), cookie="") + with_cookie = self.make_account("a") + self.make_account("b", cookie="") assert self._selected_ids(job) == [with_cookie.id] def test_skips_accounts_with_inflight_attempt(self): job = self._job() - idle = self.make_task(self.make_account("a")) - self.make_task(self.make_account("b"), attempt_status="running") - self.make_task(self.make_account("c"), attempt_status="pending") + idle = self.make_account("a") + running = self.make_account("b") + self.make_attempt(running, attempt_status="running") + pending = self.make_account("c") + self.make_attempt(pending, attempt_status="pending") assert self._selected_ids(job) == [idle.id] def test_skips_accounts_opted_out_of_automation(self): job = self._job() - managed = self.make_task(self.make_account("a")) - self.make_task(self.make_account("b", auto_managed=False)) + managed = self.make_account("a") + self.make_account("b", auto_managed=False) assert self._selected_ids(job) == [managed.id] + def test_account_without_login_history_is_selectable(self): + """回归:解耦前选行是 ``LoginTask JOIN Account``,从没登录过的账号永远进不了队列。 + + CK 现在挂在账号上,选行只按账号判定 —— 没有尝试历史的账号也必须被巡检。 + """ + job = self._job() + account = self.make_account("only-account") + assert self.session.query(LoginTask).count() == 0 + assert self._selected_ids(job) == [account.id] + def test_respects_interval_and_prefers_stalest(self): job = self._job() - fresh = self.make_task( - self.make_account("fresh"), ck_checked_at=datetime.now(UTC) + fresh = self.make_account("fresh", ck_checked_at=datetime.now(UTC)) + stale = self.make_account( + "stale", ck_checked_at=datetime.now(UTC) - timedelta(hours=30) ) - stale = self.make_task( - self.make_account("stale"), - ck_checked_at=datetime.now(UTC) - timedelta(hours=30), - ) - never = self.make_task(self.make_account("never"), ck_checked_at=None) + never = self.make_account("never", ck_checked_at=None) ids = self._selected_ids(job) assert fresh.id not in ids # 从没检测过的排最前,其次是久未检测的 @@ -147,7 +201,7 @@ class TestCkHealthSelection(_Base): job = self._job() job.batch_size = 2 for index in range(5): - self.make_task(self.make_account(f"a{index}")) + self.make_account(f"a{index}") assert len(self._selected_ids(job)) == 2 def _job(self): @@ -159,9 +213,40 @@ class TestCkHealthSelection(_Base): class TestCkHealthNeverTouchesCookie(_Base): def test_patrol_only_writes_check_fields(self): job = self._job() - account = self.make_account("acc") - task = self.make_task(account, attempt_status="failed", ck_check_status="") - before = (task.cookie, task.status, task.message, task.attempt_status) + # 账号自带 CK、生命周期状态与重登进度;巡检只许动 ck_check_* 三件套。 + account = self.make_account( + "acc", + cookie="acf_uid=1", + status="active", + relogin_attempts=2, + relogin_next_at=datetime.now(UTC) + timedelta(hours=1), + relogin_last_error="上次登录失败", + relogin_failure_kind="network", + ) + attempt = self.make_attempt( + account, + status="success", + message="历史登录结果", + attempt_status="failed", + attempt_message="历史失败", + attempt_batch_id="b0", + ) + before = ( + account.cookie, + account.status, + account.relogin_attempts, + account.relogin_next_at, + account.relogin_last_error, + account.relogin_failure_kind, + ) + before_attempt = ( + attempt.status, + attempt.message, + attempt.attempt_status, + attempt.attempt_message, + attempt.attempt_batch_id, + attempt.attempt_at, + ) ck_health.check_douyu_cookie = _fake_check(valid=True) try: @@ -169,17 +254,34 @@ class TestCkHealthNeverTouchesCookie(_Base): finally: ck_health.check_douyu_cookie = _REAL_CHECK - self.session.refresh(task) + self.session.refresh(account) + self.session.refresh(attempt) assert counters["scanned"] == 1 and counters["valid"] == 1 - assert task.ck_check_status == "valid" - assert task.ck_checked_at is not None - assert task.ck_check_result["nickname"] == "测试" - # 核心断言:CK 本体与尝试状态一字未动 - assert (task.cookie, task.status, task.message, task.attempt_status) == before + assert account.ck_check_status == "valid" + assert account.ck_checked_at is not None + assert account.ck_check_result["nickname"] == "测试" + # 核心断言:账号的 CK 本体、生命周期状态、重登进度一字未动 + assert ( + account.cookie, + account.status, + account.relogin_attempts, + account.relogin_next_at, + account.relogin_last_error, + account.relogin_failure_kind, + ) == before + # 登录尝试历史同样不是巡检该碰的东西 + assert ( + attempt.status, + attempt.message, + attempt.attempt_status, + attempt.attempt_message, + attempt.attempt_batch_id, + attempt.attempt_at, + ) == before_attempt def test_invalid_result_marks_invalid_but_keeps_cookie(self): job = self._job() - task = self.make_task(self.make_account("acc")) + account = self.make_account("acc", cookie="acf_uid=1") # 必须是鉴权类失败(斗鱼说未登录)才会被标成 invalid ck_health.check_douyu_cookie = _fake_check( valid=False, @@ -189,29 +291,29 @@ class TestCkHealthNeverTouchesCookie(_Base): counters = ck_health.run_ck_health(self.session, job) finally: ck_health.check_douyu_cookie = _REAL_CHECK - self.session.refresh(task) + self.session.refresh(account) assert counters["invalid"] == 1 - assert task.ck_check_status == "invalid" - assert task.cookie == "acf_uid=1" + assert account.ck_check_status == "invalid" + assert account.cookie == "acf_uid=1" def test_detection_exception_does_not_abort_round(self): """单个账号检测抛异常不影响整轮,但也不能把它标成失效。""" job = self._job() - first = self.make_task(self.make_account("a"), ck_check_status="valid") - second = self.make_task(self.make_account("b"), ck_check_status="valid") + first = self.make_account("a", ck_check_status="valid") + second = self.make_account("b", ck_check_status="valid") ck_health.check_douyu_cookie = _fake_check(raises=True) try: counters = ck_health.run_ck_health(self.session, job) finally: ck_health.check_douyu_cookie = _REAL_CHECK assert counters["scanned"] == 2 - # 查不到不等于失效:两行都保留原结论,只记录这次异常 + # 查不到不等于失效:两个账号都保留原结论,只记录这次异常 assert counters["invalid"] == 0 assert counters["skipped"] == 2 - for task in (first, second): - self.session.refresh(task) - assert task.ck_check_status == "valid" - assert task.ck_check_result["verdict"] == "error" + for account in (first, second): + self.session.refresh(account) + assert account.ck_check_status == "valid" + assert account.ck_check_result["verdict"] == "error" def _job(self): return AutomationJob( @@ -448,7 +550,7 @@ class TestAutomationApi(_Base): def test_run_endpoint_records_run_and_audit(self): api = self._api() - self.make_task(self.make_account("a")) + self.make_account("a") ck_health.check_douyu_cookie = _fake_check(valid=True) try: outcome = api.run_job( @@ -528,7 +630,7 @@ class TestFailureClassification(_Base): def test_error_keeps_previous_verdict_but_marks_checked(self): job = self._job() - task = self.make_task(self.make_account("acc"), ck_check_status="valid") + account = self.make_account("acc", ck_check_status="valid") ck_health.check_douyu_cookie = _fake_check( valid=False, message="请求失败: timeout" ) @@ -536,19 +638,19 @@ class TestFailureClassification(_Base): counters = ck_health.run_ck_health(self.session, job) finally: ck_health.check_douyu_cookie = _REAL_CHECK - self.session.refresh(task) + self.session.refresh(account) assert counters["valid"] == 0 and counters["invalid"] == 0 assert counters["skipped"] == 1 # error 计入 skipped - assert task.ck_check_status == "valid" # 原结论保留 - assert task.ck_check_result["verdict"] == "error" # 但记录了这次查不到 - assert task.ck_checked_at is not None + assert account.ck_check_status == "valid" # 原结论保留 + assert account.ck_check_result["verdict"] == "error" # 但记录了这次查不到 + assert account.ck_checked_at is not None def test_mass_network_failure_pauses_the_job(self): job = self._job() job.enabled = True job.batch_size = 40 for index in range(40): - self.make_task(self.make_account(f"a{index}")) + self.make_account(f"a{index}") ck_health.check_douyu_cookie = _fake_check( valid=False, message="请求失败: Connection refused" ) @@ -567,7 +669,7 @@ class TestFailureClassification(_Base): job.enabled = True job.batch_size = 40 for index in range(40): - self.make_task(self.make_account(f"a{index}")) + self.make_account(f"a{index}") ck_health.check_douyu_cookie = _fake_check( valid=False, message="鱼丸接口: 账户登陆异常,请重新登陆;等级接口: 用户未登录", diff --git a/tests/test_automation_ck_relogin.py b/tests/test_automation_ck_relogin.py index 1cbefba..3a53999 100644 --- a/tests/test_automation_ck_relogin.py +++ b/tests/test_automation_ck_relogin.py @@ -10,6 +10,11 @@ 结算逻辑(``settle_results``)与选行查询(``build_select_query``)都刻意抽成了 纯函数/可编译对象,这里不碰真实登录链路。 + +CK 解耦(2026-10-04)后,**选行与退避进度的单位是账号**:``ck_check_status`` 与 +``relogin_*`` 都落到了 ``accounts`` 上,``login_tasks`` 退化为纯登录尝试历史 +(本轮只读它的 ``attempt_status`` / ``attempt_message`` / ``attempt_batch_id``)。 +所以这里建的是 Account;只有「在途」这类**某次尝试**的属性才补一条 LoginTask。 """ from datetime import UTC, datetime, timedelta @@ -37,43 +42,59 @@ class _Base: def teardown_method(self): self.session.close() - def make_task( + def make_account( self, name="acc", *, ck_check_status="invalid", cookie="acf_uid=1", - attempt_status="", - attempt_message="", relogin_attempts=0, relogin_next_at=None, relogin_failure_kind="", relogin_last_error="", auto_managed=True, ): + """建一个账号 —— CK 与重登进度都是账号自己的属性。 + + ``login_tasks`` 的 ``cookie`` / ``ck_check_*`` / ``relogin_*`` 旧列已不再被 + 新代码读写,这里也不往里写值:留作回退的列不该继续被测试固化。 + """ account = Account( username=name, password="p", email=f"{name}@e.com", email_password="ep", auto_managed=auto_managed, - ) - self.session.add(account) - self.session.commit() - task = LoginTask( - batch_id="b", - account_id=account.id, - created_by=self.admin.id, - status="success", cookie=cookie, ck_check_status=ck_check_status, - attempt_status=attempt_status, - attempt_message=attempt_message, relogin_attempts=relogin_attempts, relogin_next_at=relogin_next_at, relogin_failure_kind=relogin_failure_kind, relogin_last_error=relogin_last_error, ) + self.session.add(account) + self.session.commit() + return account + + def make_attempt( + self, + account, + *, + attempt_status="", + attempt_message="", + attempt_batch_id="", + status="success", + ): + """建一条登录尝试历史(本轮重登的那条记录)。""" + task = LoginTask( + batch_id="b", + account_id=account.id, + created_by=self.admin.id, + status=status, + attempt_status=attempt_status, + attempt_message=attempt_message, + attempt_batch_id=attempt_batch_id, + ) self.session.add(task) self.session.commit() return task @@ -155,61 +176,68 @@ class TestBackoff(_Base): class TestSelection(_Base): + """选行:``build_select_query`` 现在按**账号**返回,断言取 ``account.id``。""" + def _ids(self, job): now = datetime.now(UTC) return [ - t.id - for t in ck_relogin.build_select_query( + account.id + for account in ck_relogin.build_select_query( self.session, job, now, max_attempts=2 ).all() ] def test_picks_invalid_with_expired_backoff(self): job = self.job() - ready = self.make_task("ready") + ready = self.make_account("ready") assert self._ids(job) == [ready.id] def test_skips_valid_and_empty_cookie_and_opted_out(self): job = self.job() - keep = self.make_task("keep") - self.make_task("valid", ck_check_status="valid") - self.make_task("nocookie", cookie="") - self.make_task("optedout", auto_managed=False) + keep = self.make_account("keep") + self.make_account("valid", ck_check_status="valid") + self.make_account("nocookie", cookie="") + self.make_account("optedout", auto_managed=False) assert self._ids(job) == [keep.id] def test_skips_inflight_attempt(self): + """「在途」是某次尝试的属性,仍然要靠 login_tasks 表达。""" job = self.job() - keep = self.make_task("keep") - self.make_task("running", attempt_status="running") - self.make_task("pending", attempt_status="pending") + keep = self.make_account("keep") + running = self.make_account("running") + self.make_attempt(running, attempt_status="running") + pending = self.make_account("pending") + self.make_attempt(pending, attempt_status="pending") assert self._ids(job) == [keep.id] def test_respects_backoff_window(self): job = self.job() - due = self.make_task( + due = self.make_account( "due", relogin_next_at=datetime.now(UTC) - timedelta(minutes=1) ) - self.make_task("later", relogin_next_at=datetime.now(UTC) + timedelta(hours=3)) + self.make_account( + "later", relogin_next_at=datetime.now(UTC) + timedelta(hours=3) + ) assert self._ids(job) == [due.id] def test_skips_when_attempts_exhausted(self): job = self.job() - keep = self.make_task("keep") - self.make_task("exhausted", relogin_attempts=2) + keep = self.make_account("keep") + self.make_account("exhausted", relogin_attempts=2) assert self._ids(job) == [keep.id] def test_never_retries_manual_kinds(self): """已判定极验/凭证问题的账号,绝不能再次自动重登。""" job = self.job() - keep = self.make_task("keep") - self.make_task("captcha", relogin_failure_kind="captcha") - self.make_task("credential", relogin_failure_kind="credential") + keep = self.make_account("keep") + self.make_account("captcha", relogin_failure_kind="captcha") + self.make_account("credential", relogin_failure_kind="credential") assert self._ids(job) == [keep.id] def test_retryable_kinds_stay_eligible(self): job = self.job() - code = self.make_task("code", relogin_failure_kind="email_code") - net = self.make_task("net", relogin_failure_kind="network") + code = self.make_account("code", relogin_failure_kind="email_code") + net = self.make_account("net", relogin_failure_kind="network") assert sorted(self._ids(job)) == sorted([code.id, net.id]) def test_generated_sql_is_mysql_compatible(self): @@ -224,20 +252,24 @@ class TestSelection(_Base): def test_batch_size_caps_the_round(self): job = self.job(batch_size=3) for index in range(6): - self.make_task(f"a{index}") + self.make_account(f"a{index}") assert len(self._ids(job)) == 3 class TestSettlement(_Base): - """结算:成功清零;失败按分型决定重试还是转人工。""" + """结算:成功清零;失败按分型决定重试还是转人工。 - def _settle(self, tasks, *, max_attempts=2): + 解耦后决策看「账号 + 本轮的尝试记录」:成败读 ``LoginTask.attempt_status``, + 退避进度写**账号**的 ``relogin_*``。 + """ + + def _settle(self, pairs, *, max_attempts=2): now = datetime.now(UTC) - fresh = {t.id: t for t in tasks} + fresh = {account.id: (account, task) for account, task in pairs} counters = ck_relogin.settle_results( self.session, fresh, - [t.id for t in tasks], + [account.id for account, _ in pairs], now=now, max_attempts=max_attempts, ) @@ -245,77 +277,85 @@ class TestSettlement(_Base): return counters, now def test_success_resets_state(self): - task = self.make_task( + account = self.make_account( "ok", - attempt_status="success", relogin_attempts=1, relogin_failure_kind="email_code", relogin_last_error="旧的报错", relogin_next_at=datetime.now(UTC) + timedelta(hours=1), ) - counters, _ = self._settle([task]) - self.session.refresh(task) + task = self.make_attempt(account, attempt_status="success") + counters, _ = self._settle([(account, task)]) + self.session.refresh(account) assert counters["valid"] == 1 and counters["invalid"] == 0 - assert task.relogin_attempts == 0 - assert task.relogin_next_at is None - assert task.relogin_failure_kind == "" - assert task.relogin_last_error == "" + assert account.relogin_attempts == 0 + assert account.relogin_next_at is None + assert account.relogin_failure_kind == "" + assert account.relogin_last_error == "" def test_retryable_failure_schedules_next_attempt(self): - task = self.make_task( - "retry", + account = self.make_account("retry") + task = self.make_attempt( + account, attempt_status="failed", attempt_message="等待验证码超时(旧 Cookie 已保留)", ) - counters, now = self._settle([task]) - self.session.refresh(task) + counters, now = self._settle([(account, task)]) + self.session.refresh(account) assert counters["invalid"] == 1 and counters["retry"] == 1 - assert task.relogin_failure_kind == "email_code" - assert task.relogin_attempts == 1 - assert task.relogin_next_at is not None and task.relogin_next_at > now - assert "旧 Cookie" in task.relogin_last_error + assert account.relogin_failure_kind == "email_code" + assert account.relogin_attempts == 1 + assert account.relogin_next_at is not None and account.relogin_next_at > now + assert "旧 Cookie" in account.relogin_last_error def test_captcha_goes_to_manual_without_schedule(self): - task = self.make_task( - "cap", attempt_status="failed", attempt_message="极验验证失败: slide" + account = self.make_account("cap") + task = self.make_attempt( + account, attempt_status="failed", attempt_message="极验验证失败: slide" ) - counters, _ = self._settle([task]) - self.session.refresh(task) + counters, _ = self._settle([(account, task)]) + self.session.refresh(account) assert counters["manual"] == 1 and counters["retry"] == 0 - assert task.relogin_failure_kind == "captcha" - assert task.relogin_next_at is None # 不再自动排期 + assert account.relogin_failure_kind == "captcha" + assert account.relogin_next_at is None # 不再自动排期 def test_quota_backs_off_to_next_day(self): - task = self.make_task( - "quota", + account = self.make_account("quota") + task = self.make_attempt( + account, attempt_status="failed", attempt_message="您的邮箱验证码发送次数已达上限,请改天再试!", ) - counters, now = self._settle([task]) - self.session.refresh(task) + counters, now = self._settle([(account, task)]) + self.session.refresh(account) assert counters["email_quota"] == 1 and counters["retry"] == 1 - assert task.relogin_next_at.date() == (now + timedelta(days=1)).date() + assert account.relogin_next_at.date() == (now + timedelta(days=1)).date() def test_attempts_exhausted_stops_retrying(self): - task = self.make_task( - "used", + account = self.make_account("used", relogin_attempts=1) + task = self.make_attempt( + account, attempt_status="failed", attempt_message="等待验证码超时", - relogin_attempts=1, ) - counters, _ = self._settle([task], max_attempts=2) - self.session.refresh(task) + counters, _ = self._settle([(account, task)], max_attempts=2) + self.session.refresh(account) assert counters["manual"] == 1 and counters["retry"] == 0 - assert task.relogin_next_at is None + assert account.relogin_next_at is None def test_settlement_never_touches_cookie(self): - task = self.make_task( - "keepck", attempt_status="failed", attempt_message="极验验证失败: slide" + """结算只改账号的 ``relogin_*``,绝不碰账号持有的 CK。""" + account = self.make_account("keepck", cookie="acf_uid=1") + task = self.make_attempt( + account, attempt_status="failed", attempt_message="极验验证失败: slide" ) - before = (task.cookie, task.status) - self._settle([task]) - self.session.refresh(task) - assert (task.cookie, task.status) == before + before = (account.cookie, account.status) + self._settle([(account, task)]) + self.session.refresh(account) + assert (account.cookie, account.status) == before + assert account.cookie == "acf_uid=1" + # 顺带确认结算确实跑了:分型写上了,而 CK 仍原封不动。 + assert account.relogin_failure_kind == "captcha" class TestManualQueueApi(_Base): @@ -327,56 +367,79 @@ class TestManualQueueApi(_Base): return api.manual_queue(limit=20, db=self.session, current=self.admin) def test_groups_manual_kinds_only(self): - captcha = self.make_task( + captcha = self.make_account( "cap", - attempt_status="failed", - attempt_message="极验验证失败: slide", relogin_failure_kind="captcha", relogin_attempts=1, relogin_last_error="极验验证失败: slide", ) - self.make_task( + self.make_attempt( + captcha, attempt_status="failed", attempt_message="极验验证失败: slide" + ) + cred = self.make_account( "cred", - attempt_status="failed", - attempt_message="账号缺少密码或邮箱", relogin_failure_kind="credential", relogin_attempts=1, relogin_last_error="账号缺少密码或邮箱", ) + self.make_attempt( + cred, attempt_status="failed", attempt_message="账号缺少密码或邮箱" + ) # 可自动重试的不该出现在人工队列里 - self.make_task( + retryable = self.make_account( "retryable", - attempt_status="failed", - attempt_message="等待验证码超时", relogin_failure_kind="email_code", relogin_attempts=1, relogin_next_at=datetime.now(UTC) + timedelta(hours=1), relogin_last_error="等待验证码超时", ) + self.make_attempt( + retryable, attempt_status="failed", attempt_message="等待验证码超时" + ) payload = self._queue() kinds = [group["kind"] for group in payload["groups"]] assert sorted(kinds) == ["captcha", "credential"] assert payload["total"] == 2 captcha_group = next(g for g in payload["groups"] if g["kind"] == "captcha") assert captcha_group["count"] == 1 - assert captcha_group["samples"][0]["task_id"] == captcha.id + assert captcha_group["samples"][0]["account_id"] == captcha.id assert "极验" in captcha_group["label"] def test_exhausted_retries_show_up_as_manual(self): """次数用尽的(next_run_at 已清空)也要冒出来,否则会静默卡住。""" - self.make_task( + used = self.make_account( "used", - attempt_status="failed", - attempt_message="等待验证码超时", relogin_failure_kind="email_code", relogin_attempts=2, relogin_next_at=None, relogin_last_error="等待验证码超时", ) + self.make_attempt( + used, attempt_status="failed", attempt_message="等待验证码超时" + ) payload = self._queue() assert payload["total"] == 1 assert "重试次数用尽" in payload["groups"][0]["label"] + def test_sample_last_error_comes_from_account(self): + """样本里的 last_error 取账号的 ``relogin_last_error``,不回退到尝试文案。""" + account = self.make_account( + "cap", + relogin_failure_kind="captcha", + relogin_attempts=1, + relogin_last_error="账号侧记录的失败原因", + ) + self.make_attempt( + account, + attempt_status="failed", + attempt_message="这是尝试记录里的文案,不该被当成账号的最后错误", + ) + payload = self._queue() + assert payload["total"] == 1 + assert ( + payload["groups"][0]["samples"][0]["last_error"] == "账号侧记录的失败原因" + ) + def test_empty_queue(self): payload = self._queue() assert payload["total"] == 0 and payload["groups"] == [] @@ -386,15 +449,16 @@ class TestLinkageWithHealthJob(_Base): """两个作业是上下游:jobs 接口要暴露「当前有多少条等着自动重登」。 页面靠这个数字把巡检 → 重登的关系显性化;没有它,两个开关看起来像互不相干。 + 解耦后 ``count_pending_relogin`` 按**账号**计数。 """ def test_pending_relogin_counts_only_actionable_invalid(self): from web.backend.routers import automation as api - ready = self.make_task("ready") - self.make_task("valid", ck_check_status="valid") - self.make_task("nocookie", cookie="") - self.make_task("optedout", auto_managed=False) + ready = self.make_account("ready") + self.make_account("valid", ck_check_status="valid") + self.make_account("nocookie", cookie="") + self.make_account("optedout", auto_managed=False) payload = api.list_jobs(db=self.session, current=self.admin) # 只有 ready 同时满足:invalid + 有 CK + 允许自动管理 @@ -412,7 +476,7 @@ class TestLinkageWithHealthJob(_Base): from web.backend.routers import automation as api for index in range(25): - self.make_task(f"acc{index}") + self.make_account(f"acc{index}") payload = api.list_jobs(db=self.session, current=self.admin) relogin = next(j for j in payload["jobs"] if j["key"] == ck_relogin.JOB_KEY) assert relogin["batch_size"] == 10 @@ -428,10 +492,21 @@ class TestSettlementVisibility(_Base): SQLite 的读一致性不同,这条在 SQLite 上复现不出来 —— 属于「测试跑在 SQLite 就漏掉」的典型,现在测试跑在 MySQL 上才守得住。 + + 解耦后本轮记录靠 ``LoginTask.attempt_batch_id`` 找回,退避进度落在账号上。 """ def test_settlement_sees_other_session_commit(self): - task = self.make_task("acc", attempt_status="pending") + account = self.make_account( + "acc", + relogin_attempts=1, + relogin_failure_kind="email_code", + relogin_last_error="上一次的失败", + relogin_next_at=datetime.now(UTC) + timedelta(hours=1), + ) + task = self.make_attempt( + account, attempt_status="pending", attempt_batch_id="batch-visibility" + ) # 调用方会话先开事务(模拟调度器挑完候选后的状态)。 stale = make_test_session(self.engine) @@ -455,20 +530,38 @@ class TestSettlementVisibility(_Base): ), "REPEATABLE READ 下同一事务读不到别人的提交,这正是当初踩的坑" counters = ck_relogin.settle_fresh_results( - [task.id], now=datetime.now(UTC), max_attempts=2 + "batch-visibility", now=datetime.now(UTC), max_attempts=2 ) - self.session.expire_all() assert counters["valid"] == 1 and counters["invalid"] == 0 - assert self.session.get(LoginTask, task.id).relogin_failure_kind == "" + # 结算写在自己的事务里。这里必须再用**一个全新会话**读回:当前测试会话的 + # 事务快照在上面读 ``task.id``(commit 后属性已过期,取用即触发刷新)时 + # 就已经开了,复用它会看到结算前的旧值 —— 正是这条用例要守的坑。 + checker = make_test_session(self.engine) + settled = checker.get(Account, account.id) + assert settled.relogin_failure_kind == "" + assert settled.relogin_attempts == 0 + assert settled.relogin_next_at is None + checker.close() stale.close() def test_settlement_marks_real_success_as_valid(self): """结算看到 success 就必须清零退避,不能写成失败。""" - task = self.make_task("acc", attempt_status="success", relogin_attempts=1) + account = self.make_account( + "acc", + relogin_attempts=1, + relogin_failure_kind="email_code", + relogin_last_error="上一次的失败", + relogin_next_at=datetime.now(UTC) + timedelta(hours=1), + ) + task = self.make_attempt(account, attempt_status="success") now = datetime.now(UTC) counters = ck_relogin.settle_results( - self.session, {task.id: task}, [task.id], now=now, max_attempts=2 + self.session, + {account.id: (account, task)}, + [account.id], + now=now, + max_attempts=2, ) assert counters["valid"] == 1 and counters["invalid"] == 0 - assert task.relogin_failure_kind == "" - assert task.relogin_next_at is None + assert account.relogin_failure_kind == "" + assert account.relogin_next_at is None diff --git a/tests/test_cookie_custom_order.py b/tests/test_cookie_custom_order.py index 83ff36f..99fa01a 100644 --- a/tests/test_cookie_custom_order.py +++ b/tests/test_cookie_custom_order.py @@ -1,8 +1,14 @@ +"""回归:CK 列表按用户粘贴的账号名顺序排序,而不是按「最近拿到 CK 的时间」。 + +2026-10-04 CK 解耦后,列表单位从「登录记录」变成「账号」,排序键也跟着从 +``login_tasks.finished_at`` 换成 ``accounts.cookie_updated_at``。 +""" + from datetime import UTC, datetime, timedelta from tests.support.db import get_test_engine, make_test_session -from web.backend.models import Account, LoginTask, User -from web.backend.routers.cookies import _order_cookie_tasks +from web.backend.models import Account, User +from web.backend.routers.cookies import _order_cookie_accounts class TestCustomCookieOrder: @@ -14,38 +20,37 @@ class TestCustomCookieOrder: self.db.flush() now = datetime.now(UTC) - accounts = { - name: Account(username=name, password="p", email="e", email_password="ep") - for name in ("account-a", "account-b", "account-c") - } - self.db.add_all(accounts.values()) - self.db.flush() - # 完成时间特意与用户输入顺序相反。 + self.accounts = {} for index, name in enumerate(("account-a", "account-b", "account-c")): - self.db.add( - LoginTask( - batch_id=f"batch-{name}", - account_id=accounts[name].id, - status="success", - created_by=self.user.id, - finished_at=now + timedelta(minutes=index), - ) + account = Account( + username=name, + password="p", + email="e", + email_password="ep", + cookie=f"ck-{name}", + # 拿到 CK 的时间特意与用户输入顺序相反。 + cookie_updated_at=now + timedelta(minutes=index), ) + self.accounts[name] = account + self.db.add(account) self.db.commit() def teardown_method(self): self.db.close() - def test_custom_cookie_order_follows_input_not_finished_time(self): + def test_custom_cookie_order_follows_input_not_updated_time(self): selected_names = ["account-c", "account-a", "account-b"] - tasks = _order_cookie_tasks( - self.db.query(LoginTask) - .join(Account, LoginTask.account_id == Account.id) - .filter(Account.username.in_(selected_names)), + accounts = _order_cookie_accounts( + self.db.query(Account).filter(Account.username.in_(selected_names)), selected_names, ).all() - account_names = { - account.id: account.username for account in self.db.query(Account).all() - } - assert [account_names[task.account_id] for task in tasks] == selected_names + assert [account.username for account in accounts] == selected_names + + def test_default_order_is_most_recent_cookie_first(self): + accounts = _order_cookie_accounts(self.db.query(Account), []).all() + assert [account.username for account in accounts] == [ + "account-c", + "account-b", + "account-a", + ] diff --git a/tests/test_cookie_operations.py b/tests/test_cookie_operations.py index 69623db..62567cd 100644 --- a/tests/test_cookie_operations.py +++ b/tests/test_cookie_operations.py @@ -1,3 +1,9 @@ +"""CK 操作页的权限隔离、脱敏与重登入口。 + +2026-10-04 CK 解耦后本页面的**记录单位从「登录记录」变成「账号」**: +CK 与健康/重登进度都挂在 `accounts` 上,所有接口的 id 都是 account_id。 +""" + from types import SimpleNamespace from typing import cast from unittest.mock import patch @@ -34,47 +40,25 @@ class TestCookieOperation: self.session.add_all([self.support, self.other_support]) self.session.commit() - owned = Account( + # CK 挂在账号上(2026-10-04 CK 解耦),所以「CK 记录」就是账号本身。 + self.owned_account = Account( username="owned-account", password="owned-password", email="owned@example.com", email_password="mail-password", assigned_to=self.support.id, + cookie="owned-secret", ) - other = Account( + self.other_account = Account( username="other-account", password="other-password", email="other@example.com", email_password="mail-password", assigned_to=self.other_support.id, + cookie="other-secret", ) - self.session.add_all([owned, other]) + self.session.add_all([self.owned_account, self.other_account]) self.session.commit() - self.session.add_all( - [ - LoginTask( - batch_id="owned", - account_id=owned.id, - created_by=self.support.id, - status="success", - cookie="owned-secret", - ), - LoginTask( - batch_id="other", - account_id=other.id, - created_by=self.other_support.id, - status="success", - cookie="other-secret", - ), - ] - ) - self.session.commit() - self.owned_task = ( - self.session.query(LoginTask).filter(LoginTask.batch_id == "owned").one() - ) - self.other_task = ( - self.session.query(LoginTask).filter(LoginTask.batch_id == "other").one() - ) def teardown_method(self): self.session.close() @@ -92,7 +76,7 @@ class TestCookieOperation: assert result["total"] == 1 assert result["items"] == [ { - "id": self.owned_task.id, + "id": self.owned_account.id, "account_username": "owned-account", "tag": "", "ck_check_status": "", @@ -110,17 +94,13 @@ class TestCookieOperation: with pytest.raises(Exception, match="cookie:view"): list_cookies(db=self.session, current=self.support) with pytest.raises(Exception, match="cookie:view"): - get_cookie(self.owned_task.id, db=self.session, current=self.support) + get_cookie(self.owned_account.id, db=self.session, current=self.support) def test_cookie_list_filters_by_account_tag(self): admin = User(username="admin", password_hash="hash", role="super_admin") self.session.add(admin) - self.session.query(Account).filter( - Account.id == self.owned_task.account_id - ).update({"tag": "A组"}) - self.session.query(Account).filter( - Account.id == self.other_task.account_id - ).update({"tag": "B组"}) + self.owned_account.tag = "A组" + self.other_account.tag = "B组" self.session.commit() result = list_cookies( @@ -136,15 +116,11 @@ class TestCookieOperation: assert isinstance(result, dict) assert result["total"] == 1 - assert [item["id"] for item in result["items"]] == [self.owned_task.id] + assert [item["id"] for item in result["items"]] == [self.owned_account.id] def test_support_operation_tag_filter_and_list_stay_scoped(self): - self.session.query(Account).filter( - Account.id == self.owned_task.account_id - ).update({"tag": "我的标签"}) - self.session.query(Account).filter( - Account.id == self.other_task.account_id - ).update({"tag": "别人的标签"}) + self.owned_account.tag = "我的标签" + self.other_account.tag = "别人的标签" self.session.commit() tags = list_cookie_operation_tags(db=self.session, current=self.support) @@ -163,7 +139,7 @@ class TestCookieOperation: @patch("web.backend.routers.cookies._check_one_cookie") def test_support_can_check_only_assigned_cookie(self, check_one_cookie): check_one_cookie.return_value = { - "id": self.owned_task.id, + "id": self.owned_account.id, "valid": True, "message": "有效", "fish_ball": 9, @@ -173,16 +149,17 @@ class TestCookieOperation: } result = check_cookie_operations( - ids=f"{self.owned_task.id},{self.other_task.id}", + ids=f"{self.owned_account.id},{self.other_account.id}", db=self.session, current=self.support, ) - assert [item["id"] for item in result["results"]] == [self.owned_task.id] - self.session.refresh(self.owned_task) - self.session.refresh(self.other_task) - assert self.owned_task.ck_check_status == "valid" - assert self.other_task.ck_check_status == "" + assert [item["id"] for item in result["results"]] == [self.owned_account.id] + self.session.refresh(self.owned_account) + self.session.refresh(self.other_account) + # 检测结论写在账号上,且只写了被授权的那一个 + assert self.owned_account.ck_check_status == "valid" + assert self.other_account.ck_check_status == "" def test_operation_permission_does_not_allow_unrelated_users(self): no_permission_user = SimpleNamespace( @@ -201,9 +178,10 @@ class TestCookieOperation: current=cast(User, no_permission_user), ) - def test_relogin_failed_record_stays_visible_and_can_be_checked(self): - self.owned_task.attempt_status = "failed" - self.owned_task.attempt_message = "重新登录失败: 密码错误(旧 Cookie 已保留)" + def test_relogin_failed_account_stays_visible_and_can_be_checked(self): + # 重登进度就写在账号上:判失效 + 有待重试时间 = 排队中 + self.owned_account.ck_check_status = "invalid" + self.owned_account.relogin_last_error = "重新登录失败: 密码错误" self.session.commit() result = list_cookie_operations( @@ -217,58 +195,89 @@ class TestCookieOperation: assert result["total"] == 1 assert result["items"][0]["relogin_status"] == "relogin_failed" - assert "旧 Cookie 已保留" in result["items"][0]["relogin_message"] - # 失败的重登尝试不影响记录持有的 CK - assert self.owned_task.cookie == "owned-secret" - assert self.owned_task.status == "success" + assert "重新登录失败" in result["items"][0]["relogin_message"] + # 重登失败不影响账号持有的 CK + assert self.owned_account.cookie == "owned-secret" + + def test_relogin_pending_account_is_reported_as_pending(self): + self.owned_account.ck_check_status = "invalid" + from datetime import UTC, datetime, timedelta + + self.owned_account.relogin_next_at = datetime.now(UTC) + timedelta(hours=1) + self.session.commit() + + result = list_cookie_operations( + search="", + tag="", + page=1, + page_size=20, + db=self.session, + current=self.support, + ) + + assert result["items"][0]["relogin_status"] == "relogin_pending" def test_service_restart_cleans_orphan_relogin_tasks(self): - self.owned_task.attempt_status = "running" - self.owned_task.attempt_message = "正在重新登录,旧 Cookie 保留中" + attempt = LoginTask( + batch_id="orphan-relogin", + account_id=self.owned_account.id, + created_by=self.support.id, + status="success", + attempt_status="running", + attempt_message="正在重新登录,旧 Cookie 保留中", + ) + self.session.add(attempt) self.session.commit() cleaned = cleanup_orphan_relogin_tasks(self.session) assert cleaned == 1 - self.session.refresh(self.owned_task) - self.session.refresh(self.other_task) - # 只收敛尝试状态,已有 CK 与成功状态原封不动 - assert self.owned_task.attempt_status == "failed" - assert "服务重启" in self.owned_task.attempt_message - assert self.owned_task.status == "success" - assert self.owned_task.cookie == "owned-secret" - assert self.other_task.attempt_status == "" - assert self.other_task.status == "success" + self.session.refresh(attempt) + self.session.refresh(self.owned_account) + # 只收敛尝试状态,账号的 CK 原封不动 + assert attempt.attempt_status == "failed" + assert "服务重启" in attempt.attempt_message + assert self.owned_account.cookie == "owned-secret" def test_service_restart_cleans_orphan_login_tasks(self): - """普通登录批次遗留的在途尝试必须收敛,且保留旧 CK。""" - self.owned_task.attempt_status = "pending" - self.owned_task.cookie = "acf_uid=owned" + """普通登录批次遗留的在途尝试必须收敛,且不动账号的旧 CK。""" + attempt = LoginTask( + batch_id="orphan-login", + account_id=self.owned_account.id, + created_by=self.support.id, + status="success", + attempt_status="pending", + ) + self.session.add(attempt) self.session.commit() cleaned = cleanup_orphan_login_tasks(self.session) assert cleaned == 1 - self.session.refresh(self.owned_task) - self.session.refresh(self.other_task) - assert self.owned_task.attempt_status == "failed" - assert self.owned_task.cookie == "acf_uid=owned" - assert self.owned_task.status == "success" - assert "服务重启" in self.owned_task.attempt_message - assert self.other_task.status == "success" + self.session.refresh(attempt) + self.session.refresh(self.owned_account) + assert attempt.attempt_status == "failed" + assert "服务重启" in attempt.attempt_message + assert self.owned_account.cookie == "owned-secret" def test_service_restart_cleans_legacy_in_flight_status(self): """升级前直接把状态写在 status 上的在途记录也要能收敛。""" - self.owned_task.status = "relogin_running" - self.owned_task.attempt_status = "" + attempt = LoginTask( + batch_id="legacy-inflight", + account_id=self.owned_account.id, + created_by=self.support.id, + status="relogin_running", + attempt_status="", + ) + self.session.add(attempt) self.session.commit() cleaned = cleanup_orphan_login_tasks(self.session) assert cleaned == 1 - self.session.refresh(self.owned_task) - assert self.owned_task.attempt_status == "failed" - assert self.owned_task.cookie == "owned-secret" + self.session.refresh(attempt) + assert attempt.attempt_status == "failed" + assert self.owned_account.cookie == "owned-secret" def test_runner_copies_proxy_config_before_background_execution(self): proxy = ProxyConfig( @@ -314,10 +323,10 @@ class TestCookieOperation: assert result["nickname"] == "new-name" assert result["level"] == 12 - @patch("web.backend.routers.cookies._start_relogin_tasks") + @patch("web.backend.routers.cookies._start_relogin_accounts") def test_relogin_invalid_only_targets_support_visible_accounts(self, start_relogin): - self.owned_task.ck_check_status = "invalid" - self.other_task.ck_check_status = "invalid" + self.owned_account.ck_check_status = "invalid" + self.other_account.ck_check_status = "invalid" self.session.commit() start_relogin.return_value = { "batch_id": "batch", @@ -334,5 +343,5 @@ class TestCookieOperation: ) assert result["success"] - selected_tasks = start_relogin.call_args.args[0] - assert [task.id for task in selected_tasks] == [self.owned_task.id] + selected_accounts = start_relogin.call_args.args[0] + assert [account.id for account in selected_accounts] == [self.owned_account.id] diff --git a/tests/test_douyu_account_name_filter.py b/tests/test_douyu_account_name_filter.py index 1ea3d3d..917fa8e 100644 --- a/tests/test_douyu_account_name_filter.py +++ b/tests/test_douyu_account_name_filter.py @@ -1,5 +1,5 @@ from tests.support.db import get_test_engine, make_test_session -from web.backend.models import Account, LoginTask, User +from web.backend.models import Account, User from web.backend.routers.douyu import ( _parse_account_names, list_task_account_ids, @@ -22,18 +22,11 @@ class TestDouyuAccountNameFilter: password="password", email=f"{name}@example.com", email_password="mail-password", + # CK 挂在账号上(2026-10-04 CK 解耦) + cookie="acf_uid=1", ) self.session.add(account) self.session.flush() - self.session.add( - LoginTask( - batch_id=f"login-{name}", - account_id=account.id, - created_by=self.user.id, - status="success", - cookie="acf_uid=1", - ) - ) self.accounts[name] = account self.session.commit() diff --git a/tests/test_douyu_cookie_visibility.py b/tests/test_douyu_cookie_visibility.py index 66918fa..06c254a 100644 --- a/tests/test_douyu_cookie_visibility.py +++ b/tests/test_douyu_cookie_visibility.py @@ -1,10 +1,13 @@ -"""回归:只要账号还留着 CK,就不得因为登录批次状态而从工作台消失。 +"""回归:账号只要自己有 CK 就可见,**与任何登录记录的状态无关**。 -线上事故(2026-10-01):账号「远行者103」被分配给客服后,客服与管理员在 -斗鱼工作台都看不到它,只有账号管理里还能搜到。原因是可见性判定写成了 -``LoginTask.status == "success"``,而普通登录批次启动时会把复用的成功记录 -置为 ``pending``,CK 重登失败会置为 ``relogin_failed`` —— 两种状态都保留旧 -Cookie,却让账号从工作台与 CK 列表里凭空消失。 +这一条是 2026-10-04 CK 解耦的核心验收点。 + +解耦前:CK 存在 `login_tasks.cookie` 上,于是「记录被复用置为 pending」「重登失败」 +都会让账号看起来「没有 CK」;线上出过事故(账号「远行者103」被分配后, +客服与管理员在工作台都看不到它)。 +解耦后:CK 在账号上,登录记录只是一次尝试的历史 —— 删历史、批次排队、 +重登失败都不可能让账号失去可见性。所以这个用例从「绕开记录状态」变成了 +「根本不看记录」。 """ import pytest @@ -16,7 +19,6 @@ from web.backend.routers.douyu import list_task_account_ids, list_task_accounts from web.backend.services.account_service import cookie_account_ids_query from web.backend.services.douyu_service import ( create_douyu_planned_tasks, - latest_cookie_login_task, visible_douyu_task_accounts, ) @@ -31,30 +33,30 @@ class TestDouyuCookieVisibility: self.session.add_all([self.admin, self.support]) self.session.commit() - # relogin_failed:重登失败但旧 CK 已按设计保留 + # 有 CK 的账号:即使历史记录是「重登失败」,也必须可见。 self.relogin_account = self._make_account("远行者103", self.support.id) + self.relogin_account.cookie = "acf_uid=481802039" + self.relogin_account.ck_check_status = "valid" self.session.add( LoginTask( batch_id="relogin-failed", account_id=self.relogin_account.id, created_by=self.support.id, status="relogin_failed", - cookie="acf_uid=481802039", - ck_check_status="valid", ) ) - # pending:普通登录批次排队中,复用了成功记录,旧 CK 仍在 + # 有 CK 的账号:即使历史记录停在 pending(批次排队中),也必须可见。 self.pending_account = self._make_account("排队中账号", self.support.id) + self.pending_account.cookie = "acf_uid=2" self.session.add( LoginTask( batch_id="queued", account_id=self.pending_account.id, created_by=self.admin.id, status="pending", - cookie="acf_uid=2", ) ) - # 从未登录成功:没有 CK,必须继续隐藏 + # 从未登录成功:没有 CK,必须继续隐藏。 self.empty_account = self._make_account("无CK账号", self.support.id) self.session.add( LoginTask( @@ -62,7 +64,6 @@ class TestDouyuCookieVisibility: account_id=self.empty_account.id, created_by=self.admin.id, status="failed", - cookie="", ) ) self.session.commit() @@ -115,11 +116,6 @@ class TestDouyuCookieVisibility: self.pending_account.id, } - def test_latest_cookie_login_task_ignores_batch_status(self): - task = latest_cookie_login_task(self.session, self.relogin_account.id) - assert task is not None - assert task.cookie == "acf_uid=481802039" - def test_visible_task_accounts_include_retained_cookie(self): accounts = visible_douyu_task_accounts( self.session, @@ -133,7 +129,7 @@ class TestDouyuCookieVisibility: } assert ids == {self.relogin_account.id, self.pending_account.id} - def test_cookie_management_page_lists_retained_cookie(self): + def test_cookie_management_page_lists_accounts_with_cookie(self): items = list_cookies( search="", tag="", @@ -148,6 +144,11 @@ class TestDouyuCookieVisibility: "远行者103", "排队中账号", } + # 解耦后 id 就是账号 id(以前是「持有 CK 的登录记录 id」)。 + assert {item["id"] for item in items} == { + self.relogin_account.id, + self.pending_account.id, + } def test_retained_cookie_account_can_still_create_task(self): batch_id, created = create_douyu_planned_tasks( @@ -159,3 +160,18 @@ class TestDouyuCookieVisibility: ) assert batch_id assert created == 1 + + def test_deleting_login_history_does_not_touch_cookie(self): + """本次改造的验收核心:删掉全部登录历史,账号的 CK 与健康状态仍在。""" + self.session.query(LoginTask).delete(synchronize_session=False) + self.session.commit() + self.session.expire_all() + + fresh = ( + self.session.query(Account) + .filter(Account.id == self.relogin_account.id) + .one() + ) + assert fresh.cookie == "acf_uid=481802039" + assert fresh.ck_check_status == "valid" + assert self._task_account_names(self.admin) == {"远行者103", "排队中账号"} diff --git a/tests/test_douyu_gold_recharge_channel.py b/tests/test_douyu_gold_recharge_channel.py index 00785f2..d7ef7bb 100644 --- a/tests/test_douyu_gold_recharge_channel.py +++ b/tests/test_douyu_gold_recharge_channel.py @@ -6,7 +6,7 @@ from unittest.mock import ANY, AsyncMock, Mock, patch from core.douyu import FishFinRechargeClient, FishFinRechargeConfig from tests.support.db import get_test_engine, make_test_session -from web.backend.models import Account, AuditLog, DouyuTask, LoginTask, User +from web.backend.models import Account, AuditLog, DouyuTask, User from web.backend.routers.douyu import get_recharge_channel, supplier_recharge_callback from web.backend.services.audit_service import record_audit from web.backend.services.douyu_runner import DouyuBatchRunner @@ -192,18 +192,12 @@ class TestDouyuGoldRechargeChannel: def test_invalid_cookie_skips_activity_and_marks_relogin_required( self, session_local, check_cookie ): - login_task = LoginTask( - batch_id="login-batch", - account_id=self.account.id, - created_by=self.task.created_by, - status="success", - cookie="acf_uid=10001", - ) + # CK 挂在账号上(2026-10-04 CK 解耦) + self.account.cookie = "acf_uid=10001" self.task.status = "planned" - self.session.add(login_task) self.session.commit() task_id = self.task.id - login_task_id = login_task.id + account_id = self.account.id session_local.return_value = self.session check_cookie.return_value = { "valid": False, @@ -221,15 +215,17 @@ class TestDouyuGoldRechargeChannel: verify_session = make_test_session(self.engine) try: task = verify_session.get(DouyuTask, task_id) - checked_login = verify_session.get(LoginTask, login_task_id) + checked_account = verify_session.get(Account, account_id) assert task is not None - assert checked_login is not None + assert checked_account is not None assert task.status == "failed" assert "Cookie 已失效,请重新登录" in task.message - assert checked_login.ck_check_status == "invalid" - check_result = checked_login.ck_check_result + # 检测结论写在**账号**上,CK 本体不动 + assert checked_account.ck_check_status == "invalid" + check_result = checked_account.ck_check_result assert isinstance(check_result, dict) assert check_result["message"] == "鱼丸接口: 未登录;等级接口: 未登录" + assert checked_account.cookie == "acf_uid=10001" finally: verify_session.close() diff --git a/tests/test_douyu_task_assigned_optimization.py b/tests/test_douyu_task_assigned_optimization.py index 1f2080f..0e2967d 100644 --- a/tests/test_douyu_task_assigned_optimization.py +++ b/tests/test_douyu_task_assigned_optimization.py @@ -1,5 +1,7 @@ """验证 DouyuTask 的 account_assigned_to 字段与任务查询优化。""" +from sqlalchemy.orm import Session + from tests.support.db import init_test_schema, make_test_session from web.backend.models import Account, DouyuTask, User from web.backend.routers.douyu import _visible_tasks_query @@ -38,6 +40,8 @@ def test_douyu_task_assigned_to_flow(): email="em1@test.com", email_password="pwd", assigned_to=user_a.id, + # CK 挂在账号上(2026-10-04 CK 解耦) + cookie="acf_uid=101; acf_auth=fake", ) acc_2 = Account( username="dy_user_2", @@ -45,29 +49,11 @@ def test_douyu_task_assigned_to_flow(): email="em2@test.com", email_password="pwd", assigned_to=user_b.id, + cookie="acf_uid=102; acf_auth=fake", ) db.add_all([acc_1, acc_2]) db.flush() - from web.backend.models import LoginTask - - lt_1 = LoginTask( - account_id=acc_1.id, - batch_id="b1", - status="success", - cookie="acf_uid=101; acf_auth=fake", - created_by=user_a.id, - ) - lt_2 = LoginTask( - account_id=acc_2.id, - batch_id="b2", - status="success", - cookie="acf_uid=102; acf_auth=fake", - created_by=user_b.id, - ) - db.add_all([lt_1, lt_2]) - db.flush() - # 1. 验证创建任务时,自动继承 account.assigned_to batch_id, count = create_douyu_planned_tasks( db=db, diff --git a/tests/test_douyu_workbench_scopes.py b/tests/test_douyu_workbench_scopes.py index 02e9a93..a355cc5 100644 --- a/tests/test_douyu_workbench_scopes.py +++ b/tests/test_douyu_workbench_scopes.py @@ -6,7 +6,6 @@ from web.backend.models import ( DouyuTask, DouyuWorkbench, DouyuWorkbenchAccount, - LoginTask, User, ) from web.backend.routers.accounts import delete_account @@ -31,19 +30,11 @@ class TestDouyuWorkbenchScope: password="password", email="mail@example.com", email_password="mail-password", + # CK 挂在账号上(2026-10-04 CK 解耦),不再需要造一条登录记录 + cookie="acf_uid=1", ) self.session.add(self.account) self.session.commit() - self.session.add( - LoginTask( - batch_id="login", - account_id=self.account.id, - created_by=self.user.id, - status="success", - cookie="acf_uid=1", - ) - ) - self.session.commit() def teardown_method(self): self.session.close() diff --git a/tests/test_login_attempt_isolation.py b/tests/test_login_attempt_isolation.py index 98f2aaa..196327b 100644 --- a/tests/test_login_attempt_isolation.py +++ b/tests/test_login_attempt_isolation.py @@ -1,9 +1,10 @@ -"""回归:替换 CK 必须是最后一步,只有登录成功才会写回记录。 +"""回归:替换 CK 必须是最后一步,只有登录成功才会写回**账号**。 事故背景:批次启动时会把复用的成功记录直接改成 pending(旧 CK 仍保留), 批次被停止或失败后没有任何地方把它改回成功,账号于是从工作台消失。 -现在尝试状态独立存放在 attempt_*,status/cookie/message 只在登录成功时 -更新,所以停止、失败、进程重启都不可能破坏账号已有的 CK。 +现在尝试状态独立存放在 attempt_*,而 CK 自 2026-10-04 起挂在**账号**上 +(`accounts.cookie`),登录记录只是一次尝试的历史 —— +停止、失败、进程重启都不可能破坏账号已有的 CK。 """ from types import SimpleNamespace @@ -56,6 +57,8 @@ class TestLoginAttemptIsolation: password="password", email="yuanxingzhe@example.com", email_password="mail-password", + # CK 是账号的属性(解耦前它挂在 login_tasks.cookie 上) + cookie="old-cookie", ) self.session.add(self.account) self.session.flush() @@ -64,8 +67,11 @@ class TestLoginAttemptIsolation: account_id=self.account.id, created_by=self.user.id, status="success", - cookie="old-cookie", message="登录成功", + # 上一次尝试失败过 —— 解耦后「复用行为」按 attempt_status 判定, + # 批次会复用这一行继续重试(而不是每次失败都新建一行)。 + attempt_status="failed", + attempt_message="上一次登录失败", ) self.session.add(self.task) self.session.commit() @@ -104,6 +110,14 @@ class TestLoginAttemptIsolation: finally: session.close() + def _fresh_account(self) -> Account: + """重新读账号 —— CK 现在挂在它身上,断言要跨会话读才作数。""" + session = sessionmaker(bind=self.engine)() + try: + return session.query(Account).filter(Account.id == self.account.id).one() + finally: + session.close() + def _fresh_user(self, session) -> User: return session.query(User).filter(User.id == self.user.id).one() @@ -114,17 +128,18 @@ class TestLoginAttemptIsolation: self._run_batch(monkeypatch) task = self._fresh(self.task_id) - # 记录持有 CK 的字段完全没被碰过 + # 登录记录只是历史,它的状态字段没被碰过 assert task.status == "success" - assert task.cookie == "old-cookie" assert task.message == "登录成功" assert task.batch_id == "old-batch" # 失败只体现在尝试字段上 assert task.attempt_status == "failed" assert task.attempt_message == "账号密码错误" assert task.attempt_at is not None + # 账号的 CK 完全没被碰过 + assert self._fresh_account().cookie == "old-cookie" - def test_failed_attempt_reuses_the_cookie_row(self, monkeypatch): + def test_failed_attempt_reuses_the_failed_row(self, monkeypatch): _FakeLogin.success = False _FakeLogin.message = "账号密码错误" @@ -145,19 +160,23 @@ class TestLoginAttemptIsolation: self._run_batch(monkeypatch) task = self._fresh(self.task_id) + account = self._fresh_account() assert task.status == "success" - assert task.cookie == "new-cookie" assert task.message == "登录成功" assert task.attempt_status == "success" assert task.attempt_at is not None assert task.batch_id != "old-batch" + # 唯一替换 CK 的入口:写的是**账号** + assert account.cookie == "new-cookie" + assert account.cookie_updated_at is not None + assert account.cookie_source == "web" def test_stopped_batch_keeps_existing_cookie(self, monkeypatch): self._run_batch(monkeypatch, stop=True) task = self._fresh(self.task_id) assert _FakeLogin.calls == 0 - assert task.cookie == "old-cookie" + assert self._fresh_account().cookie == "old-cookie" assert task.status == "success" assert task.attempt_status == "failed" assert "旧 Cookie 已保留" in task.attempt_message @@ -177,11 +196,11 @@ class TestLoginAttemptIsolation: read_db.close() assert len(rows) == 1 row = rows[0] - # 页面看到的是「这次尝试怎么样了」,而记录仍然持有 CK + # 页面看到的是「这次尝试怎么样了」,而账号仍然持有 CK assert row.status == "failed" assert row.message == "账号密码错误" assert row.batch_id == runner.batch_id - assert self._fresh(self.task_id).cookie == "old-cookie" + assert self._fresh_account().cookie == "old-cookie" def test_account_without_cookie_still_reports_failure(self, monkeypatch): """首次登录失败的账号没有 CK 需要保护,保持原来的失败语义。""" @@ -208,6 +227,12 @@ class TestLoginAttemptIsolation: assert task.status == "failed" assert task.message == "账号密码错误" assert task.attempt_status == "failed" - assert task.cookie == "" + assert ( + session.query(Account) + .filter(Account.id == fresh_account.id) + .one() + .cookie + == "" + ) finally: session.close() diff --git a/tests/test_login_tasks_pagination.py b/tests/test_login_tasks_pagination.py index 5e9cecc..fdefe8b 100644 --- a/tests/test_login_tasks_pagination.py +++ b/tests/test_login_tasks_pagination.py @@ -231,16 +231,15 @@ class TestBulkDelete: assert "无效的任务ID" in str(exc.value) -class TestCkProtection: - """⚠️ 最重要的护栏:删除不能丢 CK。 +class TestDeleteGuard: + """⚠️ 护栏语义已随 CK 解耦改变(2026-10-04)。 - 事故背景(2026-10-03):斗鱼账号的 CK **只存在 `login_tasks.cookie`**, - `accounts` 表没有 cookie 列。列表显示的「状态」是最近一次尝试 - (``attempt_status``),于是一条**显示「失败」但实际持有有效 CK** 的记录 - (``status='success'`` + cookie + 上次尝试失败)会被「清空失败」当成垃圾删掉, - 账号的登录态随之永久丢失。 + 旧护栏:「持有 CK 的记录不可删」—— 因为那时 CK 只存在 `login_tasks.cookie`, + 删历史就是删账号的登录态(2026-10-03 事故)。 - 所以判定必须看 ``cookie``,不能看显示状态。 + 新护栏:CK 已迁到 `accounts.cookie`,**删除历史不再影响任何账号的登录态**, + 所以 CK 不再是护栏理由(历史终于可以自由清理)。保留的是**在途护栏**: + 批次线程正拿着那条记录,删掉会让它找不到目标。 """ def setup_method(self): @@ -252,51 +251,48 @@ class TestCkProtection: self.session.add(self.user) self.session.flush() self.account = Account( - username="CK保护账号", + username="CK账号", password="p", email="ck@example.com", email_password="ep", + cookie="valid-ck-1", ) self.session.add(self.account) self.session.flush() - # ① 显示失败、但持有有效 CK —— 生产事故就是删到了这种 - self.holds_ck_shows_failed = self._add( - status="success", attempt_status="failed", cookie="valid-ck-1" + # ① 历史:显示失败、但账号仍持有有效 CK(本次改造前会被当成垃圾删掉) + self.failed_history = self._add(status="failed", attempt_status="failed") + # ② 历史:成功 + self.success_history = self._add(status="success", attempt_status="success") + # ③ 在途:批次正拿着它,不可删 + self.in_flight = self._add( + status="pending", attempt_status="running", attempt_batch_id="live-batch" ) - # ② 成功且持有 CK - self.holds_ck_shows_success = self._add( - status="success", attempt_status="success", cookie="valid-ck-2" - ) - # ③ 真垃圾:没有任何 CK - self.no_ck = self._add(status="failed", attempt_status="failed", cookie="") - # 用两个账号,避免同一账号多条记录干扰「账号是否还有 CK」的判断 self.other = Account( - username="CK保护账号2", + username="CK账号2", password="p", email="ck2@example.com", email_password="ep", + cookie="valid-ck-2", ) self.session.add(self.other) self.session.flush() - self.holds_ck_other = self._add( - status="success", - attempt_status="failed", - cookie="valid-ck-3", - account_id=self.other.id, + self.other_history = self._add( + status="success", attempt_status="failed", account_id=self.other.id ) self.session.commit() - def _add(self, *, status, attempt_status, cookie, account_id=None): + def _add( + self, *, status, attempt_status, account_id=None, attempt_batch_id="ck-batch" + ): task = LoginTask( batch_id="ck-batch", account_id=account_id or self.account.id, created_by=self.user.id, status=status, - cookie=cookie, attempt_status=attempt_status, - attempt_batch_id="ck-batch", + attempt_batch_id=attempt_batch_id, message="x", ) self.session.add(task) @@ -318,65 +314,75 @@ class TestCkProtection: finally: db.close() - def test_bulk_delete_skips_ck_holding_records(self): - """「清空失败」绝不能删掉持有 CK 的记录,哪怕它显示为失败。""" + def _account_cookie(self, account_id: int) -> str: db = self._fresh() try: - res = delete_tasks( - status="failed", all_matching=True, db=db, current=self.user - ) + return db.query(Account).filter(Account.id == account_id).one().cookie finally: db.close() - assert self._exists(self.holds_ck_shows_failed), ( - "显示失败但持有 CK 的记录被删了!" - ) - assert self._exists(self.holds_ck_other), "另一个账号的 CK 记录被删了!" - assert not self._exists(self.no_ck), "无 CK 的历史记录应被删除" - assert res["deleted"] == 1 - assert res["skipped"] >= 2 - - def test_bulk_delete_reports_skipped_count(self): + def test_bulk_delete_removes_history_even_when_account_holds_cookie(self): + """核心验收:删掉全部历史,账号的 CK 一点不受影响。""" db = self._fresh() try: res = delete_tasks(all_matching=True, db=db, current=self.user) finally: db.close() - # 3 条持有 CK(含另一个账号)、1 条无 CK - assert res["deleted"] == 1 - assert res["skipped"] == 3 - assert "持有 CK" in res["message"] - def test_explicit_ids_cannot_delete_ck_holder(self): - """即使用户手动勾选,持有 CK 的也不能删。""" + assert not self._exists(self.failed_history) + assert not self._exists(self.success_history) + assert not self._exists(self.other_history) + # 在途那条被跳过 + assert self._exists(self.in_flight) + assert res["deleted"] == 3 + assert res["skipped"] == 1 + # 账号的 CK 仍在 —— 这就是解耦要的效果 + assert self._account_cookie(self.account.id) == "valid-ck-1" + assert self._account_cookie(self.other.id) == "valid-ck-2" + + def test_bulk_delete_reports_in_flight_skipped_count(self): + db = self._fresh() + try: + res = delete_tasks(all_matching=True, db=db, current=self.user) + finally: + db.close() + assert res["deleted"] == 3 + assert res["skipped"] == 1 + assert "在途" in res["message"] + + def test_explicit_ids_can_delete_cookie_history_but_not_in_flight(self): + """用户手动勾选时,历史可删,在途仍不可删。""" db = self._fresh() try: res = delete_tasks( - task_ids=f"{self.holds_ck_shows_failed},{self.no_ck}", + task_ids=f"{self.failed_history},{self.in_flight}", db=db, current=self.user, ) finally: db.close() - assert self._exists(self.holds_ck_shows_failed) - assert not self._exists(self.no_ck) + assert not self._exists(self.failed_history) + assert self._exists(self.in_flight) assert res["deleted"] == 1 + assert res["skipped"] == 1 + assert self._account_cookie(self.account.id) == "valid-ck-1" - def test_single_delete_refuses_ck_holder(self): + def test_single_delete_refuses_in_flight_record(self): db = self._fresh() try: with pytest.raises(Exception) as exc: - delete_task(self.holds_ck_shows_success, db=db, current=self.user) + delete_task(self.in_flight, db=db, current=self.user) finally: db.close() - assert "持有 CK" in str(exc.value) - assert self._exists(self.holds_ck_shows_success) + assert "正在被批次处理" in str(exc.value) + assert self._exists(self.in_flight) - def test_single_delete_allows_empty_cookie(self): + def test_single_delete_allows_cookie_history(self): db = self._fresh() try: - res = delete_task(self.no_ck, db=db, current=self.user) + res = delete_task(self.success_history, db=db, current=self.user) finally: db.close() assert res["success"] is True - assert not self._exists(self.no_ck) + assert not self._exists(self.success_history) + assert self._account_cookie(self.account.id) == "valid-ck-1" diff --git a/web/backend/routers/accounts.py b/web/backend/routers/accounts.py index b33d89c..666dfe2 100644 --- a/web/backend/routers/accounts.py +++ b/web/backend/routers/accounts.py @@ -287,11 +287,11 @@ def assign_account( if target.role != "support": raise HTTPException(status_code=400, detail="只能分配给客服角色") - # 只能分配当前仍持有 CK 的账号(旧 CK 在重登失败/排队时也会保留) + # 只能分配当前仍持有 CK 的账号(CK 现在挂在账号上) has_success = ( - db.query(LoginTask) + db.query(Account) .filter( - LoginTask.account_id == account_id, + Account.id == account_id, cookie_holding_filter(), ) .first() @@ -334,8 +334,8 @@ def batch_assign_accounts( if target.role != "support": raise HTTPException(status_code=400, detail="只能分配给客服角色") - # 只能分配当前仍持有 CK 的账号(旧 CK 在重登失败/排队时也会保留) - cookie_ids_query = select(LoginTask.account_id).where(cookie_holding_filter()) + # 只能分配当前仍持有 CK 的账号(CK 现在挂在账号上) + cookie_ids_query = select(Account.id).where(cookie_holding_filter()) invalid_ids = ( db.query(Account.id) .filter( @@ -388,7 +388,7 @@ def assignments_summary( current: User = Depends(require_permission("account:assign")), ): """分配概览:每个客服分配了多少账号(仅统计当前仍持有 CK 的账号)。""" - cookie_subq = select(LoginTask.account_id).where(cookie_holding_filter()) + cookie_subq = select(Account.id).where(cookie_holding_filter()) cookie_accounts = db.query(Account).filter(Account.id.in_(cookie_subq)).subquery() results = ( @@ -626,16 +626,16 @@ def account_overview( or {} ) - # 登录次数取历史记录(事件表不回填,所以 CK 相关计数仍以 login_tasks 为准)。 + # 登录次数取历史记录(事件表不回填,所以登录相关计数仍以 login_tasks 为准); + # 「最近 CK 检测」改从**账号**上读(2026-10-04 CK 解耦:ck_checked_at 已搬到 accounts)。 login_stats = ( db.execute( text( """ SELECT COUNT(*) AS login_count, - MAX(finished_at) AS last_login_at, - MAX(ck_checked_at) AS last_ck_check_at + MAX(finished_at) AS last_login_at FROM login_tasks - WHERE account_id = :aid AND cookie IS NOT NULL AND cookie <> '' + WHERE account_id = :aid AND status = 'success' """ ), {"aid": account_id}, @@ -677,7 +677,7 @@ def account_overview( "event_count": int(stats.get("event_count") or 0), "login_count": int(login_stats.get("login_count") or 0), "last_login_at": login_stats.get("last_login_at"), - "last_ck_check_at": login_stats.get("last_ck_check_at"), + "last_ck_check_at": acc.ck_checked_at, }, # 「开通时间」由事件表提供;事件表从上线起才有,早于上线的不显示。 "opened_at": { diff --git a/web/backend/routers/automation.py b/web/backend/routers/automation.py index 3dc056b..c3a5d90 100644 --- a/web/backend/routers/automation.py +++ b/web/backend/routers/automation.py @@ -12,7 +12,7 @@ from typing import Any from fastapi import APIRouter, Depends, HTTPException, Query from pydantic import BaseModel, Field -from sqlalchemy import and_, or_ +from sqlalchemy import and_, or_, select from sqlalchemy.orm import Session from ..database import get_db @@ -203,27 +203,36 @@ def manual_queue( """ from ..services.automation.jobs.ck_relogin import MANUAL_KINDS - # 转人工 = 分型属于需人工,或者次数用尽(next_run_at 已清空且失败过)。 + # 解耦后这一队列的单位是**账号**(2026-10-04):重登进度与 CK 都在 accounts 上。 + # 「最近尝试时间」仍来自登录记录(那是尝试的属性),用相关子查询取最后一条。 + latest_attempt_at = ( + select(LoginTask.attempt_at) + .where(LoginTask.account_id == Account.id) + .order_by(LoginTask.id.desc()) + .limit(1) + .scalar_subquery() + ) + + # 转人工 = 分型属于需人工,或者次数用尽(next_at 已清空且失败过)。 manual_filter = or_( - LoginTask.relogin_failure_kind.in_(tuple(MANUAL_KINDS)), + Account.relogin_failure_kind.in_(tuple(sorted(MANUAL_KINDS))), and_( - LoginTask.relogin_last_error != "", - LoginTask.relogin_next_at.is_(None), - LoginTask.relogin_attempts > 0, + Account.relogin_last_error != "", + Account.relogin_next_at.is_(None), + Account.relogin_attempts > 0, ), ) rows = ( - db.query(LoginTask, Account.username) - .join(Account, LoginTask.account_id == Account.id) - .filter(LoginTask.ck_check_status == "invalid") - .filter(LoginTask.relogin_failure_kind != "") + db.query(Account, latest_attempt_at.label("last_attempt_at")) + .filter(Account.ck_check_status == "invalid") + .filter(Account.relogin_failure_kind != "") .filter(manual_filter) - .order_by(LoginTask.attempt_at.desc(), LoginTask.id.desc()) + .order_by(latest_attempt_at.desc(), Account.id.desc()) .all() ) grouped: dict[str, dict] = {} - for task, username in rows: - kind = task.relogin_failure_kind or "" + for account, last_attempt_at in rows: + kind = account.relogin_failure_kind or "" # 可重试的类型却已经没有下次排期 = 次数用尽,单独成组。 # 否则它会混进「验证码失败」组里,客服看不出「这个已经不会再自动试了」。 if kind not in MANUAL_KINDS: @@ -241,12 +250,12 @@ def manual_queue( if len(group["samples"]) < limit: group["samples"].append( { - "task_id": task.id, - "account_username": username, - "relogin_attempts": int(task.relogin_attempts or 0), - "last_error": task.relogin_last_error or task.attempt_message or "", - "attempt_at": task.attempt_at.isoformat() - if task.attempt_at + "account_id": account.id, + "account_username": account.username, + "relogin_attempts": int(account.relogin_attempts or 0), + "last_error": account.relogin_last_error or "", + "attempt_at": last_attempt_at.isoformat() + if last_attempt_at else None, } ) diff --git a/web/backend/routers/cookies.py b/web/backend/routers/cookies.py index f8dead8..4347f2f 100644 --- a/web/backend/routers/cookies.py +++ b/web/backend/routers/cookies.py @@ -1,6 +1,22 @@ -"""Cookie 管理路由""" +"""Cookie 管理路由。 + +## 2026-10-04 语义变更(CK 解耦) + +CK 从 `login_tasks.cookie` 迁到 `accounts.cookie`,因此本页面的**记录单位从 +「登录记录」变成「账号」**: + +- 列表/汇总/导出 = **持有 CK 的账号**(一行一账号,不再出现同一账号多行); +- 所有接口的 `id` 入参/出参 = **account_id**(以前是 task_id); +- `DELETE /cookies/{id}` = **清掉该账号的 CK**(不可逆),必写审计; +- 重复检测 = **同一个 CK 值出现在多个账号上**(真正的风险:两个账号共用一个 + 登录态)。以前查的是「同一账号多行持 CK」,那在解耦后已不可能出现。 + +⚠️ 这是本项目**破坏性最强**的接口面:清错账号的 CK 比 10-03 那次事故更糟。 +所以删除/批量删除都单独写审计,且入参语义有专门的用例覆盖。 +""" import csv +import hashlib import io import threading from concurrent.futures import ThreadPoolExecutor, as_completed @@ -62,7 +78,7 @@ def _parse_account_names(raw_names: str) -> list[str]: return names -def _order_cookie_tasks(query, selected_names: list[str]): +def _order_cookie_accounts(query, selected_names: list[str]): """按自定义账号名的输入顺序排序,未指定时保持默认的最新优先。""" if selected_names: input_order = case( @@ -71,71 +87,109 @@ def _order_cookie_tasks(query, selected_names: list[str]): else_=len(selected_names), ) return query.order_by( - input_order, LoginTask.finished_at.desc(), LoginTask.id.desc() + input_order, Account.cookie_updated_at.desc(), Account.id.desc() ) - return query.order_by(LoginTask.finished_at.desc(), LoginTask.id.desc()) + return query.order_by(Account.cookie_updated_at.desc(), Account.id.desc()) -# CK 操作页沿用的重登状态词表;attempt_* 是唯一来源,历史值一并兼容。 -_RELOGIN_STATUS_BY_ATTEMPT = { - "pending": "relogin_pending", - "running": "relogin_running", - "failed": "relogin_failed", - "error": "relogin_failed", - "relogin_pending": "relogin_pending", - "relogin_running": "relogin_running", - "relogin_failed": "relogin_failed", -} +def _relogin_display(account: Account, in_flight_batch_id: str = "") -> dict: + """把账号上的重登进度映射成 CK 操作页展示用的状态。 - -def _relogin_display(task: LoginTask) -> dict: - """把最近一次登录尝试映射成 CK 操作页展示用的重登状态。 - - 账号持有的 CK 与「这次尝试的结果」分开存储,页面只关心后者;尝试结束 - 后旧 CK 仍在记录里,因此停止或失败都不会让这一行消失。 + 解耦后重登进度就写在账号上(`relogin_*`),不再需要顺着「最近一条登录记录」 + 去猜。「正在跑」仍然要看有没有在途尝试 —— 那是尝试的属性,由调用方批量查出后 + 以 ``in_flight_batch_id`` 传进来(避免 N+1)。 """ - attempt_status = task.attempt_status or "" + if in_flight_batch_id: + status = "relogin_running" + elif account.ck_check_status == "invalid" and account.relogin_next_at is not None: + status = "relogin_pending" + elif account.ck_check_status == "invalid" and account.relogin_last_error: + status = "relogin_failed" + else: + status = "" return { - "relogin_status": _RELOGIN_STATUS_BY_ATTEMPT.get(attempt_status, ""), - "relogin_message": task.attempt_message or "", - "relogin_batch_id": task.attempt_batch_id - if attempt_status in LOGIN_ATTEMPT_IN_FLIGHT_STATUSES - else "", + "relogin_status": status, + "relogin_message": account.relogin_last_error or "", + "relogin_batch_id": in_flight_batch_id, } -def _check_one_cookie(task: LoginTask) -> dict: - """将共享检测结果补充为 Cookie 记录的接口返回格式。""" - result = check_douyu_cookie(task.cookie or "") - result["id"] = task.id +def _check_one_cookie(account: Account) -> dict: + """将共享检测结果补充为 CK 记录的接口返回格式。""" + result = check_douyu_cookie(account.cookie or "") + result["id"] = account.id result["checked_at"] = result["checked_at"].isoformat() return result -def _visible_cookie_tasks_query(db: Session, current: User): - """返回当前用户可见的、仍持有 Cookie 的登录记录查询。 +def _visible_cookie_accounts_query(db: Session, current: User): + """返回当前用户可见的、仍持有 CK 的**账号**查询。 - 以「记录里还留着 CK」为准:重登失败(relogin_failed)与排队中的登录 - 都保留了旧 CK,管理页必须继续能看到这些账号,否则客服会以为账号消失。 + 以「账号上还有 CK」为准:重登失败与排队中的登录都不会让账号失去 CK, + 管理页必须继续能看到这些账号,否则客服会以为账号消失。 """ - query = db.query(LoginTask).filter(cookie_holding_filter()) - - # 无全量登录任务权限时,只能操作分配给自己的账号 Cookie。 + query = db.query(Account).filter(cookie_holding_filter()) + # 无全量登录任务权限时,只能操作分配给自己的账号。 if not user_has_permission(current, "login:view_all"): - query = query.join(Account, LoginTask.account_id == Account.id).filter( - Account.assigned_to == current.id - ) + query = query.filter(Account.assigned_to == current.id) return query -def _visible_cookie_operation_tasks_query(db: Session, current: User): - """返回可检测/重登的 Cookie 记录,重登中或失败时仍保留在操作列表。""" - query = db.query(LoginTask).filter(cookie_holding_filter()) - if not user_has_permission(current, "login:view_all"): - query = query.join(Account, LoginTask.account_id == Account.id).filter( - Account.assigned_to == current.id +def _visible_cookie_accounts_query_with_users(db: Session, current: User): + """同上,但预加载归属客服(列表展示需要)。""" + return _visible_cookie_accounts_query(db, current).options( + joinedload(Account.assigned_user) + ) + + +def _in_flight_batch_ids(db: Session, account_ids: list[int]) -> dict[int, str]: + """批量取「在途尝试」的批次号,供重登状态展示(一次查询,避免 N+1)。""" + if not account_ids: + return {} + rows = ( + db.query(LoginTask.account_id, func.max(LoginTask.attempt_batch_id)) + .filter(LoginTask.account_id.in_(account_ids)) + .filter( + func.coalesce(LoginTask.attempt_status, "").in_( + LOGIN_ATTEMPT_IN_FLIGHT_STATUSES + ) ) - return query + .group_by(LoginTask.account_id) + .all() + ) + return {int(account_id): (batch_id or "") for account_id, batch_id in rows} + + +def _cookie_item( + acc: Account, + *, + include_cookie: bool, + in_flight_batch_id: str = "", +) -> dict: + """构造账号的 CK 列表项(REST 与详情接口共用)。""" + cookie = (acc.cookie or "") if include_cookie else "" + item = { + "id": acc.id, + "account_id": acc.id, + "account_username": acc.username, + "assigned_to": acc.assigned_to, + "assigned_username": acc.assigned_user.username if acc.assigned_user else None, + # 这份 CK 是什么时候拿到的(原来是登录记录的 finished_at) + "created_at": _fmt_dt(acc.cookie_updated_at), + "ck_check_status": acc.ck_check_status or "", + "ck_check_result": acc.ck_check_result, + "ck_checked_at": _fmt_dt(acc.ck_checked_at), + **_relogin_display(acc, in_flight_batch_id), + } + if include_cookie: + item["cookie"] = cookie + item["cookie_preview"] = cookie[:50] + "..." if len(cookie) > 50 else cookie + item["account_password"] = acc.password + else: + item["cookie"] = "" + item["cookie_preview"] = "***" + item["account_password"] = "" + return item @router.get("") @@ -149,37 +203,30 @@ def list_cookies( db: Session = Depends(get_db), current: User = Depends(get_current_user), ): - """查看登录成功的 Cookie 列表。""" + """查看持有 CK 的账号列表(一行一账号)。""" if not user_has_permission(current, "cookie:view"): raise HTTPException(status_code=403, detail="无权限: cookie:view") + query = _visible_cookie_accounts_query_with_users(db, current) if not include_cookie: - query = _visible_cookie_tasks_query(db, current).options( - defer(LoginTask.cookie) + # 列表默认不拖 1.7KB 的密文;复制/导出时才按 id 取完整值。 + query = query.options( + defer(Account.cookie), + defer(Account.password), + defer(Account.email), + defer(Account.email_password), ) else: - query = _visible_cookie_tasks_query(db, current).options( - joinedload(LoginTask.account).joinedload(Account.assigned_user), - ) + query = query.options(defer(Account.email), defer(Account.email_password)) + search_text = (search or "").strip() tag_value = (tag or "").strip() selected_names = _parse_account_names(account_names) - can_view_all = user_has_permission(current, "login:view_all") - account_joined = not can_view_all if selected_names: - if not account_joined: - query = query.join(Account, LoginTask.account_id == Account.id) - account_joined = True query = query.filter(Account.username.in_(selected_names)) if tag_value: - if not account_joined: - query = query.join(Account, LoginTask.account_id == Account.id) - account_joined = True query = query.filter(Account.tag == tag_value) if search_text: pattern = f"%{search_text}%" - if not account_joined: - query = query.join(Account, LoginTask.account_id == Account.id) - account_joined = True query = query.outerjoin(User, Account.assigned_to == User.id).filter( or_( Account.username.ilike(pattern), @@ -190,58 +237,22 @@ def list_cookies( total = None if page is not None: - total = ( - query.order_by(None).with_entities(func.count(LoginTask.id)).scalar() or 0 - ) - query = _order_cookie_tasks(query, selected_names) + total = query.order_by(None).with_entities(func.count(Account.id)).scalar() or 0 + query = _order_cookie_accounts(query, selected_names) if page is not None: query = query.offset((page - 1) * page_size).limit(page_size) - tasks = query.all() - - # 批量查账号,避免 N+1 查询 - account_ids = [t.account_id for t in tasks] - accounts_map = {} - if account_ids: - account_query = db.query(Account).filter(Account.id.in_(account_ids)) - if not include_cookie: - account_query = account_query.options( - joinedload(Account.assigned_user), - defer(Account.password), - defer(Account.email), - defer(Account.email_password), - ) - accs = account_query.all() - accounts_map = {a.id: a for a in accs} - - result = [] - for t in tasks: - acc = accounts_map.get(t.account_id) - item = { - "id": t.id, - "batch_id": t.batch_id, - "account_id": t.account_id, - "account_username": acc.username if acc else "", - "assigned_to": acc.assigned_to if acc else None, - "assigned_username": acc.assigned_user.username - if acc and acc.assigned_user - else None, - "created_at": _fmt_dt(t.finished_at), - "ck_check_status": t.ck_check_status or "", - "ck_check_result": t.ck_check_result, - "ck_checked_at": _fmt_dt(t.ck_checked_at), - } - # 分页列表默认只返回预览,复制/导出时再获取完整敏感字段。 - if include_cookie and user_has_permission(current, "cookie:view"): - cookie = t.cookie or "" - item["cookie"] = cookie - item["cookie_preview"] = cookie[:50] + "..." if len(cookie) > 50 else cookie - item["account_password"] = acc.password if acc else "" - else: - item["cookie"] = "" - item["cookie_preview"] = "***" - item["account_password"] = "" - result.append(item) + accounts = query.all() + in_flight = _in_flight_batch_ids(db, [acc.id for acc in accounts]) + # include_cookie 已经在上面的权限里保证只对有 cookie:view 的人开放。 + result = [ + _cookie_item( + acc, + include_cookie=include_cookie, + in_flight_batch_id=in_flight.get(acc.id, ""), + ) + for acc in accounts + ] if page is not None: return { "items": result, @@ -260,13 +271,11 @@ def cookies_summary( """Cookie 管理统计,避免前端为了卡片统计拉全量 Cookie。""" if not user_has_permission(current, "cookie:view"): raise HTTPException(status_code=403, detail="无权限: cookie:view") - query = _visible_cookie_tasks_query(db, current) - if user_has_permission(current, "login:view_all"): - query = query.join(Account, LoginTask.account_id == Account.id) - total = query.with_entities(func.count(LoginTask.id)).scalar() or 0 + query = _visible_cookie_accounts_query(db, current) + total = query.with_entities(func.count(Account.id)).scalar() or 0 assigned_count = ( query.filter(Account.assigned_to.isnot(None)) - .with_entities(func.count(LoginTask.id)) + .with_entities(func.count(Account.id)) .scalar() or 0 ) @@ -288,9 +297,7 @@ def list_cookie_operations( ): """脱敏的 CK 操作列表,只用于检测与重登。""" _require_cookie_operation_perm(current) - query = _visible_cookie_operation_tasks_query(db, current) - if user_has_permission(current, "login:view_all"): - query = query.join(Account, LoginTask.account_id == Account.id) + query = _visible_cookie_accounts_query(db, current) search_text = (search or "").strip() tag_value = (tag or "").strip() if tag_value: @@ -303,25 +310,26 @@ def list_cookie_operations( ) ) - total = query.order_by(None).with_entities(func.count(LoginTask.id)).scalar() or 0 - tasks = ( - query.order_by(LoginTask.finished_at.desc(), LoginTask.id.desc()) + total = query.order_by(None).with_entities(func.count(Account.id)).scalar() or 0 + accounts = ( + query.order_by(Account.cookie_updated_at.desc(), Account.id.desc()) .offset((page - 1) * page_size) .limit(page_size) .all() ) + in_flight = _in_flight_batch_ids(db, [acc.id for acc in accounts]) return { "items": [ { - "id": task.id, - "account_username": task.account.username if task.account else "", - "tag": task.account.tag if task.account else "", - "ck_check_status": task.ck_check_status or "", - "ck_checked_at": _fmt_dt(task.ck_checked_at), - "created_at": _fmt_dt(task.finished_at), - **_relogin_display(task), + "id": acc.id, + "account_username": acc.username, + "tag": acc.tag or "", + "ck_check_status": acc.ck_check_status or "", + "ck_checked_at": _fmt_dt(acc.ck_checked_at), + "created_at": _fmt_dt(acc.cookie_updated_at), + **_relogin_display(acc, in_flight.get(acc.id, "")), } - for task in tasks + for acc in accounts ], "total": total, "page": page, @@ -334,11 +342,9 @@ def list_cookie_operation_tags( db: Session = Depends(get_db), current: User = Depends(get_current_user), ): - """返回当前用户可操作 CK 记录对应的标签,不跨越账号分配范围。""" + """返回当前用户可操作 CK 的账号标签,不跨越账号分配范围。""" _require_cookie_operation_perm(current) - query = _visible_cookie_operation_tasks_query(db, current) - if user_has_permission(current, "login:view_all"): - query = query.join(Account, LoginTask.account_id == Account.id) + query = _visible_cookie_accounts_query(db, current) rows = ( query.filter(Account.tag != "", Account.tag.isnot(None)) .with_entities(Account.tag) @@ -354,67 +360,53 @@ def find_duplicate_cookies( db: Session = Depends(get_db), current: User = Depends(require_permission("cookie:view")), ): - """检测 CK 管理中同一账号出现多次的记录,不返回 Cookie 敏感字段。""" - query = ( - db.query( - LoginTask.id.label("cookie_id"), - LoginTask.account_id.label("account_id"), - LoginTask.batch_id.label("batch_id"), - LoginTask.finished_at.label("finished_at"), - Account.username.label("username"), - ) - .join(Account, LoginTask.account_id == Account.id) - .filter(cookie_holding_filter()) + """检测**同一个 CK 值出现在多个账号上**,不返回 Cookie 敏感字段。 + + 解耦前这里查的是「同一账号有多行持 CK」—— 那是「一行一账号」不变量漏水的产物。 + 现在每个账号只有一份 CK,真正值得报警的是**多个账号共用一个登录态**: + 要么是导入时复制粘贴错了,要么是同一个斗鱼号被建了两次。这两种都会让 + 「按账号算的积分/兑换」失真,所以按 CK 指纹分组。 + + 指纹用 sha256 而不是密文本身:CK 用 AES-GCM + 随机 nonce 加密, + **同一个明文每次加密结果都不同**,直接比密文永远比不出重复。 + """ + accounts = ( + _visible_cookie_accounts_query(db, current) + .order_by(Account.username.asc(), Account.id.asc()) + .all() ) - # 与 CK 列表保持相同的数据可见范围,客服只能检测自己被分配的账号。 - if not user_has_permission(current, "login:view_all"): - query = query.filter(Account.assigned_to == current.id) - - rows = query.order_by( - Account.username.asc(), LoginTask.finished_at.desc(), LoginTask.id.desc() - ).all() - grouped: dict[str, dict] = {} - for row in rows: - username = (row.username or "").strip() - if not username: + grouped: dict[str, list[Account]] = {} + for acc in accounts: + cookie = acc.cookie or "" + if not cookie: continue - account_key = username.casefold() - group = grouped.setdefault( - account_key, - { - "account_key": account_key, - "account_names": set(), - "account_ids": set(), - "cookie_ids": [], - "records": [], - }, - ) - group["account_names"].add(username) - group["account_ids"].add(row.account_id) - group["cookie_ids"].append(row.cookie_id) - group["records"].append( - { - "id": row.cookie_id, - "account_id": row.account_id, - "batch_id": row.batch_id, - "finished_at": _fmt_dt(row.finished_at), - } - ) + fingerprint = hashlib.sha256(cookie.encode("utf-8")).hexdigest() + grouped.setdefault(fingerprint, []).append(acc) duplicate_groups = [] - for group in grouped.values(): - if len(group["cookie_ids"]) < 2: + for fingerprint, group in grouped.items(): + if len(group) < 2: continue + account_ids = [acc.id for acc in group] duplicate_groups.append( { - "account_key": group["account_key"], - "account_names": sorted(group["account_names"]), - "cookie_count": len(group["cookie_ids"]), - "account_count": len(group["account_ids"]), - "cookie_ids": group["cookie_ids"], - "account_ids": sorted(group["account_ids"]), - "records": group["records"], + # account_key 保持字段名兼容,现在放的是 CK 指纹前 12 位。 + "account_key": fingerprint[:12], + "account_names": [acc.username for acc in group], + "cookie_count": len(group), + "account_count": len(group), + "cookie_ids": account_ids, + "account_ids": account_ids, + "records": [ + { + "id": acc.id, + "account_id": acc.id, + "batch_id": "", + "finished_at": _fmt_dt(acc.cookie_updated_at), + } + for acc in group + ], } ) duplicate_groups.sort(key=lambda item: (-item["cookie_count"], item["account_key"])) @@ -438,30 +430,18 @@ def export_cookies( csv: 账号, Cookie, 时间 custom: 账号----密码----ck """ - query = _visible_cookie_tasks_query(db, current) + query = _visible_cookie_accounts_query(db, current) selected_names = _parse_account_names(account_names) if selected_names: - if user_has_permission(current, "login:view_all"): - query = query.join(Account, LoginTask.account_id == Account.id) query = query.filter(Account.username.in_(selected_names)) - tasks = _order_cookie_tasks(query, selected_names).all() - - # 批量查账号,避免 N+1 - account_ids = [t.account_id for t in tasks] - accounts_map = {} - if account_ids: - accs = db.query(Account).filter(Account.id.in_(account_ids)).all() - accounts_map = {a.id: a for a in accs} + accounts = _order_cookie_accounts(query, selected_names).all() if format == "custom": # 账号----密码----ck - lines = [] - for t in tasks: - acc = accounts_map.get(t.account_id) - username = acc.username if acc else "" - password = acc.password if acc else "" - ck = t.cookie or "" - lines.append(f"{username}----{password}----{ck}") + lines = [ + f"{acc.username}----{acc.password}----{acc.cookie or ''}" + for acc in accounts + ] content = "\r\n".join(lines) filename = "cookies_custom.txt" media = "text/plain" @@ -470,10 +450,10 @@ def export_cookies( output = io.StringIO() writer = csv.writer(output) writer.writerow(["账号", "Cookie", "时间"]) - for t in tasks: - acc = accounts_map.get(t.account_id) - username = acc.username if acc else "" - writer.writerow([username, t.cookie or "", _fmt_dt(t.finished_at) or ""]) + for acc in accounts: + writer.writerow( + [acc.username, acc.cookie or "", _fmt_dt(acc.cookie_updated_at) or ""] + ) content = output.getvalue() filename = "cookies.csv" media = "text/csv" @@ -486,36 +466,35 @@ def export_cookies( def _check_cookies(ids: str, db: Session, current: User, *, detailed: bool) -> dict: - """执行 CK 检测;客服操作页仅返回状态,不返回账号衍生信息。""" + """执行 CK 检测;客服操作页仅返回状态,不返回账号衍生信息。 + + `ids` 是**账号 ID**(2026-10-04 起)。 + """ if not ids: - raise HTTPException(status_code=400, detail="请指定记录ID") + raise HTTPException(status_code=400, detail="请指定账号ID") id_list = [int(x) for x in ids.split(",") if x.strip().isdigit()] if not id_list: raise HTTPException(status_code=400, detail="无效的ID") - base_query = ( - _visible_cookie_tasks_query(db, current) - if detailed - else _visible_cookie_operation_tasks_query(db, current) - ) - tasks = ( - base_query.filter(LoginTask.id.in_(id_list)) + accounts = ( + _visible_cookie_accounts_query(db, current) + .filter(Account.id.in_(id_list)) .filter(cookie_operable_filter()) .all() ) - if not tasks: - raise HTTPException(status_code=404, detail="记录不存在") + if not accounts: + raise HTTPException(status_code=404, detail="账号不存在或不可检测") results: list[dict] = [] with ThreadPoolExecutor(max_workers=8) as pool: - futures = {pool.submit(_check_one_cookie, task): task for task in tasks} + futures = {pool.submit(_check_one_cookie, acc): acc for acc in accounts} for future in as_completed(futures): try: results.append(future.result()) except Exception as exc: # noqa: BLE001 外部接口与任务边界需要保留宽泛异常兜底 - task = futures[future] + acc = futures[future] results.append( { - "id": task.id, + "id": acc.id, "valid": False, "message": f"检测异常: {exc}", "fish_ball": None, @@ -526,26 +505,26 @@ def _check_cookies(ids: str, db: Session, current: User, *, detailed: bool) -> d ) results.sort(key=lambda item: item["id"]) - # 持久化检测结果,刷新/翻页不丢失 - tasks_by_id = {task.id: task for task in tasks} + # 持久化检测结果到**账号**,刷新/翻页不丢失。 + accounts_by_id = {acc.id: acc for acc in accounts} for item in results: - task = tasks_by_id.get(item["id"]) - if not task: + acc = accounts_by_id.get(item["id"]) + if not acc: continue - task.ck_check_status = "valid" if item["valid"] else "invalid" - task.ck_check_result = { + acc.ck_check_status = "valid" if item["valid"] else "invalid" + acc.ck_check_result = { "fish_ball": item.get("fish_ball"), "nickname": item.get("nickname"), "level": item.get("level"), "message": item.get("message", ""), } - task.ck_checked_at = datetime.now(UTC) + acc.ck_checked_at = datetime.now(UTC) db.add( AuditLog( user_id=current.id, username=current.username, action="cookie:check", - target=f"检测 {len(results)} 条已分配账号 CK", + target=f"检测 {len(results)} 个账号的 CK", ) ) db.commit() @@ -580,38 +559,41 @@ def check_cookie_operations( return _check_cookies(ids, db, current, detailed=False) -def _start_relogin_tasks( - tasks: list[LoginTask], +def _start_relogin_accounts( + accounts: list[Account], db: Session, current: User, *, action: str = "cookie:relogin", ): - """启动重登批次:旧 Cookie 保留到新登录成功后才替换。""" - if not tasks: - raise HTTPException(status_code=404, detail="记录不存在") + """启动重登批次:旧 CK 保留到新登录成功后才替换。 - task_ids: list[int] = [] + 入参是**账号**(2026-10-04 起):调度单位就是账号,登录记录只是每次尝试的历史。 + """ + if not accounts: + raise HTTPException(status_code=404, detail="账号不存在") + + account_ids: list[int] = [] skipped: list[int] = [] - for task in tasks: - acc = db.query(Account).filter(Account.id == task.account_id).first() - if not acc: - skipped.append(task.id) - continue + for acc in accounts: if not acc.password or not acc.email: - # 只记录这次尝试失败,账号已有的 CK 原封不动。 - task.attempt_status = "failed" - task.attempt_message = "重新登录失败: 账号缺少密码或邮箱,旧 Cookie 已保留" - task.attempt_at = datetime.now(UTC) - skipped.append(task.id) + # 记录这次尝试失败即可;账号已有的 CK 原封不动。 + db.add( + LoginTask( + batch_id="relogin-precheck", + account_id=acc.id, + status="failed", + attempt_status="failed", + attempt_message="重新登录失败: 账号缺少密码或邮箱,旧 Cookie 已保留", + attempt_at=datetime.now(UTC), + created_by=current.id, + ) + ) + skipped.append(acc.id) continue - if task.attempt_status in LOGIN_ATTEMPT_IN_FLIGHT_STATUSES: - skipped.append(task.id) - continue - # 排队状态由 LoginBatchRunner 在批次启动时写入 attempt_*(含批次号)。 - task_ids.append(task.id) + account_ids.append(acc.id) - if not task_ids: + if not account_ids: db.commit() raise HTTPException( status_code=400, detail="没有可重登的账号,请检查账号凭据或当前重登状态" @@ -622,7 +604,7 @@ def _start_relogin_tasks( max_login_retries, max_total_time = get_relogin_limits() runner = LoginBatchRunner( db=thread_db, - account_ids=[], + account_ids=account_ids, created_by=current.id, creator_permissions=get_user_permissions(current), max_login_retries=max_login_retries, @@ -632,7 +614,6 @@ def _start_relogin_tasks( loop=None, concurrency=3, mode="relogin", - relogin_task_ids=task_ids, ) batch_id = runner.batch_id db.add( @@ -640,7 +621,7 @@ def _start_relogin_tasks( user_id=current.id, username=current.username, action=action, - target=f"重登 {len(task_ids)} 条账号 CK", + target=f"重登 {len(account_ids)} 个账号的 CK", detail="旧 Cookie 将在新登录成功后替换", ) ) @@ -653,8 +634,8 @@ def _start_relogin_tasks( except Exception as exc: # noqa: BLE001 外部接口与任务边界需要保留宽泛异常兜底 runner._push_log("error", f"批量重新登录异常: {exc}") message = f"重新登录异常: {exc}(旧 Cookie 已保留)" - for task_id in task_ids: - runner._mark_relogin_stopped(task_id, message) + for account_id in account_ids: + runner._mark_account_relogin_stopped(account_id, message) runner.db.close() finally: cookie_relogin_registry.pop(batch_id) @@ -662,7 +643,7 @@ def _start_relogin_tasks( threading.Thread(target=run_relogin_batch, daemon=True).start() return { "batch_id": batch_id, - "count": len(task_ids), + "count": len(account_ids), "skipped": len(skipped), "success": True, } @@ -703,16 +684,16 @@ def stop_cookie_relogin( def _start_relogin(req: CookieReloginRequest, db: Session, current: User): - """按选中的 Cookie 记录启动重新登录。""" + """按选中的账号启动重新登录(`req.ids` 是账号 ID)。""" if not req.ids: - raise HTTPException(status_code=400, detail="请选择要重新登录的 Cookie") - tasks = ( - _visible_cookie_operation_tasks_query(db, current) - .filter(LoginTask.id.in_(req.ids)) + raise HTTPException(status_code=400, detail="请选择要重新登录的账号") + accounts = ( + _visible_cookie_accounts_query(db, current) + .filter(Account.id.in_(req.ids)) .filter(cookie_operable_filter()) .all() ) - return _start_relogin_tasks(tasks, db, current) + return _start_relogin_accounts(accounts, db, current) @router.post("/relogin") @@ -745,12 +726,10 @@ def relogin_invalid_cookie_operations( ): """批量重新登录当前用户可见且最近检测为无效的账号。""" _require_cookie_operation_perm(current) - query = _visible_cookie_operation_tasks_query(db, current).filter( - LoginTask.ck_check_status == "invalid", + query = _visible_cookie_accounts_query(db, current).filter( + Account.ck_check_status == "invalid", cookie_operable_filter(), ) - if user_has_permission(current, "login:view_all"): - query = query.join(Account, LoginTask.account_id == Account.id) if tag.strip(): query = query.filter(Account.tag == tag.strip()) if search.strip(): @@ -758,89 +737,102 @@ def relogin_invalid_cookie_operations( query = query.filter( or_(Account.username.ilike(pattern), Account.tag.ilike(pattern)) ) - tasks = query.order_by(LoginTask.id.asc()).all() - return _start_relogin_tasks(tasks, db, current, action="cookie:relogin_invalid") + accounts = query.order_by(Account.id.asc()).all() + return _start_relogin_accounts( + accounts, db, current, action="cookie:relogin_invalid" + ) -@router.get("/{task_id}") +@router.get("/{account_id}") def get_cookie( - task_id: int, + account_id: int, db: Session = Depends(get_db), current: User = Depends(get_current_user), ): - """获取单条 Cookie 详情,供复制操作按需读取完整敏感字段。""" + """获取单个账号的 CK 详情,供复制操作按需读取完整敏感字段。""" if not user_has_permission(current, "cookie:view"): raise HTTPException(status_code=403, detail="无权限: cookie:view") - task = ( - _visible_cookie_tasks_query(db, current).filter(LoginTask.id == task_id).first() + acc = ( + _visible_cookie_accounts_query_with_users(db, current) + .filter(Account.id == account_id) + .first() ) - if not task: - raise HTTPException(status_code=404, detail="记录不存在") - acc = db.query(Account).filter(Account.id == task.account_id).first() - item = { - "id": task.id, - "batch_id": task.batch_id, - "account_id": task.account_id, - "account_username": acc.username if acc else "", - "assigned_to": acc.assigned_to if acc else None, - "assigned_username": acc.assigned_user.username - if acc and acc.assigned_user - else None, - "created_at": _fmt_dt(task.finished_at), - "ck_check_status": task.ck_check_status or "", - "ck_check_result": task.ck_check_result, - "ck_checked_at": _fmt_dt(task.ck_checked_at), - "cookie": "", - "cookie_preview": "***", - "account_password": "", - } - if user_has_permission(current, "cookie:view"): - cookie = task.cookie or "" - item["cookie"] = cookie - item["cookie_preview"] = cookie[:50] + "..." if len(cookie) > 50 else cookie - item["account_password"] = acc.password if acc else "" - return item + if not acc: + raise HTTPException(status_code=404, detail="账号不存在或没有 CK") + return _cookie_item(acc, include_cookie=True) @router.delete("/batch") def delete_cookies_batch( - task_ids: str = "", + account_ids: str = "", db: Session = Depends(get_db), current: User = Depends(require_permission("cookie:export")), ): - """批量删除 Cookie 记录。""" - if not task_ids: - raise HTTPException(status_code=400, detail="请指定记录ID") - ids = [int(x) for x in task_ids.split(",") if x.strip().isdigit()] + """批量清除账号的 CK(**不可逆**,写审计)。""" + if not account_ids: + raise HTTPException(status_code=400, detail="请指定账号ID") + ids = [int(x) for x in account_ids.split(",") if x.strip().isdigit()] if not ids: raise HTTPException(status_code=400, detail="无效的ID") - tasks = _visible_cookie_tasks_query(db, current).filter(LoginTask.id.in_(ids)).all() - for t in tasks: - t.cookie = "" - t.status = "failed" - t.message = "Cookie已清除" + accounts = ( + _visible_cookie_accounts_query(db, current).filter(Account.id.in_(ids)).all() + ) + now = datetime.now(UTC) + for acc in accounts: + acc.cookie = "" + acc.cookie_updated_at = now + acc.ck_check_status = "" + acc.ck_check_result = None + acc.ck_checked_at = None + db.add( + AuditLog( + user_id=current.id, + username=current.username, + action="cookie:delete", + target=f"清除 {len(accounts)} 个账号的 CK", + detail="不可逆:CK 已从账号上清空", + ) + ) db.commit() return { - "message": f"已删除 {len(tasks)} 条", - "deleted": len(tasks), + "message": f"已清除 {len(accounts)} 个账号的 CK", + "deleted": len(accounts), "success": True, } -@router.delete("/{task_id}") +@router.delete("/{account_id}") def delete_cookie( - task_id: int, + account_id: int, db: Session = Depends(get_db), current: User = Depends(require_permission("cookie:export")), ): - """删除一条 Cookie 记录。""" - task = ( - _visible_cookie_tasks_query(db, current).filter(LoginTask.id == task_id).first() + """清除一个账号的 CK(**不可逆**,写审计)。 + + ⚠️ 入参是 **account_id**(2026-10-04 起)。以前是 task_id —— 语义变了, + 改错会变成「点删除清掉另一个账号的登录态」,所以有专门用例盯这一条。 + """ + acc = ( + _visible_cookie_accounts_query(db, current) + .filter(Account.id == account_id) + .first() + ) + if not acc: + raise HTTPException(status_code=404, detail="账号不存在或没有 CK") + username = acc.username + acc.cookie = "" + acc.cookie_updated_at = datetime.now(UTC) + acc.ck_check_status = "" + acc.ck_check_result = None + acc.ck_checked_at = None + db.add( + AuditLog( + user_id=current.id, + username=current.username, + action="cookie:delete", + target=username, + detail="不可逆:CK 已从该账号清空", + ) ) - if not task: - raise HTTPException(status_code=404, detail="记录不存在") - task.cookie = "" - task.status = "failed" - task.message = "Cookie已清除" db.commit() return {"message": "已删除", "success": True} diff --git a/web/backend/routers/dashboard.py b/web/backend/routers/dashboard.py index 51ada46..b62b2d5 100644 --- a/web/backend/routers/dashboard.py +++ b/web/backend/routers/dashboard.py @@ -84,14 +84,12 @@ def dashboard_summary( cookies = 0 if user_has_permission(current, "cookie:view"): - cookie_query = ( - db.query(LoginTask) - .join(Account, LoginTask.account_id == Account.id) - .filter(cookie_holding_filter()) - ) + # CK 已挂在账号上(2026-10-04 解耦),所以这里统计的是「账号数」而不是 + # 「带 CK 的登录记录行数」—— 后者在有历史行时会把同一个账号算多次。 + cookie_query = db.query(Account).filter(cookie_holding_filter()) if not user_has_permission(current, "login:view_all"): cookie_query = cookie_query.filter(Account.assigned_to == current.id) - cookies = cookie_query.with_entities(func.count(LoginTask.id)).scalar() or 0 + cookies = cookie_query.with_entities(func.count(Account.id)).scalar() or 0 huya_accounts = 0 can_view_huya_accounts = any( diff --git a/web/backend/routers/douyu.py b/web/backend/routers/douyu.py index 831ead4..f3d00f9 100644 --- a/web/backend/routers/douyu.py +++ b/web/backend/routers/douyu.py @@ -37,7 +37,6 @@ from ..models import ( DouyuWorkbench, DouyuWorkbenchAccount, DouyuXpdGoodsSnapshot, - LoginTask, User, ) from ..permissions import user_has_permission @@ -185,7 +184,7 @@ def _visible_task_accounts_query(db: Session, current: User): 只要账号还持有 Cookie 就可见:排队中的普通登录(pending/running)和 重登失败(relogin_failed)都会保留旧 CK,不能让账号从工作台消失。 """ - cookie_ids = select(LoginTask.account_id).where(cookie_holding_filter()).distinct() + cookie_ids = select(Account.id).where(cookie_holding_filter()) query = ( db.query(Account) .options(joinedload(Account.assigned_user)) diff --git a/web/backend/routers/login.py b/web/backend/routers/login.py index 4acf344..98cfb2b 100644 --- a/web/backend/routers/login.py +++ b/web/backend/routers/login.py @@ -11,7 +11,7 @@ from fastapi import ( WebSocketDisconnect, ) from sqlalchemy import and_, func, or_ -from sqlalchemy.orm import Session, defer, load_only +from sqlalchemy.orm import Session, load_only from ..database import SessionLocal, get_db from ..deps import authenticate_websocket, get_current_user, require_permission @@ -142,12 +142,11 @@ async def create_batch( def _visible_tasks_query(db: Session, current: User, batch_id: str | None): - """按权限与批次过滤登录任务。客服只能看自己名下账号的任务。""" - query = ( - db.query(LoginTask) - .options(defer(LoginTask.cookie)) - .join(Account, LoginTask.account_id == Account.id) - ) + """按权限与批次过滤登录任务。客服只能看自己名下账号的任务。 + + 这里已经**不需要 defer cookie** —— 登录记录不再持有凭证(2026-10-04 CK 解耦)。 + """ + query = db.query(LoginTask).join(Account, LoginTask.account_id == Account.id) if not user_has_permission(current, "login:view_all"): query = query.filter(Account.assigned_to == current.id) @@ -255,19 +254,34 @@ def tasks_summary( return _login_task_summary(query) -def _holds_ck_clause(): - """判定登录记录是否仍**持有 CK**(与 `cookie_holding_filter` 同口径)。 +def _in_flight_clause(): + """不可删除的记录:正被批次处理的**在途尝试**。 - ⚠️ 删除登录记录会连带删掉它持有的 CK —— 斗鱼账号的 CK **只存在 - `login_tasks.cookie` 里**(`accounts` 表没有 cookie 列)。删掉就再也找不回来, - 所以删除接口一律只允许删「空 cookie」的历史记录。 + ⚠️ **CK 自 2026-10-04 起不再是护栏理由** —— CK 已迁到 `accounts.cookie`, + 删登录历史不再影响任何账号的登录态(这正是解耦的目的)。但「在途」必须保留: + 删掉会让批次线程找不到记录;停批次请走 ``/stop/{batch_id}``。 """ - return and_(LoginTask.cookie.isnot(None), LoginTask.cookie != "") + in_flight = ("pending", "running") + return or_( + and_( + LoginTask.attempt_status != "", + LoginTask.attempt_status.in_(in_flight), + ), + and_(LoginTask.attempt_status == "", LoginTask.status.in_(in_flight)), + ) def _deletable_clause(): - """可安全删除的记录:没有持有 CK。""" - return or_(LoginTask.cookie.is_(None), LoginTask.cookie == "") + """可安全删除的记录:不在途。""" + return ~_in_flight_clause() + + +def _is_in_flight(task: LoginTask) -> bool: + """单条记录是否在途(与 `_in_flight_clause` 同口径)。""" + in_flight = ("pending", "running") + if task.attempt_status: + return task.attempt_status in in_flight + return (task.status or "") in in_flight @router.delete("/tasks/{task_id}") @@ -276,14 +290,14 @@ def delete_task( db: Session = Depends(get_db), current: User = Depends(require_permission("login:batch")), ): - """删除单个登录任务(**持有 CK 的记录不允许删除**)。""" + """删除单个登录任务(**在途的记录不允许删除**;删历史不影响 CK)。""" task = db.query(LoginTask).filter(LoginTask.id == task_id).first() if not task: raise HTTPException(status_code=404, detail="任务不存在") - if task.cookie: + if _is_in_flight(task): raise HTTPException( status_code=409, - detail="该记录仍持有 CK,删除会丢失账号的登录态;如需清理请先处理该账号的 CK", + detail="该记录正在被批次处理,删除会让批次失去目标;请先停止批次", ) db.delete(task) db.commit() @@ -307,22 +321,13 @@ def delete_tasks( 两条护栏(都会在响应里报告跳过数量): - - **持有 CK 的记录一律不删**:斗鱼账号的 CK 只存在 `login_tasks.cookie`, - 删掉即永久丢失登录态。列表显示的「状态」是最近一次尝试 - (``attempt_status``),一条显示「失败」的记录完全可能仍持有有效 CK —— - 曾因此丢过一批 CK,所以这里按 cookie 而不是按显示状态判断。 - - **进行中(pending/running)的记录不删**:删掉会让批次线程找不到记录; + - **在途(pending/running)的记录不删**:删掉会让批次线程找不到记录; 停批次请走 ``/stop/{batch_id}``。 + - ~~持有 CK 的记录不删~~ —— **这条护栏已随 CK 解耦(2026-10-04)取消**: + CK 现在挂在账号上,删登录历史不影响登录态。历史终于可以自由清理。 """ # 护栏:不删在途任务,避免批次线程与数据库状态打架。 - in_flight = ("pending", "running") - not_in_flight = or_( - and_( - LoginTask.attempt_status != "", - LoginTask.attempt_status.notin_(in_flight), - ), - and_(LoginTask.attempt_status == "", LoginTask.status.notin_(in_flight)), - ) + not_in_flight = _deletable_clause() if task_ids: ids = [int(x) for x in task_ids.split(",") if x.strip().isdigit()] @@ -355,16 +360,16 @@ def delete_tasks( } rows = ( - db.query(LoginTask.id, LoginTask.cookie, not_in_flight.label("ok_to_delete")) + db.query(LoginTask.id, not_in_flight.label("ok_to_delete")) .filter(LoginTask.id.in_(ids)) .all() ) - target_ids = [r[0] for r in rows if r[2] and not r[1]] + target_ids = [r[0] for r in rows if r[1]] skipped = len(rows) - len(target_ids) if not target_ids: return { - "message": f"没有可删除的记录({skipped} 条持有 CK 或在途,已跳过)", + "message": f"没有可删除的记录({skipped} 条在途,已跳过)", "deleted": 0, "skipped": skipped, "success": True, @@ -379,7 +384,7 @@ def delete_tasks( message = f"已删除 {deleted} 个任务" if skipped: - message += f"({skipped} 条持有 CK 或在途,已跳过)" + message += f"({skipped} 条在途,已跳过)" return {"message": message, "deleted": deleted, "skipped": skipped, "success": True} diff --git a/web/backend/schemas.py b/web/backend/schemas.py index f185580..cd513b8 100644 --- a/web/backend/schemas.py +++ b/web/backend/schemas.py @@ -192,7 +192,7 @@ class LoginBatchRequest(BaseModel): class CookieReloginRequest(BaseModel): - ids: list[int] = [] # 需要重新登录的 Cookie 记录 ID + ids: list[int] = [] # 需要重新登录的**账号** ID(2026-10-04 CK 解耦前是记录 ID) class LoginTaskOut(BaseModel): diff --git a/web/backend/services/automation/jobs/ck_health.py b/web/backend/services/automation/jobs/ck_health.py index 88adac3..5a34406 100644 --- a/web/backend/services/automation/jobs/ck_health.py +++ b/web/backend/services/automation/jobs/ck_health.py @@ -16,7 +16,7 @@ from typing import Any from sqlalchemy import or_ from sqlalchemy.orm import Session -from ....models import Account, AutomationJob, LoginTask +from ....models import Account, AutomationJob from ...account_events_service import EVENT_CK_INVALID, emit from ...cookie_check_service import check_douyu_cookie from ...douyu_service import cookie_operable_filter @@ -60,11 +60,11 @@ def classify_result(result: dict[str, Any]) -> str: return "error" -def build_select_query(db: Session, job: AutomationJob, now: datetime): - """构造本轮要检测记录的查询(与取行分开,便于按方言编译做回归)。 +def _candidates_query(db: Session, job: AutomationJob, now: datetime): + """本轮「待检测账号」的完整候选集(**不带 LIMIT**,供计数复用)。 - 口径与人工点检测一致(``cookie_operable_filter``:持有 CK 且没有在途尝试), - 再加三条:距上次检测超过作业间隔、账号允许自动管理、最久没查的优先。 + 口径与人工点检测一致(``cookie_operable_filter``:账号有 CK 且没有在途尝试), + 再加两条:距上次检测超过作业间隔、账号允许自动管理。 排序刻意不用 ``NULLS FIRST``:那是 PostgreSQL/SQLite 语法,**MySQL 会报 1064 语法错误**(生产踩过一次)。改用 ``ck_checked_at IS NULL DESC``, @@ -72,32 +72,52 @@ def build_select_query(db: Session, job: AutomationJob, now: datetime): """ cutoff = now - timedelta(seconds=max(int(job.interval_seconds or 0), 60)) return ( - db.query(LoginTask) - .join(Account, LoginTask.account_id == Account.id) + db.query(Account) .filter(cookie_operable_filter()) .filter(Account.auto_managed.is_(True)) .filter( or_( - LoginTask.ck_checked_at.is_(None), - LoginTask.ck_checked_at < cutoff, + Account.ck_checked_at.is_(None), + Account.ck_checked_at < cutoff, ) ) .order_by( - LoginTask.ck_checked_at.is_(None).desc(), - LoginTask.ck_checked_at.asc(), - LoginTask.id.asc(), + Account.ck_checked_at.is_(None).desc(), + Account.ck_checked_at.asc(), + Account.id.asc(), ) - .limit(max(int(job.batch_size or 0), 1)) ) -def _select_tasks(db: Session, job: AutomationJob, now: datetime) -> list[LoginTask]: +def build_select_query(db: Session, job: AutomationJob, now: datetime): + """构造本轮要检测**账号**的查询(与取行分开,便于按方言编译做回归)。 + + 解耦后**按账号选行**(2026-10-04):CK 与 ``ck_check_*`` 都在 ``accounts`` 上, + 以前是 ``db.query(LoginTask).join(Account)`` —— 那样「没有登录历史的账号」 + 永远进不了巡检队列,而且一个账号有多行时会被重复检测。 + """ + return _candidates_query(db, job, now).limit(max(int(job.batch_size or 0), 1)) + + +def _select_accounts(db: Session, job: AutomationJob, now: datetime) -> list[Account]: return build_select_query(db, job, now).all() -def _detect(task: LoginTask) -> dict[str, Any]: +def count_remaining(db: Session, job: AutomationJob, now: datetime) -> int: + """剩余待检账号数(给页面提示用)。 + + 刻意**不用** ``build_select_query`` —— 那个带 ``LIMIT batch_size``, + 取 count 只会得到批次上限,完全看不出积压。 + """ + return int( + _candidates_query(db, job, now).order_by(None).with_entities(Account.id).count() + or 0 + ) + + +def _detect(account: Account) -> dict[str, Any]: try: - return check_douyu_cookie(task.cookie or "") + return check_douyu_cookie(account.cookie or "") except Exception as exc: # noqa: BLE001 单个账号失败不影响整轮 return { "valid": False, @@ -111,22 +131,22 @@ def _detect(task: LoginTask) -> dict[str, Any]: def run_ck_health(db: Session, job: AutomationJob) -> dict[str, Any]: now = datetime.now(UTC) - tasks = _select_tasks(db, job, now) - if not tasks: + accounts = _select_accounts(db, job, now) + if not accounts: return {"scanned": 0, "valid": 0, "invalid": 0, "skipped": 0} results: dict[int, dict[str, Any]] = {} concurrency = max(min(int(job.concurrency or 1), 16), 1) with ThreadPoolExecutor(max_workers=concurrency) as pool: - futures = {pool.submit(_detect, task): task for task in tasks} + futures = {pool.submit(_detect, account): account for account in accounts} for future in as_completed(futures): - task = futures[future] - results[task.id] = future.result() + account = futures[future] + results[account.id] = future.result() # 只写检测结果三件套;cookie / status / attempt_* 一律不动。 valid = invalid = errors = 0 - for index, task in enumerate(tasks, start=1): - result = results.get(task.id) + for index, account in enumerate(accounts, start=1): + result = results.get(account.id) if result is None: continue verdict = classify_result(result) @@ -136,17 +156,17 @@ def run_ck_health(db: Session, job: AutomationJob) -> dict[str, Any]: invalid += 1 else: errors += 1 - # error 时保留该行原来的结论:查不到不等于失效,宁可维持上一次判断。 + # error 时保留该账号原来的结论:查不到不等于失效,宁可维持上一次判断。 if verdict != "error": - task.ck_check_status = verdict - task.ck_check_result = { + account.ck_check_status = verdict + account.ck_check_result = { "fish_ball": result.get("fish_ball"), "nickname": result.get("nickname"), "level": result.get("level"), "message": result.get("message", ""), "verdict": verdict, } - task.ck_checked_at = now + account.ck_checked_at = now # 判定失效时记一条账号事件:`ck_check_status` / `ck_checked_at` 都是**覆盖式** # 字段,覆盖之后就再也看不出「什么时候开始失效的」。只在真的判成 invalid 时记, @@ -154,7 +174,7 @@ def run_ck_health(db: Session, job: AutomationJob) -> dict[str, Any]: if verdict == "invalid": emit( db, - account_id=task.account_id, + account_id=account.id, event_type=EVENT_CK_INVALID, title="CK 检测失效", occurred_at=now, @@ -165,7 +185,7 @@ def run_ck_health(db: Session, job: AutomationJob) -> dict[str, Any]: source="login", # 幂等键带检测时间:同一账号**再次**失效算新事件,但同一次检测 # 重跑(例如失败重试)不会重复入库。 - source_ref=f"ck_invalid:{task.id}:{int(now.timestamp())}", + source_ref=f"ck_invalid:{account.id}:{int(now.timestamp())}", ) if index % FLUSH_EVERY == 0: @@ -173,21 +193,8 @@ def run_ck_health(db: Session, job: AutomationJob) -> dict[str, Any]: db.commit() # 剩余待检数量只用于提示,不参与调度。 - remaining = ( - db.query(LoginTask) - .join(Account, LoginTask.account_id == Account.id) - .filter(cookie_operable_filter()) - .filter(Account.auto_managed.is_(True)) - .filter( - or_( - LoginTask.ck_checked_at.is_(None), - LoginTask.ck_checked_at - < now - timedelta(seconds=max(int(job.interval_seconds or 0), 60)), - ) - ) - .count() - ) - error_ratio = errors / len(tasks) if tasks else 0.0 + remaining = count_remaining(db, job, now) + error_ratio = errors / len(accounts) if accounts else 0.0 paused = False if errors >= ERROR_PAUSE_MIN_SCANNED and error_ratio > ERROR_PAUSE_RATIO: # 大面积「查不到」通常是斗鱼接口抖动或被风控,继续跑只会污染更多账号。 @@ -195,13 +202,13 @@ def run_ck_health(db: Session, job: AutomationJob) -> dict[str, Any]: job.updated_at = datetime.now(UTC) paused = True - message = f"本轮检测 {len(tasks)} 条,剩余待检 {remaining} 条" + message = f"本轮检测 {len(accounts)} 条,剩余待检 {remaining} 条" if errors: message += f";非鉴权类失败 {errors} 条({error_ratio:.0%})" if paused: message += ";失败占比过高,已自动暂停作业" return { - "scanned": len(tasks), + "scanned": len(accounts), "valid": valid, "invalid": invalid, "skipped": errors, diff --git a/web/backend/services/automation/jobs/ck_relogin.py b/web/backend/services/automation/jobs/ck_relogin.py index 2ca34db..e5c0b49 100644 --- a/web/backend/services/automation/jobs/ck_relogin.py +++ b/web/backend/services/automation/jobs/ck_relogin.py @@ -24,7 +24,7 @@ from ....database import SessionLocal from ....models import Account, AutomationJob, LoginTask, User from ....models import ProxyConfig as ProxyConfigModel from ...account_events_service import EVENT_RELOGIN_FAILED, emit -from ...douyu_service import LOGIN_ATTEMPT_IN_FLIGHT_STATUSES, cookie_holding_filter +from ...douyu_service import cookie_holding_filter, cookie_operable_filter from ...login_service import LoginBatchRunner, get_relogin_limits JOB_KEY = "ck_relogin" @@ -82,41 +82,71 @@ def build_select_query( max_attempts: int = 2, system_user_id: int | None = None, ): - """构造本轮重登候选的查询(与取行分开,便于按方言编译做回归)。""" + """构造本轮重登候选的查询(与取行分开,便于按方言编译做回归)。 + + 解耦后**按账号选行**(2026-10-04):CK 与 ``ck_check_status`` / ``relogin_*`` + 都在 ``accounts`` 上。以前是 ``db.query(LoginTask).join(Account)`` —— 那样 + 「没有登录历史的账号」永远进不了重登队列,而重登本来只关心账号。 + + 「在途尝试」仍然要查 ``login_tasks``(那是某次尝试的属性),由 + ``cookie_operable_filter`` 的 NOT EXISTS 子查询完成,走迁移 0042 新增的 + ``(account_id, attempt_status)`` 索引。 + """ query = ( - db.query(LoginTask) - .join(Account, LoginTask.account_id == Account.id) - .filter(LoginTask.ck_check_status == "invalid") - .filter(cookie_holding_filter()) - .filter( - func.coalesce(LoginTask.attempt_status, "").notin_( - LOGIN_ATTEMPT_IN_FLIGHT_STATUSES - ) - ) + db.query(Account) + .filter(Account.ck_check_status == "invalid") + .filter(cookie_operable_filter()) .filter(Account.auto_managed.is_(True)) - .filter(LoginTask.relogin_attempts < max_attempts) + .filter(Account.relogin_attempts < max_attempts) .filter( or_( - LoginTask.relogin_next_at.is_(None), - LoginTask.relogin_next_at <= _cutoff(now), + Account.relogin_next_at.is_(None), + Account.relogin_next_at <= _cutoff(now), ) ) ) - # 已判定需要人工的类型不再自动重试(极验/凭证)。空值用 coalesce 兜住历史行。 + # 已判定需要人工的类型不再自动重试(极验/凭证)。空值用 coalesce 兜住历史值。 query = query.filter( - func.coalesce(LoginTask.relogin_failure_kind, "").notin_(tuple(MANUAL_KINDS)) + func.coalesce(Account.relogin_failure_kind, "").notin_( + tuple(sorted(MANUAL_KINDS)) + ) ) if system_user_id is not None: pass # 预留:将来若需要限制到某个归属人,在这里加过滤 return query.order_by( - LoginTask.relogin_next_at.is_(None).desc(), - LoginTask.relogin_next_at.asc(), - LoginTask.id.asc(), + Account.relogin_next_at.is_(None).desc(), + Account.relogin_next_at.asc(), + Account.id.asc(), ).limit(max(int(job.batch_size or 0), 1)) +def _load_batch_state( + session: Session, batch_id: str +) -> tuple[dict[int, tuple[Account, LoginTask]], list[int]]: + """读回某一批次产生的尝试记录,并配对其所属账号。 + + 按 ``attempt_batch_id`` 找 —— ``_begin_attempt`` 会给每一条被本批次接管的记录 + 打上批次号。所以即使记录是**复用**的旧行,也能被准确对应回本轮。 + """ + tasks = ( + session.query(LoginTask) + .filter(LoginTask.attempt_batch_id == batch_id) + .order_by(LoginTask.id.asc()) + .all() + ) + state: dict[int, tuple[Account, LoginTask]] = {} + order: list[int] = [] + for task in tasks: + account = session.get(Account, task.account_id) + if account is None: + continue + state[account.id] = (account, task) + order.append(account.id) + return state, order + + def settle_fresh_results( - task_ids: list[int], *, now: datetime, max_attempts: int = 2 + batch_id: str, *, now: datetime, max_attempts: int = 2 ) -> dict[str, int]: """在**新会话(新事务)**里读回批次结果并结算。 @@ -130,11 +160,11 @@ def settle_fresh_results( """ session = SessionLocal() try: - rows = session.query(LoginTask).filter(LoginTask.id.in_(task_ids)).all() + fresh, order = _load_batch_state(session, batch_id) counters = settle_results( session, - {row.id: row for row in rows}, - task_ids, + fresh, + order, now=now, max_attempts=max_attempts, ) @@ -152,15 +182,14 @@ def count_pending_relogin(db: Session, *, max_attempts: int = 2) -> int: 按退避窗口过滤** —— 退避是「什么时候试」的细节,不影响「还有多少要处理」。 """ return int( - db.query(func.count(LoginTask.id)) - .join(Account, LoginTask.account_id == Account.id) - .filter(LoginTask.ck_check_status == "invalid") + db.query(func.count(Account.id)) + .filter(Account.ck_check_status == "invalid") .filter(cookie_holding_filter()) .filter(Account.auto_managed.is_(True)) - .filter(LoginTask.relogin_attempts < max_attempts) + .filter(Account.relogin_attempts < max_attempts) .filter( - func.coalesce(LoginTask.relogin_failure_kind, "").notin_( - tuple(MANUAL_KINDS) + func.coalesce(Account.relogin_failure_kind, "").notin_( + tuple(sorted(MANUAL_KINDS)) ) ) .scalar() @@ -210,11 +239,12 @@ def run_ck_relogin(db: Session, job: AutomationJob) -> dict[str, Any]: "message": "库里没有可用用户,无法以系统身份发起重登", } - tasks = build_select_query(db, job, now, max_attempts=max_attempts).all() - if not tasks: + accounts = build_select_query(db, job, now, max_attempts=max_attempts).all() + if not accounts: return {"scanned": 0, "valid": 0, "invalid": 0, "skipped": 0} - task_ids = [task.id for task in tasks] + # 调度单位是**账号**(CK 在账号上);登录记录只是这次尝试的历史。 + account_ids = [account.id for account in accounts] proxy = db.query(ProxyConfigModel).first() _, max_total_time = get_relogin_limits() @@ -222,7 +252,7 @@ def run_ck_relogin(db: Session, job: AutomationJob) -> dict[str, Any]: runner_db = SessionLocal() runner = LoginBatchRunner( db=runner_db, - account_ids=[], + account_ids=account_ids, created_by=system_user_id, # 自动化要对全库生效,不受「只能操作分配给自己的账号」限制。 creator_permissions=["login:view_all", "cookie:operate"], @@ -233,7 +263,6 @@ def run_ck_relogin(db: Session, job: AutomationJob) -> dict[str, Any]: loop=None, concurrency=max(min(int(job.concurrency or 3), 8), 1), mode="relogin", - relogin_task_ids=task_ids, ) batch_id = runner.batch_id try: @@ -242,7 +271,7 @@ def run_ck_relogin(db: Session, job: AutomationJob) -> dict[str, Any]: runner_db.close() # runner 在另一个会话里提交了结果:必须用新事务读回(见 settle_fresh_results)。 - counters = settle_fresh_results(task_ids, now=now, max_attempts=max_attempts) + counters = settle_fresh_results(batch_id, now=now, max_attempts=max_attempts) parts = [ f"本轮重登 {counters['scanned']} 条", @@ -274,58 +303,64 @@ def run_ck_relogin(db: Session, job: AutomationJob) -> dict[str, Any]: def settle_results( db: Session, - fresh: dict[int, LoginTask], - task_ids: list[int], + fresh: dict[int, tuple[Account, LoginTask]], + account_ids: list[int], *, now: datetime, max_attempts: int = 2, ) -> dict[str, int]: """批次跑完后结算:成功的清零,失败的按分型决定重试还是转人工。 + 解耦后 **决策看「账号 + 本轮的尝试记录」**(2026-10-04): + - 成败来自这次尝试(``LoginTask.attempt_status``); + - 退避进度写在**账号**上(``Account.relogin_*``)—— 它是账号的状态, + 不该随某条历史记录一起被删掉。 + 单独抽出来是因为这段是 P1b 的核心决策逻辑,必须能脱离真实登录做单测。 只改 ``relogin_*`` 四个字段,**不碰 CK 本体**(CK 由 runner 在成功时替换)。 ``db`` 用于写账号事件(转人工时记一条);事件是旁路,写失败不影响结算。 """ counters = { - "scanned": len(task_ids), + "scanned": len(account_ids), "valid": 0, "invalid": 0, "manual": 0, "retry": 0, "email_quota": 0, } - for task_id in task_ids: - task = fresh.get(task_id) - if task is None: + for account_id in account_ids: + entry = fresh.get(account_id) + if entry is None: continue + account, task = entry if task.attempt_status == "success": counters["valid"] += 1 - task.relogin_attempts = 0 - task.relogin_next_at = None - task.relogin_failure_kind = "" - task.relogin_last_error = "" + account.relogin_attempts = 0 + account.relogin_next_at = None + account.relogin_failure_kind = "" + account.relogin_last_error = "" continue counters["invalid"] += 1 kind = classify_failure(task.attempt_message) - task.relogin_failure_kind = kind - task.relogin_last_error = (task.attempt_message or "")[:500] - task.relogin_attempts = int(task.relogin_attempts or 0) + 1 - if kind in MANUAL_KINDS or task.relogin_attempts >= max_attempts: + account.relogin_failure_kind = kind + account.relogin_last_error = (task.attempt_message or "")[:500] + account.relogin_attempts = int(account.relogin_attempts or 0) + 1 + if kind in MANUAL_KINDS or account.relogin_attempts >= max_attempts: # 转人工:极验/凭证问题重试无意义,次数用尽也不该无限撞。 counters["manual"] += 1 - task.relogin_next_at = None + account.relogin_next_at = None # 只在「转人工」时记事件:还要退避重试的不记,否则同一账号每轮都刷一条。 emit( db, - account_id=task.account_id, + account_id=account.id, event_type=EVENT_RELOGIN_FAILED, title=f"自动重登转人工({kind})", occurred_at=now, detail={ "kind": kind, - "attempts": task.relogin_attempts, + "attempts": account.relogin_attempts, "error": (task.attempt_message or "")[:200], }, source="login", @@ -335,5 +370,5 @@ def settle_results( if kind == "email_quota": counters["email_quota"] += 1 counters["retry"] += 1 - task.relogin_next_at = _next_retry_at(kind, task.relogin_attempts, now) + account.relogin_next_at = _next_retry_at(kind, account.relogin_attempts, now) return counters diff --git a/web/backend/services/douyu_runner.py b/web/backend/services/douyu_runner.py index d8281b7..6e2ca38 100644 --- a/web/backend/services/douyu_runner.py +++ b/web/backend/services/douyu_runner.py @@ -21,7 +21,7 @@ from .douyu_runner_gold import GoldMixin from .douyu_runner_goods import GoodsMixin from .douyu_runner_manual import ManualMixin from .douyu_runner_xpd import XpdMixin -from .douyu_service import latest_cookie_login_task, update_account_profile_from_cookie +from .douyu_service import update_account_profile_from_cookie class DouyuBatchRunner( @@ -56,23 +56,22 @@ class DouyuBatchRunner( self._push_log( "info", f"[{current}/{total}] 开始: {self._account_name(account)}" ) - login_task = latest_cookie_login_task(worker_db, account.id) - cookie = login_task.cookie if login_task else "" + # CK 与健康状态都挂在账号上(2026-10-04 CK 解耦)。 + cookie = account.cookie or "" if not cookie: self._mark_task(worker_db, task, "failed", "账号没有可用登录 Cookie") self._push_log( "warning", f"[{current}] {self._account_name(account)} 无 Cookie" ) return - assert login_task is not None cookie_check = check_douyu_cookie(cookie) - login_task.ck_check_status = "valid" if cookie_check["valid"] else "invalid" - login_task.ck_check_result = { + account.ck_check_status = "valid" if cookie_check["valid"] else "invalid" + account.ck_check_result = { key: cookie_check.get(key) for key in ("fish_ball", "nickname", "level", "message") } - login_task.ck_checked_at = cookie_check["checked_at"] + account.ck_checked_at = cookie_check["checked_at"] worker_db.commit() if not cookie_check["valid"]: message = f"Cookie 已失效,请重新登录:{cookie_check['message']}" diff --git a/web/backend/services/douyu_service.py b/web/backend/services/douyu_service.py index 001daaf..886f4f4 100644 --- a/web/backend/services/douyu_service.py +++ b/web/backend/services/douyu_service.py @@ -183,69 +183,63 @@ def ensure_douyu_config(db: Session) -> DouyuConfig: return config -# 登录/重登尝试的在途状态:判断只看 attempt_*,与记录持有的 CK 无关。 +# 登录/重登尝试的在途状态:判断只看 attempt_*,与账号持有的 CK 无关。 LOGIN_ATTEMPT_IN_FLIGHT_STATUSES = ("pending", "running") def cookie_holding_filter(): - """判定登录记录当前是否仍持有 Cookie。 + """判定**账号**当前是否持有 CK。 - 账号是否有 CK 只取决于记录里是否还留着 Cookie。登录尝试(attempt_*) - 在成功前不会碰 status/cookie/message,所以停止、失败、进程重启都不会 - 让账号失去 CK;只有 Cookie 真正被清空(账号检测成功)时才不再持有。 + CK 是账号级凭证(2026-10-04 起从 `login_tasks.cookie` 迁到 `accounts.cookie`)。 + 判定只看账号自己,不再看任何登录记录的状态 —— 这正是解耦的目的: + 删历史、批次排队、重登失败都不该让账号凭空「没有 CK」。 """ return and_( - LoginTask.cookie.isnot(None), - LoginTask.cookie != "", - ) - - -def latest_cookie_login_task(db: Session, account_id: int) -> LoginTask | None: - """读取账号最近一条仍持有 Cookie 的登录记录。""" - return ( - db.query(LoginTask) - .filter( - LoginTask.account_id == account_id, - cookie_holding_filter(), - ) - .order_by(LoginTask.id.desc()) - .first() + Account.cookie.isnot(None), + Account.cookie != "", ) def cookie_operable_filter(): - """判定登录记录能否基于现有 Cookie 再做检测或重登。 + """判定**账号**能否基于现有 CK 再做检测或重登。 - 正在被批次处理的记录(attempt_* 处于在途状态)已由批次接管,并发再点 - 检测或重登只会互相覆盖,因此排除在外;CK 与 status 不参与判断。 + 在途排除仍然要查 `login_tasks`:「正被批次处理」是**某次尝试**的属性, + CK 才是账号的属性。所以这里是「账号有 CK」+「该账号没有在途尝试」。 + + 走 `login_tasks(account_id, attempt_status)` 索引(迁移 0042 新增), + 否则每轮选行都要回表。 """ - return and_( - cookie_holding_filter(), - func.coalesce(LoginTask.attempt_status, "").notin_( - LOGIN_ATTEMPT_IN_FLIGHT_STATUSES - ), + in_flight = ( + select(LoginTask.id) + .where(LoginTask.account_id == Account.id) + .where( + func.coalesce(LoginTask.attempt_status, "").in_( + LOGIN_ATTEMPT_IN_FLIGHT_STATUSES + ) + ) + .exists() ) + return and_(cookie_holding_filter(), ~in_flight) def latest_cookie(db: Session, account_id: int) -> str: - """读取账号当前 Cookie。""" - task = latest_cookie_login_task(db, account_id) - return task.cookie if task else "" + """读取账号当前 CK。""" + account = db.query(Account.cookie).filter(Account.id == account_id).first() + return (account[0] if account else "") or "" def cookie_holding_account_ids_query(db: Session): - """返回当前仍持有 Cookie 的斗鱼账号 ID 查询。""" - return db.query(LoginTask.account_id).filter(cookie_holding_filter()).distinct() + """返回当前仍持有 CK 的斗鱼账号 ID 查询。""" + return db.query(Account.id).filter(cookie_holding_filter()) def visible_douyu_task_accounts(db: Session, account_ids: list[int]) -> list[Account]: - """只保留当前仍持有 Cookie 的斗鱼账号。""" + """只保留当前仍持有 CK 的斗鱼账号。""" if not account_ids: return [] - cookie_ids = select(LoginTask.account_id).where(cookie_holding_filter()) return ( db.query(Account) - .filter(Account.id.in_(account_ids), Account.id.in_(cookie_ids)) + .filter(Account.id.in_(account_ids), cookie_holding_filter()) .all() ) diff --git a/web/backend/services/login_service.py b/web/backend/services/login_service.py index a049a67..4a7106e 100644 --- a/web/backend/services/login_service.py +++ b/web/backend/services/login_service.py @@ -11,7 +11,7 @@ from types import SimpleNamespace from typing import cast from loguru import logger -from sqlalchemy import or_ +from sqlalchemy import and_, func, or_ from sqlalchemy.orm import Session from core.douyu import DouyuLogin, IframeLoginAPI, WgapiLoginAPI @@ -23,7 +23,7 @@ from ..models import LoginTask from ..models import ProxyConfig as ProxyConfigModel from .account_events_service import EVENT_LOGIN_OK, EVENT_RELOGIN_OK, emit from .cookie_check_service import check_douyu_cookie -from .douyu_service import cookie_holding_filter +from .douyu_service import LOGIN_ATTEMPT_IN_FLIGHT_STATUSES def _create_api_strategy(strategy_name: str): @@ -124,13 +124,18 @@ def _begin_attempt(task: LoginTask, batch_id: str, message: str) -> None: def _mark_attempt_failed( task: LoginTask, message: str, *, status: str = "failed" ) -> None: - """记录一次失败/被中断的尝试;已经持有 CK 的记录保持原样。""" + """记录一次失败/被中断的尝试;**账号已有的 CK 原样不动**。 + + 这里读的是 `task.account.cookie`(CK 自 2026-10-04 起挂在账号上), + 而不是这条登录记录 —— 记录不再持有凭证。 + """ completed_at = datetime.now(UTC) task.attempt_status = status if status in {"failed", "error"} else "failed" task.attempt_message = message task.attempt_at = completed_at - if not task.cookie: - # 还没有任何 CK 需要保护:保持旧的失败语义,便于复用记录与页面统计。 + account = task.account + if not (account and account.cookie): + # 账号还没有任何 CK 需要保护:保持旧的失败语义,便于复用记录与页面统计。 task.status = task.attempt_status task.message = message task.finished_at = completed_at @@ -145,14 +150,14 @@ def _write_login_success( batch_id: str, event_type: str = EVENT_LOGIN_OK, ) -> None: - """登录成功后把新 CK 写回记录:这是唯一替换旧 CK 的入口。 + """登录成功后把新 CK 写到**账号**上:这是唯一替换旧 CK 的入口。 顺带记一条账号事件 —— CK 是**覆盖式**字段,换掉之后就看不出「这账号什么时候 登录成功过、换过几次 CK」。事件是旁路(emit 内部吞异常),记不上也不影响登录。 """ completed_at = datetime.now(UTC) + account = task.account task.status = "success" - task.cookie = cookie task.message = message task.batch_id = batch_id task.finished_at = completed_at @@ -161,6 +166,12 @@ def _write_login_success( task.attempt_batch_id = batch_id task.attempt_at = completed_at + if account is not None: + # CK 是**账号级凭证**,不是「某次登录的产物」—— 写账号,不写记录。 + account.cookie = cookie + account.cookie_updated_at = completed_at + account.cookie_source = "web" + emit( db, account_id=task.account_id, @@ -194,7 +205,6 @@ class LoginBatchRunner: concurrency: int = 3, api_strategy: str = "wgapi", mode: str = "login", - relogin_task_ids: list[int] | None = None, ): self.db = db self.account_ids = account_ids @@ -211,7 +221,6 @@ class LoginBatchRunner: self.concurrency = max(1, min(concurrency, 10)) # 限制 1-10 self.api_strategy = _create_api_strategy(api_strategy) self.mode = mode if mode in ("login", "check", "relogin") else "login" - self.relogin_task_ids = list(relogin_task_ids) if relogin_task_ids else [] self._stop = threading.Event() self._counter_lock = threading.Lock() self._completed = 0 @@ -273,6 +282,34 @@ class LoginBatchRunner: finally: worker_db.close() + def _mark_account_relogin_stopped( + self, + account_id: int, + message: str = "重新登录已停止,旧 Cookie 已保留", + ) -> None: + """按**账号**收敛在途的重登尝试(新模型下调度单位是账号)。 + + 与 `_mark_relogin_stopped` 的区别只是入口:批次挂了但记录还没建出来时, + 这里是唯一能把「该账号的在途尝试」收干净的路径。 + """ + worker_db = SessionLocal() + try: + tasks = ( + worker_db.query(LoginTask) + .filter(LoginTask.account_id == account_id) + .order_by(LoginTask.id.desc()) + .all() + ) + changed = False + for task in tasks: + if _attempt_in_flight(task): + _mark_attempt_failed(task, message) + changed = True + if changed: + worker_db.commit() + finally: + worker_db.close() + def _push_log(self, level: str, message: str): # 即使没有页面实时日志,也要保留批次进度到 app.log,便于排查卡点。 if message: @@ -313,8 +350,10 @@ class LoginBatchRunner: task = worker_db.query(LoginTask).filter(LoginTask.id == task_id).first() if not task: return + # CK、健康与重登状态都挂在账号上;登录记录只是一次尝试的历史。 + account = task.account - # 只推进尝试状态;记录持有的旧 CK 在成功前保持不变。 + # 只推进尝试状态;账号持有的旧 CK 在成功前保持不变。 task.attempt_status = "running" task.attempt_message = ( "正在重新登录,旧 Cookie 保留中" @@ -402,7 +441,12 @@ class LoginBatchRunner: else "account_auth_unknown" ) task.status = status - task.cookie = "" + # ⚠️ 行为变更(2026-10-04 CK 解耦):**检测不再清空账号的 CK**。 + # 旧代码在这里写 `task.cookie = ""`,而那时记录的 cookie 就是账号的 + # 唯一凭证 —— 于是一次「批量检测」就会把账号的登录态删掉。CK 换到账号 + # 上之后,「检测账号凭据」与「CK 是否有效」本就是两件事(后者归 + # ck_health 判),继续清空等于凭空制造不可逆的数据丢失 + # (重登当前成功率极低,丢了补不回来)。 task.message = result.message or CHECK_STATUS_MESSAGES[status] # 检测结果本身就是这次尝试的结论,页面按 attempt_* 展示。 task.attempt_status = status @@ -416,25 +460,35 @@ class LoginBatchRunner: ) elif result.success: message = result.message or "登录成功" - if self.mode == "relogin": + if self.mode == "relogin" and account is not None: check_result = check_douyu_cookie(result.cookie) - task.ck_check_status = ( + # 复检结论写账号(CK 在哪,健康状态就在哪)。 + account.ck_check_status = ( "valid" if check_result["valid"] else "invalid" ) - task.ck_check_result = { + account.ck_check_result = { "fish_ball": check_result["fish_ball"], "nickname": check_result["nickname"], "level": check_result["level"], "message": check_result["message"], } - task.ck_checked_at = check_result["checked_at"] + account.ck_checked_at = check_result["checked_at"] if check_result["valid"]: message = "重新登录成功,Cookie 有效" else: message = f"重新登录成功,但 Cookie 有效性检测失败: {check_result['message']}" - # 唯一替换旧 CK 的入口:登录成功后写回新 CK。 + # 唯一替换旧 CK 的入口:登录成功后写回账号。 + # + # ⚠️ 必须传 **worker_db**(本线程的会话),不能传 self.db: + # ① `self.db` 由主线程持有,SQLAlchemy Session 不是线程安全的; + # ② 更致命的是 `emit()` 会往 account_events 插一行,其 + # `account_id` 外键会对 accounts 那一行加**共享锁**。如果这 + # 把锁加在**另一个**会话上,紧接着本会话对同一个账号的 + # `UPDATE accounts SET cookie=...` 就会被自己人挡住 —— + # 实测直接 50 秒后 `Lock wait timeout exceeded`,登录明明成功 + # 却写不回 CK。事件本就该和状态变更在同一个事务里提交。 _write_login_success( - self.db, + worker_db, task, cookie=result.cookie, message=message, @@ -446,15 +500,20 @@ class LoginBatchRunner: ), ) if self.mode == "relogin": - if task.ck_check_status == "valid": + if account is not None and account.ck_check_status == "valid": self._push_log( "success", f"[{current}] {acc_info['username']} 重新登录成功,Cookie 已替换并验证有效", ) else: + detail = ( + account.ck_check_result.get("message") + if account is not None and account.ck_check_result + else "" + ) self._push_log( "warning", - f"[{current}] {acc_info['username']} 重新登录成功,但 Cookie 有效性检测失败: {task.ck_check_result.get('message') if task.ck_check_result else ''}", + f"[{current}] {acc_info['username']} 重新登录成功,但 Cookie 有效性检测失败: {detail}", ) else: self._push_log( @@ -509,7 +568,7 @@ class LoginBatchRunner: ) self._push_log( "info", - f"批量{action_name}任务 {batch_id} 开始,共 {len(self.account_ids or self.relogin_task_ids)} 个账号,并发数: {concurrency}", + f"批量{action_name}任务 {batch_id} 开始,共 {len(self.account_ids)} 个账号,并发数: {concurrency}", ) # 批次开始前同步一次出口 IP 到白名单,后续 fetch_new_proxy 不再主动同步 @@ -550,88 +609,82 @@ class LoginBatchRunner: try: # 创建或复用任务记录(顺序执行,线程安全) + # + # **重登模式也从 account_ids 走**(2026-10-04 CK 解耦):以前重登传的是 + # 「持有 CK 的记录 id」,因为 CK 挂在记录上;现在 CK 在账号上,调度单位就是 + # 账号,登录记录只是「这次尝试」的历史 —— 顺带让**没有登录历史的账号** + # 也能被重登(以前它根本进不了候选)。 task_infos: list[dict] = [] # {task_id, acc_info} - if self.relogin_task_ids: - # 重新登录模式:复用指定 Cookie 记录,登录成功后原地替换 Cookie - for task_id in self.relogin_task_ids: - task = ( - self.db.query(LoginTask).filter(LoginTask.id == task_id).first() - ) - if not task: - continue - acc = ( - self.db.query(AccountModel) - .filter(AccountModel.id == task.account_id) - .first() - ) - if not acc: - self._push_log("warning", f"跳过无账号的任务 #{task_id}") - continue - if ( - "login:view_all" not in self.creator_permissions - and acc.assigned_to != self.created_by - ): - self._push_log("warning", f"跳过无权账号: {acc.username}") - continue - _append_task_info(task, acc) - else: - seen_account_ids = set() - for aid in self.account_ids: - if aid in seen_account_ids: - continue - seen_account_ids.add(aid) - acc = ( - self.db.query(AccountModel) - .filter(AccountModel.id == aid) - .with_for_update() - .first() - ) - if not acc: - continue - # 权限检查:客服只能跑分配给自己的 - if ( - "login:view_all" not in self.creator_permissions - and acc.assigned_to != self.created_by - ): - self._push_log("warning", f"跳过无权账号: {acc.username}") - continue + seen_account_ids = set() + for aid in self.account_ids: + if aid in seen_account_ids: + continue + seen_account_ids.add(aid) + acc = ( + self.db.query(AccountModel) + .filter(AccountModel.id == aid) + .with_for_update() + .first() + ) + if not acc: + continue + # 权限检查:客服只能跑分配给自己的 + if ( + "login:view_all" not in self.creator_permissions + and acc.assigned_to != self.created_by + ): + self._push_log("warning", f"跳过无权账号: {acc.username}") + continue - # 一个斗鱼账号只保留一条持有 CK 的记录:优先复用它, - # 登录成功后才把新 CK 写回同一条记录。 + # 解耦后不再需要「哪一行持有 CK」这个不变量(CK 在账号上)。 + # 这里只需要保证**同一账号不并发两行**: + # ① 复用该账号在途的尝试(避免并发两个批次各建一行); + # ② 否则复用最近一条失败/中断的历史,避免刷屏; + # ③ 都没有就新建一行(历史而已,允许一个账号多行)。 + task = ( + self.db.query(LoginTask) + .filter( + LoginTask.account_id == aid, + or_( + func.coalesce(LoginTask.attempt_status, "").in_( + LOGIN_ATTEMPT_IN_FLIGHT_STATUSES + ), + and_( + LoginTask.attempt_status == "", + LoginTask.status.in_(LOGIN_ATTEMPT_IN_FLIGHT_STATUSES), + ), + ), + ) + .order_by(LoginTask.id.desc()) + .first() + ) + if task is None: + # 复用最近一条**失败/中断**的历史,避免每次失败都刷一行。 + # 注意要同时看 attempt_status:账号有 CK 时 `_mark_attempt_failed` + # 刻意不动 status(保护旧语义),所以失败记录的 status 可能还停在 + # pending —— 只看 status 就永远命不中,行数会无限长。 task = ( self.db.query(LoginTask) .filter( LoginTask.account_id == aid, - cookie_holding_filter(), + or_( + LoginTask.status.in_(["failed", "error"]), + LoginTask.attempt_status.in_(["failed", "error"]), + ), ) .order_by(LoginTask.id.desc()) .first() ) - if task is None: - # 复用最近一条失败记录,避免重复产生多条失败历史。 - task = ( - self.db.query(LoginTask) - .filter( - LoginTask.account_id == aid, - LoginTask.status.in_(["failed", "error"]), - or_( - LoginTask.cookie.is_(None), - LoginTask.cookie == "", - ), - ) - .order_by(LoginTask.id.desc()) - .first() - ) - if task is None: - task = LoginTask( - batch_id=batch_id, - account_id=aid, - status="pending", - created_by=self.created_by, - ) - self.db.add(task) + if task is None: + task = LoginTask( + batch_id=batch_id, + account_id=aid, + status="pending", + created_by=self.created_by, + ) + self.db.add(task) - _append_task_info(task, acc) + _append_task_info(task, acc) self.db.commit() total = len(task_infos) diff --git a/web/frontend/src/api/types.ts b/web/frontend/src/api/types.ts index 8ea0857..6f3f252 100644 --- a/web/frontend/src/api/types.ts +++ b/web/frontend/src/api/types.ts @@ -344,8 +344,8 @@ export interface AccountCheckBatch { // ==================== Cookie ==================== export interface CookieItem { + // 2026-10-04 CK 解耦后,本页面的记录单位是**账号**,id 就是 account_id id: number; - batch_id: string; account_id: number; account_username: string; assigned_to: number | null; @@ -395,7 +395,6 @@ export interface CookieCheckResult { export interface CookieDuplicateRecord { id: number; account_id: number; - batch_id: string; finished_at: string | null; } @@ -989,7 +988,7 @@ export interface AutomationRunOutcome { } export interface AutomationManualSample { - task_id: number; + account_id: number; account_username: string; relogin_attempts: number; last_error: string; diff --git a/web/frontend/src/pages/AutomationPage.tsx b/web/frontend/src/pages/AutomationPage.tsx index 95100ea..7fcf09f 100644 --- a/web/frontend/src/pages/AutomationPage.tsx +++ b/web/frontend/src/pages/AutomationPage.tsx @@ -155,7 +155,7 @@ export default function AutomationPage() { }; const manualColumns: ColumnsType = [ - { title: "记录", dataIndex: "task_id", width: 80 }, + { title: "账号ID", dataIndex: "account_id", width: 80 }, { title: "账号", dataIndex: "account_username", diff --git a/web/frontend/src/pages/CookiePage.tsx b/web/frontend/src/pages/CookiePage.tsx index 0a86963..8ef6419 100644 --- a/web/frontend/src/pages/CookiePage.tsx +++ b/web/frontend/src/pages/CookiePage.tsx @@ -165,9 +165,9 @@ export default function CookiePage() { const result = await cookieApi.duplicates(); setDuplicateResult(result); if (result.duplicate_groups > 0) { - message.warning(`发现 ${result.duplicate_groups} 个重复账号,共 ${result.duplicate_rows} 条 CK 记录`); + message.warning(`发现 ${result.duplicate_groups} 组「多个账号共用同一份 CK」,涉及 ${result.duplicate_rows} 个账号`); } else { - message.success('未发现重复账号'); + message.success('未发现共用 CK 的账号'); } } catch (e: unknown) { message.error(getErrorMessage(e)); @@ -327,7 +327,7 @@ export default function CookiePage() { const handleDelete = async (id: number) => { try { await cookieApi.delete(id); - message.success('已删除'); + message.success('已清除该账号的 CK'); setSelectedRowKeys((prev) => prev.filter((k) => k !== id)); loadCookies(); loadSummary(); @@ -338,7 +338,7 @@ export default function CookiePage() { const handleDeleteSelected = async () => { if (selectedRowKeys.length === 0) { - message.warning('请先选择要删除的 Cookie'); + message.warning('请先选择要清除 CK 的账号'); return; } try { @@ -461,7 +461,7 @@ export default function CookiePage() { )} {canExport && ( - handleDelete(record.id)}> + handleDelete(record.id)}> )} @@ -708,8 +708,8 @@ export default function CookiePage() { type={duplicateResult.duplicate_groups > 0 ? 'warning' : 'success'} showIcon message={duplicateResult.duplicate_groups > 0 - ? `发现 ${duplicateResult.duplicate_groups} 个重复账号,共 ${duplicateResult.duplicate_rows} 条 CK 记录` - : '未发现重复账号'} + ? `发现 ${duplicateResult.duplicate_groups} 组「多个账号共用同一份 CK」,涉及 ${duplicateResult.duplicate_rows} 个账号` + : '未发现共用 CK 的账号'} style={{ marginBottom: 16 }} /> {duplicateResult.groups.length > 0 ? ( @@ -722,7 +722,7 @@ export default function CookiePage() { scroll={{ x: 900 }} /> ) : ( - + )} ) : null}