Files
live-hub-py/web/backend/routers/cookies.py
T
yml2213 1a8710834a feat(account): S3 生命周期状态收口 —— 6 状态状态机、唯一推导入口、停用/归档闭环
`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 账号页重构。
2026-10-04 14:48:22 +08:00

858 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 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}