Files
live-hub-py/web/backend/routers/cookies.py
T
yml2213 0a922ce56d feat(account): S4 事件发射点补齐 —— 审计与时间线打通、批量事件、ck_invalid 只认转变
S3 让 `status` 可信,S4 让**时间线完整**:账号上发生的事,时间线里都能查到。

## 补齐「定义了却从没发过」的事件

`assigned` / `tag_changed` 这两个类型早就定义了、前端 `EVENT_STYLE` 也渲染了,
但**全仓没有任何代码发过**(`grep` 只有定义处)。本次接上:

- 单个分配 / 批量分配 → `assigned`(detail 带 assigned_to / assigned_username)
- 单个改标签 / 批量改标签 / 按筛选批量改标签 → `tag_changed`(detail 带 from/to)

## 新增 `ck_cleared`,并修掉 `ck_invalid` 的刷屏

- **`ck_cleared`**:清 CK 是**不可逆**操作(重登当前成功率极低),时间线里却不留痕。
  现在单个清除与批量清除共用 `account_lifecycle.on_ck_cleared()`。
- **`ck_invalid` 改为只在「由好变坏」时发**。旧实现每轮巡检只要判定 invalid 就发一条,
  源码注释写着「避免每轮刷屏」,**但代码并没有做到** —— 6 小时一轮、失效一周就是
  28 条一模一样的事件。判定失效是**一次状态转变**,不是每轮读数。`error`(查不到)
  同样不改变结论、不发事件。

## 审计与时间线一次写完(防漏一边)

新增 `audit_service.record_account_action()`:**审计 + 账号事件一起写**,并把
`account_id` 落到审计上。这两件事必须成对出现 —— 只写审计,时间线就缺「谁改了什么」;
只发事件,合规审计就缺一条。分成两次调用写,迟早有人漏一边(`assigned` 就是这么漏的)。

顺带把批量删除的审计补上被删账号 id(`detail.account_ids`,截前 200 条):
删账号会**连带删掉它的事件时间线**(外键),审计是唯一痕迹 —— 这正是 10-03
那次事故要留的东西。

## 批量操作不再逐条开 SAVEPOINT

批量分配/改标签可能覆盖几千个账号,而每个账号的时间线都该各留一条。
逐条走 `emit()` 会给每条开一个 SAVEPOINT,几千条时请求明显变慢。
新增 `emit_bulk()`:一次 executemany,整批包在**一个** SAVEPOINT 里,
与 `emit()` 一样**绝不抛异常**(整批失败就整批丢弃 + warning,不影响业务写入)。

## 检测入口收口:4 处 → 1 个 `on_ck_verdict()`

`ck_health` / 人工检测 / 工作台执行前检测 / 重登后复检 原本各自写
`ck_check_status` + `ck_check_result` + `ck_checked_at` + 发事件 + 调 `sync_status`,
口径容易分叉(`ck_invalid` 的刷屏就是这么来的)。现在统一走
`account_lifecycle.on_ck_verdict()`,顺序固定:**写结论 → 由好变坏记事件 → 重算状态**。

至此 `account_lifecycle` 成为**唯一**写生命周期字段的模块,并加了一条**源码扫描用例**
把它钉死(任何地方直接 `acc.status = ...` 都会让它红,并指出 file:line)——
比写在文档里的约定可靠。虎牙的 `huya_accounts.status` 是同一个病,但属另一套模型,
已在用例里显式排除并注明。

## 接口与前端

- 时间线支持 `?category=` 筛选(lifecycle / auth / business / ops);
  响应补 `category` / `subject_*` / `actor_*`
- 前端 `EVENT_STYLE` 补上 `status_changed`(**S3 我漏的**:加了事件类型却没加样式,
  页面上显示成原始英文)与 `ck_cleared`;时间线加「分类」筛选

## 验证

- `TZ=Asia/Shanghai`:**420 passed / 6 skipped**(S3 后 404 → 新增 16 条)
- 迁移链 3 passed;ruff check/format 全绿;前端 `tsc -b && vite build` 与
  `npm run lint`(0 errors)均通过
- **两条关键用例都做过「反向验证」**(先注入旧行为确认会红):
  - 把 `ck_invalid` 改回「每轮都发」→ 用例报 `3 == 1`;
  - 在路由里直接写 `acc.status = ...` → 不变量用例给出精确 file:line。
- 新用例还覆盖:批量分配/改标签逐账号发事件、清 CK 事件与审计关联、
  `emit_bulk` 的跳过与容错、时间线分类筛选、批量删除的 id 留痕。

未做(后续):S5 业务事实直发事件 + `douyu_tasks` 保留策略、S6 账号页重构 +
`bind_status` 三列拆解。`remark_changed` 目前没有改备注的接口,故无发射点(已在文档标注)。
2026-10-04 15:09:46 +08:00

848 lines
30 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.account_lifecycle import on_ck_cleared, on_ck_verdict
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}
checked_at = datetime.now(UTC)
for item in results:
acc = accounts_by_id.get(item["id"])
if not acc:
continue
# 人工检测结论同样走统一入口(写结论 + 由好变坏记事件 + 重算状态)
on_ck_verdict(
db,
acc,
"valid" if item["valid"] else "invalid",
detail=item,
occurred_at=checked_at,
actor_type="user",
actor_id=current.id,
)
# 审计带上账号:单个检测时可直接反查(批量时只能落到 target 文本里)
db.add(
AuditLog(
user_id=current.id,
username=current.username,
action="cookie:check",
target=f"检测 {len(results)} 个账号的 CK",
account_id=accounts[0].id if len(accounts) == 1 else None,
)
)
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:
# 清 CK 是**不可逆**操作:清本体 + 清健康状态 + 记事件 + 重算状态,
# 全在 on_ck_cleared 一处(单个清除与批量清除共用)。
on_ck_cleared(
db, acc, occurred_at=now, actor_id=current.id, reason="manual_clear_batch"
)
# 批量清 CK 涉及多个账号,只能落到 target 文本里;单个账号时补 account_id,
# 便于审计页/账号时间线按索引反查。
db.add(
AuditLog(
user_id=current.id,
username=current.username,
action="cookie:delete",
target=f"清除 {len(accounts)} 个账号的 CK",
account_id=accounts[0].id if len(accounts) == 1 else None,
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")
account_id = acc.id
username = acc.username
now = datetime.now(UTC)
on_ck_cleared(db, acc, occurred_at=now, actor_id=current.id)
db.add(
AuditLog(
user_id=current.id,
username=current.username,
action="cookie:delete",
target=username,
# 关联到账号:S1 给审计表加了 account_id,这里才是它第一个真正的写入方
account_id=account_id,
detail="不可逆:CK 已从该账号清空",
)
)
db.commit()
return {"message": "已删除", "success": True}