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` 目前没有改备注的接口,故无发射点(已在文档标注)。
848 lines
30 KiB
Python
848 lines
30 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.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}
|