`status` 列从「空壳」变成可信状态:S1 建了列、S2 让账号独立,本提交让状态真正
反映账号处境,并把「哪些账号要人处理」变成可筛选的事实。
## 状态机(7 → 6 个状态)
`imported` / `active` / `invalid` / `manual` / `disabled` / `archived`
**砍掉 `relogin`**:它完全等价于 `invalid + relogin_next_at is not None`,
独立成状态就多出一个必须与 `relogin_next_at` 同步的字段 —— 正是要消灭的「双份真相」。
## 唯一推导入口(防「双份真相」)
新增 `services/account_lifecycle.py`:
- `derive_status(account)` —— **纯函数**,按 cookie / ck_check_status / relogin_*
推导状态;人工态(disabled/archived)优先级最高,不被覆盖;
- `sync_status(db, account)` —— 推导 + 变了才写,**幂等**(没变则不写库、不发事件,
否则巡检每轮都往时间线刷一条);
- `set_manual_state(db, account, state)` —— 停用/归档/`auto`(恢复自动推导);
- `on_login_success(db, account)` —— **「登录成功对账号意味着什么」只定义一次**:
作废本轮重登退避痕迹 + 重算状态。于是**「待人工」的账号被人工登录救回来时状态
会自动恢复**,而不是卡在 manual。
- `MANUAL_KINDS` 从 `jobs/ck_relogin.py` 挪到这里(service 不该反向依赖 job)。
**9 处底层判据写入点全部接线**:登录成功、CK 清除(单个/批量)、ck_health 巡检、
人工 CK 检测、工作台执行前检测、重登复检、重登结算(成功/失败退避)。
## 消费端:补上一个真缺口 + 删掉两处临时派生
- ⚠️ **缺口**:`ck_health` / `ck_relogin` / `count_pending_relogin` 此前只按底层判据
选行,**没排除 disabled/archived** —— S2 提交里已标注,状态一上线就会变成
「停用的账号照样被自动重登」。现已全部排除。
- `automation.manual_queue` 原本用 4 个条件自己拼「需要人工」,现在一句
`status == 'manual'`。同一判据从「3 处各写一遍」收敛成「1 处推导」。
## 接口与前端
- `PUT /api/accounts/{id}/lifecycle`(disabled/archived/auto + 原因,写审计)
- `PUT /api/accounts/{id}/automation`(**接上死开关**:`auto_managed` 此前没有任何
接口/前端能改,永远是默认 True)
- 新增权限点 `account:manage`(运营默认拥有)
- 列表支持 `?status=` 筛选;**归档默认不出现**(要看需显式筛选)
- `AccountOut` / 概览接口补 status / status_label / status_reason / auto_managed
- 前端:账号列表加「状态」列与状态筛选;详情页状态卡 + 停用/归档/恢复/自动化开关
## 两个自查出来的问题(都已修)
1. **幂等键反而丢事件**:状态事件一开始用 `status:{id}:{target}:{秒级时间戳}` 作
`source_ref`,结果同一账号在同一秒内两次切到同一状态就撞唯一键,`emit` 静默吞掉
—— **状态改了、时间线却少一条**,正是本模块要消灭的「悄悄不一致」。
改为不传 `source_ref`:`sync_status` 本身已幂等,不存在重复写入路径。已加用例。
2. **迁移与运行时规则分叉**:迁移 0042 的状态回填 CASE 原本会写出已被砍掉的
`relogin`,且判据与 `derive_status` 不一致(次数用尽的会回填成 invalid 而非 manual)。
已改成逐字对应,并加一条用例**真跑那段 SQL 与纯函数逐条比对**(已验证:
临时改回旧 CASE 该用例立刻红)。
## 验证
- `TZ=Asia/Shanghai`:**404 passed / 6 skipped**(S2 后 373 → 新增 31 条)
- 迁移链 3 passed;ruff check/format 全绿;前端 `tsc -b && vite build` 通过
- 真实开发库核对:落库状态分布 active 4 / imported 8,**与 derive_status 逐账号比对无漂移**
- 新增用例覆盖:derive 真值表(每状态入口 + 人工态优先)、sync 幂等、
人工态切换、**全生命周期走查**(导入→登录→失效→重登→转人工→人工救回→清 CK→停用)、
自动化排除 disabled/archived、列表状态筛选、接口
未做(后续阶段):S4 事件发射点补齐、S5 业务事实直发事件、S6 账号页重构。
858 lines
30 KiB
Python
858 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 sync_status
|
||
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
|
||
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 = checked_at
|
||
# 人工检测同样改变生命周期状态(正常 ↔ CK 失效)
|
||
sync_status(
|
||
db, acc, 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:
|
||
acc.cookie = ""
|
||
acc.cookie_updated_at = now
|
||
acc.ck_check_status = ""
|
||
acc.ck_check_result = None
|
||
acc.ck_checked_at = None
|
||
# 没有 CK 就跑不了业务 → 状态回到「未登录」
|
||
sync_status(db, acc, occurred_at=now, actor_type="user", actor_id=current.id)
|
||
# 批量清 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)
|
||
acc.cookie = ""
|
||
acc.cookie_updated_at = now
|
||
acc.ck_check_status = ""
|
||
acc.ck_check_result = None
|
||
acc.ck_checked_at = None
|
||
# 没有 CK 就跑不了业务 → 状态回到「未登录」
|
||
sync_status(db, acc, occurred_at=now, actor_type="user", 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}
|