"""Cookie 管理路由。 ## 2026-10-04 语义变更(CK 解耦) CK 从 `login_tasks.cookie` 迁到 `accounts.cookie`,因此本页面的**记录单位从 「登录记录」变成「账号」**: - 列表/汇总/导出 = **持有 CK 的账号**(一行一账号,不再出现同一账号多行); - 所有接口的 `id` 入参/出参 = **account_id**(以前是 task_id); - `DELETE /cookies/{id}` = **清掉该账号的 CK**(不可逆),必写审计; - 重复检测 = **同一个 CK 值出现在多个账号上**(真正的风险:两个账号共用一个 登录态)。以前查的是「同一账号多行持 CK」,那在解耦后已不可能出现。 ⚠️ 这是本项目**破坏性最强**的接口面:清错账号的 CK 比 10-03 那次事故更糟。 所以删除/批量删除都单独写审计,且入参语义有专门的用例覆盖。 """ import csv import hashlib import io import threading from concurrent.futures import ThreadPoolExecutor, as_completed from datetime import UTC, datetime from fastapi import APIRouter, Depends, HTTPException, Query from fastapi.responses import StreamingResponse from sqlalchemy import case, func, or_ from sqlalchemy.orm import Session, defer, joinedload from ..database import SessionLocal, get_db from ..deps import get_current_user, require_permission from ..models import Account, AuditLog, LoginTask, User from ..models import ProxyConfig as ProxyConfigModel from ..permissions import get_user_permissions, user_has_permission from ..schemas import CookieReloginRequest from ..services.cookie_check_service import check_douyu_cookie from ..services.douyu_service import ( LOGIN_ATTEMPT_IN_FLIGHT_STATUSES, cookie_holding_filter, cookie_operable_filter, ) from ..services.login_service import BatchRegistry, LoginBatchRunner, get_relogin_limits def _fmt_dt(dt) -> str | None: """将 datetime 格式化为带时区的 ISO 字符串。""" if dt is None: return None if dt.tzinfo is None: dt = dt.replace(tzinfo=UTC) return dt.isoformat() router = APIRouter(prefix="/api/cookies", tags=["Cookie管理"]) cookie_relogin_registry = BatchRegistry() def _require_cookie_operation_perm(current: User) -> None: """允许脱敏的 CK 检测与重登,不授予 CK 查看或导出能力。""" if not ( user_has_permission(current, "cookie:operate") or user_has_permission(current, "cookie:view") ): raise HTTPException(status_code=403, detail="无权限: cookie:operate") def _parse_account_names(raw_names: str) -> list[str]: """解析前端粘贴的 Excel 账号名,每行一个并去重。""" names = [] seen = set() for value in ( (raw_names or "").replace("\r\n", "\n").replace("\r", "\n").split("\n") ): name = value.strip() if name and name not in seen: names.append(name) seen.add(name) return names def _order_cookie_accounts(query, selected_names: list[str]): """按自定义账号名的输入顺序排序,未指定时保持默认的最新优先。""" if selected_names: input_order = case( {name: index for index, name in enumerate(selected_names)}, value=Account.username, else_=len(selected_names), ) return query.order_by( input_order, Account.cookie_updated_at.desc(), Account.id.desc() ) return query.order_by(Account.cookie_updated_at.desc(), Account.id.desc()) def _relogin_display(account: Account, in_flight_batch_id: str = "") -> dict: """把账号上的重登进度映射成 CK 操作页展示用的状态。 解耦后重登进度就写在账号上(`relogin_*`),不再需要顺着「最近一条登录记录」 去猜。「正在跑」仍然要看有没有在途尝试 —— 那是尝试的属性,由调用方批量查出后 以 ``in_flight_batch_id`` 传进来(避免 N+1)。 """ if in_flight_batch_id: status = "relogin_running" elif account.ck_check_status == "invalid" and account.relogin_next_at is not None: status = "relogin_pending" elif account.ck_check_status == "invalid" and account.relogin_last_error: status = "relogin_failed" else: status = "" return { "relogin_status": status, "relogin_message": account.relogin_last_error or "", "relogin_batch_id": in_flight_batch_id, } def _check_one_cookie(account: Account) -> dict: """将共享检测结果补充为 CK 记录的接口返回格式。""" result = check_douyu_cookie(account.cookie or "") result["id"] = account.id result["checked_at"] = result["checked_at"].isoformat() return result def _visible_cookie_accounts_query(db: Session, current: User): """返回当前用户可见的、仍持有 CK 的**账号**查询。 以「账号上还有 CK」为准:重登失败与排队中的登录都不会让账号失去 CK, 管理页必须继续能看到这些账号,否则客服会以为账号消失。 """ query = db.query(Account).filter(cookie_holding_filter()) # 无全量登录任务权限时,只能操作分配给自己的账号。 if not user_has_permission(current, "login:view_all"): query = query.filter(Account.assigned_to == current.id) return query def _visible_cookie_accounts_query_with_users(db: Session, current: User): """同上,但预加载归属客服(列表展示需要)。""" return _visible_cookie_accounts_query(db, current).options( joinedload(Account.assigned_user) ) def _in_flight_batch_ids(db: Session, account_ids: list[int]) -> dict[int, str]: """批量取「在途尝试」的批次号,供重登状态展示(一次查询,避免 N+1)。""" if not account_ids: return {} rows = ( db.query(LoginTask.account_id, func.max(LoginTask.attempt_batch_id)) .filter(LoginTask.account_id.in_(account_ids)) .filter( func.coalesce(LoginTask.attempt_status, "").in_( LOGIN_ATTEMPT_IN_FLIGHT_STATUSES ) ) .group_by(LoginTask.account_id) .all() ) return {int(account_id): (batch_id or "") for account_id, batch_id in rows} def _cookie_item( acc: Account, *, include_cookie: bool, in_flight_batch_id: str = "", ) -> dict: """构造账号的 CK 列表项(REST 与详情接口共用)。""" cookie = (acc.cookie or "") if include_cookie else "" item = { "id": acc.id, "account_id": acc.id, "account_username": acc.username, "assigned_to": acc.assigned_to, "assigned_username": acc.assigned_user.username if acc.assigned_user else None, # 这份 CK 是什么时候拿到的(原来是登录记录的 finished_at) "created_at": _fmt_dt(acc.cookie_updated_at), "ck_check_status": acc.ck_check_status or "", "ck_check_result": acc.ck_check_result, "ck_checked_at": _fmt_dt(acc.ck_checked_at), **_relogin_display(acc, in_flight_batch_id), } if include_cookie: item["cookie"] = cookie item["cookie_preview"] = cookie[:50] + "..." if len(cookie) > 50 else cookie item["account_password"] = acc.password else: item["cookie"] = "" item["cookie_preview"] = "***" item["account_password"] = "" return item @router.get("") def list_cookies( search: str = Query(""), tag: str = Query(""), account_names: str = Query(""), page: int | None = Query(None, ge=1), page_size: int = Query(20, ge=1, le=200), include_cookie: bool = Query(True), db: Session = Depends(get_db), current: User = Depends(get_current_user), ): """查看持有 CK 的账号列表(一行一账号)。""" if not user_has_permission(current, "cookie:view"): raise HTTPException(status_code=403, detail="无权限: cookie:view") query = _visible_cookie_accounts_query_with_users(db, current) if not include_cookie: # 列表默认不拖 1.7KB 的密文;复制/导出时才按 id 取完整值。 query = query.options( defer(Account.cookie), defer(Account.password), defer(Account.email), defer(Account.email_password), ) else: query = query.options(defer(Account.email), defer(Account.email_password)) search_text = (search or "").strip() tag_value = (tag or "").strip() selected_names = _parse_account_names(account_names) if selected_names: query = query.filter(Account.username.in_(selected_names)) if tag_value: query = query.filter(Account.tag == tag_value) if search_text: pattern = f"%{search_text}%" query = query.outerjoin(User, Account.assigned_to == User.id).filter( or_( Account.username.ilike(pattern), Account.tag.ilike(pattern), User.username.ilike(pattern), ) ) total = None if page is not None: total = query.order_by(None).with_entities(func.count(Account.id)).scalar() or 0 query = _order_cookie_accounts(query, selected_names) if page is not None: query = query.offset((page - 1) * page_size).limit(page_size) accounts = query.all() in_flight = _in_flight_batch_ids(db, [acc.id for acc in accounts]) # include_cookie 已经在上面的权限里保证只对有 cookie:view 的人开放。 result = [ _cookie_item( acc, include_cookie=include_cookie, in_flight_batch_id=in_flight.get(acc.id, ""), ) for acc in accounts ] if page is not None: return { "items": result, "total": total or 0, "page": page, "page_size": page_size, } return result @router.get("/summary") def cookies_summary( db: Session = Depends(get_db), current: User = Depends(get_current_user), ): """Cookie 管理统计,避免前端为了卡片统计拉全量 Cookie。""" if not user_has_permission(current, "cookie:view"): raise HTTPException(status_code=403, detail="无权限: cookie:view") query = _visible_cookie_accounts_query(db, current) total = query.with_entities(func.count(Account.id)).scalar() or 0 assigned_count = ( query.filter(Account.assigned_to.isnot(None)) .with_entities(func.count(Account.id)) .scalar() or 0 ) return { "total": total, "assigned_count": assigned_count, "unassigned_count": max(0, total - assigned_count), } @router.get("/operations") def list_cookie_operations( search: str = Query(""), tag: str = Query(""), page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=200), db: Session = Depends(get_db), current: User = Depends(get_current_user), ): """脱敏的 CK 操作列表,只用于检测与重登。""" _require_cookie_operation_perm(current) query = _visible_cookie_accounts_query(db, current) search_text = (search or "").strip() tag_value = (tag or "").strip() if tag_value: query = query.filter(Account.tag == tag_value) if search_text: query = query.filter( or_( Account.username.ilike(f"%{search_text}%"), Account.tag.ilike(f"%{search_text}%"), ) ) total = query.order_by(None).with_entities(func.count(Account.id)).scalar() or 0 accounts = ( query.order_by(Account.cookie_updated_at.desc(), Account.id.desc()) .offset((page - 1) * page_size) .limit(page_size) .all() ) in_flight = _in_flight_batch_ids(db, [acc.id for acc in accounts]) return { "items": [ { "id": acc.id, "account_username": acc.username, "tag": acc.tag or "", "ck_check_status": acc.ck_check_status or "", "ck_checked_at": _fmt_dt(acc.ck_checked_at), "created_at": _fmt_dt(acc.cookie_updated_at), **_relogin_display(acc, in_flight.get(acc.id, "")), } for acc in accounts ], "total": total, "page": page, "page_size": page_size, } @router.get("/operations/tags") def list_cookie_operation_tags( db: Session = Depends(get_db), current: User = Depends(get_current_user), ): """返回当前用户可操作 CK 的账号标签,不跨越账号分配范围。""" _require_cookie_operation_perm(current) query = _visible_cookie_accounts_query(db, current) rows = ( query.filter(Account.tag != "", Account.tag.isnot(None)) .with_entities(Account.tag) .distinct() .order_by(Account.tag.asc()) .all() ) return [tag for (tag,) in rows if tag] @router.get("/duplicates") def find_duplicate_cookies( db: Session = Depends(get_db), current: User = Depends(require_permission("cookie:view")), ): """检测**同一个 CK 值出现在多个账号上**,不返回 Cookie 敏感字段。 解耦前这里查的是「同一账号有多行持 CK」—— 那是「一行一账号」不变量漏水的产物。 现在每个账号只有一份 CK,真正值得报警的是**多个账号共用一个登录态**: 要么是导入时复制粘贴错了,要么是同一个斗鱼号被建了两次。这两种都会让 「按账号算的积分/兑换」失真,所以按 CK 指纹分组。 指纹用 sha256 而不是密文本身:CK 用 AES-GCM + 随机 nonce 加密, **同一个明文每次加密结果都不同**,直接比密文永远比不出重复。 """ accounts = ( _visible_cookie_accounts_query(db, current) .order_by(Account.username.asc(), Account.id.asc()) .all() ) grouped: dict[str, list[Account]] = {} for acc in accounts: cookie = acc.cookie or "" if not cookie: continue fingerprint = hashlib.sha256(cookie.encode("utf-8")).hexdigest() grouped.setdefault(fingerprint, []).append(acc) duplicate_groups = [] for fingerprint, group in grouped.items(): if len(group) < 2: continue account_ids = [acc.id for acc in group] duplicate_groups.append( { # account_key 保持字段名兼容,现在放的是 CK 指纹前 12 位。 "account_key": fingerprint[:12], "account_names": [acc.username for acc in group], "cookie_count": len(group), "account_count": len(group), "cookie_ids": account_ids, "account_ids": account_ids, "records": [ { "id": acc.id, "account_id": acc.id, "batch_id": "", "finished_at": _fmt_dt(acc.cookie_updated_at), } for acc in group ], } ) duplicate_groups.sort(key=lambda item: (-item["cookie_count"], item["account_key"])) return { "success": True, "duplicate_groups": len(duplicate_groups), "duplicate_rows": sum(item["cookie_count"] for item in duplicate_groups), "groups": duplicate_groups, } @router.get("/export") def export_cookies( format: str = "csv", account_names: str = Query(""), db: Session = Depends(get_db), current: User = Depends(require_permission("cookie:export")), ): """导出 Cookie,支持 csv 和 custom 格式。 csv: 账号, Cookie, 时间 custom: 账号----密码----ck """ query = _visible_cookie_accounts_query(db, current) selected_names = _parse_account_names(account_names) if selected_names: query = query.filter(Account.username.in_(selected_names)) accounts = _order_cookie_accounts(query, selected_names).all() if format == "custom": # 账号----密码----ck lines = [ f"{acc.username}----{acc.password}----{acc.cookie or ''}" for acc in accounts ] content = "\r\n".join(lines) filename = "cookies_custom.txt" media = "text/plain" else: # CSV: 账号, Cookie, 时间 output = io.StringIO() writer = csv.writer(output) writer.writerow(["账号", "Cookie", "时间"]) for acc in accounts: writer.writerow( [acc.username, acc.cookie or "", _fmt_dt(acc.cookie_updated_at) or ""] ) content = output.getvalue() filename = "cookies.csv" media = "text/csv" return StreamingResponse( iter([content]), media_type=media, headers={"Content-Disposition": f"attachment; filename={filename}"}, ) def _check_cookies(ids: str, db: Session, current: User, *, detailed: bool) -> dict: """执行 CK 检测;客服操作页仅返回状态,不返回账号衍生信息。 `ids` 是**账号 ID**(2026-10-04 起)。 """ if not ids: raise HTTPException(status_code=400, detail="请指定账号ID") id_list = [int(x) for x in ids.split(",") if x.strip().isdigit()] if not id_list: raise HTTPException(status_code=400, detail="无效的ID") accounts = ( _visible_cookie_accounts_query(db, current) .filter(Account.id.in_(id_list)) .filter(cookie_operable_filter()) .all() ) if not accounts: raise HTTPException(status_code=404, detail="账号不存在或不可检测") results: list[dict] = [] with ThreadPoolExecutor(max_workers=8) as pool: futures = {pool.submit(_check_one_cookie, acc): acc for acc in accounts} for future in as_completed(futures): try: results.append(future.result()) except Exception as exc: # noqa: BLE001 外部接口与任务边界需要保留宽泛异常兜底 acc = futures[future] results.append( { "id": acc.id, "valid": False, "message": f"检测异常: {exc}", "fish_ball": None, "nickname": None, "level": None, "checked_at": datetime.now(UTC).isoformat(), } ) results.sort(key=lambda item: item["id"]) # 持久化检测结果到**账号**,刷新/翻页不丢失。 accounts_by_id = {acc.id: acc for acc in accounts} for item in results: acc = accounts_by_id.get(item["id"]) if not acc: continue acc.ck_check_status = "valid" if item["valid"] else "invalid" acc.ck_check_result = { "fish_ball": item.get("fish_ball"), "nickname": item.get("nickname"), "level": item.get("level"), "message": item.get("message", ""), } acc.ck_checked_at = datetime.now(UTC) db.add( AuditLog( user_id=current.id, username=current.username, action="cookie:check", target=f"检测 {len(results)} 个账号的 CK", ) ) db.commit() if not detailed: results = [ {"id": item["id"], "valid": item["valid"], "checked_at": item["checked_at"]} for item in results ] return {"results": results, "success": True} @router.post("/check") def check_cookies( ids: str = "", db: Session = Depends(get_db), current: User = Depends(get_current_user), ): """批量检测斗鱼 Cookie 有效性,供 Cookie 管理页查看详细结果。""" if not user_has_permission(current, "cookie:view"): raise HTTPException(status_code=403, detail="无权限: cookie:view") return _check_cookies(ids, db, current, detailed=True) @router.post("/operations/check") def check_cookie_operations( ids: str = "", db: Session = Depends(get_db), current: User = Depends(get_current_user), ): """脱敏 CK 检测,只返回有效性与检测时间。""" _require_cookie_operation_perm(current) return _check_cookies(ids, db, current, detailed=False) def _start_relogin_accounts( accounts: list[Account], db: Session, current: User, *, action: str = "cookie:relogin", ): """启动重登批次:旧 CK 保留到新登录成功后才替换。 入参是**账号**(2026-10-04 起):调度单位就是账号,登录记录只是每次尝试的历史。 """ if not accounts: raise HTTPException(status_code=404, detail="账号不存在") account_ids: list[int] = [] skipped: list[int] = [] for acc in accounts: if not acc.password or not acc.email: # 记录这次尝试失败即可;账号已有的 CK 原封不动。 db.add( LoginTask( batch_id="relogin-precheck", account_id=acc.id, status="failed", attempt_status="failed", attempt_message="重新登录失败: 账号缺少密码或邮箱,旧 Cookie 已保留", attempt_at=datetime.now(UTC), created_by=current.id, ) ) skipped.append(acc.id) continue account_ids.append(acc.id) if not account_ids: db.commit() raise HTTPException( status_code=400, detail="没有可重登的账号,请检查账号凭据或当前重登状态" ) proxy = db.query(ProxyConfigModel).first() thread_db = SessionLocal() max_login_retries, max_total_time = get_relogin_limits() runner = LoginBatchRunner( db=thread_db, account_ids=account_ids, created_by=current.id, creator_permissions=get_user_permissions(current), max_login_retries=max_login_retries, max_total_time=max_total_time, proxy_config=proxy, log_queue=None, loop=None, concurrency=3, mode="relogin", ) batch_id = runner.batch_id db.add( AuditLog( user_id=current.id, username=current.username, action=action, target=f"重登 {len(account_ids)} 个账号的 CK", detail="旧 Cookie 将在新登录成功后替换", ) ) db.commit() cookie_relogin_registry.register(batch_id, None, None, runner, owner_id=current.id) def run_relogin_batch(): try: runner.run() except Exception as exc: # noqa: BLE001 外部接口与任务边界需要保留宽泛异常兜底 runner._push_log("error", f"批量重新登录异常: {exc}") message = f"重新登录异常: {exc}(旧 Cookie 已保留)" for account_id in account_ids: runner._mark_account_relogin_stopped(account_id, message) runner.db.close() finally: cookie_relogin_registry.pop(batch_id) threading.Thread(target=run_relogin_batch, daemon=True).start() return { "batch_id": batch_id, "count": len(account_ids), "skipped": len(skipped), "success": True, } @router.post("/operations/relogin/{batch_id}/stop") def stop_cookie_relogin( batch_id: str, db: Session = Depends(get_db), current: User = Depends(get_current_user), ): """停止当前用户发起的 CK 重登批次。""" _require_cookie_operation_perm(current) batch = cookie_relogin_registry.get(batch_id) if not batch: raise HTTPException( status_code=404, detail="重登批次不存在、已结束或服务已重启" ) if batch.get("owner_id") != current.id and not user_has_permission( current, "login:view_all" ): raise HTTPException(status_code=403, detail="无权限停止该重登批次") batch["runner"].stop() db.add( AuditLog( user_id=current.id, username=current.username, action="cookie:relogin_stop", target=f"停止 CK 重登批次 {batch_id}", ) ) db.commit() return { "message": "已发送停止信号,正在登录的账号会在当前请求结束后停止", "success": True, } def _start_relogin(req: CookieReloginRequest, db: Session, current: User): """按选中的账号启动重新登录(`req.ids` 是账号 ID)。""" if not req.ids: raise HTTPException(status_code=400, detail="请选择要重新登录的账号") accounts = ( _visible_cookie_accounts_query(db, current) .filter(Account.id.in_(req.ids)) .filter(cookie_operable_filter()) .all() ) return _start_relogin_accounts(accounts, db, current) @router.post("/relogin") def relogin_cookies( req: CookieReloginRequest, db: Session = Depends(get_db), current: User = Depends(require_permission("login:batch")), ): """Cookie 管理页的重登入口,保留既有 login:batch 权限。""" return _start_relogin(req, db, current) @router.post("/operations/relogin") def relogin_cookie_operations( req: CookieReloginRequest, db: Session = Depends(get_db), current: User = Depends(get_current_user), ): """脱敏 CK 操作页的重登入口。""" _require_cookie_operation_perm(current) return _start_relogin(req, db, current) @router.post("/operations/relogin-invalid") def relogin_invalid_cookie_operations( search: str = Query(""), tag: str = Query(""), db: Session = Depends(get_db), current: User = Depends(get_current_user), ): """批量重新登录当前用户可见且最近检测为无效的账号。""" _require_cookie_operation_perm(current) query = _visible_cookie_accounts_query(db, current).filter( Account.ck_check_status == "invalid", cookie_operable_filter(), ) if tag.strip(): query = query.filter(Account.tag == tag.strip()) if search.strip(): pattern = f"%{search.strip()}%" query = query.filter( or_(Account.username.ilike(pattern), Account.tag.ilike(pattern)) ) accounts = query.order_by(Account.id.asc()).all() return _start_relogin_accounts( accounts, db, current, action="cookie:relogin_invalid" ) @router.get("/{account_id}") def get_cookie( account_id: int, db: Session = Depends(get_db), current: User = Depends(get_current_user), ): """获取单个账号的 CK 详情,供复制操作按需读取完整敏感字段。""" if not user_has_permission(current, "cookie:view"): raise HTTPException(status_code=403, detail="无权限: cookie:view") acc = ( _visible_cookie_accounts_query_with_users(db, current) .filter(Account.id == account_id) .first() ) if not acc: raise HTTPException(status_code=404, detail="账号不存在或没有 CK") return _cookie_item(acc, include_cookie=True) @router.delete("/batch") def delete_cookies_batch( account_ids: str = "", db: Session = Depends(get_db), current: User = Depends(require_permission("cookie:export")), ): """批量清除账号的 CK(**不可逆**,写审计)。""" if not account_ids: raise HTTPException(status_code=400, detail="请指定账号ID") ids = [int(x) for x in account_ids.split(",") if x.strip().isdigit()] if not ids: raise HTTPException(status_code=400, detail="无效的ID") accounts = ( _visible_cookie_accounts_query(db, current).filter(Account.id.in_(ids)).all() ) now = datetime.now(UTC) for acc in accounts: acc.cookie = "" acc.cookie_updated_at = now acc.ck_check_status = "" acc.ck_check_result = None acc.ck_checked_at = None db.add( AuditLog( user_id=current.id, username=current.username, action="cookie:delete", target=f"清除 {len(accounts)} 个账号的 CK", detail="不可逆:CK 已从账号上清空", ) ) db.commit() return { "message": f"已清除 {len(accounts)} 个账号的 CK", "deleted": len(accounts), "success": True, } @router.delete("/{account_id}") def delete_cookie( account_id: int, db: Session = Depends(get_db), current: User = Depends(require_permission("cookie:export")), ): """清除一个账号的 CK(**不可逆**,写审计)。 ⚠️ 入参是 **account_id**(2026-10-04 起)。以前是 task_id —— 语义变了, 改错会变成「点删除清掉另一个账号的登录态」,所以有专门用例盯这一条。 """ acc = ( _visible_cookie_accounts_query(db, current) .filter(Account.id == account_id) .first() ) if not acc: raise HTTPException(status_code=404, detail="账号不存在或没有 CK") username = acc.username acc.cookie = "" acc.cookie_updated_at = datetime.now(UTC) acc.ck_check_status = "" acc.ck_check_result = None acc.ck_checked_at = None db.add( AuditLog( user_id=current.id, username=current.username, action="cookie:delete", target=username, detail="不可逆:CK 已从该账号清空", ) ) db.commit() return {"message": "已删除", "success": True}