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 落地时要把自动化选行的状态过滤一起补上。
839 lines
29 KiB
Python
839 lines
29 KiB
Python
"""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
|
||
from datetime import UTC, datetime
|
||
|
||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||
from fastapi.responses import StreamingResponse
|
||
from sqlalchemy import case, func, or_
|
||
from sqlalchemy.orm import Session, defer, joinedload
|
||
|
||
from ..database import SessionLocal, get_db
|
||
from ..deps import get_current_user, require_permission
|
||
from ..models import Account, AuditLog, LoginTask, User
|
||
from ..models import ProxyConfig as ProxyConfigModel
|
||
from ..permissions import get_user_permissions, user_has_permission
|
||
from ..schemas import CookieReloginRequest
|
||
from ..services.cookie_check_service import check_douyu_cookie
|
||
from ..services.douyu_service import (
|
||
LOGIN_ATTEMPT_IN_FLIGHT_STATUSES,
|
||
cookie_holding_filter,
|
||
cookie_operable_filter,
|
||
)
|
||
from ..services.login_service import BatchRegistry, LoginBatchRunner, get_relogin_limits
|
||
|
||
|
||
def _fmt_dt(dt) -> str | None:
|
||
"""将 datetime 格式化为带时区的 ISO 字符串。"""
|
||
if dt is None:
|
||
return None
|
||
if dt.tzinfo is None:
|
||
dt = dt.replace(tzinfo=UTC)
|
||
return dt.isoformat()
|
||
|
||
|
||
router = APIRouter(prefix="/api/cookies", tags=["Cookie管理"])
|
||
cookie_relogin_registry = BatchRegistry()
|
||
|
||
|
||
def _require_cookie_operation_perm(current: User) -> None:
|
||
"""允许脱敏的 CK 检测与重登,不授予 CK 查看或导出能力。"""
|
||
if not (
|
||
user_has_permission(current, "cookie:operate")
|
||
or user_has_permission(current, "cookie:view")
|
||
):
|
||
raise HTTPException(status_code=403, detail="无权限: cookie:operate")
|
||
|
||
|
||
def _parse_account_names(raw_names: str) -> list[str]:
|
||
"""解析前端粘贴的 Excel 账号名,每行一个并去重。"""
|
||
names = []
|
||
seen = set()
|
||
for value in (
|
||
(raw_names or "").replace("\r\n", "\n").replace("\r", "\n").split("\n")
|
||
):
|
||
name = value.strip()
|
||
if name and name not in seen:
|
||
names.append(name)
|
||
seen.add(name)
|
||
return names
|
||
|
||
|
||
def _order_cookie_accounts(query, selected_names: list[str]):
|
||
"""按自定义账号名的输入顺序排序,未指定时保持默认的最新优先。"""
|
||
if selected_names:
|
||
input_order = case(
|
||
{name: index for index, name in enumerate(selected_names)},
|
||
value=Account.username,
|
||
else_=len(selected_names),
|
||
)
|
||
return query.order_by(
|
||
input_order, Account.cookie_updated_at.desc(), Account.id.desc()
|
||
)
|
||
return query.order_by(Account.cookie_updated_at.desc(), Account.id.desc())
|
||
|
||
|
||
def _relogin_display(account: Account, in_flight_batch_id: str = "") -> dict:
|
||
"""把账号上的重登进度映射成 CK 操作页展示用的状态。
|
||
|
||
解耦后重登进度就写在账号上(`relogin_*`),不再需要顺着「最近一条登录记录」
|
||
去猜。「正在跑」仍然要看有没有在途尝试 —— 那是尝试的属性,由调用方批量查出后
|
||
以 ``in_flight_batch_id`` 传进来(避免 N+1)。
|
||
"""
|
||
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": status,
|
||
"relogin_message": account.relogin_last_error or "",
|
||
"relogin_batch_id": in_flight_batch_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_accounts_query(db: Session, current: User):
|
||
"""返回当前用户可见的、仍持有 CK 的**账号**查询。
|
||
|
||
以「账号上还有 CK」为准:重登失败与排队中的登录都不会让账号失去 CK,
|
||
管理页必须继续能看到这些账号,否则客服会以为账号消失。
|
||
"""
|
||
query = db.query(Account).filter(cookie_holding_filter())
|
||
# 无全量登录任务权限时,只能操作分配给自己的账号。
|
||
if not user_has_permission(current, "login:view_all"):
|
||
query = query.filter(Account.assigned_to == current.id)
|
||
return query
|
||
|
||
|
||
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
|
||
)
|
||
)
|
||
.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("")
|
||
def list_cookies(
|
||
search: str = Query(""),
|
||
tag: str = Query(""),
|
||
account_names: str = Query(""),
|
||
page: int | None = Query(None, ge=1),
|
||
page_size: int = Query(20, ge=1, le=200),
|
||
include_cookie: bool = Query(True),
|
||
db: Session = Depends(get_db),
|
||
current: User = Depends(get_current_user),
|
||
):
|
||
"""查看持有 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:
|
||
# 列表默认不拖 1.7KB 的密文;复制/导出时才按 id 取完整值。
|
||
query = query.options(
|
||
defer(Account.cookie),
|
||
defer(Account.password),
|
||
defer(Account.email),
|
||
defer(Account.email_password),
|
||
)
|
||
else:
|
||
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)
|
||
if selected_names:
|
||
query = query.filter(Account.username.in_(selected_names))
|
||
if tag_value:
|
||
query = query.filter(Account.tag == tag_value)
|
||
if search_text:
|
||
pattern = f"%{search_text}%"
|
||
query = query.outerjoin(User, Account.assigned_to == User.id).filter(
|
||
or_(
|
||
Account.username.ilike(pattern),
|
||
Account.tag.ilike(pattern),
|
||
User.username.ilike(pattern),
|
||
)
|
||
)
|
||
|
||
total = None
|
||
if page is not None:
|
||
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)
|
||
|
||
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,
|
||
"total": total or 0,
|
||
"page": page,
|
||
"page_size": page_size,
|
||
}
|
||
return result
|
||
|
||
|
||
@router.get("/summary")
|
||
def cookies_summary(
|
||
db: Session = Depends(get_db),
|
||
current: User = Depends(get_current_user),
|
||
):
|
||
"""Cookie 管理统计,避免前端为了卡片统计拉全量 Cookie。"""
|
||
if not user_has_permission(current, "cookie:view"):
|
||
raise HTTPException(status_code=403, detail="无权限: cookie:view")
|
||
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(Account.id))
|
||
.scalar()
|
||
or 0
|
||
)
|
||
return {
|
||
"total": total,
|
||
"assigned_count": assigned_count,
|
||
"unassigned_count": max(0, total - assigned_count),
|
||
}
|
||
|
||
|
||
@router.get("/operations")
|
||
def list_cookie_operations(
|
||
search: str = Query(""),
|
||
tag: str = Query(""),
|
||
page: int = Query(1, ge=1),
|
||
page_size: int = Query(20, ge=1, le=200),
|
||
db: Session = Depends(get_db),
|
||
current: User = Depends(get_current_user),
|
||
):
|
||
"""脱敏的 CK 操作列表,只用于检测与重登。"""
|
||
_require_cookie_operation_perm(current)
|
||
query = _visible_cookie_accounts_query(db, current)
|
||
search_text = (search or "").strip()
|
||
tag_value = (tag or "").strip()
|
||
if tag_value:
|
||
query = query.filter(Account.tag == tag_value)
|
||
if search_text:
|
||
query = query.filter(
|
||
or_(
|
||
Account.username.ilike(f"%{search_text}%"),
|
||
Account.tag.ilike(f"%{search_text}%"),
|
||
)
|
||
)
|
||
|
||
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": 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 acc in accounts
|
||
],
|
||
"total": total,
|
||
"page": page,
|
||
"page_size": page_size,
|
||
}
|
||
|
||
|
||
@router.get("/operations/tags")
|
||
def list_cookie_operation_tags(
|
||
db: Session = Depends(get_db),
|
||
current: User = Depends(get_current_user),
|
||
):
|
||
"""返回当前用户可操作 CK 的账号标签,不跨越账号分配范围。"""
|
||
_require_cookie_operation_perm(current)
|
||
query = _visible_cookie_accounts_query(db, current)
|
||
rows = (
|
||
query.filter(Account.tag != "", Account.tag.isnot(None))
|
||
.with_entities(Account.tag)
|
||
.distinct()
|
||
.order_by(Account.tag.asc())
|
||
.all()
|
||
)
|
||
return [tag for (tag,) in rows if tag]
|
||
|
||
|
||
@router.get("/duplicates")
|
||
def find_duplicate_cookies(
|
||
db: Session = Depends(get_db),
|
||
current: User = Depends(require_permission("cookie:view")),
|
||
):
|
||
"""检测**同一个 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()
|
||
)
|
||
|
||
grouped: dict[str, list[Account]] = {}
|
||
for acc in accounts:
|
||
cookie = acc.cookie or ""
|
||
if not cookie:
|
||
continue
|
||
fingerprint = hashlib.sha256(cookie.encode("utf-8")).hexdigest()
|
||
grouped.setdefault(fingerprint, []).append(acc)
|
||
|
||
duplicate_groups = []
|
||
for fingerprint, group in grouped.items():
|
||
if len(group) < 2:
|
||
continue
|
||
account_ids = [acc.id for acc in group]
|
||
duplicate_groups.append(
|
||
{
|
||
# 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"]))
|
||
|
||
return {
|
||
"success": True,
|
||
"duplicate_groups": len(duplicate_groups),
|
||
"duplicate_rows": sum(item["cookie_count"] for item in duplicate_groups),
|
||
"groups": duplicate_groups,
|
||
}
|
||
|
||
|
||
@router.get("/export")
|
||
def export_cookies(
|
||
format: str = "csv",
|
||
account_names: str = Query(""),
|
||
db: Session = Depends(get_db),
|
||
current: User = Depends(require_permission("cookie:export")),
|
||
):
|
||
"""导出 Cookie,支持 csv 和 custom 格式。
|
||
csv: 账号, Cookie, 时间
|
||
custom: 账号----密码----ck
|
||
"""
|
||
query = _visible_cookie_accounts_query(db, current)
|
||
selected_names = _parse_account_names(account_names)
|
||
if selected_names:
|
||
query = query.filter(Account.username.in_(selected_names))
|
||
accounts = _order_cookie_accounts(query, selected_names).all()
|
||
|
||
if format == "custom":
|
||
# 账号----密码----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"
|
||
else:
|
||
# CSV: 账号, Cookie, 时间
|
||
output = io.StringIO()
|
||
writer = csv.writer(output)
|
||
writer.writerow(["账号", "Cookie", "时间"])
|
||
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"
|
||
|
||
return StreamingResponse(
|
||
iter([content]),
|
||
media_type=media,
|
||
headers={"Content-Disposition": f"attachment; filename={filename}"},
|
||
)
|
||
|
||
|
||
def _check_cookies(ids: str, db: Session, current: User, *, detailed: bool) -> dict:
|
||
"""执行 CK 检测;客服操作页仅返回状态,不返回账号衍生信息。
|
||
|
||
`ids` 是**账号 ID**(2026-10-04 起)。
|
||
"""
|
||
if not ids:
|
||
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")
|
||
accounts = (
|
||
_visible_cookie_accounts_query(db, current)
|
||
.filter(Account.id.in_(id_list))
|
||
.filter(cookie_operable_filter())
|
||
.all()
|
||
)
|
||
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, acc): acc for acc in accounts}
|
||
for future in as_completed(futures):
|
||
try:
|
||
results.append(future.result())
|
||
except Exception as exc: # noqa: BLE001 外部接口与任务边界需要保留宽泛异常兜底
|
||
acc = futures[future]
|
||
results.append(
|
||
{
|
||
"id": acc.id,
|
||
"valid": False,
|
||
"message": f"检测异常: {exc}",
|
||
"fish_ball": None,
|
||
"nickname": None,
|
||
"level": None,
|
||
"checked_at": datetime.now(UTC).isoformat(),
|
||
}
|
||
)
|
||
results.sort(key=lambda item: item["id"])
|
||
|
||
# 持久化检测结果到**账号**,刷新/翻页不丢失。
|
||
accounts_by_id = {acc.id: acc for acc in accounts}
|
||
for item in results:
|
||
acc = accounts_by_id.get(item["id"])
|
||
if not acc:
|
||
continue
|
||
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", ""),
|
||
}
|
||
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",
|
||
)
|
||
)
|
||
db.commit()
|
||
if not detailed:
|
||
results = [
|
||
{"id": item["id"], "valid": item["valid"], "checked_at": item["checked_at"]}
|
||
for item in results
|
||
]
|
||
return {"results": results, "success": True}
|
||
|
||
|
||
@router.post("/check")
|
||
def check_cookies(
|
||
ids: str = "",
|
||
db: Session = Depends(get_db),
|
||
current: User = Depends(get_current_user),
|
||
):
|
||
"""批量检测斗鱼 Cookie 有效性,供 Cookie 管理页查看详细结果。"""
|
||
if not user_has_permission(current, "cookie:view"):
|
||
raise HTTPException(status_code=403, detail="无权限: cookie:view")
|
||
return _check_cookies(ids, db, current, detailed=True)
|
||
|
||
|
||
@router.post("/operations/check")
|
||
def check_cookie_operations(
|
||
ids: str = "",
|
||
db: Session = Depends(get_db),
|
||
current: User = Depends(get_current_user),
|
||
):
|
||
"""脱敏 CK 检测,只返回有效性与检测时间。"""
|
||
_require_cookie_operation_perm(current)
|
||
return _check_cookies(ids, db, current, detailed=False)
|
||
|
||
|
||
def _start_relogin_accounts(
|
||
accounts: list[Account],
|
||
db: Session,
|
||
current: User,
|
||
*,
|
||
action: str = "cookie:relogin",
|
||
):
|
||
"""启动重登批次:旧 CK 保留到新登录成功后才替换。
|
||
|
||
入参是**账号**(2026-10-04 起):调度单位就是账号,登录记录只是每次尝试的历史。
|
||
"""
|
||
if not accounts:
|
||
raise HTTPException(status_code=404, detail="账号不存在")
|
||
|
||
account_ids: list[int] = []
|
||
skipped: list[int] = []
|
||
for acc in accounts:
|
||
if not acc.password or not acc.email:
|
||
# 记录这次尝试失败即可;账号已有的 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
|
||
account_ids.append(acc.id)
|
||
|
||
if not account_ids:
|
||
db.commit()
|
||
raise HTTPException(
|
||
status_code=400, detail="没有可重登的账号,请检查账号凭据或当前重登状态"
|
||
)
|
||
|
||
proxy = db.query(ProxyConfigModel).first()
|
||
thread_db = SessionLocal()
|
||
max_login_retries, max_total_time = get_relogin_limits()
|
||
runner = LoginBatchRunner(
|
||
db=thread_db,
|
||
account_ids=account_ids,
|
||
created_by=current.id,
|
||
creator_permissions=get_user_permissions(current),
|
||
max_login_retries=max_login_retries,
|
||
max_total_time=max_total_time,
|
||
proxy_config=proxy,
|
||
log_queue=None,
|
||
loop=None,
|
||
concurrency=3,
|
||
mode="relogin",
|
||
)
|
||
batch_id = runner.batch_id
|
||
db.add(
|
||
AuditLog(
|
||
user_id=current.id,
|
||
username=current.username,
|
||
action=action,
|
||
target=f"重登 {len(account_ids)} 个账号的 CK",
|
||
detail="旧 Cookie 将在新登录成功后替换",
|
||
)
|
||
)
|
||
db.commit()
|
||
cookie_relogin_registry.register(batch_id, None, None, runner, owner_id=current.id)
|
||
|
||
def run_relogin_batch():
|
||
try:
|
||
runner.run()
|
||
except Exception as exc: # noqa: BLE001 外部接口与任务边界需要保留宽泛异常兜底
|
||
runner._push_log("error", f"批量重新登录异常: {exc}")
|
||
message = f"重新登录异常: {exc}(旧 Cookie 已保留)"
|
||
for account_id in account_ids:
|
||
runner._mark_account_relogin_stopped(account_id, message)
|
||
runner.db.close()
|
||
finally:
|
||
cookie_relogin_registry.pop(batch_id)
|
||
|
||
threading.Thread(target=run_relogin_batch, daemon=True).start()
|
||
return {
|
||
"batch_id": batch_id,
|
||
"count": len(account_ids),
|
||
"skipped": len(skipped),
|
||
"success": True,
|
||
}
|
||
|
||
|
||
@router.post("/operations/relogin/{batch_id}/stop")
|
||
def stop_cookie_relogin(
|
||
batch_id: str,
|
||
db: Session = Depends(get_db),
|
||
current: User = Depends(get_current_user),
|
||
):
|
||
"""停止当前用户发起的 CK 重登批次。"""
|
||
_require_cookie_operation_perm(current)
|
||
batch = cookie_relogin_registry.get(batch_id)
|
||
if not batch:
|
||
raise HTTPException(
|
||
status_code=404, detail="重登批次不存在、已结束或服务已重启"
|
||
)
|
||
if batch.get("owner_id") != current.id and not user_has_permission(
|
||
current, "login:view_all"
|
||
):
|
||
raise HTTPException(status_code=403, detail="无权限停止该重登批次")
|
||
|
||
batch["runner"].stop()
|
||
db.add(
|
||
AuditLog(
|
||
user_id=current.id,
|
||
username=current.username,
|
||
action="cookie:relogin_stop",
|
||
target=f"停止 CK 重登批次 {batch_id}",
|
||
)
|
||
)
|
||
db.commit()
|
||
return {
|
||
"message": "已发送停止信号,正在登录的账号会在当前请求结束后停止",
|
||
"success": True,
|
||
}
|
||
|
||
|
||
def _start_relogin(req: CookieReloginRequest, db: Session, current: User):
|
||
"""按选中的账号启动重新登录(`req.ids` 是账号 ID)。"""
|
||
if not 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_accounts(accounts, db, current)
|
||
|
||
|
||
@router.post("/relogin")
|
||
def relogin_cookies(
|
||
req: CookieReloginRequest,
|
||
db: Session = Depends(get_db),
|
||
current: User = Depends(require_permission("login:batch")),
|
||
):
|
||
"""Cookie 管理页的重登入口,保留既有 login:batch 权限。"""
|
||
return _start_relogin(req, db, current)
|
||
|
||
|
||
@router.post("/operations/relogin")
|
||
def relogin_cookie_operations(
|
||
req: CookieReloginRequest,
|
||
db: Session = Depends(get_db),
|
||
current: User = Depends(get_current_user),
|
||
):
|
||
"""脱敏 CK 操作页的重登入口。"""
|
||
_require_cookie_operation_perm(current)
|
||
return _start_relogin(req, db, current)
|
||
|
||
|
||
@router.post("/operations/relogin-invalid")
|
||
def relogin_invalid_cookie_operations(
|
||
search: str = Query(""),
|
||
tag: str = Query(""),
|
||
db: Session = Depends(get_db),
|
||
current: User = Depends(get_current_user),
|
||
):
|
||
"""批量重新登录当前用户可见且最近检测为无效的账号。"""
|
||
_require_cookie_operation_perm(current)
|
||
query = _visible_cookie_accounts_query(db, current).filter(
|
||
Account.ck_check_status == "invalid",
|
||
cookie_operable_filter(),
|
||
)
|
||
if tag.strip():
|
||
query = query.filter(Account.tag == tag.strip())
|
||
if search.strip():
|
||
pattern = f"%{search.strip()}%"
|
||
query = query.filter(
|
||
or_(Account.username.ilike(pattern), Account.tag.ilike(pattern))
|
||
)
|
||
accounts = query.order_by(Account.id.asc()).all()
|
||
return _start_relogin_accounts(
|
||
accounts, db, current, action="cookie:relogin_invalid"
|
||
)
|
||
|
||
|
||
@router.get("/{account_id}")
|
||
def get_cookie(
|
||
account_id: int,
|
||
db: Session = Depends(get_db),
|
||
current: User = Depends(get_current_user),
|
||
):
|
||
"""获取单个账号的 CK 详情,供复制操作按需读取完整敏感字段。"""
|
||
if not user_has_permission(current, "cookie:view"):
|
||
raise HTTPException(status_code=403, detail="无权限: cookie:view")
|
||
acc = (
|
||
_visible_cookie_accounts_query_with_users(db, current)
|
||
.filter(Account.id == account_id)
|
||
.first()
|
||
)
|
||
if not acc:
|
||
raise HTTPException(status_code=404, detail="账号不存在或没有 CK")
|
||
return _cookie_item(acc, include_cookie=True)
|
||
|
||
|
||
@router.delete("/batch")
|
||
def delete_cookies_batch(
|
||
account_ids: str = "",
|
||
db: Session = Depends(get_db),
|
||
current: User = Depends(require_permission("cookie:export")),
|
||
):
|
||
"""批量清除账号的 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")
|
||
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(accounts)} 个账号的 CK",
|
||
"deleted": len(accounts),
|
||
"success": True,
|
||
}
|
||
|
||
|
||
@router.delete("/{account_id}")
|
||
def delete_cookie(
|
||
account_id: int,
|
||
db: Session = Depends(get_db),
|
||
current: User = Depends(require_permission("cookie:export")),
|
||
):
|
||
"""清除一个账号的 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 已从该账号清空",
|
||
)
|
||
)
|
||
db.commit()
|
||
return {"message": "已删除", "success": True}
|