refactor(account): S2 读写一次切换 —— CK/健康/重登彻底搬到账号,登录记录退化为历史
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 落地时要把自动化选行的状态过滤一起补上。
This commit is contained in:
@@ -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="鱼丸接口: 账户登陆异常,请重新登陆;等级接口: 用户未登录",
|
||||
|
||||
+195
-102
@@ -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
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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", "排队中账号"}
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
)
|
||||
|
||||
+318
-326
@@ -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}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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']}"
|
||||
|
||||
@@ -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()
|
||||
)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -155,7 +155,7 @@ export default function AutomationPage() {
|
||||
};
|
||||
|
||||
const manualColumns: ColumnsType<AutomationManualSample> = [
|
||||
{ title: "记录", dataIndex: "task_id", width: 80 },
|
||||
{ title: "账号ID", dataIndex: "account_id", width: 80 },
|
||||
{
|
||||
title: "账号",
|
||||
dataIndex: "account_username",
|
||||
|
||||
@@ -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() {
|
||||
</Popconfirm>
|
||||
)}
|
||||
{canExport && (
|
||||
<Popconfirm title="确认删除?" onConfirm={() => handleDelete(record.id)}>
|
||||
<Popconfirm title="确认清除该账号的 CK?不可逆" onConfirm={() => handleDelete(record.id)}>
|
||||
<Button danger size="small" icon={<DeleteOutlined />} />
|
||||
</Popconfirm>
|
||||
)}
|
||||
@@ -486,13 +486,13 @@ export default function CookiePage() {
|
||||
render: (count: number) => <Tag color="error">{count}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '账号记录数',
|
||||
title: '共用 CK 的账号数',
|
||||
dataIndex: 'account_count',
|
||||
width: 110,
|
||||
align: 'center' as const,
|
||||
},
|
||||
{
|
||||
title: 'CK 记录 ID',
|
||||
title: '账号 ID(共用同一份 CK)',
|
||||
dataIndex: 'cookie_ids',
|
||||
width: 220,
|
||||
ellipsis: true,
|
||||
@@ -506,7 +506,7 @@ export default function CookiePage() {
|
||||
render: (ids: number[]) => ids.join(', '),
|
||||
},
|
||||
{
|
||||
title: '最近记录',
|
||||
title: '最近拿到 CK',
|
||||
width: 170,
|
||||
align: 'center' as const,
|
||||
render: (_: unknown, group: CookieDuplicateGroup) => formatTime(group.records[0]?.finished_at),
|
||||
@@ -559,7 +559,7 @@ export default function CookiePage() {
|
||||
)}
|
||||
{canExport && (
|
||||
<Popconfirm
|
||||
title={`确认删除选中的 ${selectedRowKeys.length} 条 Cookie?`}
|
||||
title={`确认清除选中的 ${selectedRowKeys.length} 个账号的 CK?不可逆`}
|
||||
onConfirm={handleDeleteSelected}
|
||||
disabled={selectedRowKeys.length === 0}
|
||||
>
|
||||
@@ -568,7 +568,7 @@ export default function CookiePage() {
|
||||
icon={<DeleteOutlined />}
|
||||
disabled={selectedRowKeys.length === 0}
|
||||
>
|
||||
删除选中 {selectedRowKeys.length > 0 && `(${selectedRowKeys.length})`}
|
||||
清除选中 {selectedRowKeys.length > 0 && `(${selectedRowKeys.length})`}
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
)}
|
||||
@@ -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 }}
|
||||
/>
|
||||
) : (
|
||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="未发现重复账号" />
|
||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="未发现共用 CK 的账号" />
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
|
||||
Reference in New Issue
Block a user