Files
live-hub-py/web/backend/routers/cookies.py
T
yml2213 00125fbfc4 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 落地时要把自动化选行的状态过滤一起补上。
2026-10-04 14:10:13 +08:00

839 lines
29 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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}