"""账号凭证检测:静态 Cookie 分析 + IM 运行时校验""" import asyncio import json import logging from typing import Optional from rpa_engine.douyin_im.auth import DouyinAuth from rpa_engine.douyin_im.session import DouyinImSession from utils.cookie_store import analyze_cookie logger = logging.getLogger("credential") CREDENTIAL_EGRESS_PUBLIC_IP_KEY = "credential_egress_public_ip" def credential_egress_mismatch( cookie_data: Optional[str], selected_public_ip: str = "", ) -> bool: """Compare historical browser egress metadata for diagnostics only. This is not an authentication check: a different or missing local marker cannot prove that cookies are invalid. Callers must keep the credentials and use normal validation instead of forcing a reset or browser login. """ if not cookie_data: return False try: storage = json.loads(cookie_data) except (TypeError, ValueError): return False if not isinstance(storage, dict): return False selected = str(selected_public_ip or "").strip() if CREDENTIAL_EGRESS_PUBLIC_IP_KEY not in storage: return bool(selected) stored = str(storage.get(CREDENTIAL_EGRESS_PUBLIC_IP_KEY) or "").strip() return stored != selected def _should_reset_credentials(assessment: dict) -> bool: """凭证全面失效时需清空 Cookie/IM 数据并重新登录。""" if not assessment.get("has_cookie"): return False if not assessment.get("cookie_valid"): return True message = assessment.get("message") or "" # 仅缺 ticket/签名/浏览器采集 — 保留 Cookie,走浏览器补全即可 if any( token in message for token in ("ticket", "签名密钥", "浏览器模式", "web_protect") ): return False if ( not assessment.get("im_ready") and not assessment.get("can_skip_browser") and assessment.get("has_sessionid") ): return True return False def build_im_session_from_storage( storage: dict, saved_im_data: Optional[str] = None, ) -> DouyinImSession: session = DouyinImSession.from_storage_state(storage or {}) if saved_im_data: try: saved = DouyinImSession.from_dict(json.loads(saved_im_data)) # 新粘贴的 storage_state(含真实 frontier_ws_url)优先;只有它没带时才用缓存的。 if saved.ws_urls and not session.ws_urls: session.ws_urls = saved.ws_urls if saved.sdk_cert and not session.sdk_cert: session.sdk_cert = saved.sdk_cert if saved.frontier_ts_sign and not session.frontier_ts_sign: session.frontier_ts_sign = saved.frontier_ts_sign if saved.keys_str and not session.keys_str: session.keys_str = saved.keys_str if saved.web_protect_str and not session.web_protect_str: session.web_protect_str = saved.web_protect_str # A UID verified from the account profile must win over collector # guesses such as web_runtime_security_uid. Persisting this flag # keeps API/manual-send builders on the same identity as hosting. if saved.uid_verified and saved.my_uid: session.my_uid = saved.my_uid # device_id 必须与 my_uid 指向同一账号:protobuf/frontier 的 # device_id 优先取 session.device_id,凭证里残留的旧设备号 # (如 www 域 web_runtime_security_uid)会导致 device_id != my_uid # -> 安全网关 decision=KICK。用已核验 UID 同步 device_id。 if str(session.device_id or "") != str(saved.my_uid): session.device_id = str(saved.my_uid) session.uid_verified = True elif saved.my_uid and not session.my_uid: session.my_uid = saved.my_uid if saved.device_id and not session.device_id: session.device_id = saved.device_id if saved.web_id and not session.web_id: session.web_id = saved.web_id if saved.conv_meta: session.conv_meta = {**saved.conv_meta, **session.conv_meta} except Exception: pass session.sanitize_ws_urls() # Keep this builder pure and non-blocking. Frontier discovery can perform # a synchronous network request (up to 15 seconds); callers that need it # already do so from validate_im_session() through asyncio.to_thread() and # the shared background-traffic limiter. Running it here made a large # batch freeze the FastAPI event loop before any limiter was acquired. return session def has_im_session_token(session: DouyinImSession) -> bool: return bool(session.cookies.get("sessionid") or session.cookies.get("sessionid_ss")) def extract_sessionid_info(session: DouyinImSession) -> dict: sessionid = session.cookies.get("sessionid") or "" sessionid_ss = session.cookies.get("sessionid_ss") or "" return { "has_sessionid": bool(sessionid or sessionid_ss), "sessionid": sessionid, "sessionid_ss": sessionid_ss, } async def build_cookie_credential_detail( cookie_data: Optional[str], im_session_data: Optional[str] = None, runtime_check: bool = True, ) -> dict: """供编辑账号页展示 IM 凭证与 sessionid 信息""" sessionid_info = { "has_sessionid": False, "sessionid": "", "sessionid_ss": "", "im_ready": False, "im_status": "未保存 Cookie", "can_skip_browser": False, "should_reset": False, } if not cookie_data: return sessionid_info try: storage = json.loads(cookie_data) session = build_im_session_from_storage(storage, im_session_data) sessionid_info.update(extract_sessionid_info(session)) except Exception: sessionid_info["im_status"] = "Cookie 格式错误" return sessionid_info if not runtime_check: if sessionid_info["has_sessionid"]: sessionid_info["im_status"] = "已检测到 sessionid(未做运行时验证)" else: sessionid_info["im_status"] = "缺少 sessionid,无法 IM 直连" return sessionid_info assessment = await assess_account_credential(cookie_data, im_session_data) sessionid_info["im_ready"] = assessment["im_ready"] sessionid_info["im_status"] = assessment["message"] sessionid_info["can_skip_browser"] = assessment["can_skip_browser"] sessionid_info["should_reset"] = assessment["should_reset"] return sessionid_info async def validate_im_session( session: DouyinImSession, _bypass_global_limit: bool = False, *, startup_priority: bool = False, ) -> tuple[bool, str]: if not _bypass_global_limit: from rpa_engine.douyin_im.traffic_control import get_traffic_controller controller = get_traffic_controller() # Startup validation must not sit behind hundreds of recurring # conversation polls. It still shares the same global concurrency # cap, so this changes ordering without increasing bandwidth usage. async with controller.background_slot( 0, "credential validation", startup=startup_priority, ): return await validate_im_session( session, _bypass_global_limit=True, startup_priority=startup_priority, ) if not session.can_direct_im(): if not has_im_session_token(session): return False, "缺少 sessionid,无法直连 IM" return False, "Cookie 不满足 IM 直连条件" # Frontier discovery belongs to the worker startup lifecycle. Running it # here populated only this temporary assessment session, so a bulk start # immediately repeated the same signing / query work for every account. try: auth = DouyinAuth.from_im_session(session) # 优先用已持久化的 my_uid,避免每次都发起网络 query_my_uid(uid_tt 是加密串, # int() 解析必然失败而回退到网络请求;该请求偶发失败会误判为“未就绪”)。 uid = session.my_uid if not uid: # get_uid() may fall back to a synchronous HTTP request with a # multi-second timeout. Keep that work off FastAPI's event loop # so a manual credential recheck cannot freeze account editing or # unrelated API requests. uid = await asyncio.to_thread(auth.get_uid) if not uid: return False, "服务端未认可当前 Cookie(无法获取用户 UID)" if not auth.is_sign_ready(): return False, "缺少 IM 签名密钥(web_protect/keys),请用浏览器登录补全" session.my_uid = int(uid) # unread_count and ticket probes were previously issued here, but # neither result changed the final decision: unread failures become # zero and a stale/missing ticket is resolved lazily at send time. # Keeping those probes doubled large-batch startup traffic without # adding an authoritative validation signal. return True, "IM 凭证就绪(Cookie 与签名密钥齐全,可直连托管)" except Exception as e: logger.warning(f"IM session validation failed: {e}") return False, f"IM 运行时验证失败: {e}" async def assess_account_credential( cookie_data: Optional[str], im_session_data: Optional[str] = None, *, startup_priority: bool = False, egress_public_ip: str = "", ) -> dict: cookie_info = analyze_cookie(cookie_data) result = { "has_cookie": cookie_info.get("has_cookie", False), "cookie_valid": cookie_info.get("cookie_valid", False), "cookie_status": cookie_info.get("reason", ""), "has_sessionid": False, "im_ready": False, "can_skip_browser": False, "login_mode": "browser", "message": "未保存 Cookie,需浏览器扫码登录", "should_reset": False, } if not cookie_data: return result try: storage = json.loads(cookie_data) except Exception: result["message"] = "Cookie 格式错误" result["should_reset"] = _should_reset_credentials(result) return result session = build_im_session_from_storage(storage, im_session_data) selected_public_ip = str(egress_public_ip or "").strip() if selected_public_ip: try: from rpa_engine.egress_channels import resolve_fixed_channel route = await resolve_fixed_channel(selected_public_ip) session.egress_public_ip = selected_public_ip session.egress_source_ip = str(route.source_ip or "") except Exception as exc: result["message"] = f"指定公网通道 {selected_public_ip} 当前不可用:{exc}" result["login_mode"] = "browser" return result result["has_sessionid"] = has_im_session_token(session) if not cookie_info.get("cookie_valid"): result["message"] = cookie_info.get("reason") or "Cookie 无效,需重新登录" result["should_reset"] = _should_reset_credentials(result) return result if not result["has_sessionid"]: result["login_mode"] = "browser" result["message"] = "Cookie 已保存但缺少 sessionid,需浏览器刷新登录态" result["should_reset"] = _should_reset_credentials(result) return result im_ok, im_reason = await validate_im_session( session, startup_priority=startup_priority, ) result["im_ready"] = im_ok if im_ok: result["can_skip_browser"] = True result["login_mode"] = "im_direct" result["message"] = im_reason else: result["login_mode"] = "browser" result["message"] = im_reason result["should_reset"] = _should_reset_credentials(result) return result