"""CPA 远端推送失败队列。 本地 auth 已写成功但 POST /v0/management/auth-files 失败时,把文件名记入 `cpa_push_pending.txt`;下次开启推送时先重试队列里的文件,成功则剔除。 行格式(兼容只写文件名): xai-email@x.com.json----reason----unix_ts """ from __future__ import annotations import json import threading import time from pathlib import Path from typing import Any, Callable from .client import CpaPushError, push_auth_file PENDING_NAME = "cpa_push_pending.txt" _lock = threading.RLock() def pending_path(out_dir: str | Path) -> Path: return Path(out_dir).expanduser().resolve() / PENDING_NAME def _normalize_filename(name: str) -> str: name = (name or "").strip().replace("\\", "/").split("/")[-1] if name and not name.endswith(".json"): name += ".json" return name def _parse_line(line: str) -> tuple[str, str, int] | None: line = (line or "").strip() if not line or line.startswith("#"): return None parts = line.split("----") filename = _normalize_filename(parts[0]) if not filename: return None reason = parts[1].strip() if len(parts) > 1 else "" ts = 0 if len(parts) > 2: try: ts = int(parts[2].strip()) except ValueError: ts = 0 return filename, reason, ts def load_pending(out_dir: str | Path) -> list[tuple[str, str, int]]: """按文件名去重,保留最后一次记录。""" path = pending_path(out_dir) if not path.is_file(): return [] ordered: dict[str, tuple[str, str, int]] = {} try: text = path.read_text(encoding="utf-8", errors="replace") except OSError: return [] for line in text.splitlines(): row = _parse_line(line) if row: ordered[row[0]] = row return list(ordered.values()) def _rewrite(out_dir: str | Path, rows: list[tuple[str, str, int]]) -> None: out = Path(out_dir).expanduser().resolve() out.mkdir(parents=True, exist_ok=True) path = pending_path(out) lines = [ f"{fn}----{reason}----{ts or int(time.time())}\n" for fn, reason, ts in rows if fn ] tmp = path.with_suffix(".tmp") tmp.write_text("".join(lines), encoding="utf-8") tmp.replace(path) def record_push_failure( out_dir: str | Path, filename: str, reason: str = "", ) -> None: """推送失败:加入待重试队列(同名覆盖为最新原因)。""" fn = _normalize_filename(filename) if not fn: return reason = (reason or "").replace("\n", " ").replace("\r", " ").strip()[:300] now = int(time.time()) with _lock: rows = {r[0]: r for r in load_pending(out_dir)} rows[fn] = (fn, reason, now) _rewrite(out_dir, list(rows.values())) def remove_push_pending(out_dir: str | Path, filename: str) -> None: """推送成功:从队列移除。""" fn = _normalize_filename(filename) if not fn: return with _lock: rows = [r for r in load_pending(out_dir) if r[0] != fn] path = pending_path(out_dir) if not rows: if path.is_file(): try: path.unlink() except OSError: _rewrite(out_dir, []) return _rewrite(out_dir, rows) def push_local_auth_file( out_dir: str | Path, filename: str, *, remote_base: str, secret: str, proxy: str | None = None, verify_tls: bool = True, ) -> tuple[bool, int, str]: """读取本地 xai-*.json 并推送。文件缺失返回 (False, 0, 'missing').""" fn = _normalize_filename(filename) path = Path(out_dir).expanduser().resolve() / fn if not path.is_file(): return False, 0, "missing local file" try: payload = json.loads(path.read_text(encoding="utf-8")) except Exception as exc: # noqa: BLE001 return False, 0, f"read/json: {exc}" return push_auth_file( remote_base=remote_base, secret=secret, filename=fn, payload=payload, proxy=proxy, verify_tls=verify_tls, ) def flush_push_pending( out_dir: str | Path, *, remote_base: str, secret: str, proxy: str | None = None, verify_tls: bool = True, log: Callable[[str], None] | None = None, ) -> dict[str, Any]: """重试队列中所有仍存在的本地 auth 文件。成功剔除,失败保留。""" log = log or (lambda m: None) remote_base = (remote_base or "").strip() secret = (secret or "").strip() if not remote_base or not secret: return {"ok": 0, "fail": 0, "missing": 0, "skipped": True, "reason": "no remote config"} with _lock: pending = load_pending(out_dir) if not pending: return {"ok": 0, "fail": 0, "missing": 0, "total": 0} log(f"[cpa] 重试待推送队列 count={len(pending)}") ok_n = fail_n = missing_n = 0 for filename, old_reason, _ts in pending: try: ok, status, text = push_local_auth_file( out_dir, filename, remote_base=remote_base, secret=secret, proxy=proxy, verify_tls=verify_tls, ) except CpaPushError as exc: ok, status, text = False, -1, str(exc) except Exception as exc: # noqa: BLE001 ok, status, text = False, -1, str(exc) if status == 0 and text.startswith("missing"): missing_n += 1 remove_push_pending(out_dir, filename) log(f"[cpa] 待推送文件已不存在,移出队列: {filename}") continue if ok: ok_n += 1 remove_push_pending(out_dir, filename) log(f"[cpa] 队列重推成功: {filename} (HTTP {status})") else: fail_n += 1 reason = f"HTTP {status}: {text[:200]}" if status > 0 else text[:200] record_push_failure(out_dir, filename, reason or old_reason) log(f"[!] [cpa] 队列重推失败: {filename} {reason}") return { "ok": ok_n, "fail": fail_n, "missing": missing_n, "total": len(pending), "remaining": len(load_pending(out_dir)), } def push_with_queue( out_dir: str | Path, filename: str, payload: dict | bytes | str | None, *, remote_base: str, secret: str, proxy: str | None = None, verify_tls: bool = True, flush_first: bool = True, log: Callable[[str], None] | None = None, ) -> dict[str, Any]: """推送当前文件;可选先 flush 队列。失败记入 pending,成功移出。 返回 {pushed, push_status?, push_error?, flush?}。 """ log = log or (lambda m: None) result: dict[str, Any] = {"pushed": False} remote_base = (remote_base or "").strip() secret = (secret or "").strip() if not remote_base or not secret: result["push_error"] = "cpa_remote_base/secret 未配置" return result if flush_first: result["flush"] = flush_push_pending( out_dir, remote_base=remote_base, secret=secret, proxy=proxy, verify_tls=verify_tls, log=log, ) fn = _normalize_filename(filename) try: if payload is None: ok, status, text = push_local_auth_file( out_dir, fn, remote_base=remote_base, secret=secret, proxy=proxy, verify_tls=verify_tls, ) else: ok, status, text = push_auth_file( remote_base=remote_base, secret=secret, filename=fn, payload=payload, proxy=proxy, verify_tls=verify_tls, ) result["push_status"] = status if ok: result["pushed"] = True remove_push_pending(out_dir, fn) log(f"[Debug] 已推送远端 (HTTP {status}): {fn}") else: result["push_error"] = text[:300] record_push_failure(out_dir, fn, f"HTTP {status}: {text[:200]}") log(f"[!] [cpa] 推送远端失败 HTTP {status}: {text[:200]}") except Exception as exc: # noqa: BLE001 result["push_error"] = str(exc) record_push_failure(out_dir, fn, str(exc)) log(f"[!] [cpa] 推送远端异常: {exc}") return result